feat(wishes): add Wünsche module — wishlists with price tracking

New module for managing wishes/gift ideas with lists, price targets,
product URLs, price history, and AI tools. Includes ListView with
filter tabs, inline list management, search, and DetailView with
notes and price history. Encrypted at rest (title, description, URLs,
notes). Registered in database v24, module-registry, crypto registry,
seed registry, tool init, and DnD type system.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-17 14:02:37 +02:00
parent fea37c36a4
commit 5bdacaa5ea
17 changed files with 1413 additions and 1 deletions

View file

@ -563,6 +563,17 @@ db.version(23).stores({
userContext: 'id',
});
// v24 — Wishes module: wishlists with price tracking.
// wishesItems indexes [listId+order] for the per-list view,
// status for the active/fulfilled filter tabs.
// wishesPriceChecks indexes [wishId+checkedAt] for the per-wish
// price history timeline (reverse range scan).
db.version(24).stores({
wishesItems: 'id, listId, status, priority, category, [listId+order], [status+order]',
wishesLists: 'id, order, isArchived',
wishesPriceChecks: 'id, wishId, checkedAt, [wishId+checkedAt]',
});
// v25 — Wetter module: saved locations and user preferences.
db.version(25).stores({
wetterLocations: 'id, isDefault, order',

View file

@ -35,6 +35,7 @@ import { MEDITATE_GUEST_SEED } from '$lib/modules/meditate/collections';
import { SLEEP_GUEST_SEED } from '$lib/modules/sleep/collections';
import { MOOD_GUEST_SEED } from '$lib/modules/mood/collections';
import { QUIZ_GUEST_SEED } from '$lib/modules/quiz/collections';
import { WISHES_GUEST_SEED } from '$lib/modules/wishes/collections';
/**
* Flat list of { tableName, rows } entries. Only modules with non-empty
@ -74,6 +75,7 @@ register(MEDITATE_GUEST_SEED);
register(SLEEP_GUEST_SEED);
register(MOOD_GUEST_SEED);
register(QUIZ_GUEST_SEED);
register(WISHES_GUEST_SEED);
/**
* Seed all module guest data into empty tables. Idempotent: tables

View file

@ -0,0 +1,341 @@
<script lang="ts">
import { goto } from '$app/navigation';
import {
Plus,
Star,
Check,
Archive,
Link as LinkIcon,
MagnifyingGlass,
CaretUp,
CaretDown,
Minus,
ShootingStar,
Folder,
X,
Trash,
} from '@mana/shared-icons';
import { wishesStore } from './stores/wishes.svelte';
import { listsStore } from './stores/lists.svelte';
import {
useAllWishes,
useAllLists,
filterByStatus,
filterByList,
searchWishes,
getTotalEstimatedCost,
} from './queries';
const allWishes = useAllWishes();
const allLists = useAllLists();
let filter = $state<'active' | 'fulfilled' | 'all'>('active');
let selectedListId = $state<string | null>(null);
let searchQuery = $state('');
let showAdd = $state(false);
let newTitle = $state('');
let newTargetPrice = $state('');
let newCategory = $state('');
let showNewList = $state(false);
let newListName = $state('');
const wishes = $derived(allWishes.value);
const lists = $derived(allLists.value);
const filtered = $derived.by(() => {
let result = wishes;
if (filter !== 'all') result = filterByStatus(result, filter);
if (selectedListId) result = filterByList(result, selectedListId);
if (searchQuery) result = searchWishes(result, searchQuery);
return result;
});
const totalCost = $derived(getTotalEstimatedCost(filterByStatus(wishes, 'active')));
const activeCount = $derived(filterByStatus(wishes, 'active').length);
const fulfilledCount = $derived(filterByStatus(wishes, 'fulfilled').length);
async function addWish() {
const title = newTitle.trim();
if (!title) return;
await wishesStore.create({
title,
listId: selectedListId,
targetPrice: newTargetPrice ? parseFloat(newTargetPrice) : undefined,
category: newCategory || undefined,
});
newTitle = '';
newTargetPrice = '';
newCategory = '';
showAdd = false;
}
async function addList() {
const name = newListName.trim();
if (!name) return;
const created = await listsStore.create({ name });
selectedListId = created.id;
newListName = '';
showNewList = false;
}
async function deleteList(id: string) {
await listsStore.delete(id);
if (selectedListId === id) selectedListId = null;
}
</script>
<svelte:head>
<title>Wünsche - Mana</title>
</svelte:head>
<div class="space-y-3">
<!-- Stats + Add -->
<div class="flex items-center justify-between">
<p class="text-xs text-[hsl(var(--color-muted-foreground))]">
{activeCount} offen · {fulfilledCount} erfüllt
{#if totalCost > 0}
· ~{totalCost.toLocaleString('de-DE')}
{/if}
</p>
<button
onclick={() => (showAdd = !showAdd)}
class="flex items-center gap-1.5 rounded-md bg-[hsl(var(--color-primary))] px-3 py-1.5 text-xs font-medium text-[hsl(var(--color-primary-foreground))] transition-colors hover:opacity-90"
>
<Plus size={14} />
Hinzufügen
</button>
</div>
<!-- Quick Add -->
{#if showAdd}
<form
onsubmit={(e) => {
e.preventDefault();
addWish();
}}
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-3"
>
<div class="space-y-2">
<input
bind:value={newTitle}
placeholder="Was wünschst du dir?"
class="w-full rounded-md border border-[hsl(var(--color-border))] bg-transparent px-3 py-1.5 text-sm text-[hsl(var(--color-foreground))] outline-none placeholder:text-[hsl(var(--color-muted-foreground))] focus:border-[hsl(var(--color-primary))]"
/>
<div class="flex flex-wrap gap-2">
<input
bind:value={newTargetPrice}
placeholder="Zielpreis (€)"
type="number"
step="0.01"
class="w-28 rounded-md border border-[hsl(var(--color-border))] bg-transparent px-2.5 py-1.5 text-xs text-[hsl(var(--color-foreground))] outline-none placeholder:text-[hsl(var(--color-muted-foreground))] focus:border-[hsl(var(--color-primary))]"
/>
<input
bind:value={newCategory}
placeholder="Kategorie"
class="w-28 rounded-md border border-[hsl(var(--color-border))] bg-transparent px-2.5 py-1.5 text-xs text-[hsl(var(--color-foreground))] outline-none placeholder:text-[hsl(var(--color-muted-foreground))] focus:border-[hsl(var(--color-primary))]"
/>
{#if lists.length > 0}
<select
bind:value={selectedListId}
class="rounded-md border border-[hsl(var(--color-border))] bg-transparent px-2.5 py-1.5 text-xs text-[hsl(var(--color-foreground))] outline-none"
>
<option value={null}>Keine Liste</option>
{#each lists as list (list.id)}
<option value={list.id}>{list.name}</option>
{/each}
</select>
{/if}
</div>
<div class="flex justify-end gap-2">
<button
type="button"
onclick={() => (showAdd = false)}
class="rounded-md px-2.5 py-1 text-xs text-[hsl(var(--color-muted-foreground))] hover:bg-[hsl(var(--color-muted))]"
>
Abbrechen
</button>
<button
type="submit"
disabled={!newTitle.trim()}
class="rounded-md bg-[hsl(var(--color-primary))] px-3 py-1 text-xs font-medium text-[hsl(var(--color-primary-foreground))] disabled:opacity-40"
>
Speichern
</button>
</div>
</div>
</form>
{/if}
<!-- Filter tabs -->
<div class="flex gap-1 rounded-lg bg-[hsl(var(--color-muted))] p-0.5">
{#each [{ key: 'active', label: 'Offen', icon: Star }, { key: 'fulfilled', label: 'Erfüllt', icon: Check }, { key: 'all', label: 'Alle', icon: Archive }] as tab (tab.key)}
<button
onclick={() => (filter = tab.key as 'active' | 'fulfilled' | 'all')}
class="flex flex-1 items-center justify-center gap-1 rounded-md px-2.5 py-1 text-[11px] font-medium transition-colors {filter ===
tab.key
? 'bg-[hsl(var(--color-card))] text-[hsl(var(--color-foreground))] shadow-sm'
: 'text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]'}"
>
<tab.icon size={12} />
{tab.label}
</button>
{/each}
</div>
<!-- Lists -->
<div class="flex items-center gap-1 overflow-x-auto">
{#if lists.length > 0}
<button
onclick={() => (selectedListId = null)}
class="flex-shrink-0 rounded-md px-2 py-1 text-[11px] font-medium transition-colors {selectedListId ===
null
? 'bg-[hsl(var(--color-primary)/0.1)] text-[hsl(var(--color-primary))]'
: 'text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]'}"
>
Alle
</button>
{/if}
{#each lists as list (list.id)}
<div class="group relative flex-shrink-0">
<button
onclick={() => (selectedListId = selectedListId === list.id ? null : list.id)}
class="flex items-center gap-1 rounded-md px-2 py-1 text-[11px] font-medium transition-colors {selectedListId ===
list.id
? 'bg-[hsl(var(--color-primary)/0.1)] text-[hsl(var(--color-primary))]'
: 'text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]'}"
>
<Folder size={11} />
{list.name}
</button>
<button
onclick={() => deleteList(list.id)}
class="absolute -right-1 -top-1 hidden rounded-full bg-[hsl(var(--color-card))] p-0.5 text-[hsl(var(--color-muted-foreground))] shadow-sm hover:text-red-400 group-hover:block"
>
<X size={8} />
</button>
</div>
{/each}
{#if showNewList}
<form
onsubmit={(e) => {
e.preventDefault();
addList();
}}
class="flex items-center gap-1"
>
<input
bind:value={newListName}
placeholder="Listenname"
class="w-24 rounded-md border border-[hsl(var(--color-border))] bg-transparent px-2 py-0.5 text-[11px] outline-none placeholder:text-[hsl(var(--color-muted-foreground))] focus:border-[hsl(var(--color-primary))]"
/>
<button
type="submit"
disabled={!newListName.trim()}
class="rounded p-0.5 text-[hsl(var(--color-primary))] disabled:opacity-30"
>
<Check size={12} />
</button>
<button
type="button"
onclick={() => {
showNewList = false;
newListName = '';
}}
class="rounded p-0.5 text-[hsl(var(--color-muted-foreground))]"
>
<X size={12} />
</button>
</form>
{:else}
<button
onclick={() => (showNewList = true)}
class="flex flex-shrink-0 items-center gap-0.5 rounded-md px-1.5 py-1 text-[11px] text-[hsl(var(--color-muted-foreground))] transition-colors hover:text-[hsl(var(--color-foreground))]"
>
<Plus size={11} />
</button>
{/if}
</div>
<!-- Search -->
<div class="relative">
<MagnifyingGlass
size={14}
class="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 text-[hsl(var(--color-muted-foreground))]"
/>
<input
bind:value={searchQuery}
placeholder="Suchen..."
class="w-full rounded-md border border-[hsl(var(--color-border))] bg-transparent py-1.5 pl-8 pr-3 text-xs text-[hsl(var(--color-foreground))] outline-none placeholder:text-[hsl(var(--color-muted-foreground))] focus:border-[hsl(var(--color-primary))]"
/>
</div>
<!-- Wish list -->
{#if filtered.length === 0}
<div
class="flex flex-col items-center justify-center rounded-lg border border-dashed border-[hsl(var(--color-border))] py-12"
>
<ShootingStar size={32} class="mb-3 text-[hsl(var(--color-muted-foreground))]" />
<p class="text-sm text-[hsl(var(--color-muted-foreground))]">
{filter === 'active' ? 'Noch keine Wünsche' : 'Keine Wünsche in dieser Ansicht'}
</p>
</div>
{:else}
<div class="space-y-1">
{#each filtered as wish (wish.id)}
<button
onclick={() => goto(`/wishes/${wish.id}`)}
class="group flex w-full items-center gap-2.5 rounded-md border border-transparent bg-transparent px-2.5 py-2 text-left transition-colors hover:bg-[hsl(var(--color-muted)/0.5)]"
>
<!-- Priority icon -->
{#if wish.priority === 'high'}
<CaretUp size={14} weight="fill" class="flex-shrink-0 text-red-400" />
{:else if wish.priority === 'low'}
<CaretDown
size={14}
weight="fill"
class="flex-shrink-0 text-[hsl(var(--color-muted-foreground))]"
/>
{:else}
<Minus size={14} class="flex-shrink-0 text-[hsl(var(--color-muted-foreground))]" />
{/if}
<div class="min-w-0 flex-1">
<div class="flex items-center gap-1.5">
<span
class="truncate text-sm text-[hsl(var(--color-foreground))] {wish.status ===
'fulfilled'
? 'line-through opacity-50'
: ''}"
>
{wish.title}
</span>
{#if wish.productUrls.length > 0}
<LinkIcon
size={11}
class="flex-shrink-0 text-[hsl(var(--color-muted-foreground))]"
/>
{/if}
</div>
{#if wish.category || wish.tags.length > 0}
<div class="mt-0.5 flex items-center gap-1">
{#if wish.category}
<span class="text-[10px] text-[hsl(var(--color-muted-foreground))]">
{wish.category}
</span>
{/if}
</div>
{/if}
</div>
{#if wish.targetPrice}
<span
class="flex-shrink-0 text-xs tabular-nums text-[hsl(var(--color-muted-foreground))]"
>
{wish.targetPrice.toLocaleString('de-DE')}
</span>
{/if}
</button>
{/each}
</div>
{/if}
</div>

View file

@ -0,0 +1,67 @@
/**
* Wishes module collection accessors and guest seed data.
*
* Uses prefixed table names in the unified DB: wishesItems, wishesLists, wishesPriceChecks.
*/
import { db } from '$lib/data/database';
import type { LocalWish, LocalWishList, LocalPriceCheck } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const wishTable = db.table<LocalWish>('wishesItems');
export const listTable = db.table<LocalWishList>('wishesLists');
export const priceCheckTable = db.table<LocalPriceCheck>('wishesPriceChecks');
// ─── Guest Seed ────────────────────────────────────────────
const DEMO_LIST_ID = 'demo-birthday-wishes';
export const WISHES_GUEST_SEED = {
wishesLists: [
{
id: DEMO_LIST_ID,
name: 'Geburtstagsgeschenke',
description: 'Ideen für Geburtstag',
icon: '🎁',
color: '#ec4899',
isArchived: false,
order: 0,
},
],
wishesItems: [
{
id: 'demo-wish-1',
title: 'Neue Kopfhörer',
description: 'Wireless, aktive Geräuschunterdrückung',
listId: DEMO_LIST_ID,
priority: 'medium' as const,
status: 'active' as const,
targetPrice: 150,
currency: 'EUR',
productUrls: [],
imageUrl: null,
category: 'Technik',
tags: ['audio', 'tech'],
notes: [],
order: 0,
},
{
id: 'demo-wish-2',
title: 'Kochbuch — vegetarische Rezepte',
description: 'Am liebsten asiatisch-vegetarisch',
listId: DEMO_LIST_ID,
priority: 'low' as const,
status: 'active' as const,
targetPrice: 25,
currency: 'EUR',
productUrls: [],
imageUrl: null,
category: 'Bücher',
tags: ['books', 'cooking'],
notes: [],
order: 1,
},
],
wishesPriceChecks: [] as Record<string, unknown>[],
};

View file

@ -0,0 +1,38 @@
/**
* Wishes module barrel exports.
*/
// Stores
export { wishesStore } from './stores/wishes.svelte';
export { listsStore } from './stores/lists.svelte';
export { priceChecksStore } from './stores/price-checks.svelte';
// Queries
export {
useAllWishes,
useAllLists,
usePriceChecks,
toWish,
toWishList,
toPriceCheck,
filterByStatus,
filterByPriority,
filterByList,
searchWishes,
getTotalEstimatedCost,
} from './queries';
// Collections
export { wishTable, listTable, priceCheckTable, WISHES_GUEST_SEED } from './collections';
// Types
export type {
LocalWish,
LocalWishList,
LocalPriceCheck,
Wish,
WishList,
PriceCheck,
WishStatus,
WishPriority,
} from './types';

View file

@ -0,0 +1,10 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const wishesModuleConfig: ModuleConfig = {
appId: 'wishes',
tables: [
{ name: 'wishesItems', syncName: 'items' },
{ name: 'wishesLists', syncName: 'lists' },
{ name: 'wishesPriceChecks', syncName: 'priceChecks' },
],
};

View file

@ -0,0 +1,129 @@
/**
* Reactive queries & pure helpers for Wishes uses Dexie liveQuery on the unified DB.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import type {
LocalWish,
LocalWishList,
LocalPriceCheck,
Wish,
WishList,
PriceCheck,
} from './types';
// ─── Type Converters ───────────────────────────────────────
export function toWish(local: LocalWish): Wish {
return {
id: local.id,
title: local.title,
description: local.description ?? null,
listId: local.listId ?? null,
priority: local.priority,
targetPrice: local.targetPrice ?? null,
currency: local.currency ?? null,
productUrls: local.productUrls ?? [],
imageUrl: local.imageUrl ?? null,
category: local.category ?? null,
status: local.status,
tags: local.tags ?? [],
notes: local.notes ?? [],
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toWishList(local: LocalWishList): WishList {
return {
id: local.id,
name: local.name,
description: local.description ?? null,
icon: local.icon ?? null,
color: local.color ?? null,
isArchived: local.isArchived,
order: local.order,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toPriceCheck(local: LocalPriceCheck): PriceCheck {
return {
id: local.id,
wishId: local.wishId,
url: local.url,
price: local.price,
currency: local.currency,
available: local.available,
checkedAt: local.checkedAt,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllWishes() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalWish>('wishesItems').orderBy('order').toArray();
const visible = locals.filter((w) => !w.deletedAt);
const decrypted = await decryptRecords('wishesItems', visible);
return decrypted.map(toWish);
}, [] as Wish[]);
}
export function useAllLists() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalWishList>('wishesLists').orderBy('order').toArray();
const visible = locals.filter((l) => !l.deletedAt && !l.isArchived);
return visible.map(toWishList);
}, [] as WishList[]);
}
export function usePriceChecks(wishId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await db
.table<LocalPriceCheck>('wishesPriceChecks')
.where('wishId')
.equals(wishId)
.toArray();
const visible = locals.filter((p) => !p.deletedAt);
return visible
.sort((a, b) => new Date(b.checkedAt).getTime() - new Date(a.checkedAt).getTime())
.map(toPriceCheck);
}, [] as PriceCheck[]);
}
// ─── Pure Filter Functions ────────────────────────────────
export function filterByStatus(wishes: Wish[], status: string): Wish[] {
return wishes.filter((w) => w.status === status);
}
export function filterByPriority(wishes: Wish[], priority: string): Wish[] {
return wishes.filter((w) => w.priority === priority);
}
export function filterByList(wishes: Wish[], listId: string | null): Wish[] {
if (listId === null) return wishes.filter((w) => !w.listId);
return wishes.filter((w) => w.listId === listId);
}
export function searchWishes(wishes: Wish[], query: string): Wish[] {
if (!query.trim()) return wishes;
const q = query.toLowerCase().trim();
return wishes.filter(
(w) =>
w.title.toLowerCase().includes(q) ||
w.description?.toLowerCase().includes(q) ||
w.category?.toLowerCase().includes(q) ||
w.tags.some((t) => t.toLowerCase().includes(q))
);
}
export function getTotalEstimatedCost(wishes: Wish[]): number {
return wishes.reduce((sum, w) => sum + (w.targetPrice ?? 0), 0);
}

View file

@ -0,0 +1,57 @@
/**
* Wish Lists Store Mutations Only
*/
import { listTable } from '../collections';
import { toWishList } from '../queries';
import type { LocalWishList } from '../types';
export const listsStore = {
async create(data: { name: string; description?: string; icon?: string; color?: string }) {
const all = await listTable.toArray();
const active = all.filter((l) => !l.deletedAt);
const newLocal: LocalWishList = {
id: crypto.randomUUID(),
name: data.name,
description: data.description ?? null,
icon: data.icon ?? null,
color: data.color ?? null,
isArchived: false,
order: active.length,
};
await listTable.add(newLocal);
return toWishList(newLocal);
},
async update(
id: string,
data: Partial<Pick<LocalWishList, 'name' | 'description' | 'icon' | 'color'>>
) {
await listTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async archive(id: string) {
await listTable.update(id, {
isArchived: true,
updatedAt: new Date().toISOString(),
});
},
async delete(id: string) {
await listTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async reorder(orderedIds: string[]) {
const now = new Date().toISOString();
for (let i = 0; i < orderedIds.length; i++) {
await listTable.update(orderedIds[i], { order: i, updatedAt: now });
}
},
};

View file

@ -0,0 +1,29 @@
/**
* Price Checks Store Mutations Only
*/
import { priceCheckTable } from '../collections';
import type { LocalPriceCheck } from '../types';
export const priceChecksStore = {
async record(data: {
wishId: string;
url: string;
price: number;
currency: string;
available?: boolean;
}) {
const now = new Date().toISOString();
const newLocal: LocalPriceCheck = {
id: crypto.randomUUID(),
wishId: data.wishId,
url: data.url,
price: data.price,
currency: data.currency,
available: data.available ?? true,
checkedAt: now,
};
await priceCheckTable.add(newLocal);
return newLocal;
},
};

View file

@ -0,0 +1,142 @@
/**
* Wishes Store Mutations Only
*
* All reads are handled by liveQuery hooks in queries.ts.
*/
import { wishTable } from '../collections';
import { toWish } from '../queries';
import type { LocalWish, WishPriority } from '../types';
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
export const wishesStore = {
async create(data: {
title: string;
description?: string;
listId?: string | null;
priority?: WishPriority;
targetPrice?: number;
currency?: string;
productUrls?: string[];
imageUrl?: string;
category?: string;
tags?: string[];
}) {
const existing = await wishTable.toArray();
const active = existing.filter((w) => !w.deletedAt);
const newLocal: LocalWish = {
id: crypto.randomUUID(),
title: data.title,
description: data.description ?? null,
listId: data.listId ?? null,
priority: data.priority ?? 'medium',
targetPrice: data.targetPrice ?? null,
currency: data.currency ?? 'EUR',
productUrls: data.productUrls ?? [],
imageUrl: data.imageUrl ?? null,
category: data.category ?? null,
status: 'active',
tags: data.tags ?? [],
notes: [],
order: active.length,
};
const plaintextSnapshot = toWish(newLocal);
await encryptRecord('wishesItems', newLocal);
await wishTable.add(newLocal);
emitDomainEvent('WishCreated', 'wishes', 'wishesItems', newLocal.id, {
wishId: newLocal.id,
title: data.title,
listId: data.listId,
});
return plaintextSnapshot;
},
async update(
id: string,
data: Partial<
Pick<
LocalWish,
| 'title'
| 'description'
| 'priority'
| 'status'
| 'targetPrice'
| 'currency'
| 'productUrls'
| 'imageUrl'
| 'category'
| 'tags'
| 'listId'
>
>
) {
const diff: Partial<LocalWish> = {
...data,
updatedAt: new Date().toISOString(),
};
await encryptRecord('wishesItems', diff);
await wishTable.update(id, diff);
},
async fulfill(id: string) {
await wishTable.update(id, {
status: 'fulfilled',
updatedAt: new Date().toISOString(),
});
emitDomainEvent('WishFulfilled', 'wishes', 'wishesItems', id, { wishId: id });
},
async archive(id: string) {
await wishTable.update(id, {
status: 'archived',
updatedAt: new Date().toISOString(),
});
},
async delete(id: string) {
await wishTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async addNote(wishId: string, content: string) {
const wish = await wishTable.get(wishId);
if (!wish) return;
const now = new Date().toISOString();
const note = { id: crypto.randomUUID(), content, createdAt: now };
await wishTable.update(wishId, {
notes: [...wish.notes, note],
updatedAt: now,
});
},
async addProductUrl(wishId: string, url: string) {
const wish = await wishTable.get(wishId);
if (!wish) return;
if (wish.productUrls.includes(url)) return;
await wishTable.update(wishId, {
productUrls: [...wish.productUrls, url],
updatedAt: new Date().toISOString(),
});
},
async removeProductUrl(wishId: string, url: string) {
const wish = await wishTable.get(wishId);
if (!wish) return;
await wishTable.update(wishId, {
productUrls: wish.productUrls.filter((u) => u !== url),
updatedAt: new Date().toISOString(),
});
},
async reorder(orderedIds: string[]) {
const now = new Date().toISOString();
for (let i = 0; i < orderedIds.length; i++) {
await wishTable.update(orderedIds[i], { order: i, updatedAt: now });
}
},
};

View file

@ -0,0 +1,109 @@
/**
* Wishes Tools LLM-accessible operations for the wishes module.
*/
import type { ModuleTool } from '$lib/data/tools/types';
export const wishesTools: ModuleTool[] = [
{
name: 'create_wish',
module: 'wishes',
description:
'Erstellt einen neuen Wunsch auf der Wunschliste. Nutze dies wenn der Nutzer sich etwas wünscht oder eine Geschenkidee hat.',
parameters: [
{ name: 'title', type: 'string', description: 'Wunsch-Titel', required: true },
{ name: 'description', type: 'string', description: 'Beschreibung', required: false },
{
name: 'priority',
type: 'string',
description: 'Priorität',
required: false,
enum: ['low', 'medium', 'high'],
},
{
name: 'targetPrice',
type: 'number',
description: 'Zielpreis / Budget in EUR',
required: false,
},
{
name: 'category',
type: 'string',
description: 'Kategorie (z.B. Technik, Bücher)',
required: false,
},
],
async execute(params) {
const { wishesStore } = await import('./stores/wishes.svelte');
const wish = await wishesStore.create({
title: params.title as string,
description: params.description as string | undefined,
priority: (params.priority as 'low' | 'medium' | 'high') ?? undefined,
targetPrice: params.targetPrice as number | undefined,
category: params.category as string | undefined,
});
return { success: true, data: wish, message: `Wunsch "${wish.title}" erstellt` };
},
},
{
name: 'list_wishes',
module: 'wishes',
description:
'Listet alle Wünsche auf der Wunschliste. Nutze dies wenn der Nutzer nach seinen Wünschen fragt.',
parameters: [
{
name: 'filter',
type: 'string',
description: 'Nach Status filtern',
required: false,
enum: ['active', 'fulfilled', 'all'],
},
],
async execute(params) {
const { wishTable } = await import('./collections');
const { toWish } = await import('./queries');
const { decryptRecords } = await import('$lib/data/crypto');
const all = await wishTable.toArray();
const active = all.filter((w) => !w.deletedAt);
const decrypted = await decryptRecords('wishesItems', active);
const wishes = decrypted.map(toWish);
const filter = (params.filter as string) ?? 'active';
let filtered = wishes;
if (filter === 'active') filtered = wishes.filter((w) => w.status === 'active');
else if (filter === 'fulfilled') filtered = wishes.filter((w) => w.status === 'fulfilled');
const list = filtered.map((w) => ({
id: w.id,
title: w.title,
priority: w.priority,
targetPrice: w.targetPrice,
currency: w.currency,
category: w.category,
status: w.status,
}));
return {
success: true,
data: list,
message:
list.length === 0
? `Keine ${filter === 'all' ? '' : filter + 'n'} Wünsche`
: `${list.length} Wünsche gefunden`,
};
},
},
{
name: 'fulfill_wish',
module: 'wishes',
description: 'Markiert einen Wunsch als erfüllt.',
parameters: [
{ name: 'wishId', type: 'string', description: 'ID des Wunsches', required: true },
],
async execute(params) {
const { wishesStore } = await import('./stores/wishes.svelte');
await wishesStore.fulfill(params.wishId as string);
return { success: true, message: 'Wunsch als erfüllt markiert' };
},
},
];

View file

@ -0,0 +1,86 @@
/**
* Wishes module types for the unified app.
*/
import type { BaseRecord } from '@mana/local-store';
export interface LocalWish extends BaseRecord {
title: string;
description?: string | null;
listId?: string | null;
priority: 'low' | 'medium' | 'high';
targetPrice?: number | null;
currency?: string | null;
productUrls: string[];
imageUrl?: string | null;
category?: string | null;
status: 'active' | 'fulfilled' | 'archived';
tags: string[];
notes: Array<{ id: string; content: string; createdAt: string }>;
order: number;
}
export interface LocalWishList extends BaseRecord {
name: string;
description?: string | null;
icon?: string | null;
color?: string | null;
isArchived: boolean;
order: number;
}
export interface LocalPriceCheck extends BaseRecord {
wishId: string;
url: string;
price: number;
currency: string;
available: boolean;
checkedAt: string;
}
// ─── Public Types (post-decryption, used in UI) ───────────
export interface Wish {
id: string;
title: string;
description?: string | null;
listId?: string | null;
priority: 'low' | 'medium' | 'high';
targetPrice?: number | null;
currency?: string | null;
productUrls: string[];
imageUrl?: string | null;
category?: string | null;
status: 'active' | 'fulfilled' | 'archived';
tags: string[];
notes: Array<{ id: string; content: string; createdAt: string }>;
order: number;
createdAt: string;
updatedAt: string;
}
export interface WishList {
id: string;
name: string;
description?: string | null;
icon?: string | null;
color?: string | null;
isArchived: boolean;
order: number;
createdAt: string;
updatedAt: string;
}
export interface PriceCheck {
id: string;
wishId: string;
url: string;
price: number;
currency: string;
available: boolean;
checkedAt: string;
createdAt: string;
}
export type WishStatus = Wish['status'];
export type WishPriority = Wish['priority'];

View file

@ -0,0 +1,373 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/stores';
import { ArrowLeft, Check, Trash, Plus, Link as LinkIcon, Star, X } from '@mana/shared-icons';
import { wishesStore } from '../stores/wishes.svelte';
import { useAllWishes, usePriceChecks } from '../queries';
import type { PriceCheck } from '../types';
const allWishes = useAllWishes();
const wishId = $derived($page.params.id ?? '');
const wish = $derived(allWishes.value.find((w) => w.id === wishId));
let priceChecksQuery: { readonly value: PriceCheck[] } | null = $state(null);
$effect(() => {
if (wishId) {
priceChecksQuery = usePriceChecks(wishId);
}
});
const priceChecks = $derived(priceChecksQuery ?? { value: [] as PriceCheck[] });
let editing = $state(false);
let editTitle = $state('');
let editDescription = $state('');
let editTargetPrice = $state('');
let editCategory = $state('');
let newUrl = $state('');
let newNote = $state('');
function startEdit() {
if (!wish) return;
editTitle = wish.title;
editDescription = wish.description ?? '';
editTargetPrice = wish.targetPrice?.toString() ?? '';
editCategory = wish.category ?? '';
editing = true;
}
async function saveEdit() {
if (!wish) return;
await wishesStore.update(wish.id, {
title: editTitle,
description: editDescription || null,
targetPrice: editTargetPrice ? parseFloat(editTargetPrice) : null,
category: editCategory || null,
});
editing = false;
}
async function handleFulfill() {
if (!wish) return;
await wishesStore.fulfill(wish.id);
}
async function handleDelete() {
if (!wish) return;
if (confirm('Wunsch löschen?')) {
await wishesStore.delete(wish.id);
goto('/wishes');
}
}
async function addUrl() {
if (!wish || !newUrl.trim()) return;
await wishesStore.addProductUrl(wish.id, newUrl.trim());
newUrl = '';
}
async function removeUrl(url: string) {
if (!wish) return;
await wishesStore.removeProductUrl(wish.id, url);
}
async function addNote() {
if (!wish || !newNote.trim()) return;
await wishesStore.addNote(wish.id, newNote.trim());
newNote = '';
}
function priorityLabel(p: string) {
if (p === 'high') return 'Hoch';
if (p === 'medium') return 'Mittel';
return 'Niedrig';
}
const lowestPrice = $derived.by(() => {
const checks = priceChecks.value;
if (checks.length === 0) return null;
return Math.min(...checks.map((c) => c.price));
});
</script>
<svelte:head>
<title>{wish?.title ?? 'Wunsch'} - Wünsche - Mana</title>
</svelte:head>
{#if !wish}
<div class="flex items-center justify-center py-20">
<p class="text-[hsl(var(--color-muted-foreground))]">Wunsch nicht gefunden</p>
</div>
{:else}
<div class="space-y-6">
<!-- Back + Actions -->
<div class="flex items-center justify-between">
<button
onclick={() => goto('/wishes')}
class="flex items-center gap-1 text-sm text-[hsl(var(--color-muted-foreground))] hover:text-[hsl(var(--color-foreground))]"
>
<ArrowLeft size={16} />
Zurück
</button>
<div class="flex gap-2">
{#if wish.status === 'active'}
<button
onclick={handleFulfill}
class="flex items-center gap-1.5 rounded-md bg-green-500/10 px-3 py-1.5 text-sm font-medium text-green-500 hover:bg-green-500/20"
>
<Check size={14} />
Erfüllt
</button>
{/if}
<button
onclick={handleDelete}
class="flex items-center gap-1.5 rounded-md px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:bg-red-500/10 hover:text-red-500"
>
<Trash size={14} />
</button>
</div>
</div>
<!-- Main content -->
{#if editing}
<div
class="space-y-3 rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<input
bind:value={editTitle}
class="w-full rounded-md border border-[hsl(var(--color-border))] bg-transparent px-3 py-2 text-lg font-bold text-[hsl(var(--color-foreground))] outline-none focus:border-[hsl(var(--color-primary))]"
/>
<textarea
bind:value={editDescription}
placeholder="Beschreibung"
rows="3"
class="w-full resize-none rounded-md border border-[hsl(var(--color-border))] bg-transparent px-3 py-2 text-sm text-[hsl(var(--color-foreground))] outline-none placeholder:text-[hsl(var(--color-muted-foreground))] focus:border-[hsl(var(--color-primary))]"
></textarea>
<div class="flex gap-2">
<input
bind:value={editTargetPrice}
placeholder="Zielpreis"
type="number"
step="0.01"
class="w-32 rounded-md border border-[hsl(var(--color-border))] bg-transparent px-3 py-2 text-sm outline-none focus:border-[hsl(var(--color-primary))]"
/>
<input
bind:value={editCategory}
placeholder="Kategorie"
class="w-40 rounded-md border border-[hsl(var(--color-border))] bg-transparent px-3 py-2 text-sm outline-none focus:border-[hsl(var(--color-primary))]"
/>
</div>
<div class="flex justify-end gap-2">
<button
onclick={() => (editing = false)}
class="rounded-md px-3 py-1.5 text-sm text-[hsl(var(--color-muted-foreground))] hover:bg-[hsl(var(--color-muted))]"
>
Abbrechen
</button>
<button
onclick={saveEdit}
class="rounded-md bg-[hsl(var(--color-primary))] px-4 py-1.5 text-sm font-medium text-[hsl(var(--color-primary-foreground))]"
>
Speichern
</button>
</div>
</div>
{:else}
<button
onclick={startEdit}
class="w-full rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4 text-left transition-colors hover:border-[hsl(var(--color-primary)/0.3)]"
>
<div class="flex items-start justify-between">
<div>
<h1
class="text-xl font-bold text-[hsl(var(--color-foreground))] {wish.status ===
'fulfilled'
? 'line-through opacity-60'
: ''}"
>
{wish.title}
</h1>
{#if wish.description}
<p class="mt-1 text-sm text-[hsl(var(--color-muted-foreground))]">
{wish.description}
</p>
{/if}
</div>
{#if wish.status === 'fulfilled'}
<span
class="rounded-full bg-green-500/10 px-2 py-0.5 text-xs font-medium text-green-500"
>
Erfüllt
</span>
{/if}
</div>
<div class="mt-3 flex flex-wrap gap-3 text-sm text-[hsl(var(--color-muted-foreground))]">
<span class="flex items-center gap-1">
<Star size={14} />
{priorityLabel(wish.priority)}
</span>
{#if wish.targetPrice}
<span>Zielpreis: {wish.targetPrice.toLocaleString('de-DE')} {wish.currency ?? '€'}</span
>
{/if}
{#if wish.category}
<span class="rounded bg-[hsl(var(--color-muted))] px-1.5 py-0.5 text-xs">
{wish.category}
</span>
{/if}
{#if lowestPrice != null && wish.targetPrice}
<span
class={lowestPrice <= wish.targetPrice
? 'font-medium text-green-500'
: 'text-orange-400'}
>
Bester Preis: {lowestPrice.toLocaleString('de-DE')}
</span>
{/if}
</div>
</button>
{/if}
<!-- Product URLs -->
<div
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<h2
class="mb-3 flex items-center gap-2 text-sm font-semibold text-[hsl(var(--color-foreground))]"
>
<LinkIcon size={16} />
Produkt-Links ({wish.productUrls.length})
</h2>
{#if wish.productUrls.length > 0}
<ul class="mb-3 space-y-1.5">
{#each wish.productUrls as url}
<li class="flex items-center gap-2 text-sm">
<a
href={url}
target="_blank"
rel="noopener noreferrer"
class="min-w-0 flex-1 truncate text-[hsl(var(--color-primary))] hover:underline"
>
{url}
</a>
<button
onclick={() => removeUrl(url)}
class="flex-shrink-0 rounded p-0.5 text-[hsl(var(--color-muted-foreground))] hover:text-red-500"
>
<X size={14} />
</button>
</li>
{/each}
</ul>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
addUrl();
}}
class="flex gap-2"
>
<input
bind:value={newUrl}
placeholder="https://..."
type="url"
class="min-w-0 flex-1 rounded-md border border-[hsl(var(--color-border))] bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-[hsl(var(--color-muted-foreground))] focus:border-[hsl(var(--color-primary))]"
/>
<button
type="submit"
disabled={!newUrl.trim()}
class="flex items-center gap-1 rounded-md bg-[hsl(var(--color-primary))] px-3 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-40"
>
<Plus size={14} />
</button>
</form>
</div>
<!-- Price History -->
{#if priceChecks.value.length > 0}
<div
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<h2 class="mb-3 text-sm font-semibold text-[hsl(var(--color-foreground))]">Preisverlauf</h2>
<div class="space-y-1.5">
{#each priceChecks.value.slice(0, 10) as check (check.id)}
<div class="flex items-center justify-between text-sm">
<span class="truncate text-[hsl(var(--color-muted-foreground))]">
{new Date(check.checkedAt).toLocaleDateString('de-DE')}
</span>
<span
class="font-medium {check.available
? 'text-[hsl(var(--color-foreground))]'
: 'text-red-400 line-through'}"
>
{check.price.toLocaleString('de-DE')}
{check.currency}
</span>
</div>
{/each}
</div>
</div>
{/if}
<!-- Notes -->
<div
class="rounded-lg border border-[hsl(var(--color-border))] bg-[hsl(var(--color-card))] p-4"
>
<h2 class="mb-3 text-sm font-semibold text-[hsl(var(--color-foreground))]">
Notizen ({wish.notes.length})
</h2>
{#if wish.notes.length > 0}
<ul class="mb-3 space-y-2">
{#each wish.notes as note (note.id)}
<li
class="rounded-md bg-[hsl(var(--color-muted))] p-2.5 text-sm text-[hsl(var(--color-foreground))]"
>
<p>{note.content}</p>
<p class="mt-1 text-[10px] text-[hsl(var(--color-muted-foreground))]">
{new Date(note.createdAt).toLocaleString('de-DE')}
</p>
</li>
{/each}
</ul>
{/if}
<form
onsubmit={(e) => {
e.preventDefault();
addNote();
}}
class="flex gap-2"
>
<input
bind:value={newNote}
placeholder="Notiz hinzufügen..."
class="min-w-0 flex-1 rounded-md border border-[hsl(var(--color-border))] bg-transparent px-3 py-1.5 text-sm outline-none placeholder:text-[hsl(var(--color-muted-foreground))] focus:border-[hsl(var(--color-primary))]"
/>
<button
type="submit"
disabled={!newNote.trim()}
class="flex items-center gap-1 rounded-md bg-[hsl(var(--color-primary))] px-3 py-1.5 text-sm text-[hsl(var(--color-primary-foreground))] disabled:opacity-40"
>
<Plus size={14} />
</button>
</form>
</div>
<!-- Tags -->
{#if wish.tags.length > 0}
<div class="flex flex-wrap gap-1.5">
{#each wish.tags as tag}
<span
class="rounded-full bg-[hsl(var(--color-muted))] px-2.5 py-1 text-xs text-[hsl(var(--color-muted-foreground))]"
>
{tag}
</span>
{/each}
</div>
{/if}
</div>
{/if}

View file

@ -0,0 +1,7 @@
<script lang="ts">
import type { Snippet } from 'svelte';
let { children }: { children: Snippet } = $props();
</script>
{@render children()}

View file

@ -0,0 +1,5 @@
<script lang="ts">
import ListView from '$lib/modules/wishes/ListView.svelte';
</script>
<ListView />

View file

@ -0,0 +1,5 @@
<script lang="ts">
import DetailView from '$lib/modules/wishes/views/DetailView.svelte';
</script>
<DetailView />

View file

@ -26,7 +26,8 @@ export type DragType =
| 'place'
| 'dream'
| 'journal-entry'
| 'first';
| 'first'
| 'wish';
export interface DragPayload<T = Record<string, unknown>> {
type: DragType;