refactor: rename planta → plants, clean up codebase

- Rename planta module to plants everywhere (routes, modules, API,
  branding, i18n, docker, docs, shared packages)
- Fix package name collisions: @mana/credits-service, @mana/subscriptions-service
  (unblocks turbo)
- Extract layout composables: use-ai-tier-items, use-sync-status-items,
  RouteTierGate (layout 1345→1015 lines)
- Create shared DB pool for apps/api (lib/db.ts), migrate 5 modules
- Add automations module queries.ts with useAllAutomations/useEnabledAutomations
- Remove debug console.log statements from production code
- Rename storage display name: Ablage → Speicher

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-12 18:59:44 +02:00
parent c6c19dbc77
commit a91a6076cc
110 changed files with 831 additions and 707 deletions

View file

@ -24,7 +24,7 @@ import { contextRoutes } from './modules/context/routes';
import { pictureRoutes } from './modules/picture/routes';
import { storageRoutes } from './modules/storage/routes';
import { todoRoutes } from './modules/todo/routes';
import { plantaRoutes } from './modules/planta/routes';
import { plantsRoutes } from './modules/plants/routes';
import { nutriphiRoutes } from './modules/nutriphi/routes';
import { guidesRoutes } from './modules/guides/routes';
import { moodlitRoutes } from './modules/moodlit/routes';
@ -56,7 +56,7 @@ app.route('/api/v1/context', contextRoutes);
app.route('/api/v1/picture', pictureRoutes);
app.route('/api/v1/storage', storageRoutes);
app.route('/api/v1/todo', todoRoutes);
app.route('/api/v1/planta', plantaRoutes);
app.route('/api/v1/plants', plantsRoutes);
app.route('/api/v1/nutriphi', nutriphiRoutes);
app.route('/api/v1/guides', guidesRoutes);
app.route('/api/v1/moodlit', moodlitRoutes);

32
apps/api/src/lib/db.ts Normal file
View file

@ -0,0 +1,32 @@
/**
* Shared database connection pool for mana-api.
*
* All modules share a single connection pool instead of each creating
* their own. The pool is created lazily on first call and reused.
*
* Usage:
* ```ts
* import { getConnection } from '../../lib/db';
* import { drizzle } from 'drizzle-orm/postgres-js';
*
* const db = drizzle(getConnection(), { schema: { ... } });
* ```
*/
import postgres from 'postgres';
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
let pool: ReturnType<typeof postgres> | null = null;
/**
* Returns the shared postgres connection pool.
* Created lazily with sensible defaults (max 10 connections).
*/
export function getConnection() {
if (!pool) {
pool = postgres(DATABASE_URL, { max: 10, idle_timeout: 30 });
}
return pool;
}

View file

@ -14,16 +14,12 @@ import { Hono } from 'hono';
import { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { sql } from 'drizzle-orm';
import { getConnection } from '../../lib/db';
// ─── DB Connection (reads from news.curated_articles) ──────
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
const connection = postgres(DATABASE_URL, { max: 10 });
const db = drizzle(connection);
const db = drizzle(getConnection());
// ─── Extract Service (Readability fallback for ad-hoc URLs) ─

View file

@ -1,5 +1,5 @@
/**
* Planta module Photo upload + AI plant identification.
* Plants module Photo upload + AI plant identification.
*
* CRUD for plants, photos, watering is handled by mana-sync. This
* module owns the server-only operations: photo upload to mana-media
@ -61,7 +61,7 @@ routes.post('/photos/upload', async (c) => {
try {
const { uploadImageToMedia } = await import('../../lib/media');
const buffer = await file.arrayBuffer();
const result = await uploadImageToMedia(buffer, file.name, { app: 'planta', userId });
const result = await uploadImageToMedia(buffer, file.name, { app: 'plants', userId });
return c.json(
{
@ -73,7 +73,7 @@ routes.post('/photos/upload', async (c) => {
201
);
} catch (err) {
logger.error('planta.upload_failed', {
logger.error('plants.upload_failed', {
error: err instanceof Error ? err.message : String(err),
});
return c.json({ error: 'Upload failed' }, 500);
@ -107,11 +107,11 @@ routes.post('/analysis/identify', async (c) => {
});
return c.json(envelope(object));
} catch (err) {
logger.error('planta.analysis_failed', {
logger.error('plants.analysis_failed', {
error: err instanceof Error ? err.message : String(err),
});
return c.json({ error: 'Analysis failed' }, 500);
}
});
export { routes as plantaRoutes };
export { routes as plantsRoutes };

View file

@ -12,7 +12,7 @@ import { HTTPException } from 'hono/http-exception';
import { authMiddleware } from '@mana/shared-hono/auth';
import type { AuthVariables } from '@mana/shared-hono';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { getConnection } from '../../lib/db';
import {
decks,
slides,
@ -25,11 +25,7 @@ import {
// ─── DB Connection ─────────────────────────────────────────
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 });
const db = drizzle(connection, {
const db = drizzle(getConnection(), {
schema: {
decks,
slides,

View file

@ -13,11 +13,8 @@
*/
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { pgSchema, uuid, text, timestamp, integer, jsonb } from 'drizzle-orm/pg-core';
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
import { getConnection } from '../../lib/db';
export const researchSchema = pgSchema('research');
@ -58,8 +55,7 @@ export const sources = researchSchema.table('sources', {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 });
export const db = drizzle(connection, { schema: { researchResults, sources } });
export const db = drizzle(getConnection(), { schema: { researchResults, sources } });
export type ResearchResult = typeof researchResults.$inferSelect;
export type Source = typeof sources.$inferSelect;

View file

@ -17,8 +17,8 @@ import { rrulestr } from 'rrule';
import { z } from 'zod';
import { eq, and, asc, sql } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { serviceAuthMiddleware, type AuthVariables } from '@mana/shared-hono';
import { getConnection } from '../../lib/db';
import {
pgSchema,
uuid,
@ -33,9 +33,6 @@ import {
// ─── DB Schema (minimal, server-only) ──────────────────────
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
const todoSchema = pgSchema('todo');
const tasks = todoSchema.table('tasks', {
@ -88,8 +85,7 @@ const reminders = todoSchema.table(
})
);
const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 });
const db = drizzle(connection, { schema: { tasks, projects, reminders } });
const db = drizzle(getConnection(), { schema: { tasks, projects, reminders } });
// ─── Routes ────────────────────────────────────────────────

View file

@ -10,7 +10,7 @@ import { Hono } from 'hono';
import { logger, type AuthVariables } from '@mana/shared-hono';
import { eq, and } from 'drizzle-orm';
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { getConnection } from '../../lib/db';
import {
pgSchema,
uuid,
@ -23,8 +23,6 @@ import {
// ─── DB Schema ──────────────────────────────────────────────
const DATABASE_URL =
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
const LLM_URL = process.env.MANA_LLM_URL || 'http://localhost:3025';
const tracesSchema = pgSchema('traces');
@ -116,8 +114,7 @@ const guidePois = tracesSchema.table('guide_pois', {
createdAt: timestamp('created_at', { withTimezone: true }).defaultNow().notNull(),
});
const connection = postgres(DATABASE_URL, { max: 5, idle_timeout: 20 });
const db = drizzle(connection, { schema: { locations, cities, pois, guides, guidePois } });
const db = drizzle(getConnection(), { schema: { locations, cities, pois, guides, guidePois } });
// ─── Routes ─────────────────────────────────────────────────

View file

@ -56,7 +56,7 @@ const APP_SUBDOMAINS = new Set([
'contacts',
'zitare',
'skilltree',
'planta',
'plants',
'cards',
'storage',
'presi',

View file

@ -605,13 +605,13 @@ registerApp({
});
registerApp({
id: 'planta',
name: 'Planta',
id: 'plants',
name: 'Plants',
color: '#16A34A',
icon: Plant,
views: {
list: { load: () => import('$lib/modules/planta/ListView.svelte') },
detail: { load: () => import('$lib/modules/planta/views/DetailView.svelte') },
list: { load: () => import('$lib/modules/plants/ListView.svelte') },
detail: { load: () => import('$lib/modules/plants/views/DetailView.svelte') },
},
});

View file

@ -0,0 +1,81 @@
<!--
RouteTierGate — blocks access to routes that require a higher tier
than the current user holds. Shows a denial panel with the user's
current tier vs. the required tier.
-->
<script lang="ts">
import { goto } from '$app/navigation';
import { locale } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
interface Props {
appName: string;
userTierLabel: string;
requiredTierLabel: string;
}
let { appName, userTierLabel, requiredTierLabel }: Props = $props();
let isDE = $derived(($locale || 'de') === 'de');
</script>
<div class="flex min-h-[60vh] items-center justify-center p-6">
<div
class="w-full max-w-96 rounded-2xl border px-8 py-10 text-center shadow-sm"
style:border-color="hsl(var(--border, 0 0% 90%))"
style:background-color="hsl(var(--card, 0 0% 100%))"
>
<h1 class="mb-4 text-xl font-bold" style:color="hsl(var(--foreground, 0 0% 9%))">
{appName}
</h1>
<div class="mb-4 text-5xl">🔒</div>
<p
class="mb-6 text-[0.9375rem] leading-relaxed"
style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>
{isDE
? 'Diese App ist aktuell in der geschlossenen '
: 'This app is currently in closed '}<strong>{requiredTierLabel}</strong>{isDE
? '-Phase.'
: ' phase.'}
</p>
<div
class="mb-6 flex flex-col gap-2 rounded-xl p-4"
style:background-color="hsl(var(--muted, 0 0% 96%))"
>
<div class="flex items-center justify-between text-sm">
<span style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>{isDE ? 'Dein Zugang:' : 'Your access:'}</span
>
<span class="font-semibold" style:color="hsl(var(--foreground, 0 0% 9%))"
>{userTierLabel}</span
>
</div>
<div class="flex items-center justify-between text-sm">
<span style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>{isDE ? 'Benötigt:' : 'Required:'}</span
>
<span class="font-semibold text-violet-500">{requiredTierLabel}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<button
class="w-full cursor-pointer rounded-lg border-none px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
style:background-color="hsl(var(--primary, 239 84% 67%))"
style:color="hsl(var(--primary-foreground, 0 0% 100%))"
onclick={() => goto('/')}
>
{isDE ? 'Zur Übersicht' : 'Back to overview'}
</button>
{#if !authStore.isAuthenticated}
<button
class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
style:border-color="hsl(var(--border, 0 0% 90%))"
style:color="hsl(var(--foreground, 0 0% 9%))"
onclick={() => goto('/login')}
>
{isDE ? 'Anmelden' : 'Sign in'}
</button>
{/if}
</div>
</div>
</div>

View file

@ -0,0 +1,226 @@
/**
* AI Tier Selector composable for the PillNavigation AI dropdown.
*
* Manages LLM tier toggles (browser/server/cloud) and STT model
* selection. Returns reactive PillDropdownItem arrays + labels.
*/
import { goto } from '$app/navigation';
import type { PillDropdownItem } from '@mana/shared-ui';
import { llmSettingsState, updateLlmSettings, type LlmTier } from '@mana/shared-llm';
import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm';
import {
getLocalSttStatus,
loadLocalStt,
isLocalSttSupported,
MODELS as STT_MODELS,
DEFAULT_MODEL as STT_DEFAULT_MODEL,
type ModelKey as SttModelKey,
} from '@mana/local-stt';
const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string; icon: string }> = [
{ tier: 'browser', shortLabel: 'Lokal (Gemma 4)', icon: 'robot' },
{ tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'globe' },
{ tier: 'cloud', shortLabel: 'Cloud (Gemini)', icon: 'cloud' },
];
export function useAiTierItems() {
const webgpuSupported = isLocalLlmSupported();
const localLlmStatus = getLocalLlmStatus();
const sttSupported = isLocalSttSupported();
const localSttStatus = getLocalSttStatus();
let selectedSttModel = $state<SttModelKey>(STT_DEFAULT_MODEL);
const llmSettings = $derived(llmSettingsState.current);
function toggleAiTier(tier: LlmTier) {
const current = llmSettings.allowedTiers;
const next = current.includes(tier)
? current.filter((t: LlmTier) => t !== tier)
: [...current, tier];
updateLlmSettings({ allowedTiers: next });
}
function buildLlmStatusItem(): PillDropdownItem {
const s = localLlmStatus.current;
const state = s.state;
let label: string;
let icon: string;
let danger = false;
let disabled = false;
switch (state) {
case 'ready':
label = 'Geladen';
icon = 'checkCircle';
disabled = true;
break;
case 'downloading':
label = `Lade… ${((s as { progress: number }).progress * 100).toFixed(0)}%`;
icon = 'clock';
disabled = true;
break;
case 'loading':
label = 'Initialisiere…';
icon = 'clock';
disabled = true;
break;
case 'checking':
label = 'Prüfe…';
icon = 'clock';
disabled = true;
break;
case 'error':
label = 'Fehler — erneut versuchen';
icon = 'bell';
danger = true;
break;
default:
label = 'Modell laden';
icon = 'cloud';
}
return {
id: 'ai-browser-status',
label,
icon,
group: 'local-llm',
danger,
disabled,
progress: state === 'downloading' ? (s as { progress: number }).progress : undefined,
onClick: !disabled ? () => void loadLocalLlm() : undefined,
};
}
function buildSttStatusItem(): PillDropdownItem {
const s = localSttStatus.current;
const state = s.state;
let label: string;
let icon: string;
let danger = false;
let disabled = false;
switch (state) {
case 'ready':
label = 'STT bereit';
icon = 'checkCircle';
disabled = true;
break;
case 'downloading':
label = `STT Lade… ${((s as { progress: number }).progress * 100).toFixed(0)}%`;
icon = 'clock';
disabled = true;
break;
case 'loading':
label = 'STT lädt…';
icon = 'clock';
disabled = true;
break;
case 'checking':
label = 'STT prüft…';
icon = 'clock';
disabled = true;
break;
case 'error':
label = 'STT Fehler';
icon = 'bell';
danger = true;
break;
default:
label = 'STT Modell laden';
icon = 'mic';
}
return {
id: 'stt-status',
label,
icon,
danger,
disabled,
progress: state === 'downloading' ? (s as { progress: number }).progress : undefined,
onClick: !disabled ? () => void loadLocalStt(selectedSttModel) : undefined,
};
}
const items = $derived<PillDropdownItem[]>([
// Tier toggles
...TIER_TOGGLE_LIST.filter((t) => t.tier !== 'browser' || webgpuSupported).map((t) => {
const isActive = llmSettings.allowedTiers.includes(t.tier);
return {
id: `ai-tier-${t.tier}`,
label: t.shortLabel,
icon: isActive ? 'checkCircle' : t.icon,
active: isActive,
onClick: () => toggleAiTier(t.tier),
...(t.tier === 'browser' ? { group: 'local-llm' } : {}),
};
}),
// Browser model status (grouped with the "Lokal" toggle)
...(llmSettings.allowedTiers.includes('browser') && webgpuSupported
? [buildLlmStatusItem()]
: []),
// STT section
{ id: 'stt-divider', label: '', divider: true },
...(sttSupported
? (Object.entries(STT_MODELS) as [SttModelKey, (typeof STT_MODELS)[SttModelKey]][]).map(
([key, model]) => {
const isSelected = selectedSttModel === key;
return {
id: `stt-model-${key}`,
label: model.displayName,
icon: isSelected ? 'checkCircle' : 'mic',
active: isSelected,
onClick: () => {
selectedSttModel = key;
void loadLocalStt(key);
},
};
}
)
: []),
...(sttSupported ? [buildSttStatusItem()] : []),
// Settings link
{ id: 'ai-divider', label: '', divider: true },
{
id: 'ai-settings',
label: 'KI-Einstellungen',
icon: 'settings',
onClick: () => goto('/settings#ai-options'),
},
]);
const label = $derived.by(() => {
const active = llmSettings.allowedTiers;
if (active.length === 0) return 'Aus';
const sorted = [...active].sort(
(a, b) =>
TIER_TOGGLE_LIST.findIndex((t) => t.tier === a) -
TIER_TOGGLE_LIST.findIndex((t) => t.tier === b)
);
const first = TIER_TOGGLE_LIST.find((t) => t.tier === sorted[0]);
return first ? first.shortLabel.split(' (')[0] : 'KI';
});
const icon = $derived.by(() => {
const active = llmSettings.allowedTiers;
if (active.length === 0) return 'power';
const sorted = [...active].sort(
(a, b) =>
TIER_TOGGLE_LIST.findIndex((t) => t.tier === a) -
TIER_TOGGLE_LIST.findIndex((t) => t.tier === b)
);
const first = TIER_TOGGLE_LIST.find((t) => t.tier === sorted[0]);
return first ? first.icon : 'cpu';
});
return {
get items() {
return items;
},
get label() {
return label;
},
get icon() {
return icon;
},
};
}

View file

@ -0,0 +1,88 @@
/**
* Sync Status Dropdown composable for the PillNavigation sync pill.
*
* Returns reactive PillDropdownItems showing sync subscription state
* (active / paused / inactive) with links to settings and credits.
*/
import { goto } from '$app/navigation';
import type { PillDropdownItem } from '@mana/shared-ui';
import { syncBilling } from '$lib/stores/sync-billing.svelte';
export function useSyncStatusItems() {
const items = $derived.by(() => {
const result: PillDropdownItem[] = [];
if (syncBilling.active) {
result.push({
id: 'sync-active',
label: 'Cloud Sync aktiv',
icon: 'cloud',
active: true,
disabled: true,
});
if (syncBilling.nextChargeAt) {
const date = new Date(syncBilling.nextChargeAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
result.push({
id: 'sync-next',
label: `Nächste Abbuchung: ${date}`,
icon: 'calendar',
disabled: true,
});
}
} else if (syncBilling.paused) {
result.push({
id: 'sync-paused',
label: 'Sync pausiert — Credits aufladen',
icon: 'bell',
onClick: () => goto('/credits?tab=packages'),
});
} else {
result.push({
id: 'sync-inactive',
label: 'Sync aktivieren',
icon: 'cloud',
onClick: () => goto('/settings/sync'),
});
result.push({
id: 'sync-info',
label: 'Nur lokal — ab 30 Credits/Monat',
icon: 'creditCard',
disabled: true,
});
}
result.push({ id: 'sync-divider', label: '', divider: true });
result.push({
id: 'sync-settings',
label: 'Sync-Einstellungen',
icon: 'settings',
onClick: () => goto('/settings/sync'),
});
return result;
});
const label = $derived(
syncBilling.loading
? '...'
: syncBilling.active
? 'Sync'
: syncBilling.paused
? 'Pausiert'
: 'Lokal'
);
return {
get items() {
return items;
},
get label() {
return label;
},
};
}

View file

@ -160,7 +160,7 @@ export const APP_POSITIONS: Record<string, { x: number; y: number; lakeId: strin
photos: { x: 1110, y: 575, lakeId: 'db-right' },
skilltree: { x: 1310, y: 590, lakeId: 'db-right' },
context: { x: 1320, y: 660, lakeId: 'db-right' },
planta: { x: 1115, y: 675, lakeId: 'db-right' },
plants: { x: 1115, y: 675, lakeId: 'db-right' },
// Around Bergsee (redis) - lightweight/cache
traces: { x: 400, y: 385, lakeId: 'redis' },

View file

@ -266,8 +266,8 @@ const APP_DEFINITIONS: AppDefinition[] = [
},
},
{
id: 'planta',
displayName: 'Planta',
id: 'plants',
displayName: 'Plants',
score: 50,
status: 'alpha',
categories: {

View file

@ -41,7 +41,7 @@
zitare: 'https://zitare.mana.how',
mana: 'https://mana.how',
cards: 'https://cards.mana.how',
planta: 'https://planta.mana.how',
plants: 'https://plants.mana.how',
playground: 'https://playground.mana.how',
};

View file

@ -30,7 +30,7 @@
minio: 'Objekt-Speicher fur Dateien, Bilder und Medien aller Apps.',
'db-left': 'PostgreSQL-Datenbanken fur Calendar, Todo, Contacts, Storage.',
'db-center': 'PostgreSQL-Datenbanken fur Zitare, Music, Clock, NutriPhi.',
'db-right': 'PostgreSQL-Datenbanken fur Photos, SkillTree, Context, Planta.',
'db-right': 'PostgreSQL-Datenbanken fur Photos, SkillTree, Context, Plants.',
};
const lakeIcons: Record<string, string> = {

View file

@ -151,16 +151,8 @@ export function useLocalStt(options?: { language?: string }): LocalSttHandle {
const capturedRate = sampleRate;
cleanup();
console.log(
'[local-stt] Captured',
capturedChunks.length,
'chunks, sample rate:',
capturedRate
);
if (capturedChunks.length === 0) {
error = 'Keine Audiodaten aufgenommen.';
console.warn('[local-stt] No audio chunks captured');
state = 'idle';
return;
}
@ -180,25 +172,14 @@ export function useLocalStt(options?: { language?: string }): LocalSttHandle {
// Resample to 16 kHz if needed
const audio = capturedRate === 16000 ? merged : resample(merged, capturedRate, 16000);
const durationSec = audio.length / 16000;
console.log('[local-stt] Audio ready:', {
originalSamples: merged.length,
resampledSamples: audio.length,
durationSec: durationSec.toFixed(1),
sampleRate: capturedRate,
maxAmplitude: Math.max(...Array.from(audio.slice(0, 16000)).map(Math.abs)),
});
const result = await transcribe({
audio,
language: options?.language,
onChunk: (t: string) => {
partial += t;
console.log('[local-stt] Chunk:', t);
},
});
console.log('[local-stt] Result:', result);
text = result.text.trim();
} catch (e) {
error = e instanceof Error ? e.message : String(e);

View file

@ -35,7 +35,7 @@
| 3 | Client Wire-up: vault-client, record-helpers, layout integration | ✅ | `354cbcb17` |
| 4 | Pilot: notes table mit 8 End-to-End Tests | ✅ | `bed08a1aa` |
| 5 | Rollout: chat, dreams, memoro, contacts, cycles, finance | ✅ | `af92720a6` |
| 6.1 | Rollout: cards, presi, inventar, planta | ✅ | `73f294b29` |
| 6.1 | Rollout: cards, presi, inventar, plants | ✅ | `73f294b29` |
| 6.2 + 6.3 | Settings UI (`/settings/security`) + Encryption Intro Banner | ✅ | `6b8e2c717` |
| Roundup | DATA_LAYER_AUDIT roll-up vor Phase 7 | ✅ | `4bdf4238c` |
| 7.1 | timeBlocks-Hub: tasks + calendar.events + timeBlocks (mit Habits-Coupling) | ✅ | `c875b4e96` |
@ -396,7 +396,7 @@ Unlock-Flow (Login auf neuem Gerät):
| presi | `presiDecks` | `title`, `description` | 6.1 |
| | `slides` | `content` (SlideContent JSON) | 6.1 |
| inventar | `invItems` | `description` (name + notes-array bleiben plaintext) | 6.1 |
| planta | `plants` | `name`, `careNotes`, `temperature`, `soilType` | 6.1 |
| plants | `plants` | `name`, `careNotes`, `temperature`, `soilType` | 6.1 |
| **todo** | **`tasks`** | **`title`, `description`, `subtasks`, `metadata`** | **7.1** |
| **calendar** | **`events`** | **`title`, `description`, `location`** | **7.1** |
| **time-blocks** | **`timeBlocks`** | **`title`, `description`** (Cross-Module-Hub für todo/calendar/habits/times) | **7.1** |

View file

@ -148,11 +148,11 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// - photoMediaId / photoUrl / photoThumbnailUrl: opaque pointers to
// mana-media; the URL alone is not PII (anyone with the URL
// already has the bytes), and CAS-deduped media IDs leak no user
// content. Same rationale planta uses for plantPhotos.
// content. Same rationale plants uses for plantPhotos.
// - confidence (float 0-1): pure metadata about the AI run.
meals: { enabled: true, fields: ['description', 'portionSize', 'foods'] },
// ─── Planta ──────────────────────────────────────────────
// ─── Plants ──────────────────────────────────────────────
// `name` is NOT in the schema index for plants (only isActive +
// healthStatus), so encrypting it is safe. LocalPlant uses
// `careNotes` (no separate `notes`) plus the user-typed metadata.

View file

@ -182,7 +182,7 @@ db.version(1).stores({
nutriFavorites: 'id, mealType, usageCount',
mealTags: 'id, mealId, tagId, [mealId+tagId]',
// ─── Planta (appId: 'planta') ───
// ─── Plants (appId: 'plants') ───
plants: 'id, isActive, healthStatus',
plantPhotos: 'id, plantId, isPrimary, [plantId+isPrimary]',
wateringSchedules: 'id, plantId, nextWateringAt',

View file

@ -156,7 +156,7 @@ describe('module-registry — pre-refactor snapshot', () => {
context: ['contextSpaces', 'documents', 'documentTags'],
questions: ['qCollections', 'questions', 'answers', 'questionTags'],
nutriphi: ['meals', 'goals', 'nutriFavorites', 'mealTags'],
planta: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'],
plants: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'],
uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'],
calc: ['calculations', 'savedFormulas'],
moodlit: ['moods', 'sequences', 'moodTags'],

View file

@ -69,7 +69,7 @@ import { timesModuleConfig } from '$lib/modules/times/module.config';
import { contextModuleConfig } from '$lib/modules/context/module.config';
import { questionsModuleConfig } from '$lib/modules/questions/module.config';
import { nutriphiModuleConfig } from '$lib/modules/nutriphi/module.config';
import { plantaModuleConfig } from '$lib/modules/planta/module.config';
import { plantsModuleConfig } from '$lib/modules/plants/module.config';
import { uloadModuleConfig } from '$lib/modules/uload/module.config';
import { calcModuleConfig } from '$lib/modules/calc/module.config';
import { moodlitModuleConfig } from '$lib/modules/moodlit/module.config';
@ -113,7 +113,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
contextModuleConfig,
questionsModuleConfig,
nutriphiModuleConfig,
plantaModuleConfig,
plantsModuleConfig,
uloadModuleConfig,
calcModuleConfig,
moodlitModuleConfig,

View file

@ -27,7 +27,7 @@ import { SKILLTREE_GUEST_SEED } from '$lib/modules/skilltree/collections';
import { TODO_GUEST_SEED } from '$lib/modules/todo/collections';
import { NOTES_GUEST_SEED } from '$lib/modules/notes/collections';
import { TIMES_GUEST_SEED } from '$lib/modules/times/collections';
import { PLANTA_GUEST_SEED } from '$lib/modules/planta/collections';
import { PLANTS_GUEST_SEED } from '$lib/modules/plants/collections';
import { DRINK_GUEST_SEED } from '$lib/modules/drink/collections';
/**
@ -60,7 +60,7 @@ register(SKILLTREE_GUEST_SEED);
register(TODO_GUEST_SEED);
register(NOTES_GUEST_SEED);
register(TIMES_GUEST_SEED);
register(PLANTA_GUEST_SEED);
register(PLANTS_GUEST_SEED);
register(DRINK_GUEST_SEED);
/**

View file

@ -38,7 +38,7 @@ export type TimeBlockSourceModule =
| 'habits'
| 'events'
| 'body'
| 'planta'
| 'plants'
| 'dreams'
| 'skilltree'
| 'cycles'

View file

@ -41,7 +41,7 @@ function registerLocale(lang: SupportedLocale) {
inventory,
photos,
nutriphi,
planta,
plants,
skilltree,
citycorners,
calc,
@ -77,7 +77,7 @@ function registerLocale(lang: SupportedLocale) {
import(`./locales/inventory/${lang}.json`),
import(`./locales/photos/${lang}.json`),
import(`./locales/nutriphi/${lang}.json`),
import(`./locales/planta/${lang}.json`),
import(`./locales/plants/${lang}.json`),
import(`./locales/skilltree/${lang}.json`),
import(`./locales/citycorners/${lang}.json`),
import(`./locales/calc/${lang}.json`),
@ -115,7 +115,7 @@ function registerLocale(lang: SupportedLocale) {
inventory: inventory.default,
photos: photos.default,
nutriphi: nutriphi.default,
planta: planta.default,
plants: plants.default,
skilltree: skilltree.default,
citycorners: citycorners.default,
calc: calc.default,

View file

@ -14,9 +14,9 @@
"picture": "Bilder",
"music": "Musik",
"photos": "Fotos",
"storage": "Ablage",
"storage": "Speicher",
"nutriphi": "NutriPhi",
"planta": "Planta",
"plants": "Pflanzen",
"presi": "Presi",
"inventory": "Inventar",
"memoro": "Memoro",

View file

@ -16,7 +16,7 @@
"photos": "Photos",
"storage": "Storage",
"nutriphi": "NutriPhi",
"planta": "Planta",
"plants": "Plants",
"presi": "Presi",
"inventory": "Inventory",
"memoro": "Memoro",

View file

@ -16,7 +16,7 @@
"photos": "Fotos",
"storage": "Almacén",
"nutriphi": "NutriPhi",
"planta": "Planta",
"plants": "Plantas",
"presi": "Presi",
"inventory": "Inventario",
"memoro": "Memoro",

View file

@ -16,7 +16,7 @@
"photos": "Photos",
"storage": "Stockage",
"nutriphi": "NutriPhi",
"planta": "Planta",
"plants": "Plantes",
"presi": "Presi",
"inventory": "Inventaire",
"memoro": "Memoro",

View file

@ -16,7 +16,7 @@
"photos": "Foto",
"storage": "Archivio",
"nutriphi": "NutriPhi",
"planta": "Planta",
"plants": "Piante",
"presi": "Presi",
"inventory": "Inventario",
"memoro": "Memoro",

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Planta",
"name": "Plants",
"loading": "Laden...",
"tagline": "Pflanzenpflege leicht gemacht"
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Planta",
"name": "Plants",
"loading": "Loading...",
"tagline": "Plant care made easy"
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Planta",
"name": "Plants",
"loading": "Cargando...",
"tagline": "Cuidado de plantas simplificado"
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Planta",
"name": "Plants",
"loading": "Chargement...",
"tagline": "L'entretien des plantes simplifié"
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Planta",
"name": "Plants",
"loading": "Caricamento...",
"tagline": "Cura delle piante semplificata"
},

View file

@ -1,4 +1,6 @@
export { automationsStore } from './stores/automations.svelte';
export { automationTable } from './collections';
export { useAllAutomations, useEnabledAutomations, toAutomation } from './queries';
export type { Automation } from './queries';
export type { LocalAutomation, SourceOption, ActionOption } from './types';
export { SOURCE_OPTIONS, ACTION_OPTIONS, CONDITION_OPS } from './types';

View file

@ -0,0 +1,63 @@
/**
* Reactive Queries for Automations module.
*
* Uses useLiveQueryWithDefault on the unified database.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { automationTable } from './collections';
import type { LocalAutomation } from './types';
// ─── Type Converter ──────────────────────────────────────
export interface Automation {
id: string;
name: string;
enabled: boolean;
sourceApp: string;
sourceCollection: string;
sourceOp: 'insert' | 'update';
conditionField?: string;
conditionOp?: string;
conditionValue?: string;
targetApp: string;
targetAction: string;
targetParams?: Record<string, string>;
createdAt: string;
updatedAt: string;
}
export function toAutomation(local: LocalAutomation): Automation {
return {
id: local.id,
name: local.name,
enabled: local.enabled,
sourceApp: local.sourceApp,
sourceCollection: local.sourceCollection,
sourceOp: local.sourceOp,
conditionField: local.conditionField,
conditionOp: local.conditionOp,
conditionValue: local.conditionValue,
targetApp: local.targetApp,
targetAction: local.targetAction,
targetParams: local.targetParams,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ────────────────────────────────────────
export function useAllAutomations() {
return useLiveQueryWithDefault(async () => {
const locals = await automationTable.toArray();
return locals.filter((a) => !a.deletedAt).map(toAutomation);
}, [] as Automation[]);
}
export function useEnabledAutomations() {
return useLiveQueryWithDefault(async () => {
const locals = await automationTable.toArray();
return locals.filter((a) => !a.deletedAt && a.enabled).map(toAutomation);
}, [] as Automation[]);
}

View file

@ -124,7 +124,7 @@
<div class="mb-2 text-3xl">&#127793;</div>
<p class="text-sm text-muted-foreground">Noch keine Pflanzen angelegt.</p>
<a
href="/planta"
href="/plants"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
Pflanze hinzufügen
@ -140,7 +140,7 @@
<div class="space-y-2">
{#each plantsToWater as plant (plant.id)}
<a
href="/planta"
href="/plants"
class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover"
>
<span class="text-lg">{@html getHealthIcon(plant.healthStatus)}</span>
@ -157,7 +157,7 @@
{/each}
</div>
<a href="/planta" class="mt-3 block text-center text-sm text-primary hover:underline">
<a href="/plants" class="mt-3 block text-center text-sm text-primary hover:underline">
Alle Pflanzen anzeigen
</a>
{/if}

View file

@ -15,7 +15,7 @@ import { beforeEach, describe, expect, it, vi } from 'vitest';
// Database hooks call into funnel-tracking + trigger registry on every write.
// They reach for browser-only globals (localStorage), so stub them the same
// way the planta tests do.
// way the plants tests do.
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
vi.mock('$lib/triggers/inline-suggest', () => ({

View file

@ -53,7 +53,7 @@ export function useAllMeals() {
/**
* Look up a single meal by id and decrypt it. Used by the detail page,
* which inlines its own useLiveQueryWithDefault wrapper so the querier
* can capture the route param directly (matches planta DetailView pattern).
* can capture the route param directly (matches plants DetailView pattern).
*/
export async function loadMealById(id: string): Promise<MealWithNutrition | null> {
const local = await db.table<LocalMeal>('meals').get(id);

View file

@ -1,5 +1,5 @@
<!--
Planta — Workbench ListView
Plants — Workbench ListView
Plant overview with watering schedule.
-->
<script lang="ts">
@ -69,7 +69,7 @@
};
</script>
<BaseListView items={plants} getKey={(p) => p.id} emptyTitle={$_('planta.list.empty')}>
<BaseListView items={plants} getKey={(p) => p.id} emptyTitle={$_('plants.list.empty')}>
{#snippet toolbar()}
<div class="flex items-center justify-end">
<button
@ -78,8 +78,8 @@
onclick={() => (creating = !creating)}
>
{creating
? $_('planta.create.cancel', { default: 'Abbrechen' })
: $_('planta.create.new', { default: '+ Neue Pflanze' })}
? $_('plants.create.cancel', { default: 'Abbrechen' })
: $_('plants.create.new', { default: '+ Neue Pflanze' })}
</button>
</div>
@ -88,14 +88,14 @@
<input
type="text"
bind:value={newName}
placeholder={$_('planta.create.namePlaceholder', { default: 'Name (z. B. Monstera)' })}
placeholder={$_('plants.create.namePlaceholder', { default: 'Name (z. B. Monstera)' })}
required
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
/>
<input
type="text"
bind:value={newScientific}
placeholder={$_('planta.create.scientificPlaceholder', {
placeholder={$_('plants.create.scientificPlaceholder', {
default: 'Botanischer Name (optional)',
})}
class="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm text-white placeholder:text-white/30 focus:border-white/20 focus:outline-none"
@ -105,22 +105,22 @@
class="rounded-md bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
disabled={!newName.trim()}
>
{$_('planta.create.save', { default: 'Pflanze anlegen' })}
{$_('plants.create.save', { default: 'Pflanze anlegen' })}
</button>
</form>
{/if}
{/snippet}
{#snippet header()}
<span>{$_('planta.list.count', { values: { count: plants.length } })}</span>
<span>{$_('plants.list.count', { values: { count: plants.length } })}</span>
{#if dueForWatering.length > 0}
<span class="text-blue-400"
>{$_('planta.list.dueWatering', { values: { count: dueForWatering.length } })}</span
>{$_('plants.list.dueWatering', { values: { count: dueForWatering.length } })}</span
>
{/if}
{#if needsAttention.length > 0}
<span class="text-amber-400"
>{$_('planta.list.needsCare', { values: { count: needsAttention.length } })}</span
>{$_('plants.list.needsCare', { values: { count: needsAttention.length } })}</span
>
{/if}
{/snippet}
@ -153,7 +153,7 @@
</div>
{#if schedule}
<p class="mt-1 text-xs text-white/30">
{$_('planta.list.everyXDays', { values: { days: schedule.frequencyDays } })}
{$_('plants.list.everyXDays', { values: { days: schedule.frequencyDays } })}
</p>
{/if}
</button>

View file

@ -1,5 +1,5 @@
/**
* Planta server-only API client
* Plants 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
@ -53,7 +53,7 @@ export async function uploadPlantPhoto(file: File, plantId: string): Promise<Upl
formData.append('file', file);
formData.append('plantId', plantId);
const res = await fetch(`${getManaApiUrl()}/api/v1/planta/photos/upload`, {
const res = await fetch(`${getManaApiUrl()}/api/v1/plants/photos/upload`, {
method: 'POST',
headers: await authHeader(),
body: formData,
@ -69,7 +69,7 @@ export async function uploadPlantPhoto(file: File, plantId: string): Promise<Upl
/** 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`, {
const res = await fetch(`${getManaApiUrl()}/api/v1/plants/analysis/identify`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',

View file

@ -1,5 +1,5 @@
/**
* Planta module collection accessors and guest seed data.
* Plants module collection accessors and guest seed data.
*
* Tables are already defined in the unified database (database.ts):
* plants, plantPhotos, wateringSchedules, wateringLogs
@ -19,7 +19,7 @@ export const wateringLogTable = db.table<LocalWateringLog>('wateringLogs');
const DEMO_PLANT_ID = 'demo-monstera';
export const PLANTA_GUEST_SEED = {
export const PLANTS_GUEST_SEED = {
plants: [
{
id: DEMO_PLANT_ID,

View file

@ -1,5 +1,5 @@
/**
* Planta module barrel exports.
* Plants module barrel exports.
*/
// Collections & seed data
@ -8,7 +8,7 @@ export {
plantPhotoTable,
wateringScheduleTable,
wateringLogTable,
PLANTA_GUEST_SEED,
PLANTS_GUEST_SEED,
} from './collections';
// Types
@ -65,4 +65,4 @@ export {
type ParsedPlant,
type ParsedPlantWithIds,
} from './utils/plant-parser';
export { PLANTA_SYNTAX } from './utils/syntax-help';
export { PLANTS_SYNTAX } from './utils/syntax-help';

View file

@ -1,7 +1,7 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const plantaModuleConfig: ModuleConfig = {
appId: 'planta',
export const plantsModuleConfig: ModuleConfig = {
appId: 'plants',
tables: [
{ name: 'plants' },
{ name: 'plantPhotos' },

View file

@ -1,7 +1,7 @@
/**
* Integration tests for planta mutations against a real (fake) IndexedDB.
* Integration tests for plants mutations against a real (fake) IndexedDB.
*
* Focus: wateringMutations.logWatering the most consequential planta
* Focus: wateringMutations.logWatering the most consequential plants
* write because it (a) appends a log and (b) re-anchors the schedule's
* nextWateringAt, which drives every "needs water" badge in the UI.
*/
@ -10,7 +10,7 @@ import 'fake-indexeddb/auto';
import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@mana/shared-utils/analytics', () => ({
PlantaEvents: {
PlantsEvents: {
plantCreated: vi.fn(),
plantDeleted: vi.fn(),
plantWatered: vi.fn(),
@ -37,7 +37,7 @@ const wateringSchedules = () => db.table<LocalWateringSchedule>('wateringSchedul
beforeEach(async () => {
setCurrentUserId('test-user');
// Planta `plants` table is encrypted; install a real Web Crypto key
// Plants `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.

View file

@ -1,5 +1,5 @@
/**
* Planta Mutation Helpers (Local-First)
* Plants Mutation Helpers (Local-First)
*
* All writes go to IndexedDB first, sync handles the rest. Mutations throw
* on failure so UI callers can surface errors via toasts.
@ -7,7 +7,7 @@
import { db } from '$lib/data/database';
import { toPlant, toWateringSchedule } from './queries';
import { PlantaEvents } from '@mana/shared-utils/analytics';
import { PlantsEvents } from '@mana/shared-utils/analytics';
import { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { createBlock } from '$lib/data/time-blocks/service';
import { uploadPlantPhoto, identifyPlant, type IdentifyResult } from './api';
@ -45,7 +45,7 @@ export const plantMutations = {
const plaintextSnapshot = toPlant(newLocal);
await encryptRecord('plants', newLocal);
await db.table('plants').add(newLocal);
PlantaEvents.plantCreated();
PlantsEvents.plantCreated();
return plaintextSnapshot;
},
@ -77,7 +77,7 @@ export const plantMutations = {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
PlantaEvents.plantDeleted();
PlantsEvents.plantDeleted();
},
/**
@ -139,7 +139,7 @@ export const wateringMutations = {
endDate: now,
kind: 'logged',
type: 'watering',
sourceModule: 'planta',
sourceModule: 'plants',
sourceId: logEntry.id,
title: `${plantName} gegossen`,
color: '#06b6d4',
@ -159,7 +159,7 @@ export const wateringMutations = {
});
}
PlantaEvents.plantWatered();
PlantsEvents.plantWatered();
},
async updateSchedule(plantId: string, frequencyDays: number): Promise<void> {

View file

@ -1,5 +1,5 @@
/**
* Pure-function tests for planta queries.
* Pure-function tests for plants 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

View file

@ -1,5 +1,5 @@
/**
* Reactive Queries & Pure Helpers for Planta
* Reactive Queries & Pure Helpers for Plants
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks

View file

@ -1,5 +1,5 @@
/**
* Planta QuickInputBar Adapter
* Plants QuickInputBar Adapter
*/
import type { InputBarAdapter } from '$lib/quick-input/types';

View file

@ -1,5 +1,5 @@
/**
* Planta Tags Uses shared global tags + module-specific junction table.
* Plants Tags Uses shared global tags + module-specific junction table.
*/
import { db } from '$lib/data/database';

View file

@ -1,5 +1,5 @@
/**
* Planta module types for the unified app.
* Plants module types for the unified app.
*/
import type { BaseRecord } from '@mana/local-store';

View file

@ -1,5 +1,5 @@
/**
* Plant Parser for Planta Module
* Plant Parser for Plants Module
*
* Extends the base parser with plant-specific patterns:
* - Scientific names (italic Latin names)

View file

@ -1,9 +1,9 @@
/**
* Planta-specific syntax help patterns
* Plants-specific syntax help patterns
*/
import type { SyntaxGroup } from '@mana/shared-ui';
export const PLANTA_SYNTAX: SyntaxGroup[] = [
export const PLANTS_SYNTAX: SyntaxGroup[] = [
{
title: 'Pflanzen',
items: [

View file

@ -1,5 +1,5 @@
<!--
Planta — DetailView (inline editable overlay)
Plants — DetailView (inline editable overlay)
All fields are always editable. Changes auto-save on blur.
Includes photo upload + AI plant identification.
-->
@ -92,7 +92,7 @@
showTagPicker = false;
} catch (err) {
console.error('add tag failed:', err);
toast.error($_('planta.errors.saveFailed'));
toast.error($_('plants.errors.saveFailed'));
}
}
@ -101,7 +101,7 @@
await plantTagOps.removeTag(plantId, tagId);
} catch (err) {
console.error('remove tag failed:', err);
toast.error($_('planta.errors.saveFailed'));
toast.error($_('plants.errors.saveFailed'));
}
}
@ -117,7 +117,7 @@
detail.blur();
try {
await plantMutations.update(plantId, {
name: editName.trim() || detail.entity?.name || $_('planta.plant.unnamed'),
name: editName.trim() || detail.entity?.name || $_('plants.plant.unnamed'),
scientificName: editScientificName.trim() || undefined,
careNotes: editCareNotes.trim() || undefined,
lightRequirements: editLightRequirements || undefined,
@ -133,7 +133,7 @@
});
} catch (err) {
console.error('plant save failed:', err);
toast.error($_('planta.errors.saveFailed'));
toast.error($_('plants.errors.saveFailed'));
}
}
@ -146,7 +146,7 @@
});
} catch (err) {
console.error('plant select save failed:', err);
toast.error($_('planta.errors.saveFailed'));
toast.error($_('plants.errors.saveFailed'));
}
}
@ -161,10 +161,10 @@
uploading = true;
try {
await photoMutations.upload(plantId, file);
toast.success($_('planta.success.photoUploaded'));
toast.success($_('plants.success.photoUploaded'));
} catch (err) {
console.error('photo upload failed:', err);
toast.error($_('planta.errors.uploadFailed'));
toast.error($_('plants.errors.uploadFailed'));
} finally {
uploading = false;
if (fileInput) fileInput.value = '';
@ -174,7 +174,7 @@
async function handleIdentify() {
const primary = photos[0];
if (!primary) {
toast.error($_('planta.errors.identifyFailed'));
toast.error($_('plants.errors.identifyFailed'));
return;
}
identifying = true;
@ -182,10 +182,10 @@
try {
const result = await photoMutations.identify(primary.id);
identifyResult = result;
toast.success($_('planta.success.identified'));
toast.success($_('plants.success.identified'));
} catch (err) {
console.error('identify failed:', err);
toast.error($_('planta.errors.identifyFailed'));
toast.error($_('plants.errors.identifyFailed'));
} finally {
identifying = false;
}
@ -195,11 +195,11 @@
if (!identifyResult) return;
try {
await plantMutations.applyIdentification(plantId, identifyResult, { overwrite: false });
toast.success($_('planta.success.plantSaved'));
toast.success($_('plants.success.plantSaved'));
identifyResult = null;
} catch (err) {
console.error('apply identification failed:', err);
toast.error($_('planta.errors.saveFailed'));
toast.error($_('plants.errors.saveFailed'));
}
}
@ -208,7 +208,7 @@
await photoMutations.setPrimary(plantId, photoId);
} catch (err) {
console.error('set primary failed:', err);
toast.error($_('planta.errors.saveFailed'));
toast.error($_('plants.errors.saveFailed'));
}
}
@ -217,7 +217,7 @@
await photoMutations.remove(photoId);
} catch (err) {
console.error('remove photo failed:', err);
toast.error($_('planta.errors.deleteFailed'));
toast.error($_('plants.errors.deleteFailed'));
}
}
</script>
@ -225,14 +225,14 @@
<DetailViewShell
entity={detail.entity}
loading={detail.loading}
notFoundLabel={$_('planta.plant.notFound')}
notFoundLabel={$_('plants.plant.notFound')}
confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete}
confirmDeleteLabel={$_('planta.plant.confirmDelete')}
confirmDeleteLabel={$_('plants.plant.confirmDelete')}
onConfirmDelete={() =>
detail.deleteWithUndo({
label: $_('planta.success.plantDeleted'),
label: $_('plants.success.plantDeleted'),
delete: deletePlant,
goBack,
})}
@ -243,75 +243,75 @@
bind:value={editName}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('planta.plant.namePlaceholder')}
placeholder={$_('plants.plant.namePlaceholder')}
/>
<div class="properties">
<div class="prop-row">
<span class="prop-label">{$_('planta.plant.scientificName')}</span>
<span class="prop-label">{$_('plants.plant.scientificName')}</span>
<input
class="prop-input"
bind:value={editScientificName}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('planta.common.none')}
placeholder={$_('plants.common.none')}
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('planta.plant.species')}</span>
<span class="prop-label">{$_('plants.plant.species')}</span>
<input
class="prop-input"
bind:value={editSpecies}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('planta.common.none')}
placeholder={$_('plants.common.none')}
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('planta.plant.state')}</span>
<span class="prop-label">{$_('plants.plant.state')}</span>
<select
class="prop-select health-{editHealthStatus}"
bind:value={editHealthStatus}
onchange={handleSelectChange}
>
<option value="healthy">{$_('planta.health.healthy')}</option>
<option value="needs_attention">{$_('planta.health.needsAttention')}</option>
<option value="sick">{$_('planta.health.sick')}</option>
<option value="healthy">{$_('plants.health.healthy')}</option>
<option value="needs_attention">{$_('plants.health.needsAttention')}</option>
<option value="sick">{$_('plants.health.sick')}</option>
</select>
</div>
<div class="prop-row">
<span class="prop-label">{$_('planta.plant.light')}</span>
<span class="prop-label">{$_('plants.plant.light')}</span>
<select
class="prop-select"
bind:value={editLightRequirements}
onchange={handleSelectChange}
>
<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>
<option value="">{$_('plants.common.none')}</option>
<option value="low">{$_('plants.light.low')}</option>
<option value="medium">{$_('plants.light.medium')}</option>
<option value="bright">{$_('plants.light.bright')}</option>
<option value="direct">{$_('plants.light.direct')}</option>
</select>
</div>
<div class="prop-row">
<span class="prop-label">{$_('planta.plant.wateringDays')}</span>
<span class="prop-label">{$_('plants.plant.wateringDays')}</span>
<input
type="number"
class="prop-input"
bind:value={editWateringFrequencyDays}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('planta.common.none')}
placeholder={$_('plants.common.none')}
min="1"
/>
</div>
<div class="prop-row">
<span class="prop-label">{$_('planta.plant.acquired')}</span>
<span class="prop-label">{$_('plants.plant.acquired')}</span>
<input
type="date"
class="prop-input"
@ -323,20 +323,20 @@
</div>
<div class="section">
<span class="section-label">{$_('planta.plant.careNotes')}</span>
<span class="section-label">{$_('plants.plant.careNotes')}</span>
<textarea
class="description-input"
bind:value={editCareNotes}
onfocus={detail.focus}
onblur={saveField}
placeholder={$_('planta.plant.notesPlaceholder')}
placeholder={$_('plants.plant.notesPlaceholder')}
rows={3}
></textarea>
</div>
<div class="section">
<div class="section-header">
<span class="section-label">{$_('planta.photo.section')}</span>
<span class="section-label">{$_('plants.photo.section')}</span>
<div class="photo-actions">
<button
type="button"
@ -344,7 +344,7 @@
onclick={() => fileInput?.click()}
disabled={uploading}
>
{uploading ? $_('planta.photo.uploading') : $_('planta.photo.upload')}
{uploading ? $_('plants.photo.uploading') : $_('plants.photo.upload')}
</button>
{#if photos.length > 0}
<button
@ -353,7 +353,7 @@
onclick={handleIdentify}
disabled={identifying}
>
{identifying ? $_('planta.identify.analyzing') : $_('planta.identify.button')}
{identifying ? $_('plants.identify.analyzing') : $_('plants.identify.button')}
</button>
{/if}
</div>
@ -367,7 +367,7 @@
/>
{#if photos.length === 0}
<p class="empty">{$_('planta.photo.noPhotos')}</p>
<p class="empty">{$_('plants.photo.noPhotos')}</p>
{:else}
<div class="photo-grid">
{#each photos as photo (photo.id)}
@ -379,7 +379,7 @@
type="button"
class="photo-btn"
onclick={() => handleSetPrimary(photo.id)}
title={$_('planta.photo.primary')}
title={$_('plants.photo.primary')}
>
</button>
@ -388,7 +388,7 @@
type="button"
class="photo-btn danger"
onclick={() => handleRemovePhoto(photo.id)}
title={$_('planta.common.delete')}
title={$_('plants.common.delete')}
>
×
</button>
@ -401,14 +401,14 @@
{#if identifyResult}
<div class="identify-result">
<div class="identify-header">
<span class="identify-title">{$_('planta.identify.resultTitle')}</span>
<span class="identify-title">{$_('plants.identify.resultTitle')}</span>
<button type="button" class="action-btn primary" onclick={applyIdentification}>
{$_('planta.identify.applyResult')}
{$_('plants.identify.applyResult')}
</button>
</div>
{#if identifyResult.scientificName}
<p>
<strong>{$_('planta.plant.scientificName')}:</strong>
<strong>{$_('plants.plant.scientificName')}:</strong>
{identifyResult.scientificName}
</p>
{/if}
@ -417,7 +417,7 @@
{/if}
{#if identifyResult.confidence !== undefined}
<p class="muted">
{$_('planta.identify.confidence')}: {Math.round(identifyResult.confidence * 100)}%
{$_('plants.identify.confidence')}: {Math.round(identifyResult.confidence * 100)}%
</p>
{/if}
{#if identifyResult.wateringAdvice}
@ -441,7 +441,7 @@
type="button"
class="tag-remove"
onclick={() => handleRemoveTag(tag.id)}
aria-label={$_('planta.common.delete')}
aria-label={$_('plants.common.delete')}
>
×
</button>
@ -468,7 +468,7 @@
<!-- Watering history -->
{#if wateringLogs.length > 0}
<div class="section">
<span class="section-label">{$_('planta.watering.lastWatered')}</span>
<span class="section-label">{$_('plants.watering.lastWatered')}</span>
<ul class="watering-history">
{#each wateringLogs as log (log.id)}
<li>
@ -484,10 +484,10 @@
<div class="meta">
<span
>{$_('planta.plant.created')}: {new Date(plant.createdAt ?? '').toLocaleDateString()}</span
>{$_('plants.plant.created')}: {new Date(plant.createdAt ?? '').toLocaleDateString()}</span
>
{#if plant.updatedAt}
<span>{$_('planta.plant.edited')}: {new Date(plant.updatedAt).toLocaleDateString()}</span>
<span>{$_('plants.plant.edited')}: {new Date(plant.updatedAt).toLocaleDateString()}</span>
{/if}
</div>
{/snippet}

View file

@ -12,7 +12,7 @@ const registry = new Map<string, () => Promise<AdapterModule>>([
['/calendar', () => import('$lib/modules/calendar/quick-input-adapter')],
['/contacts', () => import('$lib/modules/contacts/quick-input-adapter')],
['/times', () => import('$lib/modules/times/quick-input-adapter')],
['/planta', () => import('$lib/modules/planta/quick-input-adapter')],
['/plants', () => import('$lib/modules/plants/quick-input-adapter')],
['/nutriphi', () => import('$lib/modules/nutriphi/quick-input-adapter')],
]);

View file

@ -25,7 +25,7 @@ const SPLIT_APP_ID_LIST = [
'context',
'questions',
'nutriphi',
'planta',
'plants',
'uload',
'calc',
'moodlit',

View file

@ -59,7 +59,7 @@ export async function loadAutomations(): Promise<void> {
});
}
if (active.length > 0) {
if (import.meta.env.DEV && active.length > 0) {
console.log(`[Triggers] Loaded ${active.length} automation(s)`);
}
}

View file

@ -54,7 +54,7 @@ describe('WIDGET_REGISTRY', () => {
'context',
'mana-auth',
'nutriphi',
'planta',
'plants',
'cycles',
undefined,
];

View file

@ -27,7 +27,7 @@ export type WidgetType =
| 'context-docs' // Context: recent documents & spaces
| 'active-timer' // Times: running timer
| 'nutrition-progress' // NutriPhi: today's calorie progress
| 'plant-watering' // Planta: plants due for watering
| 'plant-watering' // Plants: plants due for watering
| 'day-timeline' // TimeBlocks: chronological day timeline
| 'activity-feed' // TimeBlocks: recent activity across modules
| 'cycles' // Cycles: current phase + days until next period
@ -131,7 +131,7 @@ export interface WidgetMeta {
| 'context'
| 'times'
| 'nutriphi'
| 'planta'
| 'plants'
| 'cycles'
| 'body'
| 'mana-auth';
@ -318,7 +318,7 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
icon: '🌱',
defaultSize: 'small',
allowMultiple: false,
requiredBackend: 'planta',
requiredBackend: 'plants',
},
{
type: 'day-timeline',

View file

@ -42,16 +42,9 @@
import { linkLocalStore, linkMutations } from '@mana/shared-links';
import { manaStore } from '$lib/data/local-store';
import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue';
import { llmSettingsState, updateLlmSettings, tierLabel, type LlmTier } from '@mana/shared-llm';
import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm';
import {
getLocalSttStatus,
loadLocalStt,
isLocalSttSupported,
MODELS as STT_MODELS,
DEFAULT_MODEL as STT_DEFAULT_MODEL,
type ModelKey as SttModelKey,
} from '@mana/local-stt';
import { useAiTierItems } from '$lib/components/layout/use-ai-tier-items.svelte';
import { useSyncStatusItems } from '$lib/components/layout/use-sync-status-items.svelte';
import RouteTierGate from '$lib/components/layout/RouteTierGate.svelte';
import { useLocalStt } from '$lib/components/voice/use-local-stt.svelte';
import { Microphone, Stop } from '@mana/shared-icons';
import {
@ -186,274 +179,10 @@
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// ── AI Tier Selector (PillNav dropdown) ─────────────────
const webgpuSupported = isLocalLlmSupported();
const localLlmStatus = getLocalLlmStatus();
const sttSupported = isLocalSttSupported();
const localSttStatus = getLocalSttStatus();
let selectedSttModel = $state<SttModelKey>(STT_DEFAULT_MODEL);
const llmSettings = $derived(llmSettingsState.current);
function toggleAiTier(tier: LlmTier) {
const current = llmSettings.allowedTiers;
const next = current.includes(tier)
? current.filter((t: LlmTier) => t !== tier)
: [...current, tier];
updateLlmSettings({ allowedTiers: next });
}
const TIER_TOGGLE_LIST: Array<{ tier: LlmTier; shortLabel: string; icon: string }> = [
{ tier: 'browser', shortLabel: 'Lokal (Gemma 4)', icon: 'robot' },
{ tier: 'mana-server', shortLabel: 'Server (Gemma 4)', icon: 'globe' },
{ tier: 'cloud', shortLabel: 'Cloud (Gemini)', icon: 'cloud' },
];
let aiTierItems = $derived<PillDropdownItem[]>([
// Tier toggles — browser tier item and its model-status buddy share a
// group so PillDropdownBar renders them as a paired pill.
...TIER_TOGGLE_LIST.filter((t) => t.tier !== 'browser' || webgpuSupported).map((t) => {
const isActive = llmSettings.allowedTiers.includes(t.tier);
return {
id: `ai-tier-${t.tier}`,
label: t.shortLabel,
icon: isActive ? 'checkCircle' : t.icon,
active: isActive,
onClick: () => toggleAiTier(t.tier),
...(t.tier === 'browser' ? { group: 'local-llm' } : {}),
};
}),
// Browser model status / load button (grouped with the "Lokal" toggle).
// Handles all LoadingStatus states so the user sees feedback during
// download, initialization, and on error (e.g. worker crash).
...(llmSettings.allowedTiers.includes('browser') && webgpuSupported
? [
(() => {
const s = localLlmStatus.current;
const state = s.state;
let label: string;
let icon: string;
let danger = false;
let disabled = false;
switch (state) {
case 'ready':
label = 'Geladen';
icon = 'checkCircle';
disabled = true;
break;
case 'downloading':
label = `Lade… ${((s as { progress: number }).progress * 100).toFixed(0)}%`;
icon = 'clock';
disabled = true;
break;
case 'loading':
label = 'Initialisiere…';
icon = 'clock';
disabled = true;
break;
case 'checking':
label = 'Prüfe…';
icon = 'clock';
disabled = true;
break;
case 'error':
label = 'Fehler — erneut versuchen';
icon = 'bell';
danger = true;
break;
default:
label = 'Modell laden';
icon = 'cloud';
}
return {
id: 'ai-browser-status',
label,
icon,
group: 'local-llm',
danger,
disabled,
progress: state === 'downloading' ? (s as { progress: number }).progress : undefined,
onClick: !disabled ? () => void loadLocalLlm() : undefined,
};
})(),
]
: []),
// ── STT section ──────────────────────────────────
{ id: 'stt-divider', label: '', divider: true },
// STT model selector — each model is a pill, active = currently selected
...(sttSupported
? (Object.entries(STT_MODELS) as [SttModelKey, (typeof STT_MODELS)[SttModelKey]][]).map(
([key, model]) => {
const isSelected = selectedSttModel === key;
return {
id: `stt-model-${key}`,
label: model.displayName,
icon: isSelected ? 'checkCircle' : 'mic',
active: isSelected,
onClick: () => {
selectedSttModel = key;
void loadLocalStt(key);
},
};
}
)
: []),
// STT model status (grouped with selected model)
...(sttSupported
? [
(() => {
const s = localSttStatus.current;
const state = s.state;
let label: string;
let icon: string;
let danger = false;
let disabled = false;
switch (state) {
case 'ready':
label = 'STT bereit';
icon = 'checkCircle';
disabled = true;
break;
case 'downloading':
label = `STT Lade… ${((s as { progress: number }).progress * 100).toFixed(0)}%`;
icon = 'clock';
disabled = true;
break;
case 'loading':
label = 'STT lädt…';
icon = 'clock';
disabled = true;
break;
case 'checking':
label = 'STT prüft…';
icon = 'clock';
disabled = true;
break;
case 'error':
label = 'STT Fehler';
icon = 'bell';
danger = true;
break;
default:
label = 'STT Modell laden';
icon = 'mic';
}
return {
id: 'stt-status',
label,
icon,
danger,
disabled,
progress: state === 'downloading' ? (s as { progress: number }).progress : undefined,
onClick: !disabled ? () => void loadLocalStt(selectedSttModel) : undefined,
};
})(),
]
: []),
// Divider + settings link
{ id: 'ai-divider', label: '', divider: true },
{
id: 'ai-settings',
label: 'KI-Einstellungen',
icon: 'settings',
onClick: () => goto('/settings#ai-options'),
},
]);
let currentAiTierLabel = $derived.by(() => {
const active = llmSettings.allowedTiers;
if (active.length === 0) return 'Aus';
// Show the first (privacy-sorted) tier's short name
const sorted = [...active].sort(
(a, b) =>
TIER_TOGGLE_LIST.findIndex((t) => t.tier === a) -
TIER_TOGGLE_LIST.findIndex((t) => t.tier === b)
);
const first = TIER_TOGGLE_LIST.find((t) => t.tier === sorted[0]);
return first ? first.shortLabel.split(' (')[0] : 'KI';
});
let currentAiTierIcon = $derived.by(() => {
const active = llmSettings.allowedTiers;
if (active.length === 0) return 'power';
const sorted = [...active].sort(
(a, b) =>
TIER_TOGGLE_LIST.findIndex((t) => t.tier === a) -
TIER_TOGGLE_LIST.findIndex((t) => t.tier === b)
);
const first = TIER_TOGGLE_LIST.find((t) => t.tier === sorted[0]);
return first ? first.icon : 'cpu';
});
const aiTier = useAiTierItems();
// ── Sync status dropdown ────────────────────────────────
let syncStatusItems = $derived.by(() => {
const items: import('@mana/shared-ui').PillDropdownItem[] = [];
if (syncBilling.active) {
items.push({
id: 'sync-active',
label: 'Cloud Sync aktiv',
icon: 'cloud',
active: true,
disabled: true,
});
if (syncBilling.nextChargeAt) {
const date = new Date(syncBilling.nextChargeAt).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
items.push({
id: 'sync-next',
label: `Nächste Abbuchung: ${date}`,
icon: 'calendar',
disabled: true,
});
}
} else if (syncBilling.paused) {
items.push({
id: 'sync-paused',
label: 'Sync pausiert — Credits aufladen',
icon: 'bell',
onClick: () => goto('/credits?tab=packages'),
});
} else {
items.push({
id: 'sync-inactive',
label: 'Sync aktivieren',
icon: 'cloud',
onClick: () => goto('/settings/sync'),
});
items.push({
id: 'sync-info',
label: 'Nur lokal — ab 30 Credits/Monat',
icon: 'creditCard',
disabled: true,
});
}
items.push({ id: 'sync-divider', label: '', divider: true });
items.push({
id: 'sync-settings',
label: 'Sync-Einstellungen',
icon: 'settings',
onClick: () => goto('/settings/sync'),
});
return items;
});
let currentSyncLabel = $derived(
syncBilling.loading
? '...'
: syncBilling.active
? 'Sync'
: syncBilling.paused
? 'Pausiert'
: 'Lokal'
);
const syncStatus = useSyncStatusItems();
// ── User / Guest awareness ──────────────────────────────
let userEmail = $derived(
@ -817,11 +546,10 @@
$effect(() => {
const t = localStt.text;
const e = localStt.error;
if (e) {
if (import.meta.env.DEV && e) {
console.warn('[layout-stt] Error:', e);
}
if (t) {
console.log('[layout-stt] Transcribed text:', t);
sttInjectedText = t;
}
});
@ -1082,12 +810,12 @@
primaryColor="#6366f1"
showAppSwitcher={true}
showAiTierSelector={true}
{aiTierItems}
{currentAiTierLabel}
{currentAiTierIcon}
aiTierItems={aiTier.items}
currentAiTierLabel={aiTier.label}
currentAiTierIcon={aiTier.icon}
showSyncStatus={authStore.isAuthenticated}
{syncStatusItems}
{currentSyncLabel}
syncStatusItems={syncStatus.items}
currentSyncLabel={syncStatus.label}
{appItems}
{userEmail}
settingsHref="/settings"
@ -1121,70 +849,11 @@
>
<div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
{#if routeBlocked && routeAppId}
<!-- Per-route tier gate. The wrapping AuthGate only fires
onMount + only for authenticated users, so this is the
only place that catches direct URL navigation into a
gated module by a guest or under-tier user. -->
<div class="flex min-h-[60vh] items-center justify-center p-6">
<div
class="w-full max-w-96 rounded-2xl border px-8 py-10 text-center shadow-sm"
style:border-color="hsl(var(--border, 0 0% 90%))"
style:background-color="hsl(var(--card, 0 0% 100%))"
>
<h1 class="mb-4 text-xl font-bold" style:color="hsl(var(--foreground, 0 0% 9%))">
{routeAppId.name}
</h1>
<div class="mb-4 text-5xl">🔒</div>
<p
class="mb-6 text-[0.9375rem] leading-relaxed"
style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>
{($locale || 'de') === 'de'
? 'Diese App ist aktuell in der geschlossenen '
: 'This app is currently in closed '}<strong>{routeTierLabels.required}</strong
>{($locale || 'de') === 'de' ? '-Phase.' : ' phase.'}
</p>
<div
class="mb-6 flex flex-col gap-2 rounded-xl p-4"
style:background-color="hsl(var(--muted, 0 0% 96%))"
>
<div class="flex items-center justify-between text-sm">
<span style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>{($locale || 'de') === 'de' ? 'Dein Zugang:' : 'Your access:'}</span
>
<span class="font-semibold" style:color="hsl(var(--foreground, 0 0% 9%))"
>{routeTierLabels.user}</span
>
</div>
<div class="flex items-center justify-between text-sm">
<span style:color="hsl(var(--muted-foreground, 0 0% 45%))"
>{($locale || 'de') === 'de' ? 'Benötigt:' : 'Required:'}</span
>
<span class="font-semibold text-violet-500">{routeTierLabels.required}</span>
</div>
</div>
<div class="flex flex-col gap-2">
<button
class="w-full cursor-pointer rounded-lg border-none px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
style:background-color="hsl(var(--primary, 239 84% 67%))"
style:color="hsl(var(--primary-foreground, 0 0% 100%))"
onclick={() => goto('/')}
>
{($locale || 'de') === 'de' ? 'Zur Übersicht' : 'Back to overview'}
</button>
{#if !authStore.isAuthenticated}
<button
class="w-full cursor-pointer rounded-lg border px-4 py-2.5 text-sm font-medium transition-opacity hover:opacity-90"
style:border-color="hsl(var(--border, 0 0% 90%))"
style:color="hsl(var(--foreground, 0 0% 9%))"
onclick={() => goto('/login')}
>
{($locale || 'de') === 'de' ? 'Anmelden' : 'Sign in'}
</button>
{/if}
</div>
</div>
</div>
<RouteTierGate
appName={routeAppId.name}
userTierLabel={routeTierLabels.user}
requiredTierLabel={routeTierLabels.required}
/>
{:else}
{@render children()}
{/if}

View file

@ -68,7 +68,7 @@
cards: 'Cards',
nutriphi: 'NutriPhi',
picture: 'Picture',
planta: 'Planta',
plants: 'Plants',
presi: 'Presi',
questions: 'Questions',
skilltree: 'SkillTree',

View file

@ -9,7 +9,7 @@
import { ArrowLeft, Trash } from '@mana/shared-icons';
// Inline the live query so the closure captures page.params.id directly
// (matches the planta DetailView pattern).
// (matches the plants DetailView pattern).
const mealQuery = useLiveQueryWithDefault(
() => (page.params.id ? loadMealById(page.params.id) : Promise.resolve(null)),
null as MealWithNutrition | null

View file

@ -1,8 +1,8 @@
<!--
Planta routes layout
Plants routes layout
Provides live-query contexts to all child routes (/planta, /planta/[id],
/planta/add, /planta/tags). The contexts are referenced via getContext()
Provides live-query contexts to all child routes (/plants, /plants/[id],
/plants/add, /plants/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".
-->
@ -18,7 +18,7 @@
toWateringSchedule,
toWateringLog,
useAllTags,
} from '$lib/modules/planta/queries';
} from '$lib/modules/plants/queries';
import type {
LocalPlant,
LocalPlantPhoto,
@ -29,7 +29,7 @@
PlantPhoto,
WateringSchedule,
WateringLog,
} from '$lib/modules/planta/types';
} from '$lib/modules/plants/types';
let { children }: { children: Snippet } = $props();

View file

@ -3,15 +3,15 @@
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { toast } from '$lib/stores/toast.svelte';
import { wateringMutations } from '$lib/modules/planta/mutations';
import { wateringMutations } from '$lib/modules/plants/mutations';
import {
getActivePlants,
getPrimaryPhoto,
getScheduleForPlant,
getDaysUntilWatering,
isWateringOverdue,
} from '$lib/modules/planta/queries';
import type { Plant, PlantPhoto, WateringSchedule } from '$lib/modules/planta/types';
} from '$lib/modules/plants/queries';
import type { Plant, PlantPhoto, WateringSchedule } from '$lib/modules/plants/types';
const allPlants: { readonly value: Plant[] } = getContext('plants');
const allPlantPhotos: { readonly value: PlantPhoto[] } = getContext('plantPhotos');
@ -35,9 +35,9 @@
if (!schedule) return '';
const days = getDaysUntilWatering(schedule);
if (days === null) return '';
if (days < 0) return $_('planta.watering.overdue');
if (days === 0) return $_('planta.watering.today');
return $_('planta.watering.daysUntil', { values: { days } });
if (days < 0) return $_('plants.watering.overdue');
if (days === 0) return $_('plants.watering.today');
return $_('plants.watering.daysUntil', { values: { days } });
}
function shouldShowWaterButton(plantId: string): boolean {
@ -51,30 +51,30 @@
e.stopPropagation();
try {
await wateringMutations.logWatering(plantId);
toast.success($_('planta.success.plantWatered'));
toast.success($_('plants.success.plantWatered'));
} catch (err) {
console.error('logWatering failed:', err);
toast.error($_('planta.errors.wateringFailed'));
toast.error($_('plants.errors.wateringFailed'));
}
}
</script>
<svelte:head>
<title>{$_('planta.nav.plants')} - Planta</title>
<title>{$_('plants.nav.plants')} - Plants</title>
</svelte:head>
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">{$_('planta.nav.plants')}</h1>
<a href="/planta/add" class="btn btn-success">{$_('planta.plant.add')}</a>
<h1 class="text-2xl font-bold">{$_('plants.nav.plants')}</h1>
<a href="/plants/add" class="btn btn-success">{$_('plants.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">{$_('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>
<h2 class="text-xl font-semibold mb-2">{$_('plants.plant.noPlants')}</h2>
<p class="text-muted-foreground mb-4">{$_('plants.app.tagline')}</p>
<a href="/plants/add" class="btn btn-success">{$_('plants.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">
@ -87,11 +87,11 @@
role="link"
tabindex="0"
class="card plant-card text-left"
onclick={() => goto(`/planta/${plant.id}`)}
onclick={() => goto(`/plants/${plant.id}`)}
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
goto(`/planta/${plant.id}`);
goto(`/plants/${plant.id}`);
}
}}
>
@ -118,9 +118,9 @@
type="button"
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={$_('planta.watering.water')}
title={$_('plants.watering.water')}
>
{$_('planta.watering.water')}
{$_('plants.watering.water')}
</button>
{/if}
</div>

View file

@ -4,14 +4,14 @@
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { toast } from '$lib/stores/toast.svelte';
import { plantMutations, wateringMutations } from '$lib/modules/planta/mutations';
import { plantMutations, wateringMutations } from '$lib/modules/plants/mutations';
import {
getPlantById,
getPhotosForPlant,
getScheduleForPlant,
getLogsForPlant,
} from '$lib/modules/planta/queries';
import type { Plant, PlantPhoto, WateringSchedule, WateringLog } from '$lib/modules/planta/types';
} from '$lib/modules/plants/queries';
import type { Plant, PlantPhoto, WateringSchedule, WateringLog } from '$lib/modules/plants/types';
const allPlants: { readonly value: Plant[] } = getContext('plants');
const allPlantPhotos: { readonly value: PlantPhoto[] } = getContext('plantPhotos');
@ -36,10 +36,10 @@
watering = true;
try {
await wateringMutations.logWatering(plant.id);
toast.success($_('planta.success.plantWatered'));
toast.success($_('plants.success.plantWatered'));
} catch (err) {
console.error('logWatering failed:', err);
toast.error($_('planta.errors.wateringFailed'));
toast.error($_('plants.errors.wateringFailed'));
} finally {
watering = false;
}
@ -47,15 +47,15 @@
async function handleDelete() {
if (!plant) return;
if (!confirm($_('planta.plant.confirmDelete'))) return;
if (!confirm($_('plants.plant.confirmDelete'))) return;
try {
await plantMutations.delete(plant.id);
toast.success($_('planta.success.plantDeleted'));
goto('/planta');
toast.success($_('plants.success.plantDeleted'));
goto('/plants');
} catch (err) {
console.error('delete plant failed:', err);
toast.error($_('planta.errors.deleteFailed'));
toast.error($_('plants.errors.deleteFailed'));
}
}
@ -75,48 +75,48 @@
}
function getHealthText(status: string | null | undefined): string {
if (status === 'needs_attention') return $_('planta.health.needsAttention');
if (status === 'sick') return $_('planta.health.sick');
return $_('planta.health.healthy');
if (status === 'needs_attention') return $_('plants.health.needsAttention');
if (status === 'sick') return $_('plants.health.sick');
return $_('plants.health.healthy');
}
function getLightText(light: string | null | undefined): string {
switch (light) {
case 'low':
return $_('planta.light.low');
return $_('plants.light.low');
case 'medium':
return $_('planta.light.medium');
return $_('plants.light.medium');
case 'bright':
return $_('planta.light.bright');
return $_('plants.light.bright');
case 'direct':
return $_('planta.light.direct');
return $_('plants.light.direct');
default:
return $_('planta.common.none');
return $_('plants.common.none');
}
}
function getHumidityText(humidity: string | null | undefined): string {
switch (humidity) {
case 'low':
return $_('planta.humidity.low');
return $_('plants.humidity.low');
case 'medium':
return $_('planta.humidity.medium');
return $_('plants.humidity.medium');
case 'high':
return $_('planta.humidity.high');
return $_('plants.humidity.high');
default:
return $_('planta.common.none');
return $_('plants.common.none');
}
}
</script>
<svelte:head>
<title>{plant?.name || $_('planta.app.name')} - Planta</title>
<title>{plant?.name || $_('plants.app.name')} - Plants</title>
</svelte:head>
{#if !plant}
<div class="text-center py-12">
<p class="text-lg">{$_('planta.plant.notFound')}</p>
<a href="/planta" class="btn btn-primary mt-4">{$_('planta.nav.plants')}</a>
<p class="text-lg">{$_('plants.plant.notFound')}</p>
<a href="/plants" class="btn btn-primary mt-4">{$_('plants.nav.plants')}</a>
</div>
{:else}
<div class="space-y-6">
@ -153,28 +153,28 @@
<!-- Care Info -->
<div class="card">
<h2 class="font-semibold mb-4">{$_('planta.plant.careNotes')}</h2>
<h2 class="font-semibold mb-4">{$_('plants.plant.careNotes')}</h2>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-muted-foreground">{$_('planta.plant.light')}</p>
<p class="text-sm text-muted-foreground">{$_('plants.plant.light')}</p>
<p class="font-medium">{getLightText(plant.lightRequirements)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('planta.watering.water')}</p>
<p class="text-sm text-muted-foreground">{$_('plants.watering.water')}</p>
<p class="font-medium">
{plant.wateringFrequencyDays
? $_('planta.list.everyXDays', { values: { days: plant.wateringFrequencyDays } })
: $_('planta.common.none')}
? $_('plants.list.everyXDays', { values: { days: plant.wateringFrequencyDays } })
: $_('plants.common.none')}
</p>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('planta.humidity.medium')}</p>
<p class="text-sm text-muted-foreground">{$_('plants.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">{$_('planta.plant.careNotes')}</p>
<p class="text-sm text-muted-foreground mb-1">{$_('plants.plant.careNotes')}</p>
<p class="text-sm whitespace-pre-line">{plant.careNotes}</p>
</div>
{/if}
@ -183,14 +183,14 @@
<!-- Watering Schedule -->
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold">{$_('planta.nav.watering')}</h2>
<h2 class="font-semibold">{$_('plants.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}
{$_('planta.watering.water')}
{$_('plants.watering.water')}
{/if}
</button>
</div>
@ -198,11 +198,11 @@
{#if wateringSchedule}
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm text-muted-foreground">{$_('planta.watering.lastWatered')}</p>
<p class="text-sm text-muted-foreground">{$_('plants.watering.lastWatered')}</p>
<p class="font-medium">{formatDate(wateringSchedule.lastWateredAt)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">{$_('planta.watering.nextWatering')}</p>
<p class="text-sm text-muted-foreground">{$_('plants.watering.nextWatering')}</p>
<p class="font-medium">{formatDate(wateringSchedule.nextWateringAt)}</p>
</div>
</div>
@ -210,11 +210,11 @@
{#if wateringHistory.length > 0}
<div class="border-t pt-4">
<p class="text-sm text-muted-foreground mb-2">{$_('planta.watering.watered')}</p>
<p class="text-sm text-muted-foreground mb-2">{$_('plants.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>{$_('planta.watering.watered')}</span>
<span>{$_('plants.watering.watered')}</span>
<span class="text-muted-foreground">{formatDate(log.wateredAt)}</span>
</li>
{/each}
@ -225,9 +225,9 @@
<!-- Actions -->
<div class="flex gap-4">
<a href="/planta" class="btn flex-1 bg-muted text-foreground">{$_('planta.nav.plants')}</a>
<a href="/plants" class="btn flex-1 bg-muted text-foreground">{$_('plants.nav.plants')}</a>
<button type="button" class="btn bg-destructive text-white" onclick={handleDelete}>
{$_('planta.plant.delete')}
{$_('plants.plant.delete')}
</button>
</div>
</div>

View file

@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { toast } from '$lib/stores/toast.svelte';
import { plantMutations } from '$lib/modules/planta/mutations';
import { plantMutations } from '$lib/modules/plants/mutations';
let plantName = $state('');
let scientificName = $state('');
@ -12,7 +12,7 @@
async function savePlant() {
if (!plantName.trim()) {
error = $_('planta.errors.saveFailed');
error = $_('plants.errors.saveFailed');
return;
}
@ -25,23 +25,23 @@
scientificName: scientificName.trim() || undefined,
commonName: commonName.trim() || undefined,
});
toast.success($_('planta.success.plantAdded'));
goto(`/planta/${plant.id}`);
toast.success($_('plants.success.plantAdded'));
goto(`/plants/${plant.id}`);
} catch (err) {
console.error('Failed to create plant:', err);
error = $_('planta.errors.saveFailed');
toast.error($_('planta.errors.saveFailed'));
error = $_('plants.errors.saveFailed');
toast.error($_('plants.errors.saveFailed'));
saving = false;
}
}
</script>
<svelte:head>
<title>{$_('planta.plant.add')} - Planta</title>
<title>{$_('plants.plant.add')} - Plants</title>
</svelte:head>
<div class="max-w-2xl mx-auto space-y-6">
<h1 class="text-2xl font-bold">{$_('planta.plant.add')}</h1>
<h1 class="text-2xl font-bold">{$_('plants.plant.add')}</h1>
{#if error}
<div class="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
@ -52,40 +52,40 @@
<div class="card p-6 space-y-4">
<div>
<label for="plant-name" class="block text-sm font-medium mb-2">
{$_('planta.plant.name')} *
{$_('plants.plant.name')} *
</label>
<input
id="plant-name"
type="text"
bind:value={plantName}
class="input w-full"
placeholder={$_('planta.plant.namePlaceholder')}
placeholder={$_('plants.plant.namePlaceholder')}
/>
</div>
<div>
<label for="scientific-name" class="block text-sm font-medium mb-2">
{$_('planta.plant.scientificName')}
{$_('plants.plant.scientificName')}
</label>
<input
id="scientific-name"
type="text"
bind:value={scientificName}
class="input w-full"
placeholder={$_('planta.common.none')}
placeholder={$_('plants.common.none')}
/>
</div>
<div>
<label for="common-name" class="block text-sm font-medium mb-2">
{$_('planta.plant.species')}
{$_('plants.plant.species')}
</label>
<input
id="common-name"
type="text"
bind:value={commonName}
class="input w-full"
placeholder={$_('planta.common.none')}
placeholder={$_('plants.common.none')}
/>
</div>
@ -95,14 +95,14 @@
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
{$_('planta.common.save')}
{$_('plants.common.save')}
{/if}
</button>
</div>
<div class="text-center">
<a href="/planta" class="text-sm text-muted-foreground hover:text-foreground">
{$_('planta.nav.plants')}
<a href="/plants" class="text-sm text-muted-foreground hover:text-foreground">
{$_('plants.nav.plants')}
</a>
</div>
</div>

View file

@ -7,17 +7,17 @@
</script>
<svelte:head>
<title>Tags | Planta</title>
<title>Tags | Plants</title>
</svelte:head>
<div class="tags-page">
<h1>Tags</h1>
<p class="text-sm text-muted-foreground mb-4">
{$_('planta.app.tagline')}
{$_('plants.app.tagline')}
</p>
{#if tagsCtx.value.length === 0}
<p>{$_('planta.list.empty')}</p>
<p>{$_('plants.list.empty')}</p>
{:else}
<div class="grid gap-2">
{#each tagsCtx.value as tag}

View file

@ -1,22 +1,22 @@
# Planta — consolidated into the unified Mana app
# Plants — consolidated into the unified Mana app
This product was migrated into the unified Mana monorepo. The legacy
per-product `apps/planta/apps/backend/` and `apps/planta/apps/web/`
per-product `apps/plants/apps/backend/` and `apps/plants/apps/web/`
directories have been removed. Active code now lives in:
- **Backend compute routes**: [`apps/api/src/modules/planta/routes.ts`](../api/src/modules/planta/routes.ts) (Gemini Vision plant analysis, S3 upload)
- **Frontend module** (local-first): [`apps/mana/apps/web/src/lib/modules/planta/`](../mana/apps/web/src/lib/modules/planta/)
- **Web route**: [`apps/mana/apps/web/src/routes/(app)/planta/`](../mana/apps/web/src/routes/(app)/planta/)
- **Backend compute routes**: [`apps/api/src/modules/plants/routes.ts`](../api/src/modules/plants/routes.ts) (Gemini Vision plant analysis, S3 upload)
- **Frontend module** (local-first): [`apps/mana/apps/web/src/lib/modules/plants/`](../mana/apps/web/src/lib/modules/plants/)
- **Web route**: [`apps/mana/apps/web/src/routes/(app)/plants/`](../mana/apps/web/src/routes/(app)/plants/)
For monorepo-wide patterns (auth, sync, encryption, services), see the
[root `CLAUDE.md`](../../CLAUDE.md) and [`apps/mana/CLAUDE.md`](../mana/CLAUDE.md).
The previous standalone "Planta Project Guide" describing a per-product
The previous standalone "Plants Project Guide" describing a per-product
backend with its own database, schema, and watering scheduler was deleted
in the audit cleanup of 2026-04-09 — it had been inaccurate since the
consolidation. Pre-consolidation reference is in git history.
> **Note:** The orphaned `apps/planta/packages/shared/` package was
> **Note:** The orphaned `apps/plants/packages/shared/` package was
> removed on 2026-04-09 — it had zero consumers across the repo. The
> remaining `apps/planta/package.json` is kept as a placeholder so the
> remaining `apps/plants/package.json` is kept as a placeholder so the
> directory still surfaces in IDEs and as a doc anchor.

View file

@ -1,5 +1,5 @@
{
"name": "planta",
"name": "plants",
"version": "0.1.0",
"private": true,
"description": "Planta - Plant Documentation & Care App",