feat(cycles): dashboard widget with phase + countdown

Add a CyclesWidget that appears on the unified dashboard. Shows the
current phase as a colored badge, the cycle day, a big-number
countdown to the next period, and the predicted next-period date.
Clickable — links to /cycles.

- New CyclesWidget.svelte under modules/core/widgets using liveQuery
  against the cycles table
- Registered via WIDGET_REGISTRY + widgetComponents map
- WidgetType union + requiredBackend union both extended
- Existing dashboard.test.ts whitelist updated for the new backend
- i18n keys dashboard.widgets.cycles.{title,description,empty,open}
  added across all 5 locales

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 17:37:27 +02:00
parent 0896b1afd1
commit 9e802b1e17
9 changed files with 179 additions and 1 deletions

View file

@ -30,6 +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 DayTimelineWidget from './widgets/DayTimelineWidget.svelte';
import ActivityFeedWidget from './widgets/ActivityFeedWidget.svelte';
@ -56,4 +57,5 @@ export const widgetComponents: Record<WidgetType, Component> = {
'plant-watering': PlantWateringWidget,
'day-timeline': DayTimelineWidget,
'activity-feed': ActivityFeedWidget,
cycles: CyclesWidget,
};

View file

@ -144,6 +144,12 @@
"title": "Aktivität",
"description": "Letzte Änderungen über alle Module",
"empty": "Noch keine Aktivität"
},
"cycles": {
"title": "Zyklus",
"description": "Aktuelle Phase und Countdown zur nächsten Periode",
"empty": "Noch kein Zyklus erfasst.",
"open": "Öffnen"
}
}
}

View file

@ -144,6 +144,12 @@
"title": "Activity",
"description": "Recent changes across all modules",
"empty": "No activity yet"
},
"cycles": {
"title": "Cycle",
"description": "Current phase and countdown to next period",
"empty": "No cycle logged yet.",
"open": "Open"
}
}
}

View file

@ -139,6 +139,12 @@
"title": "Mi Día",
"description": "Línea temporal cronológica de todas las actividades",
"empty": "Nada todavía hoy"
},
"cycles": {
"title": "Ciclo",
"description": "Fase actual y cuenta regresiva hasta el próximo período",
"empty": "Ningún ciclo registrado.",
"open": "Abrir"
}
}
}

View file

@ -139,6 +139,12 @@
"title": "Ma Journée",
"description": "Chronologie de toutes les activités de la journée",
"empty": "Rien encore aujourd'hui"
},
"cycles": {
"title": "Cycle",
"description": "Phase actuelle et compte à rebours jusqu'aux prochaines règles",
"empty": "Aucun cycle enregistré.",
"open": "Ouvrir"
}
}
}

View file

@ -139,6 +139,12 @@
"title": "La Mia Giornata",
"description": "Cronologia di tutte le attività della giornata",
"empty": "Niente ancora oggi"
},
"cycles": {
"title": "Ciclo",
"description": "Fase attuale e conto alla rovescia per il prossimo ciclo",
"empty": "Nessun ciclo registrato.",
"open": "Apri"
}
}
}

View file

@ -0,0 +1,134 @@
<script lang="ts">
/**
* CyclesWidget — 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.
*/
import { _ } from 'svelte-i18n';
import { liveQuery } from 'dexie';
import { db } from '$lib/data/database';
import { derivePhase, getCycleDayNumber } from '$lib/modules/cycles/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';
let cycles: Cycle[] = $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);
}).subscribe({
next: (val) => {
cycles = val;
loading = false;
},
error: () => {
loading = false;
},
});
return () => sub.unsubscribe();
});
const todayIso = new Date().toISOString().slice(0, 10);
const phase = $derived(derivePhase(todayIso, cycles));
const currentCycle = $derived(
cycles
.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));
function formatShortDate(iso: string | null): string {
if (!iso) return '—';
return new Date(iso).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit' });
}
</script>
<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')}
</h3>
{#if phase !== 'unknown'}
<span
class="rounded-full px-2.5 py-0.5 text-sm font-medium"
style="background: color-mix(in srgb, {PHASE_COLORS[
phase
]} 14%, transparent); color: {PHASE_COLORS[phase]}"
>
{$_(`cycles.phase.${phase}`)}
</span>
{/if}
</div>
{#if loading}
<div class="h-20 animate-pulse rounded bg-surface-hover"></div>
{:else if cycles.length === 0}
<div class="py-6 text-center">
<p class="text-sm text-muted-foreground">
{$_('dashboard.widgets.cycles.empty')}
</p>
<a
href="/cycles"
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')}
</a>
</div>
{:else}
<a
href="/cycles"
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}
<p class="text-xs text-muted-foreground">
{$_('cycles.label.cycleDay')}
{cycleDay}
</p>
{/if}
{#if daysUntil !== null}
<p class="text-2xl font-semibold" style="color: {PHASE_COLORS[phase]};">
{#if daysUntil > 0}
{daysUntil}
{:else if daysUntil === 0}
{$_('cycles.label.today')}
{:else}
+{Math.abs(daysUntil)}
{/if}
</p>
<p class="text-xs text-muted-foreground">
{#if daysUntil > 0}
{$_('cycles.label.daysUntilPeriod')}
{:else if daysUntil === 0}
{$_('cycles.label.predicted')}
{:else}
{$_('cycles.label.daysOverdue')}
{/if}
</p>
{/if}
</div>
{#if nextPeriod && daysUntil !== null && daysUntil >= 0}
<div class="text-right">
<p class="text-xs text-muted-foreground">
{$_('cycles.stats.nextPeriod')}
</p>
<p class="text-sm font-medium">{formatShortDate(nextPeriod)}</p>
</div>
{/if}
</div>
</a>
{/if}
</div>

View file

@ -55,6 +55,7 @@ describe('WIDGET_REGISTRY', () => {
'mana-auth',
'nutriphi',
'planta',
'cycles',
undefined,
];
for (const widget of WIDGET_REGISTRY) {

View file

@ -29,7 +29,8 @@ export type WidgetType =
| 'nutrition-progress' // NutriPhi: today's calorie progress
| 'plant-watering' // Planta: plants due for watering
| 'day-timeline' // TimeBlocks: chronological day timeline
| 'activity-feed'; // TimeBlocks: recent activity across modules
| 'activity-feed' // TimeBlocks: recent activity across modules
| 'cycles'; // Cycles: current phase + days until next period
/**
* Widget size - maps to CSS Grid columns
@ -129,6 +130,7 @@ export interface WidgetMeta {
| 'times'
| 'nutriphi'
| 'planta'
| 'cycles'
| 'mana-auth';
}
@ -331,6 +333,15 @@ export const WIDGET_REGISTRY: WidgetMeta[] = [
defaultSize: 'medium',
allowMultiple: false,
},
{
type: 'cycles',
nameKey: 'dashboard.widgets.cycles.title',
descriptionKey: 'dashboard.widgets.cycles.description',
icon: '🌸',
defaultSize: 'small',
allowMultiple: false,
requiredBackend: 'cycles',
},
];
/**