„Zuletzt gekauft" — Datengrundlage (SILV-200)
Datengrundlage für die 1-Tap-Wiederaufnahme (Bring!-Moment): schon gekaufte Artikel, die nicht auf der aktuellen Wochenliste stehen, gerankt nach Recency × Frequenz. Client (MacClaude) baut die UI dagegen; Server liefert nur die geordnete Wahrheit. Deterministisch, kein KI.
Endpoint
GET /api/shopping/{week}/recent?limit=20
week— Montag der Woche (YYYY-MM-DD), wie überall inshopping/*.limit— Deckel, Default 20, Maximum 50 (geklemmt).
Antwort
{
"week": "2026-06-15",
"ranking": "recency(0.5^(age/21d)) × sqrt(1+freq)",
"items": [
{
"food_id": 583,
"name": "Pinky Donut", // exakter Anzeigename (display_name || name)
"group_name": "Süßes & Snacks", // Warengruppe (kann null sein → 'Sonstiges')
"buy_qty": 3, // typische Kaufmenge (Vorbelegung), >= 1
"n_purchases": 74, // wie oft je gekauft (haushaltsweit)
"last_bought": "2026-06-11", // letztes Kaufdatum (YYYY-MM-DD)
"age_days": 4, // Tage seit letztem Kauf
"score": 8.6603 // Rang-Score (nur Sortier-/Debug-Hilfe)
}
]
}
items ist absteigend nach score sortiert (Tiebreak n_purchases). Der Client
zeigt einfach prefix(N) — keine Client-seitige Neu-Sortierung nötig (Anti-Drift).
Native Decoder (convertFromSnakeCase): food_id→foodId, group_name→groupName,
buy_qty→buyQty, n_purchases→nPurchases, last_bought→lastBought,
age_days→ageDays.
Semantik / Garantien
- Quelle =
receipt_items(haushaltsweit), nicht der Staple-Radar.is_deposit=0,is_food=1,food_idnon-null. Pfand/Non-Food/Freitext sind raus. - Ausschluss: Artikel, die schon auf der Wochenliste (
shopping_items.food_id) stehen, erscheinen nicht — re-add schlägt nie etwas vor, das schon drauf ist. (Konsistent: sobald der Client einen Posten addet, fällt er beim nächsten Pull raus.) buy_qty(typische Menge): Median der gekauften Stückzahl (robuster als Schnitt gegen Bulk-Ausreißer),>= 1. Gewichtsartikel (is_weight=1, z. B. Bananen nach kg) → immer1(man addet „einen Eintrag", keine kg-Zahl). Best-effort-Vorbelegung, der Nutzer ändert sie beim Hinzufügen.- Rang (tunebar):
score = recency × frequenzmitrecency = 0.5^(age_days / 21)(Halbwertszeit 21 Tage — ein 3-Wochen-alter Kauf zählt halb) undfrequenz = sqrt(1 + n_purchases)(saturierend, damit Dauerläufer den Rang nicht erdrücken). KonstantenRECENT_HALFLIFE_DAYSinbackend/main.py, Dennis-kalibrierbar — vor Änderung A/B/Dry-Run zeigen (Kalibrier-Disziplin).
Wie der Client den Tap verdrahtet
food_id → POST /api/shopping/{week}/items {food_id, by} (idempotent je
(week, food_id), SILV-86-1). buy_qty ist eine Vorbelegung — wenn > 1, danach
PATCH /api/shopping/items/{id} {buy_qty} (SILV-155). Die Mutation bumpt automatisch
shopping_rev (SILV-193) → die geteilte Liste/LA reconciled.
Abgrenzung
- Nicht
_radar_suggestions(/generate//suggest-Radar = Staple-Prädiktion „läuft bald leer", median-intervallbasiert).recentist die Recency-Wiederaufnahme. - Nicht
GET …/suggest(frequency-only, ohne Recency/qty/group) — bleibt für das alte Schnell-Panel,recentist die reichere Bring!-Quelle.
Antipp-Katalog (SILV-204) — GET /api/shopping/{week}/catalog
Für den tap-only Antipp-Modus (SILV-203): EIN Server-Katalog statt client-seitiger Schatten-Membership (Anti-Drift). Der Server ist die Wahrheit über „was ist drauf".
GET /api/shopping/{week}/catalog?limit=60 (Default 60, Max 200).
{
"week": "2026-06-15",
"ranking": "radar/band zuerst (Dringlichkeit), dann recall(recency×freq) desc",
"items": [
{
"food_id": 654, "name": "Schwip Schwap Zero", "group_name": "Getränke",
"buy_qty": 6, // typische Menge (aus Recall-Median)
"band": "unsicher", // Radar-Band ODER null (reiner Recall-Posten)
"suggestion_score": 1.0, // Radar-Score ODER null
"median_interval_days": 7, // Kaufrhythmus ODER null
"perish_urgency": 0.0, // SILV-266 E-1: Frische-Term 0..1 (nur Sortierung, Client zeigt nicht)
"age_days": 4, "last_bought": "2026-06-11",
"on_list": false, // steht der Artikel schon auf der Wochenliste?
"item_id": null, // wenn on_list: die shopping_items.id (für Toggle/PATCH)
"done": false // wenn on_list: abgehakt-Zustand
}
]
}
Inhalt: Vereinigung Radar-Staples ∪ Recall (/recent-Logik), dedupe je food_id
(Radar liefert band/suggestion_score/median_interval_days, Recall die buy_qty).
Schließt gelistete NICHT aus — ein bereits gelisteter Radar-/Recall-Artikel erscheint mit
on_list:true + item_id + done (= 3-Zustand: nicht-drauf / drauf-offen / drauf-abgehakt).
Sortierung (SERVER, kein Client-Re-Sort, Anti-Drift) — EINE Rang-Achse (SILV-266 E-1):
rank = max(suggestion_score, perish_urgency) · overdue_factor, absteigend (Tiebreak: Recall-
Score, dann food_id). Frische (perish_urgency, nur bei Bestand qty>0) webt sich so in die
Schärfe ein: ein verderblicher bald_weg-Recall (kein Band) rankt VOR einem
geht_zur_neige-Staple und HINTER wohl_leer. Client zeigt prefix(N) bzw. gruppiert nach
group_name (Laden-Reihenfolge via groups.sort_order, SILV-197).
Additive Felder/Param (SILV-266 E-1): jeder Posten trägt perish_urgency: float (0..1,
aus fresh_status × fresh_class; Client zeigt es NICHT — nur Sortier-Transparenz). Der
Endpoint akzeptiert optional ?today=YYYY-MM-DD (datumsabhängige Frische, sonst echtes Heute).
Manuelle (Freitext) On-List-Posten (SILV-267): zusätzlich zur food-basierten Vereinigung
liefert /catalog die on_list shopping_items OHNE food_id (manueller Freitext) als eigene
Karten: food_id: null, name=label, group_name: "Sonstiges", band/suggestion_score/
median_interval_days: null, perish_urgency: 0, on_list: true, item_id gesetzt, done,
buy_qty. food_id ist damit nullable → CatalogItem.foodId ist clientseitig Int?
(Identität/Identifiable der Manuellen über item_id, nicht foodId). Manuelle sind
remove-only (DELETE items/{item_id}), kein Re-Add-Pfad. Dedupe: food-basiert je
food_id, manuell je item_id.
limit (SILV-267): kappt nur die Vorschläge (off-list radar/recall). on_list-Posten
(= der aktuelle Plan) erscheinen IMMER vollständig, auch jenseits des Limits — sonst fiele ein
geplanter Artikel still aus der Planungs-Ansicht.
Toggle-Verdrahtung: on_list:false → POST items {food_id, by} (idempotent). on_list:true
→ PATCH items/{item_id} {done} (abhaken) bzw. DELETE items/{item_id} (vom Zettel). Jede
Mutation bumpt shopping_rev (SILV-193); der nächste /catalog-Pull spiegelt on_list/done
autoritativ — kein Client-Schatten.