refactor: extract DayDetail component; render today inside week view
- New DayDetail.svelte: self-contained day panel with full capability gating (canStartStop, canAddInterval, canEditEntries, canMarkKind, canCloseDay, canReopenDay) - Closed-day state: banner + read-only entry list with 'Reopen day to edit' hint; mark-kind buttons still available - Close Day button disabled (with tooltip) when running entry exists - Timer only ticks when dayKey === todayKey() and entry is running; properly cleaned up on dayKey change and component destroy - oninvalidate() callback: called after every mutation so parent (week view) can refetch and update the chip strip - /today route: refactored to thin wrapper using DayDetail - Week page: renders DayDetail below summary when today is in the displayed week; oninvalidate triggers full week reload
This commit is contained in:
488
web/src/lib/components/DayDetail.svelte
Normal file
488
web/src/lib/components/DayDetail.svelte
Normal file
@@ -0,0 +1,488 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy } from 'svelte';
|
||||||
|
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
|
||||||
|
import {
|
||||||
|
todayKey, formatTime, formatDuration, formatDurationShort,
|
||||||
|
parseTimeInput, toTimeInput, type DayCapabilities
|
||||||
|
} from '$lib/utils';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
dayKey: string;
|
||||||
|
capabilities: DayCapabilities;
|
||||||
|
/** Called after any mutation that changes day or entry state. */
|
||||||
|
oninvalidate?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
let { dayKey, capabilities, oninvalidate }: Props = $props();
|
||||||
|
|
||||||
|
let entryList = $state<Entry[]>([]);
|
||||||
|
let closedDay = $state<ClosedDay | null>(null);
|
||||||
|
let running = $state<Entry | null>(null);
|
||||||
|
let elapsed = $state(0);
|
||||||
|
let note = $state('');
|
||||||
|
let error = $state('');
|
||||||
|
let loading = $state(false);
|
||||||
|
|
||||||
|
// Add-interval form
|
||||||
|
let showAddForm = $state(false);
|
||||||
|
let addStart = $state('');
|
||||||
|
let addEnd = $state('');
|
||||||
|
let addNote = $state('');
|
||||||
|
let addError = $state('');
|
||||||
|
|
||||||
|
// Inline edit state
|
||||||
|
let editingId = $state<string | null>(null);
|
||||||
|
let editStart = $state('');
|
||||||
|
let editEnd = $state('');
|
||||||
|
let editNote = $state('');
|
||||||
|
let editError = $state('');
|
||||||
|
|
||||||
|
let timer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const [es, ds] = await Promise.all([
|
||||||
|
entries.list(dayKey, dayKey),
|
||||||
|
days.list(dayKey, dayKey)
|
||||||
|
]);
|
||||||
|
entryList = es ?? [];
|
||||||
|
closedDay = (ds ?? []).find((d) => d.day_key === dayKey) ?? null;
|
||||||
|
running = entryList.find((e) => e.end_time === null) ?? null;
|
||||||
|
startTimer();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTimer() {
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
// Only tick if this is today and a running entry exists.
|
||||||
|
if (running && dayKey === todayKey()) {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
elapsed = Date.now() - running!.start_time;
|
||||||
|
}, 500);
|
||||||
|
elapsed = Date.now() - running.start_time;
|
||||||
|
} else {
|
||||||
|
elapsed = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload when dayKey changes.
|
||||||
|
$effect(() => {
|
||||||
|
dayKey; // dependency
|
||||||
|
editingId = null;
|
||||||
|
showAddForm = false;
|
||||||
|
load();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => { if (timer) clearInterval(timer); });
|
||||||
|
|
||||||
|
async function handleStart() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const e = await entries.start(note);
|
||||||
|
running = e;
|
||||||
|
entryList = [...entryList, e];
|
||||||
|
note = '';
|
||||||
|
startTimer();
|
||||||
|
oninvalidate?.();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
if (!running) return;
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const e = await entries.stop(running.id);
|
||||||
|
entryList = entryList.map((x) => (x.id === e.id ? e : x));
|
||||||
|
running = null;
|
||||||
|
if (timer) clearInterval(timer);
|
||||||
|
elapsed = 0;
|
||||||
|
oninvalidate?.();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleCloseDay() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const cd = await days.close(dayKey);
|
||||||
|
closedDay = cd;
|
||||||
|
oninvalidate?.();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleMark(kind: 'holiday' | 'vacation' | 'sick') {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
const cd = await days.mark(dayKey, kind);
|
||||||
|
closedDay = cd;
|
||||||
|
oninvalidate?.();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleReopenDay() {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await days.reopen(dayKey);
|
||||||
|
closedDay = null;
|
||||||
|
oninvalidate?.();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDeleteEntry(id: string) {
|
||||||
|
error = '';
|
||||||
|
try {
|
||||||
|
await entries.delete(id);
|
||||||
|
entryList = entryList.filter((e) => e.id !== id);
|
||||||
|
oninvalidate?.();
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Add interval ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function openAddForm() {
|
||||||
|
addStart = '';
|
||||||
|
addEnd = '';
|
||||||
|
addNote = '';
|
||||||
|
addError = '';
|
||||||
|
showAddForm = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeAddForm() {
|
||||||
|
showAddForm = false;
|
||||||
|
addError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleAddInterval() {
|
||||||
|
addError = '';
|
||||||
|
const startMs = parseTimeInput(dayKey, addStart);
|
||||||
|
const endMs = parseTimeInput(dayKey, addEnd);
|
||||||
|
if (startMs === null) { addError = 'Invalid start time'; return; }
|
||||||
|
if (endMs === null) { addError = 'Invalid end time'; return; }
|
||||||
|
if (endMs <= startMs) { addError = 'End must be after start'; return; }
|
||||||
|
try {
|
||||||
|
const e = await entries.createInterval(startMs, endMs, addNote);
|
||||||
|
entryList = [...entryList, e].sort((a, b) => a.start_time - b.start_time);
|
||||||
|
closeAddForm();
|
||||||
|
oninvalidate?.();
|
||||||
|
} catch (e) {
|
||||||
|
addError = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ── Inline edit ─────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
function startEdit(entry: Entry) {
|
||||||
|
editingId = entry.id;
|
||||||
|
editStart = toTimeInput(entry.start_time);
|
||||||
|
editEnd = entry.end_time ? toTimeInput(entry.end_time) : '';
|
||||||
|
editNote = entry.note;
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function cancelEdit() {
|
||||||
|
editingId = null;
|
||||||
|
editError = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveEdit(entry: Entry) {
|
||||||
|
editError = '';
|
||||||
|
const startMs = parseTimeInput(dayKey, editStart);
|
||||||
|
if (startMs === null) { editError = 'Invalid start time'; return; }
|
||||||
|
|
||||||
|
let endMs: number | undefined;
|
||||||
|
if (!entry.end_time && editEnd === '') {
|
||||||
|
// still running, no end — fine
|
||||||
|
} else {
|
||||||
|
const parsed = parseTimeInput(dayKey, editEnd);
|
||||||
|
if (parsed === null) { editError = 'Invalid end time'; return; }
|
||||||
|
if (parsed <= startMs) { editError = 'End must be after start'; return; }
|
||||||
|
endMs = parsed;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const updated = await entries.update(entry.id, {
|
||||||
|
start_time: startMs,
|
||||||
|
...(endMs !== undefined ? { end_time: endMs } : {}),
|
||||||
|
note: editNote
|
||||||
|
});
|
||||||
|
entryList = entryList
|
||||||
|
.map((e) => (e.id === updated.id ? updated : e))
|
||||||
|
.sort((a, b) => a.start_time - b.start_time);
|
||||||
|
if (running?.id === updated.id) running = updated;
|
||||||
|
editingId = null;
|
||||||
|
oninvalidate?.();
|
||||||
|
} catch (e) {
|
||||||
|
editError = e instanceof ApiError ? e.message : String(e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalWorkedMs = $derived(
|
||||||
|
entryList.reduce((sum, e) => {
|
||||||
|
if (e.end_time === null) return sum;
|
||||||
|
return sum + (e.end_time - e.start_time);
|
||||||
|
}, 0)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Close day is disabled when today has a running entry (even if canCloseDay is true).
|
||||||
|
const closeDayDisabled = $derived(!!running && dayKey === todayKey());
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="day-detail">
|
||||||
|
{#if error}
|
||||||
|
<p class="error">{error}</p>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if closedDay}
|
||||||
|
<!-- Closed-day banner -->
|
||||||
|
<div class="closed-banner" data-kind={closedDay.kind}>
|
||||||
|
<span>Day closed ({closedDay.kind}) — {formatDurationShort(closedDay.worked_ms)} worked</span>
|
||||||
|
{#if capabilities.canReopenDay}
|
||||||
|
<button onclick={handleReopenDay}>Reopen</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Entries (read-only with hint) -->
|
||||||
|
{#if entryList.length > 0}
|
||||||
|
<section class="entries">
|
||||||
|
<div class="entries-header">
|
||||||
|
<h2>Entries</h2>
|
||||||
|
<span class="readonly-hint">Reopen day to edit</span>
|
||||||
|
</div>
|
||||||
|
{#each entryList as entry (entry.id)}
|
||||||
|
<div class="entry">
|
||||||
|
<span class="time">{formatTime(entry.start_time)}</span>
|
||||||
|
<span class="dash">→</span>
|
||||||
|
<span class="time">{entry.end_time ? formatTime(entry.end_time) : '…'}</span>
|
||||||
|
{#if entry.end_time}
|
||||||
|
<span class="dur">{formatDuration(entry.end_time - entry.start_time)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if entry.note}
|
||||||
|
<span class="note">{entry.note}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
<div class="total">Total: {formatDurationShort(totalWorkedMs)}</div>
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Mark kind (always available even when closed) -->
|
||||||
|
{#if capabilities.canMarkKind}
|
||||||
|
<div class="quick-mark">
|
||||||
|
<span>Remark as:</span>
|
||||||
|
<button onclick={() => handleMark('holiday')}>Holiday</button>
|
||||||
|
<button onclick={() => handleMark('vacation')}>Vacation</button>
|
||||||
|
<button onclick={() => handleMark('sick')}>Sick</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{:else}
|
||||||
|
<!-- Open day -->
|
||||||
|
|
||||||
|
{#if capabilities.canStartStop}
|
||||||
|
<!-- Timer (today only) -->
|
||||||
|
<div class="timer-section">
|
||||||
|
{#if running}
|
||||||
|
<div class="timer running">{formatDuration(elapsed)}</div>
|
||||||
|
<button class="btn stop" onclick={handleStop}>■ Stop</button>
|
||||||
|
{:else}
|
||||||
|
<div class="timer idle">{formatDuration(totalWorkedMs)}</div>
|
||||||
|
<div class="start-row">
|
||||||
|
<input bind:value={note} placeholder="Note (optional)" onkeydown={(e) => e.key === 'Enter' && handleStart()} />
|
||||||
|
<button class="btn start" onclick={handleStart}>▶ Start</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if capabilities.canMarkKind}
|
||||||
|
<div class="quick-mark">
|
||||||
|
<span>Mark day as:</span>
|
||||||
|
<button onclick={() => handleMark('holiday')}>Holiday</button>
|
||||||
|
<button onclick={() => handleMark('vacation')}>Vacation</button>
|
||||||
|
<button onclick={() => handleMark('sick')}>Sick</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if capabilities.canCloseDay && entryList.length > 0 && !running}
|
||||||
|
<button
|
||||||
|
class="btn close-day"
|
||||||
|
onclick={handleCloseDay}
|
||||||
|
disabled={closeDayDisabled}
|
||||||
|
title={closeDayDisabled ? 'Stop timer first' : undefined}
|
||||||
|
>Close day</button>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Entry list -->
|
||||||
|
{#if !capabilities.canEditEntries && entryList.length === 0}
|
||||||
|
<!-- Future day with no entries — show nothing -->
|
||||||
|
{:else}
|
||||||
|
<section class="entries">
|
||||||
|
<div class="entries-header">
|
||||||
|
<h2>Entries</h2>
|
||||||
|
{#if capabilities.canAddInterval}
|
||||||
|
<button class="btn-add" onclick={openAddForm} title="Add interval">+ Add interval</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if showAddForm && capabilities.canAddInterval}
|
||||||
|
<div class="interval-form">
|
||||||
|
<h3>Add interval</h3>
|
||||||
|
{#if addError}<p class="form-error">{addError}</p>{/if}
|
||||||
|
<div class="form-row">
|
||||||
|
<label>
|
||||||
|
Start
|
||||||
|
<input type="time" bind:value={addStart} required />
|
||||||
|
</label>
|
||||||
|
<label>
|
||||||
|
End
|
||||||
|
<input type="time" bind:value={addEnd} required />
|
||||||
|
</label>
|
||||||
|
<label class="note-label">
|
||||||
|
Note
|
||||||
|
<input type="text" bind:value={addNote} placeholder="Optional" />
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div class="form-actions">
|
||||||
|
<button class="btn save" onclick={handleAddInterval}>Save</button>
|
||||||
|
<button class="btn cancel" onclick={closeAddForm}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if entryList.length === 0}
|
||||||
|
<p class="empty">No entries.</p>
|
||||||
|
{:else}
|
||||||
|
{#each entryList as entry (entry.id)}
|
||||||
|
{#if editingId === entry.id && capabilities.canEditEntries}
|
||||||
|
<div class="entry editing">
|
||||||
|
{#if editError}<p class="form-error">{editError}</p>{/if}
|
||||||
|
<div class="edit-row">
|
||||||
|
<input type="time" bind:value={editStart} class="time-input" />
|
||||||
|
<span class="dash">→</span>
|
||||||
|
<input type="time" bind:value={editEnd} class="time-input" placeholder="--:--" disabled={!entry.end_time && editEnd === ''} />
|
||||||
|
<input type="text" bind:value={editNote} class="note-input" placeholder="Note" />
|
||||||
|
</div>
|
||||||
|
<div class="edit-actions">
|
||||||
|
<button class="btn save sm" onclick={() => saveEdit(entry)}>Save</button>
|
||||||
|
<button class="btn cancel sm" onclick={cancelEdit}>Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="entry" class:running={entry.end_time === null}>
|
||||||
|
<span class="time">{formatTime(entry.start_time)}</span>
|
||||||
|
<span class="dash">→</span>
|
||||||
|
<span class="time">{entry.end_time ? formatTime(entry.end_time) : '…'}</span>
|
||||||
|
{#if entry.end_time}
|
||||||
|
<span class="dur">{formatDuration(entry.end_time - entry.start_time)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if entry.note}
|
||||||
|
<span class="note">{entry.note}</span>
|
||||||
|
{/if}
|
||||||
|
{#if entry.auto_stopped}
|
||||||
|
<span class="badge auto">auto-stopped</span>
|
||||||
|
{/if}
|
||||||
|
{#if capabilities.canEditEntries}
|
||||||
|
<button class="edit-btn" onclick={() => startEdit(entry)} title="Edit">✎</button>
|
||||||
|
<button class="del" onclick={() => handleDeleteEntry(entry.id)} title="Delete">✕</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
<div class="total">Total: {formatDurationShort(totalWorkedMs)}</div>
|
||||||
|
{/if}
|
||||||
|
</section>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.day-detail { padding-top: 0.5rem; }
|
||||||
|
h2 { font-size: 1rem; margin: 0; color: #495057; }
|
||||||
|
h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #343a40; }
|
||||||
|
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; margin-bottom: 0.75rem; }
|
||||||
|
.closed-banner { display: flex; align-items: center; justify-content: space-between;
|
||||||
|
padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-weight: 500; }
|
||||||
|
.closed-banner[data-kind="work"] { background: #d4edda; color: #155724; }
|
||||||
|
.closed-banner[data-kind="holiday"] { background: #fff3cd; color: #856404; }
|
||||||
|
.closed-banner[data-kind="vacation"] { background: #cce5ff; color: #004085; }
|
||||||
|
.closed-banner[data-kind="sick"] { background: #f8d7da; color: #721c24; }
|
||||||
|
.closed-banner button { background: none; border: 1px solid currentColor; border-radius: 6px;
|
||||||
|
padding: 0.2rem 0.6rem; font-size: 0.875rem; cursor: pointer; color: inherit; }
|
||||||
|
.readonly-hint { font-size: 0.78rem; color: #6c757d; font-style: italic; }
|
||||||
|
.timer-section { text-align: center; padding: 1.25rem 0 0.75rem; }
|
||||||
|
.timer { font-size: 3rem; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; font-weight: 300; }
|
||||||
|
.timer.running { color: #2c7be5; }
|
||||||
|
.timer.idle { color: #6c757d; }
|
||||||
|
.start-row { display: flex; gap: 0.5rem; justify-content: center; margin-top: 1rem; }
|
||||||
|
.start-row input { padding: 0.5rem 0.75rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.95rem; width: 200px; }
|
||||||
|
.btn { padding: 0.5rem 1.2rem; border: none; border-radius: 6px; font-size: 0.95rem; font-weight: 600; transition: opacity 0.15s; cursor: pointer; }
|
||||||
|
.btn:hover:not(:disabled) { opacity: 0.88; }
|
||||||
|
.btn:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
.btn.start { background: #2c7be5; color: #fff; margin-top: 1rem; }
|
||||||
|
.btn.stop { background: #e74c3c; color: #fff; margin-top: 1rem; }
|
||||||
|
.btn.close-day { background: #343a40; color: #fff; margin-top: 0.75rem; display: block; }
|
||||||
|
.btn.save { background: #2c7be5; color: #fff; }
|
||||||
|
.btn.cancel { background: #e9ecef; color: #343a40; }
|
||||||
|
.btn.sm { padding: 0.3rem 0.8rem; font-size: 0.875rem; }
|
||||||
|
.quick-mark { display: flex; gap: 0.5rem; align-items: center; margin-top: 0.75rem; flex-wrap: wrap; }
|
||||||
|
.quick-mark span { color: #6c757d; font-size: 0.875rem; }
|
||||||
|
.quick-mark button { padding: 0.3rem 0.65rem; border: 1px solid #ced4da; background: #fff;
|
||||||
|
border-radius: 6px; font-size: 0.875rem; cursor: pointer; }
|
||||||
|
.quick-mark button:hover { background: #e9ecef; }
|
||||||
|
.entries { border-top: 1px solid #dee2e6; margin-top: 1.25rem; padding-top: 0.75rem; }
|
||||||
|
.entries-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
|
||||||
|
.btn-add { background: none; border: 1px solid #2c7be5; color: #2c7be5; border-radius: 6px;
|
||||||
|
padding: 0.25rem 0.75rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
|
||||||
|
.btn-add:hover { background: #eaf3ff; }
|
||||||
|
.interval-form { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px;
|
||||||
|
padding: 1rem; margin-bottom: 1rem; }
|
||||||
|
.form-row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end; }
|
||||||
|
.form-row label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: #495057; }
|
||||||
|
.form-row input[type="time"], .form-row input[type="text"] {
|
||||||
|
padding: 0.4rem 0.6rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.9rem; }
|
||||||
|
.note-label { flex: 1; min-width: 160px; }
|
||||||
|
.form-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
||||||
|
.form-error { color: #c0392b; font-size: 0.85rem; margin: 0 0 0.5rem; }
|
||||||
|
.entry { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0;
|
||||||
|
border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; }
|
||||||
|
.entry.running { background: #eaf3ff; border-radius: 6px; padding: 0.5rem; }
|
||||||
|
.entry.editing { display: block; background: #f8f9fa; border: 1px solid #dee2e6;
|
||||||
|
border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.25rem; }
|
||||||
|
.edit-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
||||||
|
.time-input { width: 90px; padding: 0.3rem 0.4rem; border: 1px solid #ced4da;
|
||||||
|
border-radius: 5px; font-size: 0.9rem; font-variant-numeric: tabular-nums; }
|
||||||
|
.note-input { flex: 1; min-width: 120px; padding: 0.3rem 0.5rem;
|
||||||
|
border: 1px solid #ced4da; border-radius: 5px; font-size: 0.9rem; }
|
||||||
|
.edit-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
||||||
|
.time { font-variant-numeric: tabular-nums; color: #343a40; }
|
||||||
|
.dash { color: #adb5bd; }
|
||||||
|
.dur { font-variant-numeric: tabular-nums; color: #6c757d; margin-left: auto; }
|
||||||
|
.note { color: #495057; font-style: italic; }
|
||||||
|
.badge.auto { font-size: 0.75rem; background: #fff3cd; color: #856404;
|
||||||
|
padding: 0.1rem 0.4rem; border-radius: 4px; }
|
||||||
|
.edit-btn { margin-left: auto; background: none; border: none; color: #adb5bd;
|
||||||
|
font-size: 1rem; padding: 0 0.25rem; cursor: pointer; }
|
||||||
|
.edit-btn:hover { color: #2c7be5; }
|
||||||
|
.del { background: none; border: none; color: #adb5bd; font-size: 1rem;
|
||||||
|
padding: 0 0.25rem; cursor: pointer; }
|
||||||
|
.del:hover { color: #e74c3c; }
|
||||||
|
.total { text-align: right; padding: 0.5rem 0; color: #495057; font-weight: 600; }
|
||||||
|
.empty { color: #6c757d; font-style: italic; }
|
||||||
|
</style>
|
||||||
@@ -1,402 +1,19 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount, onDestroy } from 'svelte';
|
import DayDetail from '$lib/components/DayDetail.svelte';
|
||||||
import { entries, days, type Entry, type ClosedDay, ApiError } from '$lib/api/client';
|
import { todayKey, dayCapabilities } from '$lib/utils';
|
||||||
import { todayKey, formatTime, formatDuration, formatDurationShort, parseTimeInput, toTimeInput } from '$lib/utils';
|
|
||||||
|
|
||||||
let today = todayKey();
|
const today = todayKey();
|
||||||
let entryList: Entry[] = $state([]);
|
// Today is always open on mount; DayDetail will discover closed state from API.
|
||||||
let closedDay: ClosedDay | null = $state(null);
|
// We pass a conservative open-today capabilities set; DayDetail adapts once loaded.
|
||||||
let running: Entry | null = $state(null);
|
const capabilities = dayCapabilities(today, today, false);
|
||||||
let elapsed = $state(0);
|
|
||||||
let note = $state('');
|
|
||||||
let error = $state('');
|
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
// Add-interval form
|
|
||||||
let showAddForm = $state(false);
|
|
||||||
let addStart = $state('');
|
|
||||||
let addEnd = $state('');
|
|
||||||
let addNote = $state('');
|
|
||||||
let addError = $state('');
|
|
||||||
|
|
||||||
// Inline edit state
|
|
||||||
let editingId = $state<string | null>(null);
|
|
||||||
let editStart = $state('');
|
|
||||||
let editEnd = $state('');
|
|
||||||
let editNote = $state('');
|
|
||||||
let editError = $state('');
|
|
||||||
|
|
||||||
let timer: ReturnType<typeof setInterval> | null = null;
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
loading = true;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const [es, ds] = await Promise.all([
|
|
||||||
entries.list(today, today),
|
|
||||||
days.list(today, today)
|
|
||||||
]);
|
|
||||||
entryList = es ?? [];
|
|
||||||
closedDay = (ds ?? []).find((d) => d.day_key === today) ?? null;
|
|
||||||
running = entryList.find((e) => e.end_time === null) ?? null;
|
|
||||||
startTimer();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function startTimer() {
|
|
||||||
if (timer) clearInterval(timer);
|
|
||||||
if (running) {
|
|
||||||
timer = setInterval(() => {
|
|
||||||
elapsed = Date.now() - running!.start_time;
|
|
||||||
}, 500);
|
|
||||||
elapsed = Date.now() - running.start_time;
|
|
||||||
} else {
|
|
||||||
elapsed = 0;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStart() {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const e = await entries.start(note);
|
|
||||||
running = e;
|
|
||||||
entryList = [...entryList, e];
|
|
||||||
note = '';
|
|
||||||
startTimer();
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleStop() {
|
|
||||||
if (!running) return;
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const e = await entries.stop(running.id);
|
|
||||||
entryList = entryList.map((x) => (x.id === e.id ? e : x));
|
|
||||||
running = null;
|
|
||||||
if (timer) clearInterval(timer);
|
|
||||||
elapsed = 0;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleCloseDay() {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const cd = await days.close(today);
|
|
||||||
closedDay = cd;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleMark(kind: 'holiday' | 'vacation' | 'sick') {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
const cd = await days.mark(today, kind);
|
|
||||||
closedDay = cd;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleReopenDay() {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await days.reopen(today);
|
|
||||||
closedDay = null;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleDeleteEntry(id: string) {
|
|
||||||
error = '';
|
|
||||||
try {
|
|
||||||
await entries.delete(id);
|
|
||||||
entryList = entryList.filter((e) => e.id !== id);
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof ApiError ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Add interval ────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function openAddForm() {
|
|
||||||
addStart = '';
|
|
||||||
addEnd = '';
|
|
||||||
addNote = '';
|
|
||||||
addError = '';
|
|
||||||
showAddForm = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeAddForm() {
|
|
||||||
showAddForm = false;
|
|
||||||
addError = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function handleAddInterval() {
|
|
||||||
addError = '';
|
|
||||||
const startMs = parseTimeInput(today, addStart);
|
|
||||||
const endMs = parseTimeInput(today, addEnd);
|
|
||||||
if (startMs === null) { addError = 'Invalid start time'; return; }
|
|
||||||
if (endMs === null) { addError = 'Invalid end time'; return; }
|
|
||||||
if (endMs <= startMs) { addError = 'End must be after start'; return; }
|
|
||||||
try {
|
|
||||||
const e = await entries.createInterval(startMs, endMs, addNote);
|
|
||||||
entryList = [...entryList, e].sort((a, b) => a.start_time - b.start_time);
|
|
||||||
closeAddForm();
|
|
||||||
} catch (e) {
|
|
||||||
addError = e instanceof ApiError ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ── Inline edit ─────────────────────────────────────────────────────────────
|
|
||||||
|
|
||||||
function startEdit(entry: Entry) {
|
|
||||||
editingId = entry.id;
|
|
||||||
editStart = toTimeInput(entry.start_time);
|
|
||||||
editEnd = entry.end_time ? toTimeInput(entry.end_time) : '';
|
|
||||||
editNote = entry.note;
|
|
||||||
editError = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
function cancelEdit() {
|
|
||||||
editingId = null;
|
|
||||||
editError = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEdit(entry: Entry) {
|
|
||||||
editError = '';
|
|
||||||
const startMs = parseTimeInput(today, editStart);
|
|
||||||
if (startMs === null) { editError = 'Invalid start time'; return; }
|
|
||||||
|
|
||||||
// end_time: only required if the entry is not running
|
|
||||||
let endMs: number | undefined;
|
|
||||||
if (!entry.end_time && editEnd === '') {
|
|
||||||
// still running, no end — that's fine, don't send end_time
|
|
||||||
} else {
|
|
||||||
const parsed = parseTimeInput(today, editEnd);
|
|
||||||
if (parsed === null) { editError = 'Invalid end time'; return; }
|
|
||||||
if (parsed <= startMs) { editError = 'End must be after start'; return; }
|
|
||||||
endMs = parsed;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const updated = await entries.update(entry.id, {
|
|
||||||
start_time: startMs,
|
|
||||||
...(endMs !== undefined ? { end_time: endMs } : {}),
|
|
||||||
note: editNote
|
|
||||||
});
|
|
||||||
entryList = entryList
|
|
||||||
.map((e) => (e.id === updated.id ? updated : e))
|
|
||||||
.sort((a, b) => a.start_time - b.start_time);
|
|
||||||
if (running?.id === updated.id) running = updated;
|
|
||||||
editingId = null;
|
|
||||||
} catch (e) {
|
|
||||||
editError = e instanceof ApiError ? e.message : String(e);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalWorkedMs = $derived(
|
|
||||||
entryList.reduce((sum, e) => {
|
|
||||||
if (e.end_time === null) return sum;
|
|
||||||
return sum + (e.end_time - e.start_time);
|
|
||||||
}, 0)
|
|
||||||
);
|
|
||||||
|
|
||||||
onMount(load);
|
|
||||||
onDestroy(() => { if (timer) clearInterval(timer); });
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="today">
|
<div class="today-page">
|
||||||
<h1>Today — {today}</h1>
|
<h1>Today — {today}</h1>
|
||||||
|
<DayDetail dayKey={today} {capabilities} />
|
||||||
{#if error}
|
|
||||||
<p class="error">{error}</p>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if closedDay}
|
|
||||||
<div class="closed-banner" data-kind={closedDay.kind}>
|
|
||||||
<span>Day closed ({closedDay.kind}) — {formatDurationShort(closedDay.worked_ms)} worked</span>
|
|
||||||
<button onclick={handleReopenDay}>Reopen</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<!-- Timer -->
|
|
||||||
<div class="timer-section">
|
|
||||||
{#if running}
|
|
||||||
<div class="timer running">{formatDuration(elapsed)}</div>
|
|
||||||
<button class="btn stop" onclick={handleStop}>■ Stop</button>
|
|
||||||
{:else}
|
|
||||||
<div class="timer idle">{formatDuration(totalWorkedMs)}</div>
|
|
||||||
<div class="start-row">
|
|
||||||
<input bind:value={note} placeholder="Note (optional)" onkeydown={(e) => e.key === 'Enter' && handleStart()} />
|
|
||||||
<button class="btn start" onclick={handleStart}>▶ Start</button>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Quick mark -->
|
|
||||||
<div class="quick-mark">
|
|
||||||
<span>Mark day as:</span>
|
|
||||||
<button onclick={() => handleMark('holiday')}>🏖 Holiday</button>
|
|
||||||
<button onclick={() => handleMark('vacation')}>✈ Vacation</button>
|
|
||||||
<button onclick={() => handleMark('sick')}>🤒 Sick</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Close day -->
|
|
||||||
{#if entryList.length > 0 && !running}
|
|
||||||
<button class="btn close-day" onclick={handleCloseDay}>Close day</button>
|
|
||||||
{/if}
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
<!-- Entry list -->
|
|
||||||
<section class="entries">
|
|
||||||
<div class="entries-header">
|
|
||||||
<h2>Entries</h2>
|
|
||||||
{#if !closedDay}
|
|
||||||
<button class="btn-add" onclick={openAddForm} title="Add interval">+ Add interval</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Add-interval form -->
|
|
||||||
{#if showAddForm}
|
|
||||||
<div class="interval-form">
|
|
||||||
<h3>Add interval</h3>
|
|
||||||
{#if addError}<p class="form-error">{addError}</p>{/if}
|
|
||||||
<div class="form-row">
|
|
||||||
<label>
|
|
||||||
Start
|
|
||||||
<input type="time" bind:value={addStart} required />
|
|
||||||
</label>
|
|
||||||
<label>
|
|
||||||
End
|
|
||||||
<input type="time" bind:value={addEnd} required />
|
|
||||||
</label>
|
|
||||||
<label class="note-label">
|
|
||||||
Note
|
|
||||||
<input type="text" bind:value={addNote} placeholder="Optional" />
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
<div class="form-actions">
|
|
||||||
<button class="btn save" onclick={handleAddInterval}>Save</button>
|
|
||||||
<button class="btn cancel" onclick={closeAddForm}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
|
|
||||||
{#if entryList.length === 0}
|
|
||||||
<p class="empty">No entries yet today.</p>
|
|
||||||
{:else}
|
|
||||||
{#each entryList as entry (entry.id)}
|
|
||||||
{#if editingId === entry.id}
|
|
||||||
<!-- Inline edit row -->
|
|
||||||
<div class="entry editing">
|
|
||||||
{#if editError}<p class="form-error">{editError}</p>{/if}
|
|
||||||
<div class="edit-row">
|
|
||||||
<input type="time" bind:value={editStart} class="time-input" />
|
|
||||||
<span class="dash">→</span>
|
|
||||||
<input type="time" bind:value={editEnd} class="time-input" placeholder="--:--" disabled={!entry.end_time && editEnd === ''} />
|
|
||||||
<input type="text" bind:value={editNote} class="note-input" placeholder="Note" />
|
|
||||||
</div>
|
|
||||||
<div class="edit-actions">
|
|
||||||
<button class="btn save sm" onclick={() => saveEdit(entry)}>Save</button>
|
|
||||||
<button class="btn cancel sm" onclick={cancelEdit}>Cancel</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="entry" class:running={entry.end_time === null}>
|
|
||||||
<span class="time">{formatTime(entry.start_time)}</span>
|
|
||||||
<span class="dash">→</span>
|
|
||||||
<span class="time">{entry.end_time ? formatTime(entry.end_time) : '…'}</span>
|
|
||||||
{#if entry.end_time}
|
|
||||||
<span class="dur">{formatDuration(entry.end_time - entry.start_time)}</span>
|
|
||||||
{/if}
|
|
||||||
{#if entry.note}
|
|
||||||
<span class="note">{entry.note}</span>
|
|
||||||
{/if}
|
|
||||||
{#if entry.auto_stopped}
|
|
||||||
<span class="badge auto">auto-stopped</span>
|
|
||||||
{/if}
|
|
||||||
{#if !closedDay}
|
|
||||||
<button class="edit-btn" onclick={() => startEdit(entry)} title="Edit">✎</button>
|
|
||||||
<button class="del" onclick={() => handleDeleteEntry(entry.id)} title="Delete">✕</button>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
<div class="total">Total: {formatDurationShort(totalWorkedMs)}</div>
|
|
||||||
{/if}
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
|
.today-page { padding: 0; }
|
||||||
h1 { margin: 0 0 1rem; font-size: 1.4rem; }
|
h1 { margin: 0 0 1rem; font-size: 1.4rem; }
|
||||||
h2 { font-size: 1rem; margin: 0; color: #495057; }
|
|
||||||
h3 { margin: 0 0 0.75rem; font-size: 0.95rem; color: #343a40; }
|
|
||||||
.error { color: #c0392b; background: #fde8e4; padding: 0.5rem; border-radius: 6px; }
|
|
||||||
.closed-banner { display: flex; align-items: center; justify-content: space-between;
|
|
||||||
padding: 0.75rem 1rem; border-radius: 8px; margin-bottom: 1rem; font-weight: 500; }
|
|
||||||
.closed-banner[data-kind="work"] { background: #d4edda; color: #155724; }
|
|
||||||
.closed-banner[data-kind="holiday"] { background: #fff3cd; color: #856404; }
|
|
||||||
.closed-banner[data-kind="vacation"] { background: #cce5ff; color: #004085; }
|
|
||||||
.closed-banner[data-kind="sick"] { background: #f8d7da; color: #721c24; }
|
|
||||||
.timer-section { text-align: center; padding: 2rem 0 1rem; }
|
|
||||||
.timer { font-size: 3.5rem; font-variant-numeric: tabular-nums; letter-spacing: 0.05em; font-weight: 300; }
|
|
||||||
.timer.running { color: #2c7be5; }
|
|
||||||
.timer.idle { color: #6c757d; }
|
|
||||||
.start-row { display: flex; gap: 0.5rem; justify-content: center; margin-top: 1rem; }
|
|
||||||
.start-row input { padding: 0.5rem 0.75rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.95rem; width: 220px; }
|
|
||||||
.btn { padding: 0.55rem 1.4rem; border: none; border-radius: 6px; font-size: 1rem; font-weight: 600; transition: opacity 0.15s; cursor: pointer; }
|
|
||||||
.btn:hover { opacity: 0.88; }
|
|
||||||
.btn.start { background: #2c7be5; color: #fff; margin-top: 1rem; }
|
|
||||||
.btn.stop { background: #e74c3c; color: #fff; margin-top: 1rem; }
|
|
||||||
.btn.close-day { background: #343a40; color: #fff; margin-top: 1rem; display: block; }
|
|
||||||
.btn.save { background: #2c7be5; color: #fff; }
|
|
||||||
.btn.cancel { background: #e9ecef; color: #343a40; }
|
|
||||||
.btn.sm { padding: 0.3rem 0.8rem; font-size: 0.875rem; }
|
|
||||||
.quick-mark { display: flex; gap: 0.5rem; align-items: center; margin-top: 1rem; flex-wrap: wrap; }
|
|
||||||
.quick-mark span { color: #6c757d; font-size: 0.875rem; }
|
|
||||||
.quick-mark button { padding: 0.35rem 0.75rem; border: 1px solid #ced4da; background: #fff; border-radius: 6px; font-size: 0.875rem; cursor: pointer; }
|
|
||||||
.quick-mark button:hover { background: #e9ecef; }
|
|
||||||
.entries { border-top: 1px solid #dee2e6; margin-top: 1.5rem; padding-top: 0.75rem; }
|
|
||||||
.entries-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.5rem; }
|
|
||||||
.btn-add { background: none; border: 1px solid #2c7be5; color: #2c7be5; border-radius: 6px;
|
|
||||||
padding: 0.25rem 0.75rem; font-size: 0.85rem; font-weight: 600; cursor: pointer; }
|
|
||||||
.btn-add:hover { background: #eaf3ff; }
|
|
||||||
/* Add-interval form */
|
|
||||||
.interval-form { background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 8px;
|
|
||||||
padding: 1rem; margin-bottom: 1rem; }
|
|
||||||
.form-row { display: flex; gap: 1rem; flex-wrap: wrap; align-items: flex-end; }
|
|
||||||
.form-row label { display: flex; flex-direction: column; gap: 0.25rem; font-size: 0.85rem; color: #495057; }
|
|
||||||
.form-row input[type="time"], .form-row input[type="text"] {
|
|
||||||
padding: 0.4rem 0.6rem; border: 1px solid #ced4da; border-radius: 6px; font-size: 0.9rem; }
|
|
||||||
.note-label { flex: 1; min-width: 160px; }
|
|
||||||
.form-actions { display: flex; gap: 0.5rem; margin-top: 0.75rem; }
|
|
||||||
.form-error { color: #c0392b; font-size: 0.85rem; margin: 0 0 0.5rem; }
|
|
||||||
/* Entry rows */
|
|
||||||
.entry { display: flex; align-items: center; gap: 0.5rem; padding: 0.5rem 0; border-bottom: 1px solid #f1f3f5; font-size: 0.9rem; }
|
|
||||||
.entry.running { background: #eaf3ff; border-radius: 6px; padding: 0.5rem 0.5rem; }
|
|
||||||
.entry.editing { display: block; background: #f8f9fa; border: 1px solid #dee2e6; border-radius: 6px; padding: 0.6rem 0.75rem; margin-bottom: 0.25rem; }
|
|
||||||
.edit-row { display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap; }
|
|
||||||
.time-input { width: 90px; padding: 0.3rem 0.4rem; border: 1px solid #ced4da; border-radius: 5px; font-size: 0.9rem; font-variant-numeric: tabular-nums; }
|
|
||||||
.note-input { flex: 1; min-width: 120px; padding: 0.3rem 0.5rem; border: 1px solid #ced4da; border-radius: 5px; font-size: 0.9rem; }
|
|
||||||
.edit-actions { display: flex; gap: 0.5rem; margin-top: 0.5rem; }
|
|
||||||
.time { font-variant-numeric: tabular-nums; color: #343a40; }
|
|
||||||
.dash { color: #adb5bd; }
|
|
||||||
.dur { font-variant-numeric: tabular-nums; color: #6c757d; margin-left: auto; }
|
|
||||||
.note { color: #495057; font-style: italic; }
|
|
||||||
.badge.auto { font-size: 0.75rem; background: #fff3cd; color: #856404; padding: 0.1rem 0.4rem; border-radius: 4px; }
|
|
||||||
.edit-btn { margin-left: auto; background: none; border: none; color: #adb5bd; font-size: 1rem; padding: 0 0.25rem; cursor: pointer; }
|
|
||||||
.edit-btn:hover { color: #2c7be5; }
|
|
||||||
.del { background: none; border: none; color: #adb5bd; font-size: 1rem; padding: 0 0.25rem; cursor: pointer; }
|
|
||||||
.del:hover { color: #e74c3c; }
|
|
||||||
.total { text-align: right; padding: 0.5rem 0; color: #495057; font-weight: 600; }
|
|
||||||
.empty { color: #6c757d; font-style: italic; }
|
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -5,6 +5,8 @@
|
|||||||
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta, todayKey, isWorkday
|
currentWeekKey, weekDayKeys, formatDurationShort, formatDelta, todayKey, isWorkday
|
||||||
} 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 { dayCapabilities } from '$lib/utils';
|
||||||
|
|
||||||
let weekKey = $state(currentWeekKey());
|
let weekKey = $state(currentWeekKey());
|
||||||
let dayKeys = $derived(weekDayKeys(weekKey));
|
let dayKeys = $derived(weekDayKeys(weekKey));
|
||||||
@@ -102,6 +104,15 @@
|
|||||||
|
|
||||||
function prevWeek() { weekKey = offsetWeek(weekKey, -1); }
|
function prevWeek() { weekKey = offsetWeek(weekKey, -1); }
|
||||||
function nextWeek() { 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);
|
||||||
|
const detailCaps = $derived(
|
||||||
|
detailDayKey
|
||||||
|
? dayCapabilities(detailDayKey, todayKey(), !!closedDaysMap[detailDayKey])
|
||||||
|
: null
|
||||||
|
);
|
||||||
function offsetWeek(wk: string, offset: number): string {
|
function offsetWeek(wk: string, offset: number): string {
|
||||||
const [y, w] = wk.split('-W').map(Number);
|
const [y, w] = wk.split('-W').map(Number);
|
||||||
const date = new Date(y, 0, 4);
|
const date = new Date(y, 0, 4);
|
||||||
@@ -163,6 +174,13 @@
|
|||||||
{:else if canCloseWeek}
|
{:else if canCloseWeek}
|
||||||
<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}
|
||||||
|
<div class="day-detail-panel">
|
||||||
|
<h2 class="detail-heading">{detailDayKey}</h2>
|
||||||
|
<DayDetail dayKey={detailDayKey} capabilities={detailCaps} oninvalidate={load} />
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style>
|
<style>
|
||||||
@@ -188,4 +206,6 @@
|
|||||||
.btn.close-week { background: #343a40; color: #fff; border: none; padding: 0.55rem 1.4rem; border-radius: 6px; font-size: 1rem; font-weight: 600; }
|
.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 { 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; }
|
.week-closed button { background: none; border: 1px solid #155724; color: #155724; border-radius: 6px; padding: 0.3rem 0.6rem; font-size: 0.875rem; }
|
||||||
|
.day-detail-panel { border-top: 2px solid #dee2e6; margin-top: 1.25rem; padding-top: 0.75rem; }
|
||||||
|
.detail-heading { font-size: 1rem; font-weight: 600; color: #495057; margin: 0 0 0.5rem; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user