feat(dreams): scaffold Traumtagebuch module

Adds a new Dreams module to the unified Mana app for capturing dream
journal entries with mood, lucid status, recurring symbols, and
timeline insights. Founder-tier gated for now.

- Dexie schema v5 with dreams, dreamSymbols, dreamTags
- Mutation store with auto symbol counting on create/update/delete
- ListView with quick capture, inline editor, mood picker, lucid
  toggle, monthly grouping, insights ribbon, context menu
- Workbench registration with note → dream drop transform
- New 'dream' DragType, dreams app icon, mana-apps catalog entry

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 14:07:12 +02:00
parent b9fdf0802f
commit 8e71096a61
11 changed files with 1287 additions and 4 deletions

View file

@ -259,6 +259,48 @@ registerApp({
},
});
registerApp({
id: 'dreams',
name: 'Dreams',
color: '#6366F1',
views: {
list: { load: () => import('$lib/modules/dreams/ListView.svelte') },
},
contextMenuActions: [
{
id: 'new-dream',
label: 'Neuer Traum',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'dreams', action: 'new' } })
),
},
],
collection: 'dreams',
paramKey: 'dreamId',
dragType: 'dream',
acceptsDropFrom: ['note'],
transformIncoming: {
note: (source) => ({
title: source.title as string,
content: (source.content as string) ?? '',
}),
},
getDisplayData: (item) => ({
title: (item.title as string) || 'Traum',
subtitle: (item.dreamDate as string) ?? undefined,
}),
createItem: async (data) => {
const { dreamsStore } = await import('$lib/modules/dreams/stores/dreams.svelte');
const dream = await dreamsStore.createDream({
title: (data.title as string) ?? null,
content: (data.content as string) ?? '',
});
return dream.id;
},
});
registerApp({
id: 'finance',
name: 'Finance',

View file

@ -405,6 +405,25 @@ db.version(4).stores({
'id, startDate, kind, type, sourceModule, sourceId, parentBlockId, [sourceModule+sourceId], [type+startDate], [kind+startDate], [parentBlockId+recurrenceDate]',
});
// ─── Version 5: Dreams (Traumtagebuch) ────────────────────────
// Adds dreams, dreamSymbols, dreamTags tables.
db.version(5).stores({
dreams: 'id, dreamDate, mood, isLucid, isPinned, isArchived, updatedAt',
dreamSymbols: 'id, name, count, updatedAt',
dreamTags: 'id, dreamId, tagId, [dreamId+tagId]',
});
// ─── Version 6: Events (Social gatherings) ────────────────────
// Distinct from calendar's `events` table — these are gatherings with guests/RSVPs.
// Main table is `socialEvents` to avoid collision with calendar.events.
db.version(6).stores({
socialEvents: 'id, status, timeBlockId, hostContactId, isPublished, [status+createdAt]',
eventGuests: 'id, eventId, contactId, rsvpStatus, [eventId+rsvpStatus], [eventId+contactId]',
eventInvitations: 'id, eventId, guestId, channel, [eventId+guestId]',
});
// ─── Sync App Map ──────────────────────────────────────────
// Maps each table to its appId for sync routing.
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
@ -447,6 +466,8 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
guides: ['guides', 'sections', 'steps', 'guideCollections', 'runs', 'guideTags'],
habits: ['habits', 'habitLogs'],
notes: ['notes', 'noteTags'],
dreams: ['dreams', 'dreamSymbols', 'dreamTags'],
events: ['socialEvents', 'eventGuests', 'eventInvitations'],
finance: ['transactions', 'financeCategories', 'budgets'],
places: ['places', 'locationLogs', 'placeTags'],
tags: ['globalTags', 'tagGroups'],
@ -518,6 +539,8 @@ export const TABLE_TO_SYNC_NAME: Record<string, string> = {
guideCollections: 'collections',
// finance
financeCategories: 'categories',
// events (social gatherings)
socialEvents: 'events',
// shared: tags
globalTags: 'tags',
tagGroups: 'tagGroups',
@ -550,11 +573,52 @@ export function fromSyncName(appId: string, syncCollection: string): string {
// Automatically records pending changes for every write to sync-relevant tables.
// This means module stores (taskTable.add(), etc.) don't need manual trackChange() calls.
let _applyingServerChanges = false;
/**
* Tables that are currently having server changes applied. Hooks for tables
* in this set skip pending-change tracking (sync loop guard) but writes to
* OTHER tables continue tracking normally, so a user typing into chat while
* todo is syncing no longer silently drops the chat write.
*
* Replaces a single global boolean that previously caused a cross-app race:
* one app's apply could swallow another app's writes for the duration.
*/
const _applyingTables = new Set<string>();
/** Set to true while applying server changes to prevent sync loops. */
/**
* Marks one or more tables as "currently applying server changes".
* Returned dispose function MUST be called (use try/finally) to clear them.
*/
export function beginApplyingTables(tables: Iterable<string>): () => void {
const added: string[] = [];
for (const t of tables) {
if (!_applyingTables.has(t)) {
_applyingTables.add(t);
added.push(t);
}
}
return () => {
for (const t of added) _applyingTables.delete(t);
};
}
/** True when a write to `tableName` should bypass the pending-change hook. */
export function isApplyingTable(tableName: string): boolean {
return _applyingTables.has(tableName);
}
/**
* @deprecated Legacy single-flag API kept temporarily for any external
* caller. Prefer `beginApplyingTables` so per-table races stay impossible.
* When `v === true` it marks every sync-tracked table; `false` clears them.
*/
export function setApplyingServerChanges(v: boolean): void {
_applyingServerChanges = v;
if (v) {
for (const tables of Object.values(SYNC_APP_MAP)) {
for (const t of tables) _applyingTables.add(t);
}
} else {
_applyingTables.clear();
}
}
const pendingChangesTable = db.table('_pendingChanges');

View file

@ -0,0 +1,624 @@
<!--
Dreams — Workbench ListView
Quick capture, inline edit, mood + lucid + symbol affordances.
-->
<script lang="ts">
import {
computeInsights,
formatDreamDate,
groupByMonth,
searchDreams,
useAllDreams,
} from './queries';
import { dreamsStore } from './stores/dreams.svelte';
import { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood } from './types';
import type { ViewProps } from '$lib/app-registry';
import { ContextMenu, type ContextMenuItem } from '@mana/shared-ui';
import { PencilSimple, PushPin, Trash } from '@mana/shared-icons';
let { navigate, goBack, params }: ViewProps = $props();
let dreams$ = useAllDreams();
let dreams = $derived(dreams$.value);
let searchQuery = $state('');
let editingId = $state<string | null>(null);
let editTitle = $state('');
let editContent = $state('');
let editSymbols = $state('');
let editMood = $state<DreamMood | null>(null);
let editIsLucid = $state(false);
let newTitle = $state('');
let filtered = $derived(searchDreams(dreams, searchQuery));
let grouped = $derived(groupByMonth(filtered));
let insights = $derived(computeInsights(dreams));
async function handleQuickCreate(e: KeyboardEvent) {
if (e.key !== 'Enter' || !newTitle.trim()) return;
e.preventDefault();
const dream = await dreamsStore.createDream({ title: newTitle.trim() });
newTitle = '';
startEdit(dream);
}
function startEdit(dream: Dream) {
if (editingId && editingId !== dream.id) saveEdit();
editingId = dream.id;
editTitle = dream.title ?? '';
editContent = dream.content;
editSymbols = (dream.symbols ?? []).join(', ');
editMood = dream.mood;
editIsLucid = dream.isLucid;
}
async function saveEdit() {
if (!editingId) return;
const symbols = editSymbols
.split(',')
.map((s) => s.trim())
.filter(Boolean);
await dreamsStore.updateDream(editingId, {
title: editTitle.trim() || null,
content: editContent,
symbols,
mood: editMood,
isLucid: editIsLucid,
});
editingId = null;
}
async function handleDelete(id: string) {
await dreamsStore.deleteDream(id);
if (editingId === id) editingId = null;
}
// Context menu
let ctxMenu = $state<{ visible: boolean; x: number; y: number; dream: Dream | null }>({
visible: false,
x: 0,
y: 0,
dream: null,
});
function handleItemContextMenu(e: MouseEvent, dream: Dream) {
e.preventDefault();
ctxMenu = { visible: true, x: e.clientX, y: e.clientY, dream };
}
let ctxMenuItems = $derived<ContextMenuItem[]>(
ctxMenu.dream
? [
{
id: 'edit',
label: 'Bearbeiten',
icon: PencilSimple,
action: () => {
if (ctxMenu.dream) startEdit(ctxMenu.dream);
},
},
{
id: 'pin',
label: ctxMenu.dream.isPinned ? 'Lösen' : 'Pinnen',
icon: PushPin,
action: () => {
if (ctxMenu.dream) dreamsStore.togglePin(ctxMenu.dream.id);
},
},
{ id: 'div', label: '', type: 'divider' as const },
{
id: 'delete',
label: 'Löschen',
icon: Trash,
variant: 'danger' as const,
action: () => {
if (ctxMenu.dream) handleDelete(ctxMenu.dream.id);
},
},
]
: []
);
const MOODS: DreamMood[] = ['angenehm', 'neutral', 'unangenehm', 'albtraum'];
</script>
<div class="app-view">
<!-- Quick create -->
<form onsubmit={(e) => e.preventDefault()} class="quick-add">
<span class="add-icon">&#x1f319;</span>
<input
class="add-input"
type="text"
placeholder="Was hast du geträumt? (Enter)"
bind:value={newTitle}
onkeydown={handleQuickCreate}
/>
</form>
<!-- Insights ribbon -->
{#if insights.total > 0}
<div class="insights">
<span class="ins-stat">{insights.total} Träume</span>
{#if insights.lucidCount > 0}
<span class="ins-stat">&#x2728; {insights.lucidCount} Klarträume</span>
{/if}
{#each insights.topSymbols as sym}
<span class="ins-symbol">{sym.name} · {sym.count}</span>
{/each}
</div>
{/if}
<!-- Search -->
{#if dreams.length > 5}
<input
class="search-input"
type="text"
placeholder="Träume durchsuchen..."
bind:value={searchQuery}
/>
{/if}
<!-- Dream list -->
<div class="dream-list">
{#each grouped as group (group.label)}
<div class="month-label">{group.label}</div>
{#each group.dreams as dream (dream.id)}
{#if editingId === dream.id}
<!-- Inline editor -->
<div
class="dream-item editing"
onkeydown={(e) => {
if (e.key === 'Escape') saveEdit();
}}
>
<input
class="ed-title"
type="text"
bind:value={editTitle}
placeholder="Titel (optional)..."
autofocus
/>
<textarea
class="ed-content"
bind:value={editContent}
placeholder="Erzähl mir den Traum..."
rows="5"
></textarea>
<input
class="ed-symbols"
type="text"
bind:value={editSymbols}
placeholder="Symbole (Komma-getrennt): Wasser, Fliegen, Tür"
/>
<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-dot"></span>
{MOOD_LABELS[mood]}
</button>
{/each}
</div>
<label class="lucid-toggle">
<input type="checkbox" bind:checked={editIsLucid} />
&#x2728; Klartraum
</label>
</div>
<div class="ed-actions">
<button class="ed-btn danger" onclick={() => handleDelete(dream.id)}>Löschen</button>
<button class="ed-btn primary" onclick={saveEdit}>Fertig</button>
</div>
</div>
{:else}
<!-- Dream row -->
<button
class="dream-item"
onclick={() => startEdit(dream)}
oncontextmenu={(e) => handleItemContextMenu(e, dream)}
>
{#if dream.mood}
<span class="mood-dot-row" style="background: {MOOD_COLORS[dream.mood]}"></span>
{:else}
<span class="mood-dot-row empty"></span>
{/if}
<div class="dream-content">
<div class="dream-top">
<span class="dream-title">{dream.title || 'Traum ohne Titel'}</span>
{#if dream.isLucid}<span class="badge lucid">&#x2728;</span>{/if}
{#if dream.isPinned}<span class="badge">&#x1f4cc;</span>{/if}
{#if dream.isPrivate}<span class="badge">&#x1f512;</span>{/if}
</div>
{#if dream.content}
<p class="dream-preview">{dream.content.split('\n')[0]}</p>
{/if}
<div class="dream-meta">
<span>{formatDreamDate(dream.dreamDate)}</span>
{#if dream.symbols.length > 0}
<span class="dot">·</span>
<span class="symbols">{dream.symbols.slice(0, 3).join(' · ')}</span>
{/if}
</div>
</div>
</button>
{/if}
{/each}
{/each}
{#if filtered.length === 0 && dreams.length > 0}
<p class="empty">Keine Treffer</p>
{/if}
</div>
{#if dreams.length === 0}
<p class="empty">Tippe oben, um deinen ersten Traum festzuhalten.</p>
{/if}
<ContextMenu
visible={ctxMenu.visible}
x={ctxMenu.x}
y={ctxMenu.y}
items={ctxMenuItems}
onClose={() => (ctxMenu = { ...ctxMenu, visible: false, dream: null })}
/>
</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 rgba(0, 0, 0, 0.08);
background: transparent;
}
:global(.dark) .quick-add {
border-color: rgba(255, 255, 255, 0.08);
}
.add-icon {
font-size: 0.875rem;
}
.add-input {
flex: 1;
border: none;
background: transparent;
outline: none;
font-size: 0.8125rem;
color: #374151;
}
.add-input::placeholder {
color: #c0bfba;
}
:global(.dark) .add-input {
color: #f3f4f6;
}
:global(.dark) .add-input::placeholder {
color: #4b5563;
}
/* ── Insights ──────────────────────────────── */
.insights {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
padding: 0.375rem 0.25rem;
font-size: 0.6875rem;
color: #9ca3af;
}
.ins-stat {
font-weight: 500;
}
.ins-symbol {
padding: 0.125rem 0.5rem;
border-radius: 9999px;
background: rgba(99, 102, 241, 0.08);
color: #6366f1;
}
/* ── Search ────────────────────────────────── */
.search-input {
padding: 0.3rem 0.5rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
font-size: 0.75rem;
color: #374151;
outline: none;
}
.search-input:focus {
border-color: #6366f1;
}
:global(.dark) .search-input {
border-color: rgba(255, 255, 255, 0.08);
color: #f3f4f6;
}
/* ── Dream List ────────────────────────────── */
.dream-list {
flex: 1;
overflow-y: auto;
}
.month-label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #c0bfba;
padding: 0.75rem 0.25rem 0.25rem;
font-weight: 600;
}
:global(.dark) .month-label {
color: #4b5563;
}
.dream-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;
}
.dream-item:hover {
background: rgba(0, 0, 0, 0.03);
}
:global(.dark) .dream-item:hover {
background: rgba(255, 255, 255, 0.04);
}
.mood-dot-row {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
margin-top: 0.4375rem;
}
.mood-dot-row.empty {
background: rgba(0, 0, 0, 0.08);
}
:global(.dark) .mood-dot-row.empty {
background: rgba(255, 255, 255, 0.08);
}
.dream-content {
min-width: 0;
flex: 1;
}
.dream-top {
display: flex;
align-items: center;
gap: 0.375rem;
}
.dream-title {
font-size: 0.8125rem;
font-weight: 500;
color: #374151;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
:global(.dark) .dream-title {
color: #e5e7eb;
}
.badge {
font-size: 0.625rem;
}
.dream-preview {
font-size: 0.6875rem;
color: #9ca3af;
margin: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.dream-meta {
display: flex;
gap: 0.25rem;
font-size: 0.625rem;
color: #c0bfba;
margin-top: 0.125rem;
}
.dream-meta .symbols {
color: #6366f1;
}
.dream-meta .dot {
opacity: 0.5;
}
:global(.dark) .dream-meta {
color: #4b5563;
}
/* ── Inline Editor ─────────────────────────── */
.dream-item.editing {
cursor: default;
flex-direction: column;
gap: 0.5rem;
padding: 0.625rem;
border: 1px solid rgba(99, 102, 241, 0.3);
border-radius: 0.375rem;
background: rgba(99, 102, 241, 0.03);
}
.dream-item.editing:hover {
background: rgba(99, 102, 241, 0.03);
}
:global(.dark) .dream-item.editing {
border-color: rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.06);
}
.ed-title {
width: 100%;
background: transparent;
border: none;
color: #374151;
font-size: 0.8125rem;
font-weight: 600;
padding: 0;
outline: none;
}
:global(.dark) .ed-title {
color: #f3f4f6;
}
.ed-content {
width: 100%;
background: transparent;
border: none;
color: #374151;
font-size: 0.75rem;
padding: 0;
outline: none;
resize: vertical;
min-height: 4rem;
font-family: inherit;
line-height: 1.5;
}
:global(.dark) .ed-content {
color: #e5e7eb;
}
.ed-symbols {
width: 100%;
background: transparent;
border: none;
border-top: 1px dashed rgba(0, 0, 0, 0.08);
padding: 0.25rem 0 0;
font-size: 0.6875rem;
color: #6366f1;
outline: none;
}
.ed-symbols::placeholder {
color: #c0bfba;
}
:global(.dark) .ed-symbols {
border-top-color: rgba(255, 255, 255, 0.08);
}
.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;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
font-size: 0.625rem;
color: #9ca3af;
cursor: pointer;
}
.mood-btn .mood-dot {
width: 6px;
height: 6px;
border-radius: 9999px;
background: var(--mood-color);
}
.mood-btn.active {
border-color: var(--mood-color);
color: var(--mood-color);
background: color-mix(in srgb, var(--mood-color) 10%, transparent);
}
.lucid-toggle {
display: inline-flex;
align-items: center;
gap: 0.25rem;
font-size: 0.6875rem;
color: #9ca3af;
cursor: pointer;
}
.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: #9ca3af;
transition: all 0.15s;
}
.ed-btn:hover {
background: rgba(0, 0, 0, 0.04);
color: #374151;
}
:global(.dark) .ed-btn:hover {
background: rgba(255, 255, 255, 0.06);
color: #e5e7eb;
}
.ed-btn.primary {
background: #6366f1;
color: white;
}
.ed-btn.primary:hover {
background: #5558e6;
}
.ed-btn.danger:hover {
color: #ef4444;
background: rgba(239, 68, 68, 0.08);
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
@media (max-width: 640px) {
.app-view {
padding: 0.75rem;
}
.dream-item {
min-height: 44px;
}
}
</style>

View file

@ -0,0 +1,88 @@
/**
* Dreams module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalDream, LocalDreamSymbol, LocalDreamTag } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const dreamTable = db.table<LocalDream>('dreams');
export const dreamSymbolTable = db.table<LocalDreamSymbol>('dreamSymbols');
export const dreamTagTable = db.table<LocalDreamTag>('dreamTags');
// ─── 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 DREAMS_GUEST_SEED = {
dreams: [
{
id: 'dream-welcome',
title: 'Willkommen im Traumtagebuch',
content:
'Notiere deine Träume direkt nach dem Aufwachen. Je früher du sie festhältst, desto mehr Details bleiben dir.\n\n**Tipps:**\n- Stichworte reichen — Sätze bilden sich später.\n- Stimmung und Klarheit helfen bei Mustern über Wochen.\n- Pinne wiederkehrende Träume.',
dreamDate: today,
mood: 'angenehm',
clarity: 4,
isLucid: false,
isRecurring: false,
sleepQuality: null,
bedtime: null,
wakeTime: null,
location: null,
people: [],
emotions: ['Ruhe', 'Neugier'],
symbols: [],
audioPath: null,
transcript: null,
interpretation: null,
aiInterpretation: null,
isPrivate: false,
isPinned: true,
isArchived: false,
},
{
id: 'dream-fliegen',
title: 'Über der Stadt geflogen',
content:
'Ich konnte mich aus dem Bett heben und über die Stadt fliegen. Alles war weich und leuchtete in goldenem Licht.',
dreamDate: yesterday,
mood: 'angenehm',
clarity: 5,
isLucid: true,
isRecurring: false,
sleepQuality: 4,
bedtime: '23:30',
wakeTime: '07:15',
location: 'Über einer fremden Stadt',
people: [],
emotions: ['Freiheit', 'Staunen'],
symbols: ['Fliegen', 'Licht'],
audioPath: null,
transcript: null,
interpretation: 'Gefühl von Kontrolle und Leichtigkeit nach einer entspannten Woche.',
aiInterpretation: null,
isPrivate: false,
isPinned: false,
isArchived: false,
},
] satisfies LocalDream[],
dreamSymbols: [
{
id: 'dream-symbol-fliegen',
name: 'Fliegen',
meaning: 'Freiheit, Loslösung, Kontrolle',
color: '#6366f1',
count: 1,
},
{
id: 'dream-symbol-licht',
name: 'Licht',
meaning: 'Klarheit, Bewusstsein',
color: '#f59e0b',
count: 1,
},
] satisfies LocalDreamSymbol[],
};

View file

@ -0,0 +1,34 @@
/**
* Dreams module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { dreamsStore } from './stores/dreams.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllDreams,
useAllDreamSymbols,
toDream,
toDreamSymbol,
searchDreams,
groupByMonth,
formatDreamDate,
computeInsights,
} from './queries';
// ─── Collections ─────────────────────────────────────────
export { dreamTable, dreamSymbolTable, dreamTagTable, DREAMS_GUEST_SEED } from './collections';
// ─── Types ───────────────────────────────────────────────
export { MOOD_COLORS, MOOD_LABELS } from './types';
export type {
LocalDream,
LocalDreamSymbol,
LocalDreamTag,
Dream,
DreamSymbol,
DreamMood,
DreamClarity,
SleepQuality,
} from './types';

View file

@ -0,0 +1,142 @@
/**
* Reactive Queries & Pure Helpers for Dreams module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type { Dream, DreamSymbol, LocalDream, LocalDreamSymbol } from './types';
// ─── Type Converters ───────────────────────────────────────
export function toDream(local: LocalDream): Dream {
return {
id: local.id,
title: local.title,
content: local.content,
dreamDate: local.dreamDate,
mood: local.mood,
clarity: local.clarity,
isLucid: local.isLucid,
isRecurring: local.isRecurring,
sleepQuality: local.sleepQuality,
bedtime: local.bedtime,
wakeTime: local.wakeTime,
location: local.location,
people: local.people ?? [],
emotions: local.emotions ?? [],
symbols: local.symbols ?? [],
audioPath: local.audioPath,
transcript: local.transcript,
interpretation: local.interpretation,
aiInterpretation: local.aiInterpretation,
isPrivate: local.isPrivate,
isPinned: local.isPinned,
isArchived: local.isArchived,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toDreamSymbol(local: LocalDreamSymbol): DreamSymbol {
return {
id: local.id,
name: local.name,
meaning: local.meaning,
color: local.color,
count: local.count ?? 0,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllDreams() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalDream>('dreams').toArray();
return locals
.filter((d) => !d.deletedAt && !d.isArchived)
.map(toDream)
.sort((a, b) => {
if (a.isPinned !== b.isPinned) return a.isPinned ? -1 : 1;
return b.dreamDate.localeCompare(a.dreamDate);
});
}, [] as Dream[]);
}
export function useAllDreamSymbols() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalDreamSymbol>('dreamSymbols').toArray();
return locals
.filter((s) => !s.deletedAt)
.map(toDreamSymbol)
.sort((a, b) => b.count - a.count);
}, [] as DreamSymbol[]);
}
// ─── Pure Helpers ──────────────────────────────────────────
/** Search dreams by title, content, location, symbols and emotions. */
export function searchDreams(dreams: Dream[], query: string): Dream[] {
if (!query.trim()) return dreams;
const q = query.toLowerCase();
return dreams.filter((d) => {
const haystack = [
d.title,
d.content,
d.location,
d.interpretation,
...(d.symbols ?? []),
...(d.emotions ?? []),
...(d.people ?? []),
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return haystack.includes(q);
});
}
/** Group dreams by month label (e.g. "April 2026"). */
export function groupByMonth(dreams: Dream[]): Array<{ label: string; dreams: Dream[] }> {
const groups = new Map<string, Dream[]>();
for (const d of dreams) {
const date = new Date(d.dreamDate);
const label = date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
if (!groups.has(label)) groups.set(label, []);
groups.get(label)!.push(d);
}
return Array.from(groups, ([label, dreams]) => ({ label, dreams }));
}
/** Format the dream date relative to today. */
export function formatDreamDate(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 Nacht';
if (diffDays === 1) return 'Gestern Nacht';
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
}
/** Compute insights snapshot from dreams collection. */
export function computeInsights(dreams: Dream[]) {
const total = dreams.length;
const lucidCount = dreams.filter((d) => d.isLucid).length;
const symbolCounts = new Map<string, number>();
for (const d of dreams) {
for (const sym of d.symbols ?? []) {
symbolCounts.set(sym, (symbolCounts.get(sym) ?? 0) + 1);
}
}
const topSymbols = Array.from(symbolCounts, ([name, count]) => ({ name, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 5);
return {
total,
lucidCount,
lucidRate: total ? lucidCount / total : 0,
topSymbols,
};
}

View file

@ -0,0 +1,165 @@
/**
* Dreams Store Mutation-Only Service
*/
import { dreamSymbolTable, dreamTable } from '../collections';
import { toDream } from '../queries';
import type { DreamClarity, DreamMood, LocalDream, SleepQuality } from '../types';
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
export const dreamsStore = {
async createDream(data: {
title?: string | null;
content?: string;
dreamDate?: string;
mood?: DreamMood | null;
clarity?: DreamClarity | null;
isLucid?: boolean;
symbols?: string[];
emotions?: string[];
}) {
const newLocal: LocalDream = {
id: crypto.randomUUID(),
title: data.title ?? null,
content: data.content ?? '',
dreamDate: data.dreamDate ?? todayIsoDate(),
mood: data.mood ?? null,
clarity: data.clarity ?? null,
isLucid: data.isLucid ?? false,
isRecurring: false,
sleepQuality: null,
bedtime: null,
wakeTime: null,
location: null,
people: [],
emotions: data.emotions ?? [],
symbols: data.symbols ?? [],
audioPath: null,
transcript: null,
interpretation: null,
aiInterpretation: null,
isPrivate: false,
isPinned: false,
isArchived: false,
};
await dreamTable.add(newLocal);
await this.touchSymbols(newLocal.symbols, +1);
return toDream(newLocal);
},
async updateDream(
id: string,
data: Partial<
Pick<
LocalDream,
| 'title'
| 'content'
| 'dreamDate'
| 'mood'
| 'clarity'
| 'isLucid'
| 'isRecurring'
| 'sleepQuality'
| 'bedtime'
| 'wakeTime'
| 'location'
| 'people'
| 'emotions'
| 'symbols'
| 'interpretation'
| 'aiInterpretation'
| 'isPrivate'
| 'isPinned'
| 'isArchived'
>
>
) {
if (data.symbols) {
const existing = await dreamTable.get(id);
if (existing) {
const oldSet = new Set(existing.symbols ?? []);
const newSet = new Set(data.symbols);
const added = [...newSet].filter((s) => !oldSet.has(s));
const removed = [...oldSet].filter((s) => !newSet.has(s));
if (added.length) await this.touchSymbols(added, +1);
if (removed.length) await this.touchSymbols(removed, -1);
}
}
await dreamTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async deleteDream(id: string) {
const existing = await dreamTable.get(id);
if (existing?.symbols?.length) {
await this.touchSymbols(existing.symbols, -1);
}
await dreamTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async togglePin(id: string) {
const dream = await dreamTable.get(id);
if (!dream) return;
await dreamTable.update(id, {
isPinned: !dream.isPinned,
updatedAt: new Date().toISOString(),
});
},
async toggleLucid(id: string) {
const dream = await dreamTable.get(id);
if (!dream) return;
await dreamTable.update(id, {
isLucid: !dream.isLucid,
updatedAt: new Date().toISOString(),
});
},
async setMood(id: string, mood: DreamMood | null) {
await dreamTable.update(id, {
mood,
updatedAt: new Date().toISOString(),
});
},
async setSleepQuality(id: string, quality: SleepQuality | null) {
await dreamTable.update(id, {
sleepQuality: quality,
updatedAt: new Date().toISOString(),
});
},
/** Increment or decrement counts for the given symbol names. Creates symbols on demand. */
async touchSymbols(names: string[], delta: number) {
for (const name of names) {
const trimmed = name.trim();
if (!trimmed) continue;
const existing = await dreamSymbolTable.where('name').equals(trimmed).first();
if (existing) {
const next = Math.max(0, (existing.count ?? 0) + delta);
await dreamSymbolTable.update(existing.id, {
count: next,
updatedAt: new Date().toISOString(),
});
} else if (delta > 0) {
await dreamSymbolTable.add({
id: crypto.randomUUID(),
name: trimmed,
meaning: null,
color: null,
count: delta,
});
}
}
},
};

View file

@ -0,0 +1,102 @@
/**
* Dreams module types Traumtagebuch.
*/
import type { BaseRecord } from '@mana/local-store';
export type DreamMood = 'angenehm' | 'neutral' | 'unangenehm' | 'albtraum';
export type DreamClarity = 1 | 2 | 3 | 4 | 5;
export type SleepQuality = 1 | 2 | 3 | 4 | 5;
// ─── Local Record Types (Dexie) ───────────────────────────
export interface LocalDream extends BaseRecord {
title: string | null;
content: string;
dreamDate: string; // ISO date (YYYY-MM-DD) — die Nacht, in der geträumt wurde
mood: DreamMood | null;
clarity: DreamClarity | null;
isLucid: boolean;
isRecurring: boolean;
sleepQuality: SleepQuality | null;
bedtime: string | null; // ISO time (HH:mm)
wakeTime: string | null; // ISO time (HH:mm)
location: string | null;
people: string[];
emotions: string[];
symbols: string[];
audioPath: string | null;
transcript: string | null;
interpretation: string | null;
aiInterpretation: string | null;
isPrivate: boolean;
isPinned: boolean;
isArchived: boolean;
}
export interface LocalDreamSymbol extends BaseRecord {
name: string;
meaning: string | null;
color: string | null;
count: number;
}
export interface LocalDreamTag extends BaseRecord {
dreamId: string;
tagId: string;
}
// ─── Domain Types ─────────────────────────────────────────
export interface Dream {
id: string;
title: string | null;
content: string;
dreamDate: string;
mood: DreamMood | null;
clarity: DreamClarity | null;
isLucid: boolean;
isRecurring: boolean;
sleepQuality: SleepQuality | null;
bedtime: string | null;
wakeTime: string | null;
location: string | null;
people: string[];
emotions: string[];
symbols: string[];
audioPath: string | null;
transcript: string | null;
interpretation: string | null;
aiInterpretation: string | null;
isPrivate: boolean;
isPinned: boolean;
isArchived: boolean;
createdAt: string;
updatedAt: string;
}
export interface DreamSymbol {
id: string;
name: string;
meaning: string | null;
color: string | null;
count: number;
createdAt: string;
updatedAt: string;
}
// ─── Constants ────────────────────────────────────────────
export const MOOD_COLORS: Record<DreamMood, string> = {
angenehm: '#22c55e',
neutral: '#9ca3af',
unangenehm: '#f59e0b',
albtraum: '#ef4444',
};
export const MOOD_LABELS: Record<DreamMood, string> = {
angenehm: 'Angenehm',
neutral: 'Neutral',
unangenehm: 'Unangenehm',
albtraum: 'Albtraum',
};

View file

@ -133,6 +133,9 @@ export const APP_ICONS = {
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>`
),
dreams: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="dr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#312e81"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#dr)"/><path d="M62 30a22 22 0 1 0 18 34 18 18 0 0 1-18-34z" fill="white"/><circle cx="32" cy="38" r="1.6" fill="white"/><circle cx="26" cy="58" r="1.2" fill="white" fill-opacity="0.8"/><circle cx="40" cy="68" r="1.4" fill="white" fill-opacity="0.7"/><circle cx="22" cy="46" r="1" fill="white" fill-opacity="0.6"/></svg>`
),
finance: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="fn" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#22c55e"/><stop offset="100%" style="stop-color:#16a34a"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#fn)"/><circle cx="50" cy="50" r="22" stroke="white" stroke-width="4" fill="none"/><path d="M50 34v32M42 42c0-4 3.5-6 8-6s8 2 8 6-3.5 5-8 5-8 2-8 6 3.5 6 8 6 8-2 8-6" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/></svg>`
),

View file

@ -615,6 +615,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'founder',
},
{
id: 'dreams',
name: 'Dreams',
description: {
de: 'Traumtagebuch',
en: 'Dream Journal',
},
longDescription: {
de: 'Halte deine Träume fest, bevor sie verblassen. Stimmung, Klartraum-Status, wiederkehrende Symbole und Insights über die Zeit.',
en: 'Capture your dreams before they fade. Mood, lucid status, recurring symbols, and insights over time.',
},
icon: APP_ICONS.dreams,
color: '#6366f1',
comingSoon: false,
status: 'development',
requiredTier: 'founder',
},
{
id: 'finance',
name: 'Finance',
@ -777,6 +794,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
guides: { dev: 'http://localhost:5173/guides', prod: 'https://mana.how/guides' },
habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' },
notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' },
dreams: { dev: 'http://localhost:5173/dreams', prod: 'https://mana.how/dreams' },
finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' },
places: { dev: 'http://localhost:5173/places', prod: 'https://mana.how/places' },
wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' },

View file

@ -23,7 +23,8 @@ export type DragType =
| 'habit'
| 'note'
| 'transaction'
| 'place';
| 'place'
| 'dream';
export interface DragPayload<T = Record<string, unknown>> {
type: DragType;