mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
b83e8d6d92
commit
60fedbb611
15 changed files with 1450 additions and 247 deletions
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
71
apps/mana/apps/web/src/lib/modules/planta/api.ts
Normal file
71
apps/mana/apps/web/src/lib/modules/planta/api.ts
Normal 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>;
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
185
apps/mana/apps/web/src/lib/modules/planta/mutations.test.ts
Normal file
185
apps/mana/apps/web/src/lib/modules/planta/mutations.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
|
|
@ -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 });
|
||||
},
|
||||
};
|
||||
|
|
|
|||
138
apps/mana/apps/web/src/lib/modules/planta/queries.test.ts
Normal file
138
apps/mana/apps/web/src/lib/modules/planta/queries.test.ts
Normal 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']);
|
||||
});
|
||||
});
|
||||
|
|
@ -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));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue