mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 21:01:08 +02:00
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:
parent
575c5c36fd
commit
fbab96c74b
17 changed files with 1780 additions and 0 deletions
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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'],
|
||||
|
|
|
|||
612
apps/mana/apps/web/src/lib/modules/cycles/ListView.svelte
Normal file
612
apps/mana/apps/web/src/lib/modules/cycles/ListView.svelte
Normal 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>
|
||||
129
apps/mana/apps/web/src/lib/modules/cycles/collections.ts
Normal file
129
apps/mana/apps/web/src/lib/modules/cycles/collections.ts
Normal 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[],
|
||||
};
|
||||
62
apps/mana/apps/web/src/lib/modules/cycles/index.ts
Normal file
62
apps/mana/apps/web/src/lib/modules/cycles/index.ts
Normal 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';
|
||||
148
apps/mana/apps/web/src/lib/modules/cycles/queries.ts
Normal file
148
apps/mana/apps/web/src/lib/modules/cycles/queries.ts
Normal 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' });
|
||||
}
|
||||
|
|
@ -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(),
|
||||
});
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -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(),
|
||||
});
|
||||
}
|
||||
},
|
||||
};
|
||||
146
apps/mana/apps/web/src/lib/modules/cycles/types.ts
Normal file
146
apps/mana/apps/web/src/lib/modules/cycles/types.ts
Normal 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;
|
||||
109
apps/mana/apps/web/src/lib/modules/cycles/utils/phase.test.ts
Normal file
109
apps/mana/apps/web/src/lib/modules/cycles/utils/phase.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
68
apps/mana/apps/web/src/lib/modules/cycles/utils/phase.ts
Normal file
68
apps/mana/apps/web/src/lib/modules/cycles/utils/phase.ts
Normal 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';
|
||||
}
|
||||
|
|
@ -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 });
|
||||
});
|
||||
});
|
||||
|
|
@ -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 };
|
||||
}
|
||||
9
apps/mana/apps/web/src/routes/(app)/cycles/+page.svelte
Normal file
9
apps/mana/apps/web/src/routes/(app)/cycles/+page.svelte
Normal 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={{}} />
|
||||
|
|
@ -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>`
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue