refactor: rename eventstream -> activity, cycles -> period

eventstream was confusingly branded "Events" in the app registry,
colliding with the real events calendar module. Renamed to activity
(DE: Aktivität) since it's a live activity feed across all modules.

cycles -> period (DE: Periode) makes the menstrual-tracking module
self-describing. Tables cycles/cycleDayLogs/cycleSymptoms renamed to
periods/periodDayLogs/periodSymptoms; field cycleId -> periodId;
TimeBlockType 'cycle' -> 'period'; domain event CycleDayLogged ->
PeriodDayLogged. Generic "cycle" usages (billing, lifecycle, breath,
bicycle, import cycles) left untouched.

Constant disambiguation: prior DEFAULT_PERIOD_LENGTH (bleeding days)
renamed to DEFAULT_BLEEDING_DAYS; prior DEFAULT_CYCLE_LENGTH (28d full
cycle) is now DEFAULT_PERIOD_LENGTH.

Pre-launch, no data migration needed.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-14 19:45:43 +02:00
parent 66cda80620
commit b857063120
69 changed files with 698 additions and 674 deletions

View file

@ -25,7 +25,7 @@ Mana encrypts user-typed content with **AES-GCM-256** before it touches IndexedD
| Dreams | `title`, `content`, `transcript`, `interpretation`, `location` |
| Memoro | `title`, `intro`, `transcript` (the largest plaintext blobs in the app) |
| Contacts | 16 PII fields (firstName, lastName, email, phone, mobile, birthday, address, social) |
| Cycles | `notes`, `mood` (GDPR Art. 9 sensitive personal data) |
| Period | `notes`, `mood` (GDPR Art. 9 sensitive personal data) |
| Finance | `transactions.description`, `transactions.note` |
| Cards | `front`, `back`, deck name + description |
| Todo | `tasks.title`, `description`, `subtasks`, `metadata` |

View file

@ -1,6 +1,6 @@
---
title: 'Encryption Phasen 19: Vault-Ende-zu-Ende + Dreams, Cycles, Events Module'
description: 'Größter Tag der Woche: AES-GCM-256 Encryption für 27 Tabellen in 9 Phasen ausgerollt, inkl. Zero-Knowledge-Modus mit Recovery-Code. Plus drei neue Module: Dreams (Voice→STT), Cycles (Menstrual-Tracking) und Events (öffentliche RSVP).'
title: 'Encryption Phasen 19: Vault-Ende-zu-Ende + Dreams, Period, Events Module'
description: 'Größter Tag der Woche: AES-GCM-256 Encryption für 27 Tabellen in 9 Phasen ausgerollt, inkl. Zero-Knowledge-Modus mit Recovery-Code. Plus drei neue Module: Dreams (Voice→STT), Period (Menstrual-Tracking) und Events (öffentliche RSVP).'
date: 2026-04-07
author: 'Till Schneider'
category: 'feature'
@ -11,7 +11,7 @@ tags:
'zero-knowledge',
'recovery-code',
'dreams',
'cycles',
'period',
'events',
'rsvp',
'mana-stt',
@ -39,7 +39,7 @@ workingHours:
- **At-Rest Encryption** in 9 Phasen ausgerollt: AES-GCM-256 für 27 Tabellen
- **Zero-Knowledge-Modus** mit User-only Recovery-Code (Mana kann nichts lesen)
- **Lock-Screen** mit Recovery-Unlock-Modal
- **Drei neue Module**: Dreams (Traumtagebuch), Cycles (Zyklus-Tracking), Events (öffentliche RSVP)
- **Drei neue Module**: Dreams (Traumtagebuch), Period (Zyklus-Tracking), Events (öffentliche RSVP)
- **Data-Layer-Audit Sprints 14** abgeschlossen — LWW, retry, atomic cascades, perf, quota, telemetry
- **mana-stt Voice-Pipeline** für Dreams + Memoro live
- **Pre-Launch Cleanup** — Schema-Collapse, Ghost-API-Clients raus, RLS auf sync_changes
@ -100,7 +100,7 @@ Erstes Modul mit aktiver Encryption: **Notes**. Klein, kontrolliert, low-risk. F
### Phase 5: Rollout auf 6 Module
chat, dreams, memoro, contacts, cycles, finance — alles user-typed Content der eindeutig privat ist.
chat, dreams, memoro, contacts, period, finance — alles user-typed Content der eindeutig privat ist.
### Phase 6: Polish + UI
@ -186,7 +186,7 @@ Während die Encryption durch die Phasen lief, entstanden parallel drei neue Mod
- **Mic-Permission UX** auf macOS — wenn Browser den Prompt nicht zeigt, gibt's einen erklärenden Screen + Force-Retry
- **Proxy-Toleranz**: octet-stream und invalid form bodies werden vom Voice-Proxy nicht abgewiesen
### Cycles (Menstruelle Zyklus-Tracking)
### Period (Menstruelle Zyklus-Tracking)
- **Period Auto-Detect**: Start/Ende werden aus Symptomen + Bleeding-Levels abgeleitet
- **Symptom Management UI**: konfigurierbare Symptome mit Severity
@ -331,7 +331,7 @@ Vor dem Production-Launch eine größere Aufräumrunde:
- **`PRE_LAUNCH_CLEANUP.md`** — was wurde entfernt vor Launch und warum
- **`FILE_BYTES_ENCRYPTION_PLAN.md`** — nächste Encryption-Stufe für Bytes/Bilder
- **`docs/postmortems/2026-04-07-stt-tunnel-down.md`** — STT-Ausfall Postmortem
- **`docs/cycles/ROADMAP.md`** — Cycles Feature-Backlog
- **`docs/period/ROADMAP.md`** — Period Feature-Backlog
- **`docs/events/PHASE2_ROADMAP.md`** — Events Phase 2 + tech debt
- GPU Tunnel Setup, STT env wiring docs
@ -344,7 +344,7 @@ Vor dem Production-Launch eine größere Aufräumrunde:
| Encryption Phasen 19 | ~22 | 27 Tabellen, ZK-Modus, Recovery-Code, Lock-Screen, Settings, Tests |
| Data-Layer Sprints | ~8 | LWW, retry, cascades, perf, quota, telemetry |
| Dreams Modul | ~9 | Voice via mana-stt, Symbol-Library, Mic-UX |
| Cycles Modul | ~12 | Phase-Detection, Symptome, Calendar-View, Widget, i18n |
| Period Modul | ~12 | Phase-Detection, Symptome, Calendar-View, Widget, i18n |
| Events Modul | ~12 | RSVP-Flow, Bring-List, 35 Tests, Playwright, Phase 2 |
| mana-stt | ~3 | Voice-Pipeline, Postmortem, GPU-Tunnel |
| Pre-Launch Cleanup | ~7 | Schema-Collapse, RLS, idempotent startup |

View file

@ -65,6 +65,7 @@ import {
Question,
ChatCircleDots,
CreditCard,
SquaresFour,
} from '@mana/shared-icons';
// ── Apps with entity capabilities ───────────────────────────
@ -409,12 +410,12 @@ registerApp({
});
registerApp({
id: 'cycles',
name: 'Cycles',
id: 'period',
name: 'Period',
color: '#ec4899',
icon: GenderFemale,
views: {
list: { load: () => import('$lib/modules/cycles/ListView.svelte') },
list: { load: () => import('$lib/modules/period/ListView.svelte') },
},
contextMenuActions: [
{
@ -423,18 +424,18 @@ registerApp({
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'cycles', action: 'new' } })
new CustomEvent('mana:quick-action', { detail: { app: 'period', action: 'new' } })
),
},
],
collection: 'cycleDayLogs',
collection: 'periodDayLogs',
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 { dayLogsStore } = await import('$lib/modules/period/stores/dayLogs.svelte');
const log = await dayLogsStore.logDay({
logDate: (data.logDate as string) ?? undefined,
notes: (data.title as string) ?? null,
@ -957,12 +958,12 @@ registerApp({
});
registerApp({
id: 'eventstream',
id: 'activity',
name: 'Events',
color: '#6366F1',
icon: Pulse,
views: {
list: { load: () => import('$lib/modules/eventstream/ListView.svelte') },
list: { load: () => import('$lib/modules/activity/ListView.svelte') },
},
});
@ -1028,6 +1029,16 @@ registerApp({
},
});
registerApp({
id: 'complexity',
name: 'Complexity',
color: '#0EA5E9',
icon: SquaresFour,
views: {
list: { load: () => import('$lib/modules/complexity/ListView.svelte') },
},
});
registerApp({
id: 'api-keys',
name: 'API Keys',

View file

@ -3,7 +3,7 @@
* can find pages by intent rather than scanning an alphabetical list.
*
* Five categories (Vorschlag C):
* - companion: Companion Brain pages (myday, eventstream, companion, goals)
* - companion: Companion Brain pages (myday, activity, companion, goals)
* - life: Personal / wellness / everyday-life tracking
* - work: Productivity & planning
* - creative: Creative, learning, generation
@ -40,7 +40,7 @@ export const APP_CATEGORIES: CategoryMeta[] = [
export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
// Companion Brain
myday: 'companion',
eventstream: 'companion',
activity: 'companion',
companion: 'companion',
goals: 'companion',
@ -50,7 +50,7 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
sleep: 'life',
mood: 'life',
stretch: 'life',
cycles: 'life',
period: 'life',
dreams: 'life',
drink: 'life',
meditate: 'life',

View file

@ -38,8 +38,8 @@
contacts: 'Kontakt',
events: 'Termin',
timeBlocks: 'Zeitblock',
cycles: 'Zyklus',
cycleDayLogs: 'Tageseintrag',
period: 'Zyklus',
periodDayLogs: 'Tageseintrag',
transactions: 'Transaktion',
cards: 'Karte',
cardDecks: 'Kartendeck',

View file

@ -30,7 +30,7 @@ import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget
import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelte';
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
import CyclesWidget from '$lib/modules/core/widgets/CyclesWidget.svelte';
import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
@ -59,7 +59,7 @@ export const widgetComponents: Record<WidgetType, Component> = {
'plant-watering': PlantWateringWidget,
'day-timeline': DayTimelineWidget,
'activity-feed': ActivityFeedWidget,
cycles: CyclesWidget,
period: PeriodWidget,
'news-unread': NewsUnreadWidget,
'body-stats': BodyStatsWidget,
};

View file

@ -279,7 +279,7 @@ describe('encryption registry', () => {
expect(tables).toContain('notes');
expect(tables).toContain('memos');
expect(tables).toContain('contacts');
expect(tables).toContain('cycleDayLogs');
expect(tables).toContain('periodDayLogs');
});
it('every registry entry has a non-empty fields list', () => {

View file

@ -119,15 +119,15 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// would still leak via the timeBlocks table.
events: { enabled: true, fields: ['title', 'description', 'location'] },
// ─── Cycles ──────────────────────────────────────────────
// ─── Period ──────────────────────────────────────────────
// Health data — GDPR Art. 9 sensitive personal data category.
// `symptoms` stays plaintext: it's a string-array of standardised
// labels (cramps, headache, ...) used as a Set in the symptom
// counter store; encrypting it would break the diff loop in
// dayLogsStore.logDay. `mood` is a single enum but with the same
// privacy sensitivity as `notes` — encrypt it.
cycles: { enabled: true, fields: ['notes'] },
cycleDayLogs: { enabled: true, fields: ['notes', 'mood'] },
periods: { enabled: true, fields: ['notes'] },
periodDayLogs: { enabled: true, fields: ['notes', 'mood'] },
// ─── Food ────────────────────────────────────────────
// LocalMeal user-typed / AI-generated content → encrypted:
@ -353,7 +353,7 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
// plaintext indexes still resolve "which sets did I do" without
// leaking how heavy or how many.
// - bodyChecks.energy/sleep/soreness/mood: 1-5 mood-style ratings with
// the same sensitivity as cycleDayLogs.mood.
// the same sensitivity as periodDayLogs.mood.
// - bodyPhases.startWeight/targetWeight: identical reasoning to
// measurement values.
// Plaintext (intentional):

View file

@ -236,10 +236,10 @@ db.version(1).stores({
dreamSymbols: 'id, name, count, updatedAt',
dreamTags: 'id, dreamId, tagId, [dreamId+tagId]',
// ─── Cycles (appId: 'cycles') ───
cycles: 'id, startDate, endDate, isPredicted, isArchived, updatedAt',
cycleDayLogs: 'id, logDate, cycleId, flow, [cycleId+logDate]',
cycleSymptoms: 'id, name, category, count, updatedAt',
// ─── Period (appId: 'period') ───
periods: 'id, startDate, endDate, isPredicted, isArchived, updatedAt',
periodDayLogs: 'id, logDate, periodId, flow, [periodId+logDate]',
periodSymptoms: 'id, name, category, count, updatedAt',
// ─── Social Events (appId: 'events') ───
// `socialEvents` is named distinctly to avoid colliding with calendar.events.

View file

@ -442,14 +442,14 @@ export interface SocialEventDeletedPayload {
export type SocialEventsEventType = 'SocialEventCreated' | 'SocialEventDeleted';
// ── Cycles ──────────────────────────────────────────
// ── Period ──────────────────────────────────────────
export interface CycleDayLoggedPayload {
export interface PeriodDayLoggedPayload {
logId: string;
date: string;
flow?: string;
}
export type CyclesEventType = 'CycleDayLogged';
export type PeriodEventType = 'PeriodDayLogged';
// ── Firsts ──────────────────────────────────────────
@ -653,7 +653,7 @@ export type ManaEventType =
| ChatEventType
| MemoroEventType
| SkilltreeEventType
| CyclesEventType
| PeriodEventType
| FirstsEventType
| GuidesEventType
| InventoryEventType
@ -746,8 +746,8 @@ export type ManaEvent =
// Skilltree
| DomainEvent<'SkillXpAdded', SkillXpAddedPayload>
| DomainEvent<'SkillCreated', SkillCreatedPayload>
// Cycles
| DomainEvent<'CycleDayLogged', CycleDayLoggedPayload>
// Period
| DomainEvent<'PeriodDayLogged', PeriodDayLoggedPayload>
// Firsts
| DomainEvent<'FirstCreated', FirstCreatedPayload>
// Guides

View file

@ -165,7 +165,7 @@ describe('module-registry — pre-refactor snapshot', () => {
habits: ['habits', 'habitLogs'],
notes: ['notes', 'noteTags'],
dreams: ['dreams', 'dreamSymbols', 'dreamTags'],
cycles: ['cycles', 'cycleDayLogs', 'cycleSymptoms'],
period: ['periods', 'periodDayLogs', 'periodSymptoms'],
events: ['socialEvents', 'eventGuests', 'eventInvitations', 'eventItems'],
finance: ['transactions', 'financeCategories', 'budgets'],
places: ['places', 'locationLogs', 'placeTags'],

View file

@ -79,7 +79,7 @@ import { habitsModuleConfig } from '$lib/modules/habits/module.config';
import { notesModuleConfig } from '$lib/modules/notes/module.config';
import { journalModuleConfig } from '$lib/modules/journal/module.config';
import { dreamsModuleConfig } from '$lib/modules/dreams/module.config';
import { cyclesModuleConfig } from '$lib/modules/cycles/module.config';
import { periodModuleConfig } from '$lib/modules/period/module.config';
import { eventsModuleConfig } from '$lib/modules/events/module.config';
import { financeModuleConfig } from '$lib/modules/finance/module.config';
import { placesModuleConfig } from '$lib/modules/places/module.config';
@ -129,7 +129,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
notesModuleConfig,
journalModuleConfig,
dreamsModuleConfig,
cyclesModuleConfig,
periodModuleConfig,
eventsModuleConfig,
financeModuleConfig,
placesModuleConfig,

View file

@ -29,7 +29,7 @@ const TYPE_COLORS: Record<TimeBlockType, string> = {
watering: '#06b6d4',
sleep: '#6366f1',
practice: '#f97316',
cycle: '#ec4899',
period: '#ec4899',
guide: '#14b8a6',
visit: '#a855f7',
study: '#0ea5e9',
@ -49,7 +49,7 @@ const TYPE_LABELS: Record<TimeBlockType, string> = {
watering: 'Gießen',
sleep: 'Schlaf',
practice: 'Übung',
cycle: 'Zyklus',
period: 'Periode',
guide: 'Guides',
visit: 'Besuche',
study: 'Lernen',

View file

@ -23,7 +23,7 @@ export type TimeBlockType =
| 'watering'
| 'sleep'
| 'practice'
| 'cycle'
| 'period'
| 'guide'
| 'visit'
| 'study'
@ -41,7 +41,7 @@ export type TimeBlockSourceModule =
| 'plants'
| 'dreams'
| 'skilltree'
| 'cycles'
| 'period'
| 'guides'
| 'places'
| 'cards'

View file

@ -24,7 +24,7 @@ import { storageTools } from '$lib/modules/storage/tools';
import { chatTools } from '$lib/modules/chat/tools';
import { memoroTools } from '$lib/modules/memoro/tools';
import { skilltreeTools } from '$lib/modules/skilltree/tools';
import { cyclesTools } from '$lib/modules/cycles/tools';
import { periodTools } from '$lib/modules/period/tools';
import { firstsTools } from '$lib/modules/firsts/tools';
import { guidesTools } from '$lib/modules/guides/tools';
import { inventoryTools } from '$lib/modules/inventory/tools';
@ -59,7 +59,7 @@ export function initTools(): void {
registerTools(chatTools);
registerTools(memoroTools);
registerTools(skilltreeTools);
registerTools(cyclesTools);
registerTools(periodTools);
registerTools(firstsTools);
registerTools(guidesTools);
registerTools(inventoryTools);

View file

@ -48,7 +48,7 @@ function registerLocale(lang: SupportedLocale) {
questions,
guides,
help,
cycles,
period,
news,
body,
] = await Promise.all([
@ -84,7 +84,7 @@ function registerLocale(lang: SupportedLocale) {
import(`./locales/questions/${lang}.json`),
import(`./locales/guides/${lang}.json`),
import(`./locales/help/${lang}.json`),
import(`./locales/cycles/${lang}.json`),
import(`./locales/period/${lang}.json`),
import(`./locales/news/${lang}.json`),
import(`./locales/body/${lang}.json`),
]);
@ -122,7 +122,7 @@ function registerLocale(lang: SupportedLocale) {
questions: questions.default,
guides: guides.default,
help: help.default,
cycles: cycles.default,
period: period.default,
news: news.default,
body: body.default,
};

View file

@ -26,7 +26,7 @@
"citycorners": "Stadtführer",
"uload": "uLoad",
"calc": "Rechner",
"cycles": "Zyklus",
"period": "Periode",
"body": "Körper",
"dreams": "Träume",
"journal": "Tagebuch",

View file

@ -26,7 +26,7 @@
"citycorners": "City Guide",
"uload": "uLoad",
"calc": "Calculator",
"cycles": "Cycles",
"period": "Period",
"body": "Body",
"dreams": "Dreams",
"journal": "Journal",

View file

@ -26,7 +26,7 @@
"citycorners": "Guía urbana",
"uload": "uLoad",
"calc": "Calculadora",
"cycles": "Ciclo",
"period": "Ciclo",
"body": "Cuerpo",
"dreams": "Sueños",
"journal": "Diario",

View file

@ -26,7 +26,7 @@
"citycorners": "Guide urbain",
"uload": "uLoad",
"calc": "Calculatrice",
"cycles": "Cycle",
"period": "Règles",
"body": "Corps",
"dreams": "Rêves",
"journal": "Journal",

View file

@ -26,7 +26,7 @@
"citycorners": "Guida città",
"uload": "uLoad",
"calc": "Calcolatrice",
"cycles": "Ciclo",
"period": "Ciclo",
"body": "Corpo",
"dreams": "Sogni",
"journal": "Diario",

View file

@ -145,7 +145,7 @@
"description": "Letzte Änderungen über alle Module",
"empty": "Noch keine Aktivität"
},
"cycles": {
"period": {
"title": "Zyklus",
"description": "Aktuelle Phase und Countdown zur nächsten Periode",
"empty": "Noch kein Zyklus erfasst.",

View file

@ -145,7 +145,7 @@
"description": "Recent changes across all modules",
"empty": "No activity yet"
},
"cycles": {
"period": {
"title": "Cycle",
"description": "Current phase and countdown to next period",
"empty": "No cycle logged yet.",

View file

@ -140,7 +140,7 @@
"description": "Línea temporal cronológica de todas las actividades",
"empty": "Nada todavía hoy"
},
"cycles": {
"period": {
"title": "Ciclo",
"description": "Fase actual y cuenta regresiva hasta el próximo período",
"empty": "Ningún ciclo registrado.",

View file

@ -140,7 +140,7 @@
"description": "Chronologie de toutes les activités de la journée",
"empty": "Rien encore aujourd'hui"
},
"cycles": {
"period": {
"title": "Cycle",
"description": "Phase actuelle et compte à rebours jusqu'aux prochaines règles",
"empty": "Aucun cycle enregistré.",

View file

@ -140,7 +140,7 @@
"description": "Cronologia di tutte le attività della giornata",
"empty": "Niente ancora oggi"
},
"cycles": {
"period": {
"title": "Ciclo",
"description": "Fase attuale e conto alla rovescia per il prossimo ciclo",
"empty": "Nessun ciclo registrato.",

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Cycles",
"name": "Period",
"tagline": "Menstruationszyklus-Tracking"
},
"phase": {
@ -25,7 +25,7 @@
"bad": "Schlecht"
},
"label": {
"cycleDay": "Zyklustag",
"periodDay": "Zyklustag",
"daysUntilPeriod": "Tage bis zur Periode",
"today": "Heute",
"predicted": "vorhergesagt",
@ -53,7 +53,7 @@
"avgDays": "Ø Tage",
"shortest": "kürzester",
"longest": "längster",
"cycles": "Zyklen",
"period": "Zyklen",
"nextPeriod": "Nächste Periode:",
"fertileWindow": "Fruchtbares Fenster:"
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Cycles",
"name": "Period",
"tagline": "Menstrual Cycle Tracking"
},
"phase": {
@ -25,7 +25,7 @@
"bad": "Bad"
},
"label": {
"cycleDay": "Cycle day",
"periodDay": "Cycle day",
"daysUntilPeriod": "days until period",
"today": "Today",
"predicted": "predicted",
@ -53,7 +53,7 @@
"avgDays": "Avg days",
"shortest": "shortest",
"longest": "longest",
"cycles": "cycles",
"period": "period",
"nextPeriod": "Next period:",
"fertileWindow": "Fertile window:"
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Cycles",
"name": "Period",
"tagline": "Seguimiento del ciclo menstrual"
},
"phase": {
@ -25,7 +25,7 @@
"bad": "Malo"
},
"label": {
"cycleDay": "Día del ciclo",
"periodDay": "Día del ciclo",
"daysUntilPeriod": "días hasta el período",
"today": "Hoy",
"predicted": "previsto",
@ -53,7 +53,7 @@
"avgDays": "Prom. días",
"shortest": "más corto",
"longest": "más largo",
"cycles": "ciclos",
"period": "ciclos",
"nextPeriod": "Próximo período:",
"fertileWindow": "Ventana fértil:"
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Cycles",
"name": "Period",
"tagline": "Suivi du cycle menstruel"
},
"phase": {
@ -25,7 +25,7 @@
"bad": "Mauvais"
},
"label": {
"cycleDay": "Jour du cycle",
"periodDay": "Jour du cycle",
"daysUntilPeriod": "jours avant les règles",
"today": "Aujourd'hui",
"predicted": "prévu",
@ -53,7 +53,7 @@
"avgDays": "Moy. jours",
"shortest": "plus court",
"longest": "plus long",
"cycles": "cycles",
"period": "period",
"nextPeriod": "Prochaines règles :",
"fertileWindow": "Fenêtre fertile :"
},

View file

@ -1,6 +1,6 @@
{
"app": {
"name": "Cycles",
"name": "Period",
"tagline": "Monitoraggio del ciclo mestruale"
},
"phase": {
@ -25,7 +25,7 @@
"bad": "Cattivo"
},
"label": {
"cycleDay": "Giorno del ciclo",
"periodDay": "Giorno del ciclo",
"daysUntilPeriod": "giorni al ciclo",
"today": "Oggi",
"predicted": "previsto",
@ -53,7 +53,7 @@
"avgDays": "Media gg",
"shortest": "più breve",
"longest": "più lungo",
"cycles": "cicli",
"period": "cicli",
"nextPeriod": "Prossimo ciclo:",
"fertileWindow": "Finestra fertile:"
},

View file

@ -1,5 +1,5 @@
/**
* i18n parity test for the cycles module.
* i18n parity test for the period module.
*
* Ensures all 5 locale files (de/en/it/fr/es) have identical key
* structure stub copies of en.json would otherwise drift silently
@ -41,7 +41,7 @@ const locales = {
es: es as Dict,
};
describe('cycles i18n parity', () => {
describe('period i18n parity', () => {
const referenceKeys = flattenKeys(locales.de);
test('de has a non-empty set of keys', () => {

View file

@ -49,7 +49,7 @@
{ type: 'watering', label: 'Gießen', icon: Drop },
{ type: 'sleep', label: 'Schlaf', icon: Moon },
{ type: 'practice', label: 'Übung', icon: GraduationCap },
{ type: 'cycle', label: 'Zyklus', icon: FlowerLotus },
{ type: 'period', label: 'Periode', icon: FlowerLotus },
{ type: 'guide', label: 'Guides', icon: Compass },
{ type: 'visit', label: 'Besuche', icon: MapPin },
{ type: 'study', label: 'Lernen', icon: BookOpen },

View file

@ -35,7 +35,7 @@ let visibleBlockTypes = $state<Set<TimeBlockType>>(
'watering',
'sleep',
'practice',
'cycle',
'period',
'guide',
'visit',
'study',

View file

@ -1,32 +1,32 @@
<script lang="ts">
/**
* CyclesWidget — Aktuelle Phase + Countdown bis zur nächsten Periode.
* PeriodWidget — Aktuelle Phase + Countdown bis zur nächsten Periode.
*
* Liest direkt aus der unified IndexedDB (cycles table) und leitet Phase
* + Vorhersage pro Render ab. Linkt zur /cycles Route.
* Liest direkt aus der unified IndexedDB (period table) und leitet Phase
* + Vorhersage pro Render ab. Linkt zur /period Route.
*/
import { _, locale } from 'svelte-i18n';
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { derivePhase, getCycleDayNumber } from '$lib/modules/cycles/utils/phase';
import { derivePhase, getPeriodDayNumber } from '$lib/modules/period/utils/phase';
import {
daysUntilNextPeriod,
predictNextPeriodStart,
} from '$lib/modules/cycles/utils/prediction';
import { PHASE_COLORS, type Cycle, type LocalCycle } from '$lib/modules/cycles/types';
import { toCycle } from '$lib/modules/cycles/queries';
} from '$lib/modules/period/utils/prediction';
import { PHASE_COLORS, type Period, type LocalPeriod } from '$lib/modules/period/types';
import { toPeriod } from '$lib/modules/period/queries';
let cycles: Cycle[] = $state([]);
let period: Period[] = $state([]);
let loading = $state(true);
$effect(() => {
const sub = liveQuery(async () => {
const locals = await db.table<LocalCycle>('cycles').toArray();
return locals.filter((c) => !c.deletedAt && !c.isArchived).map(toCycle);
const locals = await db.table<LocalPeriod>('periods').toArray();
return locals.filter((c) => !c.deletedAt && !c.isArchived).map(toPeriod);
}).subscribe({
next: (val) => {
cycles = val;
period = val;
loading = false;
},
error: () => {
@ -38,15 +38,15 @@
const todayIso = new Date().toISOString().slice(0, 10);
const phase = $derived(derivePhase(todayIso, cycles));
const currentCycle = $derived(
cycles
const phase = $derived(derivePhase(todayIso, period));
const currentPeriod = $derived(
period
.filter((c) => !c.isPredicted)
.sort((a, b) => b.startDate.localeCompare(a.startDate))[0] ?? null
);
const cycleDay = $derived(currentCycle ? getCycleDayNumber(todayIso, currentCycle) : null);
const daysUntil = $derived(daysUntilNextPeriod(cycles));
const nextPeriod = $derived(predictNextPeriodStart(cycles));
const periodDay = $derived(currentPeriod ? getPeriodDayNumber(todayIso, currentPeriod) : null);
const daysUntil = $derived(daysUntilNextPeriod(period));
const nextPeriod = $derived(predictNextPeriodStart(period));
const dateLocale = $derived.by(() => {
const l = $locale ?? 'de';
@ -62,7 +62,7 @@
<div>
<div class="mb-3 flex items-center justify-between">
<h3 class="flex items-center gap-2 text-lg font-semibold">
{$_('dashboard.widgets.cycles.title')}
{$_('dashboard.widgets.period.title')}
</h3>
{#if phase !== 'unknown'}
<span
@ -71,37 +71,37 @@
phase
]} 14%, transparent); color: {PHASE_COLORS[phase]}"
>
{$_(`cycles.phase.${phase}`)}
{$_(`period.phase.${phase}`)}
</span>
{/if}
</div>
{#if loading}
<div class="h-20 animate-pulse rounded bg-surface-hover"></div>
{:else if cycles.length === 0}
{:else if period.length === 0}
<div class="py-6 text-center">
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.cycles.empty')}
{$_('dashboard.widgets.period.empty')}
</p>
<a
href="/cycles"
href="/period"
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
>
{$_('dashboard.widgets.cycles.open')}
{$_('dashboard.widgets.period.open')}
</a>
</div>
{:else}
<a
href="/cycles"
href="/period"
class="block rounded-lg p-3 transition-colors hover:bg-surface-hover"
style="background: color-mix(in srgb, {PHASE_COLORS[phase]} 6%, transparent);"
>
<div class="flex items-baseline justify-between gap-3">
<div>
{#if cycleDay}
{#if periodDay}
<p class="text-xs text-muted-foreground">
{$_('cycles.label.cycleDay')}
{cycleDay}
{$_('period.label.periodDay')}
{periodDay}
</p>
{/if}
{#if daysUntil !== null}
@ -109,18 +109,18 @@
{#if daysUntil > 0}
{daysUntil}
{:else if daysUntil === 0}
{$_('cycles.label.today')}
{$_('period.label.today')}
{:else}
+{Math.abs(daysUntil)}
{/if}
</p>
<p class="text-xs text-muted-foreground">
{#if daysUntil > 0}
{$_('cycles.label.daysUntilPeriod')}
{$_('period.label.daysUntilPeriod')}
{:else if daysUntil === 0}
{$_('cycles.label.predicted')}
{$_('period.label.predicted')}
{:else}
{$_('cycles.label.daysOverdue')}
{$_('period.label.daysOverdue')}
{/if}
</p>
{/if}
@ -128,7 +128,7 @@
{#if nextPeriod && daysUntil !== null && daysUntil >= 0}
<div class="text-right">
<p class="text-xs text-muted-foreground">
{$_('cycles.stats.nextPeriod')}
{$_('period.stats.nextPeriod')}
</p>
<p class="text-sm font-medium">{formatShortDate(nextPeriod)}</p>
</div>

View file

@ -1,28 +1,28 @@
<!--
Cycles — Workbench ListView
Periods — Workbench ListView
Aktueller Zyklus, heutiger Quick-Log, einfache Statistiken.
-->
<script lang="ts">
import { _, locale } from 'svelte-i18n';
import {
formatLogDate,
useAllCycles,
useAllPeriods,
useAllDayLogs,
useAllSymptoms,
useCurrentCycle,
useCurrentPeriod,
type RelativeDateLabels,
} from './queries';
import { cyclesStore } from './stores/cycles.svelte';
import { periodsStore } from './stores/periods.svelte';
import { dayLogsStore } from './stores/dayLogs.svelte';
import { derivePhase, getCycleDayNumber } from './utils/phase';
import { derivePhase, getPeriodDayNumber } from './utils/phase';
import {
computeCycleStats,
computePeriodStats,
daysUntilNextPeriod,
predictFertileWindow,
predictNextPeriodStart,
} from './utils/prediction';
import { FLOW_COLORS, MOOD_COLORS, PHASE_COLORS, type Flow, type Mood } from './types';
import CycleCalendar from './components/CycleCalendar.svelte';
import PeriodCalendar from './components/PeriodCalendar.svelte';
import SymptomManager from './components/SymptomManager.svelte';
import type { ViewProps } from '$lib/app-registry';
import { toast } from '$lib/stores/toast.svelte';
@ -33,15 +33,15 @@
const todayIso = new Date().toISOString().slice(0, 10);
let cycles$ = useAllCycles();
let periods$ = useAllPeriods();
let logs$ = useAllDayLogs();
let symptoms$ = useAllSymptoms();
let current$ = useCurrentCycle();
let current$ = useCurrentPeriod();
let cycles = $derived(cycles$.value);
let periods = $derived(periods$.value);
let logs = $derived(logs$.value);
let symptoms = $derived(symptoms$.value);
let currentCycle = $derived(current$.value);
let currentPeriod = $derived(current$.value);
// Locale-aware date formatting: use the active svelte-i18n locale, with
// 'de-DE' as a fallback since the project defaults to German.
@ -51,17 +51,17 @@
});
const relativeLabels = $derived<RelativeDateLabels>({
today: $_('cycles.relativeDate.today'),
yesterday: $_('cycles.relativeDate.yesterday'),
daysAgo: (n: number) => $_('cycles.relativeDate.daysAgo', { values: { days: n } }),
today: $_('period.relativeDate.today'),
yesterday: $_('period.relativeDate.yesterday'),
daysAgo: (n: number) => $_('period.relativeDate.daysAgo', { values: { days: n } }),
});
let phase = $derived(derivePhase(todayIso, cycles));
let cycleDay = $derived(currentCycle ? getCycleDayNumber(todayIso, currentCycle) : null);
let stats = $derived(computeCycleStats(cycles));
let daysUntil = $derived(daysUntilNextPeriod(cycles));
let nextPeriod = $derived(predictNextPeriodStart(cycles));
let fertile = $derived(predictFertileWindow(cycles));
let phase = $derived(derivePhase(todayIso, periods));
let periodDay = $derived(currentPeriod ? getPeriodDayNumber(todayIso, currentPeriod) : null);
let stats = $derived(computePeriodStats(periods));
let daysUntil = $derived(daysUntilNextPeriod(periods));
let nextPeriod = $derived(predictNextPeriodStart(periods));
let fertile = $derived(predictFertileWindow(periods));
const FLOWS: Flow[] = ['none', 'spotting', 'light', 'medium', 'heavy'];
const MOODS: Mood[] = ['great', 'good', 'neutral', 'low', 'bad'];
@ -148,21 +148,21 @@
return;
}
const dateStr = new Date(editingDate).toLocaleDateString(dateLocale);
const ok = confirm($_('cycles.confirm.deleteEntry', { values: { date: dateStr } }));
const ok = confirm($_('period.confirm.deleteEntry', { values: { date: dateStr } }));
if (!ok) return;
await dayLogsStore.deleteLog(editingLog.id);
backToToday();
}
async function startPeriodToday() {
await cyclesStore.createCycle({ startDate: todayIso });
await periodsStore.createPeriod({ startDate: todayIso });
await safeLogDay({ logDate: todayIso, flow: 'medium' });
backToToday();
}
async function endPeriodToday() {
if (!currentCycle) return;
await cyclesStore.setPeriodEnd(currentCycle.id, todayIso);
if (!currentPeriod) return;
await periodsStore.setPeriodEnd(currentPeriod.id, todayIso);
}
function formatDate(iso: string | null): string {
@ -180,34 +180,34 @@
<div class="phase-top">
<span class="phase-dot"></span>
<div class="phase-info">
<span class="phase-label">{$_(`cycles.phase.${phase}`)}</span>
{#if cycleDay}
<span class="phase-sub">{$_('cycles.label.cycleDay')} {cycleDay}</span>
<span class="phase-label">{$_(`period.phase.${phase}`)}</span>
{#if periodDay}
<span class="phase-sub">{$_('period.label.periodDay')} {periodDay}</span>
{/if}
</div>
{#if daysUntil !== null}
<div class="phase-countdown">
{#if daysUntil > 0}
<strong>{daysUntil}</strong>
<span>{$_('cycles.label.daysUntilPeriod')}</span>
<span>{$_('period.label.daysUntilPeriod')}</span>
{:else if daysUntil === 0}
<strong>{$_('cycles.label.today')}</strong>
<span>{$_('cycles.label.predicted')}</span>
<strong>{$_('period.label.today')}</strong>
<span>{$_('period.label.predicted')}</span>
{:else}
<strong>{Math.abs(daysUntil)}</strong>
<span>{$_('cycles.label.daysOverdue')}</span>
<span>{$_('period.label.daysOverdue')}</span>
{/if}
</div>
{/if}
</div>
<div class="phase-actions">
{#if !currentCycle || (currentCycle.periodEndDate && currentCycle.periodEndDate < todayIso && phase !== 'menstruation')}
{#if !currentPeriod || (currentPeriod.periodEndDate && currentPeriod.periodEndDate < todayIso && phase !== 'menstruation')}
<button class="btn-primary" onclick={startPeriodToday}
>{$_('cycles.action.startPeriod')}</button
>{$_('period.action.startPeriod')}</button
>
{:else if currentCycle && !currentCycle.periodEndDate}
{:else if currentPeriod && !currentPeriod.periodEndDate}
<button class="btn-secondary" onclick={endPeriodToday}
>{$_('cycles.action.endPeriod')}</button
>{$_('period.action.endPeriod')}</button
>
{/if}
</div>
@ -216,13 +216,13 @@
<!-- Calendar -->
<section class="log-section">
<div class="section-header">
<h3 class="section-label">{$_('cycles.calendar.title')}</h3>
<h3 class="section-label">{$_('period.calendar.title')}</h3>
<button class="section-action" onclick={() => (calendarOpen = !calendarOpen)}>
{calendarOpen ? '' : '+'}
</button>
</div>
{#if calendarOpen}
<CycleCalendar {cycles} {logs} {editingDate} {todayIso} onSelectDay={selectDay} />
<PeriodCalendar {periods} {logs} {editingDate} {todayIso} onSelectDay={selectDay} />
{/if}
</section>
@ -230,16 +230,16 @@
{#if isEditingPast}
<div class="edit-banner">
<span class="edit-banner-label">
{$_('cycles.label.editing')}
{$_('period.label.editing')}
<strong>{new Date(editingDate).toLocaleDateString(dateLocale)}</strong>
</span>
<div class="edit-banner-actions">
{#if editingLog}
<button class="banner-btn danger" onclick={deleteEditingLog}
>{$_('cycles.action.delete')}</button
>{$_('period.action.delete')}</button
>
{/if}
<button class="banner-btn" onclick={backToToday}>{$_('cycles.action.backToToday')}</button>
<button class="banner-btn" onclick={backToToday}>{$_('period.action.backToToday')}</button>
</div>
</div>
{/if}
@ -247,7 +247,7 @@
<!-- Flow -->
<section class="log-section">
<h3 class="section-label">
{isEditingPast ? $_('cycles.label.bleeding') : $_('cycles.label.todayBleeding')}
{isEditingPast ? $_('period.label.bleeding') : $_('period.label.todayBleeding')}
</h3>
<div class="row">
{#each FLOWS as flow}
@ -258,7 +258,7 @@
onclick={() => setFlow(flow)}
>
<span class="flow-dot"></span>
{$_(`cycles.flow.${flow}`)}
{$_(`period.flow.${flow}`)}
</button>
{/each}
</div>
@ -266,7 +266,7 @@
<!-- Mood -->
<section class="log-section">
<h3 class="section-label">{$_('cycles.label.mood')}</h3>
<h3 class="section-label">{$_('period.label.mood')}</h3>
<div class="row">
{#each MOODS as mood}
<button
@ -276,7 +276,7 @@
onclick={() => setMood(mood)}
>
<span class="mood-dot"></span>
{$_(`cycles.mood.${mood}`)}
{$_(`period.mood.${mood}`)}
</button>
{/each}
</div>
@ -285,9 +285,9 @@
<!-- Symptoms -->
<section class="log-section">
<div class="section-header">
<h3 class="section-label">{$_('cycles.label.symptoms')}</h3>
<h3 class="section-label">{$_('period.label.symptoms')}</h3>
<button class="section-action" onclick={() => (symptomManagerOpen = true)}>
{$_('cycles.symptomManager.open')}
{$_('period.symptomManager.open')}
</button>
</div>
{#if symptoms.length > 0}
@ -309,20 +309,20 @@
<!-- Temperature & Notes -->
<section class="log-section">
<h3 class="section-label">{$_('cycles.label.basalAndNotes')}</h3>
<h3 class="section-label">{$_('period.label.basalAndNotes')}</h3>
<div class="row inputs">
<input
type="number"
step="0.01"
class="temp-input"
placeholder={$_('cycles.input.temperaturePlaceholder')}
placeholder={$_('period.input.temperaturePlaceholder')}
bind:value={temperature}
onblur={saveTemperature}
/>
<input
type="text"
class="notes-input"
placeholder={$_('cycles.input.notesPlaceholder')}
placeholder={$_('period.input.notesPlaceholder')}
bind:value={notesText}
onblur={saveNotes}
/>
@ -332,30 +332,30 @@
<!-- Stats -->
{#if stats.total > 0}
<section class="log-section stats">
<h3 class="section-label">{$_('cycles.label.stats')}</h3>
<h3 class="section-label">{$_('period.label.stats')}</h3>
<div class="stats-grid">
<div class="stat">
<strong>{stats.avg}</strong>
<span>{$_('cycles.stats.avgDays')}</span>
<span>{$_('period.stats.avgDays')}</span>
</div>
<div class="stat">
<strong>{stats.shortest}</strong>
<span>{$_('cycles.stats.shortest')}</span>
<span>{$_('period.stats.shortest')}</span>
</div>
<div class="stat">
<strong>{stats.longest}</strong>
<span>{$_('cycles.stats.longest')}</span>
<span>{$_('period.stats.longest')}</span>
</div>
<div class="stat">
<strong>{stats.total}</strong>
<span>{$_('cycles.stats.cycles')}</span>
<span>{$_('period.stats.periods')}</span>
</div>
</div>
{#if nextPeriod}
<div class="prediction">
{$_('cycles.stats.nextPeriod')} <strong>{formatDate(nextPeriod)}</strong>
{$_('period.stats.nextPeriod')} <strong>{formatDate(nextPeriod)}</strong>
{#if fertile}
· {$_('cycles.stats.fertileWindow')}
· {$_('period.stats.fertileWindow')}
<strong>{formatDate(fertile.start)}</strong>
<strong>{formatDate(fertile.end)}</strong>
{/if}
@ -367,7 +367,7 @@
<!-- Recent logs -->
{#if logs.length > 0}
<section class="log-section">
<h3 class="section-label">{$_('cycles.label.recentEntries')}</h3>
<h3 class="section-label">{$_('period.label.recentEntries')}</h3>
<div class="log-list">
{#each logs.slice(0, 10) as log (log.id)}
<button
@ -383,11 +383,11 @@
>{formatLogDate(log.logDate, relativeLabels, dateLocale)}</span
>
{#if log.flow !== 'none'}
<span class="log-tag">{$_(`cycles.flow.${log.flow}`)}</span>
<span class="log-tag">{$_(`period.flow.${log.flow}`)}</span>
{/if}
{#if log.mood}
<span class="log-tag" style="color: {MOOD_COLORS[log.mood]}"
>{$_(`cycles.mood.${log.mood}`)}</span
>{$_(`period.mood.${log.mood}`)}</span
>
{/if}
</div>
@ -401,8 +401,8 @@
</section>
{/if}
{#if cycles.length === 0 && logs.length === 0}
<p class="empty">{$_('cycles.empty')}</p>
{#if periods.length === 0 && logs.length === 0}
<p class="empty">{$_('period.empty')}</p>
{/if}
</div>

View file

@ -1,11 +1,11 @@
# Cycles Module — Roadmap
# Periods Module — Roadmap
Ideen für Features, Tests und Refinements, die im aktuellen Sprint nicht gemacht wurden. Sortiert grob nach Aufwand und Impact. Dies ist kein Commitment, sondern ein Ideenspeicher.
## Was bereits drin ist
- Datenschicht (v7): `cycles`, `cycleDayLogs`, `cycleSymptoms` mit Sync-Registrierung
- Stores: `cyclesStore`, `dayLogsStore`, `symptomsStore` mit Auto-Close, Upsert, Symptom-Counter
- Datenschicht (v7): `periods`, `periodDayLogs`, `periodSymptoms` mit Sync-Registrierung
- Stores: `periodsStore`, `dayLogsStore`, `symptomsStore` mit Auto-Close, Upsert, Symptom-Counter
- Pure Utils: Phase-Ableitung, Prediction (gleitender Mittelwert), Auto-Start/Auto-End-Detection
- UI: ListView mit Phase-Karte, Quick-Log (Flow/Mood/Symptome/BBT/Notizen), Kalender-Ansicht, Symptom-Manager, Edit-/Delete-vergangener-Tage-Banner
- Dashboard-Widget mit Phase + Countdown
@ -18,14 +18,14 @@ Ideen für Features, Tests und Refinements, die im aktuellen Sprint nicht gemach
- **Keyboard-Shortcuts** in ListView: `1-5` für Flow-Level, `Esc` für "Zurück zu heute", `J/K` für Previous/Next im Log-Verlauf
- **Date-Picker im Edit-Banner**: `<input type="date">` um direkt zu einem Tag zu springen statt im Kalender suchen zu müssen
- **Cycle-Notizen-UI**: `cycles.notes` wird gespeichert aber nirgends gerendert. Textarea in der Phase-Karte oder separater Cycle-Detail-View
- **Funnel-Tracking**: `trackFirstContent('cycles')` beim ersten Log, analog zu anderen Modulen
- **Period-Notizen-UI**: `periods.notes` wird gespeichert aber nirgends gerendert. Textarea in der Phase-Karte oder separater Period-Detail-View
- **Funnel-Tracking**: `trackFirstContent('periods')` beim ersten Log, analog zu anderen Modulen
### Pure-Function-Robustheit
- **Orphaned Symptom-IDs**: Wenn ein Symptom gelöscht wird, bleiben Day-Logs mit toten IDs zurück. Entweder `symptomsStore.deleteSymptom()` entfernt die IDs aus allen Logs, oder die UI filtert `sym?.deletedAt == null` beim Rendern
- **Manual recalculate button**: `cyclesStore.recalculateCycles()` die alle Zyklen re-validiert (Auto-End auch für Tage nachfeuert, die nicht durch einen `logDay`-Aufruf gingen)
- **Temperature-Units**: Placeholder in en.json sagt "98.6 °F", gespeichert wird aber als `number` ohne Einheit. Entweder `temperatureUnit: 'C' | 'F'` auf `cycles` oder in `userSettings`
- **Manual recalculate button**: `periodsStore.recalculatePeriods()` die alle Zyklen re-validiert (Auto-End auch für Tage nachfeuert, die nicht durch einen `logDay`-Aufruf gingen)
- **Temperature-Units**: Placeholder in en.json sagt "98.6 °F", gespeichert wird aber als `number` ohne Einheit. Entweder `temperatureUnit: 'C' | 'F'` auf `periods` oder in `userSettings`
### i18n-Details
@ -36,7 +36,7 @@ Ideen für Features, Tests und Refinements, die im aktuellen Sprint nicht gemach
### BBT-Chart
Liniendiagramm der Basaltemperatur über den aktuellen Zyklus. Daten sind schon da (`cycleDayLogs.temperature`), es fehlt nur ein SVG-Chart-Component.
Liniendiagramm der Basaltemperatur über den aktuellen Zyklus. Daten sind schon da (`periodDayLogs.temperature`), es fehlt nur ein SVG-Chart-Component.
- Reines SVG, keine Library nötig
- Markiere Ovulation (erkannter Temperaturanstieg) visuell
@ -44,7 +44,7 @@ Liniendiagramm der Basaltemperatur über den aktuellen Zyklus. Daten sind schon
### History-Seite
`/cycles/history` mit Liste aller Zyklen:
`/periods/history` mit Liste aller Zyklen:
- Länge pro Zyklus, Durchschnitt, Min/Max
- Visuelle Bar pro Zyklus (Menstruation rot, Follikular gelb, Ovulation grün, Luteal lila)
@ -53,19 +53,19 @@ Liniendiagramm der Basaltemperatur über den aktuellen Zyklus. Daten sind schon
### Detail-Page pro Tag
`/cycles/log/[date]`:
`/periods/log/[date]`:
- Vollständiger Editor mit allen Feldern: cervicalMucus-Picker, sexualActivity-Toggle, Energy-Slider
- Textarea mit mehr Platz für Notizen statt Inline-Input
- Vergleich mit Vortag / Vorjahr
### Cycle-Notizen-Panel
### Period-Notizen-Panel
In der Phase-Karte eine ausklappbare Notiz-Sektion pro Zyklus. `cycles.notes` wird aktuell persistiert aber nicht angezeigt.
In der Phase-Karte eine ausklappbare Notiz-Sektion pro Zyklus. `periods.notes` wird aktuell persistiert aber nicht angezeigt.
### Pattern-Erkennung (lokal, ohne LLM)
- "Du hast in 4 von 5 Lutealphasen Kopfschmerzen" — reine Aggregation über `cycleDayLogs` + `cycleSymptoms`
- "Du hast in 4 von 5 Lutealphasen Kopfschmerzen" — reine Aggregation über `periodDayLogs` + `periodSymptoms`
- "Dein aktueller Zyklus ist {avg + 7} Tage lang — ungewöhnlich lang"
- "Das fruchtbare Fenster beginnt in 2 Tagen"
- Eigene Sektion in der ListView unter "Statistik"
@ -99,13 +99,13 @@ Vollständiger Happy Path über die neu hinzugefügte `e2e/`-Infrastruktur:
Unit-Test mit `fake-indexeddb` der eine alte v6-DB erstellt, die Migration auf v10+ laufen lässt, und prüft:
- `cycles`-Tabellen existieren
- `periods`-Tabellen existieren
- Legacy-Daten unverändert
- `_pendingChanges` korrekt getaggt mit `appId: 'cycles'`
- `_pendingChanges` korrekt getaggt mit `appId: 'periods'`
### Guest-Seed-Test
Beim ersten Öffnen als Guest sollten die 10 Default-Symptome angelegt werden. Aktuell ist `CYCLES_GUEST_SEED` exportiert, aber es gibt keinen Test, der prüft dass das Seed-Laden tatsächlich passiert.
Beim ersten Öffnen als Guest sollten die 10 Default-Symptome angelegt werden. Aktuell ist `PERIODS_GUEST_SEED` exportiert, aber es gibt keinen Test, der prüft dass das Seed-Laden tatsächlich passiert.
## Langfristig — Produktreife
@ -130,22 +130,22 @@ Beim ersten Öffnen als Guest sollten die 10 Default-Symptome angelegt werden. A
### Datenschutz-Modus
Cycles ist sensibler als die meisten anderen Module. Optionen:
Periods ist sensibler als die meisten anderen Module. Optionen:
- **App-Lock**: Extra PIN / Biometrie vor Öffnen von `/cycles`
- **App-Lock**: Extra PIN / Biometrie vor Öffnen von `/periods`
- **Versteckter App-Switcher-Eintrag**: Nur über Direkt-URL erreichbar
- **Lokale Verschlüsselung**: `cycles`-Tabellen mit einem User-Passwort verschlüsseln (würde ein neues Pattern im Repo etablieren, das später auch Memoro/Dreams nutzen könnten)
- **Lokale Verschlüsselung**: `periods`-Tabellen mit einem User-Passwort verschlüsseln (würde ein neues Pattern im Repo etablieren, das später auch Memoro/Dreams nutzen könnten)
- **Field-level Sync-Control**: Manche Felder (sexualActivity, detaillierte Notizen) nur lokal halten, andere syncen. Bräuchte Erweiterung von `SYNC_APP_MAP` um Pro-Feld-Regeln
### Mobile-App
Cycles ist ein klassischer Mobile-First-Use-Case. Ein Expo-Port kann die unified IndexedDB nicht nutzen, müsste die Sync-Brücke als primäre Datenquelle verwenden. Das wäre ein größeres Projekt, lohnt sich aber nach Produkt-Validierung.
Periods ist ein klassischer Mobile-First-Use-Case. Ein Expo-Port kann die unified IndexedDB nicht nutzen, müsste die Sync-Brücke als primäre Datenquelle verwenden. Das wäre ein größeres Projekt, lohnt sich aber nach Produkt-Validierung.
## Monitoring / Ops
### ManaScore-Entry
Modul in `apps/mana/apps/landing/src/content/manascore/cycles.md` eintragen. Initiale Scores (geschätzt):
Modul in `apps/mana/apps/landing/src/content/manascore/periods.md` eintragen. Initiale Scores (geschätzt):
| Kategorie | Score | Begründung |
| ------------- | ----- | ------------------------------------------------------------------- |
@ -162,7 +162,7 @@ Modul in `apps/mana/apps/landing/src/content/manascore/cycles.md` eintragen. Ini
### Ecosystem Health
Nach nächstem `ecosystem-audit.mjs`-Lauf sollte Cycles positiv zählen für:
Nach nächstem `ecosystem-audit.mjs`-Lauf sollte Periods positiv zählen für:
- Shared Packages (@mana/shared-ui, @mana/local-store, svelte-i18n)
- i18n-Coverage (5 Locales, alle voll übersetzt, Parity-Test)
@ -174,7 +174,7 @@ Nach nächstem `ecosystem-audit.mjs`-Lauf sollte Cycles positiv zählen für:
- **FLOW_LABELS / MOOD_LABELS / PHASE_LABELS / CERVICAL_MUCUS_LABELS** in `types.ts` sind seit der i18n-Umstellung unbenutzt — entweder löschen oder als explizite Fallbacks dokumentieren
- **Module CLAUDE.md** im Ordner anlegen, das die Architektur kurz beschreibt (wie andere Module es haben)
- **JSDoc-Comments** auf public Store-Methoden erweitern, besonders auf `cyclesStore.createCycle` (Auto-Close-Verhalten ist nicht offensichtlich)
- **JSDoc-Comments** auf public Store-Methoden erweitern, besonders auf `periodsStore.createPeriod` (Auto-Close-Verhalten ist nicht offensichtlich)
## Nicht-Ziele (bewusst ausgeklammert)

View file

@ -1,25 +1,25 @@
/**
* Cycles module collection accessors and guest seed data.
* Periods module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalCycle, LocalCycleDayLog, LocalCycleSymptom } from './types';
import type { LocalPeriod, LocalPeriodDayLog, LocalPeriodSymptom } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const cycleTable = db.table<LocalCycle>('cycles');
export const cycleDayLogTable = db.table<LocalCycleDayLog>('cycleDayLogs');
export const cycleSymptomTable = db.table<LocalCycleSymptom>('cycleSymptoms');
export const periodTable = db.table<LocalPeriod>('periods');
export const periodDayLogTable = db.table<LocalPeriodDayLog>('periodDayLogs');
export const periodSymptomTable = db.table<LocalPeriodSymptom>('periodSymptoms');
// ─── 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: [
export const PERIODS_GUEST_SEED = {
periods: [
{
id: 'cycle-prev',
id: 'period-prev',
startDate: daysAgo(56),
periodEndDate: daysAgo(52),
endDate: daysAgo(29),
@ -29,7 +29,7 @@ export const CYCLES_GUEST_SEED = {
notes: null,
},
{
id: 'cycle-current',
id: 'period-current',
startDate: daysAgo(28),
periodEndDate: daysAgo(24),
endDate: null,
@ -38,12 +38,12 @@ export const CYCLES_GUEST_SEED = {
isArchived: false,
notes: 'Aktueller Zyklus',
},
] satisfies LocalCycle[],
cycleDayLogs: [
] satisfies LocalPeriod[],
periodDayLogs: [
{
id: 'cycle-log-today',
id: 'period-log-today',
logDate: today,
cycleId: 'cycle-current',
periodId: 'period-current',
flow: 'none',
mood: 'good',
energy: 4,
@ -53,8 +53,8 @@ export const CYCLES_GUEST_SEED = {
sexualActivity: null,
notes: null,
},
] satisfies LocalCycleDayLog[],
cycleSymptoms: [
] satisfies LocalPeriodDayLog[],
periodSymptoms: [
{
id: 'sym-cramps',
name: 'Krämpfe',
@ -125,5 +125,5 @@ export const CYCLES_GUEST_SEED = {
color: '#d946ef',
count: 0,
},
] satisfies LocalCycleSymptom[],
] satisfies LocalPeriodSymptom[],
};

View file

@ -1,24 +1,24 @@
<!--
Cycle Calendar — Month grid colored by phase, with flow markers.
Period Calendar — Month grid colored by phase, with flow markers.
Click a day to switch the editing target. Navigate prev/next month
with arrow buttons. Week starts on Monday (DE convention).
-->
<script lang="ts">
import { _, locale } from 'svelte-i18n';
import type { Cycle, CycleDayLog, CyclePhase, Flow } from '../types';
import type { Period, PeriodDayLog, PeriodPhase, Flow } from '../types';
import { FLOW_COLORS, PHASE_COLORS } from '../types';
import { derivePhase } from '../utils/phase';
interface Props {
cycles: Cycle[];
logs: CycleDayLog[];
periods: Period[];
logs: PeriodDayLog[];
editingDate: string;
todayIso: string;
onSelectDay: (iso: string) => void;
}
const { cycles, logs, editingDate, todayIso, onSelectDay }: Props = $props();
const { periods, logs, editingDate, todayIso, onSelectDay }: Props = $props();
// ─ Month state ──────────────────────────────────────────
// svelte-ignore state_referenced_locally
@ -28,7 +28,7 @@
// ─ Logs indexed by date for O(1) lookup ─
const logByDate = $derived.by(() => {
const map = new Map<string, CycleDayLog>();
const map = new Map<string, PeriodDayLog>();
for (const log of logs) {
map.set(log.logDate, log);
}
@ -40,7 +40,7 @@
iso: string;
dayOfMonth: number;
inCurrentMonth: boolean;
phase: CyclePhase;
phase: PeriodPhase;
flow: Flow | null;
isToday: boolean;
isEditing: boolean;
@ -73,7 +73,7 @@
iso,
dayOfMonth: day,
inCurrentMonth: m === viewMonth,
phase: derivePhase(iso, cycles),
phase: derivePhase(iso, periods),
flow: log && log.flow !== 'none' ? log.flow : null,
isToday: iso === todayIso,
isEditing: iso === editingDate,
@ -138,14 +138,14 @@
class="cal-nav"
type="button"
onclick={prevMonth}
aria-label={$_('cycles.calendar.prev')}></button
aria-label={$_('period.calendar.prev')}></button
>
<button class="cal-title" type="button" onclick={goToToday}>{monthLabel}</button>
<button
class="cal-nav"
type="button"
onclick={nextMonth}
aria-label={$_('cycles.calendar.next')}></button
aria-label={$_('period.calendar.next')}></button
>
</div>

View file

@ -1,11 +1,11 @@
<!--
Symptom Manager — Modal to create, rename, recolor, and delete cycle symptoms.
Symptom Manager — Modal to create, rename, recolor, and delete period symptoms.
-->
<script lang="ts">
import { _ } from 'svelte-i18n';
import { useAllSymptoms } from '../queries';
import { symptomsStore } from '../stores/symptoms.svelte';
import type { CycleSymptom, SymptomCategory } from '../types';
import type { PeriodSymptom, SymptomCategory } from '../types';
import { Modal } from '@mana/shared-ui';
interface Props {
@ -35,7 +35,7 @@
newCategory = 'physical';
}
function startEdit(sym: CycleSymptom) {
function startEdit(sym: PeriodSymptom) {
editingId = sym.id;
editName = sym.name;
editCategory = sym.category;
@ -56,38 +56,38 @@
editingId = null;
}
async function handleDelete(sym: CycleSymptom) {
async function handleDelete(sym: PeriodSymptom) {
const ok = confirm(
$_('cycles.confirm.deleteSymptom', { values: { name: sym.name } }) || `"${sym.name}" löschen?`
$_('period.confirm.deleteSymptom', { values: { name: sym.name } }) || `"${sym.name}" löschen?`
);
if (!ok) return;
await symptomsStore.deleteSymptom(sym.id);
}
</script>
<Modal {visible} {onClose} title={$_('cycles.symptomManager.title')} maxWidth="md">
<Modal {visible} {onClose} title={$_('period.symptomManager.title')} maxWidth="md">
<div class="sm-content">
<!-- Create form -->
<form class="sm-create" onsubmit={handleCreate}>
<input
class="sm-input"
type="text"
placeholder={$_('cycles.symptomManager.newNamePlaceholder')}
placeholder={$_('period.symptomManager.newNamePlaceholder')}
bind:value={newName}
/>
<select class="sm-select" bind:value={newCategory}>
{#each CATEGORIES as cat}
<option value={cat}>{$_(`cycles.symptomCategory.${cat}`)}</option>
<option value={cat}>{$_(`period.symptomCategory.${cat}`)}</option>
{/each}
</select>
<button class="sm-add" type="submit" disabled={!newName.trim()}>
{$_('cycles.symptomManager.add')}
{$_('period.symptomManager.add')}
</button>
</form>
<!-- Symptom list -->
{#if symptoms.length === 0}
<p class="sm-empty">{$_('cycles.symptomManager.empty')}</p>
<p class="sm-empty">{$_('period.symptomManager.empty')}</p>
{:else}
<ul class="sm-list">
{#each symptoms as sym (sym.id)}
@ -101,15 +101,15 @@
/>
<select class="sm-select" bind:value={editCategory}>
{#each CATEGORIES as cat}
<option value={cat}>{$_(`cycles.symptomCategory.${cat}`)}</option>
<option value={cat}>{$_(`period.symptomCategory.${cat}`)}</option>
{/each}
</select>
<div class="sm-actions">
<button class="sm-btn primary" type="button" onclick={saveEdit}>
{$_('cycles.symptomManager.save')}
{$_('period.symptomManager.save')}
</button>
<button class="sm-btn" type="button" onclick={cancelEdit}>
{$_('cycles.symptomManager.cancel')}
{$_('period.symptomManager.cancel')}
</button>
</div>
{:else}
@ -120,7 +120,7 @@
<div class="sm-info">
<span class="sm-name">{sym.name}</span>
<span class="sm-cat">
{$_(`cycles.symptomCategory.${sym.category}`)}
{$_(`period.symptomCategory.${sym.category}`)}
{#if sym.count > 0}
· {sym.count}
{/if}
@ -128,10 +128,10 @@
</div>
<div class="sm-actions">
<button class="sm-btn" type="button" onclick={() => startEdit(sym)}>
{$_('cycles.symptomManager.edit')}
{$_('period.symptomManager.edit')}
</button>
<button class="sm-btn danger" type="button" onclick={() => handleDelete(sym)}>
{$_('cycles.symptomManager.delete')}
{$_('period.symptomManager.delete')}
</button>
</div>
{/if}

View file

@ -1,39 +1,44 @@
/**
* Cycles module barrel exports.
* Periods module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { cyclesStore } from './stores/cycles.svelte';
export { periodsStore } from './stores/periods.svelte';
export { dayLogsStore } from './stores/dayLogs.svelte';
export { symptomsStore } from './stores/symptoms.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllCycles,
useCurrentCycle,
useAllPeriods,
useCurrentPeriod,
useAllDayLogs,
useDayLog,
useAllSymptoms,
toCycle,
toCycleDayLog,
toCycleSymptom,
toPeriod,
toPeriodDayLog,
toPeriodSymptom,
groupLogsByMonth,
formatLogDate,
} from './queries';
export type { RelativeDateLabels } from './queries';
// ─── Utils ───────────────────────────────────────────────
export { derivePhase, findCycleForDate, getCycleDayNumber, daysBetween } from './utils/phase';
export { derivePhase, findPeriodForDate, getPeriodDayNumber, daysBetween } from './utils/phase';
export {
averageCycleLength,
averagePeriodLength,
predictNextPeriodStart,
daysUntilNextPeriod,
predictFertileWindow,
computeCycleStats,
computePeriodStats,
} from './utils/prediction';
// ─── Collections ─────────────────────────────────────────
export { cycleTable, cycleDayLogTable, cycleSymptomTable, CYCLES_GUEST_SEED } from './collections';
export {
periodTable,
periodDayLogTable,
periodSymptomTable,
PERIODS_GUEST_SEED,
} from './collections';
// ─── Types & Constants ───────────────────────────────────
export {
@ -44,20 +49,20 @@ export {
PHASE_COLORS,
PHASE_LABELS,
CERVICAL_MUCUS_LABELS,
DEFAULT_CYCLE_LENGTH,
DEFAULT_PERIOD_LENGTH,
DEFAULT_BLEEDING_DAYS,
DEFAULT_LUTEAL_LENGTH,
} from './types';
export type {
LocalCycle,
LocalCycleDayLog,
LocalCycleSymptom,
Cycle,
CycleDayLog,
CycleSymptom,
LocalPeriod,
LocalPeriodDayLog,
LocalPeriodSymptom,
Period,
PeriodDayLog,
PeriodSymptom,
Flow,
Mood,
CervicalMucus,
SymptomCategory,
CyclePhase,
PeriodPhase,
} from './types';

View file

@ -1,6 +1,6 @@
import type { ModuleConfig } from '$lib/data/module-registry';
export const cyclesModuleConfig: ModuleConfig = {
appId: 'cycles',
tables: [{ name: 'cycles' }, { name: 'cycleDayLogs' }, { name: 'cycleSymptoms' }],
export const periodModuleConfig: ModuleConfig = {
appId: 'period',
tables: [{ name: 'periods' }, { name: 'periodDayLogs' }, { name: 'periodSymptoms' }],
};

View file

@ -1,22 +1,22 @@
/**
* Reactive Queries & Pure Helpers for Cycles module.
* Reactive Queries & Pure Helpers for Periods module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
import type {
Cycle,
CycleDayLog,
CycleSymptom,
LocalCycle,
LocalCycleDayLog,
LocalCycleSymptom,
Period,
PeriodDayLog,
PeriodSymptom,
LocalPeriod,
LocalPeriodDayLog,
LocalPeriodSymptom,
} from './types';
// ─── Type Converters ───────────────────────────────────────
export function toCycle(local: LocalCycle): Cycle {
export function toPeriod(local: LocalPeriod): Period {
return {
id: local.id,
startDate: local.startDate,
@ -31,11 +31,11 @@ export function toCycle(local: LocalCycle): Cycle {
};
}
export function toCycleDayLog(local: LocalCycleDayLog): CycleDayLog {
export function toPeriodDayLog(local: LocalPeriodDayLog): PeriodDayLog {
return {
id: local.id,
logDate: local.logDate,
cycleId: local.cycleId,
periodId: local.periodId,
flow: local.flow,
mood: local.mood,
energy: local.energy,
@ -49,7 +49,7 @@ export function toCycleDayLog(local: LocalCycleDayLog): CycleDayLog {
};
}
export function toCycleSymptom(local: LocalCycleSymptom): CycleSymptom {
export function toPeriodSymptom(local: LocalPeriodSymptom): PeriodSymptom {
return {
id: local.id,
name: local.name,
@ -63,65 +63,65 @@ export function toCycleSymptom(local: LocalCycleSymptom): CycleSymptom {
// ─── Live Queries ──────────────────────────────────────────
export function useAllCycles() {
export function useAllPeriods() {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalCycle>('cycles').toArray()).filter(
const visible = (await db.table<LocalPeriod>('periods').toArray()).filter(
(c) => !c.deletedAt && !c.isArchived
);
const decrypted = await decryptRecords('cycles', visible);
return decrypted.map(toCycle).sort((a, b) => b.startDate.localeCompare(a.startDate));
}, [] as Cycle[]);
const decrypted = await decryptRecords('periods', visible);
return decrypted.map(toPeriod).sort((a, b) => b.startDate.localeCompare(a.startDate));
}, [] as Period[]);
}
export function useCurrentCycle() {
export function useCurrentPeriod() {
return useLiveQueryWithDefault(
async () => {
const locals = await db.table<LocalCycle>('cycles').toArray();
const locals = await db.table<LocalPeriod>('periods').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];
const decrypted = await decryptRecord('cycles', { ...latest });
return toCycle(decrypted);
const decrypted = await decryptRecord('periods', { ...latest });
return toPeriod(decrypted);
},
null as Cycle | null
null as Period | null
);
}
export function useAllDayLogs() {
return useLiveQueryWithDefault(async () => {
const visible = (await db.table<LocalCycleDayLog>('cycleDayLogs').toArray()).filter(
const visible = (await db.table<LocalPeriodDayLog>('periodDayLogs').toArray()).filter(
(l) => !l.deletedAt
);
const decrypted = await decryptRecords('cycleDayLogs', visible);
return decrypted.map(toCycleDayLog).sort((a, b) => b.logDate.localeCompare(a.logDate));
}, [] as CycleDayLog[]);
const decrypted = await decryptRecords('periodDayLogs', visible);
return decrypted.map(toPeriodDayLog).sort((a, b) => b.logDate.localeCompare(a.logDate));
}, [] as PeriodDayLog[]);
}
export function useDayLog(date: string) {
return useLiveQueryWithDefault(
async () => {
const locals = await db
.table<LocalCycleDayLog>('cycleDayLogs')
.table<LocalPeriodDayLog>('periodDayLogs')
.where('logDate')
.equals(date)
.toArray();
const active = locals.find((l) => !l.deletedAt);
if (!active) return null;
const decrypted = await decryptRecord('cycleDayLogs', { ...active });
return toCycleDayLog(decrypted);
const decrypted = await decryptRecord('periodDayLogs', { ...active });
return toPeriodDayLog(decrypted);
},
null as CycleDayLog | null
null as PeriodDayLog | null
);
}
export function useAllSymptoms() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalCycleSymptom>('cycleSymptoms').toArray();
const locals = await db.table<LocalPeriodSymptom>('periodSymptoms').toArray();
return locals
.filter((s) => !s.deletedAt)
.map(toCycleSymptom)
.map(toPeriodSymptom)
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
}, [] as CycleSymptom[]);
}, [] as PeriodSymptom[]);
}
// ─── Pure Helpers ──────────────────────────────────────────
@ -136,10 +136,10 @@ export interface RelativeDateLabels {
/** Group day logs by localized month label. */
export function groupLogsByMonth(
logs: CycleDayLog[],
logs: PeriodDayLog[],
dateLocale: string = 'de-DE'
): Array<{ label: string; logs: CycleDayLog[] }> {
const groups = new Map<string, CycleDayLog[]>();
): Array<{ label: string; logs: PeriodDayLog[] }> {
const groups = new Map<string, PeriodDayLog[]>();
for (const l of logs) {
const date = new Date(l.logDate);
const label = date.toLocaleDateString(dateLocale, { month: 'long', year: 'numeric' });

View file

@ -1,24 +1,24 @@
/**
* Day Logs Store Mutation-Only Service for daily cycle entries.
* Day Logs Store Mutation-Only Service for daily period entries.
*/
import { cycleDayLogTable, cycleTable } from '../collections';
import { toCycle, toCycleDayLog } from '../queries';
import { detectPeriodEnd, shouldStartNewCycle } from '../utils/auto-detect';
import { cyclesStore } from './cycles.svelte';
import { periodDayLogTable, periodTable } from '../collections';
import { toPeriod, toPeriodDayLog } from '../queries';
import { detectPeriodEnd, shouldStartNewPeriod } from '../utils/auto-detect';
import { periodsStore } from './periods.svelte';
import { symptomsStore } from './symptoms.svelte';
import { encryptRecord } from '$lib/data/crypto';
import type { CervicalMucus, Flow, LocalCycle, LocalCycleDayLog, Mood } from '../types';
import type { CervicalMucus, Flow, LocalPeriod, LocalPeriodDayLog, 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<string | null> {
const all = await cycleTable.toArray();
async function resolvePeriodId(date: string): Promise<string | null> {
const all = await periodTable.toArray();
const candidates = all
.filter((c: LocalCycle) => !c.deletedAt && !c.isPredicted && c.startDate <= date)
.filter((c: LocalPeriod) => !c.deletedAt && !c.isPredicted && c.startDate <= date)
.sort((a, b) => b.startDate.localeCompare(a.startDate));
return candidates[0]?.id ?? null;
}
@ -42,18 +42,18 @@ export const dayLogsStore = {
// ─ Auto-Start: explizites flow + Bedingungen erfüllt → neuen Zyklus VOR dem Schreiben anlegen
if (data.flow !== undefined) {
const allCycles = await cycleTable.toArray();
const visibleCycles = allCycles.filter((c) => !c.deletedAt).map(toCycle);
if (shouldStartNewCycle(logDate, data.flow, visibleCycles)) {
await cyclesStore.createCycle({ startDate: logDate });
const allPeriods = await periodTable.toArray();
const visiblePeriods = allPeriods.filter((c) => !c.deletedAt).map(toPeriod);
if (shouldStartNewPeriod(logDate, data.flow, visiblePeriods)) {
await periodsStore.createPeriod({ startDate: logDate });
}
}
const existing = (await cycleDayLogTable.where('logDate').equals(logDate).toArray()).find(
const existing = (await periodDayLogTable.where('logDate').equals(logDate).toArray()).find(
(l) => !l.deletedAt
);
let result: LocalCycleDayLog;
let result: LocalPeriodDayLog;
if (existing) {
// Symptom-Counter aktualisieren.
if (data.symptoms) {
@ -64,22 +64,22 @@ export const dayLogsStore = {
if (added.length) await symptomsStore.touchSymptoms(added, +1);
if (removed.length) await symptomsStore.touchSymptoms(removed, -1);
}
const updateDiff: Partial<LocalCycleDayLog> = {
const updateDiff: Partial<LocalPeriodDayLog> = {
...data,
logDate,
updatedAt: new Date().toISOString(),
};
await encryptRecord('cycleDayLogs', updateDiff);
await cycleDayLogTable.update(existing.id, updateDiff);
await encryptRecord('periodDayLogs', updateDiff);
await periodDayLogTable.update(existing.id, updateDiff);
// `result` keeps the plaintext for the return value — caller
// expects to render the input back.
result = { ...existing, ...data, logDate };
} else {
const cycleId = await resolveCycleId(logDate);
const newLocal: LocalCycleDayLog = {
const periodId = await resolvePeriodId(logDate);
const newLocal: LocalPeriodDayLog = {
id: crypto.randomUUID(),
logDate,
cycleId,
periodId,
flow: data.flow ?? 'none',
mood: data.mood ?? null,
energy: data.energy ?? null,
@ -92,52 +92,52 @@ export const dayLogsStore = {
// Plaintext copy retained for the return value — what we
// write to disk is encrypted.
result = { ...newLocal };
await encryptRecord('cycleDayLogs', newLocal);
await cycleDayLogTable.add(newLocal);
await encryptRecord('periodDayLogs', newLocal);
await periodDayLogTable.add(newLocal);
if (result.symptoms.length) {
await symptomsStore.touchSymptoms(result.symptoms, +1);
}
}
// ─ Auto-End: Wenn explizit 'none' geloggt wurde, prüfe ob die Periode beendet werden soll
if (data.flow === 'none' && result.cycleId) {
const openCycleLocal = await cycleTable.get(result.cycleId);
if (openCycleLocal && !openCycleLocal.deletedAt && !openCycleLocal.periodEndDate) {
const cycleLogsLocal = await cycleDayLogTable
.where('cycleId')
.equals(result.cycleId)
if (data.flow === 'none' && result.periodId) {
const openPeriodLocal = await periodTable.get(result.periodId);
if (openPeriodLocal && !openPeriodLocal.deletedAt && !openPeriodLocal.periodEndDate) {
const periodLogsLocal = await periodDayLogTable
.where('periodId')
.equals(result.periodId)
.toArray();
const cycleLogs = cycleLogsLocal.filter((l) => !l.deletedAt).map(toCycleDayLog);
const endDate = detectPeriodEnd(logDate, 'none', toCycle(openCycleLocal), cycleLogs);
const periodLogs = periodLogsLocal.filter((l) => !l.deletedAt).map(toPeriodDayLog);
const endDate = detectPeriodEnd(logDate, 'none', toPeriod(openPeriodLocal), periodLogs);
if (endDate) {
await cyclesStore.setPeriodEnd(openCycleLocal.id, endDate);
await periodsStore.setPeriodEnd(openPeriodLocal.id, endDate);
}
}
}
return toCycleDayLog(result);
return toPeriodDayLog(result);
},
async deleteLog(id: string) {
const existing = await cycleDayLogTable.get(id);
const existing = await periodDayLogTable.get(id);
if (existing?.symptoms?.length) {
await symptomsStore.touchSymptoms(existing.symptoms, -1);
}
await cycleDayLogTable.update(id, {
await periodDayLogTable.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();
async autoAssignPeriod() {
const logs = await periodDayLogTable.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,
if (log.periodId || log.deletedAt) continue;
const periodId = await resolvePeriodId(log.logDate);
if (periodId) {
await periodDayLogTable.update(log.id, {
periodId,
updatedAt: new Date().toISOString(),
});
}

View file

@ -1,10 +1,10 @@
/**
* Integration tests for cycles stores against a real (fake) IndexedDB.
* Integration tests for periods stores against a real (fake) IndexedDB.
*
* Covers the complex interactions that pure-function tests cannot:
* - cyclesStore auto-closes the previous open cycle when a new one starts
* - periodsStore auto-closes the previous open period when a new one starts
* - dayLogsStore upserts per date (no duplicates)
* - dayLogsStore auto-creates a cycle on first bleeding log
* - dayLogsStore auto-creates a period on first bleeding log
* - dayLogsStore auto-sets periodEndDate after 2 dry days
* - symptomsStore.touchSymptoms increments/decrements ref counts
* - dayLogsStore updates symptom counters when symptoms change on an existing log
@ -27,19 +27,19 @@ import {
setKeyProvider,
decryptRecords,
} from '$lib/data/crypto';
import { cyclesStore } from './cycles.svelte';
import { periodsStore } from './periods.svelte';
import { dayLogsStore } from './dayLogs.svelte';
import { symptomsStore } from './symptoms.svelte';
import type { LocalCycle, LocalCycleDayLog, LocalCycleSymptom } from '../types';
import type { LocalPeriod, LocalPeriodDayLog, LocalPeriodSymptom } from '../types';
const cycleTable = () => db.table<LocalCycle>('cycles');
const dayLogTable = () => db.table<LocalCycleDayLog>('cycleDayLogs');
const symptomTable = () => db.table<LocalCycleSymptom>('cycleSymptoms');
const periodTable = () => db.table<LocalPeriod>('periods');
const dayLogTable = () => db.table<LocalPeriodDayLog>('periodDayLogs');
const symptomTable = () => db.table<LocalPeriodSymptom>('periodSymptoms');
const iso = (dateStr: string) => dateStr; // alias for readability
async function resetCyclesTables() {
await cycleTable().clear();
async function resetPeriodsTables() {
await periodTable().clear();
await dayLogTable().clear();
await symptomTable().clear();
await db.table('_pendingChanges').clear();
@ -48,65 +48,65 @@ async function resetCyclesTables() {
beforeEach(async () => {
setCurrentUserId('test-user');
// Phase 5 cycles encryption requires an unlocked vault — install a
// Phase 5 periods encryption requires an unlocked vault — install a
// real Web Crypto key in a fresh MemoryKeyProvider for each test
// run so the dayLogsStore.logDay calls below can encrypt notes/mood.
const key = await generateMasterKey();
const provider = new MemoryKeyProvider();
provider.setKey(key);
setKeyProvider(provider);
await resetCyclesTables();
await resetPeriodsTables();
});
describe('cyclesStore.createCycle', () => {
it('creates a single open cycle when none exists', async () => {
const cycle = await cyclesStore.createCycle({ startDate: iso('2026-01-01') });
expect(cycle.startDate).toBe('2026-01-01');
expect(cycle.endDate).toBeNull();
expect(cycle.length).toBeNull();
describe('periodsStore.createPeriod', () => {
it('creates a single open period when none exists', async () => {
const period = await periodsStore.createPeriod({ startDate: iso('2026-01-01') });
expect(period.startDate).toBe('2026-01-01');
expect(period.endDate).toBeNull();
expect(period.length).toBeNull();
const stored = await cycleTable().toArray();
const stored = await periodTable().toArray();
expect(stored).toHaveLength(1);
expect(stored[0].id).toBe(cycle.id);
expect(stored[0].id).toBe(period.id);
});
it('auto-closes the previous open cycle and computes length', async () => {
const first = await cyclesStore.createCycle({ startDate: iso('2026-01-01') });
await cyclesStore.createCycle({ startDate: iso('2026-01-29') });
it('auto-closes the previous open period and computes length', async () => {
const first = await periodsStore.createPeriod({ startDate: iso('2026-01-01') });
await periodsStore.createPeriod({ startDate: iso('2026-01-29') });
const firstStored = await cycleTable().get(first.id);
const firstStored = await periodTable().get(first.id);
expect(firstStored?.endDate).toBe('2026-01-28'); // day before new start
expect(firstStored?.length).toBe(28);
});
it('does not touch cycles whose startDate is >= the new cycle', async () => {
// Backfilling an older cycle should NOT close a future one
const future = await cyclesStore.createCycle({ startDate: iso('2026-03-01') });
await cyclesStore.createCycle({ startDate: iso('2026-02-01') });
it('does not touch periods whose startDate is >= the new period', async () => {
// Backfilling an older period should NOT close a future one
const future = await periodsStore.createPeriod({ startDate: iso('2026-03-01') });
await periodsStore.createPeriod({ startDate: iso('2026-02-01') });
const futureStored = await cycleTable().get(future.id);
const futureStored = await periodTable().get(future.id);
expect(futureStored?.endDate).toBeNull();
expect(futureStored?.length).toBeNull();
});
});
describe('cyclesStore.setPeriodEnd', () => {
describe('periodsStore.setPeriodEnd', () => {
it('sets periodEndDate without affecting endDate', async () => {
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
await cyclesStore.setPeriodEnd(c.id, '2026-04-05');
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await periodsStore.setPeriodEnd(c.id, '2026-04-05');
const stored = await cycleTable().get(c.id);
const stored = await periodTable().get(c.id);
expect(stored?.periodEndDate).toBe('2026-04-05');
expect(stored?.endDate).toBeNull();
});
});
describe('cyclesStore.deleteCycle', () => {
describe('periodsStore.deletePeriod', () => {
it('soft-deletes via deletedAt', async () => {
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
await cyclesStore.deleteCycle(c.id);
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await periodsStore.deletePeriod(c.id);
const stored = await cycleTable().get(c.id);
const stored = await periodTable().get(c.id);
expect(stored?.deletedAt).toBeTruthy();
});
});
@ -129,110 +129,110 @@ describe('dayLogsStore.logDay — upsert behavior', () => {
// Phase 5: `mood` is encrypted on disk — decrypt before asserting
// so the test reads the same view the UI does.
const raw = (await dayLogTable().toArray()).filter((l) => !l.deletedAt);
const logs = await decryptRecords<LocalCycleDayLog>('cycleDayLogs', raw);
const logs = await decryptRecords<LocalPeriodDayLog>('periodDayLogs', raw);
expect(logs).toHaveLength(1);
expect(logs[0].flow).toBe('light');
expect(logs[0].mood).toBe('good');
expect(logs[0].temperature).toBe(36.6);
});
it('assigns cycleId when a matching cycle exists', async () => {
await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
it('assigns periodId when a matching period exists', async () => {
await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await dayLogsStore.logDay({ logDate: '2026-04-05', mood: 'good' });
const log = (await dayLogTable().toArray())[0];
expect(log.cycleId).toBeTruthy();
expect(log.periodId).toBeTruthy();
});
it('leaves cycleId null when no cycle covers the date', async () => {
it('leaves periodId null when no period covers the date', async () => {
await dayLogsStore.logDay({ logDate: '2026-04-07', mood: 'good' });
const log = (await dayLogTable().toArray())[0];
expect(log.cycleId).toBeNull();
expect(log.periodId).toBeNull();
});
});
describe('dayLogsStore.logDay — auto-start cycle', () => {
it('creates a new cycle on first bleeding log with no history', async () => {
describe('dayLogsStore.logDay — auto-start period', () => {
it('creates a new period on first bleeding log with no history', async () => {
await dayLogsStore.logDay({ logDate: '2026-04-07', flow: 'medium' });
const cycles = await cycleTable().toArray();
expect(cycles).toHaveLength(1);
expect(cycles[0].startDate).toBe('2026-04-07');
const periods = await periodTable().toArray();
expect(periods).toHaveLength(1);
expect(periods[0].startDate).toBe('2026-04-07');
// And the log itself is attached to that cycle
// And the log itself is attached to that period
const log = (await dayLogTable().toArray())[0];
expect(log.cycleId).toBe(cycles[0].id);
expect(log.periodId).toBe(periods[0].id);
});
it('does NOT create a new cycle for spotting', async () => {
it('does NOT create a new period for spotting', async () => {
await dayLogsStore.logDay({ logDate: '2026-04-07', flow: 'spotting' });
const cycles = await cycleTable().toArray();
expect(cycles).toHaveLength(0);
const periods = await periodTable().toArray();
expect(periods).toHaveLength(0);
});
it('does NOT create a new cycle during an open cycle', async () => {
await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
// Mid-cycle bleeding should NOT spawn a second cycle
it('does NOT create a new period during an open period', async () => {
await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
// Mid-period bleeding should NOT spawn a second period
await dayLogsStore.logDay({ logDate: '2026-04-10', flow: 'medium' });
const cycles = await cycleTable().toArray();
expect(cycles).toHaveLength(1);
const periods = await periodTable().toArray();
expect(periods).toHaveLength(1);
});
it('creates a new cycle when the previous is closed and far enough apart', async () => {
const first = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
await cyclesStore.setPeriodEnd(first.id, '2026-04-05');
it('creates a new period when the previous is closed and far enough apart', async () => {
const first = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await periodsStore.setPeriodEnd(first.id, '2026-04-05');
// 15 days after periodEndDate — well beyond MIN_GAP (10)
await dayLogsStore.logDay({ logDate: '2026-04-20', flow: 'medium' });
const cycles = (await cycleTable().toArray()).filter((c) => !c.deletedAt);
expect(cycles).toHaveLength(2);
const periods = (await periodTable().toArray()).filter((c) => !c.deletedAt);
expect(periods).toHaveLength(2);
});
it('does NOT create a new cycle if bleeding is too soon after period end', async () => {
const first = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
await cyclesStore.setPeriodEnd(first.id, '2026-04-05');
it('does NOT create a new period if bleeding is too soon after period end', async () => {
const first = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await periodsStore.setPeriodEnd(first.id, '2026-04-05');
// Only 8 days after — treated as mid-cycle spotting
// Only 8 days after — treated as mid-period spotting
await dayLogsStore.logDay({ logDate: '2026-04-13', flow: 'medium' });
const cycles = (await cycleTable().toArray()).filter((c) => !c.deletedAt);
expect(cycles).toHaveLength(1);
const periods = (await periodTable().toArray()).filter((c) => !c.deletedAt);
expect(periods).toHaveLength(1);
});
});
describe('dayLogsStore.logDay — auto-end period', () => {
it('sets periodEndDate after 2 dry days following bleeding', async () => {
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
await dayLogsStore.logDay({ logDate: '2026-04-02', flow: 'medium' });
await dayLogsStore.logDay({ logDate: '2026-04-03', flow: 'light' });
await dayLogsStore.logDay({ logDate: '2026-04-04', flow: 'none' });
await dayLogsStore.logDay({ logDate: '2026-04-05', flow: 'none' });
const stored = await cycleTable().get(c.id);
const stored = await periodTable().get(c.id);
expect(stored?.periodEndDate).toBe('2026-04-03');
});
it('does NOT set periodEndDate after only 1 dry day', async () => {
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
await dayLogsStore.logDay({ logDate: '2026-04-02', flow: 'none' });
const stored = await cycleTable().get(c.id);
const stored = await periodTable().get(c.id);
expect(stored?.periodEndDate).toBeNull();
});
it('does not overwrite an already-set periodEndDate', async () => {
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
await cyclesStore.setPeriodEnd(c.id, '2026-04-03');
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await periodsStore.setPeriodEnd(c.id, '2026-04-03');
// Logging more none days should not re-trigger
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
await dayLogsStore.logDay({ logDate: '2026-04-10', flow: 'none' });
const stored = await cycleTable().get(c.id);
const stored = await periodTable().get(c.id);
expect(stored?.periodEndDate).toBe('2026-04-03');
});
});
@ -296,18 +296,18 @@ describe('dayLogsStore.logDay — symptom counter integration', () => {
});
});
describe('dayLogsStore.autoAssignCycle', () => {
it('retroactively attaches orphan logs to the right cycle', async () => {
// Log something before any cycle exists
describe('dayLogsStore.autoAssignPeriod', () => {
it('retroactively attaches orphan logs to the right period', async () => {
// Log something before any period exists
await dayLogsStore.logDay({ logDate: '2026-04-07', mood: 'good' });
const orphan = (await dayLogTable().toArray())[0];
expect(orphan.cycleId).toBeNull();
expect(orphan.periodId).toBeNull();
// Now create a cycle that should claim that day
const cycle = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
await dayLogsStore.autoAssignCycle();
// Now create a period that should claim that day
const period = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
await dayLogsStore.autoAssignPeriod();
const reattached = await dayLogTable().get(orphan.id);
expect(reattached?.cycleId).toBe(cycle.id);
expect(reattached?.periodId).toBe(period.id);
});
});

View file

@ -1,14 +1,14 @@
/**
* Cycles Store Mutation-Only Service for menstrual cycles.
* Periods Store Mutation-Only Service for menstrual periods.
*/
import { cycleTable } from '../collections';
import { toCycle } from '../queries';
import { periodTable } from '../collections';
import { toPeriod } from '../queries';
import { daysBetween } from '../utils/phase';
import { encryptRecord } from '$lib/data/crypto';
import { emitDomainEvent } from '$lib/data/events';
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
import type { LocalCycle } from '../types';
import type { LocalPeriod } from '../types';
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
@ -20,13 +20,13 @@ function dayBefore(iso: string): string {
return d.toISOString().slice(0, 10);
}
export const cyclesStore = {
export const periodsStore = {
/** Startet einen neuen Zyklus. Schließt automatisch den vorigen offenen Zyklus. */
async createCycle(data: { startDate?: string; notes?: string | null }) {
async createPeriod(data: { startDate?: string; notes?: string | null }) {
const startDate = data.startDate ?? todayIsoDate();
// Vorigen offenen Zyklus schließen.
const all = await cycleTable.toArray();
const all = await periodTable.toArray();
const open = all
.filter((c) => !c.deletedAt && !c.isPredicted && c.endDate === null)
.sort((a, b) => b.startDate.localeCompare(a.startDate));
@ -34,7 +34,7 @@ export const cyclesStore = {
if (prev.startDate >= startDate) continue;
const endDate = dayBefore(startDate);
const length = daysBetween(startDate, prev.startDate);
await cycleTable.update(prev.id, {
await periodTable.update(prev.id, {
endDate,
length,
updatedAt: new Date().toISOString(),
@ -42,21 +42,21 @@ export const cyclesStore = {
}
// Create a TimeBlock for the menstruation phase (allDay, open-ended until periodEnd is set)
const cycleId = crypto.randomUUID();
const periodId = crypto.randomUUID();
const timeBlockId = await createBlock({
startDate: `${startDate}T00:00:00.000Z`,
endDate: null,
allDay: true,
kind: 'logged',
type: 'cycle',
sourceModule: 'cycles',
sourceId: cycleId,
type: 'period',
sourceModule: 'period',
sourceId: periodId,
title: 'Periode',
color: '#ec4899',
});
const newLocal: LocalCycle = {
id: cycleId,
const newLocal: LocalPeriod = {
id: periodId,
startDate,
periodEndDate: null,
endDate: null,
@ -66,10 +66,10 @@ export const cyclesStore = {
notes: data.notes ?? null,
timeBlockId,
};
const plaintextSnapshot = toCycle(newLocal);
await encryptRecord('cycles', newLocal);
await cycleTable.add(newLocal);
emitDomainEvent('CycleDayLogged', 'cycles', 'cycleDayLogs', newLocal.id, {
const plaintextSnapshot = toPeriod(newLocal);
await encryptRecord('periods', newLocal);
await periodTable.add(newLocal);
emitDomainEvent('PeriodDayLogged', 'period', 'periodDayLogs', newLocal.id, {
logId: newLocal.id,
date: newLocal.startDate,
flow: null,
@ -77,51 +77,51 @@ export const cyclesStore = {
return plaintextSnapshot;
},
async updateCycle(
async updatePeriod(
id: string,
data: Partial<
Pick<
LocalCycle,
LocalPeriod,
'startDate' | 'periodEndDate' | 'endDate' | 'length' | 'notes' | 'isArchived'
>
>
) {
const diff: Partial<LocalCycle> = {
const diff: Partial<LocalPeriod> = {
...data,
updatedAt: new Date().toISOString(),
};
await encryptRecord('cycles', diff);
await cycleTable.update(id, diff);
await encryptRecord('periods', diff);
await periodTable.update(id, diff);
},
/** Markiert das Ende der Blutung (nicht das Ende des Zyklus). */
async setPeriodEnd(id: string, periodEndDate: string | null) {
const cycle = await cycleTable.get(id);
await cycleTable.update(id, {
const period = await periodTable.get(id);
await periodTable.update(id, {
periodEndDate,
updatedAt: new Date().toISOString(),
});
// Update the TimeBlock's endDate to reflect the period duration
if (cycle?.timeBlockId && periodEndDate) {
await updateBlock(cycle.timeBlockId, {
if (period?.timeBlockId && periodEndDate) {
await updateBlock(period.timeBlockId, {
endDate: `${periodEndDate}T23:59:59.999Z`,
});
}
},
async deleteCycle(id: string) {
const cycle = await cycleTable.get(id);
if (cycle?.timeBlockId) {
await deleteBlock(cycle.timeBlockId);
async deletePeriod(id: string) {
const period = await periodTable.get(id);
if (period?.timeBlockId) {
await deleteBlock(period.timeBlockId);
}
await cycleTable.update(id, {
await periodTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async archiveCycle(id: string) {
await cycleTable.update(id, {
async archivePeriod(id: string) {
await periodTable.update(id, {
isArchived: true,
updatedAt: new Date().toISOString(),
});

View file

@ -1,35 +1,35 @@
/**
* Symptoms Store Mutation-Only Service for cycle symptom taxonomy.
* Symptoms Store Mutation-Only Service for period symptom taxonomy.
*/
import { cycleSymptomTable } from '../collections';
import type { LocalCycleSymptom, SymptomCategory } from '../types';
import { periodSymptomTable } from '../collections';
import type { LocalPeriodSymptom, SymptomCategory } from '../types';
export const symptomsStore = {
async createSymptom(data: { name: string; category?: SymptomCategory; color?: string | null }) {
const newLocal: LocalCycleSymptom = {
const newLocal: LocalPeriodSymptom = {
id: crypto.randomUUID(),
name: data.name.trim(),
category: data.category ?? 'physical',
color: data.color ?? null,
count: 0,
};
await cycleSymptomTable.add(newLocal);
await periodSymptomTable.add(newLocal);
return newLocal;
},
async updateSymptom(
id: string,
data: Partial<Pick<LocalCycleSymptom, 'name' | 'category' | 'color'>>
data: Partial<Pick<LocalPeriodSymptom, 'name' | 'category' | 'color'>>
) {
await cycleSymptomTable.update(id, {
await periodSymptomTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async deleteSymptom(id: string) {
await cycleSymptomTable.update(id, {
await periodSymptomTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
@ -38,10 +38,10 @@ export const symptomsStore = {
/** 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);
const existing = await periodSymptomTable.get(id);
if (!existing) continue;
const next = Math.max(0, (existing.count ?? 0) + delta);
await cycleSymptomTable.update(id, {
await periodSymptomTable.update(id, {
count: next,
updatedAt: new Date().toISOString(),
});

View file

@ -1,8 +1,8 @@
import type { ModuleTool } from '$lib/data/tools/types';
export const cyclesTools: ModuleTool[] = [
export const periodTools: ModuleTool[] = [
{
name: 'log_cycle_day',
module: 'cycles',
name: 'log_period_day',
module: 'period',
description: 'Loggt einen Zyklus-Tag (Menstruationszyklus)',
parameters: [
{
@ -14,8 +14,8 @@ export const cyclesTools: ModuleTool[] = [
},
],
async execute(params) {
const { cyclesStore } = await import('./stores/cycles.svelte');
const entry = await cyclesStore.createCycle({});
const { periodsStore } = await import('./stores/periods.svelte');
const entry = await periodsStore.createPeriod({});
return { success: true, data: entry, message: 'Zyklus-Tag geloggt' };
},
},

View file

@ -1,5 +1,5 @@
/**
* Cycles module types Menstruationszyklus-Tracking.
* Periods module types Menstruationszyklus-Tracking.
*/
import type { BaseRecord } from '@mana/local-store';
@ -8,11 +8,11 @@ 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';
export type PeriodPhase = 'menstruation' | 'follicular' | 'ovulation' | 'luteal' | 'unknown';
// ─── Local Record Types (Dexie) ───────────────────────────
export interface LocalCycle extends BaseRecord {
export interface LocalPeriod 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)
@ -23,20 +23,20 @@ export interface LocalCycle extends BaseRecord {
timeBlockId?: string | null; // link to timeBlocks table (menstruation phase)
}
export interface LocalCycleDayLog extends BaseRecord {
export interface LocalPeriodDayLog extends BaseRecord {
logDate: string; // ISO YYYY-MM-DD
cycleId: string | null;
periodId: 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
symptoms: string[]; // periodSymptom.id refs
sexualActivity: boolean | null;
notes: string | null;
}
export interface LocalCycleSymptom extends BaseRecord {
export interface LocalPeriodSymptom extends BaseRecord {
name: string;
category: SymptomCategory;
color: string | null;
@ -45,7 +45,7 @@ export interface LocalCycleSymptom extends BaseRecord {
// ─── Domain Types ─────────────────────────────────────────
export interface Cycle {
export interface Period {
id: string;
startDate: string;
periodEndDate: string | null;
@ -58,10 +58,10 @@ export interface Cycle {
updatedAt: string;
}
export interface CycleDayLog {
export interface PeriodDayLog {
id: string;
logDate: string;
cycleId: string | null;
periodId: string | null;
flow: Flow;
mood: Mood | null;
energy: number | null;
@ -74,7 +74,7 @@ export interface CycleDayLog {
updatedAt: string;
}
export interface CycleSymptom {
export interface PeriodSymptom {
id: string;
name: string;
category: SymptomCategory;
@ -118,7 +118,7 @@ export const MOOD_COLORS: Record<Mood, string> = {
bad: '#ef4444',
};
export const PHASE_LABELS: Record<CyclePhase, string> = {
export const PHASE_LABELS: Record<PeriodPhase, string> = {
menstruation: 'Menstruation',
follicular: 'Follikelphase',
ovulation: 'Eisprung',
@ -126,7 +126,7 @@ export const PHASE_LABELS: Record<CyclePhase, string> = {
unknown: 'Unbekannt',
};
export const PHASE_COLORS: Record<CyclePhase, string> = {
export const PHASE_COLORS: Record<PeriodPhase, string> = {
menstruation: '#e11d48',
follicular: '#f59e0b',
ovulation: '#22c55e',
@ -142,6 +142,6 @@ export const CERVICAL_MUCUS_LABELS: Record<CervicalMucus, string> = {
eggwhite: 'Eiweiß',
};
export const DEFAULT_CYCLE_LENGTH = 28;
export const DEFAULT_PERIOD_LENGTH = 5;
export const DEFAULT_PERIOD_LENGTH = 28;
export const DEFAULT_BLEEDING_DAYS = 5;
export const DEFAULT_LUTEAL_LENGTH = 14;

View file

@ -3,12 +3,12 @@ import {
detectPeriodEnd,
DRY_DAYS_FOR_PERIOD_END,
isBleedingFlow,
MIN_GAP_FOR_NEW_CYCLE,
shouldStartNewCycle,
MIN_GAP_FOR_NEW_PERIOD,
shouldStartNewPeriod,
} from './auto-detect';
import type { Cycle, CycleDayLog, Flow } from '../types';
import type { Period, PeriodDayLog, Flow } from '../types';
function makeCycle(overrides: Partial<Cycle>): Cycle {
function makePeriod(overrides: Partial<Period>): Period {
return {
id: 'c',
startDate: '2026-01-01',
@ -24,11 +24,11 @@ function makeCycle(overrides: Partial<Cycle>): Cycle {
};
}
function makeLog(logDate: string, flow: Flow): CycleDayLog {
function makeLog(logDate: string, flow: Flow): PeriodDayLog {
return {
id: `log-${logDate}`,
logDate,
cycleId: 'c',
periodId: 'c',
flow,
mood: null,
energy: null,
@ -54,76 +54,76 @@ describe('isBleedingFlow', () => {
});
});
describe('shouldStartNewCycle', () => {
describe('shouldStartNewPeriod', () => {
it('returns false for non-bleeding flow', () => {
expect(shouldStartNewCycle('2026-04-07', 'none', [])).toBe(false);
expect(shouldStartNewCycle('2026-04-07', 'spotting', [])).toBe(false);
expect(shouldStartNewPeriod('2026-04-07', 'none', [])).toBe(false);
expect(shouldStartNewPeriod('2026-04-07', 'spotting', [])).toBe(false);
});
it('returns true with no existing cycles and bleeding flow', () => {
expect(shouldStartNewCycle('2026-04-07', 'medium', [])).toBe(true);
it('returns true with no existing periods and bleeding flow', () => {
expect(shouldStartNewPeriod('2026-04-07', 'medium', [])).toBe(true);
});
it('returns false when current cycle is still open (no periodEndDate)', () => {
const cycles = [makeCycle({ startDate: '2026-04-01' })];
// flow during the open period — not a new cycle
expect(shouldStartNewCycle('2026-04-03', 'heavy', cycles)).toBe(false);
it('returns false when current period is still open (no periodEndDate)', () => {
const periods = [makePeriod({ startDate: '2026-04-01' })];
// flow during the open period — not a new period
expect(shouldStartNewPeriod('2026-04-03', 'heavy', periods)).toBe(false);
});
it('returns false when bleed is too soon after period end', () => {
const cycles = [makeCycle({ startDate: '2026-04-01', periodEndDate: '2026-04-05' })];
// 9 days after periodEndDate — too soon, probably mid-cycle bleeding
expect(shouldStartNewCycle('2026-04-14', 'medium', cycles)).toBe(false);
const periods = [makePeriod({ startDate: '2026-04-01', periodEndDate: '2026-04-05' })];
// 9 days after periodEndDate — too soon, probably mid-period bleeding
expect(shouldStartNewPeriod('2026-04-14', 'medium', periods)).toBe(false);
});
it('returns true when bleed is at least MIN_GAP days after period end', () => {
const cycles = [makeCycle({ startDate: '2026-04-01', periodEndDate: '2026-04-05' })];
const periods = [makePeriod({ startDate: '2026-04-01', periodEndDate: '2026-04-05' })];
const newDate = '2026-04-15'; // 10 days after
expect(daysGapForTest(newDate, '2026-04-05')).toBe(MIN_GAP_FOR_NEW_CYCLE);
expect(shouldStartNewCycle(newDate, 'medium', cycles)).toBe(true);
expect(daysGapForTest(newDate, '2026-04-05')).toBe(MIN_GAP_FOR_NEW_PERIOD);
expect(shouldStartNewPeriod(newDate, 'medium', periods)).toBe(true);
});
it('ignores predicted cycles', () => {
const cycles = [
makeCycle({ id: 'real', startDate: '2026-01-01', periodEndDate: '2026-01-05' }),
makeCycle({ id: 'pred', startDate: '2026-04-01', isPredicted: true }),
it('ignores predicted periods', () => {
const periods = [
makePeriod({ id: 'real', startDate: '2026-01-01', periodEndDate: '2026-01-05' }),
makePeriod({ id: 'pred', startDate: '2026-04-01', isPredicted: true }),
];
// 2026-04-15 → with the real cycle far in the past, should start new
expect(shouldStartNewCycle('2026-04-15', 'medium', cycles)).toBe(true);
// 2026-04-15 → with the real period far in the past, should start new
expect(shouldStartNewPeriod('2026-04-15', 'medium', periods)).toBe(true);
});
it('returns false for date before the latest cycle', () => {
const cycles = [makeCycle({ startDate: '2026-04-01', periodEndDate: '2026-04-05' })];
it('returns false for date before the latest period', () => {
const periods = [makePeriod({ startDate: '2026-04-01', periodEndDate: '2026-04-05' })];
// Backfilling an old date should never auto-create
expect(shouldStartNewCycle('2026-03-10', 'medium', cycles)).toBe(false);
expect(shouldStartNewPeriod('2026-03-10', 'medium', periods)).toBe(false);
});
});
describe('detectPeriodEnd', () => {
const openCycle = makeCycle({ id: 'c', startDate: '2026-04-01' });
const openPeriod = makePeriod({ id: 'c', startDate: '2026-04-01' });
it('returns null for non-none flow', () => {
expect(detectPeriodEnd('2026-04-07', 'light', openCycle, [])).toBeNull();
expect(detectPeriodEnd('2026-04-07', 'light', openPeriod, [])).toBeNull();
});
it('returns null without an open cycle', () => {
it('returns null without an open period', () => {
expect(detectPeriodEnd('2026-04-07', 'none', null, [])).toBeNull();
});
it('returns null when cycle already has periodEndDate', () => {
const closed = makeCycle({ id: 'c', startDate: '2026-04-01', periodEndDate: '2026-04-05' });
it('returns null when period already has periodEndDate', () => {
const closed = makePeriod({ id: 'c', startDate: '2026-04-01', periodEndDate: '2026-04-05' });
expect(detectPeriodEnd('2026-04-07', 'none', closed, [])).toBeNull();
});
it('returns null when no bleeding day exists in cycle', () => {
it('returns null when no bleeding day exists in period', () => {
const logs = [makeLog('2026-04-01', 'none'), makeLog('2026-04-02', 'none')];
expect(detectPeriodEnd('2026-04-07', 'none', openCycle, logs)).toBeNull();
expect(detectPeriodEnd('2026-04-07', 'none', openPeriod, logs)).toBeNull();
});
it('returns null when not enough dry days have passed', () => {
const logs = [makeLog('2026-04-04', 'medium')];
// logDate = 2026-04-05 → only 1 day after bleeding
expect(detectPeriodEnd('2026-04-05', 'none', openCycle, logs)).toBeNull();
expect(detectPeriodEnd('2026-04-05', 'none', openPeriod, logs)).toBeNull();
});
it('returns lastBleedingDay after DRY_DAYS_FOR_PERIOD_END', () => {
@ -135,7 +135,7 @@ describe('detectPeriodEnd', () => {
];
// logDate = 2026-04-06 → 2 days after last bleeding (04-04)
expect(daysGapForTest('2026-04-06', '2026-04-04')).toBe(DRY_DAYS_FOR_PERIOD_END);
expect(detectPeriodEnd('2026-04-06', 'none', openCycle, logs)).toBe('2026-04-04');
expect(detectPeriodEnd('2026-04-06', 'none', openPeriod, logs)).toBe('2026-04-04');
});
it('uses the LAST bleeding day, not the first', () => {
@ -144,7 +144,7 @@ describe('detectPeriodEnd', () => {
makeLog('2026-04-02', 'medium'),
makeLog('2026-04-03', 'light'),
];
expect(detectPeriodEnd('2026-04-05', 'none', openCycle, logs)).toBe('2026-04-03');
expect(detectPeriodEnd('2026-04-05', 'none', openPeriod, logs)).toBe('2026-04-03');
});
it('ignores logs after the current logDate (chronology safe)', () => {
@ -155,7 +155,7 @@ describe('detectPeriodEnd', () => {
makeLog('2026-04-10', 'medium'), // future log shouldn't affect detection for 04-03
];
// 2026-04-03 - 2026-04-02 = 1 day → not enough
expect(detectPeriodEnd('2026-04-03', 'none', openCycle, logs)).toBeNull();
expect(detectPeriodEnd('2026-04-03', 'none', openPeriod, logs)).toBeNull();
});
it('handles spotting as not bleeding (so spotting is not lastBleedingDay)', () => {
@ -164,7 +164,7 @@ describe('detectPeriodEnd', () => {
makeLog('2026-04-02', 'spotting'), // not counted as bleeding
];
// 2026-04-03 - 2026-04-01 = 2 → trigger, lastBleedingDay = 04-01
expect(detectPeriodEnd('2026-04-03', 'none', openCycle, logs)).toBe('2026-04-01');
expect(detectPeriodEnd('2026-04-03', 'none', openPeriod, logs)).toBe('2026-04-01');
});
});

View file

@ -9,7 +9,7 @@
* periodEndDate auf den letzten Bleeding-Tag.
*/
import type { Cycle, CycleDayLog, Flow } from '../types';
import type { Period, PeriodDayLog, Flow } from '../types';
import { daysBetween } from './phase';
/** Welche Flow-Werte zählen als "Blutung" (= Periode)? */
@ -18,7 +18,7 @@ export function isBleedingFlow(flow: Flow): boolean {
}
/** Mindestabstand (Tage) zwischen Ende einer Periode und Start eines neuen Zyklus. */
export const MIN_GAP_FOR_NEW_CYCLE = 10;
export const MIN_GAP_FOR_NEW_PERIOD = 10;
/** Wieviele zusammenhängende trockene Tage nach Bleeding für Period-End-Detection. */
export const DRY_DAYS_FOR_PERIOD_END = 2;
@ -28,14 +28,14 @@ export const DRY_DAYS_FOR_PERIOD_END = 2;
* Ja, wenn:
* - flow ist eine echte Blutung (nicht none/spotting), UND
* - es gibt keinen Zyklus, ODER der letzte Zyklus hat eine periodEndDate UND
* logDate liegt mindestens MIN_GAP_FOR_NEW_CYCLE Tage danach.
* logDate liegt mindestens MIN_GAP_FOR_NEW_PERIOD Tage danach.
*
* Verhindert false positives für Tage innerhalb eines bestehenden Zyklus.
*/
export function shouldStartNewCycle(logDate: string, flow: Flow, cycles: Cycle[]): boolean {
export function shouldStartNewPeriod(logDate: string, flow: Flow, periods: Period[]): boolean {
if (!isBleedingFlow(flow)) return false;
const real = cycles.filter((c) => !c.isPredicted && !c.isArchived);
const real = periods.filter((c) => !c.isPredicted && !c.isArchived);
if (real.length === 0) return true;
const latest = [...real].sort((a, b) => b.startDate.localeCompare(a.startDate))[0];
@ -43,11 +43,11 @@ export function shouldStartNewCycle(logDate: string, flow: Flow, cycles: Cycle[]
// logDate vor dem letzten Zyklus → wir bauen keinen "vergangenen" Zyklus auto
if (logDate < latest.startDate) return false;
// Aktueller Zyklus läuft noch — Blutung gehört dazu (Mid-Cycle-Spotting o.ä.)
// Aktueller Zyklus läuft noch — Blutung gehört dazu (Mid-Period-Spotting o.ä.)
if (!latest.periodEndDate) return false;
// Aktueller Zyklus ist abgeschlossen → wenn genug Abstand, ist das eine neue Periode
return daysBetween(logDate, latest.periodEndDate) >= MIN_GAP_FOR_NEW_CYCLE;
return daysBetween(logDate, latest.periodEndDate) >= MIN_GAP_FOR_NEW_PERIOD;
}
/**
@ -65,14 +65,14 @@ export function shouldStartNewCycle(logDate: string, flow: Flow, cycles: Cycle[]
export function detectPeriodEnd(
logDate: string,
flow: Flow,
openCycle: Cycle | null,
logsInCycle: CycleDayLog[]
openPeriod: Period | null,
logsInPeriod: PeriodDayLog[]
): string | null {
if (flow !== 'none') return null;
if (!openCycle || openCycle.periodEndDate) return null;
if (!openPeriod || openPeriod.periodEndDate) return null;
// Tage des Zyklus nach Datum sortieren, alle bis einschließlich logDate
const sorted = [...logsInCycle]
const sorted = [...logsInPeriod]
.filter((l) => l.logDate <= logDate)
.sort((a, b) => a.logDate.localeCompare(b.logDate));

View file

@ -1,8 +1,8 @@
import { describe, expect, it } from 'vitest';
import { daysBetween, derivePhase, findCycleForDate, getCycleDayNumber } from './phase';
import type { Cycle } from '../types';
import { daysBetween, derivePhase, findPeriodForDate, getPeriodDayNumber } from './phase';
import type { Period } from '../types';
function makeCycle(overrides: Partial<Cycle>): Cycle {
function makePeriod(overrides: Partial<Period>): Period {
return {
id: 'c',
startDate: '2026-01-01',
@ -33,43 +33,43 @@ describe('daysBetween', () => {
});
});
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' }),
describe('findPeriodForDate', () => {
const periods: Period[] = [
makePeriod({ id: 'c1', startDate: '2026-01-01' }),
makePeriod({ id: 'c2', startDate: '2026-01-29' }),
makePeriod({ id: 'c3', startDate: '2026-02-26' }),
];
it('returns null for date before any cycle', () => {
expect(findCycleForDate('2025-12-31', cycles)).toBeNull();
it('returns null for date before any period', () => {
expect(findPeriodForDate('2025-12-31', periods)).toBeNull();
});
it('finds the latest cycle whose startDate <= date', () => {
expect(findCycleForDate('2026-02-15', cycles)?.id).toBe('c2');
it('finds the latest period whose startDate <= date', () => {
expect(findPeriodForDate('2026-02-15', periods)?.id).toBe('c2');
});
it('matches exact start date', () => {
expect(findCycleForDate('2026-02-26', cycles)?.id).toBe('c3');
expect(findPeriodForDate('2026-02-26', periods)?.id).toBe('c3');
});
it('returns most recent for late date', () => {
expect(findCycleForDate('2026-12-31', cycles)?.id).toBe('c3');
expect(findPeriodForDate('2026-12-31', periods)?.id).toBe('c3');
});
});
describe('getCycleDayNumber', () => {
const cycle = makeCycle({ startDate: '2026-04-01' });
describe('getPeriodDayNumber', () => {
const period = makePeriod({ startDate: '2026-04-01' });
it('returns 1 on the start date', () => {
expect(getCycleDayNumber('2026-04-01', cycle)).toBe(1);
expect(getPeriodDayNumber('2026-04-01', period)).toBe(1);
});
it('returns N+1 N days after start', () => {
expect(getCycleDayNumber('2026-04-08', cycle)).toBe(8);
expect(getPeriodDayNumber('2026-04-08', period)).toBe(8);
});
it('returns null before the cycle', () => {
expect(getCycleDayNumber('2026-03-30', cycle)).toBeNull();
it('returns null before the period', () => {
expect(getPeriodDayNumber('2026-03-30', period)).toBeNull();
});
});
describe('derivePhase', () => {
const cycles: Cycle[] = [
makeCycle({
const periods: Period[] = [
makePeriod({
id: 'c1',
startDate: '2026-04-01',
periodEndDate: '2026-04-05', // 5 days of period
@ -77,31 +77,31 @@ describe('derivePhase', () => {
}),
];
it('returns unknown when no cycle covers the date', () => {
expect(derivePhase('2025-12-31', cycles)).toBe('unknown');
it('returns unknown when no period covers the date', () => {
expect(derivePhase('2025-12-31', periods)).toBe('unknown');
});
it('returns menstruation on day 1', () => {
expect(derivePhase('2026-04-01', cycles)).toBe('menstruation');
expect(derivePhase('2026-04-01', periods)).toBe('menstruation');
});
it('returns menstruation on the last bleeding day', () => {
expect(derivePhase('2026-04-05', cycles)).toBe('menstruation');
expect(derivePhase('2026-04-05', periods)).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');
expect(derivePhase('2026-04-08', periods)).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');
expect(derivePhase('2026-04-14', periods)).toBe('ovulation');
expect(derivePhase('2026-04-13', periods)).toBe('ovulation');
expect(derivePhase('2026-04-15', periods)).toBe('ovulation');
});
it('returns luteal after ovulation', () => {
// day 20
expect(derivePhase('2026-04-20', cycles)).toBe('luteal');
expect(derivePhase('2026-04-20', periods)).toBe('luteal');
});
it('falls back to default period length when periodEndDate missing', () => {
const c: Cycle[] = [makeCycle({ startDate: '2026-04-01', length: 28 })];
const c: Period[] = [makePeriod({ 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');

View file

@ -3,11 +3,11 @@
*/
import {
DEFAULT_CYCLE_LENGTH,
DEFAULT_LUTEAL_LENGTH,
DEFAULT_PERIOD_LENGTH,
type Cycle,
type CyclePhase,
DEFAULT_LUTEAL_LENGTH,
DEFAULT_BLEEDING_DAYS,
type Period,
type PeriodPhase,
} from '../types';
/** Tage zwischen zwei ISO-Daten (a - b) */
@ -16,10 +16,10 @@ export function daysBetween(a: string, b: string): number {
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;
/** Findet den Zyklus, der das gegebene Datum enthält. Periods müssen nach startDate sortiert sein. */
export function findPeriodForDate(date: string, periods: Period[]): Period | null {
const sorted = [...periods].sort((a, b) => a.startDate.localeCompare(b.startDate));
let match: Period | null = null;
for (const c of sorted) {
if (c.startDate <= date) match = c;
else break;
@ -28,8 +28,8 @@ export function findCycleForDate(date: string, cycles: Cycle[]): Cycle | null {
}
/** 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);
export function getPeriodDayNumber(date: string, period: Period): number | null {
const diff = daysBetween(date, period.startDate);
if (diff < 0) return null;
return diff + 1;
}
@ -39,29 +39,29 @@ export function getCycleDayNumber(date: string, cycle: Cycle): number | null {
*
* Heuristik:
* - Periode: Tag 1..periodLength
* - Eisprung: cycleLength - lutealLength (±1 Tag)
* - Eisprung: periodLength - 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';
periods: Period[],
avgPeriodLength = DEFAULT_PERIOD_LENGTH
): PeriodPhase {
const period = findPeriodForDate(date, periods);
if (!period) return 'unknown';
const dayNum = getCycleDayNumber(date, cycle);
const dayNum = getPeriodDayNumber(date, period);
if (dayNum === null) return 'unknown';
const periodLength =
cycle.periodEndDate && cycle.periodEndDate >= cycle.startDate
? daysBetween(cycle.periodEndDate, cycle.startDate) + 1
: DEFAULT_PERIOD_LENGTH;
const bleedingLength =
period.periodEndDate && period.periodEndDate >= period.startDate
? daysBetween(period.periodEndDate, period.startDate) + 1
: DEFAULT_BLEEDING_DAYS;
const cycleLength = cycle.length ?? avgCycleLength;
const ovulationDay = cycleLength - DEFAULT_LUTEAL_LENGTH;
const periodLength = period.length ?? avgPeriodLength;
const ovulationDay = periodLength - DEFAULT_LUTEAL_LENGTH;
if (dayNum <= periodLength) return 'menstruation';
if (dayNum <= bleedingLength) return 'menstruation';
if (Math.abs(dayNum - ovulationDay) <= 1) return 'ovulation';
if (dayNum < ovulationDay) return 'follicular';
return 'luteal';

View file

@ -1,14 +1,14 @@
import { describe, expect, it } from 'vitest';
import {
averageCycleLength,
computeCycleStats,
averagePeriodLength,
computePeriodStats,
daysUntilNextPeriod,
predictFertileWindow,
predictNextPeriodStart,
} from './prediction';
import type { Cycle } from '../types';
import type { Period } from '../types';
function cycle(startDate: string, length: number | null = null, isPredicted = false): Cycle {
function period(startDate: string, length: number | null = null, isPredicted = false): Period {
return {
id: `c-${startDate}`,
startDate,
@ -23,50 +23,52 @@ function cycle(startDate: string, length: number | null = null, isPredicted = fa
};
}
describe('averageCycleLength', () => {
it('returns default 28 when no cycles', () => {
expect(averageCycleLength([])).toBe(28);
describe('averagePeriodLength', () => {
it('returns default 28 when no periods', () => {
expect(averagePeriodLength([])).toBe(28);
});
it('returns default when no closed cycles (no length)', () => {
expect(averageCycleLength([cycle('2026-01-01')])).toBe(28);
it('returns default when no closed periods (no length)', () => {
expect(averagePeriodLength([period('2026-01-01')])).toBe(28);
});
it('averages closed cycle lengths', () => {
it('averages closed period lengths', () => {
expect(
averageCycleLength([
cycle('2026-01-01', 28),
cycle('2026-02-01', 30),
cycle('2026-03-01', 26),
averagePeriodLength([
period('2026-01-01', 28),
period('2026-02-01', 30),
period('2026-03-01', 26),
])
).toBe(28);
});
it('caps to most recent N cycles', () => {
// 7 cycles, but window=6 — oldest (length 100) should be ignored
it('caps to most recent N periods', () => {
// 7 periods, 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),
period('2026-01-01', 100),
period('2026-02-01', 28),
period('2026-03-01', 28),
period('2026-04-01', 28),
period('2026-05-01', 28),
period('2026-06-01', 28),
period('2026-07-01', 28),
];
expect(averageCycleLength(c, 6)).toBe(28);
expect(averagePeriodLength(c, 6)).toBe(28);
});
it('ignores predicted cycles', () => {
expect(averageCycleLength([cycle('2026-01-01', 28), cycle('2026-02-01', 100, true)])).toBe(28);
it('ignores predicted periods', () => {
expect(averagePeriodLength([period('2026-01-01', 28), period('2026-02-01', 100, true)])).toBe(
28
);
});
});
describe('predictNextPeriodStart', () => {
it('returns null with no cycles', () => {
it('returns null with no periods', () => {
expect(predictNextPeriodStart([])).toBeNull();
});
it('predicts based on latest start + average length', () => {
const c = [cycle('2026-01-01', 28), cycle('2026-01-29', 28)];
const c = [period('2026-01-01', 28), period('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')];
it('uses default length when no closed periods exist', () => {
const c = [period('2026-01-01')];
// 2026-01-01 + 28 days = 2026-01-29
expect(predictNextPeriodStart(c)).toBe('2026-01-29');
});
@ -78,9 +80,9 @@ describe('daysUntilNextPeriod', () => {
});
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)
// Create a period 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)]);
const result = daysUntilNextPeriod([period(start)]);
expect(result).toBeGreaterThanOrEqual(13);
expect(result).toBeLessThanOrEqual(15);
expect(todayIso).toBeTruthy();
@ -92,8 +94,8 @@ describe('predictFertileWindow', () => {
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)];
// Default 28 day period, luteal=14, so ovulation = day 14 (= start + 13 days)
const c = [period('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
@ -103,12 +105,12 @@ describe('predictFertileWindow', () => {
});
});
describe('computeCycleStats', () => {
describe('computePeriodStats', () => {
it('returns zeros for empty input', () => {
expect(computeCycleStats([])).toEqual({ total: 0, avg: 0, shortest: 0, longest: 0 });
expect(computePeriodStats([])).toEqual({ total: 0, avg: 0, shortest: 0, longest: 0 });
});
it('ignores cycles with no length', () => {
expect(computeCycleStats([cycle('2026-01-01')])).toEqual({
it('ignores periods with no length', () => {
expect(computePeriodStats([period('2026-01-01')])).toEqual({
total: 0,
avg: 0,
shortest: 0,
@ -116,7 +118,7 @@ describe('computeCycleStats', () => {
});
});
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 });
const c = [period('2026-01-01', 26), period('2026-02-01', 28), period('2026-03-01', 30)];
expect(computePeriodStats(c)).toEqual({ total: 3, avg: 28, shortest: 26, longest: 30 });
});
});

View file

@ -2,44 +2,44 @@
* Prediction einfache Vorhersagen über gleitenden Mittelwert.
*/
import { DEFAULT_CYCLE_LENGTH, DEFAULT_LUTEAL_LENGTH, type Cycle } from '../types';
import { DEFAULT_PERIOD_LENGTH, DEFAULT_LUTEAL_LENGTH, type Period } 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
export function averagePeriodLength(periods: Period[], window = 6): number {
const closed = periods
.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;
if (closed.length === 0) return DEFAULT_PERIOD_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);
export function predictNextPeriodStart(periods: Period[]): string | null {
const real = periods.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 avg = averagePeriodLength(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);
export function daysUntilNextPeriod(periods: Period[]): number | null {
const next = predictNextPeriodStart(periods);
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);
export function predictFertileWindow(periods: Period[]): { start: string; end: string } | null {
const real = periods.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 avg = averagePeriodLength(real);
const ovulationDay = avg - DEFAULT_LUTEAL_LENGTH;
const start = new Date(latest.startDate);
start.setUTCDate(start.getUTCDate() + ovulationDay - 5);
@ -52,8 +52,8 @@ export function predictFertileWindow(cycles: Cycle[]): { start: string; end: str
}
/** Statistik-Snapshot über alle echten Zyklen. */
export function computeCycleStats(cycles: Cycle[]) {
const real = cycles.filter((c) => !c.isPredicted && typeof c.length === 'number');
export function computePeriodStats(periods: Period[]) {
const real = periods.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;

View file

@ -19,7 +19,7 @@ vi.mock('@mana/shared-utils/analytics', () => ({
// Database hooks call into funnel-tracking + trigger registry on every
// write. They reach for browser-only globals (localStorage), so stub them
// the same way cycles.integration.test.ts does.
// the same way period.integration.test.ts does.
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
vi.mock('$lib/triggers/inline-suggest', () => ({

View file

@ -4,7 +4,7 @@
*
* Lives next to the playground UI rather than in a shared package because
* the playground is the only consumer right now. If chat / todo enrichment
* / cycles insights end up calling the same surface in the future, lift
* / period insights end up calling the same surface in the future, lift
* this into `$lib/data/llm-client.ts`.
*
* The chunk parser is hand-rolled rather than pulled from a library: the

View file

@ -55,7 +55,7 @@ describe('WIDGET_REGISTRY', () => {
'mana-auth',
'food',
'plants',
'cycles',
'period',
undefined,
];
for (const widget of WIDGET_REGISTRY) {

View file

@ -30,7 +30,7 @@ export type WidgetType =
| 'plant-watering' // Plants: plants due for watering
| 'day-timeline' // TimeBlocks: chronological day timeline
| 'activity-feed' // TimeBlocks: recent activity across modules
| 'cycles' // Cycles: current phase + days until next period
| 'period' // Period: current phase + days until next period
| 'news-unread' // News: latest unread curated articles
| 'body-stats'; // Body: latest weight + active workout summary
@ -132,7 +132,7 @@ export interface WidgetMeta {
| 'times'
| 'food'
| 'plants'
| 'cycles'
| 'period'
| 'body'
| 'mana-auth';
}
@ -337,13 +337,13 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
allowMultiple: false,
},
{
type: 'cycles',
nameKey: 'dashboard.widgets.cycles.title',
descriptionKey: 'dashboard.widgets.cycles.description',
type: 'period',
nameKey: 'dashboard.widgets.period.title',
descriptionKey: 'dashboard.widgets.period.description',
icon: '🌸',
defaultSize: 'small',
allowMultiple: false,
requiredBackend: 'cycles',
requiredBackend: 'period',
},
{
type: 'news-unread',

View file

@ -20,6 +20,7 @@
{ href: '/admin/users', label: 'Users', icon: 'users' },
{ href: '/admin/user-data', label: 'User Data', icon: 'database' },
{ href: '/admin/system', label: 'System', icon: 'server' },
{ href: '/admin/complexity', label: 'Complexity', icon: 'chart' },
];
const icons: Record<string, string> = {
@ -27,6 +28,7 @@
users: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`,
database: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />`,
server: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />`,
chart: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3v18h18M7 14l4-4 4 4 5-5" />`,
};
function isActive(href: string, pathname: string): boolean {

View file

@ -1,9 +1,9 @@
<script lang="ts">
import ListView from '$lib/modules/cycles/ListView.svelte';
import ListView from '$lib/modules/period/ListView.svelte';
</script>
<svelte:head>
<title>Cycles - Mana</title>
<title>Period - Mana</title>
</svelte:head>
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />

View file

@ -247,7 +247,7 @@ scrape_configs:
- https://mana.how/journal
- https://mana.how/dreams
- https://mana.how/firsts
- https://mana.how/cycles
- https://mana.how/period
- https://mana.how/events
- https://mana.how/finance
- https://mana.how/places

View file

@ -1394,7 +1394,7 @@ Phase 1 (Events) ──────┬──> Phase 2 (Projections)
| 18 | Chat | 2 | 1 | Batch 4 |
| 19 | Memoro | 1 | 1 | Batch 4 |
| 20 | Skilltree | 2 | 2 | Batch 4 |
| 21 | Cycles | 1 | 1 | Batch 5 |
| 21 | Period | 1 | 1 | Batch 5 |
| 22 | Firsts | 1 | 1 | Batch 5 |
| 23 | Guides | 1 | 1 | Batch 5 |
| 24 | Inventory | 1 | 1 | Batch 5 |

View file

@ -19,7 +19,7 @@ recommendation.
**Productivity:** todo, calendar, contacts, notes, habits, times, timeblocks, events
**Knowledge & learning:** cards, zitare, guides, questions, skilltree, memoro, context
**Health & self:** food, cycles, dreams, moodlit, plants
**Health & self:** food, period, dreams, moodlit, plants
**Media & creative:** chat, picture, presi, music, photos, storage, uload
**Data & tools:** finance, calc, inventory, places, citycorners, who, news, links, tags, playground

View file

@ -13,7 +13,7 @@ Tracked improvements for UI/styling consistency across the Mana unified app.
Module ListViews use two different styling approaches:
- **Scoped CSS + `hsl(var(--color-*))` theme tokens** — 27 modules (65%)
- todo, notes, drink, contacts, journal, dreams, habits, firsts, calendar, chat, places, inventory, finance, news, body, calc, events, photos, automations, cycles, uload, picture, recipes
- todo, notes, drink, contacts, journal, dreams, habits, firsts, calendar, chat, places, inventory, finance, news, body, calc, events, photos, automations, period, uload, picture, recipes
- **Tailwind utility classes** — 13 modules (35%)
- food, plants, moodlit, cards, presi, storage, skilltree, context, guides, memoro, who, music, playground, citycorners, questions, times

View file

@ -19,6 +19,10 @@
"check:status": "bash scripts/check-status.sh",
"validate:dockerfiles": "node scripts/validate-dockerfiles.mjs",
"audit:deps": "node scripts/audit-workspace-deps.mjs",
"audit:modules": "node scripts/audit-modules.mjs",
"audit:coupling": "node scripts/audit-module-coupling.mjs",
"audit:complexity": "node scripts/audit-complexity.mjs",
"audit:map": "node scripts/build-complexity-map.mjs",
"generate:dockerfiles": "node scripts/generate-dockerfiles.mjs",
"setup:env": "node scripts/generate-env.mjs",
"setup:secrets": "node scripts/setup-secrets.mjs",

View file

@ -135,7 +135,7 @@ export const APP_ICONS = {
dreams: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="dr" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366f1"/><stop offset="100%" style="stop-color:#312e81"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#dr)"/><path d="M62 30a22 22 0 1 0 18 34 18 18 0 0 1-18-34z" fill="white"/><circle cx="32" cy="38" r="1.6" fill="white"/><circle cx="26" cy="58" r="1.2" fill="white" fill-opacity="0.8"/><circle cx="40" cy="68" r="1.4" fill="white" fill-opacity="0.7"/><circle cx="22" cy="46" r="1" fill="white" fill-opacity="0.6"/></svg>`
),
cycles: svgToDataUrl(
period: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="cy" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ec4899"/><stop offset="100%" style="stop-color:#be185d"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#cy)"/><circle cx="50" cy="50" r="26" stroke="white" stroke-width="4" fill="none"/><path d="M50 24a26 26 0 0 1 22 40" stroke="white" stroke-width="4" stroke-linecap="round" fill="none"/><circle cx="72" cy="64" r="3.5" fill="white"/><circle cx="50" cy="50" r="9" fill="white"/></svg>`
),
finance: svgToDataUrl(
@ -153,7 +153,7 @@ export const APP_ICONS = {
body: svgToDataUrl(
// Dumbbell + heart-pulse hybrid: training (barbell) + body (pulse line).
// Red→orange gradient to set it apart from the green health-adjacent
// modules (plants, food) and the pink cycles icon.
// modules (plants, food) and the pink period icon.
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="bd" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#f97316"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#bd)"/><rect x="18" y="42" width="6" height="16" rx="2" fill="white"/><rect x="76" y="42" width="6" height="16" rx="2" fill="white"/><rect x="24" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="72" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="28" y="48" width="44" height="4" rx="2" fill="white"/><path d="M30 70h12l4-8 6 16 4-10 6 6h12" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
),
firsts: svgToDataUrl(
@ -200,7 +200,7 @@ export const APP_ICONS = {
myday: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="md" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#F59E0B"/><stop offset="100%" style="stop-color:#F97316"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#md)"/><circle cx="50" cy="44" r="16" fill="white" fill-opacity="0.9"/><line x1="50" y1="20" x2="50" y2="26" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="50" y1="62" x2="50" y2="68" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="26" y1="44" x2="32" y2="44" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="68" y1="44" x2="74" y2="44" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><rect x="24" y="74" width="52" height="4" rx="2" fill="white" fill-opacity="0.5"/><rect x="30" y="82" width="40" height="3" rx="1.5" fill="white" fill-opacity="0.3"/></svg>`
),
eventstream: svgToDataUrl(
activity: svgToDataUrl(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="es" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#8B5CF6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#es)"/><polyline points="20,55 35,40 50,50 65,30 80,45" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/><circle cx="35" cy="40" r="3" fill="white"/><circle cx="50" cy="50" r="3" fill="white"/><circle cx="65" cy="30" r="3" fill="white"/><circle cx="80" cy="45" r="3" fill="white"/><rect x="24" y="66" width="52" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><rect x="24" y="74" width="36" height="3" rx="1.5" fill="white" fill-opacity="0.3"/></svg>`
),
companion: svgToDataUrl(

View file

@ -667,8 +667,8 @@ export const MANA_APPS: ManaApp[] = [
requiredTier: 'guest',
},
{
id: 'cycles',
name: 'Cycles',
id: 'period',
name: 'Periode',
description: {
de: 'Menstruationszyklus-Tracking',
en: 'Menstrual Cycle Tracking',
@ -677,7 +677,7 @@ export const MANA_APPS: ManaApp[] = [
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,
icon: APP_ICONS.period,
color: '#ec4899',
comingSoon: false,
status: 'development',
@ -894,14 +894,14 @@ export const MANA_APPS: ManaApp[] = [
requiredTier: 'guest',
},
{
id: 'eventstream',
name: 'Events',
description: { de: 'Live Event-Stream', en: 'Live Event Stream' },
id: 'activity',
name: 'Aktivität',
description: { de: 'Live Aktivitäts-Stream', en: 'Live Activity Stream' },
longDescription: {
de: 'Echtzeit-Feed aller Aktionen ueber alle Module: Tasks, Drinks, Termine, Mahlzeiten.',
en: 'Real-time feed of all actions across modules: tasks, drinks, events, meals.',
},
icon: APP_ICONS.eventstream ?? '⚡',
icon: APP_ICONS.activity ?? '⚡',
color: '#6366F1',
comingSoon: false,
status: 'development',

View file

@ -35,7 +35,7 @@
* color: hsl(var(--color-foreground));
* background: hsl(var(--color-primary) / 0.12);
*
* 4. Brand-literal colors (cycles pink, observatory cosmic scenes, the
* 4. Brand-literal colors (period pink, observatory cosmic scenes, the
* automations/spiral indigoviolet ramp, sport/category palettes, the
* photo viewer's near-black backdrop, etc.) deliberately stay as
* literal hex/rgba/hsl. They are NOT theme intent they encode brand