Skip to content

Moment-Feed (P3): Entwurf und Annahmen

Version 2, 04.10.2026. Basis origin/development 4cc6f9a. Eingearbeitet ist der Claude-Prüfer-Vorlauf auf Version 1 (Befunde B1 bis B11, Nebenbefunde N1 und N2). Am finalen Head prüfen zwei unabhängige Claude-Prüfer: einer auf Korrektheit, einer auf Migration, Daten und Löschung. Codex prüft nicht, das hat Max ausgesetzt.

Ziel

Die App zeigt Momente genau einmal je Konto, auch auf einem neuen Gerät, nach einer Neuinstallation und über iPhone und Web hinweg. Momente sind Level-Aufstieg, Duell-Ergebnis, abgeschlossene Herausforderung, Auszeichnung, Rekord und Saison-Urkunde. Heute merkt sich nur der Level-Aufstieg seinen Stand, und das lokal (useLevelUp.ts).

Modell

model MomentEvent {
  id        String   @id @default(uuid())
  user      User     @relation(fields: [userId], references: [id], onDelete: Cascade)
  userId    String
  key       String   // fachlicher Schlüssel, eindeutig je Konto
  kind      String   // 'level_up' | 'challenge_done' | 'badge' | 'duel_result' | 'record' | 'certificate'
  seasonId  String?  // nur Bezug, keine Relation
  payload   Json
  createdAt DateTime @default(now())

  @@unique([userId, key])
  @@index([userId, createdAt, id])
}

Cursor: neue Spalte User.momentsSeenAt DateTime? und User.momentsSeenId String?. Zusammen bilden sie das Paar (createdAt, id) der zuletzt bestätigten Zeile.

Schreiber

Allgemein gilt: - Alle Schreiber sind idempotent über @@unique([userId, key]). createMany({ skipDuplicates: true }) macht einen Konflikt zum No-Op. - Ein Schreibfehler lässt die auslösende Buchung nie scheitern. Jeder Schreiber hat einen eigenen try/catch nach der Buchung. Geloggt wird ohne Fehlertext (fehlerKennungMitUrsache). - Kein Schreiber läuft in einer $transaction (A13).

kind Stelle key payload
level_up awardSp (services/seasonPass.ts) level:{seasonId}:{L} { level: L, maxLevel }
challenge_done awardSp, nur für die Liste R unten challenge:{seasonId}:{reason-Basis}:{Periode} { reason, delta, refType, refId }
badge seasonPass.ts:137 (Meilenstein) und routes/challenges.ts:7955 (Katalog) badge:{badgeKey}:{tier} { badgeKey, tier, spAwarded }, kein achievedValue
duel_result persistFinalizedDuelState, nur bei beidseitiger Freigabe (A7) duel:{challengeId} { challengeId, outcome, mode, sp }, ohne Ringwerte, ohne Gegner
record sessionEvaluationNotifier, nach der Kategorieauflösung record:{clubId}:{scheibenId} seit Welle 1 (P1): { wertung: 'bis40_v1', rings, shots, previousRings, expected, disziplin }; ältere Zeilen { previousTotalRing, totalRing, expected, disziplin } (siehe Punkt 8)
certificate Settle-Sweep und /season/honours, sobald die Urkunde verdient ist (A8) certificate:{seasonId} { seasonNumber }, Inhalt live aus /season/honours

Level (B1): awardSp liest die Summe vor dem eigenen Insert (getSpBalance). Danach rechnet es wie bisher neu und schreibt je Level L in (L(vorher), L(nachher)] einen eigenen Schlüssel. Warum keine Lücke entsteht: Die Buchung, deren Commit eine Schwelle überschreitet, hat vorher eine Summe darunter gelesen und danach eine darüber. Der Ledger wächst nur (A3).

Reason-Liste R für challenge_done (B3): - Enthalten: WEEKLY_CORE, WEEKLY_FREESTYLE, MONTHLY_PRESENCE, MONTHLY_IMPROVEMENT, MONTHLY_PRECISION, SEASON_LOYALTY, SEASON_PEAK, SEASON_MASTERY, OPTIONAL_CHALLENGE. - Nicht enthalten: - WEEKLY_TRAINING: wird je Training gebucht. - Die Gold-Boni (*_GOLD): Sie bekommen denselben Schlüssel wie die Grundkarte, über die reason-Basis ohne _GOLD, und lösen damit keinen zweiten Moment aus. - CLUB_GOAL_WEEKLY: hat keinen Schreiber. - Die Duell-Reasons: dafür gibt es duel_result. - Die Periode kommt aus dem Idempotenzschlüssel der Buchung (Woche, Monat, Saison oder Challenge-Kennung).

Duell (B4): persistFinalizedDuelState bekommt einen Parameter, ob beide Seiten GRANTED sind. Der Sweep und countWonDuelsInPeriod prüfen das schon. mapChallengeToDuelEntry hat die Menge aus :4310. Die Pfade bei :4384 und :4615 übergeben „unbekannt“ und schreiben dann keinen Moment. Der Moment-Schreiber steht außerhalb von if (challenge.clubId), if (season), if (!sp) continue und dem stillen catch {}. So entsteht er auch bei Duellen über Vereinsgrenzen, in der Sommerpause und bei Geister-Niederlagen. Weil persistFinalizedDuelState bei jedem Abruf läuft, wird erst geprüft, ob es den Schlüssel schon gibt. Ein Insert gibt es nur bei frischer Finalisierung, also wenn alreadyPersisted falsch ist. Sonst kostet jeder Listenabruf bis zu 120 Konflikt-Inserts.

Rekord (B2): Geschrieben wird nur bei personalBest && category !== 'prize', gleiche Regel wie die Push-Überschrift. isNewPersonalBest liefert künftig { isBest, previous } statt boolean. Der Aufrufer im Notifier wird angepasst.

Leser und Cursor (B6, B9, B10)

  • GET /api/moments: liefert Ereignisse mit (createdAt, id) > (momentsSeenAt, momentsSeenId) und createdAt < now() - 5 s (gegen Commit-Nachzügler), aufsteigend nach (createdAt, id), höchstens 50. Dazu cursor: { at, id } der letzten ausgelieferten Zeile und hasMore.
  • POST /api/moments/seen { at, id }: übernimmt nur einen Cursor, der zu einer Zeile des eigenen Kontos gehört, und nur vorwärts. Die Prüfung WHERE (seenAt, seenId) < (at, id) steht im selben UPDATE. Die Uhrzeit der App zählt nicht.
  • Erstkontakt (A1): Ist der Cursor NULL, liefert GET nur Ereignisse, die nach User.momentsFeedSince entstanden sind. Diese dritte Spalte setzt die Migration für alle Bestandskonten auf den Deploy-Zeitpunkt, Vorgabe now() für neue Konten. GET verändert nichts. So bleibt nichts zwischen Deploy und erstem Öffnen liegen, und es gibt trotzdem keine Rückfüllung alter Taten.
  • Gesehene Ereignisse liest der Feed nicht. Eine Bilanz auf dem zweiten Gerät ist nicht Teil von P3. Die 120 Tage Aufbewahrung begründen sich nur mit Fehlersuche und Datenauskunft.
  • Kein Lesefilter level <= unlockedLevel (B11): Es gibt keine Negativbuchungen (A3), und unlockedLevel kann durch ein nicht gesperrtes Upsert zurückfallen.
  • Zugang: dieselbe Plan-Prüfung wie /season/pass. Saisonpunkte sind nur mit Pro sichtbar, Jugend-Pro eingeschlossen.

Löschung und Auskunft

  • Konto-Purge (A4): onDelete: Cascade. Es gibt keinen Anonymisierungspfad für User: accountDeletion.ts:984 und members.ts:1866 löschen die Zeile. Einzutragen in userDataPurge.ts:
  • (a) FK MomentEvent.userId als cascade in USER_FOREIGN_KEY_REFERENCES,
  • (b) momentEvent in CASCADING_USER_RELATIONS,
  • (c) MomentEvent.payload in USER_TEXT_AND_JSON_REFERENCES,
  • (d) Seed und Restprüfung in test/userDataPurge.db.test.ts.
  • Zusätzlich ein Test, der nach dem Purge null MomentEvent-Zeilen belegt.
  • Auskunft (A5, B8): EXPORT_COVERAGE_RELATIONS mit eigenem Pfad. momentsSeenAt, momentsSeenId und momentsFeedSince stehen ausdrücklich in kontoStatus. Ein neuer Wächtertest prüft: Jeder FK mit cascade in der Landkarte steht in CASCADING_USER_RELATIONS. Damit ist die Lücke aus B8 strukturell geschlossen.
  • Widerruf der Schießdaten (A6, B5): In purgeRevokedShootingData wird je REVOKED-Einwilligung über consent.userId gelöscht. Das läuft vor und unabhängig von dem continue bei :61, also bei jedem Lauf. Gelöscht werden alle MomentEvent außer certificate, also record, duel_result, challenge_done, level_up und badge, denn alle sind aus Schießdaten abgeleitet. Ledger, BadgeAward und Duell-Snapshots bleiben wie heute stehen. Diese Altlast ist nicht Teil von P3 und wird gesondert gemeldet.

Urkunde (A8, B7, A9)

  • Materialisiert:
  • Der Settle-Sweep schreibt certificate:{seasonId}, sobald das Konto in einer beendeten Saison Punkte hat (deservesCertificate). Das gilt auch für Nachbuchungen nach endsAt.
  • /season/honours schreibt denselben Schlüssel beim Lesen nach. Das deckt Konten ab, die der Sweep überspringt (Mehrverein, kein Sportpass-Saatgut).
  • Das bedeutet einen Schreibzugriff in einem GET. Er ist idempotent und wird nur versucht, wenn die Urkunde im Ergebnis steht und das Ereignis fehlt.
  • Fakten im Urkunden-DTO (Auftrag der Koordination):
  • best40er: dieselbe Definition wie „Bestes 40er“ in der Sammlung. Eingeschränkt wird auf die Saison mit demselben Zeitvergleich wie der Saison-Sync (challenges.ts:7147–7150): zeitstempel roh gegen die Date.UTC-Grenzen, Rückfall auf createdAt.
  • Damit gilt eine einzige Definition von „Bestes 40er“ in der App. Ursprünglich war das max(totalRing) über die Trainingsblätter ohne Schusszahl.
  • Seit Welle 1 (P1, Entscheid A1): die ersten 40 wertbaren Schüsse eines Blatts mit mindestens 40 davon, in Ganzring (queryBestFirst40InPeriod im Modus mind40, Regel in services/bestes40er.ts, wie sessionTotal40Strict im Web). Ohne Einzelschüsse zählt totalRing nur bei trefferzahl genau 40. 20er und 30er haben kein „Bestes 40er“ (anders als Bestschütze-Karte und Rekord-Push, die nach Entscheid E1 „bis 40 Schuss“ rechnen).
  • duels: { wins, losses, draws }: beendete Duelle mit endedAt in der Saison, dieselbe Logik wie GET /duels/record.
  • Ist ein Wert nicht ermittelbar (keine Freigabe, keine Daten), ist das Feld null. Die App lässt die Kachel dann weg.
  • Die Felder gibt es nur für das eigene Konto.

Annahmen, Stand Version 2

ID Annahme Stand
A1 Kein Rückfüllen alter Taten; Start über momentsFeedSince = Deploy-Zeitpunkt neu entworfen
A2 Level-Ereignisse lückenlos über die Vor-Summe vor dem Insert neu entworfen (B1)
A3 Es gibt keine Negativbuchungen, nur der Konto-Purge löscht Ledger-Zeilen bestätigt
A4 Cascade reicht, die Einträge (a) bis (d) sind Pflicht präzisiert
A5 Auskunft mit eigenem Pfad und Spalten in kontoStatus, neuer Gleichlauf-Wächter präzisiert (B8)
A6 Widerruf löscht über consent.userId, unabhängig vom continue neu entworfen (B5)
A7 duel_result nur bei beidseitiger Freigabe neu entworfen (B4)
A8 Urkunde als eigenes Ereignis, materialisiert neu entworfen (B7)
A9 „Bestes 40er“ wie in der Sammlung, Zeitvergleich wie im Saison-Sync entschieden
A10 Rekord wie der Push, ohne Preisschießen; über Disziplinen hinweg (Produktfrage, unverändert wie heute) präzisiert (B2)
A11 120 Tage Aufbewahrung über den Scheduler Begründung korrigiert (B10)
A12 Migration additiv: Tabelle, drei Spalten, momentsFeedSince für Bestand auf den Deploy-Zeitpunkt bestätigt, beim Bau präzisiert (siehe Abweichungen, Punkt 1)
A13 awardSp läuft nie in einer $transaction bestätigt

Abweichungen beim Bau

Stand des Baus vom 04.10.2026 (Branch feat/moment-feed). Abweichungen und Präzisierungen, jeweils mit Grund.

  1. momentsFeedSince ist NOT NULL mit @default(now()), nicht nullable (A12). Eine leere Spalte hieße „alles seit jeher“ und wäre genau die Rückfüllung, die A1 ausschließt. momentsSeenAt und momentsSeenId bleiben nullable.
  2. Die Migration füllt den Bestand in drei Schritten: Spalte anlegen, UPDATE … = now() AT TIME ZONE 'UTC', dann Vorgabe und NOT NULL. Ein einfaches ADD COLUMN … NOT NULL DEFAULT CURRENT_TIMESTAMP rechnet in der Zeitzone der Sitzung in die timestamp-Spalte um. Gemessen auf einer lokalen Instanz mit TimeZone = Europe/Berlin: Die Datenbank-Vorgabe hätte 21:02 geschrieben, Prisma schreibt 19:02 (UTC). Der Stichtag läge damit zwei Stunden in der Zukunft.
  3. Neue Konten bekommen den Wert von Prisma selbst, in UTC (gemessen). Die Datenbank-Vorgabe CURRENT_TIMESTAMP steht nur, weil schema.prisma sie erwartet, sonst meldet der Drift-Wächter der CI eine Abweichung.
  4. Widerruf: Selektion auf alle REVOKED-Einwilligungen erweitert (A6). Bisher filterte purgeRevokedShootingData auf sportpassId IS NOT NULL. Die Moment-Löschung hängt aber am Konto, nicht an der Nummer, und „je REVOKED-Einwilligung“ meint alle. Einwilligungen ohne Nummer löschen nur die Momente und springen dann weiter. Dafür gibt es eine neue Schutzzeile if (!(sportpassId > 0)) continue (geschärft in Korrekturrunde 1). Number(null) und Number('') sind 0 und wären sonst als Sportpass 0 in die Schießdaten-Löschung gelaufen.
  5. best40er nur aus eigenen, freigegebenen Nummern. Definition und Zeitvergleich sind wie vorgegeben: dieselbe Abfrage wie in der Sammlung (anfangs queryBestTotalInPeriod, seit Welle 1 queryBestFirst40InPeriod), Saisongrenzen wie im Saison-Sync. Zusätzlich gilt:
  6. Die eigene Einwilligung muss GRANTED sein, sonst ist der Wert null.
  7. Es zählen nur Sportpass-Nummern, die die User-Spalte genau diesem Konto zuordnet. Die Identitätsauflösung der Sammlung kann über E-Mail oder Namen auch fremde Nummern einsammeln (Cross-Subject-Falle), und eine Urkunde mit dem Ergebnis einer anderen Person wäre schlimmer als keine Zahl.
  8. Folge: Ein Konto ohne Nummer in der Spalte bekommt null, und die App lässt die Kachel weg.
  9. duels ohne Geisterduelle, null ohne gewertetes Duell. „Dieselbe Logik wie /duels/record“ heißt dort: Die Bilanz (totals) führt Geisterduelle getrennt. Der Zeitanker ist wie dort participation.endedAt mit Rückfall auf finalizedAt. Eine Saison ohne ein einziges gewertetes Duell liefert null statt {0,0,0}, denn „0 · 0 · 0“ ist keine Bilanz. Eine Einwilligungssperre gibt es wie bei /duels/record nicht, weil die Bilanz keine Schießzahl trägt.
  10. POST /api/moments/seen: geschrieben wird der createdAt der Zeile, nicht das mitgeschickte at. at dient nur als Gegenprobe. Passt es nicht zur Zeile, ändert sich nichts. Verglichen wird in Epoch-Millisekunden statt mit einem Datumsparameter in rohem SQL (Zeitzonenfalle wie in Punkt 1). Die Antwort ist { updated: boolean }. Fremde Zeile, rückwärts und schon quittiert enden gleich, ohne 404, damit über fremde Kennungen nichts zu erfahren ist.
  11. Die Untergrenze momentsFeedSince gilt auch mit gesetztem Cursor. seen nimmt jede eigene Zeile an, auch eine vor dem Stichtag. Ohne die Untergrenze käme darüber eine Rückfüllung durch die Hintertür.
  12. GET /api/moments ohne User-Zeile liefert einen leeren Feed statt 404. Ohne Pro antwortet die Route 403 plan_required, wie /season/pass.
  13. Rekord: geschrieben vor dem Versand und unabhängig von den Benachrichtigungs-Einstellungen. Wer Push abbestellt hat, bekommt den Moment in der App trotzdem.
  14. Die Kategorie ist dieselbe wie in der Überschrift: Vereinsangabe, dann ListenTyp, dann Namensheuristik.
  15. Bis Welle 1: previousTotalRing und totalRing stehen roh in Zehntelringen (wie ClubDataScoreSheet.totalRing) und meinen das ganze Blatt. Die App teilt für die Anzeige durch 10.
  16. Seit Welle 1 (P1, Entscheide F6 und E1): Rekord ist der Wert des Blatts nach „bis 40 Schuss“ gegen den bisherigen Bestwert derselben Sportpass-Nummer nach derselben Regel (Filter sonst wie bisher). „Bis 40 Schuss“ heißt: jede Scheibe zählt, höchstens mit ihren ersten 40 wertbaren Schüssen; ein 30er zählt mit 30, ein 60er mit seinen ersten 40. Ein unvollständiges Blatt (weniger wertbare Schüsse als min(40, trefferzahl), etwa bei einer Topic-Lücke) hat keinen Wert und erzeugt keinen Rekord. Regel in services/bestes40er.ts, Modus bis40.
  17. Felder: wertung: 'bis40_v1' kennzeichnet die Regel. rings (Wert dieser Session) und previousRings (bisheriger Bestwert) stehen in Ganzring (365 = 365 Ringe, kein ÷10). shots ist die Zahl der Schüsse in rings (höchstens 40), expected bleibt die Schusszahl des Blatts.
  18. Der Push nennt bei mehr als 40 Schuss „365 Ringe (erste 40 von 60 Schuss)“, sonst „290 Ringe aus 30 Schuss“.
  19. Bewusste Annahme Zwischenstand (Koordination 17.10.2026, Befunde P1D-ABFRAGE-02 und P1D-WIRKUNG-03): Der Notifier hält ein Blatt für fertig, sobald 3 Minuten Ruhe sind und die Treffer-Zeilen trefferzahl erreichen. trefferzahl ist aber ein laufender Zähler. Bei einer Pause ab 3 Minuten mitten im Blatt (Zähler und Zeilen gleich, etwa 31/31) wird das Blatt beansprucht und nie wieder geprüft. Nach „bis 40 Schuss“ hat dieser Zwischenstand einen Wert. Liegt er über einem kürzeren Bestwert (31 Schuss gegen den besten 30er), gehen Push „Neuer Bestwert … aus 31 Schuss“ und Rekord-Moment { rings, shots: 31, expected: 31 } mit dem Zwischenstand hinaus; das Endergebnis wird nicht mehr gemeldet.
    • Betroffen sind nur Zwischenstände unter 40 Schuss, denn ab 40 wertbaren Schüssen steht der Wert fest.
    • Vor Welle 1 (MAX(totalRing)) konnte das bei jeder Schusszahl passieren. Nur der Zwischenbau mit „mindestens 40“ kannte unter 40 Schuss keinen Wert.
    • Angenommen, weil selten: Es braucht einen Wechsel von kürzeren auf längere Programme und eine Pause ab 3 Minuten. Ein Riegel fehlt bewusst. Eine zweite, spätere Rekordprüfung steht auf der Nachliste.
    • Richtigstellung zum P1-Bau (dort Abweichung 4: „Zwischenstand zählt, der Wert wächst nur, kein falscher Abschluss“): Das gilt nur für die Bestschütze-Karte. Dort wächst der Wert nur, und der Sync rechnet bei jedem Abruf neu. Für den einmaligen Rekord-Push gilt es nicht, siehe oben.
  20. isNewPersonalBest bekommt einen optionalen Logger (N2).
  21. Duell-Moment. seasonId ist null, sp sind die tatsächlich gebuchten Punkte (0, wenn nichts gebucht wurde: kein Verein, Sommerpause, Geister-Niederlage), outcome ist win, loss oder draw.
  22. Frisch heißt: persistDuelResultSnapshot fand vorher keinen Snapshot. Die Funktion meldet das jetzt zurück.
  23. Freigabe: Der Detailpfad (:4431 am Basisstand) rechnet sie aus der dort schon geladenen Menge (consentVisibleUserIds). countWonDuelsInPeriod und der Sweep übergeben true, weil beide vorher prüfen. Die zwei Pfade mit vorhandenem Snapshot übergeben null.
  24. Herausforderung: Periode aus dem Idempotenzschlüssel, über das bekannte Präfix zerlegt. Für Woche und Monat ist das der Wochen- bzw. Monatsschlüssel, für die Saison die seasonId, für OPTIONAL_CHALLENGE die Karten-Kennung. Ein unbekanntes Format schreibt keinen Moment, statt einen Schlüssel zu raten. Der Gold-Bonus schreibt unter dem Schlüssel der Grundkarte und ist damit ein Konflikt-No-Op.
  25. Auszeichnung aus dem Katalog: Moment direkt nach dem frischen badgeAward.create, vor der SP-Buchung. Die Auszeichnung steht ab dort fest. seasonId ist bei Katalog-Auszeichnungen null (wie BadgeAward.seasonId), bei Meilensteinen die Saison.
  26. Urkunde im Sweep. Geprüft wird je Ziel-Saison des Settle-Sweeps (laufende und Nachlauf-Saisons im Horizont), sobald endsAt vor dem Stichtag liegt. Der Lesepfad schreibt nur fehlende Schlüssel (erst lesen, dann schreiben).
  27. Aufbewahrung ohne eigenen Index auf createdAt. Der Lauf ist täglich wie die übrigen Aufbewahrungsläufe. Bei 120 Tagen Bestand ist ein sequentieller Durchlauf einmal am Tag billiger als ein weiterer Index, den jeder Insert pflegt.
  28. Datenauskunft: eigener Abschnitt momente.ereignisse, nicht unter saisonpass. Rekord und Duell-Ergebnis sind keine Saisonpunkte. Die Spalten stehen in kontoStatus als momenteGesehenBisZeitpunkt, momenteGesehenBisKennung und momenteAbZeitpunkt.

Korrekturrunde 1 (Prüfbefunde am Head 7462b928 und Entscheid der Koordination)

  1. Die Urkunde ist von der 120-Tage-Aufbewahrung ausgenommen (F1). /season/honours schreibt einen fehlenden Urkunden-Schlüssel ohne Altersgrenze und mit createdAt = jetzt nach. Eine gelöschte, längst gesehene Urkunde läge danach hinter dem Cursor und käme alle 120 Tage wieder. Es bleibt eine Zeile je Saison und Konto. A11 gilt damit für alle Arten außer certificate.
  2. Der Feed prüft die eigene Freigabe (F2). Ohne GRANTED-Einwilligung liefert GET /api/moments nur certificate. Der Widerrufs-Purge läuft nur im Takt, bis dahin hielte der Feed sonst record mit Ringwerten bereit.
  3. Gesperrte Zeilen gelten nicht als ausgeliefert. Die Seite endet VOR der ersten gesperrten Zeile, der Cursor bildet sich nur aus dem gelieferten Stück davor, und hasMore ist dann falsch.
  4. Würde die gesperrte Zeile übersprungen, schöbe das nächste seen den Cursor über sie hinweg. Bei erneut erteilter Freigabe wäre sie verloren.
  5. Dauerhaft hängen bleibt nichts: Der Purge entfernt die gesperrte Zeile beim Widerruf, sonst fällt sie spätestens nach 14 Tagen aus dem Frischefenster (Punkt 17).
  6. Frischefenster 14 Tage (Entscheid der Koordination, Prüferbefund F3). Der Feed liefert nur Momente, deren createdAt höchstens 14 Tage zurückliegt, die Urkunde immer.
  7. Das begrenzt das gesammelte Nachliefern nach einem Pro-Upgrade und nach langer Abwesenheit. Die Kaufpfade bleiben unberührt.
  8. Die Bedingung steht IN der Abfrage. Zu alte Zeilen werden nie wieder lieferbar und gelten deshalb als übersprungen: Der Cursor darf über sie hinweg. Als nachträglicher Filter hielte eine Seite voller alter Zeilen den Feed fest.
  9. Die Urkunde von Season 0 wird einmal gezeigt, das ist gewollt.
  10. Log-Spur: Der Scheduler loggt den Widerrufs-Lauf auch dann, wenn er nur Momente gelöscht hat (deletedMoments > 0).

Nicht Teil von P3, gesondert gemeldet

  • N1: Der Duell-Sweep lädt active: true mit take: 200, orderBy createdAt asc. Finalisierte Duelle bleiben aktiv. Ab 200 sieht der Sweep neue Duelle nicht mehr.
  • N2: isNewPersonalBest verschluckt DB-Fehler. In P3 kommt nur ein Log ohne Fehlertext dazu.
  • Widerruf: Ledger, BadgeAward, SeasonPassProgress und rulesJson.duelResult bleiben nach dem Widerruf stehen.
  • App-Seite (MomentHost), Push, Animationen.
  • Mobile-API-Typen (frontend/mobile/src/api/) für den Feed und die neuen Urkunden-Felder best40er und duels: Der Bau-PR ist nur Backend.