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) sindnull, solange in der Woche noch keine getrackte Aktion lief.SILV-191 (P3) Atomarität:
lastActorUserId+lastActorName/AvatarTint/Emoji+activeShoppers[].userId+updatedAtwerden 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_syncliest bei JEDEM Aufruf den aktuellen Stand → die Generierung ist monoton (updatedAtnie 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 lokalenupdate()-Stellen (optimisticCheckoff,reload-Reconcile) perupdatedAt— 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) monotonerevje Woche im Content-State (s. o.). (2)GET /api/shopping/{week}?since_rev=N— der Client zieht beim Push-Aufwachen (oder Foreground) mit seiner letztenrev. Server gleich →{week, rev, unchanged:true}(kein Item-Body, kein Radar-Rechnen). Server neuer → die volle Wahrheit +rev+unchanged:false. Server-Ordnung (race-sicher):revwird 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 mitbymacht, frischt denlast_heartbeatseiner aktivenshopping_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 NICHT — rev 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)
- Broadcast-Capability — ERLEDIGT (Korrektur 11.06., SILV-193): die
channels-Row fürpurpose=shoppingwurde am 2026-06-08 via erfolgreichemcreate_channelangelegt (Bundlede.dennisfisch.silverscale.native,production) — die Capability war/ist aktiv (sonst400 BroadcastFeatureNotEnabled)._la_shopping_syncfeuert heute beides:apns.broadcast(channel)undupdate_by_contextan diela-update-Token. Die Sync hängt also nicht allein anla-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). - App liefert
pushToStartToken(→registerkindla-pushToStart) und je gestarteter Activity denupdate-Token (→ kindla-update) bzw. abonniert den Channel (Activity.PushType.channel(channelID)). - B1.3 (Update bei jeder Mutation Tagebuch/Budget) ist bewusst noch offen – wird abgestimmt, sobald die LA-Struct + ein Budget-Content-State stehen.