mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-16 09:59:41 +02:00
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:
parent
85fda7b5df
commit
473b8c0091
3 changed files with 315 additions and 20 deletions
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue