From bf4d9cb9aac8749465499c0b3b0afe86f20d0593 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 28 Mar 2026 16:25:30 +0100 Subject: [PATCH] refactor(go-services): integrate shared-go into crawler + gateway, fix Dockerfiles MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - mana-crawler: config → envutil, handler → httputil.WriteJSON - mana-api-gateway: config → envutil, handlers → httputil.WriteJSON - Fix Dockerfile COPY paths (remove stale -go suffix in all 4 services) - All services now use packages/shared-go via replace directive Co-Authored-By: Claude Opus 4.6 (1M context) --- .../inventar/apps/web/src/lib/data/queries.ts | 288 ++++++++++++++++++ .../web/src/lib/stores/categories.svelte.ts | 108 ++----- .../web/src/lib/stores/collections.svelte.ts | 109 ++----- .../apps/web/src/lib/stores/items.svelte.ts | 269 ++++------------ .../web/src/lib/stores/locations.svelte.ts | 123 ++------ .../apps/web/src/routes/(app)/+layout.svelte | 29 +- .../apps/web/src/routes/(app)/+page.svelte | 22 +- .../src/routes/(app)/categories/+page.svelte | 19 +- .../(app)/collections/[id]/+page.svelte | 20 +- .../(app)/collections/[id]/edit/+page.svelte | 12 +- .../routes/(app)/collections/new/+page.svelte | 4 +- .../web/src/routes/(app)/items/+page.svelte | 15 +- .../src/routes/(app)/items/[id]/+page.svelte | 50 +-- .../src/routes/(app)/locations/+page.svelte | 16 +- .../web/src/routes/(app)/search/+page.svelte | 14 +- .../src/lib/components/DailySummary.svelte | 145 ++++----- .../web/src/lib/components/MealList.svelte | 57 +--- .../nutriphi/apps/web/src/lib/data/queries.ts | 174 +++++++++++ .../apps/web/src/lib/stores/meals.svelte.ts | 202 +++--------- .../apps/web/src/routes/+layout.svelte | 9 +- .../src/lib/components/StatsOverview.svelte | 33 +- .../apps/web/src/lib/data/queries.ts | 186 +++++++++++ .../web/src/lib/stores/achievements.svelte.ts | 203 ++++-------- .../apps/web/src/lib/stores/skills.svelte.ts | 247 +-------------- .../apps/web/src/routes/+layout.svelte | 8 +- .../apps/web/src/routes/+page.svelte | 61 ++-- .../web/src/routes/achievements/+page.svelte | 33 +- .../apps/web/src/routes/tree/+page.svelte | 12 +- services/mana-api-gateway/Dockerfile | 4 +- services/mana-api-gateway/go.mod | 3 + .../internal/config/config.go | 72 ++--- .../internal/handler/apikeys.go | 35 +-- .../internal/handler/health.go | 4 +- services/mana-crawler/Dockerfile | 4 +- services/mana-crawler/go.mod | 3 + .../mana-crawler/internal/config/config.go | 58 ++-- .../mana-crawler/internal/handler/handler.go | 33 +- services/mana-notify/Dockerfile | 4 +- services/mana-search/Dockerfile | 4 +- 39 files changed, 1313 insertions(+), 1379 deletions(-) create mode 100644 apps/inventar/apps/web/src/lib/data/queries.ts create mode 100644 apps/nutriphi/apps/web/src/lib/data/queries.ts create mode 100644 apps/skilltree/apps/web/src/lib/data/queries.ts diff --git a/apps/inventar/apps/web/src/lib/data/queries.ts b/apps/inventar/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..d2eb1bde0 --- /dev/null +++ b/apps/inventar/apps/web/src/lib/data/queries.ts @@ -0,0 +1,288 @@ +/** + * Reactive Queries & Pure Helpers for Inventar + * + * Uses Dexie liveQuery to automatically re-render when IndexedDB changes + * (local writes, sync updates, other tabs). Components call these hooks + * at init time; no manual fetch/refresh needed. + */ + +import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; +import { + collectionCollection, + itemCollection, + locationCollection, + categoryCollection, + type LocalCollection, + type LocalItem, + type LocalLocation, + type LocalCategory, +} from './local-store'; +import type { + Collection, + Item, + Location, + Category, + ItemStatus, + SortOption, + FilterCriteria, +} from '@inventar/shared'; + +// ─── Type Converters ─────────────────────────────────────── + +/** Convert a LocalCollection (IndexedDB) to the shared Collection type. */ +export function toCollection(local: LocalCollection): Collection { + return { + id: local.id, + name: local.name, + description: local.description ?? undefined, + icon: local.icon ?? undefined, + color: local.color ?? undefined, + schema: local.schema, + templateId: local.templateId ?? undefined, + order: local.order, + itemCount: local.itemCount, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +/** Convert a LocalItem (IndexedDB) to the shared Item type. */ +export function toItem(local: LocalItem): Item { + return { + id: local.id, + collectionId: local.collectionId, + locationId: local.locationId ?? undefined, + categoryId: local.categoryId ?? undefined, + name: local.name, + description: local.description ?? undefined, + status: local.status, + quantity: local.quantity, + fieldValues: local.fieldValues, + purchaseData: local.purchaseData ?? undefined, + photos: local.photos, + notes: local.notes, + documents: [], + tags: local.tags, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +/** Convert a LocalLocation (IndexedDB) to the shared Location type. */ +export function toLocation(local: LocalLocation): Location { + return { + id: local.id, + parentId: local.parentId ?? undefined, + name: local.name, + description: local.description ?? undefined, + icon: local.icon ?? undefined, + path: local.path, + depth: local.depth, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +/** Convert a LocalCategory (IndexedDB) to the shared Category type. */ +export function toCategory(local: LocalCategory): Category { + return { + id: local.id, + parentId: local.parentId ?? undefined, + name: local.name, + icon: local.icon ?? undefined, + color: local.color ?? undefined, + order: local.order, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Query Hooks (call during component init) ───────── + +/** All collections. Auto-updates on any change. */ +export function useAllCollections() { + return useLiveQueryWithDefault(async () => { + const locals = await collectionCollection.getAll(); + return locals.map(toCollection); + }, [] as Collection[]); +} + +/** All items. Auto-updates on any change. */ +export function useAllItems() { + return useLiveQueryWithDefault(async () => { + const locals = await itemCollection.getAll(); + return locals.map(toItem); + }, [] as Item[]); +} + +/** All locations. Auto-updates on any change. */ +export function useAllLocations() { + return useLiveQueryWithDefault(async () => { + const locals = await locationCollection.getAll(); + return locals.map(toLocation); + }, [] as Location[]); +} + +/** All categories. Auto-updates on any change. */ +export function useAllCategories() { + return useLiveQueryWithDefault(async () => { + const locals = await categoryCollection.getAll(); + return locals.map(toCategory); + }, [] as Category[]); +} + +// ─── Pure Collection Helpers ────────────────────────────── + +/** Get a collection by ID. */ +export function getCollectionById(collections: Collection[], id: string): Collection | undefined { + return collections.find((c) => c.id === id); +} + +/** Get collections sorted by order. */ +export function getSortedCollections(collections: Collection[]): Collection[] { + return [...collections].sort((a, b) => a.order - b.order); +} + +// ─── Pure Item Helpers ──────────────────────────────────── + +/** Get an item by ID. */ +export function getItemById(items: Item[], id: string): Item | undefined { + return items.find((i) => i.id === id); +} + +/** Get items for a specific collection. */ +export function getItemsByCollection(items: Item[], collectionId: string): Item[] { + return items.filter((i) => i.collectionId === collectionId); +} + +/** Count items for a specific collection. */ +export function getItemCountByCollection(items: Item[], collectionId: string): number { + return items.filter((i) => i.collectionId === collectionId).length; +} + +/** Get total item count. */ +export function getTotalItemCount(items: Item[]): number { + return items.length; +} + +/** Filter items by criteria. */ +export function getFilteredItems(items: Item[], filters: FilterCriteria): Item[] { + let result = items; + + if (filters.collectionId) { + result = result.filter((i) => i.collectionId === filters.collectionId); + } + if (filters.locationId) { + result = result.filter((i) => i.locationId === filters.locationId); + } + if (filters.categoryId) { + result = result.filter((i) => i.categoryId === filters.categoryId); + } + if (filters.status?.length) { + result = result.filter((i) => filters.status!.includes(i.status)); + } + if (filters.tagIds?.length) { + result = result.filter((i) => filters.tagIds!.some((t) => i.tags.includes(t))); + } + if (filters.search) { + const q = filters.search.toLowerCase(); + result = result.filter( + (i) => + i.name.toLowerCase().includes(q) || + i.description?.toLowerCase().includes(q) || + Object.values(i.fieldValues).some((v) => String(v).toLowerCase().includes(q)) + ); + } + + return result; +} + +/** Sort items by a sort option. */ +export function getSortedItems(itemList: Item[], sort: SortOption): Item[] { + return [...itemList].sort((a, b) => { + let cmp = 0; + switch (sort.field) { + case 'name': + cmp = a.name.localeCompare(b.name); + break; + case 'createdAt': + cmp = a.createdAt.localeCompare(b.createdAt); + break; + case 'updatedAt': + cmp = a.updatedAt.localeCompare(b.updatedAt); + break; + case 'status': + cmp = a.status.localeCompare(b.status); + break; + case 'quantity': + cmp = a.quantity - b.quantity; + break; + } + return sort.direction === 'desc' ? -cmp : cmp; + }); +} + +// ─── Pure Location Helpers ──────────────────────────────── + +/** Get a location by ID. */ +export function getLocationById(locations: Location[], id: string): Location | undefined { + return locations.find((l) => l.id === id); +} + +/** Get root locations (no parent). */ +export function getRootLocations(locations: Location[]): Location[] { + return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order); +} + +/** Get children of a location. */ +export function getLocationChildren(locations: Location[], parentId: string): Location[] { + return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order); +} + +/** Build a tree structure from flat locations. */ +export function getLocationTree(locations: Location[]): Location[] { + const buildTree = (parentId?: string): Location[] => { + return locations + .filter((l) => l.parentId === parentId) + .sort((a, b) => a.order - b.order) + .map((l) => ({ ...l, children: buildTree(l.id) })); + }; + return buildTree(undefined); +} + +/** Get full path for a location. */ +export function getLocationFullPath(locations: Location[], id: string): string { + const location = locations.find((l) => l.id === id); + if (!location) return ''; + return location.path ? `${location.path}/${location.name}` : location.name; +} + +// ─── Pure Category Helpers ──────────────────────────────── + +/** Get a category by ID. */ +export function getCategoryById(categories: Category[], id: string): Category | undefined { + return categories.find((c) => c.id === id); +} + +/** Get root categories (no parent). */ +export function getRootCategories(categories: Category[]): Category[] { + return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order); +} + +/** Get children of a category. */ +export function getCategoryChildren(categories: Category[], parentId: string): Category[] { + return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order); +} + +/** Build a tree structure from flat categories. */ +export function getCategoryTree(categories: Category[]): Category[] { + const buildTree = (parentId?: string): Category[] => { + return categories + .filter((c) => c.parentId === parentId) + .sort((a, b) => a.order - b.order) + .map((c) => ({ ...c, children: buildTree(c.id) })); + }; + return buildTree(undefined); +} diff --git a/apps/inventar/apps/web/src/lib/stores/categories.svelte.ts b/apps/inventar/apps/web/src/lib/stores/categories.svelte.ts index 974dd4ff4..37b82620f 100644 --- a/apps/inventar/apps/web/src/lib/stores/categories.svelte.ts +++ b/apps/inventar/apps/web/src/lib/stores/categories.svelte.ts @@ -1,100 +1,44 @@ -import { browser } from '$app/environment'; -import type { Category } from '@inventar/shared'; +/** + * Categories Store — Mutations Only + * + * Reads come from useLiveQuery (see $lib/data/queries.ts). + * This store only handles writes to IndexedDB via local-store. + */ -const STORAGE_KEY = 'inventar_categories'; - -function loadFromStorage(): Category[] { - if (!browser) return []; - try { - const data = localStorage.getItem(STORAGE_KEY); - return data ? JSON.parse(data) : []; - } catch { - return []; - } -} - -function saveToStorage(categories: Category[]) { - if (!browser) return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(categories)); -} - -function generateId(): string { - return crypto.randomUUID(); -} - -let categories = $state([]); -let initialized = $state(false); +import { categoryCollection, type LocalCategory } from '$lib/data/local-store'; +import { toCategory } from '$lib/data/queries'; export const categoriesStore = { - get categories() { - return categories; - }, - get initialized() { - return initialized; - }, + async create(data: { name: string; icon?: string; color?: string; parentId?: string }) { + const all = await categoryCollection.getAll(); + const siblings = all.filter((c) => c.parentId === data.parentId); - initialize() { - if (initialized) return; - categories = loadFromStorage(); - initialized = true; - }, - - getById(id: string): Category | undefined { - return categories.find((c) => c.id === id); - }, - - getRootCategories(): Category[] { - return categories.filter((c) => !c.parentId).sort((a, b) => a.order - b.order); - }, - - getChildren(parentId: string): Category[] { - return categories.filter((c) => c.parentId === parentId).sort((a, b) => a.order - b.order); - }, - - getTree(): Category[] { - const buildTree = (parentId?: string): Category[] => { - return categories - .filter((c) => c.parentId === parentId) - .sort((a, b) => a.order - b.order) - .map((c) => ({ ...c, children: buildTree(c.id) })); - }; - return buildTree(undefined); - }, - - create(data: { name: string; icon?: string; color?: string; parentId?: string }): Category { - const now = new Date().toISOString(); - const siblings = categories.filter((c) => c.parentId === data.parentId); - - const category: Category = { - id: generateId(), - parentId: data.parentId, + const newLocal: LocalCategory = { + id: crypto.randomUUID(), + parentId: data.parentId ?? null, name: data.name, - icon: data.icon, - color: data.color, + icon: data.icon ?? null, + color: data.color ?? null, order: siblings.length, - createdAt: now, - updatedAt: now, }; - categories = [...categories, category]; - saveToStorage(categories); - return category; + const inserted = await categoryCollection.insert(newLocal); + return toCategory(inserted); }, - update(id: string, data: Partial>) { - categories = categories.map((c) => - c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c - ); - saveToStorage(categories); + async update(id: string, data: Partial>) { + await categoryCollection.update(id, data); }, - delete(id: string) { + async delete(id: string) { + const all = await categoryCollection.getAll(); const idsToDelete = new Set(); const collectIds = (parentId: string) => { idsToDelete.add(parentId); - categories.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id)); + all.filter((c) => c.parentId === parentId).forEach((c) => collectIds(c.id)); }; collectIds(id); - categories = categories.filter((c) => !idsToDelete.has(c.id)); - saveToStorage(categories); + for (const deleteId of idsToDelete) { + await categoryCollection.delete(deleteId); + } }, }; diff --git a/apps/inventar/apps/web/src/lib/stores/collections.svelte.ts b/apps/inventar/apps/web/src/lib/stores/collections.svelte.ts index b4d4544f8..44be33b02 100644 --- a/apps/inventar/apps/web/src/lib/stores/collections.svelte.ts +++ b/apps/inventar/apps/web/src/lib/stores/collections.svelte.ts @@ -1,102 +1,57 @@ -import { browser } from '$app/environment'; -import type { Collection, CollectionSchema } from '@inventar/shared'; +/** + * Collections Store — Mutations Only + * + * Reads come from useLiveQuery (see $lib/data/queries.ts). + * This store only handles writes to IndexedDB via local-store. + */ -const STORAGE_KEY = 'inventar_collections'; - -function loadFromStorage(): Collection[] { - if (!browser) return []; - try { - const data = localStorage.getItem(STORAGE_KEY); - return data ? JSON.parse(data) : []; - } catch { - return []; - } -} - -function saveToStorage(collections: Collection[]) { - if (!browser) return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(collections)); -} - -function generateId(): string { - return crypto.randomUUID(); -} - -let collections = $state([]); -let initialized = $state(false); +import type { CollectionSchema } from '@inventar/shared'; +import { collectionCollection, type LocalCollection } from '$lib/data/local-store'; +import { toCollection } from '$lib/data/queries'; export const collectionsStore = { - get collections() { - return collections; - }, - get initialized() { - return initialized; - }, - - initialize() { - if (initialized) return; - collections = loadFromStorage(); - initialized = true; - }, - - getById(id: string): Collection | undefined { - return collections.find((c) => c.id === id); - }, - - create(data: { + async create(data: { name: string; description?: string; icon?: string; color?: string; schema: CollectionSchema; templateId?: string; - }): Collection { - const now = new Date().toISOString(); - const collection: Collection = { - id: generateId(), + }) { + const all = await collectionCollection.getAll(); + const newLocal: LocalCollection = { + id: crypto.randomUUID(), name: data.name, - description: data.description, - icon: data.icon, - color: data.color, + description: data.description ?? null, + icon: data.icon ?? null, + color: data.color ?? null, schema: data.schema, - templateId: data.templateId, - order: collections.length, + templateId: data.templateId ?? null, + order: all.length, itemCount: 0, - createdAt: now, - updatedAt: now, }; - collections = [...collections, collection]; - saveToStorage(collections); - return collection; + const inserted = await collectionCollection.insert(newLocal); + return toCollection(inserted); }, - update( + async update( id: string, - data: Partial> + data: Partial> ) { - collections = collections.map((c) => - c.id === id ? { ...c, ...data, updatedAt: new Date().toISOString() } : c - ); - saveToStorage(collections); + await collectionCollection.update(id, data); }, - delete(id: string) { - collections = collections.filter((c) => c.id !== id); - saveToStorage(collections); + async delete(id: string) { + await collectionCollection.delete(id); }, - reorder(orderedIds: string[]) { - collections = orderedIds - .map((id, index) => { - const c = collections.find((col) => col.id === id); - return c ? { ...c, order: index } : null; - }) - .filter((c): c is Collection => c !== null); - saveToStorage(collections); + async reorder(orderedIds: string[]) { + for (let i = 0; i < orderedIds.length; i++) { + await collectionCollection.update(orderedIds[i], { order: i }); + } }, - updateItemCount(collectionId: string, count: number) { - collections = collections.map((c) => (c.id === collectionId ? { ...c, itemCount: count } : c)); - saveToStorage(collections); + async updateItemCount(collectionId: string, count: number) { + await collectionCollection.update(collectionId, { itemCount: count }); }, }; diff --git a/apps/inventar/apps/web/src/lib/stores/items.svelte.ts b/apps/inventar/apps/web/src/lib/stores/items.svelte.ts index eda2f1327..fd21d78c2 100644 --- a/apps/inventar/apps/web/src/lib/stores/items.svelte.ts +++ b/apps/inventar/apps/web/src/lib/stores/items.svelte.ts @@ -1,122 +1,16 @@ -import { browser } from '$app/environment'; -import type { - Item, - ItemStatus, - ItemNote, - ItemPhoto, - PurchaseData, - SortOption, -} from '@inventar/shared'; +/** + * Items Store — Mutations Only + * + * Reads come from useLiveQuery (see $lib/data/queries.ts). + * This store only handles writes to IndexedDB via local-store. + */ -const STORAGE_KEY = 'inventar_items'; - -function loadFromStorage(): Item[] { - if (!browser) return []; - try { - const data = localStorage.getItem(STORAGE_KEY); - return data ? JSON.parse(data) : []; - } catch { - return []; - } -} - -function saveToStorage(items: Item[]) { - if (!browser) return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(items)); -} - -function generateId(): string { - return crypto.randomUUID(); -} - -let items = $state([]); -let initialized = $state(false); +import type { ItemStatus, PurchaseData, ItemPhoto, ItemNote } from '@inventar/shared'; +import { itemCollection, type LocalItem } from '$lib/data/local-store'; +import { toItem } from '$lib/data/queries'; export const itemsStore = { - get items() { - return items; - }, - get initialized() { - return initialized; - }, - - initialize() { - if (initialized) return; - items = loadFromStorage(); - initialized = true; - }, - - getById(id: string): Item | undefined { - return items.find((i) => i.id === id); - }, - - getByCollection(collectionId: string): Item[] { - return items.filter((i) => i.collectionId === collectionId); - }, - - getFiltered(filters: { - collectionId?: string; - locationId?: string; - categoryId?: string; - status?: ItemStatus[]; - search?: string; - tagIds?: string[]; - }): Item[] { - let result = items; - - if (filters.collectionId) { - result = result.filter((i) => i.collectionId === filters.collectionId); - } - if (filters.locationId) { - result = result.filter((i) => i.locationId === filters.locationId); - } - if (filters.categoryId) { - result = result.filter((i) => i.categoryId === filters.categoryId); - } - if (filters.status?.length) { - result = result.filter((i) => filters.status!.includes(i.status)); - } - if (filters.tagIds?.length) { - result = result.filter((i) => filters.tagIds!.some((t) => i.tags.includes(t))); - } - if (filters.search) { - const q = filters.search.toLowerCase(); - result = result.filter( - (i) => - i.name.toLowerCase().includes(q) || - i.description?.toLowerCase().includes(q) || - Object.values(i.fieldValues).some((v) => String(v).toLowerCase().includes(q)) - ); - } - - return result; - }, - - getSorted(itemList: Item[], sort: SortOption): Item[] { - return [...itemList].sort((a, b) => { - let cmp = 0; - switch (sort.field) { - case 'name': - cmp = a.name.localeCompare(b.name); - break; - case 'createdAt': - cmp = a.createdAt.localeCompare(b.createdAt); - break; - case 'updatedAt': - cmp = a.updatedAt.localeCompare(b.updatedAt); - break; - case 'status': - cmp = a.status.localeCompare(b.status); - break; - case 'quantity': - cmp = a.quantity - b.quantity; - break; - } - return sort.direction === 'desc' ? -cmp : cmp; - }); - }, - - create(data: { + async create(data: { collectionId: string; name: string; description?: string; @@ -127,37 +21,35 @@ export const itemsStore = { fieldValues?: Record; purchaseData?: PurchaseData; tags?: string[]; - }): Item { - const now = new Date().toISOString(); - const item: Item = { - id: generateId(), + }) { + const existing = await itemCollection.getAll(); + const collectionItems = existing.filter((i) => i.collectionId === data.collectionId); + + const newLocal: LocalItem = { + id: crypto.randomUUID(), collectionId: data.collectionId, name: data.name, - description: data.description, + description: data.description ?? null, status: data.status || 'owned', quantity: data.quantity || 1, - locationId: data.locationId, - categoryId: data.categoryId, + locationId: data.locationId ?? null, + categoryId: data.categoryId ?? null, fieldValues: data.fieldValues || {}, - purchaseData: data.purchaseData, + purchaseData: data.purchaseData ?? null, photos: [], notes: [], - documents: [], tags: data.tags || [], - order: items.filter((i) => i.collectionId === data.collectionId).length, - createdAt: now, - updatedAt: now, + order: collectionItems.length, }; - items = [...items, item]; - saveToStorage(items); - return item; + const inserted = await itemCollection.insert(newLocal); + return toItem(inserted); }, - update( + async update( id: string, data: Partial< Pick< - Item, + LocalItem, | 'name' | 'description' | 'status' @@ -170,91 +62,62 @@ export const itemsStore = { > > ) { - items = items.map((i) => - i.id === id ? { ...i, ...data, updatedAt: new Date().toISOString() } : i - ); - saveToStorage(items); + await itemCollection.update(id, data); }, - delete(id: string) { - items = items.filter((i) => i.id !== id); - saveToStorage(items); + async delete(id: string) { + await itemCollection.delete(id); }, - deleteByCollection(collectionId: string) { - items = items.filter((i) => i.collectionId !== collectionId); - saveToStorage(items); + async deleteByCollection(collectionId: string) { + const all = await itemCollection.getAll(); + const toDelete = all.filter((i) => i.collectionId === collectionId); + for (const item of toDelete) { + await itemCollection.delete(item.id); + } }, - addNote(itemId: string, content: string) { + async addNote(itemId: string, content: string) { + const item = await itemCollection.get(itemId); + if (!item) return; const now = new Date().toISOString(); - const note: ItemNote = { id: generateId(), content, createdAt: now, updatedAt: now }; - items = items.map((i) => - i.id === itemId ? { ...i, notes: [...i.notes, note], updatedAt: now } : i - ); - saveToStorage(items); + const note: ItemNote = { id: crypto.randomUUID(), content, createdAt: now, updatedAt: now }; + await itemCollection.update(itemId, { + notes: [...item.notes, note], + }); }, - updateNote(itemId: string, noteId: string, content: string) { + async updateNote(itemId: string, noteId: string, content: string) { + const item = await itemCollection.get(itemId); + if (!item) return; const now = new Date().toISOString(); - items = items.map((i) => - i.id === itemId - ? { - ...i, - notes: i.notes.map((n) => (n.id === noteId ? { ...n, content, updatedAt: now } : n)), - updatedAt: now, - } - : i - ); - saveToStorage(items); + await itemCollection.update(itemId, { + notes: item.notes.map((n) => (n.id === noteId ? { ...n, content, updatedAt: now } : n)), + }); }, - deleteNote(itemId: string, noteId: string) { - items = items.map((i) => - i.id === itemId - ? { - ...i, - notes: i.notes.filter((n) => n.id !== noteId), - updatedAt: new Date().toISOString(), - } - : i - ); - saveToStorage(items); + async deleteNote(itemId: string, noteId: string) { + const item = await itemCollection.get(itemId); + if (!item) return; + await itemCollection.update(itemId, { + notes: item.notes.filter((n) => n.id !== noteId), + }); }, - addPhoto(itemId: string, photo: Omit) { - const item = items.find((i) => i.id === itemId); - const newPhoto: ItemPhoto = { ...photo, id: generateId(), order: item?.photos.length || 0 }; - items = items.map((i) => - i.id === itemId - ? { ...i, photos: [...i.photos, newPhoto], updatedAt: new Date().toISOString() } - : i - ); - saveToStorage(items); + async addPhoto(itemId: string, photo: Omit) { + const item = await itemCollection.get(itemId); + if (!item) return; + const newPhoto: ItemPhoto = { ...photo, id: crypto.randomUUID(), order: item.photos.length }; + await itemCollection.update(itemId, { + photos: [...item.photos, newPhoto], + }); }, - deletePhoto(itemId: string, photoId: string) { - items = items.map((i) => - i.id === itemId - ? { - ...i, - photos: i.photos.filter((p) => p.id !== photoId), - updatedAt: new Date().toISOString(), - } - : i - ); - saveToStorage(items); - }, - - getCountByCollection(collectionId: string): number { - return items.filter((i) => i.collectionId === collectionId).length; - }, - - getTotalCount(): number { - return items.length; - }, - - getCountByStatus(status: ItemStatus): number { - return items.filter((i) => i.status === status).length; + async deletePhoto(itemId: string, photoId: string) { + const item = await itemCollection.get(itemId); + if (!item) return; + await itemCollection.update(itemId, { + photos: item.photos.filter((p) => p.id !== photoId), + }); }, }; diff --git a/apps/inventar/apps/web/src/lib/stores/locations.svelte.ts b/apps/inventar/apps/web/src/lib/stores/locations.svelte.ts index a54db3084..f4806e158 100644 --- a/apps/inventar/apps/web/src/lib/stores/locations.svelte.ts +++ b/apps/inventar/apps/web/src/lib/stores/locations.svelte.ts @@ -1,124 +1,61 @@ -import { browser } from '$app/environment'; -import type { Location } from '@inventar/shared'; +/** + * Locations Store — Mutations Only + * + * Reads come from useLiveQuery (see $lib/data/queries.ts). + * This store only handles writes to IndexedDB via local-store. + */ -const STORAGE_KEY = 'inventar_locations'; +import { locationCollection, type LocalLocation } from '$lib/data/local-store'; +import { toLocation } from '$lib/data/queries'; -function loadFromStorage(): Location[] { - if (!browser) return []; - try { - const data = localStorage.getItem(STORAGE_KEY); - return data ? JSON.parse(data) : []; - } catch { - return []; - } -} - -function saveToStorage(locations: Location[]) { - if (!browser) return; - localStorage.setItem(STORAGE_KEY, JSON.stringify(locations)); -} - -function generateId(): string { - return crypto.randomUUID(); -} - -function buildPath(locations: Location[], parentId?: string): string { +function buildPath(locations: LocalLocation[], parentId?: string): string { if (!parentId) return ''; const parent = locations.find((l) => l.id === parentId); if (!parent) return ''; return parent.path ? `${parent.path}/${parent.name}` : parent.name; } -function getDepth(locations: Location[], parentId?: string): number { +function getDepth(locations: LocalLocation[], parentId?: string): number { if (!parentId) return 0; const parent = locations.find((l) => l.id === parentId); return parent ? parent.depth + 1 : 0; } -let locations = $state([]); -let initialized = $state(false); - export const locationsStore = { - get locations() { - return locations; - }, - get initialized() { - return initialized; - }, + async create(data: { name: string; description?: string; icon?: string; parentId?: string }) { + const all = await locationCollection.getAll(); + const path = buildPath(all, data.parentId); + const depth = getDepth(all, data.parentId); + const siblings = all.filter((l) => l.parentId === data.parentId); - initialize() { - if (initialized) return; - locations = loadFromStorage(); - initialized = true; - }, - - getById(id: string): Location | undefined { - return locations.find((l) => l.id === id); - }, - - getRootLocations(): Location[] { - return locations.filter((l) => !l.parentId).sort((a, b) => a.order - b.order); - }, - - getChildren(parentId: string): Location[] { - return locations.filter((l) => l.parentId === parentId).sort((a, b) => a.order - b.order); - }, - - getTree(): Location[] { - const buildTree = (parentId?: string): Location[] => { - return locations - .filter((l) => l.parentId === parentId) - .sort((a, b) => a.order - b.order) - .map((l) => ({ ...l, children: buildTree(l.id) })); - }; - return buildTree(undefined); - }, - - getFullPath(id: string): string { - const location = locations.find((l) => l.id === id); - if (!location) return ''; - return location.path ? `${location.path}/${location.name}` : location.name; - }, - - create(data: { name: string; description?: string; icon?: string; parentId?: string }): Location { - const now = new Date().toISOString(); - const path = buildPath(locations, data.parentId); - const depth = getDepth(locations, data.parentId); - const siblings = locations.filter((l) => l.parentId === data.parentId); - - const location: Location = { - id: generateId(), - parentId: data.parentId, + const newLocal: LocalLocation = { + id: crypto.randomUUID(), + parentId: data.parentId ?? null, name: data.name, - description: data.description, - icon: data.icon, + description: data.description ?? null, + icon: data.icon ?? null, path, depth, order: siblings.length, - createdAt: now, - updatedAt: now, }; - locations = [...locations, location]; - saveToStorage(locations); - return location; + const inserted = await locationCollection.insert(newLocal); + return toLocation(inserted); }, - update(id: string, data: Partial>) { - locations = locations.map((l) => - l.id === id ? { ...l, ...data, updatedAt: new Date().toISOString() } : l - ); - saveToStorage(locations); + async update(id: string, data: Partial>) { + await locationCollection.update(id, data); }, - delete(id: string) { - // Delete location and all children + async delete(id: string) { + const all = await locationCollection.getAll(); const idsToDelete = new Set(); const collectIds = (parentId: string) => { idsToDelete.add(parentId); - locations.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id)); + all.filter((l) => l.parentId === parentId).forEach((l) => collectIds(l.id)); }; collectIds(id); - locations = locations.filter((l) => !idsToDelete.has(l.id)); - saveToStorage(locations); + for (const deleteId of idsToDelete) { + await locationCollection.delete(deleteId); + } }, }; diff --git a/apps/inventar/apps/web/src/routes/(app)/+layout.svelte b/apps/inventar/apps/web/src/routes/(app)/+layout.svelte index 51ae0cc05..d68a51df5 100644 --- a/apps/inventar/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/inventar/apps/web/src/routes/(app)/+layout.svelte @@ -2,11 +2,8 @@ import { goto } from '$app/navigation'; import { page } from '$app/stores'; import { _ } from 'svelte-i18n'; + import { setContext } from 'svelte'; import { authStore } from '$lib/stores/auth.svelte'; - import { collectionsStore } from '$lib/stores/collections.svelte'; - import { itemsStore } from '$lib/stores/items.svelte'; - import { locationsStore } from '$lib/stores/locations.svelte'; - import { categoriesStore } from '$lib/stores/categories.svelte'; import { viewStore } from '$lib/stores/view.svelte'; import { theme } from '$lib/stores/theme'; import { setLocale, supportedLocales } from '$lib/i18n'; @@ -15,6 +12,12 @@ import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui'; import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui'; import { inventarStore } from '$lib/data/local-store'; + import { + useAllCollections, + useAllItems, + useAllLocations, + useAllCategories, + } from '$lib/data/queries'; let { children } = $props(); @@ -22,6 +25,18 @@ let initialized = $state(false); let showGuestWelcome = $state(false); + // Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs) + const allCollections = useAllCollections(); + const allItems = useAllItems(); + const allLocations = useAllLocations(); + const allCategories = useAllCategories(); + + // Provide data to child components via Svelte context + setContext('collections', allCollections); + setContext('items', allItems); + setContext('locations', allLocations); + setContext('categories', allCategories); + async function handleAuthReady() { // Initialize local-first database await inventarStore.initialize(); @@ -31,11 +46,7 @@ inventarStore.startSync(() => authStore.getValidToken()); } - // Initialize legacy localStorage stores (will be migrated to IndexedDB later) - collectionsStore.initialize(); - itemsStore.initialize(); - locationsStore.initialize(); - categoriesStore.initialize(); + // Initialize view preferences (still localStorage-based, not data) viewStore.initialize(); initialized = true; diff --git a/apps/inventar/apps/web/src/routes/(app)/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/+page.svelte index 250c9ead6..b4f25f823 100644 --- a/apps/inventar/apps/web/src/routes/(app)/+page.svelte +++ b/apps/inventar/apps/web/src/routes/(app)/+page.svelte @@ -1,12 +1,21 @@ @@ -51,7 +61,7 @@ - {#if collectionsStore.collections.length === 0} + {#if sortedCollections.length === 0}
@@ -71,7 +81,7 @@
{:else}
- {#each collectionsStore.collections.sort((a, b) => a.order - b.order) as collection (collection.id)} + {#each sortedCollections as collection (collection.id)}
import { _ } from 'svelte-i18n'; + import { getContext } from 'svelte'; import { categoriesStore } from '$lib/stores/categories.svelte'; import type { Category } from '@inventar/shared'; + const categoriesCtx: { readonly value: Category[] } = getContext('categories'); + let showForm = $state(false); let editingId = $state(null); let name = $state(''); @@ -25,16 +28,16 @@ showForm = true; } - function save() { + async function save() { if (!name.trim()) return; if (editingId) { - categoriesStore.update(editingId, { + await categoriesStore.update(editingId, { name: name.trim(), icon: icon || undefined, color: color || undefined, }); } else { - categoriesStore.create({ + await categoriesStore.create({ name: name.trim(), icon: icon || undefined, color: color || undefined, @@ -43,12 +46,14 @@ showForm = false; } - function deleteCategory(id: string) { + async function deleteCategory(id: string) { if (confirm('Kategorie löschen?')) { - categoriesStore.delete(id); + await categoriesStore.delete(id); } } + let sortedCategories = $derived([...categoriesCtx.value].sort((a, b) => a.order - b.order)); + const inputClass = 'rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]'; @@ -108,7 +113,7 @@
{/if} - {#if categoriesStore.categories.length === 0} + {#if sortedCategories.length === 0}
@@ -117,7 +122,7 @@
{:else}
- {#each categoriesStore.categories.sort((a, b) => a.order - b.order) as category (category.id)} + {#each sortedCategories as category (category.id)}
diff --git a/apps/inventar/apps/web/src/routes/(app)/collections/[id]/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/collections/[id]/+page.svelte index 0fa022406..89a581d63 100644 --- a/apps/inventar/apps/web/src/routes/(app)/collections/[id]/+page.svelte +++ b/apps/inventar/apps/web/src/routes/(app)/collections/[id]/+page.svelte @@ -2,21 +2,23 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { _ } from 'svelte-i18n'; - import { collectionsStore } from '$lib/stores/collections.svelte'; + import { getContext } from 'svelte'; import { itemsStore } from '$lib/stores/items.svelte'; import { viewStore } from '$lib/stores/view.svelte'; - import { locationsStore } from '$lib/stores/locations.svelte'; - import { categoriesStore } from '$lib/stores/categories.svelte'; - import type { Item, ItemStatus } from '@inventar/shared'; + import { getCollectionById, getItemsByCollection, getSortedItems } from '$lib/data/queries'; + import type { Collection, Item, ItemStatus } from '@inventar/shared'; import FieldRenderer from '$lib/components/fields/FieldRenderer.svelte'; import FieldEditor from '$lib/components/fields/FieldEditor.svelte'; import StatusBadge from '$lib/components/StatusBadge.svelte'; import ViewModeToggle from '$lib/components/ViewModeToggle.svelte'; + const collectionsCtx: { readonly value: Collection[] } = getContext('collections'); + const itemsCtx: { readonly value: Item[] } = getContext('items'); + let collectionId = $derived($page.params.id); - let collection = $derived(collectionsStore.getById(collectionId)); - let items = $derived(itemsStore.getByCollection(collectionId)); - let sortedItems = $derived(itemsStore.getSorted(items, viewStore.sort)); + let collection = $derived(getCollectionById(collectionsCtx.value, collectionId)); + let items = $derived(getItemsByCollection(itemsCtx.value, collectionId)); + let sortedItems = $derived(getSortedItems(items, viewStore.sort)); // Item creation let showNewItem = $state(false); @@ -24,9 +26,9 @@ let newItemFields = $state>({}); let newItemStatus = $state('owned'); - function createItem() { + async function createItem() { if (!newItemName.trim() || !collection) return; - itemsStore.create({ + await itemsStore.create({ collectionId: collection.id, name: newItemName.trim(), status: newItemStatus, diff --git a/apps/inventar/apps/web/src/routes/(app)/collections/[id]/edit/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/collections/[id]/edit/+page.svelte index d594f806f..782715d88 100644 --- a/apps/inventar/apps/web/src/routes/(app)/collections/[id]/edit/+page.svelte +++ b/apps/inventar/apps/web/src/routes/(app)/collections/[id]/edit/+page.svelte @@ -2,12 +2,16 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { _ } from 'svelte-i18n'; + import { getContext } from 'svelte'; import { collectionsStore } from '$lib/stores/collections.svelte'; - import type { CollectionSchema } from '@inventar/shared'; + import { getCollectionById } from '$lib/data/queries'; + import type { Collection, CollectionSchema } from '@inventar/shared'; import SchemaEditor from '$lib/components/fields/SchemaEditor.svelte'; + const collectionsCtx: { readonly value: Collection[] } = getContext('collections'); + let collectionId = $derived($page.params.id); - let collection = $derived(collectionsStore.getById(collectionId)); + let collection = $derived(getCollectionById(collectionsCtx.value, collectionId)); let name = $state(''); let description = $state(''); @@ -25,9 +29,9 @@ } }); - function handleSave() { + async function handleSave() { if (!collection || !name.trim()) return; - collectionsStore.update(collection.id, { + await collectionsStore.update(collection.id, { name: name.trim(), description: description.trim() || undefined, icon: icon || undefined, diff --git a/apps/inventar/apps/web/src/routes/(app)/collections/new/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/collections/new/+page.svelte index 3fb424a55..fdd2d0d68 100644 --- a/apps/inventar/apps/web/src/routes/(app)/collections/new/+page.svelte +++ b/apps/inventar/apps/web/src/routes/(app)/collections/new/+page.svelte @@ -23,9 +23,9 @@ step = 'details'; } - function handleCreate() { + async function handleCreate() { if (!name.trim()) return; - collectionsStore.create({ + await collectionsStore.create({ name: name.trim(), description: description.trim() || undefined, icon: icon || undefined, diff --git a/apps/inventar/apps/web/src/routes/(app)/items/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/items/+page.svelte index bc0c2ff7e..c18586eed 100644 --- a/apps/inventar/apps/web/src/routes/(app)/items/+page.svelte +++ b/apps/inventar/apps/web/src/routes/(app)/items/+page.svelte @@ -1,22 +1,25 @@ diff --git a/apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte index 397dc8ece..32ea759c0 100644 --- a/apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte +++ b/apps/inventar/apps/web/src/routes/(app)/items/[id]/+page.svelte @@ -2,18 +2,30 @@ import { page } from '$app/stores'; import { goto } from '$app/navigation'; import { _ } from 'svelte-i18n'; + import { getContext } from 'svelte'; import { itemsStore } from '$lib/stores/items.svelte'; - import { collectionsStore } from '$lib/stores/collections.svelte'; - import { locationsStore } from '$lib/stores/locations.svelte'; - import { categoriesStore } from '$lib/stores/categories.svelte'; - import type { ItemStatus } from '@inventar/shared'; + import { + getItemById, + getCollectionById, + getLocationById, + getLocationFullPath, + getCategoryById, + } from '$lib/data/queries'; + import type { Collection, Item, Location, Category, ItemStatus } from '@inventar/shared'; import FieldRenderer from '$lib/components/fields/FieldRenderer.svelte'; import FieldEditor from '$lib/components/fields/FieldEditor.svelte'; import StatusBadge from '$lib/components/StatusBadge.svelte'; + const collectionsCtx: { readonly value: Collection[] } = getContext('collections'); + const itemsCtx: { readonly value: Item[] } = getContext('items'); + const locationsCtx: { readonly value: Location[] } = getContext('locations'); + const categoriesCtx: { readonly value: Category[] } = getContext('categories'); + let itemId = $derived($page.params.id); - let item = $derived(itemsStore.getById(itemId)); - let collection = $derived(item ? collectionsStore.getById(item.collectionId) : undefined); + let item = $derived(getItemById(itemsCtx.value, itemId)); + let collection = $derived( + item ? getCollectionById(collectionsCtx.value, item.collectionId) : undefined + ); let editing = $state(false); let editName = $state(''); @@ -41,9 +53,9 @@ editing = true; } - function saveEdit() { + async function saveEdit() { if (!item || !editName.trim()) return; - itemsStore.update(item.id, { + await itemsStore.update(item.id, { name: editName.trim(), description: editDescription.trim() || undefined, status: editStatus, @@ -55,15 +67,15 @@ editing = false; } - function addNote() { + async function addNote() { if (!item || !newNote.trim()) return; - itemsStore.addNote(item.id, newNote.trim()); + await itemsStore.addNote(item.id, newNote.trim()); newNote = ''; } - function deleteItem() { + async function deleteItem() { if (!item || !confirm('Item endgültig löschen?')) return; - itemsStore.delete(item.id); + await itemsStore.delete(item.id); goto(collection ? `/collections/${collection.id}` : '/items'); } @@ -165,27 +177,27 @@ >
- {#if locationsStore.locations.length > 0} + {#if locationsCtx.value.length > 0}
{/if} - {#if categoriesStore.categories.length > 0} + {#if categoriesCtx.value.length > 0}
@@ -227,15 +239,15 @@ > {/if} {#if item.locationId} - {@const loc = locationsStore.getById(item.locationId)} + {@const loc = getLocationById(locationsCtx.value, item.locationId)} {#if loc} - 📍 {locationsStore.getFullPath(loc.id)} + 📍 {getLocationFullPath(locationsCtx.value, loc.id)} {/if} {/if} {#if item.categoryId} - {@const cat = categoriesStore.getById(item.categoryId)} + {@const cat = getCategoryById(categoriesCtx.value, item.categoryId)} {#if cat} {cat.icon || '🏷️'} {cat.name} import { _ } from 'svelte-i18n'; + import { getContext } from 'svelte'; import { locationsStore } from '$lib/stores/locations.svelte'; + import { getLocationTree } from '$lib/data/queries'; import type { Location } from '@inventar/shared'; + const locationsCtx: { readonly value: Location[] } = getContext('locations'); + let showForm = $state(false); let editingId = $state(null); let parentId = $state(); @@ -25,12 +29,12 @@ showForm = true; } - function save() { + async function save() { if (!name.trim()) return; if (editingId) { - locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined }); + await locationsStore.update(editingId, { name: name.trim(), icon: icon || undefined }); } else { - locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId }); + await locationsStore.create({ name: name.trim(), icon: icon || undefined, parentId }); } showForm = false; name = ''; @@ -38,13 +42,13 @@ editingId = null; } - function deleteLocation(id: string) { + async function deleteLocation(id: string) { if (confirm('Standort und alle Unterstandorte löschen?')) { - locationsStore.delete(id); + await locationsStore.delete(id); } } - let tree = $derived(locationsStore.getTree()); + let tree = $derived(getLocationTree(locationsCtx.value)); const inputClass = 'rounded-lg border border-[hsl(var(--border))] bg-[hsl(var(--input))] px-3 py-2 text-sm text-[hsl(var(--foreground))] focus:outline-none focus:ring-2 focus:ring-[hsl(var(--primary))]'; diff --git a/apps/inventar/apps/web/src/routes/(app)/search/+page.svelte b/apps/inventar/apps/web/src/routes/(app)/search/+page.svelte index 44fa10855..17dff0ae7 100644 --- a/apps/inventar/apps/web/src/routes/(app)/search/+page.svelte +++ b/apps/inventar/apps/web/src/routes/(app)/search/+page.svelte @@ -1,12 +1,18 @@ @@ -53,7 +59,7 @@

- {collectionsStore.getById(item.collectionId)?.name || ''} + {getCollectionById(collectionsCtx.value, item.collectionId)?.name || ''}

diff --git a/apps/nutriphi/apps/web/src/lib/components/DailySummary.svelte b/apps/nutriphi/apps/web/src/lib/components/DailySummary.svelte index 9b0d0e9a6..ea57d8ce8 100644 --- a/apps/nutriphi/apps/web/src/lib/components/DailySummary.svelte +++ b/apps/nutriphi/apps/web/src/lib/components/DailySummary.svelte @@ -1,20 +1,15 @@
@@ -26,88 +21,64 @@
- {#if mealsStore.summaryError} -
+
+ - - {mealsStore.summaryError} - -
- {:else if mealsStore.summaryLoading} -
-
-
- {#each [1, 2, 3] as _} -
-
-
-
- {/each} +
+
+ {progress?.calories?.current ?? 0} +
+
+ / {progress?.calories?.target ?? 2000} +
-
- {:else} - -
- -
-
- {progress?.calories?.current ?? 0} -
-
- / {progress?.calories?.target ?? 2000} -
-
-
+ - -
-
-
- {progress?.protein?.current ?? 0}g -
-
Protein
-
-
-
+ +
+
+
+ {progress?.protein?.current ?? 0}g
- -
-
- {progress?.carbs?.current ?? 0}g -
-
Carbs
-
-
-
+
Protein
+
+
+
-
-
- {progress?.fat?.current ?? 0}g -
-
Fett
-
-
-
+
+
+ {progress?.carbs?.current ?? 0}g +
+
Carbs
+
+
+
+
+ +
+
+ {progress?.fat?.current ?? 0}g +
+
Fett
+
+
- {/if} +
diff --git a/apps/nutriphi/apps/web/src/lib/components/MealList.svelte b/apps/nutriphi/apps/web/src/lib/components/MealList.svelte index e811cbc34..31050450e 100644 --- a/apps/nutriphi/apps/web/src/lib/components/MealList.svelte +++ b/apps/nutriphi/apps/web/src/lib/components/MealList.svelte @@ -1,74 +1,49 @@
- {#if mealsStore.error} -
- - {mealsStore.error} - -
- {/if} - - {#if mealsStore.deleteError} + {#if deleteError}
- - {mealsStore.deleteError} + {deleteError}
{/if} - {#if mealsStore.loading} -
Laden...
- {:else if !mealsStore.error && mealsStore.meals.length === 0} + {#if todaysMeals.length === 0}

Noch keine Mahlzeiten heute

- Tippe auf + um deine erste Mahlzeit hinzuzufügen + Tippe auf + um deine erste Mahlzeit hinzuzufugen

{:else} - {#each mealsStore.meals as meal (meal.id)} + {#each todaysMeals as meal (meal.id)}
diff --git a/apps/nutriphi/apps/web/src/lib/data/queries.ts b/apps/nutriphi/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..e29819f20 --- /dev/null +++ b/apps/nutriphi/apps/web/src/lib/data/queries.ts @@ -0,0 +1,174 @@ +/** + * Reactive Queries & Pure Helpers for NutriPhi + * + * Uses Dexie liveQuery to automatically re-render when IndexedDB changes + * (local writes, sync updates, other tabs). Components call these hooks + * at init time; no manual fetch/refresh needed. + */ + +import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; +import { + mealCollection, + goalCollection, + favoriteCollection, + type LocalMeal, + type LocalGoal, + type LocalFavorite, +} from './local-store'; +import type { Meal, MealNutrition, DailySummary, NutritionProgress } from '@nutriphi/shared'; + +// ─── Extended Types ──────────────────────────────────────── + +export interface MealWithNutrition extends Meal { + nutrition: MealNutrition | null; +} + +// ─── Type Converters ─────────────────────────────────────── + +export function toMealWithNutrition(local: LocalMeal): MealWithNutrition { + return { + id: local.id, + userId: 'local', + date: new Date(local.date), + mealType: local.mealType as any, + inputType: local.inputType as any, + description: local.description, + portionSize: local.portionSize ?? undefined, + confidence: local.confidence, + createdAt: new Date(local.createdAt ?? Date.now()), + nutrition: local.nutrition + ? { + id: local.id, + mealId: local.id, + calories: local.nutrition.calories, + protein: local.nutrition.protein, + carbohydrates: local.nutrition.carbohydrates, + fat: local.nutrition.fat, + fiber: local.nutrition.fiber, + sugar: local.nutrition.sugar, + } + : null, + } as MealWithNutrition; +} + +// ─── Live Query Hooks (call during component init) ───────── + +/** All meals, auto-updates on any change. */ +export function useAllMeals() { + return useLiveQueryWithDefault(async () => { + const locals = await mealCollection.getAll(); + return locals.map(toMealWithNutrition); + }, [] as MealWithNutrition[]); +} + +/** All goals, auto-updates on any change. */ +export function useAllGoals() { + return useLiveQueryWithDefault(async () => { + return await goalCollection.getAll(); + }, [] as LocalGoal[]); +} + +/** All favorites, auto-updates on any change. */ +export function useAllFavorites() { + return useLiveQueryWithDefault(async () => { + return await favoriteCollection.getAll(); + }, [] as LocalFavorite[]); +} + +// ─── Pure Filter/Helper Functions (for $derived) ────────── + +/** Filter meals for a specific date string (YYYY-MM-DD). */ +export function filterByDate(meals: MealWithNutrition[], dateStr: string): MealWithNutrition[] { + return meals.filter((m) => { + const mealDate = + m.date instanceof Date ? m.date.toISOString().split('T')[0] : String(m.date).split('T')[0]; + return mealDate === dateStr; + }); +} + +/** Get today's date as YYYY-MM-DD string. */ +export function getTodayStr(): string { + return new Date().toISOString().split('T')[0]; +} + +/** Filter meals for today, sorted by creation time. */ +export function getTodaysMeals(meals: MealWithNutrition[]): MealWithNutrition[] { + const today = getTodayStr(); + return filterByDate(meals, today).sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); +} + +/** Sum nutrition values across a set of meals. */ +export function sumNutrition(meals: MealWithNutrition[]): { + calories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber: number; + sugar: number; +} { + return meals.reduce( + (acc, m) => ({ + calories: acc.calories + (m.nutrition?.calories || 0), + protein: acc.protein + (m.nutrition?.protein || 0), + carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0), + fat: acc.fat + (m.nutrition?.fat || 0), + fiber: acc.fiber + (m.nutrition?.fiber || 0), + sugar: acc.sugar + (m.nutrition?.sugar || 0), + }), + { calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 } + ); +} + +/** Build a DailySummary from meals for a given date. */ +export function getDailySummary( + meals: MealWithNutrition[], + date?: Date, + goals?: LocalGoal | null +): DailySummary { + const dateStr = (date || new Date()).toISOString().split('T')[0]; + const dayMeals = filterByDate(meals, dateStr); + const totalNutrition = sumNutrition(dayMeals); + + const calorieTarget = goals?.dailyCalories ?? 2000; + const proteinTarget = goals?.dailyProtein ?? 50; + const carbsTarget = goals?.dailyCarbs ?? 250; + const fatTarget = goals?.dailyFat ?? 65; + + const progress: NutritionProgress = { + calories: { + current: Math.round(totalNutrition.calories), + target: calorieTarget, + percentage: Math.min(Math.round((totalNutrition.calories / calorieTarget) * 100), 100), + }, + protein: { + current: Math.round(totalNutrition.protein), + target: proteinTarget, + percentage: Math.min(Math.round((totalNutrition.protein / proteinTarget) * 100), 100), + }, + carbs: { + current: Math.round(totalNutrition.carbohydrates), + target: carbsTarget, + percentage: Math.min(Math.round((totalNutrition.carbohydrates / carbsTarget) * 100), 100), + }, + fat: { + current: Math.round(totalNutrition.fat), + target: fatTarget, + percentage: Math.min(Math.round((totalNutrition.fat / fatTarget) * 100), 100), + }, + }; + + return { + date: new Date(dateStr), + meals: dayMeals, + totalNutrition, + progress, + } as DailySummary; +} + +/** Search meals by description. */ +export function searchMeals(meals: MealWithNutrition[], query: string): MealWithNutrition[] { + const q = query.toLowerCase(); + return meals.filter((m) => m.description?.toLowerCase().includes(q)); +} diff --git a/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts b/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts index 2c8970621..0642146da 100644 --- a/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts +++ b/apps/nutriphi/apps/web/src/lib/stores/meals.svelte.ts @@ -1,168 +1,56 @@ /** - * Meals Store — Local-First with @manacore/local-store + * Meals Store — Write Actions Only * - * All reads and writes go to IndexedDB first. - * When authenticated, changes sync to the server in the background. + * Reads are handled by useLiveQuery hooks in queries.ts. + * This store only exposes mutation actions that write to IndexedDB. */ import { mealCollection, type LocalMeal } from '$lib/data/local-store'; -import type { Meal, MealNutrition, DailySummary } from '@nutriphi/shared'; import { NutriPhiEvents } from '@manacore/shared-utils/analytics'; -interface MealWithNutrition extends Meal { - nutrition: MealNutrition | null; +// ─── Actions ───────────────────────────────────────────────── + +async function addMeal(mealData: { + date: string; + mealType: string; + inputType: string; + description: string; + confidence: number; + calories: number; + protein: number; + carbohydrates: number; + fat: number; + fiber?: number; + sugar?: number; +}) { + const newMeal: LocalMeal = { + id: crypto.randomUUID(), + date: mealData.date, + mealType: mealData.mealType as any, + inputType: mealData.inputType as any, + description: mealData.description, + confidence: mealData.confidence, + nutrition: { + calories: mealData.calories, + protein: mealData.protein, + carbohydrates: mealData.carbohydrates, + fat: mealData.fat, + fiber: mealData.fiber || 0, + sugar: mealData.sugar || 0, + }, + }; + + const inserted = await mealCollection.insert(newMeal); + NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType); + return inserted; } -function toMealWithNutrition(local: LocalMeal): MealWithNutrition { - return { - id: local.id, - userId: 'local', - date: new Date(local.date), - mealType: local.mealType as any, - inputType: local.inputType as any, - description: local.description, - portionSize: local.portionSize ?? undefined, - confidence: local.confidence, - createdAt: new Date(local.createdAt ?? Date.now()), - nutrition: local.nutrition - ? { - id: local.id, - mealId: local.id, - calories: local.nutrition.calories, - protein: local.nutrition.protein, - carbohydrates: local.nutrition.carbohydrates, - fat: local.nutrition.fat, - fiber: local.nutrition.fiber, - sugar: local.nutrition.sugar, - } - : null, - } as MealWithNutrition; +async function deleteMeal(mealId: string) { + await mealCollection.delete(mealId); + NutriPhiEvents.mealDeleted(); } -class MealsStore { - meals = $state([]); - loading = $state(false); - error = $state(null); - dailySummary = $state(null); - summaryLoading = $state(false); - summaryError = $state(null); - deleteError = $state(null); - - async fetchTodaysMeals() { - this.loading = true; - this.error = null; - try { - const today = new Date().toISOString().split('T')[0]; - const allMeals = await mealCollection.getAll(); - this.meals = allMeals - .filter((m) => m.date === today) - .map(toMealWithNutrition) - .sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - } catch (err) { - this.error = err instanceof Error ? err.message : 'Mahlzeiten konnten nicht geladen werden'; - } finally { - this.loading = false; - } - } - - async fetchDailySummary(date?: Date) { - this.summaryLoading = true; - this.summaryError = null; - try { - const dateStr = (date || new Date()).toISOString().split('T')[0]; - const allMeals = await mealCollection.getAll(); - const dayMeals = allMeals.filter((m) => m.date === dateStr); - - const totalNutrition = dayMeals.reduce( - (acc, m) => ({ - calories: acc.calories + (m.nutrition?.calories || 0), - protein: acc.protein + (m.nutrition?.protein || 0), - carbohydrates: acc.carbohydrates + (m.nutrition?.carbohydrates || 0), - fat: acc.fat + (m.nutrition?.fat || 0), - fiber: acc.fiber + (m.nutrition?.fiber || 0), - sugar: acc.sugar + (m.nutrition?.sugar || 0), - }), - { calories: 0, protein: 0, carbohydrates: 0, fat: 0, fiber: 0, sugar: 0 } - ); - - this.dailySummary = { - date: new Date(dateStr), - meals: dayMeals.map(toMealWithNutrition), - totalNutrition, - } as DailySummary; - } catch (err) { - this.summaryError = - err instanceof Error ? err.message : 'Zusammenfassung konnte nicht geladen werden'; - } finally { - this.summaryLoading = false; - } - } - - async addMeal(mealData: { - date: string; - mealType: string; - inputType: string; - description: string; - confidence: number; - calories: number; - protein: number; - carbohydrates: number; - fat: number; - fiber?: number; - sugar?: number; - }) { - this.error = null; - try { - const newMeal: LocalMeal = { - id: crypto.randomUUID(), - date: mealData.date, - mealType: mealData.mealType as any, - inputType: mealData.inputType as any, - description: mealData.description, - confidence: mealData.confidence, - nutrition: { - calories: mealData.calories, - protein: mealData.protein, - carbohydrates: mealData.carbohydrates, - fat: mealData.fat, - fiber: mealData.fiber || 0, - sugar: mealData.sugar || 0, - }, - }; - - const inserted = await mealCollection.insert(newMeal); - const meal = toMealWithNutrition(inserted); - this.meals = [...this.meals, meal]; - await this.fetchDailySummary(); - NutriPhiEvents.mealAdded(mealData.mealType, mealData.inputType); - return meal; - } catch (err) { - const message = - err instanceof Error ? err.message : 'Mahlzeit konnte nicht gespeichert werden'; - this.error = message; - throw new Error(message); - } - } - - async deleteMeal(mealId: string) { - this.deleteError = null; - try { - await mealCollection.delete(mealId); - this.meals = this.meals.filter((m) => m.id !== mealId); - await this.fetchDailySummary(); - NutriPhiEvents.mealDeleted(); - } catch (err) { - this.deleteError = - err instanceof Error ? err.message : 'Mahlzeit konnte nicht gelöscht werden'; - throw new Error(this.deleteError); - } - } - - clearErrors() { - this.error = null; - this.summaryError = null; - this.deleteError = null; - } -} - -export const mealsStore = new MealsStore(); +export const mealsStore = { + addMeal, + deleteMeal, +}; diff --git a/apps/nutriphi/apps/web/src/routes/+layout.svelte b/apps/nutriphi/apps/web/src/routes/+layout.svelte index db196bb37..dfc147560 100644 --- a/apps/nutriphi/apps/web/src/routes/+layout.svelte +++ b/apps/nutriphi/apps/web/src/routes/+layout.svelte @@ -6,7 +6,7 @@ import { QuickInputBar } from '@manacore/shared-ui'; import type { QuickInputItem, CreatePreview } from '@manacore/shared-ui'; import { authStore } from '$lib/stores/auth.svelte'; - import { mealsStore } from '$lib/stores/meals.svelte'; + import { useAllMeals, searchMeals } from '$lib/data/queries'; import { parseMealInput, formatParsedMealPreview } from '$lib/utils/meal-parser'; import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui'; import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui'; @@ -15,11 +15,12 @@ let showGuestWelcome = $state(false); + // Reactive live query for search + const allMeals = useAllMeals(); + // QuickInputBar handlers - search recent meals async function handleSearch(query: string): Promise { - const q = query.toLowerCase(); - return mealsStore.meals - .filter((m) => m.description?.toLowerCase().includes(q)) + return searchMeals(allMeals.value, query) .slice(0, 10) .map((meal) => ({ id: meal.id, diff --git a/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte b/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte index e9ac35d87..4efceea44 100644 --- a/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte +++ b/apps/skilltree/apps/web/src/lib/components/StatsOverview.svelte @@ -1,7 +1,22 @@
@@ -14,7 +29,7 @@

Gesamt-XP

- {skillStore.userStats.totalXp.toLocaleString()} + {userStats.totalXp.toLocaleString()}

@@ -29,7 +44,7 @@

Skills

- {skillStore.userStats.totalSkills} + {userStats.totalSkills}

@@ -42,9 +57,9 @@
-

Höchstes Level

+

Hochstes Level

- {skillStore.userStats.highestLevel} + {userStats.highestLevel}

@@ -59,7 +74,7 @@

Streak

- {skillStore.userStats.streakDays} Tage + {userStats.streakDays} Tage

@@ -77,8 +92,8 @@

Achievements

- {achievementStore.stats().unlocked}/{achievementStore.stats().total}/{achievementStats.total}

diff --git a/apps/skilltree/apps/web/src/lib/data/queries.ts b/apps/skilltree/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..b4a71ed4c --- /dev/null +++ b/apps/skilltree/apps/web/src/lib/data/queries.ts @@ -0,0 +1,186 @@ +/** + * Reactive Queries & Pure Helpers for SkilltTree + * + * Uses Dexie liveQuery to automatically re-render when IndexedDB changes + * (local writes, sync updates, other tabs). Components call these hooks + * at init time; no manual fetch/refresh needed. + */ + +import { useLiveQueryWithDefault } from '@manacore/local-store/svelte'; +import { + skillCollection, + activityCollection, + achievementCollection, + type LocalSkill, + type LocalActivity, + type LocalAchievement, +} from './local-store'; +import type { Skill, Activity, SkillBranch, UserStats } from '$lib/types'; +import { BRANCH_INFO } from '$lib/types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toSkill(local: LocalSkill): Skill { + return { + id: local.id, + name: local.name, + description: local.description, + branch: local.branch, + parentId: local.parentId ?? null, + icon: local.icon, + color: local.color ?? null, + currentXp: local.currentXp, + totalXp: local.totalXp, + level: local.level, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toActivity(local: LocalActivity): Activity { + return { + id: local.id, + skillId: local.skillId, + xpEarned: local.xpEarned, + description: local.description, + duration: local.duration ?? null, + timestamp: local.timestamp, + }; +} + +// ─── Live Query Hooks (call during component init) ───────── + +/** All skills, auto-updates on any change. */ +export function useAllSkills() { + return useLiveQueryWithDefault(async () => { + const locals = await skillCollection.getAll(); + return locals.map(toSkill); + }, [] as Skill[]); +} + +/** All activities, auto-updates on any change. */ +export function useAllActivities() { + return useLiveQueryWithDefault(async () => { + const locals = await activityCollection.getAll(); + return locals.map(toActivity); + }, [] as Activity[]); +} + +/** All achievements (raw local records), auto-updates on any change. */ +export function useAllAchievements() { + return useLiveQueryWithDefault(async () => { + return await achievementCollection.getAll(); + }, [] as LocalAchievement[]); +} + +// ─── Pure Filter/Helper Functions (for $derived) ────────── + +/** Group skills by branch. */ +export function groupByBranch(skills: Skill[]): Record { + const grouped: Record = { + intellect: [], + body: [], + creativity: [], + social: [], + practical: [], + mindset: [], + custom: [], + }; + for (const skill of skills) { + grouped[skill.branch].push(skill); + } + return grouped; +} + +/** Get top N skills by total XP. */ +export function getTopSkills(skills: Skill[], n = 5): Skill[] { + return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, n); +} + +/** Get recent N activities sorted by timestamp descending. */ +export function getRecentActivities(activities: Activity[], n = 10): Activity[] { + return [...activities] + .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) + .slice(0, n); +} + +/** Compute branch-level stats. */ +export function computeBranchStats( + skills: Skill[] +): Record { + const stats = {} as Record; + for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) { + const branchSkills = skills.filter((s) => s.branch === branch); + stats[branch] = { + count: branchSkills.length, + totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0), + avgLevel: + branchSkills.length > 0 + ? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length + : 0, + }; + } + return stats; +} + +/** Calculate activity streak in days. */ +export function calculateStreak(activities: Activity[]): number { + if (activities.length === 0) return 0; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const sortedDates = activities + .map((a) => { + const d = new Date(a.timestamp); + d.setHours(0, 0, 0, 0); + return d.getTime(); + }) + .filter((v, i, a) => a.indexOf(v) === i) + .sort((a, b) => b - a); + + let streak = 0; + let expectedDate = today.getTime(); + + for (const date of sortedDates) { + if (date === expectedDate || date === expectedDate - 86400000) { + streak++; + expectedDate = date - 86400000; + } else if (date < expectedDate - 86400000) { + break; + } + } + + return streak; +} + +/** Compute aggregate user stats from skills and activities. */ +export function computeUserStats(skills: Skill[], activities: Activity[]): UserStats { + const sortedActivities = [...activities].sort( + (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() + ); + + return { + totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0), + totalSkills: skills.length, + highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0), + streakDays: calculateStreak(activities), + lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null, + }; +} + +/** Filter skills by branch (or return all if 'all'). */ +export function filterByBranch(skills: Skill[], branch: SkillBranch | 'all'): Skill[] { + if (branch === 'all') return skills; + return skills.filter((s) => s.branch === branch); +} + +/** Find a skill by ID. */ +export function getSkillById(skills: Skill[], id: string): Skill | undefined { + return skills.find((s) => s.id === id); +} + +/** Get all activities for a specific skill. */ +export function getSkillActivities(activities: Activity[], skillId: string): Activity[] { + return activities.filter((a) => a.skillId === skillId); +} diff --git a/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts index 036bd89cd..30ba79117 100644 --- a/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/achievements.svelte.ts @@ -1,8 +1,8 @@ /** - * Achievements Store — Local-First with @manacore/local-store + * Achievements Store — Write Actions + Unlock Queue * - * All achievement state stored in IndexedDB via Dexie.js. - * Sync to server happens automatically when authenticated. + * Reads are handled by useLiveQuery hooks in queries.ts. + * This store handles achievement checking logic and the unlock celebration queue. */ import type { @@ -16,25 +16,47 @@ import type { import { ACHIEVEMENT_DEFINITIONS } from '$lib/types'; import { achievementCollection, type LocalAchievement } from '$lib/data/local-store'; -// Reactive state -let achievements = $state([]); -let isLoading = $state(true); -let initialized = $state(false); - // Queue of recently unlocked achievements to show celebrations let unlockQueue = $state([]); -// ─── Derived values ────────────────────────────────────────── +// ─── Derived helpers (pure functions for consumers) ────────── -const unlockedAchievements = $derived(() => { +/** Build achievement status list from stored records and definitions. */ +export function buildAchievementStatus(stored: LocalAchievement[]): AchievementWithStatus[] { + if (stored.length === 0) { + return ACHIEVEMENT_DEFINITIONS.map((def) => ({ + ...def, + unlocked: false, + unlockedAt: null, + progress: 0, + })); + } + return ACHIEVEMENT_DEFINITIONS.map((def) => { + const found = stored.find((s) => s.key === def.id || s.id === def.id); + return { + ...def, + unlocked: found?.unlockedAt ? true : false, + unlockedAt: found?.unlockedAt || null, + progress: 0, + }; + }); +} + +export function getUnlockedAchievements( + achievements: AchievementWithStatus[] +): AchievementWithStatus[] { return achievements.filter((a) => a.unlocked); -}); +} -const lockedAchievements = $derived(() => { +export function getLockedAchievements( + achievements: AchievementWithStatus[] +): AchievementWithStatus[] { return achievements.filter((a) => !a.unlocked); -}); +} -const achievementsByCategory = $derived(() => { +export function getAchievementsByCategory( + achievements: AchievementWithStatus[] +): Record { const grouped: Record = { xp: [], skills: [], @@ -48,81 +70,41 @@ const achievementsByCategory = $derived(() => { grouped[a.category].push(a); } return grouped; -}); +} -const stats = $derived(() => { +export function getAchievementStats(achievements: AchievementWithStatus[]): { + total: number; + unlocked: number; +} { return { total: achievements.length, unlocked: achievements.filter((a) => a.unlocked).length, }; -}); +} -const completionPercentage = $derived(() => { +export function getCompletionPercentage(achievements: AchievementWithStatus[]): number { if (achievements.length === 0) return 0; return Math.round((achievements.filter((a) => a.unlocked).length / achievements.length) * 100); -}); +} // ─── Actions ───────────────────────────────────────────────── -async function initialize() { - if (initialized) return; - - isLoading = true; - try { - const stored = await achievementCollection.getAll(); - if (stored.length === 0) { - // First time: seed from definitions - achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({ - ...def, - unlocked: false, - unlockedAt: null, - progress: 0, - })); - // Save each to IndexedDB - for (const a of achievements) { - await achievementCollection.insert({ - id: a.id, - key: a.id, - name: a.name, - description: a.description, - icon: a.icon, - unlockedAt: '', - }); - } - } else { - // Merge stored data with definitions (in case new achievements were added) - achievements = ACHIEVEMENT_DEFINITIONS.map((def) => { - const found = stored.find((s) => s.key === def.id || s.id === def.id); - return { - ...def, - unlocked: found?.unlockedAt ? true : false, - unlockedAt: found?.unlockedAt || null, - progress: 0, - }; +async function seedIfEmpty() { + const stored = await achievementCollection.getAll(); + if (stored.length === 0) { + for (const def of ACHIEVEMENT_DEFINITIONS) { + await achievementCollection.insert({ + id: def.id, + key: def.id, + name: def.name, + description: def.description, + icon: def.icon, + unlockedAt: '', }); } - initialized = true; - } catch (error) { - console.error('Failed to initialize achievements store:', error); - // Fallback to definitions - achievements = ACHIEVEMENT_DEFINITIONS.map((def) => ({ - ...def, - unlocked: false, - unlockedAt: null, - progress: 0, - })); - } finally { - isLoading = false; } } -async function reinitialize() { - initialized = false; - achievements = []; - unlockQueue = []; - await initialize(); -} - /** * Check achievements locally (offline mode). * Called after skill/activity changes. @@ -135,6 +117,10 @@ async function checkLocal(context: { }): Promise { const { skills, activities: allActivities, userStats: stats, lastActivityXp } = context; + // Get current achievements from DB + const stored = await achievementCollection.getAll(); + const achievements = buildAchievementStatus(stored); + const uniqueBranches = new Set(skills.map((s) => s.branch).filter((b) => b !== 'custom')); const mainBranches = ['intellect', 'body', 'creativity', 'social', 'practical', 'mindset']; @@ -161,8 +147,7 @@ async function checkLocal(context: { const newlyUnlocked: AchievementUnlockResult[] = []; - for (let i = 0; i < achievements.length; i++) { - const a = achievements[i]; + for (const a of achievements) { if (a.unlocked) continue; const condition = a.condition; @@ -205,23 +190,10 @@ async function checkLocal(context: { } if (met) { - const unlocked: AchievementWithStatus = { - ...a, - unlocked: true, - unlockedAt: new Date().toISOString(), - progress: condition.threshold, - }; - achievements = [...achievements.slice(0, i), unlocked, ...achievements.slice(i + 1)]; await achievementCollection.update(a.id, { - unlockedAt: unlocked.unlockedAt!, + unlockedAt: new Date().toISOString(), }); newlyUnlocked.push({ achievement: a, xpReward: a.xpReward }); - } else { - // Update progress - const updated = { ...a, progress: Math.min(current, condition.threshold) }; - if (updated.progress !== a.progress) { - achievements = [...achievements.slice(0, i), updated, ...achievements.slice(i + 1)]; - } } } @@ -232,31 +204,6 @@ async function checkLocal(context: { return newlyUnlocked; } -/** - * Handle achievements returned from server sync. - */ -function handleApiUnlocks(results: AchievementUnlockResult[]) { - if (results.length === 0) return; - - for (const result of results) { - const index = achievements.findIndex((a) => a.id === result.achievement.id); - if (index !== -1) { - achievements = [ - ...achievements.slice(0, index), - { - ...achievements[index], - unlocked: true, - unlockedAt: new Date().toISOString(), - progress: achievements[index].condition.threshold, - }, - ...achievements.slice(index + 1), - ]; - } - } - - unlockQueue = [...unlockQueue, ...results]; -} - function popUnlockQueue(): AchievementUnlockResult | null { if (unlockQueue.length === 0) return null; const [first, ...rest] = unlockQueue; @@ -265,37 +212,11 @@ function popUnlockQueue(): AchievementUnlockResult | null { } export const achievementStore = { - get achievements() { - return achievements; - }, - get isLoading() { - return isLoading; - }, - get initialized() { - return initialized; - }, - get unlockedAchievements() { - return unlockedAchievements; - }, - get lockedAchievements() { - return lockedAchievements; - }, - get achievementsByCategory() { - return achievementsByCategory; - }, - get stats() { - return stats; - }, - get completionPercentage() { - return completionPercentage; - }, get unlockQueue() { return unlockQueue; }, - initialize, - reinitialize, + seedIfEmpty, checkLocal, - handleApiUnlocks, popUnlockQueue, }; diff --git a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts index f6e6fc59e..cb51e2a00 100644 --- a/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts +++ b/apps/skilltree/apps/web/src/lib/stores/skills.svelte.ts @@ -1,12 +1,12 @@ /** - * Skills Store — Local-First with @manacore/local-store + * Skills Store — Write Actions Only * - * All reads and writes go to IndexedDB (Dexie.js) first. - * When authenticated, changes sync to the server in the background. + * Reads are handled by useLiveQuery hooks in queries.ts. + * This store only exposes mutation actions that write to IndexedDB. */ -import type { Skill, Activity, UserStats, SkillBranch } from '$lib/types'; -import { calculateLevel, createDefaultSkill, createActivity, BRANCH_INFO } from '$lib/types'; +import type { Skill, Activity } from '$lib/types'; +import { calculateLevel, createDefaultSkill, createActivity } from '$lib/types'; import { SkillTreeEvents } from '@manacore/shared-utils/analytics'; import { skillCollection, @@ -14,118 +14,9 @@ import { type LocalSkill, type LocalActivity, } from '$lib/data/local-store'; -import { achievementStore } from './achievements.svelte'; - -// Reactive state using Svelte 5 runes -let skills = $state([]); -let activities = $state([]); -let userStats = $state({ - totalXp: 0, - totalSkills: 0, - highestLevel: 0, - streakDays: 0, - lastActivityDate: null, -}); -let isLoading = $state(true); -let initialized = $state(false); - -// ─── Converters ────────────────────────────────────────────── - -function toSkill(local: LocalSkill): Skill { - return { - id: local.id, - name: local.name, - description: local.description, - branch: local.branch, - parentId: local.parentId ?? null, - icon: local.icon, - color: local.color ?? null, - currentXp: local.currentXp, - totalXp: local.totalXp, - level: local.level, - createdAt: local.createdAt ?? new Date().toISOString(), - updatedAt: local.updatedAt ?? new Date().toISOString(), - }; -} - -function toActivity(local: LocalActivity): Activity { - return { - id: local.id, - skillId: local.skillId, - xpEarned: local.xpEarned, - description: local.description, - duration: local.duration ?? null, - timestamp: local.timestamp, - }; -} - -// ─── Derived values ────────────────────────────────────────── - -const skillsByBranch = $derived(() => { - const grouped: Record = { - intellect: [], - body: [], - creativity: [], - social: [], - practical: [], - mindset: [], - custom: [], - }; - for (const skill of skills) { - grouped[skill.branch].push(skill); - } - return grouped; -}); - -const topSkills = $derived(() => { - return [...skills].sort((a, b) => b.totalXp - a.totalXp).slice(0, 5); -}); - -const recentActivities = $derived(() => { - return [...activities] - .sort((a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime()) - .slice(0, 10); -}); - -const branchStats = $derived(() => { - const stats: Record = - {} as Record; - for (const branch of Object.keys(BRANCH_INFO) as SkillBranch[]) { - const branchSkills = skills.filter((s) => s.branch === branch); - stats[branch] = { - count: branchSkills.length, - totalXp: branchSkills.reduce((sum, s) => sum + s.totalXp, 0), - avgLevel: - branchSkills.length > 0 - ? branchSkills.reduce((sum, s) => sum + s.level, 0) / branchSkills.length - : 0, - }; - } - return stats; -}); // ─── Actions ───────────────────────────────────────────────── -async function initialize() { - if (initialized) return; - - isLoading = true; - try { - const [localSkills, localActivities] = await Promise.all([ - skillCollection.getAll(), - activityCollection.getAll(), - ]); - skills = localSkills.map(toSkill); - activities = localActivities.map(toActivity); - recalculateStats(); - initialized = true; - } catch (error) { - console.error('Failed to initialize skills store:', error); - } finally { - isLoading = false; - } -} - async function addSkill(data: Partial): Promise { const skill = createDefaultSkill(data); const localSkill: LocalSkill = { @@ -141,16 +32,11 @@ async function addSkill(data: Partial): Promise { level: skill.level, }; await skillCollection.insert(localSkill); - skills = [...skills, skill]; SkillTreeEvents.skillCreated(data.branch || 'custom'); - recalculateStats(); return skill; } async function updateSkill(id: string, updates: Partial): Promise { - const index = skills.findIndex((s) => s.id === id); - if (index === -1) return; - const localUpdates: Partial = {}; if (updates.name !== undefined) localUpdates.name = updates.name; if (updates.description !== undefined) localUpdates.description = updates.description; @@ -160,9 +46,6 @@ async function updateSkill(id: string, updates: Partial): Promise { if (updates.color !== undefined) localUpdates.color = updates.color; await skillCollection.update(id, localUpdates); - const updatedSkill = { ...skills[index], ...updates, updatedAt: new Date().toISOString() }; - skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; - recalculateStats(); } async function deleteSkill(id: string): Promise { @@ -172,10 +55,7 @@ async function deleteSkill(id: string): Promise { await activityCollection.delete(a.id); } await skillCollection.delete(id); - skills = skills.filter((s) => s.id !== id); - activities = activities.filter((a) => a.skillId !== id); SkillTreeEvents.skillDeleted(); - recalculateStats(); } async function addXp( @@ -184,10 +64,10 @@ async function addXp( description: string, duration?: number ): Promise<{ leveledUp: boolean; newLevel: number }> { - const index = skills.findIndex((s) => s.id === skillId); - if (index === -1) return { leveledUp: false, newLevel: 0 }; + const existing = await skillCollection.getAll({ id: skillId }); + const skill = existing.find((s) => s.id === skillId); + if (!skill) return { leveledUp: false, newLevel: 0 }; - const skill = skills[index]; const newTotalXp = skill.totalXp + xp; const newCurrentXp = skill.currentXp + xp; const newLevel = calculateLevel(newTotalXp); @@ -210,124 +90,15 @@ async function addXp( }; await activityCollection.insert(localActivity); - const updatedSkill: Skill = { - ...skill, - totalXp: newTotalXp, - currentXp: newCurrentXp, - level: newLevel, - updatedAt: new Date().toISOString(), - }; - - skills = [...skills.slice(0, index), updatedSkill, ...skills.slice(index + 1)]; - activities = [...activities, activity]; SkillTreeEvents.xpAdded(xp, leveledUp); - recalculateStats(); return { leveledUp, newLevel }; } -function recalculateStats(): void { - const sortedActivities = [...activities].sort( - (a, b) => new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() - ); - - userStats = { - totalXp: skills.reduce((sum, s) => sum + s.totalXp, 0), - totalSkills: skills.length, - highestLevel: skills.reduce((max, s) => Math.max(max, s.level), 0), - streakDays: calculateStreak(activities), - lastActivityDate: sortedActivities.length > 0 ? sortedActivities[0].timestamp : null, - }; -} - -function calculateStreak(activityList: Activity[]): number { - if (activityList.length === 0) return 0; - - const today = new Date(); - today.setHours(0, 0, 0, 0); - - const sortedDates = activityList - .map((a) => { - const d = new Date(a.timestamp); - d.setHours(0, 0, 0, 0); - return d.getTime(); - }) - .filter((v, i, a) => a.indexOf(v) === i) - .sort((a, b) => b - a); - - let streak = 0; - let expectedDate = today.getTime(); - - for (const date of sortedDates) { - if (date === expectedDate || date === expectedDate - 86400000) { - streak++; - expectedDate = date - 86400000; - } else if (date < expectedDate - 86400000) { - break; - } - } - - return streak; -} - -function getSkill(id: string): Skill | undefined { - return skills.find((s) => s.id === id); -} - -function getSkillActivities(skillId: string): Activity[] { - return activities.filter((a) => a.skillId === skillId); -} - -async function reinitialize() { - initialized = false; - skills = []; - activities = []; - userStats = { - totalXp: 0, - totalSkills: 0, - highestLevel: 0, - streakDays: 0, - lastActivityDate: null, - }; - await initialize(); -} - -// Export store +// Export store (write-only actions) export const skillStore = { - get skills() { - return skills; - }, - get activities() { - return activities; - }, - get userStats() { - return userStats; - }, - get isLoading() { - return isLoading; - }, - get initialized() { - return initialized; - }, - get skillsByBranch() { - return skillsByBranch; - }, - get topSkills() { - return topSkills; - }, - get recentActivities() { - return recentActivities; - }, - get branchStats() { - return branchStats; - }, - - initialize, - reinitialize, addSkill, updateSkill, deleteSkill, addXp, - getSkill, - getSkillActivities, }; diff --git a/apps/skilltree/apps/web/src/routes/+layout.svelte b/apps/skilltree/apps/web/src/routes/+layout.svelte index 2e0d7f1f8..67ac85d2f 100644 --- a/apps/skilltree/apps/web/src/routes/+layout.svelte +++ b/apps/skilltree/apps/web/src/routes/+layout.svelte @@ -2,9 +2,8 @@ import '../app.css'; import '$lib/i18n'; import { isLoading as i18nLoading, _ as t } from 'svelte-i18n'; - import { skillStore } from '$lib/stores/skills.svelte'; - import { achievementStore } from '$lib/stores/achievements.svelte'; import { authStore } from '$lib/stores/auth.svelte'; + import { achievementStore } from '$lib/stores/achievements.svelte'; import { MiniOnboardingModal } from '@manacore/shared-app-onboarding'; import { skilltreeOnboarding } from '$lib/stores/app-onboarding.svelte'; import { SessionExpiredBanner, AuthGate } from '@manacore/shared-auth-ui'; @@ -18,9 +17,8 @@ if (authStore.isAuthenticated) { skilltreeStore.startSync(() => authStore.getValidToken()); } - // Load data from IndexedDB into reactive stores - await skillStore.initialize(); - await achievementStore.initialize(); + // Seed achievement definitions into IndexedDB if first run + await achievementStore.seedIfEmpty(); } diff --git a/apps/skilltree/apps/web/src/routes/+page.svelte b/apps/skilltree/apps/web/src/routes/+page.svelte index 3107e489f..46eefb6ff 100644 --- a/apps/skilltree/apps/web/src/routes/+page.svelte +++ b/apps/skilltree/apps/web/src/routes/+page.svelte @@ -1,6 +1,16 @@