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:
@@ -1,14 +1,73 @@
|
||||
<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 {
|
||||
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta, todayKey, isWorkday
|
||||
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta,
|
||||
todayKey, isWorkday, dayCapabilities
|
||||
} from '$lib/utils';
|
||||
import DayChip from '$lib/components/DayChip.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 closedDaysMap = $state<Record<string, ClosedDay>>({});
|
||||
let weekEntries = $state<Entry[]>([]);
|
||||
@@ -38,6 +97,21 @@
|
||||
|
||||
$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() {
|
||||
error = '';
|
||||
try {
|
||||
@@ -58,9 +132,10 @@
|
||||
}
|
||||
}
|
||||
|
||||
// ── Derived display values ───────────────────────────────────────────────────
|
||||
|
||||
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 {
|
||||
const cd = closedDaysMap[dk];
|
||||
if (cd) return cd.worked_ms;
|
||||
@@ -69,7 +144,6 @@
|
||||
.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 {
|
||||
if (!currentSettings) return 0;
|
||||
const mask = currentSettings.workdays_mask;
|
||||
@@ -85,69 +159,63 @@
|
||||
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(
|
||||
!closedWeek &&
|
||||
dayKeys[0] <= todayKey() &&
|
||||
dayKeys.every((dk) => {
|
||||
if (dk > todayKey()) return true; // future day — skip
|
||||
if (!daysWithEntries.has(dk)) return true; // no entries — skip
|
||||
return !!closedDaysMap[dk]; // has entries → must be closed
|
||||
if (dk > todayKey()) return true;
|
||||
if (!daysWithEntries.has(dk)) return true;
|
||||
return !!closedDaysMap[dk];
|
||||
})
|
||||
);
|
||||
|
||||
function prevWeek() { weekKey = offsetWeek(weekKey, -1); }
|
||||
function nextWeek() { weekKey = offsetWeek(weekKey, 1); }
|
||||
|
||||
// Today in this week?
|
||||
const todayInWeek = $derived(dayKeys.includes(todayKey()));
|
||||
const detailDayKey = $derived(todayInWeek ? todayKey() : null);
|
||||
// Capabilities for the selected day, reactive on closedDaysMap updates.
|
||||
const detailCaps = $derived(
|
||||
detailDayKey
|
||||
? dayCapabilities(detailDayKey, todayKey(), !!closedDaysMap[detailDayKey])
|
||||
: null
|
||||
dayCapabilities(selectedDay, todayKey(), !!closedDaysMap[selectedDay])
|
||||
);
|
||||
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')}`;
|
||||
}
|
||||
|
||||
// Scroll selected chip into view when selection changes.
|
||||
let chipRefs: Record<string, HTMLElement> = {};
|
||||
$effect(() => {
|
||||
const el = chipRefs[selectedDay];
|
||||
el?.scrollIntoView({ inline: 'nearest', block: 'nearest', behavior: 'smooth' });
|
||||
});
|
||||
</script>
|
||||
|
||||
<div class="week-view">
|
||||
<div class="header">
|
||||
<button onclick={prevWeek}>‹</button>
|
||||
<button onclick={() => navigateWeek(-1)} aria-label="Previous week">‹</button>
|
||||
<h1>Week {weekKey}</h1>
|
||||
<button onclick={nextWeek}>›</button>
|
||||
<button onclick={() => navigateWeek(1)} aria-label="Next week">›</button>
|
||||
</div>
|
||||
|
||||
{#if error}<p class="error">{error}</p>{/if}
|
||||
|
||||
<!-- 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)}
|
||||
<DayChip
|
||||
dayKey={dk}
|
||||
weekdayLabel={DAY_NAMES[i]}
|
||||
workedMs={dayWorkedMs(dk)}
|
||||
expectedMs={dailyExpectedMs(dk)}
|
||||
kind={closedDaysMap[dk]?.kind ?? null}
|
||||
closed={!!closedDaysMap[dk]}
|
||||
isToday={dk === todayKey()}
|
||||
selected={false}
|
||||
isWorkday={currentSettings ? isWorkday(dk, currentSettings.workdays_mask) : i < 5}
|
||||
/>
|
||||
<span bind:this={chipRefs[dk]}>
|
||||
<DayChip
|
||||
dayKey={dk}
|
||||
weekdayLabel={DAY_NAMES[i]}
|
||||
workedMs={dayWorkedMs(dk)}
|
||||
expectedMs={dailyExpectedMs(dk)}
|
||||
kind={closedDaysMap[dk]?.kind ?? null}
|
||||
closed={!!closedDaysMap[dk]}
|
||||
isToday={dk === todayKey()}
|
||||
selected={dk === selectedDay}
|
||||
isWorkday={currentSettings ? isWorkday(dk, currentSettings.workdays_mask) : i < 5}
|
||||
onclick={() => selectDay(dk)}
|
||||
/>
|
||||
</span>
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
@@ -175,12 +243,11 @@
|
||||
<button class="btn close-week" onclick={handleCloseWeek}>Close week</button>
|
||||
{/if}
|
||||
|
||||
{#if detailDayKey && detailCaps}
|
||||
<div class="day-detail-panel">
|
||||
<h2 class="detail-heading">{detailDayKey}</h2>
|
||||
<DayDetail dayKey={detailDayKey} capabilities={detailCaps} oninvalidate={load} />
|
||||
</div>
|
||||
{/if}
|
||||
<!-- Day detail panel -->
|
||||
<div class="day-detail-panel" role="tabpanel">
|
||||
<h2 class="detail-heading">{selectedDay}</h2>
|
||||
<DayDetail dayKey={selectedDay} capabilities={detailCaps} oninvalidate={load} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@@ -196,7 +263,7 @@
|
||||
overflow-x: auto;
|
||||
scroll-snap-type: x mandatory;
|
||||
-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; }
|
||||
|
||||
Reference in New Issue
Block a user