395 lines
21 KiB
Markdown
395 lines
21 KiB
Markdown
# Pardis
|
||
|
||
An offline-first progressive web app (PWA) for tracking on-site working hours. While
|
||
the app is open, it can start a workday automatically and prompt to close it. Each
|
||
task starts when the previous one ends. Closed days have concise summaries ready to
|
||
copy into a paper logbook.
|
||
|
||
Designed for outdoor use: high contrast, large touch targets, and usable with gloves.
|
||
Data is stored locally in IndexedDB. Optional end-to-end encrypted synchronization
|
||
shares it between authorized devices without requiring an email address or password.
|
||
|
||
French documentation: [README.fr.md](README.fr.md).
|
||
|
||
## Development
|
||
|
||
npm install
|
||
npm run dev # http://localhost:5173
|
||
npm test # unit and IndexedDB regression tests
|
||
npm run check # Svelte diagnostics
|
||
npm run build # production build into dist/
|
||
npm run validate # tests, Svelte diagnostics and full PWA build
|
||
npx playwright install chromium # install the browser for UI tests
|
||
npm run test:e2e # fresh profiles, synthetic fixtures and offline PWA test
|
||
npm run validate:all # include browser tests in full validation
|
||
|
||
## Exporting and importing data
|
||
|
||
In **Settings (Réglages) → Manage data and backups (Gérer les données et
|
||
sauvegardes)**, the **Data and backups (Données et sauvegardes)** screen groups
|
||
export, import, backup verification, and retention settings. The **Back up
|
||
(Sauvegarder)** reminder also opens this screen.
|
||
|
||
Data remains available in the browser on this device. Optional encrypted
|
||
synchronization can share it with your other devices. Clearing site data or losing
|
||
the device can result in lost history: keep a verified copy of the exported file on
|
||
another storage device.
|
||
|
||
**Export data (Exporter les données)** downloads a file named
|
||
pardis-YYYY-MM-DD.json. It contains all retained days, their entries, and saved
|
||
settings. Export works offline and does not modify the device's data. Internal entry
|
||
identifiers are regenerated on import.
|
||
|
||
To restore a backup, choose **Import file… (Importer un fichier…)**, then review the
|
||
file name, counts, and any reported adjustments. Two modes are available:
|
||
|
||
- **Merge (Fusionner)** adds only dates that are not already present. An existing
|
||
date is kept in full even if the file contains a different version of its entries.
|
||
Local settings are preserved. Importing the same file twice creates no duplicates.
|
||
- **Replace all (Remplacer tout)** asks for confirmation, then replaces every day,
|
||
entry, and setting, including a local day currently in progress. If the file has no
|
||
settings, defaults are restored.
|
||
|
||
Imported days are marked as closed. For a day exported while in progress, the last
|
||
entry is stopped at the export time, preserving elapsed time. Existing gaps between
|
||
work periods are preserved. An empty backup is valid and can restore an empty device.
|
||
|
||
The JSON format is identified by format: "timelog-export" and version: 2. Version 1
|
||
files remain importable. Older app versions reject version 2 to avoid turning breaks
|
||
into work. Foreign files, unsupported versions, duplicate dates, and invalid
|
||
settings are rejected. Unusable entries and time-range corrections are reported
|
||
before confirmation. The size limit is 20 MB. Writes are transactional: a failure
|
||
cancels the entire merge or replacement. The summary, theme, and suggestions then
|
||
refresh without reloading the page.
|
||
|
||
Under **Retention (Durée de conservation)**, **Keep (days) (Conserver (jours))**
|
||
determines which older days are offered for deletion. Changing this setting does not
|
||
delete anything; automatic deletion is disabled. Retained history feeds suggestions
|
||
and statistics. The latest closed day and active days are always kept.
|
||
|
||
**Delete now (Purger maintenant)** lists affected dates and asks for confirmation.
|
||
A local backup is kept before deletion. **Recover pre-deletion backup (Récupérer la
|
||
sauvegarde avant purge)** can merge or replace data, then export it after recovery.
|
||
This copy is replaced by the next deletion and is removed if site data is cleared; an
|
||
external file is still necessary.
|
||
|
||
Import/export tests cover the format, settings, duration fidelity, invalid files,
|
||
merge, replacement, and transaction rollback using a simulated IndexedDB.
|
||
src/lib/fixtures/backup.json is the supplied test fixture; it is not published with
|
||
the app.
|
||
|
||
## Workdays, resuming, and history
|
||
|
||
- If a workday was left open, it is closed when the app is next opened, at its planned
|
||
end time (including overtime), but never before the last recorded activity. This is
|
||
an estimate: the app does not measure work while it is closed. The final entry
|
||
keeps its duration up to that limit instead of being reset to zero.
|
||
- A day closed today offers **Resume workday (Reprendre la journée)** with
|
||
confirmation: continue the last entry, or create a break up to now (the default).
|
||
A final meal or break keeps its type; work resumes in a new entry. Past days can be
|
||
corrected via **View details → Edit this day (Voir les détails → Corriger cette
|
||
journée)**.
|
||
- Creating, closing, and resuming a day are transactional. Simultaneous actions on
|
||
the same entry cannot create duplicates; a closed day cannot receive a new active
|
||
entry. Deletion skips active days and always keeps the latest closed day.
|
||
- Lunch schedules show a reminder and an indicative return time. Transitions remain
|
||
manual unless **Lunch break (Pause déjeuner)** is enabled under automations.
|
||
- In **Summary (Récap)**, the **Day to view (Journée à consulter)** selector shows
|
||
and copies every retained closed day, including imported days.
|
||
|
||
Tests cover missed closures, resuming, concurrent calls, clock changes, transaction
|
||
failures, and data retention.
|
||
|
||
## Automations
|
||
|
||
In **Settings (Réglages) → Automations (Automatismes)**, each behavior has its own
|
||
switch:
|
||
|
||
| Option | Behavior |
|
||
| --- | --- |
|
||
| Opening | Start at the scheduled times and on scheduled days. Enabled by default, as before. |
|
||
| Start of day | When tapping “Arrived on site” (Arrivé sur site), suggest the last work task from the previous recorded day, excluding meals and travel. |
|
||
| Lunch break | Start a break at the usual time and resume the previous task after the configured duration. Reuse the meal type and restaurant from the previous recorded day. |
|
||
| Site arrival | During an automatic meal, turn an unspecified initial travel period into a travel entry of 1/5/15/30 minutes, according to the rounding setting, followed by the previous task. |
|
||
| Half-day end | At a break or day closure, replace an unspecified automatic period with the last known work task. Preserve any manual entry. |
|
||
| End of day | Close the day at the scheduled time without confirmation. |
|
||
|
||
The five newer options are disabled by default. They are included in backups and are
|
||
restored by replacement imports. **Site arrival** only applies with an automatic
|
||
meal. Without this travel estimate, **Half-day end** may replace unspecified initial
|
||
travel with work for the entire period. If a work task is already present, no
|
||
duplicate is added to the half-day.
|
||
|
||
“Previous” means the latest recorded closed day before the current date, including
|
||
after a weekend. Completion reuses the most recent known work task; when there is no
|
||
work history, it uses “On-site work” (Travaux sur site). Automatically created
|
||
entries are marked **Auto**. Editing one makes it manual and protects it from later
|
||
inference. Older entries without this marker are treated as manual.
|
||
|
||
A manually started or edited break is not ended by the lunch automation. A manually
|
||
chosen meal type or restaurant is preserved. A deleted break is not recreated.
|
||
Disabling an option stops future transitions; it does not remove entries already
|
||
saved. A zero lunch duration does not add an automatic break. The duration of an
|
||
automatic break already in progress is preserved if the setting changes later.
|
||
|
||
The app checks the clock every 30 seconds and when it returns to the foreground.
|
||
A closed or suspended PWA cannot run code at a scheduled time: on reopening, missed
|
||
steps are caught up at their planned times without overwriting a later manual action.
|
||
Already closed days remain unchanged, and entirely missing dates are not created.
|
||
For a day with no intervention, enable the desired options and open the app at least
|
||
once that day.
|
||
|
||
Overtime and resumed days are respected. A manual start leaves at least one hour
|
||
before an automatic closure. Automatic transitions are transactional: an error
|
||
cancels the whole operation, and simultaneous calls cannot create duplicates.
|
||
|
||
## Non-working days
|
||
|
||
In **Settings (Réglages) → Non-working days (Jours non travaillés)**, add dates to
|
||
block in advance. The list is sorted by date; each date can be edited or removed.
|
||
Duplicates are rejected. These exceptions take precedence over the usual weekdays
|
||
and prevent automatic opening, including catch-up when the app is reopened or used
|
||
offline.
|
||
|
||
No day or entry is created for a blocked date. Days already started or closed are
|
||
preserved. **Start anyway (Démarrer quand même)** in **Workday (Journée)** allows an
|
||
exceptional manual start. Removing a date restores the usual automatic start rules.
|
||
|
||
The list is saved with settings and included in data exports. A replacement import
|
||
restores the backup's list; a merge keeps the local list, like other settings. An
|
||
older backup without this list resets it to empty on replacement.
|
||
|
||
## Editing entries and closed days
|
||
|
||
The **Total hours present (Total des heures de présence)** excludes breaks and meals.
|
||
Meals remain separate in the summary and copied text. Totals use the selected
|
||
rounding setting.
|
||
|
||
In **Workday (Journée)**, tap an entry under **Day entries (Postes de la journée)**
|
||
to edit its start, end, task, or client. **Regular entry (Poste ordinaire)**, **Meal
|
||
(Repas)**, and **Break (Pause)** buttons select the entry type. When editing the
|
||
times or label of an existing travel entry, its internal type is preserved.
|
||
|
||
Time selectors do not include a date: they keep the entry's date. Minutes and
|
||
adjustment buttons follow the selected rounding interval (1, 5, 15, or 30 minutes).
|
||
Tasks, clients, and restaurants use the same suggestions and free-text entry as the
|
||
workday screen.
|
||
|
||
Corrections are saved together with **Save (Enregistrer)**. Cancel makes no changes;
|
||
opening and saving an unchanged time preserves its stored value. The first entry's
|
||
start and last entry's end define the day boundaries. Editing an intermediate
|
||
boundary adjusts the adjacent entry's boundary without overlap. The end of an active
|
||
entry remains controlled by task changes or day closure.
|
||
|
||
**Add a missed entry (Ajouter un poste oublié)** lets you choose an existing period,
|
||
then the times for an entry to insert into it. The start and end are prefilled with
|
||
the known boundaries (up to now for an active entry). Change either boundary to
|
||
adjust one side of the period. The portions before and after are preserved with
|
||
their original task and client. For example, 08:00–12:00 can become 08:00–09:00,
|
||
a missed task from 09:00–10:00, then 10:00–12:00. **Meal (Repas)** can add a missed
|
||
meal and set its type to packed or restaurant; **Break (Pause)** adds a non-meal
|
||
break. To add an entry immediately after an existing boundary, choose the entry
|
||
that starts at that boundary.
|
||
|
||
In **Summary (Récap)**, choose a date, then **View details → Edit this day (Voir les
|
||
détails → Corriger cette journée)**. The same actions are available without resuming
|
||
the day, even while another day is in progress. The summary and copied text are
|
||
recalculated after each save. Deleting an entry requires confirmation; its neighbor
|
||
absorbs its duration, and the final entry of a day cannot be deleted. Deleting a meal
|
||
assigns its duration to the neighboring entry and can therefore change total hours
|
||
present.
|
||
|
||
Every correction is transactional. Reversed times, insertions outside the selected
|
||
period, and forms made stale by another edit are rejected without a partial write.
|
||
|
||
## Statistics
|
||
|
||
Open **Settings (Réglages) → View statistics (Consulter les statistiques)**. The
|
||
**Stats** button appears in the bottom bar only on this page; other screens keep
|
||
their usual three buttons. The page works offline. Statistics are calculated on the
|
||
device; optional synchronization may share their source history in encrypted form.
|
||
|
||
Filters cover all history, the month, the year, the last 30 days, or an inclusive
|
||
date range. Days in progress are excluded by default. Enabling them shows
|
||
provisional totals and details. Averages under **Highlights and observations
|
||
(Repères et observations)**, the median, extremes, and weekday profiles use only
|
||
recorded closed days.
|
||
|
||
Statistics include:
|
||
|
||
- Hours present excluding meals and breaks, breaks, identified travel, and counts of
|
||
days, entries, tasks, and clients.
|
||
- Hours present by client and task, their shares of hours present, the number of
|
||
affected days, and cross-details available by opening a row.
|
||
- Monthly or weekly trends without inventing hours for missing days.
|
||
- Days with packed or restaurant meals, restaurants visited, break durations, and
|
||
days with no recorded break. Multiple meal fragments on one day count as one
|
||
meal day.
|
||
- Averages, median, extreme days, top client and task time concentrations, and
|
||
daily details.
|
||
- Unspecified clients or restaurants, entries rounded to zero, empty days, uncovered
|
||
periods, and possible overlaps.
|
||
|
||
Calculations use the summary's rounding and always exclude meals and breaks from
|
||
hours present. Only retained data is analyzed: missing or deleted days cannot be
|
||
taken as proof that no work happened. Identified travel uses the recorded entry type,
|
||
not an inference from the task name.
|
||
|
||
In development, the console exposes `timelog.seed()` to generate 20 days of realistic
|
||
history (useful for exercising suggestions) and `timelog.wipe()` to clear it.
|
||
|
||
## Deployment
|
||
|
||
The stack consists of the static app served by Caddy and an optional synchronization
|
||
API backed by SQLite in a persistent volume. Caddy obtains and renews the HTTPS
|
||
certificate. Synchronization remains disabled in each client until explicitly
|
||
activated. See [SYNC.md](SYNC.md) for pairing, encryption, recovery, quotas, and
|
||
server backups.
|
||
|
||
cp .env.example .env
|
||
$EDITOR .env # set SITE_ADDRESS, ACME_EMAIL and TRUST_PROXY
|
||
docker compose up -d --build
|
||
|
||
Requirements:
|
||
|
||
- `SITE_ADDRESS` must already resolve to the server.
|
||
- `ACME_EMAIL` is required and must be a valid contact address.
|
||
- Ports **80 and 443** must be reachable from the internet. Port 80 is mandatory:
|
||
the ACME challenge uses it to prove domain ownership.
|
||
|
||
Certificates are stored in the bind-mounted `./caddy/data` directory. Keep it:
|
||
without it, Caddy requests a new certificate after every recreation and will hit
|
||
Let's Encrypt rate limits after only a handful of restarts.
|
||
|
||
To update, pull and rebuild:
|
||
|
||
git pull && docker compose up -d --build
|
||
|
||
Running clients do not reload automatically. Each app checks hourly for a new build
|
||
and offers an **Install (Installer)** banner, but only when no workday is in progress,
|
||
so an update cannot interrupt a workday.
|
||
|
||
### HTTPS is required
|
||
|
||
Browsers only register a service worker in a secure context. Served over plain
|
||
`http://` at an IP address or hostname, the app still loads but has **no offline
|
||
support, cannot be installed, and never updates**, defeating the design. `localhost`
|
||
is the only exception, which is why `npm run dev` works without certificates.
|
||
|
||
### Behind an existing reverse proxy
|
||
|
||
The current Caddy configuration terminates TLS itself and accepts the PROXY protocol
|
||
from the address or CIDR in `TRUST_PROXY`. Set this to the actual upstream proxy
|
||
address; `192.0.2.10/32` in `.env.example` is a documentation placeholder.
|
||
`LISTEN_IP` selects the local interface for published ports and defaults to
|
||
`0.0.0.0`. A deployment that terminates TLS elsewhere needs its own Caddy and port
|
||
configuration; changing `SITE_ADDRESS` alone does not configure that mode.
|
||
|
||
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 when rendering.** 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. Time pickers follow the selected rounding grid; opening an
|
||
unchanged time preserves its stored timestamp. Summaries apply the same rounding
|
||
when rendered.
|
||
|
||
**Suggestions are derived from history, not counter tables.** Nothing needs to be
|
||
kept in sync; the model is rebuilt in memory from 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 suggestion engine (pure)
|
||
resume.js logbook text builder (pure)
|
||
db.js Dexie schema and chain invariant
|
||
day.svelte.js day lifecycle state machine
|
||
|
||
Pure helpers and database/lifecycle operations have regression tests. `db.js` keeps
|
||
day and entry changes in shared transactions, including creation, closure, resuming,
|
||
and boundary corrections.
|
||
|
||
## Breaks and newer workflows
|
||
|
||
**Workday → Start a break (Journée → Commencer une pause)** closes the current entry.
|
||
**Resume work (Reprendre le travail)** suggests the last work task and its client,
|
||
which can be edited immediately. A break has its own times and label, without a
|
||
client or meal type. Details show every entry; viewing is separate from editing.
|
||
Summaries and copied text exclude breaks. Travel is included in total hours present;
|
||
meals and breaks are excluded. Statistics show total break time separately from meals.
|
||
Imported gaps are preserved.
|
||
|
||
**Settings (Réglages) → Manage automations (Gérer les automatismes)** and
|
||
**Manage non-working days (Gérer les jours non travaillés)** open dedicated pages;
|
||
the browser Back button works through hash routes. **Manage data and backups (Gérer
|
||
les données et sauvegardes)** also opens a dedicated page, with a return to settings,
|
||
retention persistence after reload, and offline access.
|
||
|
||
Settings are written transactionally against the latest persisted value to preserve
|
||
changes made in other tabs. Startup and refresh errors are shown with a Retry action.
|
||
|
||
The production checklist and remaining validation steps are tracked in
|
||
[PRODUCTION.md](PRODUCTION.md).
|
||
|
||
## Verified backups and browser validation
|
||
|
||
**Export data (Exporter les données)** prepares a file and saves its full fingerprint.
|
||
**Verify exported file… (Vérifier le fichier exporté…)** reads the downloaded file,
|
||
checks that it can be imported, and compares its contents with the prepared export.
|
||
The date of the last verified backup is separate from the most recent prepared export.
|
||
After seven days without a verified export, Pardis shows a reminder while in use.
|
||
Export remains local; keep the file outside this device. Optional synchronization
|
||
uses a separate vault and does not replace backups.
|
||
|
||
Deletion displays a fixed list of days. Backup and deletion share a transaction; a
|
||
concurrent correction blocks deletion and requires reviewing the list again. Other
|
||
recently imported days remain untouched.
|
||
|
||
Playwright tests use fresh contexts and synthetic data at 320 px. They cover
|
||
viewing/editing, focus, Back navigation, resuming in two tabs, verified export,
|
||
deletion/recovery, and offline reload. They also cover the dedicated Data screen,
|
||
restoring a retention field after a save failure, rejecting a modified export, and
|
||
fully rolling back a failed deletion while preserving the previous backup. Traces,
|
||
videos, and screenshots are disabled. To use an installed Chrome, set
|
||
`PARDIS_CHROME_PATH` to its path; otherwise install Chromium with Playwright.
|
||
CI installs Chromium Headless Shell and its dependencies before running the tests.
|
||
It downloads only the headless browser build used by the tests and gives each
|
||
download attempt 180 seconds, with Playwright's built-in retries. Browser setup has
|
||
a 20-minute limit and the validation job has a 30-minute limit. An installation
|
||
failure blocks deployment.
|
||
|
||
## Optional device synchronization
|
||
|
||
**Data and backups (Données et sauvegardes) → Synchronization and devices
|
||
(Synchronisation et appareils)** creates an encrypted vault without an email or
|
||
password, then links devices using a temporary code or QR code and confirmation
|
||
on the dedicated screen. Data entry stays local and works offline. Concurrent days
|
||
are retained for conflict resolution. A recovery document restores access after
|
||
losing the devices.
|
||
|
||
Business rules are shared; the theme and backup tracking stay local. One device is
|
||
responsible for automations, with a transfer prompt when opening the app on a
|
||
secondary device. Pairing requests do not create alerts elsewhere in the app.
|
||
|
||
The protocol, security limits, deletion behavior, and server backup/restore commands
|
||
are documented in [SYNC.md](SYNC.md). For development, also run `npm run sync:server`
|
||
with Node.js 24.
|
||
|
||
## License and font credits
|
||
|
||
Pardis is distributed under the GNU General Public License, version 3 only
|
||
(GPL-3.0-only). See [LICENSE.md](LICENSE.md) for the full license text. The bundled font
|
||
files are separate works and remain under the SIL Open Font License, version 1.1;
|
||
their license and copyright notices are included in [public/fonts/OFL.txt](public/fonts/OFL.txt).
|
||
Copyright (C) 2026 EI Adrien Weiskopff.
|
||
|
||
- **Bebas Neue**, by Ryoichi Tsunekawa - SIL Open Font License 1.1.
|
||
- **Charis SIL** - SIL Open Font License 1.1. See the [official Charis SIL site](https://software.sil.org/fr/charis/).
|