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) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-10 19:23:19 +02:00
parent 0f634b2540
commit e42968203d
14 changed files with 1461 additions and 0 deletions

View file

@ -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',

View file

@ -59,6 +59,12 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// 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: {

View file

@ -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,

View file

@ -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<string, Record<string, unknown>[]>) {
// 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);

View file

@ -0,0 +1,886 @@
<!--
Journal — Workbench ListView
Daily freeform entries with mood, tags, "on this day" recap.
-->
<script lang="ts">
import {
computeInsights,
formatEntryDate,
getOnThisDay,
groupByMonth,
searchEntries,
useAllJournalEntries,
} from './queries';
import { journalStore } from './stores/journal.svelte';
import {
MOOD_COLORS,
MOOD_EMOJI,
MOOD_LABELS,
type JournalEntry,
type JournalMood,
} from './types';
import VoiceCaptureBar from '$lib/components/voice/VoiceCaptureBar.svelte';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { useItemContextMenu } from '$lib/data/item-context-menu.svelte';
import { PencilSimple, PushPin, Star, Trash, Archive } from '@mana/shared-icons';
let { navigate, goBack, params }: ViewProps = $props();
const MOODS: JournalMood[] = [
'dankbar',
'glücklich',
'zufrieden',
'neutral',
'nachdenklich',
'traurig',
'gestresst',
'wütend',
];
let entries$ = useAllJournalEntries();
let entries = $derived(entries$.value);
let searchQuery = $state('');
let tagFilter = $state<string | null>(null);
let moodFilter = $state<JournalMood | null>(null);
let editingId = $state<string | null>(null);
let editTitle = $state('');
let editContent = $state('');
let editTags = $state('');
let editMood = $state<JournalMood | null>(null);
let editEntryDate = $state('');
let newTitle = $state('');
// While the inline editor is open, the `entries` array updates whenever the
// transcript lands. If the user hasn't typed anything yet, fold the fresh
// content into the edit buffer so they see the transcription appear inline.
$effect(() => {
if (!editingId) return;
const live = entries.find((e) => e.id === editingId);
if (!live) return;
if (!editContent.trim() || editContent === '\u2026') {
if (live.content.trim() && live.content !== '\u2026') {
editContent = live.content;
editTitle = live.title ?? '';
}
}
});
// ── Voice capture ─────────────────────────────────────────
async function handleVoiceComplete(blob: Blob, durationMs: number) {
const entry = await journalStore.createFromVoice(blob, durationMs, 'de');
startEdit(entry);
}
let filteredByTag = $derived(
tagFilter ? entries.filter((e) => e.tags?.includes(tagFilter!)) : entries
);
let filteredByMood = $derived(
moodFilter ? filteredByTag.filter((e) => e.mood === moodFilter) : filteredByTag
);
let filtered = $derived(searchEntries(filteredByMood, searchQuery));
let grouped = $derived(groupByMonth(filtered));
let insights = $derived(computeInsights(entries));
let onThisDay = $derived(getOnThisDay(entries));
async function handleQuickCreate(e: KeyboardEvent) {
if (e.key !== 'Enter' || !newTitle.trim()) return;
e.preventDefault();
const entry = await journalStore.createEntry({ title: newTitle.trim() });
newTitle = '';
startEdit(entry);
}
function startEdit(entry: JournalEntry) {
if (editingId && editingId !== entry.id) saveEdit();
editingId = entry.id;
editTitle = entry.title ?? '';
editContent = entry.content;
editTags = (entry.tags ?? []).join(', ');
editMood = entry.mood;
editEntryDate = entry.entryDate;
}
async function saveEdit() {
if (!editingId) return;
const tags = editTags
.split(',')
.map((s) => s.trim())
.filter(Boolean);
await journalStore.updateEntry(editingId, {
title: editTitle.trim() || null,
content: editContent,
tags,
mood: editMood,
entryDate: editEntryDate || new Date().toISOString().slice(0, 10),
});
editingId = null;
}
async function handleDelete(id: string) {
await journalStore.deleteEntry(id);
if (editingId === id) editingId = null;
}
const ctxMenu = useItemContextMenu<JournalEntry>();
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.state.target
? [
{
id: 'edit',
label: 'Bearbeiten',
icon: PencilSimple,
action: () => {
const target = ctxMenu.state.target;
if (target) startEdit(target);
},
},
{
id: 'pin',
label: ctxMenu.state.target.isPinned ? 'Lösen' : 'Pinnen',
icon: PushPin,
action: () => {
const target = ctxMenu.state.target;
if (target) journalStore.togglePin(target.id);
},
},
{
id: 'favorite',
label: ctxMenu.state.target.isFavorite ? 'Favorit entfernen' : 'Favorit',
icon: Star,
action: () => {
const target = ctxMenu.state.target;
if (target) journalStore.toggleFavorite(target.id);
},
},
{
id: 'archive',
label: 'Archivieren',
icon: Archive,
action: () => {
const target = ctxMenu.state.target;
if (target) journalStore.archiveEntry(target.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
const target = ctxMenu.state.target;
if (target) handleDelete(target.id);
},
},
]
: []
);
</script>
<div class="app-view">
<!-- Voice capture -->
<VoiceCaptureBar
idleLabel="Eintrag sprechen"
feature="journal-voice-capture"
reason="Spracheinträge werden verschlüsselt in deinem Tagebuch gespeichert. Dafür brauchst du ein Mana-Konto."
onComplete={handleVoiceComplete}
/>
<!-- Quick create -->
<form onsubmit={(e) => e.preventDefault()} class="quick-add">
<span class="add-icon">{'\u{270d}\u{fe0f}'}</span>
<input
class="add-input"
type="text"
placeholder="Was bewegt dich heute? (Enter)"
bind:value={newTitle}
onkeydown={handleQuickCreate}
/>
</form>
<!-- On this day -->
{#if onThisDay.length > 0}
<div class="on-this-day">
<div class="otd-header">An diesem Tag</div>
{#each onThisDay as old (old.id)}
<button class="otd-entry" onclick={() => startEdit(old)}>
<span class="otd-year">{new Date(old.entryDate).getFullYear()}</span>
<span class="otd-title">{old.title || old.content.split('\n')[0].slice(0, 60)}</span>
{#if old.mood}
<span class="otd-mood" style="color: {MOOD_COLORS[old.mood]}"
>{MOOD_EMOJI[old.mood]}</span
>
{/if}
</button>
{/each}
</div>
{/if}
<!-- Insights ribbon -->
{#if insights.total > 0}
<div class="insights">
<span class="ins-stat">{insights.total} Einträge</span>
{#if insights.streak > 0}
<span class="ins-stat">{'\u{1f525}'} {insights.streak} Tage Streak</span>
{/if}
{#if insights.totalWords > 0}
<span class="ins-stat">{insights.totalWords.toLocaleString('de-DE')} Wörter</span>
{/if}
{#each insights.topTags as t}
<button
class="ins-tag"
class:active={tagFilter === t.tag}
onclick={() => (tagFilter = tagFilter === t.tag ? null : t.tag)}
>
{t.tag} · {t.count}
</button>
{/each}
{#if tagFilter}
<button class="ins-clear" onclick={() => (tagFilter = null)}>{'\u00d7'}&nbsp;Filter</button>
{/if}
</div>
{/if}
<!-- Mood filter -->
{#if entries.length > 0}
<div class="filter-tabs">
<button
class="filter-tab"
class:active={moodFilter === null}
onclick={() => (moodFilter = null)}
>
Alle
</button>
{#each MOODS as mood}
<button
class="filter-tab"
class:active={moodFilter === mood}
onclick={() => (moodFilter = moodFilter === mood ? null : mood)}
title={MOOD_LABELS[mood]}
>
{MOOD_EMOJI[mood]}
</button>
{/each}
</div>
{/if}
<!-- Search -->
{#if entries.length > 5}
<input
class="search-input"
type="text"
placeholder="Tagebuch durchsuchen..."
bind:value={searchQuery}
/>
{/if}
<!-- Entry list -->
<div class="entry-list">
{#each grouped as group (group.label)}
<div class="month-label">{group.label}</div>
{#each group.entries as entry (entry.id)}
{#if editingId === entry.id}
<!-- Inline editor -->
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="entry-item editing"
onkeydown={(e) => {
if (e.key === 'Escape') saveEdit();
}}
>
<!-- svelte-ignore a11y_autofocus -->
<input
class="ed-title"
type="text"
bind:value={editTitle}
placeholder="Titel (optional)..."
autofocus
/>
<textarea
class="ed-content"
bind:value={editContent}
placeholder="Schreibe frei..."
rows="8"
></textarea>
<input
class="ed-tags"
type="text"
bind:value={editTags}
placeholder="Tags (Komma-getrennt): Alltag, Arbeit, Natur"
/>
<div class="ed-row">
<div class="mood-picker">
{#each MOODS as mood}
<button
class="mood-btn"
class:active={editMood === mood}
style="--mood-color: {MOOD_COLORS[mood]}"
onclick={() => (editMood = editMood === mood ? null : mood)}
title={MOOD_LABELS[mood]}
>
<span class="mood-emoji">{MOOD_EMOJI[mood]}</span>
</button>
{/each}
</div>
</div>
<div class="ed-row">
<label class="ed-field">
<span class="ed-label">Datum</span>
<input type="date" bind:value={editEntryDate} class="ed-input-sm" />
</label>
</div>
<div class="ed-actions">
<button class="ed-btn danger" onclick={() => handleDelete(entry.id)}>Löschen</button>
<button class="ed-btn primary" onclick={saveEdit}>Fertig</button>
</div>
</div>
{:else}
<!-- Entry row -->
<div
class="entry-item"
role="button"
tabindex="0"
onclick={() => startEdit(entry)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
startEdit(entry);
}
}}
oncontextmenu={(e) => ctxMenu.open(e, entry)}
>
{#if entry.mood}
<span
class="mood-dot-row"
style="background: {MOOD_COLORS[entry.mood]}"
title={MOOD_LABELS[entry.mood]}
></span>
{:else}
<span class="mood-dot-row empty"></span>
{/if}
<div class="entry-content">
<div class="entry-top">
<span class="entry-title">{entry.title || 'Ohne Titel'}</span>
{#if entry.isPinned}<span class="badge">{'\u{1f4cc}'}</span>{/if}
{#if entry.isFavorite}<span class="badge">{'\u2b50'}</span>{/if}
</div>
{#if entry.content}
<p class="entry-preview">{entry.content.split('\n')[0]}</p>
{/if}
<div class="entry-meta">
<span>{formatEntryDate(entry.entryDate)}</span>
{#if entry.transcriptModel}
<span class="dot">{'\u00b7'}</span>
<span class="stt-chip" title="STT-Pipeline">
{'\u{1f3a4}'}
{entry.transcriptModel}
</span>
{/if}
{#if entry.wordCount > 0}
<span class="dot">{'\u00b7'}</span>
<span>{entry.wordCount} Wörter</span>
{/if}
{#if entry.tags.length > 0}
<span class="dot">{'\u00b7'}</span>
<span class="tag-chips">
{#each entry.tags.slice(0, 3) as tag}
<button
class="tag-chip"
class:active={tagFilter === tag}
onclick={(e) => {
e.stopPropagation();
tagFilter = tagFilter === tag ? null : tag;
}}
>
{tag}
</button>
{/each}
</span>
{/if}
</div>
</div>
</div>
{/if}
{/each}
{/each}
{#if filtered.length === 0 && entries.length > 0}
<p class="empty">Keine Treffer</p>
{/if}
</div>
{#if entries.length === 0}
<p class="empty">Schreibe deinen ersten Tagebucheintrag.</p>
{/if}
<ContextMenu
visible={ctxMenu.state.visible}
x={ctxMenu.state.x}
y={ctxMenu.state.y}
items={ctxMenuItems}
onClose={ctxMenu.close}
/>
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 1rem;
height: 100%;
}
/* ── Quick Add ─────────────────────────────── */
.quick-add {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
}
.add-icon {
font-size: 0.875rem;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: hsl(var(--color-foreground));
}
.add-input::placeholder {
color: hsl(var(--color-muted-foreground));
}
/* ── On This Day ──────────────────────────── */
.on-this-day {
padding: 0.5rem;
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.04);
border: 1px solid hsl(var(--color-primary) / 0.12);
}
.otd-header {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-primary));
font-weight: 600;
margin-bottom: 0.375rem;
}
.otd-entry {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.25rem 0;
background: transparent;
border: none;
cursor: pointer;
text-align: left;
font-size: 0.75rem;
color: hsl(var(--color-foreground));
}
.otd-entry:hover {
color: hsl(var(--color-primary));
}
.otd-year {
font-weight: 600;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
min-width: 2.5rem;
}
.otd-title {
flex: 1;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.otd-mood {
font-size: 0.75rem;
}
/* ── Insights ──────────────────────────────── */
.insights {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.375rem 0.25rem;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
}
.ins-stat {
font-weight: 500;
}
.ins-tag {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: hsl(var(--color-primary) / 0.08);
color: hsl(var(--color-primary));
border: 1px solid transparent;
font-size: 0.6875rem;
cursor: pointer;
}
.ins-tag:hover {
background: hsl(var(--color-primary) / 0.16);
}
.ins-tag.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.ins-clear {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: transparent;
color: hsl(var(--color-muted-foreground));
border: 1px dashed hsl(var(--color-border));
font-size: 0.6875rem;
cursor: pointer;
}
.ins-clear:hover {
color: hsl(var(--color-error));
border-color: hsl(var(--color-error));
}
/* ── Filter Tabs ───────────────────────────── */
.filter-tabs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.filter-tab {
padding: 0.25rem 0.625rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
cursor: pointer;
transition: all 0.15s;
}
.filter-tab:hover {
color: hsl(var(--color-primary));
}
.filter-tab.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
border-color: hsl(var(--color-primary));
}
/* ── Search ────────────────────────────────── */
.search-input {
padding: 0.3rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.75rem;
color: hsl(var(--color-foreground));
outline: none;
}
.search-input:focus {
border-color: hsl(var(--color-primary));
}
/* ── Entry List ────────────────────────────── */
.entry-list {
flex: 1;
overflow-y: auto;
}
.month-label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
padding: 0.75rem 0.25rem 0.25rem;
font-weight: 600;
}
.entry-item {
display: flex;
align-items: flex-start;
gap: 0.5rem;
width: 100%;
padding: 0.5rem 0.25rem;
border: none;
background: transparent;
text-align: left;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.entry-item:hover {
background: hsl(var(--color-surface-hover));
}
.mood-dot-row {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
margin-top: 0.4375rem;
}
.mood-dot-row.empty {
background: hsl(var(--color-border));
}
.entry-content {
min-width: 0;
flex: 1;
}
.entry-top {
display: flex;
align-items: center;
gap: 0.375rem;
}
.entry-title {
font-size: 0.8125rem;
font-weight: 500;
color: hsl(var(--color-foreground));
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.badge {
font-size: 0.625rem;
}
.entry-preview {
font-size: 0.6875rem;
color: hsl(var(--color-muted-foreground));
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.entry-meta {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.625rem;
color: hsl(var(--color-muted-foreground));
margin-top: 0.125rem;
}
.entry-meta .dot {
opacity: 0.5;
}
.stt-chip {
display: inline-flex;
align-items: center;
gap: 0.125rem;
padding: 0 0.375rem;
border-radius: 9999px;
background: hsl(var(--color-muted) / 0.6);
color: hsl(var(--color-muted-foreground));
font-size: 0.5625rem;
font-variant-numeric: tabular-nums;
}
.tag-chips {
display: inline-flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.tag-chip {
padding: 0.0625rem 0.375rem;
border-radius: 9999px;
border: 1px solid transparent;
background: hsl(var(--color-primary) / 0.08);
color: hsl(var(--color-primary));
font-size: 0.625rem;
cursor: pointer;
transition: all 0.15s;
}
.tag-chip:hover {
background: hsl(var(--color-primary) / 0.18);
}
.tag-chip.active {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
/* ── Inline Editor ─────────────────────────── */
.entry-item.editing {
cursor: default;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid hsl(var(--color-primary) / 0.3);
border-radius: 0.375rem;
background: hsl(var(--color-primary) / 0.03);
}
.entry-item.editing:hover {
background: hsl(var(--color-primary) / 0.03);
}
.ed-title {
width: 100%;
background: transparent;
border: none;
color: hsl(var(--color-foreground));
font-size: 0.8125rem;
font-weight: 600;
padding: 0;
outline: none;
}
.ed-content {
width: 100%;
background: transparent;
border: none;
color: hsl(var(--color-foreground));
font-size: 0.75rem;
padding: 0;
outline: none;
resize: vertical;
min-height: 6rem;
font-family: inherit;
line-height: 1.6;
}
.ed-tags {
width: 100%;
background: transparent;
border: none;
border-top: 1px dashed hsl(var(--color-border));
padding: 0.25rem 0 0;
font-size: 0.6875rem;
color: hsl(var(--color-primary));
outline: none;
}
.ed-tags::placeholder {
color: hsl(var(--color-muted-foreground));
}
.ed-row {
display: flex;
justify-content: space-between;
align-items: center;
flex-wrap: wrap;
gap: 0.5rem;
}
.mood-picker {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.mood-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 2rem;
height: 2rem;
border-radius: 9999px;
border: 1px solid hsl(var(--color-border));
background: transparent;
font-size: 0.875rem;
cursor: pointer;
transition: all 0.15s;
}
.mood-btn:hover {
border-color: var(--mood-color);
background: color-mix(in srgb, var(--mood-color) 10%, transparent);
}
.mood-btn.active {
border-color: var(--mood-color);
background: color-mix(in srgb, var(--mood-color) 15%, transparent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--mood-color) 20%, transparent);
}
.mood-emoji {
line-height: 1;
}
.ed-field {
display: flex;
flex-direction: column;
gap: 0.125rem;
}
.ed-label {
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: hsl(var(--color-muted-foreground));
font-weight: 600;
}
.ed-input-sm {
background: transparent;
border: 1px solid hsl(var(--color-border));
border-radius: 0.25rem;
padding: 0.125rem 0.375rem;
font-size: 0.6875rem;
color: hsl(var(--color-foreground));
outline: none;
font-family: inherit;
}
.ed-input-sm:focus {
border-color: hsl(var(--color-primary));
}
.ed-actions {
display: flex;
gap: 0.25rem;
justify-content: flex-end;
}
.ed-btn {
padding: 0.25rem 0.5rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
cursor: pointer;
border: none;
background: transparent;
color: hsl(var(--color-muted-foreground));
transition: all 0.15s;
}
.ed-btn:hover {
background: hsl(var(--color-surface-hover));
color: hsl(var(--color-foreground));
}
.ed-btn.primary {
background: hsl(var(--color-primary));
color: hsl(var(--color-primary-foreground));
}
.ed-btn.primary:hover {
background: hsl(var(--color-primary));
filter: brightness(0.9);
}
.ed-btn.danger:hover {
color: hsl(var(--color-error));
background: hsl(var(--color-error) / 0.08);
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: hsl(var(--color-muted-foreground));
}
@media (max-width: 640px) {
.app-view {
padding: 0.75rem;
}
.entry-item {
min-height: 44px;
}
}
</style>

View file

@ -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<LocalJournalEntry>('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[],
};

View file

@ -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';

View file

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

View file

@ -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<LocalJournalEntry>('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<LocalJournalEntry>('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<string, JournalEntry[]>();
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<string, number>();
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<string, number>();
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,
};
}

View file

@ -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<JournalEntry> {
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<LocalJournalEntry> = {
...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<JournalEntry> {
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<void> {
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<LocalJournalEntry> = {
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(),
});
},
};

View file

@ -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<JournalMood, string> = {
dankbar: '#22c55e',
glücklich: '#f59e0b',
zufrieden: '#3b82f6',
neutral: '#9ca3af',
nachdenklich: '#8b5cf6',
traurig: '#6366f1',
gestresst: '#ef4444',
wütend: '#dc2626',
};
export const MOOD_LABELS: Record<JournalMood, string> = {
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<JournalMood, string> = {
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}',
};

View file

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

View file

@ -126,6 +126,9 @@ export const APP_ICONS = {
habits: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="hb" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#8b5cf6"/><stop offset="100%" style="stop-color:#6d28d9"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#hb)"/><path d="M30 55l8 8 16-16" stroke="white" stroke-width="5" stroke-linecap="round" stroke-linejoin="round" fill="none"/><circle cx="50" cy="50" r="24" stroke="white" stroke-width="4" fill="none"/><path d="M50 26v6M50 68v6M26 50h6M68 50h6" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
journal: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="jn" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#4338ca"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#jn)"/><rect x="30" y="20" width="40" height="60" rx="4" stroke="white" stroke-width="4" fill="none"/><path d="M26 24v52" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M40 36h20M40 46h16M40 56h12" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),
notes: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="nt" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f59e0b"/><stop offset="100%" style="stop-color:#d97706"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#nt)"/><rect x="28" y="22" width="44" height="56" rx="4" stroke="white" stroke-width="4" fill="none"/><path d="M38 36h24M38 46h24M38 56h16" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
),

View file

@ -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',