Zuletzt aktualisiert:

Live-Activity Content-State-Vertrag (für MacClaude)

Wer: StratoClaude (Backend) → MacClaude (native App) Wozu: Damit die ActivityKit-Structs in der App und die Backend-Push-Payloads (Bestell-Liste B1.6 Broadcast-Channel, B1.7 pushToStart) dieselben Keys sprechen. Charter-Regel: Backend liefert den State, die App rendert ihn.

Stand 08.06.2026. Quelle: app/backend/main.py (_shopping_state, LA_SHOPPING_ATTR) + app/backend/apns.py (start_activity, broadcast).


Einkaufs-Live-Activity

ActivityAttributes-Typ (Name muss exakt matchen): ShoppingActivityAttributes

Statische attributes (beim Start):

Key Typ Bedeutung
week String (ISO-Montag, z. B. 2026-01-05) Einkaufswoche

Dynamischer content-state (Start, Update, Broadcast):

Key Typ Bedeutung
open Int offene (nicht abgehakte) Posten
done Int abgehakte Posten
total Int Posten gesamt
estEur Number geschätzte Restsumme der offenen Posten in € (nur Posten mit Preis-Historie!). SILV-155: je Posten × buy_qty (Kauf-Menge ist €-wirksam)
estPriced Int wie viele offene Posten in estEur eingehen (Bedeckung; SILV-150). Ehrlichkeit: „~14 € für estPriced von open mit Preis". SILV-155-Treue: zählt POSTEN, nicht Stück — ein Posten mit Preis bleibt „1", egal ob ×3
lastItem String? Posten der jüngsten Aktion (SILV-148: max last_action_at, NICHT höchste id)
lastActorUserId Int? SILV-191: users.id des handelnden Profils der jüngsten Aktion (derselbe by wie lastActorName). Kern für die Rollen-Render „ich vs. fremd": der Widget-/Render-Prozess hat nur ss.user (Int) aus dem App-Group-Store, NICHT verlässlich den Namen → der Vergleich läuft über den Int, ist NICHT client-ableitbar. null, solange keine getrackte Aktion lief
lastActorName String? Name des handelnden Profils der jüngsten Aktion (z. B. „Dennis")
lastActorAvatarTint String? Farb-Token des Profils (users.color, z. B. azure/coral) — für den Avatar-Chip
lastActorEmoji String? SILV-163: Profil-Emoji (users.emoji, z. B. 🐟/🐠) — Avatar zeigt das Emoji statt der nackten Initiale
lastEventText String? serverseitig formuliert: „Dennis hat Milch eingepackt" / „Jaqueline hat Joghurt dazugelegt" / „… zurückgelegt"
updatedAt String? Zeitstempel der jüngsten Aktion (YYYY-MM-DD HH:MM:SS, lexikografisch vergleichbar) — Client verwirft ältere LA-Updates (Flacker-Versicherung)
rev Int SILV-193: monotone Wochen-Revision (shopping_rev), +1 bei JEDER Mutation (add/patch/delete/generate). Der Client guardet/reconciled gegen rev statt gegen updatedAt-Sekunden → killt den Sekunden-Tie-Race (zwei Aktionen in derselben Sekunde). 0, solange die Woche nie über die API mutiert wurde
nextOpen Array SILV-168: die offenen Posten in Laden-Reihenfolge: [{ "id": Int, "label": String }]. Das DI-Widget bietet sie als Häkchen an (Abhaken via PATCH items/{id} mit X-Session-Token, SILV-166). Leer, wenn alles abgehakt. SILV-192 (konsolidiert): trägt die GANZE offene Liste (Sicherheits-Deckel 30 gegen das 4-KB-ActivityKit-Limit; reale Listen 15–25 passen komplett, ~1 KB) — vorher 3 bzw. 6. Der Client zeigt nur prefix(3), hält den Rest als Puffer: beim optimistischen Abhaken rückt sofort der nächste nach, ohne Server-Push; und ein wieder-demarkierter Posten (done→offen) erscheint an seiner Laden-Position wieder. Sortierung byte-genau wie die Client-Sektionierung (ShoppingModel.groups): Sektion nach kleinstem Gruppen-sort_order (ohne Gruppe → ganz hinten, „Sonstiges"), Sektions-Tiebreak = Gruppenname, innerhalb der Gruppe id-stabil — in JEDEM nextOpen-Pfad (Broadcast, pushToStart, presence/restart, Un-Check)
activeShoppers Array wer ist gerade im Markt (SILV-152): [{ "name": String, "avatarTint": String, "userId": Int }], leer wenn niemand. Geister (kein Heartbeat im TTL) fallen automatisch raus. SILV-154: userId mitgeführt, damit der Client autoritativ erkennt, ob ER SELBST im Markt ist (Modus-Restore). SILV-191 (P2): userId ist users.id (PK) → IMMER non-null, in JEDEM Pfad (presence-GET, Broadcast, pushToStart) derselbe Wert — der Client darf sich auf den Int-Vergleich verlassen
ended Bool? SILV-270: beim Session-Abschluss (der LETZTE Shopper geht) sendet der Server den finalen Content-State mit ended:true als APNs-end-Event (Broadcast + la-update), dismissal-date ~2 min → Client rendert 'Erledigt' als kurzen Abschluss-Beat, dann räumt iOS die Karte ab. Im laufenden Betrieb fehlt das Feld / ist false/null.

Die neuen Story-Felder (lastItem/lastActorUserId/lastActorName/-AvatarTint/-Emoji/lastEventText/updatedAt) sind null, solange in der Woche noch keine getrackte Aktion lief.

SILV-191 (P3) Atomarität: lastActorUserId + lastActorName/AvatarTint/Emoji + activeShoppers[].userId + updatedAt werden serverseitig IMMER aus EINEM konsistenten Lese-Snapshot gebaut (_shopping_state: eigene WAL-Read-Verbindung + BEGIN), nie teil-aktualisiert. Der Client bekommt also nie eine Mischung aus altem Actor + neuer Shopper-Liste → kein Solo/Fremd-Flacker.

SILV-191 (P4) Reihenfolge/Monotonie: Ein „ältere Updates verwerfen"-Guard ist im Push-LA-Render NICHT ausführbar (kein Vorzustand im View-Build). Real-Garantie: _la_shopping_sync liest bei JEDEM Aufruf den aktuellen Stand → die Generierung ist monoton (updatedAt nie rückläufig), jeder Push ist ein vollständiger Snapshot (kein Delta). Der Server hält keine Suppression veralteter Broadcasts vor dem Send (APNs-/Netz-Reordering ist nicht server-kontrollierbar). Darum guardet der Client an seinen zwei lokalen update()-Stellen (optimisticCheckoff, reload-Reconcile) per updatedAt — das genügt, weil jeder Push idempotent + voll ist. KEIN unmöglicher Render-Guard nötig.

SILV-193 — Live-Sync = GARANTIE über Pull-Reconcile (Push=Wecker, Pull=Wahrheit). Kein Push-Pfad (Broadcast oder la-update) ist eine Zustell-Garantie (APNs fire-and-forget, droppt/reordert). Darum: (1) monotone rev je Woche im Content-State (s. o.). (2) GET /api/shopping/{week}?since_rev=N — der Client zieht beim Push-Aufwachen (oder Foreground) mit seiner letzten rev. Server gleich → {week, rev, unchanged:true} (kein Item-Body, kein Radar-Rechnen). Server neuer → die volle Wahrheit + rev + unchanged:false. Server-Ordnung (race-sicher): rev wird vor den Items gelesen → der Client wird nie stale (höchstens ein redundanter Voll-Pull). Damit ist „live" ehrlich: bei dünnem/abgerissenem Netz reconciled der nächste Pull, die UI behauptet nie „live", wenn sie's nicht ist (Client zeigt „zuletzt vor X" statt eingefrorenem Balken). (3) B-FIX-5 — Präsenz-Heartbeat via Item-Mutation: wer eine Item-Aktion mit by macht, frischt den last_heartbeat seiner aktiven shopping_session (_touch_presence) → ein Einkäufer fällt nicht aus der Partner-Präsenz, während er im Hintergrund abhakt. Frischt nur eine bereits aktive Sitzung — legt KEINE an (Hinzufügen von daheim macht nicht zum Einkäufer; explizites Präsenz-Modell, einkauf-praesenz-modell.md).

Swift-Seite (Vorlage):

struct ShoppingActivityAttributes: ActivityAttributes {
    public struct ContentState: Codable, Hashable {
        var open: Int
        var done: Int
        var total: Int
        var estEur: Double
        var estPriced: Int                // SILV-150 (Bedeckung: estPriced von open mit Preis)
        var lastItem: String?
        var lastActorUserId: Int?         // SILV-191 (by-User als Int; Rollen-Render 'ich vs. fremd')
        var lastActorName: String?        // SILV-148
        var lastActorAvatarTint: String?  // SILV-148 (users.color)
        var lastActorEmoji: String?       // SILV-163 (users.emoji, 🐟/🐠)
        var lastEventText: String?        // SILV-148 (serverseitig formuliert)
        var updatedAt: String?            // SILV-148 (ältere Updates verwerfen)
        var nextOpen: [OpenItem]          // SILV-168 (nächste offene Posten fürs DI-Abhaken)
        var activeShoppers: [Shopper]     // SILV-152 (wer ist im Markt)
        var ended: Bool?                  // SILV-270 (Session-Abschluss -> 'Erledigt', Karte endet)
    }
    struct OpenItem: Codable, Hashable { var id: Int; var label: String }  // SILV-168
    struct Shopper: Codable, Hashable { var name: String; var avatarTint: String; var userId: Int }  // SILV-154: userId
    var week: String
}

Der Server schickt im aps:

{ "aps": { "timestamp": 1234567890, "event": "start|update",
           "content-state": { "open": 7, "done": 2, "total": 9,
                              "estEur": 14.30, "estPriced": 6, "lastItem": "Milch",
                              "lastActorUserId": 1,
                              "lastActorName": "Dennis", "lastActorAvatarTint": "azure",
                              "lastActorEmoji": "🐟",
                              "lastEventText": "Dennis hat Milch eingepackt",
                              "updatedAt": "2026-06-11 00:46:40",
                              "nextOpen": [{ "id": 42, "label": "Bananen" }, { "id": 43, "label": "Hafermilch" }],
                              "activeShoppers": [{ "name": "Jaqueline", "avatarTint": "coral", "userId": 2 }] },
           "attributes-type": "ShoppingActivityAttributes",
           "attributes": { "week": "2026-01-05" } } }

Wer hat gehandelt (by-Vertrag, SILV-148): Im Wir-Modus teilt sich ggf. eine Geräte-Session → der Server kann den Handelnden NICHT aus der Session ableiten. Der Client schickt den aktiven Profil-user_id als by im Body von PATCH /api/shopping/items/{id} und POST /api/shopping/{week}/items mit. Ohne by fällt der Server auf den Session-User zurück (bricht nichts, aber die „wer"-Story bleibt leer).


Backend-Endpoints (schon gebaut, deployed)

Methode Pfad Zweck
POST /api/apns/register Geräte-/Activity-Token registrieren. kind: device · la-update · la-pushToStart. topic = Bundle-ID.
POST /api/channels Broadcast-Channel anlegen (purpose:"shopping") → channel_id. Braucht Broadcast-Capability am Bundle.
GET /api/channels Channels auflisten.
POST /api/live-activity/shopping/start?week= B1.7 pushToStart an alle la-pushToStart-Tokens. Liefert content_state mit. SILV-157: idempotent je (week, deviceToken) — ein Gerät mit laufender Karte für die Woche bekommt KEINEN zweiten Start (verhindert Doppelkarte bei zweitem Starter); Dedup in la_starts. Inhalts-Updates laufen über broadcast, nicht über Starts.
POST /api/live-activity/shopping/broadcast?week= B1.6 ein Push an den shopping-Channel.
GET /api/shopping/{week}/presence SILV-154 autoritativer Lese-Endpoint: { "activeShoppers": [{name, avatarTint, userId}] }. Reines Lesen (kein Push, idempotent). Client-Modus-Restore beim Wieder-Öffnen + HEUTE-Indikator „wer kauft gerade ein".
GET /api/shopping/{week}?since_rev=N SILV-193 Pull-Reconcile (Wahrheit). Ohne since_rev: volle Liste + rev + unchanged:false (additiv, alte Caller unberührt). Mit since_rev==rev: {week, rev, unchanged:true} (Leicht-Antwort). Mit since_rev<rev: volle Wahrheit. Client zieht das beim Push-Aufwachen/Foreground. SILV-205 (additiv, Welle-C): die volle Antwort (kein-since_rev und since_rev<rev) trägt jetzt top-level lastActorUserId (Int?) + lastActorAt (String?) — wer hat zuletzt auf der ganzen Liste gehandelt, fürs App-View (reitender Avatar-Marker auf der Füllkante + live „Dennis hat Milch eingepackt"). Identische Auswahl wie der LA-ContentState (_shopping_state: jüngste Aktion max(last_action_at), Tiebreak id; Verb-Gate done/added/undone) → LA und Listen-Objekt laufen nie auseinander. null, solange keine getrackte Aktion lief. Die unchanged:true-Leicht-Antwort trägt es bewusst NICHTrev unverändert ⇒ Client behält seinen letzten Actor (rev-Guard, nichts neu rendern).
POST /api/shopping/{week}/presence/start SILV-152 Einkaufs-Modus betreten (Body {by}). Idempotent je Woche/User. Broadcastet. Liefert activeShoppers. SILV-266 E-2: optional client_ts (ISO) — ein älter als das Präsenz-TTL zugestellter Stale-Replay wird verworfen ({ok:true, stale:true}, kein Zustands-Write), statt eine beendete Präsenz zu reanimieren. Ohne client_ts Verhalten wie bisher.
POST /api/shopping/{week}/presence/heartbeat SILV-152 am Leben halten (Body {by}, ~alle 1–2 min). Bewusst OHNE Push. SILV-157: Geister-TTL = 15 min. SILV-266 E-2: frischt NUR eine bereits aktive Sitzung (WHERE active=1, setzt active NIE auf 1) — ein verspäteter Heartbeat reanimiert keine beendete Präsenz mehr.
POST /api/shopping/{week}/presence/end SILV-152 Einkaufs-Modus verlassen (Body {by}). Broadcastet. Liefert activeShoppers. SILV-270 Session-Abschluss: schließt dieser end die letzte aktive Sitzung (danach activeShoppers leer = echtes Ende), werden die abgehakten (done=1) Posten der Woche gelöscht (gekauft=erledigt), ungekaufte (done=0) bleiben; Response trägt sessionEnded:true + clearedDone:Int, und die LA wird mit ended:true (event=end, ~2 min Nachklang) beendet. Nicht-letzter / kein echtes Ende: nur Präsenz raus, sessionEnded:false, clearedDone:0, kein Clear (Präsenz-Lock „niemand strandet"). Idempotent (zweites „Fertig"/Retry schließt keine Sitzung mehr → kein Re-Clear/Re-Signal).

Abhaken (PATCH /api/shopping/items/{id}) und Hinzufügen (POST /api/shopping/{week}/items, SILV-148) rufen serverseitig automatisch _la_shopping_sync(week) → Broadcast + Einzelgerät-Update (fire-and-forget). Beide tragen den by-User in die Aktions-Chronologie ein.

Status Broadcast vs. Pull (SILV-193)

  1. Broadcast-Capability — ERLEDIGT (Korrektur 11.06., SILV-193): die channels-Row für purpose=shopping wurde am 2026-06-08 via erfolgreichem create_channel angelegt (Bundle de.dennisfisch.silverscale.native, production) — die Capability war/ist aktiv (sonst 400 BroadcastFeatureNotEnabled). _la_shopping_sync feuert heute beides: apns.broadcast(channel) und update_by_context an die la-update-Token. Die Sync hängt also nicht allein an la-update. Wichtig: kein Push-Pfad ist eine Zustell-Garantie → die Garantie liefert der Pull-Reconcile (?since_rev, s. SILV-193- Block oben), nicht ein „besserer" Push. Ein scharfer End-to-End-Broadcast-Beweis steht aus (würde 31 Live-Geräte-Karten anstoßen → nur mit Dennis am Gerät, kein Sessel-Test).
  2. App liefert pushToStartToken (→ register kind la-pushToStart) und je gestarteter Activity den update-Token (→ kind la-update) bzw. abonniert den Channel (Activity.PushType.channel(channelID)).
  3. B1.3 (Update bei jeder Mutation Tagebuch/Budget) ist bewusst noch offen – wird abgestimmt, sobald die LA-Struct + ein Budget-Content-State stehen.