feat(cycles): month calendar view with phase coloring

Add a CycleCalendar component above the edit sections in the
workbench ListView. Shows a 7×6 month grid with each day colored by
its derived phase, small flow markers on days with bleeding, the
current day outlined, and the edit target highlighted with a ring.

- Prev/next month buttons and a clickable header to jump back to
  the current month
- Monday-first week, weekday labels localized via locale store
- Clicking any day switches editingDate so the flow/mood/symptom
  controls below update that day directly
- Collapsible via a +/− toggle in the section header
- i18n keys for calendar.title/prev/next; de + en translated,
  it/fr/es mirrored from en

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-04-07 17:05:20 +02:00
parent e7585fb870
commit b0a9dfeedb
7 changed files with 347 additions and 0 deletions

View file

@ -67,6 +67,11 @@
"emotional": "Emotional",
"other": "Sonstiges"
},
"calendar": {
"title": "Kalender",
"prev": "Vorheriger Monat",
"next": "Nächster Monat"
},
"symptomManager": {
"title": "Symptome verwalten",
"open": "Verwalten",

View file

@ -67,6 +67,11 @@
"emotional": "Emotional",
"other": "Other"
},
"calendar": {
"title": "Calendar",
"prev": "Previous month",
"next": "Next month"
},
"symptomManager": {
"title": "Manage symptoms",
"open": "Manage",

View file

@ -67,6 +67,11 @@
"emotional": "Emotional",
"other": "Other"
},
"calendar": {
"title": "Calendar",
"prev": "Previous month",
"next": "Next month"
},
"symptomManager": {
"title": "Manage symptoms",
"open": "Manage",

View file

@ -67,6 +67,11 @@
"emotional": "Emotional",
"other": "Other"
},
"calendar": {
"title": "Calendar",
"prev": "Previous month",
"next": "Next month"
},
"symptomManager": {
"title": "Manage symptoms",
"open": "Manage",

View file

@ -67,6 +67,11 @@
"emotional": "Emotional",
"other": "Other"
},
"calendar": {
"title": "Calendar",
"prev": "Previous month",
"next": "Next month"
},
"symptomManager": {
"title": "Manage symptoms",
"open": "Manage",

View file

@ -21,6 +21,7 @@
predictNextPeriodStart,
} from './utils/prediction';
import { FLOW_COLORS, MOOD_COLORS, PHASE_COLORS, type Flow, type Mood } from './types';
import CycleCalendar from './components/CycleCalendar.svelte';
import SymptomManager from './components/SymptomManager.svelte';
import type { ViewProps } from '$lib/app-registry';
@ -52,6 +53,9 @@
// ─ Symptom manager modal state
let symptomManagerOpen = $state(false);
// ─ Calendar visibility toggle
let calendarOpen = $state(true);
// ─ Editing state — defaults to today, can be switched to any past day
let editingDate = $state(todayIso);
let editingLog = $derived(logs.find((l) => l.logDate === editingDate) ?? null);
@ -177,6 +181,19 @@
</div>
</div>
<!-- Calendar -->
<section class="log-section">
<div class="section-header">
<h3 class="section-label">{$_('cycles.calendar.title')}</h3>
<button class="section-action" onclick={() => (calendarOpen = !calendarOpen)}>
{calendarOpen ? '' : '+'}
</button>
</div>
{#if calendarOpen}
<CycleCalendar {cycles} {logs} {editingDate} {todayIso} onSelectDay={selectDay} />
{/if}
</section>
<!-- Edit-past-day Banner -->
{#if isEditingPast}
<div class="edit-banner">

View file

@ -0,0 +1,305 @@
<!--
Cycle 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 { FLOW_COLORS, PHASE_COLORS } from '../types';
import { derivePhase } from '../utils/phase';
interface Props {
cycles: Cycle[];
logs: CycleDayLog[];
editingDate: string;
todayIso: string;
onSelectDay: (iso: string) => void;
}
const { cycles, logs, editingDate, todayIso, onSelectDay }: Props = $props();
// ─ Month state ──────────────────────────────────────────
const [initialYear, initialMonth] = todayIso.split('-').map((n) => parseInt(n, 10));
let viewYear = $state(initialYear);
let viewMonth = $state(initialMonth); // 1..12
// ─ Logs indexed by date for O(1) lookup ─
const logByDate = $derived.by(() => {
const map = new Map<string, CycleDayLog>();
for (const log of logs) {
map.set(log.logDate, log);
}
return map;
});
// ─ Compute grid: 6 weeks × 7 days from Monday before the 1st ─
interface DayCell {
iso: string;
dayOfMonth: number;
inCurrentMonth: boolean;
phase: CyclePhase;
flow: Flow | null;
isToday: boolean;
isEditing: boolean;
}
function isoDate(y: number, m: number, d: number): string {
const mm = String(m).padStart(2, '0');
const dd = String(d).padStart(2, '0');
return `${y}-${mm}-${dd}`;
}
const cells = $derived.by<DayCell[]>(() => {
const firstOfMonth = new Date(Date.UTC(viewYear, viewMonth - 1, 1));
// JS getUTCDay: Sun=0, Mon=1..Sat=6. We want Monday as col 0.
const dow = firstOfMonth.getUTCDay();
const offset = (dow + 6) % 7; // Mon=0
const start = new Date(firstOfMonth);
start.setUTCDate(start.getUTCDate() - offset);
const result: DayCell[] = [];
for (let i = 0; i < 42; i++) {
const d = new Date(start);
d.setUTCDate(start.getUTCDate() + i);
const y = d.getUTCFullYear();
const m = d.getUTCMonth() + 1;
const day = d.getUTCDate();
const iso = isoDate(y, m, day);
const log = logByDate.get(iso) ?? null;
result.push({
iso,
dayOfMonth: day,
inCurrentMonth: m === viewMonth,
phase: derivePhase(iso, cycles),
flow: log && log.flow !== 'none' ? log.flow : null,
isToday: iso === todayIso,
isEditing: iso === editingDate,
});
}
return result;
});
const monthLabel = $derived.by(() => {
const d = new Date(Date.UTC(viewYear, viewMonth - 1, 1));
const lang = ($locale ?? 'de').split('-')[0];
return d.toLocaleDateString(lang === 'de' ? 'de-DE' : lang, {
month: 'long',
year: 'numeric',
timeZone: 'UTC',
});
});
const weekdays = $derived.by(() => {
const lang = ($locale ?? 'de').split('-')[0];
// Build Mon..Sun labels using a reference week
const base = new Date(Date.UTC(2024, 0, 1)); // 2024-01-01 was a Monday
const result: string[] = [];
for (let i = 0; i < 7; i++) {
const d = new Date(base);
d.setUTCDate(base.getUTCDate() + i);
result.push(
d.toLocaleDateString(lang === 'de' ? 'de-DE' : lang, { weekday: 'short', timeZone: 'UTC' })
);
}
return result;
});
function prevMonth() {
if (viewMonth === 1) {
viewMonth = 12;
viewYear -= 1;
} else {
viewMonth -= 1;
}
}
function nextMonth() {
if (viewMonth === 12) {
viewMonth = 1;
viewYear += 1;
} else {
viewMonth += 1;
}
}
function goToToday() {
const [y, m] = todayIso.split('-').map((n) => parseInt(n, 10));
viewYear = y;
viewMonth = m;
}
</script>
<div class="cal">
<div class="cal-header">
<button
class="cal-nav"
type="button"
onclick={prevMonth}
aria-label={$_('cycles.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
>
</div>
<div class="cal-weekdays">
{#each weekdays as wd}
<div class="cal-weekday">{wd}</div>
{/each}
</div>
<div class="cal-grid">
{#each cells as cell (cell.iso)}
<button
type="button"
class="cal-day"
class:out={!cell.inCurrentMonth}
class:today={cell.isToday}
class:editing={cell.isEditing}
style="--phase-color: {PHASE_COLORS[cell.phase]}"
onclick={() => onSelectDay(cell.iso)}
aria-label={cell.iso}
>
<span class="cal-num">{cell.dayOfMonth}</span>
{#if cell.flow}
<span class="cal-flow" style="background: {FLOW_COLORS[cell.flow]}"></span>
{/if}
</button>
{/each}
</div>
</div>
<style>
.cal {
display: flex;
flex-direction: column;
gap: 0.375rem;
}
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.5rem;
}
.cal-title {
flex: 1;
text-align: center;
font-size: 0.8125rem;
font-weight: 600;
color: #374151;
background: transparent;
border: none;
padding: 0.25rem;
border-radius: 0.25rem;
cursor: pointer;
transition: background 0.15s;
}
.cal-title:hover {
background: rgba(236, 72, 153, 0.08);
}
:global(.dark) .cal-title {
color: #f3f4f6;
}
.cal-nav {
width: 1.75rem;
height: 1.75rem;
display: inline-flex;
align-items: center;
justify-content: center;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 0.375rem;
background: transparent;
color: #9ca3af;
font-size: 1rem;
line-height: 1;
cursor: pointer;
transition: all 0.15s;
}
.cal-nav:hover {
color: #ec4899;
border-color: #ec4899;
}
:global(.dark) .cal-nav {
border-color: rgba(255, 255, 255, 0.08);
}
.cal-weekdays {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.125rem;
}
.cal-weekday {
text-align: center;
font-size: 0.5625rem;
text-transform: uppercase;
letter-spacing: 0.05em;
color: #c0bfba;
font-weight: 600;
}
:global(.dark) .cal-weekday {
color: #6b7280;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 0.125rem;
}
.cal-day {
aspect-ratio: 1;
position: relative;
display: flex;
align-items: center;
justify-content: center;
border: 1px solid transparent;
border-radius: 0.375rem;
background: color-mix(in srgb, var(--phase-color) 10%, transparent);
color: #374151;
font-size: 0.6875rem;
cursor: pointer;
transition: all 0.15s;
padding: 0;
font-family: inherit;
}
.cal-day:hover {
background: color-mix(in srgb, var(--phase-color) 24%, transparent);
}
:global(.dark) .cal-day {
color: #e5e7eb;
}
.cal-day.out {
opacity: 0.35;
}
.cal-day.today {
font-weight: 700;
border-color: var(--phase-color);
}
.cal-day.editing {
background: color-mix(in srgb, var(--phase-color) 36%, transparent);
border-color: var(--phase-color);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--phase-color) 30%, transparent);
}
.cal-num {
position: relative;
z-index: 1;
}
.cal-flow {
position: absolute;
bottom: 3px;
left: 50%;
transform: translateX(-50%);
width: 5px;
height: 5px;
border-radius: 9999px;
}
</style>