First working prototype
This commit is contained in:
139
src/App.svelte
139
src/App.svelte
@@ -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>
|
||||
|
||||
454
src/app.css
454
src/app.css
@@ -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 |
@@ -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 |
118
src/components/EntrySheet.svelte
Normal file
118
src/components/EntrySheet.svelte
Normal 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>
|
||||
@@ -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}
|
||||
90
src/components/Sheet.svelte
Normal file
90
src/components/Sheet.svelte
Normal 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>
|
||||
149
src/components/SmartField.svelte
Normal file
149
src/components/SmartField.svelte
Normal 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>
|
||||
134
src/components/TimeField.svelte
Normal file
134
src/components/TimeField.svelte
Normal 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>
|
||||
@@ -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>
|
||||
124
src/components/Timeline.svelte
Normal file
124
src/components/Timeline.svelte
Normal 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>
|
||||
@@ -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
65
src/lib/boundary.test.js
Normal 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
306
src/lib/day.svelte.js
Normal 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;
|
||||
}
|
||||
251
src/lib/db.js
251
src/lib/db.js
@@ -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
319
src/lib/predict.js
Normal 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
218
src/lib/predict.test.js
Normal 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
105
src/lib/resume.js
Normal 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
107
src/lib/resume.test.js
Normal 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
29
src/lib/router.svelte.js
Normal 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
99
src/lib/seed.js
Normal 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()');
|
||||
}
|
||||
74
src/lib/settings.svelte.js
Normal file
74
src/lib/settings.svelte.js
Normal 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
80
src/lib/time.js
Normal 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
102
src/lib/time.test.js
Normal 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
38
src/lib/updates.svelte.js
Normal 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
148
src/routes/Resume.svelte
Normal 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
210
src/routes/Settings.svelte
Normal 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
293
src/routes/Today.svelte
Normal 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
6
src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
/// <reference types="vite/client" />
|
||||
/// <reference types="vite-plugin-pwa/client" />
|
||||
|
||||
interface Window {
|
||||
timelog?: Record<string, unknown>;
|
||||
}
|
||||
Reference in New Issue
Block a user