diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index 5c5e15cab..da8a9c01b 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -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; + }, +}); diff --git a/apps/mana/apps/web/src/lib/data/crypto/registry.ts b/apps/mana/apps/web/src/lib/data/crypto/registry.ts index 2b6bd4633..346457d5e 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -389,6 +389,16 @@ export const ENCRYPTION_REGISTRY: Record = { // 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'] }, diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index ebe5d1a1e..1d67853fe 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -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 diff --git a/apps/mana/apps/web/src/lib/data/module-registry.ts b/apps/mana/apps/web/src/lib/data/module-registry.ts index 84318f247..f9cfdefc5 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -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 ────────────────────────────────────────── diff --git a/apps/mana/apps/web/src/lib/modules/firsts/ListView.svelte b/apps/mana/apps/web/src/lib/modules/firsts/ListView.svelte new file mode 100644 index 000000000..c5878ae76 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/firsts/ListView.svelte @@ -0,0 +1,1262 @@ + + + +
+ +
+ + + +
+ + +
e.preventDefault()} class="quick-add"> +
+ + +
+
+ + +
+
+ + + {#if allFirsts.length > 0} +
+ + {#each CATEGORIES as cat} + + {/each} +
+ {/if} + + + {#if allFirsts.length > 5} + + {/if} + + + {#if allFirsts.length > 0} +
+ {lived.length} erlebt + {dreams.length} Dreams +
+ {/if} + + + {#if activeTab === 'timeline'} +
+ {#each filtered() as first (first.id)} + {#if convertingId === first.id} + + +
{ + if (e.key === 'Escape') convertingId = null; + }} + > +
+ + {first.title} +
+ + + + + + + + + + + +
+
+ {#each RATING_STARS as star} + + {/each} +
+
+ {#each ['no', 'yes', 'definitely'] as const as opt} + + {/each} +
+
+ +
+ + +
+
+ {:else if editingId === first.id} + + +
{ + if (e.key === 'Escape') saveEdit(); + }} + > + + + +
+ +
+ + {#if first.status === 'dream'} + +
+ Priorität +
+ {#each [1, 2, 3] as const as p} + + {/each} +
+
+ {:else} + + + + + + +
+
+ {#each RATING_STARS as star} + + {/each} +
+
+ {#each ['no', 'yes', 'definitely'] as const as opt} + + {/each} +
+
+ {/if} + +
+ + +
+
+ {:else} + +
startEdit(first)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + startEdit(first); + } + }} + oncontextmenu={(e) => ctxMenu.open(e, first)} + > +
+ + {first.title} + {#if first.isPinned}{'\u{1f4cc}'}{/if} + {#if first.status === 'lived' && first.rating} + + {#each RATING_STARS as star} + {star <= (first.rating ?? 0) ? '\u2605' : '\u2606'} + {/each} + + {/if} +
+ + {#if first.status === 'lived'} +
+ {#if first.date}{formatDate(first.date)}{/if} + {#if first.sharedWith} + {'\u00b7'} + {first.sharedWith} + {/if} + {#if first.wouldRepeat} + {'\u00b7'} + + {first.wouldRepeat === 'definitely' + ? 'Definitiv nochmal' + : first.wouldRepeat === 'yes' + ? 'Nochmal' + : 'Einmal reicht'} + + {/if} +
+ {#if first.expectation || first.reality} +
+ {#if first.expectation} +
+ Vorher: + {first.expectation} +
+ {/if} + {#if first.reality} +
+ Nachher: + {first.reality} +
+ {/if} +
+ {/if} + {#if first.note} +

{first.note}

+ {/if} + {:else} + +
+ {#if first.priority} + {PRIORITY_LABELS[first.priority].de} + {/if} + {#if first.sharedWith} + {'\u00b7'} + {first.sharedWith} + {/if} +
+ {#if first.motivation} +

{first.motivation}

+ {/if} + + {/if} + + + {CATEGORY_LABELS[first.category].de} + +
+ {/if} + {/each} + + {#if filtered().length === 0 && allFirsts.length > 0} +

Keine Treffer

+ {/if} +
+ {/if} + + + {#if activeTab === 'dreams'} +
+ {#each [3, 2, 1] as prio} + {@const group = filtered().filter((f) => (f.priority ?? 1) === prio)} + {#if group.length > 0} +
{PRIORITY_LABELS[prio as FirstPriority].de}
+ {#each group as first (first.id)} +
startEdit(first)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + startEdit(first); + } + }} + oncontextmenu={(e) => ctxMenu.open(e, first)} + > +
+ + {first.title} +
+ {#if first.motivation} +

{first.motivation}

+ {/if} + {#if first.sharedWith} +
{first.sharedWith}
+ {/if} + +
+ {/each} + {/if} + {/each} + + {#if dreams.length === 0} +

Keine Dreams. Füge dein erstes Wunsch-Erlebnis hinzu!

+ {/if} +
+ {/if} + + + {#if activeTab === 'people'} +
+ {#each [...personGroups.entries()] as [personKey, firsts] (personKey)} +
+ {personKey === '__alone' ? 'Alleine' : personKey} + ({firsts.length}) +
+ {#each firsts as first (first.id)} +
startEdit(first)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + startEdit(first); + } + }} + oncontextmenu={(e) => ctxMenu.open(e, first)} + > + + {first.title} + {#if first.status === 'lived' && first.date} + {formatDate(first.date)} + {:else} + Dream + {/if} +
+ {/each} + {/each} + + {#if allFirsts.length === 0} +

Noch keine Einträge.

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

Halte dein erstes "Erstes Mal" fest!

+ {/if} + + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/firsts/collections.ts b/apps/mana/apps/web/src/lib/modules/firsts/collections.ts new file mode 100644 index 000000000..95bc11630 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/firsts/collections.ts @@ -0,0 +1,57 @@ +import { db } from '$lib/data/database'; +import type { LocalFirst } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const firstTable = db.table('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[], +}; diff --git a/apps/mana/apps/web/src/lib/modules/firsts/index.ts b/apps/mana/apps/web/src/lib/modules/firsts/index.ts new file mode 100644 index 000000000..8c7e2843d --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/firsts/index.ts @@ -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'; diff --git a/apps/mana/apps/web/src/lib/modules/firsts/module.config.ts b/apps/mana/apps/web/src/lib/modules/firsts/module.config.ts new file mode 100644 index 000000000..d33d9f3b1 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/firsts/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const firstsModuleConfig: ModuleConfig = { + appId: 'firsts', + tables: [{ name: 'firsts' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/firsts/queries.ts b/apps/mana/apps/web/src/lib/modules/firsts/queries.ts new file mode 100644 index 000000000..338def160 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/firsts/queries.ts @@ -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('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('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('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('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 { + const groups = new Map(); + 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 { + const groups = new Map(); + 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; +} diff --git a/apps/mana/apps/web/src/lib/modules/firsts/stores/firsts.svelte.ts b/apps/mana/apps/web/src/lib/modules/firsts/stores/firsts.svelte.ts new file mode 100644 index 000000000..eed653c31 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/firsts/stores/firsts.svelte.ts @@ -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 { + 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 { + 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 = { + 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 = { + ...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(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/firsts/types.ts b/apps/mana/apps/web/src/lib/modules/firsts/types.ts new file mode 100644 index 000000000..0fcfc3879 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/firsts/types.ts @@ -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 = { + culinary: { de: 'Kulinarisch', en: 'Culinary' }, + adventure: { de: 'Abenteuer', en: 'Adventure' }, + travel: { de: 'Reisen', en: 'Travel' }, + people: { de: 'Menschen', en: 'People' }, + career: { de: 'Beruf', en: 'Career' }, + creative: { de: 'Kreativ', en: 'Creative' }, + nature: { de: 'Natur', en: 'Nature' }, + culture: { de: 'Kultur', en: 'Culture' }, + health: { de: 'Gesundheit', en: 'Health' }, + tech: { de: 'Technik', en: 'Tech' }, + other: { de: 'Sonstiges', en: 'Other' }, +}; + +export const CATEGORY_COLORS: Record = { + culinary: '#f97316', + adventure: '#ef4444', + travel: '#0ea5e9', + people: '#ec4899', + career: '#6366f1', + creative: '#a855f7', + nature: '#22c55e', + culture: '#eab308', + health: '#14b8a6', + tech: '#64748b', + other: '#9ca3af', +}; + +export const PRIORITY_LABELS: Record = { + 1: { de: 'Irgendwann', en: 'Someday' }, + 2: { de: 'Dieses Jahr', en: 'This Year' }, + 3: { de: 'So bald wie möglich', en: 'ASAP' }, +}; diff --git a/apps/mana/apps/web/src/routes/(app)/firsts/+page.svelte b/apps/mana/apps/web/src/routes/(app)/firsts/+page.svelte new file mode 100644 index 000000000..e27786088 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/firsts/+page.svelte @@ -0,0 +1,9 @@ + + + + Firsts - Mana + + + {}} goBack={() => history.back()} params={{}} /> diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 9db881765..f254bec69 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -156,6 +156,11 @@ export const APP_ICONS = { // modules (planta, nutriphi) and the pink cycles icon. `` ), + firsts: svgToDataUrl( + // Sparkle/star burst — represents a special "first time" moment. + // Warm amber→rose gradient to evoke excitement and novelty. + `` + ), who: svgToDataUrl( // Theatre mask silhouette in front of a question mark — references // the "guess who's behind the disguise" mechanic. Purple gradient. diff --git a/packages/shared-ui/src/dnd/types.ts b/packages/shared-ui/src/dnd/types.ts index 5741aba23..d4be73af4 100644 --- a/packages/shared-ui/src/dnd/types.ts +++ b/packages/shared-ui/src/dnd/types.ts @@ -25,7 +25,8 @@ export type DragType = | 'transaction' | 'place' | 'dream' - | 'journal-entry'; + | 'journal-entry' + | 'first'; export interface DragPayload> { type: DragType;