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 <noreply@anthropic.com>
This commit is contained in:
@@ -19,7 +19,7 @@ class Settings(BaseSettings):
|
|||||||
CLOUD_URL: str = ""
|
CLOUD_URL: str = ""
|
||||||
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
SECRET_KEY: str = "change-me-generate-a-long-random-string"
|
||||||
DATABASE_URL: str = f"sqlite:///{_DB_DEFAULT.as_posix()}"
|
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
|
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"}
|
model_config = {"env_file": str(_HERE / ".env"), "env_file_encoding": "utf-8"}
|
||||||
|
|||||||
@@ -1,6 +1,16 @@
|
|||||||
import json
|
import json
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
from fastapi import APIRouter, Depends, HTTPException, status, BackgroundTasks
|
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 sqlalchemy.orm import Session
|
||||||
from typing import List, Optional
|
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,
|
"removed_ingredients": i.removed_ingredients,
|
||||||
"notes": i.notes,
|
"notes": i.notes,
|
||||||
"status": i.status,
|
"status": i.status,
|
||||||
"added_at": i.added_at,
|
"added_at": _iso(i.added_at),
|
||||||
"printed": i.printed,
|
"printed": i.printed,
|
||||||
"paid_by": i.paid_by,
|
"paid_by": i.paid_by,
|
||||||
"paid_by_name": name_map.get(i.paid_by) if i.paid_by else None,
|
"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,
|
"payment_method": i.payment_method,
|
||||||
"paid_in_shift_id": i.paid_in_shift_id,
|
"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,
|
"amount": l.amount,
|
||||||
"payment_method": l.payment_method,
|
"payment_method": l.payment_method,
|
||||||
"note": l.note,
|
"note": l.note,
|
||||||
"created_at": l.created_at,
|
"created_at": _iso(l.created_at),
|
||||||
"offline_at": l.offline_at,
|
"offline_at": _iso(l.offline_at) if isinstance(l.offline_at, datetime) else l.offline_at,
|
||||||
"is_duplicate": l.is_duplicate,
|
"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_id": order.table_id,
|
||||||
"table_name": table_name,
|
"table_name": table_name,
|
||||||
"opened_by": order.opened_by,
|
"opened_by": order.opened_by,
|
||||||
"opened_at": order.opened_at,
|
"opened_at": _iso(order.opened_at),
|
||||||
"status": order.status,
|
"status": order.status,
|
||||||
"closed_at": order.closed_at,
|
"closed_at": _iso(order.closed_at),
|
||||||
"closed_by": order.closed_by,
|
"closed_by": order.closed_by,
|
||||||
"notes": order.notes,
|
"notes": order.notes,
|
||||||
"business_day_id": order.business_day_id,
|
"business_day_id": order.business_day_id,
|
||||||
@@ -657,8 +667,8 @@ def print_order(
|
|||||||
"order_id": order.id,
|
"order_id": order.id,
|
||||||
"table_name": table_name,
|
"table_name": table_name,
|
||||||
"waiter_name": waiter_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"),
|
||||||
"closed_at": order.closed_at.strftime("%d/%m/%Y %H:%M") if order.closed_at else None,
|
"closed_at": local_strftime(order.closed_at, "%d/%m/%Y %H:%M") if order.closed_at else None,
|
||||||
"status": order.status,
|
"status": order.status,
|
||||||
"items": items_data,
|
"items": items_data,
|
||||||
"total": grand_total,
|
"total": grand_total,
|
||||||
@@ -953,7 +963,7 @@ def print_synopsis(
|
|||||||
"order_id": order.id,
|
"order_id": order.id,
|
||||||
"table_name": table_name,
|
"table_name": table_name,
|
||||||
"waiter_name": waiter_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,
|
"items": items_data,
|
||||||
"total": total,
|
"total": total,
|
||||||
"paid_total": paid_total,
|
"paid_total": paid_total,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
|
from fastapi import APIRouter, Depends, Query, HTTPException, BackgroundTasks
|
||||||
|
from tz import local_strftime, to_local
|
||||||
from fastapi.responses import StreamingResponse
|
from fastapi.responses import StreamingResponse
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel
|
||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
@@ -87,8 +88,8 @@ def shift_summary(
|
|||||||
order = db.query(Order).filter(Order.id == oid).first()
|
order = db.query(Order).filter(Order.id == oid).first()
|
||||||
summary[wid]["order_data"][oid] = {
|
summary[wid]["order_data"][oid] = {
|
||||||
"id": oid,
|
"id": oid,
|
||||||
"time_open": order.opened_at.strftime("%H:%M") if order else "",
|
"time_open": local_strftime(order.opened_at, "%H:%M") if order else "",
|
||||||
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at 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}",
|
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
|
||||||
"total": 0.0,
|
"total": 0.0,
|
||||||
"items": [],
|
"items": [],
|
||||||
@@ -160,8 +161,8 @@ def shift_orders_summary(
|
|||||||
order = db.query(Order).filter(Order.id == oid).first()
|
order = db.query(Order).filter(Order.id == oid).first()
|
||||||
summary[wid]["order_data"][oid] = {
|
summary[wid]["order_data"][oid] = {
|
||||||
"id": oid,
|
"id": oid,
|
||||||
"time_open": order.opened_at.strftime("%H:%M") if order else "",
|
"time_open": local_strftime(order.opened_at, "%H:%M") if order else "",
|
||||||
"time_close": order.closed_at.strftime("%H:%M") if order and order.closed_at 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}",
|
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
|
||||||
"total": 0.0,
|
"total": 0.0,
|
||||||
"items": [],
|
"items": [],
|
||||||
@@ -382,7 +383,7 @@ def printer_totals(
|
|||||||
order = db.query(Order).filter(Order.id == oid).first()
|
order = db.query(Order).filter(Order.id == oid).first()
|
||||||
order_map[pid][oid] = {
|
order_map[pid][oid] = {
|
||||||
"order_id": 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}",
|
"table": tables_db.get(order.table_id, f"#{oid}") if order else f"#{oid}",
|
||||||
"total": 0.0,
|
"total": 0.0,
|
||||||
"items": [],
|
"items": [],
|
||||||
@@ -452,7 +453,7 @@ def _build_printer_block(printer_id: int, printer_name: str, logs, tables, db, m
|
|||||||
if order:
|
if order:
|
||||||
order_map[oid] = {
|
order_map[oid] = {
|
||||||
"id": 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}"),
|
"table": tables.get(order.table_id, f"#{order.table_id}"),
|
||||||
"total": 0.0,
|
"total": 0.0,
|
||||||
"items": [],
|
"items": [],
|
||||||
@@ -535,8 +536,8 @@ def print_waiter(
|
|||||||
total = sum(i.unit_price * i.quantity for i in active_items)
|
total = sum(i.unit_price * i.quantity for i in active_items)
|
||||||
order_data.append({
|
order_data.append({
|
||||||
"id": o.id,
|
"id": o.id,
|
||||||
"time_open": o.opened_at.strftime("%H:%M"),
|
"time_open": local_strftime(o.opened_at, "%H:%M"),
|
||||||
"time_close": o.closed_at.strftime("%H:%M") if o.closed_at else "",
|
"time_close": local_strftime(o.closed_at, "%H:%M") if o.closed_at else "",
|
||||||
"table": tables.get(o.table_id, f"#{o.table_id}"),
|
"table": tables.get(o.table_id, f"#{o.table_id}"),
|
||||||
"total": total,
|
"total": total,
|
||||||
"items": [
|
"items": [
|
||||||
@@ -556,8 +557,8 @@ def print_waiter(
|
|||||||
"items": items_count,
|
"items": items_count,
|
||||||
"total": grand_total,
|
"total": grand_total,
|
||||||
"order_data": order_data if body.mode == "extensive" else [],
|
"order_data": order_data if body.mode == "extensive" else [],
|
||||||
"from_dt": from_dt.strftime("%d/%m/%Y %H:%M"),
|
"from_dt": local_strftime(from_dt, "%d/%m/%Y %H:%M"),
|
||||||
"to_dt": to_dt.strftime("%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)
|
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
|
# Determine period label for the receipt header
|
||||||
if body.business_day_id:
|
if body.business_day_id:
|
||||||
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
|
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
|
period_is_workday = True
|
||||||
else:
|
else:
|
||||||
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
|
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
|
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
|
||||||
period_label = (
|
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 "—"
|
if from_dt and to_dt else "—"
|
||||||
)
|
)
|
||||||
period_is_workday = False
|
period_is_workday = False
|
||||||
@@ -649,12 +650,12 @@ def _analytics_period(body: PrintAnalyticsBody, db) -> tuple[str, bool]:
|
|||||||
"""Return (period_label, period_is_workday)."""
|
"""Return (period_label, period_is_workday)."""
|
||||||
if body.business_day_id:
|
if body.business_day_id:
|
||||||
bd = db.query(BusinessDay).filter(BusinessDay.id == body.business_day_id).first()
|
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
|
return label, True
|
||||||
from_dt = datetime.fromisoformat(body.from_dt) if body.from_dt else None
|
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
|
to_dt = datetime.fromisoformat(body.to_dt) if body.to_dt else None
|
||||||
label = (
|
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 "—"
|
if from_dt and to_dt else "—"
|
||||||
)
|
)
|
||||||
return label, False
|
return label, False
|
||||||
@@ -1253,7 +1254,7 @@ def revenue_trends(
|
|||||||
|
|
||||||
buckets: dict = {}
|
buckets: dict = {}
|
||||||
for order in orders:
|
for order in orders:
|
||||||
dt = order.opened_at
|
dt = to_local(order.opened_at)
|
||||||
if granularity == "monthly":
|
if granularity == "monthly":
|
||||||
key = dt.strftime("%Y-%m")
|
key = dt.strftime("%Y-%m")
|
||||||
elif granularity == "weekly":
|
elif granularity == "weekly":
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ from sqlalchemy.orm import Session
|
|||||||
|
|
||||||
from database import SessionLocal
|
from database import SessionLocal
|
||||||
from models.order import Order, OrderItem, PrintLog
|
from models.order import Order, OrderItem, PrintLog
|
||||||
|
from tz import to_local
|
||||||
from models.printer import Printer
|
from models.printer import Printer
|
||||||
from models.product import Product
|
from models.product import Product
|
||||||
from models.settings import PosSettings
|
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:
|
def _greek_date(dt: datetime.datetime) -> str:
|
||||||
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY"""
|
"""Return date/time string in Greek format: HH:MM DD-MM-YYYY, converted to venue local time."""
|
||||||
return dt.strftime("%H:%M %d-%m-%Y")
|
return to_local(dt).strftime("%H:%M %d-%m-%Y")
|
||||||
|
|
||||||
|
|
||||||
def check_printer(ip: str, port: int) -> bool:
|
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')
|
p._raw(b'\x1b\x21\x30')
|
||||||
_raw_text(p, f"TEST — {name}\n")
|
_raw_text(p, f"TEST — {name}\n")
|
||||||
p._raw(b'\x1b\x21\x00')
|
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")
|
_raw_text(p, f"{now}\n")
|
||||||
p._raw(b'\n\n\n')
|
p._raw(b'\n\n\n')
|
||||||
p.cut()
|
p.cut()
|
||||||
@@ -499,7 +500,7 @@ def _print_kitchen_ticket(p: Network, order: Order, items: List[OrderItem], db:
|
|||||||
# Resolve display names
|
# Resolve display names
|
||||||
table_name = order.table.label or str(order.table.number) if order.table else str(order.table_id)
|
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)
|
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 ────────────────────────────────────────
|
# ── COMPACT header — single line ────────────────────────────────────────
|
||||||
if compact:
|
if compact:
|
||||||
|
|||||||
39
local_backend/tz.py
Normal file
39
local_backend/tz.py
Normal file
@@ -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)
|
||||||
@@ -65,8 +65,8 @@ function Avatar({ name, avatarUrl, size = 36 }) {
|
|||||||
// ─── Table color tokens ────────────────────────────────────────────────────────
|
// ─── Table color tokens ────────────────────────────────────────────────────────
|
||||||
|
|
||||||
const TABLE_COLORS = {
|
const TABLE_COLORS = {
|
||||||
open: { bg: '#eef7f0', border: '#d7ecdc', dot: '#2f9e5e', text: '#1f7042', label: 'Ανοιχτό' },
|
open: { bg: '#bbf7d0', border: '#4ade80', dot: '#16a34a', text: '#14532d', label: 'Ανοιχτό' },
|
||||||
partially_paid: { bg: '#f4eefb', border: '#e3d4f3', dot: '#7a44c9', text: '#57309a', label: 'Μερική πληρ.' },
|
partially_paid: { bg: '#e9d5ff', border: '#a855f7', dot: '#7c3aed', text: '#4c1d95', label: 'Μερική πληρ.' },
|
||||||
free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' },
|
free: { bg: '#f4f4f2', border: '#dfe2e6', dot: '#8a9099', text: '#5a6169', label: 'Ελεύθερο' },
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -94,13 +94,9 @@ export const fmtTime = (iso) => {
|
|||||||
}
|
}
|
||||||
export const fmtDate = (iso) => {
|
export const fmtDate = (iso) => {
|
||||||
if (!iso) return '—'
|
if (!iso) return '—'
|
||||||
// Parse as local time by replacing the T separator so Date treats it as local
|
const d = new Date(iso)
|
||||||
const d = new Date(String(iso).replace('T', ' '))
|
|
||||||
if (isNaN(d)) return String(iso)
|
if (isNaN(d)) return String(iso)
|
||||||
const day = String(d.getDate()).padStart(2, '0')
|
return d.toLocaleDateString('en-GB', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
const month = String(d.getMonth() + 1).padStart(2, '0')
|
|
||||||
const year = d.getFullYear()
|
|
||||||
return `${day}/${month}/${year}`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format a raw YYYY-MM-DD string (from a date picker) as DD/MM/YYYY without any timezone conversion
|
// Format a raw YYYY-MM-DD string (from a date picker) as DD/MM/YYYY without any timezone conversion
|
||||||
|
|||||||
Reference in New Issue
Block a user