From fbab96c74b03cc247b2abdb9bdc57aa2f62a8a0b Mon Sep 17 00:00:00 2001 From: Till JS Date: Tue, 7 Apr 2026 14:35:33 +0200 Subject: [PATCH] feat(cycles): add menstrual cycle tracking module New unified-app module under apps/mana/apps/web/src/lib/modules/cycles. Adds three Dexie tables (cycles, cycleDayLogs, cycleSymptoms) in db v7, SYNC_APP_MAP entry, app-registry registration, branding (icon + entry + APP_URLS), and a /cycles route. Includes phase derivation (menstruation/follicular/ovulation/luteal), heuristic next-period and fertile-window prediction (rolling mean over last 6 cycles), 10 default symptoms, and 33 unit tests covering the pure utilities. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../apps/web/src/lib/app-registry/apps.ts | 34 + apps/mana/apps/web/src/lib/data/database.ts | 9 + .../src/lib/modules/cycles/ListView.svelte | 612 ++++++++++++++++++ .../web/src/lib/modules/cycles/collections.ts | 129 ++++ .../apps/web/src/lib/modules/cycles/index.ts | 62 ++ .../web/src/lib/modules/cycles/queries.ts | 148 +++++ .../modules/cycles/stores/cycles.svelte.ts | 91 +++ .../modules/cycles/stores/dayLogs.svelte.ts | 107 +++ .../modules/cycles/stores/symptoms.svelte.ts | 50 ++ .../apps/web/src/lib/modules/cycles/types.ts | 146 +++++ .../lib/modules/cycles/utils/phase.test.ts | 109 ++++ .../web/src/lib/modules/cycles/utils/phase.ts | 68 ++ .../modules/cycles/utils/prediction.test.ts | 122 ++++ .../lib/modules/cycles/utils/prediction.ts | 63 ++ .../web/src/routes/(app)/cycles/+page.svelte | 9 + packages/shared-branding/src/app-icons.ts | 3 + packages/shared-branding/src/mana-apps.ts | 18 + 17 files changed, 1780 insertions(+) create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/ListView.svelte create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/collections.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/index.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/queries.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/stores/symptoms.svelte.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/types.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/utils/phase.test.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/utils/phase.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.test.ts create mode 100644 apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.ts create mode 100644 apps/mana/apps/web/src/routes/(app)/cycles/+page.svelte diff --git a/apps/mana/apps/web/src/lib/app-registry/apps.ts b/apps/mana/apps/web/src/lib/app-registry/apps.ts index 76612a2a4..30875f16d 100644 --- a/apps/mana/apps/web/src/lib/app-registry/apps.ts +++ b/apps/mana/apps/web/src/lib/app-registry/apps.ts @@ -301,6 +301,40 @@ registerApp({ }, }); +registerApp({ + id: 'cycles', + name: 'Cycles', + color: '#ec4899', + views: { + list: { load: () => import('$lib/modules/cycles/ListView.svelte') }, + }, + contextMenuActions: [ + { + id: 'log-day', + label: 'Tag loggen', + icon: Plus, + action: () => + window.dispatchEvent( + new CustomEvent('mana:quick-action', { detail: { app: 'cycles', action: 'new' } }) + ), + }, + ], + collection: 'cycleDayLogs', + paramKey: 'logId', + getDisplayData: (item) => ({ + title: (item.logDate as string) || 'Tageseintrag', + subtitle: (item.flow as string) ?? undefined, + }), + createItem: async (data) => { + const { dayLogsStore } = await import('$lib/modules/cycles/stores/dayLogs.svelte'); + const log = await dayLogsStore.logDay({ + logDate: (data.logDate as string) ?? undefined, + notes: (data.title as string) ?? null, + }); + return log.id; + }, +}); + registerApp({ id: 'finance', name: 'Finance', diff --git a/apps/mana/apps/web/src/lib/data/database.ts b/apps/mana/apps/web/src/lib/data/database.ts index 3c3035bb2..10a9c30fa 100644 --- a/apps/mana/apps/web/src/lib/data/database.ts +++ b/apps/mana/apps/web/src/lib/data/database.ts @@ -425,6 +425,14 @@ db.version(6).stores({ eventInvitations: 'id, eventId, guestId, channel, [eventId+guestId]', }); +// ─── Version 7: Cycles (Menstruationszyklus-Tracking) ──────── + +db.version(7).stores({ + cycles: 'id, startDate, endDate, isPredicted, isArchived, updatedAt', + cycleDayLogs: 'id, logDate, cycleId, flow, [cycleId+logDate]', + cycleSymptoms: 'id, name, category, count, updatedAt', +}); + // ─── Sync App Map ────────────────────────────────────────── // Maps each table to its appId for sync routing. // The SyncEngine uses this to group pending changes and push to /sync/{appId}. @@ -468,6 +476,7 @@ export const SYNC_APP_MAP: Record = { habits: ['habits', 'habitLogs'], notes: ['notes', 'noteTags'], dreams: ['dreams', 'dreamSymbols', 'dreamTags'], + cycles: ['cycles', 'cycleDayLogs', 'cycleSymptoms'], events: ['socialEvents', 'eventGuests', 'eventInvitations'], finance: ['transactions', 'financeCategories', 'budgets'], places: ['places', 'locationLogs', 'placeTags'], diff --git a/apps/mana/apps/web/src/lib/modules/cycles/ListView.svelte b/apps/mana/apps/web/src/lib/modules/cycles/ListView.svelte new file mode 100644 index 000000000..1771afbaf --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/ListView.svelte @@ -0,0 +1,612 @@ + + + +
+ +
+
+ +
+ {PHASE_LABELS[phase]} + {#if cycleDay} + Zyklustag {cycleDay} + {/if} +
+ {#if daysUntil !== null} +
+ {#if daysUntil > 0} + {daysUntil} + Tage bis zur Periode + {:else if daysUntil === 0} + Heute + vorhergesagt + {:else} + {Math.abs(daysUntil)} + Tage überfällig + {/if} +
+ {/if} +
+
+ {#if !currentCycle || (currentCycle.periodEndDate && currentCycle.periodEndDate < todayIso && phase !== 'menstruation')} + + {:else if currentCycle && !currentCycle.periodEndDate} + + {/if} +
+
+ + +
+ +
+ {#each FLOWS as flow} + + {/each} +
+
+ + +
+ +
+ {#each MOODS as mood} + + {/each} +
+
+ + + {#if symptoms.length > 0} +
+ +
+ {#each symptoms as sym} + + {/each} +
+
+ {/if} + + +
+ +
+ + +
+
+ + + {#if stats.total > 0} +
+ +
+
+ {stats.avg} + Ø Tage +
+
+ {stats.shortest} + kürzester +
+
+ {stats.longest} + längster +
+
+ {stats.total} + Zyklen +
+
+ {#if nextPeriod} +
+ Nächste Periode: {formatDate(nextPeriod)} + {#if fertile} + · Fruchtbares Fenster: {formatDate(fertile.start)} – + {formatDate(fertile.end)} + {/if} +
+ {/if} +
+ {/if} + + + {#if logs.length > 0} +
+ +
+ {#each logs.slice(0, 10) as log (log.id)} +
+ +
+
+ {formatLogDate(log.logDate)} + {#if log.flow !== 'none'} + {FLOW_LABELS[log.flow]} + {/if} + {#if log.mood} + {MOOD_LABELS[log.mood]} + {/if} +
+ {#if log.notes} +

{log.notes}

+ {/if} +
+
+ {/each} +
+
+ {/if} + + {#if cycles.length === 0 && logs.length === 0} +

+ Tippe oben auf eine Blutungsstärke, um deinen ersten Tag festzuhalten — oder starte direkt + eine Periode. +

+ {/if} +
+ + diff --git a/apps/mana/apps/web/src/lib/modules/cycles/collections.ts b/apps/mana/apps/web/src/lib/modules/cycles/collections.ts new file mode 100644 index 000000000..5f962ddc4 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/collections.ts @@ -0,0 +1,129 @@ +/** + * Cycles module — collection accessors and guest seed data. + */ + +import { db } from '$lib/data/database'; +import type { LocalCycle, LocalCycleDayLog, LocalCycleSymptom } from './types'; + +// ─── Collection Accessors ────────────────────────────────── + +export const cycleTable = db.table('cycles'); +export const cycleDayLogTable = db.table('cycleDayLogs'); +export const cycleSymptomTable = db.table('cycleSymptoms'); + +// ─── Guest Seed ──────────────────────────────────────────── + +const today = new Date().toISOString().slice(0, 10); +const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10); + +export const CYCLES_GUEST_SEED = { + cycles: [ + { + id: 'cycle-prev', + startDate: daysAgo(56), + periodEndDate: daysAgo(52), + endDate: daysAgo(29), + length: 28, + isPredicted: false, + isArchived: false, + notes: null, + }, + { + id: 'cycle-current', + startDate: daysAgo(28), + periodEndDate: daysAgo(24), + endDate: null, + length: null, + isPredicted: false, + isArchived: false, + notes: 'Aktueller Zyklus', + }, + ] satisfies LocalCycle[], + cycleDayLogs: [ + { + id: 'cycle-log-today', + logDate: today, + cycleId: 'cycle-current', + flow: 'none', + mood: 'good', + energy: 4, + temperature: null, + cervicalMucus: null, + symptoms: [], + sexualActivity: null, + notes: null, + }, + ] satisfies LocalCycleDayLog[], + cycleSymptoms: [ + { + id: 'sym-cramps', + name: 'Krämpfe', + category: 'physical', + color: '#ef4444', + count: 0, + }, + { + id: 'sym-headache', + name: 'Kopfschmerzen', + category: 'physical', + color: '#f97316', + count: 0, + }, + { + id: 'sym-breast-tenderness', + name: 'Brustspannen', + category: 'physical', + color: '#ec4899', + count: 0, + }, + { + id: 'sym-bloating', + name: 'Blähbauch', + category: 'physical', + color: '#a855f7', + count: 0, + }, + { + id: 'sym-acne', + name: 'Akne', + category: 'physical', + color: '#84cc16', + count: 0, + }, + { + id: 'sym-fatigue', + name: 'Müdigkeit', + category: 'physical', + color: '#6366f1', + count: 0, + }, + { + id: 'sym-irritability', + name: 'Reizbarkeit', + category: 'emotional', + color: '#dc2626', + count: 0, + }, + { + id: 'sym-cravings', + name: 'Heißhunger', + category: 'emotional', + color: '#f59e0b', + count: 0, + }, + { + id: 'sym-mood-swings', + name: 'Stimmungsschwankungen', + category: 'emotional', + color: '#0ea5e9', + count: 0, + }, + { + id: 'sym-libido', + name: 'Erhöhte Libido', + category: 'other', + color: '#d946ef', + count: 0, + }, + ] satisfies LocalCycleSymptom[], +}; diff --git a/apps/mana/apps/web/src/lib/modules/cycles/index.ts b/apps/mana/apps/web/src/lib/modules/cycles/index.ts new file mode 100644 index 000000000..75d516e66 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/index.ts @@ -0,0 +1,62 @@ +/** + * Cycles module — barrel exports. + */ + +// ─── Stores ────────────────────────────────────────────── +export { cyclesStore } from './stores/cycles.svelte'; +export { dayLogsStore } from './stores/dayLogs.svelte'; +export { symptomsStore } from './stores/symptoms.svelte'; + +// ─── Queries ───────────────────────────────────────────── +export { + useAllCycles, + useCurrentCycle, + useAllDayLogs, + useDayLog, + useAllSymptoms, + toCycle, + toCycleDayLog, + toCycleSymptom, + groupLogsByMonth, + formatLogDate, +} from './queries'; + +// ─── Utils ─────────────────────────────────────────────── +export { derivePhase, findCycleForDate, getCycleDayNumber, daysBetween } from './utils/phase'; +export { + averageCycleLength, + predictNextPeriodStart, + daysUntilNextPeriod, + predictFertileWindow, + computeCycleStats, +} from './utils/prediction'; + +// ─── Collections ───────────────────────────────────────── +export { cycleTable, cycleDayLogTable, cycleSymptomTable, CYCLES_GUEST_SEED } from './collections'; + +// ─── Types & Constants ─────────────────────────────────── +export { + FLOW_COLORS, + FLOW_LABELS, + MOOD_COLORS, + MOOD_LABELS, + PHASE_COLORS, + PHASE_LABELS, + CERVICAL_MUCUS_LABELS, + DEFAULT_CYCLE_LENGTH, + DEFAULT_PERIOD_LENGTH, + DEFAULT_LUTEAL_LENGTH, +} from './types'; +export type { + LocalCycle, + LocalCycleDayLog, + LocalCycleSymptom, + Cycle, + CycleDayLog, + CycleSymptom, + Flow, + Mood, + CervicalMucus, + SymptomCategory, + CyclePhase, +} from './types'; diff --git a/apps/mana/apps/web/src/lib/modules/cycles/queries.ts b/apps/mana/apps/web/src/lib/modules/cycles/queries.ts new file mode 100644 index 000000000..94c8ed4ee --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/queries.ts @@ -0,0 +1,148 @@ +/** + * Reactive Queries & Pure Helpers for Cycles module. + */ + +import { useLiveQueryWithDefault } from '@mana/local-store/svelte'; +import { db } from '$lib/data/database'; +import type { + Cycle, + CycleDayLog, + CycleSymptom, + LocalCycle, + LocalCycleDayLog, + LocalCycleSymptom, +} from './types'; + +// ─── Type Converters ─────────────────────────────────────── + +export function toCycle(local: LocalCycle): Cycle { + return { + id: local.id, + startDate: local.startDate, + periodEndDate: local.periodEndDate, + endDate: local.endDate, + length: local.length, + isPredicted: local.isPredicted, + isArchived: local.isArchived, + notes: local.notes, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toCycleDayLog(local: LocalCycleDayLog): CycleDayLog { + return { + id: local.id, + logDate: local.logDate, + cycleId: local.cycleId, + flow: local.flow, + mood: local.mood, + energy: local.energy, + temperature: local.temperature, + cervicalMucus: local.cervicalMucus, + symptoms: local.symptoms ?? [], + sexualActivity: local.sexualActivity, + notes: local.notes, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +export function toCycleSymptom(local: LocalCycleSymptom): CycleSymptom { + return { + id: local.id, + name: local.name, + category: local.category, + color: local.color, + count: local.count ?? 0, + createdAt: local.createdAt ?? new Date().toISOString(), + updatedAt: local.updatedAt ?? new Date().toISOString(), + }; +} + +// ─── Live Queries ────────────────────────────────────────── + +export function useAllCycles() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('cycles').toArray(); + return locals + .filter((c) => !c.deletedAt && !c.isArchived) + .map(toCycle) + .sort((a, b) => b.startDate.localeCompare(a.startDate)); + }, [] as Cycle[]); +} + +export function useCurrentCycle() { + return useLiveQueryWithDefault( + async () => { + const locals = await db.table('cycles').toArray(); + const real = locals.filter((c) => !c.deletedAt && !c.isArchived && !c.isPredicted); + if (real.length === 0) return null; + const latest = real.sort((a, b) => b.startDate.localeCompare(a.startDate))[0]; + return toCycle(latest); + }, + null as Cycle | null + ); +} + +export function useAllDayLogs() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('cycleDayLogs').toArray(); + return locals + .filter((l) => !l.deletedAt) + .map(toCycleDayLog) + .sort((a, b) => b.logDate.localeCompare(a.logDate)); + }, [] as CycleDayLog[]); +} + +export function useDayLog(date: string) { + return useLiveQueryWithDefault( + async () => { + const locals = await db + .table('cycleDayLogs') + .where('logDate') + .equals(date) + .toArray(); + const active = locals.find((l) => !l.deletedAt); + return active ? toCycleDayLog(active) : null; + }, + null as CycleDayLog | null + ); +} + +export function useAllSymptoms() { + return useLiveQueryWithDefault(async () => { + const locals = await db.table('cycleSymptoms').toArray(); + return locals + .filter((s) => !s.deletedAt) + .map(toCycleSymptom) + .sort((a, b) => b.count - a.count || a.name.localeCompare(b.name)); + }, [] as CycleSymptom[]); +} + +// ─── Pure Helpers ────────────────────────────────────────── + +/** Group day logs by ISO month label. */ +export function groupLogsByMonth( + logs: CycleDayLog[] +): Array<{ label: string; logs: CycleDayLog[] }> { + const groups = new Map(); + for (const l of logs) { + const date = new Date(l.logDate); + const label = date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' }); + const bucket = groups.get(label) ?? []; + bucket.push(l); + groups.set(label, bucket); + } + return Array.from(groups, ([label, logs]) => ({ label, logs })); +} + +export function formatLogDate(iso: string): string { + const date = new Date(iso); + const today = new Date(); + const diffDays = Math.floor((today.getTime() - date.getTime()) / 86_400_000); + if (diffDays === 0) return 'Heute'; + if (diffDays === 1) return 'Gestern'; + if (diffDays < 7) return `vor ${diffDays} Tagen`; + return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' }); +} diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts b/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts new file mode 100644 index 000000000..49ecda707 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/stores/cycles.svelte.ts @@ -0,0 +1,91 @@ +/** + * Cycles Store — Mutation-Only Service for menstrual cycles. + */ + +import { cycleTable } from '../collections'; +import { toCycle } from '../queries'; +import { daysBetween } from '../utils/phase'; +import type { LocalCycle } from '../types'; + +function todayIsoDate(): string { + return new Date().toISOString().slice(0, 10); +} + +function dayBefore(iso: string): string { + const d = new Date(iso); + d.setUTCDate(d.getUTCDate() - 1); + return d.toISOString().slice(0, 10); +} + +export const cyclesStore = { + /** Startet einen neuen Zyklus. Schließt automatisch den vorigen offenen Zyklus. */ + async createCycle(data: { startDate?: string; notes?: string | null }) { + const startDate = data.startDate ?? todayIsoDate(); + + // Vorigen offenen Zyklus schließen. + const all = await cycleTable.toArray(); + const open = all + .filter((c) => !c.deletedAt && !c.isPredicted && c.endDate === null) + .sort((a, b) => b.startDate.localeCompare(a.startDate)); + for (const prev of open) { + if (prev.startDate >= startDate) continue; + const endDate = dayBefore(startDate); + const length = daysBetween(startDate, prev.startDate); + await cycleTable.update(prev.id, { + endDate, + length, + updatedAt: new Date().toISOString(), + }); + } + + const newLocal: LocalCycle = { + id: crypto.randomUUID(), + startDate, + periodEndDate: null, + endDate: null, + length: null, + isPredicted: false, + isArchived: false, + notes: data.notes ?? null, + }; + await cycleTable.add(newLocal); + return toCycle(newLocal); + }, + + async updateCycle( + id: string, + data: Partial< + Pick< + LocalCycle, + 'startDate' | 'periodEndDate' | 'endDate' | 'length' | 'notes' | 'isArchived' + > + > + ) { + await cycleTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + /** Markiert das Ende der Blutung (nicht das Ende des Zyklus). */ + async setPeriodEnd(id: string, periodEndDate: string | null) { + await cycleTable.update(id, { + periodEndDate, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteCycle(id: string) { + await cycleTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + async archiveCycle(id: string) { + await cycleTable.update(id, { + isArchived: true, + updatedAt: new Date().toISOString(), + }); + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts b/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts new file mode 100644 index 000000000..cbe7d40f5 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts @@ -0,0 +1,107 @@ +/** + * Day Logs Store — Mutation-Only Service for daily cycle entries. + */ + +import { cycleDayLogTable, cycleTable } from '../collections'; +import { toCycleDayLog } from '../queries'; +import { symptomsStore } from './symptoms.svelte'; +import type { CervicalMucus, Flow, LocalCycle, LocalCycleDayLog, Mood } from '../types'; + +function todayIsoDate(): string { + return new Date().toISOString().slice(0, 10); +} + +/** Findet den passenden Zyklus für ein Datum (letzter startDate <= date). */ +async function resolveCycleId(date: string): Promise { + const all = await cycleTable.toArray(); + const candidates = all + .filter((c: LocalCycle) => !c.deletedAt && !c.isPredicted && c.startDate <= date) + .sort((a, b) => b.startDate.localeCompare(a.startDate)); + return candidates[0]?.id ?? null; +} + +export interface LogDayInput { + logDate?: string; + flow?: Flow; + mood?: Mood | null; + energy?: number | null; + temperature?: number | null; + cervicalMucus?: CervicalMucus | null; + symptoms?: string[]; + sexualActivity?: boolean | null; + notes?: string | null; +} + +export const dayLogsStore = { + /** Erstellt oder aktualisiert den Tageseintrag (eine Zeile pro Tag). */ + async logDay(data: LogDayInput) { + const logDate = data.logDate ?? todayIsoDate(); + const existing = (await cycleDayLogTable.where('logDate').equals(logDate).toArray()).find( + (l) => !l.deletedAt + ); + + if (existing) { + // Symptom-Counter aktualisieren. + if (data.symptoms) { + const oldSet = new Set(existing.symptoms ?? []); + const newSet = new Set(data.symptoms); + const added = [...newSet].filter((s) => !oldSet.has(s)); + const removed = [...oldSet].filter((s) => !newSet.has(s)); + if (added.length) await symptomsStore.touchSymptoms(added, +1); + if (removed.length) await symptomsStore.touchSymptoms(removed, -1); + } + await cycleDayLogTable.update(existing.id, { + ...data, + logDate, + updatedAt: new Date().toISOString(), + }); + return toCycleDayLog({ ...existing, ...data, logDate }); + } + + const cycleId = await resolveCycleId(logDate); + const newLocal: LocalCycleDayLog = { + id: crypto.randomUUID(), + logDate, + cycleId, + flow: data.flow ?? 'none', + mood: data.mood ?? null, + energy: data.energy ?? null, + temperature: data.temperature ?? null, + cervicalMucus: data.cervicalMucus ?? null, + symptoms: data.symptoms ?? [], + sexualActivity: data.sexualActivity ?? null, + notes: data.notes ?? null, + }; + await cycleDayLogTable.add(newLocal); + if (newLocal.symptoms.length) { + await symptomsStore.touchSymptoms(newLocal.symptoms, +1); + } + return toCycleDayLog(newLocal); + }, + + async deleteLog(id: string) { + const existing = await cycleDayLogTable.get(id); + if (existing?.symptoms?.length) { + await symptomsStore.touchSymptoms(existing.symptoms, -1); + } + await cycleDayLogTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + /** Hängt nicht zugeordnete Logs an den passenden Zyklus an. */ + async autoAssignCycle() { + const logs = await cycleDayLogTable.toArray(); + for (const log of logs) { + if (log.cycleId || log.deletedAt) continue; + const cycleId = await resolveCycleId(log.logDate); + if (cycleId) { + await cycleDayLogTable.update(log.id, { + cycleId, + updatedAt: new Date().toISOString(), + }); + } + } + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/symptoms.svelte.ts b/apps/mana/apps/web/src/lib/modules/cycles/stores/symptoms.svelte.ts new file mode 100644 index 000000000..baa0ee4df --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/stores/symptoms.svelte.ts @@ -0,0 +1,50 @@ +/** + * Symptoms Store — Mutation-Only Service for cycle symptom taxonomy. + */ + +import { cycleSymptomTable } from '../collections'; +import type { LocalCycleSymptom, SymptomCategory } from '../types'; + +export const symptomsStore = { + async createSymptom(data: { name: string; category?: SymptomCategory; color?: string | null }) { + const newLocal: LocalCycleSymptom = { + id: crypto.randomUUID(), + name: data.name.trim(), + category: data.category ?? 'physical', + color: data.color ?? null, + count: 0, + }; + await cycleSymptomTable.add(newLocal); + return newLocal; + }, + + async updateSymptom( + id: string, + data: Partial> + ) { + await cycleSymptomTable.update(id, { + ...data, + updatedAt: new Date().toISOString(), + }); + }, + + async deleteSymptom(id: string) { + await cycleSymptomTable.update(id, { + deletedAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + }); + }, + + /** Inkrementiert/dekrementiert die Verwendungszähler für IDs. */ + async touchSymptoms(ids: string[], delta: number) { + for (const id of ids) { + const existing = await cycleSymptomTable.get(id); + if (!existing) continue; + const next = Math.max(0, (existing.count ?? 0) + delta); + await cycleSymptomTable.update(id, { + count: next, + updatedAt: new Date().toISOString(), + }); + } + }, +}; diff --git a/apps/mana/apps/web/src/lib/modules/cycles/types.ts b/apps/mana/apps/web/src/lib/modules/cycles/types.ts new file mode 100644 index 000000000..4d354e6f8 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/types.ts @@ -0,0 +1,146 @@ +/** + * Cycles module types — Menstruationszyklus-Tracking. + */ + +import type { BaseRecord } from '@mana/local-store'; + +export type Flow = 'none' | 'spotting' | 'light' | 'medium' | 'heavy'; +export type Mood = 'great' | 'good' | 'neutral' | 'low' | 'bad'; +export type CervicalMucus = 'dry' | 'sticky' | 'creamy' | 'watery' | 'eggwhite'; +export type SymptomCategory = 'physical' | 'emotional' | 'other'; +export type CyclePhase = 'menstruation' | 'follicular' | 'ovulation' | 'luteal' | 'unknown'; + +// ─── Local Record Types (Dexie) ─────────────────────────── + +export interface LocalCycle extends BaseRecord { + startDate: string; // ISO YYYY-MM-DD — erster Tag der Periode + periodEndDate: string | null; // letzter Tag der Blutung + endDate: string | null; // Tag vor dem nächsten Zyklusstart (berechnet) + length: number | null; // Zykluslänge in Tagen + isPredicted: boolean; + isArchived: boolean; + notes: string | null; +} + +export interface LocalCycleDayLog extends BaseRecord { + logDate: string; // ISO YYYY-MM-DD + cycleId: string | null; + flow: Flow; + mood: Mood | null; + energy: number | null; // 1..5 + temperature: number | null; // °C, BBT + cervicalMucus: CervicalMucus | null; + symptoms: string[]; // cycleSymptom.id refs + sexualActivity: boolean | null; + notes: string | null; +} + +export interface LocalCycleSymptom extends BaseRecord { + name: string; + category: SymptomCategory; + color: string | null; + count: number; +} + +// ─── Domain Types ───────────────────────────────────────── + +export interface Cycle { + id: string; + startDate: string; + periodEndDate: string | null; + endDate: string | null; + length: number | null; + isPredicted: boolean; + isArchived: boolean; + notes: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CycleDayLog { + id: string; + logDate: string; + cycleId: string | null; + flow: Flow; + mood: Mood | null; + energy: number | null; + temperature: number | null; + cervicalMucus: CervicalMucus | null; + symptoms: string[]; + sexualActivity: boolean | null; + notes: string | null; + createdAt: string; + updatedAt: string; +} + +export interface CycleSymptom { + id: string; + name: string; + category: SymptomCategory; + color: string | null; + count: number; + createdAt: string; + updatedAt: string; +} + +// ─── Constants ──────────────────────────────────────────── + +export const FLOW_COLORS: Record = { + none: 'rgba(0,0,0,0.08)', + spotting: '#fda4af', + light: '#fb7185', + medium: '#e11d48', + heavy: '#9f1239', +}; + +export const FLOW_LABELS: Record = { + none: 'Keine', + spotting: 'Schmierblutung', + light: 'Leicht', + medium: 'Mittel', + heavy: 'Stark', +}; + +export const MOOD_LABELS: Record = { + great: 'Großartig', + good: 'Gut', + neutral: 'Neutral', + low: 'Niedrig', + bad: 'Schlecht', +}; + +export const MOOD_COLORS: Record = { + great: '#22c55e', + good: '#84cc16', + neutral: '#9ca3af', + low: '#f59e0b', + bad: '#ef4444', +}; + +export const PHASE_LABELS: Record = { + menstruation: 'Menstruation', + follicular: 'Follikelphase', + ovulation: 'Eisprung', + luteal: 'Lutealphase', + unknown: 'Unbekannt', +}; + +export const PHASE_COLORS: Record = { + menstruation: '#e11d48', + follicular: '#f59e0b', + ovulation: '#22c55e', + luteal: '#8b5cf6', + unknown: '#9ca3af', +}; + +export const CERVICAL_MUCUS_LABELS: Record = { + dry: 'Trocken', + sticky: 'Klebrig', + creamy: 'Cremig', + watery: 'Wässrig', + eggwhite: 'Eiweiß', +}; + +export const DEFAULT_CYCLE_LENGTH = 28; +export const DEFAULT_PERIOD_LENGTH = 5; +export const DEFAULT_LUTEAL_LENGTH = 14; diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/phase.test.ts b/apps/mana/apps/web/src/lib/modules/cycles/utils/phase.test.ts new file mode 100644 index 000000000..2481fca14 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/utils/phase.test.ts @@ -0,0 +1,109 @@ +import { describe, expect, it } from 'vitest'; +import { daysBetween, derivePhase, findCycleForDate, getCycleDayNumber } from './phase'; +import type { Cycle } from '../types'; + +function makeCycle(overrides: Partial): Cycle { + return { + id: 'c', + startDate: '2026-01-01', + periodEndDate: null, + endDate: null, + length: null, + isPredicted: false, + isArchived: false, + notes: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +describe('daysBetween', () => { + it('returns 0 for same day', () => { + expect(daysBetween('2026-04-07', '2026-04-07')).toBe(0); + }); + it('returns positive when a > b', () => { + expect(daysBetween('2026-04-10', '2026-04-07')).toBe(3); + }); + it('returns negative when a < b', () => { + expect(daysBetween('2026-04-04', '2026-04-07')).toBe(-3); + }); + it('handles month boundaries', () => { + expect(daysBetween('2026-05-01', '2026-04-29')).toBe(2); + }); +}); + +describe('findCycleForDate', () => { + const cycles: Cycle[] = [ + makeCycle({ id: 'c1', startDate: '2026-01-01' }), + makeCycle({ id: 'c2', startDate: '2026-01-29' }), + makeCycle({ id: 'c3', startDate: '2026-02-26' }), + ]; + + it('returns null for date before any cycle', () => { + expect(findCycleForDate('2025-12-31', cycles)).toBeNull(); + }); + it('finds the latest cycle whose startDate <= date', () => { + expect(findCycleForDate('2026-02-15', cycles)?.id).toBe('c2'); + }); + it('matches exact start date', () => { + expect(findCycleForDate('2026-02-26', cycles)?.id).toBe('c3'); + }); + it('returns most recent for late date', () => { + expect(findCycleForDate('2026-12-31', cycles)?.id).toBe('c3'); + }); +}); + +describe('getCycleDayNumber', () => { + const cycle = makeCycle({ startDate: '2026-04-01' }); + it('returns 1 on the start date', () => { + expect(getCycleDayNumber('2026-04-01', cycle)).toBe(1); + }); + it('returns N+1 N days after start', () => { + expect(getCycleDayNumber('2026-04-08', cycle)).toBe(8); + }); + it('returns null before the cycle', () => { + expect(getCycleDayNumber('2026-03-30', cycle)).toBeNull(); + }); +}); + +describe('derivePhase', () => { + const cycles: Cycle[] = [ + makeCycle({ + id: 'c1', + startDate: '2026-04-01', + periodEndDate: '2026-04-05', // 5 days of period + length: 28, + }), + ]; + + it('returns unknown when no cycle covers the date', () => { + expect(derivePhase('2025-12-31', cycles)).toBe('unknown'); + }); + it('returns menstruation on day 1', () => { + expect(derivePhase('2026-04-01', cycles)).toBe('menstruation'); + }); + it('returns menstruation on the last bleeding day', () => { + expect(derivePhase('2026-04-05', cycles)).toBe('menstruation'); + }); + it('returns follicular after period before ovulation', () => { + // day 8, ovulation should be day 14 (28 - 14) + expect(derivePhase('2026-04-08', cycles)).toBe('follicular'); + }); + it('returns ovulation around day 14 (±1)', () => { + // 2026-04-14 = day 14 + expect(derivePhase('2026-04-14', cycles)).toBe('ovulation'); + expect(derivePhase('2026-04-13', cycles)).toBe('ovulation'); + expect(derivePhase('2026-04-15', cycles)).toBe('ovulation'); + }); + it('returns luteal after ovulation', () => { + // day 20 + expect(derivePhase('2026-04-20', cycles)).toBe('luteal'); + }); + it('falls back to default period length when periodEndDate missing', () => { + const c: Cycle[] = [makeCycle({ startDate: '2026-04-01', length: 28 })]; + // default period length = 5, so day 5 is still menstruation + expect(derivePhase('2026-04-05', c)).toBe('menstruation'); + expect(derivePhase('2026-04-06', c)).toBe('follicular'); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/phase.ts b/apps/mana/apps/web/src/lib/modules/cycles/utils/phase.ts new file mode 100644 index 000000000..0cbf98771 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/utils/phase.ts @@ -0,0 +1,68 @@ +/** + * Phase derivation — leitet die Zyklusphase aus dem Datum und der Zyklus-Historie ab. + */ + +import { + DEFAULT_CYCLE_LENGTH, + DEFAULT_LUTEAL_LENGTH, + DEFAULT_PERIOD_LENGTH, + type Cycle, + type CyclePhase, +} from '../types'; + +/** Tage zwischen zwei ISO-Daten (a - b) */ +export function daysBetween(a: string, b: string): number { + const ms = new Date(a).getTime() - new Date(b).getTime(); + return Math.round(ms / 86_400_000); +} + +/** Findet den Zyklus, der das gegebene Datum enthält. Cycles müssen nach startDate sortiert sein. */ +export function findCycleForDate(date: string, cycles: Cycle[]): Cycle | null { + const sorted = [...cycles].sort((a, b) => a.startDate.localeCompare(b.startDate)); + let match: Cycle | null = null; + for (const c of sorted) { + if (c.startDate <= date) match = c; + else break; + } + return match; +} + +/** Tag-Nummer innerhalb des Zyklus (Tag 1 = startDate). null wenn date vor dem Zyklus liegt. */ +export function getCycleDayNumber(date: string, cycle: Cycle): number | null { + const diff = daysBetween(date, cycle.startDate); + if (diff < 0) return null; + return diff + 1; +} + +/** + * Leitet die Phase ab, in der ein Datum liegt. + * + * Heuristik: + * - Periode: Tag 1..periodLength + * - Eisprung: cycleLength - lutealLength (±1 Tag) + * - Vorher = Follikelphase, danach = Lutealphase + */ +export function derivePhase( + date: string, + cycles: Cycle[], + avgCycleLength = DEFAULT_CYCLE_LENGTH +): CyclePhase { + const cycle = findCycleForDate(date, cycles); + if (!cycle) return 'unknown'; + + const dayNum = getCycleDayNumber(date, cycle); + if (dayNum === null) return 'unknown'; + + const periodLength = + cycle.periodEndDate && cycle.periodEndDate >= cycle.startDate + ? daysBetween(cycle.periodEndDate, cycle.startDate) + 1 + : DEFAULT_PERIOD_LENGTH; + + const cycleLength = cycle.length ?? avgCycleLength; + const ovulationDay = cycleLength - DEFAULT_LUTEAL_LENGTH; + + if (dayNum <= periodLength) return 'menstruation'; + if (Math.abs(dayNum - ovulationDay) <= 1) return 'ovulation'; + if (dayNum < ovulationDay) return 'follicular'; + return 'luteal'; +} diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.test.ts b/apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.test.ts new file mode 100644 index 000000000..c36498e74 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.test.ts @@ -0,0 +1,122 @@ +import { describe, expect, it } from 'vitest'; +import { + averageCycleLength, + computeCycleStats, + daysUntilNextPeriod, + predictFertileWindow, + predictNextPeriodStart, +} from './prediction'; +import type { Cycle } from '../types'; + +function cycle(startDate: string, length: number | null = null, isPredicted = false): Cycle { + return { + id: `c-${startDate}`, + startDate, + periodEndDate: null, + endDate: null, + length, + isPredicted, + isArchived: false, + notes: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; +} + +describe('averageCycleLength', () => { + it('returns default 28 when no cycles', () => { + expect(averageCycleLength([])).toBe(28); + }); + it('returns default when no closed cycles (no length)', () => { + expect(averageCycleLength([cycle('2026-01-01')])).toBe(28); + }); + it('averages closed cycle lengths', () => { + expect( + averageCycleLength([ + cycle('2026-01-01', 28), + cycle('2026-02-01', 30), + cycle('2026-03-01', 26), + ]) + ).toBe(28); + }); + it('caps to most recent N cycles', () => { + // 7 cycles, but window=6 — oldest (length 100) should be ignored + const c = [ + cycle('2026-01-01', 100), + cycle('2026-02-01', 28), + cycle('2026-03-01', 28), + cycle('2026-04-01', 28), + cycle('2026-05-01', 28), + cycle('2026-06-01', 28), + cycle('2026-07-01', 28), + ]; + expect(averageCycleLength(c, 6)).toBe(28); + }); + it('ignores predicted cycles', () => { + expect(averageCycleLength([cycle('2026-01-01', 28), cycle('2026-02-01', 100, true)])).toBe(28); + }); +}); + +describe('predictNextPeriodStart', () => { + it('returns null with no cycles', () => { + expect(predictNextPeriodStart([])).toBeNull(); + }); + it('predicts based on latest start + average length', () => { + const c = [cycle('2026-01-01', 28), cycle('2026-01-29', 28)]; + expect(predictNextPeriodStart(c)).toBe('2026-02-26'); + }); + it('uses default length when no closed cycles exist', () => { + const c = [cycle('2026-01-01')]; + // 2026-01-01 + 28 days = 2026-01-29 + expect(predictNextPeriodStart(c)).toBe('2026-01-29'); + }); +}); + +describe('daysUntilNextPeriod', () => { + it('returns null without data', () => { + expect(daysUntilNextPeriod([])).toBeNull(); + }); + it('returns positive count when prediction is in the future', () => { + const todayIso = new Date().toISOString().slice(0, 10); + // Create a cycle that started 14 days ago (default length 28 → next in 14 days) + const start = new Date(Date.now() - 14 * 86_400_000).toISOString().slice(0, 10); + const result = daysUntilNextPeriod([cycle(start)]); + expect(result).toBeGreaterThanOrEqual(13); + expect(result).toBeLessThanOrEqual(15); + expect(todayIso).toBeTruthy(); + }); +}); + +describe('predictFertileWindow', () => { + it('returns null without data', () => { + expect(predictFertileWindow([])).toBeNull(); + }); + it('predicts a 7-day window centred near ovulation', () => { + // Default 28 day cycle, luteal=14, so ovulation = day 14 (= start + 13 days) + const c = [cycle('2026-04-01', 28)]; + const window = predictFertileWindow(c); + expect(window).not.toBeNull(); + // ovulationDay = 28 - 14 = 14, so start = startDate + (14 - 5) = +9 days = 2026-04-10 + // end = startDate + (14 + 1) = +15 = 2026-04-16 + expect(window?.start).toBe('2026-04-10'); + expect(window?.end).toBe('2026-04-16'); + }); +}); + +describe('computeCycleStats', () => { + it('returns zeros for empty input', () => { + expect(computeCycleStats([])).toEqual({ total: 0, avg: 0, shortest: 0, longest: 0 }); + }); + it('ignores cycles with no length', () => { + expect(computeCycleStats([cycle('2026-01-01')])).toEqual({ + total: 0, + avg: 0, + shortest: 0, + longest: 0, + }); + }); + it('computes avg/min/max correctly', () => { + const c = [cycle('2026-01-01', 26), cycle('2026-02-01', 28), cycle('2026-03-01', 30)]; + expect(computeCycleStats(c)).toEqual({ total: 3, avg: 28, shortest: 26, longest: 30 }); + }); +}); diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.ts b/apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.ts new file mode 100644 index 000000000..0318be9f7 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/utils/prediction.ts @@ -0,0 +1,63 @@ +/** + * Prediction — einfache Vorhersagen über gleitenden Mittelwert. + */ + +import { DEFAULT_CYCLE_LENGTH, DEFAULT_LUTEAL_LENGTH, type Cycle } from '../types'; +import { daysBetween } from './phase'; + +/** Durchschnittliche Zykluslänge aus den letzten N geschlossenen Zyklen. */ +export function averageCycleLength(cycles: Cycle[], window = 6): number { + const closed = cycles + .filter((c) => !c.isPredicted && typeof c.length === 'number' && (c.length ?? 0) > 0) + .sort((a, b) => b.startDate.localeCompare(a.startDate)) + .slice(0, window); + if (closed.length === 0) return DEFAULT_CYCLE_LENGTH; + const sum = closed.reduce((acc, c) => acc + (c.length ?? 0), 0); + return Math.round(sum / closed.length); +} + +/** Vorhergesagter Start der nächsten Periode (ISO-Date). */ +export function predictNextPeriodStart(cycles: Cycle[]): string | null { + const real = cycles.filter((c) => !c.isPredicted); + if (real.length === 0) return null; + const latest = real.sort((a, b) => b.startDate.localeCompare(a.startDate))[0]; + const avg = averageCycleLength(real); + const start = new Date(latest.startDate); + start.setUTCDate(start.getUTCDate() + avg); + return start.toISOString().slice(0, 10); +} + +/** Tage bis zur nächsten Periode. Negativ = überfällig. null wenn keine Daten. */ +export function daysUntilNextPeriod(cycles: Cycle[]): number | null { + const next = predictNextPeriodStart(cycles); + if (!next) return null; + return daysBetween(next, new Date().toISOString().slice(0, 10)); +} + +/** Fruchtbares Fenster für den aktuellen Zyklus (5 Tage vor + Eisprung). */ +export function predictFertileWindow(cycles: Cycle[]): { start: string; end: string } | null { + const real = cycles.filter((c) => !c.isPredicted); + if (real.length === 0) return null; + const latest = real.sort((a, b) => b.startDate.localeCompare(a.startDate))[0]; + const avg = averageCycleLength(real); + const ovulationDay = avg - DEFAULT_LUTEAL_LENGTH; + const start = new Date(latest.startDate); + start.setUTCDate(start.getUTCDate() + ovulationDay - 5); + const end = new Date(latest.startDate); + end.setUTCDate(end.getUTCDate() + ovulationDay + 1); + return { + start: start.toISOString().slice(0, 10), + end: end.toISOString().slice(0, 10), + }; +} + +/** Statistik-Snapshot über alle echten Zyklen. */ +export function computeCycleStats(cycles: Cycle[]) { + const real = cycles.filter((c) => !c.isPredicted && typeof c.length === 'number'); + const lengths = real.map((c) => c.length as number); + const total = real.length; + const avg = lengths.length ? Math.round(lengths.reduce((a, b) => a + b, 0) / lengths.length) : 0; + const shortest = lengths.length ? Math.min(...lengths) : 0; + const longest = lengths.length ? Math.max(...lengths) : 0; + return { total, avg, shortest, longest }; +} diff --git a/apps/mana/apps/web/src/routes/(app)/cycles/+page.svelte b/apps/mana/apps/web/src/routes/(app)/cycles/+page.svelte new file mode 100644 index 000000000..cfa133287 --- /dev/null +++ b/apps/mana/apps/web/src/routes/(app)/cycles/+page.svelte @@ -0,0 +1,9 @@ + + + + Cycles - Mana + + + {}} goBack={() => history.back()} params={{}} /> diff --git a/packages/shared-branding/src/app-icons.ts b/packages/shared-branding/src/app-icons.ts index b81802d9c..8dd319b01 100644 --- a/packages/shared-branding/src/app-icons.ts +++ b/packages/shared-branding/src/app-icons.ts @@ -136,6 +136,9 @@ export const APP_ICONS = { dreams: svgToDataUrl( `` ), + cycles: svgToDataUrl( + `` + ), finance: svgToDataUrl( `` ), diff --git a/packages/shared-branding/src/mana-apps.ts b/packages/shared-branding/src/mana-apps.ts index a429029e0..3d23755b9 100644 --- a/packages/shared-branding/src/mana-apps.ts +++ b/packages/shared-branding/src/mana-apps.ts @@ -632,6 +632,23 @@ export const MANA_APPS: ManaApp[] = [ status: 'development', requiredTier: 'founder', }, + { + id: 'cycles', + name: 'Cycles', + description: { + de: 'Menstruationszyklus-Tracking', + en: 'Menstrual Cycle Tracking', + }, + longDescription: { + de: 'Tracke deinen Zyklus mit Blutungstagen, Symptomen, Stimmung und Basaltemperatur. Phasen-Erkennung und Vorhersage für die nächste Periode und das fruchtbare Fenster.', + en: 'Track your cycle with flow days, symptoms, mood, and basal temperature. Phase detection and prediction of the next period and fertile window.', + }, + icon: APP_ICONS.cycles, + color: '#ec4899', + comingSoon: false, + status: 'development', + requiredTier: 'founder', + }, { id: 'events', name: 'Events', @@ -812,6 +829,7 @@ export const APP_URLS: Record = { habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' }, notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' }, dreams: { dev: 'http://localhost:5173/dreams', prod: 'https://mana.how/dreams' }, + cycles: { dev: 'http://localhost:5173/cycles', prod: 'https://mana.how/cycles' }, events: { dev: 'http://localhost:5173/events', prod: 'https://mana.how/events' }, finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' }, places: { dev: 'http://localhost:5173/places', prod: 'https://mana.how/places' },