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)undcreatedAt < now() - 5 s(gegen Commit-Nachzügler), aufsteigend nach(createdAt, id), höchstens 50. Dazucursor: { at, id }der letzten ausgelieferten Zeile undhasMore.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üfungWHERE (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.momentsFeedSinceentstanden sind. Diese dritte Spalte setzt die Migration für alle Bestandskonten auf den Deploy-Zeitpunkt, Vorgabenow()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), undunlockedLevelkann 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:984undmembers.ts:1866löschen die Zeile. Einzutragen inuserDataPurge.ts: - (a) FK
MomentEvent.userIdalscascadeinUSER_FOREIGN_KEY_REFERENCES, - (b)
momentEventinCASCADING_USER_RELATIONS, - (c)
MomentEvent.payloadinUSER_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_RELATIONSmit eigenem Pfad.momentsSeenAt,momentsSeenIdundmomentsFeedSincestehen ausdrücklich inkontoStatus. Ein neuer Wächtertest prüft: Jeder FK mitcascadein der Landkarte steht inCASCADING_USER_RELATIONS. Damit ist die Lücke aus B8 strukturell geschlossen. - Widerruf der Schießdaten (A6, B5): In
purgeRevokedShootingDatawird je REVOKED-Einwilligung überconsent.userIdgelöscht. Das läuft vor und unabhängig von demcontinuebei:61, also bei jedem Lauf. Gelöscht werden alleMomentEventaußercertificate, alsorecord,duel_result,challenge_done,level_upundbadge, 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 nachendsAt. /season/honoursschreibt 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):zeitstempelroh gegen dieDate.UTC-Grenzen, Rückfall aufcreatedAt.- 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 (
queryBestFirst40InPeriodim Modusmind40, Regel inservices/bestes40er.ts, wiesessionTotal40Strictim Web). Ohne Einzelschüsse zählttotalRingnur beitrefferzahlgenau 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 mitendedAtin der Saison, dieselbe Logik wieGET /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.
momentsFeedSinceist 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.momentsSeenAtundmomentsSeenIdbleiben nullable.- Die Migration füllt den Bestand in drei Schritten: Spalte anlegen,
UPDATE … = now() AT TIME ZONE 'UTC', dann Vorgabe und NOT NULL. Ein einfachesADD COLUMN … NOT NULL DEFAULT CURRENT_TIMESTAMPrechnet in der Zeitzone der Sitzung in dietimestamp-Spalte um. Gemessen auf einer lokalen Instanz mitTimeZone = 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. - Neue Konten bekommen den Wert von Prisma selbst, in UTC (gemessen). Die Datenbank-Vorgabe
CURRENT_TIMESTAMPsteht nur, weilschema.prismasie erwartet, sonst meldet der Drift-Wächter der CI eine Abweichung. - Widerruf: Selektion auf alle REVOKED-Einwilligungen erweitert (A6). Bisher filterte
purgeRevokedShootingDataaufsportpassId 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 Schutzzeileif (!(sportpassId > 0)) continue(geschärft in Korrekturrunde 1).Number(null)undNumber('')sind 0 und wären sonst als Sportpass 0 in die Schießdaten-Löschung gelaufen. best40ernur aus eigenen, freigegebenen Nummern. Definition und Zeitvergleich sind wie vorgegeben: dieselbe Abfrage wie in der Sammlung (anfangsqueryBestTotalInPeriod, seit Welle 1queryBestFirst40InPeriod), Saisongrenzen wie im Saison-Sync. Zusätzlich gilt:- Die eigene Einwilligung muss GRANTED sein, sonst ist der Wert
null. - 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.
- Folge: Ein Konto ohne Nummer in der Spalte bekommt
null, und die App lässt die Kachel weg. duelsohne Geisterduelle,nullohne gewertetes Duell. „Dieselbe Logik wie/duels/record“ heißt dort: Die Bilanz (totals) führt Geisterduelle getrennt. Der Zeitanker ist wie dortparticipation.endedAtmit Rückfall auffinalizedAt. Eine Saison ohne ein einziges gewertetes Duell liefertnullstatt{0,0,0}, denn „0 · 0 · 0“ ist keine Bilanz. Eine Einwilligungssperre gibt es wie bei/duels/recordnicht, weil die Bilanz keine Schießzahl trägt.POST /api/moments/seen: geschrieben wird dercreatedAtder Zeile, nicht das mitgeschickteat.atdient 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.- Die Untergrenze
momentsFeedSincegilt auch mit gesetztem Cursor.seennimmt jede eigene Zeile an, auch eine vor dem Stichtag. Ohne die Untergrenze käme darüber eine Rückfüllung durch die Hintertür. GET /api/momentsohne User-Zeile liefert einen leeren Feed statt 404. Ohne Pro antwortet die Route 403plan_required, wie/season/pass.- Rekord: geschrieben vor dem Versand und unabhängig von den Benachrichtigungs-Einstellungen. Wer Push abbestellt hat, bekommt den Moment in der App trotzdem.
- Die Kategorie ist dieselbe wie in der Überschrift: Vereinsangabe, dann
ListenTyp, dann Namensheuristik. - Bis Welle 1:
previousTotalRingundtotalRingstehen roh in Zehntelringen (wieClubDataScoreSheet.totalRing) und meinen das ganze Blatt. Die App teilt für die Anzeige durch 10. - 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 inservices/bestes40er.ts, Modusbis40. - Felder:
wertung: 'bis40_v1'kennzeichnet die Regel.rings(Wert dieser Session) undpreviousRings(bisheriger Bestwert) stehen in Ganzring (365 = 365 Ringe, kein ÷10).shotsist die Zahl der Schüsse inrings(höchstens 40),expectedbleibt die Schusszahl des Blatts. - Der Push nennt bei mehr als 40 Schuss „365 Ringe (erste 40 von 60 Schuss)“, sonst „290 Ringe aus 30 Schuss“.
- 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
trefferzahlerreichen.trefferzahlist 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.
isNewPersonalBestbekommt einen optionalen Logger (N2).- Duell-Moment.
seasonIdistnull,spsind die tatsächlich gebuchten Punkte (0, wenn nichts gebucht wurde: kein Verein, Sommerpause, Geister-Niederlage),outcomeistwin,lossoderdraw. - Frisch heißt:
persistDuelResultSnapshotfand vorher keinen Snapshot. Die Funktion meldet das jetzt zurück. - Freigabe: Der Detailpfad (
:4431am Basisstand) rechnet sie aus der dort schon geladenen Menge (consentVisibleUserIds).countWonDuelsInPeriodund der Sweep übergebentrue, weil beide vorher prüfen. Die zwei Pfade mit vorhandenem Snapshot übergebennull. - 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ürOPTIONAL_CHALLENGEdie 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. - Auszeichnung aus dem Katalog: Moment direkt nach dem frischen
badgeAward.create, vor der SP-Buchung. Die Auszeichnung steht ab dort fest.seasonIdist bei Katalog-Auszeichnungennull(wieBadgeAward.seasonId), bei Meilensteinen die Saison. - Urkunde im Sweep. Geprüft wird je Ziel-Saison des Settle-Sweeps (laufende und Nachlauf-Saisons im Horizont), sobald
endsAtvor dem Stichtag liegt. Der Lesepfad schreibt nur fehlende Schlüssel (erst lesen, dann schreiben). - 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. - Datenauskunft: eigener Abschnitt
momente.ereignisse, nicht untersaisonpass. Rekord und Duell-Ergebnis sind keine Saisonpunkte. Die Spalten stehen inkontoStatusalsmomenteGesehenBisZeitpunkt,momenteGesehenBisKennungundmomenteAbZeitpunkt.
Korrekturrunde 1 (Prüfbefunde am Head 7462b928 und Entscheid der Koordination)¶
- Die Urkunde ist von der 120-Tage-Aufbewahrung ausgenommen (F1).
/season/honoursschreibt einen fehlenden Urkunden-Schlüssel ohne Altersgrenze und mitcreatedAt = jetztnach. 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ßercertificate. - Der Feed prüft die eigene Freigabe (F2). Ohne GRANTED-Einwilligung liefert
GET /api/momentsnurcertificate. Der Widerrufs-Purge läuft nur im Takt, bis dahin hielte der Feed sonstrecordmit Ringwerten bereit. - 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
hasMoreist dann falsch. - Würde die gesperrte Zeile übersprungen, schöbe das nächste
seenden Cursor über sie hinweg. Bei erneut erteilter Freigabe wäre sie verloren. - 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).
- Frischefenster 14 Tage (Entscheid der Koordination, Prüferbefund F3). Der Feed liefert nur Momente, deren
createdAthöchstens 14 Tage zurückliegt, die Urkunde immer. - Das begrenzt das gesammelte Nachliefern nach einem Pro-Upgrade und nach langer Abwesenheit. Die Kaufpfade bleiben unberührt.
- 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.
- Die Urkunde von Season 0 wird einmal gezeigt, das ist gewollt.
- 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: truemittake: 200, orderBy createdAt asc. Finalisierte Duelle bleiben aktiv. Ab 200 sieht der Sweep neue Duelle nicht mehr. - N2:
isNewPersonalBestverschluckt DB-Fehler. In P3 kommt nur ein Log ohne Fehlertext dazu. - Widerruf: Ledger, BadgeAward,
SeasonPassProgressundrulesJson.duelResultbleiben nach dem Widerruf stehen. - App-Seite (MomentHost), Push, Animationen.
- Mobile-API-Typen (
frontend/mobile/src/api/) für den Feed und die neuen Urkunden-Felderbest40erundduels: Der Bau-PR ist nur Backend.