feat(firsts): add first-times module with dream-to-lived tracking

New module for tracking first-time experiences with two phases:
- Dream: bucket-list items with priority and motivation
- Lived: documented moments with expectation-vs-reality, rating,
  people, places, and media

Includes:
- Full module scaffold (types, collections, queries, store, config)
- ListView with 3 tabs (Timeline, Dreams, People)
- Inline editor + dream-to-lived conversion sheet
- Encryption for all user-typed content
- Dexie schema v6, app-registry, DragType registration
- App icon (amber-rose sparkle) and branding entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 22:23:32 +02:00
parent e943ac9d94
commit ab62157a98
14 changed files with 1875 additions and 1 deletions

View file

@ -796,3 +796,38 @@ registerApp({
},
],
});
registerApp({
id: 'firsts',
name: 'Firsts',
color: '#F59E0B',
icon: Sparkle,
views: {
list: { load: () => import('$lib/modules/firsts/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-first',
label: 'Neues erstes Mal',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'firsts', action: 'new' } })
),
},
],
collection: 'firsts',
paramKey: 'firstId',
dragType: 'first',
getDisplayData: (item) => ({
title: (item.title as string) || 'Erstes Mal',
subtitle: (item.date as string) ?? (item.status === 'dream' ? 'Dream' : undefined),
}),
createItem: async (data) => {
const { firstsStore } = await import('$lib/modules/firsts/stores/firsts.svelte');
const first = await firstsStore.createDream({
title: (data.title as string) ?? 'Neues erstes Mal',
});
return first.id;
},
});

View file

@ -389,6 +389,16 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// the calendar query layer needs them for range scans.
timeBlocks: { enabled: true, fields: ['title', 'description'] },
// ─── Firsts ──────────────────────────────────────────────
// User-typed text fields (title, motivation, note, expectation, reality,
// sharedWith) are encrypted. Status, category, priority, date, rating,
// wouldRepeat, personIds, mediaIds, placeId stay plaintext for indexing
// and filtering.
firsts: {
enabled: true,
fields: ['title', 'motivation', 'note', 'expectation', 'reality', 'sharedWith'],
},
// ─── Guides ──────────────────────────────────────────────
guides: { enabled: true, fields: ['title', 'description'] },
sections: { enabled: true, fields: ['title', 'content'] },

View file

@ -372,6 +372,14 @@ db.version(5).stores({
zitareCustomQuotes: 'id, author, category',
});
// Schema version 6 — Firsts module: track first-time experiences.
// `status` indexed for dream/lived filtering, `category` for grouping,
// `date` for chronological sort of lived entries, `priority` for dream
// ranking. `isPinned`/`isArchived` for standard meta-filtering.
db.version(6).stores({
firsts: 'id, status, category, date, priority, isPinned, isArchived',
});
// ─── Sync Routing ──────────────────────────────────────────
// SYNC_APP_MAP, TABLE_TO_SYNC_NAME, TABLE_TO_APP, SYNC_NAME_TO_TABLE,
// toSyncName() and fromSyncName() are now derived from per-module

View file

@ -87,6 +87,7 @@ import { playgroundModuleConfig } from '$lib/modules/playground/module.config';
import { whoModuleConfig } from '$lib/modules/who/module.config';
import { newsModuleConfig } from '$lib/modules/news/module.config';
import { bodyModuleConfig } from '$lib/modules/body/module.config';
import { firstsModuleConfig } from '$lib/modules/firsts/module.config';
export const MODULE_CONFIGS: readonly ModuleConfig[] = [
manaCoreConfig,
@ -129,6 +130,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
whoModuleConfig,
newsModuleConfig,
bodyModuleConfig,
firstsModuleConfig,
];
// ─── Derived Maps ──────────────────────────────────────────

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,57 @@
import { db } from '$lib/data/database';
import type { LocalFirst } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const firstTable = db.table<LocalFirst>('firsts');
// ─── Guest Seed ────────────────────────────────────────────
const today = new Date().toISOString().slice(0, 10);
export const FIRSTS_GUEST_SEED = {
firsts: [
{
id: 'first-welcome',
title: 'Willkommen bei Firsts!',
status: 'lived',
category: 'other',
motivation: null,
priority: null,
date: today,
note: 'Mein erstes Mal die Firsts-App benutzen. Hier halte ich alle meine ersten Male fest.',
expectation: 'Einfach mal ausprobieren',
reality: 'Einfach und macht Spaß!',
rating: 5,
wouldRepeat: 'definitely',
personIds: [],
sharedWith: null,
mediaIds: [],
audioNoteId: null,
placeId: null,
isPinned: true,
isArchived: false,
},
{
id: 'first-dream-example',
title: 'Nordlichter sehen',
status: 'dream',
category: 'travel',
motivation: 'Soll eines der beeindruckendsten Naturschauspiele sein.',
priority: 2,
date: null,
note: null,
expectation: null,
reality: null,
rating: null,
wouldRepeat: null,
personIds: [],
sharedWith: null,
mediaIds: [],
audioNoteId: null,
placeId: null,
isPinned: false,
isArchived: false,
},
] satisfies LocalFirst[],
};

View file

@ -0,0 +1,29 @@
// ─── Stores ──────────────────────────────────────────────
export { firstsStore } from './stores/firsts.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllFirsts,
useDreams,
useLivedFirsts,
useFirstsByPerson,
toFirst,
searchFirsts,
groupByCategory,
groupByStatus,
groupByPerson,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export { firstTable, FIRSTS_GUEST_SEED } from './collections';
// ─── Types ───────────────────────────────────────────────
export { CATEGORY_LABELS, CATEGORY_COLORS, PRIORITY_LABELS } from './types';
export type {
LocalFirst,
First,
FirstStatus,
FirstCategory,
FirstPriority,
WouldRepeat,
} from './types';

View file

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

View file

@ -0,0 +1,144 @@
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type { First, FirstStatus, LocalFirst } from './types';
// ─── Type Converter ────────────────────────────────────────
export function toFirst(local: LocalFirst): First {
return {
id: local.id,
title: local.title,
status: local.status,
category: local.category,
motivation: local.motivation,
priority: local.priority,
date: local.date,
note: local.note,
expectation: local.expectation,
reality: local.reality,
rating: local.rating,
wouldRepeat: local.wouldRepeat,
personIds: local.personIds ?? [],
sharedWith: local.sharedWith,
mediaIds: local.mediaIds ?? [],
audioNoteId: local.audioNoteId,
placeId: local.placeId,
isPinned: local.isPinned,
isArchived: local.isArchived,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllFirsts() {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalFirst>('firsts').toArray()).filter(
(f) => !f.deletedAt && !f.isArchived
);
const decrypted = await decryptRecords('firsts', visible);
return decrypted.map(toFirst).sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
// Lived entries by date desc, dreams by priority desc then createdAt desc
if (a.status === 'lived' && b.status === 'lived') {
return (b.date ?? b.createdAt).localeCompare(a.date ?? a.createdAt);
}
if (a.status === 'dream' && b.status === 'dream') {
const pDiff = (b.priority ?? 0) - (a.priority ?? 0);
if (pDiff !== 0) return pDiff;
return b.createdAt.localeCompare(a.createdAt);
}
// Lived before dreams
return a.status === 'lived' ? -1 : 1;
});
}, [] as First[]);
}
export function useDreams() {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalFirst>('firsts').toArray()).filter(
(f) => !f.deletedAt && !f.isArchived && f.status === 'dream'
);
const decrypted = await decryptRecords('firsts', visible);
return decrypted.map(toFirst).sort((a, b) => {
const pDiff = (b.priority ?? 0) - (a.priority ?? 0);
if (pDiff !== 0) return pDiff;
return b.createdAt.localeCompare(a.createdAt);
});
}, [] as First[]);
}
export function useLivedFirsts() {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalFirst>('firsts').toArray()).filter(
(f) => !f.deletedAt && !f.isArchived && f.status === 'lived'
);
const decrypted = await decryptRecords('firsts', visible);
return decrypted
.map(toFirst)
.sort((a, b) => (b.date ?? b.createdAt).localeCompare(a.date ?? a.createdAt));
}, [] as First[]);
}
export function useFirstsByPerson(personId: string) {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalFirst>('firsts').toArray()).filter(
(f) => !f.deletedAt && !f.isArchived && f.personIds?.includes(personId)
);
const decrypted = await decryptRecords('firsts', visible);
return decrypted.map(toFirst);
}, [] as First[]);
}
// ─── Pure Helpers ──────────────────────────────────────────
export function searchFirsts(firsts: First[], query: string): First[] {
if (!query.trim()) return firsts;
const q = query.toLowerCase();
return firsts.filter((f) => {
const haystack = [f.title, f.note, f.motivation, f.expectation, f.reality, f.sharedWith]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(q);
});
}
export function groupByCategory(firsts: First[]): Map<string, First[]> {
const groups = new Map<string, First[]>();
for (const f of firsts) {
const cat = f.category;
if (!groups.has(cat)) groups.set(cat, []);
groups.get(cat)!.push(f);
}
return groups;
}
export function groupByStatus(firsts: First[]): { dreams: First[]; lived: First[] } {
const dreams: First[] = [];
const lived: First[] = [];
for (const f of firsts) {
if (f.status === 'dream') dreams.push(f);
else lived.push(f);
}
return { dreams, lived };
}
export function groupByPerson(firsts: First[]): Map<string, First[]> {
const groups = new Map<string, First[]>();
const alone: First[] = [];
for (const f of firsts) {
if (!f.personIds?.length) {
alone.push(f);
continue;
}
for (const pid of f.personIds) {
if (!groups.has(pid)) groups.set(pid, []);
groups.get(pid)!.push(f);
}
}
if (alone.length) groups.set('__alone', alone);
return groups;
}

View file

@ -0,0 +1,189 @@
import { firstTable } from '../collections';
import { toFirst } from '../queries';
import { encryptRecord } from '$lib/data/crypto';
import type {
First,
FirstCategory,
FirstPriority,
FirstStatus,
LocalFirst,
WouldRepeat,
} from '../types';
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export const firstsStore = {
/** Create a new dream (something you want to experience). */
async createDream(data: {
title: string;
category?: FirstCategory;
motivation?: string | null;
priority?: FirstPriority | null;
personIds?: string[];
}): Promise<First> {
const id = crypto.randomUUID();
const newLocal: LocalFirst = {
id,
title: data.title,
status: 'dream',
category: data.category ?? 'other',
motivation: data.motivation ?? null,
priority: data.priority ?? null,
date: null,
note: null,
expectation: null,
reality: null,
rating: null,
wouldRepeat: null,
personIds: data.personIds ?? [],
sharedWith: null,
mediaIds: [],
audioNoteId: null,
placeId: null,
isPinned: false,
isArchived: false,
};
const plaintextSnapshot = toFirst(newLocal);
await encryptRecord('firsts', newLocal);
await firstTable.add(newLocal);
return plaintextSnapshot;
},
/** Create a lived first directly (without prior dream). */
async createLived(data: {
title: string;
category?: FirstCategory;
date?: string;
note?: string | null;
expectation?: string | null;
reality?: string | null;
rating?: number | null;
wouldRepeat?: WouldRepeat | null;
personIds?: string[];
sharedWith?: string | null;
placeId?: string | null;
mediaIds?: string[];
}): Promise<First> {
const id = crypto.randomUUID();
const newLocal: LocalFirst = {
id,
title: data.title,
status: 'lived',
category: data.category ?? 'other',
motivation: null,
priority: null,
date: data.date ?? todayIsoDate(),
note: data.note ?? null,
expectation: data.expectation ?? null,
reality: data.reality ?? null,
rating: data.rating ?? null,
wouldRepeat: data.wouldRepeat ?? null,
personIds: data.personIds ?? [],
sharedWith: data.sharedWith ?? null,
mediaIds: data.mediaIds ?? [],
audioNoteId: null,
placeId: data.placeId ?? null,
isPinned: false,
isArchived: false,
};
const plaintextSnapshot = toFirst(newLocal);
await encryptRecord('firsts', newLocal);
await firstTable.add(newLocal);
return plaintextSnapshot;
},
/** Convert a dream to a lived first. */
async markAsLived(
id: string,
data: {
date?: string;
note?: string | null;
expectation?: string | null;
reality?: string | null;
rating?: number | null;
wouldRepeat?: WouldRepeat | null;
personIds?: string[];
sharedWith?: string | null;
placeId?: string | null;
mediaIds?: string[];
}
) {
const diff: Partial<LocalFirst> = {
status: 'lived',
date: data.date ?? todayIsoDate(),
note: data.note ?? null,
expectation: data.expectation ?? null,
reality: data.reality ?? null,
rating: data.rating ?? null,
wouldRepeat: data.wouldRepeat ?? null,
updatedAt: new Date().toISOString(),
};
if (data.personIds) diff.personIds = data.personIds;
if (data.sharedWith !== undefined) diff.sharedWith = data.sharedWith;
if (data.placeId !== undefined) diff.placeId = data.placeId;
if (data.mediaIds) diff.mediaIds = data.mediaIds;
await encryptRecord('firsts', diff);
await firstTable.update(id, diff);
},
async updateFirst(
id: string,
data: Partial<
Pick<
LocalFirst,
| 'title'
| 'category'
| 'motivation'
| 'priority'
| 'date'
| 'note'
| 'expectation'
| 'reality'
| 'rating'
| 'wouldRepeat'
| 'personIds'
| 'sharedWith'
| 'mediaIds'
| 'audioNoteId'
| 'placeId'
| 'isPinned'
| 'isArchived'
>
>
) {
const diff: Partial<LocalFirst> = {
...data,
updatedAt: new Date().toISOString(),
};
await encryptRecord('firsts', diff);
await firstTable.update(id, diff);
},
async deleteFirst(id: string) {
await firstTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async togglePin(id: string) {
const first = await firstTable.get(id);
if (!first) return;
await firstTable.update(id, {
isPinned: !first.isPinned,
updatedAt: new Date().toISOString(),
});
},
async archiveFirst(id: string) {
await firstTable.update(id, {
isArchived: true,
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,117 @@
import type { BaseRecord } from '@mana/local-store';
// ─── Enums ────────────────────────────────────────────────
export type FirstStatus = 'dream' | 'lived';
export type FirstCategory =
| 'culinary'
| 'adventure'
| 'travel'
| 'people'
| 'career'
| 'creative'
| 'nature'
| 'culture'
| 'health'
| 'tech'
| 'other';
export type FirstPriority = 1 | 2 | 3; // 1 = someday, 2 = this year, 3 = asap
export type WouldRepeat = 'yes' | 'no' | 'definitely';
// ─── Local Record Types (Dexie) ───────────────────────────
export interface LocalFirst extends BaseRecord {
title: string;
status: FirstStatus;
category: FirstCategory;
// Dream phase
motivation: string | null;
priority: FirstPriority | null;
// Lived phase
date: string | null; // ISO date (YYYY-MM-DD)
note: string | null;
expectation: string | null;
reality: string | null;
rating: number | null; // 1-5
wouldRepeat: WouldRepeat | null;
// Social
personIds: string[];
sharedWith: string | null;
// Rich media
mediaIds: string[];
audioNoteId: string | null;
placeId: string | null;
// Meta
isPinned: boolean;
isArchived: boolean;
}
// ─── Domain Types ─────────────────────────────────────────
export interface First {
id: string;
title: string;
status: FirstStatus;
category: FirstCategory;
motivation: string | null;
priority: FirstPriority | null;
date: string | null;
note: string | null;
expectation: string | null;
reality: string | null;
rating: number | null;
wouldRepeat: WouldRepeat | null;
personIds: string[];
sharedWith: string | null;
mediaIds: string[];
audioNoteId: string | null;
placeId: string | null;
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
// ─── Constants ────────────────────────────────────────────
export const CATEGORY_LABELS: Record<FirstCategory, { de: string; en: string }> = {
culinary: { de: 'Kulinarisch', en: 'Culinary' },
adventure: { de: 'Abenteuer', en: 'Adventure' },
travel: { de: 'Reisen', en: 'Travel' },
people: { de: 'Menschen', en: 'People' },
career: { de: 'Beruf', en: 'Career' },
creative: { de: 'Kreativ', en: 'Creative' },
nature: { de: 'Natur', en: 'Nature' },
culture: { de: 'Kultur', en: 'Culture' },
health: { de: 'Gesundheit', en: 'Health' },
tech: { de: 'Technik', en: 'Tech' },
other: { de: 'Sonstiges', en: 'Other' },
};
export const CATEGORY_COLORS: Record<FirstCategory, string> = {
culinary: '#f97316',
adventure: '#ef4444',
travel: '#0ea5e9',
people: '#ec4899',
career: '#6366f1',
creative: '#a855f7',
nature: '#22c55e',
culture: '#eab308',
health: '#14b8a6',
tech: '#64748b',
other: '#9ca3af',
};
export const PRIORITY_LABELS: Record<FirstPriority, { de: string; en: string }> = {
1: { de: 'Irgendwann', en: 'Someday' },
2: { de: 'Dieses Jahr', en: 'This Year' },
3: { de: 'So bald wie möglich', en: 'ASAP' },
};

View file

@ -0,0 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/firsts/ListView.svelte';
</script>
<svelte:head>
<title>Firsts - Mana</title>
</svelte:head>
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />

View file

@ -156,6 +156,11 @@ export const APP_ICONS = {
// modules (planta, nutriphi) and the pink cycles icon.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="bd" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#f97316"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#bd)"/><rect x="18" y="42" width="6" height="16" rx="2" fill="white"/><rect x="76" y="42" width="6" height="16" rx="2" fill="white"/><rect x="24" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="72" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="28" y="48" width="44" height="4" rx="2" fill="white"/><path d="M30 70h12l4-8 6 16 4-10 6 6h12" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
),
firsts: svgToDataUrl(
// Sparkle/star burst — represents a special "first time" moment.
// 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>`
),
who: svgToDataUrl(
// Theatre mask silhouette in front of a question mark — references
// the "guess who's behind the disguise" mechanic. Purple gradient.

View file

@ -25,7 +25,8 @@ export type DragType =
| 'transaction'
| 'place'
| 'dream'
| 'journal-entry';
| 'journal-entry'
| 'first';
export interface DragPayload<T = Record<string, unknown>> {
type: DragType;