Zeitraum im Kartenkopf; Verlauf feiner als ein Punkt pro Tag
Zeitraum-Auswahl: Sie stand ueber dem Diagramm und nahm ihm Hoehe weg. Jetzt sitzt sie klein im Kartenkopf neben dem Titel - die ganze Kartenhoehe gehoert dem Diagramm. Die Wahl wird in der Anordnung mitgespeichert, bleibt also nach dem Neuladen erhalten; ausserhalb des Bearbeitungsmodus geschieht das still im Hintergrund. Feinere Aufloesung: Ein Punkt je Tag verbarg, WANN ein- und ausgelagert wurde. Die Abtastrate richtet sich jetzt nach dem Zeitraum: bis 2 Tage -> stuendlich bis 14 Tage -> alle 6 Stunden bis 120 Tage -> taeglich darueber -> woechentlich So bleibt die Punktzahl immer zwischen etwa 25 und 105 - fein genug zum Erkennen, grob genug zum Zeichnen. Ein Test haelt diese Spanne fest. Die Zeitachse traegt entsprechend Uhrzeit statt Datum, wenn stuendlich abgetastet wird. Punkte heissen jetzt "at" (Zeitpunkt) statt "date". Zeitstempel aus SQLite kommen ohne Zeitzone zurueck und werden vereinheitlicht, sonst schluege die Differenzbildung fehl. Auswahl erweitert: 24 Stunden, 2 Tage, 7, 30, 90 Tage, 6 Monate, 1 Jahr. Geprueft: "npm run build" laeuft durch. pytest weiterhin nicht ausfuehrbar - kein Python auf diesem Rechner; die Tests wurden an die neue Aufloesung angepasst. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -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))
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user