mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 17:41:09 +02:00
refactor: rename eventstream -> activity, cycles -> period
eventstream was confusingly branded "Events" in the app registry, colliding with the real events calendar module. Renamed to activity (DE: Aktivität) since it's a live activity feed across all modules. cycles -> period (DE: Periode) makes the menstrual-tracking module self-describing. Tables cycles/cycleDayLogs/cycleSymptoms renamed to periods/periodDayLogs/periodSymptoms; field cycleId -> periodId; TimeBlockType 'cycle' -> 'period'; domain event CycleDayLogged -> PeriodDayLogged. Generic "cycle" usages (billing, lifecycle, breath, bicycle, import cycles) left untouched. Constant disambiguation: prior DEFAULT_PERIOD_LENGTH (bleeding days) renamed to DEFAULT_BLEEDING_DAYS; prior DEFAULT_CYCLE_LENGTH (28d full cycle) is now DEFAULT_PERIOD_LENGTH. Pre-launch, no data migration needed. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
66cda80620
commit
b857063120
69 changed files with 698 additions and 674 deletions
|
|
@ -25,7 +25,7 @@ Mana encrypts user-typed content with **AES-GCM-256** before it touches IndexedD
|
|||
| Dreams | `title`, `content`, `transcript`, `interpretation`, `location` |
|
||||
| Memoro | `title`, `intro`, `transcript` (the largest plaintext blobs in the app) |
|
||||
| Contacts | 16 PII fields (firstName, lastName, email, phone, mobile, birthday, address, social) |
|
||||
| Cycles | `notes`, `mood` (GDPR Art. 9 sensitive personal data) |
|
||||
| Period | `notes`, `mood` (GDPR Art. 9 sensitive personal data) |
|
||||
| Finance | `transactions.description`, `transactions.note` |
|
||||
| Cards | `front`, `back`, deck name + description |
|
||||
| Todo | `tasks.title`, `description`, `subtasks`, `metadata` |
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
---
|
||||
title: 'Encryption Phasen 1–9: Vault-Ende-zu-Ende + Dreams, Cycles, Events Module'
|
||||
description: 'Größter Tag der Woche: AES-GCM-256 Encryption für 27 Tabellen in 9 Phasen ausgerollt, inkl. Zero-Knowledge-Modus mit Recovery-Code. Plus drei neue Module: Dreams (Voice→STT), Cycles (Menstrual-Tracking) und Events (öffentliche RSVP).'
|
||||
title: 'Encryption Phasen 1–9: Vault-Ende-zu-Ende + Dreams, Period, Events Module'
|
||||
description: 'Größter Tag der Woche: AES-GCM-256 Encryption für 27 Tabellen in 9 Phasen ausgerollt, inkl. Zero-Knowledge-Modus mit Recovery-Code. Plus drei neue Module: Dreams (Voice→STT), Period (Menstrual-Tracking) und Events (öffentliche RSVP).'
|
||||
date: 2026-04-07
|
||||
author: 'Till Schneider'
|
||||
category: 'feature'
|
||||
|
|
@ -11,7 +11,7 @@ tags:
|
|||
'zero-knowledge',
|
||||
'recovery-code',
|
||||
'dreams',
|
||||
'cycles',
|
||||
'period',
|
||||
'events',
|
||||
'rsvp',
|
||||
'mana-stt',
|
||||
|
|
@ -39,7 +39,7 @@ workingHours:
|
|||
- **At-Rest Encryption** in 9 Phasen ausgerollt: AES-GCM-256 für 27 Tabellen
|
||||
- **Zero-Knowledge-Modus** mit User-only Recovery-Code (Mana kann nichts lesen)
|
||||
- **Lock-Screen** mit Recovery-Unlock-Modal
|
||||
- **Drei neue Module**: Dreams (Traumtagebuch), Cycles (Zyklus-Tracking), Events (öffentliche RSVP)
|
||||
- **Drei neue Module**: Dreams (Traumtagebuch), Period (Zyklus-Tracking), Events (öffentliche RSVP)
|
||||
- **Data-Layer-Audit Sprints 1–4** abgeschlossen — LWW, retry, atomic cascades, perf, quota, telemetry
|
||||
- **mana-stt Voice-Pipeline** für Dreams + Memoro live
|
||||
- **Pre-Launch Cleanup** — Schema-Collapse, Ghost-API-Clients raus, RLS auf sync_changes
|
||||
|
|
@ -100,7 +100,7 @@ Erstes Modul mit aktiver Encryption: **Notes**. Klein, kontrolliert, low-risk. F
|
|||
|
||||
### Phase 5: Rollout auf 6 Module
|
||||
|
||||
chat, dreams, memoro, contacts, cycles, finance — alles user-typed Content der eindeutig privat ist.
|
||||
chat, dreams, memoro, contacts, period, finance — alles user-typed Content der eindeutig privat ist.
|
||||
|
||||
### Phase 6: Polish + UI
|
||||
|
||||
|
|
@ -186,7 +186,7 @@ Während die Encryption durch die Phasen lief, entstanden parallel drei neue Mod
|
|||
- **Mic-Permission UX** auf macOS — wenn Browser den Prompt nicht zeigt, gibt's einen erklärenden Screen + Force-Retry
|
||||
- **Proxy-Toleranz**: octet-stream und invalid form bodies werden vom Voice-Proxy nicht abgewiesen
|
||||
|
||||
### Cycles (Menstruelle Zyklus-Tracking)
|
||||
### Period (Menstruelle Zyklus-Tracking)
|
||||
|
||||
- **Period Auto-Detect**: Start/Ende werden aus Symptomen + Bleeding-Levels abgeleitet
|
||||
- **Symptom Management UI**: konfigurierbare Symptome mit Severity
|
||||
|
|
@ -331,7 +331,7 @@ Vor dem Production-Launch eine größere Aufräumrunde:
|
|||
- **`PRE_LAUNCH_CLEANUP.md`** — was wurde entfernt vor Launch und warum
|
||||
- **`FILE_BYTES_ENCRYPTION_PLAN.md`** — nächste Encryption-Stufe für Bytes/Bilder
|
||||
- **`docs/postmortems/2026-04-07-stt-tunnel-down.md`** — STT-Ausfall Postmortem
|
||||
- **`docs/cycles/ROADMAP.md`** — Cycles Feature-Backlog
|
||||
- **`docs/period/ROADMAP.md`** — Period Feature-Backlog
|
||||
- **`docs/events/PHASE2_ROADMAP.md`** — Events Phase 2 + tech debt
|
||||
- GPU Tunnel Setup, STT env wiring docs
|
||||
|
||||
|
|
@ -344,7 +344,7 @@ Vor dem Production-Launch eine größere Aufräumrunde:
|
|||
| Encryption Phasen 1–9 | ~22 | 27 Tabellen, ZK-Modus, Recovery-Code, Lock-Screen, Settings, Tests |
|
||||
| Data-Layer Sprints | ~8 | LWW, retry, cascades, perf, quota, telemetry |
|
||||
| Dreams Modul | ~9 | Voice via mana-stt, Symbol-Library, Mic-UX |
|
||||
| Cycles Modul | ~12 | Phase-Detection, Symptome, Calendar-View, Widget, i18n |
|
||||
| Period Modul | ~12 | Phase-Detection, Symptome, Calendar-View, Widget, i18n |
|
||||
| Events Modul | ~12 | RSVP-Flow, Bring-List, 35 Tests, Playwright, Phase 2 |
|
||||
| mana-stt | ~3 | Voice-Pipeline, Postmortem, GPU-Tunnel |
|
||||
| Pre-Launch Cleanup | ~7 | Schema-Collapse, RLS, idempotent startup |
|
||||
|
|
|
|||
|
|
@ -65,6 +65,7 @@ import {
|
|||
Question,
|
||||
ChatCircleDots,
|
||||
CreditCard,
|
||||
SquaresFour,
|
||||
} from '@mana/shared-icons';
|
||||
|
||||
// ── Apps with entity capabilities ───────────────────────────
|
||||
|
|
@ -409,12 +410,12 @@ registerApp({
|
|||
});
|
||||
|
||||
registerApp({
|
||||
id: 'cycles',
|
||||
name: 'Cycles',
|
||||
id: 'period',
|
||||
name: 'Period',
|
||||
color: '#ec4899',
|
||||
icon: GenderFemale,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/cycles/ListView.svelte') },
|
||||
list: { load: () => import('$lib/modules/period/ListView.svelte') },
|
||||
},
|
||||
contextMenuActions: [
|
||||
{
|
||||
|
|
@ -423,18 +424,18 @@ registerApp({
|
|||
icon: Plus,
|
||||
action: () =>
|
||||
window.dispatchEvent(
|
||||
new CustomEvent('mana:quick-action', { detail: { app: 'cycles', action: 'new' } })
|
||||
new CustomEvent('mana:quick-action', { detail: { app: 'period', action: 'new' } })
|
||||
),
|
||||
},
|
||||
],
|
||||
collection: 'cycleDayLogs',
|
||||
collection: 'periodDayLogs',
|
||||
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 { dayLogsStore } = await import('$lib/modules/period/stores/dayLogs.svelte');
|
||||
const log = await dayLogsStore.logDay({
|
||||
logDate: (data.logDate as string) ?? undefined,
|
||||
notes: (data.title as string) ?? null,
|
||||
|
|
@ -957,12 +958,12 @@ registerApp({
|
|||
});
|
||||
|
||||
registerApp({
|
||||
id: 'eventstream',
|
||||
id: 'activity',
|
||||
name: 'Events',
|
||||
color: '#6366F1',
|
||||
icon: Pulse,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/eventstream/ListView.svelte') },
|
||||
list: { load: () => import('$lib/modules/activity/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -1028,6 +1029,16 @@ registerApp({
|
|||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'complexity',
|
||||
name: 'Complexity',
|
||||
color: '#0EA5E9',
|
||||
icon: SquaresFour,
|
||||
views: {
|
||||
list: { load: () => import('$lib/modules/complexity/ListView.svelte') },
|
||||
},
|
||||
});
|
||||
|
||||
registerApp({
|
||||
id: 'api-keys',
|
||||
name: 'API Keys',
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
* can find pages by intent rather than scanning an alphabetical list.
|
||||
*
|
||||
* Five categories (Vorschlag C):
|
||||
* - companion: Companion Brain pages (myday, eventstream, companion, goals)
|
||||
* - companion: Companion Brain pages (myday, activity, companion, goals)
|
||||
* - life: Personal / wellness / everyday-life tracking
|
||||
* - work: Productivity & planning
|
||||
* - creative: Creative, learning, generation
|
||||
|
|
@ -40,7 +40,7 @@ export const APP_CATEGORIES: CategoryMeta[] = [
|
|||
export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
|
||||
// Companion Brain
|
||||
myday: 'companion',
|
||||
eventstream: 'companion',
|
||||
activity: 'companion',
|
||||
companion: 'companion',
|
||||
goals: 'companion',
|
||||
|
||||
|
|
@ -50,7 +50,7 @@ export const APP_CATEGORY_MAP: Record<string, AppCategory> = {
|
|||
sleep: 'life',
|
||||
mood: 'life',
|
||||
stretch: 'life',
|
||||
cycles: 'life',
|
||||
period: 'life',
|
||||
dreams: 'life',
|
||||
drink: 'life',
|
||||
meditate: 'life',
|
||||
|
|
|
|||
|
|
@ -38,8 +38,8 @@
|
|||
contacts: 'Kontakt',
|
||||
events: 'Termin',
|
||||
timeBlocks: 'Zeitblock',
|
||||
cycles: 'Zyklus',
|
||||
cycleDayLogs: 'Tageseintrag',
|
||||
period: 'Zyklus',
|
||||
periodDayLogs: 'Tageseintrag',
|
||||
transactions: 'Transaktion',
|
||||
cards: 'Karte',
|
||||
cardDecks: 'Kartendeck',
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ import RecentContactsWidget from '$lib/modules/core/widgets/RecentContactsWidget
|
|||
import ActiveTimerWidget from '$lib/modules/core/widgets/ActiveTimerWidget.svelte';
|
||||
import NutritionProgressWidget from '$lib/modules/core/widgets/NutritionProgressWidget.svelte';
|
||||
import PlantWateringWidget from '$lib/modules/core/widgets/PlantWateringWidget.svelte';
|
||||
import CyclesWidget from '$lib/modules/core/widgets/CyclesWidget.svelte';
|
||||
import PeriodWidget from '$lib/modules/core/widgets/PeriodWidget.svelte';
|
||||
import NewsUnreadWidget from '$lib/modules/news/widgets/NewsUnreadWidget.svelte';
|
||||
import BodyStatsWidget from '$lib/modules/body/widgets/BodyStatsWidget.svelte';
|
||||
import DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
|
||||
|
|
@ -59,7 +59,7 @@ export const widgetComponents: Record<WidgetType, Component> = {
|
|||
'plant-watering': PlantWateringWidget,
|
||||
'day-timeline': DayTimelineWidget,
|
||||
'activity-feed': ActivityFeedWidget,
|
||||
cycles: CyclesWidget,
|
||||
period: PeriodWidget,
|
||||
'news-unread': NewsUnreadWidget,
|
||||
'body-stats': BodyStatsWidget,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -279,7 +279,7 @@ describe('encryption registry', () => {
|
|||
expect(tables).toContain('notes');
|
||||
expect(tables).toContain('memos');
|
||||
expect(tables).toContain('contacts');
|
||||
expect(tables).toContain('cycleDayLogs');
|
||||
expect(tables).toContain('periodDayLogs');
|
||||
});
|
||||
|
||||
it('every registry entry has a non-empty fields list', () => {
|
||||
|
|
|
|||
|
|
@ -119,15 +119,15 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// would still leak via the timeBlocks table.
|
||||
events: { enabled: true, fields: ['title', 'description', 'location'] },
|
||||
|
||||
// ─── Cycles ──────────────────────────────────────────────
|
||||
// ─── Period ──────────────────────────────────────────────
|
||||
// Health data — GDPR Art. 9 sensitive personal data category.
|
||||
// `symptoms` stays plaintext: it's a string-array of standardised
|
||||
// labels (cramps, headache, ...) used as a Set in the symptom
|
||||
// counter store; encrypting it would break the diff loop in
|
||||
// dayLogsStore.logDay. `mood` is a single enum but with the same
|
||||
// privacy sensitivity as `notes` — encrypt it.
|
||||
cycles: { enabled: true, fields: ['notes'] },
|
||||
cycleDayLogs: { enabled: true, fields: ['notes', 'mood'] },
|
||||
periods: { enabled: true, fields: ['notes'] },
|
||||
periodDayLogs: { enabled: true, fields: ['notes', 'mood'] },
|
||||
|
||||
// ─── Food ────────────────────────────────────────────
|
||||
// LocalMeal user-typed / AI-generated content → encrypted:
|
||||
|
|
@ -353,7 +353,7 @@ export const ENCRYPTION_REGISTRY: Record<string, EncryptionConfig> = {
|
|||
// plaintext indexes still resolve "which sets did I do" without
|
||||
// leaking how heavy or how many.
|
||||
// - bodyChecks.energy/sleep/soreness/mood: 1-5 mood-style ratings with
|
||||
// the same sensitivity as cycleDayLogs.mood.
|
||||
// the same sensitivity as periodDayLogs.mood.
|
||||
// - bodyPhases.startWeight/targetWeight: identical reasoning to
|
||||
// measurement values.
|
||||
// Plaintext (intentional):
|
||||
|
|
|
|||
|
|
@ -236,10 +236,10 @@ db.version(1).stores({
|
|||
dreamSymbols: 'id, name, count, updatedAt',
|
||||
dreamTags: 'id, dreamId, tagId, [dreamId+tagId]',
|
||||
|
||||
// ─── Cycles (appId: 'cycles') ───
|
||||
cycles: 'id, startDate, endDate, isPredicted, isArchived, updatedAt',
|
||||
cycleDayLogs: 'id, logDate, cycleId, flow, [cycleId+logDate]',
|
||||
cycleSymptoms: 'id, name, category, count, updatedAt',
|
||||
// ─── Period (appId: 'period') ───
|
||||
periods: 'id, startDate, endDate, isPredicted, isArchived, updatedAt',
|
||||
periodDayLogs: 'id, logDate, periodId, flow, [periodId+logDate]',
|
||||
periodSymptoms: 'id, name, category, count, updatedAt',
|
||||
|
||||
// ─── Social Events (appId: 'events') ───
|
||||
// `socialEvents` is named distinctly to avoid colliding with calendar.events.
|
||||
|
|
|
|||
|
|
@ -442,14 +442,14 @@ export interface SocialEventDeletedPayload {
|
|||
|
||||
export type SocialEventsEventType = 'SocialEventCreated' | 'SocialEventDeleted';
|
||||
|
||||
// ── Cycles ──────────────────────────────────────────
|
||||
// ── Period ──────────────────────────────────────────
|
||||
|
||||
export interface CycleDayLoggedPayload {
|
||||
export interface PeriodDayLoggedPayload {
|
||||
logId: string;
|
||||
date: string;
|
||||
flow?: string;
|
||||
}
|
||||
export type CyclesEventType = 'CycleDayLogged';
|
||||
export type PeriodEventType = 'PeriodDayLogged';
|
||||
|
||||
// ── Firsts ──────────────────────────────────────────
|
||||
|
||||
|
|
@ -653,7 +653,7 @@ export type ManaEventType =
|
|||
| ChatEventType
|
||||
| MemoroEventType
|
||||
| SkilltreeEventType
|
||||
| CyclesEventType
|
||||
| PeriodEventType
|
||||
| FirstsEventType
|
||||
| GuidesEventType
|
||||
| InventoryEventType
|
||||
|
|
@ -746,8 +746,8 @@ export type ManaEvent =
|
|||
// Skilltree
|
||||
| DomainEvent<'SkillXpAdded', SkillXpAddedPayload>
|
||||
| DomainEvent<'SkillCreated', SkillCreatedPayload>
|
||||
// Cycles
|
||||
| DomainEvent<'CycleDayLogged', CycleDayLoggedPayload>
|
||||
// Period
|
||||
| DomainEvent<'PeriodDayLogged', PeriodDayLoggedPayload>
|
||||
// Firsts
|
||||
| DomainEvent<'FirstCreated', FirstCreatedPayload>
|
||||
// Guides
|
||||
|
|
|
|||
|
|
@ -165,7 +165,7 @@ describe('module-registry — pre-refactor snapshot', () => {
|
|||
habits: ['habits', 'habitLogs'],
|
||||
notes: ['notes', 'noteTags'],
|
||||
dreams: ['dreams', 'dreamSymbols', 'dreamTags'],
|
||||
cycles: ['cycles', 'cycleDayLogs', 'cycleSymptoms'],
|
||||
period: ['periods', 'periodDayLogs', 'periodSymptoms'],
|
||||
events: ['socialEvents', 'eventGuests', 'eventInvitations', 'eventItems'],
|
||||
finance: ['transactions', 'financeCategories', 'budgets'],
|
||||
places: ['places', 'locationLogs', 'placeTags'],
|
||||
|
|
|
|||
|
|
@ -79,7 +79,7 @@ import { habitsModuleConfig } from '$lib/modules/habits/module.config';
|
|||
import { notesModuleConfig } from '$lib/modules/notes/module.config';
|
||||
import { journalModuleConfig } from '$lib/modules/journal/module.config';
|
||||
import { dreamsModuleConfig } from '$lib/modules/dreams/module.config';
|
||||
import { cyclesModuleConfig } from '$lib/modules/cycles/module.config';
|
||||
import { periodModuleConfig } from '$lib/modules/period/module.config';
|
||||
import { eventsModuleConfig } from '$lib/modules/events/module.config';
|
||||
import { financeModuleConfig } from '$lib/modules/finance/module.config';
|
||||
import { placesModuleConfig } from '$lib/modules/places/module.config';
|
||||
|
|
@ -129,7 +129,7 @@ export const MODULE_CONFIGS: readonly ModuleConfig[] = [
|
|||
notesModuleConfig,
|
||||
journalModuleConfig,
|
||||
dreamsModuleConfig,
|
||||
cyclesModuleConfig,
|
||||
periodModuleConfig,
|
||||
eventsModuleConfig,
|
||||
financeModuleConfig,
|
||||
placesModuleConfig,
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ const TYPE_COLORS: Record<TimeBlockType, string> = {
|
|||
watering: '#06b6d4',
|
||||
sleep: '#6366f1',
|
||||
practice: '#f97316',
|
||||
cycle: '#ec4899',
|
||||
period: '#ec4899',
|
||||
guide: '#14b8a6',
|
||||
visit: '#a855f7',
|
||||
study: '#0ea5e9',
|
||||
|
|
@ -49,7 +49,7 @@ const TYPE_LABELS: Record<TimeBlockType, string> = {
|
|||
watering: 'Gießen',
|
||||
sleep: 'Schlaf',
|
||||
practice: 'Übung',
|
||||
cycle: 'Zyklus',
|
||||
period: 'Periode',
|
||||
guide: 'Guides',
|
||||
visit: 'Besuche',
|
||||
study: 'Lernen',
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ export type TimeBlockType =
|
|||
| 'watering'
|
||||
| 'sleep'
|
||||
| 'practice'
|
||||
| 'cycle'
|
||||
| 'period'
|
||||
| 'guide'
|
||||
| 'visit'
|
||||
| 'study'
|
||||
|
|
@ -41,7 +41,7 @@ export type TimeBlockSourceModule =
|
|||
| 'plants'
|
||||
| 'dreams'
|
||||
| 'skilltree'
|
||||
| 'cycles'
|
||||
| 'period'
|
||||
| 'guides'
|
||||
| 'places'
|
||||
| 'cards'
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@ import { storageTools } from '$lib/modules/storage/tools';
|
|||
import { chatTools } from '$lib/modules/chat/tools';
|
||||
import { memoroTools } from '$lib/modules/memoro/tools';
|
||||
import { skilltreeTools } from '$lib/modules/skilltree/tools';
|
||||
import { cyclesTools } from '$lib/modules/cycles/tools';
|
||||
import { periodTools } from '$lib/modules/period/tools';
|
||||
import { firstsTools } from '$lib/modules/firsts/tools';
|
||||
import { guidesTools } from '$lib/modules/guides/tools';
|
||||
import { inventoryTools } from '$lib/modules/inventory/tools';
|
||||
|
|
@ -59,7 +59,7 @@ export function initTools(): void {
|
|||
registerTools(chatTools);
|
||||
registerTools(memoroTools);
|
||||
registerTools(skilltreeTools);
|
||||
registerTools(cyclesTools);
|
||||
registerTools(periodTools);
|
||||
registerTools(firstsTools);
|
||||
registerTools(guidesTools);
|
||||
registerTools(inventoryTools);
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ function registerLocale(lang: SupportedLocale) {
|
|||
questions,
|
||||
guides,
|
||||
help,
|
||||
cycles,
|
||||
period,
|
||||
news,
|
||||
body,
|
||||
] = await Promise.all([
|
||||
|
|
@ -84,7 +84,7 @@ function registerLocale(lang: SupportedLocale) {
|
|||
import(`./locales/questions/${lang}.json`),
|
||||
import(`./locales/guides/${lang}.json`),
|
||||
import(`./locales/help/${lang}.json`),
|
||||
import(`./locales/cycles/${lang}.json`),
|
||||
import(`./locales/period/${lang}.json`),
|
||||
import(`./locales/news/${lang}.json`),
|
||||
import(`./locales/body/${lang}.json`),
|
||||
]);
|
||||
|
|
@ -122,7 +122,7 @@ function registerLocale(lang: SupportedLocale) {
|
|||
questions: questions.default,
|
||||
guides: guides.default,
|
||||
help: help.default,
|
||||
cycles: cycles.default,
|
||||
period: period.default,
|
||||
news: news.default,
|
||||
body: body.default,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"citycorners": "Stadtführer",
|
||||
"uload": "uLoad",
|
||||
"calc": "Rechner",
|
||||
"cycles": "Zyklus",
|
||||
"period": "Periode",
|
||||
"body": "Körper",
|
||||
"dreams": "Träume",
|
||||
"journal": "Tagebuch",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"citycorners": "City Guide",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculator",
|
||||
"cycles": "Cycles",
|
||||
"period": "Period",
|
||||
"body": "Body",
|
||||
"dreams": "Dreams",
|
||||
"journal": "Journal",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"citycorners": "Guía urbana",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculadora",
|
||||
"cycles": "Ciclo",
|
||||
"period": "Ciclo",
|
||||
"body": "Cuerpo",
|
||||
"dreams": "Sueños",
|
||||
"journal": "Diario",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"citycorners": "Guide urbain",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calculatrice",
|
||||
"cycles": "Cycle",
|
||||
"period": "Règles",
|
||||
"body": "Corps",
|
||||
"dreams": "Rêves",
|
||||
"journal": "Journal",
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
"citycorners": "Guida città",
|
||||
"uload": "uLoad",
|
||||
"calc": "Calcolatrice",
|
||||
"cycles": "Ciclo",
|
||||
"period": "Ciclo",
|
||||
"body": "Corpo",
|
||||
"dreams": "Sogni",
|
||||
"journal": "Diario",
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@
|
|||
"description": "Letzte Änderungen über alle Module",
|
||||
"empty": "Noch keine Aktivität"
|
||||
},
|
||||
"cycles": {
|
||||
"period": {
|
||||
"title": "Zyklus",
|
||||
"description": "Aktuelle Phase und Countdown zur nächsten Periode",
|
||||
"empty": "Noch kein Zyklus erfasst.",
|
||||
|
|
|
|||
|
|
@ -145,7 +145,7 @@
|
|||
"description": "Recent changes across all modules",
|
||||
"empty": "No activity yet"
|
||||
},
|
||||
"cycles": {
|
||||
"period": {
|
||||
"title": "Cycle",
|
||||
"description": "Current phase and countdown to next period",
|
||||
"empty": "No cycle logged yet.",
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
"description": "Línea temporal cronológica de todas las actividades",
|
||||
"empty": "Nada todavía hoy"
|
||||
},
|
||||
"cycles": {
|
||||
"period": {
|
||||
"title": "Ciclo",
|
||||
"description": "Fase actual y cuenta regresiva hasta el próximo período",
|
||||
"empty": "Ningún ciclo registrado.",
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
"description": "Chronologie de toutes les activités de la journée",
|
||||
"empty": "Rien encore aujourd'hui"
|
||||
},
|
||||
"cycles": {
|
||||
"period": {
|
||||
"title": "Cycle",
|
||||
"description": "Phase actuelle et compte à rebours jusqu'aux prochaines règles",
|
||||
"empty": "Aucun cycle enregistré.",
|
||||
|
|
|
|||
|
|
@ -140,7 +140,7 @@
|
|||
"description": "Cronologia di tutte le attività della giornata",
|
||||
"empty": "Niente ancora oggi"
|
||||
},
|
||||
"cycles": {
|
||||
"period": {
|
||||
"title": "Ciclo",
|
||||
"description": "Fase attuale e conto alla rovescia per il prossimo ciclo",
|
||||
"empty": "Nessun ciclo registrato.",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Cycles",
|
||||
"name": "Period",
|
||||
"tagline": "Menstruationszyklus-Tracking"
|
||||
},
|
||||
"phase": {
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"bad": "Schlecht"
|
||||
},
|
||||
"label": {
|
||||
"cycleDay": "Zyklustag",
|
||||
"periodDay": "Zyklustag",
|
||||
"daysUntilPeriod": "Tage bis zur Periode",
|
||||
"today": "Heute",
|
||||
"predicted": "vorhergesagt",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"avgDays": "Ø Tage",
|
||||
"shortest": "kürzester",
|
||||
"longest": "längster",
|
||||
"cycles": "Zyklen",
|
||||
"period": "Zyklen",
|
||||
"nextPeriod": "Nächste Periode:",
|
||||
"fertileWindow": "Fruchtbares Fenster:"
|
||||
},
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Cycles",
|
||||
"name": "Period",
|
||||
"tagline": "Menstrual Cycle Tracking"
|
||||
},
|
||||
"phase": {
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"bad": "Bad"
|
||||
},
|
||||
"label": {
|
||||
"cycleDay": "Cycle day",
|
||||
"periodDay": "Cycle day",
|
||||
"daysUntilPeriod": "days until period",
|
||||
"today": "Today",
|
||||
"predicted": "predicted",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"avgDays": "Avg days",
|
||||
"shortest": "shortest",
|
||||
"longest": "longest",
|
||||
"cycles": "cycles",
|
||||
"period": "period",
|
||||
"nextPeriod": "Next period:",
|
||||
"fertileWindow": "Fertile window:"
|
||||
},
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Cycles",
|
||||
"name": "Period",
|
||||
"tagline": "Seguimiento del ciclo menstrual"
|
||||
},
|
||||
"phase": {
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"bad": "Malo"
|
||||
},
|
||||
"label": {
|
||||
"cycleDay": "Día del ciclo",
|
||||
"periodDay": "Día del ciclo",
|
||||
"daysUntilPeriod": "días hasta el período",
|
||||
"today": "Hoy",
|
||||
"predicted": "previsto",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"avgDays": "Prom. días",
|
||||
"shortest": "más corto",
|
||||
"longest": "más largo",
|
||||
"cycles": "ciclos",
|
||||
"period": "ciclos",
|
||||
"nextPeriod": "Próximo período:",
|
||||
"fertileWindow": "Ventana fértil:"
|
||||
},
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Cycles",
|
||||
"name": "Period",
|
||||
"tagline": "Suivi du cycle menstruel"
|
||||
},
|
||||
"phase": {
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"bad": "Mauvais"
|
||||
},
|
||||
"label": {
|
||||
"cycleDay": "Jour du cycle",
|
||||
"periodDay": "Jour du cycle",
|
||||
"daysUntilPeriod": "jours avant les règles",
|
||||
"today": "Aujourd'hui",
|
||||
"predicted": "prévu",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"avgDays": "Moy. jours",
|
||||
"shortest": "plus court",
|
||||
"longest": "plus long",
|
||||
"cycles": "cycles",
|
||||
"period": "period",
|
||||
"nextPeriod": "Prochaines règles :",
|
||||
"fertileWindow": "Fenêtre fertile :"
|
||||
},
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"app": {
|
||||
"name": "Cycles",
|
||||
"name": "Period",
|
||||
"tagline": "Monitoraggio del ciclo mestruale"
|
||||
},
|
||||
"phase": {
|
||||
|
|
@ -25,7 +25,7 @@
|
|||
"bad": "Cattivo"
|
||||
},
|
||||
"label": {
|
||||
"cycleDay": "Giorno del ciclo",
|
||||
"periodDay": "Giorno del ciclo",
|
||||
"daysUntilPeriod": "giorni al ciclo",
|
||||
"today": "Oggi",
|
||||
"predicted": "previsto",
|
||||
|
|
@ -53,7 +53,7 @@
|
|||
"avgDays": "Media gg",
|
||||
"shortest": "più breve",
|
||||
"longest": "più lungo",
|
||||
"cycles": "cicli",
|
||||
"period": "cicli",
|
||||
"nextPeriod": "Prossimo ciclo:",
|
||||
"fertileWindow": "Finestra fertile:"
|
||||
},
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* i18n parity test for the cycles module.
|
||||
* i18n parity test for the period module.
|
||||
*
|
||||
* Ensures all 5 locale files (de/en/it/fr/es) have identical key
|
||||
* structure — stub copies of en.json would otherwise drift silently
|
||||
|
|
@ -41,7 +41,7 @@ const locales = {
|
|||
es: es as Dict,
|
||||
};
|
||||
|
||||
describe('cycles i18n parity', () => {
|
||||
describe('period i18n parity', () => {
|
||||
const referenceKeys = flattenKeys(locales.de);
|
||||
|
||||
test('de has a non-empty set of keys', () => {
|
||||
|
|
@ -49,7 +49,7 @@
|
|||
{ type: 'watering', label: 'Gießen', icon: Drop },
|
||||
{ type: 'sleep', label: 'Schlaf', icon: Moon },
|
||||
{ type: 'practice', label: 'Übung', icon: GraduationCap },
|
||||
{ type: 'cycle', label: 'Zyklus', icon: FlowerLotus },
|
||||
{ type: 'period', label: 'Periode', icon: FlowerLotus },
|
||||
{ type: 'guide', label: 'Guides', icon: Compass },
|
||||
{ type: 'visit', label: 'Besuche', icon: MapPin },
|
||||
{ type: 'study', label: 'Lernen', icon: BookOpen },
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@ let visibleBlockTypes = $state<Set<TimeBlockType>>(
|
|||
'watering',
|
||||
'sleep',
|
||||
'practice',
|
||||
'cycle',
|
||||
'period',
|
||||
'guide',
|
||||
'visit',
|
||||
'study',
|
||||
|
|
|
|||
|
|
@ -1,32 +1,32 @@
|
|||
<script lang="ts">
|
||||
/**
|
||||
* CyclesWidget — Aktuelle Phase + Countdown bis zur nächsten Periode.
|
||||
* PeriodWidget — Aktuelle Phase + Countdown bis zur nächsten Periode.
|
||||
*
|
||||
* Liest direkt aus der unified IndexedDB (cycles table) und leitet Phase
|
||||
* + Vorhersage pro Render ab. Linkt zur /cycles Route.
|
||||
* Liest direkt aus der unified IndexedDB (period table) und leitet Phase
|
||||
* + Vorhersage pro Render ab. Linkt zur /period Route.
|
||||
*/
|
||||
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import { liveQuery } from 'dexie';
|
||||
import { db } from '$lib/data/database';
|
||||
import { derivePhase, getCycleDayNumber } from '$lib/modules/cycles/utils/phase';
|
||||
import { derivePhase, getPeriodDayNumber } from '$lib/modules/period/utils/phase';
|
||||
import {
|
||||
daysUntilNextPeriod,
|
||||
predictNextPeriodStart,
|
||||
} from '$lib/modules/cycles/utils/prediction';
|
||||
import { PHASE_COLORS, type Cycle, type LocalCycle } from '$lib/modules/cycles/types';
|
||||
import { toCycle } from '$lib/modules/cycles/queries';
|
||||
} from '$lib/modules/period/utils/prediction';
|
||||
import { PHASE_COLORS, type Period, type LocalPeriod } from '$lib/modules/period/types';
|
||||
import { toPeriod } from '$lib/modules/period/queries';
|
||||
|
||||
let cycles: Cycle[] = $state([]);
|
||||
let period: Period[] = $state([]);
|
||||
let loading = $state(true);
|
||||
|
||||
$effect(() => {
|
||||
const sub = liveQuery(async () => {
|
||||
const locals = await db.table<LocalCycle>('cycles').toArray();
|
||||
return locals.filter((c) => !c.deletedAt && !c.isArchived).map(toCycle);
|
||||
const locals = await db.table<LocalPeriod>('periods').toArray();
|
||||
return locals.filter((c) => !c.deletedAt && !c.isArchived).map(toPeriod);
|
||||
}).subscribe({
|
||||
next: (val) => {
|
||||
cycles = val;
|
||||
period = val;
|
||||
loading = false;
|
||||
},
|
||||
error: () => {
|
||||
|
|
@ -38,15 +38,15 @@
|
|||
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
const phase = $derived(derivePhase(todayIso, cycles));
|
||||
const currentCycle = $derived(
|
||||
cycles
|
||||
const phase = $derived(derivePhase(todayIso, period));
|
||||
const currentPeriod = $derived(
|
||||
period
|
||||
.filter((c) => !c.isPredicted)
|
||||
.sort((a, b) => b.startDate.localeCompare(a.startDate))[0] ?? null
|
||||
);
|
||||
const cycleDay = $derived(currentCycle ? getCycleDayNumber(todayIso, currentCycle) : null);
|
||||
const daysUntil = $derived(daysUntilNextPeriod(cycles));
|
||||
const nextPeriod = $derived(predictNextPeriodStart(cycles));
|
||||
const periodDay = $derived(currentPeriod ? getPeriodDayNumber(todayIso, currentPeriod) : null);
|
||||
const daysUntil = $derived(daysUntilNextPeriod(period));
|
||||
const nextPeriod = $derived(predictNextPeriodStart(period));
|
||||
|
||||
const dateLocale = $derived.by(() => {
|
||||
const l = $locale ?? 'de';
|
||||
|
|
@ -62,7 +62,7 @@
|
|||
<div>
|
||||
<div class="mb-3 flex items-center justify-between">
|
||||
<h3 class="flex items-center gap-2 text-lg font-semibold">
|
||||
{$_('dashboard.widgets.cycles.title')}
|
||||
{$_('dashboard.widgets.period.title')}
|
||||
</h3>
|
||||
{#if phase !== 'unknown'}
|
||||
<span
|
||||
|
|
@ -71,37 +71,37 @@
|
|||
phase
|
||||
]} 14%, transparent); color: {PHASE_COLORS[phase]}"
|
||||
>
|
||||
{$_(`cycles.phase.${phase}`)}
|
||||
{$_(`period.phase.${phase}`)}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if loading}
|
||||
<div class="h-20 animate-pulse rounded bg-surface-hover"></div>
|
||||
{:else if cycles.length === 0}
|
||||
{:else if period.length === 0}
|
||||
<div class="py-6 text-center">
|
||||
<p class="text-sm text-muted-foreground">
|
||||
{$_('dashboard.widgets.cycles.empty')}
|
||||
{$_('dashboard.widgets.period.empty')}
|
||||
</p>
|
||||
<a
|
||||
href="/cycles"
|
||||
href="/period"
|
||||
class="mt-3 inline-block rounded-lg bg-primary/10 px-4 py-2 text-sm font-medium text-primary hover:bg-primary/20"
|
||||
>
|
||||
{$_('dashboard.widgets.cycles.open')}
|
||||
{$_('dashboard.widgets.period.open')}
|
||||
</a>
|
||||
</div>
|
||||
{:else}
|
||||
<a
|
||||
href="/cycles"
|
||||
href="/period"
|
||||
class="block rounded-lg p-3 transition-colors hover:bg-surface-hover"
|
||||
style="background: color-mix(in srgb, {PHASE_COLORS[phase]} 6%, transparent);"
|
||||
>
|
||||
<div class="flex items-baseline justify-between gap-3">
|
||||
<div>
|
||||
{#if cycleDay}
|
||||
{#if periodDay}
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{$_('cycles.label.cycleDay')}
|
||||
{cycleDay}
|
||||
{$_('period.label.periodDay')}
|
||||
{periodDay}
|
||||
</p>
|
||||
{/if}
|
||||
{#if daysUntil !== null}
|
||||
|
|
@ -109,18 +109,18 @@
|
|||
{#if daysUntil > 0}
|
||||
{daysUntil}
|
||||
{:else if daysUntil === 0}
|
||||
{$_('cycles.label.today')}
|
||||
{$_('period.label.today')}
|
||||
{:else}
|
||||
+{Math.abs(daysUntil)}
|
||||
{/if}
|
||||
</p>
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{#if daysUntil > 0}
|
||||
{$_('cycles.label.daysUntilPeriod')}
|
||||
{$_('period.label.daysUntilPeriod')}
|
||||
{:else if daysUntil === 0}
|
||||
{$_('cycles.label.predicted')}
|
||||
{$_('period.label.predicted')}
|
||||
{:else}
|
||||
{$_('cycles.label.daysOverdue')}
|
||||
{$_('period.label.daysOverdue')}
|
||||
{/if}
|
||||
</p>
|
||||
{/if}
|
||||
|
|
@ -128,7 +128,7 @@
|
|||
{#if nextPeriod && daysUntil !== null && daysUntil >= 0}
|
||||
<div class="text-right">
|
||||
<p class="text-xs text-muted-foreground">
|
||||
{$_('cycles.stats.nextPeriod')}
|
||||
{$_('period.stats.nextPeriod')}
|
||||
</p>
|
||||
<p class="text-sm font-medium">{formatShortDate(nextPeriod)}</p>
|
||||
</div>
|
||||
|
|
@ -1,28 +1,28 @@
|
|||
<!--
|
||||
Cycles — Workbench ListView
|
||||
Periods — Workbench ListView
|
||||
Aktueller Zyklus, heutiger Quick-Log, einfache Statistiken.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import {
|
||||
formatLogDate,
|
||||
useAllCycles,
|
||||
useAllPeriods,
|
||||
useAllDayLogs,
|
||||
useAllSymptoms,
|
||||
useCurrentCycle,
|
||||
useCurrentPeriod,
|
||||
type RelativeDateLabels,
|
||||
} from './queries';
|
||||
import { cyclesStore } from './stores/cycles.svelte';
|
||||
import { periodsStore } from './stores/periods.svelte';
|
||||
import { dayLogsStore } from './stores/dayLogs.svelte';
|
||||
import { derivePhase, getCycleDayNumber } from './utils/phase';
|
||||
import { derivePhase, getPeriodDayNumber } from './utils/phase';
|
||||
import {
|
||||
computeCycleStats,
|
||||
computePeriodStats,
|
||||
daysUntilNextPeriod,
|
||||
predictFertileWindow,
|
||||
predictNextPeriodStart,
|
||||
} from './utils/prediction';
|
||||
import { FLOW_COLORS, MOOD_COLORS, PHASE_COLORS, type Flow, type Mood } from './types';
|
||||
import CycleCalendar from './components/CycleCalendar.svelte';
|
||||
import PeriodCalendar from './components/PeriodCalendar.svelte';
|
||||
import SymptomManager from './components/SymptomManager.svelte';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
import { toast } from '$lib/stores/toast.svelte';
|
||||
|
|
@ -33,15 +33,15 @@
|
|||
|
||||
const todayIso = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let cycles$ = useAllCycles();
|
||||
let periods$ = useAllPeriods();
|
||||
let logs$ = useAllDayLogs();
|
||||
let symptoms$ = useAllSymptoms();
|
||||
let current$ = useCurrentCycle();
|
||||
let current$ = useCurrentPeriod();
|
||||
|
||||
let cycles = $derived(cycles$.value);
|
||||
let periods = $derived(periods$.value);
|
||||
let logs = $derived(logs$.value);
|
||||
let symptoms = $derived(symptoms$.value);
|
||||
let currentCycle = $derived(current$.value);
|
||||
let currentPeriod = $derived(current$.value);
|
||||
|
||||
// Locale-aware date formatting: use the active svelte-i18n locale, with
|
||||
// 'de-DE' as a fallback since the project defaults to German.
|
||||
|
|
@ -51,17 +51,17 @@
|
|||
});
|
||||
|
||||
const relativeLabels = $derived<RelativeDateLabels>({
|
||||
today: $_('cycles.relativeDate.today'),
|
||||
yesterday: $_('cycles.relativeDate.yesterday'),
|
||||
daysAgo: (n: number) => $_('cycles.relativeDate.daysAgo', { values: { days: n } }),
|
||||
today: $_('period.relativeDate.today'),
|
||||
yesterday: $_('period.relativeDate.yesterday'),
|
||||
daysAgo: (n: number) => $_('period.relativeDate.daysAgo', { values: { days: n } }),
|
||||
});
|
||||
|
||||
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));
|
||||
let phase = $derived(derivePhase(todayIso, periods));
|
||||
let periodDay = $derived(currentPeriod ? getPeriodDayNumber(todayIso, currentPeriod) : null);
|
||||
let stats = $derived(computePeriodStats(periods));
|
||||
let daysUntil = $derived(daysUntilNextPeriod(periods));
|
||||
let nextPeriod = $derived(predictNextPeriodStart(periods));
|
||||
let fertile = $derived(predictFertileWindow(periods));
|
||||
|
||||
const FLOWS: Flow[] = ['none', 'spotting', 'light', 'medium', 'heavy'];
|
||||
const MOODS: Mood[] = ['great', 'good', 'neutral', 'low', 'bad'];
|
||||
|
|
@ -148,21 +148,21 @@
|
|||
return;
|
||||
}
|
||||
const dateStr = new Date(editingDate).toLocaleDateString(dateLocale);
|
||||
const ok = confirm($_('cycles.confirm.deleteEntry', { values: { date: dateStr } }));
|
||||
const ok = confirm($_('period.confirm.deleteEntry', { values: { date: dateStr } }));
|
||||
if (!ok) return;
|
||||
await dayLogsStore.deleteLog(editingLog.id);
|
||||
backToToday();
|
||||
}
|
||||
|
||||
async function startPeriodToday() {
|
||||
await cyclesStore.createCycle({ startDate: todayIso });
|
||||
await periodsStore.createPeriod({ startDate: todayIso });
|
||||
await safeLogDay({ logDate: todayIso, flow: 'medium' });
|
||||
backToToday();
|
||||
}
|
||||
|
||||
async function endPeriodToday() {
|
||||
if (!currentCycle) return;
|
||||
await cyclesStore.setPeriodEnd(currentCycle.id, todayIso);
|
||||
if (!currentPeriod) return;
|
||||
await periodsStore.setPeriodEnd(currentPeriod.id, todayIso);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
|
|
@ -180,34 +180,34 @@
|
|||
<div class="phase-top">
|
||||
<span class="phase-dot"></span>
|
||||
<div class="phase-info">
|
||||
<span class="phase-label">{$_(`cycles.phase.${phase}`)}</span>
|
||||
{#if cycleDay}
|
||||
<span class="phase-sub">{$_('cycles.label.cycleDay')} {cycleDay}</span>
|
||||
<span class="phase-label">{$_(`period.phase.${phase}`)}</span>
|
||||
{#if periodDay}
|
||||
<span class="phase-sub">{$_('period.label.periodDay')} {periodDay}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if daysUntil !== null}
|
||||
<div class="phase-countdown">
|
||||
{#if daysUntil > 0}
|
||||
<strong>{daysUntil}</strong>
|
||||
<span>{$_('cycles.label.daysUntilPeriod')}</span>
|
||||
<span>{$_('period.label.daysUntilPeriod')}</span>
|
||||
{:else if daysUntil === 0}
|
||||
<strong>{$_('cycles.label.today')}</strong>
|
||||
<span>{$_('cycles.label.predicted')}</span>
|
||||
<strong>{$_('period.label.today')}</strong>
|
||||
<span>{$_('period.label.predicted')}</span>
|
||||
{:else}
|
||||
<strong>{Math.abs(daysUntil)}</strong>
|
||||
<span>{$_('cycles.label.daysOverdue')}</span>
|
||||
<span>{$_('period.label.daysOverdue')}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="phase-actions">
|
||||
{#if !currentCycle || (currentCycle.periodEndDate && currentCycle.periodEndDate < todayIso && phase !== 'menstruation')}
|
||||
{#if !currentPeriod || (currentPeriod.periodEndDate && currentPeriod.periodEndDate < todayIso && phase !== 'menstruation')}
|
||||
<button class="btn-primary" onclick={startPeriodToday}
|
||||
>{$_('cycles.action.startPeriod')}</button
|
||||
>{$_('period.action.startPeriod')}</button
|
||||
>
|
||||
{:else if currentCycle && !currentCycle.periodEndDate}
|
||||
{:else if currentPeriod && !currentPeriod.periodEndDate}
|
||||
<button class="btn-secondary" onclick={endPeriodToday}
|
||||
>{$_('cycles.action.endPeriod')}</button
|
||||
>{$_('period.action.endPeriod')}</button
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -216,13 +216,13 @@
|
|||
<!-- Calendar -->
|
||||
<section class="log-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-label">{$_('cycles.calendar.title')}</h3>
|
||||
<h3 class="section-label">{$_('period.calendar.title')}</h3>
|
||||
<button class="section-action" onclick={() => (calendarOpen = !calendarOpen)}>
|
||||
{calendarOpen ? '−' : '+'}
|
||||
</button>
|
||||
</div>
|
||||
{#if calendarOpen}
|
||||
<CycleCalendar {cycles} {logs} {editingDate} {todayIso} onSelectDay={selectDay} />
|
||||
<PeriodCalendar {periods} {logs} {editingDate} {todayIso} onSelectDay={selectDay} />
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
|
|
@ -230,16 +230,16 @@
|
|||
{#if isEditingPast}
|
||||
<div class="edit-banner">
|
||||
<span class="edit-banner-label">
|
||||
{$_('cycles.label.editing')}
|
||||
{$_('period.label.editing')}
|
||||
<strong>{new Date(editingDate).toLocaleDateString(dateLocale)}</strong>
|
||||
</span>
|
||||
<div class="edit-banner-actions">
|
||||
{#if editingLog}
|
||||
<button class="banner-btn danger" onclick={deleteEditingLog}
|
||||
>{$_('cycles.action.delete')}</button
|
||||
>{$_('period.action.delete')}</button
|
||||
>
|
||||
{/if}
|
||||
<button class="banner-btn" onclick={backToToday}>{$_('cycles.action.backToToday')}</button>
|
||||
<button class="banner-btn" onclick={backToToday}>{$_('period.action.backToToday')}</button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
@ -247,7 +247,7 @@
|
|||
<!-- Flow -->
|
||||
<section class="log-section">
|
||||
<h3 class="section-label">
|
||||
{isEditingPast ? $_('cycles.label.bleeding') : $_('cycles.label.todayBleeding')}
|
||||
{isEditingPast ? $_('period.label.bleeding') : $_('period.label.todayBleeding')}
|
||||
</h3>
|
||||
<div class="row">
|
||||
{#each FLOWS as flow}
|
||||
|
|
@ -258,7 +258,7 @@
|
|||
onclick={() => setFlow(flow)}
|
||||
>
|
||||
<span class="flow-dot"></span>
|
||||
{$_(`cycles.flow.${flow}`)}
|
||||
{$_(`period.flow.${flow}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -266,7 +266,7 @@
|
|||
|
||||
<!-- Mood -->
|
||||
<section class="log-section">
|
||||
<h3 class="section-label">{$_('cycles.label.mood')}</h3>
|
||||
<h3 class="section-label">{$_('period.label.mood')}</h3>
|
||||
<div class="row">
|
||||
{#each MOODS as mood}
|
||||
<button
|
||||
|
|
@ -276,7 +276,7 @@
|
|||
onclick={() => setMood(mood)}
|
||||
>
|
||||
<span class="mood-dot"></span>
|
||||
{$_(`cycles.mood.${mood}`)}
|
||||
{$_(`period.mood.${mood}`)}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
|
|
@ -285,9 +285,9 @@
|
|||
<!-- Symptoms -->
|
||||
<section class="log-section">
|
||||
<div class="section-header">
|
||||
<h3 class="section-label">{$_('cycles.label.symptoms')}</h3>
|
||||
<h3 class="section-label">{$_('period.label.symptoms')}</h3>
|
||||
<button class="section-action" onclick={() => (symptomManagerOpen = true)}>
|
||||
{$_('cycles.symptomManager.open')}
|
||||
{$_('period.symptomManager.open')}
|
||||
</button>
|
||||
</div>
|
||||
{#if symptoms.length > 0}
|
||||
|
|
@ -309,20 +309,20 @@
|
|||
|
||||
<!-- Temperature & Notes -->
|
||||
<section class="log-section">
|
||||
<h3 class="section-label">{$_('cycles.label.basalAndNotes')}</h3>
|
||||
<h3 class="section-label">{$_('period.label.basalAndNotes')}</h3>
|
||||
<div class="row inputs">
|
||||
<input
|
||||
type="number"
|
||||
step="0.01"
|
||||
class="temp-input"
|
||||
placeholder={$_('cycles.input.temperaturePlaceholder')}
|
||||
placeholder={$_('period.input.temperaturePlaceholder')}
|
||||
bind:value={temperature}
|
||||
onblur={saveTemperature}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
class="notes-input"
|
||||
placeholder={$_('cycles.input.notesPlaceholder')}
|
||||
placeholder={$_('period.input.notesPlaceholder')}
|
||||
bind:value={notesText}
|
||||
onblur={saveNotes}
|
||||
/>
|
||||
|
|
@ -332,30 +332,30 @@
|
|||
<!-- Stats -->
|
||||
{#if stats.total > 0}
|
||||
<section class="log-section stats">
|
||||
<h3 class="section-label">{$_('cycles.label.stats')}</h3>
|
||||
<h3 class="section-label">{$_('period.label.stats')}</h3>
|
||||
<div class="stats-grid">
|
||||
<div class="stat">
|
||||
<strong>{stats.avg}</strong>
|
||||
<span>{$_('cycles.stats.avgDays')}</span>
|
||||
<span>{$_('period.stats.avgDays')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>{stats.shortest}</strong>
|
||||
<span>{$_('cycles.stats.shortest')}</span>
|
||||
<span>{$_('period.stats.shortest')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>{stats.longest}</strong>
|
||||
<span>{$_('cycles.stats.longest')}</span>
|
||||
<span>{$_('period.stats.longest')}</span>
|
||||
</div>
|
||||
<div class="stat">
|
||||
<strong>{stats.total}</strong>
|
||||
<span>{$_('cycles.stats.cycles')}</span>
|
||||
<span>{$_('period.stats.periods')}</span>
|
||||
</div>
|
||||
</div>
|
||||
{#if nextPeriod}
|
||||
<div class="prediction">
|
||||
{$_('cycles.stats.nextPeriod')} <strong>{formatDate(nextPeriod)}</strong>
|
||||
{$_('period.stats.nextPeriod')} <strong>{formatDate(nextPeriod)}</strong>
|
||||
{#if fertile}
|
||||
· {$_('cycles.stats.fertileWindow')}
|
||||
· {$_('period.stats.fertileWindow')}
|
||||
<strong>{formatDate(fertile.start)}</strong> –
|
||||
<strong>{formatDate(fertile.end)}</strong>
|
||||
{/if}
|
||||
|
|
@ -367,7 +367,7 @@
|
|||
<!-- Recent logs -->
|
||||
{#if logs.length > 0}
|
||||
<section class="log-section">
|
||||
<h3 class="section-label">{$_('cycles.label.recentEntries')}</h3>
|
||||
<h3 class="section-label">{$_('period.label.recentEntries')}</h3>
|
||||
<div class="log-list">
|
||||
{#each logs.slice(0, 10) as log (log.id)}
|
||||
<button
|
||||
|
|
@ -383,11 +383,11 @@
|
|||
>{formatLogDate(log.logDate, relativeLabels, dateLocale)}</span
|
||||
>
|
||||
{#if log.flow !== 'none'}
|
||||
<span class="log-tag">{$_(`cycles.flow.${log.flow}`)}</span>
|
||||
<span class="log-tag">{$_(`period.flow.${log.flow}`)}</span>
|
||||
{/if}
|
||||
{#if log.mood}
|
||||
<span class="log-tag" style="color: {MOOD_COLORS[log.mood]}"
|
||||
>{$_(`cycles.mood.${log.mood}`)}</span
|
||||
>{$_(`period.mood.${log.mood}`)}</span
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
|
|
@ -401,8 +401,8 @@
|
|||
</section>
|
||||
{/if}
|
||||
|
||||
{#if cycles.length === 0 && logs.length === 0}
|
||||
<p class="empty">{$_('cycles.empty')}</p>
|
||||
{#if periods.length === 0 && logs.length === 0}
|
||||
<p class="empty">{$_('period.empty')}</p>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,11 @@
|
|||
# Cycles Module — Roadmap
|
||||
# Periods Module — Roadmap
|
||||
|
||||
Ideen für Features, Tests und Refinements, die im aktuellen Sprint nicht gemacht wurden. Sortiert grob nach Aufwand und Impact. Dies ist kein Commitment, sondern ein Ideenspeicher.
|
||||
|
||||
## Was bereits drin ist
|
||||
|
||||
- Datenschicht (v7): `cycles`, `cycleDayLogs`, `cycleSymptoms` mit Sync-Registrierung
|
||||
- Stores: `cyclesStore`, `dayLogsStore`, `symptomsStore` mit Auto-Close, Upsert, Symptom-Counter
|
||||
- Datenschicht (v7): `periods`, `periodDayLogs`, `periodSymptoms` mit Sync-Registrierung
|
||||
- Stores: `periodsStore`, `dayLogsStore`, `symptomsStore` mit Auto-Close, Upsert, Symptom-Counter
|
||||
- Pure Utils: Phase-Ableitung, Prediction (gleitender Mittelwert), Auto-Start/Auto-End-Detection
|
||||
- UI: ListView mit Phase-Karte, Quick-Log (Flow/Mood/Symptome/BBT/Notizen), Kalender-Ansicht, Symptom-Manager, Edit-/Delete-vergangener-Tage-Banner
|
||||
- Dashboard-Widget mit Phase + Countdown
|
||||
|
|
@ -18,14 +18,14 @@ Ideen für Features, Tests und Refinements, die im aktuellen Sprint nicht gemach
|
|||
|
||||
- **Keyboard-Shortcuts** in ListView: `1-5` für Flow-Level, `Esc` für "Zurück zu heute", `J/K` für Previous/Next im Log-Verlauf
|
||||
- **Date-Picker im Edit-Banner**: `<input type="date">` um direkt zu einem Tag zu springen statt im Kalender suchen zu müssen
|
||||
- **Cycle-Notizen-UI**: `cycles.notes` wird gespeichert aber nirgends gerendert. Textarea in der Phase-Karte oder separater Cycle-Detail-View
|
||||
- **Funnel-Tracking**: `trackFirstContent('cycles')` beim ersten Log, analog zu anderen Modulen
|
||||
- **Period-Notizen-UI**: `periods.notes` wird gespeichert aber nirgends gerendert. Textarea in der Phase-Karte oder separater Period-Detail-View
|
||||
- **Funnel-Tracking**: `trackFirstContent('periods')` beim ersten Log, analog zu anderen Modulen
|
||||
|
||||
### Pure-Function-Robustheit
|
||||
|
||||
- **Orphaned Symptom-IDs**: Wenn ein Symptom gelöscht wird, bleiben Day-Logs mit toten IDs zurück. Entweder `symptomsStore.deleteSymptom()` entfernt die IDs aus allen Logs, oder die UI filtert `sym?.deletedAt == null` beim Rendern
|
||||
- **Manual recalculate button**: `cyclesStore.recalculateCycles()` die alle Zyklen re-validiert (Auto-End auch für Tage nachfeuert, die nicht durch einen `logDay`-Aufruf gingen)
|
||||
- **Temperature-Units**: Placeholder in en.json sagt "98.6 °F", gespeichert wird aber als `number` ohne Einheit. Entweder `temperatureUnit: 'C' | 'F'` auf `cycles` oder in `userSettings`
|
||||
- **Manual recalculate button**: `periodsStore.recalculatePeriods()` die alle Zyklen re-validiert (Auto-End auch für Tage nachfeuert, die nicht durch einen `logDay`-Aufruf gingen)
|
||||
- **Temperature-Units**: Placeholder in en.json sagt "98.6 °F", gespeichert wird aber als `number` ohne Einheit. Entweder `temperatureUnit: 'C' | 'F'` auf `periods` oder in `userSettings`
|
||||
|
||||
### i18n-Details
|
||||
|
||||
|
|
@ -36,7 +36,7 @@ Ideen für Features, Tests und Refinements, die im aktuellen Sprint nicht gemach
|
|||
|
||||
### BBT-Chart
|
||||
|
||||
Liniendiagramm der Basaltemperatur über den aktuellen Zyklus. Daten sind schon da (`cycleDayLogs.temperature`), es fehlt nur ein SVG-Chart-Component.
|
||||
Liniendiagramm der Basaltemperatur über den aktuellen Zyklus. Daten sind schon da (`periodDayLogs.temperature`), es fehlt nur ein SVG-Chart-Component.
|
||||
|
||||
- Reines SVG, keine Library nötig
|
||||
- Markiere Ovulation (erkannter Temperaturanstieg) visuell
|
||||
|
|
@ -44,7 +44,7 @@ Liniendiagramm der Basaltemperatur über den aktuellen Zyklus. Daten sind schon
|
|||
|
||||
### History-Seite
|
||||
|
||||
`/cycles/history` mit Liste aller Zyklen:
|
||||
`/periods/history` mit Liste aller Zyklen:
|
||||
|
||||
- Länge pro Zyklus, Durchschnitt, Min/Max
|
||||
- Visuelle Bar pro Zyklus (Menstruation rot, Follikular gelb, Ovulation grün, Luteal lila)
|
||||
|
|
@ -53,19 +53,19 @@ Liniendiagramm der Basaltemperatur über den aktuellen Zyklus. Daten sind schon
|
|||
|
||||
### Detail-Page pro Tag
|
||||
|
||||
`/cycles/log/[date]`:
|
||||
`/periods/log/[date]`:
|
||||
|
||||
- Vollständiger Editor mit allen Feldern: cervicalMucus-Picker, sexualActivity-Toggle, Energy-Slider
|
||||
- Textarea mit mehr Platz für Notizen statt Inline-Input
|
||||
- Vergleich mit Vortag / Vorjahr
|
||||
|
||||
### Cycle-Notizen-Panel
|
||||
### Period-Notizen-Panel
|
||||
|
||||
In der Phase-Karte eine ausklappbare Notiz-Sektion pro Zyklus. `cycles.notes` wird aktuell persistiert aber nicht angezeigt.
|
||||
In der Phase-Karte eine ausklappbare Notiz-Sektion pro Zyklus. `periods.notes` wird aktuell persistiert aber nicht angezeigt.
|
||||
|
||||
### Pattern-Erkennung (lokal, ohne LLM)
|
||||
|
||||
- "Du hast in 4 von 5 Lutealphasen Kopfschmerzen" — reine Aggregation über `cycleDayLogs` + `cycleSymptoms`
|
||||
- "Du hast in 4 von 5 Lutealphasen Kopfschmerzen" — reine Aggregation über `periodDayLogs` + `periodSymptoms`
|
||||
- "Dein aktueller Zyklus ist {avg + 7} Tage lang — ungewöhnlich lang"
|
||||
- "Das fruchtbare Fenster beginnt in 2 Tagen"
|
||||
- Eigene Sektion in der ListView unter "Statistik"
|
||||
|
|
@ -99,13 +99,13 @@ Vollständiger Happy Path über die neu hinzugefügte `e2e/`-Infrastruktur:
|
|||
|
||||
Unit-Test mit `fake-indexeddb` der eine alte v6-DB erstellt, die Migration auf v10+ laufen lässt, und prüft:
|
||||
|
||||
- `cycles`-Tabellen existieren
|
||||
- `periods`-Tabellen existieren
|
||||
- Legacy-Daten unverändert
|
||||
- `_pendingChanges` korrekt getaggt mit `appId: 'cycles'`
|
||||
- `_pendingChanges` korrekt getaggt mit `appId: 'periods'`
|
||||
|
||||
### Guest-Seed-Test
|
||||
|
||||
Beim ersten Öffnen als Guest sollten die 10 Default-Symptome angelegt werden. Aktuell ist `CYCLES_GUEST_SEED` exportiert, aber es gibt keinen Test, der prüft dass das Seed-Laden tatsächlich passiert.
|
||||
Beim ersten Öffnen als Guest sollten die 10 Default-Symptome angelegt werden. Aktuell ist `PERIODS_GUEST_SEED` exportiert, aber es gibt keinen Test, der prüft dass das Seed-Laden tatsächlich passiert.
|
||||
|
||||
## Langfristig — Produktreife
|
||||
|
||||
|
|
@ -130,22 +130,22 @@ Beim ersten Öffnen als Guest sollten die 10 Default-Symptome angelegt werden. A
|
|||
|
||||
### Datenschutz-Modus
|
||||
|
||||
Cycles ist sensibler als die meisten anderen Module. Optionen:
|
||||
Periods ist sensibler als die meisten anderen Module. Optionen:
|
||||
|
||||
- **App-Lock**: Extra PIN / Biometrie vor Öffnen von `/cycles`
|
||||
- **App-Lock**: Extra PIN / Biometrie vor Öffnen von `/periods`
|
||||
- **Versteckter App-Switcher-Eintrag**: Nur über Direkt-URL erreichbar
|
||||
- **Lokale Verschlüsselung**: `cycles`-Tabellen mit einem User-Passwort verschlüsseln (würde ein neues Pattern im Repo etablieren, das später auch Memoro/Dreams nutzen könnten)
|
||||
- **Lokale Verschlüsselung**: `periods`-Tabellen mit einem User-Passwort verschlüsseln (würde ein neues Pattern im Repo etablieren, das später auch Memoro/Dreams nutzen könnten)
|
||||
- **Field-level Sync-Control**: Manche Felder (sexualActivity, detaillierte Notizen) nur lokal halten, andere syncen. Bräuchte Erweiterung von `SYNC_APP_MAP` um Pro-Feld-Regeln
|
||||
|
||||
### Mobile-App
|
||||
|
||||
Cycles ist ein klassischer Mobile-First-Use-Case. Ein Expo-Port kann die unified IndexedDB nicht nutzen, müsste die Sync-Brücke als primäre Datenquelle verwenden. Das wäre ein größeres Projekt, lohnt sich aber nach Produkt-Validierung.
|
||||
Periods ist ein klassischer Mobile-First-Use-Case. Ein Expo-Port kann die unified IndexedDB nicht nutzen, müsste die Sync-Brücke als primäre Datenquelle verwenden. Das wäre ein größeres Projekt, lohnt sich aber nach Produkt-Validierung.
|
||||
|
||||
## Monitoring / Ops
|
||||
|
||||
### ManaScore-Entry
|
||||
|
||||
Modul in `apps/mana/apps/landing/src/content/manascore/cycles.md` eintragen. Initiale Scores (geschätzt):
|
||||
Modul in `apps/mana/apps/landing/src/content/manascore/periods.md` eintragen. Initiale Scores (geschätzt):
|
||||
|
||||
| Kategorie | Score | Begründung |
|
||||
| ------------- | ----- | ------------------------------------------------------------------- |
|
||||
|
|
@ -162,7 +162,7 @@ Modul in `apps/mana/apps/landing/src/content/manascore/cycles.md` eintragen. Ini
|
|||
|
||||
### Ecosystem Health
|
||||
|
||||
Nach nächstem `ecosystem-audit.mjs`-Lauf sollte Cycles positiv zählen für:
|
||||
Nach nächstem `ecosystem-audit.mjs`-Lauf sollte Periods positiv zählen für:
|
||||
|
||||
- Shared Packages (@mana/shared-ui, @mana/local-store, svelte-i18n)
|
||||
- i18n-Coverage (5 Locales, alle voll übersetzt, Parity-Test)
|
||||
|
|
@ -174,7 +174,7 @@ Nach nächstem `ecosystem-audit.mjs`-Lauf sollte Cycles positiv zählen für:
|
|||
|
||||
- **FLOW_LABELS / MOOD_LABELS / PHASE_LABELS / CERVICAL_MUCUS_LABELS** in `types.ts` sind seit der i18n-Umstellung unbenutzt — entweder löschen oder als explizite Fallbacks dokumentieren
|
||||
- **Module CLAUDE.md** im Ordner anlegen, das die Architektur kurz beschreibt (wie andere Module es haben)
|
||||
- **JSDoc-Comments** auf public Store-Methoden erweitern, besonders auf `cyclesStore.createCycle` (Auto-Close-Verhalten ist nicht offensichtlich)
|
||||
- **JSDoc-Comments** auf public Store-Methoden erweitern, besonders auf `periodsStore.createPeriod` (Auto-Close-Verhalten ist nicht offensichtlich)
|
||||
|
||||
## Nicht-Ziele (bewusst ausgeklammert)
|
||||
|
||||
|
|
|
|||
|
|
@ -1,25 +1,25 @@
|
|||
/**
|
||||
* Cycles module — collection accessors and guest seed data.
|
||||
* Periods module — collection accessors and guest seed data.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalCycle, LocalCycleDayLog, LocalCycleSymptom } from './types';
|
||||
import type { LocalPeriod, LocalPeriodDayLog, LocalPeriodSymptom } 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');
|
||||
export const periodTable = db.table<LocalPeriod>('periods');
|
||||
export const periodDayLogTable = db.table<LocalPeriodDayLog>('periodDayLogs');
|
||||
export const periodSymptomTable = db.table<LocalPeriodSymptom>('periodSymptoms');
|
||||
|
||||
// ─── 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: [
|
||||
export const PERIODS_GUEST_SEED = {
|
||||
periods: [
|
||||
{
|
||||
id: 'cycle-prev',
|
||||
id: 'period-prev',
|
||||
startDate: daysAgo(56),
|
||||
periodEndDate: daysAgo(52),
|
||||
endDate: daysAgo(29),
|
||||
|
|
@ -29,7 +29,7 @@ export const CYCLES_GUEST_SEED = {
|
|||
notes: null,
|
||||
},
|
||||
{
|
||||
id: 'cycle-current',
|
||||
id: 'period-current',
|
||||
startDate: daysAgo(28),
|
||||
periodEndDate: daysAgo(24),
|
||||
endDate: null,
|
||||
|
|
@ -38,12 +38,12 @@ export const CYCLES_GUEST_SEED = {
|
|||
isArchived: false,
|
||||
notes: 'Aktueller Zyklus',
|
||||
},
|
||||
] satisfies LocalCycle[],
|
||||
cycleDayLogs: [
|
||||
] satisfies LocalPeriod[],
|
||||
periodDayLogs: [
|
||||
{
|
||||
id: 'cycle-log-today',
|
||||
id: 'period-log-today',
|
||||
logDate: today,
|
||||
cycleId: 'cycle-current',
|
||||
periodId: 'period-current',
|
||||
flow: 'none',
|
||||
mood: 'good',
|
||||
energy: 4,
|
||||
|
|
@ -53,8 +53,8 @@ export const CYCLES_GUEST_SEED = {
|
|||
sexualActivity: null,
|
||||
notes: null,
|
||||
},
|
||||
] satisfies LocalCycleDayLog[],
|
||||
cycleSymptoms: [
|
||||
] satisfies LocalPeriodDayLog[],
|
||||
periodSymptoms: [
|
||||
{
|
||||
id: 'sym-cramps',
|
||||
name: 'Krämpfe',
|
||||
|
|
@ -125,5 +125,5 @@ export const CYCLES_GUEST_SEED = {
|
|||
color: '#d946ef',
|
||||
count: 0,
|
||||
},
|
||||
] satisfies LocalCycleSymptom[],
|
||||
] satisfies LocalPeriodSymptom[],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
<!--
|
||||
Cycle Calendar — Month grid colored by phase, with flow markers.
|
||||
Period Calendar — Month grid colored by phase, with flow markers.
|
||||
|
||||
Click a day to switch the editing target. Navigate prev/next month
|
||||
with arrow buttons. Week starts on Monday (DE convention).
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { _, locale } from 'svelte-i18n';
|
||||
import type { Cycle, CycleDayLog, CyclePhase, Flow } from '../types';
|
||||
import type { Period, PeriodDayLog, PeriodPhase, Flow } from '../types';
|
||||
import { FLOW_COLORS, PHASE_COLORS } from '../types';
|
||||
import { derivePhase } from '../utils/phase';
|
||||
|
||||
interface Props {
|
||||
cycles: Cycle[];
|
||||
logs: CycleDayLog[];
|
||||
periods: Period[];
|
||||
logs: PeriodDayLog[];
|
||||
editingDate: string;
|
||||
todayIso: string;
|
||||
onSelectDay: (iso: string) => void;
|
||||
}
|
||||
|
||||
const { cycles, logs, editingDate, todayIso, onSelectDay }: Props = $props();
|
||||
const { periods, logs, editingDate, todayIso, onSelectDay }: Props = $props();
|
||||
|
||||
// ─ Month state ──────────────────────────────────────────
|
||||
// svelte-ignore state_referenced_locally
|
||||
|
|
@ -28,7 +28,7 @@
|
|||
|
||||
// ─ Logs indexed by date for O(1) lookup ─
|
||||
const logByDate = $derived.by(() => {
|
||||
const map = new Map<string, CycleDayLog>();
|
||||
const map = new Map<string, PeriodDayLog>();
|
||||
for (const log of logs) {
|
||||
map.set(log.logDate, log);
|
||||
}
|
||||
|
|
@ -40,7 +40,7 @@
|
|||
iso: string;
|
||||
dayOfMonth: number;
|
||||
inCurrentMonth: boolean;
|
||||
phase: CyclePhase;
|
||||
phase: PeriodPhase;
|
||||
flow: Flow | null;
|
||||
isToday: boolean;
|
||||
isEditing: boolean;
|
||||
|
|
@ -73,7 +73,7 @@
|
|||
iso,
|
||||
dayOfMonth: day,
|
||||
inCurrentMonth: m === viewMonth,
|
||||
phase: derivePhase(iso, cycles),
|
||||
phase: derivePhase(iso, periods),
|
||||
flow: log && log.flow !== 'none' ? log.flow : null,
|
||||
isToday: iso === todayIso,
|
||||
isEditing: iso === editingDate,
|
||||
|
|
@ -138,14 +138,14 @@
|
|||
class="cal-nav"
|
||||
type="button"
|
||||
onclick={prevMonth}
|
||||
aria-label={$_('cycles.calendar.prev')}>‹</button
|
||||
aria-label={$_('period.calendar.prev')}>‹</button
|
||||
>
|
||||
<button class="cal-title" type="button" onclick={goToToday}>{monthLabel}</button>
|
||||
<button
|
||||
class="cal-nav"
|
||||
type="button"
|
||||
onclick={nextMonth}
|
||||
aria-label={$_('cycles.calendar.next')}>›</button
|
||||
aria-label={$_('period.calendar.next')}>›</button
|
||||
>
|
||||
</div>
|
||||
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
<!--
|
||||
Symptom Manager — Modal to create, rename, recolor, and delete cycle symptoms.
|
||||
Symptom Manager — Modal to create, rename, recolor, and delete period symptoms.
|
||||
-->
|
||||
<script lang="ts">
|
||||
import { _ } from 'svelte-i18n';
|
||||
import { useAllSymptoms } from '../queries';
|
||||
import { symptomsStore } from '../stores/symptoms.svelte';
|
||||
import type { CycleSymptom, SymptomCategory } from '../types';
|
||||
import type { PeriodSymptom, SymptomCategory } from '../types';
|
||||
import { Modal } from '@mana/shared-ui';
|
||||
|
||||
interface Props {
|
||||
|
|
@ -35,7 +35,7 @@
|
|||
newCategory = 'physical';
|
||||
}
|
||||
|
||||
function startEdit(sym: CycleSymptom) {
|
||||
function startEdit(sym: PeriodSymptom) {
|
||||
editingId = sym.id;
|
||||
editName = sym.name;
|
||||
editCategory = sym.category;
|
||||
|
|
@ -56,38 +56,38 @@
|
|||
editingId = null;
|
||||
}
|
||||
|
||||
async function handleDelete(sym: CycleSymptom) {
|
||||
async function handleDelete(sym: PeriodSymptom) {
|
||||
const ok = confirm(
|
||||
$_('cycles.confirm.deleteSymptom', { values: { name: sym.name } }) || `"${sym.name}" löschen?`
|
||||
$_('period.confirm.deleteSymptom', { values: { name: sym.name } }) || `"${sym.name}" löschen?`
|
||||
);
|
||||
if (!ok) return;
|
||||
await symptomsStore.deleteSymptom(sym.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<Modal {visible} {onClose} title={$_('cycles.symptomManager.title')} maxWidth="md">
|
||||
<Modal {visible} {onClose} title={$_('period.symptomManager.title')} maxWidth="md">
|
||||
<div class="sm-content">
|
||||
<!-- Create form -->
|
||||
<form class="sm-create" onsubmit={handleCreate}>
|
||||
<input
|
||||
class="sm-input"
|
||||
type="text"
|
||||
placeholder={$_('cycles.symptomManager.newNamePlaceholder')}
|
||||
placeholder={$_('period.symptomManager.newNamePlaceholder')}
|
||||
bind:value={newName}
|
||||
/>
|
||||
<select class="sm-select" bind:value={newCategory}>
|
||||
{#each CATEGORIES as cat}
|
||||
<option value={cat}>{$_(`cycles.symptomCategory.${cat}`)}</option>
|
||||
<option value={cat}>{$_(`period.symptomCategory.${cat}`)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<button class="sm-add" type="submit" disabled={!newName.trim()}>
|
||||
{$_('cycles.symptomManager.add')}
|
||||
{$_('period.symptomManager.add')}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
<!-- Symptom list -->
|
||||
{#if symptoms.length === 0}
|
||||
<p class="sm-empty">{$_('cycles.symptomManager.empty')}</p>
|
||||
<p class="sm-empty">{$_('period.symptomManager.empty')}</p>
|
||||
{:else}
|
||||
<ul class="sm-list">
|
||||
{#each symptoms as sym (sym.id)}
|
||||
|
|
@ -101,15 +101,15 @@
|
|||
/>
|
||||
<select class="sm-select" bind:value={editCategory}>
|
||||
{#each CATEGORIES as cat}
|
||||
<option value={cat}>{$_(`cycles.symptomCategory.${cat}`)}</option>
|
||||
<option value={cat}>{$_(`period.symptomCategory.${cat}`)}</option>
|
||||
{/each}
|
||||
</select>
|
||||
<div class="sm-actions">
|
||||
<button class="sm-btn primary" type="button" onclick={saveEdit}>
|
||||
{$_('cycles.symptomManager.save')}
|
||||
{$_('period.symptomManager.save')}
|
||||
</button>
|
||||
<button class="sm-btn" type="button" onclick={cancelEdit}>
|
||||
{$_('cycles.symptomManager.cancel')}
|
||||
{$_('period.symptomManager.cancel')}
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
|
|
@ -120,7 +120,7 @@
|
|||
<div class="sm-info">
|
||||
<span class="sm-name">{sym.name}</span>
|
||||
<span class="sm-cat">
|
||||
{$_(`cycles.symptomCategory.${sym.category}`)}
|
||||
{$_(`period.symptomCategory.${sym.category}`)}
|
||||
{#if sym.count > 0}
|
||||
· {sym.count}
|
||||
{/if}
|
||||
|
|
@ -128,10 +128,10 @@
|
|||
</div>
|
||||
<div class="sm-actions">
|
||||
<button class="sm-btn" type="button" onclick={() => startEdit(sym)}>
|
||||
{$_('cycles.symptomManager.edit')}
|
||||
{$_('period.symptomManager.edit')}
|
||||
</button>
|
||||
<button class="sm-btn danger" type="button" onclick={() => handleDelete(sym)}>
|
||||
{$_('cycles.symptomManager.delete')}
|
||||
{$_('period.symptomManager.delete')}
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
|
|
|
|||
|
|
@ -1,39 +1,44 @@
|
|||
/**
|
||||
* Cycles module — barrel exports.
|
||||
* Periods module — barrel exports.
|
||||
*/
|
||||
|
||||
// ─── Stores ──────────────────────────────────────────────
|
||||
export { cyclesStore } from './stores/cycles.svelte';
|
||||
export { periodsStore } from './stores/periods.svelte';
|
||||
export { dayLogsStore } from './stores/dayLogs.svelte';
|
||||
export { symptomsStore } from './stores/symptoms.svelte';
|
||||
|
||||
// ─── Queries ─────────────────────────────────────────────
|
||||
export {
|
||||
useAllCycles,
|
||||
useCurrentCycle,
|
||||
useAllPeriods,
|
||||
useCurrentPeriod,
|
||||
useAllDayLogs,
|
||||
useDayLog,
|
||||
useAllSymptoms,
|
||||
toCycle,
|
||||
toCycleDayLog,
|
||||
toCycleSymptom,
|
||||
toPeriod,
|
||||
toPeriodDayLog,
|
||||
toPeriodSymptom,
|
||||
groupLogsByMonth,
|
||||
formatLogDate,
|
||||
} from './queries';
|
||||
export type { RelativeDateLabels } from './queries';
|
||||
|
||||
// ─── Utils ───────────────────────────────────────────────
|
||||
export { derivePhase, findCycleForDate, getCycleDayNumber, daysBetween } from './utils/phase';
|
||||
export { derivePhase, findPeriodForDate, getPeriodDayNumber, daysBetween } from './utils/phase';
|
||||
export {
|
||||
averageCycleLength,
|
||||
averagePeriodLength,
|
||||
predictNextPeriodStart,
|
||||
daysUntilNextPeriod,
|
||||
predictFertileWindow,
|
||||
computeCycleStats,
|
||||
computePeriodStats,
|
||||
} from './utils/prediction';
|
||||
|
||||
// ─── Collections ─────────────────────────────────────────
|
||||
export { cycleTable, cycleDayLogTable, cycleSymptomTable, CYCLES_GUEST_SEED } from './collections';
|
||||
export {
|
||||
periodTable,
|
||||
periodDayLogTable,
|
||||
periodSymptomTable,
|
||||
PERIODS_GUEST_SEED,
|
||||
} from './collections';
|
||||
|
||||
// ─── Types & Constants ───────────────────────────────────
|
||||
export {
|
||||
|
|
@ -44,20 +49,20 @@ export {
|
|||
PHASE_COLORS,
|
||||
PHASE_LABELS,
|
||||
CERVICAL_MUCUS_LABELS,
|
||||
DEFAULT_CYCLE_LENGTH,
|
||||
DEFAULT_PERIOD_LENGTH,
|
||||
DEFAULT_BLEEDING_DAYS,
|
||||
DEFAULT_LUTEAL_LENGTH,
|
||||
} from './types';
|
||||
export type {
|
||||
LocalCycle,
|
||||
LocalCycleDayLog,
|
||||
LocalCycleSymptom,
|
||||
Cycle,
|
||||
CycleDayLog,
|
||||
CycleSymptom,
|
||||
LocalPeriod,
|
||||
LocalPeriodDayLog,
|
||||
LocalPeriodSymptom,
|
||||
Period,
|
||||
PeriodDayLog,
|
||||
PeriodSymptom,
|
||||
Flow,
|
||||
Mood,
|
||||
CervicalMucus,
|
||||
SymptomCategory,
|
||||
CyclePhase,
|
||||
PeriodPhase,
|
||||
} from './types';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { ModuleConfig } from '$lib/data/module-registry';
|
||||
|
||||
export const cyclesModuleConfig: ModuleConfig = {
|
||||
appId: 'cycles',
|
||||
tables: [{ name: 'cycles' }, { name: 'cycleDayLogs' }, { name: 'cycleSymptoms' }],
|
||||
export const periodModuleConfig: ModuleConfig = {
|
||||
appId: 'period',
|
||||
tables: [{ name: 'periods' }, { name: 'periodDayLogs' }, { name: 'periodSymptoms' }],
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,22 +1,22 @@
|
|||
/**
|
||||
* Reactive Queries & Pure Helpers for Cycles module.
|
||||
* Reactive Queries & Pure Helpers for Periods module.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { decryptRecord, decryptRecords } from '$lib/data/crypto';
|
||||
import type {
|
||||
Cycle,
|
||||
CycleDayLog,
|
||||
CycleSymptom,
|
||||
LocalCycle,
|
||||
LocalCycleDayLog,
|
||||
LocalCycleSymptom,
|
||||
Period,
|
||||
PeriodDayLog,
|
||||
PeriodSymptom,
|
||||
LocalPeriod,
|
||||
LocalPeriodDayLog,
|
||||
LocalPeriodSymptom,
|
||||
} from './types';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
export function toCycle(local: LocalCycle): Cycle {
|
||||
export function toPeriod(local: LocalPeriod): Period {
|
||||
return {
|
||||
id: local.id,
|
||||
startDate: local.startDate,
|
||||
|
|
@ -31,11 +31,11 @@ export function toCycle(local: LocalCycle): Cycle {
|
|||
};
|
||||
}
|
||||
|
||||
export function toCycleDayLog(local: LocalCycleDayLog): CycleDayLog {
|
||||
export function toPeriodDayLog(local: LocalPeriodDayLog): PeriodDayLog {
|
||||
return {
|
||||
id: local.id,
|
||||
logDate: local.logDate,
|
||||
cycleId: local.cycleId,
|
||||
periodId: local.periodId,
|
||||
flow: local.flow,
|
||||
mood: local.mood,
|
||||
energy: local.energy,
|
||||
|
|
@ -49,7 +49,7 @@ export function toCycleDayLog(local: LocalCycleDayLog): CycleDayLog {
|
|||
};
|
||||
}
|
||||
|
||||
export function toCycleSymptom(local: LocalCycleSymptom): CycleSymptom {
|
||||
export function toPeriodSymptom(local: LocalPeriodSymptom): PeriodSymptom {
|
||||
return {
|
||||
id: local.id,
|
||||
name: local.name,
|
||||
|
|
@ -63,65 +63,65 @@ export function toCycleSymptom(local: LocalCycleSymptom): CycleSymptom {
|
|||
|
||||
// ─── Live Queries ──────────────────────────────────────────
|
||||
|
||||
export function useAllCycles() {
|
||||
export function useAllPeriods() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (await db.table<LocalCycle>('cycles').toArray()).filter(
|
||||
const visible = (await db.table<LocalPeriod>('periods').toArray()).filter(
|
||||
(c) => !c.deletedAt && !c.isArchived
|
||||
);
|
||||
const decrypted = await decryptRecords('cycles', visible);
|
||||
return decrypted.map(toCycle).sort((a, b) => b.startDate.localeCompare(a.startDate));
|
||||
}, [] as Cycle[]);
|
||||
const decrypted = await decryptRecords('periods', visible);
|
||||
return decrypted.map(toPeriod).sort((a, b) => b.startDate.localeCompare(a.startDate));
|
||||
}, [] as Period[]);
|
||||
}
|
||||
|
||||
export function useCurrentCycle() {
|
||||
export function useCurrentPeriod() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const locals = await db.table<LocalCycle>('cycles').toArray();
|
||||
const locals = await db.table<LocalPeriod>('periods').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];
|
||||
const decrypted = await decryptRecord('cycles', { ...latest });
|
||||
return toCycle(decrypted);
|
||||
const decrypted = await decryptRecord('periods', { ...latest });
|
||||
return toPeriod(decrypted);
|
||||
},
|
||||
null as Cycle | null
|
||||
null as Period | null
|
||||
);
|
||||
}
|
||||
|
||||
export function useAllDayLogs() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const visible = (await db.table<LocalCycleDayLog>('cycleDayLogs').toArray()).filter(
|
||||
const visible = (await db.table<LocalPeriodDayLog>('periodDayLogs').toArray()).filter(
|
||||
(l) => !l.deletedAt
|
||||
);
|
||||
const decrypted = await decryptRecords('cycleDayLogs', visible);
|
||||
return decrypted.map(toCycleDayLog).sort((a, b) => b.logDate.localeCompare(a.logDate));
|
||||
}, [] as CycleDayLog[]);
|
||||
const decrypted = await decryptRecords('periodDayLogs', visible);
|
||||
return decrypted.map(toPeriodDayLog).sort((a, b) => b.logDate.localeCompare(a.logDate));
|
||||
}, [] as PeriodDayLog[]);
|
||||
}
|
||||
|
||||
export function useDayLog(date: string) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const locals = await db
|
||||
.table<LocalCycleDayLog>('cycleDayLogs')
|
||||
.table<LocalPeriodDayLog>('periodDayLogs')
|
||||
.where('logDate')
|
||||
.equals(date)
|
||||
.toArray();
|
||||
const active = locals.find((l) => !l.deletedAt);
|
||||
if (!active) return null;
|
||||
const decrypted = await decryptRecord('cycleDayLogs', { ...active });
|
||||
return toCycleDayLog(decrypted);
|
||||
const decrypted = await decryptRecord('periodDayLogs', { ...active });
|
||||
return toPeriodDayLog(decrypted);
|
||||
},
|
||||
null as CycleDayLog | null
|
||||
null as PeriodDayLog | null
|
||||
);
|
||||
}
|
||||
|
||||
export function useAllSymptoms() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalCycleSymptom>('cycleSymptoms').toArray();
|
||||
const locals = await db.table<LocalPeriodSymptom>('periodSymptoms').toArray();
|
||||
return locals
|
||||
.filter((s) => !s.deletedAt)
|
||||
.map(toCycleSymptom)
|
||||
.map(toPeriodSymptom)
|
||||
.sort((a, b) => b.count - a.count || a.name.localeCompare(b.name));
|
||||
}, [] as CycleSymptom[]);
|
||||
}, [] as PeriodSymptom[]);
|
||||
}
|
||||
|
||||
// ─── Pure Helpers ──────────────────────────────────────────
|
||||
|
|
@ -136,10 +136,10 @@ export interface RelativeDateLabels {
|
|||
|
||||
/** Group day logs by localized month label. */
|
||||
export function groupLogsByMonth(
|
||||
logs: CycleDayLog[],
|
||||
logs: PeriodDayLog[],
|
||||
dateLocale: string = 'de-DE'
|
||||
): Array<{ label: string; logs: CycleDayLog[] }> {
|
||||
const groups = new Map<string, CycleDayLog[]>();
|
||||
): Array<{ label: string; logs: PeriodDayLog[] }> {
|
||||
const groups = new Map<string, PeriodDayLog[]>();
|
||||
for (const l of logs) {
|
||||
const date = new Date(l.logDate);
|
||||
const label = date.toLocaleDateString(dateLocale, { month: 'long', year: 'numeric' });
|
||||
|
|
|
|||
|
|
@ -1,24 +1,24 @@
|
|||
/**
|
||||
* Day Logs Store — Mutation-Only Service for daily cycle entries.
|
||||
* Day Logs Store — Mutation-Only Service for daily period entries.
|
||||
*/
|
||||
|
||||
import { cycleDayLogTable, cycleTable } from '../collections';
|
||||
import { toCycle, toCycleDayLog } from '../queries';
|
||||
import { detectPeriodEnd, shouldStartNewCycle } from '../utils/auto-detect';
|
||||
import { cyclesStore } from './cycles.svelte';
|
||||
import { periodDayLogTable, periodTable } from '../collections';
|
||||
import { toPeriod, toPeriodDayLog } from '../queries';
|
||||
import { detectPeriodEnd, shouldStartNewPeriod } from '../utils/auto-detect';
|
||||
import { periodsStore } from './periods.svelte';
|
||||
import { symptomsStore } from './symptoms.svelte';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import type { CervicalMucus, Flow, LocalCycle, LocalCycleDayLog, Mood } from '../types';
|
||||
import type { CervicalMucus, Flow, LocalPeriod, LocalPeriodDayLog, 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();
|
||||
async function resolvePeriodId(date: string): Promise<string | null> {
|
||||
const all = await periodTable.toArray();
|
||||
const candidates = all
|
||||
.filter((c: LocalCycle) => !c.deletedAt && !c.isPredicted && c.startDate <= date)
|
||||
.filter((c: LocalPeriod) => !c.deletedAt && !c.isPredicted && c.startDate <= date)
|
||||
.sort((a, b) => b.startDate.localeCompare(a.startDate));
|
||||
return candidates[0]?.id ?? null;
|
||||
}
|
||||
|
|
@ -42,18 +42,18 @@ export const dayLogsStore = {
|
|||
|
||||
// ─ 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 allPeriods = await periodTable.toArray();
|
||||
const visiblePeriods = allPeriods.filter((c) => !c.deletedAt).map(toPeriod);
|
||||
if (shouldStartNewPeriod(logDate, data.flow, visiblePeriods)) {
|
||||
await periodsStore.createPeriod({ startDate: logDate });
|
||||
}
|
||||
}
|
||||
|
||||
const existing = (await cycleDayLogTable.where('logDate').equals(logDate).toArray()).find(
|
||||
const existing = (await periodDayLogTable.where('logDate').equals(logDate).toArray()).find(
|
||||
(l) => !l.deletedAt
|
||||
);
|
||||
|
||||
let result: LocalCycleDayLog;
|
||||
let result: LocalPeriodDayLog;
|
||||
if (existing) {
|
||||
// Symptom-Counter aktualisieren.
|
||||
if (data.symptoms) {
|
||||
|
|
@ -64,22 +64,22 @@ export const dayLogsStore = {
|
|||
if (added.length) await symptomsStore.touchSymptoms(added, +1);
|
||||
if (removed.length) await symptomsStore.touchSymptoms(removed, -1);
|
||||
}
|
||||
const updateDiff: Partial<LocalCycleDayLog> = {
|
||||
const updateDiff: Partial<LocalPeriodDayLog> = {
|
||||
...data,
|
||||
logDate,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('cycleDayLogs', updateDiff);
|
||||
await cycleDayLogTable.update(existing.id, updateDiff);
|
||||
await encryptRecord('periodDayLogs', updateDiff);
|
||||
await periodDayLogTable.update(existing.id, updateDiff);
|
||||
// `result` keeps the plaintext for the return value — caller
|
||||
// expects to render the input back.
|
||||
result = { ...existing, ...data, logDate };
|
||||
} else {
|
||||
const cycleId = await resolveCycleId(logDate);
|
||||
const newLocal: LocalCycleDayLog = {
|
||||
const periodId = await resolvePeriodId(logDate);
|
||||
const newLocal: LocalPeriodDayLog = {
|
||||
id: crypto.randomUUID(),
|
||||
logDate,
|
||||
cycleId,
|
||||
periodId,
|
||||
flow: data.flow ?? 'none',
|
||||
mood: data.mood ?? null,
|
||||
energy: data.energy ?? null,
|
||||
|
|
@ -92,52 +92,52 @@ export const dayLogsStore = {
|
|||
// Plaintext copy retained for the return value — what we
|
||||
// write to disk is encrypted.
|
||||
result = { ...newLocal };
|
||||
await encryptRecord('cycleDayLogs', newLocal);
|
||||
await cycleDayLogTable.add(newLocal);
|
||||
await encryptRecord('periodDayLogs', newLocal);
|
||||
await periodDayLogTable.add(newLocal);
|
||||
if (result.symptoms.length) {
|
||||
await symptomsStore.touchSymptoms(result.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)
|
||||
if (data.flow === 'none' && result.periodId) {
|
||||
const openPeriodLocal = await periodTable.get(result.periodId);
|
||||
if (openPeriodLocal && !openPeriodLocal.deletedAt && !openPeriodLocal.periodEndDate) {
|
||||
const periodLogsLocal = await periodDayLogTable
|
||||
.where('periodId')
|
||||
.equals(result.periodId)
|
||||
.toArray();
|
||||
const cycleLogs = cycleLogsLocal.filter((l) => !l.deletedAt).map(toCycleDayLog);
|
||||
const endDate = detectPeriodEnd(logDate, 'none', toCycle(openCycleLocal), cycleLogs);
|
||||
const periodLogs = periodLogsLocal.filter((l) => !l.deletedAt).map(toPeriodDayLog);
|
||||
const endDate = detectPeriodEnd(logDate, 'none', toPeriod(openPeriodLocal), periodLogs);
|
||||
if (endDate) {
|
||||
await cyclesStore.setPeriodEnd(openCycleLocal.id, endDate);
|
||||
await periodsStore.setPeriodEnd(openPeriodLocal.id, endDate);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return toCycleDayLog(result);
|
||||
return toPeriodDayLog(result);
|
||||
},
|
||||
|
||||
async deleteLog(id: string) {
|
||||
const existing = await cycleDayLogTable.get(id);
|
||||
const existing = await periodDayLogTable.get(id);
|
||||
if (existing?.symptoms?.length) {
|
||||
await symptomsStore.touchSymptoms(existing.symptoms, -1);
|
||||
}
|
||||
await cycleDayLogTable.update(id, {
|
||||
await periodDayLogTable.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();
|
||||
async autoAssignPeriod() {
|
||||
const logs = await periodDayLogTable.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,
|
||||
if (log.periodId || log.deletedAt) continue;
|
||||
const periodId = await resolvePeriodId(log.logDate);
|
||||
if (periodId) {
|
||||
await periodDayLogTable.update(log.id, {
|
||||
periodId,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,10 @@
|
|||
/**
|
||||
* Integration tests for cycles stores against a real (fake) IndexedDB.
|
||||
* Integration tests for periods stores against a real (fake) IndexedDB.
|
||||
*
|
||||
* Covers the complex interactions that pure-function tests cannot:
|
||||
* - cyclesStore auto-closes the previous open cycle when a new one starts
|
||||
* - periodsStore auto-closes the previous open period when a new one starts
|
||||
* - dayLogsStore upserts per date (no duplicates)
|
||||
* - dayLogsStore auto-creates a cycle on first bleeding log
|
||||
* - dayLogsStore auto-creates a period on first bleeding log
|
||||
* - dayLogsStore auto-sets periodEndDate after 2 dry days
|
||||
* - symptomsStore.touchSymptoms increments/decrements ref counts
|
||||
* - dayLogsStore updates symptom counters when symptoms change on an existing log
|
||||
|
|
@ -27,19 +27,19 @@ import {
|
|||
setKeyProvider,
|
||||
decryptRecords,
|
||||
} from '$lib/data/crypto';
|
||||
import { cyclesStore } from './cycles.svelte';
|
||||
import { periodsStore } from './periods.svelte';
|
||||
import { dayLogsStore } from './dayLogs.svelte';
|
||||
import { symptomsStore } from './symptoms.svelte';
|
||||
import type { LocalCycle, LocalCycleDayLog, LocalCycleSymptom } from '../types';
|
||||
import type { LocalPeriod, LocalPeriodDayLog, LocalPeriodSymptom } from '../types';
|
||||
|
||||
const cycleTable = () => db.table<LocalCycle>('cycles');
|
||||
const dayLogTable = () => db.table<LocalCycleDayLog>('cycleDayLogs');
|
||||
const symptomTable = () => db.table<LocalCycleSymptom>('cycleSymptoms');
|
||||
const periodTable = () => db.table<LocalPeriod>('periods');
|
||||
const dayLogTable = () => db.table<LocalPeriodDayLog>('periodDayLogs');
|
||||
const symptomTable = () => db.table<LocalPeriodSymptom>('periodSymptoms');
|
||||
|
||||
const iso = (dateStr: string) => dateStr; // alias for readability
|
||||
|
||||
async function resetCyclesTables() {
|
||||
await cycleTable().clear();
|
||||
async function resetPeriodsTables() {
|
||||
await periodTable().clear();
|
||||
await dayLogTable().clear();
|
||||
await symptomTable().clear();
|
||||
await db.table('_pendingChanges').clear();
|
||||
|
|
@ -48,65 +48,65 @@ async function resetCyclesTables() {
|
|||
|
||||
beforeEach(async () => {
|
||||
setCurrentUserId('test-user');
|
||||
// Phase 5 cycles encryption requires an unlocked vault — install a
|
||||
// Phase 5 periods encryption requires an unlocked vault — install a
|
||||
// real Web Crypto key in a fresh MemoryKeyProvider for each test
|
||||
// run so the dayLogsStore.logDay calls below can encrypt notes/mood.
|
||||
const key = await generateMasterKey();
|
||||
const provider = new MemoryKeyProvider();
|
||||
provider.setKey(key);
|
||||
setKeyProvider(provider);
|
||||
await resetCyclesTables();
|
||||
await resetPeriodsTables();
|
||||
});
|
||||
|
||||
describe('cyclesStore.createCycle', () => {
|
||||
it('creates a single open cycle when none exists', async () => {
|
||||
const cycle = await cyclesStore.createCycle({ startDate: iso('2026-01-01') });
|
||||
expect(cycle.startDate).toBe('2026-01-01');
|
||||
expect(cycle.endDate).toBeNull();
|
||||
expect(cycle.length).toBeNull();
|
||||
describe('periodsStore.createPeriod', () => {
|
||||
it('creates a single open period when none exists', async () => {
|
||||
const period = await periodsStore.createPeriod({ startDate: iso('2026-01-01') });
|
||||
expect(period.startDate).toBe('2026-01-01');
|
||||
expect(period.endDate).toBeNull();
|
||||
expect(period.length).toBeNull();
|
||||
|
||||
const stored = await cycleTable().toArray();
|
||||
const stored = await periodTable().toArray();
|
||||
expect(stored).toHaveLength(1);
|
||||
expect(stored[0].id).toBe(cycle.id);
|
||||
expect(stored[0].id).toBe(period.id);
|
||||
});
|
||||
|
||||
it('auto-closes the previous open cycle and computes length', async () => {
|
||||
const first = await cyclesStore.createCycle({ startDate: iso('2026-01-01') });
|
||||
await cyclesStore.createCycle({ startDate: iso('2026-01-29') });
|
||||
it('auto-closes the previous open period and computes length', async () => {
|
||||
const first = await periodsStore.createPeriod({ startDate: iso('2026-01-01') });
|
||||
await periodsStore.createPeriod({ startDate: iso('2026-01-29') });
|
||||
|
||||
const firstStored = await cycleTable().get(first.id);
|
||||
const firstStored = await periodTable().get(first.id);
|
||||
expect(firstStored?.endDate).toBe('2026-01-28'); // day before new start
|
||||
expect(firstStored?.length).toBe(28);
|
||||
});
|
||||
|
||||
it('does not touch cycles whose startDate is >= the new cycle', async () => {
|
||||
// Backfilling an older cycle should NOT close a future one
|
||||
const future = await cyclesStore.createCycle({ startDate: iso('2026-03-01') });
|
||||
await cyclesStore.createCycle({ startDate: iso('2026-02-01') });
|
||||
it('does not touch periods whose startDate is >= the new period', async () => {
|
||||
// Backfilling an older period should NOT close a future one
|
||||
const future = await periodsStore.createPeriod({ startDate: iso('2026-03-01') });
|
||||
await periodsStore.createPeriod({ startDate: iso('2026-02-01') });
|
||||
|
||||
const futureStored = await cycleTable().get(future.id);
|
||||
const futureStored = await periodTable().get(future.id);
|
||||
expect(futureStored?.endDate).toBeNull();
|
||||
expect(futureStored?.length).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cyclesStore.setPeriodEnd', () => {
|
||||
describe('periodsStore.setPeriodEnd', () => {
|
||||
it('sets periodEndDate without affecting endDate', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.setPeriodEnd(c.id, '2026-04-05');
|
||||
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await periodsStore.setPeriodEnd(c.id, '2026-04-05');
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
const stored = await periodTable().get(c.id);
|
||||
expect(stored?.periodEndDate).toBe('2026-04-05');
|
||||
expect(stored?.endDate).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('cyclesStore.deleteCycle', () => {
|
||||
describe('periodsStore.deletePeriod', () => {
|
||||
it('soft-deletes via deletedAt', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.deleteCycle(c.id);
|
||||
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await periodsStore.deletePeriod(c.id);
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
const stored = await periodTable().get(c.id);
|
||||
expect(stored?.deletedAt).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
|
@ -129,110 +129,110 @@ describe('dayLogsStore.logDay — upsert behavior', () => {
|
|||
// Phase 5: `mood` is encrypted on disk — decrypt before asserting
|
||||
// so the test reads the same view the UI does.
|
||||
const raw = (await dayLogTable().toArray()).filter((l) => !l.deletedAt);
|
||||
const logs = await decryptRecords<LocalCycleDayLog>('cycleDayLogs', raw);
|
||||
const logs = await decryptRecords<LocalPeriodDayLog>('periodDayLogs', raw);
|
||||
expect(logs).toHaveLength(1);
|
||||
expect(logs[0].flow).toBe('light');
|
||||
expect(logs[0].mood).toBe('good');
|
||||
expect(logs[0].temperature).toBe(36.6);
|
||||
});
|
||||
|
||||
it('assigns cycleId when a matching cycle exists', async () => {
|
||||
await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
it('assigns periodId when a matching period exists', async () => {
|
||||
await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-05', mood: 'good' });
|
||||
|
||||
const log = (await dayLogTable().toArray())[0];
|
||||
expect(log.cycleId).toBeTruthy();
|
||||
expect(log.periodId).toBeTruthy();
|
||||
});
|
||||
|
||||
it('leaves cycleId null when no cycle covers the date', async () => {
|
||||
it('leaves periodId null when no period covers the date', async () => {
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', mood: 'good' });
|
||||
const log = (await dayLogTable().toArray())[0];
|
||||
expect(log.cycleId).toBeNull();
|
||||
expect(log.periodId).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayLogsStore.logDay — auto-start cycle', () => {
|
||||
it('creates a new cycle on first bleeding log with no history', async () => {
|
||||
describe('dayLogsStore.logDay — auto-start period', () => {
|
||||
it('creates a new period on first bleeding log with no history', async () => {
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', flow: 'medium' });
|
||||
|
||||
const cycles = await cycleTable().toArray();
|
||||
expect(cycles).toHaveLength(1);
|
||||
expect(cycles[0].startDate).toBe('2026-04-07');
|
||||
const periods = await periodTable().toArray();
|
||||
expect(periods).toHaveLength(1);
|
||||
expect(periods[0].startDate).toBe('2026-04-07');
|
||||
|
||||
// And the log itself is attached to that cycle
|
||||
// And the log itself is attached to that period
|
||||
const log = (await dayLogTable().toArray())[0];
|
||||
expect(log.cycleId).toBe(cycles[0].id);
|
||||
expect(log.periodId).toBe(periods[0].id);
|
||||
});
|
||||
|
||||
it('does NOT create a new cycle for spotting', async () => {
|
||||
it('does NOT create a new period for spotting', async () => {
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', flow: 'spotting' });
|
||||
const cycles = await cycleTable().toArray();
|
||||
expect(cycles).toHaveLength(0);
|
||||
const periods = await periodTable().toArray();
|
||||
expect(periods).toHaveLength(0);
|
||||
});
|
||||
|
||||
it('does NOT create a new cycle during an open cycle', async () => {
|
||||
await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
// Mid-cycle bleeding should NOT spawn a second cycle
|
||||
it('does NOT create a new period during an open period', async () => {
|
||||
await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
// Mid-period bleeding should NOT spawn a second period
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-10', flow: 'medium' });
|
||||
|
||||
const cycles = await cycleTable().toArray();
|
||||
expect(cycles).toHaveLength(1);
|
||||
const periods = await periodTable().toArray();
|
||||
expect(periods).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('creates a new cycle when the previous is closed and far enough apart', async () => {
|
||||
const first = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.setPeriodEnd(first.id, '2026-04-05');
|
||||
it('creates a new period when the previous is closed and far enough apart', async () => {
|
||||
const first = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await periodsStore.setPeriodEnd(first.id, '2026-04-05');
|
||||
|
||||
// 15 days after periodEndDate — well beyond MIN_GAP (10)
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-20', flow: 'medium' });
|
||||
|
||||
const cycles = (await cycleTable().toArray()).filter((c) => !c.deletedAt);
|
||||
expect(cycles).toHaveLength(2);
|
||||
const periods = (await periodTable().toArray()).filter((c) => !c.deletedAt);
|
||||
expect(periods).toHaveLength(2);
|
||||
});
|
||||
|
||||
it('does NOT create a new cycle if bleeding is too soon after period end', async () => {
|
||||
const first = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.setPeriodEnd(first.id, '2026-04-05');
|
||||
it('does NOT create a new period if bleeding is too soon after period end', async () => {
|
||||
const first = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await periodsStore.setPeriodEnd(first.id, '2026-04-05');
|
||||
|
||||
// Only 8 days after — treated as mid-cycle spotting
|
||||
// Only 8 days after — treated as mid-period spotting
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-13', flow: 'medium' });
|
||||
|
||||
const cycles = (await cycleTable().toArray()).filter((c) => !c.deletedAt);
|
||||
expect(cycles).toHaveLength(1);
|
||||
const periods = (await periodTable().toArray()).filter((c) => !c.deletedAt);
|
||||
expect(periods).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('dayLogsStore.logDay — auto-end period', () => {
|
||||
it('sets periodEndDate after 2 dry days following bleeding', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-02', flow: 'medium' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-03', flow: 'light' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-04', flow: 'none' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-05', flow: 'none' });
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
const stored = await periodTable().get(c.id);
|
||||
expect(stored?.periodEndDate).toBe('2026-04-03');
|
||||
});
|
||||
|
||||
it('does NOT set periodEndDate after only 1 dry day', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-02', flow: 'none' });
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
const stored = await periodTable().get(c.id);
|
||||
expect(stored?.periodEndDate).toBeNull();
|
||||
});
|
||||
|
||||
it('does not overwrite an already-set periodEndDate', async () => {
|
||||
const c = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await cyclesStore.setPeriodEnd(c.id, '2026-04-03');
|
||||
const c = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await periodsStore.setPeriodEnd(c.id, '2026-04-03');
|
||||
|
||||
// Logging more none days should not re-trigger
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-01', flow: 'medium' });
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-10', flow: 'none' });
|
||||
|
||||
const stored = await cycleTable().get(c.id);
|
||||
const stored = await periodTable().get(c.id);
|
||||
expect(stored?.periodEndDate).toBe('2026-04-03');
|
||||
});
|
||||
});
|
||||
|
|
@ -296,18 +296,18 @@ describe('dayLogsStore.logDay — symptom counter integration', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('dayLogsStore.autoAssignCycle', () => {
|
||||
it('retroactively attaches orphan logs to the right cycle', async () => {
|
||||
// Log something before any cycle exists
|
||||
describe('dayLogsStore.autoAssignPeriod', () => {
|
||||
it('retroactively attaches orphan logs to the right period', async () => {
|
||||
// Log something before any period exists
|
||||
await dayLogsStore.logDay({ logDate: '2026-04-07', mood: 'good' });
|
||||
const orphan = (await dayLogTable().toArray())[0];
|
||||
expect(orphan.cycleId).toBeNull();
|
||||
expect(orphan.periodId).toBeNull();
|
||||
|
||||
// Now create a cycle that should claim that day
|
||||
const cycle = await cyclesStore.createCycle({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.autoAssignCycle();
|
||||
// Now create a period that should claim that day
|
||||
const period = await periodsStore.createPeriod({ startDate: iso('2026-04-01') });
|
||||
await dayLogsStore.autoAssignPeriod();
|
||||
|
||||
const reattached = await dayLogTable().get(orphan.id);
|
||||
expect(reattached?.cycleId).toBe(cycle.id);
|
||||
expect(reattached?.periodId).toBe(period.id);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,14 +1,14 @@
|
|||
/**
|
||||
* Cycles Store — Mutation-Only Service for menstrual cycles.
|
||||
* Periods Store — Mutation-Only Service for menstrual periods.
|
||||
*/
|
||||
|
||||
import { cycleTable } from '../collections';
|
||||
import { toCycle } from '../queries';
|
||||
import { periodTable } from '../collections';
|
||||
import { toPeriod } from '../queries';
|
||||
import { daysBetween } from '../utils/phase';
|
||||
import { encryptRecord } from '$lib/data/crypto';
|
||||
import { emitDomainEvent } from '$lib/data/events';
|
||||
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
||||
import type { LocalCycle } from '../types';
|
||||
import type { LocalPeriod } from '../types';
|
||||
|
||||
function todayIsoDate(): string {
|
||||
return new Date().toISOString().slice(0, 10);
|
||||
|
|
@ -20,13 +20,13 @@ function dayBefore(iso: string): string {
|
|||
return d.toISOString().slice(0, 10);
|
||||
}
|
||||
|
||||
export const cyclesStore = {
|
||||
export const periodsStore = {
|
||||
/** Startet einen neuen Zyklus. Schließt automatisch den vorigen offenen Zyklus. */
|
||||
async createCycle(data: { startDate?: string; notes?: string | null }) {
|
||||
async createPeriod(data: { startDate?: string; notes?: string | null }) {
|
||||
const startDate = data.startDate ?? todayIsoDate();
|
||||
|
||||
// Vorigen offenen Zyklus schließen.
|
||||
const all = await cycleTable.toArray();
|
||||
const all = await periodTable.toArray();
|
||||
const open = all
|
||||
.filter((c) => !c.deletedAt && !c.isPredicted && c.endDate === null)
|
||||
.sort((a, b) => b.startDate.localeCompare(a.startDate));
|
||||
|
|
@ -34,7 +34,7 @@ export const cyclesStore = {
|
|||
if (prev.startDate >= startDate) continue;
|
||||
const endDate = dayBefore(startDate);
|
||||
const length = daysBetween(startDate, prev.startDate);
|
||||
await cycleTable.update(prev.id, {
|
||||
await periodTable.update(prev.id, {
|
||||
endDate,
|
||||
length,
|
||||
updatedAt: new Date().toISOString(),
|
||||
|
|
@ -42,21 +42,21 @@ export const cyclesStore = {
|
|||
}
|
||||
|
||||
// Create a TimeBlock for the menstruation phase (allDay, open-ended until periodEnd is set)
|
||||
const cycleId = crypto.randomUUID();
|
||||
const periodId = crypto.randomUUID();
|
||||
const timeBlockId = await createBlock({
|
||||
startDate: `${startDate}T00:00:00.000Z`,
|
||||
endDate: null,
|
||||
allDay: true,
|
||||
kind: 'logged',
|
||||
type: 'cycle',
|
||||
sourceModule: 'cycles',
|
||||
sourceId: cycleId,
|
||||
type: 'period',
|
||||
sourceModule: 'period',
|
||||
sourceId: periodId,
|
||||
title: 'Periode',
|
||||
color: '#ec4899',
|
||||
});
|
||||
|
||||
const newLocal: LocalCycle = {
|
||||
id: cycleId,
|
||||
const newLocal: LocalPeriod = {
|
||||
id: periodId,
|
||||
startDate,
|
||||
periodEndDate: null,
|
||||
endDate: null,
|
||||
|
|
@ -66,10 +66,10 @@ export const cyclesStore = {
|
|||
notes: data.notes ?? null,
|
||||
timeBlockId,
|
||||
};
|
||||
const plaintextSnapshot = toCycle(newLocal);
|
||||
await encryptRecord('cycles', newLocal);
|
||||
await cycleTable.add(newLocal);
|
||||
emitDomainEvent('CycleDayLogged', 'cycles', 'cycleDayLogs', newLocal.id, {
|
||||
const plaintextSnapshot = toPeriod(newLocal);
|
||||
await encryptRecord('periods', newLocal);
|
||||
await periodTable.add(newLocal);
|
||||
emitDomainEvent('PeriodDayLogged', 'period', 'periodDayLogs', newLocal.id, {
|
||||
logId: newLocal.id,
|
||||
date: newLocal.startDate,
|
||||
flow: null,
|
||||
|
|
@ -77,51 +77,51 @@ export const cyclesStore = {
|
|||
return plaintextSnapshot;
|
||||
},
|
||||
|
||||
async updateCycle(
|
||||
async updatePeriod(
|
||||
id: string,
|
||||
data: Partial<
|
||||
Pick<
|
||||
LocalCycle,
|
||||
LocalPeriod,
|
||||
'startDate' | 'periodEndDate' | 'endDate' | 'length' | 'notes' | 'isArchived'
|
||||
>
|
||||
>
|
||||
) {
|
||||
const diff: Partial<LocalCycle> = {
|
||||
const diff: Partial<LocalPeriod> = {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await encryptRecord('cycles', diff);
|
||||
await cycleTable.update(id, diff);
|
||||
await encryptRecord('periods', diff);
|
||||
await periodTable.update(id, diff);
|
||||
},
|
||||
|
||||
/** Markiert das Ende der Blutung (nicht das Ende des Zyklus). */
|
||||
async setPeriodEnd(id: string, periodEndDate: string | null) {
|
||||
const cycle = await cycleTable.get(id);
|
||||
await cycleTable.update(id, {
|
||||
const period = await periodTable.get(id);
|
||||
await periodTable.update(id, {
|
||||
periodEndDate,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
// Update the TimeBlock's endDate to reflect the period duration
|
||||
if (cycle?.timeBlockId && periodEndDate) {
|
||||
await updateBlock(cycle.timeBlockId, {
|
||||
if (period?.timeBlockId && periodEndDate) {
|
||||
await updateBlock(period.timeBlockId, {
|
||||
endDate: `${periodEndDate}T23:59:59.999Z`,
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
async deleteCycle(id: string) {
|
||||
const cycle = await cycleTable.get(id);
|
||||
if (cycle?.timeBlockId) {
|
||||
await deleteBlock(cycle.timeBlockId);
|
||||
async deletePeriod(id: string) {
|
||||
const period = await periodTable.get(id);
|
||||
if (period?.timeBlockId) {
|
||||
await deleteBlock(period.timeBlockId);
|
||||
}
|
||||
await cycleTable.update(id, {
|
||||
await periodTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async archiveCycle(id: string) {
|
||||
await cycleTable.update(id, {
|
||||
async archivePeriod(id: string) {
|
||||
await periodTable.update(id, {
|
||||
isArchived: true,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
|
@ -1,35 +1,35 @@
|
|||
/**
|
||||
* Symptoms Store — Mutation-Only Service for cycle symptom taxonomy.
|
||||
* Symptoms Store — Mutation-Only Service for period symptom taxonomy.
|
||||
*/
|
||||
|
||||
import { cycleSymptomTable } from '../collections';
|
||||
import type { LocalCycleSymptom, SymptomCategory } from '../types';
|
||||
import { periodSymptomTable } from '../collections';
|
||||
import type { LocalPeriodSymptom, SymptomCategory } from '../types';
|
||||
|
||||
export const symptomsStore = {
|
||||
async createSymptom(data: { name: string; category?: SymptomCategory; color?: string | null }) {
|
||||
const newLocal: LocalCycleSymptom = {
|
||||
const newLocal: LocalPeriodSymptom = {
|
||||
id: crypto.randomUUID(),
|
||||
name: data.name.trim(),
|
||||
category: data.category ?? 'physical',
|
||||
color: data.color ?? null,
|
||||
count: 0,
|
||||
};
|
||||
await cycleSymptomTable.add(newLocal);
|
||||
await periodSymptomTable.add(newLocal);
|
||||
return newLocal;
|
||||
},
|
||||
|
||||
async updateSymptom(
|
||||
id: string,
|
||||
data: Partial<Pick<LocalCycleSymptom, 'name' | 'category' | 'color'>>
|
||||
data: Partial<Pick<LocalPeriodSymptom, 'name' | 'category' | 'color'>>
|
||||
) {
|
||||
await cycleSymptomTable.update(id, {
|
||||
await periodSymptomTable.update(id, {
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
},
|
||||
|
||||
async deleteSymptom(id: string) {
|
||||
await cycleSymptomTable.update(id, {
|
||||
await periodSymptomTable.update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
|
@ -38,10 +38,10 @@ export const symptomsStore = {
|
|||
/** 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);
|
||||
const existing = await periodSymptomTable.get(id);
|
||||
if (!existing) continue;
|
||||
const next = Math.max(0, (existing.count ?? 0) + delta);
|
||||
await cycleSymptomTable.update(id, {
|
||||
await periodSymptomTable.update(id, {
|
||||
count: next,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import type { ModuleTool } from '$lib/data/tools/types';
|
||||
export const cyclesTools: ModuleTool[] = [
|
||||
export const periodTools: ModuleTool[] = [
|
||||
{
|
||||
name: 'log_cycle_day',
|
||||
module: 'cycles',
|
||||
name: 'log_period_day',
|
||||
module: 'period',
|
||||
description: 'Loggt einen Zyklus-Tag (Menstruationszyklus)',
|
||||
parameters: [
|
||||
{
|
||||
|
|
@ -14,8 +14,8 @@ export const cyclesTools: ModuleTool[] = [
|
|||
},
|
||||
],
|
||||
async execute(params) {
|
||||
const { cyclesStore } = await import('./stores/cycles.svelte');
|
||||
const entry = await cyclesStore.createCycle({});
|
||||
const { periodsStore } = await import('./stores/periods.svelte');
|
||||
const entry = await periodsStore.createPeriod({});
|
||||
return { success: true, data: entry, message: 'Zyklus-Tag geloggt' };
|
||||
},
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
/**
|
||||
* Cycles module types — Menstruationszyklus-Tracking.
|
||||
* Periods module types — Menstruationszyklus-Tracking.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
|
@ -8,11 +8,11 @@ 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';
|
||||
export type PeriodPhase = 'menstruation' | 'follicular' | 'ovulation' | 'luteal' | 'unknown';
|
||||
|
||||
// ─── Local Record Types (Dexie) ───────────────────────────
|
||||
|
||||
export interface LocalCycle extends BaseRecord {
|
||||
export interface LocalPeriod 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)
|
||||
|
|
@ -23,20 +23,20 @@ export interface LocalCycle extends BaseRecord {
|
|||
timeBlockId?: string | null; // link to timeBlocks table (menstruation phase)
|
||||
}
|
||||
|
||||
export interface LocalCycleDayLog extends BaseRecord {
|
||||
export interface LocalPeriodDayLog extends BaseRecord {
|
||||
logDate: string; // ISO YYYY-MM-DD
|
||||
cycleId: string | null;
|
||||
periodId: 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
|
||||
symptoms: string[]; // periodSymptom.id refs
|
||||
sexualActivity: boolean | null;
|
||||
notes: string | null;
|
||||
}
|
||||
|
||||
export interface LocalCycleSymptom extends BaseRecord {
|
||||
export interface LocalPeriodSymptom extends BaseRecord {
|
||||
name: string;
|
||||
category: SymptomCategory;
|
||||
color: string | null;
|
||||
|
|
@ -45,7 +45,7 @@ export interface LocalCycleSymptom extends BaseRecord {
|
|||
|
||||
// ─── Domain Types ─────────────────────────────────────────
|
||||
|
||||
export interface Cycle {
|
||||
export interface Period {
|
||||
id: string;
|
||||
startDate: string;
|
||||
periodEndDate: string | null;
|
||||
|
|
@ -58,10 +58,10 @@ export interface Cycle {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CycleDayLog {
|
||||
export interface PeriodDayLog {
|
||||
id: string;
|
||||
logDate: string;
|
||||
cycleId: string | null;
|
||||
periodId: string | null;
|
||||
flow: Flow;
|
||||
mood: Mood | null;
|
||||
energy: number | null;
|
||||
|
|
@ -74,7 +74,7 @@ export interface CycleDayLog {
|
|||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface CycleSymptom {
|
||||
export interface PeriodSymptom {
|
||||
id: string;
|
||||
name: string;
|
||||
category: SymptomCategory;
|
||||
|
|
@ -118,7 +118,7 @@ export const MOOD_COLORS: Record<Mood, string> = {
|
|||
bad: '#ef4444',
|
||||
};
|
||||
|
||||
export const PHASE_LABELS: Record<CyclePhase, string> = {
|
||||
export const PHASE_LABELS: Record<PeriodPhase, string> = {
|
||||
menstruation: 'Menstruation',
|
||||
follicular: 'Follikelphase',
|
||||
ovulation: 'Eisprung',
|
||||
|
|
@ -126,7 +126,7 @@ export const PHASE_LABELS: Record<CyclePhase, string> = {
|
|||
unknown: 'Unbekannt',
|
||||
};
|
||||
|
||||
export const PHASE_COLORS: Record<CyclePhase, string> = {
|
||||
export const PHASE_COLORS: Record<PeriodPhase, string> = {
|
||||
menstruation: '#e11d48',
|
||||
follicular: '#f59e0b',
|
||||
ovulation: '#22c55e',
|
||||
|
|
@ -142,6 +142,6 @@ export const CERVICAL_MUCUS_LABELS: Record<CervicalMucus, string> = {
|
|||
eggwhite: 'Eiweiß',
|
||||
};
|
||||
|
||||
export const DEFAULT_CYCLE_LENGTH = 28;
|
||||
export const DEFAULT_PERIOD_LENGTH = 5;
|
||||
export const DEFAULT_PERIOD_LENGTH = 28;
|
||||
export const DEFAULT_BLEEDING_DAYS = 5;
|
||||
export const DEFAULT_LUTEAL_LENGTH = 14;
|
||||
|
|
|
|||
|
|
@ -3,12 +3,12 @@ import {
|
|||
detectPeriodEnd,
|
||||
DRY_DAYS_FOR_PERIOD_END,
|
||||
isBleedingFlow,
|
||||
MIN_GAP_FOR_NEW_CYCLE,
|
||||
shouldStartNewCycle,
|
||||
MIN_GAP_FOR_NEW_PERIOD,
|
||||
shouldStartNewPeriod,
|
||||
} from './auto-detect';
|
||||
import type { Cycle, CycleDayLog, Flow } from '../types';
|
||||
import type { Period, PeriodDayLog, Flow } from '../types';
|
||||
|
||||
function makeCycle(overrides: Partial<Cycle>): Cycle {
|
||||
function makePeriod(overrides: Partial<Period>): Period {
|
||||
return {
|
||||
id: 'c',
|
||||
startDate: '2026-01-01',
|
||||
|
|
@ -24,11 +24,11 @@ function makeCycle(overrides: Partial<Cycle>): Cycle {
|
|||
};
|
||||
}
|
||||
|
||||
function makeLog(logDate: string, flow: Flow): CycleDayLog {
|
||||
function makeLog(logDate: string, flow: Flow): PeriodDayLog {
|
||||
return {
|
||||
id: `log-${logDate}`,
|
||||
logDate,
|
||||
cycleId: 'c',
|
||||
periodId: 'c',
|
||||
flow,
|
||||
mood: null,
|
||||
energy: null,
|
||||
|
|
@ -54,76 +54,76 @@ describe('isBleedingFlow', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('shouldStartNewCycle', () => {
|
||||
describe('shouldStartNewPeriod', () => {
|
||||
it('returns false for non-bleeding flow', () => {
|
||||
expect(shouldStartNewCycle('2026-04-07', 'none', [])).toBe(false);
|
||||
expect(shouldStartNewCycle('2026-04-07', 'spotting', [])).toBe(false);
|
||||
expect(shouldStartNewPeriod('2026-04-07', 'none', [])).toBe(false);
|
||||
expect(shouldStartNewPeriod('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 true with no existing periods and bleeding flow', () => {
|
||||
expect(shouldStartNewPeriod('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 current period is still open (no periodEndDate)', () => {
|
||||
const periods = [makePeriod({ startDate: '2026-04-01' })];
|
||||
// flow during the open period — not a new period
|
||||
expect(shouldStartNewPeriod('2026-04-03', 'heavy', periods)).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);
|
||||
const periods = [makePeriod({ startDate: '2026-04-01', periodEndDate: '2026-04-05' })];
|
||||
// 9 days after periodEndDate — too soon, probably mid-period bleeding
|
||||
expect(shouldStartNewPeriod('2026-04-14', 'medium', periods)).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 periods = [makePeriod({ 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);
|
||||
expect(daysGapForTest(newDate, '2026-04-05')).toBe(MIN_GAP_FOR_NEW_PERIOD);
|
||||
expect(shouldStartNewPeriod(newDate, 'medium', periods)).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 }),
|
||||
it('ignores predicted periods', () => {
|
||||
const periods = [
|
||||
makePeriod({ id: 'real', startDate: '2026-01-01', periodEndDate: '2026-01-05' }),
|
||||
makePeriod({ 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);
|
||||
// 2026-04-15 → with the real period far in the past, should start new
|
||||
expect(shouldStartNewPeriod('2026-04-15', 'medium', periods)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for date before the latest cycle', () => {
|
||||
const cycles = [makeCycle({ startDate: '2026-04-01', periodEndDate: '2026-04-05' })];
|
||||
it('returns false for date before the latest period', () => {
|
||||
const periods = [makePeriod({ 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);
|
||||
expect(shouldStartNewPeriod('2026-03-10', 'medium', periods)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('detectPeriodEnd', () => {
|
||||
const openCycle = makeCycle({ id: 'c', startDate: '2026-04-01' });
|
||||
const openPeriod = makePeriod({ id: 'c', startDate: '2026-04-01' });
|
||||
|
||||
it('returns null for non-none flow', () => {
|
||||
expect(detectPeriodEnd('2026-04-07', 'light', openCycle, [])).toBeNull();
|
||||
expect(detectPeriodEnd('2026-04-07', 'light', openPeriod, [])).toBeNull();
|
||||
});
|
||||
|
||||
it('returns null without an open cycle', () => {
|
||||
it('returns null without an open period', () => {
|
||||
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' });
|
||||
it('returns null when period already has periodEndDate', () => {
|
||||
const closed = makePeriod({ 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', () => {
|
||||
it('returns null when no bleeding day exists in period', () => {
|
||||
const logs = [makeLog('2026-04-01', 'none'), makeLog('2026-04-02', 'none')];
|
||||
expect(detectPeriodEnd('2026-04-07', 'none', openCycle, logs)).toBeNull();
|
||||
expect(detectPeriodEnd('2026-04-07', 'none', openPeriod, 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();
|
||||
expect(detectPeriodEnd('2026-04-05', 'none', openPeriod, logs)).toBeNull();
|
||||
});
|
||||
|
||||
it('returns lastBleedingDay after DRY_DAYS_FOR_PERIOD_END', () => {
|
||||
|
|
@ -135,7 +135,7 @@ describe('detectPeriodEnd', () => {
|
|||
];
|
||||
// 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');
|
||||
expect(detectPeriodEnd('2026-04-06', 'none', openPeriod, logs)).toBe('2026-04-04');
|
||||
});
|
||||
|
||||
it('uses the LAST bleeding day, not the first', () => {
|
||||
|
|
@ -144,7 +144,7 @@ describe('detectPeriodEnd', () => {
|
|||
makeLog('2026-04-02', 'medium'),
|
||||
makeLog('2026-04-03', 'light'),
|
||||
];
|
||||
expect(detectPeriodEnd('2026-04-05', 'none', openCycle, logs)).toBe('2026-04-03');
|
||||
expect(detectPeriodEnd('2026-04-05', 'none', openPeriod, logs)).toBe('2026-04-03');
|
||||
});
|
||||
|
||||
it('ignores logs after the current logDate (chronology safe)', () => {
|
||||
|
|
@ -155,7 +155,7 @@ describe('detectPeriodEnd', () => {
|
|||
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();
|
||||
expect(detectPeriodEnd('2026-04-03', 'none', openPeriod, logs)).toBeNull();
|
||||
});
|
||||
|
||||
it('handles spotting as not bleeding (so spotting is not lastBleedingDay)', () => {
|
||||
|
|
@ -164,7 +164,7 @@ describe('detectPeriodEnd', () => {
|
|||
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');
|
||||
expect(detectPeriodEnd('2026-04-03', 'none', openPeriod, logs)).toBe('2026-04-01');
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,7 @@
|
|||
* periodEndDate auf den letzten Bleeding-Tag.
|
||||
*/
|
||||
|
||||
import type { Cycle, CycleDayLog, Flow } from '../types';
|
||||
import type { Period, PeriodDayLog, Flow } from '../types';
|
||||
import { daysBetween } from './phase';
|
||||
|
||||
/** Welche Flow-Werte zählen als "Blutung" (= Periode)? */
|
||||
|
|
@ -18,7 +18,7 @@ export function isBleedingFlow(flow: Flow): boolean {
|
|||
}
|
||||
|
||||
/** Mindestabstand (Tage) zwischen Ende einer Periode und Start eines neuen Zyklus. */
|
||||
export const MIN_GAP_FOR_NEW_CYCLE = 10;
|
||||
export const MIN_GAP_FOR_NEW_PERIOD = 10;
|
||||
/** Wieviele zusammenhängende trockene Tage nach Bleeding für Period-End-Detection. */
|
||||
export const DRY_DAYS_FOR_PERIOD_END = 2;
|
||||
|
||||
|
|
@ -28,14 +28,14 @@ export const DRY_DAYS_FOR_PERIOD_END = 2;
|
|||
* 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.
|
||||
* logDate liegt mindestens MIN_GAP_FOR_NEW_PERIOD Tage danach.
|
||||
*
|
||||
* Verhindert false positives für Tage innerhalb eines bestehenden Zyklus.
|
||||
*/
|
||||
export function shouldStartNewCycle(logDate: string, flow: Flow, cycles: Cycle[]): boolean {
|
||||
export function shouldStartNewPeriod(logDate: string, flow: Flow, periods: Period[]): boolean {
|
||||
if (!isBleedingFlow(flow)) return false;
|
||||
|
||||
const real = cycles.filter((c) => !c.isPredicted && !c.isArchived);
|
||||
const real = periods.filter((c) => !c.isPredicted && !c.isArchived);
|
||||
if (real.length === 0) return true;
|
||||
|
||||
const latest = [...real].sort((a, b) => b.startDate.localeCompare(a.startDate))[0];
|
||||
|
|
@ -43,11 +43,11 @@ export function shouldStartNewCycle(logDate: string, flow: Flow, cycles: Cycle[]
|
|||
// 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.ä.)
|
||||
// Aktueller Zyklus läuft noch — Blutung gehört dazu (Mid-Period-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;
|
||||
return daysBetween(logDate, latest.periodEndDate) >= MIN_GAP_FOR_NEW_PERIOD;
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -65,14 +65,14 @@ export function shouldStartNewCycle(logDate: string, flow: Flow, cycles: Cycle[]
|
|||
export function detectPeriodEnd(
|
||||
logDate: string,
|
||||
flow: Flow,
|
||||
openCycle: Cycle | null,
|
||||
logsInCycle: CycleDayLog[]
|
||||
openPeriod: Period | null,
|
||||
logsInPeriod: PeriodDayLog[]
|
||||
): string | null {
|
||||
if (flow !== 'none') return null;
|
||||
if (!openCycle || openCycle.periodEndDate) return null;
|
||||
if (!openPeriod || openPeriod.periodEndDate) return null;
|
||||
|
||||
// Tage des Zyklus nach Datum sortieren, alle bis einschließlich logDate
|
||||
const sorted = [...logsInCycle]
|
||||
const sorted = [...logsInPeriod]
|
||||
.filter((l) => l.logDate <= logDate)
|
||||
.sort((a, b) => a.logDate.localeCompare(b.logDate));
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import { daysBetween, derivePhase, findCycleForDate, getCycleDayNumber } from './phase';
|
||||
import type { Cycle } from '../types';
|
||||
import { daysBetween, derivePhase, findPeriodForDate, getPeriodDayNumber } from './phase';
|
||||
import type { Period } from '../types';
|
||||
|
||||
function makeCycle(overrides: Partial<Cycle>): Cycle {
|
||||
function makePeriod(overrides: Partial<Period>): Period {
|
||||
return {
|
||||
id: 'c',
|
||||
startDate: '2026-01-01',
|
||||
|
|
@ -33,43 +33,43 @@ describe('daysBetween', () => {
|
|||
});
|
||||
});
|
||||
|
||||
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' }),
|
||||
describe('findPeriodForDate', () => {
|
||||
const periods: Period[] = [
|
||||
makePeriod({ id: 'c1', startDate: '2026-01-01' }),
|
||||
makePeriod({ id: 'c2', startDate: '2026-01-29' }),
|
||||
makePeriod({ id: 'c3', startDate: '2026-02-26' }),
|
||||
];
|
||||
|
||||
it('returns null for date before any cycle', () => {
|
||||
expect(findCycleForDate('2025-12-31', cycles)).toBeNull();
|
||||
it('returns null for date before any period', () => {
|
||||
expect(findPeriodForDate('2025-12-31', periods)).toBeNull();
|
||||
});
|
||||
it('finds the latest cycle whose startDate <= date', () => {
|
||||
expect(findCycleForDate('2026-02-15', cycles)?.id).toBe('c2');
|
||||
it('finds the latest period whose startDate <= date', () => {
|
||||
expect(findPeriodForDate('2026-02-15', periods)?.id).toBe('c2');
|
||||
});
|
||||
it('matches exact start date', () => {
|
||||
expect(findCycleForDate('2026-02-26', cycles)?.id).toBe('c3');
|
||||
expect(findPeriodForDate('2026-02-26', periods)?.id).toBe('c3');
|
||||
});
|
||||
it('returns most recent for late date', () => {
|
||||
expect(findCycleForDate('2026-12-31', cycles)?.id).toBe('c3');
|
||||
expect(findPeriodForDate('2026-12-31', periods)?.id).toBe('c3');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCycleDayNumber', () => {
|
||||
const cycle = makeCycle({ startDate: '2026-04-01' });
|
||||
describe('getPeriodDayNumber', () => {
|
||||
const period = makePeriod({ startDate: '2026-04-01' });
|
||||
it('returns 1 on the start date', () => {
|
||||
expect(getCycleDayNumber('2026-04-01', cycle)).toBe(1);
|
||||
expect(getPeriodDayNumber('2026-04-01', period)).toBe(1);
|
||||
});
|
||||
it('returns N+1 N days after start', () => {
|
||||
expect(getCycleDayNumber('2026-04-08', cycle)).toBe(8);
|
||||
expect(getPeriodDayNumber('2026-04-08', period)).toBe(8);
|
||||
});
|
||||
it('returns null before the cycle', () => {
|
||||
expect(getCycleDayNumber('2026-03-30', cycle)).toBeNull();
|
||||
it('returns null before the period', () => {
|
||||
expect(getPeriodDayNumber('2026-03-30', period)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('derivePhase', () => {
|
||||
const cycles: Cycle[] = [
|
||||
makeCycle({
|
||||
const periods: Period[] = [
|
||||
makePeriod({
|
||||
id: 'c1',
|
||||
startDate: '2026-04-01',
|
||||
periodEndDate: '2026-04-05', // 5 days of period
|
||||
|
|
@ -77,31 +77,31 @@ describe('derivePhase', () => {
|
|||
}),
|
||||
];
|
||||
|
||||
it('returns unknown when no cycle covers the date', () => {
|
||||
expect(derivePhase('2025-12-31', cycles)).toBe('unknown');
|
||||
it('returns unknown when no period covers the date', () => {
|
||||
expect(derivePhase('2025-12-31', periods)).toBe('unknown');
|
||||
});
|
||||
it('returns menstruation on day 1', () => {
|
||||
expect(derivePhase('2026-04-01', cycles)).toBe('menstruation');
|
||||
expect(derivePhase('2026-04-01', periods)).toBe('menstruation');
|
||||
});
|
||||
it('returns menstruation on the last bleeding day', () => {
|
||||
expect(derivePhase('2026-04-05', cycles)).toBe('menstruation');
|
||||
expect(derivePhase('2026-04-05', periods)).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');
|
||||
expect(derivePhase('2026-04-08', periods)).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');
|
||||
expect(derivePhase('2026-04-14', periods)).toBe('ovulation');
|
||||
expect(derivePhase('2026-04-13', periods)).toBe('ovulation');
|
||||
expect(derivePhase('2026-04-15', periods)).toBe('ovulation');
|
||||
});
|
||||
it('returns luteal after ovulation', () => {
|
||||
// day 20
|
||||
expect(derivePhase('2026-04-20', cycles)).toBe('luteal');
|
||||
expect(derivePhase('2026-04-20', periods)).toBe('luteal');
|
||||
});
|
||||
it('falls back to default period length when periodEndDate missing', () => {
|
||||
const c: Cycle[] = [makeCycle({ startDate: '2026-04-01', length: 28 })];
|
||||
const c: Period[] = [makePeriod({ 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');
|
||||
|
|
|
|||
|
|
@ -3,11 +3,11 @@
|
|||
*/
|
||||
|
||||
import {
|
||||
DEFAULT_CYCLE_LENGTH,
|
||||
DEFAULT_LUTEAL_LENGTH,
|
||||
DEFAULT_PERIOD_LENGTH,
|
||||
type Cycle,
|
||||
type CyclePhase,
|
||||
DEFAULT_LUTEAL_LENGTH,
|
||||
DEFAULT_BLEEDING_DAYS,
|
||||
type Period,
|
||||
type PeriodPhase,
|
||||
} from '../types';
|
||||
|
||||
/** Tage zwischen zwei ISO-Daten (a - b) */
|
||||
|
|
@ -16,10 +16,10 @@ export function daysBetween(a: string, b: string): number {
|
|||
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;
|
||||
/** Findet den Zyklus, der das gegebene Datum enthält. Periods müssen nach startDate sortiert sein. */
|
||||
export function findPeriodForDate(date: string, periods: Period[]): Period | null {
|
||||
const sorted = [...periods].sort((a, b) => a.startDate.localeCompare(b.startDate));
|
||||
let match: Period | null = null;
|
||||
for (const c of sorted) {
|
||||
if (c.startDate <= date) match = c;
|
||||
else break;
|
||||
|
|
@ -28,8 +28,8 @@ export function findCycleForDate(date: string, cycles: Cycle[]): Cycle | null {
|
|||
}
|
||||
|
||||
/** 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);
|
||||
export function getPeriodDayNumber(date: string, period: Period): number | null {
|
||||
const diff = daysBetween(date, period.startDate);
|
||||
if (diff < 0) return null;
|
||||
return diff + 1;
|
||||
}
|
||||
|
|
@ -39,29 +39,29 @@ export function getCycleDayNumber(date: string, cycle: Cycle): number | null {
|
|||
*
|
||||
* Heuristik:
|
||||
* - Periode: Tag 1..periodLength
|
||||
* - Eisprung: cycleLength - lutealLength (±1 Tag)
|
||||
* - Eisprung: periodLength - 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';
|
||||
periods: Period[],
|
||||
avgPeriodLength = DEFAULT_PERIOD_LENGTH
|
||||
): PeriodPhase {
|
||||
const period = findPeriodForDate(date, periods);
|
||||
if (!period) return 'unknown';
|
||||
|
||||
const dayNum = getCycleDayNumber(date, cycle);
|
||||
const dayNum = getPeriodDayNumber(date, period);
|
||||
if (dayNum === null) return 'unknown';
|
||||
|
||||
const periodLength =
|
||||
cycle.periodEndDate && cycle.periodEndDate >= cycle.startDate
|
||||
? daysBetween(cycle.periodEndDate, cycle.startDate) + 1
|
||||
: DEFAULT_PERIOD_LENGTH;
|
||||
const bleedingLength =
|
||||
period.periodEndDate && period.periodEndDate >= period.startDate
|
||||
? daysBetween(period.periodEndDate, period.startDate) + 1
|
||||
: DEFAULT_BLEEDING_DAYS;
|
||||
|
||||
const cycleLength = cycle.length ?? avgCycleLength;
|
||||
const ovulationDay = cycleLength - DEFAULT_LUTEAL_LENGTH;
|
||||
const periodLength = period.length ?? avgPeriodLength;
|
||||
const ovulationDay = periodLength - DEFAULT_LUTEAL_LENGTH;
|
||||
|
||||
if (dayNum <= periodLength) return 'menstruation';
|
||||
if (dayNum <= bleedingLength) return 'menstruation';
|
||||
if (Math.abs(dayNum - ovulationDay) <= 1) return 'ovulation';
|
||||
if (dayNum < ovulationDay) return 'follicular';
|
||||
return 'luteal';
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import {
|
||||
averageCycleLength,
|
||||
computeCycleStats,
|
||||
averagePeriodLength,
|
||||
computePeriodStats,
|
||||
daysUntilNextPeriod,
|
||||
predictFertileWindow,
|
||||
predictNextPeriodStart,
|
||||
} from './prediction';
|
||||
import type { Cycle } from '../types';
|
||||
import type { Period } from '../types';
|
||||
|
||||
function cycle(startDate: string, length: number | null = null, isPredicted = false): Cycle {
|
||||
function period(startDate: string, length: number | null = null, isPredicted = false): Period {
|
||||
return {
|
||||
id: `c-${startDate}`,
|
||||
startDate,
|
||||
|
|
@ -23,50 +23,52 @@ function cycle(startDate: string, length: number | null = null, isPredicted = fa
|
|||
};
|
||||
}
|
||||
|
||||
describe('averageCycleLength', () => {
|
||||
it('returns default 28 when no cycles', () => {
|
||||
expect(averageCycleLength([])).toBe(28);
|
||||
describe('averagePeriodLength', () => {
|
||||
it('returns default 28 when no periods', () => {
|
||||
expect(averagePeriodLength([])).toBe(28);
|
||||
});
|
||||
it('returns default when no closed cycles (no length)', () => {
|
||||
expect(averageCycleLength([cycle('2026-01-01')])).toBe(28);
|
||||
it('returns default when no closed periods (no length)', () => {
|
||||
expect(averagePeriodLength([period('2026-01-01')])).toBe(28);
|
||||
});
|
||||
it('averages closed cycle lengths', () => {
|
||||
it('averages closed period lengths', () => {
|
||||
expect(
|
||||
averageCycleLength([
|
||||
cycle('2026-01-01', 28),
|
||||
cycle('2026-02-01', 30),
|
||||
cycle('2026-03-01', 26),
|
||||
averagePeriodLength([
|
||||
period('2026-01-01', 28),
|
||||
period('2026-02-01', 30),
|
||||
period('2026-03-01', 26),
|
||||
])
|
||||
).toBe(28);
|
||||
});
|
||||
it('caps to most recent N cycles', () => {
|
||||
// 7 cycles, but window=6 — oldest (length 100) should be ignored
|
||||
it('caps to most recent N periods', () => {
|
||||
// 7 periods, 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),
|
||||
period('2026-01-01', 100),
|
||||
period('2026-02-01', 28),
|
||||
period('2026-03-01', 28),
|
||||
period('2026-04-01', 28),
|
||||
period('2026-05-01', 28),
|
||||
period('2026-06-01', 28),
|
||||
period('2026-07-01', 28),
|
||||
];
|
||||
expect(averageCycleLength(c, 6)).toBe(28);
|
||||
expect(averagePeriodLength(c, 6)).toBe(28);
|
||||
});
|
||||
it('ignores predicted cycles', () => {
|
||||
expect(averageCycleLength([cycle('2026-01-01', 28), cycle('2026-02-01', 100, true)])).toBe(28);
|
||||
it('ignores predicted periods', () => {
|
||||
expect(averagePeriodLength([period('2026-01-01', 28), period('2026-02-01', 100, true)])).toBe(
|
||||
28
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('predictNextPeriodStart', () => {
|
||||
it('returns null with no cycles', () => {
|
||||
it('returns null with no periods', () => {
|
||||
expect(predictNextPeriodStart([])).toBeNull();
|
||||
});
|
||||
it('predicts based on latest start + average length', () => {
|
||||
const c = [cycle('2026-01-01', 28), cycle('2026-01-29', 28)];
|
||||
const c = [period('2026-01-01', 28), period('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')];
|
||||
it('uses default length when no closed periods exist', () => {
|
||||
const c = [period('2026-01-01')];
|
||||
// 2026-01-01 + 28 days = 2026-01-29
|
||||
expect(predictNextPeriodStart(c)).toBe('2026-01-29');
|
||||
});
|
||||
|
|
@ -78,9 +80,9 @@ describe('daysUntilNextPeriod', () => {
|
|||
});
|
||||
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)
|
||||
// Create a period 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)]);
|
||||
const result = daysUntilNextPeriod([period(start)]);
|
||||
expect(result).toBeGreaterThanOrEqual(13);
|
||||
expect(result).toBeLessThanOrEqual(15);
|
||||
expect(todayIso).toBeTruthy();
|
||||
|
|
@ -92,8 +94,8 @@ describe('predictFertileWindow', () => {
|
|||
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)];
|
||||
// Default 28 day period, luteal=14, so ovulation = day 14 (= start + 13 days)
|
||||
const c = [period('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
|
||||
|
|
@ -103,12 +105,12 @@ describe('predictFertileWindow', () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe('computeCycleStats', () => {
|
||||
describe('computePeriodStats', () => {
|
||||
it('returns zeros for empty input', () => {
|
||||
expect(computeCycleStats([])).toEqual({ total: 0, avg: 0, shortest: 0, longest: 0 });
|
||||
expect(computePeriodStats([])).toEqual({ total: 0, avg: 0, shortest: 0, longest: 0 });
|
||||
});
|
||||
it('ignores cycles with no length', () => {
|
||||
expect(computeCycleStats([cycle('2026-01-01')])).toEqual({
|
||||
it('ignores periods with no length', () => {
|
||||
expect(computePeriodStats([period('2026-01-01')])).toEqual({
|
||||
total: 0,
|
||||
avg: 0,
|
||||
shortest: 0,
|
||||
|
|
@ -116,7 +118,7 @@ describe('computeCycleStats', () => {
|
|||
});
|
||||
});
|
||||
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 });
|
||||
const c = [period('2026-01-01', 26), period('2026-02-01', 28), period('2026-03-01', 30)];
|
||||
expect(computePeriodStats(c)).toEqual({ total: 3, avg: 28, shortest: 26, longest: 30 });
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,44 +2,44 @@
|
|||
* Prediction — einfache Vorhersagen über gleitenden Mittelwert.
|
||||
*/
|
||||
|
||||
import { DEFAULT_CYCLE_LENGTH, DEFAULT_LUTEAL_LENGTH, type Cycle } from '../types';
|
||||
import { DEFAULT_PERIOD_LENGTH, DEFAULT_LUTEAL_LENGTH, type Period } 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
|
||||
export function averagePeriodLength(periods: Period[], window = 6): number {
|
||||
const closed = periods
|
||||
.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;
|
||||
if (closed.length === 0) return DEFAULT_PERIOD_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);
|
||||
export function predictNextPeriodStart(periods: Period[]): string | null {
|
||||
const real = periods.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 avg = averagePeriodLength(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);
|
||||
export function daysUntilNextPeriod(periods: Period[]): number | null {
|
||||
const next = predictNextPeriodStart(periods);
|
||||
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);
|
||||
export function predictFertileWindow(periods: Period[]): { start: string; end: string } | null {
|
||||
const real = periods.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 avg = averagePeriodLength(real);
|
||||
const ovulationDay = avg - DEFAULT_LUTEAL_LENGTH;
|
||||
const start = new Date(latest.startDate);
|
||||
start.setUTCDate(start.getUTCDate() + ovulationDay - 5);
|
||||
|
|
@ -52,8 +52,8 @@ export function predictFertileWindow(cycles: Cycle[]): { start: string; end: str
|
|||
}
|
||||
|
||||
/** Statistik-Snapshot über alle echten Zyklen. */
|
||||
export function computeCycleStats(cycles: Cycle[]) {
|
||||
const real = cycles.filter((c) => !c.isPredicted && typeof c.length === 'number');
|
||||
export function computePeriodStats(periods: Period[]) {
|
||||
const real = periods.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;
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ vi.mock('@mana/shared-utils/analytics', () => ({
|
|||
|
||||
// Database hooks call into funnel-tracking + trigger registry on every
|
||||
// write. They reach for browser-only globals (localStorage), so stub them
|
||||
// the same way cycles.integration.test.ts does.
|
||||
// the same way period.integration.test.ts does.
|
||||
vi.mock('$lib/stores/funnel-tracking', () => ({ trackFirstContent: vi.fn() }));
|
||||
vi.mock('$lib/triggers/registry', () => ({ fire: vi.fn() }));
|
||||
vi.mock('$lib/triggers/inline-suggest', () => ({
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@
|
|||
*
|
||||
* Lives next to the playground UI rather than in a shared package because
|
||||
* the playground is the only consumer right now. If chat / todo enrichment
|
||||
* / cycles insights end up calling the same surface in the future, lift
|
||||
* / period insights end up calling the same surface in the future, lift
|
||||
* this into `$lib/data/llm-client.ts`.
|
||||
*
|
||||
* The chunk parser is hand-rolled rather than pulled from a library: the
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ describe('WIDGET_REGISTRY', () => {
|
|||
'mana-auth',
|
||||
'food',
|
||||
'plants',
|
||||
'cycles',
|
||||
'period',
|
||||
undefined,
|
||||
];
|
||||
for (const widget of WIDGET_REGISTRY) {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ export type WidgetType =
|
|||
| 'plant-watering' // Plants: plants due for watering
|
||||
| 'day-timeline' // TimeBlocks: chronological day timeline
|
||||
| 'activity-feed' // TimeBlocks: recent activity across modules
|
||||
| 'cycles' // Cycles: current phase + days until next period
|
||||
| 'period' // Period: current phase + days until next period
|
||||
| 'news-unread' // News: latest unread curated articles
|
||||
| 'body-stats'; // Body: latest weight + active workout summary
|
||||
|
||||
|
|
@ -132,7 +132,7 @@ export interface WidgetMeta {
|
|||
| 'times'
|
||||
| 'food'
|
||||
| 'plants'
|
||||
| 'cycles'
|
||||
| 'period'
|
||||
| 'body'
|
||||
| 'mana-auth';
|
||||
}
|
||||
|
|
@ -337,13 +337,13 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
|
|||
allowMultiple: false,
|
||||
},
|
||||
{
|
||||
type: 'cycles',
|
||||
nameKey: 'dashboard.widgets.cycles.title',
|
||||
descriptionKey: 'dashboard.widgets.cycles.description',
|
||||
type: 'period',
|
||||
nameKey: 'dashboard.widgets.period.title',
|
||||
descriptionKey: 'dashboard.widgets.period.description',
|
||||
icon: '🌸',
|
||||
defaultSize: 'small',
|
||||
allowMultiple: false,
|
||||
requiredBackend: 'cycles',
|
||||
requiredBackend: 'period',
|
||||
},
|
||||
{
|
||||
type: 'news-unread',
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@
|
|||
{ href: '/admin/users', label: 'Users', icon: 'users' },
|
||||
{ href: '/admin/user-data', label: 'User Data', icon: 'database' },
|
||||
{ href: '/admin/system', label: 'System', icon: 'server' },
|
||||
{ href: '/admin/complexity', label: 'Complexity', icon: 'chart' },
|
||||
];
|
||||
|
||||
const icons: Record<string, string> = {
|
||||
|
|
@ -27,6 +28,7 @@
|
|||
users: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4.354a4 4 0 110 5.292M15 21H3v-1a6 6 0 0112 0v1zm0 0h6v-1a6 6 0 00-9-5.197M13 7a4 4 0 11-8 0 4 4 0 018 0z" />`,
|
||||
database: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 7v10c0 2.21 3.582 4 8 4s8-1.79 8-4V7M4 7c0 2.21 3.582 4 8 4s8-1.79 8-4M4 7c0-2.21 3.582-4 8-4s8 1.79 8 4m0 5c0 2.21-3.582 4-8 4s-8-1.79-8-4" />`,
|
||||
server: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 12h14M5 12a2 2 0 01-2-2V6a2 2 0 012-2h14a2 2 0 012 2v4a2 2 0 01-2 2M5 12a2 2 0 00-2 2v4a2 2 0 002 2h14a2 2 0 002-2v-4a2 2 0 00-2-2m-2-4h.01M17 16h.01" />`,
|
||||
chart: `<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 3v18h18M7 14l4-4 4 4 5-5" />`,
|
||||
};
|
||||
|
||||
function isActive(href: string, pathname: string): boolean {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
<script lang="ts">
|
||||
import ListView from '$lib/modules/cycles/ListView.svelte';
|
||||
import ListView from '$lib/modules/period/ListView.svelte';
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Cycles - Mana</title>
|
||||
<title>Period - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<ListView navigate={() => {}} goBack={() => history.back()} params={{}} />
|
||||
|
|
|
|||
|
|
@ -247,7 +247,7 @@ scrape_configs:
|
|||
- https://mana.how/journal
|
||||
- https://mana.how/dreams
|
||||
- https://mana.how/firsts
|
||||
- https://mana.how/cycles
|
||||
- https://mana.how/period
|
||||
- https://mana.how/events
|
||||
- https://mana.how/finance
|
||||
- https://mana.how/places
|
||||
|
|
|
|||
|
|
@ -1394,7 +1394,7 @@ Phase 1 (Events) ──────┬──> Phase 2 (Projections)
|
|||
| 18 | Chat | 2 | 1 | Batch 4 |
|
||||
| 19 | Memoro | 1 | 1 | Batch 4 |
|
||||
| 20 | Skilltree | 2 | 2 | Batch 4 |
|
||||
| 21 | Cycles | 1 | 1 | Batch 5 |
|
||||
| 21 | Period | 1 | 1 | Batch 5 |
|
||||
| 22 | Firsts | 1 | 1 | Batch 5 |
|
||||
| 23 | Guides | 1 | 1 | Batch 5 |
|
||||
| 24 | Inventory | 1 | 1 | Batch 5 |
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ recommendation.
|
|||
|
||||
**Productivity:** todo, calendar, contacts, notes, habits, times, timeblocks, events
|
||||
**Knowledge & learning:** cards, zitare, guides, questions, skilltree, memoro, context
|
||||
**Health & self:** food, cycles, dreams, moodlit, plants
|
||||
**Health & self:** food, period, dreams, moodlit, plants
|
||||
**Media & creative:** chat, picture, presi, music, photos, storage, uload
|
||||
**Data & tools:** finance, calc, inventory, places, citycorners, who, news, links, tags, playground
|
||||
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ Tracked improvements for UI/styling consistency across the Mana unified app.
|
|||
Module ListViews use two different styling approaches:
|
||||
|
||||
- **Scoped CSS + `hsl(var(--color-*))` theme tokens** — 27 modules (65%)
|
||||
- todo, notes, drink, contacts, journal, dreams, habits, firsts, calendar, chat, places, inventory, finance, news, body, calc, events, photos, automations, cycles, uload, picture, recipes
|
||||
- todo, notes, drink, contacts, journal, dreams, habits, firsts, calendar, chat, places, inventory, finance, news, body, calc, events, photos, automations, period, uload, picture, recipes
|
||||
- **Tailwind utility classes** — 13 modules (35%)
|
||||
- food, plants, moodlit, cards, presi, storage, skilltree, context, guides, memoro, who, music, playground, citycorners, questions, times
|
||||
|
||||
|
|
|
|||
|
|
@ -19,6 +19,10 @@
|
|||
"check:status": "bash scripts/check-status.sh",
|
||||
"validate:dockerfiles": "node scripts/validate-dockerfiles.mjs",
|
||||
"audit:deps": "node scripts/audit-workspace-deps.mjs",
|
||||
"audit:modules": "node scripts/audit-modules.mjs",
|
||||
"audit:coupling": "node scripts/audit-module-coupling.mjs",
|
||||
"audit:complexity": "node scripts/audit-complexity.mjs",
|
||||
"audit:map": "node scripts/build-complexity-map.mjs",
|
||||
"generate:dockerfiles": "node scripts/generate-dockerfiles.mjs",
|
||||
"setup:env": "node scripts/generate-env.mjs",
|
||||
"setup:secrets": "node scripts/setup-secrets.mjs",
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ 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(
|
||||
period: 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(
|
||||
|
|
@ -153,7 +153,7 @@ export const APP_ICONS = {
|
|||
body: svgToDataUrl(
|
||||
// Dumbbell + heart-pulse hybrid: training (barbell) + body (pulse line).
|
||||
// Red→orange gradient to set it apart from the green health-adjacent
|
||||
// modules (plants, food) and the pink cycles icon.
|
||||
// modules (plants, food) and the pink period icon.
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="bd" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#f97316"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#bd)"/><rect x="18" y="42" width="6" height="16" rx="2" fill="white"/><rect x="76" y="42" width="6" height="16" rx="2" fill="white"/><rect x="24" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="72" y="46" width="4" height="8" rx="1" fill="white" fill-opacity="0.85"/><rect x="28" y="48" width="44" height="4" rx="2" fill="white"/><path d="M30 70h12l4-8 6 16 4-10 6 6h12" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/></svg>`
|
||||
),
|
||||
firsts: svgToDataUrl(
|
||||
|
|
@ -200,7 +200,7 @@ export const APP_ICONS = {
|
|||
myday: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="md" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#F59E0B"/><stop offset="100%" style="stop-color:#F97316"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#md)"/><circle cx="50" cy="44" r="16" fill="white" fill-opacity="0.9"/><line x1="50" y1="20" x2="50" y2="26" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="50" y1="62" x2="50" y2="68" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="26" y1="44" x2="32" y2="44" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><line x1="68" y1="44" x2="74" y2="44" stroke="white" stroke-width="3" stroke-linecap="round" opacity="0.7"/><rect x="24" y="74" width="52" height="4" rx="2" fill="white" fill-opacity="0.5"/><rect x="30" y="82" width="40" height="3" rx="1.5" fill="white" fill-opacity="0.3"/></svg>`
|
||||
),
|
||||
eventstream: svgToDataUrl(
|
||||
activity: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="es" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#6366F1"/><stop offset="100%" style="stop-color:#8B5CF6"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#es)"/><polyline points="20,55 35,40 50,50 65,30 80,45" fill="none" stroke="white" stroke-width="3.5" stroke-linecap="round" stroke-linejoin="round" opacity="0.9"/><circle cx="35" cy="40" r="3" fill="white"/><circle cx="50" cy="50" r="3" fill="white"/><circle cx="65" cy="30" r="3" fill="white"/><circle cx="80" cy="45" r="3" fill="white"/><rect x="24" y="66" width="52" height="3" rx="1.5" fill="white" fill-opacity="0.4"/><rect x="24" y="74" width="36" height="3" rx="1.5" fill="white" fill-opacity="0.3"/></svg>`
|
||||
),
|
||||
companion: svgToDataUrl(
|
||||
|
|
|
|||
|
|
@ -667,8 +667,8 @@ export const MANA_APPS: ManaApp[] = [
|
|||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'cycles',
|
||||
name: 'Cycles',
|
||||
id: 'period',
|
||||
name: 'Periode',
|
||||
description: {
|
||||
de: 'Menstruationszyklus-Tracking',
|
||||
en: 'Menstrual Cycle Tracking',
|
||||
|
|
@ -677,7 +677,7 @@ export const MANA_APPS: ManaApp[] = [
|
|||
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,
|
||||
icon: APP_ICONS.period,
|
||||
color: '#ec4899',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
|
|
@ -894,14 +894,14 @@ export const MANA_APPS: ManaApp[] = [
|
|||
requiredTier: 'guest',
|
||||
},
|
||||
{
|
||||
id: 'eventstream',
|
||||
name: 'Events',
|
||||
description: { de: 'Live Event-Stream', en: 'Live Event Stream' },
|
||||
id: 'activity',
|
||||
name: 'Aktivität',
|
||||
description: { de: 'Live Aktivitäts-Stream', en: 'Live Activity Stream' },
|
||||
longDescription: {
|
||||
de: 'Echtzeit-Feed aller Aktionen ueber alle Module: Tasks, Drinks, Termine, Mahlzeiten.',
|
||||
en: 'Real-time feed of all actions across modules: tasks, drinks, events, meals.',
|
||||
},
|
||||
icon: APP_ICONS.eventstream ?? '⚡',
|
||||
icon: APP_ICONS.activity ?? '⚡',
|
||||
color: '#6366F1',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
|
|
|
|||
|
|
@ -35,7 +35,7 @@
|
|||
* ✅ color: hsl(var(--color-foreground));
|
||||
* ✅ background: hsl(var(--color-primary) / 0.12);
|
||||
*
|
||||
* 4. Brand-literal colors (cycles pink, observatory cosmic scenes, the
|
||||
* 4. Brand-literal colors (period pink, observatory cosmic scenes, the
|
||||
* automations/spiral indigo→violet ramp, sport/category palettes, the
|
||||
* photo viewer's near-black backdrop, etc.) deliberately stay as
|
||||
* literal hex/rgba/hsl. They are NOT theme intent — they encode brand
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue