diff --git a/apps/mana/apps/web/src/lib/i18n/locales/planta/de.json b/apps/mana/apps/web/src/lib/i18n/locales/planta/de.json index b192d2e09..ec3670c9d 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/planta/de.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/planta/de.json @@ -20,11 +20,30 @@ "noPlants": "Noch keine Pflanzen", "addFirst": "Füge deine erste Pflanze hinzu", "careNotes": "Pflegehinweise", - "health": "Gesundheit" + "health": "Gesundheit", + "unnamed": "Unbenannt", + "namePlaceholder": "Name...", + "scientificName": "Wissenschaftlicher Name", + "state": "Zustand", + "light": "Licht", + "wateringDays": "Gießen (Tage)", + "acquired": "Erworben", + "created": "Erstellt", + "edited": "Bearbeitet", + "notFound": "Pflanze nicht gefunden", + "confirmDelete": "Pflanze wirklich löschen?", + "notesPlaceholder": "Pflegehinweise hinzufügen..." + }, + "list": { + "empty": "Keine Pflanzen", + "count": "{count} Pflanzen", + "dueWatering": "{count} gießen", + "needsCare": "{count} brauchen Pflege", + "everyXDays": "Alle {days} Tage gießen" }, "health": { "healthy": "Gesund", - "needsAttention": "Braucht Aufmerksamkeit", + "needsAttention": "Braucht Pflege", "sick": "Krank" }, "watering": { @@ -42,7 +61,17 @@ "analyzing": "Analysiere...", "identified": "Identifiziert", "confidence": "Sicherheit", - "tips": "Pflegetipps" + "tips": "Pflegetipps", + "button": "Pflanze identifizieren", + "applyResult": "Übernehmen", + "resultTitle": "Vorschlag von der KI" + }, + "photo": { + "section": "Fotos", + "upload": "Foto hochladen", + "uploading": "Hochladen...", + "noPhotos": "Noch keine Fotos", + "primary": "Hauptbild" }, "light": { "low": "Wenig Licht", @@ -65,18 +94,23 @@ "search": "Suchen", "error": "Fehler", "success": "Erfolgreich", - "loading": "Laden..." + "loading": "Laden...", + "none": "—" }, "errors": { "loadPlants": "Pflanzen konnten nicht geladen werden", "identifyFailed": "Identifizierung fehlgeschlagen", "saveFailed": "Speichern fehlgeschlagen", - "uploadFailed": "Upload fehlgeschlagen" + "uploadFailed": "Upload fehlgeschlagen", + "deleteFailed": "Löschen fehlgeschlagen", + "wateringFailed": "Gießen konnte nicht gespeichert werden" }, "success": { "plantAdded": "Pflanze hinzugefügt", + "plantSaved": "Gespeichert", "plantDeleted": "Pflanze gelöscht", "plantWatered": "Pflanze gegossen", - "photoUploaded": "Foto hochgeladen" + "photoUploaded": "Foto hochgeladen", + "identified": "Pflanze identifiziert" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/planta/en.json b/apps/mana/apps/web/src/lib/i18n/locales/planta/en.json index f7856ccde..c9cf4c313 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/planta/en.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/planta/en.json @@ -20,7 +20,26 @@ "noPlants": "No plants yet", "addFirst": "Add your first plant", "careNotes": "Care notes", - "health": "Health" + "health": "Health", + "unnamed": "Unnamed", + "namePlaceholder": "Name...", + "scientificName": "Scientific name", + "state": "Condition", + "light": "Light", + "wateringDays": "Watering (days)", + "acquired": "Acquired", + "created": "Created", + "edited": "Edited", + "notFound": "Plant not found", + "confirmDelete": "Really delete this plant?", + "notesPlaceholder": "Add care notes..." + }, + "list": { + "empty": "No plants", + "count": "{count} plants", + "dueWatering": "{count} to water", + "needsCare": "{count} need care", + "everyXDays": "Water every {days} days" }, "health": { "healthy": "Healthy", @@ -42,7 +61,17 @@ "analyzing": "Analyzing...", "identified": "Identified", "confidence": "Confidence", - "tips": "Care tips" + "tips": "Care tips", + "button": "Identify plant", + "applyResult": "Apply", + "resultTitle": "AI suggestion" + }, + "photo": { + "section": "Photos", + "upload": "Upload photo", + "uploading": "Uploading...", + "noPhotos": "No photos yet", + "primary": "Primary" }, "light": { "low": "Low light", @@ -65,18 +94,23 @@ "search": "Search", "error": "Error", "success": "Success", - "loading": "Loading..." + "loading": "Loading...", + "none": "—" }, "errors": { "loadPlants": "Failed to load plants", "identifyFailed": "Identification failed", "saveFailed": "Failed to save", - "uploadFailed": "Upload failed" + "uploadFailed": "Upload failed", + "deleteFailed": "Delete failed", + "wateringFailed": "Could not log watering" }, "success": { "plantAdded": "Plant added", + "plantSaved": "Saved", "plantDeleted": "Plant deleted", "plantWatered": "Plant watered", - "photoUploaded": "Photo uploaded" + "photoUploaded": "Photo uploaded", + "identified": "Plant identified" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/planta/es.json b/apps/mana/apps/web/src/lib/i18n/locales/planta/es.json index e84c9479e..cd183a8f5 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/planta/es.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/planta/es.json @@ -20,7 +20,26 @@ "noPlants": "Aún no hay plantas", "addFirst": "Añade tu primera planta", "careNotes": "Notas de cuidado", - "health": "Salud" + "health": "Salud", + "unnamed": "Sin nombre", + "namePlaceholder": "Nombre...", + "scientificName": "Nombre científico", + "state": "Estado", + "light": "Luz", + "wateringDays": "Riego (días)", + "acquired": "Adquirida", + "created": "Creada", + "edited": "Editada", + "notFound": "Planta no encontrada", + "confirmDelete": "¿Eliminar esta planta?", + "notesPlaceholder": "Añadir notas de cuidado..." + }, + "list": { + "empty": "Sin plantas", + "count": "{count} plantas", + "dueWatering": "{count} para regar", + "needsCare": "{count} necesitan cuidado", + "everyXDays": "Regar cada {days} días" }, "health": { "healthy": "Saludable", @@ -42,7 +61,17 @@ "analyzing": "Analizando...", "identified": "Identificada", "confidence": "Confianza", - "tips": "Consejos de cuidado" + "tips": "Consejos de cuidado", + "button": "Identificar planta", + "applyResult": "Aplicar", + "resultTitle": "Sugerencia de IA" + }, + "photo": { + "section": "Fotos", + "upload": "Subir foto", + "uploading": "Subiendo...", + "noPhotos": "Aún no hay fotos", + "primary": "Principal" }, "light": { "low": "Poca luz", @@ -65,18 +94,23 @@ "search": "Buscar", "error": "Error", "success": "Éxito", - "loading": "Cargando..." + "loading": "Cargando...", + "none": "—" }, "errors": { "loadPlants": "No se pudieron cargar las plantas", "identifyFailed": "La identificación falló", "saveFailed": "No se pudo guardar", - "uploadFailed": "La subida falló" + "uploadFailed": "La subida falló", + "deleteFailed": "Error al eliminar", + "wateringFailed": "No se pudo registrar el riego" }, "success": { "plantAdded": "Planta añadida", + "plantSaved": "Guardado", "plantDeleted": "Planta eliminada", "plantWatered": "Planta regada", - "photoUploaded": "Foto subida" + "photoUploaded": "Foto subida", + "identified": "Planta identificada" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/planta/fr.json b/apps/mana/apps/web/src/lib/i18n/locales/planta/fr.json index 7e06a16e3..158fa740a 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/planta/fr.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/planta/fr.json @@ -20,7 +20,26 @@ "noPlants": "Pas encore de plantes", "addFirst": "Ajoutez votre première plante", "careNotes": "Notes d'entretien", - "health": "Santé" + "health": "Santé", + "unnamed": "Sans nom", + "namePlaceholder": "Nom...", + "scientificName": "Nom scientifique", + "state": "État", + "light": "Lumière", + "wateringDays": "Arrosage (jours)", + "acquired": "Acquise", + "created": "Créée", + "edited": "Modifiée", + "notFound": "Plante introuvable", + "confirmDelete": "Vraiment supprimer cette plante ?", + "notesPlaceholder": "Ajouter des notes d'entretien..." + }, + "list": { + "empty": "Aucune plante", + "count": "{count} plantes", + "dueWatering": "{count} à arroser", + "needsCare": "{count} à soigner", + "everyXDays": "Arroser tous les {days} jours" }, "health": { "healthy": "En bonne santé", @@ -42,7 +61,17 @@ "analyzing": "Analyse en cours...", "identified": "Identifiée", "confidence": "Confiance", - "tips": "Conseils d'entretien" + "tips": "Conseils d'entretien", + "button": "Identifier la plante", + "applyResult": "Appliquer", + "resultTitle": "Suggestion de l'IA" + }, + "photo": { + "section": "Photos", + "upload": "Téléverser une photo", + "uploading": "Téléversement...", + "noPhotos": "Aucune photo pour l'instant", + "primary": "Principale" }, "light": { "low": "Faible luminosité", @@ -65,18 +94,23 @@ "search": "Rechercher", "error": "Erreur", "success": "Succès", - "loading": "Chargement..." + "loading": "Chargement...", + "none": "—" }, "errors": { "loadPlants": "Impossible de charger les plantes", "identifyFailed": "L'identification a échoué", "saveFailed": "Impossible d'enregistrer", - "uploadFailed": "Le téléchargement a échoué" + "uploadFailed": "Le téléchargement a échoué", + "deleteFailed": "Échec de la suppression", + "wateringFailed": "Impossible d'enregistrer l'arrosage" }, "success": { "plantAdded": "Plante ajoutée", + "plantSaved": "Enregistré", "plantDeleted": "Plante supprimée", "plantWatered": "Plante arrosée", - "photoUploaded": "Photo téléchargée" + "photoUploaded": "Photo téléchargée", + "identified": "Plante identifiée" } } diff --git a/apps/mana/apps/web/src/lib/i18n/locales/planta/it.json b/apps/mana/apps/web/src/lib/i18n/locales/planta/it.json index db3380a92..20c3a99a1 100644 --- a/apps/mana/apps/web/src/lib/i18n/locales/planta/it.json +++ b/apps/mana/apps/web/src/lib/i18n/locales/planta/it.json @@ -20,7 +20,26 @@ "noPlants": "Nessuna pianta ancora", "addFirst": "Aggiungi la tua prima pianta", "careNotes": "Note di cura", - "health": "Salute" + "health": "Salute", + "unnamed": "Senza nome", + "namePlaceholder": "Nome...", + "scientificName": "Nome scientifico", + "state": "Condizione", + "light": "Luce", + "wateringDays": "Innaffiatura (giorni)", + "acquired": "Acquisita", + "created": "Creata", + "edited": "Modificata", + "notFound": "Pianta non trovata", + "confirmDelete": "Eliminare davvero questa pianta?", + "notesPlaceholder": "Aggiungi note di cura..." + }, + "list": { + "empty": "Nessuna pianta", + "count": "{count} piante", + "dueWatering": "{count} da innaffiare", + "needsCare": "{count} da curare", + "everyXDays": "Innaffia ogni {days} giorni" }, "health": { "healthy": "Sana", @@ -42,7 +61,17 @@ "analyzing": "Analisi in corso...", "identified": "Identificata", "confidence": "Affidabilità", - "tips": "Consigli di cura" + "tips": "Consigli di cura", + "button": "Identifica pianta", + "applyResult": "Applica", + "resultTitle": "Suggerimento IA" + }, + "photo": { + "section": "Foto", + "upload": "Carica foto", + "uploading": "Caricamento...", + "noPhotos": "Nessuna foto", + "primary": "Principale" }, "light": { "low": "Poca luce", @@ -65,18 +94,23 @@ "search": "Cerca", "error": "Errore", "success": "Successo", - "loading": "Caricamento..." + "loading": "Caricamento...", + "none": "—" }, "errors": { "loadPlants": "Impossibile caricare le piante", "identifyFailed": "Identificazione fallita", "saveFailed": "Salvataggio fallito", - "uploadFailed": "Caricamento fallito" + "uploadFailed": "Caricamento fallito", + "deleteFailed": "Eliminazione fallita", + "wateringFailed": "Impossibile registrare l'innaffiatura" }, "success": { "plantAdded": "Pianta aggiunta", + "plantSaved": "Salvato", "plantDeleted": "Pianta eliminata", "plantWatered": "Pianta innaffiata", - "photoUploaded": "Foto caricata" + "photoUploaded": "Foto caricata", + "identified": "Pianta identificata" } } diff --git a/apps/mana/apps/web/src/lib/modules/planta/ListView.svelte b/apps/mana/apps/web/src/lib/modules/planta/ListView.svelte index 3b46bd53f..0ed35bb7c 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/ListView.svelte +++ b/apps/mana/apps/web/src/lib/modules/planta/ListView.svelte @@ -3,6 +3,7 @@ Plant overview with watering schedule. --> - p.id} emptyTitle="Keine Pflanzen"> + p.id} emptyTitle={$_('planta.list.empty')}> {#snippet header()} - {plants.length} Pflanzen + {$_('planta.list.count', { values: { count: plants.length } })} {#if dueForWatering.length > 0} - {dueForWatering.length} giessen + {$_('planta.list.dueWatering', { values: { count: dueForWatering.length } })} {/if} {#if needsAttention.length > 0} - {needsAttention.length} brauchen Pflege + {$_('planta.list.needsCare', { values: { count: needsAttention.length } })} {/if} {/snippet} @@ -84,7 +89,7 @@ {#if schedule}

- Alle {schedule.frequencyDays} Tage giessen + {$_('planta.list.everyXDays', { values: { days: schedule.frequencyDays } })}

{/if} diff --git a/apps/mana/apps/web/src/lib/modules/planta/api.ts b/apps/mana/apps/web/src/lib/modules/planta/api.ts new file mode 100644 index 000000000..10e130c34 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/planta/api.ts @@ -0,0 +1,71 @@ +/** + * Planta — server-only API client + * + * CRUD lives in IndexedDB + sync. This module talks to mana-api for the + * two server-only operations: photo upload (S3 via mana-media) and AI + * plant identification (Gemini Vision via mana-llm). + */ + +import { authStore } from '$lib/stores/auth.svelte'; +import { getManaApiUrl } from '$lib/api/config'; + +export interface UploadPhotoResult { + storagePath: string; + publicUrl: string; + mediaId: string; + plantId: string | null; +} + +export interface IdentifyResult { + scientificName?: string; + commonNames?: string[]; + confidence?: number; + healthAssessment?: string; + wateringAdvice?: string; + lightAdvice?: string; + generalTips?: string[]; +} + +async function authHeader(): Promise> { + const token = await authStore.getAccessToken(); + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +/** Upload a photo file to mana-api → S3 (mana-media). */ +export async function uploadPlantPhoto(file: File, plantId: string): Promise { + const formData = new FormData(); + formData.append('file', file); + formData.append('plantId', plantId); + + const res = await fetch(`${getManaApiUrl()}/api/v1/planta/photos/upload`, { + method: 'POST', + headers: await authHeader(), + body: formData, + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Upload failed (${res.status}): ${body || res.statusText}`); + } + + return res.json() as Promise; +} + +/** Run AI identification on a previously uploaded photo URL. */ +export async function identifyPlant(photoUrl: string): Promise { + const res = await fetch(`${getManaApiUrl()}/api/v1/planta/analysis/identify`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(await authHeader()), + }, + body: JSON.stringify({ photoUrl }), + }); + + if (!res.ok) { + const body = await res.text().catch(() => ''); + throw new Error(`Identify failed (${res.status}): ${body || res.statusText}`); + } + + return res.json() as Promise; +} diff --git a/apps/mana/apps/web/src/lib/modules/planta/index.ts b/apps/mana/apps/web/src/lib/modules/planta/index.ts index d260466e4..98103a748 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/index.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/index.ts @@ -50,7 +50,11 @@ export { } from './queries'; // Mutations -export { plantMutations, wateringMutations } from './mutations'; +export { plantMutations, wateringMutations, photoMutations } from './mutations'; + +// API client (server-only operations) +export { uploadPlantPhoto, identifyPlant } from './api'; +export type { UploadPhotoResult, IdentifyResult } from './api'; // Utils export { diff --git a/apps/mana/apps/web/src/lib/modules/planta/mutations.test.ts b/apps/mana/apps/web/src/lib/modules/planta/mutations.test.ts new file mode 100644 index 000000000..b2451e530 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/planta/mutations.test.ts @@ -0,0 +1,185 @@ +/** + * Integration tests for planta mutations against a real (fake) IndexedDB. + * + * Focus: wateringMutations.logWatering — the most consequential planta + * write because it (a) appends a log and (b) re-anchors the schedule's + * nextWateringAt, which drives every "needs water" badge in the UI. + */ + +import 'fake-indexeddb/auto'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +vi.mock('@mana/shared-utils/analytics', () => ({ + PlantaEvents: { + plantCreated: vi.fn(), + plantDeleted: vi.fn(), + plantWatered: vi.fn(), + }, +})); + +// Database hooks call into funnel-tracking + trigger registry on every +// write. They reach for browser-only globals (localStorage), so stub them +// the same way cycles.integration.test.ts does. +vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() })); +vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); +vi.mock('$lib/triggers/inline-suggest', () => ({ + checkInlineSuggestion: vi.fn().mockResolvedValue(null), +})); + +import { db } from '$lib/data/database'; +import { setCurrentUserId } from '$lib/data/current-user'; +import { generateMasterKey, MemoryKeyProvider, setKeyProvider } from '$lib/data/crypto'; +import { wateringMutations } from './mutations'; +import type { LocalWateringLog, LocalWateringSchedule } from './types'; + +const wateringLogs = () => db.table('wateringLogs'); +const wateringSchedules = () => db.table('wateringSchedules'); + +beforeEach(async () => { + setCurrentUserId('test-user'); + // Planta `plants` table is encrypted; install a real Web Crypto key + // so any incidental reads/writes to it succeed. Watering tables + // themselves are plaintext, but the test harness still requires the + // vault to be unlocked because shared hooks call into the provider. + const key = await generateMasterKey(); + const provider = new MemoryKeyProvider(); + provider.setKey(key); + setKeyProvider(provider); + + await wateringLogs().clear(); + await wateringSchedules().clear(); + await db.table('plants').clear(); + await db.table('_pendingChanges').clear(); + await db.table('_activity').clear(); +}); + +describe('wateringMutations.logWatering', () => { + it('appends a watering log entry tagged with the plant id', async () => { + await wateringMutations.logWatering('plant-1'); + + const logs = await wateringLogs().toArray(); + expect(logs).toHaveLength(1); + expect(logs[0].plantId).toBe('plant-1'); + expect(logs[0].wateredAt).toBeTruthy(); + expect(logs[0].id).toBeTruthy(); + }); + + it('persists optional notes', async () => { + await wateringMutations.logWatering('plant-1', 'Etwas Dünger dazu'); + + const logs = await wateringLogs().toArray(); + expect(logs[0].notes).toBe('Etwas Dünger dazu'); + }); + + it('does not touch the schedule when none exists for the plant', async () => { + await wateringMutations.logWatering('plant-1'); + + const schedules = await wateringSchedules().toArray(); + expect(schedules).toHaveLength(0); + }); + + it('re-anchors nextWateringAt to now + frequencyDays for an existing schedule', async () => { + // Stale "next" date that should be replaced. + await wateringSchedules().add({ + id: 'sched-1', + plantId: 'plant-1', + frequencyDays: 7, + lastWateredAt: '2026-04-01T12:00:00.000Z', + nextWateringAt: '2026-04-08T12:00:00.000Z', + reminderEnabled: false, + reminderHoursBefore: 0, + createdAt: '2026-04-01T12:00:00.000Z', + updatedAt: '2026-04-01T12:00:00.000Z', + }); + + // Capture a tight window around the call so we can assert the new + // anchor falls inside it without depending on a frozen clock — + // fake timers and Dexie's microtask scheduler don't play nicely. + const before = Date.now(); + await wateringMutations.logWatering('plant-1'); + const after = Date.now(); + + const updated = await wateringSchedules().get('sched-1'); + expect(updated?.lastWateredAt).toBeTruthy(); + + const lastMs = new Date(updated!.lastWateredAt!).getTime(); + expect(lastMs).toBeGreaterThanOrEqual(before); + expect(lastMs).toBeLessThanOrEqual(after); + + const nextMs = new Date(updated!.nextWateringAt!).getTime(); + const expectedDelta = 7 * 24 * 60 * 60 * 1000; + // next should be ~ now + 7 days (within the same window slack) + expect(nextMs - lastMs).toBeGreaterThanOrEqual(expectedDelta - 1000); + expect(nextMs - lastMs).toBeLessThanOrEqual(expectedDelta + 1000); + }); + + it('does not update schedules of other plants', async () => { + await wateringSchedules().bulkAdd([ + { + id: 'sched-1', + plantId: 'plant-1', + frequencyDays: 7, + lastWateredAt: null, + nextWateringAt: '2026-04-08T12:00:00.000Z', + reminderEnabled: false, + reminderHoursBefore: 0, + createdAt: '2026-04-01T12:00:00.000Z', + updatedAt: '2026-04-01T12:00:00.000Z', + }, + { + id: 'sched-2', + plantId: 'plant-2', + frequencyDays: 3, + lastWateredAt: null, + nextWateringAt: '2026-04-09T12:00:00.000Z', + reminderEnabled: false, + reminderHoursBefore: 0, + createdAt: '2026-04-01T12:00:00.000Z', + updatedAt: '2026-04-01T12:00:00.000Z', + }, + ]); + + await wateringMutations.logWatering('plant-1'); + + const other = await wateringSchedules().get('sched-2'); + expect(other?.nextWateringAt).toBe('2026-04-09T12:00:00.000Z'); // untouched + expect(other?.lastWateredAt).toBeNull(); + }); + + it('skips soft-deleted schedules', async () => { + await wateringSchedules().add({ + id: 'sched-1', + plantId: 'plant-1', + frequencyDays: 7, + lastWateredAt: null, + nextWateringAt: '2026-04-08T12:00:00.000Z', + reminderEnabled: false, + reminderHoursBefore: 0, + createdAt: '2026-04-01T12:00:00.000Z', + updatedAt: '2026-04-01T12:00:00.000Z', + deletedAt: '2026-04-02T12:00:00.000Z', + }); + + await wateringMutations.logWatering('plant-1'); + + const stored = await wateringSchedules().get('sched-1'); + // Soft-deleted schedule should NOT have been re-anchored + expect(stored?.lastWateredAt).toBeNull(); + expect(stored?.nextWateringAt).toBe('2026-04-08T12:00:00.000Z'); + + // But the log entry itself should still be appended + const logs = await wateringLogs().toArray(); + expect(logs).toHaveLength(1); + }); + + it('appends multiple logs across calls without overwriting prior entries', async () => { + await wateringMutations.logWatering('plant-1'); + await wateringMutations.logWatering('plant-1'); + await wateringMutations.logWatering('plant-1'); + + const logs = await wateringLogs().toArray(); + expect(logs).toHaveLength(3); + const ids = new Set(logs.map((l) => l.id)); + expect(ids.size).toBe(3); // unique ids + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/planta/mutations.ts b/apps/mana/apps/web/src/lib/modules/planta/mutations.ts index 3a6c9b5f2..afcc5c7a5 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/mutations.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/mutations.ts @@ -1,15 +1,18 @@ /** * Planta — Mutation Helpers (Local-First) * - * All writes go to IndexedDB first, sync handles the rest. + * All writes go to IndexedDB first, sync handles the rest. Mutations throw + * on failure so UI callers can surface errors via toasts. */ import { db } from '$lib/data/database'; import { toPlant, toWateringSchedule } from './queries'; import { PlantaEvents } from '@mana/shared-utils/analytics'; -import { encryptRecord } from '$lib/data/crypto'; +import { encryptRecord, decryptRecord } from '$lib/data/crypto'; +import { uploadPlantPhoto, identifyPlant, type IdentifyResult } from './api'; import type { LocalPlant, + LocalPlantPhoto, LocalWateringSchedule, LocalWateringLog, Plant, @@ -18,156 +21,219 @@ import type { } from './types'; export const plantMutations = { - async create(dto: CreatePlantDto): Promise { - try { - const now = new Date().toISOString(); - 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, - createdAt: now, - updatedAt: now, - }; - const plaintextSnapshot = toPlant(newLocal); - await encryptRecord('plants', newLocal); - await db.table('plants').add(newLocal); - PlantaEvents.plantCreated(); - return plaintextSnapshot; - } catch (e) { - console.error('Failed to create plant:', e); - return null; - } + async create(dto: CreatePlantDto): Promise { + const now = new Date().toISOString(); + 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, + createdAt: now, + updatedAt: now, + }; + const plaintextSnapshot = toPlant(newLocal); + await encryptRecord('plants', newLocal); + await db.table('plants').add(newLocal); + PlantaEvents.plantCreated(); + return plaintextSnapshot; }, - async update(id: string, dto: UpdatePlantDto): Promise { - try { - const updateData: Record = { - updatedAt: new Date().toISOString(), - }; - 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; + async update(id: string, dto: UpdatePlantDto): Promise { + const updateData: Record = { + updatedAt: new Date().toISOString(), + }; + 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; - await encryptRecord('plants', updateData); - await db.table('plants').update(id, updateData); - // Re-read decrypts via the queries layer if a query is consumed. - // Direct returns from this function need explicit decryption. - const { decryptRecord } = await import('$lib/data/crypto'); - const updated = await db.table('plants').get(id); - if (!updated) return null; - const decrypted = await decryptRecord('plants', { ...updated }); - return toPlant(decrypted); - } catch (e) { - console.error('Failed to update plant:', e); - return null; - } + await encryptRecord('plants', updateData); + await db.table('plants').update(id, updateData); + const updated = await db.table('plants').get(id); + if (!updated) throw new Error('Plant disappeared after update'); + const decrypted = await decryptRecord('plants', { ...updated }); + return toPlant(decrypted); }, - async delete(id: string): Promise { - try { - await db.table('plants').update(id, { - deletedAt: new Date().toISOString(), - updatedAt: new Date().toISOString(), - }); - PlantaEvents.plantDeleted(); - return true; - } catch (e) { - console.error('Failed to delete plant:', e); - return false; + async delete(id: string): Promise { + await db.table('plants').update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + PlantaEvents.plantDeleted(); + }, + + /** + * Apply an AI identification result to a plant — fills in scientific + * name and watering frequency when available, leaves user-set fields + * untouched if the user already populated them. + */ + async applyIdentification( + id: string, + result: IdentifyResult, + options: { overwrite?: boolean } = {} + ): Promise { + const existing = await db.table('plants').get(id); + if (!existing) throw new Error('Plant not found'); + const decrypted = await decryptRecord('plants', { ...existing }); + + const update: UpdatePlantDto = {}; + if (result.scientificName && (options.overwrite || !decrypted.scientificName)) { + update.scientificName = result.scientificName; } + if (result.commonNames?.[0] && (options.overwrite || !decrypted.commonName)) { + update.commonName = result.commonNames[0]; + } + const tipsBlock = [result.wateringAdvice, result.lightAdvice, ...(result.generalTips ?? [])] + .filter(Boolean) + .join('\n'); + if (tipsBlock && (options.overwrite || !decrypted.careNotes)) { + update.careNotes = tipsBlock; + } + + if (Object.keys(update).length === 0) return toPlant(decrypted); + return plantMutations.update(id, update); }, }; export const wateringMutations = { - async logWatering(plantId: string, notes?: string): Promise { - try { - const now = new Date().toISOString(); + async logWatering(plantId: string, notes?: string): Promise { + const now = new Date().toISOString(); - // Create watering log entry - const logEntry: LocalWateringLog = { - id: crypto.randomUUID(), - plantId, - wateredAt: now, - notes: notes ?? null, - createdAt: now, + // Create watering log entry + const logEntry: LocalWateringLog = { + id: crypto.randomUUID(), + plantId, + wateredAt: now, + notes: notes ?? null, + createdAt: now, + updatedAt: now, + }; + await db.table('wateringLogs').add(logEntry); + + // Update watering schedule + const schedules = await db.table('wateringSchedules').toArray(); + const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt); + if (schedule) { + const nextDate = new Date(); + nextDate.setDate(nextDate.getDate() + schedule.frequencyDays); + + await db.table('wateringSchedules').update(schedule.id, { + lastWateredAt: now, + nextWateringAt: nextDate.toISOString(), updatedAt: now, - }; - await db.table('wateringLogs').add(logEntry); - - // Update watering schedule - const schedules = await db.table('wateringSchedules').toArray(); - const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt); - if (schedule) { - const nextDate = new Date(); - nextDate.setDate(nextDate.getDate() + schedule.frequencyDays); - - await db.table('wateringSchedules').update(schedule.id, { - lastWateredAt: now, - nextWateringAt: nextDate.toISOString(), - updatedAt: now, - }); - } - - PlantaEvents.plantWatered(); - return true; - } catch (e) { - console.error('Failed to log watering:', e); - return false; + }); } + + PlantaEvents.plantWatered(); }, - async updateSchedule(plantId: string, frequencyDays: number): Promise { - try { - const now = new Date().toISOString(); - const schedules = await db.table('wateringSchedules').toArray(); - const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt); + async updateSchedule(plantId: string, frequencyDays: number): Promise { + const now = new Date().toISOString(); + const schedules = await db.table('wateringSchedules').toArray(); + const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt); - if (schedule) { - const nextDate = schedule.lastWateredAt - ? new Date(new Date(schedule.lastWateredAt).getTime() + frequencyDays * 86400000) - : new Date(Date.now() + frequencyDays * 86400000); + if (schedule) { + const nextDate = schedule.lastWateredAt + ? new Date(new Date(schedule.lastWateredAt).getTime() + frequencyDays * 86400000) + : new Date(Date.now() + frequencyDays * 86400000); - await db.table('wateringSchedules').update(schedule.id, { - frequencyDays, - nextWateringAt: nextDate.toISOString(), - updatedAt: now, - }); - } else { - const nextDate = new Date(Date.now() + frequencyDays * 86400000); - await db.table('wateringSchedules').add({ - id: crypto.randomUUID(), - plantId, - frequencyDays, - lastWateredAt: null, - nextWateringAt: nextDate.toISOString(), - reminderEnabled: false, - reminderHoursBefore: 0, - createdAt: now, - updatedAt: now, - }); - } - return true; - } catch (e) { - console.error('Failed to update watering schedule:', e); - return false; + await db.table('wateringSchedules').update(schedule.id, { + frequencyDays, + nextWateringAt: nextDate.toISOString(), + updatedAt: now, + }); + } else { + const nextDate = new Date(Date.now() + frequencyDays * 86400000); + await db.table('wateringSchedules').add({ + id: crypto.randomUUID(), + plantId, + frequencyDays, + lastWateredAt: null, + nextWateringAt: nextDate.toISOString(), + reminderEnabled: false, + reminderHoursBefore: 0, + createdAt: now, + updatedAt: now, + }); } }, }; + +export const photoMutations = { + /** + * Upload a photo to mana-api and persist a plantPhoto record locally. + * Returns the new photo so callers can immediately trigger AI identification. + */ + async upload(plantId: string, file: File): Promise { + const result = await uploadPlantPhoto(file, plantId); + const now = new Date().toISOString(); + + // Mark as primary if this is the first photo for the plant. + const existing = await db.table('plantPhotos').toArray(); + const hasPrimary = existing.some((p) => p.plantId === plantId && p.isPrimary && !p.deletedAt); + + const photo: LocalPlantPhoto = { + id: crypto.randomUUID(), + plantId, + storagePath: result.storagePath, + publicUrl: result.publicUrl, + filename: file.name, + mimeType: file.type || null, + fileSize: file.size, + width: null, + height: null, + isPrimary: !hasPrimary, + isAnalyzed: false, + takenAt: now, + createdAt: now, + updatedAt: now, + }; + await db.table('plantPhotos').add(photo); + return photo; + }, + + async identify(photoId: string): Promise { + const photo = await db.table('plantPhotos').get(photoId); + if (!photo?.publicUrl) throw new Error('Photo has no public URL'); + const result = await identifyPlant(photo.publicUrl); + await db.table('plantPhotos').update(photoId, { + isAnalyzed: true, + updatedAt: new Date().toISOString(), + }); + return result; + }, + + async setPrimary(plantId: string, photoId: string): Promise { + const all = await db.table('plantPhotos').toArray(); + const now = new Date().toISOString(); + for (const p of all) { + if (p.plantId !== plantId || p.deletedAt) continue; + const shouldBe = p.id === photoId; + if (p.isPrimary !== shouldBe) { + await db.table('plantPhotos').update(p.id, { isPrimary: shouldBe, updatedAt: now }); + } + } + }, + + async remove(photoId: string): Promise { + const now = new Date().toISOString(); + await db.table('plantPhotos').update(photoId, { deletedAt: now, updatedAt: now }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/planta/queries.test.ts b/apps/mana/apps/web/src/lib/modules/planta/queries.test.ts new file mode 100644 index 000000000..bcbb28f11 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/planta/queries.test.ts @@ -0,0 +1,138 @@ +/** + * Pure-function tests for planta queries. + * + * Covers the watering date math that drives every "needs water" badge in + * the UI — getting this wrong silently causes bad reminders, so it's worth + * pinning down with explicit cases for today / overdue / future / missing. + */ + +import { describe, expect, it, beforeEach, afterEach, vi } from 'vitest'; +import { + getDaysUntilWatering, + isWateringOverdue, + getScheduleForPlant, + getLogsForPlant, +} from './queries'; +import type { WateringSchedule, WateringLog } from './types'; + +function makeSchedule(overrides: Partial = {}): WateringSchedule { + return { + id: 's1', + plantId: 'p1', + frequencyDays: 7, + lastWateredAt: undefined, + nextWateringAt: undefined, + reminderEnabled: false, + reminderHoursBefore: 0, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + updatedAt: new Date('2026-04-01T00:00:00.000Z'), + ...overrides, + }; +} + +describe('getDaysUntilWatering', () => { + beforeEach(() => { + vi.useFakeTimers(); + // Pin "now" so day-math is deterministic. + vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z')); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('returns null when schedule is undefined', () => { + expect(getDaysUntilWatering(undefined)).toBeNull(); + }); + + it('returns null when nextWateringAt is missing', () => { + const s = makeSchedule({ nextWateringAt: undefined }); + expect(getDaysUntilWatering(s)).toBeNull(); + }); + + it('returns 0 when next watering is later today', () => { + const s = makeSchedule({ nextWateringAt: new Date('2026-04-09T18:00:00.000Z') }); + expect(getDaysUntilWatering(s)).toBe(1); // 6h ahead → ceil → 1 + }); + + it('returns the exact remaining whole days for a future date', () => { + const s = makeSchedule({ nextWateringAt: new Date('2026-04-12T12:00:00.000Z') }); + expect(getDaysUntilWatering(s)).toBe(3); + }); + + it('returns negative days when overdue', () => { + const s = makeSchedule({ nextWateringAt: new Date('2026-04-06T12:00:00.000Z') }); + expect(getDaysUntilWatering(s)).toBe(-3); + }); + + it('rounds up partial days (ceil) for "almost a week from now"', () => { + // 6 days + 1 hour from now → should report 7, not 6 + const s = makeSchedule({ nextWateringAt: new Date('2026-04-15T13:00:00.000Z') }); + expect(getDaysUntilWatering(s)).toBe(7); + }); + + it('accepts a string nextWateringAt (defensive — type says Date but Dexie returns ISO)', () => { + const s = makeSchedule({ + nextWateringAt: '2026-04-12T12:00:00.000Z' as unknown as Date, + }); + expect(getDaysUntilWatering(s)).toBe(3); + }); +}); + +describe('isWateringOverdue', () => { + beforeEach(() => { + vi.useFakeTimers(); + vi.setSystemTime(new Date('2026-04-09T12:00:00.000Z')); + }); + afterEach(() => vi.useRealTimers()); + + it('is false when schedule is undefined', () => { + expect(isWateringOverdue(undefined)).toBe(false); + }); + + it('is false when next watering is in the future', () => { + expect( + isWateringOverdue(makeSchedule({ nextWateringAt: new Date('2026-04-12T12:00:00.000Z') })) + ).toBe(false); + }); + + it('is true when next watering is in the past', () => { + expect( + isWateringOverdue(makeSchedule({ nextWateringAt: new Date('2026-04-06T12:00:00.000Z') })) + ).toBe(true); + }); +}); + +describe('getScheduleForPlant', () => { + it('returns the matching schedule by plantId', () => { + const a = makeSchedule({ id: 'a', plantId: 'p1' }); + const b = makeSchedule({ id: 'b', plantId: 'p2' }); + expect(getScheduleForPlant([a, b], 'p2')?.id).toBe('b'); + }); + + it('returns undefined when no schedule matches', () => { + expect(getScheduleForPlant([], 'p1')).toBeUndefined(); + }); +}); + +describe('getLogsForPlant', () => { + const makeLog = (overrides: Partial): WateringLog => ({ + id: 'l', + plantId: 'p1', + wateredAt: new Date('2026-04-01T00:00:00.000Z'), + notes: undefined, + createdAt: new Date('2026-04-01T00:00:00.000Z'), + ...overrides, + }); + + it('filters by plantId and sorts newest first', () => { + const logs: WateringLog[] = [ + makeLog({ id: '1', plantId: 'p1', wateredAt: new Date('2026-04-01T00:00:00.000Z') }), + makeLog({ id: '2', plantId: 'p2', wateredAt: new Date('2026-04-05T00:00:00.000Z') }), + makeLog({ id: '3', plantId: 'p1', wateredAt: new Date('2026-04-07T00:00:00.000Z') }), + makeLog({ id: '4', plantId: 'p1', wateredAt: new Date('2026-04-03T00:00:00.000Z') }), + ]; + const result = getLogsForPlant(logs, 'p1'); + expect(result.map((l) => l.id)).toEqual(['3', '4', '1']); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/planta/queries.ts b/apps/mana/apps/web/src/lib/modules/planta/queries.ts index 1b7add0d8..c36087cb5 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/queries.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/queries.ts @@ -9,9 +9,11 @@ import { liveQuery } from 'dexie'; import { db } from '$lib/data/database'; import { decryptRecords } from '$lib/data/crypto'; +import type { Tag } from '@mana/shared-tags'; import type { LocalPlant, LocalPlantPhoto, + LocalPlantTag, LocalWateringSchedule, LocalWateringLog, Plant, @@ -20,6 +22,9 @@ import type { WateringLog, } from './types'; +// Re-export the global tag query so callers don't need a separate import. +export { useAllTags } from '@mana/shared-stores'; + // ─── Type Converters ─────────────────────────────────────── /** Convert a LocalPlant (IndexedDB) to the shared Plant type. */ @@ -124,6 +129,14 @@ export function useAllWateringLogs() { }); } +/** All plant↔tag junctions (active only). */ +export function useAllPlantTags() { + return liveQuery(async () => { + const locals = await db.table('plantTags').toArray(); + return locals.filter((t) => !t.deletedAt); + }); +} + // ─── Pure Plant Helpers ──────────────────────────────────── /** Get a plant by ID. */ @@ -176,3 +189,11 @@ export function isWateringOverdue(schedule: WateringSchedule | undefined): boole const days = getDaysUntilWatering(schedule); return days !== null && days < 0; } + +// ─── Tag Helpers ─────────────────────────────────────────── + +/** Resolve the global Tag objects attached to a single plant. */ +export function getTagsForPlant(tags: Tag[], plantTags: LocalPlantTag[], plantId: string): Tag[] { + const tagIds = new Set(plantTags.filter((pt) => pt.plantId === plantId).map((pt) => pt.tagId)); + return tags.filter((t) => tagIds.has(t.id)); +} diff --git a/apps/mana/apps/web/src/lib/modules/planta/quick-input-adapter.ts b/apps/mana/apps/web/src/lib/modules/planta/quick-input-adapter.ts index 03d559690..234670235 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/quick-input-adapter.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/quick-input-adapter.ts @@ -3,10 +3,11 @@ */ import type { InputBarAdapter } from '$lib/quick-input/types'; -import type { QuickInputItem } from '@mana/shared-ui'; import { db } from '$lib/data/database'; +import { decryptRecords } from '$lib/data/crypto'; import { parsePlantInput, formatParsedPlantPreview } from './utils/plant-parser'; -import { plantTable } from './collections'; +import { plantMutations } from './mutations'; +import type { LocalPlant } from './types'; export function createAdapter(): InputBarAdapter { return { @@ -18,19 +19,22 @@ export function createAdapter(): InputBarAdapter { async onSearch(query) { const q = query.toLowerCase(); - const plants = await db.table('plants').toArray(); - return (plants as Record[]) - .filter( - (p) => - !(p.deletedAt as string) && - ((p.name as string)?.toLowerCase().includes(q) || - (p.species as string)?.toLowerCase().includes(q)) - ) + // `name` is encrypted on disk — decrypt before substring matching. + const raw = await db.table('plants').toArray(); + const visible = raw.filter((p) => !p.deletedAt); + const decrypted = await decryptRecords('plants', visible); + return decrypted + .filter((p) => { + const name = p.name?.toLowerCase() ?? ''; + const sci = p.scientificName?.toLowerCase() ?? ''; + const common = p.commonName?.toLowerCase() ?? ''; + return name.includes(q) || sci.includes(q) || common.includes(q); + }) .slice(0, 10) .map((p) => ({ - id: p.id as string, - title: (p.name as string) || '', - subtitle: (p.species as string) || (p.location as string) || '', + id: p.id, + title: p.name || '', + subtitle: p.scientificName || p.commonName || '', })); }, @@ -49,10 +53,9 @@ export function createAdapter(): InputBarAdapter { async onCreate(query) { if (!query.trim()) return; const parsed = parsePlantInput(query); - await plantTable.add({ - id: crypto.randomUUID(), + await plantMutations.create({ name: parsed.name, - species: parsed.species, + acquiredAt: parsed.acquiredAt?.toISOString(), }); }, }; diff --git a/apps/mana/apps/web/src/lib/modules/planta/types.ts b/apps/mana/apps/web/src/lib/modules/planta/types.ts index ce1a804d3..732e1754e 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/types.ts +++ b/apps/mana/apps/web/src/lib/modules/planta/types.ts @@ -58,6 +58,11 @@ export interface LocalWateringLog extends BaseRecord { notes?: string | null; } +export interface LocalPlantTag extends BaseRecord { + plantId: string; + tagId: string; +} + // ─── Shared Domain Types ─────────────────────────────────── export interface Plant { diff --git a/apps/mana/apps/web/src/lib/modules/planta/views/DetailView.svelte b/apps/mana/apps/web/src/lib/modules/planta/views/DetailView.svelte index fe96d0b4a..92ddd3dfb 100644 --- a/apps/mana/apps/web/src/lib/modules/planta/views/DetailView.svelte +++ b/apps/mana/apps/web/src/lib/modules/planta/views/DetailView.svelte @@ -1,13 +1,28 @@ detail.deleteWithUndo({ - label: 'Pflanze gelöscht', + label: $_('planta.success.plantDeleted'), delete: deletePlant, goBack, })} @@ -107,75 +243,75 @@ bind:value={editName} onfocus={detail.focus} onblur={saveField} - placeholder="Name..." + placeholder={$_('planta.plant.namePlaceholder')} />
- Wissenschaftlicher Name + {$_('planta.plant.scientificName')}
- Art + {$_('planta.plant.species')}
- Zustand + {$_('planta.plant.state')}
- Licht + {$_('planta.plant.light')}
- Gießen (Tage) + {$_('planta.plant.wateringDays')}
- Erworben + {$_('planta.plant.acquired')}
- +
+
+
+ +
+ + {#if photos.length > 0} + + {/if} +
+
+ + + {#if photos.length === 0} +

{$_('planta.photo.noPhotos')}

+ {:else} +
+ {#each photos as photo (photo.id)} +
+ {plant.name} +
+ {#if !photo.isPrimary} + + {/if} + +
+
+ {/each} +
+ {/if} + + {#if identifyResult} +
+
+ {$_('planta.identify.resultTitle')} + +
+ {#if identifyResult.scientificName} +

+ {$_('planta.plant.scientificName')}: + {identifyResult.scientificName} +

+ {/if} + {#if identifyResult.commonNames?.length} +

{identifyResult.commonNames.join(', ')}

+ {/if} + {#if identifyResult.confidence !== undefined} +

+ {$_('planta.identify.confidence')}: {Math.round(identifyResult.confidence * 100)}% +

+ {/if} + {#if identifyResult.wateringAdvice} +

{identifyResult.wateringAdvice}

+ {/if} + {#if identifyResult.lightAdvice} +

{identifyResult.lightAdvice}

+ {/if} +
+ {/if} +
+ + +
+ +
+ {#each attachedTags as tag (tag.id)} + + {tag.name} + + + {/each} +
+ + {#if showTagPicker && availableTags.length > 0} + + {/if} +
+
+
+ + + {#if wateringLogs.length > 0} +
+ +
    + {#each wateringLogs as log (log.id)} +
  • + {formatLogDate(log.wateredAt)} + {#if log.notes} + {log.notes} + {/if} +
  • + {/each} +
+
+ {/if} +
- Erstellt: {new Date(plant.createdAt ?? '').toLocaleDateString('de')} + {$_('planta.plant.created')}: {new Date(plant.createdAt ?? '').toLocaleDateString()} {#if plant.updatedAt} - Bearbeitet: {new Date(plant.updatedAt).toLocaleDateString('de')} + {$_('planta.plant.edited')}: {new Date(plant.updatedAt).toLocaleDateString()} {/if}
{/snippet} + +