Files
wotra/web/src/routes/week/+page.svelte
Andreas Schneider 3e4e93a814 feat(week): add day strip with per-day progress bars and status
- New DayChip.svelte component: weekday label, date number, progress
  bar (worked/expected), kind badge (H/V/S), closed checkmark, today
  highlight, selected state, accessible role=tab/aria-selected
- Week page: replace days-grid with horizontal scroll-snap chip strip
- Per-day workedMs: uses closed_days.worked_ms when closed, else sums
  open entries for that day (so in-progress work shows immediately)
- dailyExpectedMs: evenly split hours_per_week across workdays; 0 for
  weekends (no progress bar rendered for non-workdays)
- Progress bar turns amber when worked > expected (overtime)
- weekEntries stored in state (was discarded after computing set);
  daysWithEntries now derived from weekEntries
2026-04-30 19:04:38 +02:00

192 lines
6.5 KiB
Svelte
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script lang="ts">
import { onMount } from 'svelte';
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
} from '$lib/utils';
import DayChip from '$lib/components/DayChip.svelte';
let weekKey = $state(currentWeekKey());
let dayKeys = $derived(weekDayKeys(weekKey));
let closedDaysMap = $state<Record<string, ClosedDay>>({});
let weekEntries = $state<Entry[]>([]);
let closedWeek = $state<ClosedWeek | null>(null);
let currentSettings = $state<Settings | null>(null);
let error = $state('');
async function load() {
error = '';
const from = dayKeys[0];
const to = dayKeys[6];
try {
const [ds, ws, s, es] = await Promise.all([
days.list(from, to),
weeks.list(weekKey, weekKey),
settings.current(),
entries.list(from, to)
]);
closedDaysMap = Object.fromEntries((ds ?? []).map((d) => [d.day_key, d]));
closedWeek = (ws ?? []).find((w) => w.week_key === weekKey) ?? null;
currentSettings = s;
weekEntries = es ?? [];
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
$effect(() => { weekKey; load(); });
async function handleCloseWeek() {
error = '';
try {
const cw = await weeks.close(weekKey);
closedWeek = cw;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
async function handleReopenWeek() {
error = '';
try {
await weeks.reopen(weekKey);
closedWeek = null;
} catch (e) {
error = e instanceof ApiError ? e.message : String(e);
}
}
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;
return weekEntries
.filter((e) => e.day_key === dk && e.end_time != null)
.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;
if (!isWorkday(dk, mask)) return 0;
const workdayCount = [1, 2, 4, 8, 16, 32, 64].filter((b) => mask & b).length;
return (currentSettings.hours_per_week * 3_600_000) / workdayCount;
}
const totalWorkedMs = $derived(
dayKeys.reduce((sum, dk) => sum + dayWorkedMs(dk), 0)
);
const expectedMs = $derived(
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))
);
// 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
})
);
function prevWeek() { weekKey = offsetWeek(weekKey, -1); }
function nextWeek() { weekKey = offsetWeek(weekKey, 1); }
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')}`;
}
</script>
<div class="week-view">
<div class="header">
<button onclick={prevWeek}></button>
<h1>Week {weekKey}</h1>
<button onclick={nextWeek}></button>
</div>
{#if error}<p class="error">{error}</p>{/if}
<!-- Day strip -->
<div class="day-strip" role="tablist" aria-label="Days of the week">
{#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}
/>
{/each}
</div>
<div class="summary">
<div class="row">
<span>Worked</span>
<strong>{formatDurationShort(totalWorkedMs)}</strong>
</div>
<div class="row">
<span>Expected</span>
<strong>{formatDurationShort(expectedMs)}</strong>
</div>
<div class="row delta" class:positive={totalWorkedMs >= expectedMs}>
<span>Delta</span>
<strong>{formatDelta(totalWorkedMs - expectedMs)}</strong>
</div>
</div>
{#if closedWeek}
<div class="week-closed">
<p>Week closed — overtime: <strong>{formatDelta(closedWeek.delta_ms)}</strong></p>
<button onclick={handleReopenWeek}>Reopen week</button>
</div>
{:else if canCloseWeek}
<button class="btn close-week" onclick={handleCloseWeek}>Close week</button>
{/if}
</div>
<style>
.week-view h1 { margin: 0; font-size: 1.2rem; }
.header { display: flex; align-items: center; gap: 1rem; margin-bottom: 1rem; }
.header button { background: none; border: 1px solid #dee2e6; border-radius: 6px; padding: 0.25rem 0.6rem; font-size: 1.1rem; }
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; margin-bottom: 1rem; }
.day-strip {
display: flex;
gap: 0.4rem;
margin-bottom: 1.25rem;
overflow-x: auto;
scroll-snap-type: x mandatory;
-webkit-overflow-scrolling: touch;
padding-bottom: 0.25rem; /* room for scrollbar */
}
.summary { background: #fff; border: 1px solid #dee2e6; border-radius: 8px; padding: 1rem; margin-bottom: 1rem; }
.row { display: flex; justify-content: space-between; padding: 0.25rem 0; }
.delta strong { color: #c0392b; }
.delta.positive strong { color: #27ae60; }
.btn.close-week { background: #343a40; color: #fff; border: none; padding: 0.55rem 1.4rem; border-radius: 6px; font-size: 1rem; font-weight: 600; }
.week-closed { background: #d4edda; border-radius: 8px; padding: 0.75rem 1rem; display: flex; align-items: center; justify-content: space-between; }
.week-closed button { background: none; border: 1px solid #155724; color: #155724; border-radius: 6px; padding: 0.3rem 0.6rem; font-size: 0.875rem; }
</style>