feat(week): day selection with URL-driven state

- weekKey and selectedDay driven by ?week=&?day= query params
- Bare /week canonicalizes via replaceState (adds week+day params)
- Chip clicks: replaceState (no history push, no scroll jump)
- Week prev/next: goto with history push (back/forward works)
- Default day when week changes: today if in week, else Monday
- Keyboard navigation: ArrowLeft/Right cycles through chips
- Selected chip scrolls into view on selection change
- DayDetail always rendered for selectedDay (not just today)
- detailCaps reactive on closedDaysMap — updates immediately after
  close/reopen/mark without extra load()
- Chips now wired: selected prop, onclick handler
- DayChip tabindex: 0 for selected chip, -1 for others (roving tabindex)
This commit is contained in:
2026-04-30 19:08:11 +02:00
parent d0c1f41c13
commit 4c2b220482

View File

@@ -1,14 +1,73 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte'; import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { entries, days, weeks, settings, type Entry, type ClosedDay, type ClosedWeek, type Settings, ApiError } from '$lib/api/client'; import { entries, days, weeks, settings, type Entry, type ClosedDay, type ClosedWeek, type Settings, ApiError } from '$lib/api/client';
import { import {
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta, todayKey, isWorkday currentWeekKey, weekDayKeys, formatDurationShort, formatDelta,
todayKey, isWorkday, dayCapabilities
} from '$lib/utils'; } from '$lib/utils';
import DayChip from '$lib/components/DayChip.svelte'; import DayChip from '$lib/components/DayChip.svelte';
import DayDetail from '$lib/components/DayDetail.svelte'; import DayDetail from '$lib/components/DayDetail.svelte';
import { dayCapabilities } from '$lib/utils';
let weekKey = $state(currentWeekKey()); // ── URL-driven state ────────────────────────────────────────────────────────
// Canonical default day for a given week: today if in the week, else Monday.
function defaultDayForWeek(wk: string): string {
const keys = weekDayKeys(wk);
const t = todayKey();
return keys.includes(t) ? t : keys[0];
}
// Read week+day from URL; fall back to current week + default day.
let weekKey = $derived(
$page.url.searchParams.get('week') ?? currentWeekKey()
);
let selectedDay = $derived(
$page.url.searchParams.get('day') ?? defaultDayForWeek(weekKey)
);
// On mount: if URL is bare /week (no params), canonicalize via replaceState.
$effect(() => {
const url = $page.url;
if (!url.searchParams.has('week') || !url.searchParams.has('day')) {
const wk = url.searchParams.get('week') ?? currentWeekKey();
const dk = url.searchParams.get('day') ?? defaultDayForWeek(wk);
const next = new URL(url);
next.searchParams.set('week', wk);
next.searchParams.set('day', dk);
goto(next.toString(), { replaceState: true, noScroll: true, keepFocus: true });
}
});
function selectDay(dk: string) {
const next = new URL($page.url);
next.searchParams.set('day', dk);
goto(next.toString(), { replaceState: true, noScroll: true, keepFocus: true });
}
function navigateWeek(offset: number) {
const wk = offsetWeek(weekKey, offset);
const dk = defaultDayForWeek(wk);
const next = new URL($page.url);
next.searchParams.set('week', wk);
next.searchParams.set('day', dk);
// Push history so back/forward works for week navigation.
goto(next.toString(), { noScroll: true, keepFocus: true });
}
function offsetWeek(wk: string, offset: number): string {
const [y, w] = wk.split('-W').map(Number);
const date = new Date(y, 0, 4);
date.setDate(date.getDate() + (w - 1) * 7 + offset * 7);
const thu = new Date(date);
thu.setDate(date.getDate() + 4 - (date.getDay() || 7));
const ys = new Date(thu.getFullYear(), 0, 1);
const weekNum = Math.ceil(((thu.getTime() - ys.getTime()) / 86400000 + 1) / 7);
return `${thu.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
// ── Data ────────────────────────────────────────────────────────────────────
let dayKeys = $derived(weekDayKeys(weekKey)); let dayKeys = $derived(weekDayKeys(weekKey));
let closedDaysMap = $state<Record<string, ClosedDay>>({}); let closedDaysMap = $state<Record<string, ClosedDay>>({});
let weekEntries = $state<Entry[]>([]); let weekEntries = $state<Entry[]>([]);
@@ -38,6 +97,21 @@
$effect(() => { weekKey; load(); }); $effect(() => { weekKey; load(); });
// ── Keyboard navigation for chip strip ──────────────────────────────────────
function handleStripKeydown(e: KeyboardEvent) {
const idx = dayKeys.indexOf(selectedDay);
if (e.key === 'ArrowRight' && idx < 6) {
selectDay(dayKeys[idx + 1]);
e.preventDefault();
} else if (e.key === 'ArrowLeft' && idx > 0) {
selectDay(dayKeys[idx - 1]);
e.preventDefault();
}
}
// ── Week actions ─────────────────────────────────────────────────────────────
async function handleCloseWeek() { async function handleCloseWeek() {
error = ''; error = '';
try { try {
@@ -58,9 +132,10 @@
} }
} }
// ── Derived display values ───────────────────────────────────────────────────
const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; const DAY_NAMES = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
// Per-day worked ms: use closed_days value if closed, else sum open entries.
function dayWorkedMs(dk: string): number { function dayWorkedMs(dk: string): number {
const cd = closedDaysMap[dk]; const cd = closedDaysMap[dk];
if (cd) return cd.worked_ms; if (cd) return cd.worked_ms;
@@ -69,7 +144,6 @@
.reduce((sum, e) => sum + (e.end_time! - e.start_time), 0); .reduce((sum, e) => sum + (e.end_time! - e.start_time), 0);
} }
// Daily expected ms for a workday; 0 for weekends.
function dailyExpectedMs(dk: string): number { function dailyExpectedMs(dk: string): number {
if (!currentSettings) return 0; if (!currentSettings) return 0;
const mask = currentSettings.workdays_mask; const mask = currentSettings.workdays_mask;
@@ -85,69 +159,63 @@
currentSettings ? currentSettings.hours_per_week * 3_600_000 : 0 currentSettings ? currentSettings.hours_per_week * 3_600_000 : 0
); );
// Track which day_keys have at least one entry (for close-week guard). const daysWithEntries = $derived(new Set(weekEntries.map((e) => e.day_key)));
const daysWithEntries = $derived(
new Set(weekEntries.map((e) => e.day_key))
);
// Week can be closed if it's not already closed, the week has started,
// and every past workday that has entries is also closed.
const canCloseWeek = $derived( const canCloseWeek = $derived(
!closedWeek && !closedWeek &&
dayKeys[0] <= todayKey() && dayKeys[0] <= todayKey() &&
dayKeys.every((dk) => { dayKeys.every((dk) => {
if (dk > todayKey()) return true; // future day — skip if (dk > todayKey()) return true;
if (!daysWithEntries.has(dk)) return true; // no entries — skip if (!daysWithEntries.has(dk)) return true;
return !!closedDaysMap[dk]; // has entries → must be closed return !!closedDaysMap[dk];
}) })
); );
function prevWeek() { weekKey = offsetWeek(weekKey, -1); } // Capabilities for the selected day, reactive on closedDaysMap updates.
function nextWeek() { weekKey = offsetWeek(weekKey, 1); }
// Today in this week?
const todayInWeek = $derived(dayKeys.includes(todayKey()));
const detailDayKey = $derived(todayInWeek ? todayKey() : null);
const detailCaps = $derived( const detailCaps = $derived(
detailDayKey dayCapabilities(selectedDay, todayKey(), !!closedDaysMap[selectedDay])
? dayCapabilities(detailDayKey, todayKey(), !!closedDaysMap[detailDayKey])
: null
); );
function offsetWeek(wk: string, offset: number): string {
const [y, w] = wk.split('-W').map(Number); // Scroll selected chip into view when selection changes.
const date = new Date(y, 0, 4); let chipRefs: Record<string, HTMLElement> = {};
date.setDate(date.getDate() + (w - 1) * 7 + offset * 7); $effect(() => {
const thu = new Date(date); const el = chipRefs[selectedDay];
thu.setDate(date.getDate() + 4 - (date.getDay() || 7)); el?.scrollIntoView({ inline: 'nearest', block: 'nearest', behavior: 'smooth' });
const ys = new Date(thu.getFullYear(), 0, 1); });
const weekNum = Math.ceil(((thu.getTime() - ys.getTime()) / 86400000 + 1) / 7);
return `${thu.getFullYear()}-W${String(weekNum).padStart(2, '0')}`;
}
</script> </script>
<div class="week-view"> <div class="week-view">
<div class="header"> <div class="header">
<button onclick={prevWeek}></button> <button onclick={() => navigateWeek(-1)} aria-label="Previous week"></button>
<h1>Week {weekKey}</h1> <h1>Week {weekKey}</h1>
<button onclick={nextWeek}></button> <button onclick={() => navigateWeek(1)} aria-label="Next week"></button>
</div> </div>
{#if error}<p class="error">{error}</p>{/if} {#if error}<p class="error">{error}</p>{/if}
<!-- Day strip --> <!-- Day strip -->
<div class="day-strip" role="tablist" aria-label="Days of the week"> <!-- svelte-ignore a11y_interactive_supports_focus -->
<div
class="day-strip"
role="tablist"
aria-label="Days of the week"
onkeydown={handleStripKeydown}
>
{#each dayKeys as dk, i (dk)} {#each dayKeys as dk, i (dk)}
<DayChip <span bind:this={chipRefs[dk]}>
dayKey={dk} <DayChip
weekdayLabel={DAY_NAMES[i]} dayKey={dk}
workedMs={dayWorkedMs(dk)} weekdayLabel={DAY_NAMES[i]}
expectedMs={dailyExpectedMs(dk)} workedMs={dayWorkedMs(dk)}
kind={closedDaysMap[dk]?.kind ?? null} expectedMs={dailyExpectedMs(dk)}
closed={!!closedDaysMap[dk]} kind={closedDaysMap[dk]?.kind ?? null}
isToday={dk === todayKey()} closed={!!closedDaysMap[dk]}
selected={false} isToday={dk === todayKey()}
isWorkday={currentSettings ? isWorkday(dk, currentSettings.workdays_mask) : i < 5} selected={dk === selectedDay}
/> isWorkday={currentSettings ? isWorkday(dk, currentSettings.workdays_mask) : i < 5}
onclick={() => selectDay(dk)}
/>
</span>
{/each} {/each}
</div> </div>
@@ -175,12 +243,11 @@
<button class="btn close-week" onclick={handleCloseWeek}>Close week</button> <button class="btn close-week" onclick={handleCloseWeek}>Close week</button>
{/if} {/if}
{#if detailDayKey && detailCaps} <!-- Day detail panel -->
<div class="day-detail-panel"> <div class="day-detail-panel" role="tabpanel">
<h2 class="detail-heading">{detailDayKey}</h2> <h2 class="detail-heading">{selectedDay}</h2>
<DayDetail dayKey={detailDayKey} capabilities={detailCaps} oninvalidate={load} /> <DayDetail dayKey={selectedDay} capabilities={detailCaps} oninvalidate={load} />
</div> </div>
{/if}
</div> </div>
<style> <style>
@@ -196,7 +263,7 @@
overflow-x: auto; overflow-x: auto;
scroll-snap-type: x mandatory; scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
padding-bottom: 0.25rem; /* room for scrollbar */ padding-bottom: 0.25rem;
} }
.summary { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; } .summary { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }