fix(mana/web/planta): /planta routes — layout fix, i18n, nullability, button nesting

- Add /planta/+layout.svelte that provides every live-query context
  the legacy routes already reference via getContext (plants,
  plantPhotos, wateringSchedules, wateringLogs, plantTags, tags).
  Without this layout the legacy routes would crash at runtime with
  "Cannot read properties of undefined (reading 'value')" — they had
  always relied on a provider that did not exist anywhere in the repo.
- Replace every hardcoded German label across +page.svelte,
  [id]/+page.svelte, add/+page.svelte and tags/+page.svelte with
  $_('planta.*') calls so the locale switcher actually changes the
  copy. Health/light/humidity helper maps converted from German maps
  to switch + i18n lookups.
- Fix the 4 type errors in [id]/+page.svelte caused by SvelteKit's
  $page.params.id being string | undefined: coerce to '' so the
  helpers stay strictly typed and "missing id" still resolves to
  "not found".
- Fix the SSR hydration warning on /planta from a <button> nested
  inside another <button> in the plant grid. Replaced the outer
  card with <div role="link" tabindex="0"> + Enter/Space keydown
  handler so the inner "water now" button is structurally legal.
- formatDate calls drop the hardcoded de-DE locale and use the
  browser locale (undefined) instead.
- Toast notifications on every mutation in these routes so failures
  are user-visible (handleWater, handleDelete, savePlant).

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

View file

@ -0,0 +1,75 @@
<!--
Planta routes layout
Provides live-query contexts to all child routes (/planta, /planta/[id],
/planta/add, /planta/tags). The contexts are referenced via getContext()
in the page files; without this layout the legacy routes would crash at
runtime with "Cannot read properties of undefined".
-->
<script lang="ts">
import { setContext } from 'svelte';
import type { Snippet } from 'svelte';
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecords } from '$lib/data/crypto';
import {
toPlant,
toPlantPhoto,
toWateringSchedule,
toWateringLog,
useAllTags,
} from '$lib/modules/planta/queries';
import type {
LocalPlant,
LocalPlantPhoto,
LocalPlantTag,
LocalWateringSchedule,
LocalWateringLog,
Plant,
PlantPhoto,
WateringSchedule,
WateringLog,
} from '$lib/modules/planta/types';
let { children }: { children: Snippet } = $props();
const allPlants = useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalPlant>('plants').toArray()).filter((p) => !p.deletedAt);
const decrypted = await decryptRecords('plants', visible);
return decrypted.map(toPlant);
}, [] as Plant[]);
const allPlantPhotos = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalPlantPhoto>('plantPhotos').toArray();
return locals.filter((p) => !p.deletedAt).map(toPlantPhoto);
}, [] as PlantPhoto[]);
const allWateringSchedules = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalWateringSchedule>('wateringSchedules').toArray();
return locals.filter((s) => !s.deletedAt).map(toWateringSchedule);
}, [] as WateringSchedule[]);
const allWateringLogs = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalWateringLog>('wateringLogs').toArray();
return locals.filter((l) => !l.deletedAt).map(toWateringLog);
}, [] as WateringLog[]);
const allPlantTags = useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalPlantTag>('plantTags').toArray();
return locals.filter((t) => !t.deletedAt);
}, [] as LocalPlantTag[]);
// `useAllTags` from @mana/shared-stores already wraps Dexie's
// liveQuery in `useLiveQueryWithDefault`, so it returns the same
// `{ readonly value }` shape and can be passed straight through.
const allTags = useAllTags();
setContext('plants', allPlants);
setContext('plantPhotos', allPlantPhotos);
setContext('wateringSchedules', allWateringSchedules);
setContext('wateringLogs', allWateringLogs);
setContext('plantTags', allPlantTags);
setContext('tags', allTags);
</script>
{@render children()}

View file

@ -1,6 +1,8 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { toast } from '$lib/stores/toast.svelte';
import { wateringMutations } from '$lib/modules/planta/mutations';
import {
getActivePlants,
@ -33,10 +35,9 @@
if (!schedule) return '';
const days = getDaysUntilWatering(schedule);
if (days === null) return '';
if (days < 0) return 'Ueberfaellig!';
if (days === 0) return 'Heute giessen';
if (days === 1) return 'Morgen giessen';
return `In ${days} Tagen`;
if (days < 0) return $_('planta.watering.overdue');
if (days === 0) return $_('planta.watering.today');
return $_('planta.watering.daysUntil', { values: { days } });
}
function shouldShowWaterButton(plantId: string): boolean {
@ -48,37 +49,51 @@
async function handleWater(plantId: string, e: Event) {
e.stopPropagation();
await wateringMutations.logWatering(plantId);
try {
await wateringMutations.logWatering(plantId);
toast.success($_('planta.success.plantWatered'));
} catch (err) {
console.error('logWatering failed:', err);
toast.error($_('planta.errors.wateringFailed'));
}
}
</script>
<svelte:head>
<title>Meine Pflanzen - Planta</title>
<title>{$_('planta.nav.plants')} - Planta</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Meine Pflanzen</h1>
<a href="/planta/add" class="btn btn-success"> + Pflanze hinzufuegen </a>
<h1 class="text-2xl font-bold">{$_('planta.nav.plants')}</h1>
<a href="/planta/add" class="btn btn-success">{$_('planta.plant.add')}</a>
</div>
{#if plants.length === 0}
<div class="text-center py-12">
<div class="text-6xl mb-4">🌱</div>
<h2 class="text-xl font-semibold mb-2">Noch keine Pflanzen</h2>
<p class="text-muted-foreground mb-4">
Fuege deine erste Pflanze hinzu und lass sie von der KI analysieren.
</p>
<a href="/planta/add" class="btn btn-success"> Erste Pflanze hinzufuegen </a>
<h2 class="text-xl font-semibold mb-2">{$_('planta.plant.noPlants')}</h2>
<p class="text-muted-foreground mb-4">{$_('planta.app.tagline')}</p>
<a href="/planta/add" class="btn btn-success">{$_('planta.plant.addFirst')}</a>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{#each plants as plant (plant.id)}
{@const primaryPhoto = getPrimaryPhoto(allPlantPhotos.value, plant.id)}
<button
type="button"
class="card plant-card cursor-pointer text-left"
<!-- Outer is a div with role=link so we can nest a real
<button> inside without violating HTML's "no interactive
inside interactive" rule. Keyboard nav: Enter/Space opens. -->
<div
role="link"
tabindex="0"
class="card plant-card text-left"
onclick={() => goto(`/planta/${plant.id}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/planta/${plant.id}`);
}
}}
>
{#if primaryPhoto?.publicUrl}
<img src={primaryPhoto.publicUrl} alt={plant.name} />
@ -94,7 +109,6 @@
{/if}
{#if getWateringText(plant.id)}
<div class="water-status {getWateringClass(plant.id)} mt-1">
<span>💧</span>
<span>{getWateringText(plant.id)}</span>
</div>
{/if}
@ -102,14 +116,14 @@
{#if shouldShowWaterButton(plant.id)}
<button
type="button"
class="absolute top-2 right-2 rounded-full bg-blue-500 p-2 text-white hover:bg-blue-600"
class="absolute top-2 right-2 rounded-full bg-blue-500 px-3 py-1 text-xs text-white hover:bg-blue-600"
onclick={(e) => handleWater(plant.id, e)}
title="Als gegossen markieren"
title={$_('planta.watering.water')}
>
💧
{$_('planta.watering.water')}
</button>
{/if}
</button>
</div>
{/each}
</div>
{/if}

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { toast } from '$lib/stores/toast.svelte';
import { plantMutations, wateringMutations } from '$lib/modules/planta/mutations';
import {
getPlantById,
@ -17,7 +19,9 @@
getContext('wateringSchedules');
const allWateringLogs: { readonly value: WateringLog[] } = getContext('wateringLogs');
const plantId = $derived($page.params.id);
// SvelteKit's params type allows undefined; coerce to '' so the helper
// signatures stay strict and we still resolve to "not found" for missing.
const plantId = $derived($page.params.id ?? '');
// Derived reactive data from live queries (auto-updates on any change)
let plant = $derived(getPlantById(allPlants.value, plantId));
@ -30,21 +34,34 @@
async function handleWater() {
if (!plant) return;
watering = true;
await wateringMutations.logWatering(plant.id);
watering = false;
try {
await wateringMutations.logWatering(plant.id);
toast.success($_('planta.success.plantWatered'));
} catch (err) {
console.error('logWatering failed:', err);
toast.error($_('planta.errors.wateringFailed'));
} finally {
watering = false;
}
}
async function handleDelete() {
if (!plant) return;
if (!confirm(`Moechtest du "${plant.name}" wirklich loeschen?`)) return;
if (!confirm($_('planta.plant.confirmDelete'))) return;
const success = await plantMutations.delete(plant.id);
if (success) goto('/planta');
try {
await plantMutations.delete(plant.id);
toast.success($_('planta.success.plantDeleted'));
goto('/planta');
} catch (err) {
console.error('delete plant failed:', err);
toast.error($_('planta.errors.deleteFailed'));
}
}
function formatDate(date: Date | string | undefined | null): string {
if (!date) return '-';
return new Date(date).toLocaleDateString('de-DE', {
if (!date) return '';
return new Date(date).toLocaleDateString(undefined, {
day: '2-digit',
month: '2-digit',
year: 'numeric',
@ -52,49 +69,54 @@
}
function getHealthBadgeClass(status: string | null | undefined): string {
if (!status) return 'healthy';
if (status === 'needs_attention') return 'needs_attention';
if (status === 'sick') return 'sick';
return 'healthy';
}
function getHealthText(status: string | null | undefined): string {
const map: Record<string, string> = {
healthy: 'Gesund',
needs_attention: 'Braucht Aufmerksamkeit',
sick: 'Krank',
};
return map[status || ''] || 'Gesund';
if (status === 'needs_attention') return $_('planta.health.needsAttention');
if (status === 'sick') return $_('planta.health.sick');
return $_('planta.health.healthy');
}
function getLightText(light: string | null | undefined): string {
const map: Record<string, string> = {
low: 'Wenig Licht',
medium: 'Mittleres Licht',
bright: 'Helles Licht',
direct: 'Direkte Sonne',
};
return map[light || ''] || '-';
switch (light) {
case 'low':
return $_('planta.light.low');
case 'medium':
return $_('planta.light.medium');
case 'bright':
return $_('planta.light.bright');
case 'direct':
return $_('planta.light.direct');
default:
return $_('planta.common.none');
}
}
function getHumidityText(humidity: string | null | undefined): string {
const map: Record<string, string> = {
low: 'Niedrig',
medium: 'Mittel',
high: 'Hoch',
};
return map[humidity || ''] || '-';
switch (humidity) {
case 'low':
return $_('planta.humidity.low');
case 'medium':
return $_('planta.humidity.medium');
case 'high':
return $_('planta.humidity.high');
default:
return $_('planta.common.none');
}
}
</script>
<svelte:head>
<title>{plant?.name || 'Pflanze'} - Planta</title>
<title>{plant?.name || $_('planta.app.name')} - Planta</title>
</svelte:head>
{#if !plant}
<div class="text-center py-12">
<p class="text-lg">Pflanze nicht gefunden</p>
<a href="/planta" class="btn btn-primary mt-4">Zurueck zur Uebersicht</a>
<p class="text-lg">{$_('planta.plant.notFound')}</p>
<a href="/planta" class="btn btn-primary mt-4">{$_('planta.nav.plants')}</a>
</div>
{:else}
<div class="space-y-6">
@ -131,30 +153,28 @@
<!-- Care Info -->
<div class="card">
<h2 class="font-semibold mb-4">Pflege</h2>
<h2 class="font-semibold mb-4">{$_('planta.plant.careNotes')}</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-muted-foreground">Licht</p>
<p class="font-medium">☀️ {getLightText(plant.lightRequirements)}</p>
<p class="text-sm text-muted-foreground">{$_('planta.plant.light')}</p>
<p class="font-medium">{getLightText(plant.lightRequirements)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Giessen</p>
<p class="text-sm text-muted-foreground">{$_('planta.watering.water')}</p>
<p class="font-medium">
💧 {plant.wateringFrequencyDays ? `Alle ${plant.wateringFrequencyDays} Tage` : '-'}
{plant.wateringFrequencyDays
? $_('planta.list.everyXDays', { values: { days: plant.wateringFrequencyDays } })
: $_('planta.common.none')}
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Luftfeuchtigkeit</p>
<p class="font-medium">💨 {getHumidityText(plant.humidity)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Temperatur</p>
<p class="font-medium">🌡️ {plant.temperature || '-'}</p>
<p class="text-sm text-muted-foreground">{$_('planta.humidity.medium')}</p>
<p class="font-medium">{getHumidityText(plant.humidity)}</p>
</div>
</div>
{#if plant.careNotes}
<div class="mt-4 pt-4 border-t">
<p class="text-sm text-muted-foreground mb-1">Pflegehinweise</p>
<p class="text-sm text-muted-foreground mb-1">{$_('planta.plant.careNotes')}</p>
<p class="text-sm whitespace-pre-line">{plant.careNotes}</p>
</div>
{/if}
@ -163,14 +183,14 @@
<!-- Watering Schedule -->
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold">Giessplan</h2>
<h2 class="font-semibold">{$_('planta.nav.watering')}</h2>
<button type="button" class="btn btn-success" onclick={handleWater} disabled={watering}>
{#if watering}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
💧 Jetzt giessen
{$_('planta.watering.water')}
{/if}
</button>
</div>
@ -178,11 +198,11 @@
{#if wateringSchedule}
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm text-muted-foreground">Zuletzt gegossen</p>
<p class="text-sm text-muted-foreground">{$_('planta.watering.lastWatered')}</p>
<p class="font-medium">{formatDate(wateringSchedule.lastWateredAt)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Naechstes Giessen</p>
<p class="text-sm text-muted-foreground">{$_('planta.watering.nextWatering')}</p>
<p class="font-medium">{formatDate(wateringSchedule.nextWateringAt)}</p>
</div>
</div>
@ -190,11 +210,11 @@
{#if wateringHistory.length > 0}
<div class="border-t pt-4">
<p class="text-sm text-muted-foreground mb-2">Letzte Giessvorgaenge</p>
<p class="text-sm text-muted-foreground mb-2">{$_('planta.watering.watered')}</p>
<ul class="space-y-1">
{#each wateringHistory.slice(0, 5) as log (log.id)}
<li class="text-sm flex justify-between">
<span>💧 Gegossen</span>
<span>{$_('planta.watering.watered')}</span>
<span class="text-muted-foreground">{formatDate(log.wateredAt)}</span>
</li>
{/each}
@ -205,9 +225,9 @@
<!-- Actions -->
<div class="flex gap-4">
<a href="/planta" class="btn flex-1 bg-muted text-foreground"> Zurueck </a>
<a href="/planta" class="btn flex-1 bg-muted text-foreground">{$_('planta.nav.plants')}</a>
<button type="button" class="btn bg-destructive text-white" onclick={handleDelete}>
Loeschen
{$_('planta.plant.delete')}
</button>
</div>
</div>

View file

@ -1,5 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { toast } from '$lib/stores/toast.svelte';
import { plantMutations } from '$lib/modules/planta/mutations';
let plantName = $state('');
@ -10,35 +12,36 @@
async function savePlant() {
if (!plantName.trim()) {
error = 'Bitte gib einen Namen fuer die Pflanze ein';
error = $_('planta.errors.saveFailed');
return;
}
saving = true;
error = '';
const plant = await plantMutations.create({
name: plantName.trim(),
scientificName: scientificName.trim() || undefined,
commonName: commonName.trim() || undefined,
});
if (!plant) {
error = 'Pflanze konnte nicht gespeichert werden';
try {
const plant = await plantMutations.create({
name: plantName.trim(),
scientificName: scientificName.trim() || undefined,
commonName: commonName.trim() || undefined,
});
toast.success($_('planta.success.plantAdded'));
goto(`/planta/${plant.id}`);
} catch (err) {
console.error('Failed to create plant:', err);
error = $_('planta.errors.saveFailed');
toast.error($_('planta.errors.saveFailed'));
saving = false;
return;
}
goto(`/planta/${plant.id}`);
}
</script>
<svelte:head>
<title>Pflanze hinzufuegen - Planta</title>
<title>{$_('planta.plant.add')} - Planta</title>
</svelte:head>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-2xl font-bold">Pflanze hinzufuegen</h1>
<h1 class="text-2xl font-bold">{$_('planta.plant.add')}</h1>
{#if error}
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
@ -48,37 +51,41 @@
<div class="card p-6 space-y-4">
<div>
<label for="plant-name" class="block text-sm font-medium mb-2"> Name * </label>
<label for="plant-name" class="block text-sm font-medium mb-2">
{$_('planta.plant.name')} *
</label>
<input
id="plant-name"
type="text"
bind:value={plantName}
class="input w-full"
placeholder="z.B. Meine Monstera"
placeholder={$_('planta.plant.namePlaceholder')}
/>
</div>
<div>
<label for="scientific-name" class="block text-sm font-medium mb-2">
Wissenschaftlicher Name
{$_('planta.plant.scientificName')}
</label>
<input
id="scientific-name"
type="text"
bind:value={scientificName}
class="input w-full"
placeholder="z.B. Monstera deliciosa"
placeholder={$_('planta.common.none')}
/>
</div>
<div>
<label for="common-name" class="block text-sm font-medium mb-2"> Allgemeiner Name </label>
<label for="common-name" class="block text-sm font-medium mb-2">
{$_('planta.plant.species')}
</label>
<input
id="common-name"
type="text"
bind:value={commonName}
class="input w-full"
placeholder="z.B. Fensterblatt"
placeholder={$_('planta.common.none')}
/>
</div>
@ -88,14 +95,14 @@
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
Pflanze speichern
{$_('planta.common.save')}
{/if}
</button>
</div>
<div class="text-center">
<a href="/planta" class="text-sm text-muted-foreground hover:text-foreground">
Zurueck zur Uebersicht
{$_('planta.nav.plants')}
</a>
</div>
</div>

View file

@ -1,4 +1,5 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import type { Tag } from '@mana/shared-tags';
@ -10,13 +11,13 @@
</svelte:head>
<div class="tags-page">
<h1>Tags verwalten</h1>
<h1>Tags</h1>
<p class="text-sm text-muted-foreground mb-4">
Tags sind app-uebergreifend -- Aenderungen gelten in allen Mana-Apps.
{$_('planta.app.tagline')}
</p>
{#if tagsCtx.value.length === 0}
<p>Keine Tags vorhanden.</p>
<p>{$_('planta.list.empty')}</p>
{:else}
<div class="grid gap-2">
{#each tagsCtx.value as tag}