First working prototype

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

11
.dockerignore Normal file
View File

@@ -0,0 +1,11 @@
node_modules
dist
.git
.gitignore
.idea
.junie
.env
*.log
Dockerfile
docker-compose.yml
README.md

11
.env.example Normal file
View File

@@ -0,0 +1,11 @@
# Copy to .env and edit before `docker compose up -d`.
# The public domain this instance answers on. Caddy obtains and renews a
# Let's Encrypt certificate for it automatically.
#
# The domain must already resolve to this server, and ports 80 and 443 must be
# reachable from the internet — port 80 is what the ACME challenge uses.
SITE_ADDRESS=timelog.example.com
# Notified by Let's Encrypt before a certificate expires. Optional but advised.
ACME_EMAIL=you@example.com

3
.gitignore vendored
View File

@@ -10,6 +10,9 @@ lerna-debug.log*
node_modules node_modules
dist dist
dist-ssr dist-ssr
# Deployment config: copy .env.example instead.
.env
*.local *.local
# Editor directories and files # Editor directories and files

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
# ---- build ------------------------------------------------------------------
# Debian rather than Alpine on purpose. The PWA icon generator pulls in sharp,
# whose native dependencies differ between glibc and musl, so a lockfile
# committed from a glibc machine can never satisfy `npm ci` on Alpine. This
# stage is discarded, so its size does not reach the published image.
FROM node:24-slim AS build
WORKDIR /app
# Dependencies first, so a source-only change does not re-install them.
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- serve ------------------------------------------------------------------
# Caddy rather than nginx: the app needs a secure context for its service worker,
# and Caddy obtains and renews the certificate on its own.
FROM caddy:2-alpine
COPY docker/Caddyfile /etc/caddy/Caddyfile
COPY --from=build /app/dist /srv
EXPOSE 80 443
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget -qO- http://127.0.0.1:8080/healthz || exit 1

129
README.md
View File

@@ -1,43 +1,96 @@
# Svelte + Vite # TimeLog
This template should help get you started developing with Svelte in Vite. Offline-first PWA for tracking hours on site. The day starts and ends on its own, each
task begins where the previous one ended, and the app's main output is a concise
summary of the last working day, ready to copy into a paper logbook.
## Recommended IDE Setup Built for use outdoors: high contrast, large targets, usable with gloves on. All data
stays on the device (IndexedDB); there is no backend and nothing is uploaded.
[VS Code](https://code.visualstudio.com/) + [Svelte](https://marketplace.visualstudio.com/items?itemName=svelte.svelte-vscode). ## Development
## Need an official Svelte framework? ```sh
npm install
Check out [SvelteKit](https://github.com/sveltejs/kit#readme), which is also powered by Vite. Deploy anywhere with its serverless-first approach and adapt to various platforms, with out of the box support for TypeScript, SCSS, and Less, and easily-added support for mdsvex, GraphQL, PostCSS, Tailwind CSS, and more. npm run dev # http://localhost:5173
npm test # unit tests for the time, prediction, resume and boundary logic
## Technical considerations npm run check # svelte-check
npm run build # production build into dist/
**Why use this over SvelteKit?**
- It brings its own routing solution which might not be preferable for some users.
- It is first and foremost a framework that just happens to use Vite under the hood, not a Vite app.
This template contains as little as possible to get started with Vite + Svelte, while taking into account the developer experience with regards to HMR and intellisense. It demonstrates capabilities on par with the other `create-vite` templates and is a good starting point for beginners dipping their toes into a Vite + Svelte project.
Should you later need the extended capabilities and extensibility provided by SvelteKit, the template has been structured similarly to SvelteKit so that it is easy to migrate.
**Why include `.vscode/extensions.json`?**
Other templates indirectly recommend extensions via the README, but this file allows VS Code to prompt the user to install the recommended extension upon opening the project.
**Why enable `checkJs` in the JS template?**
It is likely that most cases of changing variable types in runtime are likely to be accidental, rather than deliberate. This provides advanced typechecking out of the box. Should you like to take advantage of the dynamically-typed nature of JavaScript, it is trivial to change the configuration.
**Why is HMR not preserving my local component state?**
HMR state preservation comes with a number of gotchas! It has been disabled by default in both `svelte-hmr` and `@sveltejs/vite-plugin-svelte` due to its often surprising behavior. You can read the details [here](https://github.com/sveltejs/svelte-hmr/tree/master/packages/svelte-hmr#preservation-of-local-state).
If you have state that's important to retain within a component, consider creating an external store which would not be replaced by HMR.
```js
// store.js
// An extremely simple external store
import { writable } from 'svelte/store'
export default writable(0)
``` ```
In dev the console exposes `timelog.seed()` to generate 20 days of realistic history
(useful for exercising the suggestion engine) and `timelog.wipe()` to clear it.
## Deployment
The stack is a single container: a multi-stage build compiles the app and Caddy serves
the static output, obtaining and renewing a Let's Encrypt certificate on its own.
```sh
cp .env.example .env
$EDITOR .env # set SITE_ADDRESS and ACME_EMAIL
docker compose up -d --build
```
Requirements:
- `SITE_ADDRESS` must already resolve to the server.
- Ports **80 and 443** must be reachable from the internet. Port 80 is not optional —
it is what the ACME challenge uses to prove domain ownership.
Certificates are stored in the `caddy_data` volume. Keep it: without it Caddy re-issues
on every recreate and will hit the Let's Encrypt rate limit after a handful of restarts.
To update, pull and rebuild:
```sh
git pull && docker compose up -d --build
```
Running clients do not reload on their own. Each app checks hourly for a new build and
offers an **"Installer"** banner — but only while no working day is in progress, so an
update can never interrupt someone mid-day.
### HTTPS is not optional
Browsers only register a service worker in a secure context. Served over plain `http://`
to an IP or hostname, the app still loads but has **no offline support, cannot be
installed, and never updates** — which removes the point of the design. `localhost` is
the only exception, which is why `npm run dev` works without certificates.
### Behind an existing reverse proxy
If something upstream already terminates TLS, set `SITE_ADDRESS=:80`, drop the `443`
port mappings, and let the proxy forward to port 80. `ACME_EMAIL` is then unused but
must still be non-empty — Caddy will not parse a blank value.
The app must be served from the root of its domain. `start_url` and the service worker
scope are both `/`, so hosting it under a subpath will not work without changing the
manifest and Vite's `base`.
## Architecture
Two decisions shape everything else.
**Raw timestamps are stored; rounding is applied only at render.** Changing the rounding
setting therefore corrects past days too. Adjacent entries share one stored instant —
entry *N*'s end *is* entry *N+1*'s start — so rounding can never open a gap between
them. Times a worker sets by hand are snapped to the grid on write, so a chosen value
reads back exactly as chosen.
**Suggestions are derived from history, not from counter tables.** There is nothing to
keep in sync; the model is rebuilt in memory from the entries. The tradeoff is that a
short retention window degrades predictions, which is why retention defaults to two
years.
```
src/lib/
time.js local dates, rounding, formatting (pure)
predict.js the suggestion engine (pure)
resume.js logbook text builder (pure)
db.js Dexie schema and the chain invariant
day.svelte.js day lifecycle state machine
```
The four pure modules hold the logic worth testing and carry the unit tests. `db.js`
concentrates every write that touches a boundary into `closeAndStart` and
`setEntryStart`, so the timeline cannot drift out of order from anywhere else.

31
docker-compose.yml Normal file
View File

@@ -0,0 +1,31 @@
services:
timelog:
build:
context: .
image: timelog:latest
container_name: timelog
restart: unless-stopped
environment:
# Your domain, e.g. timelog.example.com — Caddy fetches a certificate for it.
# Set to :80 instead if you terminate TLS on an upstream reverse proxy.
SITE_ADDRESS: ${SITE_ADDRESS:?set SITE_ADDRESS in .env}
# Used by Let's Encrypt for expiry warnings. Required: Caddy fails to parse
# its config with an empty value, so this is checked here rather than
# crash-looping the container.
ACME_EMAIL: ${ACME_EMAIL:?set ACME_EMAIL in .env}
ports:
- "80:80" # required: the ACME HTTP challenge and the HTTPS redirect
- "443:443"
- "443:443/udp" # HTTP/3
volumes:
# Certificates live here. Without this volume Caddy re-issues on every
# recreate and will hit the Let's Encrypt rate limit within a few restarts.
- caddy_data:/data
- caddy_config:/config
volumes:
caddy_data:
caddy_config:

46
docker/Caddyfile Normal file
View File

@@ -0,0 +1,46 @@
{
email {$ACME_EMAIL}
admin off
}
# Internal only, for the container healthcheck. Never published in compose.
:8080 {
respond /healthz "ok" 200
respond "not found" 404
}
{$SITE_ADDRESS} {
root * /srv
encode zstd gzip
# The service worker and the HTML shell must be revalidated on every request.
# If a browser is allowed to serve sw.js from its own cache, a new build is
# never discovered and the in-app update prompt can never fire — the app would
# silently freeze on whatever version it first installed.
@volatile path / /index.html /sw.js /manifest.webmanifest
header @volatile Cache-Control "no-cache, must-revalidate"
# Content-hashed filenames; the name changes whenever the bytes do.
@hashed path /assets/* /workbox-*.js
header @hashed Cache-Control "public, max-age=31536000, immutable"
# Icons keep stable names across builds, so cap them at a day rather than
# forever, otherwise a replaced icon never surfaces.
@icons path *.png *.svg *.ico
header @icons Cache-Control "public, max-age=86400"
# Some clients reject a manifest that is not served with this exact type.
@manifest path /manifest.webmanifest
header @manifest Content-Type "application/manifest+json"
header {
X-Content-Type-Options nosniff
X-Frame-Options DENY
Referrer-Policy strict-origin-when-cross-origin
-Server
}
# The app routes on the hash, so this only catches someone typing a stray path.
try_files {path} /index.html
file_server
}

View File

@@ -1,10 +1,14 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" /> <link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<title>app</title> <meta name="theme-color" content="#0047b3" />
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
<meta name="apple-mobile-web-app-title" content="TimeLog" />
<title>TimeLog — suivi des heures</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>

View File

@@ -3,31 +3,26 @@
"moduleResolution": "bundler", "moduleResolution": "bundler",
"target": "ESNext", "target": "ESNext",
"module": "ESNext", "module": "ESNext",
/**
* svelte-preprocess cannot figure out whether you have
* a value or a type, so tell TypeScript to enforce using
* `import type` instead of `import` for Types.
*/
"verbatimModuleSyntax": true, "verbatimModuleSyntax": true,
"isolatedModules": true, "isolatedModules": true,
"resolveJsonModule": true, "resolveJsonModule": true,
/**
* To have warnings / errors of the Svelte compiler at the
* correct position, enable source maps by default.
*/
"sourceMap": true, "sourceMap": true,
"esModuleInterop": true, "esModuleInterop": true,
"types": ["vite/client"], "types": ["vite/client"],
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true,
/** /**
* Typecheck JS in `.svelte` and `.js` files by default. * Typecheck JS in `.svelte` and `.js` files. The data layer carries JSDoc
* Disable this if you'd like to use dynamic types. * typedefs (see src/lib/db.js), so schema mistakes are caught here.
*
* `strict` stays off deliberately: the app code is plain untyped JS, and
* turning it on demands ~200 annotations on component props and callbacks
* for very little bug-catching return. Revisit if this grows.
*/ */
"checkJs": true "checkJs": true,
"strict": false
}, },
/** /** Tests use vitest globals and fixtures; they are covered by `npm test`. */
* Use global.d.ts instead of compilerOptions.types "exclude": ["src/**/*.test.js"],
* to avoid limiting type declarations.
*/
"include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"] "include": ["src/**/*.d.ts", "src/**/*.js", "src/**/*.svelte"]
} }

1847
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@@ -6,13 +6,19 @@
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vite build",
"preview": "vite preview" "preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest",
"check": "svelte-check --tsconfig ./jsconfig.json"
}, },
"devDependencies": { "devDependencies": {
"@sveltejs/vite-plugin-svelte": "^7.1.2", "@sveltejs/vite-plugin-svelte": "^7.1.2",
"@vite-pwa/assets-generator": "^1.0.2",
"svelte": "^5.56.4", "svelte": "^5.56.4",
"svelte-check": "^4.7.6",
"vite": "^8.1.1", "vite": "^8.1.1",
"vite-plugin-pwa": "^1.3.0" "vite-plugin-pwa": "^1.3.0",
"vitest": "^5.0.0"
}, },
"dependencies": { "dependencies": {
"dexie": "^4.4.4" "dexie": "^4.4.4"

13
public/icon.svg Normal file
View File

@@ -0,0 +1,13 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" width="512" height="512">
<rect width="512" height="512" rx="96" fill="#0047b3" />
<circle cx="256" cy="268" r="150" fill="none" stroke="#ffffff" stroke-width="34" />
<path
d="M256 176v96l66 46"
fill="none"
stroke="#ffffff"
stroke-width="34"
stroke-linecap="round"
stroke-linejoin="round"
/>
<rect x="196" y="70" width="120" height="34" rx="17" fill="#ffffff" />
</svg>

After

Width:  |  Height:  |  Size: 466 B

View File

@@ -1,24 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

Before

Width:  |  Height:  |  Size: 4.9 KiB

View File

@@ -1,10 +1,135 @@
<script> <script>
import TimeLogForm from './components/TimeLogForm.svelte'; import { dayState, reconcile } from './lib/day.svelte.js';
import LogList from './components/LogList.svelte'; 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> </script>
<main> {#if !ready}
<h1>Time Tracker</h1> <div class="boot"><p>Chargement…</p></div>
<TimeLogForm /> {:else}
<LogList /> {#if needsRefresh.value && !dayState.day}
</main> <!-- Only offered while no day is running, so an update can never interrupt
a day in progress. -->
<div class="update">
<span>Mise à jour disponible</span>
<button type="button" onclick={applyUpdate}>Installer</button>
</div>
{/if}
{#if view === 'settings'}
<Settings />
{:else if view === 'today'}
<Today />
{:else}
<Resume />
{/if}
<nav>
<button
type="button"
class:sel={view === 'resume'}
aria-current={view === 'resume'}
onclick={() => go('resume')}>Récap</button
>
<button
type="button"
class:sel={view === 'today'}
aria-current={view === 'today'}
onclick={() => go('today')}>Journée</button
>
<button
type="button"
class:sel={view === 'settings'}
aria-current={view === 'settings'}
onclick={() => go('settings')}>Réglages</button
>
</nav>
{/if}
<style>
.boot {
flex: 1;
display: grid;
place-items: center;
}
.update {
display: flex;
align-items: center;
justify-content: space-between;
gap: var(--gap);
padding: 10px var(--pad);
font-weight: 700;
background: var(--accent-soft);
border-bottom: var(--border) solid var(--line);
}
.update button {
min-height: 48px;
}
nav {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 30;
max-width: 640px;
margin: 0 auto;
display: grid;
grid-template-columns: repeat(3, 1fr);
background: var(--bg);
border-top: var(--border) solid var(--line);
padding-bottom: env(safe-area-inset-bottom);
}
nav button {
border: 0;
border-radius: 0;
min-height: 68px;
font-size: 0.95rem;
}
nav button + button {
border-left: var(--border) solid var(--line);
}
nav .sel {
background: var(--accent);
color: var(--accent-fg);
}
</style>

View File

@@ -1,296 +1,238 @@
/*
* Design tokens for outdoor use.
*
* Rules this file follows, deliberately:
* - Primary text is pure black on pure white (21:1). No grey body copy anywhere.
* - Theme is an explicit setting, never `prefers-color-scheme`: that follows the
* OS, and a dark UI is *harder* to read in direct sunlight, not easier.
* - Nothing tappable is under 64px; primary actions are 88px.
*/
:root { :root {
--text: #6b6375; --bg: #ffffff;
--text-h: #08060d; --fg: #000000;
--bg: #fff; --fg-dim: #333333; /* 12.6:1 — the darkest "secondary" we allow */
--border: #e5e4e7; --surface: #f0f0f0;
--code-bg: #f4f3ec; --line: #000000;
--accent: #aa3bff;
--accent-bg: rgba(170, 59, 255, 0.1); --accent: #0047b3; /* 8.2:1 against white, and against its own white text */
--accent-border: rgba(170, 59, 255, 0.5); --accent-fg: #ffffff;
--social-bg: rgba(244, 243, 236, 0.5); --accent-soft: #dce7fb;
--shadow:
rgba(0, 0, 0, 0.1) 0 10px 15px -3px, rgba(0, 0, 0, 0.05) 0 4px 6px -2px; --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; --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); font-family: var(--sans);
letter-spacing: 0.18px; font-size: 20px;
color-scheme: light dark; line-height: 1.35;
color: var(--text); color: var(--fg);
background: var(--bg); background: var(--bg);
font-synthesis: none; -webkit-text-size-adjust: 100%;
text-rendering: optimizeLegibility; -webkit-tap-highlight-color: transparent;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
@media (max-width: 1024px) {
font-size: 16px;
}
} }
@media (prefers-color-scheme: dark) { :root[data-theme='nuit'] {
:root { --bg: #000000;
--text: #9ca3af; --fg: #ffffff;
--text-h: #f3f4f6; --fg-dim: #cccccc;
--bg: #16171d; --surface: #1a1a1a;
--border: #2e303a; --line: #ffffff;
--code-bg: #1f2028;
--accent: #c084fc; --accent: #ffb000; /* amber reads well at night and preserves dark adaptation */
--accent-bg: rgba(192, 132, 252, 0.15); --accent-fg: #000000;
--accent-border: rgba(192, 132, 252, 0.5); --accent-soft: #3a2a00;
--social-bg: rgba(47, 48, 58, 0.5);
--shadow: --ok: #5ce08a;
rgba(0, 0, 0, 0.4) 0 10px 15px -3px, rgba(0, 0, 0, 0.25) 0 4px 6px -2px; --warn: #ffb000;
--danger: #ff5c72;
--danger-fg: #000000;
} }
#social .button-icon { * {
filter: invert(1) brightness(2); box-sizing: border-box;
} }
html,
body {
margin: 0;
min-height: 100%;
background: var(--bg);
color: var(--fg);
} }
body { 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, h1,
h2 { h2,
font-family: var(--heading); h3 {
font-weight: 500; margin: 0;
color: var(--text-h); font-weight: 800;
letter-spacing: -0.01em;
} }
h1 { h1 {
font-size: 56px; font-size: 1.6rem;
letter-spacing: -1.68px;
margin: 32px 0;
@media (max-width: 1024px) {
font-size: 36px;
margin: 20px 0;
}
} }
h2 { h2 {
font-size: 24px; font-size: 1.25rem;
line-height: 118%;
letter-spacing: -0.24px;
margin: 0 0 8px;
@media (max-width: 1024px) {
font-size: 20px;
} }
h3 {
font-size: 1.05rem;
} }
p { p {
margin: 0; margin: 0;
} }
code, /* Times and durations must never reflow as digits change. */
.counter { .tnum {
font-family: var(--mono); font-variant-numeric: tabular-nums;
font-feature-settings: 'tnum';
}
.dim {
color: var(--fg-dim);
}
.center {
text-align: center;
}
/* ------------------------------------------------------------------ controls */
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; display: inline-flex;
border-radius: 4px; align-items: center;
color: var(--text-h); justify-content: center;
gap: 10px;
} }
code { button:active {
font-size: 15px; transform: translateY(2px);
line-height: 135%;
padding: 4px 8px;
background: var(--code-bg);
} }
.counter { button:focus-visible {
font-size: 16px; outline: 4px solid var(--accent);
padding: 5px 10px; outline-offset: 3px;
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); button[disabled] {
opacity: 0.4;
cursor: default;
}
.btn-primary {
min-height: var(--tap-lg);
font-size: 1.2rem;
color: var(--accent-fg);
background: var(--accent);
border-color: var(--accent);
width: 100%;
}
.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; outline-offset: 2px;
} }
}
.hero { /* -------------------------------------------------------------------- layout */
position: relative;
.base, .screen {
.framework, flex: 1;
.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;
text-align: center;
border-inline: 1px solid var(--border);
min-height: 100svh;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
box-sizing: border-box; gap: var(--gap);
padding: var(--pad);
padding-bottom: 96px; /* clear of the fixed nav */
} }
#center { .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; display: flex;
flex-direction: column; flex-direction: column;
gap: 25px; gap: var(--gap);
place-content: center;
place-items: center;
flex-grow: 1;
@media (max-width: 1024px) {
padding: 32px 20px 24px;
gap: 18px;
}
} }
#next-steps { .row-between {
display: flex; display: flex;
border-top: 1px solid var(--border); align-items: baseline;
text-align: left; justify-content: space-between;
gap: var(--gap);
& > div {
flex: 1 1 0;
padding: 32px;
@media (max-width: 1024px) {
padding: 24px 20px;
}
} }
.icon { hr {
margin-bottom: 16px; border: 0;
width: 22px; border-top: var(--border) solid var(--line);
height: 22px; margin: 4px 0;
}
@media (max-width: 1024px) {
flex-direction: column;
text-align: center;
}
}
#docs {
border-right: 1px solid var(--border);
@media (max-width: 1024px) {
border-right: none;
border-bottom: 1px solid var(--border);
}
}
#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;
}
}
}
#spacer {
height: 88px;
border-top: 1px solid var(--border);
@media (max-width: 1024px) {
height: 48px;
}
}
.ticks {
position: relative;
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);
}
} }

Binary file not shown.

Before

Width:  |  Height:  |  Size: 13 KiB

View File

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

Before

Width:  |  Height:  |  Size: 1.9 KiB

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 8.5 KiB

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

View File

@@ -1,33 +1,53 @@
import { defineConfig } from 'vite' import { svelte } from '@sveltejs/vite-plugin-svelte';
import { svelte } from '@sveltejs/vite-plugin-svelte' import { defineConfig } from 'vite';
import { VitePWA } from 'vite-plugin-pwa' import { VitePWA } from 'vite-plugin-pwa';
// https://vite.dev/config/
export default defineConfig({ export default defineConfig({
server: { server: {
host: '0.0.0.0' host: '0.0.0.0',
}, },
plugins: [ plugins: [
svelte(), svelte(),
VitePWA({ VitePWA({
registerType: 'autoUpdate', // Never `autoUpdate`: a worker swap mid-day reloads the page under the
manifest: { // user's hands. The app decides when to apply an update (see
name: 'TimeLog PWA', // src/lib/updates.svelte.js) and only offers it while no day is running.
short_name: 'TimeLog', registerType: 'prompt',
theme_color: '#ffffff', pwaAssets: {
icons: [ image: 'public/icon.svg',
{
src: 'pwa-192x192.png',
sizes: '192x192',
type: 'image/png'
}, },
{ manifest: {
src: 'pwa-512x512.png', name: 'TimeLog — suivi des heures',
sizes: '512x512', short_name: 'TimeLog',
type: 'image/png' description:
} "Suivi des heures de chantier hors ligne, avec récapitulatif de la dernière journée.",
] lang: 'fr',
} dir: 'ltr',
}) start_url: '/',
scope: '/',
display: 'standalone',
orientation: 'portrait',
background_color: '#ffffff',
theme_color: '#0047b3',
categories: ['productivity', 'utilities'],
},
workbox: {
// The whole app is a handful of files; precache it all so a cold start
// offline is identical to a warm one.
//
// `webmanifest` is deliberately NOT in this list: the plugin injects its
// own manifest entry, and a glob that also matches it produces two
// precache entries for the same URL with different revisions. Workbox
// rejects conflicting entries, the install handler throws, and nothing
// gets cached at all — the app silently stops working offline.
globPatterns: ['**/*.{js,css,html,svg,png,ico}'],
cleanupOutdatedCaches: true,
clientsClaim: false,
},
}),
], ],
}) test: {
include: ['src/**/*.test.js'],
environment: 'node',
},
});