Serein
Rust로 만든 가볍고 성능 좋은 Discord 전용 네이티브 클라이언트
아직 감지된 업데이트가 없습니다. GitHub 릴리스가 나오면 여기에 보입니다.
개인 계정의 공개 저장소 소유자는 GitHub로 로그인해 운영자를 확인하고 소개를 직접 관리할 수 있습니다. 관리자 승인은 필요하지 않습니다.
로그인한 뒤 확인 파일을 만들어 이 저장소의 기본 브랜치에 30분 안에 커밋해 주세요. 다른 브랜치나 포크의 파일은 인정하지 않습니다. 파일을 올린 뒤 아래에서 확인하면 관리자 승인 없이 관리할 수 있습니다. 자격을 유지하려면 파일을 남겨 두세요.
로그인이 필요하면 로그인 화면으로 이동합니다. 로그인 후 이곳으로 돌아와 원하는 확인 버튼을 다시 눌러 주세요.
프로젝트 폴더에서 /nomorevibe verify를 실행하면 소유권을 확인하고 소개를 직접 관리할 수 있습니다.
1분 만에 확인하기메이커가 쓴 소개는 위의 한 줄이 전부입니다. 더 자세한 내용은 저장소 README에서 읽을 수 있습니다. 저장소 README ↗
README 전체를 불러오는 중입니다.
README 전문 보기 ↗Eine kleine PWA für genau ein Paar: Sie erinnert euch daran, regelmäßig Dates zu planen, schlägt freie gemeinsame Zeiten aus euren Google-Kalendern vor und verwaltet eure gemeinsame Ideen-Wunschliste.
| Meilenstein | Inhalt | |
|---|---|---|
| M1 | Google-Login, Allowlist, Paar-Kopplung per Einladungscode | ✅ |
| M2 | Wunschliste mit Schnell-Erfassen, Filter, Bearbeiten | ✅ |
| M3 | Kalender verbinden, Kalenderauswahl, Slot-Finder, Slot-Einstellungen | ✅ |
| M4 | Rhythmus, Modi (abwechselnd/Vorschlag), Runden, Startseite, Cron-Tick, Dev-Uhr | ✅ |
| M5 | Date festlegen, Google-Termin mit Einladung, Passt!/Ändern/Absagen synchron | ✅ |
| M6 | Web Push, Erinnerungen, iPhone-Installationshilfe, App-Manifest | ✅ |
| M7 | Fehler-/Offline-Seiten, App-Kurzbefehle, Sicherheits-Härtung, Aufräum-Job, Doku | ✅ |
| V2 | ||
| M8 | Rückblick: „Wie war's?“-Erinnerung, Herzen + Notiz je Person, Seite „Unsere Dates“ | ✅ |
| M9 | Fotos zum Rückblick (Vercel Blob, privat) | ✅ |
| M10 | Kuratierte Köln-Ideen, „Überrasch uns“ | ✅ |
| M11 | KI-Vorschläge (Claude Haiku 5.5, Wetter von Open-Meteo) | ✅ |
| M12 | Event-Quellen: Lieblings-Seiten täglich lesen, „Was läuft“ mit Frei-Prüfung | ✅ |
| M13 | Verständlich & aufgeräumt: Einführung, Start-Checkliste, „Dein nächster Schritt“, ⓘ-Hilfen, Ideen beim Planen gebündelt, Schnell planen | ✅ |
| M14 | Tests + CI im Repo: End-to-End-Suites mit Mocks, GitHub Actions | ✅ |
| M15 | Link → Idee: TikTok/Instagram/YouTube/Webseite teilen oder einfügen, Claude macht eine Idee draus | ✅ |
| M16 | „Läuft alles?“: Status in den Einstellungen, Warnung bei stehender Automatik, Push bei getrenntem Kalender | ✅ |
| M17 | Entdecken: KI-Ideen-Stapel zum Swipen, Match nur wenn beide 💚, Rückgängig, Super-Like, Event-Kombis | ✅ |
| M18 | Budget & Spartopf: Monatsbudget, echte Kosten im Rückblick, Spartopf, Monatsrückblick | ✅ |
| M19 | Besondere Tage (Jahrestag, Geburtstage) mit Erinnerung, „Vielleicht“-Stapel, alte Ideen auffrischen | ✅ |
| M20 | Eure Date-Story: Jahresrückblick zum Durchtippen, Swipe-Statistik „Euer Ding“ | ✅ |
| M21 | Extras: Überraschungs-Date, Vorbereitungs-Checkliste, Route (ÖPNV), Gesprächskarten, iPhone-Teilen per Kurzbefehl | ✅ |
| M22 | Aufgeräumt: ein Filter für alles, 3 Ideen-Reiter, Planen ohne Reiter, Extras nach und nach, Kontrast im dunklen Modus | ✅ |
| M23 | Event-Quellen mit Unterseiten: KI sucht bis zu 6 Veranstaltungs-Seiten aus, ihr könnt welche entfernen oder hinzufügen | ✅ |
| M24 | Navigation in der Reihenfolge eines Dates: Ideen · Date planen (Mitte) · Dates – die Startseite ist darin aufgegangen | ✅ |
| M25 | Idee antippen → „Date damit planen“ oder „Statt … machen“ (nach Rückfrage, Termin bleibt); ein 💚 beim Swipen landet gleich auf der Wunschliste | ✅ |
| M26 | Ein Ablauf statt Reitern: der Start zeigt, was ansteht, darunter Idee → Zeit → fertig; Ideen, Dates und Einstellungen als Icons oben | ✅ |
| M27 | Ohne Scrollen: jeder Bildschirm passt, man klickt sich durch (Zeiten blättern, Einstellungs-Themen, Historie in Seiten, Reiter) | ✅ |
| M28 | Feinschliff: schneller Start (Zeiten erst bei Bedarf), Zeiten nach Datum, kompakte Rundenkarte, eine Ideen-Karte, einfachere Begriffe, gruppierte Einstellungen | ✅ |
| M29 | Design (Richtung A): Schatten nur für Aktionen, Farbrollen, Schritt-Punkte, Icons, Maskottchen, Fotoraster, ruhige Kopfzeile, Daumenzone | ✅ |
| M30 | Eine Wunschliste, wo sie passt: Schritt 1 = Wunschliste, „Solange swipen?“ auf dem Start, „Nächstes Mal?“ nach dem Bewerten, 💡 mit zwei Reitern | ✅ |
| M31 | Start = ein Kasten + Swipen: im Kasten „Date planen“, „Passt!“, der Vorschlag oder das Date; darunter die Swipe-Karte; „Date planen“ führt zur Wunschliste | ✅ |
| M32 | Kopfzeile mit 💡 ♥ ⚙️ rechts, die Seiten klappen von oben herunter (gleiches Icon = wieder hoch); Swipe-Karte mit Knöpfen auf der Karte, ohne Vielleicht/Super-Like | ✅ |
| M33 | Erinnerungen sind Pflicht: letzte Einführungskarte schaltet Push an, auf dem Start eine Zeile mit Ein-Tipp-Aktivierung, Hinweis wenn die andere Person keine bekommt, Hilfe bei verweigerter Erlaubnis | ✅ |
| M34 | Der Start zeigt, was ansteht: der Kasten bekommt den Platz, Swipen wird eine Zeile; „Wie war's?“ mit Foto und antippbaren Herzen | ✅ |
| M35 | Der Top-Tipp passt zur Idee: Tageslicht (Sonnenuntergang) und Wetter für Draußen-Ideen, Begründung an der Zeit, ideas.setting | ✅ |
| M36 | Swipen, das man sieht: Kategorien als Chips über der Wunschliste, Herkunft („von dir geswipt“), Fortschritt, Stapel, Flug zum 💡 mit wachsendem Zähler, ideas.category | ✅ |
| M37 | Untere Leiste: das Logo groß in der Mitte (startet „Date planen“), links 💡 Ideen, rechts ♥ Dates; oben nur Name und Einstellungen, die Seiten klappen nicht mehr herunter | ✅ |
| M38 | Der Start ist nie leer: steht etwas an, bekommt die Swipe-Karte trotzdem den Rest des Bildschirms; nur bei zu wenig Platz bleibt die eine Zeile | ✅ |
| M40 | Neues Design „Pastell & rund“ (docs/design/): Salbei, Bricolage + DM Mono, Leiste Beet · Ideen · Swipen · Archiv · Einstellungen; Start = euer Beet (Pflanze: ein Blatt pro Date, welk bei Ausfall; Gießkanne = wer dran ist; Anstupsen); Planen auf einem Screen („wann?“ / „was?“, „Gießen & an … senden“); Swipen mit Match-Vollbild; Ideen als Kacheln; Archiv „unser beet.“ mit welken Kacheln; „wie war’s?“ mit „Nochmal machen?“; setup. und „euer beet anlegen.“ | ✅ |
| App | Next.js 16 (App Router, Cache Components), React 19, TypeScript |
| Styling | Tailwind CSS 4, Design-Tokens in src/app/globals.css |
| Datenbank | Postgres (Neon), Drizzle ORM, Migrationen in drizzle/ |
| Hosting | Vercel (Region Frankfurt) |
| Auth | eigener Google-OAuth-Flow (PKCE) mit google-auth-library, DB-Sessions |
| Tests | Vitest (Logik), Playwright-End-to-End mit Mock-Servern (e2e/), GitHub Actions |
Handy (PWA) ──HTTPS──▶ Next.js auf Vercel ──▶ Neon Postgres
├─▶ Google OAuth / Calendar API
└─▶ Web Push
cron-job.org ── alle 5 Min ──▶ /api/cron/tick (Runden weiterschalten, Vorschläge, Erinnerungen)
src/
app/ Seiten (UI) und Route Handler (API)
(app)/ eingeloggter Bereich mit Navigation
login/, pair/ Login und Kopplung
api/auth/google/ OAuth-Start und -Callback
api/cron/tick/ Einstieg für cron-job.org
manifest.ts App-Manifest (Name, Icons, Kurzbefehle)
error.tsx, not-found.tsx, global-error.tsx freundliche Fehlerseiten
components/ UI-Bausteine (Button, Card, Avatar, Logo …)
core/ Kernlogik ohne I/O: Slots, Runden, Vorschläge, Erinnerungen (getestet)
lib/ kleine Hilfsfunktionen ohne Server-Zugriff (getestet)
server/ alles mit DB, Secrets oder Google
auth/ Sessions, Data Access Layer (requireUser/requireCouple), OAuth
db/ Drizzle-Client und Schema
google/ Token-Verwaltung (verschlüsselt, Refresh) und Calendar-API per fetch
availability.ts FreeBusy beider Kalender → Slot-Finder
cycles.ts Runden-Engine (anlegen, abschließen, Status)
suggestions.ts Vorschlag-Modus
idea-sources/ austauschbare Ideen-Quellen (V1: Wunschliste)
dates.ts Dates planen/ändern/bestätigen/absagen + Abgleich mit Google
push.ts Push-Abos, Einstellungen, Versand (web-push)
notifications.ts Texte und Versand der Erinnerungen
tick.ts der periodische Job hinter /api/cron/tick (inkl. Aufräumen)
proxy.ts schickt Besucher ohne Session-Cookie zur Login-Seite
public/sw.js Service Worker: Push anzeigen, Offline-Seite (public/offline.html)
drizzle/ SQL-Migrationen (generiert)
scripts/ Migration beim Build, Secret-Generator
Muster, die sich durchziehen
requireUser()/requireCouple() aus src/server/auth/dal.ts. proxy.ts ist nur eine
schnelle Vorabprüfung.<Suspense>-Grenze innerhalb der Seite. Layouts bleiben statisch.text-Spalten mit TypeScript-Typen (src/lib/domain.ts).
Neue Werte brauchen daher keine Migration.AutoRefresh (src/components/auto-refresh.tsx) lädt
die Seite neu, wenn die App wieder in den Vordergrund kommt, und alle 15 s, solange sie
sichtbar ist. Server Actions rufen nach Änderungen refresh() auf.<dialog> (src/components/sheet.tsx):
Fokus, Escape und Hintergrund-Klick macht der Browser.src/core: Slots, Zyklen, Erinnerungen) liegt als reine
Funktionen vor und ist mit Tests abgedeckt, inklusive Zeitumstellung, Terminen über
Mitternacht, ganztägigen Terminen und vollen Kalendern.await connection(), damit Google nur bei
echten Seitenaufrufen gefragt wird.src/core/cycles.ts).ENABLE_DEV_TOOLS=1 erscheint in den Einstellungen „Dev-Uhr“: Zeit um
Tage oder Wochen vorstellen, damit lassen sich mehrere Runden in Minuten durchspielen.sendUpdates=all, also mit Google-Einladung).
Kurz vor dem Speichern prüft die App per FreeBusy noch einmal, ob der Slot frei ist.syncDate() bringt das Google-Event immer auf den Stand der Datenbank (anlegen,
ändern oder löschen). Die Event-ID wird aus der Date-ID abgeleitet, deshalb kann ein
Wiederholungsversuch nie einen doppelten Termin erzeugen. Fehlgeschlagene Syncs versucht der
Cron-Tick erneut (bis zu 10-mal); bis dahin zeigt die Startseite einen Hinweis.web-push), eigener Service Worker public/sw.js. Jede Person kann
mehrere Geräte anmelden; tote Abos (404/410) räumt der Server beim Senden selbst weg.src/core/reminders.ts berechnet rein aus Runde, Date und Uhrzeit, was fällig ist.
Ruhezeit 22–8 Uhr (außer „Date in 2 Stunden“); was mehr als 2 Std. zu spät wäre, entfällt.notification_log eingetragen
(eindeutiger Schlüssel). Nur wenn das klappt, geht sie raus – parallele Ticks senden nie doppelt.after()), unabhängig von der Ruhezeit,
weil die andere Person ja gerade selbst aktiv ist.date_reviews, eine Zeile pro Person und Date, später änderbar).visibleReviews() in src/core/reviews.ts)./dates): alle Dates mit Herzen, Zahlen (Anzahl, Ø Herzen, dieses Jahr)
und Lieblingsdate. Die Einstellungen sind dafür aus der unteren Leiste in den Kopf der
Startseite gewandert (Regler-Symbol)./welcome): fünf Karten nach dem Koppeln (die letzte schaltet die Erinnerungen an, M33) (Rhythmus/Modus und Namen
eingesetzt), danach nie wieder automatisch – users.onboarded_at. In den Einstellungen
unter „Hilfe“ jederzeit erneut.nextStep() in
src/core/next-step.ts, mit Tests): Kalender verbinden → „Passt!“ geben → planen →
bewerten → sonst „Stand der Dinge“. Seit M31 steckt das nächste Date mit im Kasten, darunter
kommen die Erinnerungs-Zeile (M33), eine Start-Checkliste (Kalender, Home-Bildschirm;
verschwindet, wenn alles erledigt ist) und das Swipen (groß oder eine Zeile, M34).<Info term="…"> mit Texten aus src/lib/glossary.ts, über die Popover-API
des Browsers (kein eigenes JavaScript).usePush() in
src/components/use-push.ts, dieselbe Logik wie in den Einstellungen); auf dem iPhone in Safari
steht dort die Anleitung für den Home-Bildschirm. „Später“ ist klein, der große Knopf kommt mit Push.pushRow() in
src/core/push-nudge.ts.deniedHelp()).deckSize() in src/core/start-layout.ts entscheidet pro Zustand: Steht etwas an (Passt! geben
oder abwarten, ein Date in dieser Runde, ein Date zu bewerten, ein offener Vorschlag, ein Kalender
zu verbinden), steht der Kasten oben. Seit M38 bekommt die Swipe-Karte darunter den Rest des
Bildschirms (CSS-Container-Query .deck-fit in globals.css); nur wenn weniger als 15 rem übrig
sind, bleibt eine Zeile („6 neue Ideen zum Swipen ›“). Wenn nichts ansteht, ist die Karte groß./dates/<id>?herzen=n). Steht ein anderer Schritt oben, bekommt die
Bewertung eine eigene Karte.ideas.setting (drinnen / draußen, leer = egal).src/core/categories.ts): Essen & Trinken, Natur, Kultur, Aktiv, Kreativ, Gemütlich,
Ausflug, Events, Sonstiges. ideas.category ist optional; fehlt sie, schließt categoryOf() aus
Titel und Notiz (Stichwörter mit Wortgrenzen). Swipe-Karten übernehmen ihr Thema, im Idee-Formular
gibt es „Wo?“ und „Was für ein Date?“ in den Details.IdeaFilter.category) und gilt damit auch in der
Planungsliste; Matches und Geswipt sind nur Ansichten der Liste (/ideas?ansicht=geswipt|match).prefers-reduced-motion schaltet es ab); dahinter liegen zwei weitere Karten als Stapel; die
Swipe-Seite zeigt „3 geswipt · 2 💚 · 5 übrig“. Der Zähler am 💡 ist die Zahl der Wunschlisten-Ideen
(Server) plus das, was seit dem letzten Laden geliked wurde.SwipeDeck compact) – 💚 legt
die Idee auf die Wunschliste, beide 💚 = Match. Das Badge „x ›“ öffnet den ganzen Stapel mit
Filter. Werden die Karten knapp, mischt der Start im Hintergrund neue. Seit M34 gilt das nur,
wenn nichts ansteht – sonst ist Swipen eine Zeile (siehe unten)./plan?pick=1) zeigt die Wunschliste als eigenen Schritt mit
„← Zurück“, danach die Zeit. Unten nur noch „Zufall“ und „Was anderes“.NextStepCard note).flat, gibt beim Tippen leicht nach).components/icons.tsx): Zufall, Was anderes, Swipen, Ort,
Datum, Einstellungs-Kacheln, Ideen-Menü. Emoji bleiben beim Inhalt.DateCard): Ort mit Route, Notiz, Vorbereitung
(zugeklappt), am Tag die Gesprächskarten, „Zeit ändern“ und „Absagen“ – ohne Umweg über eine
eigene Seite. /dates/[id] zeigt kommende Dates weiterhin (für Push-Links).freeTimesAction in plan/actions.ts), und zwar im Hintergrund, frisch beim Öffnen von
Schritt 2, wenn sie älter als eine Minute sind. Das Neuladen alle 15 Sekunden kostet keine
Google-Abfrage mehr.orderSlots() in src/core/slots.ts).components/idea-card.tsx) für Wunschliste und Planen, mit 💞 Match,
wer sie eingetragen hat und ⚠️ über Budget./plan, /dates/…), ohne Umleitung.main[data-screen] ist so hoch wie das Display).
Gescrollt wird nur in Listen: Wunschliste, „Was läuft“, Listen in Sheets und lange
Einstellungs-Themen (Event-Quellen, Freie Zeiten). Passt es auf einem kleinen Handy doch nicht,
scrollt die Seite als Notlösung; die e2e-Tests prüfen das mit fitsScreen() (390×844)./dates/[id]).
„Startklar“ ist eine Zeile, „›“ zeigt den nächsten Punkt (Erinnerungen haben seit M33 eine eigene Zeile)./settings?t=budget,
alte #budget-Links werden umgeleitet).BottomNav im Layout von (app). Links 💡 Ideen
(die Wunschliste; mit Zähler; Swipen liegt auf dem Start), in der Mitte das große Logo: auf anderen Seiten
zurück zum Start, auf dem Start selbst klappt es drei runde Aktionen auf (Date planen · Idee ·
Was läuft; LogoButton in bottom-nav.tsx, das Idee-Formular öffnet per Fenster-Event), rechts ♥ Dates (Kommendes und Historie). Oben stehen nur der Name (führt zum
Start), ⚙️ Einstellungen und – wo es passt – „+ Idee“. Die Seiten öffnen normal; das Herunterklappen
von M32 gibt es nicht mehr.StartSection in src/app/(app)/overview.tsx): das nächste Date
mit „Passt!“, „Wie war's?“, der Stand der Runde, die Start-Checkliste.PlanFlow in plan/plan-form.tsx):
/plan?reschedule=…)./dates) ist nur noch die Historie: Runden, bisherige Dates, Statistik, Story.swapDateIdea() in src/server/dates.ts). Ein „Passt!“
bleibt bestehen, der Google-Termin wird angepasst, und der/die andere bekommt einen Push.src/core/filters.ts, mit Tests; Knopf + Sheet in
src/components/idea-filter.tsx): drei Fragen – Wie lang? Was darf es kosten (Gratis / bis
20 €)? Drinnen oder draußen? Gilt gleichzeitig für Wunschliste, Swipen, „Was läuft“ und die
Ideen beim Planen und bleibt pro Gerät gespeichert (localStorage). Unbekanntes passt immer
(ein Event hat keine Dauer), außer bei „Gratis“. „Mehr … holen“ im Stapel bittet Claude um
passende Karten.curatedForDeck()); /ideas/koeln leitet zum Stapel um. Die KI-Ideen mit
eigenen Chips (Budget, Stimmung, Ort) sind weg – die KI steckt im Stapel und beim Planen.couple_settings.hidden_extras)./settings#budget klappen den passenden Bereich auf (fold.tsx).bg-zitrone-soft, bg-himbeere …) bleiben im dunklen Modus hell
– in globals.css bekommen sie dort die hellen Farb-Tokens, damit Schrift darauf dunkel
bleibt. m13 misst das Kontrastverhältnis der Einführung im dunklen Modus (vorher 1,1 : 1).src/server/deck.ts, Prompt und
Prüfung in src/core/deck.ts, mit Tests). Sinkt der Stapel bei einem von euch unter 5 Karten,
kommt im Hintergrund die nächste Ladung (beim Öffnen der Seite, nach einem Swipe oder im
Cron-Tick) – höchstens 3 automatisch pro Tag, insgesamt 6 Ladungen pro Tag und Paar.source = 'deck', mit deinem Avatar); die Karte bleibt im Stapel des anderen. Gibt der/die
andere auch 💚: „Match! 💞“ mit Konfetti, Push an beide, „📅 Jetzt planen“ und auf der
Wunschliste das Abzeichen „💞 Match“. Ein ✕ zuerst nimmt die Karte für beide aus dem Stapel.
Rückgängig nimmt dein 💚 wieder von der Liste, solange es das einzige war (außer die Idee ist
schon als Date geplant); ein Match zurücknehmen lässt das 💚 des anderen stehen.dateForViewer() / maskSurprise() (src/core/date-extras.ts)./fragen.POST /api/share schickt; die Idee landet sofort
auf der Wunschliste (Link → Idee mit KI) und der Kurzbefehl zeigt „💡 Auf eurer Wunschliste:
…“. Gespeichert wird nur ein Hash des Schlüssels; neu erstellen oder löschen macht ihn ungültig./story (Link auf „Unsere Dates“, ab 27. Dezember bis Ende Januar auch auf der
Startseite, dazu ein Push „Eure Date-Story ist fertig 📖“): das Jahr zum Durchtippen –
Anzahl Dates und Stunden, Lieblingsdate (mit Foto), Ø Herzen, Date-Monat und Lieblingstag,
kurz vs. lang, Ausgaben und Spartopf, „Euer Ding“ beim Swipen (Lieblingsthemen, Matches, wer
öfter nach rechts wischt), Fotos, Ausblick. Rechts tippen = weiter, links = zurück, Pfeiltasten
gehen auch. Frühere Jahre oben auswählbar.src/core/story.ts (mit Tests). Herzen des Partners zählen erst, wenn beide
bewertet haben – wie überall in der App.src/core/special-days.ts (mit Tests, auch 29. Februar).src/core/budget.ts, mit Tests): Jedes Date bekommt beim Planen eine
Schätzung (Kosten der Idee, bei Events der Preis × 2), im Rückblick tragt ihr den echten
Betrag ein (vorausgefüllt). Abgesagte Dates zählen nicht./budget.budget_months) wandert der Rest in den Topf;
ein überzogener Monat nimmt sich etwas daraus, nie unter 0. Beim Planen: „🎁 Aus dem
Spartopf“ – das Date belastet dann nicht das Monatsbudget. Dazu ein Push mit dem
Monatsrückblick an beide (abschaltbar).src/core und src/lib: npm test.e2e/m*.mjs): echte Produktions-Build, echter Browser
(Playwright/Chromium), echte Postgres – Google, Claude, Wetter, Push und fremde Webseiten
sind kleine Mock-Server auf localhost:4010–4060. e2e/run.mjs startet den Server mit
Test-Umgebung, erzeugt Zertifikat und Schlüssel und lässt die Suites nacheinander laufen.
Die Suites leeren die Datenbank – nur gegen eine Wegwerf-DB laufen lassen:
# einmal in .env.local: E2E_DATABASE_URL=postgres://…/dateomat_test
npm run e2e # alle
npm run e2e -- m5 m14 # einzelne
Der Runner prüft, ob Postgres erreichbar ist, und baut neu, wenn sich seit dem letzten Build
etwas in src/, drizzle/ oder public/ geändert hat (--no-build überspringt das).
npm run check = Lint, Typecheck und Unit-Tests.
Screenshots landen in e2e/shots/. Suites, die vom Wochentag abhängen (Frist, freie Zeiten
diese Woche), stellen die Dev-Uhr auf Dienstag 10 Uhr der laufenden Woche (e2e/clock.mjs)..github/workflows/ci.yml): bei jedem Push und Pull Request Lint, Typecheck,
Unit-Tests; danach Build + alle E2E-Suites gegen einen Postgres-Service. Screenshots gibt's
als Artefakt.share_target → /share).src/server/link-ideas.ts): TikTok und YouTube über ihre öffentliche oEmbed-
Schnittstelle (Bildunterschrift, Kanal, Vorschaubild), alles andere über OpenGraph-Tags plus
etwas Seitentext. Kurzlinks (vm.tiktok.com …) werden erst aufgelöst. Abgerufen wird mit
denselben Schutzregeln wie die Event-Quellen (nur öffentliche Adressen, klein, schnell).LINK_SYSTEM_PROMPT in
src/core/link-idea.ts, mit Tests). Das Video selbst wird nicht angesehen – nur, was der
Post über sich verrät. Der Inhalt gilt als Daten, nicht als Anweisung.app_status), beide Kalender, Push-Geräte je Person, Event-Quellen, KI-Verbrauch heute,
Fotos. Eingeklappt mit „✅ Alles läuft“, sonst offen mit dem, was zu tun ist
(statusRows() in src/core/system-status.ts, mit Tests).src/server/ai-ideas.ts, Prompt und Prüfung in
src/core/ai-ideas.ts, mit Tests).src/server/weather.ts); ohne Termin oder bei Fehlern heißt es „unbekannt“.ai_requests.source = 'ai' auf der Wunschliste.src/core/curated-ideas.ts (feste IDs, grobe Kosten
für zwei, drinnen/draußen). Seit M22 kommen sie als 📍-Karten in den Swipe-Stapel und stehen
beim Planen unter „Was anderes …“. „Nehmen“ legt eine Idee mit source = 'curated' und
external_ref = <id> an. Neue Ideen: einfach einen Eintrag in der Datei ergänzen.pickSurprise() in
src/core/surprise.ts, mit Tests). „Nehmen“ wählt die Idee; eine Köln-Idee kommt dafür
vorher auf die Wunschliste.src/lib/resize-image.ts): 1600 px
plus 480-px-Vorschau als JPEG. Dabei fallen EXIF-Daten wie der GPS-Ort weg.sniffImageType()), nicht den Dateinamen, und legt die
Dateien in einem privaten Vercel Blob Store ab, unter zufälligen Namen pro Paar./api/photos/[id] – mit Login- und Paar-Prüfung; der Browser darf
die Bilder privat cachen. Antippen öffnet die Großansicht mit Blättern und Löschen.ENABLE_DEV_TOOLS=1 ohne Blob-Token) landen Fotos in .data/photos
(src/server/photos/store.ts).src/core/source-pages.ts, mit Tests): Beim ersten Lesen bekommt Claude
einmal die Links der Startseite und sucht bis zu 6 Seiten mit vielen Events/Tipps aus
(Kategorien, „Wochenende“, „Alle Events“) – nur Links von derselben Website, sonst wird
verworfen. Sie stehen in event_source_pages und in den Einstellungen aufklappbar unter der
Quelle, mit Einträgen pro Unterseite; ihr könnt sie entfernen oder eigene derselben Website
hinzufügen (zusammen höchstens 10). Bestehende Quellen bekommen ihre Unterseiten beim
nächsten Lesen (event_sources.pages_picked_at).src/server/events/): Der Server holt die Seite selbst (fetch-page.ts), ihre
Seite 2 (followUpLinks(): rel="next", ?page=2 …) und alle Unterseiten. Stehen über alle
Seiten mindestens 25 Events als schema.org-JSON-LD drin, werden nur die genommen – exakt und ohne KI.
Sonst wird die Seite zu Text mit [Link](url) gekürzt (htmlToText) und Claude Haiku 5.5
füllt per Structured Output eine feste Liste (Titel, Zeit, Ort, Preis, Link, Kurztext) – alle
Seiten einer Quelle in einer Anfrage, bis zu 100 Einträge, jeder Tipp eines Listen-Artikels
einzeln.cleanItems() in src/core/events.ts, mit Tests): Vergangenes und mehr als
60 Tage Entferntes fliegt raus, Links zu fremden Domains werden verworfen, Doppelte entfernt.event_sources.content_hash), bleiben die Einträge und Claude wird nicht gefragt;
„Jetzt lesen“ fragt immer und geht jederzeit. Gelesen wird nach der Antwort an cron-job.org
(after())./events, Tab neben der Wunschliste): Events der nächsten drei Wochen nach
Tagen, mit „✓ Ihr habt beide Zeit“ (eine FreeBusy-Abfrage pro Person, inkl. Puffer).
„Als Date planen“ legt das Date direkt zur Event-Zeit an (mit Google-Einladung),
„Merken“ setzt es auf die Wunschliste (ideas.source = 'web').src/core/feeds.ts, mit Tests): Blockt eine Seite automatische
Abrufe (z. B. HTTP 403), ist sie fast leer (JavaScript) oder ist sie eine Tipps-/Magazinseite,
liest der Server ihren RSS-/Atom-Feed – aus dem Seitenkopf verlinkt oder WordPress-typisch
unter (die 20 neuesten Artikel). Feeds enthalten oft die ganzen Artikel, also mehr
Tipps als die Startseite.
Findet eine als „Kalender“ eingetragene Seite nichts, liest die App sie noch einmal als
Tipps-Seite und stellt sie um, wenn das etwas bringt.scoreSlot): klein → Abende und Wochenend-Nachmittage,
groß → Wochenende und viel Zeit am Stück; früher ist leicht besser, etwas Luft drumherum auch.src/core/outdoor.ts): starker Abzug, wenn der
Slot nach Sonnenuntergang liegt (src/core/sun.ts, selbst berechnet für Köln), Bonus für
Wochenend-Nachmittage, Abzug bei Regen ab 60 % oder unter 8 °C (stündliche Vorhersage von
Open-Meteo, eine Abfrage pro Suche; ohne Vorhersage neutral). Ausnahme: Ideen mit Stichwörtern wie
Sterne, Sonnenuntergang, Lichter (darknessWanted()) verlieren nichts für die Dunkelheit.
Die Zeile an der Zeit sagt es: „☀️ 14°, trocken · hell bis 18:20“. Welche Idee Draußen ist,
steht in ideas.setting (Formular: „Wo?“; Swipe-Karten, Köln-Ideen und KI-Ideen bringen es mit).| Tabelle | Zweck |
|---|---|
couples, users, sessions | Paar, zwei Personen, Login-Sitzungen (nur Token-Hash) |
google_connections | verschlüsselte Google-Tokens, gewählte Kalender |
couple_settings | Rhythmus, Modus, Zeitfenster, Puffer … |
ideas | Wunschliste |
cycles, date_plans, suggestions | Runden, geplante Dates, App-Vorschläge |
date_reviews | Herzen + Notiz je Person und Date |
date_photos | Fotos je Date (nur Pfade; die Dateien liegen im privaten Blob Store) |
ai_requests | jeder Claude-Aufruf mit Tokenzahlen (Tageslimit, Kostenüberblick) |
event_sources, events | gefolgte Seiten (mit Abruf-Status) und was dort gefunden wurde |
push_subscriptions, notification_prefs, notification_log | Push |
ideas.source/external_ref: Herkunft einer Idee (curated = Köln-Idee, web = gemerkt aus „Was läuft“, ai = KI-Vorschlag).
npm install
cp .env.example .env.local # ausfüllen, siehe docs/SETUP.md
npm run db:migrate
npm run dev
| Script | |
|---|---|
npm run dev | Entwicklungsserver auf http://localhost:3000 |
npm test | Unit-Tests (Vitest) |
npm run lint / npm run typecheck | ESLint / TypeScript |
npm run db:generate | Migration aus geändertem schema.ts erzeugen |
npm run db:migrate | Migrationen anwenden (läuft auch automatisch vor jedem Build) |
npm run gen:keys | neue Secrets erzeugen |
Schema ändern: src/server/db/schema.ts anpassen → npm run db:generate → die erzeugte
SQL-Datei committen. Vercel wendet sie beim nächsten Deploy an.
Bewusst nicht in V1, aber vorbereitet:
| Idee | So würde man sie andocken |
|---|---|
| Neue Erinnerung | Typ in NOTIFICATION_TYPES (src/lib/domain.ts) ergänzen, Zeitpunkt in plannedReminders() (src/core/reminders.ts, mit Test), Text in reminderText() (src/server/notifications.ts), Beschriftung in push-settings.tsx. Keine Migration nötig. |
| Weitere Person / Gruppen | Bewusst nicht vorgesehen: Runden, Bestätigen und Push gehen überall von genau zwei Personen aus. |
KI: An Claude gehen nur Termin, Wetter, Auswahl und Titel (Wunschliste, vergangene Dates mit Ø Herzen) – keine Namen, Mails, Notizen oder Kalenderdaten. 20 Runden pro Tag.
Fotos: privater Blob Store, Auslieferung nur nach Login- und Paar-Prüfung, eigene strenge CSP für die Bild-Route, Dateityp anhand der Bytes geprüft, EXIF wird entfernt.
Event-Quellen: Der Server ruft nur öffentliche https-Adressen ab (keine IPs, keine
internen Namen). Die DNS-Prüfung passiert beim Verbindungsaufbau selbst und die Verbindung
nutzt genau die geprüfte Adresse (kein DNS-Rebinding); jede Weiterleitung wird neu geprüft
(src/server/events/fetch-page.ts, src/lib/url-safety.ts). Seiten: höchstens 3 MB, 15 s;
ausgewertet wird nur das erste MB, mit linearen Durchläufen statt anfälliger Regex.
Seiteninhalt gilt als fremd: Claude füllt nur ein festes JSON-Schema, alles wird danach
nochmal geprüft, Links müssen zur Quelle gehören. An Claude geht nur der Seitentext.
Kostenbremse: Jeder Claude-Aufruf wird vorher gebucht (mit Sperre pro Paar, zählt auch bei Fehlern): höchstens 20 KI-Vorschlags-Runden und 40 Seiten-Auswertungen pro Tag; „Jetzt lesen“ höchstens alle 10 Minuten pro Quelle.
Verdeckte Bewertungen fließen weder in „Ø Herzen“/„Lieblingsdate“ noch in die KI-Anfrage
ein, solange ihr selbst noch nicht bewertet habt (visibleRatings()).
Test-Schalter (ALLOW_PRIVATE_EVENT_URLS, *_BASE_URL, lokaler Foto-Ordner) werden auf
der Vercel-Produktion ignoriert, auch wenn sie versehentlich gesetzt sind.
Login nur für Adressen in ALLOWED_EMAILS. Danach gilt die Kopplung per Einladungscode;
sind zwei Personen drin, wird der Code gelöscht.
Sessions: zufälliges Token im httpOnly-Cookie, in der DB nur dessen SHA-256-Hash.
Google-Tokens werden AES-256-GCM-verschlüsselt gespeichert (TOKEN_ENCRYPTION_KEY).
Aus den Kalendern liest die App nur Belegt-Zeiten (FreeBusy) und speichert keine Termindetails.
Alle Secrets liegen nur in Umgebungsvariablen (.env.example als Vorlage).
/api/cron/tick verlangt Authorization: Bearer <CRON_SECRET> (zeitkonstanter Vergleich).
Wer aus ALLOWED_EMAILS entfernt wird, ist sofort abgemeldet (Prüfung bei jedem Seitenaufruf).
/feed/ANTHROPIC_API_KEY werden nur JSON-LD-Seiten gelesen; die Einstellungen sagen das.Fehler von Google-OAuth werden nur gekürzt geloggt (Fehlercode + HTTP-Status), weil die
Original-Fehler Refresh-Tokens und Auth-Codes enthalten (src/lib/safe-error.ts).
Push-Abos werden nur für die Push-Dienste der Browser angenommen (Google, Apple, Mozilla, Microsoft), damit der Server keine beliebigen Adressen anfragt.
Sicherheits-Header (next.config.ts): kein Einbetten in fremde Seiten, nosniff,
sparsamer Referrer. Abgelaufene Sessions löscht der Cron-Tick.