Преглед изворни кода

fix(stats): date filters use calendar days in the browser's time zone instead of UTC days; the Statistics page sends its zone, and the date-filtered archive, statistics and inventory-statistics endpoints take an optional tz (Bambuddy's own TZ when omitted)

maziggy пре 2 дана
родитељ
комит
bd692ac3c7

+ 0 - 16
.basedpyright/baseline.json

@@ -387,22 +387,6 @@
                     "lineCount": 1
                 }
             },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 29,
-                    "endColumn": 119,
-                    "lineCount": 1
-                }
-            },
-            {
-                "code": "reportArgumentType",
-                "range": {
-                    "startColumn": 29,
-                    "endColumn": 117,
-                    "lineCount": 1
-                }
-            },
             {
                 "code": "reportArgumentType",
                 "range": {

+ 1 - 0
CHANGELOG.md

@@ -164,6 +164,7 @@ All notable changes to Bambuddy will be documented in this file.
 - **The frontend build no longer warns about `path` and `crypto` being externalized for the STEP previewer (#2976)** — `occt-import-js`, the Emscripten build behind STEP previews, requires both modules, but only inside its `ENVIRONMENT_IS_NODE` branches; in the browser it loads its `.wasm` from the URL the preview worker passes and draws randomness from `crypto.getRandomValues`. Vite still externalized both and printed two warnings on every build. `vite.config.ts` now drops exactly those two warnings for that one package through `build.rolldownOptions.onLog`, so an externalization anywhere else, or of any other module, still shows.
 
 ### Fixed
+- **Statistics: Today, This Week and the other ranges used UTC days** — Near midnight they showed the wrong day's prints for anyone outside UTC: in Berlin at 01:30, "Today" showed the previous day. A day now runs from midnight to midnight in your browser's time zone, for every date filter on the Statistics page, and the date pickers allow today. API callers can name the zone with the new `tz` parameter on the date-filtered archive and statistics endpoints; without it, a date is a day in Bambuddy's own `TZ` (UTC days before).
 - **A print that failed on its first run left the archive costing only the part it printed** — With the built-in inventory, the archive took the failed run's partial cost, so a reprint was estimated, reserved and billed from that fraction. The archive now keeps the cost of one full print (as in Spoolman mode); the failed run's own cost is still recorded in the Print Log.
 - **Spoolman: a very short print could end with its estimated cost instead of the real one** — The cost estimate made at print start could still be waiting on Spoolman when the print ended, and then wrote over the real cost. Completion now stops it first.
 - **After a 2FA or SSO login, LDAP and OIDC users were offered "Change Password"** — The login answered as if they were local users, until the page was reloaded.

+ 31 - 10
backend/app/api/routes/archives.py

@@ -5,7 +5,7 @@ import logging
 import re as _re
 import zipfile
 from collections import defaultdict
-from datetime import date, datetime, time, timedelta, timezone
+from datetime import date, datetime, timedelta, timezone
 from html import escape as html_escape
 from pathlib import Path
 
@@ -60,7 +60,7 @@ from backend.app.services.print_storage import (
 from backend.app.services.printer_media import VIDEO_SUFFIXES, match_ipcam_chunks
 from backend.app.utils.archive_paths import archive_photos_dir, find_archive_photo
 from backend.app.utils.http import build_content_disposition, download_error_response, safe_download_filename
-from backend.app.utils.local_time import to_naive_utc
+from backend.app.utils.local_time import day_zone, local_date_end, local_date_start, to_naive_utc
 from backend.app.utils.threemf_tools import (
     default_plate_gcode_name,
     expand_to_project_slots,
@@ -437,6 +437,9 @@ async def list_archives(
     project_id: int | None = None,
     date_from: date | None = Query(None),
     date_to: date | None = Query(None),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     limit: int = 50,
     offset: int = 0,
     db: AsyncSession = Depends(get_db),
@@ -461,6 +464,7 @@ async def list_archives(
         offset=offset,
         visible_to_user_id=visible_to_user_id,
         printer_scope=printer_scope,
+        day_zone=day_zone(tz),
     )
 
     # Get sets of duplicate hashes and duplicate (name, hash) pairs (efficient single queries),
@@ -732,6 +736,9 @@ async def no_3mf_warning(
 async def list_archives_slim(
     date_from: date | None = Query(None),
     date_to: date | None = Query(None),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     limit: int = Query(default=10000, le=50000),
     offset: int = 0,
@@ -767,10 +774,10 @@ async def list_archives_slim(
         created_by_id = current_user.id
     filters = []
     if date_from:
-        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        dt_from = local_date_start(date_from, day_zone(tz))
         filters.append(PrintLogEntry.created_at >= dt_from)
     if date_to:
-        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        dt_to = local_date_end(date_to, day_zone(tz))
         filters.append(PrintLogEntry.created_at <= dt_to)
     _apply_run_user_filter(filters, created_by_id)
     if (clause := printer_scope.where(PrintLogEntry.printer_id)) is not None:
@@ -1028,6 +1035,9 @@ async def analyze_failures(
     days: int | None = None,
     date_from: date | None = Query(None),
     date_to: date | None = Query(None),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     printer_id: int | None = None,
     project_id: int | None = None,
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
@@ -1066,6 +1076,7 @@ async def analyze_failures(
         project_id=project_id,
         created_by_id=created_by_id,
         printer_scope=printer_scope,
+        day_zone=day_zone(tz),
     )
 
 
@@ -1140,6 +1151,9 @@ async def export_archives(
     status: str | None = None,
     date_from: str | None = Query(None, description="Start date (ISO format)"),
     date_to: str | None = Query(None, description="End date (ISO format)"),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     search: str | None = None,
     db: AsyncSession = Depends(get_db),
     printer_scope: PrinterScope = RequestPrinterScope,
@@ -1176,7 +1190,11 @@ async def export_archives(
     date_to_dt = None
     if date_from:
         try:
-            date_from_dt = to_naive_utc(datetime.fromisoformat(date_from))
+            # A date on its own is a local calendar day (DB-C12).
+            if len(date_from) == 10:
+                date_from_dt = to_naive_utc(local_date_start(date.fromisoformat(date_from), day_zone(tz)))
+            else:
+                date_from_dt = to_naive_utc(datetime.fromisoformat(date_from))
         except (ValueError, OverflowError):
             raise HTTPException(400, "Invalid date_from format")
     if date_to:
@@ -1184,7 +1202,7 @@ async def export_archives(
             # A date on its own ends with that day, as every other date filter
             # here reads it; read as midnight it left out the whole day.
             if len(date_to) == 10:
-                date_to_dt = datetime.combine(date.fromisoformat(date_to), time.max)
+                date_to_dt = to_naive_utc(local_date_end(date.fromisoformat(date_to), day_zone(tz)))
             else:
                 date_to_dt = to_naive_utc(datetime.fromisoformat(date_to))
         except (ValueError, OverflowError):
@@ -1260,6 +1278,9 @@ async def export_stats(
 async def get_archive_stats(
     date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
     date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     created_by_id: int | None = Query(None, description="Filter by user who created the print (-1 for no user)"),
     db: AsyncSession = Depends(get_db),
     current_user: User | None = RequirePermissionIfAuthEnabled(Permission.STATS_READ),
@@ -1280,10 +1301,10 @@ async def get_archive_stats(
     # Build date filter conditions scoped to PrintLogEntry (event-time).
     base_conditions = []
     if date_from:
-        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+        dt_from = local_date_start(date_from, day_zone(tz))
         base_conditions.append(PrintLogEntry.created_at >= dt_from)
     if date_to:
-        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+        dt_to = local_date_end(date_to, day_zone(tz))
         base_conditions.append(PrintLogEntry.created_at <= dt_to)
     _apply_run_user_filter(base_conditions, created_by_id)
     # Only prints on printers the caller may see (#1727)
@@ -1479,8 +1500,8 @@ async def get_archive_stats(
     elif energy_tracking_mode == "total":
         # Total consumption mode with a date filter (#941): use hourly snapshots
         # to compute per-plug (endpoint - baseline) deltas.
-        dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc) if date_from else None
-        dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc) if date_to else None
+        dt_from = local_date_start(date_from, day_zone(tz)) if date_from else None
+        dt_to = local_date_end(date_to, day_zone(tz)) if date_to else None
         total_energy_kwh, energy_data_warming_up = await _sum_snapshot_deltas(db, dt_from=dt_from, dt_to=dt_to)
         costed = await snapshot_cost(db, fallback_price=energy_cost_per_kwh, dt_from=dt_from, dt_to=dt_to)
         total_energy_cost = cost_at_average_price(total_energy_kwh, costed.kwh, costed.cost, energy_cost_per_kwh)

+ 15 - 10
backend/app/api/routes/inventory.py

@@ -1,6 +1,6 @@
 import json
 import logging
-from datetime import date, datetime, time, timezone
+from datetime import date, datetime, timezone
 
 import httpx
 from fastapi import APIRouter, Depends, File, HTTPException, Query, UploadFile
@@ -95,6 +95,7 @@ from backend.app.utils.filament_ids import (
     normalize_slicer_filament,
 )
 from backend.app.utils.filament_types import is_material_name, nozzle_temp_range, printer_filament_type
+from backend.app.utils.local_time import day_zone, local_date_end, local_date_start
 from backend.app.utils.natural_sort import natural_sort_key
 from backend.app.utils.tag_normalization import normalize_tag_uid, normalize_tray_uuid
 
@@ -1427,7 +1428,6 @@ async def export_spools_csv(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
     """Export the active inventory as CSV (same schema the importer accepts)."""
-    from datetime import datetime, timezone
 
     query = (
         select(Spool)
@@ -1738,7 +1738,6 @@ async def archive_spool(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Soft-delete a spool by setting archived_at."""
-    from datetime import datetime, timezone
 
     result = await db.execute(select(Spool).where(Spool.id == spool_id))
     spool = result.scalar_one_or_none()
@@ -1915,7 +1914,6 @@ async def bulk_archive_spools(
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_UPDATE),
 ):
     """Soft-archive every listed spool (sets archived_at). Already-archived spools are left alone and counted in already_archived."""
-    from datetime import datetime, timezone
 
     result = await db.execute(select(Spool).where(Spool.id.in_(payload.ids)))
     spools = list(result.scalars().all())
@@ -2618,6 +2616,9 @@ async def get_spool_usage_history(
 async def get_supplier_stats(
     date_from: date | None = Query(None, description="Start date (inclusive), YYYY-MM-DD"),
     date_to: date | None = Query(None, description="End date (inclusive), YYYY-MM-DD"),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
@@ -2638,9 +2639,9 @@ async def get_supplier_stats(
     purchase_link = (SpoolSupplier.spool_id == Spool.id) & SpoolSupplier.is_purchase_source.is_(True)
     usage_window = []
     if date_from:
-        usage_window.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc))
+        usage_window.append(SpoolUsageHistory.created_at >= local_date_start(date_from, day_zone(tz)))
     if date_to:
-        usage_window.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc))
+        usage_window.append(SpoolUsageHistory.created_at <= local_date_end(date_to, day_zone(tz)))
 
     remaining = Spool.label_weight - Spool.weight_used
     inventory_rows = await db.execute(
@@ -2706,6 +2707,9 @@ async def get_supplier_stats(
 async def get_material_number_stats(
     date_from: date | None = Query(None),
     date_to: date | None = Query(None),
+    tz: str | None = Query(
+        None, max_length=64, description="IANA zone the dates are calendar days in; the server's when omitted"
+    ),
     db: AsyncSession = Depends(get_db),
     _: User | None = RequirePermissionIfAuthEnabled(Permission.INVENTORY_READ),
 ):
@@ -2730,11 +2734,13 @@ async def get_material_number_stats(
     # validator, so NULL is the only "unset" state to exclude here.
     has_number = Spool.material_number.is_not(None)
 
-    usage_filters = [has_number]
+    from sqlalchemy import ColumnElement
+
+    usage_filters: list[ColumnElement[bool]] = [has_number]
     if date_from:
-        usage_filters.append(SpoolUsageHistory.created_at >= datetime.combine(date_from, time.min, tzinfo=timezone.utc))
+        usage_filters.append(SpoolUsageHistory.created_at >= local_date_start(date_from, day_zone(tz)))
     if date_to:
-        usage_filters.append(SpoolUsageHistory.created_at <= datetime.combine(date_to, time.max, tzinfo=timezone.utc))
+        usage_filters.append(SpoolUsageHistory.created_at <= local_date_end(date_to, day_zone(tz)))
 
     # Clamped PER SPOOL, like every other remaining-weight computation in the
     # codebase: a spool whose weight_used overshot its label_weight holds 0 g,
@@ -3142,7 +3148,6 @@ async def update_shopping_list_status(
     ),
 ):
     """Update the purchase status of a shopping list item."""
-    from datetime import datetime, timezone
 
     from backend.app.models.shopping_list import ShoppingListItem
 

+ 5 - 3
backend/app/services/archive.py

@@ -7,7 +7,7 @@ import re
 import shutil
 import zipfile
 from collections.abc import Sequence
-from datetime import date, datetime, time, timezone
+from datetime import date, datetime, timezone, tzinfo
 from pathlib import Path
 from typing import Any
 
@@ -25,6 +25,7 @@ from backend.app.models.printer import Printer
 from backend.app.utils.archive_paths import archive_dir as resolve_archive_dir
 from backend.app.utils.ffmpeg_output import NO_FFMPEG_OUTPUT, summarize_ffmpeg_stderr
 from backend.app.utils.filename import clean_display_name
+from backend.app.utils.local_time import local_date_end, local_date_start
 from backend.app.utils.safe_path import PathTraversalError, assert_under, safe_join_under
 from backend.app.utils.threemf_tools import bed_temperature_from_config, used_filament_indexes
 
@@ -1650,6 +1651,7 @@ class ArchiveService:
         offset: int = 0,
         visible_to_user_id: int | None = None,
         printer_scope: PrinterScope | None = None,
+        day_zone: tzinfo | None = None,
     ) -> list[PrintArchive]:
         """List archives with optional filtering.
 
@@ -1676,11 +1678,11 @@ class ArchiveService:
             query = query.where(PrintArchive.project_id == project_id)
 
         if date_from:
-            dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+            dt_from = local_date_start(date_from, day_zone)
             query = query.where(PrintArchive.created_at >= dt_from)
 
         if date_to:
-            dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+            dt_to = local_date_end(date_to, day_zone)
             query = query.where(PrintArchive.created_at <= dt_to)
 
         if visible_to_user_id is not None:

+ 10 - 4
backend/app/services/failure_analysis.py

@@ -1,5 +1,5 @@
 from collections import defaultdict
-from datetime import date, datetime, time, timedelta, timezone
+from datetime import date, datetime, timedelta, timezone, tzinfo
 
 from sqlalchemy import and_, func, select
 from sqlalchemy.ext.asyncio import AsyncSession
@@ -7,6 +7,7 @@ from sqlalchemy.ext.asyncio import AsyncSession
 from backend.app.core.printer_scope import PrinterScope
 from backend.app.models.print_log import PrintLogEntry
 from backend.app.models.printer import Printer
+from backend.app.utils.local_time import local_date_end, local_date_start
 
 
 class FailureAnalysisService:
@@ -31,17 +32,22 @@ class FailureAnalysisService:
         project_id: int | None = None,
         created_by_id: int | None = None,
         printer_scope: PrinterScope | None = None,
+        day_zone: tzinfo | None = None,
     ) -> dict:
-        """Analyze failure patterns across logged print events."""
+        """Analyze failure patterns across logged print events.
+
+        ``day_zone``: the zone ``date_from``/``date_to`` are calendar days in
+        (the server's when None).
+        """
         # Build base query — separate date vs non-date filters for trend reuse
         base_filter = []
         non_date_filter = []
         if date_from or date_to:
             if date_from:
-                dt_from = datetime.combine(date_from, time.min, tzinfo=timezone.utc)
+                dt_from = local_date_start(date_from, day_zone)
                 base_filter.append(PrintLogEntry.created_at >= dt_from)
             if date_to:
-                dt_to = datetime.combine(date_to, time.max, tzinfo=timezone.utc)
+                dt_to = local_date_end(date_to, day_zone)
                 base_filter.append(PrintLogEntry.created_at <= dt_to)
             range_start = dt_from if date_from else datetime.now(timezone.utc) - timedelta(days=365)
             range_end = dt_to if date_to else datetime.now(timezone.utc)

+ 43 - 1
backend/app/utils/local_time.py

@@ -16,7 +16,7 @@ from __future__ import annotations
 
 import logging
 import os
-from datetime import datetime, timedelta, timezone, tzinfo
+from datetime import date, datetime, timedelta, timezone, tzinfo
 from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
 
 logger = logging.getLogger(__name__)
@@ -98,6 +98,48 @@ def local_day_start(now_utc: datetime, *, days_ago: int = 0) -> datetime:
     return local_midnight.astimezone(timezone.utc)
 
 
+def day_zone(name: str | None) -> tzinfo:
+    """The zone a caller's calendar dates are in: the IANA ``name`` it sent, or
+    the server's own (``TZ``) when it sent none or one that is not a zone.
+
+    The Stats page sends the browser's zone with its dates; read in the
+    server's instead, a browser away from it saw every day shifted -- for
+    one in Tokyo on a UTC server, "Today" stayed empty until 09:00 (DB-C12).
+    """
+    if name:
+        try:
+            return ZoneInfo(name)
+        # OSError too: on some Python builds a directory name ("America")
+        # raises PermissionError instead of ZoneInfoNotFoundError.
+        except (ZoneInfoNotFoundError, ValueError, OSError):
+            logger.debug("Unknown zone %r for a date filter; using the server's", name)
+    return local_zone()
+
+
+def local_date_start(day: date, zone: tzinfo | None = None) -> datetime:
+    """The start of a calendar date in ``zone`` (the server's by default), as a UTC instant.
+
+    What a date filter means: "16 October" is the user's 16 October, not the
+    UTC one, which in Berlin starts at 02:00 (DB-C12). Built from the date
+    itself, so a DST change day is its true 23 or 25 hours.
+    """
+    try:
+        return datetime(day.year, day.month, day.day, tzinfo=zone or local_zone()).astimezone(timezone.utc)
+    except OverflowError:
+        # 0001-01-01 in a zone ahead of UTC starts before the first datetime:
+        # an open-ended range must not 500.
+        return datetime.min.replace(tzinfo=timezone.utc)
+
+
+def local_date_end(day: date, zone: tzinfo | None = None) -> datetime:
+    """The last instant of a calendar date in ``zone``, as a UTC instant."""
+    try:
+        return local_date_start(day + timedelta(days=1), zone) - timedelta(microseconds=1)
+    except OverflowError:
+        # 9999-12-31 has no next day to stop before.
+        return datetime.max.replace(tzinfo=timezone.utc)
+
+
 def next_local_hour(now_utc: datetime) -> datetime:
     """Return the next top-of-the-hour *local* time, as a UTC instant.
 

+ 112 - 0
backend/tests/integration/test_stats_local_days.py

@@ -0,0 +1,112 @@
+"""A Stats date filter takes the local calendar day (DB-C12)."""
+
+from datetime import datetime
+
+import pytest
+from httpx import AsyncClient
+
+from backend.app.models.print_log import PrintLogEntry
+
+pytestmark = [pytest.mark.asyncio, pytest.mark.integration]
+
+
+async def test_a_print_at_half_past_one_in_berlin_counts_for_that_day(
+    async_client: AsyncClient, printer_factory, db_session, monkeypatch
+):
+    monkeypatch.setenv("TZ", "Europe/Berlin")
+    printer = await printer_factory()
+    # 01:30 on 16 October in Berlin is 23:30 on the 15th in UTC.
+    db_session.add(
+        PrintLogEntry(
+            print_name="late night",
+            printer_id=printer.id,
+            status="completed",
+            created_at=datetime(2026, 10, 15, 23, 30),
+        )
+    )
+    await db_session.commit()
+
+    sixteenth = await async_client.get("/api/v1/archives/slim?date_from=2026-10-16&date_to=2026-10-16")
+    fifteenth = await async_client.get("/api/v1/archives/slim?date_from=2026-10-15&date_to=2026-10-15")
+
+    assert [r["print_name"] for r in sixteenth.json()] == ["late night"]
+    assert fifteenth.json() == []
+
+
+async def test_the_statistics_totals_take_the_local_day(
+    async_client: AsyncClient, printer_factory, db_session, monkeypatch
+):
+    monkeypatch.setenv("TZ", "Europe/Berlin")
+    printer = await printer_factory()
+    db_session.add(
+        PrintLogEntry(
+            print_name="late night", printer_id=printer.id, status="failed", created_at=datetime(2026, 10, 15, 23, 30)
+        )
+    )
+    await db_session.commit()
+
+    stats = (await async_client.get("/api/v1/archives/stats?date_from=2026-10-16&date_to=2026-10-16")).json()
+    failures = (
+        await async_client.get("/api/v1/archives/analysis/failures?date_from=2026-10-16&date_to=2026-10-16")
+    ).json()
+
+    assert stats["total_prints"] == 1
+    assert failures["total_prints"] == 1
+
+
+async def test_the_archive_list_takes_the_local_day(
+    async_client: AsyncClient, printer_factory, archive_factory, db_session, monkeypatch
+):
+    monkeypatch.setenv("TZ", "Europe/Berlin")
+    printer = await printer_factory()
+    archive = await archive_factory(printer.id, print_name="late night")
+    archive.created_at = datetime(2026, 10, 15, 23, 30)
+    await db_session.commit()
+
+    listed = (await async_client.get("/api/v1/archives/?date_from=2026-10-16&date_to=2026-10-16")).json()
+
+    assert [a["id"] for a in listed] == [archive.id]
+
+
+# The browser's own zone, sent with its dates: a browser away from the
+# server's TZ (the Docker default is Europe/Berlin) shifted every day.
+TOKYO_EARLY = datetime(2026, 10, 16, 23, 30)  # 08:30 on 17 October in Tokyo
+
+
+async def _tokyo_morning_print(printer_factory, db_session, monkeypatch):
+    monkeypatch.setenv("TZ", "UTC")
+    printer = await printer_factory()
+    db_session.add(PrintLogEntry(print_name="morning", printer_id=printer.id, status="failed", created_at=TOKYO_EARLY))
+    await db_session.commit()
+    return printer
+
+
+@pytest.mark.parametrize(
+    ("path", "count"),
+    [
+        ("/api/v1/archives/slim", lambda body: len(body)),
+        ("/api/v1/archives/stats", lambda body: body["total_prints"]),
+        ("/api/v1/archives/analysis/failures", lambda body: body["total_prints"]),
+    ],
+)
+async def test_the_dates_are_days_in_the_zone_the_caller_names(
+    async_client: AsyncClient, printer_factory, db_session, monkeypatch, path, count
+):
+    await _tokyo_morning_print(printer_factory, db_session, monkeypatch)
+
+    tokyo_17th = (await async_client.get(f"{path}?date_from=2026-10-17&date_to=2026-10-17&tz=Asia/Tokyo")).json()
+    server_17th = (await async_client.get(f"{path}?date_from=2026-10-17&date_to=2026-10-17")).json()
+
+    assert count(tokyo_17th) == 1
+    assert count(server_17th) == 0
+
+
+async def test_an_unknown_zone_falls_back_to_the_server_s(
+    async_client: AsyncClient, printer_factory, db_session, monkeypatch
+):
+    await _tokyo_morning_print(printer_factory, db_session, monkeypatch)
+
+    response = await async_client.get("/api/v1/archives/slim?date_from=2026-10-16&date_to=2026-10-16&tz=Mars/Olympus")
+
+    assert response.status_code == 200
+    assert len(response.json()) == 1

+ 53 - 0
backend/tests/unit/test_local_date_bounds.py

@@ -0,0 +1,53 @@
+"""A date filter means a local calendar day (DB-C12).
+
+The Stats page's Today / This Week / This Month sent UTC dates, and the
+backend turned each into a UTC day: in Berlin, "Today" at 01:30 showed the
+prints from 02:00 the day before until 01:59. A date is now a day in the
+zone the caller names (the Stats page sends the browser's), else in the
+server's TZ.
+"""
+
+from datetime import date, datetime, timezone
+
+import pytest
+
+from backend.app.utils.local_time import local_date_end, local_date_start
+
+
+@pytest.fixture
+def berlin(monkeypatch):
+    monkeypatch.setenv("TZ", "Europe/Berlin")
+
+
+def test_a_berlin_day_starts_at_its_midnight(berlin):
+    assert local_date_start(date(2026, 10, 16)) == datetime(2026, 10, 15, 22, 0, tzinfo=timezone.utc)
+
+
+def test_a_berlin_day_ends_just_before_the_next_midnight(berlin):
+    end = local_date_end(date(2026, 10, 16))
+    assert end == datetime(2026, 10, 16, 21, 59, 59, 999999, tzinfo=timezone.utc)
+
+
+def test_the_dst_change_day_is_25_hours(berlin):
+    start = local_date_start(date(2026, 10, 25))
+    end = local_date_end(date(2026, 10, 25))
+    assert (end - start).total_seconds() == pytest.approx(25 * 3600, abs=1)
+
+
+def test_without_a_tz_a_day_is_a_utc_day(monkeypatch):
+    monkeypatch.delenv("TZ", raising=False)
+    assert local_date_start(date(2026, 10, 16)) == datetime(2026, 10, 16, tzinfo=timezone.utc)
+
+
+def test_the_far_ends_of_the_calendar_do_not_overflow(berlin):
+    """An open-ended range sent as 0001-01-01 .. 9999-12-31 must not 500."""
+    assert local_date_start(date(1, 1, 1)) == datetime.min.replace(tzinfo=timezone.utc)
+    assert local_date_end(date(9999, 12, 31)) == datetime.max.replace(tzinfo=timezone.utc)
+
+
+def test_a_zone_name_that_is_a_folder_falls_back(monkeypatch):
+    from backend.app.utils.local_time import day_zone
+
+    monkeypatch.setenv("TZ", "UTC")
+    assert str(day_zone("America")) in ("UTC", "UTC+00:00")
+    assert str(day_zone("../etc/passwd")) in ("UTC", "UTC+00:00")

+ 57 - 0
frontend/src/__tests__/api/dayZone.test.ts

@@ -0,0 +1,57 @@
+/**
+ * Calendar dates go to the server with the browser's zone (DB-C12).
+ *
+ * The server reads a date as a day in the zone it is sent with, else in its own
+ * TZ -- which, for a browser away from the server, shifted every Stats day.
+ */
+
+import { describe, it, expect, afterAll, beforeAll } from 'vitest';
+import { http, HttpResponse } from 'msw';
+import { server } from '../mocks/server';
+import { api } from '../../api/client';
+
+const originalTz = process.env.TZ;
+
+beforeAll(() => {
+  process.env.TZ = 'Asia/Tokyo';
+});
+
+afterAll(() => {
+  if (originalTz === undefined) delete process.env.TZ;
+  else process.env.TZ = originalTz;
+});
+
+function capture(path: string, body: unknown): URL[] {
+  const seen: URL[] = [];
+  server.use(
+    http.get(path, ({ request }) => {
+      seen.push(new URL(request.url));
+      return HttpResponse.json(body);
+    }),
+  );
+  return seen;
+}
+
+describe('date filters carry the browser zone', () => {
+  it('sends it with the dates', async () => {
+    const seen = capture('/api/v1/archives/slim', []);
+    await api.getArchivesSlim('2026-10-17', '2026-10-17');
+
+    expect(seen[0].searchParams.get('tz')).toBe('Asia/Tokyo');
+    expect(seen[0].searchParams.get('date_from')).toBe('2026-10-17');
+  });
+
+  it('sends it for the statistics totals too', async () => {
+    const seen = capture('/api/v1/archives/stats', {});
+    await api.getArchiveStats({ dateFrom: '2026-10-17', dateTo: '2026-10-17' });
+
+    expect(seen[0].searchParams.get('tz')).toBe('Asia/Tokyo');
+  });
+
+  it('leaves it out without dates', async () => {
+    const seen = capture('/api/v1/archives/slim', []);
+    await api.getArchivesSlim();
+
+    expect(seen[0].searchParams.has('tz')).toBe(false);
+  });
+});

+ 50 - 0
frontend/src/__tests__/utils/statsDateRange.test.ts

@@ -0,0 +1,50 @@
+/**
+ * The Stats presets are local calendar days (DB-C12).
+ *
+ * They were built from UTC dates: at 01:30 in Berlin, "Today" asked for the
+ * previous day, and This Week / This Month started a day early or late near
+ * midnight. The backend reads a date as a day in the browser's zone, which the
+ * API client sends with it.
+ */
+
+import { describe, it, expect, beforeAll, afterAll, vi } from 'vitest';
+import { computeDateRange } from '../../utils/statsDateRange';
+
+const originalTz = process.env.TZ;
+
+beforeAll(() => {
+  process.env.TZ = 'Europe/Berlin';
+  vi.useFakeTimers();
+  // 01:30 on Friday 16 October in Berlin -- still the 15th in UTC.
+  vi.setSystemTime(new Date('2026-10-15T23:30:00Z'));
+});
+
+afterAll(() => {
+  vi.useRealTimers();
+  // Assigning undefined would set the string "undefined", a zone of its own.
+  if (originalTz === undefined) delete process.env.TZ;
+  else process.env.TZ = originalTz;
+});
+
+describe('computeDateRange', () => {
+  it('takes today as the local date', () => {
+    expect(computeDateRange('today')).toEqual({ dateFrom: '2026-10-16', dateTo: '2026-10-16' });
+  });
+
+  it('starts this week on the local Monday', () => {
+    expect(computeDateRange('this-week')).toEqual({ dateFrom: '2026-10-12', dateTo: '2026-10-16' });
+  });
+
+  it('counts the last 7 days back from the local date', () => {
+    expect(computeDateRange('last-7')).toEqual({ dateFrom: '2026-10-10', dateTo: '2026-10-16' });
+  });
+
+  it('starts this month and this year on their local first days', () => {
+    expect(computeDateRange('this-month').dateFrom).toBe('2026-10-01');
+    expect(computeDateRange('this-year').dateFrom).toBe('2026-01-01');
+  });
+
+  it('has no bounds for all time', () => {
+    expect(computeDateRange('all-time')).toEqual({ dateFrom: undefined, dateTo: undefined });
+  });
+});

+ 22 - 0
frontend/src/api/client.ts

@@ -1018,6 +1018,21 @@ export interface FailureAnalysis {
   }>;
 }
 
+/**
+ * Send the browser's zone with calendar dates: the server reads a date as a
+ * day in it, else in its own TZ, which shifted every day for a browser away
+ * from the server (DB-C12). Only when a date is set.
+ */
+function addDayZone(params: URLSearchParams): void {
+  if (!params.has('date_from') && !params.has('date_to')) return;
+  try {
+    const zone = Intl.DateTimeFormat().resolvedOptions().timeZone;
+    if (zone) params.set('tz', zone);
+  } catch {
+    // No Intl zone: the server's TZ applies.
+  }
+}
+
 export interface BulkUploadResult {
   uploaded: number;
   failed: number;
@@ -5500,6 +5515,7 @@ export const api = {
     params.set('offset', String(offset));
     if (dateFrom) params.set('date_from', dateFrom);
     if (dateTo) params.set('date_to', dateTo);
+    addDayZone(params);
     return request<Archive[]>(`/archives/?${params}`);
   },
   // Latest archive of every printer in one request, for the printer cards.
@@ -5508,6 +5524,7 @@ export const api = {
     const params = new URLSearchParams();
     if (dateFrom) params.set('date_from', dateFrom);
     if (dateTo) params.set('date_to', dateTo);
+    addDayZone(params);
     if (createdById !== undefined) params.set('created_by_id', String(createdById));
     const qs = params.toString();
     return request<ArchiveSlim[]>(`/archives/slim${qs ? `?${qs}` : ''}`);
@@ -5611,6 +5628,7 @@ export const api = {
     const params = new URLSearchParams();
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateTo) params.set('date_to', options.dateTo);
+    addDayZone(params);
     if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));
     const qs = params.toString();
     return request<ArchiveStats>(`/archives/stats${qs ? `?${qs}` : ''}`);
@@ -5633,6 +5651,7 @@ export const api = {
     if (options?.days) params.set('days', String(options.days));
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateTo) params.set('date_to', options.dateTo);
+    addDayZone(params);
     if (options?.printerId) params.set('printer_id', String(options.printerId));
     if (options?.projectId) params.set('project_id', String(options.projectId));
     if (options?.createdById !== undefined) params.set('created_by_id', String(options.createdById));
@@ -5661,6 +5680,7 @@ export const api = {
     if (options?.status) params.set('status', options.status);
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateTo) params.set('date_to', options.dateTo);
+    addDayZone(params);
     if (options?.search) params.set('search', options.search);
 
     const headers: Record<string, string> = {};
@@ -7127,6 +7147,7 @@ export const api = {
     const params = new URLSearchParams();
     if (dateFrom) params.set('date_from', dateFrom);
     if (dateTo) params.set('date_to', dateTo);
+    addDayZone(params);
     const qs = params.toString();
     return request<SupplierStats[]>(`/inventory/stats/suppliers${qs ? `?${qs}` : ''}`);
   },
@@ -7187,6 +7208,7 @@ export const api = {
     const params = new URLSearchParams();
     if (options?.dateFrom) params.set('date_from', options.dateFrom);
     if (options?.dateTo) params.set('date_to', options.dateTo);
+    addDayZone(params);
     const qs = params.toString();
     return request<MaterialNumberStats[]>(`/inventory/stats/material-numbers${qs ? `?${qs}` : ''}`);
   },

+ 3 - 34
frontend/src/pages/StatsPage.tsx

@@ -47,9 +47,9 @@ import { formatWeight } from '../utils/weight';
 import { parseUTCDate, formatDuration } from '../utils/date';
 import { MetricToggle, type Metric } from '../components/MetricToggle';
 import { useUiSettings } from '../hooks/useUiSettings';
+import { computeDateRange, localDate, type TimeframePreset } from '../utils/statsDateRange';
 
 // Timeframe types and helpers
-type TimeframePreset = 'today' | 'this-week' | 'this-month' | 'last-7' | 'last-30' | 'last-90' | 'this-year' | 'all-time' | 'custom';
 
 interface TimeframeState {
   preset: TimeframePreset;
@@ -57,37 +57,6 @@ interface TimeframeState {
   dateTo: string | undefined;   // YYYY-MM-DD
 }
 
-function computeDateRange(preset: TimeframePreset): { dateFrom?: string; dateTo?: string } {
-  const now = new Date();
-  const y = now.getUTCFullYear(), m = now.getUTCMonth(), d = now.getUTCDate();
-  const fmt = (dt: Date) => dt.toISOString().split('T')[0];
-  const todayStr = fmt(now);
-
-  switch (preset) {
-    case 'today':
-      return { dateFrom: todayStr, dateTo: todayStr };
-    case 'this-week': {
-      const day = now.getUTCDay();
-      const start = new Date(Date.UTC(y, m, d - (day === 0 ? 6 : day - 1)));
-      return { dateFrom: fmt(start), dateTo: todayStr };
-    }
-    case 'this-month':
-      return { dateFrom: fmt(new Date(Date.UTC(y, m, 1))), dateTo: todayStr };
-    case 'last-7':
-      return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 6))), dateTo: todayStr };
-    case 'last-30':
-      return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 29))), dateTo: todayStr };
-    case 'last-90':
-      return { dateFrom: fmt(new Date(Date.UTC(y, m, d - 89))), dateTo: todayStr };
-    case 'this-year':
-      return { dateFrom: fmt(new Date(Date.UTC(y, 0, 1))), dateTo: todayStr };
-    case 'all-time':
-      return { dateFrom: undefined, dateTo: undefined };
-    case 'custom':
-      return {};
-  }
-}
-
 const TIMEFRAME_PRESETS: TimeframePreset[] = [
   'today', 'this-week', 'this-month',
   'last-7', 'last-30', 'last-90',
@@ -1437,7 +1406,7 @@ export function StatsPage() {
                         <input
                           type="date"
                           value={timeframe.dateFrom || ''}
-                          max={timeframe.dateTo || new Date().toISOString().split('T')[0]}
+                          max={timeframe.dateTo || localDate(new Date())}
                           onChange={(e) => setTimeframe(prev => ({ ...prev, dateFrom: e.target.value || undefined }))}
                           className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white"
                         />
@@ -1448,7 +1417,7 @@ export function StatsPage() {
                           type="date"
                           value={timeframe.dateTo || ''}
                           min={timeframe.dateFrom}
-                          max={new Date().toISOString().split('T')[0]}
+                          max={localDate(new Date())}
                           onChange={(e) => setTimeframe(prev => ({ ...prev, dateTo: e.target.value || undefined }))}
                           className="w-full bg-bambu-dark border border-bambu-dark-tertiary rounded-md px-3 py-1.5 text-sm text-white"
                         />

+ 54 - 0
frontend/src/utils/statsDateRange.ts

@@ -0,0 +1,54 @@
+/**
+ * Date ranges for the Stats page's timeframe presets.
+ *
+ * Local calendar days, as YYYY-MM-DD, sent with the browser's zone (the API
+ * client adds it as `tz`), which the backend reads them in (DB-C12). They used
+ * to be UTC dates, so at 01:30 in Berlin "Today" asked for the previous day.
+ */
+
+export type TimeframePreset =
+  | 'today'
+  | 'this-week'
+  | 'this-month'
+  | 'last-7'
+  | 'last-30'
+  | 'last-90'
+  | 'this-year'
+  | 'all-time'
+  | 'custom';
+
+const pad = (n: number) => String(n).padStart(2, '0');
+
+/** A local date as YYYY-MM-DD (toISOString would give the UTC one). */
+export function localDate(dt: Date): string {
+  return `${dt.getFullYear()}-${pad(dt.getMonth() + 1)}-${pad(dt.getDate())}`;
+}
+
+export function computeDateRange(preset: TimeframePreset): { dateFrom?: string; dateTo?: string } {
+  const now = new Date();
+  const y = now.getFullYear(), m = now.getMonth(), d = now.getDate();
+  const todayStr = localDate(now);
+
+  switch (preset) {
+    case 'today':
+      return { dateFrom: todayStr, dateTo: todayStr };
+    case 'this-week': {
+      const day = now.getDay();
+      return { dateFrom: localDate(new Date(y, m, d - (day === 0 ? 6 : day - 1))), dateTo: todayStr };
+    }
+    case 'this-month':
+      return { dateFrom: localDate(new Date(y, m, 1)), dateTo: todayStr };
+    case 'last-7':
+      return { dateFrom: localDate(new Date(y, m, d - 6)), dateTo: todayStr };
+    case 'last-30':
+      return { dateFrom: localDate(new Date(y, m, d - 29)), dateTo: todayStr };
+    case 'last-90':
+      return { dateFrom: localDate(new Date(y, m, d - 89)), dateTo: todayStr };
+    case 'this-year':
+      return { dateFrom: localDate(new Date(y, 0, 1)), dateTo: todayStr };
+    case 'all-time':
+      return { dateFrom: undefined, dateTo: undefined };
+    case 'custom':
+      return {};
+  }
+}