feat(lasts): M1-M7 — module ship + Meilensteine-Aggregator

Mirror sibling to firsts: das *letzte* Mal, das du etwas getan hast —
markiert oder rückwirkend erkannt. Plan: docs/plans/lasts-module.md.

M1 Skelett — Dexie v51 lasts-Tabelle, Encryption-Registry, Per-Space-
Welcome-Seed, Empty-State ListView. Kategorien aus firsts/types.ts
nach \$lib/data/milestones/categories.ts extrahiert (Re-Exports halten
firsts-API stabil).

M2 CRUD + DetailView — StatusTabs (Vermutet/Bestätigt/Aufgehoben),
Quick-Add mit Mode-Toggle, always-editable DetailView mit Lifecycle-
Buttons (Bestätigen, Aufheben mit Inline-Note), 44 i18n-Keys × 5 Locales.

M3 Inbox + Inferenz — Dexie v52 lastsCooldown (12-Monate-Cooldown,
deterministische ID), Source-Registry-Pattern in inference/, places-
Source mit Heuristik visitCount>=5 Span>=180d Silence>=365d. InboxView
mit Akzeptieren/Verwerfen + manueller Scan. contacts/habits → M3.b
sobald jeweilige Frequenz-Felder existieren.

M4 AI-Tools — 5 Tools im AI_TOOL_CATALOG (create_last, confirm_last,
reclaim_last, list_lasts, suggest_lasts), Webapp-Executor mit Vault-
Locked-Handling. Server-Drift-Test 4/4, Schema-Test 6/6.

M5 Reminders + Settings — Pivot zu In-App-DueBanner statt OS-Push (kein
PWA-Push-System im Repo). Pure date-math (12 Vitest cases), Settings-
Store mit 4 Toggles, DueBanner mit max-N rendering, Test-Banner-Knopf.

M6 Visibility + Unlisted-Sharing — VisibilityPicker + SharedLinkControls
in DetailView, buildLastBlob mit reflective-core whitelist (reclaimed
Lasts gehärtet ausgeblockt), SharedLastView public-render, Share-
Dispatcher kennt 'lasts'.

M7 Meilensteine-Aggregator — Cross-modul firsts vereinigt mit lasts
Timeline + Year-Recap. Pure aggregator (mergeMilestones,
buildMilestonesRecap), 12 Vitest cases. /milestones und
/milestones/recap/[year] Routes, Cross-Link in lasts/ListView.

Validation: 0 errors / 0 warnings (svelte-check 7645 files), 24/24
tests, i18n-parity 39x5 aligned (+2 namespaces), i18n-keys baseline-
equal, crypto 211 tables.

LOCAL TIER PATCH: lasts ist 'guest' für Testing — vor Release auf
'beta' setzen (packages/shared-branding/src/mana-apps.ts).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-26 21:40:29 +02:00
parent ad5e04a554
commit bf3bca268a
53 changed files with 6572 additions and 40 deletions

View file

@ -0,0 +1,287 @@
<!--
Milestones — Timeline View
Cross-module chronological feed combining firsts lasts. Direction
filter (Alle | Firsts | Lasts), each entry links to its module's
detail route. Year-Recap-Link top-right.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import {
useMilestonesTimeline,
filterByDirection,
type Direction,
type TimelineEntry,
} from '$lib/data/milestones/timeline-query';
import { CATEGORY_COLORS, CATEGORY_LABELS } from '$lib/data/milestones/categories';
type Tab = 'all' | Direction;
let activeTab = $state<Tab>('all');
const timeline$ = useMilestonesTimeline();
const entries = $derived(timeline$.value);
const counts = $derived({
all: entries.length,
first: entries.filter((e) => e.direction === 'first').length,
last: entries.filter((e) => e.direction === 'last').length,
});
const filtered = $derived(filterByDirection(entries, activeTab));
const currentYear = new Date().getUTCFullYear();
function openEntry(e: TimelineEntry) {
if (e.direction === 'first')
goto(`/firsts`); // firsts uses inline editor — no per-entry route
else goto(`/lasts/entry/${e.source.id}`);
}
function formatDate(iso: string | null, fallback: string): string {
const src = iso ?? fallback;
return new Date(src).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
</script>
<div class="app-view">
<header class="head">
<div>
<h1 class="title">{$_('milestones.timeline.title')}</h1>
<p class="tagline">{$_('milestones.timeline.tagline')}</p>
</div>
<a class="recap-link" href="/milestones/recap/{currentYear}">
{$_('milestones.timeline.recapLink', { values: { year: currentYear } })}
</a>
</header>
<div class="tab-bar">
{#each ['all', 'first', 'last'] as const as tab}
<button class="tab" class:active={activeTab === tab} onclick={() => (activeTab = tab)}>
{$_(`milestones.tabs.${tab}`)}
{#if counts[tab] > 0}
<span class="tab-count">{counts[tab]}</span>
{/if}
</button>
{/each}
</div>
{#if entries.length === 0}
<p class="empty">{$_('milestones.timeline.empty')}</p>
{:else if filtered.length === 0}
<p class="empty">{$_('milestones.timeline.emptyTab')}</p>
{:else}
<ul class="entry-list">
{#each filtered as entry (entry.id)}
<li>
<button
class="entry-card"
class:first={entry.direction === 'first'}
class:last={entry.direction === 'last'}
onclick={() => openEntry(entry)}
>
<span class="cat-dot" style="background: {CATEGORY_COLORS[entry.category]}"></span>
<div class="card-body">
<div class="card-head">
<span class="card-title">{entry.title}</span>
{#if entry.isPinned}<span class="badge">{'\u{1f4cc}'}</span>{/if}
</div>
<div class="card-meta">
<span class="dir-chip" data-dir={entry.direction}>
{$_(`milestones.tabs.${entry.direction}`)}
</span>
<span class="date">{formatDate(entry.date, entry.createdAt)}</span>
<span class="cat-label" style="color: {CATEGORY_COLORS[entry.category]}">
{CATEGORY_LABELS[entry.category].de}
</span>
</div>
</div>
</button>
</li>
{/each}
</ul>
{/if}
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
padding: 1rem;
max-width: 720px;
margin: 0 auto;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.tagline {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.recap-link {
font-size: 0.75rem;
font-weight: 600;
color: hsl(var(--color-primary));
text-decoration: none;
padding: 0.375rem 0.625rem;
border-radius: 0.25rem;
border: 1px solid hsl(var(--color-primary) / 0.3);
transition: all 0.15s;
}
.recap-link:hover {
background: hsl(var(--color-primary) / 0.08);
}
.tab-bar {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border));
padding-bottom: 0.25rem;
}
.tab {
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.tab:hover {
color: hsl(var(--color-foreground));
}
.tab.active {
color: hsl(var(--color-primary));
border-bottom-color: hsl(var(--color-primary));
}
.tab-count {
font-size: 0.625rem;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
margin-left: 0.25rem;
}
.entry-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.entry-card {
display: flex;
gap: 0.5rem;
width: 100%;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
text-align: left;
font: inherit;
color: hsl(var(--color-foreground));
cursor: pointer;
transition: all 0.15s;
}
.entry-card:hover {
background: hsl(var(--color-surface-hover));
}
.entry-card.first {
border-left: 3px solid #f59e0b;
}
.entry-card.last {
border-left: 3px solid #6366f1;
}
.cat-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
margin-top: 0.375rem;
}
.card-body {
display: flex;
flex-direction: column;
gap: 0.25rem;
flex: 1;
min-width: 0;
}
.card-head {
display: flex;
align-items: center;
gap: 0.375rem;
}
.card-title {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
font-size: 0.625rem;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.dir-chip {
font-size: 0.5625rem;
font-weight: 700;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
text-transform: uppercase;
letter-spacing: 0.04em;
}
.dir-chip[data-dir='first'] {
background: #f59e0b1f;
color: #d97706;
}
.dir-chip[data-dir='last'] {
background: #6366f11f;
color: #4f46e5;
}
.cat-label {
margin-left: auto;
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,346 @@
<!--
Milestones — Year Recap View
Per-year summary: total + per-direction count, category breakdown,
top 5 firsts and top 5 lasts of the year, list of months that had
any activity. Pure aggregation — no fancy metrics.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { useMilestonesTimeline, type TimelineEntry } from '$lib/data/milestones/timeline-query';
import { buildMilestonesRecap } from '$lib/data/milestones/year-recap';
import {
CATEGORY_COLORS,
CATEGORY_LABELS,
MILESTONE_CATEGORIES,
} from '$lib/data/milestones/categories';
let { year }: { year: number } = $props();
const timeline$ = useMilestonesTimeline();
const recap = $derived(buildMilestonesRecap(timeline$.value, year));
const categoriesWithActivity = $derived(
MILESTONE_CATEGORIES.filter((cat) => recap.byCategory[cat].total > 0)
);
function openEntry(e: TimelineEntry) {
if (e.direction === 'first') goto(`/firsts`);
else goto(`/lasts/entry/${e.source.id}`);
}
function formatDate(iso: string | null, fallback: string): string {
const src = iso ?? fallback;
return new Date(src).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
});
}
function monthLabel(ym: string): string {
const [y, m] = ym.split('-').map(Number);
return new Date(Date.UTC(y, m - 1, 1)).toLocaleDateString('de-DE', {
month: 'short',
year: 'numeric',
});
}
</script>
<section class="recap">
<header class="head">
<h1 class="title">{$_('milestones.recap.title', { values: { year } })}</h1>
<p class="tagline">{$_('milestones.recap.tagline')}</p>
</header>
<!-- Hero stats -->
<div class="stats">
<div class="stat">
<span class="stat-value">{recap.total}</span>
<span class="stat-label">{$_('milestones.recap.totalLabel')}</span>
</div>
<div class="stat first">
<span class="stat-value">{recap.firsts}</span>
<span class="stat-label">{$_('milestones.tabs.first')}</span>
</div>
<div class="stat last">
<span class="stat-value">{recap.lasts}</span>
<span class="stat-label">{$_('milestones.tabs.last')}</span>
</div>
</div>
{#if recap.total === 0}
<p class="empty">{$_('milestones.recap.empty', { values: { year } })}</p>
{:else}
<!-- Category breakdown -->
<section class="block">
<h2 class="block-title">{$_('milestones.recap.categoriesLabel')}</h2>
<ul class="cat-list">
{#each categoriesWithActivity as cat (cat)}
{@const slot = recap.byCategory[cat]}
<li class="cat-row">
<span class="cat-dot" style="background: {CATEGORY_COLORS[cat]}"></span>
<span class="cat-name">{CATEGORY_LABELS[cat].de}</span>
<span class="cat-count">
<span class="dir-count first">{slot.firsts}</span>
<span class="cat-sep">·</span>
<span class="dir-count last">{slot.lasts}</span>
</span>
</li>
{/each}
</ul>
</section>
<!-- Top firsts + lasts -->
<div class="top-grid">
{#if recap.topFirsts.length > 0}
<section class="top-block">
<h2 class="block-title">{$_('milestones.recap.topFirstsLabel')}</h2>
<ul class="top-list">
{#each recap.topFirsts as e (e.id)}
<li>
<button class="top-row" onclick={() => openEntry(e)}>
<span class="top-dot" style="background: {CATEGORY_COLORS[e.category]}"></span>
<span class="top-title">{e.title}</span>
<span class="top-date">{formatDate(e.date, e.createdAt)}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
{#if recap.topLasts.length > 0}
<section class="top-block">
<h2 class="block-title">{$_('milestones.recap.topLastsLabel')}</h2>
<ul class="top-list">
{#each recap.topLasts as e (e.id)}
<li>
<button class="top-row" onclick={() => openEntry(e)}>
<span class="top-dot" style="background: {CATEGORY_COLORS[e.category]}"></span>
<span class="top-title">{e.title}</span>
<span class="top-date">{formatDate(e.date, e.createdAt)}</span>
</button>
</li>
{/each}
</ul>
</section>
{/if}
</div>
<!-- Active months strip -->
{#if recap.activeMonths.length > 0}
<section class="block">
<h2 class="block-title">{$_('milestones.recap.activeMonthsLabel')}</h2>
<ul class="month-strip">
{#each recap.activeMonths as ym (ym)}
<li class="month-pill">{monthLabel(ym)}</li>
{/each}
</ul>
</section>
{/if}
{/if}
</section>
<style>
.recap {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-width: 720px;
margin: 0 auto;
}
.head {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.title {
font-size: 1.125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.tagline {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.stats {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 0.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.125rem;
padding: 0.875rem 0.5rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
}
.stat.first {
border-color: #f59e0b66;
background: #f59e0b0a;
}
.stat.last {
border-color: #6366f166;
background: #6366f10a;
}
.stat-value {
font-size: 1.625rem;
font-weight: 700;
color: hsl(var(--color-foreground));
line-height: 1;
}
.stat-label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
font-weight: 600;
}
.block {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.block-title {
margin: 0;
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
font-weight: 600;
}
.cat-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.cat-row {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
}
.cat-row:hover {
background: hsl(var(--color-surface-hover));
}
.cat-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
}
.cat-name {
flex: 1;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.cat-count {
font-size: 0.75rem;
font-weight: 600;
}
.dir-count.first {
color: #d97706;
}
.dir-count.last {
color: #4f46e5;
}
.cat-sep {
color: hsl(var(--color-muted-foreground));
opacity: 0.5;
margin: 0 0.25rem;
}
.top-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1rem;
}
@media (max-width: 36rem) {
.top-grid {
grid-template-columns: 1fr;
}
}
.top-block {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.top-list {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.top-row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.5rem;
border-radius: 0.25rem;
border: 0;
background: transparent;
text-align: left;
font: inherit;
color: hsl(var(--color-foreground));
cursor: pointer;
transition: background 0.15s;
}
.top-row:hover {
background: hsl(var(--color-surface-hover));
}
.top-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
flex-shrink: 0;
}
.top-title {
flex: 1;
font-size: 0.8125rem;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.top-date {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.month-strip {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-wrap: wrap;
gap: 0.25rem;
}
.month-pill {
font-size: 0.6875rem;
padding: 0.25rem 0.625rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -463,6 +463,33 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
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'] },

View file

@ -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<MilestoneCategory, { de: string; en: string }> = {
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<MilestoneCategory, string> = {
culinary: '#f97316',
adventure: '#ef4444',
travel: '#0ea5e9',
people: '#ec4899',
career: '#6366f1',
creative: '#a855f7',
nature: '#22c55e',
culture: '#eab308',
health: '#14b8a6',
tech: '#64748b',
other: '#9ca3af',
};

View file

@ -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>): 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>): 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);
});
});

View file

@ -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<LocalFirst, string>('firsts', 'firsts').toArray(),
scopedForModule<LocalLast, string>('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<LocalFirst>('firsts', firstsVisible),
decryptRecords<LocalLast>('lasts', lastsVisible),
]);
const firsts = firstsDecrypted.map(toFirst);
const lasts = lastsDecrypted.map(toLast);
return mergeMilestones(firsts, lasts);
}, [] as TimelineEntry[]);
}

View file

@ -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<MilestoneCategory, { firsts: number; lasts: number; total: number }>;
/** 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<string>();
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),
};
}

View file

@ -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,

View file

@ -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';

View file

@ -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);
});

View file

@ -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);

View file

@ -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<Record<string, unk
resolvedAt: isResolved ? (decrypted.resolvedAt ?? null) : null,
};
}
/**
* Last snapshot blob.
*
* Whitelist: only the *reflective core* the parts a user might actually
* want to share publicly without exposing their full inner monologue.
*
* IN: title, status, category, date, meaning, whatIKnewThen, whatIKnowNow,
* tenderness, wouldReclaim
* OUT: note (often raw stream-of-consciousness), inferredFrom (internal
* provenance), confidence (internal flag), reclaimedAt/reclaimedNote
* (later state, complicated to render publicly), personIds /
* sharedWith / mediaIds / audioNoteId / placeId (private refs),
* recognisedAt (internal timeline), pin/archive flags.
*
* Tone-decision: lasts are intim. Reclaimed lasts are NOT shared (would
* leak the "this is back" emotion that's even more vulnerable). The
* whitelist already drops `reclaimedNote` but we additionally refuse to
* publish a blob whose status is 'reclaimed' to make the intent explicit.
*/
async function buildLastBlob(recordId: string): Promise<Record<string, unknown>> {
const raw = await db.table<LocalLast>('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,
};
}

View file

@ -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."
}
}

View file

@ -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."
}
}

View file

@ -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."
}
}

View file

@ -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."
}
}

View file

@ -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."
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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<FirstCategory, { de: string; en: string }> = {
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<FirstCategory, string> = {
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<FirstPriority, { de: string; en: string }> = {
1: { de: 'Irgendwann', en: 'Someday' },
2: { de: 'Dieses Jahr', en: 'This Year' },

View file

@ -0,0 +1,501 @@
<!--
Lasts — Workbench ListView (M2)
Renders status-tabbed list of the active Space's lasts. Quick-Add bar
at the top creates suspected or confirmed entries directly. Cards link
to the DetailView route for editing + lifecycle transitions.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { PushPin, Trash, Archive } from '@mana/shared-icons';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { onMount } from 'svelte';
import { useAllLasts, useInboxLasts, searchLasts } from './queries';
import { lastsStore } from './stores/items.svelte';
import { lastsSettings } from './stores/settings.svelte';
import DueBanner from './components/DueBanner.svelte';
import { CATEGORY_COLORS, CATEGORY_LABELS, STATUS_LABELS } from './types';
import type { Last, LastCategory, LastStatus } from './types';
import { MILESTONE_CATEGORIES } from '$lib/data/milestones/categories';
import type { ViewProps } from '$lib/app-registry';
let { navigate, goBack, params }: ViewProps = $props();
type ViewTab = 'all' | LastStatus;
let activeTab = $state<ViewTab>('all');
let searchQuery = $state('');
let lasts$ = useAllLasts();
let lasts = $derived(lasts$.value);
let inbox$ = useInboxLasts();
let inboxCount = $derived(inbox$.value.length);
onMount(() => {
lastsSettings.initialize();
});
// Counts per tab
let counts = $derived({
all: lasts.length,
suspected: lasts.filter((l) => l.status === 'suspected').length,
confirmed: lasts.filter((l) => l.status === 'confirmed').length,
reclaimed: lasts.filter((l) => l.status === 'reclaimed').length,
});
let filtered = $derived.by(() => {
let list = activeTab === 'all' ? lasts : lasts.filter((l) => l.status === activeTab);
return searchLasts(list, searchQuery);
});
// ── Quick create ───────────────────────────────
let newTitle = $state('');
let newCategory = $state<LastCategory>('other');
let newAsConfirmed = $state(false);
async function handleQuickCreate(e: KeyboardEvent) {
if (e.key !== 'Enter' || !newTitle.trim()) return;
e.preventDefault();
const title = newTitle.trim();
const created = newAsConfirmed
? await lastsStore.createConfirmed({ title, category: newCategory })
: await lastsStore.createSuspected({ title, category: newCategory });
newTitle = '';
// Open the just-created entry so the user can immediately reflect.
goto(`/lasts/entry/${created.id}`);
}
function openEntry(id: string) {
goto(`/lasts/entry/${id}`);
}
// ── Context menu ───────────────────────────────
const ctxMenu = useItemContextMenu<Last>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.state.target
? [
{
id: 'pin',
label: ctxMenu.state.target.isPinned
? $_('lasts.actions.unpin')
: $_('lasts.actions.pin'),
icon: PushPin,
action: () => {
const target = ctxMenu.state.target;
if (target) lastsStore.togglePin(target.id);
},
},
{
id: 'archive',
label: $_('lasts.actions.archive'),
icon: Archive,
action: () => {
const target = ctxMenu.state.target;
if (target) lastsStore.archiveLast(target.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: $_('lasts.actions.delete'),
icon: Trash,
variant: 'danger' as const,
action: () => {
const target = ctxMenu.state.target;
if (target) lastsStore.deleteLast(target.id);
},
},
]
: []
);
function formatDate(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
</script>
<div class="app-view">
<!-- In-app reminders banner (anniversary / recognition / inbox-notify) -->
<DueBanner {lasts} {inboxCount} />
<!-- Tab bar -->
<div class="tab-bar">
{#each ['all', 'suspected', 'confirmed', 'reclaimed'] as const as tab}
<button class="tab" class:active={activeTab === tab} onclick={() => (activeTab = tab)}>
{$_(`lasts.tabs.${tab}`)}
{#if counts[tab] > 0}
<span class="tab-count">{counts[tab]}</span>
{/if}
</button>
{/each}
<a class="inbox-link" href="/lasts/inbox">
{$_('lasts.tabs.inbox')}
{#if inboxCount > 0}
<span class="inbox-count">{inboxCount}</span>
{/if}
</a>
<a class="inbox-link" href="/milestones" title={$_('milestones.timeline.title')}>
{$_('milestones.timeline.title')}
</a>
<a class="inbox-link settings-link" href="/lasts/settings" title={$_('lasts.settings.title')}
>⚙</a
>
</div>
<!-- Quick create -->
<form onsubmit={(e) => e.preventDefault()} class="quick-add">
<div class="quick-top">
<select class="cat-select" bind:value={newCategory}>
{#each MILESTONE_CATEGORIES as cat}
<option value={cat}>{CATEGORY_LABELS[cat].de}</option>
{/each}
</select>
<input
class="add-input"
type="text"
placeholder={$_('lasts.quickAdd.placeholder')}
bind:value={newTitle}
onkeydown={handleQuickCreate}
/>
</div>
<div class="quick-toggle">
<button
class="toggle-btn"
class:active={!newAsConfirmed}
onclick={() => (newAsConfirmed = false)}
>
{$_('lasts.quickAdd.modeSuspected')}
</button>
<button
class="toggle-btn"
class:active={newAsConfirmed}
onclick={() => (newAsConfirmed = true)}
>
{$_('lasts.quickAdd.modeConfirmed')}
</button>
</div>
</form>
<!-- Search -->
{#if lasts.length > 5}
<input
class="search-input"
type="text"
placeholder={$_('lasts.list.searchPlaceholder')}
bind:value={searchQuery}
/>
{/if}
<!-- Entry list -->
{#if lasts.length === 0}
<p class="empty">{$_('lasts.list.emptyAll')}</p>
{:else if filtered.length === 0}
<p class="empty">{$_('lasts.list.emptyTab')}</p>
{:else}
<ul class="entry-list">
{#each filtered as last (last.id)}
<li>
<div
class="entry-card"
class:reclaimed={last.status === 'reclaimed'}
role="button"
tabindex="0"
onclick={() => openEntry(last.id)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openEntry(last.id);
}
}}
oncontextmenu={(e) => ctxMenu.open(e, last)}
>
<div class="card-header">
<span class="cat-dot" style="background: {CATEGORY_COLORS[last.category]}"></span>
<span class="card-title">{last.title}</span>
{#if last.isPinned}<span class="badge">{'\u{1f4cc}'}</span>{/if}
<span class="status-pill" data-status={last.status}>
{STATUS_LABELS[last.status].de}
</span>
</div>
<div class="card-meta">
{#if last.date}<span>{formatDate(last.date)}</span>{/if}
<span class="cat-label" style="color: {CATEGORY_COLORS[last.category]}">
{CATEGORY_LABELS[last.category].de}
</span>
</div>
{#if last.meaning}
<p class="card-note">{last.meaning}</p>
{/if}
</div>
</li>
{/each}
</ul>
{/if}
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={ctxMenu.close}
/>
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 1rem;
height: 100%;
}
/* ── Tab Bar ─────────────────────────────── */
.tab-bar {
display: flex;
gap: 0.25rem;
border-bottom: 1px solid hsl(var(--color-border));
padding-bottom: 0.25rem;
}
.tab {
padding: 0.375rem 0.75rem;
border: none;
background: transparent;
font-size: 0.75rem;
font-weight: 500;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
border-bottom: 2px solid transparent;
transition: all 0.15s;
}
.tab:hover {
color: hsl(var(--color-foreground));
}
.tab.active {
color: hsl(var(--color-primary));
border-bottom-color: hsl(var(--color-primary));
}
.tab-count {
font-size: 0.625rem;
background: hsl(var(--color-primary) / 0.12);
color: hsl(var(--color-primary));
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
margin-left: 0.25rem;
}
.inbox-link {
margin-left: auto;
padding: 0.375rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
color: hsl(var(--color-muted-foreground));
text-decoration: none;
transition: all 0.15s;
display: inline-flex;
align-items: center;
gap: 0.25rem;
}
.inbox-link:hover {
color: hsl(var(--color-primary));
background: hsl(var(--color-surface-hover));
}
.inbox-link.settings-link {
margin-left: 0;
font-size: 0.875rem;
}
.inbox-count {
font-size: 0.5625rem;
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
font-weight: 700;
}
/* ── Quick Add ───────────────────────────── */
.quick-add {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
}
.quick-top {
display: flex;
align-items: center;
gap: 0.5rem;
}
.cat-select {
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
font-size: 0.6875rem;
color: hsl(var(--color-foreground));
padding: 0.125rem 0.25rem;
outline: none;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
.quick-toggle {
display: flex;
gap: 0.25rem;
}
.toggle-btn {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.toggle-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
/* ── Search ──────────────────────────────── */
.search-input {
padding: 0.3rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.75rem;
color: hsl(var(--color-foreground));
outline: none;
}
.search-input:focus {
border-color: hsl(var(--color-primary));
}
/* ── Entry List ──────────────────────────── */
.entry-list {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 0.5rem;
list-style: none;
padding: 0;
margin: 0;
}
.entry-card {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.625rem;
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
cursor: pointer;
transition: all 0.15s;
user-select: none;
-webkit-user-select: none;
-webkit-touch-callout: none;
}
.entry-card:hover {
background: hsl(var(--color-surface-hover));
}
.entry-card.reclaimed {
opacity: 0.55;
border-style: dashed;
}
.card-header {
display: flex;
align-items: center;
gap: 0.375rem;
}
.cat-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
}
.card-title {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
font-size: 0.625rem;
}
.status-pill {
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
}
.status-pill[data-status='confirmed'] {
border-color: hsl(var(--color-primary) / 0.4);
color: hsl(var(--color-primary));
}
.status-pill[data-status='reclaimed'] {
border-style: dashed;
}
.card-meta {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.cat-label {
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
margin-left: auto;
}
.card-note {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
display: -webkit-box;
-webkit-line-clamp: 2;
line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,277 @@
<!--
Shared-Last view — public render of a single last behind an
unlisted share link.
Whitelist (set by buildLastBlob): title, status, category, date,
meaning, whatIKnewThen, whatIKnowNow, tenderness, wouldReclaim.
note / inferredFrom / personIds / placeId / mediaIds / reclaimedNote
all stay PRIVATE. Reclaimed lasts are blocked at the resolver layer
and never reach this view.
Tone: contemplative, restrained — lasts are intim even when shared.
Light cream + indigo accent (mirrors the lasts module color), no
Mana branding except a small footer.
-->
<script lang="ts">
type LastStatus = 'suspected' | 'confirmed';
interface LastBlob {
title: string;
status: LastStatus;
category:
| 'culinary'
| 'adventure'
| 'travel'
| 'people'
| 'career'
| 'creative'
| 'nature'
| 'culture'
| 'health'
| 'tech'
| 'other';
date: string | null;
meaning: string | null;
whatIKnewThen: string | null;
whatIKnowNow: string | null;
tenderness: number | null;
wouldReclaim: 'no' | 'maybe' | 'yes' | null;
}
let {
blob,
}: {
blob: Record<string, unknown>;
token: string;
expiresAt: string | null;
} = $props();
const entry = $derived(blob as unknown as LastBlob);
const CATEGORY_LABELS: Record<LastBlob['category'], string> = {
culinary: 'Kulinarisch',
adventure: 'Abenteuer',
travel: 'Reisen',
people: 'Menschen',
career: 'Beruf',
creative: 'Kreativ',
nature: 'Natur',
culture: 'Kultur',
health: 'Gesundheit',
tech: 'Technik',
other: 'Sonstiges',
};
const CATEGORY_COLORS: Record<LastBlob['category'], string> = {
culinary: '#f97316',
adventure: '#ef4444',
travel: '#0ea5e9',
people: '#ec4899',
career: '#6366f1',
creative: '#a855f7',
nature: '#22c55e',
culture: '#eab308',
health: '#14b8a6',
tech: '#64748b',
other: '#9ca3af',
};
const STATUS_LABELS: Record<LastStatus, string> = {
suspected: 'Vermutet',
confirmed: 'Bestätigt',
};
const WOULD_RECLAIM_LABELS: Record<NonNullable<LastBlob['wouldReclaim']>, string> = {
no: 'Nein',
maybe: 'Vielleicht',
yes: 'Ja',
};
const RATING_STARS = [1, 2, 3, 4, 5];
</script>
<article class="card" style:--cat={CATEGORY_COLORS[entry.category]}>
<header>
<div class="meta">
<span class="cat">{CATEGORY_LABELS[entry.category]}</span>
<span class="dot">·</span>
<span class="status">{STATUS_LABELS[entry.status]}</span>
{#if entry.date}
<span class="dot">·</span>
<span class="date">{entry.date}</span>
{/if}
</div>
<h1 class="title">{entry.title}</h1>
{#if entry.meaning}
<p class="meaning">{entry.meaning}</p>
{/if}
</header>
{#if entry.whatIKnewThen || entry.whatIKnowNow}
<section class="reflection">
{#if entry.whatIKnewThen}
<div class="reflection-block">
<h2>Damals</h2>
<p>{entry.whatIKnewThen}</p>
</div>
{/if}
{#if entry.whatIKnowNow}
<div class="reflection-block">
<h2>Heute</h2>
<p>{entry.whatIKnowNow}</p>
</div>
{/if}
</section>
{/if}
{#if entry.tenderness !== null || entry.wouldReclaim}
<section class="footer-meta">
{#if entry.tenderness !== null}
<div class="rating">
<span class="rating-label">Berührt heute</span>
<span class="stars">
{#each RATING_STARS as star}
<span class:filled={star <= (entry.tenderness ?? 0)}
>{star <= (entry.tenderness ?? 0) ? '★' : '☆'}</span
>
{/each}
</span>
</div>
{/if}
{#if entry.wouldReclaim}
<div class="reclaim">
<span class="reclaim-label">Würde es zurückholen</span>
<span class="reclaim-value">{WOULD_RECLAIM_LABELS[entry.wouldReclaim]}</span>
</div>
{/if}
</section>
{/if}
<footer>
<small>via Mana Lasts</small>
</footer>
</article>
<style>
.card {
max-width: 36rem;
margin: 4rem auto;
padding: 2rem 2.25rem;
background: white;
border-radius: 1rem;
border: 1px solid #e5e7eb;
border-left: 5px solid var(--cat);
box-shadow: 0 4px 24px rgba(15, 23, 42, 0.06);
font-family:
-apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
color: #0f172a;
}
.meta {
display: flex;
gap: 0.45rem;
font-size: 0.78rem;
color: #64748b;
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.cat {
color: var(--cat);
font-weight: 600;
}
.dot {
opacity: 0.5;
}
.status,
.date {
font-weight: 500;
}
.title {
font-size: 1.65rem;
font-weight: 600;
margin: 0 0 0.4rem;
line-height: 1.25;
}
.meaning {
margin: 0 0 0.5rem;
font-size: 1.05rem;
color: #334155;
line-height: 1.55;
font-style: italic;
}
.reflection {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid #f1f5f9;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1.5rem;
}
@media (max-width: 36rem) {
.reflection {
grid-template-columns: 1fr;
gap: 1rem;
}
}
.reflection-block h2 {
margin: 0 0 0.4rem;
font-size: 0.78rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
font-weight: 500;
}
.reflection-block p {
margin: 0;
color: #334155;
line-height: 1.55;
}
.footer-meta {
margin-top: 1.5rem;
padding-top: 1.25rem;
border-top: 1px solid #f1f5f9;
display: flex;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
.rating,
.reclaim {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.rating-label,
.reclaim-label {
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #64748b;
}
.stars {
font-size: 1.1rem;
color: #cbd5e1;
letter-spacing: 0.1em;
}
.stars .filled {
color: #f59e0b;
}
.reclaim-value {
font-size: 0.95rem;
color: #334155;
font-weight: 500;
}
footer {
margin-top: 2rem;
padding-top: 1rem;
border-top: 1px solid #f1f5f9;
text-align: center;
}
footer small {
color: #94a3b8;
font-size: 0.72rem;
}
</style>

View file

@ -0,0 +1,7 @@
import { db } from '$lib/data/database';
import type { LocalLast, LocalLastsCooldown } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const lastTable = db.table<LocalLast>('lasts');
export const lastsCooldownTable = db.table<LocalLastsCooldown>('lastsCooldown');

View file

@ -0,0 +1,235 @@
<!--
Lasts — DueBanner
In-app surfacing of today's anniversary lasts + recognition anniversaries
+ inbox-notify hint. No OS push integration (M5.b). Each toggle is
opt-in via /lasts/settings.
Hard cap: bannerMaxItems (default 3). Items prioritised:
1. Anniversaries (date) — most personal
2. Recognition anniversaries — when something was first marked as a last
3. Inbox-notify — single info row
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { CATEGORY_COLORS } from '../types';
import type { Last } from '../types';
import {
findAnniversaryLasts,
findRecognitionAnniversaryLasts,
yearsBetween,
} from '../lib/reminders';
import { lastsSettings } from '../stores/settings.svelte';
let {
lasts,
inboxCount,
}: {
lasts: Last[];
inboxCount: number;
} = $props();
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
type BannerRow =
| { kind: 'anniversary'; last: Last; years: number }
| { kind: 'recognition'; last: Last; years: number }
| { kind: 'inbox'; count: number };
let rows = $derived.by<BannerRow[]>(() => {
const today = todayIso();
const collected: BannerRow[] = [];
if (lastsSettings.anniversaryReminders) {
for (const l of findAnniversaryLasts(lasts, today)) {
collected.push({
kind: 'anniversary',
last: l,
years: yearsBetween(l.date ?? today, today),
});
}
}
if (lastsSettings.recognitionReminders) {
for (const l of findRecognitionAnniversaryLasts(lasts, today)) {
// Avoid double-listing the same last if its date already matched.
if (
lastsSettings.anniversaryReminders &&
l.status === 'confirmed' &&
l.date &&
l.date.slice(5, 10) === today.slice(5, 10) &&
(l.date.slice(0, 4) ?? '') < today.slice(0, 4)
) {
continue;
}
collected.push({
kind: 'recognition',
last: l,
years: yearsBetween(l.recognisedAt, today),
});
}
}
if (lastsSettings.inboxNotify && inboxCount > 0) {
collected.push({ kind: 'inbox', count: inboxCount });
}
const cap = Math.max(1, lastsSettings.bannerMaxItems ?? 3);
return collected.slice(0, cap);
});
function handleClick(row: BannerRow) {
if (row.kind === 'inbox') {
goto('/lasts/inbox');
} else {
goto(`/lasts/entry/${row.last.id}`);
}
}
</script>
{#if rows.length > 0}
<aside class="banner">
<header class="banner-head">
<span class="dot"></span>
<span class="banner-title">{$_('lasts.banner.title')}</span>
</header>
<ul class="rows">
{#each rows as row, i (i)}
{#if row.kind === 'anniversary'}
<li>
<button class="row" onclick={() => handleClick(row)}>
<span class="row-dot" style="background: {CATEGORY_COLORS[row.last.category]}"></span>
<span class="row-text">
<span class="row-prefix"
>{$_('lasts.banner.anniversary', { values: { years: row.years } })}</span
>
<span class="row-title">{row.last.title}</span>
</span>
</button>
</li>
{:else if row.kind === 'recognition'}
<li>
<button class="row" onclick={() => handleClick(row)}>
<span class="row-dot" style="background: {CATEGORY_COLORS[row.last.category]}"></span>
<span class="row-text">
<span class="row-prefix"
>{$_('lasts.banner.recognition', { values: { years: row.years } })}</span
>
<span class="row-title">{row.last.title}</span>
</span>
</button>
</li>
{:else}
<li>
<button class="row inbox" onclick={() => handleClick(row)}>
<span class="row-dot inbox-dot"></span>
<span class="row-text">
<span class="row-prefix"
>{$_('lasts.banner.inbox', { values: { count: row.count } })}</span
>
</span>
</button>
</li>
{/if}
{/each}
</ul>
</aside>
{/if}
<style>
.banner {
border-radius: 0.5rem;
border: 1px solid hsl(var(--color-primary) / 0.3);
background: hsl(var(--color-primary) / 0.04);
overflow: hidden;
}
.banner-head {
display: flex;
align-items: center;
gap: 0.4rem;
padding: 0.4rem 0.625rem;
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
color: hsl(var(--color-primary));
}
.dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: hsl(var(--color-primary));
box-shadow: 0 0 0 3px hsl(var(--color-primary) / 0.2);
}
.banner-title {
opacity: 0.85;
}
.rows {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
border-top: 1px solid hsl(var(--color-primary) / 0.15);
}
.row {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.75rem;
background: transparent;
border: 0;
border-bottom: 1px solid hsl(var(--color-primary) / 0.08);
text-align: left;
font: inherit;
color: hsl(var(--color-foreground));
cursor: pointer;
transition: background 0.15s;
}
.row:last-child {
border-bottom: 0;
}
.row:hover {
background: hsl(var(--color-primary) / 0.06);
}
.row-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
}
.inbox-dot {
background: hsl(var(--color-primary));
}
.row-text {
display: flex;
flex-direction: column;
gap: 0.0625rem;
min-width: 0;
flex: 1;
}
.row-prefix {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: hsl(var(--color-muted-foreground));
font-weight: 600;
}
.row-title {
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.inbox .row-prefix {
color: hsl(var(--color-primary));
}
</style>

View file

@ -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';

View file

@ -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<LocalLast[]> {
const visible = (await scopedForModule<LocalLast, string>('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<LocalLastsCooldown[]> {
return (
await scopedForModule<LocalLastsCooldown, string>('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<ScanResult> {
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<void> {
const id = cooldownIdFor(refTable, refId);
const now = new Date().toISOString();
await lastsCooldownTable.put({ id, refTable, refId, dismissedAt: now });
}

View file

@ -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<PlaceCategory, LastCategory> = {
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<LocalPlace, string>('places', 'places').toArray()
).filter((p) => !p.deletedAt && !p.isArchived);
// Place names are encrypted in the registry — decrypt before use.
const decrypted = await decryptRecords<LocalPlace>('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);
},
};

View file

@ -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<InferenceCandidate[]>;
}
/**
* 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;

View file

@ -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> = {}): 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']);
});
});

View file

@ -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);
});
}

View file

@ -0,0 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const lastsModuleConfig: ModuleConfig = {
appId: 'lasts',
tables: [{ name: 'lasts' }, { name: 'lastsCooldown' }],
};

View file

@ -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<LocalLast, string>('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<LocalLast, string>('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<LocalLast, string>('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);
});
}

View file

@ -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<Last> {
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<Last> {
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<LocalLast> = {
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<LocalLast> = {
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<LocalLast> = {
...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<ScanResult & { written: number }> {
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<LocalLast> = {
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(),
});
},
};

View file

@ -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<string, unknown> {
/** 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<LastsAppSettings>('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;
},
};

View file

@ -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<T extends string>(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<LastStatus>(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<LastConfidence>(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<WouldReclaim>(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<LastStatus>(params.status, STATUSES);
const categoryFilter = asEnum<LastCategory>(
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<LocalLast>('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).`,
};
},
},
];

View file

@ -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<LastConfidence, { de: string; en: string }> = {
probably: { de: 'Wahrscheinlich', en: 'Probably' },
likely: { de: 'Recht sicher', en: 'Likely' },
certain: { de: 'Sicher', en: 'Certain' },
};
export const STATUS_LABELS: Record<LastStatus, { de: string; en: string }> = {
suspected: { de: 'Vermutet', en: 'Suspected' },
confirmed: { de: 'Bestätigt', en: 'Confirmed' },
reclaimed: { de: 'Aufgehoben', en: 'Reclaimed' },
};

View file

@ -0,0 +1,698 @@
<!--
Lasts — Detail View
Always-editable single-entry view. Field changes save immediately on
blur (text) or change (selects, status pills). Lifecycle buttons drive
status transitions (suspected → confirmed, confirmed → reclaimed).
Delete + back at the top.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { lastsStore } from '../stores/items.svelte';
import { CATEGORY_COLORS, CATEGORY_LABELS, CONFIDENCE_LABELS, STATUS_LABELS } from '../types';
import type { Last, LastCategory, LastConfidence, WouldReclaim } from '../types';
import { MILESTONE_CATEGORIES } from '$lib/data/milestones/categories';
import {
VisibilityPicker,
SharedLinkControls,
buildShareUrl,
type VisibilityLevel,
} from '@mana/shared-privacy';
let { entry }: { entry: Last } = $props();
// Local form state, seeded from the entry. Saves on blur / change.
// The $effect below re-syncs whenever a different entry id is loaded.
/* svelte-ignore state_referenced_locally */
let title = $state(entry.title);
/* svelte-ignore state_referenced_locally */
let category = $state<LastCategory>(entry.category);
/* svelte-ignore state_referenced_locally */
let date = $state(entry.date ?? '');
/* svelte-ignore state_referenced_locally */
let confidence = $state<LastConfidence | null>(entry.confidence);
/* svelte-ignore state_referenced_locally */
let meaning = $state(entry.meaning ?? '');
/* svelte-ignore state_referenced_locally */
let whatIKnewThen = $state(entry.whatIKnewThen ?? '');
/* svelte-ignore state_referenced_locally */
let whatIKnowNow = $state(entry.whatIKnowNow ?? '');
/* svelte-ignore state_referenced_locally */
let note = $state(entry.note ?? '');
/* svelte-ignore state_referenced_locally */
let tenderness = $state<number | null>(entry.tenderness);
/* svelte-ignore state_referenced_locally */
let wouldReclaim = $state<WouldReclaim | null>(entry.wouldReclaim);
// Reclaim flow state — inline form opens when user clicks "Aufheben".
let reclaimOpen = $state(false);
let reclaimNote = $state('');
// Keep local state in sync if the entry changes upstream (e.g. from sync).
/* svelte-ignore state_referenced_locally */
let lastSeenId = $state(entry.id);
$effect(() => {
if (entry.id !== lastSeenId) {
lastSeenId = entry.id;
title = entry.title;
category = entry.category;
date = entry.date ?? '';
confidence = entry.confidence;
meaning = entry.meaning ?? '';
whatIKnewThen = entry.whatIKnewThen ?? '';
whatIKnowNow = entry.whatIKnowNow ?? '';
note = entry.note ?? '';
tenderness = entry.tenderness;
wouldReclaim = entry.wouldReclaim;
reclaimOpen = false;
reclaimNote = '';
}
});
const RATING_STARS = [1, 2, 3, 4, 5] as const;
const WOULD_RECLAIM_OPTS: WouldReclaim[] = ['no', 'maybe', 'yes'];
const CONFIDENCE_OPTS: LastConfidence[] = ['probably', 'likely', 'certain'];
async function saveTitle() {
const next = title.trim();
if (next && next !== entry.title) {
await lastsStore.updateLast(entry.id, { title: next });
}
}
async function saveCategory() {
if (category !== entry.category) {
await lastsStore.updateLast(entry.id, { category });
}
}
async function saveDate() {
const next = date || null;
if (next !== entry.date) {
await lastsStore.updateLast(entry.id, { date: next });
}
}
async function saveConfidence(next: LastConfidence | null) {
confidence = next;
await lastsStore.updateLast(entry.id, { confidence: next });
}
async function saveMeaning() {
const next = meaning.trim() || null;
if (next !== entry.meaning) {
await lastsStore.updateLast(entry.id, { meaning: next });
}
}
async function saveWhatIKnewThen() {
const next = whatIKnewThen.trim() || null;
if (next !== entry.whatIKnewThen) {
await lastsStore.updateLast(entry.id, { whatIKnewThen: next });
}
}
async function saveWhatIKnowNow() {
const next = whatIKnowNow.trim() || null;
if (next !== entry.whatIKnowNow) {
await lastsStore.updateLast(entry.id, { whatIKnowNow: next });
}
}
async function saveNote() {
const next = note.trim() || null;
if (next !== entry.note) {
await lastsStore.updateLast(entry.id, { note: next });
}
}
async function saveTenderness(star: number) {
const next = tenderness === star ? null : star;
tenderness = next;
await lastsStore.updateLast(entry.id, { tenderness: next });
}
async function saveWouldReclaim(opt: WouldReclaim) {
const next = wouldReclaim === opt ? null : opt;
wouldReclaim = next;
await lastsStore.updateLast(entry.id, { wouldReclaim: next });
}
async function handleConfirm() {
await lastsStore.confirmLast(entry.id, {
date: date || undefined,
meaning: meaning.trim() || null,
whatIKnewThen: whatIKnewThen.trim() || null,
whatIKnowNow: whatIKnowNow.trim() || null,
tenderness,
wouldReclaim,
});
}
function openReclaim() {
reclaimOpen = true;
reclaimNote = '';
}
async function confirmReclaim() {
await lastsStore.reclaimLast(entry.id, reclaimNote.trim() || null);
reclaimOpen = false;
reclaimNote = '';
}
function cancelReclaim() {
reclaimOpen = false;
reclaimNote = '';
}
async function handleDelete() {
await lastsStore.deleteLast(entry.id);
goto('/lasts');
}
// ── Visibility / Sharing ──────────────────────────────
let visibilityError = $state<string | null>(null);
async function onVisibilityChange(next: VisibilityLevel) {
visibilityError = null;
try {
await lastsStore.setVisibility(entry.id, next);
} catch (err) {
visibilityError = err instanceof Error ? err.message : String(err);
}
}
async function handleRegenerate() {
await lastsStore.regenerateUnlistedToken(entry.id);
}
async function handleRevoke() {
await lastsStore.setVisibility(entry.id, 'private');
}
async function handleExpiryChange(expiresAt: Date | null) {
await lastsStore.setUnlistedExpiry(entry.id, expiresAt);
}
const shareUrl = $derived.by(() => {
if (!entry.unlistedToken) return '';
const origin = typeof window === 'undefined' ? 'https://mana.how' : window.location.origin;
return buildShareUrl(origin, entry.unlistedToken);
});
function formatDate(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
</script>
<article class="detail">
<!-- Header: status badge + category pill -->
<header class="head">
<div class="badges">
<span class="status-pill" data-status={entry.status}>
{STATUS_LABELS[entry.status].de}
</span>
<span class="cat-pill" style="--cat: {CATEGORY_COLORS[category]}">
<span class="cat-dot" style="background: {CATEGORY_COLORS[category]}"></span>
{CATEGORY_LABELS[category].de}
</span>
</div>
{#if entry.inferredFrom}
<p class="inferred">
{$_('lasts.detail.inferredFrom')}: {entry.inferredFrom.refTable}
{#if entry.inferredFrom.frequencyHint}
<span class="freq">{entry.inferredFrom.frequencyHint}</span>
{/if}
</p>
{/if}
</header>
<!-- Title -->
<input
class="title-input"
type="text"
bind:value={title}
onblur={saveTitle}
placeholder={$_('lasts.detail.titlePlaceholder')}
/>
<!-- Category + Date row -->
<div class="row">
<label class="field">
<span class="label">{$_('lasts.detail.categoryLabel')}</span>
<select class="input-sm" bind:value={category} onchange={saveCategory}>
{#each MILESTONE_CATEGORIES as cat}
<option value={cat}>{CATEGORY_LABELS[cat].de}</option>
{/each}
</select>
</label>
<label class="field">
<span class="label">{$_('lasts.detail.dateLabel')}</span>
<input class="input-sm" type="date" bind:value={date} onchange={saveDate} />
</label>
</div>
<!-- Confidence (only meaningful for suspected) -->
{#if entry.status === 'suspected'}
<div class="field">
<span class="label">{$_('lasts.detail.confidenceLabel')}</span>
<div class="picker">
{#each CONFIDENCE_OPTS as opt}
<button
class="picker-btn"
class:active={confidence === opt}
onclick={() => saveConfidence(confidence === opt ? null : opt)}
>
{CONFIDENCE_LABELS[opt].de}
</button>
{/each}
</div>
</div>
{/if}
<!-- Meaning -->
<label class="field">
<span class="label">{$_('lasts.detail.meaningLabel')}</span>
<textarea
class="textarea"
bind:value={meaning}
onblur={saveMeaning}
rows="3"
placeholder={$_('lasts.detail.meaningPlaceholder')}
></textarea>
</label>
<!-- Reflection: what I knew then / what I know now -->
<div class="row">
<label class="field">
<span class="label">{$_('lasts.detail.whatIKnewThenLabel')}</span>
<textarea
class="textarea"
bind:value={whatIKnewThen}
onblur={saveWhatIKnewThen}
rows="3"
placeholder={$_('lasts.detail.whatIKnewThenPlaceholder')}
></textarea>
</label>
<label class="field">
<span class="label">{$_('lasts.detail.whatIKnowNowLabel')}</span>
<textarea
class="textarea"
bind:value={whatIKnowNow}
onblur={saveWhatIKnowNow}
rows="3"
placeholder={$_('lasts.detail.whatIKnowNowPlaceholder')}
></textarea>
</label>
</div>
<!-- Tenderness + WouldReclaim -->
<div class="row">
<div class="field">
<span class="label">{$_('lasts.detail.tendernessLabel')}</span>
<div class="rating-picker">
{#each RATING_STARS as star}
<button
class="star-btn"
class:filled={tenderness !== null && star <= tenderness}
onclick={() => saveTenderness(star)}
aria-label={String(star)}
>
{tenderness !== null && star <= tenderness ? '★' : '☆'}
</button>
{/each}
</div>
</div>
<div class="field">
<span class="label">{$_('lasts.detail.wouldReclaimLabel')}</span>
<div class="picker">
{#each WOULD_RECLAIM_OPTS as opt}
<button
class="picker-btn"
class:active={wouldReclaim === opt}
onclick={() => saveWouldReclaim(opt)}
>
{$_(`lasts.wouldReclaim.${opt}`)}
</button>
{/each}
</div>
</div>
</div>
<!-- Note -->
<label class="field">
<span class="label">{$_('lasts.detail.noteLabel')}</span>
<textarea
class="textarea"
bind:value={note}
onblur={saveNote}
rows="3"
placeholder={$_('lasts.detail.notePlaceholder')}
></textarea>
</label>
<!-- Reclaimed-state context (read-only summary) -->
{#if entry.status === 'reclaimed'}
<div class="reclaimed-block">
{#if entry.reclaimedAt}
<p class="meta">
<strong>{$_('lasts.detail.reclaimedAt')}:</strong>
{formatDate(entry.reclaimedAt.slice(0, 10))}
</p>
{/if}
{#if entry.reclaimedNote}
<p class="reclaimed-note">{entry.reclaimedNote}</p>
{/if}
</div>
{/if}
<!-- Visibility / Share-Link controls (M6) -->
{#if entry.status !== 'reclaimed'}
<section class="visibility-block">
<h3 class="vis-label">{$_('lasts.detail.visibilityLabel')}</h3>
<VisibilityPicker level={entry.visibility} onChange={onVisibilityChange} />
{#if visibilityError}
<p class="vis-error">{visibilityError}</p>
{/if}
{#if entry.visibility === 'unlisted' && entry.unlistedToken && shareUrl}
<div class="share-controls">
<SharedLinkControls
token={entry.unlistedToken}
url={shareUrl}
expiresAt={entry.unlistedExpiresAt}
onRegenerate={handleRegenerate}
onRevoke={handleRevoke}
onExpiryChange={handleExpiryChange}
/>
</div>
{/if}
</section>
{/if}
<!-- Inline reclaim confirmation -->
{#if reclaimOpen}
<div class="reclaim-form">
<label class="field">
<span class="label">{$_('lasts.detail.reclaimedNotePlaceholder')}</span>
<textarea class="textarea" bind:value={reclaimNote} rows="2"></textarea>
</label>
<div class="reclaim-actions">
<button class="btn" onclick={cancelReclaim}>
{$_('lasts.actions.cancel')}
</button>
<button class="btn primary" onclick={confirmReclaim}>
{$_('lasts.actions.reclaim')}
</button>
</div>
</div>
{/if}
<!-- Lifecycle action bar -->
<footer class="actions">
<button class="btn danger" onclick={handleDelete}>
{$_('lasts.actions.delete')}
</button>
<div class="spacer"></div>
{#if entry.status === 'suspected'}
<button class="btn primary" onclick={handleConfirm}>
{$_('lasts.actions.confirm')}
</button>
{:else if entry.status === 'confirmed' && !reclaimOpen}
<button class="btn" onclick={openReclaim}>
{$_('lasts.actions.reclaim')}
</button>
{/if}
</footer>
</article>
<style>
.detail {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1rem;
max-width: 720px;
margin: 0 auto;
}
.head {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.badges {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.status-pill {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
color: hsl(var(--color-muted-foreground));
}
.status-pill[data-status='confirmed'] {
border-color: hsl(var(--color-primary) / 0.4);
color: hsl(var(--color-primary));
}
.status-pill[data-status='reclaimed'] {
border-style: dashed;
}
.cat-pill {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
font-weight: 600;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
border: 1px solid var(--cat);
color: var(--cat);
}
.cat-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
}
.inferred {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.freq {
font-style: italic;
}
.title-input {
width: 100%;
font-size: 1.125rem;
font-weight: 600;
background: transparent;
border: none;
outline: none;
color: hsl(var(--color-foreground));
padding: 0.25rem 0;
border-bottom: 1px solid transparent;
transition: border-color 0.15s;
}
.title-input:focus {
border-bottom-color: hsl(var(--color-primary) / 0.4);
}
.row {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 0.75rem;
}
@media (max-width: 640px) {
.row {
grid-template-columns: 1fr;
}
}
.field {
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
font-weight: 600;
}
.input-sm {
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
font-family: inherit;
}
.input-sm:focus {
border-color: hsl(var(--color-primary));
}
.textarea {
width: 100%;
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
padding: 0.375rem 0.5rem;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
outline: none;
resize: vertical;
font-family: inherit;
line-height: 1.5;
}
.textarea:focus {
border-color: hsl(var(--color-primary));
}
.picker {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.picker-btn {
padding: 0.25rem 0.625rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.picker-btn.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.rating-picker {
display: flex;
gap: 0.125rem;
}
.star-btn {
background: transparent;
border: none;
font-size: 1.25rem;
cursor: pointer;
color: hsl(var(--color-border));
padding: 0;
line-height: 1;
}
.star-btn.filled {
color: #f59e0b;
}
.reclaimed-block {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.625rem;
border-radius: 0.375rem;
background: hsl(var(--color-surface-hover));
border: 1px dashed hsl(var(--color-border));
}
.meta {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.reclaimed-note {
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
margin: 0;
font-style: italic;
}
.reclaim-form {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding: 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.04);
border: 1px solid hsl(var(--color-primary) / 0.3);
}
.reclaim-actions {
display: flex;
gap: 0.375rem;
justify-content: flex-end;
}
.visibility-block {
display: flex;
flex-direction: column;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--color-border));
}
.vis-label {
margin: 0;
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
font-weight: 600;
}
.vis-error {
margin: 0;
font-size: 0.75rem;
color: hsl(var(--color-error));
}
.share-controls {
margin-top: 0.5rem;
}
.actions {
display: flex;
align-items: center;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--color-border));
}
.spacer {
flex: 1;
}
.btn {
padding: 0.375rem 0.875rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
transition: all 0.15s;
}
.btn:hover {
background: hsl(var(--color-surface-hover));
}
.btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.btn.primary:hover {
filter: brightness(0.92);
}
.btn.danger {
color: hsl(var(--color-error));
border-color: hsl(var(--color-error) / 0.3);
}
.btn.danger:hover {
background: hsl(var(--color-error) / 0.08);
}
</style>

View file

@ -0,0 +1,284 @@
<!--
Lasts — Inbox View
Displays AI-inferred candidates (suspected with inferredFrom != null)
awaiting user review. Two actions per row:
- Akzeptieren → strips inferredFrom, entry stays as suspected
in the main feed (user vouches for it).
- Verwerfen → soft-delete + cooldown so the same candidate
isn't re-suggested for ~12 months.
"Jetzt scannen" button manually triggers the inference engine.
Cron-based auto-scans land in M5 (mana-ai mission).
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { useInboxLasts } from '../queries';
import { lastsStore } from '../stores/items.svelte';
import { CATEGORY_COLORS, CATEGORY_LABELS } from '../types';
let inbox$ = useInboxLasts();
let inbox = $derived(inbox$.value);
let scanning = $state(false);
let scanSummary = $state<string | null>(null);
async function handleScan() {
if (scanning) return;
scanning = true;
scanSummary = null;
try {
const result = await lastsStore.suggestLasts();
scanSummary = $_('lasts.inbox.scanSummary', {
values: {
written: result.written,
cooldown: result.cooldownFiltered,
existing: result.existingFiltered,
},
});
} finally {
scanning = false;
}
}
async function handleAccept(id: string) {
await lastsStore.acceptCandidate(id);
// Take user to the now-accepted entry so they can edit/confirm.
goto(`/lasts/entry/${id}`);
}
async function handleDismiss(id: string) {
await lastsStore.dismissCandidate(id);
}
function formatDate(iso: string | null): string {
if (!iso) return '';
return new Date(iso).toLocaleDateString('de-DE', {
day: 'numeric',
month: 'short',
year: 'numeric',
});
}
</script>
<section class="inbox">
<header class="head">
<div class="head-text">
<h1 class="title">{$_('lasts.inbox.title')}</h1>
<p class="tagline">{$_('lasts.inbox.tagline')}</p>
</div>
<button class="scan-btn" onclick={handleScan} disabled={scanning}>
{scanning ? $_('lasts.inbox.scanning') : $_('lasts.inbox.scanNow')}
</button>
</header>
{#if scanSummary}
<p class="scan-summary">{scanSummary}</p>
{/if}
{#if inbox.length === 0}
<p class="empty">{$_('lasts.inbox.empty')}</p>
{:else}
<ul class="entry-list">
{#each inbox as last (last.id)}
<li class="card">
<div class="card-head">
<span class="cat-dot" style="background: {CATEGORY_COLORS[last.category]}"></span>
<span class="card-title">{last.title}</span>
</div>
<p class="card-meta">
{#if last.inferredFrom?.frequencyHint}
<span class="freq">{last.inferredFrom.frequencyHint}</span>
{/if}
{#if last.date}
<span class="dot">{'·'}</span>
<span>{formatDate(last.date)}</span>
{/if}
<span class="cat-label" style="color: {CATEGORY_COLORS[last.category]}">
{CATEGORY_LABELS[last.category].de}
</span>
</p>
<p class="provenance">
{$_('lasts.detail.inferredFrom')}: <strong>{last.inferredFrom?.refTable}</strong>
</p>
<div class="actions">
<button class="btn ghost" onclick={() => handleDismiss(last.id)}>
{$_('lasts.inbox.dismiss')}
</button>
<button class="btn primary" onclick={() => handleAccept(last.id)}>
{$_('lasts.inbox.accept')}
</button>
</div>
</li>
{/each}
</ul>
{/if}
</section>
<style>
.inbox {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1rem;
max-width: 720px;
margin: 0 auto;
}
.head {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 0.5rem;
}
.head-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.tagline {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.scan-btn {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-primary) / 0.08);
color: hsl(var(--color-primary));
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
transition: all 0.15s;
}
.scan-btn:hover:not(:disabled) {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.scan-btn:disabled {
opacity: 0.5;
cursor: progress;
}
.scan-summary {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
padding: 0.375rem 0.625rem;
border-left: 2px solid hsl(var(--color-primary) / 0.4);
background: hsl(var(--color-primary) / 0.04);
border-radius: 0 0.25rem 0.25rem 0;
margin: 0;
}
.entry-list {
display: flex;
flex-direction: column;
gap: 0.625rem;
list-style: none;
padding: 0;
margin: 0;
}
.card {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px dashed hsl(var(--color-primary) / 0.3);
background: hsl(var(--color-primary) / 0.02);
}
.card-head {
display: flex;
align-items: center;
gap: 0.375rem;
}
.cat-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
}
.card-title {
font-size: 0.875rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.card-meta {
display: flex;
align-items: center;
gap: 0.375rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.freq {
font-style: italic;
}
.dot {
opacity: 0.5;
}
.cat-label {
margin-left: auto;
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.04em;
font-weight: 600;
}
.provenance {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.actions {
display: flex;
justify-content: flex-end;
gap: 0.375rem;
padding-top: 0.25rem;
}
.btn {
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 600;
cursor: pointer;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
transition: all 0.15s;
}
.btn:hover {
background: hsl(var(--color-surface-hover));
}
.btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.btn.primary:hover {
filter: brightness(0.92);
}
.btn.ghost {
color: hsl(var(--color-muted-foreground));
border-color: transparent;
}
.btn.ghost:hover {
color: hsl(var(--color-foreground));
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
</style>

View file

@ -0,0 +1,267 @@
<!--
Lasts — Settings View
Three opt-in toggles + banner-cap slider + a "Test-Banner zeigen"-button
that briefly forces the banner to render (useful before any real
anniversary fires). Persists via lasts/stores/settings.svelte.ts
(localStorage-backed).
-->
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { lastsSettings } from '../stores/settings.svelte';
let testBannerOpen = $state(false);
onMount(() => {
lastsSettings.initialize();
});
function toggleAnniversary() {
lastsSettings.set('anniversaryReminders', !lastsSettings.anniversaryReminders);
}
function toggleRecognition() {
lastsSettings.set('recognitionReminders', !lastsSettings.recognitionReminders);
}
function toggleInbox() {
lastsSettings.set('inboxNotify', !lastsSettings.inboxNotify);
}
function setMaxItems(e: Event) {
const v = Number((e.target as HTMLInputElement).value);
if (Number.isInteger(v) && v >= 1 && v <= 10) {
lastsSettings.set('bannerMaxItems', v);
}
}
function showTestBanner() {
testBannerOpen = true;
setTimeout(() => (testBannerOpen = false), 4000);
}
function resetAll() {
lastsSettings.reset();
}
</script>
<section class="settings">
<header class="head">
<h1 class="title">{$_('lasts.settings.title')}</h1>
<p class="tagline">{$_('lasts.settings.tagline')}</p>
</header>
<ul class="toggles">
<li class="toggle">
<label>
<input
type="checkbox"
checked={lastsSettings.anniversaryReminders}
onchange={toggleAnniversary}
/>
<span class="toggle-text">
<span class="toggle-label">{$_('lasts.settings.anniversaryLabel')}</span>
<span class="toggle-desc">{$_('lasts.settings.anniversaryDesc')}</span>
</span>
</label>
</li>
<li class="toggle">
<label>
<input
type="checkbox"
checked={lastsSettings.recognitionReminders}
onchange={toggleRecognition}
/>
<span class="toggle-text">
<span class="toggle-label">{$_('lasts.settings.recognitionLabel')}</span>
<span class="toggle-desc">{$_('lasts.settings.recognitionDesc')}</span>
</span>
</label>
</li>
<li class="toggle">
<label>
<input type="checkbox" checked={lastsSettings.inboxNotify} onchange={toggleInbox} />
<span class="toggle-text">
<span class="toggle-label">{$_('lasts.settings.inboxLabel')}</span>
<span class="toggle-desc">{$_('lasts.settings.inboxDesc')}</span>
</span>
</label>
</li>
</ul>
<div class="slider-row">
<label class="slider-label" for="banner-cap">
{$_('lasts.settings.bannerCapLabel', { values: { count: lastsSettings.bannerMaxItems } })}
</label>
<input
id="banner-cap"
class="slider"
type="range"
min="1"
max="10"
step="1"
value={lastsSettings.bannerMaxItems}
oninput={setMaxItems}
/>
</div>
<div class="actions">
<button class="btn ghost" onclick={resetAll}>{$_('lasts.settings.reset')}</button>
<button class="btn primary" onclick={showTestBanner}>
{$_('lasts.settings.showTestBanner')}
</button>
</div>
{#if testBannerOpen}
<div class="test-banner">
<span class="dot"></span>
<span>
<strong>{$_('lasts.banner.title')}</strong>
{$_('lasts.banner.anniversary', { values: { years: 3 } })}
<em>{$_('lasts.settings.testSampleTitle')}</em>
</span>
</div>
{/if}
<p class="note">{$_('lasts.settings.pushNote')}</p>
</section>
<style>
.settings {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
max-width: 640px;
margin: 0 auto;
}
.head {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.title {
font-size: 1rem;
font-weight: 600;
color: hsl(var(--color-foreground));
margin: 0;
}
.tagline {
font-size: 0.75rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
}
.toggles {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.toggle {
border: 1px solid hsl(var(--color-border));
border-radius: 0.5rem;
padding: 0.625rem 0.75rem;
}
.toggle label {
display: flex;
align-items: flex-start;
gap: 0.625rem;
cursor: pointer;
}
.toggle input[type='checkbox'] {
margin-top: 0.125rem;
flex-shrink: 0;
}
.toggle-text {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.toggle-label {
font-size: 0.8125rem;
font-weight: 600;
color: hsl(var(--color-foreground));
}
.toggle-desc {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.slider-row {
display: flex;
flex-direction: column;
gap: 0.375rem;
padding: 0.5rem 0;
}
.slider-label {
font-size: 0.75rem;
color: hsl(var(--color-foreground));
}
.slider {
width: 100%;
}
.actions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
}
.btn {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 600;
cursor: pointer;
border: 1px solid hsl(var(--color-border));
background: transparent;
color: hsl(var(--color-foreground));
transition: all 0.15s;
}
.btn:hover {
background: hsl(var(--color-surface-hover));
}
.btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
.btn.primary:hover {
filter: brightness(0.92);
}
.btn.ghost {
color: hsl(var(--color-muted-foreground));
border-color: transparent;
}
.btn.ghost:hover {
color: hsl(var(--color-foreground));
}
.test-banner {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.625rem 0.75rem;
border-radius: 0.5rem;
background: hsl(var(--color-primary) / 0.08);
border: 1px solid hsl(var(--color-primary) / 0.3);
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.test-banner .dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: hsl(var(--color-primary));
}
.note {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
font-style: italic;
margin: 0;
padding-top: 0.5rem;
border-top: 1px solid hsl(var(--color-border));
}
</style>

View file

@ -0,0 +1,12 @@
<script lang="ts">
import ListView from '$lib/modules/lasts/ListView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>Lasts - Mana</title>
</svelte:head>
<RoutePage appId="lasts">
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
</RoutePage>

View file

@ -0,0 +1,44 @@
<script lang="ts">
import { page } from '$app/state';
import { _ } from 'svelte-i18n';
import DetailView from '$lib/modules/lasts/views/DetailView.svelte';
import { useAllLasts } from '$lib/modules/lasts/queries';
import { RoutePage } from '$lib/components/shell';
const lasts$ = useAllLasts();
const entry = $derived(lasts$.value.find((l) => l.id === page.params.id));
</script>
<svelte:head>
<title>{entry?.title ?? $_('lasts.detail.routeTitle')} - Mana</title>
</svelte:head>
<RoutePage appId="lasts" backHref="/lasts" title={$_('lasts.detail.routeTitle')}>
{#if lasts$.loading}
<p class="state">{$_('lasts.detail.loading')}</p>
{:else if !entry}
<div class="state">
<p>{$_('lasts.detail.notFound')}</p>
<a href="/lasts">{$_('lasts.detail.backLink')}</a>
</div>
{:else}
<DetailView {entry} />
{/if}
</RoutePage>
<style>
.state {
text-align: center;
padding: 3rem 1rem;
color: hsl(var(--color-muted-foreground));
}
.state a {
display: inline-block;
margin-top: 0.5rem;
color: hsl(var(--color-primary));
text-decoration: none;
}
.state a:hover {
text-decoration: underline;
}
</style>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import InboxView from '$lib/modules/lasts/views/InboxView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>{$_('lasts.inbox.routeTitle')} - Mana</title>
</svelte:head>
<RoutePage appId="lasts" backHref="/lasts" title={$_('lasts.inbox.routeTitle')}>
<InboxView />
</RoutePage>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import SettingsView from '$lib/modules/lasts/views/SettingsView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>{$_('lasts.settings.routeTitle')} - Mana</title>
</svelte:head>
<RoutePage appId="lasts" backHref="/lasts" title={$_('lasts.settings.routeTitle')}>
<SettingsView />
</RoutePage>

View file

@ -0,0 +1,13 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import TimelineView from '$lib/components/milestones/TimelineView.svelte';
import { RoutePage } from '$lib/components/shell';
</script>
<svelte:head>
<title>{$_('milestones.timeline.title')} - Mana</title>
</svelte:head>
<RoutePage appId="milestones" title={$_('milestones.timeline.title')}>
<TimelineView />
</RoutePage>

View file

@ -0,0 +1,46 @@
<script lang="ts">
import { page } from '$app/state';
import { _ } from 'svelte-i18n';
import YearRecapView from '$lib/components/milestones/YearRecapView.svelte';
import { RoutePage } from '$lib/components/shell';
const year = $derived.by(() => {
const raw = page.params.year;
if (!raw) return null;
const parsed = Number.parseInt(raw, 10);
if (!Number.isFinite(parsed) || parsed < 2000 || parsed > 2100) return null;
return parsed;
});
</script>
<svelte:head>
<title>{year ?? $_('milestones.recap.titleFallback')} - Milestones - Mana</title>
</svelte:head>
<RoutePage appId="milestones" backHref="/milestones" title={$_('milestones.recap.titleFallback')}>
{#if year == null}
<div class="state">
<p>{$_('milestones.recap.invalid')}</p>
<a href="/milestones">{$_('milestones.recap.backLink')}</a>
</div>
{:else}
<YearRecapView {year} />
{/if}
</RoutePage>
<style>
.state {
text-align: center;
padding: 3rem 1rem;
color: hsl(var(--color-muted-foreground));
}
.state a {
display: inline-block;
margin-top: 0.5rem;
color: hsl(var(--color-primary));
text-decoration: none;
}
.state a:hover {
text-decoration: underline;
}
</style>

View file

@ -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 @@
<SharedPlaceView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'augurEntries'}
<SharedAugurEntryView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else if data.collection === 'lasts'}
<SharedLastView blob={data.blob} token={data.token} expiresAt={data.expiresAt} />
{:else}
<div class="unknown">
<h1>Unbekannter Link-Typ</h1>

519
docs/plans/lasts-module.md Normal file
View file

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

View file

@ -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: [],
},
];
// ═══════════════════════════════════════════════════════════════

View file

@ -193,6 +193,12 @@ export const APP_ICONS = {
// Warm amber→rose gradient to evoke excitement and novelty.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="fi" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#e11d48"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#fi)"/><path d="M50 18l5 14 14-5-10 11 10 11-14-5-5 14-5-14-14 5 10-11-10-11 14 5z" fill="white"/><circle cx="28" cy="70" r="4" fill="white" fill-opacity="0.6"/><circle cx="72" cy="68" r="3" fill="white" fill-opacity="0.5"/><circle cx="38" cy="80" r="2.5" fill="white" fill-opacity="0.4"/><circle cx="65" cy="82" r="2" fill="white" fill-opacity="0.35"/></svg>`
),
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).
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="la" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#475569"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#la)"/><path d="M32 22h36" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M32 78h36" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M34 24c0 14 16 22 16 26s-16 12-16 26" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><path d="M66 24c0 14-16 22-16 26s16 12 16 26" stroke="white" stroke-width="3" fill="none" stroke-linecap="round"/><path d="M40 30h20l-10 16z" fill="white" fill-opacity="0.85"/><path d="M40 70h20l-10-16z" fill="white" fill-opacity="0.35"/><circle cx="50" cy="55" r="2" fill="white"/></svg>`
),
drink: svgToDataUrl(
// Water drop + glass — represents beverage tracking.
// Blue→cyan gradient for the hydration theme.

View file

@ -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',