Push-Benachrichtigungen für jeden Auslöser, eine stillere Plattform und eine solide Test-Abdeckung quer durch die Lösung

Push-Benachrichtigungen für jeden Auslöser, eine stillere Plattform und eine solide Test-Abdeckung quer durch die Lösung

push-notificationsonesignalreliabilitycoverageci-cdalpha
Fiction

Push-Benachrichtigungen für jeden Auslöser, eine stillere Plattform und eine solide Test-Abdeckung quer durch die Lösung

Smartphone mit eingeblendeten Benachrichtigungen — Symbol für die neue Push-Palette, die diese Woche überall im Stack landete Foto: Jamie Street auf Unsplash

Die letzten beiden Wochen hatten je einen klaren Schwerpunkt — Werkzeuge und Admin-Bildschirme, dann Sicherheit und Datenschutz. Diese Woche war vielfältiger. Ein einzelnes Thema in den Vordergrund zu rücken würde drei andere unterschlagen, deshalb erzähle ich entlang dreier Stränge: Benachrichtigungen (vom rudimentären „eine Sache pro Tag" zu einer ausgewogenen Palette mit fünfzehn Ereignistypen für Autorinnen und Leser:innen plus einem anonym-freundlichen Broadcast), Zuverlässigkeit (ein still gewordenes Sentry-Dashboard, ein automatischer Refresh-und-Retry-Pfad für ablaufende Access-Tokens, Functions-Hosts solide gegen Pod-Restarts) und Test-Abdeckung quer durch die Lösung (mit einem Schwellen-Gate, das von jetzt an jeden Pull-Request begleitet). Dazu kommen ein paar kleinere Verfeinerungen, die das Gefühl der App formen, ohne in einer Schlagzeile zu landen.

Eine ausgewogene Palette von Benachrichtigungen

Bis vor zwei Wochen kannte OutaStory genau eine Push-Benachrichtigung: „neuer Kommentar an deine Geschichte". Das war ein Anfang, aber kein Anreiz, die App wieder zu öffnen — wenn die einzige Erinnerung an die Plattform der Hinweis ist, dass jemand etwas Negatives schreiben könnte, fragst du dich, ob du die App überhaupt zugelassen hast. Diese Woche kam die Palette dazu, und sie versucht zwei Dinge gleichzeitig: Autorinnen einen ehrlichen Grund zu geben, zurückzukommen (jemand hat dir gerade gefolgt, jemand hat deine Geschichte gerade favorisiert, du hast gerade einen Meilenstein erreicht), und Leser:innen sanft an unfertige Bücher zu erinnern, die in der Bibliothek darauf warten, weitergelesen zu werden.

Fünfzehn neue Ereignistypen, in drei Schichten gebaut

Der Pipeline-Bau folgt einem einzigen Muster, das jetzt für jeden Ereignistyp gleich funktioniert: ein Auslöser im Domain-Code (irgendwo schreibt ein Controller oder ein Hintergrund-Job einen Datensatz, der ein Ereignis verdient), ein NotificationEventMessage auf einer dedizierten Service-Bus-Warteschlange, ein NotificationProcessor-Function, die die Empfänger-Liste auflöst, jede Empfänger-Präferenz pro Kanal abfragt (In-App / Push / E-Mail), den Titel und Body lokalisiert (Deutsch + Englisch), den Deep-Link baut und dann den jeweiligen Kanal bedient.

In dieser Reihenfolge sind die neuen Typen dazugekommen:

Schreib-orientierte Ereignisse (für Autorinnen, die wissen wollen, was mit ihrer Arbeit passiert):

  • newsletter.weekly — das Pendant zum täglichen „Story of the Day"-Newsletter, aber als wöchentliche, personalisierte Ausgabe pro Empfänger.
  • story.chapter_published — fan-out an jede Person, die die Geschichte favorisiert hat, sobald ein neues Kapitel veröffentlicht wird.
  • story.milestone.reads — ausgelöst, wenn eine Geschichte 100, 500, 1.000, 5.000 oder 10.000 Lese-Sessions erreicht.
  • story.milestone.favorites — gleiche Schwellen für Favoriten.
  • author.milestone.followers — gleiche Schwellen für Autor-Follows.

Reader-orientierte Ereignisse (für Leser:innen, die in ihrer Bibliothek noch etwas haben):

  • story.completed — fan-out an jede Person, die eine Geschichte favorisiert hat, sobald die Autorin sie als „abgeschlossen" markiert. Geht über die Versions-Kette — wer V1 als Lieblingsbuch gespeichert hat, bekommt den Push, sobald V2 die Serie abschließt.
  • comment.thread_activity — wenn jemand neuer in einer Kommentar-Strecke schreibt, in der du selbst schon kommentiert hast (und nicht der Auslöser bist). Strikt unter der bereits existierenden comment.replied-Benachrichtigung gehalten, weil das eine andere Beziehung ist — „die Diskussion geht weiter" statt „dir wurde geantwortet".
  • reading.resume_reminder — wenn du mit einer Geschichte zwischen 20 % und 99 % begonnen hast und sieben Tage lang nicht reingeschaut hast. Mit einem 14-Tage-Cooldown pro Person versehen, sodass die Erinnerung nicht zur Nervig-Schleife wird.
  • recommendation.weekly — eine kuratierte Wochen-Empfehlung, basierend auf dem Empfehlungs-Modell, mit einem 7-Tage-Cooldown pro Person.

Plattform-Broadcasts (für jedes Gerät, das den Push-Prompt akzeptiert hat — anonym wie eingeloggt):

  • platform.broadcast.daily_story — der tägliche „Story of the Day"-Pick als Push an die OneSignal-Audience „Subscribed Users", was bei OneSignal sowohl an einen Auth0-Sub gebundene Geräte als auch an anonyme Geräte zustellt. Das ist der Hebel, mit dem ein neuer iPad-Nutzer, der einen Tag nach der Installation noch keinen Account hat, trotzdem eine Geschichte angeboten bekommt.
  • platform.broadcast.contest_published — wenn eine Administratorin einen Wettbewerb veröffentlicht und in dem Publish-Dialog die Checkbox „Push an alle abonnierten Geräte" anhakt. Die Checkbox ist standardmäßig aus — ein versehentlicher Re-Publish nach einer Tippfehler-Korrektur soll nicht jedes Gerät ein zweites Mal anpingen.

Die Trennung zwischen broadcast-Ereignissen und per-User-Ereignissen ist wichtig: broadcast-Ereignisse durchlaufen keine Empfänger-Auflösung, schreiben keine in-app-Zeile (es gibt keine per-User-Audience, die das sehen würde), schreiben keine E-Mail (anonyme Geräte haben keine Adresse) — sie gehen direkt an die OneSignal-Segment-API. Das ist der einzige Weg, anonyme Geräte und eingeloggte Accounts in einem REST-Call zu erreichen.

In-App-Benachrichtigungs-Zentrale, Glocken-Badge und das /notifications-Routing

Bisher gab es noch keine zentrale Übersicht — eine Push-Benachrichtigung war eine Push-Benachrichtigung, und sie verschwand. Ab dieser Woche schreibt der NotificationProcessor für jeden per-User-Auslöser zusätzlich eine Notification-Zeile plus eine NotificationRecipient-Zeile pro Empfänger:in. Die Benachrichtigungs-Zentrale unter /notifications listet sie chronologisch, gruppiert nach Tab („Alle", „Kommentare", „Follows", „Promotions", „System"), und das Glocken-Symbol in der oberen Leiste zeigt einen Badge mit der ungelesenen Anzahl. Ein „Alle als gelesen markieren"-Knopf räumt auf einen Schlag auf.

Die Benachrichtigungs-Zentrale war in der Vorversion ein einfaches <a>-Element, das auf eine Inhaltsseite verwies. Die neue Version ist eine Razor-Komponente mit Live-Update über die existierende SignalR-Verbindung — wenn ein Kommentar während der laufenden Sitzung kommt, erscheint die Glocke sofort gefüllt, ohne dass die Seite neugeladen werden muss. Die Tab-Beschriftungen wurden außerdem korrigiert (PRs aus der vorherigen Woche zeigten gelegentlich rohe Resource-Keys statt der lokalisierten Strings); ein bUnit-Test in der Test-Suite hält die Bedingung jetzt aufrecht, sodass eine erneute Regression direkt rot rausspringt.

Native Push-Berechtigung auf iOS und Android

Eine peinliche Lücke, die ich diese Woche aufgeräumt habe: die TestFlight- und Android-Alpha-Apps haben nie nach Push-Berechtigung gefragt. Der OneSignal-SDK war initialisiert, die Login-Verknüpfung der Auth0-Subs zur Push-Identität lief, aber der eigentliche Berechtigungs-Prompt war eine eigene Methode auf einem Binder-Service — die irgendwann mit einem Kommentar versehen wurde, „rufe das beim ersten Notification-würdigen Vorgang auf", und die nie irgendwo aufgerufen wurde. Auf Android 13 und neuer fehlte zusätzlich die Manifest-Deklaration für POST_NOTIFICATIONS, die Android seit API-Level 33 zwingend braucht — ohne sie geht der Berechtigungs-Prompt schlicht nicht raus.

Beide Lücken sind jetzt zu. Der Prompt feuert beim ersten erfolgreichen OneSignal-Initialize, das passiert direkt nach dem App-Window-Aufbau in der MAUI-Variante. Eine Voreinstellung im lokalen Speicher gates ihn auf einmal pro Installation — wenn du Nein klickst, fragst du nicht auf jedem App-Start erneut. Stattdessen gibt es auf /settings/notifications jetzt ein zweites Panel parallel zum existierenden Browser-Push-Panel: „Push-Benachrichtigungen", mit dem aktuellen OS-Status und einem Schalter, der den Prompt erneut anstößt. Bei einer früher abgelehnten Berechtigung leitet OneSignal automatisch in die System-Einstellungen weiter, sodass die Person die Genehmigung dort umschalten kann.

Die Architektur folgt dem üblichen Muster: ein INativePushPermission-Interface in OutaStory.App.Shared für die Razor-Seiten, eine MAUI-Implementierung in OutaStory.App die den OneSignal-Binder umhüllt, und eine No-Op-Implementierung in OutaStory.App.Web die IsAvailable=false zurückgibt. Das gleiche Panel rendert auf Web nicht, weil dort der bestehende Browser-Push-Pfad zuständig ist; auf MAUI ist es das einzige Panel, das die Seite zeigt.

Einstellungs-Seite, Frequenz-Tester und Cooldowns

Mit der wachsenden Liste von Ereignistypen brauchte die Einstellungs-Seite eine Strukturreform. /settings/notifications listet jetzt jeden konfigurierbaren Typ in einer einzelnen Tabelle mit drei Spalten pro Zeile (In-App / Push / E-Mail), Standard-an für alle Kanäle, expliziter Opt-out pro Kanal. Subscription-Lifecycle-Ereignisse (Premium aktiviert, Zahlung fehlgeschlagen, Konto-Löschung geplant) sind absichtlich nicht in der Liste — sie sind unmutbar wichtig und gehen als sogenannte „mandatory"-Ereignisse durch, ohne den Präferenz-Gate-Check.

Die Cooldowns für reading.resume_reminder (14 Tage) und recommendation.weekly (7 Tage) werden pro Person in zwei neuen Spalten auf der Author-Tabelle gehalten (LastResumeReminderUtc, LastWeeklyRecommendationUtc). Eine kleine Datenbank-Migration legt die Spalten an; die Scheduled-Functions stempeln die jeweilige Spalte bei jeder erfolgreichen Enqueue-Aktion, sodass die Pipeline garantiert nicht zweimal innerhalb des Fensters schreibt.

Die Plattform ist deutlich stiller geworden

Wenn Push das Geräusch der Woche war, war Stille der andere große Hebel. Das Sentry-Dashboard zeigte am Wochenanfang etliche wiederkehrende Einträge, die keine echten Bugs waren — Boot-Beacons von Service-Startups, EF-Design-Time-Warnings, Polly-Retry-Events, ein paar Browser-Side-Manifest-404s, die nie eine User-sichtbare Folge hatten. Jeder einzelne ist klein; in Summe machten sie die echten Probleme schwer zu finden.

Schaltkreis-Nahaufnahme — Symbol für die Vorgänge, die nicht im Bild der Anwender:innen passieren Foto: Alexandre Debiève auf Unsplash

Sentry-Filter, breadcrumb-Kategorien und Boot-Beacons unter eigenem Logger-Namen

Sieben Pull-Requests in der Woche haben das Rauschen reduziert. Die meisten arbeiten mit der bestehenden SentryNoiseFilter-Klasse in OutaStory.ServiceDefaults — eine Allow-List-Architektur, die jeder Event-Kategorie eine eigene Filter-Regel zuordnet. Dazugekommen sind Filter für die SignalR-Strong-Connection-Container-Logs (eine Event-Klasse, die nur dann meldet, wenn die Backplane gerade einen Neu-Aufbau macht — das ist normal und kein Bug), Filter für die Polly-Resilience-Pipeline-Events (jeder Retry war als Sentry-Event gezählt, was die Anzahl der „Probleme" verzehnfachte ohne ein einzelnes echtes hinzuzufügen), Filter für Browser-fetch-Ausnahmen, die typischerweise nach Sleep- oder Tab-Switch-Aktionen auftreten (das ist Browser-Verhalten, kein Server-Bug), und Filter für IDBDatabase-closing-Meldungen (die Browser sich selbst aufräumt sieht).

Eine Architektur-Verfeinerung: die Boot-Beacons jedes Function-Hosts und der API werden jetzt unter dem Logger-Namen OutaStory.Boot emittiert. Damit kann der Sentry-Filter sie kategorisch droppen — eine Boot-Beacon ist nie ein Bug-Signal, sie ist ein „ich lebe noch"-Signal, das in unsere App-Insights-Logs gehört, aber nicht in den Triage-Pfad.

Browser-Side-Bridge: Toast-Mirror, Navigation-Breadcrumbs und einige weniger Mysterien

Eine ältere Schwachstelle ist diese Woche gelöst worden: wenn ein API-Call vom Web-Client einen 5xx-Fehler bekam und der API-seitige Sentry-Capture funktionierte (was er praktisch immer tat), kam das Sentry-Event in der API-Projekt-Sammlung an — aber nicht in der Web-Sammlung. Das machte das Web-Sentry-Projekt zu einer leeren Fläche, in der gelegentlich Browser-Ausnahmen auftauchten, aber nie der Kontext, was die Person genau gemacht hat, als das passierte.

Eine kleine JavaScript-Brücke in wwwroot/sentry-bridge.js hängt sich jetzt an die globalen Toast-Mitteilungen sowie an den Blazor-Navigation-Manager und schreibt einen breadcrumb pro Ereignis. Wenn jetzt ein API-Call schief läuft, hat die Web-Sentry-Sammlung eine Spur: „Person hat um 14:32 auf ‚Veröffentlichen' geklickt → API-Call 500 → Toast ‚Fehler beim Veröffentlichen' erschienen", und der Fix-Pfad wird kürzer.

Eingelogged, aber stillschweigende API-Fehlschläge nach langer Idle-Zeit

Eine subtilere Sache, die ich aus eigenem Erleben angehen musste: wenn du auf der Plattform eingeloggt bist und nach mehreren Stunden zur Seite zurückkommst, sagt die Oberfläche zu Recht, du seist eingeloggt — aber bestimmte API-Calls scheiterten lautlos, bis du dich aus- und wieder einloggtest. Auf /profile/tester zum Beispiel kam die Test-Herausforderungs-Liste nicht zurück.

Die Ursache: Blazor-Server-Schaltkreise (Circuits), die nach der ersten Verbindung Stunden lang über die SignalR-Verbindung gepingt werden, durchlaufen die Cookie-Auth-Pipeline kein zweites Mal. Der bestehende Refresh-Token-Hook (OnValidatePrincipal) feuert nur bei neuen HTTP-Anfragen — bei einer offen-gebliebenen Tab also nie. Sobald das Auth0-Access-Token nach ungefähr 24 Stunden abläuft, sind alle ausgehenden API-Calls aus diesem Schaltkreis mit einem abgelaufenen Token unterwegs, die API antwortet mit 401, und die Person sieht „nichts" auf der Seite.

Der Fix passt in drei Teile. Der AccessTokenCache speichert ab dieser Woche nicht nur das Access-Token, sondern zusätzlich den Refresh-Token und den absoluten Ablaufzeitpunkt — damit kann der WebBearerTokenHandler selbst entscheiden, wann das Token „bald abläuft". Bei einer 60-Sekunden-Reichweite vor dem Ablauf wird das Access-Token proaktiv ausgetauscht, bevor die API-Anfrage überhaupt rausgeht. Falls die API trotzdem mit einem 401 antwortet (was passieren kann, wenn der Cache-Eintrag pre-rollout keinen Refresh-Token hatte), versucht der Handler einmal einen Refresh-und-Retry: Refresh-Token gegen ein frisches Access-Token tauschen, Cookie und Cache aktualisieren, die ursprüngliche Anfrage mit dem neuen Token wiederholen. Ein einziges Retry by-design — wenn das ebenfalls in einer 401 endet, ist die Refresh-Kette tatsächlich tot und die Person sieht den Standard „Bitte erneut anmelden"-Pfad.

Eine Sentry-Brücke begleitet jede Übergangsstelle: jeder proaktive Refresh, jeder Retry-Versuch und jedes Endergebnis hinterlassen einen breadcrumb in der Sentry-Kategorie auth.bearer.*. Falls der seltene unrecoverable-Fall eintritt (Refresh-Token wurde revoziert, Audience-Mismatch, Signing-Key-Rotation), surfaced der Handler einen Error mit allen relevanten Tags, sodass der Triage-Pfad auf eine Seite passt.

Data-Protection-Keyring, der Pod-Restarts überlebt

Eine weniger sichtbare, aber wichtige Reparatur betraf die Social-Posting-Pipeline: die Bluesky-Daily-Posting-Timer konnten ihre verschlüsselt gespeicherten App-Passwörter nicht mehr entschlüsseln, sobald der entsprechende Container-Replica neu startete. Die Ursache war ein klassischer DataProtection-Anti-Pattern: der Keyring lag im Container-Filesystem, der bei Restart weg ist. Die Lösung war ein Schwenk auf einen geteilten Keyring in Azure Blob Storage, der zwischen der API und der Social-Poster-Function geteilt wird. Damit überlebt der Keyring beliebige Container-Replica-Neuaufbauten, und die täglichen Bluesky-Posts können ihre App-Passwörter auch über einen Pod-Restart hinweg lesen.

Notification-Pipeline-Diagnose und Writing-Pipeline-Diagnose

Zwei größere PR-Blöcke (#594, #596) haben eine Diagnose-Ebene über die existierenden Pipelines gelegt — keine Verhaltens-Änderung, aber strukturierte Logs an jeder Übergangsstelle. Wenn der Cover-Generator nichts zurück liefert, der SSML-Validator stolpert, der Audio-Synthesizer kein Output schreibt, ein Service-Bus-Receiver eine Nachricht zwei-mal dequeued — das taucht jetzt in einer einheitlichen Sentry-Tag-Form auf, was die Diagnose von schief-laufenden Pipelines auf einen einzigen Suchausdruck reduziert.

Test-Abdeckung quer durch die Lösung, mit einem Gate, das jeden PR begleitet

Die Test-Lage war bisher rund 2.940 grüne Unit-Tests, was viel klingt — bis du nach Projekten aufschlüsselst und feststellst, dass die Abdeckung nicht überall gleich ist. Die Domänen- und Service-Schichten waren über 95 % gut bedacht; die UI-Hosts und Funktions-Worker hingen bei rund 50 %. Diese Woche kam dazu, was die Lücke schließt.

Frisch geschnittene Schlüssel auf einer Holzplatte — Symbol für die Schwellen-Tabelle, die ab dieser Woche an jedem Pull-Request hängt Foto: Maxim Hopman auf Unsplash

Eine Tier-Tabelle und ein Coverage-Skript, das den Tier-Wert versteht

Statt einer einzelnen Schwelle für die ganze Lösung wurde diese Woche eine drei-Tier-Tabelle eingeführt:

  • Tier A — 95 %: reine Logik und Verträge. Domain, Shared, SSML, RevenueShare-Domain plus die Abstractions-Pakete, die nichts mehr als Interfaces und Datentypen enthalten.
  • Tier B — 85 %: Daten- und Service-Schichten. EF-DbContext + Repositories, die API, das Email-Modul, das IAP-Modul, die OneSignal-Push-Schicht, Search, ServiceDefaults.
  • Tier C — 70 %: Razor-Hosts, Functions und Ad-Adapter. App.Shared, App.Web, die fünf Function-Hosts, die zwei Webhook-Empfänger, die acht Ad-Adapter.

Bootstrappers (AppHost, der MAUI-Shell, die vier Initialization-Worker) sind explizit ausgeschlossen — sie sind DI-Verkabelung und Migrations-Reihenfolge, die nicht sinnvoll mit Unit-Tests abgebildet werden, sondern durch Integrations-Tests und manuelle Smoke-Checks. Neue Projekte landen standardmäßig auf Tier C, sofern nicht explizit in coverage/thresholds.json höher eingestuft.

Das Check-Coverage.ps1-Skript läuft jetzt im Report-Mode auf jedem Pull-Request: es führt die ganze Test-Suite mit Coverlet-Coverage-Collection aus, aggregiert die Ergebnisse pro Projekt, vergleicht jeden Wert gegen die Tier-Schwelle und schreibt die Ergebnis-Tabelle in die CI-Logs. Der Modus ist absichtlich Report (nicht Enforce) während wir die letzte Lücke schließen — das Razor-Page-Heavy-Projekt OutaStory.App.Shared steht noch bei rund 21 % vor der Schwelle von 70 %, weil die meisten Pages bUnit-Tests mit @inject-Mocks brauchen, und das ist eine mehrwöchige Arbeit, kein PR-Push.

Was sich diese Woche bewegt hat

Die solution-weite Lift-Up-Aktion hat 35 von 36 gemessenen Projekten auf grün gebracht. Allein für die Web-App ging es von 51,9 % auf 93,3 % — die Tests decken jetzt jeden Bearer-Handler, jeden Middleware, jede Hosted-Service und jeden Endpoint ab, plus die meisten Razor-Pages mit Unit-Tests, die kein Browser brauchen. Ein paar legitime Bootstrapper bekamen ein [ExcludeFromCodeCoverage]-Attribut mit klarer Begründung (Top-Level-Programm-Klasse, Layout-Shell, Authority-Initialisierung), nach dem Muster, das bereits AGENTS.md dokumentiert.

Drei Wave-PRs haben zusammen rund 450 neue Tests dazugefügt; jeder PR-Beschrieb listet die Tier-Tabellen-Bewegung, was die Code-Review-Erfahrung deutlich macht.

Eine Wettbewerbs-Seed-Sache, die nie auftauchte

Eine kleinere Korrektur, die für die Test-Phase wichtig war: der gesäte Beispiel-Wettbewerb („SAMPLE — The Quiet Hour" mit fingierter Jury und einer 9-Monate-Laufzeit) tauchte auf /admin/contests nicht auf, obwohl der Seed-Code korrekt aussah und das Listing keine versteckten Filter hatte. Die Ursache lag im Init-Data-Fingerprint-Mechanismus: einmal pro Umgebung wird ein Hash über alle eingebetteten Seed-Resourcen geschrieben und beim nächsten Boot verglichen — ist der Hash unverändert, wird der ganze Seed-Sweep übersprungen. In genau dieser Phase hatte mindestens eine Umgebung einen Fingerprint-Eintrag mit einer Wettbewerbs-Tabelle, die eigentlich den Seed gesehen haben sollte, aber nicht hatte (ein Partial-Transaktions-Pfad oder eine manuelle SQL-Aktion, die nur einige Tabellen geleert hat und den Fingerprint stehen ließ).

Die Lösung folgt dem etablierten Muster: SchemaVersion-Bump von 9 auf 10, was den Hash garantiert ändert und einen vollständigen Re-Seed bei der nächsten Boot-Aktion erzwingt. Der Seeder selbst ist seit dem ersten Tag idempotent — Upsert nach (Year, Slug), jury-Mitglieder werden additiv hinzugefügt, runtime-Änderungen bleiben erhalten. Umgebungen, die den Beispiel-Wettbewerb bereits haben, bekommen einen No-Op-Refresh.

Während ich dabei war, ist der ohnehin geplante Push-Broadcast für die Wettbewerbs-Veröffentlichung gleich mit reingegangen: im Admin-Dialog für „Veröffentlichen" gibt es jetzt eine Checkbox „Push-Benachrichtigung an alle abonnierten Geräte". Default ist aus — eine Re-Publish nach einem Tippfehler soll niemanden ein zweites Mal anpingen. Wenn sie aktiviert ist, geht ein einzelner Broadcast an die OneSignal-Audience „Subscribed Users", mit dem Deep-Link auf die Wettbewerbs-Landing.

Kleinere Verfeinerungen, die das Gefühl prägen

Ein paar kleinere Sachen, die nicht in eine Schlagzeile passen, aber den Tagesablauf in der App spürbar machen:

  • Entwurf-Löschen auf /my-stories (PR #592): eine zweistufige Inline-Bestätigung („Klick zum Löschen" → „Wirklich löschen?") direkt in der Storyliste, statt eines Modal. Die zweistufige Inline-Variante ist schneller, wenn man weiß, was man tut, und sicher genug, dass ein einzelner Doppel-Klick auf der falschen Zeile keinen Schaden anrichtet.
  • /write-Aufteilung in Schreib- und Detail-Tabs (PR #595): die Editor-Seite hatte alles auf einer einzelnen langen Seite — die Schreib-Oberfläche, die Tag-Verwaltung, die Cover-Generierung, die Audio-Einstellungen, die Veröffentlichungs-Auswahl. Das ist jetzt in zwei Tabs aufgeteilt: „Schreiben" (nur der eigentliche Text) und „Details" (alles andere). Beim Tab-Wechsel wird der Schreib-Tab automatisch gespeichert, sodass keine Hand-Speichern-Aktion zwischen den Tabs nötig ist.
  • Monetag als separat-schaltbarer Overlay (PR #557): die /management/ads-Seite hat jetzt einen separaten Schalter für Monetag, getrennt von der primären Werbe-Auswahl (Google AdSense / Media.net / Sample). Damit kann Monetag als Zusatz-Overlay laufen, ohne den primären Slot zu beeinflussen — wir testen, ob die Overlay-Variante eine Umsatz-Differenz macht oder nur stört.
  • Erweiterte Coming-Soon-/Maintenance-Tests (PR #538): eine spezifische Regression im App.razor SSR-Pfad — bei Last konnte ein sync-over-async-Pfad in der Layout-Auswahl in einen Deadlock kippen — wurde mit einem dedizierten Last-Test in die Suite aufgenommen.
  • AGENTS.md als zentrale Quelle (PRs #546, #582): die agent-instructions-Datei, die mehrere Tools mit der gleichen Information versorgt (Claude Code, Codex, Copilot, Cursor), ist jetzt eine kanonische Datei, statt drei separat gepflegter Kopien. Eine kleine CI-Regel hält die Konsistenz mechanisch nach.

Zahlen zum Stand

  • Migrationen: zwei neue Produktions-Migrationen diese Woche (AddNotificationPreferences für die Per-User-Präferenz-Tabelle plus die Spalten-Erweiterungen, AddReaderPushCooldowns für die zwei Cooldown-Spalten auf Author). Auf jedem Aspire-Boot automatisch angewendet.
  • Seed-Inhalte: 16 Standard-Benachrichtigungs-Präferenz-Defaults pro neuem Account, die zwei neuen Newsletter-Subscription-Defaults für die @outastory.com-Stamm-Mannschaft, der gesäte Beispiel-Wettbewerb (jetzt durchgängig erreichbar) und ein paar zusätzliche Test-Herausforderungen für die diese Woche neuen Features.
  • Pull-Requests: 64 PRs in den 8 Tagen, viele davon kleine Reliability- und Sentry-Sachen. Highlights: #616 + #617 + #618 (Push-Palette mit 15 Ereignistypen), #599 + #596 (Benachrichtigungs-Zentrale und Pipeline-Logging), #619 (native Push-Berechtigung), #620 (Wettbewerbs-Push), #621 (Access-Token-Refresh-Pfad), #602 + #603 + #607 + #610 (Coverage-Welle), #588 + #560 (DataProtection-Keyring), elf weitere Sentry-Stille-PRs.
  • Unit-Tests: jetzt rund 3.380 grün (von 2.940 vor einer Woche, plus rund 440 neue Tests). Alle Tiers außer App.Shared sind über ihrer Schwelle.
  • Sentry-Lärm-Reduzierung: das Dashboard zeigt jetzt eine handlich kurze Liste echter Bugs, nach acht weiteren Filter-PRs in dieser Woche und sieben PRs mit Code-Fixes für die Bugs, die hinter dem Rauschen versteckt waren.

Was als nächstes

Die Push-Pipeline ist mit dieser Woche in der Breite fertig — die Form ist gesetzt, neue Ereignistypen sind kleine Drop-ins. Die nächsten Schwerpunkte sind:

  • App.Shared-Razor-Pages-Tests — der letzte rote Tier-Eintrag. Das wird über mehrere Wochen verteilt, weil jede Page eigene bUnit-Setups mit @inject-Mocks braucht.
  • Trusted-Signing-Freigabe — das Microsoft-KYC-Verfahren steht jetzt seit dreieinhalb Wochen offen. Sobald es durch ist, fallen die unsigniert-Banner auf /about/apps weg.
  • Meta-App-Review für Instagram — die instagram_content_publish-Berechtigung ist die letzte Sperre fürs tägliche Posten auf Instagram (Facebook und Bluesky laufen schon mit den vorhandenen Berechtigungen).
  • Beta-Tester-Onboarding-Welle — wir wollen vor der öffentlichen Eröffnung noch eine breitere, aber immer noch eingeladene Beta-Phase laufen lassen. Das Tester-Panel ist der Ankerpunkt; eine neue Welle Test-Herausforderungen (für jeden der diese Woche dazugekommenen Push-Typen plus die Profil-Push-Schalter plus den Refresh-Token-Pfad) ist diese Woche reingegangen.

Wenn dir auf einem der neuen Bildschirme etwas seltsam vorkommt — und besonders, wenn dir eine Push-Benachrichtigung erscheint, die du nicht eingeschaltet hast (oder umgekehrt: eine, die fehlt, obwohl die Voreinstellung „an" ist) — sag mir Bescheid. Die Schwellen für die Auslöser sind im Code festgesetzt, aber sie sind nicht in Stein gemeißelt; jede konkrete Beobachtung hilft, sie an die Realität der Test-Nutzer:innen anzupassen.


Comments (0)

No comments yet.

Comments are closed for this post.

Something went wrong

An unhandled error has occurred.

Reload the page to pick up where you left off.

Reload

Connection lost

Reconnecting to the server…

Reconnect failed. Trying again in seconds.

We couldn't reconnect to the server. Retry, or reload the page.

The session was paused by the server.

The session couldn't be resumed. Please reload the page.