From bf3bca268ac310d6bc49594ad020d6435d0284f9 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sun, 26 Apr 2026 21:40:29 +0200 Subject: [PATCH] =?UTF-8?q?feat(lasts):=20M1-M7=20=E2=80=94=20module=20shi?= =?UTF-8?q?p=20+=20Meilensteine-Aggregator?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../components/milestones/TimelineView.svelte | 287 +++++++ .../milestones/YearRecapView.svelte | 346 +++++++++ .../apps/web/src/lib/data/crypto/registry.ts | 27 + .../web/src/lib/data/milestones/categories.ts | 62 ++ .../data/milestones/timeline-query.test.ts | 171 +++++ .../src/lib/data/milestones/timeline-query.ts | 132 ++++ .../web/src/lib/data/milestones/year-recap.ts | 81 ++ .../apps/web/src/lib/data/module-registry.ts | 2 + .../mana/apps/web/src/lib/data/seeds/index.ts | 3 + .../mana/apps/web/src/lib/data/seeds/lasts.ts | 59 ++ apps/mana/apps/web/src/lib/data/tools/init.ts | 2 + .../web/src/lib/data/unlisted/resolvers.ts | 46 ++ .../web/src/lib/i18n/locales/lasts/de.json | 105 +++ .../web/src/lib/i18n/locales/lasts/en.json | 105 +++ .../web/src/lib/i18n/locales/lasts/es.json | 105 +++ .../web/src/lib/i18n/locales/lasts/fr.json | 105 +++ .../web/src/lib/i18n/locales/lasts/it.json | 105 +++ .../src/lib/i18n/locales/milestones/de.json | 27 + .../src/lib/i18n/locales/milestones/en.json | 27 + .../src/lib/i18n/locales/milestones/es.json | 27 + .../src/lib/i18n/locales/milestones/fr.json | 27 + .../src/lib/i18n/locales/milestones/it.json | 27 + .../apps/web/src/lib/modules/firsts/types.ts | 49 +- .../web/src/lib/modules/lasts/ListView.svelte | 501 +++++++++++++ .../lib/modules/lasts/SharedLastView.svelte | 277 +++++++ .../web/src/lib/modules/lasts/collections.ts | 7 + .../modules/lasts/components/DueBanner.svelte | 235 ++++++ .../apps/web/src/lib/modules/lasts/index.ts | 25 + .../src/lib/modules/lasts/inference/scan.ts | 128 ++++ .../modules/lasts/inference/sources/places.ts | 90 +++ .../src/lib/modules/lasts/inference/types.ts | 42 ++ .../lib/modules/lasts/lib/reminders.test.ts | 129 ++++ .../src/lib/modules/lasts/lib/reminders.ts | 84 +++ .../src/lib/modules/lasts/module.config.ts | 6 + .../apps/web/src/lib/modules/lasts/queries.ts | 117 +++ .../lib/modules/lasts/stores/items.svelte.ts | 444 +++++++++++ .../modules/lasts/stores/settings.svelte.ts | 56 ++ .../apps/web/src/lib/modules/lasts/tools.ts | 282 +++++++ .../apps/web/src/lib/modules/lasts/types.ts | 147 ++++ .../lib/modules/lasts/views/DetailView.svelte | 698 ++++++++++++++++++ .../lib/modules/lasts/views/InboxView.svelte | 284 +++++++ .../modules/lasts/views/SettingsView.svelte | 267 +++++++ .../web/src/routes/(app)/lasts/+page.svelte | 12 + .../(app)/lasts/entry/[id]/+page.svelte | 44 ++ .../src/routes/(app)/lasts/inbox/+page.svelte | 13 + .../routes/(app)/lasts/settings/+page.svelte | 13 + .../src/routes/(app)/milestones/+page.svelte | 13 + .../milestones/recap/[year]/+page.svelte | 46 ++ .../web/src/routes/share/[token]/+page.svelte | 3 + docs/plans/lasts-module.md | 519 +++++++++++++ packages/shared-ai/src/tools/schemas.ts | 180 +++++ packages/shared-branding/src/app-icons.ts | 6 + packages/shared-branding/src/mana-apps.ts | 17 + 53 files changed, 6572 insertions(+), 40 deletions(-) create mode 100644 apps/mana/apps/web/src/lib/components/milestones/TimelineView.svelte create mode 100644 apps/mana/apps/web/src/lib/components/milestones/YearRecapView.svelte create mode 100644 apps/mana/apps/web/src/lib/data/milestones/categories.ts create mode 100644 apps/mana/apps/web/src/lib/data/milestones/timeline-query.test.ts create mode 100644 apps/mana/apps/web/src/lib/data/milestones/timeline-query.ts create mode 100644 apps/mana/apps/web/src/lib/data/milestones/year-recap.ts create mode 100644 apps/mana/apps/web/src/lib/data/seeds/lasts.ts create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/lasts/de.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/lasts/en.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/lasts/es.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/lasts/fr.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/lasts/it.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/milestones/de.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/milestones/en.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/milestones/es.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/milestones/fr.json create mode 100644 apps/mana/apps/web/src/lib/i18n/locales/milestones/it.json create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/SharedLastView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/components/DueBanner.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/inference/scan.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/inference/types.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/lib/reminders.test.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/lib/reminders.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/stores/items.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/stores/settings.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/tools.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/types.ts create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/views/DetailView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/views/InboxView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/lasts/views/SettingsView.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/lasts/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/lasts/entry/[id]/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/lasts/inbox/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/lasts/settings/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/milestones/+page.svelte create mode 100644 apps/mana/apps/web/src/routes/(app)/milestones/recap/[year]/+page.svelte create mode 100644 docs/plans/lasts-module.md diff --git a/apps/mana/apps/web/src/lib/components/milestones/TimelineView.svelte b/apps/mana/apps/web/src/lib/components/milestones/TimelineView.svelte new file mode 100644 index 000000000..71f12567b --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/milestones/TimelineView.svelte @@ -0,0 +1,287 @@ + + + +
+
+
+

{$_('milestones.timeline.title')}

+

{$_('milestones.timeline.tagline')}

+
+ + {$_('milestones.timeline.recapLink', { values: { year: currentYear } })} + +
+ +
+ {#each ['all', 'first', 'last'] as const as tab} + + {/each} +
+ + {#if entries.length === 0} +

{$_('milestones.timeline.empty')}

+ {:else if filtered.length === 0} +

{$_('milestones.timeline.emptyTab')}

+ {:else} +
    + {#each filtered as entry (entry.id)} +
  • + +
  • + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/components/milestones/YearRecapView.svelte b/apps/mana/apps/web/src/lib/components/milestones/YearRecapView.svelte new file mode 100644 index 000000000..6973c4036 --- /dev/null +++ b/apps/mana/apps/web/src/lib/components/milestones/YearRecapView.svelte @@ -0,0 +1,346 @@ + + + +
+
+

{$_('milestones.recap.title', { values: { year } })}

+

{$_('milestones.recap.tagline')}

+
+ + +
+
+ {recap.total} + {$_('milestones.recap.totalLabel')} +
+
+ {recap.firsts} + {$_('milestones.tabs.first')} +
+
+ {recap.lasts} + {$_('milestones.tabs.last')} +
+
+ + {#if recap.total === 0} +

{$_('milestones.recap.empty', { values: { year } })}

+ {:else} + +
+

{$_('milestones.recap.categoriesLabel')}

+
    + {#each categoriesWithActivity as cat (cat)} + {@const slot = recap.byCategory[cat]} +
  • + + {CATEGORY_LABELS[cat].de} + + {slot.firsts} + · + {slot.lasts} + +
  • + {/each} +
+
+ + +
+ {#if recap.topFirsts.length > 0} +
+

{$_('milestones.recap.topFirstsLabel')}

+
    + {#each recap.topFirsts as e (e.id)} +
  • + +
  • + {/each} +
+
+ {/if} + + {#if recap.topLasts.length > 0} +
+

{$_('milestones.recap.topLastsLabel')}

+
    + {#each recap.topLasts as e (e.id)} +
  • + +
  • + {/each} +
+
+ {/if} +
+ + + {#if recap.activeMonths.length > 0} +
+

{$_('milestones.recap.activeMonthsLabel')}

+
    + {#each recap.activeMonths as ym (ym)} +
  • {monthLabel(ym)}
  • + {/each} +
+
+ {/if} + {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 0edf131ef..188eee51f 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -463,6 +463,33 @@ export const ENCRYPTION_REGISTRY: Record = { fields: ['title', 'motivation', 'note', 'expectation', 'reality', 'sharedWith'], }, + // ─── Lasts ─────────────────────────────────────────────── + // Mirror sibling to firsts (docs/plans/lasts-module.md). 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. Visibility metadata + unlistedToken stay + // plaintext — they're routing fields the server-side share endpoint + // must read without the master key. + lasts: { + enabled: true, + fields: [ + 'title', + 'meaning', + 'note', + 'whatIKnewThen', + 'whatIKnowNow', + 'reclaimedNote', + 'sharedWith', + ], + }, + + // ─── Lasts inference cooldown ─────────────────────────── + // Plaintext metadata table — records dismissed inference candidates by + // (refTable, refId) so the scanner skips them for ~12 months. No + // user-typed content lives here. + lastsCooldown: { enabled: false, fields: [] }, + // ─── Guides ────────────────────────────────────────────── guides: { enabled: true, fields: ['title', 'description'] }, sections: { enabled: true, fields: ['title', 'content'] }, diff --git a/apps/mana/apps/web/src/lib/data/milestones/categories.ts b/apps/mana/apps/web/src/lib/data/milestones/categories.ts new file mode 100644 index 000000000..c075908d1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/milestones/categories.ts @@ -0,0 +1,62 @@ +/** + * Shared milestone categories — used by `firsts/` and `lasts/`. + * + * Both modules have the same 11-category vocabulary. Extracted here so + * the second module didn't have to duplicate the enum + label/color + * tables. See docs/plans/lasts-module.md. + */ + +export type MilestoneCategory = + | 'culinary' + | 'adventure' + | 'travel' + | 'people' + | 'career' + | 'creative' + | 'nature' + | 'culture' + | 'health' + | 'tech' + | 'other'; + +export const MILESTONE_CATEGORIES: MilestoneCategory[] = [ + 'culinary', + 'adventure', + 'travel', + 'people', + 'career', + 'creative', + 'nature', + 'culture', + 'health', + 'tech', + 'other', +]; + +export const CATEGORY_LABELS: Record = { + culinary: { de: 'Kulinarisch', en: 'Culinary' }, + adventure: { de: 'Abenteuer', en: 'Adventure' }, + travel: { de: 'Reisen', en: 'Travel' }, + people: { de: 'Menschen', en: 'People' }, + career: { de: 'Beruf', en: 'Career' }, + creative: { de: 'Kreativ', en: 'Creative' }, + nature: { de: 'Natur', en: 'Nature' }, + culture: { de: 'Kultur', en: 'Culture' }, + health: { de: 'Gesundheit', en: 'Health' }, + tech: { de: 'Technik', en: 'Tech' }, + other: { de: 'Sonstiges', en: 'Other' }, +}; + +export const CATEGORY_COLORS: Record = { + culinary: '#f97316', + adventure: '#ef4444', + travel: '#0ea5e9', + people: '#ec4899', + career: '#6366f1', + creative: '#a855f7', + nature: '#22c55e', + culture: '#eab308', + health: '#14b8a6', + tech: '#64748b', + other: '#9ca3af', +}; diff --git a/apps/mana/apps/web/src/lib/data/milestones/timeline-query.test.ts b/apps/mana/apps/web/src/lib/data/milestones/timeline-query.test.ts new file mode 100644 index 000000000..5501ecfd7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/milestones/timeline-query.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from 'vitest'; +import { + mergeMilestones, + filterByDirection, + filterByYear, + compareTimelineDesc, +} from './timeline-query'; +import { buildMilestonesRecap } from './year-recap'; +import type { First } from '$lib/modules/firsts/types'; +import type { Last } from '$lib/modules/lasts/types'; + +function f(overrides: Partial): First { + return { + id: overrides.id ?? 'f1', + title: overrides.title ?? 'First', + status: overrides.status ?? 'lived', + category: overrides.category ?? 'travel', + motivation: null, + priority: null, + date: overrides.date ?? '2026-04-26', + note: null, + expectation: null, + reality: null, + rating: null, + wouldRepeat: null, + personIds: [], + sharedWith: null, + mediaIds: [], + audioNoteId: null, + placeId: null, + isPinned: overrides.isPinned ?? false, + isArchived: false, + createdAt: overrides.createdAt ?? '2026-04-26T10:00:00Z', + updatedAt: '2026-04-26T10:00:00Z', + }; +} + +function l(overrides: Partial): Last { + return { + id: overrides.id ?? 'l1', + title: overrides.title ?? 'Last', + status: overrides.status ?? 'confirmed', + category: overrides.category ?? 'people', + confidence: 'certain', + inferredFrom: null, + date: overrides.date ?? '2026-04-26', + meaning: null, + note: null, + whatIKnewThen: null, + whatIKnowNow: null, + tenderness: null, + wouldReclaim: null, + reclaimedAt: null, + reclaimedNote: null, + personIds: [], + sharedWith: null, + mediaIds: [], + audioNoteId: null, + placeId: null, + recognisedAt: '2026-04-26T10:00:00Z', + isPinned: overrides.isPinned ?? false, + isArchived: false, + visibility: 'private', + unlistedToken: '', + unlistedExpiresAt: null, + createdAt: overrides.createdAt ?? '2026-04-26T10:00:00Z', + updatedAt: '2026-04-26T10:00:00Z', + }; +} + +describe('mergeMilestones', () => { + it('interleaves firsts and lasts sorted by date desc', () => { + const merged = mergeMilestones( + [f({ id: 'a', date: '2025-01-01' }), f({ id: 'b', date: '2026-04-26' })], + [l({ id: 'c', date: '2025-12-31' })] + ); + expect(merged.map((e) => e.id)).toEqual(['first:b', 'last:c', 'first:a']); + }); + + it('places pinned entries above unpinned regardless of date', () => { + const merged = mergeMilestones( + [f({ id: 'old-pinned', date: '2020-01-01', isPinned: true })], + [l({ id: 'new', date: '2026-04-01' })] + ); + expect(merged[0].id).toBe('first:old-pinned'); + }); + + it('falls back to createdAt when date is null', () => { + const merged = mergeMilestones( + [f({ id: 'dated', date: '2024-01-01' })], + [l({ id: 'undated', date: null, createdAt: '2026-04-01T00:00:00Z' })] + ); + expect(merged.map((e) => e.id)).toEqual(['last:undated', 'first:dated']); + }); +}); + +describe('filterByDirection', () => { + const merged = mergeMilestones([f({ id: 'a' })], [l({ id: 'b' })]); + + it('passes through with "all"', () => { + expect(filterByDirection(merged, 'all')).toHaveLength(2); + }); + it('keeps only firsts', () => { + expect(filterByDirection(merged, 'first').map((e) => e.id)).toEqual(['first:a']); + }); + it('keeps only lasts', () => { + expect(filterByDirection(merged, 'last').map((e) => e.id)).toEqual(['last:b']); + }); +}); + +describe('filterByYear', () => { + const merged = mergeMilestones( + [f({ id: 'a', date: '2024-06-01' }), f({ id: 'b', date: '2026-04-01' })], + [l({ id: 'c', date: '2025-12-31' })] + ); + + it('keeps only entries from the requested year', () => { + expect(filterByYear(merged, 2026).map((e) => e.id)).toEqual(['first:b']); + expect(filterByYear(merged, 2025).map((e) => e.id)).toEqual(['last:c']); + }); +}); + +describe('buildMilestonesRecap', () => { + const entries = mergeMilestones( + [ + f({ id: 'a', date: '2026-01-15', category: 'travel' }), + f({ id: 'b', date: '2026-04-26', category: 'people' }), + f({ id: 'c', date: '2025-06-01', category: 'travel' }), // wrong year + ], + [ + l({ id: 'd', date: '2026-03-10', category: 'people' }), + l({ id: 'e', date: '2026-12-01', category: 'culinary' }), + ] + ); + + it('counts by direction within the year', () => { + const recap = buildMilestonesRecap(entries, 2026); + expect(recap.year).toBe(2026); + expect(recap.total).toBe(4); + expect(recap.firsts).toBe(2); + expect(recap.lasts).toBe(2); + }); + + it('groups by category with both directions counted', () => { + const recap = buildMilestonesRecap(entries, 2026); + expect(recap.byCategory.travel).toEqual({ firsts: 1, lasts: 0, total: 1 }); + expect(recap.byCategory.people).toEqual({ firsts: 1, lasts: 1, total: 2 }); + expect(recap.byCategory.culinary).toEqual({ firsts: 0, lasts: 1, total: 1 }); + expect(recap.byCategory.career).toEqual({ firsts: 0, lasts: 0, total: 0 }); + }); + + it('returns top firsts/lasts as pre-sorted slices', () => { + const recap = buildMilestonesRecap(entries, 2026); + expect(recap.topFirsts.map((e) => e.id)).toEqual(['first:b', 'first:a']); + expect(recap.topLasts.map((e) => e.id)).toEqual(['last:e', 'last:d']); + }); + + it('lists active months in chronological order', () => { + const recap = buildMilestonesRecap(entries, 2026); + expect(recap.activeMonths).toEqual(['2026-01', '2026-03', '2026-04', '2026-12']); + }); +}); + +describe('compareTimelineDesc', () => { + it('is a stable comparator', () => { + const a = mergeMilestones([f({ id: 'a' })], [])[0]; + const b = mergeMilestones([f({ id: 'b' })], [])[0]; + // Same date → comparator returns 0 + expect(compareTimelineDesc(a, b)).toBe(0); + }); +}); diff --git a/apps/mana/apps/web/src/lib/data/milestones/timeline-query.ts b/apps/mana/apps/web/src/lib/data/milestones/timeline-query.ts new file mode 100644 index 000000000..fd8d6ee56 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/milestones/timeline-query.ts @@ -0,0 +1,132 @@ +/** + * Milestones Timeline Aggregator + * + * Cross-module union of firsts ∪ lasts as a single chronological feed — + * the "your life so far"-view. Pure helpers are testable without Dexie; + * the reactive `useMilestonesTimeline()` hook combines the two existing + * scoped live-queries. + * + * Direction discriminator distinguishes the two: + * - 'first' = entry from the firsts module (achieved or dreamed) + * - 'last' = entry from the lasts module (suspected/confirmed/reclaimed) + * + * Sort default: most-recent date first (anchored). Entries without a + * concrete date fall back to createdAt and sort to the tail. + */ + +import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; +import { scopedForModule } from '$lib/data/scope'; +import { decryptRecords } from '$lib/data/crypto'; +import { toFirst } from '$lib/modules/firsts/queries'; +import { toLast } from '$lib/modules/lasts/queries'; +import type { LocalFirst, First } from '$lib/modules/firsts/types'; +import type { LocalLast, Last } from '$lib/modules/lasts/types'; +import type { MilestoneCategory } from './categories'; + +export type Direction = 'first' | 'last'; + +export interface TimelineEntry { + id: string; + direction: Direction; + title: string; + category: MilestoneCategory; + /** Anchor date — for firsts: `date` (lived) or null (dream). + * For lasts: `date` (suspected/confirmed) or null. */ + date: string | null; + /** ISO of original creation, fallback sort key. */ + createdAt: string; + /** Direction-specific status string (lived/dream OR suspected/confirmed/reclaimed). */ + status: string; + /** Pin state across both modules. */ + isPinned: boolean; + /** Underlying record for direction-specific UI. */ + source: First | Last; +} + +function firstToEntry(f: First): TimelineEntry { + return { + id: `first:${f.id}`, + direction: 'first', + title: f.title, + category: f.category as MilestoneCategory, + date: f.date, + createdAt: f.createdAt, + status: f.status, + isPinned: f.isPinned, + source: f, + }; +} + +function lastToEntry(l: Last): TimelineEntry { + return { + id: `last:${l.id}`, + direction: 'last', + title: l.title, + category: l.category, + date: l.date, + createdAt: l.createdAt, + status: l.status, + isPinned: l.isPinned, + source: l, + }; +} + +/** Reverse-chronological sort by anchor date (date ?? createdAt). */ +export function compareTimelineDesc(a: TimelineEntry, b: TimelineEntry): number { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + const ka = a.date ?? a.createdAt; + const kb = b.date ?? b.createdAt; + return kb.localeCompare(ka); +} + +/** Pure: union firsts ∪ lasts → sorted timeline. Used by tests + view. */ +export function mergeMilestones(firsts: First[], lasts: Last[]): TimelineEntry[] { + const merged: TimelineEntry[] = [...firsts.map(firstToEntry), ...lasts.map(lastToEntry)]; + return merged.sort(compareTimelineDesc); +} + +/** Filter helper: by direction. `'all'` is a no-op. */ +export function filterByDirection( + entries: TimelineEntry[], + direction: Direction | 'all' +): TimelineEntry[] { + if (direction === 'all') return entries; + return entries.filter((e) => e.direction === direction); +} + +/** Filter helper: only entries within the given year (UTC). */ +export function filterByYear(entries: TimelineEntry[], year: number): TimelineEntry[] { + const prefix = `${year}-`; + return entries.filter((e) => (e.date ?? e.createdAt).startsWith(prefix)); +} + +// ─── Reactive Hook ────────────────────────────────────────── + +/** + * Combined live-query: firsts ∪ lasts in the active Space, both + * decrypted, returned as a sorted timeline. + * + * Implemented as a single scoped query that loads both tables in + * parallel — saves the boilerplate of joining two separate + * `useScopedLiveQuery` returns at the call-site. + */ +export function useMilestonesTimeline() { + return useScopedLiveQuery(async () => { + const [firstLocals, lastLocals] = await Promise.all([ + scopedForModule('firsts', 'firsts').toArray(), + scopedForModule('lasts', 'lasts').toArray(), + ]); + + const firstsVisible = firstLocals.filter((f) => !f.deletedAt && !f.isArchived); + const lastsVisible = lastLocals.filter((l) => !l.deletedAt && !l.isArchived); + + const [firstsDecrypted, lastsDecrypted] = await Promise.all([ + decryptRecords('firsts', firstsVisible), + decryptRecords('lasts', lastsVisible), + ]); + + const firsts = firstsDecrypted.map(toFirst); + const lasts = lastsDecrypted.map(toLast); + return mergeMilestones(firsts, lasts); + }, [] as TimelineEntry[]); +} diff --git a/apps/mana/apps/web/src/lib/data/milestones/year-recap.ts b/apps/mana/apps/web/src/lib/data/milestones/year-recap.ts new file mode 100644 index 000000000..b48356933 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/milestones/year-recap.ts @@ -0,0 +1,81 @@ +/** + * Milestones Year Recap Aggregator + * + * Pure: takes a TimelineEntry[] + a year and returns a per-year summary. + * Counts per direction + category, top entries by recency. No metrics + * fancier than counts (this isn't augur — there's nothing to "verify" + * about a milestone). + * + * Stable shape so a future LLM-phrasing layer can narrate it. + */ + +import { MILESTONE_CATEGORIES, type MilestoneCategory } from './categories'; +import { filterByYear, type TimelineEntry, type Direction } from './timeline-query'; + +export interface MilestonesRecap { + year: number; + total: number; + firsts: number; + lasts: number; + byCategory: Record; + /** Top-N entries by anchor date desc. Cap = 5 each. */ + topFirsts: TimelineEntry[]; + topLasts: TimelineEntry[]; + /** Months that had any activity, in chronological order ('YYYY-MM'). */ + activeMonths: string[]; +} + +const TOP_CAP = 5; + +function emptyByCategory(): MilestonesRecap['byCategory'] { + const out = {} as MilestonesRecap['byCategory']; + for (const cat of MILESTONE_CATEGORIES) { + out[cat] = { firsts: 0, lasts: 0, total: 0 }; + } + return out; +} + +function countByDirection(entries: TimelineEntry[], direction: Direction): number { + return entries.filter((e) => e.direction === direction).length; +} + +function topByDirection( + entries: TimelineEntry[], + direction: Direction, + cap = TOP_CAP +): TimelineEntry[] { + return entries.filter((e) => e.direction === direction).slice(0, cap); // entries are pre-sorted desc by anchor date +} + +function uniqueActiveMonths(entries: TimelineEntry[]): string[] { + const set = new Set(); + for (const e of entries) { + const anchor = e.date ?? e.createdAt; + if (anchor.length >= 7) set.add(anchor.slice(0, 7)); + } + return [...set].sort(); +} + +export function buildMilestonesRecap(allEntries: TimelineEntry[], year: number): MilestonesRecap { + const inYear = filterByYear(allEntries, year); + + const byCategory = emptyByCategory(); + for (const e of inYear) { + const slot = byCategory[e.category]; + if (!slot) continue; + slot.total += 1; + if (e.direction === 'first') slot.firsts += 1; + else slot.lasts += 1; + } + + return { + year, + total: inYear.length, + firsts: countByDirection(inYear, 'first'), + lasts: countByDirection(inYear, 'last'), + byCategory, + topFirsts: topByDirection(inYear, 'first'), + topLasts: topByDirection(inYear, 'last'), + activeMonths: uniqueActiveMonths(inYear), + }; +} diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index fa4a444db..652630877 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -88,6 +88,7 @@ import { whoModuleConfig } from '$lib/modules/who/module.config'; import { newsModuleConfig } from '$lib/modules/news/module.config'; import { bodyModuleConfig } from '$lib/modules/body/module.config'; import { firstsModuleConfig } from '$lib/modules/firsts/module.config'; +import { lastsModuleConfig } from '$lib/modules/lasts/module.config'; import { drinkModuleConfig } from '$lib/modules/drink/module.config'; import { recipesModuleConfig } from '$lib/modules/recipes/module.config'; import { stretchModuleConfig } from '$lib/modules/stretch/module.config'; @@ -152,6 +153,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ newsModuleConfig, bodyModuleConfig, firstsModuleConfig, + lastsModuleConfig, drinkModuleConfig, recipesModuleConfig, stretchModuleConfig, diff --git a/apps/mana/apps/web/src/lib/data/seeds/index.ts b/apps/mana/apps/web/src/lib/data/seeds/index.ts index 7b06143f0..c6288409b 100644 --- a/apps/mana/apps/web/src/lib/data/seeds/index.ts +++ b/apps/mana/apps/web/src/lib/data/seeds/index.ts @@ -16,3 +16,6 @@ // Side-effect: registers `workbench-home` in the per-space-seeds map. import './workbench-home'; + +// Side-effect: registers `lasts-welcome` per-space-seed. +import './lasts'; diff --git a/apps/mana/apps/web/src/lib/data/seeds/lasts.ts b/apps/mana/apps/web/src/lib/data/seeds/lasts.ts new file mode 100644 index 000000000..85ab625c2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/data/seeds/lasts.ts @@ -0,0 +1,59 @@ +/** + * Per-Space "Welcome" seed for the Lasts module. + * + * Drops a single confirmed welcome row into each Space the first time + * that Space is activated, so the empty state is replaced by a concrete + * example users can edit or delete. Idempotent via deterministic id — + * see docs/plans/workbench-seeding-cleanup.md for the per-space-seeds + * registry contract. + */ + +import { db } from '../database'; +import { encryptRecord } from '../crypto'; +import { registerSpaceSeed } from '../scope/per-space-seeds'; +import type { LocalLast } from '$lib/modules/lasts/types'; + +const TABLE = 'lasts'; + +export function lastsWelcomeSeedId(spaceId: string): string { + return `seed-welcome-${spaceId}`; +} + +registerSpaceSeed('lasts-welcome', async (spaceId) => { + const id = lastsWelcomeSeedId(spaceId); + const existing = await db.table(TABLE).get(id); + if (existing) return; + + const now = new Date().toISOString(); + const row: LocalLast = { + id, + spaceId, + title: 'Willkommen bei Lasts', + status: 'confirmed', + category: 'other', + confidence: 'certain', + inferredFrom: null, + date: now.slice(0, 10), + meaning: + 'Hier hältst du fest, was zum letzten Mal passiert ist — bewusst markiert oder rückwirkend erkannt.', + note: null, + whatIKnewThen: null, + whatIKnowNow: null, + tenderness: 3, + wouldReclaim: null, + reclaimedAt: null, + reclaimedNote: null, + personIds: [], + sharedWith: null, + mediaIds: [], + audioNoteId: null, + placeId: null, + recognisedAt: now, + isPinned: false, + isArchived: false, + visibility: 'private', + } as LocalLast; + + await encryptRecord(TABLE, row); + await db.table(TABLE).add(row); +}); diff --git a/apps/mana/apps/web/src/lib/data/tools/init.ts b/apps/mana/apps/web/src/lib/data/tools/init.ts index f86fb3ea7..16ae9b20e 100644 --- a/apps/mana/apps/web/src/lib/data/tools/init.ts +++ b/apps/mana/apps/web/src/lib/data/tools/init.ts @@ -26,6 +26,7 @@ import { memoroTools } from '$lib/modules/memoro/tools'; import { skilltreeTools } from '$lib/modules/skilltree/tools'; import { periodTools } from '$lib/modules/period/tools'; import { firstsTools } from '$lib/modules/firsts/tools'; +import { lastsTools } from '$lib/modules/lasts/tools'; import { guidesTools } from '$lib/modules/guides/tools'; import { inventoryTools } from '$lib/modules/inventory/tools'; import { plantsTools } from '$lib/modules/plants/tools'; @@ -76,6 +77,7 @@ export function initTools(): void { registerTools(skilltreeTools); registerTools(periodTools); registerTools(firstsTools); + registerTools(lastsTools); registerTools(guidesTools); registerTools(inventoryTools); registerTools(plantsTools); diff --git a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts index 17d10a272..5a5ac04e3 100644 --- a/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts +++ b/apps/mana/apps/web/src/lib/data/unlisted/resolvers.ts @@ -22,6 +22,7 @@ import type { LocalLibraryEntry } from '$lib/modules/library/types'; import type { LocalPlace } from '$lib/modules/places/types'; import type { LocalTimeBlock } from '$lib/data/time-blocks/types'; import type { LocalAugurEntry } from '$lib/modules/augur/types'; +import type { LocalLast } from '$lib/modules/lasts/types'; export class UnsupportedCollectionError extends Error { constructor(collection: string) { @@ -54,6 +55,8 @@ export async function buildUnlistedBlob( return buildPlaceBlob(recordId); case 'augurEntries': return buildAugurEntryBlob(recordId); + case 'lasts': + return buildLastBlob(recordId); default: throw new UnsupportedCollectionError(collection); } @@ -222,3 +225,46 @@ async function buildAugurEntryBlob(recordId: string): Promise> { + const raw = await db.table('lasts').get(recordId); + if (!raw || raw.deletedAt) { + throw new RecordNotFoundError('lasts', recordId); + } + if (raw.status === 'reclaimed') { + throw new RecordNotFoundError('lasts', recordId); + } + + const decrypted = (await decryptRecord('lasts', { ...raw })) as LocalLast; + + return { + title: decrypted.title, + status: decrypted.status, + category: decrypted.category, + date: decrypted.date ?? null, + meaning: decrypted.meaning ?? null, + whatIKnewThen: decrypted.whatIKnewThen ?? null, + whatIKnowNow: decrypted.whatIKnowNow ?? null, + tenderness: decrypted.tenderness ?? null, + wouldReclaim: decrypted.wouldReclaim ?? null, + }; +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/lasts/de.json b/apps/mana/apps/web/src/lib/i18n/locales/lasts/de.json new file mode 100644 index 000000000..cddb0d9d8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/lasts/de.json @@ -0,0 +1,105 @@ +{ + "app": { + "title": "Lasts", + "tagline": "Letzte Male — markieren oder erkennen." + }, + "list": { + "emptyAll": "Noch keine Lasts erfasst.", + "emptyTab": "Nichts in dieser Ansicht.", + "searchPlaceholder": "Lasts durchsuchen ..." + }, + "tabs": { + "all": "Alle", + "suspected": "Vermutet", + "confirmed": "Bestätigt", + "reclaimed": "Aufgehoben", + "inbox": "Inbox" + }, + "status": { + "suspected": "Vermutet", + "confirmed": "Bestätigt", + "reclaimed": "Aufgehoben" + }, + "quickAdd": { + "placeholder": "Letztes Mal eintragen ... (Enter)", + "modeSuspected": "Vermutet", + "modeConfirmed": "Bestätigt" + }, + "actions": { + "confirm": "Bestätigen", + "reclaim": "Aufheben", + "delete": "Löschen", + "pin": "Pinnen", + "unpin": "Lösen", + "archive": "Archivieren", + "save": "Speichern", + "cancel": "Abbrechen" + }, + "detail": { + "routeTitle": "Last", + "loading": "Lädt ...", + "notFound": "Last nicht gefunden.", + "backLink": "Zurück zur Liste", + "titlePlaceholder": "Titel ...", + "categoryLabel": "Kategorie", + "dateLabel": "Datum", + "confidenceLabel": "Sicherheit", + "meaningLabel": "Was hat es bedeutet?", + "meaningPlaceholder": "Was hat es dir bedeutet?", + "whatIKnewThenLabel": "Was wusste ich damals nicht?", + "whatIKnewThenPlaceholder": "Was hättest du damals wissen wollen?", + "whatIKnowNowLabel": "Was weiss ich jetzt?", + "whatIKnowNowPlaceholder": "Was siehst du heute klarer?", + "noteLabel": "Notiz", + "notePlaceholder": "Was willst du festhalten?", + "tendernessLabel": "Wie sehr berührt es dich heute?", + "wouldReclaimLabel": "Würdest du es zurückholen?", + "reclaimedAt": "Aufgehoben am", + "reclaimedNotePlaceholder": "Es ist wieder passiert — was?", + "inferredFrom": "Vorgeschlagen aus", + "visibilityLabel": "Sichtbarkeit" + }, + "confidence": { + "probably": "Wahrscheinlich", + "likely": "Recht sicher", + "certain": "Sicher" + }, + "wouldReclaim": { + "no": "Nein", + "maybe": "Vielleicht", + "yes": "Ja" + }, + "inbox": { + "routeTitle": "Inbox", + "title": "Inbox", + "tagline": "AI-Vorschläge zur Überprüfung. Akzeptiere oder verwirf.", + "empty": "Keine Vorschläge — die Suche hat nichts Passendes gefunden.", + "scanNow": "Jetzt scannen", + "scanning": "Scannt ...", + "scanSummary": "{written} neue Vorschläge — {cooldown} im Cooldown übersprungen, {existing} schon bekannt.", + "accept": "Akzeptieren", + "dismiss": "Verwerfen" + }, + "banner": { + "title": "Heute", + "anniversary": "Vor {years} Jahr(en) das letzte Mal", + "recognition": "Vor {years} Jahr(en) als Last erkannt", + "inbox": "{count} neue Vorschläge in der Inbox" + }, + "settings": { + "routeTitle": "Lasts — Einstellungen", + "title": "Einstellungen", + "tagline": "Wann sollen dich Lasts daran erinnern, dass du heute hier bist?", + "anniversaryLabel": "Jahrestags-Erinnerungen", + "anniversaryDesc": "Zeigt heute Lasts, deren Datum auf den heutigen Tag vor X Jahren fällt.", + "recognitionLabel": "Erkennungs-Erinnerungen", + "recognitionDesc": "Zeigt Lasts, die heute vor X Jahren als Last erkannt wurden.", + "inboxLabel": "Inbox-Hinweis", + "inboxDesc": "Zeigt eine Zeile, wenn neue AI-Vorschläge in der Inbox liegen.", + "bannerCapLabel": "Maximal {count} Erinnerungen gleichzeitig", + "reset": "Zurücksetzen", + "showTestBanner": "Test-Banner zeigen", + "testSampleTitle": "Beispiel-Last", + "pushNote": "Echtes OS-Push folgt sobald die PWA-Push-Infrastruktur landet. Bis dahin tauchen Erinnerungen oben in der Liste auf, sobald du die App öffnest." + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/lasts/en.json b/apps/mana/apps/web/src/lib/i18n/locales/lasts/en.json new file mode 100644 index 000000000..daad52772 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/lasts/en.json @@ -0,0 +1,105 @@ +{ + "app": { + "title": "Lasts", + "tagline": "Last times — marked or recognised." + }, + "list": { + "emptyAll": "No lasts captured yet.", + "emptyTab": "Nothing in this view.", + "searchPlaceholder": "Search lasts ..." + }, + "tabs": { + "all": "All", + "suspected": "Suspected", + "confirmed": "Confirmed", + "reclaimed": "Reclaimed", + "inbox": "Inbox" + }, + "status": { + "suspected": "Suspected", + "confirmed": "Confirmed", + "reclaimed": "Reclaimed" + }, + "quickAdd": { + "placeholder": "Capture a last time ... (Enter)", + "modeSuspected": "Suspected", + "modeConfirmed": "Confirmed" + }, + "actions": { + "confirm": "Confirm", + "reclaim": "Reclaim", + "delete": "Delete", + "pin": "Pin", + "unpin": "Unpin", + "archive": "Archive", + "save": "Save", + "cancel": "Cancel" + }, + "detail": { + "routeTitle": "Last", + "loading": "Loading ...", + "notFound": "Last not found.", + "backLink": "Back to list", + "titlePlaceholder": "Title ...", + "categoryLabel": "Category", + "dateLabel": "Date", + "confidenceLabel": "Confidence", + "meaningLabel": "What did it mean?", + "meaningPlaceholder": "What did it mean to you?", + "whatIKnewThenLabel": "What didn't I know then?", + "whatIKnewThenPlaceholder": "What would you have wanted to know?", + "whatIKnowNowLabel": "What do I know now?", + "whatIKnowNowPlaceholder": "What is clearer today?", + "noteLabel": "Note", + "notePlaceholder": "Anything else to capture?", + "tendernessLabel": "How much does it touch you today?", + "wouldReclaimLabel": "Would you reclaim it?", + "reclaimedAt": "Reclaimed on", + "reclaimedNotePlaceholder": "It happened again — what?", + "inferredFrom": "Suggested from", + "visibilityLabel": "Visibility" + }, + "confidence": { + "probably": "Probably", + "likely": "Likely", + "certain": "Certain" + }, + "wouldReclaim": { + "no": "No", + "maybe": "Maybe", + "yes": "Yes" + }, + "inbox": { + "routeTitle": "Inbox", + "title": "Inbox", + "tagline": "AI suggestions awaiting review. Accept or dismiss.", + "empty": "No suggestions — the scan found nothing matching.", + "scanNow": "Scan now", + "scanning": "Scanning ...", + "scanSummary": "{written} new suggestion(s) — {cooldown} skipped on cooldown, {existing} already known.", + "accept": "Accept", + "dismiss": "Dismiss" + }, + "banner": { + "title": "Today", + "anniversary": "{years} year(s) ago — last time", + "recognition": "Recognised as a last {years} year(s) ago", + "inbox": "{count} new suggestion(s) in the Inbox" + }, + "settings": { + "routeTitle": "Lasts — Settings", + "title": "Settings", + "tagline": "When should lasts remind you that you're here today?", + "anniversaryLabel": "Anniversary reminders", + "anniversaryDesc": "Surfaces lasts whose date matches today X years ago.", + "recognitionLabel": "Recognition reminders", + "recognitionDesc": "Surfaces lasts you recognised today X years ago.", + "inboxLabel": "Inbox hint", + "inboxDesc": "Shows a line when new AI suggestions land in the Inbox.", + "bannerCapLabel": "At most {count} reminders at once", + "reset": "Reset", + "showTestBanner": "Show test banner", + "testSampleTitle": "Sample last", + "pushNote": "Real OS push lands once PWA push infrastructure exists. Until then, reminders surface at the top of the list when you open the app." + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/lasts/es.json b/apps/mana/apps/web/src/lib/i18n/locales/lasts/es.json new file mode 100644 index 000000000..73874eee6 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/lasts/es.json @@ -0,0 +1,105 @@ +{ + "app": { + "title": "Lasts", + "tagline": "Últimas veces — marcadas o reconocidas." + }, + "list": { + "emptyAll": "Aún no hay lasts registrados.", + "emptyTab": "Nada en esta vista.", + "searchPlaceholder": "Buscar lasts ..." + }, + "tabs": { + "all": "Todos", + "suspected": "Sospechados", + "confirmed": "Confirmados", + "reclaimed": "Recuperados", + "inbox": "Bandeja" + }, + "status": { + "suspected": "Sospechado", + "confirmed": "Confirmado", + "reclaimed": "Recuperado" + }, + "quickAdd": { + "placeholder": "Anotar una última vez ... (Enter)", + "modeSuspected": "Sospechado", + "modeConfirmed": "Confirmado" + }, + "actions": { + "confirm": "Confirmar", + "reclaim": "Recuperar", + "delete": "Eliminar", + "pin": "Fijar", + "unpin": "Desfijar", + "archive": "Archivar", + "save": "Guardar", + "cancel": "Cancelar" + }, + "detail": { + "routeTitle": "Last", + "loading": "Cargando ...", + "notFound": "Last no encontrado.", + "backLink": "Volver a la lista", + "titlePlaceholder": "Título ...", + "categoryLabel": "Categoría", + "dateLabel": "Fecha", + "confidenceLabel": "Confianza", + "meaningLabel": "¿Qué significó?", + "meaningPlaceholder": "¿Qué significó para ti?", + "whatIKnewThenLabel": "¿Qué no sabía entonces?", + "whatIKnewThenPlaceholder": "¿Qué te habría gustado saber?", + "whatIKnowNowLabel": "¿Qué sé ahora?", + "whatIKnowNowPlaceholder": "¿Qué se ve más claro hoy?", + "noteLabel": "Nota", + "notePlaceholder": "¿Algo más para anotar?", + "tendernessLabel": "¿Cuánto te conmueve hoy?", + "wouldReclaimLabel": "¿Lo recuperarías?", + "reclaimedAt": "Recuperado el", + "reclaimedNotePlaceholder": "Volvió a pasar — ¿qué?", + "inferredFrom": "Sugerido a partir de", + "visibilityLabel": "Visibilidad" + }, + "confidence": { + "probably": "Probable", + "likely": "Bastante seguro", + "certain": "Seguro" + }, + "wouldReclaim": { + "no": "No", + "maybe": "Quizás", + "yes": "Sí" + }, + "inbox": { + "routeTitle": "Bandeja", + "title": "Bandeja", + "tagline": "Sugerencias de IA pendientes de revisión. Acepta o descarta.", + "empty": "Sin sugerencias — la búsqueda no encontró coincidencias.", + "scanNow": "Escanear ahora", + "scanning": "Escaneando ...", + "scanSummary": "{written} sugerencias nuevas — {cooldown} omitidas en enfriamiento, {existing} ya conocidas.", + "accept": "Aceptar", + "dismiss": "Descartar" + }, + "banner": { + "title": "Hoy", + "anniversary": "Hace {years} año(s) — última vez", + "recognition": "Reconocido como un last hace {years} año(s)", + "inbox": "{count} nueva(s) sugerencia(s) en la bandeja" + }, + "settings": { + "routeTitle": "Lasts — Ajustes", + "title": "Ajustes", + "tagline": "¿Cuándo deberían los lasts recordarte que hoy estás aquí?", + "anniversaryLabel": "Recordatorios de aniversario", + "anniversaryDesc": "Muestra lasts cuya fecha coincide con hoy hace X años.", + "recognitionLabel": "Recordatorios de reconocimiento", + "recognitionDesc": "Muestra lasts que reconociste hoy hace X años.", + "inboxLabel": "Aviso de bandeja", + "inboxDesc": "Muestra una línea cuando hay nuevas sugerencias de IA en la bandeja.", + "bannerCapLabel": "Como máximo {count} recordatorios a la vez", + "reset": "Restablecer", + "showTestBanner": "Mostrar banner de prueba", + "testSampleTitle": "Last de ejemplo", + "pushNote": "Las notificaciones push reales del SO llegarán cuando exista la infraestructura PWA push. Hasta entonces, los recordatorios aparecen arriba de la lista al abrir la app." + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/lasts/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/lasts/fr.json new file mode 100644 index 000000000..fa5e57177 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/lasts/fr.json @@ -0,0 +1,105 @@ +{ + "app": { + "title": "Lasts", + "tagline": "Dernières fois — marquées ou reconnues." + }, + "list": { + "emptyAll": "Aucun last enregistré pour le moment.", + "emptyTab": "Rien dans cette vue.", + "searchPlaceholder": "Rechercher des lasts ..." + }, + "tabs": { + "all": "Tous", + "suspected": "Soupçonnés", + "confirmed": "Confirmés", + "reclaimed": "Repris", + "inbox": "Boîte" + }, + "status": { + "suspected": "Soupçonné", + "confirmed": "Confirmé", + "reclaimed": "Repris" + }, + "quickAdd": { + "placeholder": "Noter une dernière fois ... (Entrée)", + "modeSuspected": "Soupçonné", + "modeConfirmed": "Confirmé" + }, + "actions": { + "confirm": "Confirmer", + "reclaim": "Reprendre", + "delete": "Supprimer", + "pin": "Épingler", + "unpin": "Désépingler", + "archive": "Archiver", + "save": "Enregistrer", + "cancel": "Annuler" + }, + "detail": { + "routeTitle": "Last", + "loading": "Chargement ...", + "notFound": "Last introuvable.", + "backLink": "Retour à la liste", + "titlePlaceholder": "Titre ...", + "categoryLabel": "Catégorie", + "dateLabel": "Date", + "confidenceLabel": "Confiance", + "meaningLabel": "Qu'est-ce que cela signifiait ?", + "meaningPlaceholder": "Qu'est-ce que cela signifiait pour toi ?", + "whatIKnewThenLabel": "Que ne savais-je pas alors ?", + "whatIKnewThenPlaceholder": "Qu'aurais-tu voulu savoir ?", + "whatIKnowNowLabel": "Que sais-je maintenant ?", + "whatIKnowNowPlaceholder": "Qu'est-ce qui est plus clair aujourd'hui ?", + "noteLabel": "Note", + "notePlaceholder": "Autre chose à noter ?", + "tendernessLabel": "À quel point cela te touche-t-il aujourd'hui ?", + "wouldReclaimLabel": "Le reprendrais-tu ?", + "reclaimedAt": "Repris le", + "reclaimedNotePlaceholder": "C'est revenu — quoi ?", + "inferredFrom": "Suggéré à partir de", + "visibilityLabel": "Visibilité" + }, + "confidence": { + "probably": "Probablement", + "likely": "Plutôt sûr", + "certain": "Sûr" + }, + "wouldReclaim": { + "no": "Non", + "maybe": "Peut-être", + "yes": "Oui" + }, + "inbox": { + "routeTitle": "Boîte de réception", + "title": "Boîte de réception", + "tagline": "Suggestions IA en attente de révision. Accepter ou écarter.", + "empty": "Aucune suggestion — l'analyse n'a rien trouvé de pertinent.", + "scanNow": "Analyser maintenant", + "scanning": "Analyse en cours ...", + "scanSummary": "{written} nouvelles suggestions — {cooldown} ignorées en délai, {existing} déjà connues.", + "accept": "Accepter", + "dismiss": "Écarter" + }, + "banner": { + "title": "Aujourd'hui", + "anniversary": "Il y a {years} an(s) — dernière fois", + "recognition": "Reconnu comme un last il y a {years} an(s)", + "inbox": "{count} nouvelle(s) suggestion(s) dans la boîte" + }, + "settings": { + "routeTitle": "Lasts — Paramètres", + "title": "Paramètres", + "tagline": "Quand les lasts doivent-ils te rappeler que tu es là aujourd'hui ?", + "anniversaryLabel": "Rappels d'anniversaire", + "anniversaryDesc": "Affiche les lasts dont la date correspond à aujourd'hui il y a X ans.", + "recognitionLabel": "Rappels de reconnaissance", + "recognitionDesc": "Affiche les lasts que tu as reconnus aujourd'hui il y a X ans.", + "inboxLabel": "Indice de boîte", + "inboxDesc": "Affiche une ligne quand de nouvelles suggestions IA arrivent dans la boîte.", + "bannerCapLabel": "Au plus {count} rappels à la fois", + "reset": "Réinitialiser", + "showTestBanner": "Afficher la bannière de test", + "testSampleTitle": "Last d'exemple", + "pushNote": "Les vraies notifications push de l'OS arriveront quand l'infrastructure PWA push existera. D'ici là, les rappels apparaissent en haut de la liste à l'ouverture de l'app." + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/lasts/it.json b/apps/mana/apps/web/src/lib/i18n/locales/lasts/it.json new file mode 100644 index 000000000..3639f9bd2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/lasts/it.json @@ -0,0 +1,105 @@ +{ + "app": { + "title": "Lasts", + "tagline": "Ultime volte — annotate o riconosciute." + }, + "list": { + "emptyAll": "Ancora nessun last registrato.", + "emptyTab": "Niente in questa vista.", + "searchPlaceholder": "Cerca lasts ..." + }, + "tabs": { + "all": "Tutti", + "suspected": "Sospettati", + "confirmed": "Confermati", + "reclaimed": "Ripresi", + "inbox": "In arrivo" + }, + "status": { + "suspected": "Sospettato", + "confirmed": "Confermato", + "reclaimed": "Ripreso" + }, + "quickAdd": { + "placeholder": "Annotare un'ultima volta ... (Invio)", + "modeSuspected": "Sospettato", + "modeConfirmed": "Confermato" + }, + "actions": { + "confirm": "Conferma", + "reclaim": "Riprendi", + "delete": "Elimina", + "pin": "Fissa", + "unpin": "Rimuovi pin", + "archive": "Archivia", + "save": "Salva", + "cancel": "Annulla" + }, + "detail": { + "routeTitle": "Last", + "loading": "Caricamento ...", + "notFound": "Last non trovato.", + "backLink": "Torna alla lista", + "titlePlaceholder": "Titolo ...", + "categoryLabel": "Categoria", + "dateLabel": "Data", + "confidenceLabel": "Sicurezza", + "meaningLabel": "Cosa ha significato?", + "meaningPlaceholder": "Cosa ha significato per te?", + "whatIKnewThenLabel": "Cosa non sapevo allora?", + "whatIKnewThenPlaceholder": "Cosa avresti voluto sapere?", + "whatIKnowNowLabel": "Cosa so adesso?", + "whatIKnowNowPlaceholder": "Cosa è più chiaro oggi?", + "noteLabel": "Nota", + "notePlaceholder": "Qualcos'altro da annotare?", + "tendernessLabel": "Quanto ti tocca oggi?", + "wouldReclaimLabel": "Lo riprenderesti?", + "reclaimedAt": "Ripreso il", + "reclaimedNotePlaceholder": "È successo di nuovo — cosa?", + "inferredFrom": "Suggerito da", + "visibilityLabel": "Visibilità" + }, + "confidence": { + "probably": "Probabile", + "likely": "Abbastanza sicuro", + "certain": "Sicuro" + }, + "wouldReclaim": { + "no": "No", + "maybe": "Forse", + "yes": "Sì" + }, + "inbox": { + "routeTitle": "In arrivo", + "title": "In arrivo", + "tagline": "Suggerimenti IA in attesa di revisione. Accetta o scarta.", + "empty": "Nessun suggerimento — la scansione non ha trovato nulla di rilevante.", + "scanNow": "Scansiona ora", + "scanning": "Scansione ...", + "scanSummary": "{written} nuovi suggerimenti — {cooldown} saltati in cooldown, {existing} già noti.", + "accept": "Accetta", + "dismiss": "Scarta" + }, + "banner": { + "title": "Oggi", + "anniversary": "{years} anno/i fa — ultima volta", + "recognition": "Riconosciuto come last {years} anno/i fa", + "inbox": "{count} nuovi suggerimenti in arrivo" + }, + "settings": { + "routeTitle": "Lasts — Impostazioni", + "title": "Impostazioni", + "tagline": "Quando dovrebbero i lasts ricordarti che sei qui oggi?", + "anniversaryLabel": "Promemoria anniversari", + "anniversaryDesc": "Mostra i lasts la cui data coincide con oggi X anni fa.", + "recognitionLabel": "Promemoria di riconoscimento", + "recognitionDesc": "Mostra i lasts che hai riconosciuto oggi X anni fa.", + "inboxLabel": "Avviso in arrivo", + "inboxDesc": "Mostra una riga quando arrivano nuovi suggerimenti IA in arrivo.", + "bannerCapLabel": "Massimo {count} promemoria alla volta", + "reset": "Ripristina", + "showTestBanner": "Mostra banner di prova", + "testSampleTitle": "Last di esempio", + "pushNote": "Le notifiche push reali del SO arriveranno quando esisterà l'infrastruttura PWA push. Fino ad allora, i promemoria appaiono in cima alla lista all'apertura dell'app." + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/milestones/de.json b/apps/mana/apps/web/src/lib/i18n/locales/milestones/de.json new file mode 100644 index 000000000..048343f3c --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/milestones/de.json @@ -0,0 +1,27 @@ +{ + "timeline": { + "title": "Meilensteine", + "tagline": "Erste Male und letzte Male nebeneinander.", + "empty": "Noch keine Meilensteine.", + "emptyTab": "Nichts in dieser Ansicht.", + "recapLink": "{year}-Rückblick" + }, + "tabs": { + "all": "Alle", + "first": "Firsts", + "last": "Lasts" + }, + "recap": { + "title": "{year} im Rückblick", + "titleFallback": "Jahresrückblick", + "tagline": "Was war neu, was endete.", + "empty": "{year} hatte keine Meilensteine.", + "invalid": "Ungültiges Jahr.", + "backLink": "Zurück zur Timeline", + "totalLabel": "Gesamt", + "categoriesLabel": "Nach Kategorie", + "topFirstsLabel": "Top Firsts", + "topLastsLabel": "Top Lasts", + "activeMonthsLabel": "Aktive Monate" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/milestones/en.json b/apps/mana/apps/web/src/lib/i18n/locales/milestones/en.json new file mode 100644 index 000000000..73ecddcc4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/milestones/en.json @@ -0,0 +1,27 @@ +{ + "timeline": { + "title": "Milestones", + "tagline": "First times and last times side by side.", + "empty": "No milestones yet.", + "emptyTab": "Nothing in this view.", + "recapLink": "{year} recap" + }, + "tabs": { + "all": "All", + "first": "Firsts", + "last": "Lasts" + }, + "recap": { + "title": "{year} in review", + "titleFallback": "Year recap", + "tagline": "What began, what ended.", + "empty": "{year} had no milestones.", + "invalid": "Invalid year.", + "backLink": "Back to timeline", + "totalLabel": "Total", + "categoriesLabel": "By category", + "topFirstsLabel": "Top firsts", + "topLastsLabel": "Top lasts", + "activeMonthsLabel": "Active months" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/milestones/es.json b/apps/mana/apps/web/src/lib/i18n/locales/milestones/es.json new file mode 100644 index 000000000..5464966c2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/milestones/es.json @@ -0,0 +1,27 @@ +{ + "timeline": { + "title": "Hitos", + "tagline": "Primeras veces y últimas veces lado a lado.", + "empty": "Aún no hay hitos.", + "emptyTab": "Nada en esta vista.", + "recapLink": "Resumen {year}" + }, + "tabs": { + "all": "Todos", + "first": "Firsts", + "last": "Lasts" + }, + "recap": { + "title": "{year} en resumen", + "titleFallback": "Resumen anual", + "tagline": "Qué empezó, qué terminó.", + "empty": "{year} no tuvo hitos.", + "invalid": "Año inválido.", + "backLink": "Volver a la timeline", + "totalLabel": "Total", + "categoriesLabel": "Por categoría", + "topFirstsLabel": "Top firsts", + "topLastsLabel": "Top lasts", + "activeMonthsLabel": "Meses activos" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/milestones/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/milestones/fr.json new file mode 100644 index 000000000..a40e87b81 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/milestones/fr.json @@ -0,0 +1,27 @@ +{ + "timeline": { + "title": "Jalons", + "tagline": "Premières fois et dernières fois côte à côte.", + "empty": "Aucun jalon pour le moment.", + "emptyTab": "Rien dans cette vue.", + "recapLink": "Bilan {year}" + }, + "tabs": { + "all": "Tous", + "first": "Firsts", + "last": "Lasts" + }, + "recap": { + "title": "{year} en revue", + "titleFallback": "Bilan annuel", + "tagline": "Ce qui a commencé, ce qui a fini.", + "empty": "{year} n'a eu aucun jalon.", + "invalid": "Année invalide.", + "backLink": "Retour à la timeline", + "totalLabel": "Total", + "categoriesLabel": "Par catégorie", + "topFirstsLabel": "Top firsts", + "topLastsLabel": "Top lasts", + "activeMonthsLabel": "Mois actifs" + } +} diff --git a/apps/mana/apps/web/src/lib/i18n/locales/milestones/it.json b/apps/mana/apps/web/src/lib/i18n/locales/milestones/it.json new file mode 100644 index 000000000..9992f3758 --- /dev/null +++ b/apps/mana/apps/web/src/lib/i18n/locales/milestones/it.json @@ -0,0 +1,27 @@ +{ + "timeline": { + "title": "Pietre miliari", + "tagline": "Prime volte e ultime volte fianco a fianco.", + "empty": "Ancora nessuna pietra miliare.", + "emptyTab": "Niente in questa vista.", + "recapLink": "Riepilogo {year}" + }, + "tabs": { + "all": "Tutte", + "first": "Firsts", + "last": "Lasts" + }, + "recap": { + "title": "{year} in sintesi", + "titleFallback": "Riepilogo annuale", + "tagline": "Cosa è iniziato, cosa è finito.", + "empty": "{year} non ha avuto pietre miliari.", + "invalid": "Anno non valido.", + "backLink": "Torna alla timeline", + "totalLabel": "Totale", + "categoriesLabel": "Per categoria", + "topFirstsLabel": "Top firsts", + "topLastsLabel": "Top lasts", + "activeMonthsLabel": "Mesi attivi" + } +} diff --git a/apps/mana/apps/web/src/lib/modules/firsts/types.ts b/apps/mana/apps/web/src/lib/modules/firsts/types.ts index 0fcfc3879..1c482117e 100644 --- a/apps/mana/apps/web/src/lib/modules/firsts/types.ts +++ b/apps/mana/apps/web/src/lib/modules/firsts/types.ts @@ -1,21 +1,18 @@ import type { BaseRecord } from '@mana/local-store'; +import type { MilestoneCategory } from '$lib/data/milestones/categories'; + +export { CATEGORY_LABELS, CATEGORY_COLORS } from '$lib/data/milestones/categories'; // ─── Enums ──────────────────────────────────────────────── export type FirstStatus = 'dream' | 'lived'; -export type FirstCategory = - | 'culinary' - | 'adventure' - | 'travel' - | 'people' - | 'career' - | 'creative' - | 'nature' - | 'culture' - | 'health' - | 'tech' - | 'other'; +/** + * `FirstCategory` is the same vocabulary as `MilestoneCategory`. Re-exported + * under the local name so existing imports from `firsts/types` keep working; + * the underlying definition lives in `$lib/data/milestones/categories`. + */ +export type FirstCategory = MilestoneCategory; export type FirstPriority = 1 | 2 | 3; // 1 = someday, 2 = this year, 3 = asap @@ -82,34 +79,6 @@ export interface First { // ─── Constants ──────────────────────────────────────────── -export const CATEGORY_LABELS: Record = { - culinary: { de: 'Kulinarisch', en: 'Culinary' }, - adventure: { de: 'Abenteuer', en: 'Adventure' }, - travel: { de: 'Reisen', en: 'Travel' }, - people: { de: 'Menschen', en: 'People' }, - career: { de: 'Beruf', en: 'Career' }, - creative: { de: 'Kreativ', en: 'Creative' }, - nature: { de: 'Natur', en: 'Nature' }, - culture: { de: 'Kultur', en: 'Culture' }, - health: { de: 'Gesundheit', en: 'Health' }, - tech: { de: 'Technik', en: 'Tech' }, - other: { de: 'Sonstiges', en: 'Other' }, -}; - -export const CATEGORY_COLORS: Record = { - culinary: '#f97316', - adventure: '#ef4444', - travel: '#0ea5e9', - people: '#ec4899', - career: '#6366f1', - creative: '#a855f7', - nature: '#22c55e', - culture: '#eab308', - health: '#14b8a6', - tech: '#64748b', - other: '#9ca3af', -}; - export const PRIORITY_LABELS: Record = { 1: { de: 'Irgendwann', en: 'Someday' }, 2: { de: 'Dieses Jahr', en: 'This Year' }, diff --git a/apps/mana/apps/web/src/lib/modules/lasts/ListView.svelte b/apps/mana/apps/web/src/lib/modules/lasts/ListView.svelte new file mode 100644 index 000000000..d64718c27 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/ListView.svelte @@ -0,0 +1,501 @@ + + + +
+ + + + +
+ {#each ['all', 'suspected', 'confirmed', 'reclaimed'] as const as tab} + + {/each} + + {$_('lasts.tabs.inbox')} + {#if inboxCount > 0} + {inboxCount} + {/if} + + + {$_('milestones.timeline.title')} + + +
+ + +
e.preventDefault()} class="quick-add"> +
+ + +
+
+ + +
+
+ + + {#if lasts.length > 5} + + {/if} + + + {#if lasts.length === 0} +

{$_('lasts.list.emptyAll')}

+ {:else if filtered.length === 0} +

{$_('lasts.list.emptyTab')}

+ {:else} +
    + {#each filtered as last (last.id)} +
  • +
    openEntry(last.id)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openEntry(last.id); + } + }} + oncontextmenu={(e) => ctxMenu.open(e, last)} + > +
    + + {last.title} + {#if last.isPinned}{'\u{1f4cc}'}{/if} + + {STATUS_LABELS[last.status].de} + +
    +
    + {#if last.date}{formatDate(last.date)}{/if} + + {CATEGORY_LABELS[last.category].de} + +
    + {#if last.meaning} +

    {last.meaning}

    + {/if} +
    +
  • + {/each} +
+ {/if} + + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/lasts/SharedLastView.svelte b/apps/mana/apps/web/src/lib/modules/lasts/SharedLastView.svelte new file mode 100644 index 000000000..b2baf6da2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/SharedLastView.svelte @@ -0,0 +1,277 @@ + + + +
+
+
+ {CATEGORY_LABELS[entry.category]} + · + {STATUS_LABELS[entry.status]} + {#if entry.date} + · + {entry.date} + {/if} +
+

{entry.title}

+ {#if entry.meaning} +

{entry.meaning}

+ {/if} +
+ + {#if entry.whatIKnewThen || entry.whatIKnowNow} +
+ {#if entry.whatIKnewThen} +
+

Damals

+

{entry.whatIKnewThen}

+
+ {/if} + {#if entry.whatIKnowNow} +
+

Heute

+

{entry.whatIKnowNow}

+
+ {/if} +
+ {/if} + + {#if entry.tenderness !== null || entry.wouldReclaim} + + {/if} + +
+ via Mana Lasts +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/lasts/collections.ts b/apps/mana/apps/web/src/lib/modules/lasts/collections.ts new file mode 100644 index 000000000..7c069488a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/collections.ts @@ -0,0 +1,7 @@ +import { db } from '$lib/data/database'; +import type { LocalLast, LocalLastsCooldown } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const lastTable = db.table('lasts'); +export const lastsCooldownTable = db.table('lastsCooldown'); diff --git a/apps/mana/apps/web/src/lib/modules/lasts/components/DueBanner.svelte b/apps/mana/apps/web/src/lib/modules/lasts/components/DueBanner.svelte new file mode 100644 index 000000000..93a476c1f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/components/DueBanner.svelte @@ -0,0 +1,235 @@ + + + +{#if rows.length > 0} + +{/if} + + diff --git a/apps/mana/apps/web/src/lib/modules/lasts/index.ts b/apps/mana/apps/web/src/lib/modules/lasts/index.ts new file mode 100644 index 000000000..578aecbd2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/index.ts @@ -0,0 +1,25 @@ +// ─── Stores ────────────────────────────────────────────── +export { lastsStore } from './stores/items.svelte'; + +// ─── Queries ───────────────────────────────────────────── +export { useAllLasts, useLastsByStatus, useInboxLasts, toLast, searchLasts } from './queries'; + +// ─── Collections ───────────────────────────────────────── +export { lastTable, lastsCooldownTable } from './collections'; + +// ─── Inference ─────────────────────────────────────────── +export { runInferenceScan, recordDismissal, cooldownIdFor } from './inference/scan'; +export type { ScanResult } from './inference/scan'; +export type { InferenceCandidate, InferenceSource } from './inference/types'; + +// ─── Types ─────────────────────────────────────────────── +export { CATEGORY_LABELS, CATEGORY_COLORS, CONFIDENCE_LABELS, STATUS_LABELS } from './types'; +export type { + LocalLast, + Last, + LastStatus, + LastCategory, + LastConfidence, + WouldReclaim, + InferredFrom, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/lasts/inference/scan.ts b/apps/mana/apps/web/src/lib/modules/lasts/inference/scan.ts new file mode 100644 index 000000000..800330502 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/inference/scan.ts @@ -0,0 +1,128 @@ +/** + * Inference orchestrator. + * + * Runs every registered source-scanner, then filters candidates against: + * 1. Cooldown table — dismissed candidates are silenced for COOLDOWN_DAYS. + * 2. Existing lasts — if a non-deleted last already references this + * (refTable, refId), skip re-suggesting (whether suspected, confirmed, + * or reclaimed — user has already engaged with this candidate). + * + * Surviving candidates are written as new `suspected` lasts with the + * `inferredFrom` provenance set, ready for the Inbox view. + * + * M3 ships only the `places` source. `habits` and `contacts` follow in + * M3.b once those modules expose direct frequency signals (HabitLog + * timestamps without a timeBlocks join, Contact.lastInteractionAt). + */ + +import { db } from '$lib/data/database'; +import { scopedForModule } from '$lib/data/scope'; +import { decryptRecords } from '$lib/data/crypto'; +import { lastsCooldownTable } from '../collections'; +import type { LocalLast, LocalLastsCooldown } from '../types'; +import { placesSource } from './sources/places'; +import { INFERENCE_DEFAULTS, type InferenceCandidate, type InferenceSource } from './types'; + +const SOURCES: InferenceSource[] = [placesSource]; + +/** Read all lasts in the active Space (decrypted). */ +async function loadExistingLasts(): Promise { + const visible = (await scopedForModule('lasts', 'lasts').toArray()).filter( + (l) => !l.deletedAt + ); + // We only need inferredFrom (plaintext) for dedup — no decrypt strictly + // necessary, but decryptRecords no-ops cleanly on already-plaintext fields. + return visible; +} + +async function loadCooldownEntries(): Promise { + return ( + await scopedForModule('lasts', 'lastsCooldown').toArray() + ).filter((c) => !c.deletedAt); +} + +function isCoolingDown( + now: Date, + candidate: InferenceCandidate, + cooldown: LocalLastsCooldown[] +): boolean { + const match = cooldown.find( + (c) => c.refTable === candidate.refTable && c.refId === candidate.refId + ); + if (!match) return false; + const dismissedAt = new Date(match.dismissedAt); + if (Number.isNaN(dismissedAt.getTime())) return false; + const ageDays = Math.floor((now.getTime() - dismissedAt.getTime()) / (1000 * 60 * 60 * 24)); + return ageDays < INFERENCE_DEFAULTS.COOLDOWN_DAYS; +} + +function alreadyHasLast(candidate: InferenceCandidate, existing: LocalLast[]): boolean { + return existing.some( + (l) => + l.inferredFrom?.refTable === candidate.refTable && l.inferredFrom?.refId === candidate.refId + ); +} + +export interface ScanResult { + candidatesProduced: number; // total raw candidates from all sources + cooldownFiltered: number; + existingFiltered: number; + finalCandidates: InferenceCandidate[]; +} + +/** + * Pure scan — runs all sources and applies filters, but does NOT write to + * the lasts table. The caller (store) decides what to do with the result. + * + * Decoupling write-from-scan lets us: + * - unit-test the pipeline without polluting the active Space + * - run a "dry-run" preview in dev tooling + * - let server-side scanners (mana-ai mission, M5+) reuse the same logic + */ +export async function runInferenceScan(now: Date = new Date()): Promise { + const [existing, cooldown] = await Promise.all([loadExistingLasts(), loadCooldownEntries()]); + + let candidatesProduced = 0; + let cooldownFiltered = 0; + let existingFiltered = 0; + const survivors: InferenceCandidate[] = []; + + for (const source of SOURCES) { + const candidates = await source.scan(now); + candidatesProduced += candidates.length; + + for (const c of candidates) { + if (isCoolingDown(now, c, cooldown)) { + cooldownFiltered += 1; + continue; + } + if (alreadyHasLast(c, existing)) { + existingFiltered += 1; + continue; + } + survivors.push(c); + } + } + + return { + candidatesProduced, + cooldownFiltered, + existingFiltered, + finalCandidates: survivors, + }; +} + +/** Deterministic id for a cooldown row — `${refTable}:${refId}`. */ +export function cooldownIdFor(refTable: string, refId: string): string { + return `${refTable}:${refId}`; +} + +/** + * Mark a (refTable, refId) pair as dismissed. Idempotent via deterministic + * id — re-dismissing just refreshes the dismissedAt stamp. + */ +export async function recordDismissal(refTable: string, refId: string): Promise { + const id = cooldownIdFor(refTable, refId); + const now = new Date().toISOString(); + await lastsCooldownTable.put({ id, refTable, refId, dismissedAt: now }); +} diff --git a/apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts b/apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts new file mode 100644 index 000000000..358e543cb --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/inference/sources/places.ts @@ -0,0 +1,90 @@ +/** + * Places inference source. + * + * Heuristic: a Place with `visitCount >= MIN_PRIOR_OCCURRENCES` whose + * `lastVisitedAt` is older than `MIN_SILENCE_DAYS` is a candidate. We + * don't have direct access to per-visit history (would need to scan + * `locationLogs`), so the visit-count + last-visit pair is the proxy + * for "was a regular thing, has stopped". + * + * Category mapping: Place.category → LastCategory by best-effort. Most + * places land in `other` if their PlaceCategory has no clean milestone + * equivalent. + */ + +import { decryptRecords } from '$lib/data/crypto'; +import { scopedForModule } from '$lib/data/scope'; +import type { LocalPlace, PlaceCategory } from '$lib/modules/places/types'; +import { INFERENCE_DEFAULTS, type InferenceCandidate, type InferenceSource } from '../types'; +import type { LastCategory } from '../../types'; + +const PLACE_CATEGORY_MAP: Record = { + home: 'other', + work: 'career', + food: 'culinary', + shopping: 'other', + transit: 'travel', + leisure: 'culture', + other: 'other', +}; + +function daysBetween(a: Date, b: Date): number { + return Math.floor((a.getTime() - b.getTime()) / (1000 * 60 * 60 * 24)); +} + +function silenceLabel(days: number): string { + if (days >= 730) return `${Math.floor(days / 365)} Jahren`; + if (days >= 365) return '1 Jahr'; + const months = Math.floor(days / 30); + return `${months} Monaten`; +} + +export const placesSource: InferenceSource = { + id: 'places', + + async scan(now) { + const visible = ( + await scopedForModule('places', 'places').toArray() + ).filter((p) => !p.deletedAt && !p.isArchived); + // Place names are encrypted in the registry — decrypt before use. + const decrypted = await decryptRecords('places', visible); + + const candidates: InferenceCandidate[] = []; + + for (const place of decrypted) { + const visitCount = place.visitCount ?? 0; + if (visitCount < INFERENCE_DEFAULTS.MIN_PRIOR_OCCURRENCES) continue; + if (!place.lastVisitedAt) continue; + + const lastVisit = new Date(place.lastVisitedAt); + if (Number.isNaN(lastVisit.getTime())) continue; + + const silenceDays = daysBetween(now, lastVisit); + if (silenceDays < INFERENCE_DEFAULTS.MIN_SILENCE_DAYS) continue; + + // Span check: createdAt → lastVisitedAt should cover at least + // MIN_PRIOR_SPAN_DAYS so we know it was a sustained habit, not a + // short burst (e.g. a one-week conference visited 5 days running). + if (place.createdAt) { + const created = new Date(place.createdAt); + const spanDays = daysBetween(lastVisit, created); + if (spanDays < INFERENCE_DEFAULTS.MIN_PRIOR_SPAN_DAYS) continue; + } + + const category = PLACE_CATEGORY_MAP[place.category ?? 'other']; + + candidates.push({ + refTable: 'places', + refId: place.id, + title: `Letztes Mal ${place.name}`, + category, + frequencyHint: `${visitCount}× besucht — seit ${silenceLabel(silenceDays)} nicht mehr`, + suggestedDate: place.lastVisitedAt.slice(0, 10), + }); + } + + // Sort by silence desc (longest gap = oldest lastVisitedAt first) and cap. + candidates.sort((a, b) => (a.suggestedDate ?? '').localeCompare(b.suggestedDate ?? '')); + return candidates.slice(0, INFERENCE_DEFAULTS.MAX_CANDIDATES_PER_SOURCE); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/lasts/inference/types.ts b/apps/mana/apps/web/src/lib/modules/lasts/inference/types.ts new file mode 100644 index 000000000..138159639 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/inference/types.ts @@ -0,0 +1,42 @@ +import type { LastCategory } from '../types'; + +/** + * One inference candidate produced by a source-scanner. The store turns + * each accepted candidate into a `suspected` last with `inferredFrom` set. + */ +export interface InferenceCandidate { + refTable: string; // e.g. 'places' + refId: string; + title: string; // suggested last title + category: LastCategory; + frequencyHint: string; // human-readable "3x/week → 0 in 18mo" + suggestedDate: string | null; // best guess at when it last happened (ISO date) +} + +/** + * Source-scanner contract. Each scanner inspects its own module's data + * and returns candidate lasts. Scanners are pure-ish: they read but + * never write. + */ +export interface InferenceSource { + id: string; // 'places' | 'habits' | … + scan: (now: Date) => Promise; +} + +/** + * Conservative thresholds shared across all sources. Inbox noise is the + * primary failure mode for this module, so defaults are deliberately + * tight. + */ +export const INFERENCE_DEFAULTS = { + /** Minimum prior occurrences to even consider this as a "habit". */ + MIN_PRIOR_OCCURRENCES: 5, + /** Minimum span of prior activity (days) — guards against short bursts. */ + MIN_PRIOR_SPAN_DAYS: 180, + /** Required silence (days) since last occurrence before suggesting. */ + MIN_SILENCE_DAYS: 365, + /** Per-source cap to avoid flooding the Inbox. */ + MAX_CANDIDATES_PER_SOURCE: 3, + /** Cooldown duration after dismiss (days) — re-suggest only if still silent past this. */ + COOLDOWN_DAYS: 365, +} as const; diff --git a/apps/mana/apps/web/src/lib/modules/lasts/lib/reminders.test.ts b/apps/mana/apps/web/src/lib/modules/lasts/lib/reminders.test.ts new file mode 100644 index 000000000..88804a71a --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/lib/reminders.test.ts @@ -0,0 +1,129 @@ +import { describe, expect, it } from 'vitest'; +import { + findAnniversaryLasts, + findRecognitionAnniversaryLasts, + isSameDayOfYear, + yearsBetween, +} from './reminders'; +import type { Last } from '../types'; + +function makeLast(overrides: Partial = {}): Last { + return { + id: overrides.id ?? 'l1', + title: overrides.title ?? 'Test', + status: overrides.status ?? 'confirmed', + category: overrides.category ?? 'other', + confidence: overrides.confidence ?? 'certain', + inferredFrom: overrides.inferredFrom ?? null, + date: overrides.date ?? null, + meaning: overrides.meaning ?? null, + note: overrides.note ?? null, + whatIKnewThen: overrides.whatIKnewThen ?? null, + whatIKnowNow: overrides.whatIKnowNow ?? null, + tenderness: overrides.tenderness ?? null, + wouldReclaim: overrides.wouldReclaim ?? null, + reclaimedAt: overrides.reclaimedAt ?? null, + reclaimedNote: overrides.reclaimedNote ?? null, + personIds: overrides.personIds ?? [], + sharedWith: overrides.sharedWith ?? null, + mediaIds: overrides.mediaIds ?? [], + audioNoteId: overrides.audioNoteId ?? null, + placeId: overrides.placeId ?? null, + recognisedAt: overrides.recognisedAt ?? '2026-04-26T10:00:00Z', + isPinned: overrides.isPinned ?? false, + isArchived: overrides.isArchived ?? false, + visibility: overrides.visibility ?? 'private', + unlistedToken: overrides.unlistedToken ?? '', + unlistedExpiresAt: overrides.unlistedExpiresAt ?? null, + createdAt: overrides.createdAt ?? '2026-04-26T10:00:00Z', + updatedAt: overrides.updatedAt ?? '2026-04-26T10:00:00Z', + }; +} + +describe('isSameDayOfYear', () => { + it('matches same month-day in earlier year', () => { + expect(isSameDayOfYear('2024-04-26', '2026-04-26')).toBe(true); + }); + + it('rejects same year (no anniversary on the day it happened)', () => { + expect(isSameDayOfYear('2026-04-26', '2026-04-26')).toBe(false); + }); + + it('rejects different month-day', () => { + expect(isSameDayOfYear('2024-04-25', '2026-04-26')).toBe(false); + expect(isSameDayOfYear('2024-05-26', '2026-04-26')).toBe(false); + }); + + it('rejects future dates', () => { + expect(isSameDayOfYear('2030-04-26', '2026-04-26')).toBe(false); + }); + + it('handles ISO timestamps too', () => { + expect(isSameDayOfYear('2024-04-26T10:00:00Z', '2026-04-26')).toBe(true); + }); + + it('rejects malformed input', () => { + expect(isSameDayOfYear('not-a-date', '2026-04-26')).toBe(false); + expect(isSameDayOfYear('', '2026-04-26')).toBe(false); + }); +}); + +describe('yearsBetween', () => { + it('counts whole-year diff (ignores month-day)', () => { + expect(yearsBetween('2024-12-31', '2026-04-26')).toBe(2); + expect(yearsBetween('2024-01-01', '2026-04-26')).toBe(2); + }); + + it('returns 0 for malformed input', () => { + expect(yearsBetween('xx', '2026-04-26')).toBe(0); + }); +}); + +describe('findAnniversaryLasts', () => { + const today = '2026-04-26'; + + it('surfaces confirmed lasts whose date hits today', () => { + const a = makeLast({ id: 'a', status: 'confirmed', date: '2024-04-26' }); + const b = makeLast({ id: 'b', status: 'confirmed', date: '2025-04-26' }); + const c = makeLast({ id: 'c', status: 'confirmed', date: '2024-04-25' }); + const result = findAnniversaryLasts([a, b, c], today); + expect(result.map((l) => l.id).sort()).toEqual(['a', 'b']); + }); + + it('skips suspected and reclaimed', () => { + const sus = makeLast({ id: 'sus', status: 'suspected', date: '2024-04-26' }); + const rec = makeLast({ id: 'rec', status: 'reclaimed', date: '2024-04-26' }); + const conf = makeLast({ id: 'conf', status: 'confirmed', date: '2024-04-26' }); + const result = findAnniversaryLasts([sus, rec, conf], today); + expect(result.map((l) => l.id)).toEqual(['conf']); + }); + + it('skips lasts without a date', () => { + const noDate = makeLast({ id: 'x', status: 'confirmed', date: null }); + expect(findAnniversaryLasts([noDate], today)).toEqual([]); + }); +}); + +describe('findRecognitionAnniversaryLasts', () => { + const today = '2026-04-26'; + + it('surfaces lasts where recognisedAt hits today, regardless of status', () => { + const sus = makeLast({ + id: 'sus', + status: 'suspected', + recognisedAt: '2024-04-26T08:00:00Z', + }); + const rec = makeLast({ + id: 'rec', + status: 'reclaimed', + recognisedAt: '2025-04-26T08:00:00Z', + }); + const off = makeLast({ + id: 'off', + status: 'confirmed', + recognisedAt: '2024-03-15T08:00:00Z', + }); + const result = findRecognitionAnniversaryLasts([sus, rec, off], today); + expect(result.map((l) => l.id).sort()).toEqual(['rec', 'sus']); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/lasts/lib/reminders.ts b/apps/mana/apps/web/src/lib/modules/lasts/lib/reminders.ts new file mode 100644 index 000000000..b11066e24 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/lib/reminders.ts @@ -0,0 +1,84 @@ +/** + * Lasts — Anniversary + Recognition reminder helpers. + * + * Pure date math, no I/O. Surfaces lasts whose anniversary is *today* + * within the in-app DueBanner (no OS push — that needs PWA push + * infrastructure that doesn't exist yet, see docs/plans/lasts-module.md + * M5.b). + * + * Strategy: + * - Anniversary = `last.date` month/day matches today, year strictly + * less than today's year, status === 'confirmed' (only confirmed + * dates are anchored facts worth celebrating). Fires every year on + * the anniversary day. + * - Recognition anniversary = `last.recognisedAt` month/day matches + * today with year < today's year. Independent of status because + * the act of recognising itself is the milestone (a reclaimed last + * can still have a meaningful "I noticed this 2 years ago" stamp). + */ + +import type { Last } from '../types'; + +interface MonthDay { + month: number; // 1-12 + day: number; // 1-31 +} + +function todayIso(): string { + return new Date().toISOString().slice(0, 10); +} + +function parseMonthDay(iso: string): MonthDay | null { + if (typeof iso !== 'string' || iso.length < 10) return null; + const month = Number(iso.slice(5, 7)); + const day = Number(iso.slice(8, 10)); + if (!Number.isInteger(month) || month < 1 || month > 12) return null; + if (!Number.isInteger(day) || day < 1 || day > 31) return null; + return { month, day }; +} + +function parseYear(iso: string): number | null { + const y = Number(iso.slice(0, 4)); + return Number.isInteger(y) && y > 1900 ? y : null; +} + +/** Years between two ISO dates (today - past). 0 if same year, ignores month/day. */ +export function yearsBetween(pastIso: string, todayIso: string): number { + const past = parseYear(pastIso); + const now = parseYear(todayIso); + if (past == null || now == null) return 0; + return now - past; +} + +/** + * True if `pastIso` falls on the same month-day as `today` in a strictly + * earlier year. Returns false for same-year (no anniversary on the day + * something happened). + */ +export function isSameDayOfYear(pastIso: string, today: string = todayIso()): boolean { + const past = parseMonthDay(pastIso); + const now = parseMonthDay(today); + if (!past || !now) return false; + if (past.month !== now.month || past.day !== now.day) return false; + const py = parseYear(pastIso); + const ny = parseYear(today); + if (py == null || ny == null) return false; + return py < ny; +} + +/** Lasts whose `date` is an anniversary today. Confirmed only. */ +export function findAnniversaryLasts(lasts: Last[], today: string = todayIso()): Last[] { + return lasts.filter((l) => { + if (l.status !== 'confirmed') return false; + if (!l.date) return false; + return isSameDayOfYear(l.date, today); + }); +} + +/** Lasts whose `recognisedAt` is an anniversary today. Any status. */ +export function findRecognitionAnniversaryLasts(lasts: Last[], today: string = todayIso()): Last[] { + return lasts.filter((l) => { + if (!l.recognisedAt) return false; + return isSameDayOfYear(l.recognisedAt, today); + }); +} diff --git a/apps/mana/apps/web/src/lib/modules/lasts/module.config.ts b/apps/mana/apps/web/src/lib/modules/lasts/module.config.ts new file mode 100644 index 000000000..b9332475d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const lastsModuleConfig: ModuleConfig = { + appId: 'lasts', + tables: [{ name: 'lasts' }, { name: 'lastsCooldown' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/lasts/queries.ts b/apps/mana/apps/web/src/lib/modules/lasts/queries.ts new file mode 100644 index 000000000..892352d72 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/queries.ts @@ -0,0 +1,117 @@ +import { useScopedLiveQuery } from '$lib/data/scope/use-scoped-live-query.svelte'; +import { scopedForModule } from '$lib/data/scope'; +import { decryptRecords } from '$lib/data/crypto'; +import type { Last, LastStatus, LocalLast } from './types'; + +// ─── Type Converter ──────────────────────────────────────── + +export function toLast(local: LocalLast): Last { + return { + id: local.id, + title: local.title, + status: local.status, + category: local.category, + confidence: local.confidence, + inferredFrom: local.inferredFrom, + date: local.date, + meaning: local.meaning, + note: local.note, + whatIKnewThen: local.whatIKnewThen, + whatIKnowNow: local.whatIKnowNow, + tenderness: local.tenderness, + wouldReclaim: local.wouldReclaim, + reclaimedAt: local.reclaimedAt, + reclaimedNote: local.reclaimedNote, + personIds: local.personIds ?? [], + sharedWith: local.sharedWith, + mediaIds: local.mediaIds ?? [], + audioNoteId: local.audioNoteId, + placeId: local.placeId, + recognisedAt: local.recognisedAt ?? local.createdAt ?? new Date().toISOString(), + isPinned: local.isPinned, + isArchived: local.isArchived, + visibility: local.visibility ?? 'private', + unlistedToken: local.unlistedToken ?? '', + unlistedExpiresAt: local.unlistedExpiresAt ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +/** + * All non-archived, non-deleted lasts in the active Space, sorted with + * pinned first, then by date desc (suspected/confirmed) and reclaimed + * pushed to the bottom. + */ +export function useAllLasts() { + return useScopedLiveQuery(async () => { + const visible = (await scopedForModule('lasts', 'lasts').toArray()).filter( + (l) => !l.deletedAt && !l.isArchived + ); + const decrypted = await decryptRecords('lasts', visible); + return decrypted.map(toLast).sort(compareLasts); + }, [] as Last[]); +} + +export function useLastsByStatus(status: LastStatus) { + return useScopedLiveQuery(async () => { + const visible = (await scopedForModule('lasts', 'lasts').toArray()).filter( + (l) => !l.deletedAt && !l.isArchived && l.status === status + ); + const decrypted = await decryptRecords('lasts', visible); + return decrypted.map(toLast).sort(compareLasts); + }, [] as Last[]); +} + +/** + * Inbox = AI-inferred suggestions still pending review. A `Last` enters + * the Inbox when the inference scanner writes it (status='suspected', + * inferredFrom != null) and leaves it when the user either accepts + * (clears inferredFrom) or dismisses (delete + cooldown). + */ +export function useInboxLasts() { + return useScopedLiveQuery(async () => { + const visible = (await scopedForModule('lasts', 'lasts').toArray()).filter( + (l) => !l.deletedAt && !l.isArchived && l.status === 'suspected' && l.inferredFrom != null + ); + const decrypted = await decryptRecords('lasts', visible); + // Newest scans first so users see the most recent inference batch. + return decrypted.map(toLast).sort((a, b) => b.recognisedAt.localeCompare(a.recognisedAt)); + }, [] as Last[]); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +function compareLasts(a: Last, b: Last): number { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + // Reclaimed pushed to bottom + const aReclaimed = a.status === 'reclaimed'; + const bReclaimed = b.status === 'reclaimed'; + if (aReclaimed !== bReclaimed) return aReclaimed ? 1 : -1; + // Newest date first; fall back to createdAt + const aKey = a.date ?? a.createdAt; + const bKey = b.date ?? b.createdAt; + return bKey.localeCompare(aKey); +} + +export function searchLasts(lasts: Last[], query: string): Last[] { + if (!query.trim()) return lasts; + const q = query.toLowerCase(); + return lasts.filter((l) => { + const haystack = [ + l.title, + l.meaning, + l.note, + l.whatIKnewThen, + l.whatIKnowNow, + l.sharedWith, + l.reclaimedNote, + ] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(q); + }); +} diff --git a/apps/mana/apps/web/src/lib/modules/lasts/stores/items.svelte.ts b/apps/mana/apps/web/src/lib/modules/lasts/stores/items.svelte.ts new file mode 100644 index 000000000..28bd79145 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/stores/items.svelte.ts @@ -0,0 +1,444 @@ +import { lastTable } from '../collections'; +import { toLast } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; +import { runInferenceScan, recordDismissal, type ScanResult } from '../inference/scan'; +import { + publishUnlistedSnapshot, + revokeUnlistedSnapshot, + type VisibilityLevel, +} from '@mana/shared-privacy'; +import { buildUnlistedBlob } from '$lib/data/unlisted/resolvers'; +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; +import { getActiveSpace } from '$lib/data/scope'; +import { getEffectiveUserId } from '$lib/data/current-user'; +import type { + InferredFrom, + Last, + LastCategory, + LastConfidence, + LocalLast, + WouldReclaim, +} from '../types'; + +function nowIso(): string { + return new Date().toISOString(); +} + +function todayIsoDate(): string { + return nowIso().slice(0, 10); +} + +export const lastsStore = { + /** + * Create a new "suspected" last — manually marked or AI-inferred. AI-inferred + * records pass `inferredFrom` so the inbox can show provenance. + */ + async createSuspected(data: { + title: string; + category?: LastCategory; + confidence?: LastConfidence | null; + date?: string | null; + meaning?: string | null; + note?: string | null; + personIds?: string[]; + placeId?: string | null; + inferredFrom?: InferredFrom | null; + }): Promise { + const id = crypto.randomUUID(); + const now = nowIso(); + const newLocal: LocalLast = { + id, + title: data.title, + status: 'suspected', + category: data.category ?? 'other', + confidence: data.confidence ?? null, + inferredFrom: data.inferredFrom ?? null, + date: data.date ?? null, + meaning: data.meaning ?? null, + note: data.note ?? null, + whatIKnewThen: null, + whatIKnowNow: null, + tenderness: null, + wouldReclaim: null, + reclaimedAt: null, + reclaimedNote: null, + personIds: data.personIds ?? [], + sharedWith: null, + mediaIds: [], + audioNoteId: null, + placeId: data.placeId ?? null, + recognisedAt: now, + isPinned: false, + isArchived: false, + }; + + const plaintextSnapshot = toLast(newLocal); + await encryptRecord('lasts', newLocal); + await lastTable.add(newLocal); + return plaintextSnapshot; + }, + + /** + * Create a confirmed last directly (skip suspected — used when the user + * already knows it was the last time, e.g. last day at the old job). + */ + async createConfirmed(data: { + title: string; + category?: LastCategory; + date?: string; + meaning?: string | null; + note?: string | null; + whatIKnewThen?: string | null; + whatIKnowNow?: string | null; + tenderness?: number | null; + wouldReclaim?: WouldReclaim | null; + personIds?: string[]; + placeId?: string | null; + }): Promise { + const id = crypto.randomUUID(); + const now = nowIso(); + const newLocal: LocalLast = { + id, + title: data.title, + status: 'confirmed', + category: data.category ?? 'other', + confidence: 'certain', + inferredFrom: null, + date: data.date ?? todayIsoDate(), + meaning: data.meaning ?? null, + note: data.note ?? null, + whatIKnewThen: data.whatIKnewThen ?? null, + whatIKnowNow: data.whatIKnowNow ?? null, + tenderness: data.tenderness ?? null, + wouldReclaim: data.wouldReclaim ?? null, + reclaimedAt: null, + reclaimedNote: null, + personIds: data.personIds ?? [], + sharedWith: null, + mediaIds: [], + audioNoteId: null, + placeId: data.placeId ?? null, + recognisedAt: now, + isPinned: false, + isArchived: false, + }; + + const plaintextSnapshot = toLast(newLocal); + await encryptRecord('lasts', newLocal); + await lastTable.add(newLocal); + return plaintextSnapshot; + }, + + async confirmLast( + id: string, + data: { + date?: string; + meaning?: string | null; + whatIKnewThen?: string | null; + whatIKnowNow?: string | null; + tenderness?: number | null; + wouldReclaim?: WouldReclaim | null; + } + ) { + const diff: Partial = { + status: 'confirmed', + date: data.date ?? todayIsoDate(), + confidence: 'certain', + meaning: data.meaning ?? null, + whatIKnewThen: data.whatIKnewThen ?? null, + whatIKnowNow: data.whatIKnowNow ?? null, + tenderness: data.tenderness ?? null, + wouldReclaim: data.wouldReclaim ?? null, + updatedAt: nowIso(), + }; + await encryptRecord('lasts', diff); + await lastTable.update(id, diff); + }, + + /** + * Mark a last as reclaimed — it happened again. Keeps the row in history + * but pushes it out of the main feed (queries sort reclaimed to the bottom). + */ + async reclaimLast(id: string, reclaimedNote: string | null = null) { + const diff: Partial = { + status: 'reclaimed', + reclaimedAt: nowIso(), + reclaimedNote, + updatedAt: nowIso(), + }; + await encryptRecord('lasts', diff); + await lastTable.update(id, diff); + }, + + async updateLast( + id: string, + data: Partial< + Pick< + LocalLast, + | 'title' + | 'category' + | 'confidence' + | 'date' + | 'meaning' + | 'note' + | 'whatIKnewThen' + | 'whatIKnowNow' + | 'tenderness' + | 'wouldReclaim' + | 'personIds' + | 'sharedWith' + | 'mediaIds' + | 'audioNoteId' + | 'placeId' + | 'isPinned' + | 'isArchived' + > + > + ) { + const diff: Partial = { + ...data, + updatedAt: nowIso(), + }; + await encryptRecord('lasts', diff); + await lastTable.update(id, diff); + }, + + async deleteLast(id: string) { + await lastTable.update(id, { + deletedAt: nowIso(), + updatedAt: nowIso(), + }); + }, + + async togglePin(id: string) { + const last = await lastTable.get(id); + if (!last) return; + await lastTable.update(id, { + isPinned: !last.isPinned, + updatedAt: nowIso(), + }); + }, + + async archiveLast(id: string) { + await lastTable.update(id, { + isArchived: true, + updatedAt: nowIso(), + }); + }, + + // ── Inbox / Inference ────────────────────────────────────── + + /** + * Run the inference scanner and persist surviving candidates as + * suspected lasts with `inferredFrom` set. Returns the scan summary + * + the count actually written, so the UI can show "3 neue Vorschläge". + */ + async suggestLasts(): Promise { + const result = await runInferenceScan(); + const now = nowIso(); + let written = 0; + + for (const candidate of result.finalCandidates) { + const id = crypto.randomUUID(); + const newLocal: LocalLast = { + id, + title: candidate.title, + status: 'suspected', + category: candidate.category, + confidence: 'likely', + inferredFrom: { + tool: 'suggest_lasts', + refTable: candidate.refTable, + refId: candidate.refId, + frequencyHint: candidate.frequencyHint, + scannedAt: now, + }, + date: candidate.suggestedDate, + meaning: null, + note: null, + whatIKnewThen: null, + whatIKnowNow: null, + tenderness: null, + wouldReclaim: null, + reclaimedAt: null, + reclaimedNote: null, + personIds: [], + sharedWith: null, + mediaIds: [], + audioNoteId: null, + placeId: candidate.refTable === 'places' ? candidate.refId : null, + recognisedAt: now, + isPinned: false, + isArchived: false, + }; + + await encryptRecord('lasts', newLocal); + await lastTable.add(newLocal); + written += 1; + } + + return { ...result, written }; + }, + + /** + * "Akzeptieren" from the Inbox — keep the entry as suspected but drop + * the inferredFrom marker so it leaves the Inbox view and lives in the + * normal feed alongside user-marked entries. + */ + async acceptCandidate(id: string) { + await lastTable.update(id, { + inferredFrom: null, + updatedAt: nowIso(), + }); + }, + + /** + * "Verwerfen" from the Inbox — soft-delete the entry and record the + * dismissal in the cooldown table so the scanner doesn't re-suggest + * the same (refTable, refId) for COOLDOWN_DAYS. + */ + async dismissCandidate(id: string) { + const last = await lastTable.get(id); + if (last?.inferredFrom) { + await recordDismissal(last.inferredFrom.refTable, last.inferredFrom.refId); + } + await this.deleteLast(id); + }, + + // ── Visibility / Unlisted-Sharing ────────────────────────── + + /** + * Change a last's visibility. Transitions to/from `unlisted` publish + * or revoke the server-side snapshot blob. Server is authoritative for + * the share token. Reclaimed lasts are blocked from going public — + * the unlisted resolver also rejects them defensively. + */ + async setVisibility(id: string, next: VisibilityLevel) { + const existing = await lastTable.get(id); + if (!existing) throw new Error(`Last ${id} not found`); + const before: VisibilityLevel = existing.visibility ?? 'private'; + if (before === next) return; + + if (next === 'unlisted' && existing.status === 'reclaimed') { + throw new Error('Aufgehobene Lasts können nicht öffentlich geteilt werden.'); + } + + const now = nowIso(); + const patch: Partial = { + visibility: next, + visibilityChangedAt: now, + visibilityChangedBy: getEffectiveUserId() ?? undefined, + updatedAt: now, + }; + + if (next === 'unlisted') { + const jwt = await authStore.getValidToken(); + if (!jwt) throw new Error('Nicht eingeloggt — Share-Link kann nicht erzeugt werden.'); + const blob = await buildUnlistedBlob('lasts', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'lasts', + recordId: id, + spaceId, + blob, + }); + patch.unlistedToken = token; + patch.unlistedExpiresAt = null; + } else if (before === 'unlisted') { + const jwt = await authStore.getValidToken(); + if (jwt) { + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'lasts', + recordId: id, + }); + } catch { + // Server may already have garbage-collected the row; the local + // state-flip below is still correct. + } + } + patch.unlistedToken = ''; + patch.unlistedExpiresAt = null; + } + + await lastTable.update(id, patch); + }, + + /** + * Rotate the share token for an unlisted last. Useful when the user + * suspects the link leaked — old URL stops working immediately, new + * one carries the same expiry (if any) for continuity. + */ + async regenerateUnlistedToken(id: string) { + const existing = await lastTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return null; + const jwt = await authStore.getValidToken(); + if (!jwt) return null; + + try { + await revokeUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'lasts', + recordId: id, + }); + } catch { + // Same defensive behavior as setVisibility — proceed even if the + // old snapshot is already gone server-side. + } + + const blob = await buildUnlistedBlob('lasts', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'lasts', + recordId: id, + spaceId, + blob, + expiresAt: existing.unlistedExpiresAt ? new Date(existing.unlistedExpiresAt) : undefined, + }); + await lastTable.update(id, { + unlistedToken: token, + updatedAt: nowIso(), + }); + return token; + }, + + /** + * Update the auto-revoke deadline of an unlisted snapshot. `null` + * means "never expires". The server re-publishes the same blob with + * the new TTL. + */ + async setUnlistedExpiry(id: string, expiresAt: Date | null) { + const existing = await lastTable.get(id); + if (!existing || existing.visibility !== 'unlisted') return; + const jwt = await authStore.getValidToken(); + if (!jwt) return; + + const blob = await buildUnlistedBlob('lasts', id); + const spaceId = + (existing as unknown as { spaceId?: string }).spaceId ?? getActiveSpace()?.id ?? ''; + const { token } = await publishUnlistedSnapshot({ + apiUrl: getManaApiUrl(), + jwt, + collection: 'lasts', + recordId: id, + spaceId, + blob, + expiresAt: expiresAt ?? undefined, + }); + await lastTable.update(id, { + unlistedToken: token, + unlistedExpiresAt: expiresAt ? expiresAt.toISOString() : null, + updatedAt: nowIso(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/lasts/stores/settings.svelte.ts b/apps/mana/apps/web/src/lib/modules/lasts/stores/settings.svelte.ts new file mode 100644 index 000000000..0c4bcc14d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/stores/settings.svelte.ts @@ -0,0 +1,56 @@ +/** + * Lasts Settings Store — opt-in toggles for the in-app DueBanner. + * + * No OS push integration yet (see docs/plans/lasts-module.md M5.b). + * These flags only gate which categories of "today touches a last" + * surfacing happens inside the app shell. + * + * Persisted in localStorage via the shared `createAppSettingsStore` + * factory — same pattern as todo/broadcast/invoices module settings. + */ + +import { createAppSettingsStore } from '@mana/shared-stores'; + +export interface LastsAppSettings extends Record { + /** Show "vor X Jahren das letzte Mal …" for confirmed lasts on their anniversary day. */ + anniversaryReminders: boolean; + /** Show "vor X Jahren als Last erkannt: …" on recognisedAt anniversary. */ + recognitionReminders: boolean; + /** Surface "X neue Vorschläge in der Inbox" badge on the banner. */ + inboxNotify: boolean; + /** Hard cap on banner items shown at once. Inbox notify counts as one. */ + bannerMaxItems: number; +} + +const DEFAULT_SETTINGS: LastsAppSettings = { + anniversaryReminders: true, + recognitionReminders: true, + inboxNotify: true, + bannerMaxItems: 3, +}; + +const baseStore = createAppSettingsStore('lasts-settings', DEFAULT_SETTINGS); + +export const lastsSettings = { + get settings() { + return baseStore.settings; + }, + initialize: baseStore.initialize, + set: baseStore.set, + update: baseStore.update, + reset: baseStore.reset, + getDefaults: baseStore.getDefaults, + + get anniversaryReminders() { + return baseStore.settings.anniversaryReminders; + }, + get recognitionReminders() { + return baseStore.settings.recognitionReminders; + }, + get inboxNotify() { + return baseStore.settings.inboxNotify; + }, + get bannerMaxItems() { + return baseStore.settings.bannerMaxItems; + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/lasts/tools.ts b/apps/mana/apps/web/src/lib/modules/lasts/tools.ts new file mode 100644 index 000000000..ed90a348c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/tools.ts @@ -0,0 +1,282 @@ +/** + * Lasts tools — AI-accessible CRUD + inference for the lasts module. + * + * Propose: + * - create_last — new last (suspected | confirmed) + * - confirm_last — suspected → confirmed with reflection fields + * - reclaim_last — confirmed → reclaimed + * + * Auto: + * - list_lasts — filtered by status + category + * - suggest_lasts — runs inference scan, writes survivors as suspected + * with inferredFrom set; user reviews via /lasts/inbox + */ + +import type { ModuleTool } from '$lib/data/tools/types'; +import { lastsStore } from './stores/items.svelte'; +import { lastTable } from './collections'; +import { decryptRecords, VaultLockedError } from '$lib/data/crypto'; +import { toLast } from './queries'; +import type { LastCategory, LastConfidence, LastStatus, LocalLast, WouldReclaim } from './types'; +import { MILESTONE_CATEGORIES } from '$lib/data/milestones/categories'; + +const STATUSES: readonly LastStatus[] = ['suspected', 'confirmed', 'reclaimed']; +const CONFIDENCES: readonly LastConfidence[] = ['probably', 'likely', 'certain']; +const WOULD_RECLAIM: readonly WouldReclaim[] = ['no', 'maybe', 'yes']; + +function asCategory(raw: unknown, fallback: LastCategory = 'other'): LastCategory { + if (typeof raw !== 'string') return fallback; + return (MILESTONE_CATEGORIES as readonly string[]).includes(raw) + ? (raw as LastCategory) + : fallback; +} + +function asEnum(raw: unknown, allowed: readonly T[]): T | undefined { + if (typeof raw !== 'string') return undefined; + return (allowed as readonly string[]).includes(raw) ? (raw as T) : undefined; +} + +function asTrimmedString(raw: unknown): string { + return typeof raw === 'string' ? raw.trim() : ''; +} + +export const lastsTools: ModuleTool[] = [ + { + name: 'create_last', + module: 'lasts', + description: 'Erstellt einen neuen Last (suspected oder confirmed)', + parameters: [ + { name: 'title', type: 'string', description: 'Titel', required: true }, + { name: 'category', type: 'string', description: 'Kategorie', required: false }, + { name: 'status', type: 'string', description: 'suspected | confirmed', required: false }, + { name: 'date', type: 'string', description: 'YYYY-MM-DD', required: false }, + { + name: 'confidence', + type: 'string', + description: 'probably | likely | certain', + required: false, + }, + { name: 'meaning', type: 'string', description: 'Bedeutung', required: false }, + { name: 'note', type: 'string', description: 'Notiz', required: false }, + ], + async execute(params) { + const title = asTrimmedString(params.title); + if (!title) return { success: false, message: 'title darf nicht leer sein' }; + + const category = asCategory(params.category); + const status = asEnum(params.status, ['suspected', 'confirmed']) ?? 'suspected'; + const meaning = asTrimmedString(params.meaning) || undefined; + const note = asTrimmedString(params.note) || undefined; + const date = typeof params.date === 'string' ? params.date.slice(0, 10) : undefined; + + if (status === 'confirmed') { + const last = await lastsStore.createConfirmed({ + title, + category, + date, + meaning: meaning ?? null, + note: note ?? null, + }); + return { + success: true, + data: { lastId: last.id, status: last.status, title: last.title }, + message: `Bestätigter Last: "${title}"`, + }; + } + + const confidence = asEnum(params.confidence, CONFIDENCES); + const last = await lastsStore.createSuspected({ + title, + category, + confidence: confidence ?? null, + date: date ?? null, + meaning: meaning ?? null, + note: note ?? null, + }); + return { + success: true, + data: { lastId: last.id, status: last.status, title: last.title }, + message: `Vermuteter Last: "${title}"`, + }; + }, + }, + + { + name: 'confirm_last', + module: 'lasts', + description: 'Bewegt einen Last von suspected auf confirmed mit Reflexion', + parameters: [ + { name: 'lastId', type: 'string', description: 'ID', required: true }, + { name: 'date', type: 'string', description: 'YYYY-MM-DD', required: false }, + { name: 'meaning', type: 'string', description: 'Bedeutung', required: false }, + { + name: 'whatIKnewThen', + type: 'string', + description: 'Was du damals nicht wusstest', + required: false, + }, + { + name: 'whatIKnowNow', + type: 'string', + description: 'Was du heute siehst', + required: false, + }, + { + name: 'tenderness', + type: 'number', + description: '1-5', + required: false, + }, + { + name: 'wouldReclaim', + type: 'string', + description: 'no | maybe | yes', + required: false, + }, + ], + async execute(params) { + const lastId = asTrimmedString(params.lastId); + if (!lastId) return { success: false, message: 'lastId darf nicht leer sein' }; + + const existing = await lastTable.get(lastId); + if (!existing || existing.deletedAt) { + return { success: false, message: `Last ${lastId} nicht gefunden` }; + } + + const tendernessRaw = params.tenderness; + let tenderness: number | null = null; + if (typeof tendernessRaw === 'number') { + if (tendernessRaw < 1 || tendernessRaw > 5) { + return { success: false, message: 'tenderness muss zwischen 1 und 5 liegen' }; + } + tenderness = Math.round(tendernessRaw); + } + + await lastsStore.confirmLast(lastId, { + date: typeof params.date === 'string' ? params.date.slice(0, 10) : undefined, + meaning: asTrimmedString(params.meaning) || null, + whatIKnewThen: asTrimmedString(params.whatIKnewThen) || null, + whatIKnowNow: asTrimmedString(params.whatIKnowNow) || null, + tenderness, + wouldReclaim: asEnum(params.wouldReclaim, WOULD_RECLAIM) ?? null, + }); + return { + success: true, + data: { lastId, status: 'confirmed' }, + message: 'Last bestätigt', + }; + }, + }, + + { + name: 'reclaim_last', + module: 'lasts', + description: 'Markiert einen Last als aufgehoben (es ist wieder passiert)', + parameters: [ + { name: 'lastId', type: 'string', description: 'ID', required: true }, + { + name: 'reclaimedNote', + type: 'string', + description: 'Was ist wieder passiert', + required: false, + }, + ], + async execute(params) { + const lastId = asTrimmedString(params.lastId); + if (!lastId) return { success: false, message: 'lastId darf nicht leer sein' }; + + const existing = await lastTable.get(lastId); + if (!existing || existing.deletedAt) { + return { success: false, message: `Last ${lastId} nicht gefunden` }; + } + if (existing.status !== 'confirmed') { + return { + success: false, + message: `Nur confirmed Lasts können aufgehoben werden (aktuell: ${existing.status})`, + }; + } + + await lastsStore.reclaimLast(lastId, asTrimmedString(params.reclaimedNote) || null); + return { + success: true, + data: { lastId, status: 'reclaimed' }, + message: 'Last aufgehoben', + }; + }, + }, + + { + name: 'list_lasts', + module: 'lasts', + description: 'Listet Lasts (filterbar nach status + category)', + parameters: [ + { name: 'status', type: 'string', description: 'Status-Filter', required: false }, + { name: 'category', type: 'string', description: 'Kategorie-Filter', required: false }, + { name: 'limit', type: 'number', description: 'Max (Standard 30)', required: false }, + ], + async execute(params) { + const statusFilter = asEnum(params.status, STATUSES); + const categoryFilter = asEnum( + params.category, + MILESTONE_CATEGORIES as readonly LastCategory[] + ); + const limit = Math.min(Math.max(Number(params.limit) || 30, 1), 100); + + try { + const all = await lastTable.toArray(); + const visible = all.filter((l) => !l.deletedAt && !l.isArchived); + const decrypted = await decryptRecords('lasts', visible); + const rows = decrypted + .map(toLast) + .filter((l) => (statusFilter ? l.status === statusFilter : true)) + .filter((l) => (categoryFilter ? l.category === categoryFilter : true)) + .sort((a, b) => (b.date ?? b.createdAt).localeCompare(a.date ?? a.createdAt)) + .slice(0, limit) + .map((l) => ({ + id: l.id, + title: l.title, + status: l.status, + category: l.category, + date: l.date, + tenderness: l.tenderness, + inferred: l.inferredFrom != null, + })); + + return { + success: true, + data: { lasts: rows, total: rows.length }, + message: `${rows.length} Last(s) gelistet`, + }; + } catch (err) { + if (err instanceof VaultLockedError) { + return { + success: false, + message: 'Vault ist gesperrt — Lasts können nicht entschlüsselt werden', + }; + } + throw err; + } + }, + }, + + { + name: 'suggest_lasts', + module: 'lasts', + description: + 'Scannt places/habits/contacts auf Frequenz-Drops und schreibt Vorschläge als suspected Lasts in die Inbox', + parameters: [], + async execute() { + const result = await lastsStore.suggestLasts(); + return { + success: true, + data: { + written: result.written, + cooldownFiltered: result.cooldownFiltered, + existingFiltered: result.existingFiltered, + candidatesProduced: result.candidatesProduced, + }, + message: `${result.written} neue Vorschläge in der Inbox (${result.cooldownFiltered} im Cooldown übersprungen, ${result.existingFiltered} schon bekannt).`, + }; + }, + }, +]; diff --git a/apps/mana/apps/web/src/lib/modules/lasts/types.ts b/apps/mana/apps/web/src/lib/modules/lasts/types.ts new file mode 100644 index 000000000..5077ab7b2 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/types.ts @@ -0,0 +1,147 @@ +import type { BaseRecord } from '@mana/local-store'; +import type { VisibilityLevel } from '@mana/shared-privacy'; +import type { MilestoneCategory } from '$lib/data/milestones/categories'; + +export { CATEGORY_LABELS, CATEGORY_COLORS } from '$lib/data/milestones/categories'; + +// ─── Enums ──────────────────────────────────────────────── + +/** + * Lifecycle of a `Last`. + * + * - `suspected` — vermutet (vom User markiert oder von der Inferenz vorgeschlagen, + * noch nicht bestätigt). Standard für AI-Vorschläge in der Inbox. + * - `confirmed` — bestätigt mit Datum und Reflexion. + * - `reclaimed` — aufgehoben: doch wieder passiert. Bleibt in der History, + * erscheint aber nicht mehr im Hauptfeed. + */ +export type LastStatus = 'suspected' | 'confirmed' | 'reclaimed'; + +/** Sicherheit, dass es das letzte Mal war. */ +export type LastConfidence = 'probably' | 'likely' | 'certain'; + +export type LastCategory = MilestoneCategory; + +export type WouldReclaim = 'no' | 'maybe' | 'yes'; + +/** + * Provenance für AI-inferred Vorschläge: woher der Scanner den Last-Kandidaten + * abgeleitet hat. `null` für manuell angelegte Einträge. + */ +export interface InferredFrom { + tool: string; // e.g. 'suggest_lasts' + refTable: string; // 'places' | 'contacts' | 'food' | 'habits' | … + refId: string; + frequencyHint?: string; // human-readable: '3x/week → 0 in 18mo' + scannedAt: string; // ISO +} + +// ─── Cooldown ───────────────────────────────────────────── + +/** + * Records a dismissed inference candidate so the scanner skips it for + * ~12 months. ID is `${refTable}:${refId}` for structural idempotency. + */ +export interface LocalLastsCooldown extends BaseRecord { + refTable: string; + refId: string; + dismissedAt: string; // ISO +} + +// ─── Local Record Types (Dexie) ─────────────────────────── + +export interface LocalLast extends BaseRecord { + title: string; + status: LastStatus; + category: LastCategory; + + // Recognition phase + confidence: LastConfidence | null; + inferredFrom: InferredFrom | null; + + // 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; + reclaimedNote: string | null; + + // 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 für AI-inferred) + isPinned: boolean; + isArchived: boolean; + + // Visibility / unlisted-sharing (M6) — optional on the local record + // because legacy rows pre-date the field; the default is `'private'` + // (intim default for lasts, anders als firsts). `toLast` narrows to a + // non-optional VisibilityLevel for callers. + visibility?: VisibilityLevel; + visibilityChangedAt?: string; + visibilityChangedBy?: string; + unlistedToken?: string; + unlistedExpiresAt?: string | null; +} + +// ─── Domain Types ───────────────────────────────────────── + +export interface Last { + id: string; + title: string; + status: LastStatus; + category: LastCategory; + confidence: LastConfidence | null; + inferredFrom: InferredFrom | null; + date: string | null; + meaning: string | null; + note: string | null; + whatIKnewThen: string | null; + whatIKnowNow: string | null; + tenderness: number | null; + wouldReclaim: WouldReclaim | null; + reclaimedAt: string | null; + reclaimedNote: string | null; + personIds: string[]; + sharedWith: string | null; + mediaIds: string[]; + audioNoteId: string | null; + placeId: string | null; + recognisedAt: string; + isPinned: boolean; + isArchived: boolean; + visibility: VisibilityLevel; + /** Server-issued share token. Empty when not 'unlisted'. */ + unlistedToken: string; + /** ISO timestamp when the unlisted snapshot expires, or null = never. */ + unlistedExpiresAt: string | null; + createdAt: string; + updatedAt: string; +} + +// ─── Constants ──────────────────────────────────────────── + +export const CONFIDENCE_LABELS: Record = { + probably: { de: 'Wahrscheinlich', en: 'Probably' }, + likely: { de: 'Recht sicher', en: 'Likely' }, + certain: { de: 'Sicher', en: 'Certain' }, +}; + +export const STATUS_LABELS: Record = { + suspected: { de: 'Vermutet', en: 'Suspected' }, + confirmed: { de: 'Bestätigt', en: 'Confirmed' }, + reclaimed: { de: 'Aufgehoben', en: 'Reclaimed' }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/lasts/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/lasts/views/DetailView.svelte new file mode 100644 index 000000000..6ce4d7336 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/views/DetailView.svelte @@ -0,0 +1,698 @@ + + + +
+ +
+
+ + {STATUS_LABELS[entry.status].de} + + + + {CATEGORY_LABELS[category].de} + +
+ + {#if entry.inferredFrom} +

+ {$_('lasts.detail.inferredFrom')}: {entry.inferredFrom.refTable} + {#if entry.inferredFrom.frequencyHint} + — {entry.inferredFrom.frequencyHint} + {/if} +

+ {/if} +
+ + + + + +
+ + +
+ + + {#if entry.status === 'suspected'} +
+ {$_('lasts.detail.confidenceLabel')} +
+ {#each CONFIDENCE_OPTS as opt} + + {/each} +
+
+ {/if} + + + + + +
+ + +
+ + +
+
+ {$_('lasts.detail.tendernessLabel')} +
+ {#each RATING_STARS as star} + + {/each} +
+
+
+ {$_('lasts.detail.wouldReclaimLabel')} +
+ {#each WOULD_RECLAIM_OPTS as opt} + + {/each} +
+
+
+ + + + + + {#if entry.status === 'reclaimed'} +
+ {#if entry.reclaimedAt} +

+ {$_('lasts.detail.reclaimedAt')}: + {formatDate(entry.reclaimedAt.slice(0, 10))} +

+ {/if} + {#if entry.reclaimedNote} +

{entry.reclaimedNote}

+ {/if} +
+ {/if} + + + {#if entry.status !== 'reclaimed'} +
+

{$_('lasts.detail.visibilityLabel')}

+ + {#if visibilityError} +

{visibilityError}

+ {/if} + {#if entry.visibility === 'unlisted' && entry.unlistedToken && shareUrl} + + {/if} +
+ {/if} + + + {#if reclaimOpen} +
+ +
+ + +
+
+ {/if} + + +
+ +
+ {#if entry.status === 'suspected'} + + {:else if entry.status === 'confirmed' && !reclaimOpen} + + {/if} +
+
+ + diff --git a/apps/mana/apps/web/src/lib/modules/lasts/views/InboxView.svelte b/apps/mana/apps/web/src/lib/modules/lasts/views/InboxView.svelte new file mode 100644 index 000000000..25c3640d1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/views/InboxView.svelte @@ -0,0 +1,284 @@ + + + +
+
+
+

{$_('lasts.inbox.title')}

+

{$_('lasts.inbox.tagline')}

+
+ +
+ + {#if scanSummary} +

{scanSummary}

+ {/if} + + {#if inbox.length === 0} +

{$_('lasts.inbox.empty')}

+ {:else} +
    + {#each inbox as last (last.id)} +
  • +
    + + {last.title} +
    +

    + {#if last.inferredFrom?.frequencyHint} + {last.inferredFrom.frequencyHint} + {/if} + {#if last.date} + {'·'} + {formatDate(last.date)} + {/if} + + {CATEGORY_LABELS[last.category].de} + +

    +

    + {$_('lasts.detail.inferredFrom')}: {last.inferredFrom?.refTable} +

    +
    + + +
    +
  • + {/each} +
+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/lasts/views/SettingsView.svelte b/apps/mana/apps/web/src/lib/modules/lasts/views/SettingsView.svelte new file mode 100644 index 000000000..73c00ddfb --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/lasts/views/SettingsView.svelte @@ -0,0 +1,267 @@ + + + +
+
+

{$_('lasts.settings.title')}

+

{$_('lasts.settings.tagline')}

+
+ +
    +
  • + +
  • +
  • + +
  • +
  • + +
  • +
+ +
+ + +
+ +
+ + +
+ + {#if testBannerOpen} +
+ + + {$_('lasts.banner.title')} + — + {$_('lasts.banner.anniversary', { values: { years: 3 } })} + {$_('lasts.settings.testSampleTitle')} + +
+ {/if} + +

{$_('lasts.settings.pushNote')}

+
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/lasts/+page.svelte b/apps/mana/apps/web/src/routes/(app)/lasts/+page.svelte new file mode 100644 index 000000000..b9b510bff --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/lasts/+page.svelte @@ -0,0 +1,12 @@ + + + + Lasts - Mana + + + + {}} goBack={() => history.back()} params={{}} /> + diff --git a/apps/mana/apps/web/src/routes/(app)/lasts/entry/[id]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/lasts/entry/[id]/+page.svelte new file mode 100644 index 000000000..664a09ddf --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/lasts/entry/[id]/+page.svelte @@ -0,0 +1,44 @@ + + + + {entry?.title ?? $_('lasts.detail.routeTitle')} - Mana + + + + {#if lasts$.loading} +

{$_('lasts.detail.loading')}

+ {:else if !entry} +
+

{$_('lasts.detail.notFound')}

+ {$_('lasts.detail.backLink')} +
+ {:else} + + {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/(app)/lasts/inbox/+page.svelte b/apps/mana/apps/web/src/routes/(app)/lasts/inbox/+page.svelte new file mode 100644 index 000000000..c76e33603 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/lasts/inbox/+page.svelte @@ -0,0 +1,13 @@ + + + + {$_('lasts.inbox.routeTitle')} - Mana + + + + + diff --git a/apps/mana/apps/web/src/routes/(app)/lasts/settings/+page.svelte b/apps/mana/apps/web/src/routes/(app)/lasts/settings/+page.svelte new file mode 100644 index 000000000..23d19aa73 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/lasts/settings/+page.svelte @@ -0,0 +1,13 @@ + + + + {$_('lasts.settings.routeTitle')} - Mana + + + + + diff --git a/apps/mana/apps/web/src/routes/(app)/milestones/+page.svelte b/apps/mana/apps/web/src/routes/(app)/milestones/+page.svelte new file mode 100644 index 000000000..c04bb1c45 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/milestones/+page.svelte @@ -0,0 +1,13 @@ + + + + {$_('milestones.timeline.title')} - Mana + + + + + diff --git a/apps/mana/apps/web/src/routes/(app)/milestones/recap/[year]/+page.svelte b/apps/mana/apps/web/src/routes/(app)/milestones/recap/[year]/+page.svelte new file mode 100644 index 000000000..5f907729f --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/milestones/recap/[year]/+page.svelte @@ -0,0 +1,46 @@ + + + + {year ?? $_('milestones.recap.titleFallback')} - Milestones - Mana + + + + {#if year == null} +
+

{$_('milestones.recap.invalid')}

+ {$_('milestones.recap.backLink')} +
+ {:else} + + {/if} +
+ + diff --git a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte index fbfecc7bf..0acd48a93 100644 --- a/apps/mana/apps/web/src/routes/share/[token]/+page.svelte +++ b/apps/mana/apps/web/src/routes/share/[token]/+page.svelte @@ -8,6 +8,7 @@ import SharedLibraryEntryView from '$lib/modules/library/SharedLibraryEntryView.svelte'; import SharedPlaceView from '$lib/modules/places/SharedPlaceView.svelte'; import SharedAugurEntryView from '$lib/modules/augur/SharedAugurEntryView.svelte'; + import SharedLastView from '$lib/modules/lasts/SharedLastView.svelte'; import type { PageData } from './$types'; let { data }: { data: PageData } = $props(); @@ -21,6 +22,8 @@ {:else if data.collection === 'augurEntries'} +{:else if data.collection === 'lasts'} + {:else}

Unbekannter Link-Typ

diff --git a/docs/plans/lasts-module.md b/docs/plans/lasts-module.md new file mode 100644 index 000000000..2c2914b3e --- /dev/null +++ b/docs/plans/lasts-module.md @@ -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**: `` 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 `` 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**: `` 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**: `` + `` 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/`, 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 ``. +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 +- `` 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. diff --git a/packages/shared-ai/src/tools/schemas.ts b/packages/shared-ai/src/tools/schemas.ts index 2d02a999e..f1dfa5734 100644 --- a/packages/shared-ai/src/tools/schemas.ts +++ b/packages/shared-ai/src/tools/schemas.ts @@ -2299,6 +2299,186 @@ export const AI_TOOL_CATALOG: readonly ToolSchema[] = [ }, ], }, + + // ── Lasts (mirror sibling to firsts) ──────────────────────── + { + name: 'create_last', + module: 'lasts', + description: + 'Erstellt einen neuen "Last" — ein letztes Mal, das markiert oder vermutet werden soll. Status standardmaessig "suspected"; "confirmed" nur setzen wenn der User sicher ist.', + defaultPolicy: 'propose', + parameters: [ + { + name: 'title', + type: 'string', + description: 'Was zum letzten Mal passiert ist (z.B. "Letzter Tag im alten Job")', + required: true, + }, + { + name: 'category', + type: 'string', + description: 'Kategorie', + required: false, + enum: [ + 'culinary', + 'adventure', + 'travel', + 'people', + 'career', + 'creative', + 'nature', + 'culture', + 'health', + 'tech', + 'other', + ], + }, + { + name: 'status', + type: 'string', + description: 'Lifecycle-Status', + required: false, + enum: ['suspected', 'confirmed'], + }, + { + name: 'date', + type: 'string', + description: 'Datum des letzten Mals (YYYY-MM-DD), falls bekannt', + required: false, + }, + { + name: 'confidence', + type: 'string', + description: 'Sicherheit, dass es das letzte Mal war', + required: false, + enum: ['probably', 'likely', 'certain'], + }, + { + name: 'meaning', + type: 'string', + description: 'Was es bedeutet hat (optional)', + required: false, + }, + { + name: 'note', + type: 'string', + description: 'Freie Notiz (optional)', + required: false, + }, + ], + }, + { + name: 'confirm_last', + module: 'lasts', + description: + 'Bewegt einen Last von "suspected" auf "confirmed" und ergaenzt Reflexionsfelder. Setzt Datum auf heute, falls keines uebergeben wird.', + defaultPolicy: 'propose', + parameters: [ + { name: 'lastId', type: 'string', description: 'ID des Lasts', required: true }, + { + name: 'date', + type: 'string', + description: 'Datum des letzten Mals (YYYY-MM-DD)', + required: false, + }, + { + name: 'meaning', + type: 'string', + description: 'Was es bedeutet hat', + required: false, + }, + { + name: 'whatIKnewThen', + type: 'string', + description: 'Was du damals nicht wusstest', + required: false, + }, + { + name: 'whatIKnowNow', + type: 'string', + description: 'Was du heute siehst', + required: false, + }, + { + name: 'tenderness', + type: 'number', + description: 'Wie sehr es dich heute beruehrt (1-5)', + required: false, + }, + { + name: 'wouldReclaim', + type: 'string', + description: 'Wuerdest du es zurueckholen?', + required: false, + enum: ['no', 'maybe', 'yes'], + }, + ], + }, + { + name: 'reclaim_last', + module: 'lasts', + description: + 'Markiert einen Last als "aufgehoben" — es ist wieder passiert. Optionaler Notiz-Text beschreibt, was zurueckgekommen ist.', + defaultPolicy: 'propose', + parameters: [ + { name: 'lastId', type: 'string', description: 'ID des Lasts', required: true }, + { + name: 'reclaimedNote', + type: 'string', + description: 'Was ist wieder passiert (optional)', + required: false, + }, + ], + }, + { + name: 'list_lasts', + module: 'lasts', + description: + 'Listet Lasts (id, title, status, category, date). Optional nach Status oder Kategorie filterbar.', + defaultPolicy: 'auto', + parameters: [ + { + name: 'status', + type: 'string', + description: 'Nur einen Status zeigen', + required: false, + enum: ['suspected', 'confirmed', 'reclaimed'], + }, + { + name: 'category', + type: 'string', + description: 'Nur eine Kategorie zeigen', + required: false, + enum: [ + 'culinary', + 'adventure', + 'travel', + 'people', + 'career', + 'creative', + 'nature', + 'culture', + 'health', + 'tech', + 'other', + ], + }, + { + name: 'limit', + type: 'number', + description: 'Maximale Anzahl (Standard 30)', + required: false, + }, + ], + }, + { + name: 'suggest_lasts', + module: 'lasts', + description: + 'Laesst die Inferenz-Engine ueber places/habits/contacts scannen und generiert "suspected"-Lasts mit inferredFrom-Provenance fuer Eintraege, die Frequenz-Drops zeigen. Dedupliziert gegen existierende Lasts und die Cooldown-Liste. Schreibt direkt in die Inbox — kein Proposal-Workflow noetig, weil die Eintraege als suspected landen und der User sie dort akzeptieren oder verwerfen kann.', + defaultPolicy: 'auto', + parameters: [], + }, ]; // ═══════════════════════════════════════════════════════════════ diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 002bc62c8..0d34460d8 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -193,6 +193,12 @@ export const APP_ICONS = { // Warm amber→rose gradient to evoke excitement and novelty. `` ), + lasts: svgToDataUrl( + // Hourglass with a single falling grain — the moment something + // passes for the last time. Indigo→slate gradient for the + // contemplative, retrospective tone (mirror to firsts' warm amber). + `` + ), drink: svgToDataUrl( // Water drop + glass — represents beverage tracking. // Blue→cyan gradient for the hydration theme. diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index f0bf532f0..5388b1938 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -751,6 +751,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'guest', }, + { + id: 'lasts', + name: 'Lasts', + description: { + de: 'Letzte Male', + en: 'Last Times', + }, + longDescription: { + de: 'Halte fest, was zum letzten Mal passiert ist — bewusst markiert oder rückwirkend erkannt. Spiegelbild zu Firsts: leise Reflexion statt Vorfreude.', + en: 'Capture what happened for the last time — marked deliberately or recognised in hindsight. Mirror sibling to Firsts: quiet reflection instead of anticipation.', + }, + icon: APP_ICONS.lasts, + color: '#6366f1', + comingSoon: false, + status: 'development', + requiredTier: 'guest', // LOCAL TIER PATCH — revert to 'beta' before release (see project_tier_patch_resolved memory) + }, { id: 'period', name: 'Periode',