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. 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 **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 | | **cards** | Card/deck management | NestJS backend, Expo mobile, SvelteKit web |
| **zitare** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing | | **zitare** | Daily inspiration quotes | NestJS backend, Expo mobile, SvelteKit web, Astro landing |
| **mukke** | Music player | NestJS backend, SvelteKit web | | **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 | | **storage** | Cloud storage | NestJS backend, SvelteKit web |
| **questions** | Q&A with web search | SvelteKit web | | **questions** | Q&A with web search | SvelteKit web |
| **skilltree** | Skill tree visualization | NestJS backend, 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 { pictureRoutes } from './modules/picture/routes';
import { storageRoutes } from './modules/storage/routes'; import { storageRoutes } from './modules/storage/routes';
import { todoRoutes } from './modules/todo/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 { nutriphiRoutes } from './modules/nutriphi/routes';
import { guidesRoutes } from './modules/guides/routes'; import { guidesRoutes } from './modules/guides/routes';
import { moodlitRoutes } from './modules/moodlit/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/picture', pictureRoutes);
app.route('/api/v1/storage', storageRoutes); app.route('/api/v1/storage', storageRoutes);
app.route('/api/v1/todo', todoRoutes); 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/nutriphi', nutriphiRoutes);
app.route('/api/v1/guides', guidesRoutes); app.route('/api/v1/guides', guidesRoutes);
app.route('/api/v1/moodlit', moodlitRoutes); 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 { Readability } from '@mozilla/readability';
import { JSDOM } from 'jsdom'; import { JSDOM } from 'jsdom';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import { sql } from 'drizzle-orm'; import { sql } from 'drizzle-orm';
import { getConnection } from '../../lib/db';
// ─── DB Connection (reads from news.curated_articles) ────── // ─── DB Connection (reads from news.curated_articles) ──────
const DATABASE_URL = const db = drizzle(getConnection());
process.env.DATABASE_URL ?? 'postgresql://mana:devpassword@localhost:5432/mana_platform';
const connection = postgres(DATABASE_URL, { max: 10 });
const db = drizzle(connection);
// ─── Extract Service (Readability fallback for ad-hoc URLs) ─ // ─── 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 * CRUD for plants, photos, watering is handled by mana-sync. This
* module owns the server-only operations: photo upload to mana-media * module owns the server-only operations: photo upload to mana-media
@ -61,7 +61,7 @@ routes.post('/photos/upload', async (c) => {
try { try {
const { uploadImageToMedia } = await import('../../lib/media'); const { uploadImageToMedia } = await import('../../lib/media');
const buffer = await file.arrayBuffer(); 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( return c.json(
{ {
@ -73,7 +73,7 @@ routes.post('/photos/upload', async (c) => {
201 201
); );
} catch (err) { } catch (err) {
logger.error('planta.upload_failed', { logger.error('plants.upload_failed', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}); });
return c.json({ error: 'Upload failed' }, 500); return c.json({ error: 'Upload failed' }, 500);
@ -107,11 +107,11 @@ routes.post('/analysis/identify', async (c) => {
}); });
return c.json(envelope(object)); return c.json(envelope(object));
} catch (err) { } catch (err) {
logger.error('planta.analysis_failed', { logger.error('plants.analysis_failed', {
error: err instanceof Error ? err.message : String(err), error: err instanceof Error ? err.message : String(err),
}); });
return c.json({ error: 'Analysis failed' }, 500); 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 { authMiddleware } from '@mana/shared-hono/auth';
import type { AuthVariables } from '@mana/shared-hono'; import type { AuthVariables } from '@mana/shared-hono';
import { drizzle } from 'drizzle-orm/postgres-js'; import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres'; import { getConnection } from '../../lib/db';
import { import {
decks, decks,
slides, slides,
@ -25,11 +25,7 @@ import {
// ─── DB Connection ───────────────────────────────────────── // ─── DB Connection ─────────────────────────────────────────
const DATABASE_URL = const db = drizzle(getConnection(), {
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, {
schema: { schema: {
decks, decks,
slides, slides,

View file

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

View file

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

View file

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

View file

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

View file

@ -605,13 +605,13 @@ registerApp({
}); });
registerApp({ registerApp({
id: 'planta', id: 'plants',
name: 'Planta', name: 'Plants',
color: '#16A34A', color: '#16A34A',
icon: Plant, icon: Plant,
views: { views: {
list: { load: () => import('$lib/modules/planta/ListView.svelte') }, list: { load: () => import('$lib/modules/plants/ListView.svelte') },
detail: { load: () => import('$lib/modules/planta/views/DetailView.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' }, photos: { x: 1110, y: 575, lakeId: 'db-right' },
skilltree: { x: 1310, y: 590, lakeId: 'db-right' }, skilltree: { x: 1310, y: 590, lakeId: 'db-right' },
context: { x: 1320, y: 660, 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 // Around Bergsee (redis) - lightweight/cache
traces: { x: 400, y: 385, lakeId: 'redis' }, traces: { x: 400, y: 385, lakeId: 'redis' },

View file

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

View file

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

View file

@ -30,7 +30,7 @@
minio: 'Objekt-Speicher fur Dateien, Bilder und Medien aller Apps.', minio: 'Objekt-Speicher fur Dateien, Bilder und Medien aller Apps.',
'db-left': 'PostgreSQL-Datenbanken fur Calendar, Todo, Contacts, Storage.', 'db-left': 'PostgreSQL-Datenbanken fur Calendar, Todo, Contacts, Storage.',
'db-center': 'PostgreSQL-Datenbanken fur Zitare, Music, Clock, NutriPhi.', '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> = { const lakeIcons: Record<string, string> = {

View file

@ -151,16 +151,8 @@ export function useLocalStt(options?: { language?: string }): LocalSttHandle {
const capturedRate = sampleRate; const capturedRate = sampleRate;
cleanup(); cleanup();
console.log(
'[local-stt] Captured',
capturedChunks.length,
'chunks, sample rate:',
capturedRate
);
if (capturedChunks.length === 0) { if (capturedChunks.length === 0) {
error = 'Keine Audiodaten aufgenommen.'; error = 'Keine Audiodaten aufgenommen.';
console.warn('[local-stt] No audio chunks captured');
state = 'idle'; state = 'idle';
return; return;
} }
@ -180,25 +172,14 @@ export function useLocalStt(options?: { language?: string }): LocalSttHandle {
// Resample to 16 kHz if needed // Resample to 16 kHz if needed
const audio = capturedRate === 16000 ? merged : resample(merged, capturedRate, 16000); 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({ const result = await transcribe({
audio, audio,
language: options?.language, language: options?.language,
onChunk: (t: string) => { onChunk: (t: string) => {
partial += t; partial += t;
console.log('[local-stt] Chunk:', t);
}, },
}); });
console.log('[local-stt] Result:', result);
text = result.text.trim(); text = result.text.trim();
} catch (e) { } catch (e) {
error = e instanceof Error ? e.message : String(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` | | 3 | Client Wire-up: vault-client, record-helpers, layout integration | ✅ | `354cbcb17` |
| 4 | Pilot: notes table mit 8 End-to-End Tests | ✅ | `bed08a1aa` | | 4 | Pilot: notes table mit 8 End-to-End Tests | ✅ | `bed08a1aa` |
| 5 | Rollout: chat, dreams, memoro, contacts, cycles, finance | ✅ | `af92720a6` | | 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` | | 6.2 + 6.3 | Settings UI (`/settings/security`) + Encryption Intro Banner | ✅ | `6b8e2c717` |
| Roundup | DATA_LAYER_AUDIT roll-up vor Phase 7 | ✅ | `4bdf4238c` | | Roundup | DATA_LAYER_AUDIT roll-up vor Phase 7 | ✅ | `4bdf4238c` |
| 7.1 | timeBlocks-Hub: tasks + calendar.events + timeBlocks (mit Habits-Coupling) | ✅ | `c875b4e96` | | 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 | | presi | `presiDecks` | `title`, `description` | 6.1 |
| | `slides` | `content` (SlideContent JSON) | 6.1 | | | `slides` | `content` (SlideContent JSON) | 6.1 |
| inventar | `invItems` | `description` (name + notes-array bleiben plaintext) | 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** | | **todo** | **`tasks`** | **`title`, `description`, `subtasks`, `metadata`** | **7.1** |
| **calendar** | **`events`** | **`title`, `description`, `location`** | **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** | | **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 // - photoMediaId / photoUrl / photoThumbnailUrl: opaque pointers to
// mana-media; the URL alone is not PII (anyone with the URL // mana-media; the URL alone is not PII (anyone with the URL
// already has the bytes), and CAS-deduped media IDs leak no user // 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. // - confidence (float 0-1): pure metadata about the AI run.
meals: { enabled: true, fields: ['description', 'portionSize', 'foods'] }, meals: { enabled: true, fields: ['description', 'portionSize', 'foods'] },
// ─── Planta ────────────────────────────────────────────── // ─── Plants ──────────────────────────────────────────────
// `name` is NOT in the schema index for plants (only isActive + // `name` is NOT in the schema index for plants (only isActive +
// healthStatus), so encrypting it is safe. LocalPlant uses // healthStatus), so encrypting it is safe. LocalPlant uses
// `careNotes` (no separate `notes`) plus the user-typed metadata. // `careNotes` (no separate `notes`) plus the user-typed metadata.

View file

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

View file

@ -156,7 +156,7 @@ describe('module-registry — pre-refactor snapshot', () => {
context: ['contextSpaces', 'documents', 'documentTags'], context: ['contextSpaces', 'documents', 'documentTags'],
questions: ['qCollections', 'questions', 'answers', 'questionTags'], questions: ['qCollections', 'questions', 'answers', 'questionTags'],
nutriphi: ['meals', 'goals', 'nutriFavorites', 'mealTags'], nutriphi: ['meals', 'goals', 'nutriFavorites', 'mealTags'],
planta: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'], plants: ['plants', 'plantPhotos', 'wateringSchedules', 'wateringLogs', 'plantTags'],
uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'], uload: ['links', 'uloadTags', 'uloadFolders', 'linkTags'],
calc: ['calculations', 'savedFormulas'], calc: ['calculations', 'savedFormulas'],
moodlit: ['moods', 'sequences', 'moodTags'], 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 { contextModuleConfig } from '$lib/modules/context/module.config';
import { questionsModuleConfig } from '$lib/modules/questions/module.config'; import { questionsModuleConfig } from '$lib/modules/questions/module.config';
import { nutriphiModuleConfig } from '$lib/modules/nutriphi/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 { uloadModuleConfig } from '$lib/modules/uload/module.config';
import { calcModuleConfig } from '$lib/modules/calc/module.config'; import { calcModuleConfig } from '$lib/modules/calc/module.config';
import { moodlitModuleConfig } from '$lib/modules/moodlit/module.config'; import { moodlitModuleConfig } from '$lib/modules/moodlit/module.config';
@ -113,7 +113,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
contextModuleConfig, contextModuleConfig,
questionsModuleConfig, questionsModuleConfig,
nutriphiModuleConfig, nutriphiModuleConfig,
plantaModuleConfig, plantsModuleConfig,
uloadModuleConfig, uloadModuleConfig,
calcModuleConfig, calcModuleConfig,
moodlitModuleConfig, 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 { TODO_GUEST_SEED } from '$lib/modules/todo/collections';
import { NOTES_GUEST_SEED } from '$lib/modules/notes/collections'; import { NOTES_GUEST_SEED } from '$lib/modules/notes/collections';
import { TIMES_GUEST_SEED } from '$lib/modules/times/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'; import { DRINK_GUEST_SEED } from '$lib/modules/drink/collections';
/** /**
@ -60,7 +60,7 @@ register(SKILLTREE_GUEST_SEED);
register(TODO_GUEST_SEED); register(TODO_GUEST_SEED);
register(NOTES_GUEST_SEED); register(NOTES_GUEST_SEED);
register(TIMES_GUEST_SEED); register(TIMES_GUEST_SEED);
register(PLANTA_GUEST_SEED); register(PLANTS_GUEST_SEED);
register(DRINK_GUEST_SEED); register(DRINK_GUEST_SEED);
/** /**

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,4 +1,6 @@
export { automationsStore } from './stores/automations.svelte'; export { automationsStore } from './stores/automations.svelte';
export { automationTable } from './collections'; export { automationTable } from './collections';
export { useAllAutomations, useEnabledAutomations, toAutomation } from './queries';
export type { Automation } from './queries';
export type { LocalAutomation, SourceOption, ActionOption } from './types'; export type { LocalAutomation, SourceOption, ActionOption } from './types';
export { SOURCE_OPTIONS, ACTION_OPTIONS, CONDITION_OPS } 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> <div class="mb-2 text-3xl">&#127793;</div>
<p class="text-sm text-muted-foreground">Noch keine Pflanzen angelegt.</p> <p class="text-sm text-muted-foreground">Noch keine Pflanzen angelegt.</p>
<a <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" 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 Pflanze hinzufügen
@ -140,7 +140,7 @@
<div class="space-y-2"> <div class="space-y-2">
{#each plantsToWater as plant (plant.id)} {#each plantsToWater as plant (plant.id)}
<a <a
href="/planta" href="/plants"
class="flex items-center gap-3 rounded-lg p-2 transition-colors hover:bg-surface-hover" 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> <span class="text-lg">{@html getHealthIcon(plant.healthStatus)}</span>
@ -157,7 +157,7 @@
{/each} {/each}
</div> </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 Alle Pflanzen anzeigen
</a> </a>
{/if} {/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. // Database hooks call into funnel-tracking + trigger registry on every write.
// They reach for browser-only globals (localStorage), so stub them the same // 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/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() })); vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
vi.mock('$lib/triggers/inline-suggest', () => ({ 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, * Look up a single meal by id and decrypt it. Used by the detail page,
* which inlines its own useLiveQueryWithDefault wrapper so the querier * 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> { export async function loadMealById(id: string): Promise<MealWithNutrition | null> {
const local = await db.table<LocalMeal>('meals').get(id); 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. Plant overview with watering schedule.
--> -->
<script lang="ts"> <script lang="ts">
@ -69,7 +69,7 @@
}; };
</script> </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()} {#snippet toolbar()}
<div class="flex items-center justify-end"> <div class="flex items-center justify-end">
<button <button
@ -78,8 +78,8 @@
onclick={() => (creating = !creating)} onclick={() => (creating = !creating)}
> >
{creating {creating
? $_('planta.create.cancel', { default: 'Abbrechen' }) ? $_('plants.create.cancel', { default: 'Abbrechen' })
: $_('planta.create.new', { default: '+ Neue Pflanze' })} : $_('plants.create.new', { default: '+ Neue Pflanze' })}
</button> </button>
</div> </div>
@ -88,14 +88,14 @@
<input <input
type="text" type="text"
bind:value={newName} bind:value={newName}
placeholder={$_('planta.create.namePlaceholder', { default: 'Name (z. B. Monstera)' })} placeholder={$_('plants.create.namePlaceholder', { default: 'Name (z. B. Monstera)' })}
required 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" 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 <input
type="text" type="text"
bind:value={newScientific} bind:value={newScientific}
placeholder={$_('planta.create.scientificPlaceholder', { placeholder={$_('plants.create.scientificPlaceholder', {
default: 'Botanischer Name (optional)', 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" 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" 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()} disabled={!newName.trim()}
> >
{$_('planta.create.save', { default: 'Pflanze anlegen' })} {$_('plants.create.save', { default: 'Pflanze anlegen' })}
</button> </button>
</form> </form>
{/if} {/if}
{/snippet} {/snippet}
{#snippet header()} {#snippet header()}
<span>{$_('planta.list.count', { values: { count: plants.length } })}</span> <span>{$_('plants.list.count', { values: { count: plants.length } })}</span>
{#if dueForWatering.length > 0} {#if dueForWatering.length > 0}
<span class="text-blue-400" <span class="text-blue-400"
>{$_('planta.list.dueWatering', { values: { count: dueForWatering.length } })}</span >{$_('plants.list.dueWatering', { values: { count: dueForWatering.length } })}</span
> >
{/if} {/if}
{#if needsAttention.length > 0} {#if needsAttention.length > 0}
<span class="text-amber-400" <span class="text-amber-400"
>{$_('planta.list.needsCare', { values: { count: needsAttention.length } })}</span >{$_('plants.list.needsCare', { values: { count: needsAttention.length } })}</span
> >
{/if} {/if}
{/snippet} {/snippet}
@ -153,7 +153,7 @@
</div> </div>
{#if schedule} {#if schedule}
<p class="mt-1 text-xs text-white/30"> <p class="mt-1 text-xs text-white/30">
{$_('planta.list.everyXDays', { values: { days: schedule.frequencyDays } })} {$_('plants.list.everyXDays', { values: { days: schedule.frequencyDays } })}
</p> </p>
{/if} {/if}
</button> </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 * 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 * 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('file', file);
formData.append('plantId', plantId); 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', method: 'POST',
headers: await authHeader(), headers: await authHeader(),
body: formData, 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. */ /** Run AI identification on a previously uploaded photo URL. */
export async function identifyPlant(photoUrl: string): Promise<IdentifyResult> { 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', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', '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): * Tables are already defined in the unified database (database.ts):
* plants, plantPhotos, wateringSchedules, wateringLogs * plants, plantPhotos, wateringSchedules, wateringLogs
@ -19,7 +19,7 @@ export const wateringLogTable = db.table<LocalWateringLog>('wateringLogs');
const DEMO_PLANT_ID = 'demo-monstera'; const DEMO_PLANT_ID = 'demo-monstera';
export const PLANTA_GUEST_SEED = { export const PLANTS_GUEST_SEED = {
plants: [ plants: [
{ {
id: DEMO_PLANT_ID, id: DEMO_PLANT_ID,

View file

@ -1,5 +1,5 @@
/** /**
* Planta module barrel exports. * Plants module barrel exports.
*/ */
// Collections & seed data // Collections & seed data
@ -8,7 +8,7 @@ export {
plantPhotoTable, plantPhotoTable,
wateringScheduleTable, wateringScheduleTable,
wateringLogTable, wateringLogTable,
PLANTA_GUEST_SEED, PLANTS_GUEST_SEED,
} from './collections'; } from './collections';
// Types // Types
@ -65,4 +65,4 @@ export {
type ParsedPlant, type ParsedPlant,
type ParsedPlantWithIds, type ParsedPlantWithIds,
} from './utils/plant-parser'; } 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'; import type { ModuleConfig } from '$lib/data/module-registry';
export const plantaModuleConfig: ModuleConfig = { export const plantsModuleConfig: ModuleConfig = {
appId: 'planta', appId: 'plants',
tables: [ tables: [
{ name: 'plants' }, { name: 'plants' },
{ name: 'plantPhotos' }, { 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 * write because it (a) appends a log and (b) re-anchors the schedule's
* nextWateringAt, which drives every "needs water" badge in the UI. * 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'; import { beforeEach, describe, expect, it, vi } from 'vitest';
vi.mock('@mana/shared-utils/analytics', () => ({ vi.mock('@mana/shared-utils/analytics', () => ({
PlantaEvents: { PlantsEvents: {
plantCreated: vi.fn(), plantCreated: vi.fn(),
plantDeleted: vi.fn(), plantDeleted: vi.fn(),
plantWatered: vi.fn(), plantWatered: vi.fn(),
@ -37,7 +37,7 @@ const wateringSchedules = () => db.table<LocalWateringSchedule>('wateringSchedul
beforeEach(async () => { beforeEach(async () => {
setCurrentUserId('test-user'); 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 // so any incidental reads/writes to it succeed. Watering tables
// themselves are plaintext, but the test harness still requires the // themselves are plaintext, but the test harness still requires the
// vault to be unlocked because shared hooks call into the provider. // 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 * All writes go to IndexedDB first, sync handles the rest. Mutations throw
* on failure so UI callers can surface errors via toasts. * on failure so UI callers can surface errors via toasts.
@ -7,7 +7,7 @@
import { db } from '$lib/data/database'; import { db } from '$lib/data/database';
import { toPlant, toWateringSchedule } from './queries'; 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 { encryptRecord, decryptRecord } from '$lib/data/crypto';
import { createBlock } from '$lib/data/time-blocks/service'; import { createBlock } from '$lib/data/time-blocks/service';
import { uploadPlantPhoto, identifyPlant, type IdentifyResult } from './api'; import { uploadPlantPhoto, identifyPlant, type IdentifyResult } from './api';
@ -45,7 +45,7 @@ export const plantMutations = {
const plaintextSnapshot = toPlant(newLocal); const plaintextSnapshot = toPlant(newLocal);
await encryptRecord('plants', newLocal); await encryptRecord('plants', newLocal);
await db.table('plants').add(newLocal); await db.table('plants').add(newLocal);
PlantaEvents.plantCreated(); PlantsEvents.plantCreated();
return plaintextSnapshot; return plaintextSnapshot;
}, },
@ -77,7 +77,7 @@ export const plantMutations = {
deletedAt: new Date().toISOString(), deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),
}); });
PlantaEvents.plantDeleted(); PlantsEvents.plantDeleted();
}, },
/** /**
@ -139,7 +139,7 @@ export const wateringMutations = {
endDate: now, endDate: now,
kind: 'logged', kind: 'logged',
type: 'watering', type: 'watering',
sourceModule: 'planta', sourceModule: 'plants',
sourceId: logEntry.id, sourceId: logEntry.id,
title: `${plantName} gegossen`, title: `${plantName} gegossen`,
color: '#06b6d4', color: '#06b6d4',
@ -159,7 +159,7 @@ export const wateringMutations = {
}); });
} }
PlantaEvents.plantWatered(); PlantsEvents.plantWatered();
}, },
async updateSchedule(plantId: string, frequencyDays: number): Promise<void> { 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 * 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 * 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 * Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks * (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'; 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'; 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'; 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: * Extends the base parser with plant-specific patterns:
* - Scientific names (italic Latin names) * - 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'; import type { SyntaxGroup } from '@mana/shared-ui';
export const PLANTA_SYNTAX: SyntaxGroup[] = [ export const PLANTS_SYNTAX: SyntaxGroup[] = [
{ {
title: 'Pflanzen', title: 'Pflanzen',
items: [ 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. All fields are always editable. Changes auto-save on blur.
Includes photo upload + AI plant identification. Includes photo upload + AI plant identification.
--> -->
@ -92,7 +92,7 @@
showTagPicker = false; showTagPicker = false;
} catch (err) { } catch (err) {
console.error('add tag failed:', 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); await plantTagOps.removeTag(plantId, tagId);
} catch (err) { } catch (err) {
console.error('remove tag failed:', err); console.error('remove tag failed:', err);
toast.error($_('planta.errors.saveFailed')); toast.error($_('plants.errors.saveFailed'));
} }
} }
@ -117,7 +117,7 @@
detail.blur(); detail.blur();
try { try {
await plantMutations.update(plantId, { 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, scientificName: editScientificName.trim() || undefined,
careNotes: editCareNotes.trim() || undefined, careNotes: editCareNotes.trim() || undefined,
lightRequirements: editLightRequirements || undefined, lightRequirements: editLightRequirements || undefined,
@ -133,7 +133,7 @@
}); });
} catch (err) { } catch (err) {
console.error('plant save failed:', err); console.error('plant save failed:', err);
toast.error($_('planta.errors.saveFailed')); toast.error($_('plants.errors.saveFailed'));
} }
} }
@ -146,7 +146,7 @@
}); });
} catch (err) { } catch (err) {
console.error('plant select save failed:', err); console.error('plant select save failed:', err);
toast.error($_('planta.errors.saveFailed')); toast.error($_('plants.errors.saveFailed'));
} }
} }
@ -161,10 +161,10 @@
uploading = true; uploading = true;
try { try {
await photoMutations.upload(plantId, file); await photoMutations.upload(plantId, file);
toast.success($_('planta.success.photoUploaded')); toast.success($_('plants.success.photoUploaded'));
} catch (err) { } catch (err) {
console.error('photo upload failed:', err); console.error('photo upload failed:', err);
toast.error($_('planta.errors.uploadFailed')); toast.error($_('plants.errors.uploadFailed'));
} finally { } finally {
uploading = false; uploading = false;
if (fileInput) fileInput.value = ''; if (fileInput) fileInput.value = '';
@ -174,7 +174,7 @@
async function handleIdentify() { async function handleIdentify() {
const primary = photos[0]; const primary = photos[0];
if (!primary) { if (!primary) {
toast.error($_('planta.errors.identifyFailed')); toast.error($_('plants.errors.identifyFailed'));
return; return;
} }
identifying = true; identifying = true;
@ -182,10 +182,10 @@
try { try {
const result = await photoMutations.identify(primary.id); const result = await photoMutations.identify(primary.id);
identifyResult = result; identifyResult = result;
toast.success($_('planta.success.identified')); toast.success($_('plants.success.identified'));
} catch (err) { } catch (err) {
console.error('identify failed:', err); console.error('identify failed:', err);
toast.error($_('planta.errors.identifyFailed')); toast.error($_('plants.errors.identifyFailed'));
} finally { } finally {
identifying = false; identifying = false;
} }
@ -195,11 +195,11 @@
if (!identifyResult) return; if (!identifyResult) return;
try { try {
await plantMutations.applyIdentification(plantId, identifyResult, { overwrite: false }); await plantMutations.applyIdentification(plantId, identifyResult, { overwrite: false });
toast.success($_('planta.success.plantSaved')); toast.success($_('plants.success.plantSaved'));
identifyResult = null; identifyResult = null;
} catch (err) { } catch (err) {
console.error('apply identification failed:', 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); await photoMutations.setPrimary(plantId, photoId);
} catch (err) { } catch (err) {
console.error('set primary failed:', 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); await photoMutations.remove(photoId);
} catch (err) { } catch (err) {
console.error('remove photo failed:', err); console.error('remove photo failed:', err);
toast.error($_('planta.errors.deleteFailed')); toast.error($_('plants.errors.deleteFailed'));
} }
} }
</script> </script>
@ -225,14 +225,14 @@
<DetailViewShell <DetailViewShell
entity={detail.entity} entity={detail.entity}
loading={detail.loading} loading={detail.loading}
notFoundLabel={$_('planta.plant.notFound')} notFoundLabel={$_('plants.plant.notFound')}
confirmDelete={detail.confirmDelete} confirmDelete={detail.confirmDelete}
onAskDelete={detail.askDelete} onAskDelete={detail.askDelete}
onCancelDelete={detail.cancelDelete} onCancelDelete={detail.cancelDelete}
confirmDeleteLabel={$_('planta.plant.confirmDelete')} confirmDeleteLabel={$_('plants.plant.confirmDelete')}
onConfirmDelete={() => onConfirmDelete={() =>
detail.deleteWithUndo({ detail.deleteWithUndo({
label: $_('planta.success.plantDeleted'), label: $_('plants.success.plantDeleted'),
delete: deletePlant, delete: deletePlant,
goBack, goBack,
})} })}
@ -243,75 +243,75 @@
bind:value={editName} bind:value={editName}
onfocus={detail.focus} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder={$_('planta.plant.namePlaceholder')} placeholder={$_('plants.plant.namePlaceholder')}
/> />
<div class="properties"> <div class="properties">
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">{$_('planta.plant.scientificName')}</span> <span class="prop-label">{$_('plants.plant.scientificName')}</span>
<input <input
class="prop-input" class="prop-input"
bind:value={editScientificName} bind:value={editScientificName}
onfocus={detail.focus} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder={$_('planta.common.none')} placeholder={$_('plants.common.none')}
/> />
</div> </div>
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">{$_('planta.plant.species')}</span> <span class="prop-label">{$_('plants.plant.species')}</span>
<input <input
class="prop-input" class="prop-input"
bind:value={editSpecies} bind:value={editSpecies}
onfocus={detail.focus} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder={$_('planta.common.none')} placeholder={$_('plants.common.none')}
/> />
</div> </div>
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">{$_('planta.plant.state')}</span> <span class="prop-label">{$_('plants.plant.state')}</span>
<select <select
class="prop-select health-{editHealthStatus}" class="prop-select health-{editHealthStatus}"
bind:value={editHealthStatus} bind:value={editHealthStatus}
onchange={handleSelectChange} onchange={handleSelectChange}
> >
<option value="healthy">{$_('planta.health.healthy')}</option> <option value="healthy">{$_('plants.health.healthy')}</option>
<option value="needs_attention">{$_('planta.health.needsAttention')}</option> <option value="needs_attention">{$_('plants.health.needsAttention')}</option>
<option value="sick">{$_('planta.health.sick')}</option> <option value="sick">{$_('plants.health.sick')}</option>
</select> </select>
</div> </div>
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">{$_('planta.plant.light')}</span> <span class="prop-label">{$_('plants.plant.light')}</span>
<select <select
class="prop-select" class="prop-select"
bind:value={editLightRequirements} bind:value={editLightRequirements}
onchange={handleSelectChange} onchange={handleSelectChange}
> >
<option value="">{$_('planta.common.none')}</option> <option value="">{$_('plants.common.none')}</option>
<option value="low">{$_('planta.light.low')}</option> <option value="low">{$_('plants.light.low')}</option>
<option value="medium">{$_('planta.light.medium')}</option> <option value="medium">{$_('plants.light.medium')}</option>
<option value="bright">{$_('planta.light.bright')}</option> <option value="bright">{$_('plants.light.bright')}</option>
<option value="direct">{$_('planta.light.direct')}</option> <option value="direct">{$_('plants.light.direct')}</option>
</select> </select>
</div> </div>
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">{$_('planta.plant.wateringDays')}</span> <span class="prop-label">{$_('plants.plant.wateringDays')}</span>
<input <input
type="number" type="number"
class="prop-input" class="prop-input"
bind:value={editWateringFrequencyDays} bind:value={editWateringFrequencyDays}
onfocus={detail.focus} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder={$_('planta.common.none')} placeholder={$_('plants.common.none')}
min="1" min="1"
/> />
</div> </div>
<div class="prop-row"> <div class="prop-row">
<span class="prop-label">{$_('planta.plant.acquired')}</span> <span class="prop-label">{$_('plants.plant.acquired')}</span>
<input <input
type="date" type="date"
class="prop-input" class="prop-input"
@ -323,20 +323,20 @@
</div> </div>
<div class="section"> <div class="section">
<span class="section-label">{$_('planta.plant.careNotes')}</span> <span class="section-label">{$_('plants.plant.careNotes')}</span>
<textarea <textarea
class="description-input" class="description-input"
bind:value={editCareNotes} bind:value={editCareNotes}
onfocus={detail.focus} onfocus={detail.focus}
onblur={saveField} onblur={saveField}
placeholder={$_('planta.plant.notesPlaceholder')} placeholder={$_('plants.plant.notesPlaceholder')}
rows={3} rows={3}
></textarea> ></textarea>
</div> </div>
<div class="section"> <div class="section">
<div class="section-header"> <div class="section-header">
<span class="section-label">{$_('planta.photo.section')}</span> <span class="section-label">{$_('plants.photo.section')}</span>
<div class="photo-actions"> <div class="photo-actions">
<button <button
type="button" type="button"
@ -344,7 +344,7 @@
onclick={() => fileInput?.click()} onclick={() => fileInput?.click()}
disabled={uploading} disabled={uploading}
> >
{uploading ? $_('planta.photo.uploading') : $_('planta.photo.upload')} {uploading ? $_('plants.photo.uploading') : $_('plants.photo.upload')}
</button> </button>
{#if photos.length > 0} {#if photos.length > 0}
<button <button
@ -353,7 +353,7 @@
onclick={handleIdentify} onclick={handleIdentify}
disabled={identifying} disabled={identifying}
> >
{identifying ? $_('planta.identify.analyzing') : $_('planta.identify.button')} {identifying ? $_('plants.identify.analyzing') : $_('plants.identify.button')}
</button> </button>
{/if} {/if}
</div> </div>
@ -367,7 +367,7 @@
/> />
{#if photos.length === 0} {#if photos.length === 0}
<p class="empty">{$_('planta.photo.noPhotos')}</p> <p class="empty">{$_('plants.photo.noPhotos')}</p>
{:else} {:else}
<div class="photo-grid"> <div class="photo-grid">
{#each photos as photo (photo.id)} {#each photos as photo (photo.id)}
@ -379,7 +379,7 @@
type="button" type="button"
class="photo-btn" class="photo-btn"
onclick={() => handleSetPrimary(photo.id)} onclick={() => handleSetPrimary(photo.id)}
title={$_('planta.photo.primary')} title={$_('plants.photo.primary')}
> >
</button> </button>
@ -388,7 +388,7 @@
type="button" type="button"
class="photo-btn danger" class="photo-btn danger"
onclick={() => handleRemovePhoto(photo.id)} onclick={() => handleRemovePhoto(photo.id)}
title={$_('planta.common.delete')} title={$_('plants.common.delete')}
> >
× ×
</button> </button>
@ -401,14 +401,14 @@
{#if identifyResult} {#if identifyResult}
<div class="identify-result"> <div class="identify-result">
<div class="identify-header"> <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}> <button type="button" class="action-btn primary" onclick={applyIdentification}>
{$_('planta.identify.applyResult')} {$_('plants.identify.applyResult')}
</button> </button>
</div> </div>
{#if identifyResult.scientificName} {#if identifyResult.scientificName}
<p> <p>
<strong>{$_('planta.plant.scientificName')}:</strong> <strong>{$_('plants.plant.scientificName')}:</strong>
{identifyResult.scientificName} {identifyResult.scientificName}
</p> </p>
{/if} {/if}
@ -417,7 +417,7 @@
{/if} {/if}
{#if identifyResult.confidence !== undefined} {#if identifyResult.confidence !== undefined}
<p class="muted"> <p class="muted">
{$_('planta.identify.confidence')}: {Math.round(identifyResult.confidence * 100)}% {$_('plants.identify.confidence')}: {Math.round(identifyResult.confidence * 100)}%
</p> </p>
{/if} {/if}
{#if identifyResult.wateringAdvice} {#if identifyResult.wateringAdvice}
@ -441,7 +441,7 @@
type="button" type="button"
class="tag-remove" class="tag-remove"
onclick={() => handleRemoveTag(tag.id)} onclick={() => handleRemoveTag(tag.id)}
aria-label={$_('planta.common.delete')} aria-label={$_('plants.common.delete')}
> >
× ×
</button> </button>
@ -468,7 +468,7 @@
<!-- Watering history --> <!-- Watering history -->
{#if wateringLogs.length > 0} {#if wateringLogs.length > 0}
<div class="section"> <div class="section">
<span class="section-label">{$_('planta.watering.lastWatered')}</span> <span class="section-label">{$_('plants.watering.lastWatered')}</span>
<ul class="watering-history"> <ul class="watering-history">
{#each wateringLogs as log (log.id)} {#each wateringLogs as log (log.id)}
<li> <li>
@ -484,10 +484,10 @@
<div class="meta"> <div class="meta">
<span <span
>{$_('planta.plant.created')}: {new Date(plant.createdAt ?? '').toLocaleDateString()}</span >{$_('plants.plant.created')}: {new Date(plant.createdAt ?? '').toLocaleDateString()}</span
> >
{#if plant.updatedAt} {#if plant.updatedAt}
<span>{$_('planta.plant.edited')}: {new Date(plant.updatedAt).toLocaleDateString()}</span> <span>{$_('plants.plant.edited')}: {new Date(plant.updatedAt).toLocaleDateString()}</span>
{/if} {/if}
</div> </div>
{/snippet} {/snippet}

View file

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

View file

@ -25,7 +25,7 @@ const SPLIT_APP_ID_LIST = [
'context', 'context',
'questions', 'questions',
'nutriphi', 'nutriphi',
'planta', 'plants',
'uload', 'uload',
'calc', 'calc',
'moodlit', '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)`); console.log(`[Triggers] Loaded ${active.length} automation(s)`);
} }
} }

View file

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

View file

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

View file

@ -42,16 +42,9 @@
import { linkLocalStore, linkMutations } from '@mana/shared-links'; import { linkLocalStore, linkMutations } from '@mana/shared-links';
import { manaStore } from '$lib/data/local-store'; import { manaStore } from '$lib/data/local-store';
import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue'; import { startLlmQueue, stopLlmQueue } from '$lib/llm-queue';
import { llmSettingsState, updateLlmSettings, tierLabel, type LlmTier } from '@mana/shared-llm'; import { useAiTierItems } from '$lib/components/layout/use-ai-tier-items.svelte';
import { isLocalLlmSupported, getLocalLlmStatus, loadLocalLlm } from '@mana/local-llm'; import { useSyncStatusItems } from '$lib/components/layout/use-sync-status-items.svelte';
import { import RouteTierGate from '$lib/components/layout/RouteTierGate.svelte';
getLocalSttStatus,
loadLocalStt,
isLocalSttSupported,
MODELS as STT_MODELS,
DEFAULT_MODEL as STT_DEFAULT_MODEL,
type ModelKey as SttModelKey,
} from '@mana/local-stt';
import { useLocalStt } from '$lib/components/voice/use-local-stt.svelte'; import { useLocalStt } from '$lib/components/voice/use-local-stt.svelte';
import { Microphone, Stop } from '@mana/shared-icons'; import { Microphone, Stop } from '@mana/shared-icons';
import { import {
@ -186,274 +179,10 @@
let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale)); let currentLanguageLabel = $derived(getCurrentLanguageLabel(currentLocale));
// ── AI Tier Selector (PillNav dropdown) ───────────────── // ── AI Tier Selector (PillNav dropdown) ─────────────────
const webgpuSupported = isLocalLlmSupported(); const aiTier = useAiTierItems();
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';
});
// ── Sync status dropdown ──────────────────────────────── // ── Sync status dropdown ────────────────────────────────
let syncStatusItems = $derived.by(() => { const syncStatus = useSyncStatusItems();
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'
);
// ── User / Guest awareness ────────────────────────────── // ── User / Guest awareness ──────────────────────────────
let userEmail = $derived( let userEmail = $derived(
@ -817,11 +546,10 @@
$effect(() => { $effect(() => {
const t = localStt.text; const t = localStt.text;
const e = localStt.error; const e = localStt.error;
if (e) { if (import.meta.env.DEV && e) {
console.warn('[layout-stt] Error:', e); console.warn('[layout-stt] Error:', e);
} }
if (t) { if (t) {
console.log('[layout-stt] Transcribed text:', t);
sttInjectedText = t; sttInjectedText = t;
} }
}); });
@ -1082,12 +810,12 @@
primaryColor="#6366f1" primaryColor="#6366f1"
showAppSwitcher={true} showAppSwitcher={true}
showAiTierSelector={true} showAiTierSelector={true}
{aiTierItems} aiTierItems={aiTier.items}
{currentAiTierLabel} currentAiTierLabel={aiTier.label}
{currentAiTierIcon} currentAiTierIcon={aiTier.icon}
showSyncStatus={authStore.isAuthenticated} showSyncStatus={authStore.isAuthenticated}
{syncStatusItems} syncStatusItems={syncStatus.items}
{currentSyncLabel} currentSyncLabel={syncStatus.label}
{appItems} {appItems}
{userEmail} {userEmail}
settingsHref="/settings" 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"> <div class="mx-auto max-w-7xl px-3 py-2 sm:px-6 sm:py-3 lg:px-8">
{#if routeBlocked && routeAppId} {#if routeBlocked && routeAppId}
<!-- Per-route tier gate. The wrapping AuthGate only fires <RouteTierGate
onMount + only for authenticated users, so this is the appName={routeAppId.name}
only place that catches direct URL navigation into a userTierLabel={routeTierLabels.user}
gated module by a guest or under-tier user. --> requiredTierLabel={routeTierLabels.required}
<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>
{:else} {:else}
{@render children()} {@render children()}
{/if} {/if}

View file

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

View file

@ -9,7 +9,7 @@
import { ArrowLeft, Trash } from '@mana/shared-icons'; import { ArrowLeft, Trash } from '@mana/shared-icons';
// Inline the live query so the closure captures page.params.id directly // 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( const mealQuery = useLiveQueryWithDefault(
() => (page.params.id ? loadMealById(page.params.id) : Promise.resolve(null)), () => (page.params.id ? loadMealById(page.params.id) : Promise.resolve(null)),
null as MealWithNutrition | 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], Provides live-query contexts to all child routes (/plants, /plants/[id],
/planta/add, /planta/tags). The contexts are referenced via getContext() /plants/add, /plants/tags). The contexts are referenced via getContext()
in the page files; without this layout the legacy routes would crash at in the page files; without this layout the legacy routes would crash at
runtime with "Cannot read properties of undefined". runtime with "Cannot read properties of undefined".
--> -->
@ -18,7 +18,7 @@
toWateringSchedule, toWateringSchedule,
toWateringLog, toWateringLog,
useAllTags, useAllTags,
} from '$lib/modules/planta/queries'; } from '$lib/modules/plants/queries';
import type { import type {
LocalPlant, LocalPlant,
LocalPlantPhoto, LocalPlantPhoto,
@ -29,7 +29,7 @@
PlantPhoto, PlantPhoto,
WateringSchedule, WateringSchedule,
WateringLog, WateringLog,
} from '$lib/modules/planta/types'; } from '$lib/modules/plants/types';
let { children }: { children: Snippet } = $props(); let { children }: { children: Snippet } = $props();

View file

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

View file

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

View file

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

View file

@ -7,17 +7,17 @@
</script> </script>
<svelte:head> <svelte:head>
<title>Tags | Planta</title> <title>Tags | Plants</title>
</svelte:head> </svelte:head>
<div class="tags-page"> <div class="tags-page">
<h1>Tags</h1> <h1>Tags</h1>
<p class="text-sm text-muted-foreground mb-4"> <p class="text-sm text-muted-foreground mb-4">
{$_('planta.app.tagline')} {$_('plants.app.tagline')}
</p> </p>
{#if tagsCtx.value.length === 0} {#if tagsCtx.value.length === 0}
<p>{$_('planta.list.empty')}</p> <p>{$_('plants.list.empty')}</p>
{:else} {:else}
<div class="grid gap-2"> <div class="grid gap-2">
{#each tagsCtx.value as tag} {#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 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: 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) - **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/planta/`](../mana/apps/web/src/lib/modules/planta/) - **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)/planta/`](../mana/apps/web/src/routes/(app)/planta/) - **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 For monorepo-wide patterns (auth, sync, encryption, services), see the
[root `CLAUDE.md`](../../CLAUDE.md) and [`apps/mana/CLAUDE.md`](../mana/CLAUDE.md). [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 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 in the audit cleanup of 2026-04-09 — it had been inaccurate since the
consolidation. Pre-consolidation reference is in git history. 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 > 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. > directory still surfaces in IDEs and as a doc anchor.

View file

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

View file

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

View file

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

View file

@ -144,7 +144,7 @@ services:
environment: environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin} MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_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:
- /Volumes/ManaData/minio:/data - /Volumes/ManaData/minio:/data
ports: ports:
@ -178,12 +178,12 @@ services:
mc mb --ignore-existing myminio/storage-storage; mc mb --ignore-existing myminio/storage-storage;
mc mb --ignore-existing myminio/inventory-storage; mc mb --ignore-existing myminio/inventory-storage;
mc mb --ignore-existing myminio/music-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/projectdoc-storage;
mc mb --ignore-existing myminio/mail-storage; mc mb --ignore-existing myminio/mail-storage;
mc anonymous set download myminio/mana-storage; mc anonymous set download myminio/mana-storage;
mc anonymous set download myminio/picture-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 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 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; 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_SECRET_KEY: ${STRIPE_SECRET_KEY:-}
STRIPE_WEBHOOK_SECRET: ${STRIPE_CREDITS_WEBHOOK_SECRET:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_CREDITS_WEBHOOK_SECRET:-}
BASE_URL: https://credits.mana.how 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: ports:
- "3002:3002" - "3002:3002"
healthcheck: healthcheck:
@ -412,7 +412,7 @@ services:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_platform DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD:-mana123}@postgres:5432/mana_platform
MANA_AUTH_URL: http://mana-auth:3001 MANA_AUTH_URL: http://mana-auth:3001
MANA_SERVICE_KEY: ${MANA_SERVICE_KEY} 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: ports:
- "3062:3062" - "3062:3062"
healthcheck: healthcheck:

View file

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

View file

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

View file

@ -99,7 +99,7 @@ scrape_configs:
scrape_interval: 30s scrape_interval: 30s
# NOTE: Individual app backends (chat, todo, calendar, contacts, storage, # 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. # local-first architecture. Only uload-server and memoro-server remain.
# Mana LLM Gateway (Ollama + Google Fallback) # Mana LLM Gateway (Ollama + Google Fallback)
@ -227,7 +227,7 @@ scrape_configs:
- https://mana.how/storage - https://mana.how/storage
- https://mana.how/presi - https://mana.how/presi
- https://mana.how/nutriphi - https://mana.how/nutriphi
- https://mana.how/planta - https://mana.how/plants
- https://mana.how/calc - https://mana.how/calc
- https://mana.how/zitare - https://mana.how/zitare
- https://mana.how/cards - https://mana.how/cards

View file

@ -103,7 +103,7 @@ Legende: LOCAL = Self-hosted | CLOUD = Externer Dienst | FREE = Kostenlos
- `picture-storage` - AI-generierte Bilder - `picture-storage` - AI-generierte Bilder
- `chat-storage` - User-Uploads - `chat-storage` - User-Uploads
- `cards-storage` - Card/Deck Assets - `cards-storage` - Card/Deck Assets
- `planta-storage` - Pflanzenfotos - `plants-storage` - Pflanzenfotos
- `nutriphi-storage` - Mahlzeitenfotos - `nutriphi-storage` - Mahlzeitenfotos
- `presi-storage` - Präsentationsfolien - `presi-storage` - Präsentationsfolien
- `calendar-storage` - Kalender-Anhänge - `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:context # Setup context database + schema
pnpm setup:db:storage # Setup storage database + schema pnpm setup:db:storage # Setup storage database + schema
pnpm setup:db:mukke # Setup mukke 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:nutriphi # Setup nutriphi database + schema
pnpm setup:db:questions # Setup questions database + schema pnpm setup:db:questions # Setup questions database + schema
pnpm setup:db:traces # Setup traces 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 | | Photos | https://stats.mana.how/share/dc201d685f784716a0b8587376eca7a1/photos-webapp |
| Storage | https://stats.mana.how/share/392ff51d11f14f0c9d556af1402a3ee6/storage-webapp | | Storage | https://stats.mana.how/share/392ff51d11f14f0c9d556af1402a3ee6/storage-webapp |
| NutriPhi | https://stats.mana.how/share/33dfae72f8e24aaa8008cbbceeaf072d/nutriphi-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 | | Presi | https://stats.mana.how/share/a1eb8d1fa4d543e6b97ac41351fe1c6f/presi-webapp |
| Skilltree | https://stats.mana.how/share/5de13e0895ae4a69aa2a834f985be14d/skilltree-webapp | | Skilltree | https://stats.mana.how/share/5de13e0895ae4a69aa2a834f985be14d/skilltree-webapp |
| Cards | https://stats.mana.how/share/1c1d54c4782943e58dde0a6db7c86ec6/cards-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 | | 3036 | cards-server | Hono/Bun | AI card generation |
| 3037 | mukke-server | Hono/Bun | Audio processing, BPM, ID3 tags | | 3037 | mukke-server | Hono/Bun | Audio processing, BPM, ID3 tags |
| 3038 | nutriphi-server | Hono/Bun | Gemini meal analysis | | 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 | | 3040 | presi-server | Hono/Bun | Share links |
| 3041-3059 | *(reserved)* | | | | 3041-3059 | *(reserved)* | | |
@ -137,7 +137,7 @@ Pure CRUD apps use mana-sync directly.
| 5025 | inventory-web | *(local-first only)* | | 5025 | inventory-web | *(local-first only)* |
| 5026 | context-web | *(local-first only)* | | 5026 | context-web | *(local-first only)* |
| 5027 | questions-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)* | | 5029 | moodlit-web | *(future)* |
| 5030-5049 | *(reserved)* | | | 5030-5049 | *(reserved)* | |

View file

@ -289,7 +289,7 @@ needed them.
removed. removed.
- `~/.cloudflared/config.yml` on the Mac Mini lost its 16 dead ingress - `~/.cloudflared/config.yml` on the Mac Mini lost its 16 dead ingress
routes (`chat-api`, `todo-api`, `calendar-api`, `clock-api`, `clock-bot`, 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`, `storage-api`, `presi-api`, `nutriphi-api`, `photos-api`, `mukke-api`,
`picture-api`). The tunnel was reloaded via `kill -HUP <pid>`. `picture-api`). The tunnel was reloaded via `kill -HUP <pid>`.
- After reload, every former 502 returns 404 from the Cloudflare edge - 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 ### 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) + `games/arcade/package.json` (im Audit-Sweep zusätzlich gefunden)
**Problem:** Diese package.json enthielten `"dev": "turbo run dev"`. **Problem:** Diese package.json enthielten `"dev": "turbo run dev"`.
@ -49,9 +49,9 @@ gefundene Dead-Code:
auf nicht-existierende `@context/web` / `@context/server` Packages — auf nicht-existierende `@context/web` / `@context/server` Packages —
entfernt, nur `dev:mobile` bleibt (das einzige real existierende entfernt, nur `dev:mobile` bleibt (das einzige real existierende
Sub-Package). Sub-Package).
- `apps/planta/package.json` hatte `dev:web`, `dev:server`, `db:push`, - `apps/plants/package.json` hatte `dev:web`, `dev:server`, `db:push`,
`db:studio`, `db:seed` Filter auf nicht-existierende `@planta/web` / `db:studio`, `db:seed` Filter auf nicht-existierende `@plants/web` /
`@planta/server` Packages — entfernt. `apps/planta/` enthält nur ein `@plants/server` Packages — entfernt. `apps/plants/` enthält nur ein
leeres `packages/shared/` (siehe Audit-Item #11/#18). leeres `packages/shared/` (siehe Audit-Item #11/#18).
- Analog ist `apps/storage/` und `apps/questions/` nur ein Stub mit - Analog ist `apps/storage/` und `apps/questions/` nur ein Stub mit
`packages/shared/` bzw. nichts — die CLAUDE.md-Files referenzieren `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 - `packages/shared-hono/src/index.ts`: Re-exportiert `logger` aus
`@mana/shared-logger`, damit Module ohne extra dependency darauf `@mana/shared-logger`, damit Module ohne extra dependency darauf
zugreifen können. 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 nutriphi,traces}/routes.ts` durch
`logger.error('module.event_name', { error: ... })` ersetzt. Event-Namen `logger.error('module.event_name', { error: ... })` ersetzt. Event-Namen
folgen `<module>.<event>` Konvention für Filterbarkeit in Sentry/JSON-Logs. 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 `apps/uload/apps/server`. Beide deployed via
`docker-compose.macmini.yml`. `docker-compose.macmini.yml`.
- Alle anderen 17 (`calendar`, `chat`, `todo`, `contacts`, `picture`, - 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 `music`, `moodlit`, `context`, `guides`, `questions`) wurden zu
`apps/api/src/modules/{name}/routes.ts` migriert. Ihre `apps/server/` `apps/api/src/modules/{name}/routes.ts` migriert. Ihre `apps/server/`
Verzeichnisse existieren nicht mehr. Verzeichnisse existieren nicht mehr.

View file

@ -64,7 +64,7 @@ Jeder einzelne Backend-Service:
| Kategorie | Backends | Source Files | Empfehlung | | Kategorie | Backends | Source Files | Empfehlung |
|-----------|----------|-------------|------------| |-----------|----------|-------------|------------|
| **Komplex (behalten)** | Chat, Todo, Calendar, Contacts, Storage, Mukke | 60-89 pro Backend | Eigenstaendige Services | | **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 | | **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 | | **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) ### 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) ### Services (17)

View file

@ -19,7 +19,7 @@ recommendation.
**Productivity:** todo, calendar, contacts, notes, habits, times, timeblocks, events **Productivity:** todo, calendar, contacts, notes, habits, times, timeblocks, events
**Knowledge & learning:** cards, zitare, guides, questions, skilltree, memoro, context **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 **Media & creative:** chat, picture, presi, music, photos, storage, uload
**Data & tools:** finance, calc, inventory, places, citycorners, who, news, links, tags, playground **Data & tools:** finance, calc, inventory, places, citycorners, who, news, links, tags, playground
@ -104,7 +104,7 @@ recommendation.
- **kids** *(ZK)* — Milestones, illnesses, growth, photos - **kids** *(ZK)* — Milestones, illnesses, growth, photos
- **pets** — Vet appointments, vaccinations, feeding, weight - **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) ## Health & Body (additional)

View file

@ -155,10 +155,10 @@
"dev:citycorners:app": "pnpm dev:citycorners:web", "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\"", "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", "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...", "plants:dev": "turbo run dev --filter=plants...",
"dev:planta:web": "pnpm --filter @planta/web dev", "dev:plants:web": "pnpm --filter @plants/web dev",
"dev:planta:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:planta:web\"", "dev:plants:app": "concurrently -n api,web -c yellow,cyan \"pnpm dev:api\" \"pnpm dev:plants:web\"",
"dev:planta:full": "concurrently -n auth,sync,api -c blue,magenta,yellow \"pnpm dev:auth\" \"pnpm dev:sync\" \"pnpm dev:api\"", "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...", "nutriphi:dev": "turbo run dev --filter=nutriphi...",
"dev:nutriphi:web": "pnpm --filter @nutriphi/web dev", "dev:nutriphi:web": "pnpm --filter @nutriphi/web dev",
"dev:nutriphi:landing": "pnpm --filter @nutriphi/landing 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: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: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: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: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: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\"", "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 // Zitare - AI explanations
AI_QUOTE_EXPLANATION = 'ai_quote_explanation', AI_QUOTE_EXPLANATION = 'ai_quote_explanation',
// Planta - Plant analysis // Plants - Plant analysis
AI_PLANT_ANALYSIS = 'ai_plant_analysis', AI_PLANT_ANALYSIS = 'ai_plant_analysis',
// Traces - City guide generation // Traces - City guide generation
@ -243,12 +243,12 @@ export const OPERATION_METADATA: Record<CreditOperationType, OperationMetadata>
app: 'zitare', app: 'zitare',
}, },
// Planta // Plants
[CreditOperationType.AI_PLANT_ANALYSIS]: { [CreditOperationType.AI_PLANT_ANALYSIS]: {
name: 'Plant Analysis', name: 'Plant Analysis',
description: 'Identify and analyze a plant from a photo', description: 'Identify and analyze a plant from a photo',
category: CreditCategory.AI, category: CreditCategory.AI,
app: 'planta', app: 'plants',
}, },
// Traces // Traces

View file

@ -59,8 +59,8 @@ export function getManaFAQs(locale: string): FAQItem[] {
id: 'faq-mana-use', id: 'faq-mana-use',
question: isDE ? 'Wofür wird Mana verwendet?' : 'What is Mana used for?', question: isDE ? 'Wofür wird Mana verwendet?' : 'What is Mana used for?',
answer: isDE 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 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 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 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', category: 'billing',
order: 92, order: 92,
language: isDE ? 'de' : 'en', language: isDE ? 'de' : 'en',

View file

@ -53,7 +53,7 @@
calc: ['Rechnen ohne Ablenkung', 'Quelloffen & unabhängig', 'Privat by Design'], calc: ['Rechnen ohne Ablenkung', 'Quelloffen & unabhängig', 'Privat by Design'],
guides: ['Anleitungen, die funktionieren', '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'], 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'], photos: ['Deine Fotos, deine Galerie', 'Quelloffen & unabhängig', 'Privat by Design'],
questions: ['Recherche mit System', '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'], context: ['Dein Wissen, strukturiert', 'Quelloffen & unabhängig', 'Privat by Design'],
@ -86,7 +86,7 @@
calc: ['Calculate without distraction', 'Open-source & independent', 'Private by design'], calc: ['Calculate without distraction', 'Open-source & independent', 'Private by design'],
guides: ['Guides that actually work', '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'], 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'], photos: ['Your photos, your gallery', 'Open-source & independent', 'Private by design'],
questions: ['Research with structure', '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'], context: ['Your knowledge, organized', 'Open-source & independent', 'Private by design'],

View file

@ -117,7 +117,7 @@ export const APP_ICONS = {
photos: svgToDataUrl( 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>` `<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>` `<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( skilltree: svgToDataUrl(
@ -153,7 +153,7 @@ export const APP_ICONS = {
body: svgToDataUrl( body: svgToDataUrl(
// Dumbbell + heart-pulse hybrid: training (barbell) + body (pulse line). // Dumbbell + heart-pulse hybrid: training (barbell) + body (pulse line).
// Red→orange gradient to set it apart from the green health-adjacent // 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>` `<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( firsts: svgToDataUrl(

View file

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

View file

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

View file

@ -10,4 +10,4 @@
let { size = 55, color, class: className = '' }: Props = $props(); let { size = 55, color, class: className = '' }: Props = $props();
</script> </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 ClockLogo } from './ClockLogo.svelte';
export { default as QuestionsLogo } from './QuestionsLogo.svelte'; export { default as QuestionsLogo } from './QuestionsLogo.svelte';
export { default as SkillTreeLogo } from './SkillTreeLogo.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 LightWriteLogo } from './LightWriteLogo.svelte';
export { default as MusicLogo } from './MusicLogo.svelte'; export { default as MusicLogo } from './MusicLogo.svelte';
export { default as ContextLogo } from './ContextLogo.svelte'; export { default as ContextLogo } from './ContextLogo.svelte';

View file

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

View file

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

View file

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

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