Zuletzt aktualisiert:

„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

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_idfoodId, group_namegroupName, buy_qtybuyQty, n_purchasesnPurchases, last_boughtlastBought, age_daysageDays.

Semantik / Garantien

Wie der Client den Tap verdrahtet

food_idPOST /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


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 nullableCatalogItem.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:falsePOST items {food_id, by} (idempotent). on_list:truePATCH 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.