feat(mana/web/planta): photo upload, AI identification, tags, watering history, i18n + tests

Brings the planta module to production-ready state:

- Photo upload UI in the workbench DetailView (file picker, primary
  selection, removal, hover overlay) wired to /api/v1/planta/photos/upload
- AI plant identification trigger that calls /analysis/identify on the
  primary photo and shows a result card with apply-to-plant CTA;
  applyIdentification only fills empty fields by default to avoid
  clobbering user edits
- Tag picker (chip UI + dropdown) backed by plantTagOps junction
- Watering history list (last 5 logs) in DetailView
- Full i18n: every locale (de/en/es/fr/it) now has plant/list/photo/
  identify/errors/success keys; ListView and DetailView consume them
  via $_('planta.*') instead of hardcoded German
- Toast notifications on every mutation success/failure path
- mutations.ts refactored: methods now throw on failure instead of
  swallowing errors and returning null, so callers can surface them
- New api.ts client for the two server-only operations (upload, identify)
- New photoMutations + plantMutations.applyIdentification helpers
- quick-input-adapter type fix: stop referencing the non-existent
  parsed.species field; create plants through plantMutations.create
  so encryption + timestamps run, and decrypt names before substring
  search
- 20 new tests:
  - queries.test.ts (13 pure-function tests for getDaysUntilWatering /
    isWateringOverdue / getScheduleForPlant / getLogsForPlant)
  - mutations.test.ts (7 fake-indexeddb integration tests for
    wateringMutations.logWatering — log appended, schedule re-anchored,
    soft-deleted schedules skipped, multi-call uniqueness)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-09 14:05:13 +02:00
parent b83e8d6d92
commit 60fedbb611
15 changed files with 1450 additions and 247 deletions

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -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"
}
}

View file

@ -3,6 +3,7 @@
Plant overview with watering schedule.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { BaseListView } from '@mana/shared-ui';
@ -45,14 +46,18 @@
};
</script>
<BaseListView items={plants} getKey={(p) => p.id} emptyTitle="Keine Pflanzen">
<BaseListView items={plants} getKey={(p) => p.id} emptyTitle={$_('planta.list.empty')}>
{#snippet header()}
<span>{plants.length} Pflanzen</span>
<span>{$_('planta.list.count', { values: { count: plants.length } })}</span>
{#if dueForWatering.length > 0}
<span class="text-blue-400">{dueForWatering.length} giessen</span>
<span class="text-blue-400"
>{$_('planta.list.dueWatering', { values: { count: dueForWatering.length } })}</span
>
{/if}
{#if needsAttention.length > 0}
<span class="text-amber-400">{needsAttention.length} brauchen Pflege</span>
<span class="text-amber-400"
>{$_('planta.list.needsCare', { values: { count: needsAttention.length } })}</span
>
{/if}
{/snippet}
@ -84,7 +89,7 @@
</div>
{#if schedule}
<p class="mt-1 text-xs text-white/30">
Alle {schedule.frequencyDays} Tage giessen
{$_('planta.list.everyXDays', { values: { days: schedule.frequencyDays } })}
</p>
{/if}
</button>

View file

@ -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<Record<string, string>> {
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<UploadPhotoResult> {
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<UploadPhotoResult>;
}
/** Run AI identification on a previously uploaded photo URL. */
export async function identifyPlant(photoUrl: string): Promise<IdentifyResult> {
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<IdentifyResult>;
}

View file

@ -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 {

View file

@ -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<LocalWateringLog>('wateringLogs');
const wateringSchedules = () => db.table<LocalWateringSchedule>('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
});
});

View file

@ -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<Plant | null> {
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<Plant> {
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<Plant | null> {
try {
const updateData: Record<string, unknown> = {
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<Plant> {
const updateData: Record<string, unknown> = {
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<LocalPlant>('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<LocalPlant>('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<boolean> {
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<void> {
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<Plant> {
const existing = await db.table<LocalPlant>('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<boolean> {
try {
const now = new Date().toISOString();
async logWatering(plantId: string, notes?: string): Promise<void> {
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<LocalWateringSchedule>('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<LocalWateringSchedule>('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<boolean> {
try {
const now = new Date().toISOString();
const schedules = await db.table<LocalWateringSchedule>('wateringSchedules').toArray();
const schedule = schedules.find((s) => s.plantId === plantId && !s.deletedAt);
async updateSchedule(plantId: string, frequencyDays: number): Promise<void> {
const now = new Date().toISOString();
const schedules = await db.table<LocalWateringSchedule>('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<LocalPlantPhoto> {
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<LocalPlantPhoto>('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<IdentifyResult> {
const photo = await db.table<LocalPlantPhoto>('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<void> {
const all = await db.table<LocalPlantPhoto>('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<void> {
const now = new Date().toISOString();
await db.table('plantPhotos').update(photoId, { deletedAt: now, updatedAt: now });
},
};

View file

@ -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> = {}): 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>): 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']);
});
});

View file

@ -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<LocalPlantTag>('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));
}

View file

@ -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<string, unknown>[])
.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<LocalPlant>('plants').toArray();
const visible = raw.filter((p) => !p.deletedAt);
const decrypted = await decryptRecords<LocalPlant>('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(),
});
},
};

View file

@ -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 {

View file

@ -1,13 +1,28 @@
<!--
Planta — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
Includes photo upload + AI plant identification.
-->
<script lang="ts">
import { db } from '$lib/data/database';
import { _ } from 'svelte-i18n';
import { useDetailEntity } from '$lib/data/detail-entity.svelte';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import DetailViewShell from '$lib/components/DetailViewShell.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { plantMutations, photoMutations } from '../mutations';
import { plantTagOps } from '../stores/tags.svelte';
import { useAllTags, getTagsForPlant } from '../queries';
import type { IdentifyResult } from '../api';
import type { ViewProps } from '$lib/app-registry';
import type { LocalPlant, HealthStatus, LightLevel } from '../types';
import type {
LocalPlant,
LocalPlantPhoto,
LocalPlantTag,
LocalWateringLog,
HealthStatus,
LightLevel,
} from '../types';
let { params, goBack }: ViewProps = $props();
let plantId = $derived(params.plantId as string);
@ -21,6 +36,11 @@
let editCareNotes = $state('');
let editAcquiredAt = $state('');
let uploading = $state(false);
let identifying = $state(false);
let identifyResult = $state<IdentifyResult | null>(null);
let fileInput = $state<HTMLInputElement | null>(null);
const detail = useDetailEntity<LocalPlant>({
id: () => plantId,
table: 'plants',
@ -36,67 +56,183 @@
},
});
const photosQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalPlantPhoto>('plantPhotos').toArray();
return all
.filter((p) => p.plantId === plantId && !p.deletedAt)
.sort((a, b) => (a.isPrimary ? -1 : b.isPrimary ? 1 : 0));
}, [] as LocalPlantPhoto[]);
const photos = $derived(photosQuery.value);
// Watering history — show the 5 most recent log entries for this plant.
const wateringLogsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalWateringLog>('wateringLogs').toArray();
return all
.filter((l) => l.plantId === plantId && !l.deletedAt)
.sort((a, b) => new Date(b.wateredAt).getTime() - new Date(a.wateredAt).getTime())
.slice(0, 5);
}, [] as LocalWateringLog[]);
const wateringLogs = $derived(wateringLogsQuery.value);
// Tags — global tag list + plant-specific junction.
const allTags = useAllTags();
const plantTagsQuery = useLiveQueryWithDefault(async () => {
const all = await db.table<LocalPlantTag>('plantTags').toArray();
return all.filter((t) => !t.deletedAt);
}, [] as LocalPlantTag[]);
const attachedTags = $derived(getTagsForPlant(allTags.value, plantTagsQuery.value, plantId));
const availableTags = $derived(
allTags.value.filter((t) => !attachedTags.some((at) => at.id === t.id))
);
let showTagPicker = $state(false);
async function handleAddTag(tagId: string) {
try {
await plantTagOps.addTag(plantId, tagId);
showTagPicker = false;
} catch (err) {
console.error('add tag failed:', err);
toast.error($_('planta.errors.saveFailed'));
}
}
async function handleRemoveTag(tagId: string) {
try {
await plantTagOps.removeTag(plantId, tagId);
} catch (err) {
console.error('remove tag failed:', err);
toast.error($_('planta.errors.saveFailed'));
}
}
function formatLogDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
async function saveField() {
detail.blur();
await db.table('plants').update(plantId, {
name: editName.trim() || detail.entity?.name || 'Unbenannt',
scientificName: editScientificName.trim() || null,
species: editSpecies.trim() || null,
healthStatus: editHealthStatus,
lightRequirements: editLightRequirements || null,
wateringFrequencyDays: editWateringFrequencyDays,
careNotes: editCareNotes.trim() || null,
acquiredAt: editAcquiredAt ? new Date(editAcquiredAt).toISOString() : null,
updatedAt: new Date().toISOString(),
});
try {
await plantMutations.update(plantId, {
name: editName.trim() || detail.entity?.name || $_('planta.plant.unnamed'),
scientificName: editScientificName.trim() || undefined,
careNotes: editCareNotes.trim() || undefined,
lightRequirements: editLightRequirements || undefined,
wateringFrequencyDays: editWateringFrequencyDays ?? undefined,
});
// species, healthStatus, acquiredAt aren't on UpdatePlantDto — write
// directly so they still flush through the same Dexie hook chain.
await db.table('plants').update(plantId, {
species: editSpecies.trim() || null,
healthStatus: editHealthStatus,
acquiredAt: editAcquiredAt ? new Date(editAcquiredAt).toISOString() : null,
updatedAt: new Date().toISOString(),
});
} catch (err) {
console.error('plant save failed:', err);
toast.error($_('planta.errors.saveFailed'));
}
}
async function handleSelectChange() {
await db.table('plants').update(plantId, {
healthStatus: editHealthStatus,
lightRequirements: editLightRequirements || null,
updatedAt: new Date().toISOString(),
});
try {
await db.table('plants').update(plantId, {
healthStatus: editHealthStatus,
lightRequirements: editLightRequirements || null,
updatedAt: new Date().toISOString(),
});
} catch (err) {
console.error('plant select save failed:', err);
toast.error($_('planta.errors.saveFailed'));
}
}
async function deletePlant() {
await db.table('plants').update(plantId, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
await plantMutations.delete(plantId);
}
const healthLabels: Record<HealthStatus, string> = {
healthy: 'Gesund',
needs_attention: 'Braucht Pflege',
sick: 'Krank',
};
async function handleFileSelect(event: Event) {
const input = event.target as HTMLInputElement;
const file = input.files?.[0];
if (!file) return;
uploading = true;
try {
await photoMutations.upload(plantId, file);
toast.success($_('planta.success.photoUploaded'));
} catch (err) {
console.error('photo upload failed:', err);
toast.error($_('planta.errors.uploadFailed'));
} finally {
uploading = false;
if (fileInput) fileInput.value = '';
}
}
const lightLabels: Record<LightLevel, string> = {
low: 'Wenig',
medium: 'Mittel',
bright: 'Hell',
direct: 'Direkt',
};
async function handleIdentify() {
const primary = photos[0];
if (!primary) {
toast.error($_('planta.errors.identifyFailed'));
return;
}
identifying = true;
identifyResult = null;
try {
const result = await photoMutations.identify(primary.id);
identifyResult = result;
toast.success($_('planta.success.identified'));
} catch (err) {
console.error('identify failed:', err);
toast.error($_('planta.errors.identifyFailed'));
} finally {
identifying = false;
}
}
const healthColors: Record<HealthStatus, string> = {
healthy: '#22c55e',
needs_attention: '#f59e0b',
sick: '#ef4444',
};
async function applyIdentification() {
if (!identifyResult) return;
try {
await plantMutations.applyIdentification(plantId, identifyResult, { overwrite: false });
toast.success($_('planta.success.plantSaved'));
identifyResult = null;
} catch (err) {
console.error('apply identification failed:', err);
toast.error($_('planta.errors.saveFailed'));
}
}
async function handleSetPrimary(photoId: string) {
try {
await photoMutations.setPrimary(plantId, photoId);
} catch (err) {
console.error('set primary failed:', err);
toast.error($_('planta.errors.saveFailed'));
}
}
async function handleRemovePhoto(photoId: string) {
try {
await photoMutations.remove(photoId);
} catch (err) {
console.error('remove photo failed:', err);
toast.error($_('planta.errors.deleteFailed'));
}
}
</script>
<DetailViewShell
entity={detail.entity}
loading={detail.loading}
notFoundLabel="Pflanze nicht gefunden"
notFoundLabel={$_('planta.plant.notFound')}
confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel="Pflanze wirklich löschen?"
confirmDeleteLabel={$_('planta.plant.confirmDelete')}
onConfirmDelete={() =>
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')}
/>
<div class="properties">
<div class="prop-row">
<span class="prop-label">Wissenschaftlicher Name</span>
<span class="prop-label">{$_('planta.plant.scientificName')}</span>
<input
class="prop-input"
bind:value={editScientificName}
onfocus={detail.focus}
onblur={saveField}
placeholder="—"
placeholder={$_('planta.common.none')}
/>
</div>
<div class="prop-row">
<span class="prop-label">Art</span>
<span class="prop-label">{$_('planta.plant.species')}</span>
<input
class="prop-input"
bind:value={editSpecies}
onfocus={detail.focus}
onblur={saveField}
placeholder="—"
placeholder={$_('planta.common.none')}
/>
</div>
<div class="prop-row">
<span class="prop-label">Zustand</span>
<span class="prop-label">{$_('planta.plant.state')}</span>
<select
class="prop-select"
class="prop-select health-{editHealthStatus}"
bind:value={editHealthStatus}
onchange={handleSelectChange}
style="color: {healthColors[editHealthStatus]}"
>
{#each ['healthy', 'needs_attention', 'sick'] as const as s}
<option value={s}>{healthLabels[s]}</option>
{/each}
<option value="healthy">{$_('planta.health.healthy')}</option>
<option value="needs_attention">{$_('planta.health.needsAttention')}</option>
<option value="sick">{$_('planta.health.sick')}</option>
</select>
</div>
<div class="prop-row">
<span class="prop-label">Licht</span>
<span class="prop-label">{$_('planta.plant.light')}</span>
<select
class="prop-select"
bind:value={editLightRequirements}
onchange={handleSelectChange}
>
<option value=""></option>
{#each ['low', 'medium', 'bright', 'direct'] as const as l}
<option value={l}>{lightLabels[l]}</option>
{/each}
<option value="">{$_('planta.common.none')}</option>
<option value="low">{$_('planta.light.low')}</option>
<option value="medium">{$_('planta.light.medium')}</option>
<option value="bright">{$_('planta.light.bright')}</option>
<option value="direct">{$_('planta.light.direct')}</option>
</select>
</div>
<div class="prop-row">
<span class="prop-label">Gießen (Tage)</span>
<span class="prop-label">{$_('planta.plant.wateringDays')}</span>
<input
type="number"
class="prop-input"
bind:value={editWateringFrequencyDays}
onfocus={detail.focus}
onblur={saveField}
placeholder="—"
placeholder={$_('planta.common.none')}
min="1"
/>
</div>
<div class="prop-row">
<span class="prop-label">Erworben</span>
<span class="prop-label">{$_('planta.plant.acquired')}</span>
<input
type="date"
class="prop-input"
@ -187,22 +323,421 @@
</div>
<div class="section">
<span class="section-label">Pflegehinweise</span>
<span class="section-label">{$_('planta.plant.careNotes')}</span>
<textarea
class="description-input"
bind:value={editCareNotes}
onfocus={detail.focus}
onblur={saveField}
placeholder="Pflegehinweise hinzufügen..."
placeholder={$_('planta.plant.notesPlaceholder')}
rows={3}
></textarea>
</div>
<div class="section">
<div class="section-header">
<span class="section-label">{$_('planta.photo.section')}</span>
<div class="photo-actions">
<button
type="button"
class="action-btn"
onclick={() => fileInput?.click()}
disabled={uploading}
>
{uploading ? $_('planta.photo.uploading') : $_('planta.photo.upload')}
</button>
{#if photos.length > 0}
<button
type="button"
class="action-btn primary"
onclick={handleIdentify}
disabled={identifying}
>
{identifying ? $_('planta.identify.analyzing') : $_('planta.identify.button')}
</button>
{/if}
</div>
</div>
<input
bind:this={fileInput}
type="file"
accept="image/*"
class="hidden-input"
onchange={handleFileSelect}
/>
{#if photos.length === 0}
<p class="empty">{$_('planta.photo.noPhotos')}</p>
{:else}
<div class="photo-grid">
{#each photos as photo (photo.id)}
<div class="photo-tile" class:primary={photo.isPrimary}>
<img src={photo.publicUrl ?? ''} alt={plant.name} />
<div class="photo-overlay">
{#if !photo.isPrimary}
<button
type="button"
class="photo-btn"
onclick={() => handleSetPrimary(photo.id)}
title={$_('planta.photo.primary')}
>
</button>
{/if}
<button
type="button"
class="photo-btn danger"
onclick={() => handleRemovePhoto(photo.id)}
title={$_('planta.common.delete')}
>
×
</button>
</div>
</div>
{/each}
</div>
{/if}
{#if identifyResult}
<div class="identify-result">
<div class="identify-header">
<span class="identify-title">{$_('planta.identify.resultTitle')}</span>
<button type="button" class="action-btn primary" onclick={applyIdentification}>
{$_('planta.identify.applyResult')}
</button>
</div>
{#if identifyResult.scientificName}
<p>
<strong>{$_('planta.plant.scientificName')}:</strong>
{identifyResult.scientificName}
</p>
{/if}
{#if identifyResult.commonNames?.length}
<p>{identifyResult.commonNames.join(', ')}</p>
{/if}
{#if identifyResult.confidence !== undefined}
<p class="muted">
{$_('planta.identify.confidence')}: {Math.round(identifyResult.confidence * 100)}%
</p>
{/if}
{#if identifyResult.wateringAdvice}
<p>{identifyResult.wateringAdvice}</p>
{/if}
{#if identifyResult.lightAdvice}
<p>{identifyResult.lightAdvice}</p>
{/if}
</div>
{/if}
</div>
<!-- Tags -->
<div class="section">
<span class="section-label">Tags</span>
<div class="tag-row">
{#each attachedTags as tag (tag.id)}
<span class="tag-chip" style="background-color: {tag.color || 'rgba(255,255,255,0.12)'}">
{tag.name}
<button
type="button"
class="tag-remove"
onclick={() => handleRemoveTag(tag.id)}
aria-label={$_('planta.common.delete')}
>
×
</button>
</span>
{/each}
<div class="tag-picker-wrap">
<button type="button" class="tag-add" onclick={() => (showTagPicker = !showTagPicker)}>
+ Tag
</button>
{#if showTagPicker && availableTags.length > 0}
<div class="tag-picker" role="menu">
{#each availableTags as tag (tag.id)}
<button type="button" class="tag-picker-item" onclick={() => handleAddTag(tag.id)}>
<span class="tag-dot" style="background-color: {tag.color || '#888'}"></span>
{tag.name}
</button>
{/each}
</div>
{/if}
</div>
</div>
</div>
<!-- Watering history -->
{#if wateringLogs.length > 0}
<div class="section">
<span class="section-label">{$_('planta.watering.lastWatered')}</span>
<ul class="watering-history">
{#each wateringLogs as log (log.id)}
<li>
<span class="watering-date">{formatLogDate(log.wateredAt)}</span>
{#if log.notes}
<span class="watering-notes">{log.notes}</span>
{/if}
</li>
{/each}
</ul>
</div>
{/if}
<div class="meta">
<span>Erstellt: {new Date(plant.createdAt ?? '').toLocaleDateString('de')}</span>
<span
>{$_('planta.plant.created')}: {new Date(plant.createdAt ?? '').toLocaleDateString()}</span
>
{#if plant.updatedAt}
<span>Bearbeitet: {new Date(plant.updatedAt).toLocaleDateString('de')}</span>
<span>{$_('planta.plant.edited')}: {new Date(plant.updatedAt).toLocaleDateString()}</span>
{/if}
</div>
{/snippet}
</DetailViewShell>
<style>
.health-healthy {
color: rgb(34 197 94);
}
.health-needs_attention {
color: rgb(245 158 11);
}
.health-sick {
color: rgb(239 68 68);
}
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.photo-actions {
display: flex;
gap: 0.5rem;
}
.action-btn {
padding: 0.25rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(255, 255, 255, 0.15);
background: transparent;
color: rgba(255, 255, 255, 0.8);
font-size: 0.75rem;
cursor: pointer;
transition: background 0.15s;
}
.action-btn:hover:not(:disabled) {
background: rgba(255, 255, 255, 0.08);
}
.action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.action-btn.primary {
background: rgba(59, 130, 246, 0.18);
border-color: rgba(59, 130, 246, 0.35);
color: rgb(147, 197, 253);
}
.hidden-input {
display: none;
}
.empty {
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.3);
padding: 0.5rem 0;
}
.photo-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(80px, 1fr));
gap: 0.5rem;
}
.photo-tile {
position: relative;
aspect-ratio: 1;
border-radius: 0.375rem;
overflow: hidden;
border: 1px solid rgba(255, 255, 255, 0.1);
}
.photo-tile.primary {
border-color: rgba(59, 130, 246, 0.6);
}
.photo-tile img {
width: 100%;
height: 100%;
object-fit: cover;
}
.photo-overlay {
position: absolute;
top: 2px;
right: 2px;
display: flex;
gap: 2px;
opacity: 0;
transition: opacity 0.15s;
}
.photo-tile:hover .photo-overlay {
opacity: 1;
}
.photo-btn {
width: 20px;
height: 20px;
border-radius: 4px;
border: none;
background: rgba(0, 0, 0, 0.6);
color: white;
font-size: 0.75rem;
cursor: pointer;
}
.photo-btn.danger {
background: rgba(239, 68, 68, 0.7);
}
.identify-result {
margin-top: 0.75rem;
padding: 0.75rem;
border-radius: 0.5rem;
border: 1px solid rgba(59, 130, 246, 0.25);
background: rgba(59, 130, 246, 0.06);
font-size: 0.8125rem;
color: rgba(255, 255, 255, 0.8);
}
.identify-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
}
.identify-title {
font-weight: 600;
font-size: 0.75rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: rgba(255, 255, 255, 0.5);
}
.identify-result p {
margin: 0.25rem 0;
}
.identify-result .muted {
color: rgba(255, 255, 255, 0.4);
font-size: 0.75rem;
}
/* Tags */
.tag-row {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
align-items: center;
}
.tag-chip {
display: inline-flex;
align-items: center;
gap: 0.25rem;
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
color: white;
font-weight: 500;
}
.tag-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
padding: 0;
font-size: 0.875rem;
line-height: 1;
opacity: 0.7;
}
.tag-remove:hover {
opacity: 1;
}
.tag-picker-wrap {
position: relative;
}
.tag-add {
background: transparent;
border: 1px dashed rgba(255, 255, 255, 0.2);
color: rgba(255, 255, 255, 0.5);
padding: 0.125rem 0.5rem;
border-radius: 9999px;
font-size: 0.6875rem;
cursor: pointer;
}
.tag-add:hover {
border-color: rgba(255, 255, 255, 0.4);
color: rgba(255, 255, 255, 0.8);
}
.tag-picker {
position: absolute;
top: calc(100% + 4px);
left: 0;
z-index: 20;
min-width: 160px;
max-height: 200px;
overflow-y: auto;
padding: 0.25rem;
background: rgb(20, 20, 24);
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 0.5rem;
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
}
.tag-picker-item {
display: flex;
align-items: center;
gap: 0.5rem;
width: 100%;
padding: 0.375rem 0.5rem;
background: transparent;
border: none;
color: rgba(255, 255, 255, 0.85);
font-size: 0.75rem;
text-align: left;
cursor: pointer;
border-radius: 0.25rem;
}
.tag-picker-item:hover {
background: rgba(255, 255, 255, 0.06);
}
.tag-dot {
width: 0.625rem;
height: 0.625rem;
border-radius: 9999px;
flex-shrink: 0;
}
/* Watering history */
.watering-history {
list-style: none;
padding: 0;
margin: 0;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
.watering-history li {
display: flex;
justify-content: space-between;
align-items: baseline;
gap: 0.5rem;
font-size: 0.75rem;
color: rgba(255, 255, 255, 0.6);
padding: 0.25rem 0;
border-bottom: 1px solid rgba(255, 255, 255, 0.05);
}
.watering-history li:last-child {
border-bottom: none;
}
.watering-date {
color: rgba(255, 255, 255, 0.85);
}
.watering-notes {
font-style: italic;
color: rgba(255, 255, 255, 0.4);
text-align: right;
}
</style>