From e42968203d470342252f0b82ec2e6351e7a4b6fc Mon Sep 17 00:00:00 2001 From: Till JS Date: Fri, 10 Apr 2026 19:23:19 +0200 Subject: [PATCH] feat(journal): add journal module with voice capture, mood tracking, and encryption New module at modules/journal/ with daily freeform entries, 8 mood states (emoji picker), tag system, "on this day" historical recaps, streak tracking, word count, favorites, and STT voice capture via VoiceCaptureBar. Title and content encrypted at rest (AES-GCM-256). Registered in module-registry, crypto registry, seed-registry, app-registry, and shared-branding. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/app-registry/apps.ts | 43 + .../apps/web/src/lib/data/crypto/registry.ts | 6 + .../apps/web/src/lib/data/module-registry.ts | 2 + .../apps/web/src/lib/data/seed-registry.ts | 2 + .../src/lib/modules/journal/ListView.svelte | 886 ++++++++++++++++++ .../src/lib/modules/journal/collections.ts | 46 + .../apps/web/src/lib/modules/journal/index.ts | 27 + .../src/lib/modules/journal/module.config.ts | 6 + .../web/src/lib/modules/journal/queries.ts | 166 ++++ .../modules/journal/stores/journal.svelte.ts | 164 ++++ .../apps/web/src/lib/modules/journal/types.ts | 84 ++ .../web/src/routes/(app)/journal/+page.svelte | 9 + packages/shared-branding/src/app-icons.ts | 3 + packages/shared-branding/src/mana-apps.ts | 17 + 14 files changed, 1461 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/modules/journal/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/journal/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/journal/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/journal/module.config.ts create mode 100644 apps/mana/apps/web/src/lib/modules/journal/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/journal/stores/journal.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/journal/types.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/journal/+page.svelte 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 2a6768bb4..5c5e15cab 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -302,6 +302,49 @@ registerApp({ }, }); +registerApp({ + id: 'journal', + name: 'Journal', + color: '#6366F1', + icon: BookOpen, + views: { + list: { load: () => import('$lib/modules/journal/ListView.svelte') }, + }, + contextMenuActions: [ + { + id: 'new-entry', + label: 'Neuer Eintrag', + icon: Plus, + action: () => + window.dispatchEvent( + new CustomEvent('mana:quick-action', { detail: { app: 'journal', action: 'new' } }) + ), + }, + ], + collection: 'journalEntries', + paramKey: 'entryId', + dragType: 'journal-entry', + acceptsDropFrom: ['note'], + transformIncoming: { + note: (source) => ({ + title: source.title as string, + content: (source.content as string) ?? '', + }), + }, + getDisplayData: (item) => ({ + title: (item.title as string) || 'Eintrag', + subtitle: (item.entryDate as string) ?? undefined, + }), + createItem: async (data) => { + const { journalStore } = await import('$lib/modules/journal/stores/journal.svelte'); + const entry = await journalStore.createEntry({ + title: (data.title as string) ?? null, + content: (data.content as string) ?? '', + }); + return entry.id; + }, +}); + registerApp({ id: 'dreams', name: 'Dreams', 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 855ec67c4..2b6bd4633 100644 --- a/apps/mana/apps/web/src/lib/data/crypto/registry.ts +++ b/apps/mana/apps/web/src/lib/data/crypto/registry.ts @@ -59,6 +59,12 @@ export const ENCRYPTION_REGISTRY: Record = { // uses `title` + `content` (no separate `body` column). notes: { enabled: true, fields: ['title', 'content'] }, + // ─── Journal ───────────────────────────────────────────── + // Daily freeform entries — title and content are the user-typed parts. + // entryDate, mood (enum), tags (string[]), isPinned/isArchived/isFavorite, + // wordCount stay plaintext for indexing, sorting, and insights. + journalEntries: { enabled: true, fields: ['title', 'content'] }, + // ─── Dreams ────────────────────────────────────────────── // LocalDream uses content + transcript + interpretation, no `notes`. dreams: { 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 3b91a0169..84318f247 100644 --- a/apps/mana/apps/web/src/lib/data/module-registry.ts +++ b/apps/mana/apps/web/src/lib/data/module-registry.ts @@ -77,6 +77,7 @@ import { memoroModuleConfig } from '$lib/modules/memoro/module.config'; import { guidesModuleConfig } from '$lib/modules/guides/module.config'; import { habitsModuleConfig } from '$lib/modules/habits/module.config'; import { notesModuleConfig } from '$lib/modules/notes/module.config'; +import { journalModuleConfig } from '$lib/modules/journal/module.config'; import { dreamsModuleConfig } from '$lib/modules/dreams/module.config'; import { cyclesModuleConfig } from '$lib/modules/cycles/module.config'; import { eventsModuleConfig } from '$lib/modules/events/module.config'; @@ -118,6 +119,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [ guidesModuleConfig, habitsModuleConfig, notesModuleConfig, + journalModuleConfig, dreamsModuleConfig, cyclesModuleConfig, eventsModuleConfig, diff --git a/apps/mana/apps/web/src/lib/data/seed-registry.ts b/apps/mana/apps/web/src/lib/data/seed-registry.ts index bc23fe76b..216067a71 100644 --- a/apps/mana/apps/web/src/lib/data/seed-registry.ts +++ b/apps/mana/apps/web/src/lib/data/seed-registry.ts @@ -16,6 +16,7 @@ import { db } from './database'; // ─── Module Seed Imports ───────────────────────────────────── import { HABITS_GUEST_SEED } from '$lib/modules/habits/collections'; import { BODY_GUEST_SEED } from '$lib/modules/body/collections'; +import { JOURNAL_GUEST_SEED } from '$lib/modules/journal/collections'; import { DREAMS_GUEST_SEED } from '$lib/modules/dreams/collections'; import { MOODLIT_GUEST_SEED } from '$lib/modules/moodlit/collections'; import { CONTACTS_GUEST_SEED } from '$lib/modules/contacts/collections'; @@ -47,6 +48,7 @@ function register(seed: Record[]>) { // Register all module seeds register(HABITS_GUEST_SEED); register(BODY_GUEST_SEED); +register(JOURNAL_GUEST_SEED); register(DREAMS_GUEST_SEED); register(MOODLIT_GUEST_SEED); register(CONTACTS_GUEST_SEED); diff --git a/apps/mana/apps/web/src/lib/modules/journal/ListView.svelte b/apps/mana/apps/web/src/lib/modules/journal/ListView.svelte new file mode 100644 index 000000000..8cdea0f6c --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/journal/ListView.svelte @@ -0,0 +1,886 @@ + + + +
+ + + + +
e.preventDefault()} class="quick-add"> + {'\u{270d}\u{fe0f}'} + +
+ + + {#if onThisDay.length > 0} +
+
An diesem Tag
+ {#each onThisDay as old (old.id)} + + {/each} +
+ {/if} + + + {#if insights.total > 0} +
+ {insights.total} Einträge + {#if insights.streak > 0} + {'\u{1f525}'} {insights.streak} Tage Streak + {/if} + {#if insights.totalWords > 0} + {insights.totalWords.toLocaleString('de-DE')} Wörter + {/if} + {#each insights.topTags as t} + + {/each} + {#if tagFilter} + + {/if} +
+ {/if} + + + {#if entries.length > 0} +
+ + {#each MOODS as mood} + + {/each} +
+ {/if} + + + {#if entries.length > 5} + + {/if} + + +
+ {#each grouped as group (group.label)} +
{group.label}
+ {#each group.entries as entry (entry.id)} + {#if editingId === entry.id} + + +
{ + if (e.key === 'Escape') saveEdit(); + }} + > + + + + + +
+
+ {#each MOODS as mood} + + {/each} +
+
+ +
+ +
+ +
+ + +
+
+ {:else} + +
startEdit(entry)} + onkeydown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + startEdit(entry); + } + }} + oncontextmenu={(e) => ctxMenu.open(e, entry)} + > + {#if entry.mood} + + {:else} + + {/if} + +
+
+ {entry.title || 'Ohne Titel'} + {#if entry.isPinned}{'\u{1f4cc}'}{/if} + {#if entry.isFavorite}{'\u2b50'}{/if} +
+ {#if entry.content} +

{entry.content.split('\n')[0]}

+ {/if} + +
+
+ {/if} + {/each} + {/each} + + {#if filtered.length === 0 && entries.length > 0} +

Keine Treffer

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

Schreibe deinen ersten Tagebucheintrag.

+ {/if} + + +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/journal/collections.ts b/apps/mana/apps/web/src/lib/modules/journal/collections.ts new file mode 100644 index 000000000..7e291a4c8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/journal/collections.ts @@ -0,0 +1,46 @@ +/** + * Journal module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalJournalEntry } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const journalEntryTable = db.table('journalEntries'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const today = new Date().toISOString().slice(0, 10); +const yesterday = new Date(Date.now() - 86_400_000).toISOString().slice(0, 10); + +export const JOURNAL_GUEST_SEED = { + journalEntries: [ + { + id: 'journal-welcome', + title: 'Willkommen im Tagebuch', + content: + 'Schreibe täglich deine Gedanken und Gefühle auf. Je regelmäßiger, desto wertvoller wird dein Tagebuch über die Zeit.\n\n**Tipps:**\n- Schreibe frei, ohne Filter — niemand liest es außer dir.\n- Wähle eine Stimmung, um Muster zu erkennen.\n- Nutze Tags, um Themen zu verfolgen.\n- Deine Einträge sind verschlüsselt.', + entryDate: today, + mood: 'zufrieden', + tags: ['Start'], + isPinned: true, + isArchived: false, + isFavorite: false, + wordCount: 42, + }, + { + id: 'journal-example', + title: 'Ein guter Tag', + content: + 'Heute war ein ruhiger Tag. Morgens Kaffee auf dem Balkon, danach produktiv gearbeitet. Am Nachmittag einen langen Spaziergang gemacht — das Wetter war perfekt. Abends gekocht und früh ins Bett.', + entryDate: yesterday, + mood: 'glücklich', + tags: ['Alltag', 'Natur'], + isPinned: false, + isArchived: false, + isFavorite: true, + wordCount: 30, + }, + ] satisfies LocalJournalEntry[], +}; diff --git a/apps/mana/apps/web/src/lib/modules/journal/index.ts b/apps/mana/apps/web/src/lib/modules/journal/index.ts new file mode 100644 index 000000000..dfbef085f --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/journal/index.ts @@ -0,0 +1,27 @@ +/** + * Journal module — barrel exports. + */ + +// ─── Stores ────────────────────────────────────────────── +export { journalStore } from './stores/journal.svelte'; + +// ─── Queries ───────────────────────────────────────────── +export { + useAllJournalEntries, + useJournalEntry, + toJournalEntry, + searchEntries, + groupByMonth, + formatEntryDate, + getOnThisDay, + getTagStats, + getMoodDistribution, + computeInsights, +} from './queries'; + +// ─── Collections ───────────────────────────────────────── +export { journalEntryTable, JOURNAL_GUEST_SEED } from './collections'; + +// ─── Types ─────────────────────────────────────────────── +export { MOOD_COLORS, MOOD_LABELS, MOOD_EMOJI } from './types'; +export type { LocalJournalEntry, JournalEntry, JournalMood } from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/journal/module.config.ts b/apps/mana/apps/web/src/lib/modules/journal/module.config.ts new file mode 100644 index 000000000..fcb6acdf0 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/journal/module.config.ts @@ -0,0 +1,6 @@ +import type { ModuleConfig } from '$lib/data/module-registry'; + +export const journalModuleConfig: ModuleConfig = { + appId: 'journal', + tables: [{ name: 'journalEntries' }], +}; diff --git a/apps/mana/apps/web/src/lib/modules/journal/queries.ts b/apps/mana/apps/web/src/lib/modules/journal/queries.ts new file mode 100644 index 000000000..07e091889 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/journal/queries.ts @@ -0,0 +1,166 @@ +/** + * Reactive Queries & Pure Helpers for Journal module. + * + * Content fields (title, content) are encrypted at rest. liveQueries + * filter on plaintext metadata first (deletedAt, isArchived) and + * then decryptRecords the visible set before mapping to public types. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; +import type { JournalEntry, JournalMood, LocalJournalEntry } from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toJournalEntry(local: LocalJournalEntry): JournalEntry { + return { + id: local.id, + title: local.title, + content: local.content, + entryDate: local.entryDate, + mood: local.mood, + tags: local.tags ?? [], + isPinned: local.isPinned, + isArchived: local.isArchived, + isFavorite: local.isFavorite ?? false, + wordCount: local.wordCount ?? 0, + transcriptModel: local.transcriptModel ?? null, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllJournalEntries() { + return useLiveQueryWithDefault(async () => { + const visible = (await db.table('journalEntries').toArray()).filter( + (e) => !e.deletedAt && !e.isArchived + ); + const decrypted = await decryptRecords('journalEntries', visible); + return decrypted.map(toJournalEntry).sort((a, b) => { + if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1; + return b.entryDate.localeCompare(a.entryDate); + }); + }, [] as JournalEntry[]); +} + +export function useJournalEntry(id: string) { + return useLiveQueryWithDefault( + async () => { + const local = await db.table('journalEntries').get(id); + if (!local || local.deletedAt) return null; + const [decrypted] = await decryptRecords('journalEntries', [local]); + return decrypted ? toJournalEntry(decrypted) : null; + }, + null as JournalEntry | null + ); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +/** Search journal entries by title, content, mood and tags. */ +export function searchEntries(entries: JournalEntry[], query: string): JournalEntry[] { + if (!query.trim()) return entries; + const q = query.toLowerCase(); + return entries.filter((e) => { + const haystack = [e.title, e.content, e.mood, ...(e.tags ?? [])] + .filter(Boolean) + .join(' ') + .toLowerCase(); + return haystack.includes(q); + }); +} + +/** Group entries by month label (e.g. "April 2026"). */ +export function groupByMonth( + entries: JournalEntry[] +): Array<{ label: string; entries: JournalEntry[] }> { + const groups = new Map(); + for (const e of entries) { + const date = new Date(e.entryDate); + const label = date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + if (!groups.has(label)) groups.set(label, []); + groups.get(label)!.push(e); + } + return Array.from(groups, ([label, entries]) => ({ label, entries })); +} + +/** Format the entry date relative to today. */ +export function formatEntryDate(iso: string): string { + const date = new Date(iso); + const today = new Date(); + const diffDays = Math.floor((today.getTime() - date.getTime()) / 86_400_000); + if (diffDays === 0) return 'Heute'; + if (diffDays === 1) return 'Gestern'; + if (diffDays < 7) return `vor ${diffDays} Tagen`; + return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' }); +} + +/** Find "On this day" entries — same month+day from previous years. */ +export function getOnThisDay(entries: JournalEntry[], today?: Date): JournalEntry[] { + const ref = today ?? new Date(); + const month = ref.getMonth(); + const day = ref.getDate(); + const thisYear = ref.getFullYear(); + + return entries + .filter((e) => { + const d = new Date(e.entryDate); + return d.getMonth() === month && d.getDate() === day && d.getFullYear() < thisYear; + }) + .sort((a, b) => b.entryDate.localeCompare(a.entryDate)); +} + +/** Collect all unique tags with their usage count, sorted by frequency. */ +export function getTagStats(entries: JournalEntry[]): Array<{ tag: string; count: number }> { + const counts = new Map(); + for (const e of entries) { + for (const tag of e.tags ?? []) { + counts.set(tag, (counts.get(tag) ?? 0) + 1); + } + } + return Array.from(counts, ([tag, count]) => ({ tag, count })).sort((a, b) => b.count - a.count); +} + +/** Mood distribution across all entries. */ +export function getMoodDistribution( + entries: JournalEntry[] +): Array<{ mood: string; count: number }> { + const buckets = new Map(); + for (const e of entries) { + const key = e.mood ?? 'unbekannt'; + buckets.set(key, (buckets.get(key) ?? 0) + 1); + } + return Array.from(buckets, ([mood, count]) => ({ mood, count })).sort( + (a, b) => b.count - a.count + ); +} + +/** Compute insights snapshot from journal entries. */ +export function computeInsights(entries: JournalEntry[]) { + const total = entries.length; + const favoriteCount = entries.filter((e) => e.isFavorite).length; + const totalWords = entries.reduce((sum, e) => sum + (e.wordCount ?? 0), 0); + const tagStats = getTagStats(entries).slice(0, 5); + const moodDist = getMoodDistribution(entries); + + // Current streak (consecutive days with entries, counting backwards from today) + let streak = 0; + const dateSet = new Set(entries.map((e) => e.entryDate)); + const cursor = new Date(); + while (dateSet.has(cursor.toISOString().slice(0, 10))) { + streak++; + cursor.setDate(cursor.getDate() - 1); + } + + return { + total, + favoriteCount, + totalWords, + streak, + topTags: tagStats, + topMood: moodDist[0] ?? null, + }; +} diff --git a/apps/mana/apps/web/src/lib/modules/journal/stores/journal.svelte.ts b/apps/mana/apps/web/src/lib/modules/journal/stores/journal.svelte.ts new file mode 100644 index 000000000..81143de04 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/journal/stores/journal.svelte.ts @@ -0,0 +1,164 @@ +/** + * Journal Store — Mutation-Only Service + * + * Title and content are encrypted at rest. Tags, mood, entryDate, + * isPinned/isArchived/isFavorite stay plaintext for indexing. + */ + +import { journalEntryTable } from '../collections'; +import { toJournalEntry } from '../queries'; +import { encryptRecord } from '$lib/data/crypto'; +import { transcribeAudio } from '$lib/voice/transcribe'; +import type { JournalEntry, JournalMood, LocalJournalEntry } from '../types'; + +function todayIsoDate(): string { + return new Date().toISOString().slice(0, 10); +} + +function countWords(text: string): number { + return text + .trim() + .split(/\s+/) + .filter((w) => w.length > 0).length; +} + +export const journalStore = { + async createEntry(data: { + title?: string | null; + content?: string; + entryDate?: string; + mood?: JournalMood | null; + tags?: string[]; + }): Promise { + const content = data.content ?? ''; + + const newLocal: LocalJournalEntry = { + id: crypto.randomUUID(), + title: data.title ?? null, + content, + entryDate: data.entryDate ?? todayIsoDate(), + mood: data.mood ?? null, + tags: data.tags ?? [], + isPinned: false, + isArchived: false, + isFavorite: false, + wordCount: countWords(content), + }; + + const plaintextSnapshot = toJournalEntry(newLocal); + await encryptRecord('journalEntries', newLocal); + await journalEntryTable.add(newLocal); + return plaintextSnapshot; + }, + + async updateEntry( + id: string, + data: Partial< + Pick< + LocalJournalEntry, + | 'title' + | 'content' + | 'entryDate' + | 'mood' + | 'tags' + | 'isPinned' + | 'isArchived' + | 'isFavorite' + > + > + ) { + const diff: Partial = { + ...data, + updatedAt: new Date().toISOString(), + }; + + // Recompute word count when content changes + if (data.content !== undefined) { + diff.wordCount = countWords(data.content); + } + + await encryptRecord('journalEntries', diff); + await journalEntryTable.update(id, diff); + }, + + /** + * Create an entry from a voice recording. Returns the placeholder + * immediately so the UI can open the editor; the transcript is + * filled in asynchronously once mana-stt returns. + */ + async createFromVoice(blob: Blob, _durationMs: number, language = 'de'): Promise { + const entry = await this.createEntry({ title: 'Spracheintrag', content: '\u2026' }); + void this.transcribeIntoEntry(entry.id, blob, language); + return entry; + }, + + /** + * Upload an audio blob to /api/v1/voice/transcribe and write the + * transcript into an existing entry. On failure, surfaces the error + * inline as the entry content. + */ + async transcribeIntoEntry(entryId: string, blob: Blob, language?: string): Promise { + try { + const result = await transcribeAudio(blob, language); + const transcript = result.text; + + const firstLine = transcript.split('\n')[0]?.trim() ?? ''; + const title = firstLine.length > 0 && firstLine.length <= 80 ? firstLine : 'Spracheintrag'; + + const diff: Partial = { + title, + content: transcript, + transcriptModel: result.model, + wordCount: countWords(transcript), + updatedAt: new Date().toISOString(), + }; + await encryptRecord('journalEntries', diff); + await journalEntryTable.update(entryId, diff); + } catch (e) { + const msg = e instanceof Error ? e.message : String(e); + await this.updateEntry(entryId, { + title: 'Spracheintrag (Fehler)', + content: `Transkription fehlgeschlagen: ${msg}`, + }); + } + }, + + async deleteEntry(id: string) { + await journalEntryTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async togglePin(id: string) { + const entry = await journalEntryTable.get(id); + if (!entry) return; + await journalEntryTable.update(id, { + isPinned: !entry.isPinned, + updatedAt: new Date().toISOString(), + }); + }, + + async toggleFavorite(id: string) { + const entry = await journalEntryTable.get(id); + if (!entry) return; + await journalEntryTable.update(id, { + isFavorite: !entry.isFavorite, + updatedAt: new Date().toISOString(), + }); + }, + + async setMood(id: string, mood: JournalMood | null) { + await journalEntryTable.update(id, { + mood, + updatedAt: new Date().toISOString(), + }); + }, + + async archiveEntry(id: string) { + await journalEntryTable.update(id, { + isArchived: true, + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/journal/types.ts b/apps/mana/apps/web/src/lib/modules/journal/types.ts new file mode 100644 index 000000000..bfb6b4e65 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/journal/types.ts @@ -0,0 +1,84 @@ +/** + * Journal module types — Tagebuch. + */ + +import type { BaseRecord } from '@mana/local-store'; + +export type JournalMood = + | 'dankbar' + | 'glücklich' + | 'zufrieden' + | 'neutral' + | 'nachdenklich' + | 'traurig' + | 'gestresst' + | 'wütend'; + +// ─── Local Record Types (Dexie) ─────────────────────────── + +export interface LocalJournalEntry extends BaseRecord { + title: string | null; + content: string; + entryDate: string; // ISO date (YYYY-MM-DD) + mood: JournalMood | null; + tags: string[]; + isPinned: boolean; + isArchived: boolean; + isFavorite: boolean; + wordCount: number; + /** STT backend/model identifier. Set when entry created via voice. */ + transcriptModel?: string | null; +} + +// ─── Domain Types ───────────────────────────────────────── + +export interface JournalEntry { + id: string; + title: string | null; + content: string; + entryDate: string; + mood: JournalMood | null; + tags: string[]; + isPinned: boolean; + isArchived: boolean; + isFavorite: boolean; + wordCount: number; + transcriptModel: string | null; + createdAt: string; + updatedAt: string; +} + +// ─── Constants ──────────────────────────────────────────── + +export const MOOD_COLORS: Record = { + dankbar: '#22c55e', + glücklich: '#f59e0b', + zufrieden: '#3b82f6', + neutral: '#9ca3af', + nachdenklich: '#8b5cf6', + traurig: '#6366f1', + gestresst: '#ef4444', + wütend: '#dc2626', +}; + +export const MOOD_LABELS: Record = { + dankbar: 'Dankbar', + glücklich: 'Glücklich', + zufrieden: 'Zufrieden', + neutral: 'Neutral', + nachdenklich: 'Nachdenklich', + traurig: 'Traurig', + gestresst: 'Gestresst', + wütend: 'Wütend', +}; + +export const MOOD_EMOJI: Record = { + dankbar: '\u{1f64f}', + glücklich: '\u{1f60a}', + zufrieden: '\u{263a}\u{fe0f}', + neutral: '\u{1f610}', + nachdenklich: '\u{1f914}', + traurig: '\u{1f614}', + gestresst: '\u{1f62b}', + wütend: '\u{1f621}', +}; diff --git a/apps/mana/apps/web/src/routes/(app)/journal/+page.svelte b/apps/mana/apps/web/src/routes/(app)/journal/+page.svelte new file mode 100644 index 000000000..d51c5d625 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/journal/+page.svelte @@ -0,0 +1,9 @@ + + + + Journal - Mana + + + {}} goBack={() => history.back()} params={{}} /> diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index 91daf641a..9db881765 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -126,6 +126,9 @@ export const APP_ICONS = { habits: svgToDataUrl( `` ), + journal: svgToDataUrl( + `` + ), notes: svgToDataUrl( `` ), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index 02fa95167..d8c8b088d 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -598,6 +598,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'founder', }, + { + id: 'journal', + name: 'Journal', + description: { + de: 'Tagebuch', + en: 'Journal', + }, + longDescription: { + de: 'Täglich deine Gedanken und Gefühle festhalten. Mit Stimmungen, Tags, Streak-Tracking und historischen Rückblicken.', + en: 'Capture your thoughts and feelings daily. With moods, tags, streak tracking, and historical recaps.', + }, + icon: APP_ICONS.journal, + color: '#6366f1', + comingSoon: false, + status: 'development', + requiredTier: 'guest', + }, { id: 'notes', name: 'Notes',