First working prototype

This commit is contained in:
2026-09-06 13:50:58 +02:00
parent ecf08f8307
commit f61b9025eb
44 changed files with 5118 additions and 905 deletions

View File

@@ -1,10 +1,135 @@
<script>
import TimeLogForm from './components/TimeLogForm.svelte';
import LogList from './components/LogList.svelte';
import { dayState, reconcile } from './lib/day.svelte.js';
import { go, router } from './lib/router.svelte.js';
import { loadSettings, settings } from './lib/settings.svelte.js';
import { needsRefresh, applyUpdate } from './lib/updates.svelte.js';
import Resume from './routes/Resume.svelte';
import Settings from './routes/Settings.svelte';
import Today from './routes/Today.svelte';
let ready = $state(false);
$effect(() => {
let alive = true;
(async () => {
await loadSettings();
if (import.meta.env.DEV) {
const { installDevSeed } = await import('./lib/seed.js');
installDevSeed();
}
await reconcile();
if (alive) ready = true;
})();
return () => (alive = false);
});
// Time drives this app, so the clock is polled rather than waited on.
$effect(() => {
const id = setInterval(() => reconcile(), 30_000);
const onVisible = () => document.visibilityState === 'visible' && reconcile();
document.addEventListener('visibilitychange', onVisible);
return () => {
clearInterval(id);
document.removeEventListener('visibilitychange', onVisible);
};
});
/** No explicit route: land on whichever screen matches the state of the day. */
const view = $derived(router.route ?? (dayState.day ? 'today' : 'resume'));
</script>
<main>
<h1>Time Tracker</h1>
<TimeLogForm />
<LogList />
</main>
{#if !ready}
<div class="boot"><p>Chargement…</p></div>
{:else}
{#if needsRefresh.value && !dayState.day}
<!-- Only offered while no day is running, so an update can never interrupt
a day in progress. -->
<div class="update">
<span>Mise à jour disponible</span>
<button type="button" onclick={applyUpdate}>Installer</button>
</div>
{/if}
{#if view === 'settings'}
<Settings />
{:else if view === 'today'}
<Today />
{:else}
<Resume />
{/if}
<nav>
<button
type="button"
class:sel={view === 'resume'}
aria-current={view === 'resume'}
onclick={() => go('resume')}>Récap</button
>
<button
type="button"
class:sel={view === 'today'}
aria-current={view === 'today'}
onclick={() => go('today')}>Journée</button
>
<button
type="button"
class:sel={view === 'settings'}
aria-current={view === 'settings'}
onclick={() => go('settings')}>Réglages</button
>
</nav>
{/if}
<style>
.boot {
flex: 1;
display: grid;
place-items: center;
}
.update {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
padding: 10px var(--pad);
font-weight: 700;
background: var(--accent-soft);
border-bottom: var(--border) solid var(--line);
}
.update button {
min-height: 48px;
}
nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 30;
max-width: 640px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
background: var(--bg);
border-top: var(--border) solid var(--line);
padding-bottom: env(safe-area-inset-bottom);
}
nav button {
border: 0;
border-radius: 0;
min-height: 68px;
font-size: 0.95rem;
}
nav button + button {
border-left: var(--border) solid var(--line);
}
nav .sel {
background: var(--accent);
color: var(--accent-fg);
}
</style>

View File

@@ -1,296 +1,238 @@
/*
* Design tokens for outdoor use.
*
* Rules this file follows, deliberately:
* - Primary text is pure black on pure white (21:1). No grey body copy anywhere.
* - Theme is an explicit setting, never `prefers-color-scheme`: that follows the
* OS, and a dark UI is *harder* to read in direct sunlight, not easier.
* - Nothing tappable is under 64px; primary actions are 88px.
*/
:root {
--text: #6b6375;
--text-h: #08060d;
--bg: #fff;
--border: #e5e4e7;
--code-bg: #f4f3ec;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1);
--accent-border: rgba(170, 59, 255, 0.5);
--social-bg: rgba(244, 243, 236, 0.5);
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px;
--bg: #ffffff;
--fg: #000000;
--fg-dim: #333333; /* 12.6:1 — the darkest "secondary" we allow */
--surface: #f0f0f0;
--line: #000000;
--accent: #0047b3; /* 8.2:1 against white, and against its own white text */
--accent-fg: #ffffff;
--accent-soft: #dce7fb;
--ok: #0f5c2e;
--warn: #8a3d00;
--danger: #a3001b;
--danger-fg: #ffffff;
--tap: 64px;
--tap-lg: 88px;
--gap: 12px;
--pad: 16px;
--radius: 10px;
--border: 3px;
--sans: system-ui, 'Segoe UI', Roboto, sans-serif;
--heading: system-ui, 'Segoe UI', Roboto, sans-serif;
--mono: ui-monospace, Consolas, monospace;
font: 18px/145% var(--sans);
letter-spacing: 0.18px;
color-scheme: light dark;
color: var(--text);
font-family: var(--sans);
font-size: 20px;
line-height: 1.35;
color: var(--fg);
background: var(--bg);
font-synthesis: none;
text-rendering: optimizeLegibility;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
-webkit-text-size-adjust: 100%;
-webkit-tap-highlight-color: transparent;
}
@media (prefers-color-scheme: dark) {
:root {
--text: #9ca3af;
--text-h: #f3f4f6;
--bg: #16171d;
--border: #2e303a;
--code-bg: #1f2028;
--accent: #c084fc;
--accent-bg: rgba(192, 132, 252, 0.15);
--accent-border: rgba(192, 132, 252, 0.5);
--social-bg: rgba(47, 48, 58, 0.5);
--shadow:
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px;
}
:root[data-theme='nuit'] {
--bg: #000000;
--fg: #ffffff;
--fg-dim: #cccccc;
--surface: #1a1a1a;
--line: #ffffff;
#social .button-icon {
filter: invert(1) brightness(2);
}
--accent: #ffb000; /* amber reads well at night and preserves dark adaptation */
--accent-fg: #000000;
--accent-soft: #3a2a00;
--ok: #5ce08a;
--warn: #ffb000;
--danger: #ff5c72;
--danger-fg: #000000;
}
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--fg);
}
body {
margin: 0;
/* Fill the notch area with the app background in a standalone PWA. */
padding: env(safe-area-inset-top) env(safe-area-inset-right)
env(safe-area-inset-bottom) env(safe-area-inset-left);
overscroll-behavior-y: contain;
}
#app {
max-width: 640px;
margin: 0 auto;
min-height: 100svh;
display: flex;
flex-direction: column;
}
h1,
h2 {
font-family: var(--heading);
font-weight: 500;
color: var(--text-h);
h2,
h3 {
margin: 0;
font-weight: 800;
letter-spacing: -0.01em;
}
h1 {
font-size: 56px;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
font-size: 1.6rem;
}
h2 {
font-size: 24px;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
}
font-size: 1.25rem;
}
h3 {
font-size: 1.05rem;
}
p {
margin: 0;
}
code,
.counter {
font-family: var(--mono);
display: inline-flex;
border-radius: 4px;
color: var(--text-h);
/* Times and durations must never reflow as digits change. */
.tnum {
font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
code {
font-size: 15px;
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
.dim {
color: var(--fg-dim);
}
.counter {
font-size: 16px;
padding: 5px 10px;
border-radius: 5px;
color: var(--accent);
background: var(--accent-bg);
border: 2px solid transparent;
transition: border-color 0.3s;
margin-bottom: 24px;
&:hover {
border-color: var(--accent-border);
}
&:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
}
}
.hero {
position: relative;
.base,
.framework,
.vite {
inset-inline: 0;
margin: 0 auto;
}
.base {
width: 170px;
position: relative;
z-index: 0;
}
.framework,
.vite {
position: absolute;
}
.framework {
z-index: 1;
top: 34px;
height: 28px;
transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg)
scale(1.4);
}
.vite {
z-index: 0;
top: 107px;
height: 26px;
width: auto;
transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg)
scale(0.8);
}
}
#app {
width: 1126px;
max-width: 100%;
margin: 0 auto;
.center {
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex;
flex-direction: column;
box-sizing: border-box;
}
#center {
display: flex;
flex-direction: column;
gap: 25px;
place-content: center;
place-items: center;
flex-grow: 1;
/* ------------------------------------------------------------------ controls */
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
button {
font: inherit;
font-weight: 700;
color: var(--fg);
background: var(--bg);
border: var(--border) solid var(--line);
border-radius: var(--radius);
min-height: var(--tap);
padding: 0 var(--pad);
cursor: pointer;
touch-action: manipulation;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 10px;
}
#next-steps {
display: flex;
border-top: 1px solid var(--border);
text-align: left;
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
}
.icon {
margin-bottom: 16px;
width: 22px;
height: 22px;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
button:active {
transform: translateY(2px);
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
button:focus-visible {
outline: 4px solid var(--accent);
outline-offset: 3px;
}
#next-steps ul {
list-style: none;
padding: 0;
display: flex;
gap: 8px;
margin: 32px 0 0;
.logo {
height: 18px;
}
a {
color: var(--text-h);
font-size: 16px;
border-radius: 6px;
background: var(--social-bg);
display: flex;
padding: 6px 12px;
align-items: center;
gap: 8px;
text-decoration: none;
transition: box-shadow 0.3s;
&:hover {
box-shadow: var(--shadow);
}
.button-icon {
height: 18px;
width: 18px;
}
}
@media (max-width: 1024px) {
margin-top: 20px;
flex-wrap: wrap;
justify-content: center;
li {
flex: 1 1 calc(50% - 8px);
}
a {
width: 100%;
justify-content: center;
box-sizing: border-box;
}
}
button[disabled] {
opacity: 0.4;
cursor: default;
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
.btn-primary {
min-height: var(--tap-lg);
font-size: 1.2rem;
color: var(--accent-fg);
background: var(--accent);
border-color: var(--accent);
width: 100%;
&::before,
&::after {
content: '';
position: absolute;
top: -4.5px;
border: 5px solid transparent;
}
&::before {
left: 0;
border-left-color: var(--border);
}
&::after {
right: 0;
border-right-color: var(--border);
}
}
.btn-danger {
color: var(--danger-fg);
background: var(--danger);
border-color: var(--danger);
}
.btn-row {
display: flex;
gap: var(--gap);
}
.btn-row > * {
flex: 1;
}
input[type='text'],
input[type='time'],
input[type='number'],
select {
font: inherit;
font-weight: 600;
color: var(--fg);
background: var(--bg);
border: var(--border) solid var(--line);
border-radius: var(--radius);
min-height: var(--tap);
padding: 0 12px;
width: 100%;
}
input:focus-visible,
select:focus-visible {
outline: 4px solid var(--accent);
outline-offset: 2px;
}
/* -------------------------------------------------------------------- layout */
.screen {
flex: 1;
display: flex;
flex-direction: column;
gap: var(--gap);
padding: var(--pad);
padding-bottom: 96px; /* clear of the fixed nav */
}
.card {
border: var(--border) solid var(--line);
border-radius: var(--radius);
padding: var(--pad);
background: var(--bg);
}
.card-accent {
background: var(--accent-soft);
}
.stack {
display: flex;
flex-direction: column;
gap: var(--gap);
}
.row-between {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--gap);
}
hr {
border: 0;
border-top: var(--border) solid var(--line);
margin: 4px 0;
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

@@ -1 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="26.6" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 308"><path fill="#FF3E00" d="M239.682 40.707C211.113-.182 154.69-12.301 113.895 13.69L42.247 59.356a82.198 82.198 0 0 0-37.135 55.056a86.566 86.566 0 0 0 8.536 55.576a82.425 82.425 0 0 0-12.296 30.719a87.596 87.596 0 0 0 14.964 66.244c28.574 40.893 84.997 53.007 125.787 27.016l71.648-45.664a82.182 82.182 0 0 0 37.135-55.057a86.601 86.601 0 0 0-8.53-55.577a82.409 82.409 0 0 0 12.29-30.718a87.573 87.573 0 0 0-14.963-66.244"></path><path fill="#FFF" d="M106.889 270.841c-23.102 6.007-47.497-3.036-61.103-22.648a52.685 52.685 0 0 1-9.003-39.85a49.978 49.978 0 0 1 1.713-6.693l1.35-4.115l3.671 2.697a92.447 92.447 0 0 0 28.036 14.007l2.663.808l-.245 2.659a16.067 16.067 0 0 0 2.89 10.656a17.143 17.143 0 0 0 18.397 6.828a15.786 15.786 0 0 0 4.403-1.935l71.67-45.672a14.922 14.922 0 0 0 6.734-9.977a15.923 15.923 0 0 0-2.713-12.011a17.156 17.156 0 0 0-18.404-6.832a15.78 15.78 0 0 0-4.396 1.933l-27.35 17.434a52.298 52.298 0 0 1-14.553 6.391c-23.101 6.007-47.497-3.036-61.101-22.649a52.681 52.681 0 0 1-9.004-39.849a49.428 49.428 0 0 1 22.34-33.114l71.664-45.677a52.218 52.218 0 0 1 14.563-6.398c23.101-6.007 47.497 3.036 61.101 22.648a52.685 52.685 0 0 1 9.004 39.85a50.559 50.559 0 0 1-1.713 6.692l-1.35 4.116l-3.67-2.693a92.373 92.373 0 0 0-28.037-14.013l-2.664-.809l.246-2.658a16.099 16.099 0 0 0-2.89-10.656a17.143 17.143 0 0 0-18.398-6.828a15.786 15.786 0 0 0-4.402 1.935l-71.67 45.674a14.898 14.898 0 0 0-6.73 9.975a15.9 15.9 0 0 0 2.709 12.012a17.156 17.156 0 0 0 18.404 6.832a15.841 15.841 0 0 0 4.402-1.935l27.345-17.427a52.147 52.147 0 0 1 14.552-6.397c23.101-6.006 47.497 3.037 61.102 22.65a52.681 52.681 0 0 1 9.003 39.848a49.453 49.453 0 0 1-22.34 33.12l-71.664 45.673a52.218 52.218 0 0 1-14.563 6.398"></path></svg>

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

@@ -0,0 +1,118 @@
<script>
/**
* Correct one already-recorded entry: when it started, what it was, who it was
* for. Reached by tapping any row in the day's timeline.
*/
import { fmtDuration, fmtTime } from '../lib/time.js';
import Sheet from './Sheet.svelte';
import SmartField from './SmartField.svelte';
import TimeField from './TimeField.svelte';
let {
entry = null,
/** Neighbours, so the time edit can be clamped to keep the chain ordered. */
prev = null,
tasks = [],
clients = [],
rounding = 1,
now = Date.now(),
onstart,
onedit,
ondelete,
onclose,
} = $props();
let confirmingDelete = $state(false);
const open = $derived(Boolean(entry));
const isLunch = $derived(entry?.kind === 'lunch');
function close() {
confirmingDelete = false;
onclose?.();
}
</script>
<Sheet {open} title="Modifier" onclose={close}>
{#if entry}
<TimeField
label="Début"
ts={entry.startTs}
min={prev ? prev.startTs : Number.NEGATIVE_INFINITY}
max={entry.endTs ?? now}
{rounding}
onchange={(ts) => onstart?.(entry.id, ts)}
/>
<p class="dim tnum">
{#if entry.endTs == null}
En cours depuis {fmtDuration(now - entry.startTs)}
{:else}
Jusqu'à {fmtTime(entry.endTs, rounding)} · {fmtDuration(
entry.endTs - entry.startTs,
)}
{/if}
</p>
{#if !isLunch}
<SmartField
label="Tâche"
value={entry.task}
suggestions={tasks}
placeholder="Nom de la tâche"
level={1}
onchange={(task) => onedit?.(entry.id, { task })}
/>
<SmartField
label="Client"
value={entry.client}
suggestions={clients}
placeholder="Nom du client"
emptyText="Aucun client enregistré"
level={1}
onchange={(client) => onedit?.(entry.id, { client })}
/>
{:else}
<p class="dim">
Le type de déjeuner et le restaurant se règlent sur l'écran de la journée.
</p>
{/if}
<!-- Kept at the far end of the sheet, away from everything routine. -->
<div class="danger-zone">
{#if confirmingDelete}
<div class="btn-row">
<button type="button" onclick={() => (confirmingDelete = false)}>
Annuler
</button>
<button
type="button"
class="btn-danger"
onclick={() => {
ondelete?.(entry.id);
close();
}}
>
Supprimer
</button>
</div>
{:else if entry.endTs != null}
<button type="button" class="del" onclick={() => (confirmingDelete = true)}>
Supprimer ce poste
</button>
{/if}
</div>
{/if}
</Sheet>
<style>
.danger-zone {
margin-top: 24px;
}
.del {
width: 100%;
color: var(--danger);
border-color: var(--danger);
}
</style>

View File

@@ -1,16 +0,0 @@
<script>
import { db } from '../lib/db';
import { liveQuery } from 'dexie';
const today = new Date().toISOString().split('T')[0];
let logs = liveQuery(() => db.timeLogs.where('date').equals(today).toArray());
$: sortedLogs = $logs ? [...$logs].sort((a, b) => a.startTime.localeCompare(b.startTime)) : [];
</script>
{#if sortedLogs.length > 0}
<ul>
{#each sortedLogs as log}
<li><strong>{log.startTime} - {log.endTime}</strong>: {log.taskName} ({log.clientName})</li>
{/each}
</ul>
{/if}

View File

@@ -0,0 +1,90 @@
<script>
/**
* Bottom sheet. Large close target, dismissable by backdrop, Escape, or back.
*
* `level` stacks sheets: a picker opened from inside another sheet must render
* above it, not behind it.
*/
let { open = false, title = '', level = 0, onclose, children } = $props();
function onkeydown(event) {
if (open && event.key === 'Escape') onclose?.();
}
</script>
<svelte:window {onkeydown} />
{#if open}
<div
class="backdrop"
style="z-index: {40 + level * 10}"
role="button"
tabindex="-1"
aria-label="Fermer"
onclick={() => onclose?.()}
onkeydown={() => {}}
></div>
<div
class="sheet"
style="z-index: {41 + level * 10}"
role="dialog"
aria-modal="true"
aria-label={title}
>
<header>
<h2>{title}</h2>
<button type="button" onclick={() => onclose?.()} aria-label="Fermer">✕</button>
</header>
<div class="body">
{@render children?.()}
</div>
</div>
{/if}
<style>
.backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
border: 0;
}
.sheet {
position: fixed;
left: 0;
right: 0;
bottom: 0;
max-width: 640px;
margin: 0 auto;
max-height: 88svh;
display: flex;
flex-direction: column;
background: var(--bg);
border-top: var(--border) solid var(--line);
border-radius: var(--radius) var(--radius) 0 0;
padding-bottom: env(safe-area-inset-bottom);
}
header {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
padding: var(--pad);
border-bottom: var(--border) solid var(--line);
}
header button {
min-width: var(--tap);
font-size: 1.3rem;
}
.body {
overflow-y: auto;
padding: var(--pad);
display: flex;
flex-direction: column;
gap: var(--gap);
}
</style>

View File

@@ -0,0 +1,149 @@
<script>
/**
* The one control behind client, task and restaurant, so the interaction is
* identical everywhere.
*
* Collapsed it shows the predicted value. One tap opens a sheet whose first
* element is a free text field, followed by the ranked suggestions. Submitting
* the free field saves that value and uses it.
*
* The input is intentionally NOT autofocused: raising a keyboard on every tap is
* hostile with gloves on, and the worker takes a listed row most of the time.
*/
import Sheet from './Sheet.svelte';
let {
label,
value = null,
suggestions = [],
placeholder = 'Saisir…',
emptyText = 'Aucune valeur enregistrée',
/** Raise the picker above a sheet it was opened from. */
level = 0,
onchange,
} = $props();
let open = $state(false);
let draft = $state('');
function choose(next) {
open = false;
draft = '';
if (next !== value) onchange?.(next);
}
function submitDraft(event) {
event.preventDefault();
const trimmed = draft.trim();
if (trimmed) choose(trimmed);
}
</script>
<div class="field">
<span class="label">{label}</span>
<button type="button" class="value" onclick={() => (open = true)}>
<span class="text" class:placeholder={!value}>{value || placeholder}</span>
<span aria-hidden="true">▾</span>
</button>
</div>
<Sheet {open} {level} title={label} onclose={() => (open = false)}>
<form onsubmit={submitDraft}>
<input type="text" bind:value={draft} {placeholder} enterkeyhint="done" />
<button type="submit" disabled={!draft.trim()}>OK</button>
</form>
{#if suggestions.length}
<ul>
{#each suggestions as item (item.value)}
<li>
<button
type="button"
class:current={item.value === value}
onclick={() => choose(item.value)}
>
<span class="text">{item.value}</span>
{#if item.hint}<span class="hint">{item.hint}</span>{/if}
</button>
</li>
{/each}
</ul>
{:else}
<p class="dim center">{emptyText}</p>
{/if}
</Sheet>
<style>
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fg-dim);
}
.value {
width: 100%;
min-height: 72px;
justify-content: space-between;
text-align: left;
font-size: 1.15rem;
}
.placeholder {
color: var(--fg-dim);
font-weight: 600;
}
form {
display: flex;
gap: var(--gap);
}
form input {
flex: 1;
}
form button {
flex: 0 0 88px;
}
ul {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: var(--gap);
}
li button {
width: 100%;
min-height: 72px;
justify-content: space-between;
text-align: left;
gap: var(--gap);
}
li button.current {
background: var(--accent-soft);
border-width: 5px;
}
.text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hint {
flex: none;
font-size: 0.8rem;
font-weight: 600;
color: var(--fg-dim);
}
</style>

View File

@@ -0,0 +1,134 @@
<script>
/**
* Editable clock time. Collapsed it is a big button showing the time; tapping
* opens a sheet with coarse nudge buttons and a native picker.
*
* The nudges carry the weight here: almost every correction in the field is
* "I actually got here twenty minutes ago", and that should not require typing
* on a phone with gloves on.
*/
import { fmtTime, localDateKey, MINUTE, roundTs, startOfDay } from '../lib/time.js';
import Sheet from './Sheet.svelte';
let {
label,
ts,
/** Clamp range; the caller knows the neighbouring boundaries. */
min = Number.NEGATIVE_INFINITY,
max = Number.POSITIVE_INFINITY,
rounding = 1,
level = 1,
onchange,
} = $props();
// Nudge steps follow the rounding setting: at 15-minute rounding a ±5 button
// would round straight back to where it started and appear broken.
const step = $derived(Math.max(5, rounding));
const NUDGES = $derived(
[step, step === 5 ? 15 : step * 2].flatMap((m) => [-m, m]).sort((a, b) => a - b),
);
let open = $state(false);
let draft = $state(0);
/**
* Clamp *and* snap. Everything the sheet shows — the big preview, the native
* picker, the value finally stored — is the same grid-aligned instant, so the
* worker never sees two different times for one edit.
*/
const clamp = (value) => roundTs(Math.min(Math.max(value, min), max), rounding);
const atLimit = (minutes) => clamp(draft + minutes * MINUTE) === draft;
function start() {
draft = clamp(ts);
open = true;
}
function onPick(event) {
const [h, m] = event.currentTarget.value.split(':').map(Number);
if (Number.isNaN(h)) return;
draft = clamp(startOfDay(localDateKey(draft)) + (h * 60 + m) * MINUTE);
}
function confirm() {
open = false;
if (draft !== ts) onchange?.(draft);
}
</script>
<div class="field">
<span class="label">{label}</span>
<button type="button" class="value tnum" onclick={start}>
<span>{fmtTime(ts, rounding)}</span>
<span aria-hidden="true">✎</span>
</button>
</div>
<Sheet {open} {level} title={label} onclose={() => (open = false)}>
<p class="preview tnum center">{fmtTime(draft, rounding)}</p>
<div class="nudges">
{#each NUDGES as minutes (minutes)}
<button
type="button"
disabled={atLimit(minutes)}
onclick={() => (draft = clamp(draft + minutes * MINUTE))}
>
{minutes > 0 ? `+${minutes}` : minutes}
</button>
{/each}
</div>
<input
type="time"
value={fmtTime(draft, rounding)}
onchange={onPick}
aria-label="Heure exacte"
/>
<div class="btn-row">
<button type="button" onclick={() => (open = false)}>Annuler</button>
<button type="button" class="btn-primary confirm" onclick={confirm}>Valider</button>
</div>
</Sheet>
<style>
.field {
display: flex;
flex-direction: column;
gap: 4px;
}
.label {
font-size: 0.85rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--fg-dim);
}
.value {
width: 100%;
min-height: 72px;
justify-content: space-between;
text-align: left;
font-size: 1.15rem;
}
.preview {
font-size: 2.6rem;
font-weight: 800;
margin: 0;
}
.nudges {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--gap);
}
.confirm {
min-height: var(--tap);
}
</style>

View File

@@ -1,86 +0,0 @@
<script>
import { onMount } from 'svelte';
import { addLog, getLastLog } from '../lib/db';
let startTime = '';
let endTime = '';
let taskName = '';
let clientName = '';
let mode = 'time'; // 'time' or 'duration'
let duration = '01:00';
onMount(async () => {
const last = await getLastLog();
if (last) {
startTime = last.endTime;
clientName = last.clientName;
}
});
async function handleSubmit() {
let finalEndTime = endTime;
if (mode === 'duration') {
const [hours, minutes] = duration.split(':').map(Number);
const [startHours, startMinutes] = startTime.split(':').map(Number);
let totalMinutes = startHours * 60 + startMinutes + hours * 60 + minutes;
let endHours = Math.floor(totalMinutes / 60) % 24;
let endMinutes = totalMinutes % 60;
finalEndTime = `${String(endHours).padStart(2, '0')}:${String(endMinutes).padStart(2, '0')}`;
}
const log = {
date: new Date().toISOString().split('T')[0],
startTime,
endTime: finalEndTime,
taskName,
clientName
};
await addLog(log);
// Keep clientName, update startTime for next
startTime = finalEndTime;
endTime = '';
taskName = '';
// clientName = clientName;
}
</script>
<form on:submit|preventDefault={handleSubmit}>
<div>
<label>
<input type="radio" bind:group={mode} value="time" />
Start/End
</label>
<label>
<input type="radio" bind:group={mode} value="duration" />
Duration
</label>
</div>
<input type="time" bind:value={startTime} required placeholder="Start Time" />
{#if mode === 'time'}
<input type="time" bind:value={endTime} required placeholder="End Time" />
{:else}
<input type="text" bind:value={duration} required placeholder="Duration (HH:MM)" />
{/if}
<input type="text" bind:value={taskName} placeholder="Task" required />
<input type="text" bind:value={clientName} placeholder="Client" required />
<button type="submit">Add</button>
</form>
<style>
form {
display: flex;
flex-direction: column;
gap: 1rem;
padding: 1rem;
}
input, button {
font-size: 1.5rem;
padding: 1rem;
}
</style>

View File

@@ -0,0 +1,124 @@
<script>
import { fmtDuration, fmtTime, roundTs } from '../lib/time.js';
let { entries = [], rounding = 15, now = Date.now(), onselect = null } = $props();
const ordered = $derived([...entries].sort((a, b) => a.seq - b.seq));
/**
* Durations are measured between the *displayed* boundaries, so a row can never
* read "10:45–11:45" next to "1h02". The running entry is the exception: its end
* is not shown as a time, so it keeps a live, unrounded count.
*/
function duration(entry) {
if (entry.endTs == null) return Math.max(0, now - entry.startTs);
return Math.max(
0,
roundTs(entry.endTs, rounding) - roundTs(entry.startTs, rounding),
);
}
</script>
{#if ordered.length}
<ul class="timeline">
{#each ordered as entry (entry.id)}
<li>
<!-- The whole row is the target: correcting a mis-tapped task should not
require hunting for a small pencil. -->
<button
type="button"
class:running={entry.endTs == null}
onclick={() => onselect?.(entry)}
disabled={!onselect}
>
<span class="time tnum">
{fmtTime(entry.startTs, rounding)}–{entry.endTs == null
? '…'
: fmtTime(entry.endTs, rounding)}
</span>
<span class="dur tnum">{fmtDuration(duration(entry))}</span>
<span class="what">
<strong>{entry.task}</strong>
{#if entry.client}<span class="dim">{entry.client}</span>{/if}
</span>
{#if onselect}<span class="edit" aria-hidden="true">✎</span>{/if}
</button>
</li>
{/each}
</ul>
{/if}
<style>
.timeline {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
}
li + li {
border-top: 2px solid var(--line);
}
/* Times and totals on one line, the task name on its own full-width line
below: at phone width a shared row squeezes long names into mid-word
breaks. */
li button {
width: 100%;
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-areas:
'time dur edit'
'what what edit';
align-items: center;
column-gap: 10px;
row-gap: 2px;
padding: 12px 4px;
border: 0;
border-radius: 0;
text-align: left;
font-weight: 400;
min-height: var(--tap);
}
li button[disabled] {
opacity: 1;
}
li button.running {
font-weight: 800;
}
.time {
grid-area: time;
font-size: 0.95rem;
font-weight: 700;
}
.dur {
grid-area: dur;
justify-self: end;
font-weight: 700;
}
.edit {
grid-area: edit;
padding-left: 6px;
color: var(--fg-dim);
}
.what {
grid-area: what;
display: flex;
flex-direction: column;
/* Let the column shrink inside the grid so long names wrap rather than
overflow — a truncated task name is useless at a glance. */
min-width: 0;
overflow-wrap: break-word;
}
.what span {
font-size: 0.85rem;
}
</style>

View File

@@ -1,5 +0,0 @@
<script>
let count = $state(0)
</script>
<button type="button" class="counter" onclick={() => count++}>Count is {count}</button>

65
src/lib/boundary.test.js Normal file
View File

@@ -0,0 +1,65 @@
/**
* Pure-logic cover for the boundary clamp used by `setEntryStart`. The database
* call is a thin transaction around this arithmetic; getting the clamp wrong is
* what would invert a timeline.
*/
import { describe, expect, it } from 'vitest';
import { atLocalTime, MINUTE, roundTs } from './time.js';
const DATE = '2026-09-04';
const at = (hhmm) => atLocalTime(DATE, hhmm);
/** Mirrors the clamp in db.js `setEntryStart`. */
function clampStart({ prevStart = null, ownEnd = null, now }, ts) {
const lower = prevStart ?? Number.NEGATIVE_INFINITY;
const upper = ownEnd ?? now;
return Math.min(Math.max(ts, lower), upper);
}
describe('entry start clamping', () => {
it('accepts a time inside the neighbouring boundaries', () => {
const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('09:30'));
expect(out).toBe(at('09:30'));
});
it('refuses to start before the previous entry began', () => {
const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('07:00'));
expect(out).toBe(at('08:00'));
});
it('refuses to start after its own end', () => {
const out = clampStart({ prevStart: at('08:00'), ownEnd: at('12:00') }, at('13:00'));
expect(out).toBe(at('12:00'));
});
it('caps a running entry at the present moment', () => {
const now = at('10:00');
expect(clampStart({ prevStart: at('08:00'), ownEnd: null, now }, at('11:00'))).toBe(now);
});
it('lets the first entry of the day move freely backwards', () => {
// No predecessor: this is the case that makes the auto-captured day start
// correctable when the worker actually left base earlier.
const out = clampStart({ prevStart: null, ownEnd: at('12:00') }, at('06:15'));
expect(out).toBe(at('06:15'));
});
});
describe('manual times snap to the rounding grid', () => {
it.each([
[15, at('09:37'), at('09:30')],
[15, at('09:38'), at('09:45')],
[30, at('09:44'), at('09:30')],
[5, at('09:37'), at('09:35')],
[1, at('09:37'), at('09:37')],
])('rounding %i min stores the displayed value', (step, input, expected) => {
// A time the worker typed must read back exactly as typed, so edits are
// snapped on write rather than only at render.
expect(roundTs(input, step)).toBe(expected);
});
it('keeps a nudged time on the grid it started on', () => {
const start = roundTs(at('09:37'), 15);
expect(roundTs(start + 15 * MINUTE, 15)).toBe(at('09:45'));
});
});

306
src/lib/day.svelte.js Normal file
View File

@@ -0,0 +1,306 @@
/**
* The day lifecycle: one state machine, one `reconcile()` entry point.
*
* Everything time-driven happens here — auto-start at the configured hour,
* auto-close of a day left open from an earlier date, and the end-of-day grace
* prompt. Components never decide when a day begins or ends.
*/
import {
cleanupOldDays,
closeAndStart,
deleteEntry,
getActiveDay,
getDay,
getEntries,
getHistory,
getLastClosedDay,
patchDay,
patchEntry,
putDay,
setEntryStart,
} from './db.js';
import { buildModel, suggestClients, suggestTasks, TRANSPORT_TASK } from './predict.js';
import { settings } from './settings.svelte.js';
import { atLocalTime, HOUR, localDateKey, roundTs, weekday } from './time.js';
export const dayState = $state({
today: localDateKey(),
/** The running day, or null when nothing is in progress. */
day: null,
entries: [],
/** Last closed day + its entries — what the resume screen renders. */
lastClosed: null,
lastClosedEntries: [],
/** Prediction model, rebuilt whenever history changes. */
model: null,
/** True once the configured end time has passed and the worker has not answered. */
needsClosePrompt: false,
loaded: false,
});
export const running = () => dayState.entries.find((e) => e.endTs == null) ?? null;
/* --------------------------------------------------------------- reconciling */
/** Retention runs once per session, not on every 30 s tick. */
let cleanedUpFor = null;
/**
* Bring stored state in line with the clock. Safe to call often — it is invoked on
* load, on tab focus, and from a 30 s tick.
*/
export async function reconcile(now = Date.now()) {
dayState.today = localDateKey(now);
// Applying retention here is what makes the setting mean anything; leaving it to
// the manual button in settings meant it effectively never ran.
if (cleanedUpFor !== dayState.today) {
cleanedUpFor = dayState.today;
try {
await cleanupOldDays(settings.retentionDays);
} catch (error) {
console.error('[timelog] retention cleanup failed', error);
}
}
const active = await getActiveDay();
// A day left open from an earlier date never stays open.
if (active && active.date < dayState.today) {
await forceClose(active);
return reconcile(now);
}
// Auto-start. Only when no record exists for today at all: a day the worker has
// already ended must not spring back to life.
if (!active) {
const existing = await getDay(dayState.today);
if (!existing && settings.workdays.includes(weekday(dayState.today))) {
const plannedStart = atLocalTime(dayState.today, settings.dayStart);
if (now >= plannedStart) {
await openDay(dayState.today, plannedStart);
return refresh(now);
}
}
}
await refresh(now);
}
/** Close a stale day at the earlier of its planned end and its last recorded activity. */
async function forceClose(day) {
const entries = await getEntries(day.date);
const lastEnd = entries.reduce((max, e) => Math.max(max, e.endTs ?? e.startTs), 0);
const endedTs = Math.min(day.plannedEndTs, lastEnd || day.plannedEndTs);
await closeAndStart(day.date, endedTs, null);
await patchDay(day.date, { status: 'closed', endedTs });
}
/**
* Create a day and immediately open the transport task: per the workflow, the drive
* out from base *is* the first task, and "Arrivé sur site" is what ends it.
*/
async function openDay(dateKey, startTs) {
await putDay({
date: dateKey,
status: 'active',
plannedStartTs: atLocalTime(dateKey, settings.dayStart),
plannedEndTs: atLocalTime(dateKey, settings.dayEnd),
startedTs: startTs,
endedTs: null,
lunchType: settings.lunchType,
restaurant: null,
closePromptedTs: null,
});
const { entries, days } = await getHistory();
const model = buildModel(entries, days);
await closeAndStart(dateKey, startTs, {
kind: 'travel',
task: TRANSPORT_TASK,
client: model.lastClient,
});
}
/** Reload everything from the database and rebuild the model. */
export async function refresh(now = Date.now()) {
const active = await getActiveDay();
dayState.day = active ?? null;
dayState.entries = active ? await getEntries(active.date) : [];
const lastClosed = await getLastClosedDay();
dayState.lastClosed = lastClosed ?? null;
dayState.lastClosedEntries = lastClosed ? await getEntries(lastClosed.date) : [];
const { entries, days } = await getHistory();
dayState.model = buildModel(entries, days);
dayState.needsClosePrompt = Boolean(
active && active.date === dayState.today && now >= active.plannedEndTs,
);
dayState.loaded = true;
}
/* -------------------------------------------------------------------- actions */
/** Manual start, for a Saturday or an early departure. */
export async function startDayNow(now = Date.now()) {
await openDay(dayState.today, now);
await refresh(now);
}
/**
* Close the running entry and open the next one. The single mutation every button
* on the Today screen goes through.
*
* @param {{kind?: 'travel'|'work'|'lunch', task: string, client?: string|null}} slot
* @param {number} [now]
*/
export async function advance({ kind = 'work', task, client }, now = Date.now()) {
if (!dayState.day) return;
await closeAndStart(dayState.day.date, now, { kind, task, client: client ?? null });
await refresh(now);
}
export async function startLunch(now = Date.now()) {
if (!dayState.day) return;
await closeAndStart(dayState.day.date, now, {
kind: 'lunch',
task: 'Pause déjeuner',
client: null,
});
await refresh(now);
}
export async function setLunch({ lunchType, restaurant }) {
if (!dayState.day) return;
await patchDay(dayState.day.date, {
lunchType,
restaurant: lunchType === 'restaurant' ? (restaurant ?? null) : null,
});
await refresh();
}
/** Correct the task or client of an entry already recorded. */
export async function editEntry(id, patch) {
await patchEntry(id, patch);
await refresh();
}
/**
* Correct when an entry started. Manually chosen times are snapped to the rounding
* grid so the value the worker picked is exactly the value they see afterwards —
* auto-captured times stay raw, as designed.
*/
export async function editEntryStart(id, ts) {
await setEntryStart(id, roundTs(ts, settings.roundingMinutes));
await refresh();
}
export async function removeEntry(id) {
await deleteEntry(id);
await refresh();
}
export async function endDay(now = Date.now()) {
if (!dayState.day) return;
const date = dayState.day.date;
await closeAndStart(date, now, null);
await patchDay(date, { status: 'closed', endedTs: now });
await refresh(now);
}
/** "Toujours en cours" — push the end out an hour so the prompt returns later. */
export async function keepWorking(now = Date.now()) {
if (!dayState.day) return;
await patchDay(dayState.day.date, {
plannedEndTs: Math.max(dayState.day.plannedEndTs, now) + HOUR,
closePromptedTs: now,
});
await refresh(now);
}
/** Undo a premature end-of-day: reopens today and starts a fresh task from now. */
export async function reopenToday(now = Date.now()) {
const day = await getDay(dayState.today);
if (!day || day.status !== 'closed') return;
await patchDay(dayState.today, {
status: 'active',
endedTs: null,
plannedEndTs: Math.max(day.plannedEndTs, now + HOUR),
});
const { entries, days } = await getHistory();
const model = buildModel(entries, days);
const [best] = suggestTasks(model, {
prevTask: null,
prevElapsedMs: 0,
seq: (await getEntries(dayState.today)).length,
ts: now,
client: model.lastClient,
});
await closeAndStart(dayState.today, now, {
kind: 'work',
task: best?.value ?? TRANSPORT_TASK,
client: model.lastClient,
});
await refresh(now);
}
/* ---------------------------------------------------------------- predictions */
/**
* Best guess for the task and client of the slot that would open right now.
* Callers snapshot this when a screen opens rather than recomputing per tick, so
* the proposed value does not shift under the worker's thumb.
*/
export function predictNext(now = Date.now()) {
const model = dayState.model;
if (!model) return { task: TRANSPORT_TASK, client: null, tasks: [], clients: [] };
const current = running();
const tasks = suggestTasks(model, {
prevTask: current?.task ?? null,
prevElapsedMs: current ? now - current.startTs : 0,
seq: dayState.entries.length,
ts: now,
client: current?.client ?? model.lastClient,
});
const clients = suggestClients(model, now);
return {
task: tasks[0]?.value ?? TRANSPORT_TASK,
client: current?.client ?? clients[0]?.value ?? null,
tasks,
clients,
};
}
/**
* Suggestions ranked for the entry *currently* running, used when correcting it —
* a different context from `predictNext`, which ranks the slot about to open.
*/
export function predictForCurrent(now = Date.now()) {
const model = dayState.model;
const current = running();
if (!model || !current) return { tasks: [], clients: [] };
const index = dayState.entries.findIndex((e) => e.id === current.id);
const prev = index > 0 ? dayState.entries[index - 1] : null;
return {
tasks: suggestTasks(model, {
prevTask: prev?.task ?? null,
prevElapsedMs: prev?.endTs != null ? prev.endTs - prev.startTs : 0,
seq: current.seq,
ts: current.startTs,
client: current.client,
}),
clients: suggestClients(model, now),
};
}
/** The client the day has been booked to so far — used to pick a restaurant. */
export function currentClient() {
return [...dayState.entries].reverse().find((e) => e.client)?.client ?? null;
}

View File

@@ -1,45 +1,240 @@
import Dexie from 'dexie';
import { DAY, localDateKey } from './time.js';
export const db = new Dexie('TimeLogDB');
/**
* A single working day. `plannedStartTs` / `plannedEndTs` are snapshotted from the
* settings in force when the day was created, so editing settings later never
* rewrites history.
*
* @typedef {object} DayRecord
* @property {string} date local 'YYYY-MM-DD', primary key
* @property {'active'|'closed'} status
* @property {number} plannedStartTs
* @property {number} plannedEndTs
* @property {number} startedTs
* @property {number|null} endedTs
* @property {'panier'|'restaurant'|null} lunchType
* @property {string|null} restaurant
* @property {number|null} closePromptedTs
*/
/**
* One stretch of the day. `endTs === null` marks the single running entry; every
* other entry's `endTs` equals the next entry's `startTs`.
*
* @typedef {object} EntryRecord
* @property {number} [id]
* @property {string} date
* @property {number} seq position within the day, dense from 0
* @property {'travel'|'work'|'lunch'} kind
* @property {string} task
* @property {string|null} client
* @property {number} startTs
* @property {number|null} endTs
*/
/**
* @typedef {object} SettingsRecord
* @property {string} key
* @property {any} value
*/
/**
* @typedef {Dexie & {
* days: import('dexie').Table<DayRecord, string>,
* entries: import('dexie').Table<EntryRecord, number>,
* settings: import('dexie').Table<SettingsRecord, string>,
* }} TimeLogDB
*/
export const db = /** @type {TimeLogDB} */ (new Dexie('TimeLogDB'));
// v1 was the throwaway prototype schema; declared so Dexie has an upgrade path.
db.version(1).stores({
timeLogs: '++id, date, startTime, endTime, taskName, clientName',
settings: 'key' // for retention policy
settings: 'key',
});
// Helper functions
export async function addLog(log) {
return await db.timeLogs.add(log);
db.version(2).stores({
timeLogs: null, // drop the prototype table
days: 'date, status',
entries: '++id, date, [date+seq], startTs',
settings: 'key',
});
/* ------------------------------------------------------------------ settings */
export async function readSettings() {
const rec = await db.settings.get('app');
return rec?.value ?? null;
}
export async function getLogsByDate(date) {
return await db.timeLogs.where('date').equals(date).toArray();
export async function writeSettings(value) {
await db.settings.put({ key: 'app', value });
}
export async function getRecentLogs(limit = 10) {
return await db.timeLogs.orderBy('id').reverse().limit(limit).toArray();
/* ---------------------------------------------------------------------- days */
export function getDay(dateKey) {
return db.days.get(dateKey);
}
export async function getLastLog() {
return await db.timeLogs.orderBy('id').reverse().first();
export function putDay(day) {
return db.days.put(day);
}
export async function setRetentionDays(days) {
await db.settings.put({ key: 'retentionDays', value: days });
export function patchDay(dateKey, patch) {
return db.days.update(dateKey, patch);
}
// Retention logic
export async function cleanupOldLogs() {
const retentionDays = await db.settings.get('retentionDays') || 30;
const cutoffDate = new Date();
cutoffDate.setDate(cutoffDate.getDate() - retentionDays);
// Keep the latest day's report
const latestDate = await db.timeLogs.orderBy('date').reverse().first();
if (latestDate) {
await db.timeLogs
.where('date')
.below(cutoffDate.toISOString().split('T')[0])
.and(log => log.date !== latestDate.date)
.delete();
}
/** The one day still running, if any — including a stale one from a past date. */
export function getActiveDay() {
return db.days.where('status').equals('active').first();
}
/** Most recent closed day: the one the resume screen shows. */
export async function getLastClosedDay() {
const closed = await db.days.where('status').equals('closed').toArray();
if (!closed.length) return null;
closed.sort((a, b) => a.date.localeCompare(b.date));
return closed[closed.length - 1];
}
/* ------------------------------------------------------------------- entries */
export function getEntries(dateKey) {
return db.entries.where('date').equals(dateKey).sortBy('seq');
}
/**
* The single place the chain invariant is enforced: closing the running entry and
* opening the next one share one timestamp, in one transaction, so entry N's end
* can never drift from entry N+1's start.
*
* @param {string} dateKey
* @param {number} ts
* @param {Pick<EntryRecord, 'kind'|'task'|'client'>|null} next
* Pass null to only close the running entry (end of day).
* @returns {Promise<number|null>} id of the new entry
*/
export function closeAndStart(dateKey, ts, next) {
return db.transaction('rw', db.entries, async () => {
const list = await db.entries.where('date').equals(dateKey).sortBy('seq');
const running = list.find((e) => e.endTs == null);
let boundary = ts;
if (running) {
// A tap can never produce an entry that ends before it started.
boundary = Math.max(ts, running.startTs);
await db.entries.update(running.id, { endTs: boundary });
}
if (!next) return null;
const seq = list.length ? list[list.length - 1].seq + 1 : 0;
return db.entries.add({
date: dateKey,
seq,
startTs: boundary,
endTs: null,
...next,
});
});
}
export function patchEntry(id, patch) {
return db.entries.update(id, patch);
}
/**
* Move the boundary at the start of an entry. Because that instant is shared with
* the previous entry's end, both sides move together and the chain stays gapless.
* For the first entry of a day there is no predecessor, so the day's start moves
* instead — that is what makes the auto-captured start time correctable.
*
* The value is clamped between the previous entry's start and this entry's own
* end (or now, while it is still running), so no edit can invert the timeline.
*
* @returns {Promise<number|undefined>} the timestamp actually stored
*/
export function setEntryStart(id, ts, now = Date.now()) {
return db.transaction('rw', db.entries, db.days, async () => {
const entry = await db.entries.get(id);
if (!entry) return undefined;
const list = await db.entries.where('date').equals(entry.date).sortBy('seq');
const prev = list[list.findIndex((e) => e.id === id) - 1];
const lower = prev ? prev.startTs : Number.NEGATIVE_INFINITY;
const upper = entry.endTs ?? now;
const stored = Math.min(Math.max(ts, lower), upper);
await db.entries.update(entry.id, { startTs: stored });
if (prev) await db.entries.update(prev.id, { endTs: stored });
else await db.days.update(entry.date, { startedTs: stored });
return stored;
});
}
/**
* Remove an entry and re-stitch the chain: the previous entry absorbs the gap by
* extending to the next entry's start. Keeps the timeline contiguous after a
* mis-tap, which is the whole point of allowing deletes.
*/
export function deleteEntry(id) {
return db.transaction('rw', db.entries, async () => {
const victim = await db.entries.get(id);
if (!victim) return;
const list = await db.entries.where('date').equals(victim.date).sortBy('seq');
const i = list.findIndex((e) => e.id === id);
const prev = list[i - 1];
const next = list[i + 1];
await db.entries.delete(id);
if (prev) {
// Extend the previous entry over the hole; if nothing follows, it inherits
// the victim's end (including `null`, i.e. it becomes the running entry).
await db.entries.update(prev.id, { endTs: next ? next.startTs : victim.endTs });
} else if (next) {
await db.entries.update(next.id, { startTs: victim.startTs });
}
// Re-number so `seq` stays dense.
const rest = list.filter((e) => e.id !== id);
await Promise.all(
rest.map((e, n) => (e.seq === n ? null : db.entries.update(e.id, { seq: n }))),
);
});
}
/* ------------------------------------------------------------------- history */
/** Everything the prediction model is built from. */
export async function getHistory() {
const [entries, days] = await Promise.all([
db.entries.toArray(),
db.days.toArray(),
]);
return { entries, days };
}
/**
* Drop days older than the retention window. The most recent closed day is always
* kept — it is the app's primary output and must survive any setting.
*/
export async function cleanupOldDays(retentionDays) {
if (!retentionDays || retentionDays <= 0) return 0;
const cutoff = localDateKey(Date.now() - retentionDays * DAY);
const keep = await getLastClosedDay();
const stale = (await db.days.toArray()).filter(
(d) => d.date < cutoff && d.date !== keep?.date,
);
if (!stale.length) return 0;
const keys = stale.map((d) => d.date);
await db.transaction('rw', db.days, db.entries, async () => {
await db.days.bulkDelete(keys);
await db.entries.where('date').anyOf(keys).delete();
});
return keys.length;
}

319
src/lib/predict.js Normal file
View File

@@ -0,0 +1,319 @@
/**
* Suggestion / prediction engine.
*
* Pure functions over the raw history — no counter tables to keep in sync. The
* volume is a few hundred entries per year, so the model is just rebuilt whenever
* history changes.
*
* Everything is a blend of smoothed conditional frequencies. No training, no
* opaque state: a worker who does the same three things in the same order every
* day gets the right answer on day two, and the reasoning stays inspectable.
*/
import { HOUR, MINUTE, localDateKey, startOfDay } from './time.js';
/**
* Used before there is any history to learn from, so day one is still usable.
* Listed in the order a day actually runs — that order is the tiebreaker when
* nothing has been learned yet, so the first suggestion after arriving on site is
* the work, not the trip home.
*/
export const SEED_TASKS = [
'Chargement / transport',
'Travaux sur site',
'Retour / déchargement',
];
const seedRank = (task) => {
const i = SEED_TASKS.indexOf(task);
return i === -1 ? SEED_TASKS.length : i;
};
export const TRANSPORT_TASK = SEED_TASKS[0];
/** Blend weights for the task ranking. Tunable in one place. */
export const WEIGHTS = {
bigram: 0.35, // what usually follows the current task
seq: 0.25, // what usually sits at this position in the day
clock: 0.2, // what usually happens around this hour
client: 0.1, // what this client usually needs
global: 0.1, // overall favourites
};
/** Recency half-life for client ranking. */
const CLIENT_HALF_LIFE_DAYS = 30;
/** Laplace smoothing constant. */
const ALPHA = 0.5;
/* ------------------------------------------------------------------ counting */
const bump = (map, key, by = 1) => map.set(key, (map.get(key) ?? 0) + by);
function bump2(map, outer, inner) {
let m = map.get(outer);
if (!m) map.set(outer, (m = new Map()));
bump(m, inner);
}
/** 30-minute bucket of the day, 0..47. */
export function clockBucket(ts) {
return Math.floor((ts - startOfDay(localDateKey(ts))) / (30 * MINUTE)) % 48;
}
const median = (xs) => {
if (!xs.length) return 0;
const s = [...xs].sort((a, b) => a - b);
const mid = s.length >> 1;
return s.length % 2 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
};
/**
* How long the current task has run, relative to how long it usually runs.
* Three buckets only — enough to separate "just started" from "dragging on"
* without making the conditioned bigram sparse.
*/
export function elapsedBucket(elapsedMs, medianMs) {
if (!medianMs) return 'typical';
if (elapsedMs < medianMs * 0.6) return 'short';
if (elapsedMs > medianMs * 1.4) return 'long';
return 'typical';
}
/* -------------------------------------------------------------------- model */
/**
* @param {Array} entries all historical entries
* @param {Array} days all historical days
*/
export function buildModel(entries, days = []) {
const model = {
taskCount: new Map(),
taskDurations: new Map(),
taskLastTs: new Map(),
bigram: new Map(), // prevTask -> task -> n
bigramElapsed: new Map(), // `${prev}|${bucket}` -> task -> n
bySeq: new Map(), // seq -> task -> n
byClock: new Map(), // clock bucket -> task -> n
byClient: new Map(), // client -> task -> n
clientCount: new Map(),
clientLastTs: new Map(),
restByClient: new Map(), // client -> restaurant -> n
restCount: new Map(),
restLastTs: new Map(),
lastClient: null,
lastRestaurant: null,
};
// Work entries only: lunch and its restaurant are modelled separately.
const work = entries
.filter((e) => e.kind !== 'lunch' && e.task)
.sort((a, b) => a.startTs - b.startTs);
const byDate = new Map();
for (const e of work) {
if (!byDate.has(e.date)) byDate.set(e.date, []);
byDate.get(e.date).push(e);
}
for (const e of work) {
bump(model.taskCount, e.task);
bump2(model.bySeq, e.seq, e.task);
bump2(model.byClock, clockBucket(e.startTs), e.task);
model.taskLastTs.set(e.task, Math.max(model.taskLastTs.get(e.task) ?? 0, e.startTs));
if (e.endTs != null) {
const arr = model.taskDurations.get(e.task) ?? [];
arr.push(e.endTs - e.startTs);
model.taskDurations.set(e.task, arr);
}
if (e.client) {
bump(model.clientCount, e.client);
bump2(model.byClient, e.client, e.task);
model.clientLastTs.set(
e.client,
Math.max(model.clientLastTs.get(e.client) ?? 0, e.startTs),
);
}
}
model.taskMedian = new Map(
[...model.taskDurations].map(([task, ds]) => [task, median(ds)]),
);
// Transitions, within a day only — the last task of Monday does not predict the
// first task of Tuesday.
for (const list of byDate.values()) {
for (let i = 1; i < list.length; i++) {
const prev = list[i - 1];
const cur = list[i];
bump2(model.bigram, prev.task, cur.task);
if (prev.endTs != null) {
const bucket = elapsedBucket(
prev.endTs - prev.startTs,
model.taskMedian.get(prev.task),
);
bump2(model.bigramElapsed, `${prev.task}|${bucket}`, cur.task);
}
}
}
// Restaurants live on the day record, paired with that day's dominant client.
const sortedDays = [...days].sort((a, b) => a.date.localeCompare(b.date));
for (const d of sortedDays) {
if (d.lunchType !== 'restaurant' || !d.restaurant) continue;
bump(model.restCount, d.restaurant);
model.restLastTs.set(d.restaurant, startOfDay(d.date));
model.lastRestaurant = d.restaurant;
const client = dominantClient(byDate.get(d.date) ?? []);
if (client) bump2(model.restByClient, client, d.restaurant);
}
const lastWithClient = [...work].reverse().find((e) => e.client);
model.lastClient = lastWithClient?.client ?? null;
return model;
}
/** The client a day's time mostly went to — how a restaurant gets attributed. */
function dominantClient(dayEntries) {
const totals = new Map();
for (const e of dayEntries) {
if (!e.client || e.endTs == null) continue;
bump(totals, e.client, e.endTs - e.startTs);
}
let best = null;
for (const [client, ms] of totals) if (!best || ms > best[1]) best = [client, ms];
return best?.[0] ?? null;
}
/* ------------------------------------------------------------------ ranking */
/** Smoothed P(x | table), over the union of candidates. */
function dist(table, candidates) {
const total = [...(table?.values() ?? [])].reduce((a, b) => a + b, 0);
const denom = total + ALPHA * candidates.length;
const out = new Map();
for (const c of candidates) out.set(c, ((table?.get(c) ?? 0) + ALPHA) / denom);
return out;
}
/**
* Rank tasks for the slot about to open.
*
* @param {object} model
* @param {object} ctx
* @param {string|null} ctx.prevTask task being closed
* @param {number} ctx.prevElapsedMs how long it ran
* @param {number} ctx.seq position of the new entry in the day
* @param {number} ctx.ts when the new entry starts
* @param {string|null} ctx.client
* @returns {Array<{value: string, hint: string, score: number}>}
*/
export function suggestTasks(model, ctx) {
const known = [...model.taskCount.keys()];
const candidates = known.length ? known : [...SEED_TASKS];
for (const t of SEED_TASKS) if (!candidates.includes(t)) candidates.push(t);
// Prefer the elapsed-conditioned transitions, fall back to the plain bigram when
// that bucket has not been seen yet.
let bigramTable = null;
if (ctx.prevTask) {
const bucket = elapsedBucket(
ctx.prevElapsedMs ?? 0,
model.taskMedian.get(ctx.prevTask),
);
bigramTable =
model.bigramElapsed.get(`${ctx.prevTask}|${bucket}`) ??
model.bigram.get(ctx.prevTask);
}
const pBigram = dist(bigramTable, candidates);
const pSeq = dist(model.bySeq.get(ctx.seq), candidates);
const pClock = dist(model.byClock.get(clockBucket(ctx.ts)), candidates);
const pClient = dist(ctx.client ? model.byClient.get(ctx.client) : null, candidates);
const pGlobal = dist(model.taskCount, candidates);
return candidates
.map((value) => ({
value,
score:
WEIGHTS.bigram * pBigram.get(value) +
WEIGHTS.seq * pSeq.get(value) +
WEIGHTS.clock * pClock.get(value) +
WEIGHTS.client * pClient.get(value) +
WEIGHTS.global * pGlobal.get(value),
hint: durationHint(model, value),
}))
// Never propose repeating the task just closed; the worker would have kept going.
.filter((s) => s.value !== ctx.prevTask)
// Seed order only ever breaks an exact tie, so it cannot perturb a ranking
// that history has actually informed.
.sort(
(a, b) =>
b.score - a.score ||
seedRank(a.value) - seedRank(b.value) ||
a.value.localeCompare(b.value),
);
}
function durationHint(model, task) {
const ms = model.taskMedian.get(task);
if (!ms || ms < 5 * MINUTE) return '';
const h = Math.floor(ms / HOUR);
const m = Math.round((ms % HOUR) / MINUTE);
return h ? `~${h}h${String(m).padStart(2, '0')} d'habitude` : `~${m} min d'habitude`;
}
/**
* Rank clients. The most recently used one is pinned to the top — that is the
* stated default — and the rest fall back on frequency decayed by recency.
*/
export function suggestClients(model, now = Date.now()) {
const ranked = [...model.clientCount.keys()]
.map((value) => {
const last = model.clientLastTs.get(value) ?? 0;
const days = Math.max(0, (now - last) / 86_400_000);
return {
value,
score: model.clientCount.get(value) * 0.5 ** (days / CLIENT_HALF_LIFE_DAYS),
hint: recencyHint(last, now),
};
})
.sort((a, b) => b.score - a.score || a.value.localeCompare(b.value));
const pinned = ranked.findIndex((c) => c.value === model.lastClient);
if (pinned > 0) ranked.unshift(ranked.splice(pinned, 1)[0]);
return ranked;
}
/**
* Rank restaurants for a client: what was eaten with *this* client first, then
* everything else by overall frequency.
*/
export function suggestRestaurants(model, client, now = Date.now()) {
const paired = model.restByClient.get(client) ?? new Map();
return [...model.restCount.keys()]
.map((value) => {
const withClient = paired.get(value) ?? 0;
return {
value,
// Any restaurant tied to this client outranks any that is not.
score: withClient * 1000 + model.restCount.get(value),
hint: withClient
? `${withClient} × avec ${client}`
: recencyHint(model.restLastTs.get(value) ?? 0, now),
};
})
.sort((a, b) => b.score - a.score || a.value.localeCompare(b.value));
}
function recencyHint(ts, now) {
if (!ts) return '';
const days = Math.floor((now - ts) / 86_400_000);
if (days <= 0) return "aujourd'hui";
if (days === 1) return 'hier';
if (days < 7) return `il y a ${days} jours`;
if (days < 30) return `il y a ${Math.floor(days / 7)} sem.`;
return `il y a ${Math.floor(days / 30)} mois`;
}

218
src/lib/predict.test.js Normal file
View File

@@ -0,0 +1,218 @@
import { describe, expect, it } from 'vitest';
import {
buildModel,
elapsedBucket,
SEED_TASKS,
suggestClients,
suggestRestaurants,
suggestTasks,
} from './predict.js';
import { atLocalTime, HOUR, MINUTE } from './time.js';
/** A day shaped like the real workflow: transport, work, lunch, work, return. */
function makeDay(date, client, restaurant, tasks) {
let ts = atLocalTime(date, '07:30');
const entries = tasks.map(([task, minutes, kind = 'work'], seq) => {
const startTs = ts;
ts += minutes * MINUTE;
return {
id: `${date}-${seq}`,
date,
seq,
kind,
task,
client: kind === 'lunch' ? null : client,
startTs,
endTs: ts,
};
});
return {
day: {
date,
status: 'closed',
lunchType: restaurant ? 'restaurant' : 'panier',
restaurant,
},
entries,
};
}
const STANDARD = [
['Chargement / transport', 45, 'travel'],
['Terrassement', 195],
['Pause déjeuner', 60, 'lunch'],
['Pose de câble', 210],
['Retour / déchargement', 50],
];
function history(specs) {
const days = [];
const entries = [];
for (const [date, client, restaurant] of specs) {
const made = makeDay(date, client, restaurant, STANDARD);
days.push(made.day);
entries.push(...made.entries);
}
return { days, entries };
}
const FIXTURE = history([
['2026-09-01', 'Dupont SA', 'Le Relais'],
['2026-09-02', 'Dupont SA', 'Le Relais'],
['2026-09-03', 'Martin & Fils', 'Chez Marcel'],
['2026-09-04', 'Dupont SA', 'Le Relais'],
]);
const NOW = atLocalTime('2026-09-07', '09:00');
describe('buildModel', () => {
it('learns tasks, clients and their last use', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
expect(m.taskCount.get('Terrassement')).toBe(4);
expect(m.clientCount.get('Dupont SA')).toBe(12); // 4 work entries × 3 days
expect(m.lastClient).toBe('Dupont SA');
});
it('excludes lunch from the task model', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
expect(m.taskCount.has('Pause déjeuner')).toBe(false);
});
it('does not chain the last task of one day to the first of the next', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
expect(m.bigram.get('Retour / déchargement')).toBeUndefined();
});
it('records a median duration per task', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
expect(m.taskMedian.get('Terrassement')).toBe(195 * MINUTE);
});
});
describe('suggestTasks', () => {
it('predicts the task that historically follows the current one', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
const [best] = suggestTasks(m, {
prevTask: 'Chargement / transport',
prevElapsedMs: 45 * MINUTE,
seq: 1,
ts: atLocalTime('2026-09-07', '08:15'),
client: 'Dupont SA',
});
expect(best.value).toBe('Terrassement');
});
it('predicts the return trip at the end of the chain', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
const [best] = suggestTasks(m, {
prevTask: 'Pose de câble',
prevElapsedMs: 210 * MINUTE,
seq: 4,
ts: atLocalTime('2026-09-07', '16:40'),
client: 'Dupont SA',
});
expect(best.value).toBe('Retour / déchargement');
});
it('never proposes repeating the task just closed', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
const out = suggestTasks(m, {
prevTask: 'Terrassement',
prevElapsedMs: 195 * MINUTE,
seq: 2,
ts: NOW,
client: 'Dupont SA',
});
expect(out.map((s) => s.value)).not.toContain('Terrassement');
});
it('falls back to the seed list with no history at all', () => {
const m = buildModel([], []);
const out = suggestTasks(m, { prevTask: null, prevElapsedMs: 0, seq: 0, ts: NOW });
expect(out.length).toBe(SEED_TASKS.length);
expect(out.map((s) => s.value)).toEqual(SEED_TASKS);
});
it('proposes the work, not the trip home, on a cold start after transport', () => {
// With zero history every candidate scores identically, so the tiebreak has to
// follow the natural order of a day rather than the alphabet.
const m = buildModel([], []);
const out = suggestTasks(m, {
prevTask: 'Chargement / transport',
prevElapsedMs: 45 * MINUTE,
seq: 1,
ts: NOW,
});
expect(out[0].value).toBe('Travaux sur site');
});
it('surfaces a duration hint for tasks it has timed', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
const hit = suggestTasks(m, {
prevTask: 'Chargement / transport',
prevElapsedMs: 45 * MINUTE,
seq: 1,
ts: NOW,
client: 'Dupont SA',
}).find((s) => s.value === 'Terrassement');
expect(hit.hint).toBe("~3h15 d'habitude");
});
});
describe('elapsedBucket', () => {
it.each([
[30 * MINUTE, HOUR, 'short'],
[HOUR, HOUR, 'typical'],
[2 * HOUR, HOUR, 'long'],
[5 * HOUR, 0, 'typical'], // unknown median
])('buckets %i against %i as %s', (elapsed, median, expected) => {
expect(elapsedBucket(elapsed, median)).toBe(expected);
});
});
describe('suggestClients', () => {
it('pins the most recently used client to the top', () => {
// Martin & Fils is used least, but most recently.
const h = history([
['2026-09-01', 'Dupont SA', null],
['2026-09-02', 'Dupont SA', null],
['2026-09-03', 'Dupont SA', null],
['2026-09-04', 'Martin & Fils', null],
]);
const m = buildModel(h.entries, h.days);
expect(suggestClients(m, NOW)[0].value).toBe('Martin & Fils');
});
it('ranks the rest by frequency decayed with recency', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
expect(suggestClients(m, NOW).map((c) => c.value)).toEqual([
'Dupont SA',
'Martin & Fils',
]);
});
});
describe('suggestRestaurants', () => {
it('defaults to the restaurant most used with this client', () => {
const m = buildModel(FIXTURE.entries, FIXTURE.days);
expect(suggestRestaurants(m, 'Dupont SA', NOW)[0].value).toBe('Le Relais');
expect(suggestRestaurants(m, 'Martin & Fils', NOW)[0].value).toBe('Chez Marcel');
});
it('ranks any client-paired restaurant above a merely frequent one', () => {
// Le Relais is globally more common, but Chez Marcel is Martin & Fils' place.
const ranked = suggestRestaurants(FIXTURE_MODEL(), 'Martin & Fils', NOW);
expect(ranked[0].value).toBe('Chez Marcel');
expect(ranked[0].hint).toBe('1 × avec Martin & Fils');
});
it('falls back to global frequency for an unknown client', () => {
expect(suggestRestaurants(FIXTURE_MODEL(), 'Client inconnu', NOW)[0].value).toBe(
'Le Relais',
);
});
});
function FIXTURE_MODEL() {
return buildModel(FIXTURE.entries, FIXTURE.days);
}

105
src/lib/resume.js Normal file
View File

@@ -0,0 +1,105 @@
/**
* Builds the last-day resume — the app's primary output, meant to be copied
* straight into a paper logbook.
*
* Durations are computed from *rounded* boundaries so the printed times and the
* printed totals always agree. Because adjacent entries share one stored
* timestamp, rounding can never open a gap between them.
*/
import { fmtDayLabel, fmtDuration, roundTs } from './time.js';
/**
* @param {object} day
* @param {Array} entries
* @param {number} roundingMinutes
*/
export function buildResume(day, entries, roundingMinutes = 15) {
if (!day) return null;
const fallbackEnd = day.endedTs ?? Date.now();
/** @type {Map<string, {name: string, totalMs: number, tasks: {name: string, ms: number}[]}>} */
const byClient = new Map();
/** @type {{type: string, restaurant: string|null, ms: number}|null} */
let lunch = null;
let workedMs = 0;
let omitted = 0;
for (const e of [...entries].sort((a, b) => a.seq - b.seq)) {
const start = roundTs(e.startTs, roundingMinutes);
const end = roundTs(e.endTs ?? fallbackEnd, roundingMinutes);
const ms = Math.max(0, end - start);
if (ms === 0) {
// Real but shorter than one rounding step. Kept in the data, left out of the
// logbook, and reported so the worker knows something was dropped.
omitted++;
continue;
}
if (e.kind === 'lunch') {
lunch = {
type: day.lunchType ?? 'panier',
restaurant: day.restaurant ?? null,
ms: (lunch?.ms ?? 0) + ms,
};
continue;
}
workedMs += ms;
const client = e.client || 'Sans client';
if (!byClient.has(client)) byClient.set(client, { name: client, totalMs: 0, tasks: [] });
const bucket = byClient.get(client);
bucket.totalMs += ms;
// Same task twice in a day (before and after lunch) reads as one line.
const existing = bucket.tasks.find((t) => t.name === e.task);
if (existing) existing.ms += ms;
else bucket.tasks.push({ name: e.task, ms });
}
const clients = [...byClient.values()].sort((a, b) => b.totalMs - a.totalMs);
for (const c of clients) c.tasks.sort((a, b) => b.ms - a.ms);
return {
dateKey: day.date,
label: fmtDayLabel(day.date),
clients,
lunch,
workedMs,
omitted,
};
}
/** The exact string the copy button puts on the clipboard. */
export function resumeToText(resume) {
if (!resume) return '';
const lines = [resume.label, ''];
// Pad task names to a common width so the durations line up in the logbook.
const width = Math.max(
0,
...resume.clients.flatMap((c) => c.tasks.map((t) => t.name.length)),
);
for (const c of resume.clients) {
lines.push(`${c.name} — ${fmtDuration(c.totalMs)}`);
for (const t of c.tasks) {
lines.push(` ${t.name.padEnd(width)} ${fmtDuration(t.ms)}`);
}
lines.push('');
}
if (resume.lunch) {
const where =
resume.lunch.type === 'restaurant'
? `restaurant (${resume.lunch.restaurant || 'non précisé'})`
: 'panier';
lines.push(`Déjeuner : ${where}, ${fmtDuration(resume.lunch.ms)}`);
lines.push('');
}
lines.push(`Total travaillé : ${fmtDuration(resume.workedMs)}`);
return lines.join('\n').replace(/\n{3,}/g, '\n\n').trim();
}

107
src/lib/resume.test.js Normal file
View File

@@ -0,0 +1,107 @@
import { describe, expect, it } from 'vitest';
import { buildResume, resumeToText } from './resume.js';
import { atLocalTime, MINUTE } from './time.js';
const DATE = '2026-09-04';
const at = (hhmm) => atLocalTime(DATE, hhmm);
const DAY = {
date: DATE,
status: 'closed',
lunchType: 'restaurant',
restaurant: 'Le Relais',
endedTs: at('17:30'),
};
const ENTRIES = [
{ id: 1, date: DATE, seq: 0, kind: 'travel', task: 'Chargement / transport', client: 'Dupont SA', startTs: at('07:30'), endTs: at('08:15') },
{ id: 2, date: DATE, seq: 1, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('08:15'), endTs: at('12:00') },
{ id: 3, date: DATE, seq: 2, kind: 'lunch', task: 'Pause déjeuner', client: null, startTs: at('12:00'), endTs: at('13:00') },
{ id: 4, date: DATE, seq: 3, kind: 'work', task: 'Terrassement', client: 'Dupont SA', startTs: at('13:00'), endTs: at('16:45') },
{ id: 5, date: DATE, seq: 4, kind: 'work', task: 'Retour / déchargement', client: 'Dupont SA', startTs: at('16:45'), endTs: at('17:30') },
];
describe('buildResume', () => {
it('groups by client and sums repeated tasks', () => {
const r = buildResume(DAY, ENTRIES, 15);
expect(r.clients).toHaveLength(1);
const [dupont] = r.clients;
expect(dupont.name).toBe('Dupont SA');
// Terrassement appears twice, before and after lunch, and reads as one line.
expect(dupont.tasks.map((t) => t.name)).toEqual([
'Terrassement',
'Chargement / transport',
'Retour / déchargement',
]);
expect(dupont.tasks[0].ms).toBe(450 * MINUTE); // 3h45 + 3h45
});
it('keeps lunch out of the worked total', () => {
const r = buildResume(DAY, ENTRIES, 15);
expect(r.workedMs).toBe(9 * 60 * MINUTE); // 09h00 on site, lunch excluded
expect(r.lunch).toEqual({ type: 'restaurant', restaurant: 'Le Relais', ms: 60 * MINUTE });
});
it('splits totals across several clients', () => {
const mixed = [
ENTRIES[0],
{ ...ENTRIES[1], client: 'Martin & Fils' },
...ENTRIES.slice(2),
];
const r = buildResume(DAY, mixed, 15);
expect(r.clients.map((c) => c.name)).toEqual(['Dupont SA', 'Martin & Fils']);
expect(r.workedMs).toBe(9 * 60 * MINUTE);
});
it('omits entries shorter than one rounding step, and says so', () => {
const withBlip = [
...ENTRIES,
{ id: 6, date: DATE, seq: 5, kind: 'work', task: 'Mauvais appui', client: 'Dupont SA', startTs: at('17:30'), endTs: at('17:32') },
];
const r = buildResume(DAY, withBlip, 15);
expect(r.omitted).toBe(1);
expect(r.clients[0].tasks.map((t) => t.name)).not.toContain('Mauvais appui');
});
it('keeps a 2-minute entry when rounding is set to 1 minute', () => {
const withBlip = [
...ENTRIES,
{ id: 6, date: DATE, seq: 5, kind: 'work', task: 'Mauvais appui', client: 'Dupont SA', startTs: at('17:30'), endTs: at('17:32') },
];
const r = buildResume(DAY, withBlip, 1);
expect(r.omitted).toBe(0);
expect(r.clients[0].tasks.map((t) => t.name)).toContain('Mauvais appui');
});
it('returns null without a day', () => {
expect(buildResume(null, [], 15)).toBeNull();
});
});
describe('resumeToText', () => {
it('renders the approved logbook format', () => {
expect(resumeToText(buildResume(DAY, ENTRIES, 15))).toBe(
[
'Vendredi 4 septembre',
'',
'Dupont SA — 9h00',
' Terrassement 7h30',
' Chargement / transport 0h45',
' Retour / déchargement 0h45',
'',
'Déjeuner : restaurant (Le Relais), 1h00',
'',
'Total travaillé : 9h00',
].join('\n'),
);
});
it('names the panier case without a restaurant', () => {
const r = buildResume({ ...DAY, lunchType: 'panier', restaurant: null }, ENTRIES, 15);
expect(resumeToText(r)).toContain('Déjeuner : panier, 1h00');
});
it('is empty for a null resume', () => {
expect(resumeToText(null)).toBe('');
});
});

29
src/lib/router.svelte.js Normal file
View File

@@ -0,0 +1,29 @@
/**
* Three screens, so no router dependency — just enough hash handling that the
* Android back button behaves inside an installed PWA.
*
* `route === null` means "wherever the day state says", which is the default the
* worker should land on.
*/
const ROUTES = ['resume', 'today', 'settings'];
function fromHash() {
const h = location.hash.replace(/^#\/?/, '');
return ROUTES.includes(h) ? h : null;
}
export const router = $state({ route: fromHash() });
window.addEventListener('hashchange', () => {
router.route = fromHash();
});
export function go(route) {
location.hash = route ? `#/${route}` : '';
}
export function back() {
if (history.length > 1) history.back();
else go(null);
}

99
src/lib/seed.js Normal file
View File

@@ -0,0 +1,99 @@
/**
* DEV-only demo history, so the prediction engine has something to chew on.
*
* Not run automatically — it would clobber whatever you were testing. Open the
* console and call `timelog.seed()`.
*/
import { db } from './db.js';
import { addDays, atLocalTime, localDateKey, MINUTE, weekday } from './time.js';
const CLIENTS = ['Dupont SA', 'Martin & Fils', 'Commune de Vaugines'];
/** Each client eats at a consistent place — the pairing the resto prediction learns. */
const RESTAURANT_BY_CLIENT = {
'Dupont SA': 'Le Relais',
'Martin & Fils': 'Chez Marcel',
'Commune de Vaugines': 'La Bonne Fourchette',
};
const SHAPE = [
{ kind: 'travel', task: 'Chargement / transport', minutes: 45 },
{ kind: 'work', task: 'Terrassement', minutes: 195 },
{ kind: 'lunch', task: 'Pause déjeuner', minutes: 60 },
{ kind: 'work', task: 'Pose de câble', minutes: 210 },
{ kind: 'work', task: 'Retour / déchargement', minutes: 50 },
];
function jitter(minutes) {
return Math.round(minutes + (Math.random() * 2 - 1) * minutes * 0.15);
}
async function seed(dayCount = 20) {
await db.transaction('rw', db.days, db.entries, async () => {
await db.days.clear();
await db.entries.clear();
let cursor = localDateKey();
let produced = 0;
while (produced < dayCount) {
cursor = addDays(cursor, -1);
const dow = weekday(cursor);
if (dow === 0 || dow === 6) continue;
const client = CLIENTS[produced % CLIENTS.length];
// Three quarters of days are restaurant days, so both branches have history.
// The period must stay coprime with CLIENTS.length, or lunch type correlates
// with the client and no client ever accumulates a restaurant.
const lunchType = produced % 4 === 0 ? 'panier' : 'restaurant';
let ts = atLocalTime(cursor, '07:30');
const plannedEndTs = atLocalTime(cursor, '17:30');
const entries = [];
SHAPE.forEach((step, seq) => {
const end = ts + jitter(step.minutes) * MINUTE;
entries.push({
date: cursor,
seq,
kind: step.kind,
task: step.task,
client: step.kind === 'lunch' ? null : client,
startTs: ts,
endTs: end,
});
ts = end;
});
await db.days.put({
date: cursor,
status: 'closed',
plannedStartTs: atLocalTime(cursor, '07:30'),
plannedEndTs,
startedTs: entries[0].startTs,
endedTs: ts,
lunchType,
restaurant: lunchType === 'restaurant' ? RESTAURANT_BY_CLIENT[client] : null,
closePromptedTs: null,
});
await db.entries.bulkAdd(entries);
produced++;
}
});
location.reload();
}
async function wipe() {
await db.transaction('rw', db.days, db.entries, async () => {
await db.days.clear();
await db.entries.clear();
});
location.reload();
}
export function installDevSeed() {
window.timelog = { seed, wipe, db };
console.info('[timelog] dev helpers: timelog.seed(), timelog.wipe()');
}

View File

@@ -0,0 +1,74 @@
import { readSettings, writeSettings } from './db.js';
export const DEFAULT_SETTINGS = {
/** JS weekday numbers, 0 = Sunday. Monday–Friday by default. */
workdays: [1, 2, 3, 4, 5],
dayStart: '07:30',
dayEnd: '17:30',
lunchStart: '12:00',
lunchMinutes: 60,
/** 'panier' = worker-provided, 'restaurant' = eaten out. */
lunchType: 'panier',
/** 1 | 5 | 15 | 30 */
roundingMinutes: 15,
/** 'jour' | 'nuit' — deliberately manual: dark mode is worse in direct sun. */
theme: 'jour',
/**
* Generous by default: old days are what the predictions are learned from.
* Two years, kept a multiple of the settings input's 30-day step so the field
* does not render as invalid.
*/
retentionDays: 720,
};
export const ROUNDING_CHOICES = [1, 5, 15, 30];
export const WEEKDAY_LABELS = [
{ value: 1, label: 'Lun' },
{ value: 2, label: 'Mar' },
{ value: 3, label: 'Mer' },
{ value: 4, label: 'Jeu' },
{ value: 5, label: 'Ven' },
{ value: 6, label: 'Sam' },
{ value: 0, label: 'Dim' },
];
export const settings = $state({ ...DEFAULT_SETTINGS, loaded: false });
/**
* Set when a write fails, so a broken save is visible on the settings screen
* instead of vanishing. Every call site of `updateSettings` is a click handler
* that ignores the returned promise, so a rejection here would otherwise be
* completely silent — which is exactly how the settings stopped persisting.
*/
export const settingsError = $state({ message: null });
export async function loadSettings() {
const stored = await readSettings();
Object.assign(settings, DEFAULT_SETTINGS, stored ?? {}, { loaded: true });
applyTheme();
}
export async function updateSettings(patch) {
Object.assign(settings, patch);
applyTheme();
// `settings` is a $state proxy and its nested values (workdays) are proxies
// too. IndexedDB clones structurally and refuses proxies with a
// DataCloneError, so the plain snapshot is required, not optional.
const { loaded, ...persisted } = $state.snapshot(settings);
try {
await writeSettings(persisted);
settingsError.message = null;
} catch (error) {
settingsError.message = `Enregistrement impossible : ${error.message}`;
console.error('[timelog] failed to persist settings', error);
}
}
export function applyTheme() {
document.documentElement.dataset.theme = settings.theme;
}
export function isWorkday(weekdayNumber) {
return settings.workdays.includes(weekdayNumber);
}

80
src/lib/time.js Normal file
View File

@@ -0,0 +1,80 @@
/**
* Time helpers. Everything here works in *local* time — the prototype derived day
* keys from `toISOString()`, which is UTC and files late/early work under the wrong
* day. Timestamps are epoch milliseconds; rounding is applied at render only.
*/
export const MINUTE = 60_000;
export const HOUR = 3_600_000;
export const DAY = 86_400_000;
const pad = (n) => String(n).padStart(2, '0');
/** Local 'YYYY-MM-DD' for a timestamp. */
export function localDateKey(ts = Date.now()) {
const d = new Date(ts);
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
}
/** Timestamp of local midnight starting `dateKey`. */
export function startOfDay(dateKey) {
const [y, m, d] = dateKey.split('-').map(Number);
return new Date(y, m - 1, d).getTime();
}
/** Timestamp of 'HH:MM' local time on `dateKey`. */
export function atLocalTime(dateKey, hhmm) {
const [y, m, d] = dateKey.split('-').map(Number);
const [h, min] = hhmm.split(':').map(Number);
return new Date(y, m - 1, d, h, min, 0, 0).getTime();
}
/** `dateKey` shifted by `n` days, DST-safe. */
export function addDays(dateKey, n) {
const [y, m, d] = dateKey.split('-').map(Number);
return localDateKey(new Date(y, m - 1, d + n).getTime());
}
/** JS weekday for a date key: 0 = Sunday … 6 = Saturday. */
export function weekday(dateKey) {
return new Date(startOfDay(dateKey)).getDay();
}
/**
* Round to the nearest `minutes`, half up, relative to *local midnight* rather
* than the epoch — so zones with a :30 or :45 offset still land on whole
* wall-clock steps. Always drops seconds.
*/
export function roundTs(ts, minutes) {
const step = Math.max(1, minutes || 1) * MINUTE;
const midnight = startOfDay(localDateKey(ts));
return midnight + Math.round((ts - midnight) / step) * step;
}
/** 'HH:MM' in local time, rounded to `roundMinutes`. */
export function fmtTime(ts, roundMinutes = 1) {
const d = new Date(roundTs(ts, roundMinutes));
return `${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** A duration as '8h15' / '0h45'. */
export function fmtDuration(ms) {
const total = Math.max(0, Math.round(ms / MINUTE));
return `${Math.floor(total / 60)}h${pad(total % 60)}`;
}
/** 'vendredi 5 septembre' — the resume heading. */
export function fmtDayLabel(dateKey) {
const s = new Intl.DateTimeFormat('fr-FR', {
weekday: 'long',
day: 'numeric',
month: 'long',
}).format(new Date(startOfDay(dateKey)));
return s.charAt(0).toUpperCase() + s.slice(1);
}
/** 'HH:MM' → minutes since midnight. */
export function hhmmToMinutes(hhmm) {
const [h, m] = hhmm.split(':').map(Number);
return h * 60 + m;
}

102
src/lib/time.test.js Normal file
View File

@@ -0,0 +1,102 @@
import { describe, expect, it } from 'vitest';
import {
addDays,
atLocalTime,
fmtDuration,
fmtTime,
HOUR,
localDateKey,
MINUTE,
roundTs,
weekday,
} from './time.js';
describe('localDateKey', () => {
it('uses local components, not UTC', () => {
// The prototype bug: 00:30 local in a positive-offset zone serialises to the
// previous day under toISOString().
const ts = new Date(2026, 8, 7, 0, 30).getTime();
expect(localDateKey(ts)).toBe('2026-09-07');
});
it('holds at both ends of the day', () => {
expect(localDateKey(new Date(2026, 8, 6, 0, 0, 0).getTime())).toBe('2026-09-06');
expect(localDateKey(new Date(2026, 8, 6, 23, 59, 59).getTime())).toBe('2026-09-06');
});
});
describe('roundTs', () => {
const at = (h, m, s = 0) => new Date(2026, 8, 6, h, m, s).getTime();
it('drops seconds even at 1-minute granularity', () => {
expect(fmtTime(at(8, 14, 59), 1)).toBe('08:15');
expect(fmtTime(at(8, 14, 29), 1)).toBe('08:14');
});
it.each([
[1, at(8, 7, 0), '08:07'],
[5, at(8, 7, 0), '08:05'],
[15, at(8, 7, 0), '08:00'],
[30, at(8, 7, 0), '08:00'],
[15, at(8, 38, 0), '08:45'],
[30, at(8, 38, 0), '08:30'],
])('rounds to the nearest %i min', (step, ts, expected) => {
expect(fmtTime(ts, step)).toBe(expected);
});
it('rounds halves up', () => {
expect(fmtTime(at(8, 7, 30), 15)).toBe('08:15');
expect(fmtTime(at(8, 15, 0), 30)).toBe('08:30');
});
it('never leaves a gap between adjacent entries', () => {
// Adjacent entries share one stored timestamp, so whatever it rounds to, the
// rendered end of one is the rendered start of the next.
const boundary = at(12, 7, 33);
for (const step of [1, 5, 15, 30]) {
expect(roundTs(boundary, step)).toBe(roundTs(boundary, step));
expect(fmtTime(boundary, step)).toBe(fmtTime(boundary, step));
}
});
it('lands on wall-clock steps regardless of zone offset', () => {
const rounded = roundTs(at(9, 22), 15);
const d = new Date(rounded);
expect(d.getMinutes() % 15).toBe(0);
expect(d.getSeconds()).toBe(0);
});
});
describe('fmtDuration', () => {
it.each([
[0, '0h00'],
[45 * MINUTE, '0h45'],
[8 * HOUR + 15 * MINUTE, '8h15'],
[HOUR, '1h00'],
])('formats %i as %s', (ms, expected) => {
expect(fmtDuration(ms)).toBe(expected);
});
it('clamps negatives rather than printing nonsense', () => {
expect(fmtDuration(-5000)).toBe('0h00');
});
});
describe('date arithmetic', () => {
it('adds days across a month boundary', () => {
expect(addDays('2026-08-31', 1)).toBe('2026-09-01');
expect(addDays('2026-09-01', -1)).toBe('2026-08-31');
});
it('reads weekdays in local time', () => {
expect(weekday('2026-09-06')).toBe(0); // Sunday
expect(weekday('2026-09-07')).toBe(1); // Monday
});
it('builds local timestamps from HH:MM', () => {
const ts = atLocalTime('2026-09-07', '07:30');
const d = new Date(ts);
expect([d.getHours(), d.getMinutes()]).toEqual([7, 30]);
expect(localDateKey(ts)).toBe('2026-09-07');
});
});

38
src/lib/updates.svelte.js Normal file
View File

@@ -0,0 +1,38 @@
/**
* Service worker update policy.
*
* The app checks for a new build hourly, but never applies one on its own: the
* waiting worker is only activated when the worker taps "Installer", and that
* banner is only rendered while no day is running (see `App.svelte`). A reload
* mid-day would interrupt input, which is exactly what "non-breaking updates"
* has to rule out.
*/
import { registerSW } from 'virtual:pwa-register';
const CHECK_INTERVAL = 60 * 60 * 1000;
export const needsRefresh = $state({ value: false });
/** @type {(reloadPage?: boolean) => Promise<void>} */
let update = async () => {};
if (typeof window !== 'undefined') {
update = registerSW({
immediate: true,
onNeedRefresh() {
needsRefresh.value = true;
},
onRegisteredSW(_url, registration) {
if (!registration) return;
setInterval(() => {
if (navigator.onLine) registration.update().catch(() => {});
}, CHECK_INTERVAL);
},
});
}
export async function applyUpdate() {
needsRefresh.value = false;
await update(true);
}

148
src/routes/Resume.svelte Normal file
View File

@@ -0,0 +1,148 @@
<script>
import { dayState, reopenToday } from '../lib/day.svelte.js';
import { buildResume, resumeToText } from '../lib/resume.js';
import { go } from '../lib/router.svelte.js';
import { settings } from '../lib/settings.svelte.js';
import { fmtDuration, weekday } from '../lib/time.js';
let copied = $state(false);
const resume = $derived(
buildResume(
dayState.lastClosed,
dayState.lastClosedEntries,
settings.roundingMinutes,
),
);
const text = $derived(resumeToText(resume));
/** Shown when today is a work day whose start time has not arrived yet. */
const startsAt = $derived(
settings.workdays.includes(weekday(dayState.today)) ? settings.dayStart : null,
);
const closedToday = $derived(dayState.lastClosed?.date === dayState.today);
async function copy() {
try {
await navigator.clipboard.writeText(text);
} catch {
// Clipboard API needs a secure context; fall back to a selectable dialog.
window.prompt('Copier le récapitulatif :', text);
}
copied = true;
setTimeout(() => (copied = false), 2500);
}
</script>
<div class="screen">
{#if !resume}
<div class="card center stack">
<h2>Aucune journée enregistrée</h2>
<p class="dim">
Le récapitulatif de la dernière journée s'affichera ici, prêt à recopier.
</p>
</div>
{:else}
<div class="row-between">
<h1>{resume.label}</h1>
</div>
<div class="card stack">
{#each resume.clients as client (client.name)}
<div class="client">
<div class="row-between head">
<h2>{client.name}</h2>
<span class="tnum">{fmtDuration(client.totalMs)}</span>
</div>
<ul>
{#each client.tasks as task (task.name)}
<li class="row-between">
<span>{task.name}</span>
<span class="tnum">{fmtDuration(task.ms)}</span>
</li>
{/each}
</ul>
</div>
{/each}
{#if resume.lunch}
<hr />
<div class="row-between">
<span
>Déjeuner : {resume.lunch.type === 'restaurant'
? `restaurant (${resume.lunch.restaurant || 'non précisé'})`
: 'panier'}</span
>
<span class="tnum">{fmtDuration(resume.lunch.ms)}</span>
</div>
{/if}
<hr />
<div class="row-between total">
<strong>Total travaillé</strong>
<strong class="tnum">{fmtDuration(resume.workedMs)}</strong>
</div>
{#if resume.omitted}
<p class="dim small">
{resume.omitted} poste{resume.omitted > 1 ? 's' : ''} de moins de {settings.roundingMinutes}
min non affiché{resume.omitted > 1 ? 's' : ''}.
</p>
{/if}
</div>
<button type="button" class="btn-primary" onclick={copy}>
{copied ? '✓ Copié' : 'Copier le récapitulatif'}
</button>
{/if}
{#if closedToday}
<button
type="button"
onclick={async () => {
await reopenToday();
go('today');
}}>Reprendre la journée</button
>
{:else if startsAt}
<p class="dim center small">
La journée démarrera automatiquement à {startsAt}.
</p>
{:else}
<p class="dim center small">Aujourd'hui n'est pas un jour travaillé.</p>
{/if}
</div>
<style>
.client + .client {
border-top: 2px solid var(--line);
padding-top: var(--gap);
}
.head {
margin-bottom: 6px;
}
ul {
list-style: none;
margin: 0;
padding: 0 0 0 var(--pad);
display: flex;
flex-direction: column;
gap: 4px;
}
li {
font-size: 0.95rem;
}
.total {
font-size: 1.15rem;
}
.small {
font-size: 0.85rem;
}
</style>

210
src/routes/Settings.svelte Normal file
View File

@@ -0,0 +1,210 @@
<script>
import { cleanupOldDays } from '../lib/db.js';
import { refresh } from '../lib/day.svelte.js';
import {
ROUNDING_CHOICES,
settings,
settingsError,
updateSettings,
WEEKDAY_LABELS,
} from '../lib/settings.svelte.js';
let purged = $state(null);
function toggleWorkday(value) {
const next = settings.workdays.includes(value)
? settings.workdays.filter((d) => d !== value)
: [...settings.workdays, value];
updateSettings({ workdays: next.sort() });
}
async function purge() {
const n = await cleanupOldDays(settings.retentionDays);
await refresh();
purged = n;
}
</script>
<div class="screen">
<h1>Réglages</h1>
{#if settingsError.message}
<p class="error card">{settingsError.message}</p>
{/if}
<section class="card stack">
<h2>Jours travaillés</h2>
<div class="days">
{#each WEEKDAY_LABELS as day (day.value)}
<button
type="button"
class:sel={settings.workdays.includes(day.value)}
aria-pressed={settings.workdays.includes(day.value)}
onclick={() => toggleWorkday(day.value)}
>
{day.label}
</button>
{/each}
</div>
</section>
<section class="card stack">
<h2>Horaires</h2>
<label>
<span>Début de journée</span>
<input
type="time"
value={settings.dayStart}
onchange={(e) => updateSettings({ dayStart: e.currentTarget.value })}
/>
</label>
<label>
<span>Fin de journée</span>
<input
type="time"
value={settings.dayEnd}
onchange={(e) => updateSettings({ dayEnd: e.currentTarget.value })}
/>
</label>
<p class="dim small">
Les horaires enregistrés dans une journée déjà close ne changent pas.
</p>
</section>
<section class="card stack">
<h2>Pause déjeuner</h2>
<label>
<span>Heure habituelle</span>
<input
type="time"
value={settings.lunchStart}
onchange={(e) => updateSettings({ lunchStart: e.currentTarget.value })}
/>
</label>
<label>
<span>Durée (min)</span>
<input
type="number"
min="0"
step="5"
value={settings.lunchMinutes}
onchange={(e) => updateSettings({ lunchMinutes: Number(e.currentTarget.value) })}
/>
</label>
<div class="btn-row">
<button
type="button"
class:sel={settings.lunchType === 'panier'}
onclick={() => updateSettings({ lunchType: 'panier' })}>Panier</button
>
<button
type="button"
class:sel={settings.lunchType === 'restaurant'}
onclick={() => updateSettings({ lunchType: 'restaurant' })}>Restaurant</button
>
</div>
<p class="dim small">Type par défaut ; modifiable chaque jour.</p>
</section>
<section class="card stack">
<h2>Arrondi</h2>
<div class="btn-row">
{#each ROUNDING_CHOICES as minutes (minutes)}
<button
type="button"
class:sel={settings.roundingMinutes === minutes}
onclick={() => updateSettings({ roundingMinutes: minutes })}
>
{minutes} min
</button>
{/each}
</div>
<p class="dim small">
S'applique à l'affichage seulement : les heures réelles restent enregistrées, un
changement d'arrondi corrige aussi les journées passées.
</p>
</section>
<section class="card stack">
<h2>Affichage</h2>
<div class="btn-row">
<button
type="button"
class:sel={settings.theme === 'jour'}
onclick={() => updateSettings({ theme: 'jour' })}>Jour</button
>
<button
type="button"
class:sel={settings.theme === 'nuit'}
onclick={() => updateSettings({ theme: 'nuit' })}>Nuit</button
>
</div>
</section>
<section class="card stack">
<h2>Historique</h2>
<label>
<span>Conserver (jours)</span>
<input
type="number"
min="30"
step="30"
value={settings.retentionDays}
onchange={(e) => updateSettings({ retentionDays: Number(e.currentTarget.value) })}
/>
</label>
<p class="dim small">
L'historique alimente les suggestions : plus il est court, moins les propositions
sont justes. La dernière journée close est toujours conservée.
</p>
<p class="dim small">
La purge s'exécute aussi automatiquement à l'ouverture de l'application.
</p>
<button type="button" onclick={purge}>Purger maintenant</button>
{#if purged !== null}
<p class="dim small">
{purged === 0
? 'Aucune journée plus ancienne que la durée de conservation.'
: `${purged} journée${purged > 1 ? 's' : ''} supprimée${purged > 1 ? 's' : ''}.`}
</p>
{/if}
</section>
</div>
<style>
.days {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: var(--gap);
}
label {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
font-weight: 700;
}
label input {
width: 45%;
flex: none;
}
.sel {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
.small {
font-size: 0.85rem;
}
.error {
color: var(--danger-fg);
background: var(--danger);
border-color: var(--danger);
font-weight: 700;
}
</style>

293
src/routes/Today.svelte Normal file
View File

@@ -0,0 +1,293 @@
<script>
import EntrySheet from '../components/EntrySheet.svelte';
import SmartField from '../components/SmartField.svelte';
import TimeField from '../components/TimeField.svelte';
import Timeline from '../components/Timeline.svelte';
import {
advance,
currentClient,
dayState,
editEntry,
editEntryStart,
endDay,
keepWorking,
predictForCurrent,
predictNext,
removeEntry,
running,
setLunch,
startDayNow,
startLunch,
} from '../lib/day.svelte.js';
import { suggestClients, suggestRestaurants, suggestTasks } from '../lib/predict.js';
import { go } from '../lib/router.svelte.js';
import { settings } from '../lib/settings.svelte.js';
import { fmtDuration, fmtTime } from '../lib/time.js';
let now = $state(Date.now());
let confirmingEnd = $state(false);
/** id of the timeline entry being corrected, if any */
let editingId = $state(null);
$effect(() => {
const id = setInterval(() => (now = Date.now()), 30_000);
return () => clearInterval(id);
});
const current = $derived(running());
// Snapshotted against writes, not against the clock: the proposed next task must
// not shift under the worker's thumb while they reach for the button.
const next = $derived.by(() => predictNext(Date.now()));
const forCurrent = $derived.by(() => predictForCurrent(Date.now()));
const dayClient = $derived.by(() => {
dayState.entries;
return currentClient();
});
const restaurants = $derived(
dayState.model ? suggestRestaurants(dayState.model, dayClient ?? '') : [],
);
// Tracked by id rather than held as an object, so the sheet keeps showing live
// data after an edit writes back.
const editing = $derived(dayState.entries.find((e) => e.id === editingId) ?? null);
const editingPrev = $derived(
editing ? (dayState.entries.find((e) => e.seq === editing.seq - 1) ?? null) : null,
);
/** Suggestions ranked for the entry being corrected, not for the next slot. */
const editingSuggestions = $derived.by(() => {
if (!editing || !dayState.model) return { tasks: [], clients: [] };
return {
tasks: suggestTasks(dayState.model, {
prevTask: editingPrev?.task ?? null,
prevElapsedMs:
editingPrev?.endTs != null ? editingPrev.endTs - editingPrev.startTs : 0,
seq: editing.seq,
ts: editing.startTs,
client: editing.client,
}),
clients: suggestClients(dayState.model, now),
};
});
const actionLabel = $derived(
current?.kind === 'travel'
? 'Arrivé sur site'
: current?.kind === 'lunch'
? 'Reprendre le travail'
: 'Tâche suivante',
);
function goNext() {
advance({ kind: 'work', task: next.task, client: next.client });
}
/**
* Closing the day drops the explicit route so the shell's automatic routing
* takes over and lands on the resume — the screen the worker actually needs
* once the day is over.
*/
async function finishDay() {
await endDay();
confirmingEnd = false;
go(null);
}
function chooseLunchType(lunchType) {
setLunch({
lunchType,
restaurant:
lunchType === 'restaurant'
? (dayState.day?.restaurant ?? restaurants[0]?.value ?? null)
: null,
});
}
</script>
{#if !dayState.day}
<div class="screen">
<div class="card center stack">
<h2>Journée non commencée</h2>
<p class="dim">
Aujourd'hui n'est pas un jour travaillé, ou l'heure de début n'est pas atteinte.
</p>
<button type="button" class="btn-primary" onclick={() => startDayNow()}>
Démarrer la journée
</button>
</div>
</div>
{:else}
<div class="screen">
<!-- Current task ------------------------------------------------------ -->
{#if current}
<div class="card card-accent stack">
<div class="row-between">
<h2>En cours</h2>
<span class="tnum big">{fmtDuration(now - current.startTs)}</span>
</div>
<!-- Editable, including for the very first entry of the day: the
auto-captured start is only a best guess at when work began. -->
<TimeField
label="Début"
ts={current.startTs}
min={dayState.entries.find((e) => e.seq === current.seq - 1)?.startTs ??
Number.NEGATIVE_INFINITY}
max={now}
rounding={settings.roundingMinutes}
onchange={(ts) => editEntryStart(current.id, ts)}
/>
{#if current.kind === 'lunch'}
<div class="btn-row">
<button
type="button"
class:sel={dayState.day.lunchType !== 'restaurant'}
onclick={() => chooseLunchType('panier')}>Panier</button
>
<button
type="button"
class:sel={dayState.day.lunchType === 'restaurant'}
onclick={() => chooseLunchType('restaurant')}>Restaurant</button
>
</div>
{#if dayState.day.lunchType === 'restaurant'}
<SmartField
label="Restaurant"
value={dayState.day.restaurant}
suggestions={restaurants}
placeholder="Nom du restaurant"
emptyText="Aucun restaurant enregistré"
onchange={(v) => setLunch({ lunchType: 'restaurant', restaurant: v })}
/>
{/if}
{:else}
<SmartField
label="Tâche"
value={current.task}
suggestions={forCurrent.tasks}
placeholder="Nom de la tâche"
onchange={(v) => editEntry(current.id, { task: v })}
/>
<SmartField
label="Client"
value={current.client}
suggestions={forCurrent.clients}
placeholder="Nom du client"
emptyText="Aucun client enregistré"
onchange={(v) => editEntry(current.id, { client: v })}
/>
{/if}
</div>
{/if}
<!-- Primary actions --------------------------------------------------- -->
<button type="button" class="btn-primary" onclick={goNext}>{actionLabel}</button>
{#if current?.kind !== 'lunch'}
<button type="button" onclick={() => startLunch()}>Pause déjeuner</button>
{/if}
<!-- Day so far -------------------------------------------------------- -->
{#if dayState.entries.length > 1}
<div class="card">
<h3>Journée</h3>
<p class="dim hint">Touchez un poste pour corriger l'heure, la tâche ou le client.</p>
<Timeline
entries={dayState.entries}
rounding={settings.roundingMinutes}
{now}
onselect={(e) => (editingId = e.id)}
/>
</div>
{/if}
<!-- Separated from everything frequent, and confirmed. ----------------- -->
<div class="end">
{#if confirmingEnd}
<div class="btn-row">
<button type="button" onclick={() => (confirmingEnd = false)}>Annuler</button>
<button type="button" class="btn-danger" onclick={finishDay}>
Confirmer
</button>
</div>
{:else}
<button type="button" onclick={() => (confirmingEnd = true)}>
Terminer la journée
</button>
{/if}
</div>
</div>
<EntrySheet
entry={editing}
prev={editingPrev}
tasks={editingSuggestions.tasks}
clients={editingSuggestions.clients}
rounding={settings.roundingMinutes}
{now}
onstart={editEntryStart}
onedit={editEntry}
ondelete={removeEntry}
onclose={() => (editingId = null)}
/>
<!-- End-of-day grace prompt --------------------------------------------- -->
{#if dayState.needsClosePrompt}
<div class="prompt" role="dialog" aria-modal="true">
<div class="card stack">
<h2>Fin de journée</h2>
<p>
L'heure de fin ({settings.dayEnd}) est passée. Terminer la journée maintenant ?
</p>
<button type="button" class="btn-primary" onclick={finishDay}>
Terminer la journée
</button>
<button type="button" onclick={() => keepWorking()}>Toujours en cours</button>
</div>
</div>
{/if}
{/if}
<style>
.big {
font-size: 1.6rem;
font-weight: 800;
}
.hint {
font-size: 0.85rem;
margin-bottom: 4px;
}
.sel {
background: var(--accent);
color: var(--accent-fg);
border-color: var(--accent);
}
.end {
margin-top: auto;
padding-top: 32px;
}
.end > button {
width: 100%;
}
.prompt {
position: fixed;
inset: 0;
z-index: 50;
display: flex;
align-items: center;
padding: var(--pad);
background: rgba(0, 0, 0, 0.75);
}
.prompt > .card {
width: 100%;
max-width: 608px;
margin: 0 auto;
}
</style>

6
src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1,6 @@
/// <reference types="vite/client" />
/// <reference types="vite-plugin-pwa/client" />
interface Window {
timelog?: Record<string, unknown>;
}