mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-15 11:21:09 +02:00
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:
parent
0896b1afd1
commit
9e802b1e17
9 changed files with 179 additions and 1 deletions
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
@ -55,6 +55,7 @@ describe('WIDGET_REGISTRY', () => {
|
|||
'mana-auth',
|
||||
'nutriphi',
|
||||
'planta',
|
||||
'cycles',
|
||||
undefined,
|
||||
];
|
||||
for (const widget of WIDGET_REGISTRY) {
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue