From 72b12ddd9ce98ae565819437c98754532fd857d5 Mon Sep 17 00:00:00 2001 From: bonamin Date: Mon, 8 Jun 2026 19:18:46 +0300 Subject: [PATCH] fix: fix timezone handling across backend and frontend (v0.3.1) - Add tz.py with local_strftime/to_local helpers that read system.timezone from DB and convert UTC datetimes to venue local time before formatting - Fix all strftime() calls in orders.py, reports.py, printer_service.py that were formatting UTC datetimes without timezone conversion - Fix get_order endpoint returning raw dicts without Z suffix on datetimes, causing JS new Date() to treat timestamps as local instead of UTC - Fix fmtDate() in tokens.js that stripped the T separator before parsing, breaking UTC-to-local conversion for all report date displays - Make open/partially_paid table chips more visually distinct on dashboard Co-Authored-By: Claude Sonnet 4.6 --- local_backend/config.py | 2 +- local_backend/routers/orders.py | 28 ++++++++----- local_backend/routers/reports.py | 31 ++++++++------- local_backend/services/printer_service.py | 9 +++-- local_backend/tz.py | 39 +++++++++++++++++++ manager_dashboard/src/pages/DashboardPage.jsx | 4 +- manager_dashboard/src/ui/tokens.js | 8 +--- 7 files changed, 84 insertions(+), 37 deletions(-) create mode 100644 local_backend/tz.py diff --git a/local_backend/config.py b/local_backend/config.py index 7dd2455..09e7a80 100644 --- a/local_backend/config.py +++ b/local_backend/config.py @@ -19,7 +19,7 @@ class Settings(BaseSettings): CLOUD_URL: str = "" SECRET_KEY: str = "change-me-generate-a-long-random-string" DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}" - VERSION: str = "0.0.0" + VERSION: str = "0.3.1" CONNECT_SYNC_INTERVAL_SECONDS: int = 30 # how often to poll for pending online orders model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"} diff --git a/local_backend/routers/orders.py b/local_backend/routers/orders.py index c397e8a..f0d0be0 100644 --- a/local_backend/routers/orders.py +++ b/local_backend/routers/orders.py @@ -1,6 +1,16 @@ import json from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks +from tz import local_strftime + + +def _iso(dt) -> str | None: + """Serialize a datetime to ISO 8601 with Z suffix so browsers parse it as UTC.""" + if dt is None: + return None + if dt.tzinfo is None: + return dt.isoformat() + "Z" + return dt.isoformat() from sqlalchemy.orm import Session from typing import List, Optional @@ -163,11 +173,11 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends "removed_ingredients": i.removed_ingredients, "notes": i.notes, "status": i.status, - "added_at": i.added_at, + "added_at": _iso(i.added_at), "printed": i.printed, "paid_by": i.paid_by, "paid_by_name": name_map.get(i.paid_by) if i.paid_by else None, - "paid_at": i.paid_at, + "paid_at": _iso(i.paid_at), "payment_method": i.payment_method, "paid_in_shift_id": i.paid_in_shift_id, } @@ -183,8 +193,8 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends "amount": l.amount, "payment_method": l.payment_method, "note": l.note, - "created_at": l.created_at, - "offline_at": l.offline_at, + "created_at": _iso(l.created_at), + "offline_at": _iso(l.offline_at) if isinstance(l.offline_at, datetime) else l.offline_at, "is_duplicate": l.is_duplicate, } @@ -205,9 +215,9 @@ def get_order(order_id: int, db: Session = Depends(get_db), user: User = Depends "table_id": order.table_id, "table_name": table_name, "opened_by": order.opened_by, - "opened_at": order.opened_at, + "opened_at": _iso(order.opened_at), "status": order.status, - "closed_at": order.closed_at, + "closed_at": _iso(order.closed_at), "closed_by": order.closed_by, "notes": order.notes, "business_day_id": order.business_day_id, @@ -657,8 +667,8 @@ def print_order( "order_id": order.id, "table_name": table_name, "waiter_name": waiter_name, - "opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"), - "closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None, + "opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"), + "closed_at": local_strftime(order.closed_at, "%d/%m/%Y %H:%M") if order.closed_at else None, "status": order.status, "items": items_data, "total": grand_total, @@ -953,7 +963,7 @@ def print_synopsis( "order_id": order.id, "table_name": table_name, "waiter_name": waiter_name, - "opened_at": order.opened_at.strftime("%d/%m/%Y %H:%M"), + "opened_at": local_strftime(order.opened_at, "%d/%m/%Y %H:%M"), "items": items_data, "total": total, "paid_total": paid_total, diff --git a/local_backend/routers/reports.py b/local_backend/routers/reports.py index 42b0209..b463e41 100644 --- a/local_backend/routers/reports.py +++ b/local_backend/routers/reports.py @@ -3,6 +3,7 @@ import io import json from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks +from tz import local_strftime, to_local from fastapi.responses import StreamingResponse from pydantic import BaseModel from sqlalchemy.orm import Session @@ -87,8 +88,8 @@ def shift_summary( order = db.query(Order).filter(Order.id == oid).first() summary[wid]["order_data"][oid] = { "id": oid, - "time_open": order.opened_at.strftime("%H:%M") if order else "", - "time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "", + "time_open": local_strftime(order.opened_at, "%H:%M") if order else "", + "time_close": local_strftime(order.closed_at, "%H:%M") if order and order.closed_at else "", "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", "total": 0.0, "items": [], @@ -160,8 +161,8 @@ def shift_orders_summary( order = db.query(Order).filter(Order.id == oid).first() summary[wid]["order_data"][oid] = { "id": oid, - "time_open": order.opened_at.strftime("%H:%M") if order else "", - "time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at else "", + "time_open": local_strftime(order.opened_at, "%H:%M") if order else "", + "time_close": local_strftime(order.closed_at, "%H:%M") if order and order.closed_at else "", "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", "total": 0.0, "items": [], @@ -382,7 +383,7 @@ def printer_totals( order = db.query(Order).filter(Order.id == oid).first() order_map[pid][oid] = { "order_id": oid, - "time": log.printed_at.strftime("%H:%M"), + "time": local_strftime(log.printed_at, "%H:%M"), "table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}", "total": 0.0, "items": [], @@ -452,7 +453,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m if order: order_map[oid] = { "id": oid, - "time": log.printed_at.strftime("%H:%M"), + "time": local_strftime(log.printed_at, "%H:%M"), "table": tables.get(order.table_id, f"#{order.table_id}"), "total": 0.0, "items": [], @@ -535,8 +536,8 @@ def print_waiter( total = sum(i.unit_price * i.quantity for i in active_items) order_data.append({ "id": o.id, - "time_open": o.opened_at.strftime("%H:%M"), - "time_close": o.closed_at.strftime("%H:%M") if o.closed_at else "", + "time_open": local_strftime(o.opened_at, "%H:%M"), + "time_close": local_strftime(o.closed_at, "%H:%M") if o.closed_at else "", "table": tables.get(o.table_id, f"#{o.table_id}"), "total": total, "items": [ @@ -556,8 +557,8 @@ def print_waiter( "items": items_count, "total": grand_total, "order_data": order_data if body.mode == "extensive" else [], - "from_dt": from_dt.strftime("%d/%m/%Y %H:%M"), - "to_dt": to_dt.strftime("%d/%m/%Y %H:%M"), + "from_dt": local_strftime(from_dt, "%d/%m/%Y %H:%M"), + "to_dt": local_strftime(to_dt, "%d/%m/%Y %H:%M"), } background_tasks.add_task(print_waiter_report, printer.ip_address, printer.port, report, body.mode) @@ -592,13 +593,13 @@ def print_printer_totals( # Determine period label for the receipt header if body.business_day_id: bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first() - period_label = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}" + period_label = local_strftime(bd.opened_at, "%d/%m/%Y") if bd else f"Workday #{body.business_day_id}" period_is_workday = True else: from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None period_label = ( - f"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}" + f"{local_strftime(from_dt, '%d/%m/%Y')} - {local_strftime(to_dt, '%d/%m/%Y')}" if from_dt and to_dt else "—" ) period_is_workday = False @@ -649,12 +650,12 @@ def _analytics_period(body: PrintAnalyticsBody, db) -> tuple[str, bool]: """Return (period_label, period_is_workday).""" if body.business_day_id: bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first() - label = bd.opened_at.strftime("%d/%m/%Y") if bd else f"Workday #{body.business_day_id}" + label = local_strftime(bd.opened_at, "%d/%m/%Y") if bd else f"Workday #{body.business_day_id}" return label, True from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None label = ( - f"{from_dt.strftime('%d/%m/%Y')} - {to_dt.strftime('%d/%m/%Y')}" + f"{local_strftime(from_dt, '%d/%m/%Y')} - {local_strftime(to_dt, '%d/%m/%Y')}" if from_dt and to_dt else "—" ) return label, False @@ -1253,7 +1254,7 @@ def revenue_trends( buckets: dict = {} for order in orders: - dt = order.opened_at + dt = to_local(order.opened_at) if granularity == "monthly": key = dt.strftime("%Y-%m") elif granularity == "weekly": diff --git a/local_backend/services/printer_service.py b/local_backend/services/printer_service.py index dee2962..cfbc5e8 100644 --- a/local_backend/services/printer_service.py +++ b/local_backend/services/printer_service.py @@ -18,6 +18,7 @@ from sqlalchemy.orm import Session from database import SessionLocal from models.order import Order, OrderItem, PrintLog +from tz import to_local from models.printer import Printer from models.product import Product from models.settings import PosSettings @@ -232,8 +233,8 @@ def _print_line(p: Network, text: str, size: int, bold: bool, caps: bool, def _greek_date(dt: datetime.datetime) -> str: - """Return date/time string in Greek format: HH:MM DD-MM-YYYY""" - return dt.strftime("%H:%M %d-%m-%Y") + """Return date/time string in Greek format: HH:MM DD-MM-YYYY, converted to venue local time.""" + return to_local(dt).strftime("%H:%M %d-%m-%Y") def check_printer(ip: str, port: int) -> bool: @@ -282,7 +283,7 @@ def send_test_print(ip: str, port: int, name: str) -> Tuple[bool, str]: p._raw(b'\x1b\x21\x30') _raw_text(p, f"TEST — {name}\n") p._raw(b'\x1b\x21\x00') - now = datetime.datetime.now().strftime("%Y-%m-%d %H:%M:%S") + now = to_local(datetime.datetime.now(datetime.timezone.utc)).strftime("%Y-%m-%d %H:%M:%S") _raw_text(p, f"{now}\n") p._raw(b'\n\n\n') p.cut() @@ -499,7 +500,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db: # Resolve display names table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id) waiter_nick = (order.opener.nickname or order.opener.username) if order.opener else str(order.opened_by) - now_str = _greek_date(datetime.datetime.now()) + now_str = _greek_date(datetime.datetime.now(datetime.timezone.utc)) # ── COMPACT header — single line ──────────────────────────────────────── if compact: diff --git a/local_backend/tz.py b/local_backend/tz.py new file mode 100644 index 0000000..d830bac --- /dev/null +++ b/local_backend/tz.py @@ -0,0 +1,39 @@ +"""Timezone utilities for converting UTC datetimes to the venue's local time.""" +from zoneinfo import ZoneInfo, ZoneInfoNotFoundError +from datetime import datetime, timezone + +from database import SessionLocal +from models.settings import PosSettings + +_FALLBACK = "Europe/Athens" + + +def _get_tz_name() -> str: + db = SessionLocal() + try: + row = db.query(PosSettings).filter(PosSettings.key == "system.timezone").first() + return row.value if row and row.value else _FALLBACK + except Exception: + return _FALLBACK + finally: + db.close() + + +def to_local(dt: datetime) -> datetime: + """Convert a UTC-aware datetime to the venue's configured local timezone.""" + if dt is None: + return dt + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + try: + tz = ZoneInfo(_get_tz_name()) + except ZoneInfoNotFoundError: + tz = ZoneInfo(_FALLBACK) + return dt.astimezone(tz) + + +def local_strftime(dt: datetime, fmt: str) -> str: + """Format a UTC-aware datetime in local time using the venue's timezone.""" + if dt is None: + return "—" + return to_local(dt).strftime(fmt) diff --git a/manager_dashboard/src/pages/DashboardPage.jsx b/manager_dashboard/src/pages/DashboardPage.jsx index 880f938..0b7a4cc 100644 --- a/manager_dashboard/src/pages/DashboardPage.jsx +++ b/manager_dashboard/src/pages/DashboardPage.jsx @@ -65,8 +65,8 @@ function Avatar({ name, avatarUrl, size = 36 }) { // ─── Table color tokens ──────────────────────────────────────────────────────── const TABLE_COLORS = { - open: { bg: '#eef7f0', border: '#d7ecdc', dot: '#2f9e5e', text: '#1f7042', label: 'Ανοιχτό' }, - partially_paid: { bg: '#f4eefb', border: '#e3d4f3', dot: '#7a44c9', text: '#57309a', label: 'Μερική πληρ.' }, + open: { bg: '#bbf7d0', border: '#4ade80', dot: '#16a34a', text: '#14532d', label: 'Ανοιχτό' }, + partially_paid: { bg: '#e9d5ff', border: '#a855f7', dot: '#7c3aed', text: '#4c1d95', label: 'Μερική πληρ.' }, free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' }, } diff --git a/manager_dashboard/src/ui/tokens.js b/manager_dashboard/src/ui/tokens.js index 34f7631..19e84bd 100644 --- a/manager_dashboard/src/ui/tokens.js +++ b/manager_dashboard/src/ui/tokens.js @@ -94,13 +94,9 @@ export const fmtTime = (iso) => { } export const fmtDate = (iso) => { if (!iso) return '—' - // Parse as local time by replacing the T separator so Date treats it as local - const d = new Date(String(iso).replace('T', ' ')) + const d = new Date(iso) if (isNaN(d)) return String(iso) - const day = String(d.getDate()).padStart(2, '0') - const month = String(d.getMonth() + 1).padStart(2, '0') - const year = d.getFullYear() - return `${day}/${month}/${year}` + return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' }) } // Format a raw YYYY-MM-DD string (from a date picker) as DD/MM/YYYY without any timezone conversion