diff --git a/backend/app/routers/dashboard.py b/backend/app/routers/dashboard.py index 72d3f69..d644bd7 100644 --- a/backend/app/routers/dashboard.py +++ b/backend/app/routers/dashboard.py @@ -301,6 +301,35 @@ def _signiert(movement: Movement) -> float: return movement.quantity # Korrekturen sind bereits vorzeichenbehaftet +def _utc(zeitpunkt: datetime) -> datetime: + """SQLite gibt Zeitstempel ohne Zeitzone zurück – hier vereinheitlichen.""" + return zeitpunkt if zeitpunkt.tzinfo else zeitpunkt.replace(tzinfo=timezone.utc) + + +def _schrittweite(days: int) -> timedelta: + """Abtastrate passend zum Zeitraum. + + Ein Punkt je Tag verbirgt, wann am Tag etwas passiert ist; ein Punkt je + Stunde über ein Jahr wären knapp 9000 Punkte. Deshalb gestaffelt – die + Anzahl der Punkte bleibt so immer im Bereich von etwa 25 bis 170. + """ + if days <= 2: + return timedelta(hours=1) + if days <= 14: + return timedelta(hours=6) + if days <= 120: + return timedelta(days=1) + return timedelta(days=7) + + +def _eimer(days: int) -> tuple[datetime, timedelta, int]: + """Endzeitpunkt, Schrittweite und Anzahl der Abschnitte.""" + schritt = _schrittweite(days) + jetzt = datetime.now(timezone.utc) + anzahl = max(1, int(timedelta(days=days) / schritt)) + return jetzt, schritt, anzahl + + @router.get("/timeline", response_model=list[TimelinePoint]) def timeline( days: int = 90, @@ -317,7 +346,7 @@ def timeline( Packungsgröße. Wird sie später geändert, verschiebt sich auch die Historie. """ days = max(1, min(days, 730)) - heute = date.today() + jetzt, schritt, anzahl = _eimer(days) produkte = db.query(Product) if product_id is not None: @@ -329,26 +358,29 @@ def timeline( faktoren = {p.id: (article_unit(p)[0] or 1.0) for p in produkte} bestand = {p.id: current_stock(db, p.id) for p in produkte} - beginn = datetime.combine(heute - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc) + beginn = jetzt - schritt * anzahl bewegungen = db.query(Movement).filter(Movement.created_at >= beginn) if product_id is not None: bewegungen = bewegungen.filter(Movement.product_id == product_id) - delta: dict[date, dict[int, float]] = defaultdict(lambda: defaultdict(float)) + # Abschnitt 0 ist der jüngste (von jetzt rückwärts eine Schrittweite). + sekunden = schritt.total_seconds() + delta: dict[int, dict[int, float]] = defaultdict(lambda: defaultdict(float)) for m in bewegungen.all(): - if m.product_id in faktoren: - delta[m.created_at.date()][m.product_id] += _signiert(m) + if m.product_id not in faktoren: + continue + index = int((jetzt - _utc(m.created_at)).total_seconds() // sekunden) + if 0 <= index < anzahl: + delta[index][m.product_id] += _signiert(m) def summe() -> float: return round(sum(bestand[pid] / faktoren[pid] for pid in bestand), 3) - punkte = [TimelinePoint(date=heute, article_units=summe())] - tag = heute - for _ in range(days - 1): - for pid, wert in delta.get(tag, {}).items(): + punkte = [TimelinePoint(at=jetzt, article_units=summe())] + for i in range(anzahl): + for pid, wert in delta.get(i, {}).items(): bestand[pid] = bestand.get(pid, 0.0) - wert - tag = tag - timedelta(days=1) - punkte.append(TimelinePoint(date=tag, article_units=summe())) + punkte.append(TimelinePoint(at=jetzt - schritt * (i + 1), article_units=summe())) punkte.reverse() return punkte @@ -360,25 +392,33 @@ def activity( db: Session = Depends(get_db), _: User = Depends(get_current_user), ) -> list[ActivityPoint]: - """Anzahl der Ein- und Auslagerungen je Tag.""" + """Anzahl der Ein- und Auslagerungen je Abschnitt. + + Die Abschnittslänge richtet sich nach dem Zeitraum: bei ein bis zwei Tagen + stündlich, damit erkennbar wird, zu welcher Tageszeit gelagert wird. + """ days = max(1, min(days, 365)) - heute = date.today() - beginn = datetime.combine(heute - timedelta(days=days - 1), datetime.min.time(), tzinfo=timezone.utc) + jetzt, schritt, anzahl = _eimer(days) + beginn = jetzt - schritt * anzahl + sekunden = schritt.total_seconds() - ein: dict[date, int] = defaultdict(int) - aus: dict[date, int] = defaultdict(int) + ein: dict[int, int] = defaultdict(int) + aus: dict[int, int] = defaultdict(int) for m in db.query(Movement).filter(Movement.created_at >= beginn).all(): - tag = m.created_at.date() + index = int((jetzt - _utc(m.created_at)).total_seconds() // sekunden) + if not 0 <= index < anzahl: + continue if m.type == MovementType.in_: - ein[tag] += 1 + ein[index] += 1 elif m.type == MovementType.out: - aus[tag] += 1 + aus[index] += 1 + # Von alt nach neu ausgeben; Abschnitt 0 ist der jüngste. return [ ActivityPoint( - date=heute - timedelta(days=days - 1 - i), - checked_in=ein.get(heute - timedelta(days=days - 1 - i), 0), - checked_out=aus.get(heute - timedelta(days=days - 1 - i), 0), + at=jetzt - schritt * i, + checked_in=ein.get(i, 0), + checked_out=aus.get(i, 0), ) - for i in range(days) + for i in reversed(range(anzahl)) ] diff --git a/backend/app/schemas.py b/backend/app/schemas.py index 367dbb2..130ada7 100644 --- a/backend/app/schemas.py +++ b/backend/app/schemas.py @@ -431,11 +431,13 @@ class CategoryShare(BaseModel): class TimelinePoint(BaseModel): - date: date + # Zeitpunkt statt Datum: Bei kurzen Zeiträumen wird stündlich abgetastet, + # damit sichtbar wird, *wann* am Tag ein- und ausgelagert wurde. + at: datetime article_units: float class ActivityPoint(BaseModel): - date: date + at: datetime checked_in: int checked_out: int diff --git a/backend/tests/test_dashboard.py b/backend/tests/test_dashboard.py index 02516bb..3e6ddd6 100644 --- a/backend/tests/test_dashboard.py +++ b/backend/tests/test_dashboard.py @@ -1,4 +1,4 @@ -from datetime import date, timedelta +from datetime import date, timedelta # noqa: F401 (date fuer die Zustands-Tests) import pytest @@ -90,19 +90,34 @@ def test_by_category_bündelt_und_sortiert(db, user): # ----------------------------------------------------------------- Verlauf +def test_schrittweite_haengt_am_zeitraum(): + """Kurze Zeitraeume werden fein abgetastet, lange grob.""" + assert dashboard._schrittweite(1) == timedelta(hours=1) + assert dashboard._schrittweite(2) == timedelta(hours=1) + assert dashboard._schrittweite(7) == timedelta(hours=6) + assert dashboard._schrittweite(90) == timedelta(days=1) + assert dashboard._schrittweite(365) == timedelta(days=7) + + +def test_schrittweite_haelt_die_punktzahl_im_rahmen(): + """Egal welcher Zeitraum – die Kurve bleibt zeichenbar.""" + for tage in (1, 2, 7, 14, 30, 90, 180, 365, 730): + anzahl = timedelta(days=tage) / dashboard._schrittweite(tage) + assert 20 <= anzahl <= 180, f"{tage} Tage ergeben {anzahl} Punkte" + + def test_timeline_rechnet_bewegungen_zurueck(db, rice, user): """Der Verlauf entsteht rueckwaerts aus dem heutigen Bestand.""" - check_in(db, rice, 4, "package", None, None, user) # +4 Einheiten heute - check_out(db, rice, 1, "package", user) # -1 Einheit heute + check_in(db, rice, 4, "package", None, None, user) # +4 Einheiten jetzt + check_out(db, rice, 1, "package", user) # -1 Einheit jetzt db.commit() punkte = dashboard.timeline(days=3, product_id=None, db=db, _=user) - assert len(punkte) == 3 - # Heute: 3 Einheiten; die Tage davor lag nichts im Lager. - assert punkte[-1].date == date.today() + # Jetzt 3 Einheiten; vor dem Einlagern war das Lager leer. assert punkte[-1].article_units == 3 assert punkte[0].article_units == 0 - assert punkte[1].article_units == 0 + # Aufsteigend sortiert, damit die Linie von links nach rechts laeuft. + assert punkte[0].at < punkte[-1].at def test_timeline_kann_auf_ein_produkt_eingegrenzt_werden(db, rice, user): @@ -125,12 +140,13 @@ def test_activity_zaehlt_ein_und_auslagerungen(db, rice, user): check_out(db, rice, 1, "package", user) db.commit() - tage = dashboard.activity(days=3, db=db, _=user) - assert len(tage) == 3 - heute = tage[-1] - assert heute.date == date.today() - assert heute.checked_in == 2 - assert heute.checked_out == 1 + abschnitte = dashboard.activity(days=3, db=db, _=user) + # Alles gerade eben passiert -> liegt im juengsten Abschnitt. + juengster = abschnitte[-1] + assert juengster.checked_in == 2 + assert juengster.checked_out == 1 + assert sum(a.checked_in for a in abschnitte) == 2 + assert abschnitte[0].at < juengster.at # ---------------------------------------------------------------- Anordnung diff --git a/web/src/dashboard/cards.jsx b/web/src/dashboard/cards.jsx index de6f75c..9b8c82c 100644 --- a/web/src/dashboard/cards.jsx +++ b/web/src/dashboard/cards.jsx @@ -41,11 +41,18 @@ function Zustand({ fehler, daten, children, leer = "Keine Daten." }) { return children; } -function kurzesDatum(iso) { - const d = new Date(`${iso}T00:00:00`); - return Number.isNaN(d.getTime()) - ? iso - : d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }); +/** + * Beschriftung der Zeitachse. Bei kurzen Zeiträumen tastet der Server stündlich + * ab – dann ist die Uhrzeit die nützliche Angabe, sonst das Datum. + */ +function zeitLabel(iso, tage) { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return String(iso); + if (tage <= 2) return d.toLocaleTimeString("de-DE", { hour: "2-digit", minute: "2-digit" }); + if (tage <= 14) { + return d.toLocaleString("de-DE", { day: "2-digit", month: "2-digit", hour: "2-digit" }); + } + return d.toLocaleDateString("de-DE", { day: "2-digit", month: "2-digit" }); } // ---------------------------------------------------------------- Aktionen @@ -272,11 +279,14 @@ function KarteKategorienSaeulen() { ); } -/** Zeitraum-Auswahl für die Verlaufskarten. */ -const ZEITRAEUME = [ - // 1 Tag ergibt einen einzelnen Wert – das Diagramm zeigt dann die blanke Zahl - // statt einer Linie aus einem Punkt. - { tage: 1, label: "1 Tag" }, +/** + * Auswahlmöglichkeiten für die Verlaufskarten. Die Auswahl selbst sitzt im + * Kartenkopf (siehe Dashboard) – so bleibt die ganze Kartenhöhe dem Diagramm. + */ +export const ZEITRAEUME = [ + // 1 Tag wird stündlich abgetastet; man sieht also, wann am Tag gelagert wurde. + { tage: 1, label: "24 Stunden" }, + { tage: 2, label: "2 Tage" }, { tage: 7, label: "7 Tage" }, { tage: 30, label: "30 Tage" }, { tage: 90, label: "90 Tage" }, @@ -284,44 +294,28 @@ const ZEITRAEUME = [ { tage: 365, label: "1 Jahr" }, ]; -function ZeitraumWahl({ wert, setzen }) { - return ( - - ); -} - -function KarteBestandsverlauf() { - const [tage, setTage] = useState(90); +function KarteBestandsverlauf({ tage = 90 }) { const { daten, fehler } = useDaten(() => api.dashboardTimeline(tage), [tage]); + if (fehler) return