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

@ -85,7 +85,7 @@ uploadRoutes.post('/avatar', async (c) => {
Only servers that need their own database use Drizzle. Most apps rely on mana-sync for data persistence.
**Servers with Drizzle:** chat, todo, moodlit, context, planta, presi, traces, uload, wisekeep, news
**Servers with Drizzle:** chat, todo, moodlit, context, plants, presi, traces, uload, wisekeep, news
**Servers without Drizzle (mana-sync only):** calendar, contacts, cards, mukke, nutriphi, picture, questions, storage

View file

@ -0,0 +1 @@
{"sessionId":"7b3ce34c-30d3-4f38-8ce5-3d170ce48f50","pid":91068,"acquiredAt":1775918818294}

View file

@ -16,7 +16,7 @@ Monorepo containing all Mana projects — a self-hosted multi-app ecosystem with
| **cards** | Card/deck management | NestJS backend, Expo mobile, SvelteKit web |
| **zitare** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
| **mukke** | Music player | NestJS backend, SvelteKit web |
| **planta** | Plant care tracker | NestJS backend, SvelteKit web |
| **plants** | Plant care tracker | NestJS backend, SvelteKit web |
| **storage** | Cloud storage | NestJS backend, SvelteKit web |
| **questions** | Q&A with web search | SvelteKit web |
| **skilltree** | Skill tree visualization | NestJS backend, SvelteKit web |

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",

View file

@ -54,7 +54,7 @@ ingress:
service: http://localhost:5000
- hostname: skilltree.mana.how
service: http://localhost:5000
- hostname: planta.mana.how
- hostname: plants.mana.how
service: http://localhost:5000
- hostname: cards.mana.how
service: http://localhost:5000

View file

@ -108,12 +108,12 @@ services:
mc mb --ignore-existing myminio/contacts-storage;
mc mb --ignore-existing myminio/storage-storage;
mc mb --ignore-existing myminio/inventory-storage;
mc mb --ignore-existing myminio/planta-storage;
mc mb --ignore-existing myminio/plants-storage;
mc mb --ignore-existing myminio/projectdoc-storage;
mc mb --ignore-existing myminio/music-storage;
mc anonymous set download myminio/mana-storage;
mc anonymous set download myminio/picture-storage;
mc anonymous set download myminio/planta-storage;
mc anonymous set download myminio/plants-storage;
mc anonymous set download myminio/inventory-storage;
mc ilm rule add --expire-days 90 myminio/chat-storage --prefix 'tmp/' 2>/dev/null || true;
mc ilm rule add --expire-days 30 myminio/calendar-storage --prefix 'tmp/' 2>/dev/null || true;

View file

@ -144,7 +144,7 @@ services:
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:-minioadmin}
MINIO_API_CORS_ALLOW_ORIGIN: https://music.mana.how,https://mana.how,https://picture.mana.how,https://storage.mana.how,https://planta.mana.how,https://contacts.mana.how,https://chat.mana.how,https://nutriphi.mana.how,https://photos.mana.how
MINIO_API_CORS_ALLOW_ORIGIN: https://music.mana.how,https://mana.how,https://picture.mana.how,https://storage.mana.how,https://plants.mana.how,https://contacts.mana.how,https://chat.mana.how,https://nutriphi.mana.how,https://photos.mana.how
volumes:
- /Volumes/ManaData/minio:/data
ports:
@ -178,12 +178,12 @@ services:
mc mb --ignore-existing myminio/storage-storage;
mc mb --ignore-existing myminio/inventory-storage;
mc mb --ignore-existing myminio/music-storage;
mc mb --ignore-existing myminio/planta-storage;
mc mb --ignore-existing myminio/plants-storage;
mc mb --ignore-existing myminio/projectdoc-storage;
mc mb --ignore-existing myminio/mail-storage;
mc anonymous set download myminio/mana-storage;
mc anonymous set download myminio/picture-storage;
mc anonymous set download myminio/planta-storage;
mc anonymous set download myminio/plants-storage;
mc anonymous set download myminio/inventory-storage;
mc ilm rule add --expire-days 90 myminio/chat-storage --prefix 'tmp/' 2>/dev/null || true;
mc ilm rule add --expire-days 30 myminio/calendar-storage --prefix 'tmp/' 2>/dev/null || true;
@ -314,7 +314,7 @@ services:
STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_CREDITS_WEBHOOK_SECRET:-}
BASE_URL: https://credits.mana.how
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://picture.mana.how,https://todo.mana.how,https://zitare.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://cards.mana.how,https://presi.mana.how,https://storage.mana.how,https://nutriphi.mana.how,https://planta.mana.how,https://music.mana.how,https://context.mana.how,https://photos.mana.how,https://questions.mana.how,https://calc.mana.how
CORS_ORIGINS: https://mana.how,https://chat.mana.how,https://picture.mana.how,https://todo.mana.how,https://zitare.mana.how,https://calendar.mana.how,https://clock.mana.how,https://contacts.mana.how,https://cards.mana.how,https://presi.mana.how,https://storage.mana.how,https://nutriphi.mana.how,https://plants.mana.how,https://music.mana.how,https://context.mana.how,https://photos.mana.how,https://questions.mana.how,https://calc.mana.how
ports:
- "3002:3002"
healthcheck:
@ -412,7 +412,7 @@ services:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_platform
MANA_AUTH_URL: http://mana-auth:3001
MANA_SERVICE_KEY: ${MANA_SERVICE_KEY}
CORS_ORIGINS: https://mana.how,https://calc.mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://cards.mana.how,https://music.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://planta.mana.how,https://presi.mana.how,https://questions.mana.how,https://storage.mana.how,https://todo.mana.how,https://zitare.mana.how
CORS_ORIGINS: https://mana.how,https://calc.mana.how,https://calendar.mana.how,https://chat.mana.how,https://clock.mana.how,https://contacts.mana.how,https://context.mana.how,https://cards.mana.how,https://music.mana.how,https://nutriphi.mana.how,https://photos.mana.how,https://picture.mana.how,https://plants.mana.how,https://presi.mana.how,https://questions.mana.how,https://storage.mana.how,https://todo.mana.how,https://zitare.mana.how
ports:
- "3062:3062"
healthcheck:

View file

@ -661,7 +661,7 @@
"type": "prometheus",
"uid": "${datasource}"
},
"expr": "up{job=\"planta-backend\"}",
"expr": "up{job=\"plants-backend\"}",
"legendFormat": "Planta",
"refId": "Q"
},

View file

@ -615,7 +615,7 @@
},
{
"datasource": { "type": "prometheus", "uid": "${datasource}" },
"expr": "up{job=\"planta-backend\"}",
"expr": "up{job=\"plants-backend\"}",
"legendFormat": "Planta",
"refId": "Q"
},

View file

@ -99,7 +99,7 @@ scrape_configs:
scrape_interval: 30s
# NOTE: Individual app backends (chat, todo, calendar, contacts, storage,
# nutriphi, music, planta, picture) have been REMOVED — all migrated to
# nutriphi, music, plants, picture) have been REMOVED — all migrated to
# local-first architecture. Only uload-server and memoro-server remain.
# Mana LLM Gateway (Ollama + Google Fallback)
@ -227,7 +227,7 @@ scrape_configs:
- https://mana.how/storage
- https://mana.how/presi
- https://mana.how/nutriphi
- https://mana.how/planta
- https://mana.how/plants
- https://mana.how/calc
- https://mana.how/zitare
- https://mana.how/cards

View file

@ -103,7 +103,7 @@ Legende: LOCAL = Self-hosted | CLOUD = Externer Dienst | FREE = Kostenlos
- `picture-storage` - AI-generierte Bilder
- `chat-storage` - User-Uploads
- `cards-storage` - Card/Deck Assets
- `planta-storage` - Pflanzenfotos
- `plants-storage` - Pflanzenfotos
- `nutriphi-storage` - Mahlzeitenfotos
- `presi-storage` - Präsentationsfolien
- `calendar-storage` - Kalender-Anhänge

View file

@ -160,7 +160,7 @@ pnpm setup:db:uload # Setup uload database + schema
pnpm setup:db:context # Setup context database + schema
pnpm setup:db:storage # Setup storage database + schema
pnpm setup:db:mukke # Setup mukke database + schema
pnpm setup:db:planta # Setup planta database + schema
pnpm setup:db:plants # Setup plants database + schema
pnpm setup:db:nutriphi # Setup nutriphi database + schema
pnpm setup:db:questions # Setup questions database + schema
pnpm setup:db:traces # Setup traces database + schema

View file

@ -36,7 +36,7 @@ All monitoring tools are publicly accessible - no login required (except GlitchT
| Photos | https://stats.mana.how/share/dc201d685f784716a0b8587376eca7a1/photos-webapp |
| Storage | https://stats.mana.how/share/392ff51d11f14f0c9d556af1402a3ee6/storage-webapp |
| NutriPhi | https://stats.mana.how/share/33dfae72f8e24aaa8008cbbceeaf072d/nutriphi-webapp |
| Planta | https://stats.mana.how/share/1e83a8a67fa84d3995455c21dedbe3a2/planta-webapp |
| Planta | https://stats.mana.how/share/1e83a8a67fa84d3995455c21dedbe3a2/plants-webapp |
| Presi | https://stats.mana.how/share/a1eb8d1fa4d543e6b97ac41351fe1c6f/presi-webapp |
| Skilltree | https://stats.mana.how/share/5de13e0895ae4a69aa2a834f985be14d/skilltree-webapp |
| Cards | https://stats.mana.how/share/1c1d54c4782943e58dde0a6db7c86ec6/cards-webapp |

View file

@ -104,7 +104,7 @@ Pure CRUD apps use mana-sync directly.
| 3036 | cards-server | Hono/Bun | AI card generation |
| 3037 | mukke-server | Hono/Bun | Audio processing, BPM, ID3 tags |
| 3038 | nutriphi-server | Hono/Bun | Gemini meal analysis |
| 3039 | planta-server | Hono/Bun | Gemini plant analysis |
| 3039 | plants-server | Hono/Bun | Gemini plant analysis |
| 3040 | presi-server | Hono/Bun | Share links |
| 3041-3059 | *(reserved)* | | |
@ -137,7 +137,7 @@ Pure CRUD apps use mana-sync directly.
| 5025 | inventory-web | *(local-first only)* |
| 5026 | context-web | *(local-first only)* |
| 5027 | questions-web | *(local-first only)* |
| 5028 | planta-web | 3039 planta-server |
| 5028 | plants-web | 3039 plants-server |
| 5029 | moodlit-web | *(future)* |
| 5030-5049 | *(reserved)* | |

View file

@ -289,7 +289,7 @@ needed them.
removed.
- `~/.cloudflared/config.yml` on the Mac Mini lost its 16 dead ingress
routes (`chat-api`, `todo-api`, `calendar-api`, `clock-api`, `clock-bot`,
`contacts-api`, `zitare-api`, `skilltree-api`, `planta-api`, `cards-api`,
`contacts-api`, `zitare-api`, `skilltree-api`, `plants-api`, `cards-api`,
`storage-api`, `presi-api`, `nutriphi-api`, `photos-api`, `mukke-api`,
`picture-api`). The tunnel was reloaded via `kill -HUP <pid>`.
- After reload, every former 502 returns 404 from the Cloudflare edge

View file

@ -33,7 +33,7 @@ nicht zentralisiert.
### 2. ✅ 8× Recursive Turbo Anti-Pattern fixen
**Wo:** `apps/{uload,context,moodlit,planta,storage,news,questions}/package.json`
**Wo:** `apps/{uload,context,moodlit,plants,storage,news,questions}/package.json`
+ `games/arcade/package.json` (im Audit-Sweep zusätzlich gefunden)
**Problem:** Diese package.json enthielten `"dev": "turbo run dev"`.
@ -49,9 +49,9 @@ gefundene Dead-Code:
auf nicht-existierende `@context/web` / `@context/server` Packages —
entfernt, nur `dev:mobile` bleibt (das einzige real existierende
Sub-Package).
- `apps/planta/package.json` hatte `dev:web`, `dev:server`, `db:push`,
`db:studio`, `db:seed` Filter auf nicht-existierende `@planta/web` /
`@planta/server` Packages — entfernt. `apps/planta/` enthält nur ein
- `apps/plants/package.json` hatte `dev:web`, `dev:server`, `db:push`,
`db:studio`, `db:seed` Filter auf nicht-existierende `@plants/web` /
`@plants/server` Packages — entfernt. `apps/plants/` enthält nur ein
leeres `packages/shared/` (siehe Audit-Item #11/#18).
- Analog ist `apps/storage/` und `apps/questions/` nur ein Stub mit
`packages/shared/` bzw. nichts — die CLAUDE.md-Files referenzieren
@ -86,7 +86,7 @@ hatten die Infrastruktur bereits — sie wurde nur nicht benutzt.
- `packages/shared-hono/src/index.ts`: Re-exportiert `logger` aus
`@mana/shared-logger`, damit Module ohne extra dependency darauf
zugreifen können.
- 7 `console.error` Aufrufe in `apps/api/src/modules/{guides,planta,
- 7 `console.error` Aufrufe in `apps/api/src/modules/{guides,plants,
nutriphi,traces}/routes.ts` durch
`logger.error('module.event_name', { error: ... })` ersetzt. Event-Namen
folgen `<module>.<event>` Konvention für Filterbarkeit in Sentry/JSON-Logs.
@ -373,7 +373,7 @@ nicht mehr existiert.
`apps/uload/apps/server`. Beide deployed via
`docker-compose.macmini.yml`.
- Alle anderen 17 (`calendar`, `chat`, `todo`, `contacts`, `picture`,
`cards`, `planta`, `nutriphi`, `news`, `traces`, `presi`, `storage`,
`cards`, `plants`, `nutriphi`, `news`, `traces`, `presi`, `storage`,
`music`, `moodlit`, `context`, `guides`, `questions`) wurden zu
`apps/api/src/modules/{name}/routes.ts` migriert. Ihre `apps/server/`
Verzeichnisse existieren nicht mehr.

View file

@ -64,7 +64,7 @@ Jeder einzelne Backend-Service:
| Kategorie | Backends | Source Files | Empfehlung |
|-----------|----------|-------------|------------|
| **Komplex (behalten)** | Chat, Todo, Calendar, Contacts, Storage, Mukke | 60-89 pro Backend | Eigenstaendige Services |
| **AI-spezialisiert (behalten)** | Picture, Planta, NutriPhi | 30-50 pro Backend | Eigene AI-Pipelines |
| **AI-spezialisiert (behalten)** | Picture, Plants, NutriPhi | 30-50 pro Backend | Eigene AI-Pipelines |
| **Triviales CRUD (konsolidieren)** | Zitare (20!), Clock (31), Presi, CityCorners, Questions, Context | 20-35 pro Backend | -> 1 Content Service |
| **Eliminieren** | Photos (mana-media Proxy), Skilltree (Frontend-only) | minimal | Backend loeschen |
@ -558,7 +558,7 @@ Diese Technologie-Entscheidungen sind gut und sollten beibehalten werden:
### Apps (24)
calendar, chat, citycorners, clock, contacts, context, docs, inventar, mana, cards, matrix, mukke, nutriphi, photos, picture, planta, playground, presi, questions, skilltree, storage, todo, traces, zitare
calendar, chat, citycorners, clock, contacts, context, docs, inventar, mana, cards, matrix, mukke, nutriphi, photos, picture, plants, playground, presi, questions, skilltree, storage, todo, traces, zitare
### Services (17)

View file

@ -19,7 +19,7 @@ recommendation.
**Productivity:** todo, calendar, contacts, notes, habits, times, timeblocks, events
**Knowledge & learning:** cards, zitare, guides, questions, skilltree, memoro, context
**Health & self:** nutriphi, cycles, dreams, moodlit, planta
**Health & self:** nutriphi, cycles, dreams, moodlit, plants
**Media & creative:** chat, picture, presi, music, photos, storage, uload
**Data & tools:** finance, calc, inventory, places, citycorners, who, news, links, tags, playground
@ -104,7 +104,7 @@ recommendation.
- **kids** *(ZK)* — Milestones, illnesses, growth, photos
- **pets** — Vet appointments, vaccinations, feeding, weight
- **plants-care** — Extension of `planta`: watering plan, fertilizing, repotting
- **plants-care** — Extension of `plants`: watering plan, fertilizing, repotting
## Health & Body (additional)

View file

@ -155,10 +155,10 @@
"dev:citycorners:app": "pnpm dev:citycorners:web",
"dev:citycorners:full": "concurrently -n auth,sync,web -c blue,magenta,cyan \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:citycorners:web\"",
"deploy:landing:citycorners": "pnpm --filter @citycorners/landing build && npx wrangler pages deploy apps/citycorners/apps/landing/dist --project-name=citycorners-landing",
"planta:dev": "turbo run dev --filter=planta...",
"dev:planta:web": "pnpm --filter @planta/web dev",
"dev:planta:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:planta:web\"",
"dev:planta:full": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\"",
"plants:dev": "turbo run dev --filter=plants...",
"dev:plants:web": "pnpm --filter @plants/web dev",
"dev:plants:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:plants:web\"",
"dev:plants:full": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\"",
"nutriphi:dev": "turbo run dev --filter=nutriphi...",
"dev:nutriphi:web": "pnpm --filter @nutriphi/web dev",
"dev:nutriphi:landing": "pnpm --filter @nutriphi/landing dev",
@ -237,7 +237,7 @@
"dev:music:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:nutriphi:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:picture:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:planta:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:plants:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:questions:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:storage:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",
"dev:presi:local": "concurrently -n sync,api -c magenta,yellow \"pnpm dev:sync\" \"pnpm dev:api\"",

View file

@ -44,7 +44,7 @@ export enum CreditOperationType {
// Zitare - AI explanations
AI_QUOTE_EXPLANATION = 'ai_quote_explanation',
// Planta - Plant analysis
// Plants - Plant analysis
AI_PLANT_ANALYSIS = 'ai_plant_analysis',
// Traces - City guide generation
@ -243,12 +243,12 @@ export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata>
app: 'zitare',
},
// Planta
// Plants
[CreditOperationType.AI_PLANT_ANALYSIS]: {
name: 'Plant Analysis',
description: 'Identify and analyze a plant from a photo',
category: CreditCategory.AI,
app: 'planta',
app: 'plants',
},
// Traces

View file

@ -59,8 +59,8 @@ export function getManaFAQs(locale: string): FAQItem[] {
id: 'faq-mana-use',
question: isDE ? 'Wofür wird Mana verwendet?' : 'What is Mana used for?',
answer: isDE
? '<p>Mana wird für Premium-Funktionen innerhalb der Mana-Apps verwendet:</p><ul><li><strong>Chat</strong>: Cloud-KI-Modelle (Claude, GPT, DeepSeek) — lokale Modelle sind kostenlos</li><li><strong>Picture</strong>: KI-Bildgenerierungen (nach 3 kostenlosen Generierungen)</li><li><strong>Context</strong>: KI-Textgenerierung und -analyse</li><li><strong>Weitere Apps</strong>: KI-gestützte Features in Planta, Questions, etc.</li></ul><p>Basis-Funktionen wie Aufgaben, Kalender, Kontakte, Dateien und Chats mit lokalen Modellen sind <strong>immer kostenlos</strong>.</p>'
: '<p>Mana is used for premium features within Mana apps:</p><ul><li><strong>Chat</strong>: Cloud AI models (Claude, GPT, DeepSeek) — local models are free</li><li><strong>Picture</strong>: AI image generations (after 3 free generations)</li><li><strong>Context</strong>: AI text generation and analysis</li><li><strong>More apps</strong>: AI-powered features in Planta, Questions, etc.</li></ul><p>Core features like tasks, calendar, contacts, files, and chats with local models are <strong>always free</strong>.</p>',
? '<p>Mana wird für Premium-Funktionen innerhalb der Mana-Apps verwendet:</p><ul><li><strong>Chat</strong>: Cloud-KI-Modelle (Claude, GPT, DeepSeek) — lokale Modelle sind kostenlos</li><li><strong>Picture</strong>: KI-Bildgenerierungen (nach 3 kostenlosen Generierungen)</li><li><strong>Context</strong>: KI-Textgenerierung und -analyse</li><li><strong>Weitere Apps</strong>: KI-gestützte Features in Plants, Questions, etc.</li></ul><p>Basis-Funktionen wie Aufgaben, Kalender, Kontakte, Dateien und Chats mit lokalen Modellen sind <strong>immer kostenlos</strong>.</p>'
: '<p>Mana is used for premium features within Mana apps:</p><ul><li><strong>Chat</strong>: Cloud AI models (Claude, GPT, DeepSeek) — local models are free</li><li><strong>Picture</strong>: AI image generations (after 3 free generations)</li><li><strong>Context</strong>: AI text generation and analysis</li><li><strong>More apps</strong>: AI-powered features in Plants, Questions, etc.</li></ul><p>Core features like tasks, calendar, contacts, files, and chats with local models are <strong>always free</strong>.</p>',
category: 'billing',
order: 92,
language: isDE ? 'de' : 'en',

View file

@ -53,7 +53,7 @@
calc: ['Rechnen ohne Ablenkung', 'Quelloffen & unabhängig', 'Privat by Design'],
guides: ['Anleitungen, die funktionieren', 'Quelloffen & unabhängig', 'Privat by Design'],
citycorners: ['Entdecke deine Stadt', 'Quelloffen & unabhängig', 'Privat by Design'],
planta: ['Pflanzenpflege leicht gemacht', 'Quelloffen & unabhängig', 'Privat by Design'],
plants: ['Pflanzenpflege leicht gemacht', 'Quelloffen & unabhängig', 'Privat by Design'],
photos: ['Deine Fotos, deine Galerie', 'Quelloffen & unabhängig', 'Privat by Design'],
questions: ['Recherche mit System', 'Quelloffen & unabhängig', 'Privat by Design'],
context: ['Dein Wissen, strukturiert', 'Quelloffen & unabhängig', 'Privat by Design'],
@ -86,7 +86,7 @@
calc: ['Calculate without distraction', 'Open-source & independent', 'Private by design'],
guides: ['Guides that actually work', 'Open-source & independent', 'Private by design'],
citycorners: ['Discover your city', 'Open-source & independent', 'Private by design'],
planta: ['Plant care made simple', 'Open-source & independent', 'Private by design'],
plants: ['Plant care made simple', 'Open-source & independent', 'Private by design'],
photos: ['Your photos, your gallery', 'Open-source & independent', 'Private by design'],
questions: ['Research with structure', 'Open-source & independent', 'Private by design'],
context: ['Your knowledge, organized', 'Open-source & independent', 'Private by design'],

View file

@ -117,7 +117,7 @@ export const APP_ICONS = {
photos: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ph" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#8b5cf6"/><stop offset="100%" style="stop-color:#7c3aed"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ph)"/><rect x="20" y="28" width="60" height="44" rx="5" stroke="white" stroke-width="4" fill="none"/><circle cx="40" cy="44" r="6" stroke="white" stroke-width="3" fill="none"/><path d="M20 60l16-14 12 10 14-12 18 16" stroke="white" stroke-width="3" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
),
planta: svgToDataUrl(
plants: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="pl" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#22c55e"/><stop offset="100%" style="stop-color:#16a34a"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#pl)"/><path d="M50 72V42" stroke="white" stroke-width="4" stroke-linecap="round"/><path d="M50 52c-12-2-20-14-18-24 10 0 20 8 18 24z" fill="white" fill-opacity="0.9"/><path d="M50 42c10-4 22-2 24 10-10 2-22-2-24-10z" fill="white" fill-opacity="0.7"/><path d="M38 72h24" stroke="white" stroke-width="4" stroke-linecap="round"/></svg>`
),
skilltree: svgToDataUrl(
@ -153,7 +153,7 @@ export const APP_ICONS = {
body: svgToDataUrl(
// Dumbbell + heart-pulse hybrid: training (barbell) + body (pulse line).
// Red→orange gradient to set it apart from the green health-adjacent
// modules (planta, nutriphi) and the pink cycles icon.
// modules (plants, nutriphi) and the pink cycles icon.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="bd" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#f97316"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#bd)"/><rect x="18" y="42" width="6" height="16" rx="2" fill="white"/><rect x="76" y="42" width="6" height="16" rx="2" fill="white"/><rect x="24" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="72" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="28" y="48" width="44" height="4" rx="2" fill="white"/><path d="M30 70h12l4-8 6 16 4-10 6 6h12" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
),
firsts: svgToDataUrl(

View file

@ -246,9 +246,9 @@ export const APP_BRANDING: Record<AppId, AppBranding> = {
logoStroke: true,
logoStrokeWidth: 1.5,
},
planta: {
id: 'planta',
name: 'Planta',
plants: {
id: 'plants',
name: 'Plants',
tagline: 'Plant Care Assistant',
primaryColor: '#22c55e',
secondaryColor: '#4ade80',

View file

@ -33,7 +33,7 @@ export {
ClockLogo,
QuestionsLogo,
SkillTreeLogo,
PlantaLogo,
PlantsLogo,
LightWriteLogo,
MusicLogo,
ContextLogo,

View file

@ -10,4 +10,4 @@
let { size = 55, color, class: className = '' }: Props = $props();
</script>
<AppLogo app="planta" {size} {color} class={className} />
<AppLogo app="plants" {size} {color} class={className} />

View file

@ -20,7 +20,7 @@ export { default as InventoryLogo } from './InventoryLogo.svelte';
export { default as ClockLogo } from './ClockLogo.svelte';
export { default as QuestionsLogo } from './QuestionsLogo.svelte';
export { default as SkillTreeLogo } from './SkillTreeLogo.svelte';
export { default as PlantaLogo } from './PlantaLogo.svelte';
export { default as PlantsLogo } from './PlantsLogo.svelte';
export { default as LightWriteLogo } from './LightWriteLogo.svelte';
export { default as MusicLogo } from './MusicLogo.svelte';
export { default as ContextLogo } from './ContextLogo.svelte';

View file

@ -531,17 +531,17 @@ export const MANA_APPS: ManaApp[] = [
requiredTier: 'guest',
},
{
id: 'planta',
name: 'Planta',
id: 'plants',
name: 'Plants',
description: {
de: 'Pflanzenpflege',
de: 'Pflanzen',
en: 'Plant Care',
},
longDescription: {
de: 'Verwalte deine Pflanzen mit Gießplänen, Fotos und Pflegeprotokollen.',
en: 'Manage your plants with watering schedules, photos, and care logs.',
},
icon: APP_ICONS.planta,
icon: APP_ICONS.plants,
color: '#22c55e',
comingSoon: false,
status: 'beta',

View file

@ -21,7 +21,7 @@ export type AppId =
| 'inventory'
| 'questions'
| 'skilltree'
| 'planta'
| 'plants'
| 'lightwrite'
| 'context'
| 'music'

View file

@ -28,7 +28,7 @@ Each app gets its own isolated bucket, created automatically by `minio-init`:
| `mail-storage` | Mail | Email attachments |
| `inventory-storage` | Inventory | Product photos |
| `music-storage` | Music | Music tracks, beats, covers |
| `planta-storage` | Planta | Plant photos |
| `plants-storage` | Planta | Plant photos |
| `projectdoc-storage` | ProjectDoc | Document files |
## Usage

Some files were not shown because too many files have changed in this diff Show more