feat(dreams): polish symbol library — sort, auto-save, merge, navigation

SymbolsView:
- Sort tabs (Häufigkeit / A-Z / Zuletzt) above the cloud
- More dramatic font scaling using sqrt easing for visual hierarchy
- "?" badge on symbols without a personal meaning, dimmed until hover
- "Zuletzt" sort shows the most recent dreamDate per symbol
- A-Z and Zuletzt switch the cloud to a vertical list layout
- Hides symbols whose count dropped to zero (e.g. after merge)

SymbolDetailView:
- Auto-save with 500ms debounce + transient "Gespeichert" hint
- Co-occurring chips are clickable and navigate to that symbol's detail
- Dream refs are clickable buttons; ListView passes onOpenDream so a
  click jumps back to the timeline and opens the dream for editing
- Manual merge UI: "Zusammenführen…" button reveals a select with all
  other symbols, confirmation dialog before merging
- Re-initializes edit buffer when navigating between symbols (lastInitId
  guard instead of one-shot initialized flag)

Helpers:
- getLastUsedBySymbol returns a Map of symbol → most recent dreamDate

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 14:28:05 +02:00
parent 216746721e
commit 771721ca30
4 changed files with 421 additions and 66 deletions

View file

@ -184,7 +184,12 @@
</div>
{#if viewMode === 'symbols'}
<SymbolsView />
<SymbolsView
onOpenDream={(d) => {
viewMode = 'list';
startEdit(d);
}}
/>
{:else}
<!-- Quick create -->
<form onsubmit={(e) => e.preventDefault()} class="quick-add">

View file

@ -120,6 +120,18 @@ export function formatDreamDate(iso: string): string {
return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
}
/** Map of symbol name → most recent dreamDate that references it. */
export function getLastUsedBySymbol(dreams: Dream[]): Map<string, string> {
const map = new Map<string, string>();
for (const d of dreams) {
for (const sym of d.symbols ?? []) {
const prev = map.get(sym);
if (!prev || d.dreamDate > prev) map.set(sym, d.dreamDate);
}
}
return map;
}
/** All dreams that contain the given symbol, newest first. */
export function getDreamsWithSymbol(dreams: Dream[], symbolName: string): Dream[] {
return dreams

View file

@ -12,9 +12,19 @@
useAllDreamSymbols,
} from '../queries';
import { dreamsStore } from '../stores/dreams.svelte';
import { MOOD_COLORS, MOOD_LABELS, type DreamMood } from '../types';
import { MOOD_COLORS, MOOD_LABELS, type Dream, type DreamMood } from '../types';
let { symbolId, onBack }: { symbolId: string; onBack: () => void } = $props();
let {
symbolId,
onBack,
onSelectSymbol,
onOpenDream,
}: {
symbolId: string;
onBack: () => void;
onSelectSymbol?: (id: string) => void;
onOpenDream?: (dream: Dream) => void;
} = $props();
let symbols$ = useAllDreamSymbols();
let dreams$ = useAllDreams();
@ -27,14 +37,14 @@
let editName = $state('');
let editMeaning = $state('');
let editColor = $state<string>('#6366f1');
let initialized = $state(false);
let lastInitId = $state<string | null>(null);
$effect(() => {
if (symbol && !initialized) {
if (symbol && lastInitId !== symbol.id) {
editName = symbol.name;
editMeaning = symbol.meaning ?? '';
editColor = symbol.color ?? '#6366f1';
initialized = true;
lastInitId = symbol.id;
}
});
@ -45,11 +55,42 @@
editColor !== (symbol.color ?? '#6366f1'))
);
let savedHint = $state(false);
// Debounced auto-save
let saveTimer: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
// react to edit fields
void editName;
void editMeaning;
void editColor;
if (!dirty || !symbol) return;
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(() => {
void save();
}, 500);
return () => {
if (saveTimer) clearTimeout(saveTimer);
};
});
let dreamsWithSymbol = $derived(symbol ? getDreamsWithSymbol(dreams, symbol.name) : []);
let moodDist = $derived(symbol ? getMoodDistribution(dreams, symbol.name) : []);
let cooccurring = $derived(symbol ? getCooccurringSymbols(dreams, symbol.name) : []);
let totalForBars = $derived(moodDist.reduce((sum, m) => sum + m.count, 0) || 1);
// Merge target candidates
let mergeOpen = $state(false);
let mergeTargetId = $state('');
let mergeCandidates = $derived(
symbol
? symbols
.filter((s) => s.id !== symbol.id && s.count > 0)
.sort((a, b) => a.name.localeCompare(b.name, 'de'))
: []
);
const PALETTE = ['#6366f1', '#22c55e', '#f59e0b', '#ef4444', '#06b6d4', '#a855f7', '#ec4899'];
async function save() {
@ -59,6 +100,8 @@
meaning: editMeaning.trim() || null,
color: editColor,
});
savedHint = true;
setTimeout(() => (savedHint = false), 1500);
}
async function handleDelete() {
@ -71,6 +114,25 @@
onBack();
}
async function handleMerge() {
if (!symbol || !mergeTargetId) return;
const target = symbols.find((s) => s.id === mergeTargetId);
if (!target) return;
const ok = confirm(
`"${symbol.name}" in "${target.name}" zusammenführen? Alle Träume werden umgeschrieben.`
);
if (!ok) return;
await dreamsStore.mergeSymbols(symbol.id, mergeTargetId);
mergeOpen = false;
mergeTargetId = '';
onBack();
}
function navigateToCooccurring(name: string) {
const target = symbols.find((s) => s.name === name);
if (target && onSelectSymbol) onSelectSymbol(target.id);
}
function moodColor(mood: string): string {
if (mood in MOOD_COLORS) return MOOD_COLORS[mood as DreamMood];
return '#9ca3af';
@ -85,11 +147,35 @@
<div class="detail-view">
<div class="header">
<button class="back-btn" onclick={onBack}> Symbole</button>
{#if symbol}
<button class="del-btn" onclick={handleDelete}>Löschen</button>
{/if}
<div class="header-actions">
{#if savedHint}
<span class="saved-hint">Gespeichert</span>
{/if}
{#if symbol && mergeCandidates.length > 0}
<button class="meta-btn" onclick={() => (mergeOpen = !mergeOpen)}>Zusammenführen…</button>
{/if}
{#if symbol}
<button class="del-btn" onclick={handleDelete}>Löschen</button>
{/if}
</div>
</div>
{#if mergeOpen && symbol}
<div class="merge-panel">
<span class="merge-label">"{symbol.name}" zusammenführen mit:</span>
<select class="merge-select" bind:value={mergeTargetId}>
<option value=""> Symbol wählen </option>
{#each mergeCandidates as c}
<option value={c.id}>{c.name} ({c.count})</option>
{/each}
</select>
<button class="merge-confirm" disabled={!mergeTargetId} onclick={handleMerge}>OK</button>
<button class="merge-cancel" onclick={() => ((mergeOpen = false), (mergeTargetId = ''))}
>Abbrechen</button
>
</div>
{/if}
{#if !symbol}
<p class="empty">Symbol nicht gefunden.</p>
{:else}
@ -129,12 +215,6 @@
></textarea>
</div>
{#if dirty}
<div class="save-row">
<button class="save-btn" onclick={save}>Speichern</button>
</div>
{/if}
<!-- Mood distribution -->
{#if moodDist.length > 0}
<div class="section">
@ -162,7 +242,13 @@
<span class="section-label">Häufig zusammen mit</span>
<div class="cooc-row">
{#each cooccurring as c}
<span class="cooc-chip">{c.name} <span class="cooc-count">{c.count}</span></span>
<button
class="cooc-chip"
onclick={() => navigateToCooccurring(c.name)}
title={`Zu "${c.name}" wechseln`}
>
{c.name} <span class="cooc-count">{c.count}</span>
</button>
{/each}
</div>
</div>
@ -174,7 +260,7 @@
<span class="section-label">Träume mit diesem Symbol</span>
<div class="dream-refs">
{#each dreamsWithSymbol as d (d.id)}
<div class="dream-ref">
<button class="dream-ref" onclick={() => onOpenDream?.(d)} disabled={!onOpenDream}>
{#if d.mood}
<span class="ref-dot" style="background: {MOOD_COLORS[d.mood]}"></span>
{:else}
@ -184,7 +270,7 @@
<span class="ref-title">{d.title || 'Traum ohne Titel'}</span>
<span class="ref-date">{formatDreamDate(d.dreamDate)}</span>
</div>
</div>
</button>
{/each}
</div>
</div>
@ -206,6 +292,13 @@
display: flex;
justify-content: space-between;
align-items: center;
gap: 0.5rem;
}
.header-actions {
display: flex;
align-items: center;
gap: 0.375rem;
}
.back-btn {
@ -220,6 +313,41 @@
text-decoration: underline;
}
.saved-hint {
font-size: 0.625rem;
color: #22c55e;
font-weight: 500;
animation: fade-in 0.2s ease-out;
}
@keyframes fade-in {
from {
opacity: 0;
transform: translateY(-2px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.meta-btn {
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.08);
color: #6b7280;
font-size: 0.6875rem;
padding: 0.25rem 0.625rem;
border-radius: 0.25rem;
cursor: pointer;
}
.meta-btn:hover {
border-color: #6366f1;
color: #6366f1;
}
:global(.dark) .meta-btn {
border-color: rgba(255, 255, 255, 0.08);
color: #9ca3af;
}
.del-btn {
background: transparent;
border: 1px solid rgba(239, 68, 68, 0.3);
@ -233,6 +361,62 @@
background: rgba(239, 68, 68, 0.08);
}
/* Merge panel */
.merge-panel {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
padding: 0.5rem 0.625rem;
border-radius: 0.375rem;
background: rgba(99, 102, 241, 0.05);
border: 1px solid rgba(99, 102, 241, 0.2);
}
.merge-label {
font-size: 0.6875rem;
color: #6b7280;
}
.merge-select {
background: transparent;
border: 1px solid rgba(0, 0, 0, 0.1);
border-radius: 0.25rem;
padding: 0.1875rem 0.375rem;
font-size: 0.6875rem;
color: #374151;
outline: none;
font-family: inherit;
}
:global(.dark) .merge-select {
border-color: rgba(255, 255, 255, 0.12);
color: #f3f4f6;
color-scheme: dark;
}
.merge-confirm {
padding: 0.1875rem 0.625rem;
border-radius: 0.25rem;
border: none;
background: #6366f1;
color: white;
font-size: 0.6875rem;
cursor: pointer;
}
.merge-confirm:disabled {
background: rgba(99, 102, 241, 0.3);
cursor: not-allowed;
}
.merge-cancel {
padding: 0.1875rem 0.5rem;
border-radius: 0.25rem;
border: none;
background: transparent;
color: #9ca3af;
font-size: 0.6875rem;
cursor: pointer;
}
.merge-cancel:hover {
color: #374151;
}
.sym-header {
display: flex;
align-items: center;
@ -318,24 +502,6 @@
color: #f3f4f6;
}
.save-row {
display: flex;
justify-content: flex-end;
}
.save-btn {
padding: 0.25rem 0.75rem;
border-radius: 0.25rem;
font-size: 0.6875rem;
font-weight: 500;
background: #6366f1;
color: white;
border: none;
cursor: pointer;
}
.save-btn:hover {
background: #5558e6;
}
/* Bars */
.bars {
display: flex;
@ -384,6 +550,14 @@
background: rgba(99, 102, 241, 0.08);
color: #6366f1;
font-size: 0.6875rem;
border: 1px solid transparent;
cursor: pointer;
font-family: inherit;
transition: all 0.15s;
}
.cooc-chip:hover {
background: rgba(99, 102, 241, 0.18);
border-color: #6366f1;
}
.cooc-count {
font-size: 0.5625rem;
@ -401,13 +575,22 @@
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.375rem 0.25rem;
padding: 0.375rem 0.375rem;
border-radius: 0.25rem;
background: transparent;
border: none;
width: 100%;
text-align: left;
cursor: pointer;
font-family: inherit;
}
.dream-ref:hover {
.dream-ref:disabled {
cursor: default;
}
.dream-ref:not(:disabled):hover {
background: rgba(0, 0, 0, 0.03);
}
:global(.dark) .dream-ref:hover {
:global(.dark) .dream-ref:not(:disabled):hover {
background: rgba(255, 255, 255, 0.04);
}

View file

@ -4,61 +4,141 @@
Click → opens detail panel.
-->
<script lang="ts">
import { useAllDreamSymbols } from '../queries';
import type { DreamSymbol } from '../types';
import {
formatDreamDate,
getLastUsedBySymbol,
useAllDreams,
useAllDreamSymbols,
} from '../queries';
import type { Dream, DreamSymbol } from '../types';
import SymbolDetailView from './SymbolDetailView.svelte';
let symbols$ = useAllDreamSymbols();
let symbols = $derived(symbols$.value);
let { onOpenDream }: { onOpenDream?: (dream: Dream) => void } = $props();
let symbols$ = useAllDreamSymbols();
let dreams$ = useAllDreams();
let symbols = $derived(symbols$.value);
let dreams = $derived(dreams$.value);
type SortMode = 'count' | 'alpha' | 'recent';
let sortMode = $state<SortMode>('count');
let searchQuery = $state('');
let selectedSymbolId = $state<string | null>(null);
let lastUsedMap = $derived(getLastUsedBySymbol(dreams));
let active = $derived(symbols.filter((s) => s.count > 0));
let filtered = $derived(
searchQuery.trim()
? symbols.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()))
: symbols
? active.filter((s) => s.name.toLowerCase().includes(searchQuery.toLowerCase()))
: active
);
let maxCount = $derived(filtered.reduce((m, s) => Math.max(m, s.count), 1));
let sorted = $derived.by(() => {
const list = [...filtered];
switch (sortMode) {
case 'alpha':
return list.sort((a, b) => a.name.localeCompare(b.name, 'de'));
case 'recent':
return list.sort((a, b) => {
const ra = lastUsedMap.get(a.name) ?? '';
const rb = lastUsedMap.get(b.name) ?? '';
return rb.localeCompare(ra);
});
default:
return list.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name, 'de'));
}
});
let maxCount = $derived(sorted.reduce((m, s) => Math.max(m, s.count), 1));
function fontSize(sym: DreamSymbol): string {
// Scale 0.75rem .. 1.5rem based on count
const ratio = Math.max(0.2, sym.count / maxCount);
return `${0.75 + ratio * 0.75}rem`;
// More dramatic scaling: 0.75rem .. 1.625rem
const ratio = Math.max(0.1, sym.count / maxCount);
// ease-out so big symbols stand out more
const eased = Math.sqrt(ratio);
return `${0.75 + eased * 0.875}rem`;
}
function selectSymbol(id: string) {
selectedSymbolId = id;
}
</script>
{#if selectedSymbolId}
<SymbolDetailView symbolId={selectedSymbolId} onBack={() => (selectedSymbolId = null)} />
<SymbolDetailView
symbolId={selectedSymbolId}
onBack={() => (selectedSymbolId = null)}
onSelectSymbol={selectSymbol}
{onOpenDream}
/>
{:else}
<div class="symbols-view">
{#if symbols.length > 5}
<input
class="search-input"
type="text"
placeholder="Symbol suchen..."
bind:value={searchQuery}
/>
{/if}
<!-- Toolbar: search + sort -->
<div class="toolbar">
{#if active.length > 5}
<input
class="search-input"
type="text"
placeholder="Symbol suchen..."
bind:value={searchQuery}
/>
{/if}
<div class="sort-tabs">
<button
class="sort-tab"
class:active={sortMode === 'count'}
onclick={() => (sortMode = 'count')}
>
Häufigkeit
</button>
<button
class="sort-tab"
class:active={sortMode === 'alpha'}
onclick={() => (sortMode = 'alpha')}
>
A-Z
</button>
<button
class="sort-tab"
class:active={sortMode === 'recent'}
onclick={() => (sortMode = 'recent')}
>
Zuletzt
</button>
</div>
</div>
{#if filtered.length === 0}
{#if sorted.length === 0}
<p class="empty">
{symbols.length === 0
{active.length === 0
? 'Noch keine Symbole. Füge Symbole zu deinen Träumen hinzu, um sie hier zu sehen.'
: 'Keine Treffer'}
</p>
{:else}
<div class="cloud">
{#each filtered as sym (sym.id)}
<div class="cloud" class:list-mode={sortMode !== 'count'}>
{#each sorted as sym (sym.id)}
{@const lastDate = lastUsedMap.get(sym.name)}
{@const noMeaning = !sym.meaning?.trim()}
<button
class="sym-chip"
style="font-size: {fontSize(sym)}; --sym-color: {sym.color ?? '#6366f1'}"
class:no-meaning={noMeaning}
style="font-size: {sortMode === 'count'
? fontSize(sym)
: '0.8125rem'}; --sym-color: {sym.color ?? '#6366f1'}"
onclick={() => (selectedSymbolId = sym.id)}
title={sym.meaning ?? 'Noch keine Bedeutung hinterlegt'}
>
<span class="sym-dot"></span>
<span class="sym-name">{sym.name}</span>
<span class="sym-count">{sym.count}</span>
{#if sortMode === 'recent' && lastDate}
<span class="sym-meta">· {formatDreamDate(lastDate)}</span>
{/if}
{#if noMeaning}
<span class="sym-badge" title="Keine Bedeutung hinterlegt">?</span>
{/if}
</button>
{/each}
</div>
@ -70,11 +150,17 @@
.symbols-view {
display: flex;
flex-direction: column;
gap: 0.75rem;
gap: 0.625rem;
flex: 1;
min-height: 0;
}
.toolbar {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.search-input {
padding: 0.3rem 0.5rem;
border-radius: 0.375rem;
@ -92,14 +178,46 @@
color: #f3f4f6;
}
.sort-tabs {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.sort-tab {
padding: 0.1875rem 0.5rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
font-size: 0.625rem;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
.sort-tab:hover {
color: #6366f1;
}
.sort-tab.active {
background: #6366f1;
color: white;
border-color: #6366f1;
}
:global(.dark) .sort-tab {
border-color: rgba(255, 255, 255, 0.08);
}
.cloud {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 0.5rem 0.75rem;
gap: 0.5rem 0.625rem;
padding: 0.5rem 0.25rem;
overflow-y: auto;
}
.cloud.list-mode {
flex-direction: column;
align-items: stretch;
gap: 0.1875rem;
}
.sym-chip {
display: inline-flex;
@ -114,11 +232,22 @@
transition: all 0.15s;
font-weight: 500;
line-height: 1.4;
text-align: left;
}
.cloud.list-mode .sym-chip {
border-radius: 0.375rem;
justify-content: flex-start;
}
.sym-chip:hover {
background: color-mix(in srgb, var(--sym-color) 10%, transparent);
background: color-mix(in srgb, var(--sym-color) 12%, transparent);
border-color: var(--sym-color);
}
.sym-chip.no-meaning {
opacity: 0.7;
}
.sym-chip.no-meaning:hover {
opacity: 1;
}
:global(.dark) .sym-chip {
border-color: rgba(255, 255, 255, 0.08);
}
@ -141,6 +270,32 @@
font-weight: 400;
}
.sym-meta {
font-size: 0.625rem;
color: #c0bfba;
font-weight: 400;
}
:global(.dark) .sym-meta {
color: #4b5563;
}
.sym-badge {
display: inline-flex;
align-items: center;
justify-content: center;
width: 13px;
height: 13px;
border-radius: 9999px;
background: rgba(0, 0, 0, 0.06);
color: #9ca3af;
font-size: 0.5625rem;
font-weight: 700;
margin-left: 0.125rem;
}
:global(.dark) .sym-badge {
background: rgba(255, 255, 255, 0.08);
}
.empty {
padding: 2rem 0;
text-align: center;