mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
e943ac9d94
commit
ab62157a98
14 changed files with 1875 additions and 1 deletions
|
|
@ -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;
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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'] },
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 ──────────────────────────────────────────
|
||||
|
|
|
|||
1262
apps/mana/apps/web/src/lib/modules/firsts/ListView.svelte
Normal file
1262
apps/mana/apps/web/src/lib/modules/firsts/ListView.svelte
Normal file
File diff suppressed because it is too large
Load diff
57
apps/mana/apps/web/src/lib/modules/firsts/collections.ts
Normal file
57
apps/mana/apps/web/src/lib/modules/firsts/collections.ts
Normal 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[],
|
||||
};
|
||||
29
apps/mana/apps/web/src/lib/modules/firsts/index.ts
Normal file
29
apps/mana/apps/web/src/lib/modules/firsts/index.ts
Normal 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';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const firstsModuleConfig: ModuleConfig = {
|
||||
appId: 'firsts',
|
||||
tables: [{ name: 'firsts' }],
|
||||
};
|
||||
144
apps/mana/apps/web/src/lib/modules/firsts/queries.ts
Normal file
144
apps/mana/apps/web/src/lib/modules/firsts/queries.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
117
apps/mana/apps/web/src/lib/modules/firsts/types.ts
Normal file
117
apps/mana/apps/web/src/lib/modules/firsts/types.ts
Normal 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' },
|
||||
};
|
||||
9
apps/mana/apps/web/src/routes/(app)/firsts/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/firsts/+page.svelte
Normal 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={{}} />
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -25,7 +25,8 @@ export type DragType =
|
|||
| 'transaction'
|
||||
| 'place'
|
||||
| 'dream'
|
||||
| 'journal-entry';
|
||||
| 'journal-entry'
|
||||
| 'first';
|
||||
|
||||
export interface DragPayload<T = Record<string, unknown>> {
|
||||
type: DragType;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue