feat(cycles): auto-detect period start and end

When the user logs a bleeding flow (light/medium/heavy) and the previous
cycle ended at least 10 days ago (or no cycle exists), automatically
create a new cycle. When the user logs 'none' for at least 2 consecutive
days after the last bleeding day in an open cycle, automatically set
periodEndDate to that last bleeding day.

Heuristics live in utils/auto-detect.ts as pure functions and are wired
into dayLogsStore.logDay. Conservative thresholds avoid false positives
for mid-cycle spotting and partial bleeding patterns. 18 unit tests
cover the edge cases.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 14:52:06 +02:00
parent 85fda7b5df
commit 473b8c0091
3 changed files with 315 additions and 20 deletions

View file

@ -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) {

View file

@ -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>): 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);
}

View file

@ -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;
}