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