feat(cycles): add menstrual cycle tracking module

New unified-app module under apps/mana/apps/web/src/lib/modules/cycles.
Adds three Dexie tables (cycles, cycleDayLogs, cycleSymptoms) in db v7,
SYNC_APP_MAP entry, app-registry registration, branding (icon + entry +
APP_URLS), and a /cycles route.

Includes phase derivation (menstruation/follicular/ovulation/luteal),
heuristic next-period and fertile-window prediction (rolling mean over
last 6 cycles), 10 default symptoms, and 33 unit tests covering the
pure utilities.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 14:35:33 +02:00
parent 575c5c36fd
commit fbab96c74b
17 changed files with 1780 additions and 0 deletions

View file

@ -301,6 +301,40 @@ registerApp({
},
});
registerApp({
id: 'cycles',
name: 'Cycles',
color: '#ec4899',
views: {
list: { load: () => import('$lib/modules/cycles/ListView.svelte') },
},
contextMenuActions: [
{
id: 'log-day',
label: 'Tag loggen',
icon: Plus,
action: () =>
window.dispatchEvent(
new CustomEvent('mana:quick-action', { detail: { app: 'cycles', action: 'new' } })
),
},
],
collection: 'cycleDayLogs',
paramKey: 'logId',
getDisplayData: (item) => ({
title: (item.logDate as string) || 'Tageseintrag',
subtitle: (item.flow as string) ?? undefined,
}),
createItem: async (data) => {
const { dayLogsStore } = await import('$lib/modules/cycles/stores/dayLogs.svelte');
const log = await dayLogsStore.logDay({
logDate: (data.logDate as string) ?? undefined,
notes: (data.title as string) ?? null,
});
return log.id;
},
});
registerApp({
id: 'finance',
name: 'Finance',

View file

@ -425,6 +425,14 @@ db.version(6).stores({
eventInvitations: 'id, eventId, guestId, channel, [eventId+guestId]',
});
// ─── Version 7: Cycles (Menstruationszyklus-Tracking) ────────
db.version(7).stores({
cycles: 'id, startDate, endDate, isPredicted, isArchived, updatedAt',
cycleDayLogs: 'id, logDate, cycleId, flow, [cycleId+logDate]',
cycleSymptoms: 'id, name, category, count, updatedAt',
});
// ─── Sync App Map ──────────────────────────────────────────
// Maps each table to its appId for sync routing.
// The SyncEngine uses this to group pending changes and push to /sync/{appId}.
@ -468,6 +476,7 @@ export const SYNC_APP_MAP: Record<string, string[]> = {
habits: ['habits', 'habitLogs'],
notes: ['notes', 'noteTags'],
dreams: ['dreams', 'dreamSymbols', 'dreamTags'],
cycles: ['cycles', 'cycleDayLogs', 'cycleSymptoms'],
events: ['socialEvents', 'eventGuests', 'eventInvitations'],
finance: ['transactions', 'financeCategories', 'budgets'],
places: ['places', 'locationLogs', 'placeTags'],

View file

@ -0,0 +1,612 @@
<!--
Cycles — Workbench ListView
Aktueller Zyklus, heutiger Quick-Log, einfache Statistiken.
-->
<script lang="ts">
import {
formatLogDate,
useAllCycles,
useAllDayLogs,
useAllSymptoms,
useCurrentCycle,
useDayLog,
} from './queries';
import { cyclesStore } from './stores/cycles.svelte';
import { dayLogsStore } from './stores/dayLogs.svelte';
import { derivePhase, getCycleDayNumber } from './utils/phase';
import {
computeCycleStats,
daysUntilNextPeriod,
predictFertileWindow,
predictNextPeriodStart,
} from './utils/prediction';
import {
FLOW_COLORS,
FLOW_LABELS,
MOOD_COLORS,
MOOD_LABELS,
PHASE_COLORS,
PHASE_LABELS,
type Flow,
type Mood,
} from './types';
import type { ViewProps } from '$lib/app-registry';
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const _props: ViewProps = $props();
const todayIso = new Date().toISOString().slice(0, 10);
let cycles$ = useAllCycles();
let logs$ = useAllDayLogs();
let symptoms$ = useAllSymptoms();
let current$ = useCurrentCycle();
let todayLog$ = useDayLog(todayIso);
let cycles = $derived(cycles$.value);
let logs = $derived(logs$.value);
let symptoms = $derived(symptoms$.value);
let currentCycle = $derived(current$.value);
let todayLog = $derived(todayLog$.value);
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));
const FLOWS: Flow[] = ['none', 'spotting', 'light', 'medium', 'heavy'];
const MOODS: Mood[] = ['great', 'good', 'neutral', 'low', 'bad'];
let selectedFlow = $derived(todayLog?.flow ?? 'none');
let selectedMood = $derived(todayLog?.mood ?? null);
let selectedSymptoms = $derived(todayLog?.symptoms ?? []);
let temperature = $state('');
let notesText = $state('');
$effect(() => {
if (todayLog) {
temperature = todayLog.temperature?.toString() ?? '';
notesText = todayLog.notes ?? '';
}
});
async function setFlow(flow: Flow) {
await dayLogsStore.logDay({ logDate: todayIso, flow });
}
async function setMood(mood: Mood) {
const next = selectedMood === mood ? null : mood;
await dayLogsStore.logDay({ logDate: todayIso, mood: next });
}
async function toggleSymptom(id: string) {
const has = selectedSymptoms.includes(id);
const next = has ? selectedSymptoms.filter((s) => s !== id) : [...selectedSymptoms, id];
await dayLogsStore.logDay({ logDate: todayIso, symptoms: next });
}
async function saveTemperature() {
const num = parseFloat(temperature);
await dayLogsStore.logDay({
logDate: todayIso,
temperature: Number.isFinite(num) ? num : null,
});
}
async function saveNotes() {
await dayLogsStore.logDay({ logDate: todayIso, notes: notesText.trim() || null });
}
async function startPeriodToday() {
await cyclesStore.createCycle({ startDate: todayIso });
await dayLogsStore.logDay({ logDate: todayIso, flow: 'medium' });
}
async function endPeriodToday() {
if (!currentCycle) return;
await cyclesStore.setPeriodEnd(currentCycle.id, todayIso);
}
function formatDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('de-DE', {
day: '2-digit',
month: '2-digit',
});
}
</script>
<div class="app-view">
<!-- Phase + Status Header -->
<div class="phase-card" style="--phase-color: {PHASE_COLORS[phase]}">
<div class="phase-top">
<span class="phase-dot"></span>
<div class="phase-info">
<span class="phase-label">{PHASE_LABELS[phase]}</span>
{#if cycleDay}
<span class="phase-sub">Zyklustag {cycleDay}</span>
{/if}
</div>
{#if daysUntil !== null}
<div class="phase-countdown">
{#if daysUntil > 0}
<strong>{daysUntil}</strong>
<span>Tage bis zur Periode</span>
{:else if daysUntil === 0}
<strong>Heute</strong>
<span>vorhergesagt</span>
{:else}
<strong>{Math.abs(daysUntil)}</strong>
<span>Tage überfällig</span>
{/if}
</div>
{/if}
</div>
<div class="phase-actions">
{#if !currentCycle || (currentCycle.periodEndDate && currentCycle.periodEndDate < todayIso && phase !== 'menstruation')}
<button class="btn-primary" onclick={startPeriodToday}>Periode starten</button>
{:else if currentCycle && !currentCycle.periodEndDate}
<button class="btn-secondary" onclick={endPeriodToday}>Periode beendet</button>
{/if}
</div>
</div>
<!-- Today: Flow -->
<section class="log-section">
<h3 class="section-label">Heute · Blutung</h3>
<div class="row">
{#each FLOWS as flow}
<button
class="flow-btn"
class:active={selectedFlow === flow}
style="--flow-color: {FLOW_COLORS[flow]}"
onclick={() => setFlow(flow)}
>
<span class="flow-dot"></span>
{FLOW_LABELS[flow]}
</button>
{/each}
</div>
</section>
<!-- Today: Mood -->
<section class="log-section">
<h3 class="section-label">Stimmung</h3>
<div class="row">
{#each MOODS as mood}
<button
class="mood-btn"
class:active={selectedMood === mood}
style="--mood-color: {MOOD_COLORS[mood]}"
onclick={() => setMood(mood)}
>
<span class="mood-dot"></span>
{MOOD_LABELS[mood]}
</button>
{/each}
</div>
</section>
<!-- Today: Symptoms -->
{#if symptoms.length > 0}
<section class="log-section">
<h3 class="section-label">Symptome</h3>
<div class="row">
{#each symptoms as sym}
<button
class="sym-chip"
class:active={selectedSymptoms.includes(sym.id)}
style="--sym-color: {sym.color ?? '#9ca3af'}"
onclick={() => toggleSymptom(sym.id)}
>
{sym.name}
{#if sym.count > 0}<small>· {sym.count}</small>{/if}
</button>
{/each}
</div>
</section>
{/if}
<!-- Temperature & Notes -->
<section class="log-section">
<h3 class="section-label">Basaltemperatur & Notizen</h3>
<div class="row inputs">
<input
type="number"
step="0.01"
class="temp-input"
placeholder="36.5 °C"
bind:value={temperature}
onblur={saveTemperature}
/>
<input
type="text"
class="notes-input"
placeholder="Notiz..."
bind:value={notesText}
onblur={saveNotes}
/>
</div>
</section>
<!-- Stats -->
{#if stats.total > 0}
<section class="log-section stats">
<h3 class="section-label">Statistik</h3>
<div class="stats-grid">
<div class="stat">
<strong>{stats.avg}</strong>
<span>Ø Tage</span>
</div>
<div class="stat">
<strong>{stats.shortest}</strong>
<span>kürzester</span>
</div>
<div class="stat">
<strong>{stats.longest}</strong>
<span>längster</span>
</div>
<div class="stat">
<strong>{stats.total}</strong>
<span>Zyklen</span>
</div>
</div>
{#if nextPeriod}
<div class="prediction">
Nächste Periode: <strong>{formatDate(nextPeriod)}</strong>
{#if fertile}
· Fruchtbares Fenster: <strong>{formatDate(fertile.start)}</strong>
<strong>{formatDate(fertile.end)}</strong>
{/if}
</div>
{/if}
</section>
{/if}
<!-- Recent logs -->
{#if logs.length > 0}
<section class="log-section">
<h3 class="section-label">Letzte Einträge</h3>
<div class="log-list">
{#each logs.slice(0, 10) as log (log.id)}
<div class="log-row">
<span class="log-flow" style="background: {FLOW_COLORS[log.flow]}"></span>
<div class="log-content">
<div class="log-top">
<span class="log-date">{formatLogDate(log.logDate)}</span>
{#if log.flow !== 'none'}
<span class="log-tag">{FLOW_LABELS[log.flow]}</span>
{/if}
{#if log.mood}
<span class="log-tag" style="color: {MOOD_COLORS[log.mood]}"
>{MOOD_LABELS[log.mood]}</span
>
{/if}
</div>
{#if log.notes}
<p class="log-note">{log.notes}</p>
{/if}
</div>
</div>
{/each}
</div>
</section>
{/if}
{#if cycles.length === 0 && logs.length === 0}
<p class="empty">
Tippe oben auf eine Blutungsstärke, um deinen ersten Tag festzuhalten — oder starte direkt
eine Periode.
</p>
{/if}
</div>
<style>
.app-view {
display: flex;
flex-direction: column;
gap: 0.875rem;
padding: 1rem;
height: 100%;
overflow-y: auto;
}
/* ── Phase Card ────────────────────────────── */
.phase-card {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding: 0.875rem 1rem;
border-radius: 0.75rem;
border: 1px solid color-mix(in srgb, var(--phase-color) 30%, transparent);
background: color-mix(in srgb, var(--phase-color) 6%, transparent);
}
.phase-top {
display: flex;
align-items: center;
gap: 0.625rem;
}
.phase-dot {
width: 12px;
height: 12px;
border-radius: 9999px;
background: var(--phase-color);
}
.phase-info {
display: flex;
flex-direction: column;
flex: 1;
}
.phase-label {
font-size: 0.875rem;
font-weight: 600;
color: var(--phase-color);
}
.phase-sub {
font-size: 0.6875rem;
color: #9ca3af;
}
.phase-countdown {
display: flex;
flex-direction: column;
align-items: flex-end;
font-size: 0.625rem;
color: #9ca3af;
}
.phase-countdown strong {
font-size: 1rem;
color: var(--phase-color);
}
.phase-actions {
display: flex;
gap: 0.5rem;
}
.btn-primary,
.btn-secondary {
padding: 0.375rem 0.75rem;
border-radius: 0.375rem;
font-size: 0.75rem;
font-weight: 500;
cursor: pointer;
border: 1px solid transparent;
transition: filter 0.15s;
}
.btn-primary {
background: var(--phase-color);
color: white;
}
.btn-primary:hover {
filter: brightness(1.1);
}
.btn-secondary {
background: transparent;
color: var(--phase-color);
border-color: var(--phase-color);
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--phase-color) 10%, transparent);
}
/* ── Sections ──────────────────────────────── */
.log-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.section-label {
font-size: 0.625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #c0bfba;
font-weight: 600;
margin: 0;
}
:global(.dark) .section-label {
color: #6b7280;
}
.row {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.row.inputs {
flex-wrap: wrap;
}
/* ── Flow / Mood / Symptom buttons ─────────── */
.flow-btn,
.mood-btn {
display: inline-flex;
align-items: center;
gap: 0.375rem;
padding: 0.3125rem 0.625rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
font-size: 0.6875rem;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .flow-btn,
:global(.dark) .mood-btn {
border-color: rgba(255, 255, 255, 0.08);
}
.flow-btn .flow-dot,
.mood-btn .mood-dot {
width: 8px;
height: 8px;
border-radius: 9999px;
background: var(--flow-color, var(--mood-color));
}
.flow-btn.active {
border-color: var(--flow-color);
color: var(--flow-color);
background: color-mix(in srgb, var(--flow-color) 12%, transparent);
}
.mood-btn.active {
border-color: var(--mood-color);
color: var(--mood-color);
background: color-mix(in srgb, var(--mood-color) 12%, transparent);
}
.sym-chip {
padding: 0.25rem 0.625rem;
border-radius: 9999px;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
font-size: 0.6875rem;
color: #9ca3af;
cursor: pointer;
transition: all 0.15s;
}
:global(.dark) .sym-chip {
border-color: rgba(255, 255, 255, 0.08);
}
.sym-chip:hover {
color: var(--sym-color);
}
.sym-chip.active {
border-color: var(--sym-color);
color: var(--sym-color);
background: color-mix(in srgb, var(--sym-color) 12%, transparent);
}
.sym-chip small {
opacity: 0.65;
font-size: 0.6em;
}
/* ── Inputs ───────────────────────────────── */
.temp-input,
.notes-input {
padding: 0.375rem 0.625rem;
border-radius: 0.375rem;
border: 1px solid rgba(0, 0, 0, 0.08);
background: transparent;
font-size: 0.75rem;
color: #374151;
outline: none;
font-family: inherit;
}
.temp-input {
width: 5.5rem;
}
.notes-input {
flex: 1;
min-width: 8rem;
}
.temp-input:focus,
.notes-input:focus {
border-color: #ec4899;
}
:global(.dark) .temp-input,
:global(.dark) .notes-input {
border-color: rgba(255, 255, 255, 0.08);
color: #f3f4f6;
color-scheme: dark;
}
/* ── Stats ────────────────────────────────── */
.stats-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.5rem;
}
.stat {
display: flex;
flex-direction: column;
align-items: center;
padding: 0.5rem 0.375rem;
border-radius: 0.375rem;
background: rgba(236, 72, 153, 0.06);
}
.stat strong {
font-size: 1rem;
color: #ec4899;
font-weight: 600;
}
.stat span {
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #9ca3af;
margin-top: 0.125rem;
}
.prediction {
font-size: 0.6875rem;
color: #9ca3af;
padding: 0.375rem 0.25rem;
}
.prediction strong {
color: #ec4899;
font-weight: 600;
}
/* ── Log List ─────────────────────────────── */
.log-list {
display: flex;
flex-direction: column;
}
.log-row {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.4375rem 0.25rem;
}
.log-flow {
width: 8px;
height: 8px;
border-radius: 9999px;
flex-shrink: 0;
margin-top: 0.4375rem;
}
.log-content {
flex: 1;
min-width: 0;
}
.log-top {
display: flex;
align-items: center;
gap: 0.375rem;
flex-wrap: wrap;
}
.log-date {
font-size: 0.75rem;
font-weight: 500;
color: #374151;
}
:global(.dark) .log-date {
color: #e5e7eb;
}
.log-tag {
font-size: 0.625rem;
color: #9ca3af;
}
.log-note {
font-size: 0.6875rem;
color: #9ca3af;
margin: 0.125rem 0 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.empty {
padding: 2rem 0;
text-align: center;
font-size: 0.8125rem;
color: #9ca3af;
}
@media (max-width: 640px) {
.app-view {
padding: 0.75rem;
}
.stats-grid {
grid-template-columns: repeat(2, 1fr);
}
}
</style>

View file

@ -0,0 +1,129 @@
/**
* Cycles module collection accessors and guest seed data.
*/
import { db } from '$lib/data/database';
import type { LocalCycle, LocalCycleDayLog, LocalCycleSymptom } from './types';
// ─── Collection Accessors ──────────────────────────────────
export const cycleTable = db.table<LocalCycle>('cycles');
export const cycleDayLogTable = db.table<LocalCycleDayLog>('cycleDayLogs');
export const cycleSymptomTable = db.table<LocalCycleSymptom>('cycleSymptoms');
// ─── Guest Seed ────────────────────────────────────────────
const today = new Date().toISOString().slice(0, 10);
const daysAgo = (n: number) => new Date(Date.now() - n * 86_400_000).toISOString().slice(0, 10);
export const CYCLES_GUEST_SEED = {
cycles: [
{
id: 'cycle-prev',
startDate: daysAgo(56),
periodEndDate: daysAgo(52),
endDate: daysAgo(29),
length: 28,
isPredicted: false,
isArchived: false,
notes: null,
},
{
id: 'cycle-current',
startDate: daysAgo(28),
periodEndDate: daysAgo(24),
endDate: null,
length: null,
isPredicted: false,
isArchived: false,
notes: 'Aktueller Zyklus',
},
] satisfies LocalCycle[],
cycleDayLogs: [
{
id: 'cycle-log-today',
logDate: today,
cycleId: 'cycle-current',
flow: 'none',
mood: 'good',
energy: 4,
temperature: null,
cervicalMucus: null,
symptoms: [],
sexualActivity: null,
notes: null,
},
] satisfies LocalCycleDayLog[],
cycleSymptoms: [
{
id: 'sym-cramps',
name: 'Krämpfe',
category: 'physical',
color: '#ef4444',
count: 0,
},
{
id: 'sym-headache',
name: 'Kopfschmerzen',
category: 'physical',
color: '#f97316',
count: 0,
},
{
id: 'sym-breast-tenderness',
name: 'Brustspannen',
category: 'physical',
color: '#ec4899',
count: 0,
},
{
id: 'sym-bloating',
name: 'Blähbauch',
category: 'physical',
color: '#a855f7',
count: 0,
},
{
id: 'sym-acne',
name: 'Akne',
category: 'physical',
color: '#84cc16',
count: 0,
},
{
id: 'sym-fatigue',
name: 'Müdigkeit',
category: 'physical',
color: '#6366f1',
count: 0,
},
{
id: 'sym-irritability',
name: 'Reizbarkeit',
category: 'emotional',
color: '#dc2626',
count: 0,
},
{
id: 'sym-cravings',
name: 'Heißhunger',
category: 'emotional',
color: '#f59e0b',
count: 0,
},
{
id: 'sym-mood-swings',
name: 'Stimmungsschwankungen',
category: 'emotional',
color: '#0ea5e9',
count: 0,
},
{
id: 'sym-libido',
name: 'Erhöhte Libido',
category: 'other',
color: '#d946ef',
count: 0,
},
] satisfies LocalCycleSymptom[],
};

View file

@ -0,0 +1,62 @@
/**
* Cycles module barrel exports.
*/
// ─── Stores ──────────────────────────────────────────────
export { cyclesStore } from './stores/cycles.svelte';
export { dayLogsStore } from './stores/dayLogs.svelte';
export { symptomsStore } from './stores/symptoms.svelte';
// ─── Queries ─────────────────────────────────────────────
export {
useAllCycles,
useCurrentCycle,
useAllDayLogs,
useDayLog,
useAllSymptoms,
toCycle,
toCycleDayLog,
toCycleSymptom,
groupLogsByMonth,
formatLogDate,
} from './queries';
// ─── Utils ───────────────────────────────────────────────
export { derivePhase, findCycleForDate, getCycleDayNumber, daysBetween } from './utils/phase';
export {
averageCycleLength,
predictNextPeriodStart,
daysUntilNextPeriod,
predictFertileWindow,
computeCycleStats,
} from './utils/prediction';
// ─── Collections ─────────────────────────────────────────
export { cycleTable, cycleDayLogTable, cycleSymptomTable, CYCLES_GUEST_SEED } from './collections';
// ─── Types & Constants ───────────────────────────────────
export {
FLOW_COLORS,
FLOW_LABELS,
MOOD_COLORS,
MOOD_LABELS,
PHASE_COLORS,
PHASE_LABELS,
CERVICAL_MUCUS_LABELS,
DEFAULT_CYCLE_LENGTH,
DEFAULT_PERIOD_LENGTH,
DEFAULT_LUTEAL_LENGTH,
} from './types';
export type {
LocalCycle,
LocalCycleDayLog,
LocalCycleSymptom,
Cycle,
CycleDayLog,
CycleSymptom,
Flow,
Mood,
CervicalMucus,
SymptomCategory,
CyclePhase,
} from './types';

View file

@ -0,0 +1,148 @@
/**
* Reactive Queries & Pure Helpers for Cycles module.
*/
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
import { db } from '$lib/data/database';
import type {
Cycle,
CycleDayLog,
CycleSymptom,
LocalCycle,
LocalCycleDayLog,
LocalCycleSymptom,
} from './types';
// ─── Type Converters ───────────────────────────────────────
export function toCycle(local: LocalCycle): Cycle {
return {
id: local.id,
startDate: local.startDate,
periodEndDate: local.periodEndDate,
endDate: local.endDate,
length: local.length,
isPredicted: local.isPredicted,
isArchived: local.isArchived,
notes: local.notes,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toCycleDayLog(local: LocalCycleDayLog): CycleDayLog {
return {
id: local.id,
logDate: local.logDate,
cycleId: local.cycleId,
flow: local.flow,
mood: local.mood,
energy: local.energy,
temperature: local.temperature,
cervicalMucus: local.cervicalMucus,
symptoms: local.symptoms ?? [],
sexualActivity: local.sexualActivity,
notes: local.notes,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toCycleSymptom(local: LocalCycleSymptom): CycleSymptom {
return {
id: local.id,
name: local.name,
category: local.category,
color: local.color,
count: local.count ?? 0,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Queries ──────────────────────────────────────────
export function useAllCycles() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalCycle>('cycles').toArray();
return locals
.filter((c) => !c.deletedAt && !c.isArchived)
.map(toCycle)
.sort((a, b) => b.startDate.localeCompare(a.startDate));
}, [] as Cycle[]);
}
export function useCurrentCycle() {
return useLiveQueryWithDefault(
async () => {
const locals = await db.table<LocalCycle>('cycles').toArray();
const real = locals.filter((c) => !c.deletedAt && !c.isArchived && !c.isPredicted);
if (real.length === 0) return null;
const latest = real.sort((a, b) => b.startDate.localeCompare(a.startDate))[0];
return toCycle(latest);
},
null as Cycle | null
);
}
export function useAllDayLogs() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalCycleDayLog>('cycleDayLogs').toArray();
return locals
.filter((l) => !l.deletedAt)
.map(toCycleDayLog)
.sort((a, b) => b.logDate.localeCompare(a.logDate));
}, [] as CycleDayLog[]);
}
export function useDayLog(date: string) {
return useLiveQueryWithDefault(
async () => {
const locals = await db
.table<LocalCycleDayLog>('cycleDayLogs')
.where('logDate')
.equals(date)
.toArray();
const active = locals.find((l) => !l.deletedAt);
return active ? toCycleDayLog(active) : null;
},
null as CycleDayLog | null
);
}
export function useAllSymptoms() {
return useLiveQueryWithDefault(async () => {
const locals = await db.table<LocalCycleSymptom>('cycleSymptoms').toArray();
return locals
.filter((s) => !s.deletedAt)
.map(toCycleSymptom)
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
}, [] as CycleSymptom[]);
}
// ─── Pure Helpers ──────────────────────────────────────────
/** Group day logs by ISO month label. */
export function groupLogsByMonth(
logs: CycleDayLog[]
): Array<{ label: string; logs: CycleDayLog[] }> {
const groups = new Map<string, CycleDayLog[]>();
for (const l of logs) {
const date = new Date(l.logDate);
const label = date.toLocaleDateString('de-DE', { month: 'long', year: 'numeric' });
const bucket = groups.get(label) ?? [];
bucket.push(l);
groups.set(label, bucket);
}
return Array.from(groups, ([label, logs]) => ({ label, logs }));
}
export function formatLogDate(iso: string): string {
const date = new Date(iso);
const today = new Date();
const diffDays = Math.floor((today.getTime() - date.getTime()) / 86_400_000);
if (diffDays === 0) return 'Heute';
if (diffDays === 1) return 'Gestern';
if (diffDays < 7) return `vor ${diffDays} Tagen`;
return date.toLocaleDateString('de-DE', { day: 'numeric', month: 'short', year: 'numeric' });
}

View file

@ -0,0 +1,91 @@
/**
* Cycles Store Mutation-Only Service for menstrual cycles.
*/
import { cycleTable } from '../collections';
import { toCycle } from '../queries';
import { daysBetween } from '../utils/phase';
import type { LocalCycle } from '../types';
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
function dayBefore(iso: string): string {
const d = new Date(iso);
d.setUTCDate(d.getUTCDate() - 1);
return d.toISOString().slice(0, 10);
}
export const cyclesStore = {
/** Startet einen neuen Zyklus. Schließt automatisch den vorigen offenen Zyklus. */
async createCycle(data: { startDate?: string; notes?: string | null }) {
const startDate = data.startDate ?? todayIsoDate();
// Vorigen offenen Zyklus schließen.
const all = await cycleTable.toArray();
const open = all
.filter((c) => !c.deletedAt && !c.isPredicted && c.endDate === null)
.sort((a, b) => b.startDate.localeCompare(a.startDate));
for (const prev of open) {
if (prev.startDate >= startDate) continue;
const endDate = dayBefore(startDate);
const length = daysBetween(startDate, prev.startDate);
await cycleTable.update(prev.id, {
endDate,
length,
updatedAt: new Date().toISOString(),
});
}
const newLocal: LocalCycle = {
id: crypto.randomUUID(),
startDate,
periodEndDate: null,
endDate: null,
length: null,
isPredicted: false,
isArchived: false,
notes: data.notes ?? null,
};
await cycleTable.add(newLocal);
return toCycle(newLocal);
},
async updateCycle(
id: string,
data: Partial<
Pick<
LocalCycle,
'startDate' | 'periodEndDate' | 'endDate' | 'length' | 'notes' | 'isArchived'
>
>
) {
await cycleTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
/** Markiert das Ende der Blutung (nicht das Ende des Zyklus). */
async setPeriodEnd(id: string, periodEndDate: string | null) {
await cycleTable.update(id, {
periodEndDate,
updatedAt: new Date().toISOString(),
});
},
async deleteCycle(id: string) {
await cycleTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
async archiveCycle(id: string) {
await cycleTable.update(id, {
isArchived: true,
updatedAt: new Date().toISOString(),
});
},
};

View file

@ -0,0 +1,107 @@
/**
* Day Logs Store Mutation-Only Service for daily cycle entries.
*/
import { cycleDayLogTable, cycleTable } from '../collections';
import { toCycleDayLog } from '../queries';
import { symptomsStore } from './symptoms.svelte';
import type { CervicalMucus, Flow, LocalCycle, LocalCycleDayLog, Mood } from '../types';
function todayIsoDate(): string {
return new Date().toISOString().slice(0, 10);
}
/** Findet den passenden Zyklus für ein Datum (letzter startDate <= date). */
async function resolveCycleId(date: string): Promise<string | null> {
const all = await cycleTable.toArray();
const candidates = all
.filter((c: LocalCycle) => !c.deletedAt && !c.isPredicted && c.startDate <= date)
.sort((a, b) => b.startDate.localeCompare(a.startDate));
return candidates[0]?.id ?? null;
}
export interface LogDayInput {
logDate?: string;
flow?: Flow;
mood?: Mood | null;
energy?: number | null;
temperature?: number | null;
cervicalMucus?: CervicalMucus | null;
symptoms?: string[];
sexualActivity?: boolean | null;
notes?: string | null;
}
export const dayLogsStore = {
/** Erstellt oder aktualisiert den Tageseintrag (eine Zeile pro Tag). */
async logDay(data: LogDayInput) {
const logDate = data.logDate ?? todayIsoDate();
const existing = (await cycleDayLogTable.where('logDate').equals(logDate).toArray()).find(
(l) => !l.deletedAt
);
if (existing) {
// Symptom-Counter aktualisieren.
if (data.symptoms) {
const oldSet = new Set(existing.symptoms ?? []);
const newSet = new Set(data.symptoms);
const added = [...newSet].filter((s) => !oldSet.has(s));
const removed = [...oldSet].filter((s) => !newSet.has(s));
if (added.length) await symptomsStore.touchSymptoms(added, +1);
if (removed.length) await symptomsStore.touchSymptoms(removed, -1);
}
await cycleDayLogTable.update(existing.id, {
...data,
logDate,
updatedAt: new Date().toISOString(),
});
return toCycleDayLog({ ...existing, ...data, logDate });
}
const cycleId = await resolveCycleId(logDate);
const newLocal: LocalCycleDayLog = {
id: crypto.randomUUID(),
logDate,
cycleId,
flow: data.flow ?? 'none',
mood: data.mood ?? null,
energy: data.energy ?? null,
temperature: data.temperature ?? null,
cervicalMucus: data.cervicalMucus ?? null,
symptoms: data.symptoms ?? [],
sexualActivity: data.sexualActivity ?? null,
notes: data.notes ?? null,
};
await cycleDayLogTable.add(newLocal);
if (newLocal.symptoms.length) {
await symptomsStore.touchSymptoms(newLocal.symptoms, +1);
}
return toCycleDayLog(newLocal);
},
async deleteLog(id: string) {
const existing = await cycleDayLogTable.get(id);
if (existing?.symptoms?.length) {
await symptomsStore.touchSymptoms(existing.symptoms, -1);
}
await cycleDayLogTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
/** Hängt nicht zugeordnete Logs an den passenden Zyklus an. */
async autoAssignCycle() {
const logs = await cycleDayLogTable.toArray();
for (const log of logs) {
if (log.cycleId || log.deletedAt) continue;
const cycleId = await resolveCycleId(log.logDate);
if (cycleId) {
await cycleDayLogTable.update(log.id, {
cycleId,
updatedAt: new Date().toISOString(),
});
}
}
},
};

View file

@ -0,0 +1,50 @@
/**
* Symptoms Store Mutation-Only Service for cycle symptom taxonomy.
*/
import { cycleSymptomTable } from '../collections';
import type { LocalCycleSymptom, SymptomCategory } from '../types';
export const symptomsStore = {
async createSymptom(data: { name: string; category?: SymptomCategory; color?: string | null }) {
const newLocal: LocalCycleSymptom = {
id: crypto.randomUUID(),
name: data.name.trim(),
category: data.category ?? 'physical',
color: data.color ?? null,
count: 0,
};
await cycleSymptomTable.add(newLocal);
return newLocal;
},
async updateSymptom(
id: string,
data: Partial<Pick<LocalCycleSymptom, 'name' | 'category' | 'color'>>
) {
await cycleSymptomTable.update(id, {
...data,
updatedAt: new Date().toISOString(),
});
},
async deleteSymptom(id: string) {
await cycleSymptomTable.update(id, {
deletedAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
});
},
/** Inkrementiert/dekrementiert die Verwendungszähler für IDs. */
async touchSymptoms(ids: string[], delta: number) {
for (const id of ids) {
const existing = await cycleSymptomTable.get(id);
if (!existing) continue;
const next = Math.max(0, (existing.count ?? 0) + delta);
await cycleSymptomTable.update(id, {
count: next,
updatedAt: new Date().toISOString(),
});
}
},
};

View file

@ -0,0 +1,146 @@
/**
* Cycles module types Menstruationszyklus-Tracking.
*/
import type { BaseRecord } from '@mana/local-store';
export type Flow = 'none' | 'spotting' | 'light' | 'medium' | 'heavy';
export type Mood = 'great' | 'good' | 'neutral' | 'low' | 'bad';
export type CervicalMucus = 'dry' | 'sticky' | 'creamy' | 'watery' | 'eggwhite';
export type SymptomCategory = 'physical' | 'emotional' | 'other';
export type CyclePhase = 'menstruation' | 'follicular' | 'ovulation' | 'luteal' | 'unknown';
// ─── Local Record Types (Dexie) ───────────────────────────
export interface LocalCycle extends BaseRecord {
startDate: string; // ISO YYYY-MM-DD — erster Tag der Periode
periodEndDate: string | null; // letzter Tag der Blutung
endDate: string | null; // Tag vor dem nächsten Zyklusstart (berechnet)
length: number | null; // Zykluslänge in Tagen
isPredicted: boolean;
isArchived: boolean;
notes: string | null;
}
export interface LocalCycleDayLog extends BaseRecord {
logDate: string; // ISO YYYY-MM-DD
cycleId: string | null;
flow: Flow;
mood: Mood | null;
energy: number | null; // 1..5
temperature: number | null; // °C, BBT
cervicalMucus: CervicalMucus | null;
symptoms: string[]; // cycleSymptom.id refs
sexualActivity: boolean | null;
notes: string | null;
}
export interface LocalCycleSymptom extends BaseRecord {
name: string;
category: SymptomCategory;
color: string | null;
count: number;
}
// ─── Domain Types ─────────────────────────────────────────
export interface Cycle {
id: string;
startDate: string;
periodEndDate: string | null;
endDate: string | null;
length: number | null;
isPredicted: boolean;
isArchived: boolean;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface CycleDayLog {
id: string;
logDate: string;
cycleId: string | null;
flow: Flow;
mood: Mood | null;
energy: number | null;
temperature: number | null;
cervicalMucus: CervicalMucus | null;
symptoms: string[];
sexualActivity: boolean | null;
notes: string | null;
createdAt: string;
updatedAt: string;
}
export interface CycleSymptom {
id: string;
name: string;
category: SymptomCategory;
color: string | null;
count: number;
createdAt: string;
updatedAt: string;
}
// ─── Constants ────────────────────────────────────────────
export const FLOW_COLORS: Record<Flow, string> = {
none: 'rgba(0,0,0,0.08)',
spotting: '#fda4af',
light: '#fb7185',
medium: '#e11d48',
heavy: '#9f1239',
};
export const FLOW_LABELS: Record<Flow, string> = {
none: 'Keine',
spotting: 'Schmierblutung',
light: 'Leicht',
medium: 'Mittel',
heavy: 'Stark',
};
export const MOOD_LABELS: Record<Mood, string> = {
great: 'Großartig',
good: 'Gut',
neutral: 'Neutral',
low: 'Niedrig',
bad: 'Schlecht',
};
export const MOOD_COLORS: Record<Mood, string> = {
great: '#22c55e',
good: '#84cc16',
neutral: '#9ca3af',
low: '#f59e0b',
bad: '#ef4444',
};
export const PHASE_LABELS: Record<CyclePhase, string> = {
menstruation: 'Menstruation',
follicular: 'Follikelphase',
ovulation: 'Eisprung',
luteal: 'Lutealphase',
unknown: 'Unbekannt',
};
export const PHASE_COLORS: Record<CyclePhase, string> = {
menstruation: '#e11d48',
follicular: '#f59e0b',
ovulation: '#22c55e',
luteal: '#8b5cf6',
unknown: '#9ca3af',
};
export const CERVICAL_MUCUS_LABELS: Record<CervicalMucus, string> = {
dry: 'Trocken',
sticky: 'Klebrig',
creamy: 'Cremig',
watery: 'Wässrig',
eggwhite: 'Eiweiß',
};
export const DEFAULT_CYCLE_LENGTH = 28;
export const DEFAULT_PERIOD_LENGTH = 5;
export const DEFAULT_LUTEAL_LENGTH = 14;

View file

@ -0,0 +1,109 @@
import { describe, expect, it } from 'vitest';
import { daysBetween, derivePhase, findCycleForDate, getCycleDayNumber } from './phase';
import type { Cycle } from '../types';
function makeCycle(overrides: Partial<Cycle>): Cycle {
return {
id: 'c',
startDate: '2026-01-01',
periodEndDate: null,
endDate: null,
length: null,
isPredicted: false,
isArchived: false,
notes: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
...overrides,
};
}
describe('daysBetween', () => {
it('returns 0 for same day', () => {
expect(daysBetween('2026-04-07', '2026-04-07')).toBe(0);
});
it('returns positive when a > b', () => {
expect(daysBetween('2026-04-10', '2026-04-07')).toBe(3);
});
it('returns negative when a < b', () => {
expect(daysBetween('2026-04-04', '2026-04-07')).toBe(-3);
});
it('handles month boundaries', () => {
expect(daysBetween('2026-05-01', '2026-04-29')).toBe(2);
});
});
describe('findCycleForDate', () => {
const cycles: Cycle[] = [
makeCycle({ id: 'c1', startDate: '2026-01-01' }),
makeCycle({ id: 'c2', startDate: '2026-01-29' }),
makeCycle({ id: 'c3', startDate: '2026-02-26' }),
];
it('returns null for date before any cycle', () => {
expect(findCycleForDate('2025-12-31', cycles)).toBeNull();
});
it('finds the latest cycle whose startDate <= date', () => {
expect(findCycleForDate('2026-02-15', cycles)?.id).toBe('c2');
});
it('matches exact start date', () => {
expect(findCycleForDate('2026-02-26', cycles)?.id).toBe('c3');
});
it('returns most recent for late date', () => {
expect(findCycleForDate('2026-12-31', cycles)?.id).toBe('c3');
});
});
describe('getCycleDayNumber', () => {
const cycle = makeCycle({ startDate: '2026-04-01' });
it('returns 1 on the start date', () => {
expect(getCycleDayNumber('2026-04-01', cycle)).toBe(1);
});
it('returns N+1 N days after start', () => {
expect(getCycleDayNumber('2026-04-08', cycle)).toBe(8);
});
it('returns null before the cycle', () => {
expect(getCycleDayNumber('2026-03-30', cycle)).toBeNull();
});
});
describe('derivePhase', () => {
const cycles: Cycle[] = [
makeCycle({
id: 'c1',
startDate: '2026-04-01',
periodEndDate: '2026-04-05', // 5 days of period
length: 28,
}),
];
it('returns unknown when no cycle covers the date', () => {
expect(derivePhase('2025-12-31', cycles)).toBe('unknown');
});
it('returns menstruation on day 1', () => {
expect(derivePhase('2026-04-01', cycles)).toBe('menstruation');
});
it('returns menstruation on the last bleeding day', () => {
expect(derivePhase('2026-04-05', cycles)).toBe('menstruation');
});
it('returns follicular after period before ovulation', () => {
// day 8, ovulation should be day 14 (28 - 14)
expect(derivePhase('2026-04-08', cycles)).toBe('follicular');
});
it('returns ovulation around day 14 (±1)', () => {
// 2026-04-14 = day 14
expect(derivePhase('2026-04-14', cycles)).toBe('ovulation');
expect(derivePhase('2026-04-13', cycles)).toBe('ovulation');
expect(derivePhase('2026-04-15', cycles)).toBe('ovulation');
});
it('returns luteal after ovulation', () => {
// day 20
expect(derivePhase('2026-04-20', cycles)).toBe('luteal');
});
it('falls back to default period length when periodEndDate missing', () => {
const c: Cycle[] = [makeCycle({ startDate: '2026-04-01', length: 28 })];
// default period length = 5, so day 5 is still menstruation
expect(derivePhase('2026-04-05', c)).toBe('menstruation');
expect(derivePhase('2026-04-06', c)).toBe('follicular');
});
});

View file

@ -0,0 +1,68 @@
/**
* Phase derivation leitet die Zyklusphase aus dem Datum und der Zyklus-Historie ab.
*/
import {
DEFAULT_CYCLE_LENGTH,
DEFAULT_LUTEAL_LENGTH,
DEFAULT_PERIOD_LENGTH,
type Cycle,
type CyclePhase,
} from '../types';
/** Tage zwischen zwei ISO-Daten (a - b) */
export function daysBetween(a: string, b: string): number {
const ms = new Date(a).getTime() - new Date(b).getTime();
return Math.round(ms / 86_400_000);
}
/** Findet den Zyklus, der das gegebene Datum enthält. Cycles müssen nach startDate sortiert sein. */
export function findCycleForDate(date: string, cycles: Cycle[]): Cycle | null {
const sorted = [...cycles].sort((a, b) => a.startDate.localeCompare(b.startDate));
let match: Cycle | null = null;
for (const c of sorted) {
if (c.startDate <= date) match = c;
else break;
}
return match;
}
/** Tag-Nummer innerhalb des Zyklus (Tag 1 = startDate). null wenn date vor dem Zyklus liegt. */
export function getCycleDayNumber(date: string, cycle: Cycle): number | null {
const diff = daysBetween(date, cycle.startDate);
if (diff < 0) return null;
return diff + 1;
}
/**
* Leitet die Phase ab, in der ein Datum liegt.
*
* Heuristik:
* - Periode: Tag 1..periodLength
* - Eisprung: cycleLength - lutealLength (±1 Tag)
* - Vorher = Follikelphase, danach = Lutealphase
*/
export function derivePhase(
date: string,
cycles: Cycle[],
avgCycleLength = DEFAULT_CYCLE_LENGTH
): CyclePhase {
const cycle = findCycleForDate(date, cycles);
if (!cycle) return 'unknown';
const dayNum = getCycleDayNumber(date, cycle);
if (dayNum === null) return 'unknown';
const periodLength =
cycle.periodEndDate && cycle.periodEndDate >= cycle.startDate
? daysBetween(cycle.periodEndDate, cycle.startDate) + 1
: DEFAULT_PERIOD_LENGTH;
const cycleLength = cycle.length ?? avgCycleLength;
const ovulationDay = cycleLength - DEFAULT_LUTEAL_LENGTH;
if (dayNum <= periodLength) return 'menstruation';
if (Math.abs(dayNum - ovulationDay) <= 1) return 'ovulation';
if (dayNum < ovulationDay) return 'follicular';
return 'luteal';
}

View file

@ -0,0 +1,122 @@
import { describe, expect, it } from 'vitest';
import {
averageCycleLength,
computeCycleStats,
daysUntilNextPeriod,
predictFertileWindow,
predictNextPeriodStart,
} from './prediction';
import type { Cycle } from '../types';
function cycle(startDate: string, length: number | null = null, isPredicted = false): Cycle {
return {
id: `c-${startDate}`,
startDate,
periodEndDate: null,
endDate: null,
length,
isPredicted,
isArchived: false,
notes: null,
createdAt: '2026-01-01T00:00:00.000Z',
updatedAt: '2026-01-01T00:00:00.000Z',
};
}
describe('averageCycleLength', () => {
it('returns default 28 when no cycles', () => {
expect(averageCycleLength([])).toBe(28);
});
it('returns default when no closed cycles (no length)', () => {
expect(averageCycleLength([cycle('2026-01-01')])).toBe(28);
});
it('averages closed cycle lengths', () => {
expect(
averageCycleLength([
cycle('2026-01-01', 28),
cycle('2026-02-01', 30),
cycle('2026-03-01', 26),
])
).toBe(28);
});
it('caps to most recent N cycles', () => {
// 7 cycles, but window=6 — oldest (length 100) should be ignored
const c = [
cycle('2026-01-01', 100),
cycle('2026-02-01', 28),
cycle('2026-03-01', 28),
cycle('2026-04-01', 28),
cycle('2026-05-01', 28),
cycle('2026-06-01', 28),
cycle('2026-07-01', 28),
];
expect(averageCycleLength(c, 6)).toBe(28);
});
it('ignores predicted cycles', () => {
expect(averageCycleLength([cycle('2026-01-01', 28), cycle('2026-02-01', 100, true)])).toBe(28);
});
});
describe('predictNextPeriodStart', () => {
it('returns null with no cycles', () => {
expect(predictNextPeriodStart([])).toBeNull();
});
it('predicts based on latest start + average length', () => {
const c = [cycle('2026-01-01', 28), cycle('2026-01-29', 28)];
expect(predictNextPeriodStart(c)).toBe('2026-02-26');
});
it('uses default length when no closed cycles exist', () => {
const c = [cycle('2026-01-01')];
// 2026-01-01 + 28 days = 2026-01-29
expect(predictNextPeriodStart(c)).toBe('2026-01-29');
});
});
describe('daysUntilNextPeriod', () => {
it('returns null without data', () => {
expect(daysUntilNextPeriod([])).toBeNull();
});
it('returns positive count when prediction is in the future', () => {
const todayIso = new Date().toISOString().slice(0, 10);
// Create a cycle that started 14 days ago (default length 28 → next in 14 days)
const start = new Date(Date.now() - 14 * 86_400_000).toISOString().slice(0, 10);
const result = daysUntilNextPeriod([cycle(start)]);
expect(result).toBeGreaterThanOrEqual(13);
expect(result).toBeLessThanOrEqual(15);
expect(todayIso).toBeTruthy();
});
});
describe('predictFertileWindow', () => {
it('returns null without data', () => {
expect(predictFertileWindow([])).toBeNull();
});
it('predicts a 7-day window centred near ovulation', () => {
// Default 28 day cycle, luteal=14, so ovulation = day 14 (= start + 13 days)
const c = [cycle('2026-04-01', 28)];
const window = predictFertileWindow(c);
expect(window).not.toBeNull();
// ovulationDay = 28 - 14 = 14, so start = startDate + (14 - 5) = +9 days = 2026-04-10
// end = startDate + (14 + 1) = +15 = 2026-04-16
expect(window?.start).toBe('2026-04-10');
expect(window?.end).toBe('2026-04-16');
});
});
describe('computeCycleStats', () => {
it('returns zeros for empty input', () => {
expect(computeCycleStats([])).toEqual({ total: 0, avg: 0, shortest: 0, longest: 0 });
});
it('ignores cycles with no length', () => {
expect(computeCycleStats([cycle('2026-01-01')])).toEqual({
total: 0,
avg: 0,
shortest: 0,
longest: 0,
});
});
it('computes avg/min/max correctly', () => {
const c = [cycle('2026-01-01', 26), cycle('2026-02-01', 28), cycle('2026-03-01', 30)];
expect(computeCycleStats(c)).toEqual({ total: 3, avg: 28, shortest: 26, longest: 30 });
});
});

View file

@ -0,0 +1,63 @@
/**
* Prediction einfache Vorhersagen über gleitenden Mittelwert.
*/
import { DEFAULT_CYCLE_LENGTH, DEFAULT_LUTEAL_LENGTH, type Cycle } from '../types';
import { daysBetween } from './phase';
/** Durchschnittliche Zykluslänge aus den letzten N geschlossenen Zyklen. */
export function averageCycleLength(cycles: Cycle[], window = 6): number {
const closed = cycles
.filter((c) => !c.isPredicted && typeof c.length === 'number' && (c.length ?? 0) > 0)
.sort((a, b) => b.startDate.localeCompare(a.startDate))
.slice(0, window);
if (closed.length === 0) return DEFAULT_CYCLE_LENGTH;
const sum = closed.reduce((acc, c) => acc + (c.length ?? 0), 0);
return Math.round(sum / closed.length);
}
/** Vorhergesagter Start der nächsten Periode (ISO-Date). */
export function predictNextPeriodStart(cycles: Cycle[]): string | null {
const real = cycles.filter((c) => !c.isPredicted);
if (real.length === 0) return null;
const latest = real.sort((a, b) => b.startDate.localeCompare(a.startDate))[0];
const avg = averageCycleLength(real);
const start = new Date(latest.startDate);
start.setUTCDate(start.getUTCDate() + avg);
return start.toISOString().slice(0, 10);
}
/** Tage bis zur nächsten Periode. Negativ = überfällig. null wenn keine Daten. */
export function daysUntilNextPeriod(cycles: Cycle[]): number | null {
const next = predictNextPeriodStart(cycles);
if (!next) return null;
return daysBetween(next, new Date().toISOString().slice(0, 10));
}
/** Fruchtbares Fenster für den aktuellen Zyklus (5 Tage vor + Eisprung). */
export function predictFertileWindow(cycles: Cycle[]): { start: string; end: string } | null {
const real = cycles.filter((c) => !c.isPredicted);
if (real.length === 0) return null;
const latest = real.sort((a, b) => b.startDate.localeCompare(a.startDate))[0];
const avg = averageCycleLength(real);
const ovulationDay = avg - DEFAULT_LUTEAL_LENGTH;
const start = new Date(latest.startDate);
start.setUTCDate(start.getUTCDate() + ovulationDay - 5);
const end = new Date(latest.startDate);
end.setUTCDate(end.getUTCDate() + ovulationDay + 1);
return {
start: start.toISOString().slice(0, 10),
end: end.toISOString().slice(0, 10),
};
}
/** Statistik-Snapshot über alle echten Zyklen. */
export function computeCycleStats(cycles: Cycle[]) {
const real = cycles.filter((c) => !c.isPredicted && typeof c.length === 'number');
const lengths = real.map((c) => c.length as number);
const total = real.length;
const avg = lengths.length ? Math.round(lengths.reduce((a, b) => a + b, 0) / lengths.length) : 0;
const shortest = lengths.length ? Math.min(...lengths) : 0;
const longest = lengths.length ? Math.max(...lengths) : 0;
return { total, avg, shortest, longest };
}

View file

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

View file

@ -136,6 +136,9 @@ 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(
`<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(
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="fn" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#22c55e"/><stop offset="100%" style="stop-color:#16a34a"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#fn)"/><circle cx="50" cy="50" r="22" stroke="white" stroke-width="4" fill="none"/><path d="M50 34v32M42 42c0-4 3.5-6 8-6s8 2 8 6-3.5 5-8 5-8 2-8 6 3.5 6 8 6 8-2 8-6" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/></svg>`
),

View file

@ -632,6 +632,23 @@ export const MANA_APPS: ManaApp[] = [
status: 'development',
requiredTier: 'founder',
},
{
id: 'cycles',
name: 'Cycles',
description: {
de: 'Menstruationszyklus-Tracking',
en: 'Menstrual Cycle Tracking',
},
longDescription: {
de: 'Tracke deinen Zyklus mit Blutungstagen, Symptomen, Stimmung und Basaltemperatur. Phasen-Erkennung und Vorhersage für die nächste Periode und das fruchtbare Fenster.',
en: 'Track your cycle with flow days, symptoms, mood, and basal temperature. Phase detection and prediction of the next period and fertile window.',
},
icon: APP_ICONS.cycles,
color: '#ec4899',
comingSoon: false,
status: 'development',
requiredTier: 'founder',
},
{
id: 'events',
name: 'Events',
@ -812,6 +829,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' },
notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' },
dreams: { dev: 'http://localhost:5173/dreams', prod: 'https://mana.how/dreams' },
cycles: { dev: 'http://localhost:5173/cycles', prod: 'https://mana.how/cycles' },
events: { dev: 'http://localhost:5173/events', prod: 'https://mana.how/events' },
finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' },
places: { dev: 'http://localhost:5173/places', prod: 'https://mana.how/places' },