From 924c15277ad2c5a3debfedd69d4575bd356b1b73 Mon Sep 17 00:00:00 2001 From: Till JS Date: Sat, 28 Mar 2026 02:42:13 +0100 Subject: [PATCH] feat(local-first): migrate remaining 6 apps to reactive useLiveQuery reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Complete the useLiveQuery migration across all apps. Same pattern: queries.ts with live query hooks, stores slimmed to mutation-only, components use Svelte context for reactive reads. Apps migrated: - Picture: images, boards, boardItems (writable stores → liveQuery) - Photos: albums, albumItems, favorites - Planta: plants, plantPhotos, wateringSchedules, wateringLogs - Questions: collections, questions - Mukke: songs, playlists, playlistSongs, projects - CityCorners: locations, favorites Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/data/queries.ts | 67 ++++++ .../web/src/lib/stores/favorites.svelte.ts | 101 ++------- .../apps/web/src/routes/(app)/+page.svelte | 14 +- .../src/routes/(app)/favorites/+page.svelte | 8 +- .../routes/(app)/locations/[id]/+page.svelte | 15 +- apps/mukke/apps/web/src/lib/data/queries.ts | 157 ++++++++++++++ .../apps/web/src/lib/stores/library.svelte.ts | 196 ++++++++++------- .../web/src/lib/stores/playlist.svelte.ts | 200 +++++++++++------ .../apps/web/src/lib/stores/project.svelte.ts | 166 ++++++++++----- .../web/src/routes/(app)/library/+page.svelte | 22 +- .../src/routes/(app)/playlists/+page.svelte | 37 +--- .../lib/components/albums/AlbumGrid.svelte | 4 +- apps/photos/apps/web/src/lib/data/queries.ts | 110 ++++++++++ .../apps/web/src/lib/stores/albums.svelte.ts | 173 +++------------ .../apps/web/src/lib/stores/photos.svelte.ts | 22 +- .../apps/web/src/routes/(app)/+layout.svelte | 17 +- .../web/src/routes/(app)/albums/+page.svelte | 24 +-- .../src/routes/(app)/albums/[id]/+page.svelte | 42 ++-- .../src/routes/(app)/favorites/+page.svelte | 38 +--- .../archive/ArchivedImageModal.svelte | 14 +- .../components/board/ImagePickerModal.svelte | 55 +---- .../gallery/ImageDetailModal.svelte | 24 ++- .../src/lib/components/ui/ContextMenu.svelte | 38 +--- apps/picture/apps/web/src/lib/data/queries.ts | 155 ++++++++++++++ .../apps/web/src/lib/stores/archive.ts | 12 +- .../picture/apps/web/src/lib/stores/boards.ts | 58 +---- .../picture/apps/web/src/lib/stores/images.ts | 9 +- .../apps/web/src/routes/app/+layout.svelte | 19 ++ .../web/src/routes/app/archive/+page.svelte | 100 +-------- .../web/src/routes/app/board/+page.svelte | 153 +------------ .../web/src/routes/app/gallery/+page.svelte | 201 ++---------------- .../apps/web/src/routes/app/mana/+page.svelte | 2 +- .../web/src/routes/app/upload/+page.svelte | 4 +- .../planta/apps/web/src/lib/data/mutations.ts | 148 +++++++++++++ apps/planta/apps/web/src/lib/data/queries.ts | 180 ++++++++++++++++ .../apps/web/src/routes/(app)/+layout.svelte | 31 ++- .../web/src/routes/(app)/add/+page.svelte | 6 +- .../src/routes/(app)/dashboard/+page.svelte | 101 ++++----- .../src/routes/(app)/plants/[id]/+page.svelte | 90 ++++---- .../apps/web/src/lib/data/queries.ts | 108 ++++++++++ .../web/src/lib/stores/collections.svelte.ts | 138 ++++-------- .../web/src/lib/stores/questions.svelte.ts | 176 ++++----------- .../apps/web/src/routes/(app)/+layout.svelte | 57 ++--- .../apps/web/src/routes/(app)/+page.svelte | 66 +++--- .../src/routes/(app)/collections/+page.svelte | 8 +- .../web/src/routes/(app)/new/+page.svelte | 6 +- 46 files changed, 1825 insertions(+), 1547 deletions(-) create mode 100644 apps/citycorners/apps/web/src/lib/data/queries.ts create mode 100644 apps/mukke/apps/web/src/lib/data/queries.ts create mode 100644 apps/photos/apps/web/src/lib/data/queries.ts create mode 100644 apps/picture/apps/web/src/lib/data/queries.ts create mode 100644 apps/planta/apps/web/src/lib/data/mutations.ts create mode 100644 apps/planta/apps/web/src/lib/data/queries.ts create mode 100644 apps/questions/apps/web/src/lib/data/queries.ts diff --git a/apps/citycorners/apps/web/src/lib/data/queries.ts b/apps/citycorners/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..c704ffe7b --- /dev/null +++ b/apps/citycorners/apps/web/src/lib/data/queries.ts @@ -0,0 +1,67 @@ +/** + * Reactive Queries & Pure Filter Helpers for CityCorners + * + * 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 { + locationCollection, + favoriteCollection, + type LocalLocation, + type LocalFavorite, +} from './local-store'; + +// ─── Live Query Hooks (call during component init) ────────── + +/** All locations, sorted by name. Auto-updates on any change. */ +export function useAllLocations() { + return useLiveQueryWithDefault(async () => { + return locationCollection.getAll(undefined, { + sortBy: 'name', + sortDirection: 'asc', + }); + }, [] as LocalLocation[]); +} + +/** All favorites. Auto-updates on any change. */ +export function useAllFavorites() { + return useLiveQueryWithDefault(async () => { + return favoriteCollection.getAll(); + }, [] as LocalFavorite[]); +} + +// ─── Pure Filter Functions (for $derived) ─────────────────── + +/** Get a Set of favorite location IDs for quick lookup. */ +export function getFavoriteIds(favorites: LocalFavorite[]): Set { + return new Set(favorites.map((f) => f.locationId)); +} + +/** Check if a location is favorited. */ +export function isFavorite(favorites: LocalFavorite[], locationId: string): boolean { + return favorites.some((f) => f.locationId === locationId); +} + +/** Filter locations by category. */ +export function filterByCategory( + locations: LocalLocation[], + category: string | null +): LocalLocation[] { + if (!category) return locations; + return locations.filter((l) => l.category === category); +} + +/** Filter locations by search query across name, description, address. */ +export function searchLocations(locations: LocalLocation[], query: string): LocalLocation[] { + if (!query.trim()) return locations; + const search = query.toLowerCase().trim(); + return locations.filter( + (l) => + l.name.toLowerCase().includes(search) || + l.description?.toLowerCase().includes(search) || + l.address?.toLowerCase().includes(search) + ); +} diff --git a/apps/citycorners/apps/web/src/lib/stores/favorites.svelte.ts b/apps/citycorners/apps/web/src/lib/stores/favorites.svelte.ts index 466cd4b1a..f0aea466f 100644 --- a/apps/citycorners/apps/web/src/lib/stores/favorites.svelte.ts +++ b/apps/citycorners/apps/web/src/lib/stores/favorites.svelte.ts @@ -1,102 +1,47 @@ /** - * Favorites Store - Manages favorite locations using Svelte 5 runes + * Favorites Store — Mutation-Only + * + * All reads are handled by useLiveQuery (see $lib/data/queries.ts). + * This store only exposes mutations that write to IndexedDB. + * The live queries will automatically pick up the changes. */ -import { authStore } from './auth.svelte'; -import { api } from '$lib/api'; +import { favoriteCollection, type LocalFavorite } from '$lib/data/local-store'; -interface Favorite { - id: string; - userId: string; - locationId: string; - createdAt: string; -} - -let favoriteLocationIds = $state>(new Set()); let loading = $state(false); export const favoritesStore = { - get favoriteIds() { - return favoriteLocationIds; - }, get loading() { return loading; }, - isFavorite(locationId: string): boolean { - return favoriteLocationIds.has(locationId); - }, - - async load() { - if (!authStore.isAuthenticated) return; - + /** + * Toggle a favorite — writes to / removes from IndexedDB instantly. + */ + async toggle(locationId: string) { loading = true; + try { - const token = await authStore.getValidToken(); - if (!token) return; + const all = await favoriteCollection.getAll(); + const existing = all.find((f) => f.locationId === locationId); - const res = await fetch(`${api('/favorites')}`, { - headers: { Authorization: `Bearer ${token}` }, - }); - - if (res.ok) { - const data = await res.json(); - favoriteLocationIds = new Set(data.favorites.map((f: Favorite) => f.locationId)); + if (existing) { + await favoriteCollection.delete(existing.id); + } else { + const newFav: LocalFavorite = { + id: crypto.randomUUID(), + locationId, + }; + await favoriteCollection.insert(newFav); } } catch (err) { - console.error('Failed to load favorites:', err); + console.error('Failed to toggle favorite:', err); } finally { loading = false; } }, - async toggle(locationId: string) { - if (!authStore.isAuthenticated) return; - - const token = await authStore.getValidToken(); - if (!token) return; - - const isFav = favoriteLocationIds.has(locationId); - - // Optimistic update - const newSet = new Set(favoriteLocationIds); - if (isFav) { - newSet.delete(locationId); - } else { - newSet.add(locationId); - } - favoriteLocationIds = newSet; - - try { - const res = await fetch(`${api(`/favorites/${locationId}`)}`, { - method: isFav ? 'DELETE' : 'POST', - headers: { Authorization: `Bearer ${token}` }, - }); - - if (!res.ok) { - // Revert on error - const revertSet = new Set(favoriteLocationIds); - if (isFav) { - revertSet.add(locationId); - } else { - revertSet.delete(locationId); - } - favoriteLocationIds = revertSet; - } - } catch (err) { - console.error('Failed to toggle favorite:', err); - // Revert - const revertSet = new Set(favoriteLocationIds); - if (isFav) { - revertSet.add(locationId); - } else { - revertSet.delete(locationId); - } - favoriteLocationIds = revertSet; - } - }, - clear() { - favoriteLocationIds = new Set(); + // Nothing to clear — reads come from liveQuery }, }; diff --git a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte index 93adda58b..b2b5dfb2b 100644 --- a/apps/citycorners/apps/web/src/routes/(app)/+page.svelte +++ b/apps/citycorners/apps/web/src/routes/(app)/+page.svelte @@ -3,9 +3,14 @@ import { _ } from 'svelte-i18n'; import { authStore } from '$lib/stores/auth.svelte'; import { favoritesStore } from '$lib/stores/favorites.svelte'; + import { useAllFavorites, getFavoriteIds } from '$lib/data/queries'; import { api } from '$lib/api'; import { isOpenNow } from '$lib/opening-hours'; + // Live query for favorites — auto-updates on IndexedDB changes + const allFavorites = useAllFavorites(); + let favoriteIds = $derived(getFavoriteIds(allFavorites.value)); + interface Location { id: string; slug?: string; @@ -95,9 +100,6 @@ onMount(() => { loadLocations(); - if (authStore.isAuthenticated) { - favoritesStore.load(); - } }); // Reload when category changes @@ -230,11 +232,9 @@ {:else} {#if libraryStore.activeTab === 'songs'} - {#if libraryStore.songs.length === 0} + {#if songs.length === 0}
- {#each libraryStore.songs as song, index} + {#each songs as song, index}
{ editingSong = null; - libraryStore.loadSongs(); }} /> {/if} diff --git a/apps/mukke/apps/web/src/routes/(app)/playlists/+page.svelte b/apps/mukke/apps/web/src/routes/(app)/playlists/+page.svelte index 0634f967a..aafa4b8de 100644 --- a/apps/mukke/apps/web/src/routes/(app)/playlists/+page.svelte +++ b/apps/mukke/apps/web/src/routes/(app)/playlists/+page.svelte @@ -1,17 +1,16 @@ - {albumStore.currentAlbum?.name || $_('albums.title')} | Photos + {currentAlbum?.name || $_('albums.title')} | Photos
- {#if albumStore.loading} + {#if !currentAlbum}
{$_('common.loading')}
- {:else if albumStore.error} -
-

{albumStore.error}

-
- {:else if albumStore.currentAlbum} + {:else} - {#if albumStore.albumPhotos.length === 0} + {#if albumPhotos.length === 0}

{$_('gallery.empty')}

{:else} - import { onMount } from 'svelte'; import { _ } from 'svelte-i18n'; + import { getContext } from 'svelte'; import { photoStore } from '$lib/stores/photos.svelte'; - import { favoriteCollection } from '$lib/data/local-store'; import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte'; import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte'; import type { Photo } from '@photos/shared'; + import type { LocalFavorite } from '$lib/data/local-store'; - let favorites = $state([]); - let loading = $state(true); - let error = $state(null); + const allFavorites: { readonly value: LocalFavorite[] } = getContext('favorites'); - onMount(async () => { - await loadFavorites(); - }); - - async function loadFavorites() { - loading = true; - error = null; - - try { - const localFavs = await favoriteCollection.getAll(); - // Favorited media IDs — full photo data would come from mana-media - favorites = localFavs.map((f) => ({ id: f.mediaId, isFavorited: true }) as Photo); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load favorites'; - } finally { - loading = false; - } - } + // Derive favorite photos from live query (auto-updates when favorites change) + let favorites = $derived( + allFavorites.value.map((f) => ({ id: f.mediaId, isFavorited: true }) as Photo) + ); function handlePhotoClick(photo: Photo) { photoStore.selectPhoto(photo); @@ -52,11 +36,7 @@ - {#if error} -
-

{error}

-
- {:else if favorites.length === 0 && !loading} + {#if favorites.length === 0}
{}} diff --git a/apps/picture/apps/web/src/lib/components/archive/ArchivedImageModal.svelte b/apps/picture/apps/web/src/lib/components/archive/ArchivedImageModal.svelte index b958b77f2..69d296631 100644 --- a/apps/picture/apps/web/src/lib/components/archive/ArchivedImageModal.svelte +++ b/apps/picture/apps/web/src/lib/components/archive/ArchivedImageModal.svelte @@ -2,8 +2,8 @@ import type { Image } from '$lib/api/images'; import Modal from '../ui/Modal.svelte'; import Button from '../ui/Button.svelte'; - import { unarchiveImage, deleteImage, downloadImage } from '$lib/api/images'; - import { archivedImages } from '$lib/stores/archive'; + import { downloadImage } from '$lib/api/images'; + import { imageCollection } from '$lib/data/local-store'; import { DownloadSimple, ArrowCounterClockwise, Trash } from '@manacore/shared-icons'; interface Props { @@ -21,9 +21,8 @@ isUnarchiving = true; try { - await unarchiveImage(image.id); - // Update store - archivedImages.update((current) => current.filter((img) => img.id !== image.id)); + // Clear archivedAt locally (live query auto-refreshes) + await imageCollection.update(image.id, { archivedAt: null }); onClose(); } catch (error) { console.error('Error unarchiving image:', error); @@ -40,9 +39,8 @@ isDeleting = true; try { - await deleteImage(image.id); - // Update store - archivedImages.update((current) => current.filter((img) => img.id !== image.id)); + // Delete locally (live query auto-refreshes) + await imageCollection.delete(image.id); onClose(); } catch (error) { console.error('Error deleting image:', error); diff --git a/apps/picture/apps/web/src/lib/components/board/ImagePickerModal.svelte b/apps/picture/apps/web/src/lib/components/board/ImagePickerModal.svelte index b31af47a4..556dde430 100644 --- a/apps/picture/apps/web/src/lib/components/board/ImagePickerModal.svelte +++ b/apps/picture/apps/web/src/lib/components/board/ImagePickerModal.svelte @@ -1,12 +1,11 @@ @@ -175,13 +148,7 @@
- {#if $isLoadingImages} -
- {#each Array(15) as _} -
- {/each} -
- {:else if filteredImages.length === 0} + {#if filteredImages.length === 0}

diff --git a/apps/picture/apps/web/src/lib/components/gallery/ImageDetailModal.svelte b/apps/picture/apps/web/src/lib/components/gallery/ImageDetailModal.svelte index 4ba0c1e6f..625da4072 100644 --- a/apps/picture/apps/web/src/lib/components/gallery/ImageDetailModal.svelte +++ b/apps/picture/apps/web/src/lib/components/gallery/ImageDetailModal.svelte @@ -8,7 +8,8 @@ publishImage, unpublishImage, } from '$lib/api/images'; - import { images, selectedImage } from '$lib/stores/images'; + import { selectedImage } from '$lib/stores/images'; + import { imageCollection } from '$lib/data/local-store'; import { toastStore } from '@manacore/shared-ui'; import { fade, fly } from 'svelte/transition'; import { getImageTags, addTagToImage, removeTagFromImage } from '$lib/api/tags'; @@ -35,6 +36,7 @@ let { image, onClose }: Props = $props(); const sharedTags: { value: SharedTag[] } = getContext('tags'); + const allImages: { value: Image[] } = getContext('allImages'); let isArchiving = $state(false); let isDeleting = $state(false); @@ -57,10 +59,12 @@ ); // Get current image index - const currentIndex = $derived(image ? $images.findIndex((img) => img.id === image?.id) : -1); + const currentIndex = $derived( + image ? allImages.value.findIndex((img) => img.id === image?.id) : -1 + ); const hasPrevious = $derived(currentIndex > 0); - const hasNext = $derived(currentIndex >= 0 && currentIndex < $images.length - 1); + const hasNext = $derived(currentIndex >= 0 && currentIndex < allImages.value.length - 1); // Load tags for current image $effect(() => { @@ -79,13 +83,13 @@ function navigatePrevious() { if (hasPrevious) { - selectedImage.set($images[currentIndex - 1]); + selectedImage.set(allImages.value[currentIndex - 1]); } } function navigateNext() { if (hasNext) { - selectedImage.set($images[currentIndex + 1]); + selectedImage.set(allImages.value[currentIndex + 1]); } } @@ -115,9 +119,8 @@ isArchiving = true; try { - await archiveImage(imageId); - // Update store - images.update((current) => current.filter((img) => img.id !== imageId)); + // Update locally (live query auto-refreshes) + await imageCollection.update(imageId, { archivedAt: new Date().toISOString() }); toastStore.show('Bild erfolgreich archiviert', 'success'); onClose(); } catch (error) { @@ -140,9 +143,8 @@ isDeleting = true; try { - await deleteImage(imageId); - // Update store - images.update((current) => current.filter((img) => img.id !== imageId)); + // Delete locally (live query auto-refreshes) + await imageCollection.delete(imageId); toastStore.show('Bild erfolgreich gelöscht', 'success'); onClose(); } catch (error) { diff --git a/apps/picture/apps/web/src/lib/components/ui/ContextMenu.svelte b/apps/picture/apps/web/src/lib/components/ui/ContextMenu.svelte index 79ce1851c..bbc6e74e0 100644 --- a/apps/picture/apps/web/src/lib/components/ui/ContextMenu.svelte +++ b/apps/picture/apps/web/src/lib/components/ui/ContextMenu.svelte @@ -12,9 +12,7 @@ const allTags: { value: Tag[] } = getContext('tags'); import { addTagToImage, removeTagFromImage, getImageTags } from '$lib/api/tags'; - import { archiveImage, unarchiveImage, deleteImage, toggleFavorite } from '$lib/api/images'; - import { images } from '$lib/stores/images'; - import { archivedImages } from '$lib/stores/archive'; + import { imageCollection } from '$lib/data/local-store'; import { toastStore } from '@manacore/shared-ui'; import { DownloadSimple, @@ -122,9 +120,7 @@ if (confirm('Möchten Sie dieses Bild wirklich löschen?')) { try { - await deleteImage($contextMenu.image.id); - // Remove from store - images.update((current) => current.filter((img) => img.id !== $contextMenu.image?.id)); + await imageCollection.delete($contextMenu.image.id); hideContextMenu(); toastStore.show('Bild gelöscht', 'success'); } catch (error) { @@ -139,19 +135,15 @@ try { if (isArchived) { - // Unarchive: Move back to gallery - await unarchiveImage($contextMenu.image.id); - // Remove from archive store - archivedImages.update((current) => - current.filter((img) => img.id !== $contextMenu.image?.id) - ); + // Unarchive: clear archivedAt + await imageCollection.update($contextMenu.image.id, { archivedAt: null }); hideContextMenu(); toastStore.show('Bild wiederhergestellt', 'success'); } else { - // Archive: Move to archive - await archiveImage($contextMenu.image.id); - // Remove from gallery store - images.update((current) => current.filter((img) => img.id !== $contextMenu.image?.id)); + // Archive: set archivedAt + await imageCollection.update($contextMenu.image.id, { + archivedAt: new Date().toISOString(), + }); hideContextMenu(); toastStore.show('Bild archiviert', 'success'); } @@ -166,19 +158,7 @@ try { const newFavoriteStatus = !isFavorite; - await toggleFavorite($contextMenu.image.id, newFavoriteStatus); - - // Update in all stores - images.update((current) => - current.map((img) => - img.id === $contextMenu.image?.id ? { ...img, isFavorite: newFavoriteStatus } : img - ) - ); - archivedImages.update((current) => - current.map((img) => - img.id === $contextMenu.image?.id ? { ...img, isFavorite: newFavoriteStatus } : img - ) - ); + await imageCollection.update($contextMenu.image.id, { isFavorite: newFavoriteStatus }); hideContextMenu(); toastStore.show( diff --git a/apps/picture/apps/web/src/lib/data/queries.ts b/apps/picture/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..6af37bfe0 --- /dev/null +++ b/apps/picture/apps/web/src/lib/data/queries.ts @@ -0,0 +1,155 @@ +/** + * Reactive Queries & Pure Helpers for Picture + * + * 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 { + imageCollection, + boardCollection, + boardItemCollection, + imageTagCollection, + type LocalImage, + type LocalBoard, + type LocalBoardItem, +} from './local-store'; +import type { Image } from '$lib/api/images'; +import type { Board, BoardWithCount } from '$lib/api/boards'; + +// ─── Type Converters ────────────────────────────────────── + +/** Convert LocalImage (IndexedDB) to the Image type used by components. */ +export function toImage(local: LocalImage): Image { + return { + id: local.id, + userId: 'local', + prompt: local.prompt, + negativePrompt: local.negativePrompt ?? undefined, + model: local.model ?? undefined, + style: local.style ?? undefined, + publicUrl: local.publicUrl ?? undefined, + storagePath: local.storagePath, + filename: local.filename, + format: local.format ?? undefined, + width: local.width ?? undefined, + height: local.height ?? undefined, + fileSize: local.fileSize ?? undefined, + blurhash: local.blurhash ?? undefined, + isPublic: local.isPublic, + isFavorite: local.isFavorite, + downloadCount: local.downloadCount, + rating: local.rating ?? undefined, + archivedAt: local.archivedAt ?? undefined, + generationId: local.generationId ?? undefined, + sourceImageId: local.sourceImageId ?? undefined, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +/** Convert LocalBoard (IndexedDB) to Board type. */ +export function toBoard(local: LocalBoard): Board { + return { + id: local.id, + userId: 'local', + name: local.name, + description: local.description ?? undefined, + thumbnailUrl: local.thumbnailUrl ?? undefined, + canvasWidth: local.canvasWidth, + canvasHeight: local.canvasHeight, + backgroundColor: local.backgroundColor, + isPublic: local.isPublic, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Query Hooks (call during component init) ──────── + +/** All non-archived images, sorted by createdAt desc. Auto-updates on any change. */ +export function useAllImages() { + return useLiveQueryWithDefault(async () => { + const locals = await imageCollection.getAll(); + return locals + .filter((img) => !img.archivedAt) + .map(toImage) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, [] as Image[]); +} + +/** All archived images, sorted by createdAt desc. Auto-updates on any change. */ +export function useArchivedImages() { + return useLiveQueryWithDefault(async () => { + const locals = await imageCollection.getAll(); + return locals + .filter((img) => !!img.archivedAt) + .map(toImage) + .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + }, [] as Image[]); +} + +/** All boards with item counts, sorted by updatedAt desc. Auto-updates on any change. */ +export function useAllBoards() { + return useLiveQueryWithDefault(async () => { + const locals = await boardCollection.getAll(); + const allItems = await boardItemCollection.getAll(); + + // Count items per board + const itemCounts = new Map(); + for (const item of allItems) { + itemCounts.set(item.boardId, (itemCounts.get(item.boardId) || 0) + 1); + } + + return locals + .map( + (local): BoardWithCount => ({ + ...toBoard(local), + itemCount: itemCounts.get(local.id) || 0, + }) + ) + .sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()); + }, [] as BoardWithCount[]); +} + +/** All image-tag associations. Auto-updates on any change. */ +export function useAllImageTags() { + return useLiveQueryWithDefault( + async () => { + return await imageTagCollection.getAll(); + }, + [] as { id: string; imageId: string; tagId: string }[] + ); +} + +// ─── Pure Helper Functions (for $derived) ───────────────── + +/** Filter images by favorites only. */ +export function getFavoriteImages(images: Image[]): Image[] { + return images.filter((img) => img.isFavorite); +} + +/** Filter images by tag IDs using image-tag associations. */ +export function getImagesByTags( + images: Image[], + imageTags: { imageId: string; tagId: string }[], + selectedTagIds: string[] +): Image[] { + if (selectedTagIds.length === 0) return images; + const imageIdsWithTags = new Set( + imageTags.filter((it) => selectedTagIds.includes(it.tagId)).map((it) => it.imageId) + ); + return images.filter((img) => imageIdsWithTags.has(img.id)); +} + +/** Find an image by ID. */ +export function findImageById(images: Image[], id: string): Image | undefined { + return images.find((img) => img.id === id); +} + +/** Find a board by ID. */ +export function findBoardById(boards: BoardWithCount[], id: string): BoardWithCount | undefined { + return boards.find((b) => b.id === id); +} diff --git a/apps/picture/apps/web/src/lib/stores/archive.ts b/apps/picture/apps/web/src/lib/stores/archive.ts index 56c25ec48..c6429aac5 100644 --- a/apps/picture/apps/web/src/lib/stores/archive.ts +++ b/apps/picture/apps/web/src/lib/stores/archive.ts @@ -1,7 +1,7 @@ -import { writable } from 'svelte/store'; -import type { Image } from '$lib/api/images'; +/** + * Archive store — UI-only state. + * Archived image reads are handled by useLiveQuery hooks in queries.ts. + */ -export const archivedImages = writable([]); -export const isLoadingArchive = writable(false); -export const hasMoreArchive = writable(true); -export const currentArchivePage = writable(1); +// This file is kept for backwards compatibility. +// The archivedImages data is now provided via live query (useArchivedImages) from queries.ts. diff --git a/apps/picture/apps/web/src/lib/stores/boards.ts b/apps/picture/apps/web/src/lib/stores/boards.ts index 001bc900d..5ee514ed7 100644 --- a/apps/picture/apps/web/src/lib/stores/boards.ts +++ b/apps/picture/apps/web/src/lib/stores/boards.ts @@ -1,23 +1,14 @@ import { writable, derived } from 'svelte/store'; -import type { Board, BoardWithCount } from '$lib/api/boards'; +import type { Board } from '$lib/api/boards'; -// Current boards list -export const boards = writable([]); +/** + * UI-only state for board editing. + * Board list reads are handled by useLiveQuery hooks in queries.ts. + */ -// Current board being edited +// Current board being edited (on the canvas page) export const currentBoard = writable(null); -// Loading states -export const isLoadingBoards = writable(false); -export const isLoadingBoard = writable(false); - -// Pagination -export const currentBoardsPage = writable(1); -export const hasBoardsMore = writable(true); - -// Selected board (for actions like delete, duplicate) -export const selectedBoard = writable(null); - // Create board modal export const showCreateBoardModal = writable(false); @@ -31,40 +22,3 @@ export const boardSettings = derived(currentBoard, ($currentBoard) => ({ height: $currentBoard?.canvasHeight || 1500, backgroundColor: $currentBoard?.backgroundColor || '#ffffff', })); - -// Helper functions for board management -export function resetBoardsState() { - boards.set([]); - currentBoardsPage.set(1); - hasBoardsMore.set(true); -} - -export function addBoard(board: BoardWithCount) { - boards.update((current) => [board, ...current]); -} - -export function updateBoardInList(boardId: string, updates: Partial) { - boards.update((current) => - current.map((board) => (board.id === boardId ? { ...board, ...updates } : board)) - ); -} - -export function removeBoardFromList(boardId: string) { - boards.update((current) => current.filter((board) => board.id !== boardId)); -} - -export function incrementBoardItemCount(boardId: string) { - boards.update((current) => - current.map((board) => - board.id === boardId ? { ...board, itemCount: board.itemCount + 1 } : board - ) - ); -} - -export function decrementBoardItemCount(boardId: string) { - boards.update((current) => - current.map((board) => - board.id === boardId ? { ...board, itemCount: Math.max(0, board.itemCount - 1) } : board - ) - ); -} diff --git a/apps/picture/apps/web/src/lib/stores/images.ts b/apps/picture/apps/web/src/lib/stores/images.ts index 212f3a4be..7c51343fc 100644 --- a/apps/picture/apps/web/src/lib/stores/images.ts +++ b/apps/picture/apps/web/src/lib/stores/images.ts @@ -1,9 +1,10 @@ import { writable } from 'svelte/store'; import type { Image } from '$lib/api/images'; -export const images = writable([]); +/** + * UI-only state for gallery image selection and filter toggles. + * Data reads are handled by useLiveQuery hooks in queries.ts. + */ + export const selectedImage = writable(null); -export const isLoading = writable(false); -export const hasMore = writable(true); -export const currentPage = writable(1); export const showFavoritesOnly = writable(false); diff --git a/apps/picture/apps/web/src/routes/app/+layout.svelte b/apps/picture/apps/web/src/routes/app/+layout.svelte index a0f60abe7..53b4d705f 100644 --- a/apps/picture/apps/web/src/routes/app/+layout.svelte +++ b/apps/picture/apps/web/src/routes/app/+layout.svelte @@ -30,6 +30,12 @@ import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui'; import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui'; import { pictureStore } from '$lib/data/local-store'; + import { + useAllImages, + useArchivedImages, + useAllBoards, + useAllImageTags, + } from '$lib/data/queries'; import { viewMode, setViewMode } from '$lib/stores/view'; import type { ViewMode } from '$lib/stores/view'; import { browser } from '$app/environment'; @@ -41,6 +47,19 @@ const allTags = useAllSharedTags(); setContext('tags', allTags); + // Live queries for picture data (local-first) + const allImages = useAllImages(); + setContext('allImages', allImages); + + const archivedImages = useArchivedImages(); + setContext('archivedImages', archivedImages); + + const allBoards = useAllBoards(); + setContext('allBoards', allBoards); + + const allImageTags = useAllImageTags(); + setContext('allImageTags', allImageTags); + let { children } = $props(); // PillNav state diff --git a/apps/picture/apps/web/src/routes/app/archive/+page.svelte b/apps/picture/apps/web/src/routes/app/archive/+page.svelte index e7c887eaa..d9aef95e5 100644 --- a/apps/picture/apps/web/src/routes/app/archive/+page.svelte +++ b/apps/picture/apps/web/src/routes/app/archive/+page.svelte @@ -1,88 +1,15 @@ @@ -235,28 +93,9 @@ {/if}

-{#if $isLoading} -
- -
-{:else} -
- - - - {#if $hasMore} -
- {#if loadingMore} -
- {:else} -

Scroll to load more

- {/if} -
- {/if} -
-{/if} +
+ +
selectedImage.set(null)} /> @@ -266,5 +105,5 @@ {#if $isUIVisible} - + {/if} diff --git a/apps/picture/apps/web/src/routes/app/mana/+page.svelte b/apps/picture/apps/web/src/routes/app/mana/+page.svelte index 4c20c9ab7..1b885893b 100644 --- a/apps/picture/apps/web/src/routes/app/mana/+page.svelte +++ b/apps/picture/apps/web/src/routes/app/mana/+page.svelte @@ -31,7 +31,7 @@ pageTitle="Wähle dein Abo" subscriptionsTitle="Abonnements" packagesTitle="Einmal-Pakete" - yearlyDiscount="2 Monate gratis" + yearlyDiscount="20% Rabatt" />
diff --git a/apps/picture/apps/web/src/routes/app/upload/+page.svelte b/apps/picture/apps/web/src/routes/app/upload/+page.svelte index 1b92ef97b..92c8ec641 100644 --- a/apps/picture/apps/web/src/routes/app/upload/+page.svelte +++ b/apps/picture/apps/web/src/routes/app/upload/+page.svelte @@ -6,7 +6,6 @@ import { toastStore } from '@manacore/shared-ui'; import { PageHeader } from '@manacore/shared-ui'; import DropZone from '$lib/components/upload/DropZone.svelte'; - import { images } from '$lib/stores/images'; import { Check, Image, CloudArrowUp, CheckCircle } from '@manacore/shared-icons'; let uploading = $state(false); @@ -29,8 +28,7 @@ successCount = uploadedImages.length; - // Add uploaded images to store - images.update((current) => [...uploadedImages, ...current]); + // Images will appear in gallery automatically via live query if (successCount === files.length) { toastStore.show( diff --git a/apps/planta/apps/web/src/lib/data/mutations.ts b/apps/planta/apps/web/src/lib/data/mutations.ts new file mode 100644 index 000000000..d4dabb3c6 --- /dev/null +++ b/apps/planta/apps/web/src/lib/data/mutations.ts @@ -0,0 +1,148 @@ +/** + * Planta — Mutation Helpers (Local-First) + * + * All writes go to IndexedDB first, sync handles the rest. + */ + +import { + plantCollection, + wateringScheduleCollection, + wateringLogCollection, + type LocalPlant, + type LocalWateringSchedule, + type LocalWateringLog, +} from './local-store'; +import { toPlant, toWateringSchedule } from './queries'; +import { trackEvent } from '@manacore/shared-utils/analytics'; +import type { Plant, CreatePlantDto, UpdatePlantDto } from '@planta/shared'; + +export const plantMutations = { + async create(dto: CreatePlantDto): Promise { + try { + const newLocal: LocalPlant = { + id: crypto.randomUUID(), + name: dto.name, + scientificName: dto.scientificName ?? null, + commonName: dto.commonName ?? null, + species: null, + lightRequirements: null, + wateringFrequencyDays: null, + humidity: null, + temperature: null, + soilType: null, + careNotes: null, + isActive: true, + healthStatus: null, + acquiredAt: dto.acquiredAt ?? null, + }; + const inserted = await plantCollection.insert(newLocal); + trackEvent('plant_created'); + return toPlant(inserted); + } catch (e) { + console.error('Failed to create plant:', e); + return null; + } + }, + + async update(id: string, dto: UpdatePlantDto): Promise { + try { + const updateData: Partial = {}; + if (dto.name !== undefined) updateData.name = dto.name; + if (dto.scientificName !== undefined) updateData.scientificName = dto.scientificName ?? null; + if (dto.commonName !== undefined) updateData.commonName = dto.commonName ?? null; + if (dto.careNotes !== undefined) updateData.careNotes = dto.careNotes ?? null; + if (dto.isActive !== undefined) updateData.isActive = dto.isActive; + if (dto.lightRequirements !== undefined) + updateData.lightRequirements = dto.lightRequirements ?? null; + if (dto.wateringFrequencyDays !== undefined) + updateData.wateringFrequencyDays = dto.wateringFrequencyDays ?? null; + if (dto.humidity !== undefined) updateData.humidity = dto.humidity ?? null; + + const updated = await plantCollection.update(id, updateData); + return updated ? toPlant(updated) : null; + } catch (e) { + console.error('Failed to update plant:', e); + return null; + } + }, + + async delete(id: string): Promise { + try { + await plantCollection.delete(id); + trackEvent('plant_deleted'); + return true; + } catch (e) { + console.error('Failed to delete plant:', e); + return false; + } + }, +}; + +export const wateringMutations = { + async logWatering(plantId: string, notes?: string): Promise { + try { + const now = new Date().toISOString(); + + // Create watering log entry + const logEntry: LocalWateringLog = { + id: crypto.randomUUID(), + plantId, + wateredAt: now, + notes: notes ?? null, + }; + await wateringLogCollection.insert(logEntry); + + // Update watering schedule + const schedules = await wateringScheduleCollection.getAll(); + const schedule = schedules.find((s) => s.plantId === plantId); + if (schedule) { + const nextDate = new Date(); + nextDate.setDate(nextDate.getDate() + schedule.frequencyDays); + + await wateringScheduleCollection.update(schedule.id, { + lastWateredAt: now, + nextWateringAt: nextDate.toISOString(), + } as Partial); + } + + trackEvent('plant_watered'); + return true; + } catch (e) { + console.error('Failed to log watering:', e); + return false; + } + }, + + async updateSchedule(plantId: string, frequencyDays: number): Promise { + try { + const schedules = await wateringScheduleCollection.getAll(); + const schedule = schedules.find((s) => s.plantId === plantId); + + if (schedule) { + const nextDate = schedule.lastWateredAt + ? new Date(new Date(schedule.lastWateredAt).getTime() + frequencyDays * 86400000) + : new Date(Date.now() + frequencyDays * 86400000); + + await wateringScheduleCollection.update(schedule.id, { + frequencyDays, + nextWateringAt: nextDate.toISOString(), + } as Partial); + } else { + const nextDate = new Date(Date.now() + frequencyDays * 86400000); + await wateringScheduleCollection.insert({ + id: crypto.randomUUID(), + plantId, + frequencyDays, + lastWateredAt: null, + nextWateringAt: nextDate.toISOString(), + reminderEnabled: false, + reminderHoursBefore: 0, + } as LocalWateringSchedule); + } + return true; + } catch (e) { + console.error('Failed to update watering schedule:', e); + return false; + } + }, +}; diff --git a/apps/planta/apps/web/src/lib/data/queries.ts b/apps/planta/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..43f04ab66 --- /dev/null +++ b/apps/planta/apps/web/src/lib/data/queries.ts @@ -0,0 +1,180 @@ +/** + * Reactive Queries & Pure Helpers for Planta + * + * 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 { + plantCollection, + plantPhotoCollection, + wateringScheduleCollection, + wateringLogCollection, + type LocalPlant, + type LocalPlantPhoto, + type LocalWateringSchedule, + type LocalWateringLog, +} from './local-store'; +import type { Plant, PlantPhoto, WateringSchedule, WateringLog } from '@planta/shared'; + +// ─── Type Converters ─────────────────────────────────────── + +/** Convert a LocalPlant (IndexedDB) to the shared Plant type. */ +export function toPlant(local: LocalPlant): Plant { + return { + id: local.id, + userId: 'local', + name: local.name, + scientificName: local.scientificName ?? undefined, + commonName: local.commonName ?? undefined, + species: local.species ?? undefined, + lightRequirements: local.lightRequirements ?? undefined, + wateringFrequencyDays: local.wateringFrequencyDays ?? undefined, + humidity: local.humidity ?? undefined, + temperature: local.temperature ?? undefined, + soilType: local.soilType ?? undefined, + careNotes: local.careNotes ?? undefined, + isActive: local.isActive, + healthStatus: local.healthStatus ?? undefined, + acquiredAt: local.acquiredAt ? new Date(local.acquiredAt) : undefined, + createdAt: new Date(local.createdAt ?? new Date().toISOString()), + updatedAt: new Date(local.updatedAt ?? new Date().toISOString()), + }; +} + +/** Convert a LocalPlantPhoto (IndexedDB) to the shared PlantPhoto type. */ +export function toPlantPhoto(local: LocalPlantPhoto): PlantPhoto { + return { + id: local.id, + plantId: local.plantId, + userId: 'local', + storagePath: local.storagePath, + publicUrl: local.publicUrl ?? undefined, + filename: local.filename, + mimeType: local.mimeType ?? undefined, + fileSize: local.fileSize ?? undefined, + width: local.width ?? undefined, + height: local.height ?? undefined, + isPrimary: local.isPrimary, + isAnalyzed: local.isAnalyzed, + takenAt: local.takenAt ? new Date(local.takenAt) : undefined, + createdAt: new Date(local.createdAt ?? new Date().toISOString()), + }; +} + +/** Convert a LocalWateringSchedule (IndexedDB) to the shared WateringSchedule type. */ +export function toWateringSchedule(local: LocalWateringSchedule): WateringSchedule { + return { + id: local.id, + plantId: local.plantId, + userId: 'local', + frequencyDays: local.frequencyDays, + lastWateredAt: local.lastWateredAt ? new Date(local.lastWateredAt) : undefined, + nextWateringAt: local.nextWateringAt ? new Date(local.nextWateringAt) : undefined, + reminderEnabled: local.reminderEnabled, + reminderHoursBefore: local.reminderHoursBefore, + createdAt: new Date(local.createdAt ?? new Date().toISOString()), + updatedAt: new Date(local.updatedAt ?? new Date().toISOString()), + }; +} + +/** Convert a LocalWateringLog (IndexedDB) to the shared WateringLog type. */ +export function toWateringLog(local: LocalWateringLog): WateringLog { + return { + id: local.id, + plantId: local.plantId, + userId: 'local', + wateredAt: new Date(local.wateredAt), + notes: local.notes ?? undefined, + createdAt: new Date(local.createdAt ?? new Date().toISOString()), + }; +} + +// ─── Live Query Hooks (call during component init) ───────── + +/** All plants. Auto-updates on any change. */ +export function useAllPlants() { + return useLiveQueryWithDefault(async () => { + const locals = await plantCollection.getAll(); + return locals.map(toPlant); + }, [] as Plant[]); +} + +/** All plant photos. Auto-updates on any change. */ +export function useAllPlantPhotos() { + return useLiveQueryWithDefault(async () => { + const locals = await plantPhotoCollection.getAll(); + return locals.map(toPlantPhoto); + }, [] as PlantPhoto[]); +} + +/** All watering schedules. Auto-updates on any change. */ +export function useAllWateringSchedules() { + return useLiveQueryWithDefault(async () => { + const locals = await wateringScheduleCollection.getAll(); + return locals.map(toWateringSchedule); + }, [] as WateringSchedule[]); +} + +/** All watering logs. Auto-updates on any change. */ +export function useAllWateringLogs() { + return useLiveQueryWithDefault(async () => { + const locals = await wateringLogCollection.getAll(); + return locals.map(toWateringLog); + }, [] as WateringLog[]); +} + +// ─── Pure Plant Helpers ──────────────────────────────────── + +/** Get a plant by ID. */ +export function getPlantById(plants: Plant[], id: string): Plant | undefined { + return plants.find((p) => p.id === id); +} + +/** Get active plants only. */ +export function getActivePlants(plants: Plant[]): Plant[] { + return plants.filter((p) => p.isActive); +} + +/** Get photos for a specific plant. */ +export function getPhotosForPlant(photos: PlantPhoto[], plantId: string): PlantPhoto[] { + return photos.filter((p) => p.plantId === plantId); +} + +/** Get the primary photo for a plant. */ +export function getPrimaryPhoto(photos: PlantPhoto[], plantId: string): PlantPhoto | undefined { + return photos.find((p) => p.plantId === plantId && p.isPrimary); +} + +// ─── Pure Watering Helpers ───────────────────────────────── + +/** Get watering schedule for a specific plant. */ +export function getScheduleForPlant( + schedules: WateringSchedule[], + plantId: string +): WateringSchedule | undefined { + return schedules.find((s) => s.plantId === plantId); +} + +/** Get watering logs for a specific plant, sorted by date (newest first). */ +export function getLogsForPlant(logs: WateringLog[], plantId: string): WateringLog[] { + return logs + .filter((l) => l.plantId === plantId) + .sort((a, b) => new Date(b.wateredAt).getTime() - new Date(a.wateredAt).getTime()); +} + +/** Calculate days until next watering. Negative means overdue. */ +export function getDaysUntilWatering(schedule: WateringSchedule | undefined): number | null { + if (!schedule?.nextWateringAt) return null; + const now = new Date(); + const next = new Date(schedule.nextWateringAt); + return Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24)); +} + +/** Check if a plant's watering is overdue. */ +export function isWateringOverdue(schedule: WateringSchedule | undefined): boolean { + const days = getDaysUntilWatering(schedule); + return days !== null && days < 0; +} diff --git a/apps/planta/apps/web/src/routes/(app)/+layout.svelte b/apps/planta/apps/web/src/routes/(app)/+layout.svelte index a27a1d925..9eaae19d7 100644 --- a/apps/planta/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/planta/apps/web/src/routes/(app)/+layout.svelte @@ -10,7 +10,13 @@ } from '@manacore/shared-stores'; import { theme } from '$lib/stores/theme'; import { authStore } from '$lib/stores/auth.svelte'; - import { plantsApi } from '$lib/api/plants'; + import { plantMutations } from '$lib/data/mutations'; + import { + useAllPlants, + useAllPlantPhotos, + useAllWateringSchedules, + useAllWateringLogs, + } from '$lib/data/queries'; import { parsePlantInput, resolvePlantData, @@ -23,7 +29,18 @@ let { children } = $props(); + // Live queries for local-first data (auto-update on Dexie changes) + const allPlants = useAllPlants(); + const allPlantPhotos = useAllPlantPhotos(); + const allWateringSchedules = useAllWateringSchedules(); + const allWateringLogs = useAllWateringLogs(); const allTags = useAllSharedTags(); + + // Set context for child components + setContext('plants', allPlants); + setContext('plantPhotos', allPlantPhotos); + setContext('wateringSchedules', allWateringSchedules); + setContext('wateringLogs', allWateringLogs); setContext('tags', allTags); let showGuestWelcome = $state(false); @@ -37,7 +54,7 @@ // Navigation items for Planta const navItems: PillNavItem[] = [ { href: '/dashboard', label: 'Meine Pflanzen', icon: 'document' }, - { href: '/add', label: 'Hinzufügen', icon: 'plus' }, + { href: '/add', label: 'Hinzufuegen', icon: 'plus' }, { href: '/settings', label: 'Einstellungen', icon: 'settings' }, { href: '/', @@ -59,9 +76,9 @@ goto('/login'); } - // QuickInputBar handlers + // QuickInputBar handlers — use live query data instead of API async function handleInputSearch(query: string): Promise { - const plants = await plantsApi.getAll(); + const plants = allPlants.value; const q = query.toLowerCase(); return plants .filter( @@ -79,7 +96,7 @@ } function handleInputSelect(item: QuickInputItem) { - goto(`/plant/${item.id}`); + goto(`/plants/${item.id}`); } // Quick-Create handlers @@ -99,12 +116,12 @@ const parsed = parsePlantInput(query); if (!parsed.name) return; const resolved = resolvePlantData(parsed); - const plant = await plantsApi.create({ + const plant = await plantMutations.create({ name: resolved.name, acquiredAt: resolved.acquiredAt, }); if (plant?.id) { - goto(`/plant/${plant.id}`); + goto(`/plants/${plant.id}`); } } diff --git a/apps/planta/apps/web/src/routes/(app)/add/+page.svelte b/apps/planta/apps/web/src/routes/(app)/add/+page.svelte index 33d34e1d9..4d19b2299 100644 --- a/apps/planta/apps/web/src/routes/(app)/add/+page.svelte +++ b/apps/planta/apps/web/src/routes/(app)/add/+page.svelte @@ -2,7 +2,7 @@ import { goto } from '$app/navigation'; import { photosApi } from '$lib/api/photos'; import { analysisApi } from '$lib/api/analysis'; - import { plantsApi } from '$lib/api/plants'; + import { plantMutations } from '$lib/data/mutations'; import { PlantaEvents } from '@manacore/shared-utils/analytics'; import type { PlantPhoto, PlantAnalysis } from '@planta/shared'; @@ -94,8 +94,8 @@ saving = true; error = ''; - // Create plant - const plant = await plantsApi.create({ + // Create plant (local-first) + const plant = await plantMutations.create({ name: plantName.trim(), scientificName: analysis.scientificName || undefined, commonName: analysis.commonNames?.[0] || undefined, diff --git a/apps/planta/apps/web/src/routes/(app)/dashboard/+page.svelte b/apps/planta/apps/web/src/routes/(app)/dashboard/+page.svelte index 2a0f5dacf..564e2c06d 100644 --- a/apps/planta/apps/web/src/routes/(app)/dashboard/+page.svelte +++ b/apps/planta/apps/web/src/routes/(app)/dashboard/+page.svelte @@ -1,51 +1,57 @@ @@ -57,35 +63,29 @@
- {#if loading} -
-
-
- {:else if plants.length === 0} + {#if plants.length === 0}
🌱

Noch keine Pflanzen

- Füge deine erste Pflanze hinzu und lass sie von der KI analysieren. + Fuege deine erste Pflanze hinzu und lass sie von der KI analysieren.

- Erste Pflanze hinzufügen + Erste Pflanze hinzufuegen
{:else}
{#each plants as plant (plant.id)} - {@const status = getWateringStatusForPlant(plant.id)} + {@const primaryPhoto = getPrimaryPhoto(allPlantPhotos.value, plant.id)}
- {#if plant.wateringSchedule} + {#if wateringSchedule}

Zuletzt gegossen

-

{formatDate(plant.wateringSchedule.lastWateredAt)}

+

{formatDate(wateringSchedule.lastWateredAt)}

-

Nächstes Gießen

-

{formatDate(plant.wateringSchedule.nextWateringAt)}

+

Naechstes Giessen

+

{formatDate(wateringSchedule.nextWateringAt)}

{/if} {#if wateringHistory.length > 0}
-

Letzte Gießvorgänge

+

Letzte Giessvorgaenge

    {#each wateringHistory.slice(0, 5) as log (log.id)}
  • @@ -222,9 +212,9 @@
    - ← Zurück + Zurueck
diff --git a/apps/questions/apps/web/src/lib/data/queries.ts b/apps/questions/apps/web/src/lib/data/queries.ts new file mode 100644 index 000000000..03173f512 --- /dev/null +++ b/apps/questions/apps/web/src/lib/data/queries.ts @@ -0,0 +1,108 @@ +/** + * Reactive Queries & Pure Filter Helpers for Questions + * + * 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, + questionCollection, + answerCollection, + type LocalCollection, + type LocalQuestion, + type LocalAnswer, +} from './local-store'; +import type { Collection, Question } from '$lib/types'; + +// ─── Type Converters ──────────────────────────────────────── + +/** Convert a LocalCollection (IndexedDB record) to the shared Collection type. */ +export function toCollection(local: LocalCollection): Collection { + return { + id: local.id, + userId: 'local', + name: local.name, + description: local.description ?? undefined, + color: local.color, + icon: local.icon, + isDefault: local.isDefault, + sortOrder: local.sortOrder, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +/** Convert a LocalQuestion (IndexedDB record) to the shared Question type. */ +export function toQuestion(local: LocalQuestion): Question { + return { + id: local.id, + userId: 'local', + collectionId: local.collectionId ?? undefined, + title: local.title, + description: local.description ?? undefined, + status: local.status, + priority: local.priority, + tags: local.tags ?? [], + researchDepth: local.researchDepth, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Query Hooks (call during component init) ────────── + +/** All collections, sorted by sortOrder. Auto-updates on any change. */ +export function useAllCollections() { + return useLiveQueryWithDefault(async () => { + const locals = await collectionCollection.getAll(undefined, { + sortBy: 'sortOrder', + sortDirection: 'asc', + }); + return locals.map(toCollection); + }, [] as Collection[]); +} + +/** All questions. Auto-updates on any change. */ +export function useAllQuestions() { + return useLiveQueryWithDefault(async () => { + const locals = await questionCollection.getAll(); + return locals.map(toQuestion); + }, [] as Question[]); +} + +/** All answers for a given question. */ +export function useAnswersByQuestion(questionId: string) { + return useLiveQueryWithDefault(async () => { + const locals = await answerCollection.getAll(); + return locals.filter((a) => a.questionId === questionId); + }, [] as LocalAnswer[]); +} + +// ─── Pure Filter Functions (for $derived) ─────────────────── + +/** Filter questions by collection ID. */ +export function filterByCollection(questions: Question[], collectionId: string | null): Question[] { + if (!collectionId) return questions; + return questions.filter((q) => q.collectionId === collectionId); +} + +/** Filter questions by status. */ +export function filterByStatus(questions: Question[], status: string): Question[] { + if (!status) return questions; + return questions.filter((q) => q.status === status); +} + +/** Filter questions by search query across title, description, and tags. */ +export function searchQuestions(questions: Question[], query: string): Question[] { + if (!query.trim()) return questions; + const search = query.toLowerCase().trim(); + return questions.filter( + (q) => + q.title.toLowerCase().includes(search) || + q.description?.toLowerCase().includes(search) || + q.tags?.some((t: string) => t.toLowerCase().includes(search)) + ); +} diff --git a/apps/questions/apps/web/src/lib/stores/collections.svelte.ts b/apps/questions/apps/web/src/lib/stores/collections.svelte.ts index ed2b117c4..97f81f052 100644 --- a/apps/questions/apps/web/src/lib/stores/collections.svelte.ts +++ b/apps/questions/apps/web/src/lib/stores/collections.svelte.ts @@ -1,24 +1,21 @@ /** - * Collections Store - Manages collections state using Svelte 5 runes - * Authenticated users: collections from API - * Demo mode: static sample collection to showcase the app + * Collections Store — Mutation-Only + * + * All reads are handled by useLiveQuery (see $lib/data/queries.ts). + * This store only exposes mutations that write to IndexedDB. + * The live queries will automatically pick up the changes. */ -import { collectionsApi } from '$lib/api/collections'; +import { collectionCollection, type LocalCollection } from '$lib/data/local-store'; +import { toCollection } from '$lib/data/queries'; import { QuestionsEvents } from '@manacore/shared-utils/analytics'; import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types'; -import { authStore } from './auth.svelte'; -import { DEMO_COLLECTION, isDemoCollection } from '$lib/data/demo-questions'; -let collections = $state([]); let loading = $state(false); let error = $state(null); let selectedId = $state(null); export const collectionsStore = { - get collections() { - return collections; - }, get loading() { return loading; }, @@ -28,57 +25,28 @@ export const collectionsStore = { get selectedId() { return selectedId; }, - get selected() { - return selectedId ? collections.find((c) => c.id === selectedId) : null; - }, /** - * Load collections - * Demo mode: shows static sample collection - * Authenticated: fetches from API - */ - async load() { - loading = true; - error = null; - - // Demo mode: load demo collection - if (!authStore.isAuthenticated) { - collections = [DEMO_COLLECTION]; - loading = false; - return; - } - - // Authenticated: fetch from API - try { - collections = await collectionsApi.getAll(); - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load collections'; - collections = []; - } finally { - loading = false; - } - }, - - /** - * Create a new collection - * Demo mode: returns auth_required error - * Authenticated: creates via API + * Create a new collection — writes to IndexedDB instantly. */ async create(data: CreateCollectionDto): Promise { - // Demo mode: require authentication - if (!authStore.isAuthenticated) { - error = 'Login required to create collections'; - return null; - } - loading = true; error = null; try { - const collection = await collectionsApi.create(data); - collections = [...collections, collection]; + const newLocal: LocalCollection = { + id: crypto.randomUUID(), + name: data.name, + description: data.description ?? null, + color: data.color || '#6366f1', + icon: data.icon || 'folder', + isDefault: data.isDefault || false, + sortOrder: Date.now(), + }; + + const inserted = await collectionCollection.insert(newLocal); QuestionsEvents.collectionCreated(); - return collection; + return toCollection(inserted); } catch (e) { error = e instanceof Error ? e.message : 'Failed to create collection'; return null; @@ -88,23 +56,22 @@ export const collectionsStore = { }, /** - * Update a collection - * Demo mode: returns auth_required error - * Authenticated: updates via API + * Update a collection — writes to IndexedDB instantly. */ async update(id: string, data: UpdateCollectionDto): Promise { - // Demo collection or not authenticated: require authentication - if (isDemoCollection(id) || !authStore.isAuthenticated) { - error = 'Login required to update collections'; - return null; - } - error = null; try { - const updated = await collectionsApi.update(id, data); - collections = collections.map((c) => (c.id === id ? updated : c)); - return updated; + const updateData: Partial = {}; + if (data.name !== undefined) updateData.name = data.name; + if (data.description !== undefined) updateData.description = data.description ?? null; + if (data.color !== undefined) updateData.color = data.color; + if (data.icon !== undefined) updateData.icon = data.icon; + if (data.isDefault !== undefined) updateData.isDefault = data.isDefault; + + const updated = await collectionCollection.update(id, updateData); + if (updated) return toCollection(updated); + return null; } catch (e) { error = e instanceof Error ? e.message : 'Failed to update collection'; return null; @@ -112,22 +79,13 @@ export const collectionsStore = { }, /** - * Delete a collection - * Demo mode: returns auth_required error - * Authenticated: deletes via API + * Delete a collection — removes from IndexedDB instantly. */ async delete(id: string): Promise { - // Demo collection or not authenticated: require authentication - if (isDemoCollection(id) || !authStore.isAuthenticated) { - error = 'Login required to delete collections'; - return false; - } - error = null; try { - await collectionsApi.delete(id); - collections = collections.filter((c) => c.id !== id); + await collectionCollection.delete(id); QuestionsEvents.collectionDeleted(); if (selectedId === id) { selectedId = null; @@ -140,26 +98,15 @@ export const collectionsStore = { }, /** - * Reorder collections - * Demo mode: returns auth_required error - * Authenticated: reorders via API + * Reorder collections — updates sortOrder in IndexedDB. */ async reorder(orderedIds: string[]): Promise { - // Demo mode: require authentication - if (!authStore.isAuthenticated) { - error = 'Login required to reorder collections'; - return false; - } - error = null; try { - await collectionsApi.reorder(orderedIds); - // Reorder local state - const reordered = orderedIds - .map((id) => collections.find((c) => c.id === id)) - .filter((c): c is Collection => c !== undefined); - collections = reordered; + for (let i = 0; i < orderedIds.length; i++) { + await collectionCollection.update(orderedIds[i], { sortOrder: i }); + } return true; } catch (e) { error = e instanceof Error ? e.message : 'Failed to reorder collections'; @@ -171,19 +118,14 @@ export const collectionsStore = { selectedId = id; }, - getById(id: string): Collection | undefined { - return collections.find((c) => c.id === id); - }, - /** - * Check if a collection is a demo collection + * No longer relevant — all collections are local and editable. */ - isDemoCollection(id: string): boolean { - return isDemoCollection(id); + isDemoCollection(_id: string): boolean { + return false; }, clear() { - collections = []; error = null; selectedId = null; }, diff --git a/apps/questions/apps/web/src/lib/stores/questions.svelte.ts b/apps/questions/apps/web/src/lib/stores/questions.svelte.ts index b8d86bc88..d62ea171b 100644 --- a/apps/questions/apps/web/src/lib/stores/questions.svelte.ts +++ b/apps/questions/apps/web/src/lib/stores/questions.svelte.ts @@ -1,112 +1,49 @@ /** - * Questions Store - Manages questions state using Svelte 5 runes - * Authenticated users: questions from API - * Demo mode: static sample questions to showcase the app + * Questions Store — Mutation-Only + * + * All reads are handled by useLiveQuery (see $lib/data/queries.ts). + * This store only exposes mutations that write to IndexedDB. + * The live queries will automatically pick up the changes. */ -import { questionsApi, type QuestionFilters } from '$lib/api/questions'; +import { questionCollection, type LocalQuestion } from '$lib/data/local-store'; +import { toQuestion } from '$lib/data/queries'; import { QuestionsEvents } from '@manacore/shared-utils/analytics'; import type { Question, CreateQuestionDto, UpdateQuestionDto } from '$lib/types'; -import { authStore } from './auth.svelte'; -import { generateDemoQuestions, isDemoQuestion } from '$lib/data/demo-questions'; -let questions = $state([]); let loading = $state(false); let error = $state(null); -let total = $state(0); -let currentFilters = $state({}); export const questionsStore = { - get questions() { - return questions; - }, get loading() { return loading; }, get error() { return error; }, - get total() { - return total; - }, - get filters() { - return currentFilters; - }, /** - * Load questions - * Demo mode: shows static sample questions - * Authenticated: fetches from API - */ - async load(filters?: QuestionFilters) { - loading = true; - error = null; - currentFilters = filters || {}; - - // Demo mode: load demo questions - if (!authStore.isAuthenticated) { - let demoQuestions = generateDemoQuestions(); - - // Apply filters - if (filters?.collectionId) { - demoQuestions = demoQuestions.filter( - (q: Question) => q.collectionId === filters.collectionId - ); - } - if (filters?.status) { - demoQuestions = demoQuestions.filter((q: Question) => q.status === filters.status); - } - if (filters?.search) { - const search = filters.search.toLowerCase(); - demoQuestions = demoQuestions.filter( - (q: Question) => - q.title.toLowerCase().includes(search) || - q.description?.toLowerCase().includes(search) || - q.tags?.some((t: string) => t.toLowerCase().includes(search)) - ); - } - - questions = demoQuestions; - total = demoQuestions.length; - loading = false; - return; - } - - // Authenticated: fetch from API - try { - const response = await questionsApi.getAll(filters); - questions = response.data; - total = response.total; - } catch (e) { - error = e instanceof Error ? e.message : 'Failed to load questions'; - questions = []; - total = 0; - } finally { - loading = false; - } - }, - - /** - * Create a new question - * Demo mode: returns auth_required error - * Authenticated: creates via API + * Create a new question — writes to IndexedDB instantly. */ async create(data: CreateQuestionDto): Promise { - // Demo mode: require authentication - if (!authStore.isAuthenticated) { - error = 'Login required to create questions'; - return null; - } - loading = true; error = null; try { - const question = await questionsApi.create(data); - questions = [question, ...questions]; - total++; + const newLocal: LocalQuestion = { + id: crypto.randomUUID(), + collectionId: data.collectionId ?? null, + title: data.title, + description: data.description ?? null, + status: 'open', + priority: data.priority || 'normal', + tags: data.tags || [], + researchDepth: data.researchDepth || 'standard', + }; + + const inserted = await questionCollection.insert(newLocal); QuestionsEvents.questionCreated(data.researchDepth || 'standard'); - return question; + return toQuestion(inserted); } catch (e) { error = e instanceof Error ? e.message : 'Failed to create question'; return null; @@ -116,23 +53,25 @@ export const questionsStore = { }, /** - * Update a question - * Demo mode: returns auth_required error - * Authenticated: updates via API + * Update a question — writes to IndexedDB instantly. */ async update(id: string, data: UpdateQuestionDto): Promise { - // Demo question or not authenticated: require authentication - if (isDemoQuestion(id) || !authStore.isAuthenticated) { - error = 'Login required to update questions'; - return null; - } - error = null; try { - const updated = await questionsApi.update(id, data); - questions = questions.map((q) => (q.id === id ? updated : q)); - return updated; + const updateData: Partial = {}; + if (data.title !== undefined) updateData.title = data.title; + if (data.description !== undefined) updateData.description = data.description ?? null; + if (data.collectionId !== undefined) updateData.collectionId = data.collectionId ?? null; + if (data.tags !== undefined) updateData.tags = data.tags; + if (data.priority !== undefined) + updateData.priority = data.priority as LocalQuestion['priority']; + if (data.researchDepth !== undefined) + updateData.researchDepth = data.researchDepth as LocalQuestion['researchDepth']; + + const updated = await questionCollection.update(id, updateData); + if (updated) return toQuestion(updated); + return null; } catch (e) { error = e instanceof Error ? e.message : 'Failed to update question'; return null; @@ -140,23 +79,13 @@ export const questionsStore = { }, /** - * Delete a question - * Demo mode: returns auth_required error - * Authenticated: deletes via API + * Delete a question — removes from IndexedDB instantly. */ async delete(id: string): Promise { - // Demo question or not authenticated: require authentication - if (isDemoQuestion(id) || !authStore.isAuthenticated) { - error = 'Login required to delete questions'; - return false; - } - error = null; try { - await questionsApi.delete(id); - questions = questions.filter((q) => q.id !== id); - total--; + await questionCollection.delete(id); QuestionsEvents.questionDeleted(); return true; } catch (e) { @@ -166,44 +95,31 @@ export const questionsStore = { }, /** - * Update question status - * Demo mode: returns auth_required error - * Authenticated: updates via API + * Update question status — writes to IndexedDB instantly. */ async updateStatus(id: string, status: string): Promise { - // Demo question or not authenticated: require authentication - if (isDemoQuestion(id) || !authStore.isAuthenticated) { - error = 'Login required to update question status'; - return null; - } - error = null; try { - const updated = await questionsApi.updateStatus(id, status); - questions = questions.map((q) => (q.id === id ? updated : q)); - return updated; + const updated = await questionCollection.update(id, { + status: status as LocalQuestion['status'], + }); + if (updated) return toQuestion(updated); + return null; } catch (e) { error = e instanceof Error ? e.message : 'Failed to update status'; return null; } }, - getById(id: string): Question | undefined { - return questions.find((q) => q.id === id); - }, - /** - * Check if a question is a demo question + * No longer relevant — all questions are local and editable. */ - isDemoQuestion(id: string): boolean { - return isDemoQuestion(id); + isDemoQuestion(_id: string): boolean { + return false; }, clear() { - questions = []; - total = 0; error = null; - currentFilters = {}; }, }; diff --git a/apps/questions/apps/web/src/routes/(app)/+layout.svelte b/apps/questions/apps/web/src/routes/(app)/+layout.svelte index e559c60c1..c522821f7 100644 --- a/apps/questions/apps/web/src/routes/(app)/+layout.svelte +++ b/apps/questions/apps/web/src/routes/(app)/+layout.svelte @@ -5,12 +5,16 @@ import { browser } from '$app/environment'; import { locale } from 'svelte-i18n'; import { authStore, collectionsStore, questionsStore } from '$lib/stores'; - import { apiClient } from '$lib/api/client'; - import { questionsApi } from '$lib/api/questions'; import { theme } from '$lib/stores/theme'; import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui'; import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui'; import { questionsAppStore } from '$lib/data/local-store'; + import { + useAllCollections, + useAllQuestions, + filterByCollection, + searchQuestions, + } from '$lib/data/queries'; import { PillNavigation, QuickInputBar, TagStrip } from '@manacore/shared-ui'; import type { PillNavItem, @@ -30,6 +34,10 @@ const allTags = useAllSharedTags(); setContext('tags', allTags); + // Reactive live queries from IndexedDB + const allCollections = useAllCollections(); + const allQuestions = useAllQuestions(); + // App switcher items const appItems = getPillAppItems('questions'); @@ -59,10 +67,6 @@ const getToken = () => authStore.getValidToken(); questionsAppStore.startSync(getToken); tagMutations.startSync(getToken); - const token = await authStore.getValidToken(); - apiClient.setAccessToken(token); - await collectionsStore.load(); - await questionsStore.load(); } if (!authStore.isAuthenticated && shouldShowGuestWelcome('questions')) { showGuestWelcome = true; @@ -93,31 +97,16 @@ } } - // InputBar search - search questions + // InputBar search - search questions from liveQuery data async function handleSearch(query: string): Promise { if (!query.trim()) return []; - // Demo mode: search from store - if (!authStore.isAuthenticated) { - await questionsStore.load({ search: query }); - return questionsStore.questions.slice(0, 10).map((q) => ({ - id: q.id, - title: q.title, - subtitle: q.status || 'pending', - })); - } - - // Authenticated: search via API - try { - const response = await questionsApi.getAll({ search: query, limit: 10 }); - return response.data.map((q) => ({ - id: q.id, - title: q.title, - subtitle: q.status || 'pending', - })); - } catch { - return []; - } + const results = searchQuestions(allQuestions.value, query); + return results.slice(0, 10).map((q) => ({ + id: q.id, + title: q.title, + subtitle: q.status || 'pending', + })); } function handleSelect(item: QuickInputItem) { @@ -148,7 +137,7 @@ } } - // Collection dropdown items + // Collection dropdown items — driven by liveQuery let collectionItems = $derived([ { id: 'all', @@ -157,7 +146,7 @@ onClick: () => selectCollection(null), active: !collectionsStore.selectedId, }, - ...collectionsStore.collections.map((c) => ({ + ...allCollections.value.map((c) => ({ id: c.id, label: c.name, icon: 'folder', @@ -168,18 +157,12 @@ let currentCollectionLabel = $derived( collectionsStore.selectedId - ? collectionsStore.collections.find((c) => c.id === collectionsStore.selectedId)?.name || - 'Collection' + ? allCollections.value.find((c) => c.id === collectionsStore.selectedId)?.name || 'Collection' : 'All Questions' ); function selectCollection(id: string | null) { collectionsStore.select(id); - if (id) { - questionsStore.load({ collectionId: id }); - } else { - questionsStore.load(); - } } // TagStrip visibility diff --git a/apps/questions/apps/web/src/routes/(app)/+page.svelte b/apps/questions/apps/web/src/routes/(app)/+page.svelte index 5895f197c..25e319c34 100644 --- a/apps/questions/apps/web/src/routes/(app)/+page.svelte +++ b/apps/questions/apps/web/src/routes/(app)/+page.svelte @@ -1,6 +1,6 @@