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
{fehler}
; + if (!daten) return
Lädt…
; + return ( -
- - {fehler ? ( -
{fehler}
- ) : !daten ? ( -
Lädt…
- ) : ( - kurzesDatum(p.date))} - serien={[{ - name: "Artikeleinheiten", - color: tokenFarbe("--accent", "#3f51b5"), - werte: daten.map((p) => p.article_units), - }]} - einheit="Einheiten" - /> - )} -
+ zeitLabel(p.at, tage))} + serien={[{ + name: "Artikeleinheiten", + color: tokenFarbe("--accent", "#3f51b5"), + werte: daten.map((p) => p.article_units), + }]} + einheit="Einheiten" + /> ); } -function KarteArtikelverlauf() { +function KarteArtikelverlauf({ tage = 90 }) { const [produkte, setProdukte] = useState([]); const [gewaehlt, setGewaehlt] = useState(""); - const [tage, setTage] = useState(90); const { daten, fehler } = useDaten( () => (gewaehlt ? api.dashboardTimeline(tage, gewaehlt) : Promise.resolve([])), [gewaehlt, tage], @@ -337,18 +331,16 @@ function KarteArtikelverlauf() { return (
-
- - -
+ {fehler ? (
{fehler}
) : ( kurzesDatum(p.date))} + labels={(daten || []).map((p) => zeitLabel(p.at, tage))} serien={[{ name: "Bestand", color: tokenFarbe("--accent", "#3f51b5"), @@ -361,28 +353,21 @@ function KarteArtikelverlauf() { ); } -function KarteBewegungsverlauf() { - const [tage, setTage] = useState(30); +function KarteBewegungsverlauf({ tage = 30 }) { const { daten, fehler } = useDaten(() => api.dashboardActivity(tage), [tage]); + if (fehler) return
{fehler}
; + if (!daten) return
Lädt…
; + return ( -
- - {fehler ? ( -
{fehler}
- ) : !daten ? ( -
Lädt…
- ) : ( - kurzesDatum(p.date))} - serien={[ - { name: "Eingelagert", color: tokenFarbe("--ok", "#2e7d55"), werte: daten.map((p) => p.checked_in) }, - { name: "Ausgelagert", color: tokenFarbe("--warn", "#b06f14"), werte: daten.map((p) => p.checked_out) }, - ]} - einheit="Vorgänge" - /> - )} -
+ zeitLabel(p.at, tage))} + serien={[ + { name: "Eingelagert", color: tokenFarbe("--ok", "#2e7d55"), werte: daten.map((p) => p.checked_in) }, + { name: "Ausgelagert", color: tokenFarbe("--warn", "#b06f14"), werte: daten.map((p) => p.checked_out) }, + ]} + einheit="Vorgänge" + /> ); } @@ -465,19 +450,19 @@ export const KARTEN = { "stock-timeline": { titel: "Bestandsverlauf", icon: "history", vorschau: "linie", beschreibung: "Artikeleinheiten im Lager über 90 Tage", - standard: { w: 8, h: 6 }, min: { w: 4, h: 5 }, + zeitraum: 90, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 }, komponente: KarteBestandsverlauf, }, "product-timeline": { titel: "Verlauf eines Artikels", icon: "package", vorschau: "linie", beschreibung: "Ein wählbarer Artikel über 90 Tage", - standard: { w: 8, h: 7 }, min: { w: 4, h: 6 }, + zeitraum: 90, standard: { w: 8, h: 7 }, min: { w: 4, h: 6 }, komponente: KarteArtikelverlauf, }, "activity-timeline": { titel: "Ein- und Auslagerungen", icon: "history", vorschau: "linien", beschreibung: "Vorgänge je Tag über 30 Tage", - standard: { w: 8, h: 6 }, min: { w: 4, h: 5 }, + zeitraum: 30, standard: { w: 8, h: 6 }, min: { w: 4, h: 5 }, komponente: KarteBewegungsverlauf, }, }; diff --git a/web/src/pages/Dashboard.jsx b/web/src/pages/Dashboard.jsx index 9a836aa..92d7b33 100644 --- a/web/src/pages/Dashboard.jsx +++ b/web/src/pages/Dashboard.jsx @@ -5,7 +5,7 @@ import { useAuth } from "../auth"; import Icon from "../components/Icon"; import { useConfirm } from "../confirm"; import { useToast } from "../toast"; -import { KARTEN, KARTEN_IDS } from "../dashboard/cards"; +import { KARTEN, KARTEN_IDS, ZEITRAEUME } from "../dashboard/cards"; import Vorschau from "../dashboard/Vorschau"; const Raster = WidthProvider(GridLayout); @@ -158,9 +158,32 @@ export default function Dashboard() { } } - /** Nur die Rasterfelder speichern – react-grid-layout hängt viel Beiwerk an. */ + /** Nur die Rasterfelder speichern – react-grid-layout hängt viel Beiwerk an. + * Der gewählte Zeitraum gehört zur Karte und wird mitgespeichert. */ function schlank(liste) { - return (liste || []).map(({ i, x, y, w, h }) => ({ i, x, y, w, h })); + return (liste || []).map(({ i, x, y, w, h, tage }) => ( + tage ? { i, x, y, w, h, tage } : { i, x, y, w, h } + )); + } + + /** + * Zeitraum einer Verlaufskarte ändern. Außerhalb des Bearbeitungsmodus wird + * die Wahl still gespeichert – sonst wäre sie nach dem nächsten Laden wieder + * weg, ohne dass es einen sichtbaren Speichern-Knopf gäbe. + */ + async function setzeZeitraum(id, tage) { + const geaendert = aktuell.map((k) => (k.i === id ? { ...k, tage } : k)); + if (bearbeiten) { + setEntwurf(geaendert); + return; + } + setLayout(geaendert); + if (erzwungen) return; + try { + await api.saveDashboardLayout(schlank(geaendert)); + } catch { + /* Nicht speichern zu können ist hier kein Grund, die Ansicht zu stören. */ + } } return ( @@ -244,6 +267,17 @@ export default function Dashboard() {

{karte.titel}

+ {/* Zeitraum gehört in den Kopf: So bleibt die volle Kartenhöhe + dem Diagramm. onMouseDown stoppt das Ziehen der Karte. */} + {karte.zeitraum && ( + + )} {bearbeiten && ( )}
-
+
+ +
); })} diff --git a/web/src/styles.css b/web/src/styles.css index 1ffcabb..8814d90 100644 --- a/web/src/styles.css +++ b/web/src/styles.css @@ -554,9 +554,14 @@ td select { width: auto; min-width: 0; max-width: 100%; } .karten-titel { font-weight: 600; font-size: 0.875rem; } /* Auswahlfelder in Karten (Zeitraum, Artikel) */ -.card-toolbar { display: flex; gap: var(--sp-2); flex-wrap: wrap; } -.card-toolbar select { margin-top: 0; width: auto; min-width: 0; flex: 1 1 140px; } -select.zeitraum { margin-top: 0; width: auto; min-width: 0; flex: 0 0 auto; align-self: flex-start; } +/* Zeitraum-Auswahl sitzt im Kartenkopf – klein und zurueckhaltend, damit sie + die Ueberschrift nicht ueberlagert. */ +select.zeitraum { + margin-top: 0; width: auto; min-width: 0; flex: 0 0 auto; + padding: 3px 6px; font-size: 0.78rem; + color: var(--muted); border-color: var(--border); +} +select.zeitraum:hover { color: var(--text); } /* ---------- Startseite: Kartentypen ---------- */ .card-actions { display: flex; gap: var(--sp-2); flex-wrap: wrap; }