mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:21:10 +02:00
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:
parent
0f634b2540
commit
e42968203d
14 changed files with 1461 additions and 0 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
886
apps/mana/apps/web/src/lib/modules/journal/ListView.svelte
Normal file
886
apps/mana/apps/web/src/lib/modules/journal/ListView.svelte
Normal 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'} 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>
|
||||
46
apps/mana/apps/web/src/lib/modules/journal/collections.ts
Normal file
46
apps/mana/apps/web/src/lib/modules/journal/collections.ts
Normal 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[],
|
||||
};
|
||||
27
apps/mana/apps/web/src/lib/modules/journal/index.ts
Normal file
27
apps/mana/apps/web/src/lib/modules/journal/index.ts
Normal 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';
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const journalModuleConfig: ModuleConfig = {
|
||||
appId: 'journal',
|
||||
tables: [{ name: 'journalEntries' }],
|
||||
};
|
||||
166
apps/mana/apps/web/src/lib/modules/journal/queries.ts
Normal file
166
apps/mana/apps/web/src/lib/modules/journal/queries.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
84
apps/mana/apps/web/src/lib/modules/journal/types.ts
Normal file
84
apps/mana/apps/web/src/lib/modules/journal/types.ts
Normal 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}',
|
||||
};
|
||||
9
apps/mana/apps/web/src/routes/(app)/journal/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/journal/+page.svelte
Normal 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={{}} />
|
||||
|
|
@ -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>`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue