mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:01:09 +02:00
feat(lasts): M1-M7 — module ship + Meilensteine-Aggregator
Mirror sibling to firsts: das *letzte* Mal, das du etwas getan hast — markiert oder rückwirkend erkannt. Plan: docs/plans/lasts-module.md. M1 Skelett — Dexie v51 lasts-Tabelle, Encryption-Registry, Per-Space- Welcome-Seed, Empty-State ListView. Kategorien aus firsts/types.ts nach \$lib/data/milestones/categories.ts extrahiert (Re-Exports halten firsts-API stabil). M2 CRUD + DetailView — StatusTabs (Vermutet/Bestätigt/Aufgehoben), Quick-Add mit Mode-Toggle, always-editable DetailView mit Lifecycle- Buttons (Bestätigen, Aufheben mit Inline-Note), 44 i18n-Keys × 5 Locales. M3 Inbox + Inferenz — Dexie v52 lastsCooldown (12-Monate-Cooldown, deterministische ID), Source-Registry-Pattern in inference/, places- Source mit Heuristik visitCount>=5 Span>=180d Silence>=365d. InboxView mit Akzeptieren/Verwerfen + manueller Scan. contacts/habits → M3.b sobald jeweilige Frequenz-Felder existieren. M4 AI-Tools — 5 Tools im AI_TOOL_CATALOG (create_last, confirm_last, reclaim_last, list_lasts, suggest_lasts), Webapp-Executor mit Vault- Locked-Handling. Server-Drift-Test 4/4, Schema-Test 6/6. M5 Reminders + Settings — Pivot zu In-App-DueBanner statt OS-Push (kein PWA-Push-System im Repo). Pure date-math (12 Vitest cases), Settings- Store mit 4 Toggles, DueBanner mit max-N rendering, Test-Banner-Knopf. M6 Visibility + Unlisted-Sharing — VisibilityPicker + SharedLinkControls in DetailView, buildLastBlob mit reflective-core whitelist (reclaimed Lasts gehärtet ausgeblockt), SharedLastView public-render, Share- Dispatcher kennt 'lasts'. M7 Meilensteine-Aggregator — Cross-modul firsts vereinigt mit lasts Timeline + Year-Recap. Pure aggregator (mergeMilestones, buildMilestonesRecap), 12 Vitest cases. /milestones und /milestones/recap/[year] Routes, Cross-Link in lasts/ListView. Validation: 0 errors / 0 warnings (svelte-check 7645 files), 24/24 tests, i18n-parity 39x5 aligned (+2 namespaces), i18n-keys baseline- equal, crypto 211 tables. LOCAL TIER PATCH: lasts ist 'guest' für Testing — vor Release auf 'beta' setzen (packages/shared-branding/src/mana-apps.ts). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ad5e04a554
commit
bf3bca268a
53 changed files with 6572 additions and 40 deletions
519
docs/plans/lasts-module.md
Normal file
519
docs/plans/lasts-module.md
Normal file
|
|
@ -0,0 +1,519 @@
|
|||
# Lasts — Module Plan
|
||||
|
||||
## Status (2026-04-26)
|
||||
|
||||
**M1 Skelett: DONE** — `lasts/`-Modul registriert, Dexie v51, Encryption-Registry, Per-Space-Welcome-Seed, Route `/lasts` mountet mit Empty-State, Refactor `firsts/types.ts` extrahiert Categories nach `data/milestones/categories.ts` ohne API-Bruch.
|
||||
|
||||
**M2 CRUD + DetailView: DONE** — ListView mit StatusTabs (Alle | Vermutet | Bestätigt | Aufgehoben), Quick-Add (Suspected/Confirmed-Toggle, Enter erstellt + öffnet Detail), Context-Menu, Search ab > 5 Einträgen. DetailView (`views/DetailView.svelte` + Route `/lasts/entry/[id]`) mit always-editable Feldern, Autosave on blur/change, Lifecycle-Buttons (Bestätigen, Aufheben mit Inline-Note), Delete + Auto-Back. 44 i18n-Keys × 5 Locales.
|
||||
|
||||
**M3 Inbox + Inferenz: DONE** — Dexie v52 mit `lastsCooldown`-Tabelle (deterministische ID `${refTable}:${refId}`, 12-Monate-Cooldown). Inferenz-Engine (`inference/scan.ts` + `inference/sources/places.ts`) als Source-Registry-Pattern. Erste Quelle: Places — Heuristik `visitCount ≥ 5 ∧ Span ≥ 180d ∧ Silence ≥ 365d`. Orchestrator dedupliziert gegen existierende Lasts + Cooldown-Liste. `suggestLasts()`-Store-Methode triggert Scan + schreibt Survivors als `suspected` mit `inferredFrom`. InboxView (`views/InboxView.svelte` + Route `/lasts/inbox`) mit "Jetzt scannen"-Button + Akzeptieren (löscht inferredFrom → bleibt suspected im Hauptfeed) / Verwerfen (delete + cooldown). ListView trägt Inbox-Link + Live-Count rechts in der Tab-Bar.
|
||||
|
||||
**Deferred zu M3.b**: `contacts`-Source braucht `lastInteractionAt`-Feld auf Contact-Records (existiert nicht); `habits`-Source braucht direkten Timestamp im HabitLog (aktuell via `timeBlockId`-Join). Beide nachziehen sobald jeweilige Felder existieren oder via separater Aggregation. Tabu-Liste (kein Auto-Suggest für `relationship: family|partner` in contacts, no refs zu period/dreams/losses/regret) wird erst beim Hinzufügen der jeweiligen Source aktiv — Hooks sind im Orchestrator vorbereitet (kann pro Source-Scanner früh ausgefiltert werden, bevor Kandidat zurückkehrt).
|
||||
|
||||
**M4 AI-Tools: DONE** — 5 Tools im `AI_TOOL_CATALOG` (`@mana/shared-ai/src/tools/schemas.ts`):
|
||||
- `create_last` (propose) — neuer Last suspected | confirmed
|
||||
- `confirm_last` (propose) — suspected → confirmed mit Reflexion (date, meaning, whatIKnewThen, whatIKnowNow, tenderness 1-5, wouldReclaim no/maybe/yes)
|
||||
- `reclaim_last` (propose) — confirmed → reclaimed mit optionaler Note
|
||||
- `list_lasts` (auto) — gefiltert nach status + category, max 100
|
||||
- `suggest_lasts` (auto) — triggert Inferenz-Engine, schreibt Survivors als suspected mit inferredFrom in Inbox
|
||||
|
||||
Webapp-Implementierungen in `lasts/tools.ts` (Vault-locked-Handling für `list_lasts`, Validierung von Enums + Range-Checks). Registriert in `data/tools/init.ts`. Server-side Planner-Drift-Test (`services/mana-ai/src/planner/tools.test.ts`) bestätigt Konsistenz: 4/4 grün. Shared-AI Schema-Tests: 6/6 grün.
|
||||
|
||||
**Nicht in M4**: `<AiProposalInbox module="lasts" />` Wiring in ListView entfällt — die Komponente existiert nicht im Repo (root `apps/mana/CLAUDE.md` beschreibt sie als "wired in /todo, /calendar, /places, /drink, /food, /news, /notes" aber `find apps/mana -iname "*proposal*"` liefert null). Aspirational-Doc-Drift, nicht meine M4-Lieferung. Sobald die Komponente existiert, ist `<AiProposalInbox module="lasts" />` ein Einzeiler in `lasts/ListView.svelte` zwischen Tab-Bar und Quick-Add.
|
||||
|
||||
**M5 Reminders + Settings: DONE** (Pivot zu In-App-Banner statt OS-Push) — kein PWA-Push-System existiert im Repo (`mana-notify` ist server-side für Email/Web-Push, kein Service-Worker-Push-Subscription, keine `Notification.requestPermission()` Aufrufe in der webapp). Pragmatischer Pfad analog zum **augur `DueBanner`-Pattern**: in-app surfacing der heutigen Lasts beim Öffnen von `/lasts`, opt-in-toggelbar in den Settings.
|
||||
|
||||
Lieferung:
|
||||
- **Pure Date-Math** (`lib/reminders.ts`): `isSameDayOfYear`, `yearsBetween`, `findAnniversaryLasts` (confirmed lasts mit `date` heute vor X Jahren), `findRecognitionAnniversaryLasts` (any status mit `recognisedAt` heute vor X Jahren). 12 Vitest-Cases, alle grün.
|
||||
- **Settings-Store** (`stores/settings.svelte.ts`) via `createAppSettingsStore('lasts-settings', …)`: 4 persistent localStorage-Flags — `anniversaryReminders`, `recognitionReminders`, `inboxNotify`, `bannerMaxItems` (Default 3). Modul-Pattern analog `todoSettings`/`broadcastSettings`.
|
||||
- **DueBanner-Component** (`components/DueBanner.svelte`): rendert max-N Zeilen — Anniversaries → Recognition-Anniversaries → Inbox-Notify in dieser Priorität, deduplicated wenn Anniversary + Recognition denselben Last treffen. Klick → `/lasts/entry/[id]` oder `/lasts/inbox`.
|
||||
- **SettingsView + Route** (`views/SettingsView.svelte` + `/lasts/settings`): 3 Toggles + Slider für `bannerMaxItems` + "Zurücksetzen" + "Test-Banner zeigen" (rendert 4 Sek Beispiel) + Footnote zur fehlenden OS-Push-Infrastruktur.
|
||||
- **ListView-Wiring**: `<DueBanner {lasts} {inboxCount} />` ganz oben, `⚙`-Settings-Link in der Tab-Bar.
|
||||
- **i18n**: 22 neue Keys × 5 Locales (banner.* + settings.*).
|
||||
|
||||
**Deferred zu M5.b — echtes OS-Push** sobald PWA-Push-Infra existiert: Service-Worker-Subscription via `Notification.requestPermission()` + Push-Subscription-Endpoint, `mana-notify`-Backend-Cron für Anniversary-Scans (statt client-side beim App-Öffnen), Hard-Cap 2 Pushs/Monat als Server-Throttle. Die Date-Math-Helper (`findAnniversaryLasts` + `findRecognitionAnniversaryLasts`) sind bereits push-tauglich purer Code ohne Svelte-Runen-Bindings — können der Server-Cron ohne Refactor wiederverwendet werden.
|
||||
|
||||
**Vor-Push-Validatoren**: `validate:i18n-parity` rot wegen pre-existing untracked WIP `apps/mana/apps/web/src/lib/i18n/locales/settings/{de,en,es,fr}.json` — `it.json` fehlt; nicht in git history, mtime = 20:42 (nicht meine Lieferung; vermutlich parallele Session oder Hook). Mein `lasts/`-Namespace hat alle 5 Locales aligned.
|
||||
|
||||
**M6 Visibility + Unlisted-Sharing: DONE** — Modul auf das Repo-weite `@mana/shared-privacy`-System aufgesattelt, analog augur/library/places/events.
|
||||
|
||||
Lieferung:
|
||||
- **Type-Erweiterung**: `LocalLast` und `Last` haben jetzt `visibility`, `visibilityChangedAt`, `visibilityChangedBy`, `unlistedToken`, `unlistedExpiresAt`. `toLast` setzt Default `'private'` (intim, anders als firsts).
|
||||
- **Encryption-Registry**: visibility/Token-Felder bleiben plaintext (Server-Routing-Felder, keine User-typed Inhalte). Crypto-Audit weiter sauber bei 211 Tables.
|
||||
- **Per-Space-Welcome-Seed**: explizit `visibility: 'private'`.
|
||||
- **Resolver**: `buildLastBlob` in `data/unlisted/resolvers.ts` — Whitelist nur "reflective core" (title, status, category, date, meaning, whatIKnewThen, whatIKnowNow, tenderness, wouldReclaim). `note`, `inferredFrom`, person/place/media-Refs, recognisedAt, reclaimedNote bleiben PRIVAT. **Hard-Block: reclaimed Lasts werden nicht serialisiert** — die zurückgekommen-Emotion ist verletzlicher als der Last selbst.
|
||||
- **Store-Methoden** (`stores/items.svelte.ts`): `setVisibility(id, level)` mit publish/revoke unlisted-Snapshot via `@mana/shared-privacy/unlisted-client`, `regenerateUnlistedToken(id)` für Token-Rotation, `setUnlistedExpiry(id, date)` für TTL-Update. Reclaim-Lasts → unlisted wird im Store geblockt mit klarer Fehlermeldung.
|
||||
- **SharedLastView** (`SharedLastView.svelte`): public-render-Komponente, kontemplativer Ton, weisse Karte mit Kategorie-Akzent links, "Damals / Heute"-Reflexion zweispaltig, optional Tenderness-Stars + WouldReclaim. "via Mana Lasts" Footer, kein Marketing.
|
||||
- **Share-Dispatcher**: `routes/share/[token]/+page.svelte` kennt jetzt `data.collection === 'lasts'`.
|
||||
- **DetailView-Wire**: `<VisibilityPicker>` + `<SharedLinkControls>` Block oberhalb der Lifecycle-Action-Bar — nur sichtbar für non-reclaimed Lasts.
|
||||
- **i18n**: `lasts.detail.visibilityLabel` × 5 Locales.
|
||||
|
||||
**M6 Done-Definition**: ✓ Last kann auf `unlisted` gesetzt werden, Share-Link funktioniert öffentlich ohne Login (Snapshot-Server-Resolution via mana-api `/api/v1/unlisted/public/<token>`, dann SSR-Render via `SharedLastView`).
|
||||
|
||||
**Vor-Push-Validatoren** weiter: 2 svelte-check-Errors in `SettingsSidebar.svelte` — gleiche Orphan-WIP-Quelle wie `settings/it.json` (nicht meine Lieferung). Mein Code: 0/0/0 in allen reminders.test (12/12), i18n-keys baseline-equal, crypto 211 ✓.
|
||||
|
||||
**M7 Timeline-Aggregator + Year-Recap: DONE** — Cross-modulares "Meilensteine"-Surface, das firsts ∪ lasts als ein chronologisches Feed rendert.
|
||||
|
||||
Lieferung:
|
||||
- **Pure Aggregator** `data/milestones/timeline-query.ts`: `mergeMilestones(firsts, lasts)` mit Discriminator-Direction (`'first'` | `'last'`), Pinned-First-Sort, Date-desc-fallback-zu-createdAt. Plus `filterByDirection`, `filterByYear`, `compareTimelineDesc`. Reactive Hook `useMilestonesTimeline()` lädt beide Tabellen parallel + dekodiert client-seitig.
|
||||
- **Recap-Aggregator** `data/milestones/year-recap.ts`: `buildMilestonesRecap(entries, year)` → `{ year, total, firsts, lasts, byCategory: per-Cat × per-Direction, topFirsts (5), topLasts (5), activeMonths: 'YYYY-MM'[] }`. Bewusst nur Counts, keine Hit-Rate/Brier-Style-Metriken (lasts/firsts haben kein "verifizierbares" Element).
|
||||
- **Tests** `timeline-query.test.ts`: 12/12 passed (mergeMilestones, filterByDirection, filterByYear, buildMilestonesRecap mit allen Feldern, compareTimelineDesc).
|
||||
- **TimelineView** (`lib/components/milestones/TimelineView.svelte`): Tab-Bar (Alle | Firsts | Lasts), Karten mit Direction-Chip + Kategorie-Pille, Klick → jeweilige Modul-Detail-Route. Recap-Link top-right zum aktuellen Jahr.
|
||||
- **YearRecapView** (`lib/components/milestones/YearRecapView.svelte`): Hero-Stats (Total | Firsts | Lasts mit direction-coloring), Kategorie-Breakdown mit Per-Direction-Counts, Top-5-Listen pro Direction (klickbar), Active-Months-Strip.
|
||||
- **Routes** `/milestones` und `/milestones/recap/[year]` (nutzen `RoutePage` mit `appId="milestones"` — Registry hat keinen Eintrag, fallback rendert sauber mit Title-Override).
|
||||
- **Cross-Link** in `lasts/ListView.svelte` Tab-Bar: "Meilensteine"-Link führt zu `/milestones`.
|
||||
- **i18n-Namespace** `milestones/` × 5 Locales mit `timeline.*`, `tabs.*`, `recap.*` Keys (i18n-parity nun 39 namespaces × 5 locales aligned).
|
||||
|
||||
**M7 Done-Definition**: ✓ Timeline-View zeigt firsts und lasts interleaved, sortierbar (date desc, pinned-first), filterbar (direction tabs, year-filter über Recap-Route).
|
||||
|
||||
**Nicht in M7** (Polish): Eintrag in `packages/shared-branding/src/mana-apps.ts` als "milestones"-App mit Icon/Color für den App-Launcher. Ohne Eintrag funktioniert die Route via direkte URL-Navigation oder den Cross-Link von `/lasts`. Kann später ergänzt werden — kostet einen App-Icon-SVG und einen mana-apps.ts-Block.
|
||||
|
||||
---
|
||||
|
||||
**M1-M7 SHIPPED** — Modul `lasts` ist feature-komplett gemäß Plan. Validation: 0/0/0 in svelte-check (7645 files), 24/24 tests grün (12 reminders + 12 timeline), i18n-parity 39×5 aligned (+2 namespaces: lasts, milestones), i18n-keys baseline-equal, crypto 211 tables. Browser-Test offen (`pnpm run mana:dev` → `/lasts`, `/lasts/inbox`, `/lasts/settings`, `/lasts/entry/[id]`, `/milestones`, `/milestones/recap/2026`).
|
||||
|
||||
Vorbild: das bereits existierende Modul [`firsts/`](../../apps/mana/apps/web/src/lib/modules/firsts/) (Bucket-List + Reflexion mit `dream → lived` Lifecycle, 11 Kategorien, Foto/Audio/Place/People). `lasts` ist das spiegelbildliche Modul: das *letzte* Mal, dass du etwas getan/gefühlt/gesehen hast — meistens erst rückwirkend erkennbar.
|
||||
|
||||
---
|
||||
|
||||
## Ziel
|
||||
|
||||
Ein Modul `lasts`, in dem der Nutzer **Letzte Male** erfasst und reflektiert. Kernfrage: *"Wann habe ich das eigentlich zum letzten Mal getan/gefühlt — und wusste ich's damals?"*
|
||||
|
||||
Zwei Eingabewege:
|
||||
|
||||
1. **Manuell** — der User markiert bewusst (selten, oft an Wendepunkten: letzter Tag im Job, letztes Konzert mit X, letzte Nacht in der alten Wohnung).
|
||||
2. **Inferred** — die AI scannt regelmässig die anderen Module (places, contacts, food, habits, routes, music) auf Frequenz-Muster und schlägt Last-Kandidaten in einer Inbox vor: *"Du warst seit 18 Monaten nicht mehr in [Café X] — vorher 3×/Woche. Last?"*
|
||||
|
||||
Nicht im Scope:
|
||||
- Trauer-Workflow für Verluste/Tod (eigenes Modul `losses` aus Module-Ideas).
|
||||
- Bucket-List / Vorfreude — bleibt bei `firsts`.
|
||||
- Streak-Tracking — bleibt bei `habits`.
|
||||
|
||||
## Abgrenzung
|
||||
|
||||
- **Nicht `firsts`**: Tonalität ist anders (Kontemplation vs. Vorfreude), Lifecycle ist anders (`suspected → confirmed` vs. `dream → lived`), Push-Quoten sind anders. Eigenes Modul, eigene Tabelle. Geteilt wird nur der Code drumherum (Komponenten, Kategorien, Picker).
|
||||
- **Nicht `losses`**: dort gehört der Trauer-Workflow für markierte Verluste hin. `lasts` ist breiter und oft *zärtlich* statt schmerzhaft. Ein Last kann zu einem Loss eskaliert werden (Cross-Link), aber ein Loss erzeugt keinen Last.
|
||||
- **Nicht `eras`**: Eras aggregieren ganze Lebensabschnitte. `lasts` sind die Endpunkte einzelner Dinge — Eras können auf Lasts referenzieren ("Burnout-Jahr endete mit folgenden Lasts: …").
|
||||
- **Nicht `journal`**: ein Journal-Eintrag ist datiert auf den Schreibtag; ein Last ist datiert auf das (vermutete) Ereignis. Reflexion lebt im Last-Datensatz selbst, nicht als verlinkter Journal-Eintrag.
|
||||
|
||||
## Architektur-Entscheidung: zwei Tabellen, geteilte Komponenten
|
||||
|
||||
**Eigene Dexie-Tabelle `lasts`** — nicht `milestones` mit Diskriminator. Begründung in der vorgelagerten Diskussion (Modul-Boundary, `_pendingChanges`-Tagging, Encryption-Registry pro Tabelle, eigene Visibility-Defaults, eigene Migrations).
|
||||
|
||||
**Geteilt** wird stattdessen alles ausserhalb der Tabelle:
|
||||
- Kategorien (11 Stück, identisch mit `firsts`) → `lib/data/milestones/categories.ts`
|
||||
- Lifecycle-Helpers, Validators → `lib/data/milestones/lifecycle.ts`
|
||||
- Timeline-Aggregator-Query (firsts + lasts gemerged) → `lib/data/milestones/timeline-query.ts`
|
||||
- UI-Komponenten (Card, Editor, ReflectionFields, LifecycleToggle, CategoryPill) → `lib/components/milestones/`
|
||||
|
||||
Das macht ein zukünftiges drittes Geschwister-Modul (`peaks`, `pivots`, `cycles`) trivial — nur neue Tabelle + Lifecycle-Strings, alles andere ist da.
|
||||
|
||||
## Lifecycle-Mapping
|
||||
|
||||
| `firsts` | `lasts` |
|
||||
|---|---|
|
||||
| `dream` (geplant, will ich erleben) | `suspected` (vermutet, vom User oder AI markiert) |
|
||||
| `lived` (gemacht, mit Reflexion) | `confirmed` (bestätigt, mit Reflexion) |
|
||||
| — (kein Rückwärts-Pfad) | `reclaimed` (war doch nicht das letzte Mal — ist wieder passiert) |
|
||||
|
||||
`reclaimed` ist semantisch wichtig: das Modul soll mit dem Leben atmen. Wenn du wieder mit der Person sprichst oder doch wieder ins Café gehst, klickst du "Aufgehoben" — der Eintrag bleibt in der History (mit Notiz "Aufgehoben am …"), erscheint aber nicht mehr im Hauptfeed. Reclaimed-Items sind ihre eigene kleine emotional bedeutsame Untersicht.
|
||||
|
||||
## Felder-Mapping (`lasts` ↔ `firsts`)
|
||||
|
||||
| `firsts` Feld | `lasts` Feld | Bemerkung |
|
||||
|---|---|---|
|
||||
| `title` | `title` | identisch |
|
||||
| `status: 'dream'\|'lived'` | `status: 'suspected'\|'confirmed'\|'reclaimed'` | Discriminator |
|
||||
| `category` | `category` | gleiches Enum |
|
||||
| `motivation` | `meaning` | "Was hat es dir bedeutet?" statt "Warum willst du das?" |
|
||||
| `priority: 1\|2\|3` | `confidence: 'probably'\|'likely'\|'certain'` | Wie sicher ist es das letzte Mal? |
|
||||
| `date` | `date` | Vermutetes/bestätigtes Datum (oft approximativ) |
|
||||
| `note` | `note` | identisch |
|
||||
| `expectation` | `whatIKnewThen` | "Was wusstest du damals nicht?" |
|
||||
| `reality` | `whatIKnowNow` | "Was weisst du jetzt?" |
|
||||
| `rating: 1-5` | `tenderness: 1-5` | Nicht "gut/schlecht" — wie sehr berührt es dich heute |
|
||||
| `wouldRepeat: yes\|no\|definitely` | `wouldReclaim: no\|maybe\|yes` | Würdest du es zurückholen, wenn du könntest? |
|
||||
| `personIds[]` | `personIds[]` | identisch |
|
||||
| `placeId` | `placeId` | identisch |
|
||||
| `mediaIds[]` | `mediaIds[]` | identisch |
|
||||
| `audioNoteId` | `audioNoteId` | identisch |
|
||||
| `sharedWith` | `sharedWith` | identisch |
|
||||
| `isPinned`, `isArchived` | `isPinned`, `isArchived` | identisch |
|
||||
| — | `recognisedAt` | Wann wurde es als Last erkannt (oft Jahre nach `date`) — wichtig für "vor X Jahren erkannt"-Reminder |
|
||||
| — | `inferredFrom` | Optionales Provenance-Object: `{ tool: 'suggest_lasts', refTable: 'places', refId: '...', frequencyHint: '3x/week → 0 in 18mo' }` für AI-Vorschläge |
|
||||
|
||||
## Modul-Struktur
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/modules/lasts/
|
||||
├── types.ts # LocalLast, Last, LastStatus, LastConfidence, WouldReclaim, Tenderness
|
||||
├── collections.ts # lastTable + LASTS_GUEST_SEED (1 confirmed + 1 suspected Beispiel)
|
||||
├── queries.ts # useAllLasts, useLastsByStatus, useLastsByCategory, useLast(id), useLastsInbox (suspected only), useLastsStats
|
||||
├── stores/
|
||||
│ └── items.svelte.ts # createLast, updateLast, confirmLast, reclaimLast, suggestLasts (Inferenz-Loop), pin/archive/delete
|
||||
├── tools.ts # AI-Tools: create_last (propose), confirm_last (propose), reclaim_last (propose), list_lasts (auto), suggest_lasts (auto)
|
||||
├── inference/
|
||||
│ └── scan.ts # Cross-Modul-Reader: places/contacts/food/habits/routes für Frequenz-Drops
|
||||
├── ListView.svelte # Modul-Root (komponiert StatusTabs + Liste, leitet zu InboxView)
|
||||
├── InboxView.svelte # Suspected-Vorschläge: Akzeptieren / Verwerfen
|
||||
├── DetailView.svelte # Einzelansicht inkl. Reflexion + Reclaim-Button
|
||||
├── module.config.ts # { appId: 'lasts', tables: [{ name: 'lasts' }] }
|
||||
└── index.ts # Re-Exports
|
||||
```
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/data/milestones/ # NEU — geteilt firsts ↔ lasts
|
||||
├── categories.ts # MilestoneCategory, CATEGORY_LABELS, CATEGORY_COLORS (extrahiert aus firsts/types.ts)
|
||||
├── lifecycle.ts # Status-Transition-Helpers, Validators
|
||||
├── shared-types.ts # Person/Place/Media-Ref-Shapes (re-exports BaseRecord)
|
||||
└── timeline-query.ts # useMilestonesTimeline() — Union-Query firsts ∪ lasts, sortiert nach date
|
||||
```
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/lib/components/milestones/ # NEU — geteilte UI
|
||||
├── MilestoneCard.svelte # generisch, props: direction, status, category, title, date, isPinned
|
||||
├── MilestoneEditor.svelte # Formular-Body — slot-basiert für direction-spezifische Reflexions-Felder
|
||||
├── ReflectionFields.svelte # zwei Textareas, Labels via props
|
||||
├── LifecycleToggle.svelte # generisch, status-options via props
|
||||
├── CategoryPill.svelte # Farb-Pill aus CATEGORY_COLORS
|
||||
└── PeoplePlaceMediaPicker.svelte # bündelt die drei Picker (existieren schon einzeln)
|
||||
```
|
||||
|
||||
```
|
||||
apps/mana/apps/web/src/routes/(app)/
|
||||
├── lasts/+page.svelte # NEU — Modul-Root
|
||||
├── lasts/[id]/+page.svelte # NEU — Detail-Route
|
||||
├── lasts/inbox/+page.svelte # NEU — Suspected-Inbox (separate Route weil eigenes mentales Modell)
|
||||
└── milestones/+page.svelte # OPTIONAL M7 — Timeline-Aggregator firsts + lasts
|
||||
```
|
||||
|
||||
## Daten-Schema
|
||||
|
||||
### `LocalLast` (Dexie)
|
||||
|
||||
```typescript
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
import type { MilestoneCategory } from '$lib/data/milestones/categories';
|
||||
|
||||
export type LastStatus = 'suspected' | 'confirmed' | 'reclaimed';
|
||||
export type LastConfidence = 'probably' | 'likely' | 'certain';
|
||||
export type WouldReclaim = 'no' | 'maybe' | 'yes';
|
||||
|
||||
export interface InferredFrom {
|
||||
tool: string; // z.B. 'suggest_lasts'
|
||||
refTable: string; // 'places' | 'contacts' | 'food' | 'habits' | 'routes' | …
|
||||
refId: string;
|
||||
frequencyHint?: string; // human-readable: '3x/week → 0 in 18mo'
|
||||
scannedAt: string; // ISO
|
||||
}
|
||||
|
||||
export interface LocalLast extends BaseRecord {
|
||||
title: string;
|
||||
status: LastStatus;
|
||||
category: MilestoneCategory;
|
||||
|
||||
// Recognition phase
|
||||
confidence: LastConfidence | null; // wie sicher
|
||||
inferredFrom: InferredFrom | null; // null = manuell
|
||||
|
||||
// Confirmed phase (Reflexion)
|
||||
date: string | null; // ISO date — vermutet oder bestätigt
|
||||
meaning: string | null; // "was hat es bedeutet"
|
||||
note: string | null;
|
||||
whatIKnewThen: string | null;
|
||||
whatIKnowNow: string | null;
|
||||
tenderness: number | null; // 1-5
|
||||
wouldReclaim: WouldReclaim | null;
|
||||
|
||||
// Reclaimed phase
|
||||
reclaimedAt: string | null; // ISO — falls aufgehoben
|
||||
reclaimedNote: string | null; // optional Begründung
|
||||
|
||||
// Social
|
||||
personIds: string[];
|
||||
sharedWith: string | null;
|
||||
|
||||
// Rich media
|
||||
mediaIds: string[];
|
||||
audioNoteId: string | null;
|
||||
placeId: string | null;
|
||||
|
||||
// Meta
|
||||
recognisedAt: string; // wann wurde der Last erkannt (≠ createdAt nicht garantiert, aber meist gleich)
|
||||
isPinned: boolean;
|
||||
isArchived: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
### Domain-Typ `Last` — gleiche Form ohne BaseRecord-Internals (analog `firsts/types.ts`).
|
||||
|
||||
### Encryption-Registry
|
||||
|
||||
In `apps/mana/apps/web/src/lib/data/crypto/registry.ts` (analog zu `firsts`-Block):
|
||||
|
||||
```typescript
|
||||
// ─── Lasts ───────────────────────────────────────────────
|
||||
// User-typed text fields are encrypted. Status, category, confidence, dates,
|
||||
// tenderness, wouldReclaim, personIds, mediaIds, placeId, inferredFrom stay
|
||||
// plaintext for indexing/filtering and so the inference scanner can read
|
||||
// provenance without master-key access.
|
||||
lasts: {
|
||||
enabled: true,
|
||||
fields: ['title', 'meaning', 'note', 'whatIKnewThen', 'whatIKnowNow', 'reclaimedNote', 'sharedWith'],
|
||||
},
|
||||
```
|
||||
|
||||
### Dexie-Migration
|
||||
|
||||
Neue Version `db.version(51)` in `apps/mana/apps/web/src/lib/data/database.ts`:
|
||||
|
||||
```typescript
|
||||
db.version(51).stores({
|
||||
// … alle existierenden Tabellen 1:1 übernehmen aus v50 …
|
||||
lasts: 'id, spaceId, userId, status, category, date, recognisedAt, isPinned, isArchived',
|
||||
});
|
||||
```
|
||||
|
||||
Index-Strategie:
|
||||
- `status` — schnelle Filter für Inbox vs. Confirmed-Liste
|
||||
- `category` — Kategorie-Filter
|
||||
- `date` — Sort + Anniversary-Scans
|
||||
- `recognisedAt` — "vor X Jahren erkannt"-Reminder
|
||||
- `isPinned`, `isArchived` — Standard-Listing-Filter
|
||||
|
||||
Kein Soft/Hard-Split nötig — neue Tabelle, keine bestehenden Daten zu migrieren.
|
||||
|
||||
## Inferenz-Engine (`inference/scan.ts`)
|
||||
|
||||
Heuristik pro Quell-Modul:
|
||||
|
||||
| Quelle | Signal |
|
||||
|---|---|
|
||||
| `places` | Visit-Frequenz drop: war `≥ N visits / month` über `≥ M months`, jetzt `0 visits` seit `≥ K months` |
|
||||
| `contacts` | Last-contact-date in `contacts.lastInteractionAt` (falls vorhanden) — wenn `> threshold` Monate und vorher häufig |
|
||||
| `food` | Gericht in `meals` regelmässig, jetzt nicht mehr |
|
||||
| `habits` | Habit pausiert oder seit X nicht mehr geloggt |
|
||||
| `routes`/`hikes` | Route mit Wiederholungs-Counter, jetzt 0 |
|
||||
| `music` (falls Listening-Logs existieren) | Künstler-Drop |
|
||||
| `notes`/`writing`/`quotes` | Tag/Theme-Frequenz-Drop |
|
||||
|
||||
Default-Schwellen konservativ (Inbox-Lärm ist tödlich für die emotionale Wirkung):
|
||||
- minimale Vorgeschichte: ≥ 5 Vorkommen über ≥ 6 Monate
|
||||
- minimale Stille: ≥ 12 Monate ohne Vorkommen
|
||||
- max. Vorschläge pro Scan: 3
|
||||
- Cooldown: keine Wiedervorschläge derselben `(refTable, refId)` für 12 Monate nach Verwerfen
|
||||
|
||||
Cron: einmal pro Monat, z.B. am 1. um 9:00 Lokalzeit. Ausführung im AI-Mission-Runner als Mission `lasts.monthly-scan` (oder als simpler client-seitiger Cron-Job — tendiere zu Mission, weil dadurch Audit-Log + Pause-Switch gratis). Modul-Owner: einer der bestehenden Agents oder ein dedizierter "Gefährte"-Agent.
|
||||
|
||||
**Hard rules** (in der Heuristik verdrahtet, nicht User-konfigurierbar):
|
||||
- Keine Vorschläge für `contacts` mit `relationship: 'family' | 'partner'` ohne explizite Opt-In — Trauer-Trigger.
|
||||
- Keine Vorschläge für Refs in `period`, `dreams`, `losses`, `regret/forgive` — zu intim.
|
||||
- Wenn `losses` einen Eintrag mit gleicher `personId` hat, suspend alle Inferenz für diese Person komplett.
|
||||
|
||||
## AI-Tool-Coverage
|
||||
|
||||
Im `AI_TOOL_CATALOG` in `@mana/shared-ai/src/tools/schemas.ts`:
|
||||
|
||||
| Modul | Propose | Auto |
|
||||
|---|---|---|
|
||||
| **lasts** | `create_last`, `confirm_last`, `reclaim_last` | `list_lasts`, `suggest_lasts` |
|
||||
|
||||
Schemas (skizziert):
|
||||
|
||||
```typescript
|
||||
create_last: {
|
||||
policyHint: 'standard',
|
||||
input: { title, category, status?, date?, confidence?, meaning?, note?, personIds?, placeId? }
|
||||
}
|
||||
confirm_last: {
|
||||
policyHint: 'standard',
|
||||
input: { id, date?, whatIKnewThen?, whatIKnowNow?, tenderness?, wouldReclaim? }
|
||||
}
|
||||
reclaim_last: {
|
||||
policyHint: 'standard',
|
||||
input: { id, reclaimedAt, reclaimedNote? }
|
||||
}
|
||||
list_lasts: {
|
||||
policyHint: 'read',
|
||||
input: { status?, category?, sinceDate? }
|
||||
}
|
||||
suggest_lasts: {
|
||||
policyHint: 'read', // liefert Vorschläge, schreibt nicht
|
||||
input: { sources?: string[], minMonthsSilent?, limit? }
|
||||
}
|
||||
```
|
||||
|
||||
`suggest_lasts` schreibt selbst nichts — der Planner kann das Resultat in eine `create_last`-Proposal umwandeln, die der User approved.
|
||||
|
||||
## Push-Notifications (M5)
|
||||
|
||||
Drei opt-in-Klassen, getrennt umschaltbar in `/lasts/settings`:
|
||||
|
||||
1. **Anniversary-Reminder** — "Heute vor X Jahren das letzte Mal …" (nur für `confirmed` mit `date`).
|
||||
2. **Recognition-Reminder** — "Vor X Jahren als Last erkannt: …" (nutzt `recognisedAt`).
|
||||
3. **Inbox-Notify** — "3 neue Last-Vorschläge in der Inbox" (max. 1×/Monat nach dem Scan).
|
||||
|
||||
Hard-Cap insgesamt: 2 Push pro Monat. Snooze-pro-Item.
|
||||
|
||||
Implementierung über das bestehende Notification-System (zu prüfen: existiert das schon zentral, oder ad-hoc pro Modul?). Falls noch nicht vorhanden: M5 als separater Sub-Plan, M1-M4 funktionieren ohne.
|
||||
|
||||
## Visibility / Sharing
|
||||
|
||||
Default-Visibility: `private`. Anders als `firsts` (oft teilbar — du erzählst gerne von deinem ersten Mal Bungee-Jumping) sind Lasts intim.
|
||||
|
||||
Embed-Resolver für Visibility-System (analog `events`/`library` aus `project_visibility_system.md`-Memory): einzelne `lasts` können `unlisted` werden für `/share/[token]`-Routen, mit QR + Expiry. Sinnvolle Public-Aggregate kommen erst in M7+:
|
||||
- "Lasts of 2026" Year-Recap (anonymisiert/kuratiert)
|
||||
- Embed auf personal-site: poetische Sammlung kuratierter Lasts
|
||||
|
||||
## Refactor `firsts/` (Vorbereitung M1)
|
||||
|
||||
Damit `lasts` die geteilten Pieces wirklich nutzen kann, muss `firsts/` minimal umgebaut werden:
|
||||
|
||||
1. **Extract Categories**: `CATEGORY_LABELS`, `CATEGORY_COLORS` aus `firsts/types.ts` raus → `data/milestones/categories.ts`. `firsts/types.ts` re-exportiert für Abwärtskompatibilität.
|
||||
2. **Extract MilestoneCard**: aus `firsts/ListView.svelte` die Listen-Item-Markup-Logik extrahieren in `components/milestones/MilestoneCard.svelte`. `ListView.svelte` rendert dann `<MilestoneCard direction="first">`.
|
||||
3. **Optional jetzt, sicher später**: ReflectionFields, LifecycleToggle, CategoryPill, PeoplePlaceMediaPicker analog extrahieren. Nicht im kritischen Pfad — kann passieren, wenn `lasts` sie real braucht.
|
||||
|
||||
Klassischer **soft-first**-Migrationsstil (siehe Memory `feedback_soft_before_hard_migrations.md`): zuerst extrahieren mit Re-Export-Aliassen, dann später Imports umstellen, dann Aliasse löschen. Aber kein Schema-Change — nur Code-Move, deshalb risikoarm.
|
||||
|
||||
## Milestones
|
||||
|
||||
### M1 — Refactor + Skelett (~ 1 Tag)
|
||||
- `data/milestones/categories.ts` extrahieren, `firsts/types.ts` re-exportiert
|
||||
- `components/milestones/MilestoneCard.svelte` extrahieren, `firsts/ListView.svelte` umstellen
|
||||
- `lasts/` Modul-Skelett: `module.config.ts`, `types.ts`, `collections.ts`, `index.ts`
|
||||
- Dexie v51 mit `lasts`-Tabelle
|
||||
- Encryption-Registry-Eintrag
|
||||
- Guest-Seed: 1 confirmed Beispiel ("Letzter Tag im alten Job"), 1 suspected ("Vermutlich letztes Mal …")
|
||||
- Route `/lasts/+page.svelte` mountet leer mit "Noch keine Lasts"
|
||||
|
||||
**Done-Definition**: `lasts`-Modul lädt, leere Liste rendert, Dexie-Inspector zeigt Tabelle, `validate:all` grün.
|
||||
|
||||
### M2 — CRUD + ListView + DetailView (~ 1 Tag)
|
||||
- `stores/items.svelte.ts`: createLast, updateLast, confirmLast, reclaimLast, pin/archive/delete
|
||||
- `queries.ts`: useAllLasts, useLastsByStatus, useLast(id)
|
||||
- `ListView.svelte`: StatusTabs (Suspected | Confirmed | Reclaimed), MilestoneCard-basierte Liste
|
||||
- `DetailView.svelte` + Route `/lasts/[id]/+page.svelte`: Reflexionsfelder, Lifecycle-Buttons
|
||||
- Editor-Component (kann inline oder modal): nutzt geteilten `MilestoneEditor` mit lasts-spezifischen Reflexions-Labels
|
||||
|
||||
**Done-Definition**: User kann Last manuell anlegen, von suspected nach confirmed bewegen, reflektieren, reclaimen.
|
||||
|
||||
### M3 — Inbox + Inference (~ 1 Tag)
|
||||
- `inference/scan.ts`: Place-Drop + Contact-Drop + Habit-Drop Heuristiken (erste drei reichen für M3)
|
||||
- `InboxView.svelte` + Route `/lasts/inbox/+page.svelte`: Liste der suspected mit `inferredFrom != null`, Akzeptieren/Verwerfen
|
||||
- `suggestLasts()`-Methode im Store (nicht der AI-Tool — direct call), die einen Scan triggern und Suspected-Records anlegen kann
|
||||
- "Scan jetzt"-Button für Dev/Manual-Trigger
|
||||
- Cooldown-Liste: Tabelle `lastsInferenceCooldown` oder Feld in einer Settings-Tabelle für die "verworfen, nicht wieder vorschlagen"-Logik
|
||||
|
||||
**Done-Definition**: Manueller Scan-Trigger erzeugt sinnvolle Vorschläge basierend auf realen places/contacts/habits-Daten; Verwerfen unterdrückt Wiedervorschlag.
|
||||
|
||||
### M4 — AI-Tools (~ 0.5 Tage)
|
||||
- `tools.ts` mit den fünf Tool-Definitionen
|
||||
- Eintrag in `AI_TOOL_CATALOG` (`@mana/shared-ai/src/tools/schemas.ts`)
|
||||
- Server-side Planner-Drift-Check läuft automatisch grün
|
||||
- `<AiProposalInbox module="lasts" />` in ListView eingebaut
|
||||
- Tool-Implementierungen in `data/ai/tools/` analog zu existierenden Modulen
|
||||
|
||||
**Done-Definition**: AI-Mission "Schau mal, ob es Lasts gibt" generiert Proposals, User kann approven, Eintrag landet als `suspected` mit `inferredFrom`.
|
||||
|
||||
### M5 — Push-Notifications + Settings (~ 0.5 Tage, abhängig vom Notification-System)
|
||||
- `/lasts/settings/+page.svelte` mit drei opt-in-Toggles
|
||||
- Anniversary-Scan (Cron + match auf `date` mit Jahres-Differenz)
|
||||
- Recognition-Scan (Cron + match auf `recognisedAt`)
|
||||
- Inbox-Notify-Hook nach `suggest_lasts`
|
||||
- Hard-Cap-Logik
|
||||
|
||||
**Done-Definition**: Toggles persistent, eine Push-Test-Funktion sendet Beispiel.
|
||||
|
||||
### M6 — Visibility + Unlisted-Sharing (~ 0.5 Tage)
|
||||
- Default-Visibility auf `private`
|
||||
- VisibilityPicker in DetailView
|
||||
- Embed-Resolver für `lasts` registrieren (analog `events`/`library`)
|
||||
- `/share/[token]`-Route lädt einzelnes Last in lesbarem Format
|
||||
|
||||
**Done-Definition**: Last kann auf `unlisted` gesetzt werden, Share-Link funktioniert öffentlich ohne Login.
|
||||
|
||||
### M7 — Optional: Timeline-Aggregator + Year-Recap
|
||||
- `data/milestones/timeline-query.ts`: Union-Query firsts ∪ lasts
|
||||
- `routes/(app)/milestones/+page.svelte`: chronologische Timeline beider, Filter nach Direction
|
||||
- Year-Recap-Page mit Top-N Lasts + Firsts des Jahres (analog Augur-Year-Recap aus Memory)
|
||||
|
||||
**Done-Definition**: Timeline-View zeigt firsts und lasts interleaved, sortierbar, filterbar.
|
||||
|
||||
## Per-Space-Seeds
|
||||
|
||||
`lasts` ist per-space (analog allen post-spaces-foundation Modulen). Ein Per-Space-Seeder registriert sich in `apps/mana/apps/web/src/lib/data/seeds/lasts.ts`:
|
||||
|
||||
```typescript
|
||||
registerSpaceSeed('lasts-welcome', async (spaceId) => {
|
||||
const id = `seed-welcome-${spaceId}`;
|
||||
if (await db.table('lasts').get(id)) return;
|
||||
await db.table('lasts').add({
|
||||
id, spaceId,
|
||||
title: 'Willkommen bei Lasts',
|
||||
status: 'confirmed',
|
||||
category: 'other',
|
||||
confidence: 'certain',
|
||||
inferredFrom: null,
|
||||
date: new Date().toISOString().slice(0, 10),
|
||||
meaning: 'Hier fängst du an, deine "letzten Male" festzuhalten.',
|
||||
note: null,
|
||||
whatIKnewThen: null,
|
||||
whatIKnowNow: null,
|
||||
tenderness: 3,
|
||||
wouldReclaim: null,
|
||||
reclaimedAt: null,
|
||||
reclaimedNote: null,
|
||||
personIds: [], sharedWith: null,
|
||||
mediaIds: [], audioNoteId: null, placeId: null,
|
||||
recognisedAt: new Date().toISOString(),
|
||||
isPinned: false, isArchived: false,
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
Side-effect-Import in `data/seeds/index.ts`.
|
||||
|
||||
## App-Registry
|
||||
|
||||
In `packages/shared-branding/src/mana-apps.ts`:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: 'lasts',
|
||||
name: 'Lasts',
|
||||
description: 'Letzte Male — bewusst markiert oder rückwirkend erkannt',
|
||||
category: 'reflection', // gleiche Kategorie wie firsts
|
||||
requiredTier: 'guest', // LOCAL TIER PATCH bis Release, dann auf prod-tier setzen
|
||||
icon: '…',
|
||||
// …
|
||||
}
|
||||
```
|
||||
|
||||
## Tier-Strategie
|
||||
|
||||
Während Entwicklung: `'guest'` mit `// LOCAL TIER PATCH` Marker (Memory `project_tier_patch_resolved.md`). Vor Release auf prod-tier hochziehen — Vorschlag: `beta` analog zu firsts.
|
||||
|
||||
## Offene Fragen
|
||||
|
||||
1. **`losses` Cross-Link**: Soll ein Last → Loss eskaliert werden können (Button "Das war ein echter Verlust → in losses übernehmen")? Ja, aber `losses` existiert noch nicht als Modul. Hook-Point in DetailView vorbereiten, no-op bis losses gebaut ist.
|
||||
2. **Audio-First-Eingabe**: Mic → STT → AI-strukturiert → Last-Draft? Würde gut zu Rubberduck/Scribe-Pattern passen. Erstmal nicht in M1-M6, aber DetailView so designen, dass `audioNoteId`-Eingabe leicht später ergänzbar ist (Feld existiert ja schon im Schema).
|
||||
3. **Persona-Begleitung**: Soll ein dedizierter "Gefährte"-Agent (sanft, kontemplativ) für die Lasts-Begleitung gespawnt werden, oder reicht der Default-Mana-Agent? Vorschlag: in M5 prüfen, fürs Erste Default-Agent.
|
||||
4. **`recognisedAt` vs. `createdAt`**: In 99% der Fälle gleich. Brauchen wir beide? Ja, weil bei AI-inferred Records der `createdAt` der Scan-Zeitpunkt ist und `recognisedAt` der "User-akzeptiert"-Zeitpunkt — relevant für Recognition-Reminder.
|
||||
5. **i18n**: Direkt mit echten Keys bauen (nach Memory `project_i18n_hardening.md` ist hardcoded German verboten; validator wird sonst rot). Namespace: `lasts.*`.
|
||||
|
||||
## Kosten-Schätzung
|
||||
|
||||
| Milestone | Aufwand |
|
||||
|---|---|
|
||||
| M1 Refactor + Skelett | 1 Tag |
|
||||
| M2 CRUD + Views | 1 Tag |
|
||||
| M3 Inbox + Inference | 1 Tag |
|
||||
| M4 AI-Tools | 0.5 Tage |
|
||||
| M5 Push + Settings | 0.5 Tage |
|
||||
| M6 Visibility + Sharing | 0.5 Tage |
|
||||
| M7 Timeline (optional) | 0.5 Tage |
|
||||
| **Total M1-M6** | **4 Tage** |
|
||||
| **Total inkl. M7** | **4.5 Tage** |
|
||||
|
||||
Die ursprüngliche Schätzung "1-2 Tage für M1, +1 Tag Inferenz, +1 Tag Push" war zu knapp — vor allem M3 (Inferenz mit konservativen Schwellen + Cooldown-Mechanik) und der Refactor-Vorlauf in M1 brauchen jeweils ihren vollen Tag.
|
||||
Loading…
Add table
Add a link
Reference in a new issue