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">
|
<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,58 +159,50 @@
|
|||||||
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)}
|
||||||
|
<span bind:this={chipRefs[dk]}>
|
||||||
<DayChip
|
<DayChip
|
||||||
dayKey={dk}
|
dayKey={dk}
|
||||||
weekdayLabel={DAY_NAMES[i]}
|
weekdayLabel={DAY_NAMES[i]}
|
||||||
@@ -145,9 +211,11 @@
|
|||||||
kind={closedDaysMap[dk]?.kind ?? null}
|
kind={closedDaysMap[dk]?.kind ?? null}
|
||||||
closed={!!closedDaysMap[dk]}
|
closed={!!closedDaysMap[dk]}
|
||||||
isToday={dk === todayKey()}
|
isToday={dk === todayKey()}
|
||||||
selected={false}
|
selected={dk === selectedDay}
|
||||||
isWorkday={currentSettings ? isWorkday(dk, currentSettings.workdays_mask) : i < 5}
|
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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user