diff --git a/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts b/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts index cbe7d40f5..c231250b8 100644 --- a/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts +++ b/apps/mana/apps/web/src/lib/modules/cycles/stores/dayLogs.svelte.ts @@ -3,7 +3,9 @@ */ import { cycleDayLogTable, cycleTable } from '../collections'; -import { toCycleDayLog } from '../queries'; +import { toCycle, toCycleDayLog } from '../queries'; +import { detectPeriodEnd, shouldStartNewCycle } from '../utils/auto-detect'; +import { cyclesStore } from './cycles.svelte'; import { symptomsStore } from './symptoms.svelte'; import type { CervicalMucus, Flow, LocalCycle, LocalCycleDayLog, Mood } from '../types'; @@ -36,10 +38,21 @@ export const dayLogsStore = { /** Erstellt oder aktualisiert den Tageseintrag (eine Zeile pro Tag). */ async logDay(data: LogDayInput) { const logDate = data.logDate ?? todayIsoDate(); + + // ─ 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 existing = (await cycleDayLogTable.where('logDate').equals(logDate).toArray()).find( (l) => !l.deletedAt ); + let result: LocalCycleDayLog; if (existing) { // Symptom-Counter aktualisieren. if (data.symptoms) { @@ -55,28 +68,46 @@ export const dayLogsStore = { logDate, updatedAt: new Date().toISOString(), }); - return toCycleDayLog({ ...existing, ...data, logDate }); + result = { ...existing, ...data, logDate }; + } else { + 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); + } + result = newLocal; } - 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); + // ─ 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) + .toArray(); + const cycleLogs = cycleLogsLocal.filter((l) => !l.deletedAt).map(toCycleDayLog); + const endDate = detectPeriodEnd(logDate, 'none', toCycle(openCycleLocal), cycleLogs); + if (endDate) { + await cyclesStore.setPeriodEnd(openCycleLocal.id, endDate); + } + } } - return toCycleDayLog(newLocal); + + return toCycleDayLog(result); }, async deleteLog(id: string) { diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.test.ts b/apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.test.ts new file mode 100644 index 000000000..ddf53d4ba --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.test.ts @@ -0,0 +1,174 @@ +import { describe, expect, it } from 'vitest'; +import { + detectPeriodEnd, + DRY_DAYS_FOR_PERIOD_END, + isBleedingFlow, + MIN_GAP_FOR_NEW_CYCLE, + shouldStartNewCycle, +} from './auto-detect'; +import type { Cycle, CycleDayLog, Flow } from '../types'; + +function makeCycle(overrides: Partial): Cycle { + return { + id: 'c', + startDate: '2026-01-01', + periodEndDate: null, + endDate: null, + length: null, + isPredicted: false, + isArchived: false, + notes: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + ...overrides, + }; +} + +function makeLog(logDate: string, flow: Flow): CycleDayLog { + return { + id: `log-${logDate}`, + logDate, + cycleId: 'c', + flow, + mood: null, + energy: null, + temperature: null, + cervicalMucus: null, + symptoms: [], + sexualActivity: null, + notes: null, + createdAt: '2026-01-01T00:00:00.000Z', + updatedAt: '2026-01-01T00:00:00.000Z', + }; +} + +describe('isBleedingFlow', () => { + it('returns true for light/medium/heavy', () => { + expect(isBleedingFlow('light')).toBe(true); + expect(isBleedingFlow('medium')).toBe(true); + expect(isBleedingFlow('heavy')).toBe(true); + }); + it('returns false for none/spotting', () => { + expect(isBleedingFlow('none')).toBe(false); + expect(isBleedingFlow('spotting')).toBe(false); + }); +}); + +describe('shouldStartNewCycle', () => { + it('returns false for non-bleeding flow', () => { + expect(shouldStartNewCycle('2026-04-07', 'none', [])).toBe(false); + expect(shouldStartNewCycle('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 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 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); + }); + + 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 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); + }); + + 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 }), + ]; + // 2026-04-15 → with the real cycle far in the past, should start new + expect(shouldStartNewCycle('2026-04-15', 'medium', cycles)).toBe(true); + }); + + it('returns false for date before the latest cycle', () => { + const cycles = [makeCycle({ 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); + }); +}); + +describe('detectPeriodEnd', () => { + const openCycle = makeCycle({ id: 'c', startDate: '2026-04-01' }); + + it('returns null for non-none flow', () => { + expect(detectPeriodEnd('2026-04-07', 'light', openCycle, [])).toBeNull(); + }); + + it('returns null without an open cycle', () => { + 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' }); + expect(detectPeriodEnd('2026-04-07', 'none', closed, [])).toBeNull(); + }); + + it('returns null when no bleeding day exists in cycle', () => { + const logs = [makeLog('2026-04-01', 'none'), makeLog('2026-04-02', 'none')]; + expect(detectPeriodEnd('2026-04-07', 'none', openCycle, 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(); + }); + + it('returns lastBleedingDay after DRY_DAYS_FOR_PERIOD_END', () => { + const logs = [ + makeLog('2026-04-01', 'medium'), + makeLog('2026-04-02', 'medium'), + makeLog('2026-04-03', 'medium'), + makeLog('2026-04-04', 'light'), + ]; + // 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'); + }); + + it('uses the LAST bleeding day, not the first', () => { + const logs = [ + makeLog('2026-04-01', 'heavy'), + makeLog('2026-04-02', 'medium'), + makeLog('2026-04-03', 'light'), + ]; + expect(detectPeriodEnd('2026-04-05', 'none', openCycle, logs)).toBe('2026-04-03'); + }); + + it('ignores logs after the current logDate (chronology safe)', () => { + const logs = [ + makeLog('2026-04-01', 'medium'), + makeLog('2026-04-02', 'medium'), + // User backfills logDate '2026-04-03' as none → should look at logs ≤ 2026-04-03 + 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(); + }); + + it('handles spotting as not bleeding (so spotting is not lastBleedingDay)', () => { + const logs = [ + makeLog('2026-04-01', 'medium'), + 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'); + }); +}); + +// Helper for assertion clarity in tests +function daysGapForTest(later: string, earlier: string): number { + return Math.round((new Date(later).getTime() - new Date(earlier).getTime()) / 86_400_000); +} diff --git a/apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.ts b/apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.ts new file mode 100644 index 000000000..805cbd702 --- /dev/null +++ b/apps/mana/apps/web/src/lib/modules/cycles/utils/auto-detect.ts @@ -0,0 +1,90 @@ +/** + * Auto-Detection — leitet Period-Start und Period-End aus Tageslogs ab. + * + * Konservative Heuristiken: + * - Period-Start: Blutung (light/medium/heavy) ohne offenen Zyklus → neuer Zyklus + * nur, wenn der letzte Zyklus mindestens 10 Tage abgeschlossen ist (vermeidet + * Zwischenblutungs-Fehlinterpretation). + * - Period-End: 2 trockene Tage in Folge nach dem letzten Bleeding-Tag → setze + * periodEndDate auf den letzten Bleeding-Tag. + */ + +import type { Cycle, CycleDayLog, Flow } from '../types'; +import { daysBetween } from './phase'; + +/** Welche Flow-Werte zählen als "Blutung" (= Periode)? */ +export function isBleedingFlow(flow: Flow): boolean { + return flow === 'light' || flow === 'medium' || flow === 'heavy'; +} + +/** Mindestabstand (Tage) zwischen Ende einer Periode und Start eines neuen Zyklus. */ +export const MIN_GAP_FOR_NEW_CYCLE = 10; +/** Wieviele zusammenhängende trockene Tage nach Bleeding für Period-End-Detection. */ +export const DRY_DAYS_FOR_PERIOD_END = 2; + +/** + * Soll für diesen Tageseintrag ein neuer Zyklus angelegt werden? + * + * 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. + * + * Verhindert false positives für Tage innerhalb eines bestehenden Zyklus. + */ +export function shouldStartNewCycle(logDate: string, flow: Flow, cycles: Cycle[]): boolean { + if (!isBleedingFlow(flow)) return false; + + const real = cycles.filter((c) => !c.isPredicted && !c.isArchived); + if (real.length === 0) return true; + + const latest = [...real].sort((a, b) => b.startDate.localeCompare(a.startDate))[0]; + + // 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.ä.) + 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; +} + +/** + * Wenn der aktuelle Tag (logDate) trocken ist, prüfe ob die Periode beendet werden soll. + * + * Gibt das Datum des letzten Bleeding-Tags zurück (= das zu setzende periodEndDate), + * wenn alle Bedingungen erfüllt sind. Sonst null. + * + * Bedingungen: + * - flow === 'none' (nicht spotting — spotting könnte Periode-Ende-Zeichen sein) + * - Es gibt einen offenen Zyklus (periodEndDate ist null) + * - Im aktuellen Zyklus existiert ein letzter Bleeding-Tag + * - Zwischen letztem Bleeding-Tag und logDate liegen mindestens DRY_DAYS_FOR_PERIOD_END Tage + */ +export function detectPeriodEnd( + logDate: string, + flow: Flow, + openCycle: Cycle | null, + logsInCycle: CycleDayLog[] +): string | null { + if (flow !== 'none') return null; + if (!openCycle || openCycle.periodEndDate) return null; + + // Tage des Zyklus nach Datum sortieren, alle bis einschließlich logDate + const sorted = [...logsInCycle] + .filter((l) => l.logDate <= logDate) + .sort((a, b) => a.logDate.localeCompare(b.logDate)); + + // Letzter Tag mit Blutung + let lastBleedingDay: string | null = null; + for (const log of sorted) { + if (isBleedingFlow(log.flow)) lastBleedingDay = log.logDate; + } + if (!lastBleedingDay) return null; + + if (daysBetween(logDate, lastBleedingDay) >= DRY_DAYS_FOR_PERIOD_END) { + return lastBleedingDay; + } + return null; +}