No description
  • JavaScript 54%
  • Lua 29%
  • Python 10.8%
  • Vim Script 3.3%
  • Nix 1.5%
  • Other 1.4%
Find a file
Antoine Vaure df7e4e755e
All checks were successful
ci-tron/build Success
ci-tron/test Success
CI: publish a GitLab release on version tags
2026-10-01 17:22:41 +02:00
ci CI: publish a GitLab release on version tags 2026-10-01 17:22:41 +02:00
nvim Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00
.ci-tron.json CI: run nix in docker on shell runner, fix ci-tron artifacts 2026-10-01 17:17:20 +02:00
.gitignore CI: run nix in docker on shell runner, fix ci-tron artifacts 2026-10-01 17:17:20 +02:00
.gitlab-ci.yml CI: publish a GitLab release on version tags 2026-10-01 17:22:41 +02:00
.nvim.lua Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00
build.js Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00
compile.py Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00
eurecia_extract_options_bookmarklet.src.js Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00
eurecia_extract_week_bookmarklet.src.js Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00
eurecia_fill_bookmarklet.src.js Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00
flake.lock Add Nix flake (build, run, tests, dev shell) 2026-10-01 17:01:47 +02:00
flake.nix Add Nix flake (build, run, tests, dev shell) 2026-10-01 17:01:47 +02:00
README.md CI: publish a GitLab release on version tags 2026-10-01 17:22:41 +02:00
timesheet.template.json Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00
week.example.tsheet Add Eurécia timesheet bookmarklets and .tsheet compiler 2026-10-01 16:51:31 +02:00

Eurécia timesheet bookmarklets

Three bookmarklets for the Eurécia "Suivi Activités" weekly timesheet without touching DevTools each week:

  • eurecia_extract_options_bookmarklet — crawls the cascading Domain → Project → Customer → CIR dropdowns on the currently open week and dumps every valid {value, text} combination as JSON. Feeds compile.py (see Writing timesheets as plain text below), which turns a short hand-written .tsheet file into precise timesheet data — hence exact {value, text} pairs, not just display text.
  • eurecia_fill_bookmarklet — takes timesheet JSON (from the clipboard) and fills the currently open week's form. Strictly validates the JSON first and selects Domain/Project/Customer/CIR by exact {value, text} pair (as produced by the extractor above), verifying the resulting value matches before reporting success. It never clicks "Enregistrer" / "Soumettre à validation" — you review the on-page result panel and save yourself.
  • eurecia_extract_week_bookmarklet — the reverse direction: reads the entries already logged on the currently open week and renders them as a .tsheet week block, ready to paste onto the end of a .tsheet file (e.g. real.tsheet). Works on both submitted ("Validée") and unsubmitted ("Nouvelle") weeks.

All three scripts only touch the DOM of the Eurécia timesheet page; nothing is sent anywhere over the network by these scripts themselves.

Note: this project was made with heavy LLM assistance (most of the code and docs were written with an AI coding assistant). Review the scripts before trusting them with your timesheet, and always check the filled form yourself before saving.

Setup

The *.url.txt bookmarklet files are generated (and gitignored): run node build.js (or nix run .#build) first.

Chrome strips the javascript: scheme when you paste a full bookmarklet URL into the address bar, so bookmarks must be created via the bookmark editor, not the omnibox:

  1. Bookmarks bar → right-click → "Add page" (or Bookmark Manager → "Add new bookmark").
  2. Name it (e.g. "Eurécia: extract options" / "Eurécia: fill timesheet" / "Eurécia: extract week").
  3. Paste the entire contents of the matching *.url.txt file into the URL field, save.

Workflow

open the Eurécia week
        │
        ▼
click "extract options" bookmarklet ──► valid-value tree JSON (clipboard)
        │
        ▼
  (your app, TBD) turns the tree + your hours into timesheet.json
        │
        ▼
copy timesheet.json to the clipboard (e.g. `xclip -selection clipboard < timesheet.json`)
        │
        ▼
click "fill timesheet" bookmarklet ──► form filled, on-page panel shows per-field results
        │
        ▼
review the form yourself, click Enregistrer

If clipboard read/write is denied by the browser, all three bookmarklets fall back gracefully (fill prompts you to paste; the two extractors still show their output in an on-page textarea you can copy manually).

To pull an already-logged week the other direction — page to .tsheet text — open that week and click "extract week" instead:

open an already-logged Eurécia week
        │
        ▼
click "extract week" bookmarklet ──► .tsheet week block (clipboard)
        │
        ▼
paste onto the end of real.tsheet (or wherever)

How Domain/Project/Customer/CIR selection actually works

These four fields are select2 widgets, and getting them right took several rounds of live debugging against the real page. Three things are true of them that aren't obvious from the DOM:

  1. No level's full choice list is reliably written into the native <select>. Only whichever value is currently selected (plus maybe one stray entry) ever ends up there. First confirmed for Domain (select.options showed 1, the rendered dropdown showed 4) — initially assumed not to apply to Project/Customer/CIR, because every domain tested at the time happened to have exactly one project, which trivially looks "fully populated" either way. It doesn't generalize: a domain with real data had 40 real projects, and the native <select> only ever showed 1 of them. So every level is read by opening its select2 popup and reading the rendered results — never the native <select>'s options.

  2. Setting a <select>'s value programmatically doesn't reliably fire the next cascade level. Neither jQuery(el).val(x).trigger('change') nor injecting a fresh <option> node and triggering change reliably kicks off the AJAX call that loads the next level's options — select2 tracks its own "did this really change" state separately from the raw DOM value, and a programmatic set can silently fail to register as a real selection. The only mechanism confirmed to work at every level is a genuine click: open the dropdown, find the rendered result whose text matches, and dispatch a mouseup on it (select2 listens for mouseup on its results). Both bookmarklets do this everywhere — there's no .val()-based path left.

  3. A value read before selecting isn't always the value you get after selecting. Confirmed live: a Project entry's pre-selection listing had its own display text as the value attribute, but actually clicking it resolved to a completely different opaque id — consistently, on repeat selections. So the extractor never trusts a listing's value; it always captures the value that comes back from actually clicking each item (including CIR, a leaf with nothing to cascade into, which still gets clicked once purely to capture its true value).

  4. A level can render as a tree, not a flat list. Confirmed live on Project, on an account with a real hierarchical catalog: a category header like "E4XX" has an expand-icon child (i.icon-section-closed / .icon-section-open) and isn't itself selectable — clicking its row pops up a message instead of selecting (this is the exact symptom that first surfaced the bug: "only the first entry gets extracted" under a domain whose project list was actually a tree). Its children (e.g. "E4XX/Base") are present in the flat DOM list from the start but carry aria-disabled="true" until the category's icon is clicked to expand it, which clears that attribute and makes them genuinely selectable. Both scripts handle this automatically: the extractor skips category headers entirely (they aren't real values, just group labels) and auto-expands a child's category before clicking it; the filler does the same when the target text turns out to be a disabled child. Only one level of nesting has been observed — no child entry itself has an expand icon — so deeper trees aren't handled.

Net effect: value is often an opaque internal id (confirmed for Domain, Project, Customer, CIR at various points), not the display text — so text has to travel alongside value everywhere, both in the extractor's output and the filler's input, because text is what's used to find the right dropdown entry to click, and value is what's verified afterward.

eurecia_extract_options_bookmarklet

window.__eureciaExtractBookmarkletMain(opts?)

Runs on row 0 of the open week as scratch space — saves its current values, crawls the cascade, then restores them, so it doesn't permanently change your timesheet (avoid running it mid-edit on an unsaved row, though). Restoring a row that started genuinely blank clears fields directly rather than clicking the dropdown's blank entry — that entry resolves to an internal sentinel value distinct from true "nothing selected", confirmed live.

Output shape:

[
  {
    "domain": { "value": "TENANT---...-cb53", "text": "PreSales" },
    "projects": [
      {
        "project": { "value": "TENANT---...-cb57", "text": "Presales Support" },
        "customers": [
          {
            "customer": { "value": "TENANT---...-bb0", "text": "All" },
            "cirs": [{ "value": "TENANT---...-c31", "text": "AUTRE" }]
          }
        ]
      }
    ]
  }
]

Cost warning: roughly one AJAX round trip per cascade step, plus one per item just to open its dropdown and read the full rendered list (point 1 above), plus one more per leaf item to capture its true value (point 3). A single domain can have dozens of projects (confirmed: 40 under one domain) — with customers and CIRs nested under each, a full maxDepth: 4 crawl of an account with real data can mean many hundreds or thousands of round trips and take a long time. maxDepth defaults to 4 (full depth) anyway, since Customer/CIR are needed for real .tsheet entries and a shallow crawl just defers that cost to a second run — pass a shallower maxDepth explicitly if you only need part of the tree:

window.__eureciaExtractBookmarkletMain({ maxDepth: 1 })  // domains only
window.__eureciaExtractBookmarkletMain({ maxDepth: 2 })  // + projects
window.__eureciaExtractBookmarkletMain({ maxDepth: 3 })  // + customers
window.__eureciaExtractBookmarkletMain()                 // + CIR (default, full depth)

eurecia_fill_bookmarklet

Input JSON: an array of day entries. The array may span several weeks (e.g. a multi-week .tsheet compiled in one shot — see Writing timesheets as plain text below); the bookmarklet auto-detects which week is currently open by reading the dates actually rendered on the page and only fills entries whose date matches one of them. Entries for other weeks are reported as "skipped" in the result panel, not as failures — so the same clipboard JSON can be reused unchanged every week without trimming it down first. If none of the entries match the open week at all, the panel says so instead of filling anything.

[
  {
    "date": "31/08/2026",
    "days": 1,
    "domain": { "value": "TENANT---...-cb53", "text": "PreSales" },
    "project": { "value": "TENANT---...-cb57", "text": "Presales Support" },
    "customer": { "value": "TENANT---...-bb0", "text": "All" },
    "cir": { "value": "TENANT---...-c31", "text": "AUTRE" },
    "comment": "Client call"
  },
  { "date": "01/09/2026", "days": 0.5 }
]
  • date: dd/mm/yyyy, must match the page's "Date" column exactly.
  • days: number in [0, 7], split into integer + nearest eighth-of-a-day fraction the page supports.
  • domain / project / customer / cir: optional {value, text} objects (both required together), taken verbatim from the extractor's output — don't hand-edit value (see point 3 above for why). The field is selected by clicking the dropdown result matching text, then the resulting value is checked against value; a mismatch (stale/wrong pair, or the option no longer exists) is reported as a per-field error, nothing is guessed at.
  • comment: optional free text.

Validation runs before any DOM change and reports every schema-level problem at once (bad/duplicate dates, out-of-range days, wrong field shapes). If validation fails, nothing is filled. Per-field selection failures (wrong value, option not found) are only knowable at fill time and are reported per field in the result panel instead.

Also exposed for manual use in DevTools: window.fillTimesheet(entries, opts?).

Template

timesheet.template.json is a ready-to-edit starting point — a Mon–Fri week using a real {value, text} pair from this account (PostSales / Support-IP, pulled from options.json, a saved extractor run). To use it:

  1. Replace each <DATE_LUNDI> … <DATE_VENDREDI> placeholder with the real dd/mm/yyyy date shown in that row's "Date" column for the week you have open (JSON has no comments, so these are deliberately invalid dates — left as-is, validation will point at exactly which one you forgot).
  2. Swap domain/project (and add customer/cir if needed) for whichever entries apply to each day — copy the matching {value, text} pairs from a fresh extractor run (or options.json if it's still current) rather than typing them by hand.
  3. Copy the file's contents to the clipboard and run the fill bookmarklet.

eurecia_extract_week_bookmarklet

window.__eureciaExtractWeekBookmarkletMain()

Reads the entries already logged on the currently open week and renders them as a .tsheet week block (see Writing timesheets as plain text below) — copied to the clipboard and shown in an on-page textarea, ready to paste onto the end of a .tsheet file. This is the reverse direction of eurecia_fill_bookmarklet: page → text, not text → page.

Works on both week statuses, which render very differently:

  • "Nouvelle" (unsubmitted) weeks use the same live edit-form ids eurecia_fill_bookmarklet fills (daysWorked_int_<i> / daysWorked_fraction_<i>, imputationStructureId{1,2,3,4}_<i>, comment_<i>, dateActivite_<i>).
  • "Validée" (submitted) weeks render Domain/Project/Customer/CIR as plain read-only text with no stable id to key off — read positionally from each row's table cells instead (confirmed stable against known data: columns 4/5/6/7 of the row containing that row's daysWorked_<i> input). Days and Comment remain live-editable inputs even on a submitted week and do have ids (daysWorked_<i>, comment_<i>).

Rows are never emitted as .tsheet path entries unless they have days > 0 and a real top-level domain (PreSales, R&D, PostSales, Other). Everything else falls into one of two buckets:

  • Silently dropped — a row with days of exactly 0 and no comment, confirmed live to show up as leftover mid-edit/scratch state (a fully populated domain/project/customer/cir but no real time logged), not something you'd want appearing as a phantom entry; same for a genuinely unfilled future day.
  • Noted, not dropped — a row outside the real-domain whitelist that does have a comment. Eurécia lists placeholder rows like "Jour Férié" / "Jours Non Travaillés" / "Congés payés" alongside real entries; these aren't project time, but losing them entirely would also lose the reminder that part of that day is already accounted for elsewhere. These come through as a # congé: <date> — <comment> line in the output (a #-prefixed line is already a comment as far as compile.py is concerned, so it never resolves as a path entry) and as a separate count in the result panel.

Customer/CIR are folded into the comment as [Customer/Cir] rather than emitted as a full 4-level path — this bookmarklet reads Customer/CIR straight off the live page, not from options.json, so it has no way to know whether your saved options.json was crawled deep enough (maxDepth: 3 or 4) for those names to actually resolve as path segments when the .tsheet file is compiled; folding them into the comment instead never risks a compile error.

Also exposed for manual use in DevTools: window.extractTimesheetWeek().

Writing timesheets as plain text (.tsheet)

Hand-editing timesheet.template.json works but is tedious (verbose JSON, opaque {value, text} pairs, manual date arithmetic). compile.py is a small, dependency-free Python script that compiles a short plain-text format into that same JSON, resolving human-readable names against a saved options.json (an extractor run) instead of you having to copy {value, text} pairs by hand.

Format

A week YYYY-MM-DD line — the Monday of that week — starts a new week block; every entry's date is computed from the block's Monday plus a day keyword, so you never type dd/mm/yyyy by hand. A file can contain a single week line (one week), or several, one after another — each entry belongs to whichever week line most recently preceded it, so a running multi-week .tsheet compiles down to one JSON array spanning all of them (see eurecia_fill_bookmarklet above for how filling picks out just the current week from that array). Duplicating the same Monday twice in one file, or putting an entry before any week line, is a compile error. Each entry line is:

<day> [<days>] [<domain>[/<project>[/<customer>[/<cir>]]]] [<comment>]
  • <day>: mon/tue/wed/thu/fri/sat/sun, case-insensitive. French is also accepted, either abbreviated (lun/mar/mer/jeu/ven/sam/dim) or spelled out (lundi/mardi/mercredi/jeudi/vendredi/samedi/dimanche) — useful since that's what the Eurécia page itself displays.
  • <days>: a whole number of eighths of a day, 0–56 (8 = 1 full day, 4 = half a day, 56 = 7 days) — matching the granularity the page itself supports (the fill bookmarklet splits days into integer + nearest eighth-of-a-day fraction). Optional — a day can be mon alone, or mon 8 with no path, matching the fill bookmarklet's own schema where days and every cascade field are independently optional.
  • The path segments are matched case-insensitively and exactly against options.json's tree (no fuzzy matching — a typo is a compile error, not a silent wrong-project selection). Segments may contain spaces (e.g. Presales Support).
  • <comment> is free text, no marker needed — it runs from the first run of 2 or more consecutive spaces after the path to end of line. A single space stays part of the path (segment names routinely have one, e.g. Team Meetings), so only a wider gap starts the comment — the same gap :TimesheetFmt column-aligns entries with, so a formatted file always parses back the same way. A whole line starting with # (optionally indented) is still a full-line comment, ignored anywhere — that's unrelated to the per-entry <comment> field and unaffected by this.
  • A day can have more than one entry (splitting it across projects) — see eurecia_fill_bookmarklet's "Nouveau" row auto-add above. If a day's entries don't sum to exactly 1, compile.py prints a warning (not an error — it doesn't block build); with multiple week blocks in one file, this check is done per week, so e.g. mon in one week and mon in another are never summed together.

Example

week 2026-08-31

mon  8  PreSales/Presales Support  kickoff call
tue  4  PostSales/Support-IP
tue  4  PreSales/Presales Support  half day split across two projects
wed  8  PostSales/Support-IP
thu  8  PostSales/Support-IP
fri  4  PostSales/Support-IP       half day, deliberately under a full day -> warns

week 2026-09-07

mon  8  PostSales/Support-IP
tue  8  PostSales/Support-IP

A file doesn't need a second week block — a single-week file (just the first block above) works exactly as before.

Checking this (python3 compile.py check week.tsheet) reports exactly one warning — "fri" (week 2026-08-31) entries sum to 0.5 days, expected 1 — and nothing else; every other day in both weeks resolves cleanly. A four-level domain/project/customer/cir path is also valid (see eurecia_extract_options_bookmarklet's output shape above) but only resolves if your saved options.json was crawled deep enough (maxDepth: 3 or 4) to include that project's customers/CIRs — the bookmarklet now defaults to maxDepth: 4, but an older, shallower options.json on disk won't have them until it's re-crawled.

PreSales/Presales Support and PostSales/Support-IP are the same illustrative names used earlier in this README (from eurecia_extract_options_bookmarklet's output shape and timesheet.template.json) — swap them for whatever your own options.json actually contains.

compile.py

python3 compile.py check week.tsheet [--options path/to/options.json]
python3 compile.py build week.tsheet [--options path/to/options.json] [--no-clipboard] [--out file.json]
  • --options defaults to options.json next to the script.
  • check prints one file:line:col: message line per problem (gcc-style, so editors can turn it into a jumpable list) and exits non-zero on errors; warnings print the same way but don't affect the exit code.
  • build does the same validation, then on success copies the JSON to the clipboard (tries xclip, wl-copy, pbcopy in turn; falls back to printing it to stdout if none are found). --out file.json writes to a file instead. On error, nothing is emitted — same all-or-nothing validation the fill bookmarklet itself does.

Neovim integration

nvim/ in this repo adds filetype detection, syntax highlighting, and two commands for .tsheet files.

Automatic, via this repo's .nvim.lua: add vim.o.exrc = true to your own config once, then trust this file once (:trust while it's the current buffer, or vim.secure.trust({action='allow', path='.../ts/.nvim.lua'})) — after that, opening nvim with ~/work/ts as your working directory auto-loads everything below, no per-project edits to your own config needed. See :h 'exrc' / :h :trust. (week.example.tsheet in this repo carries a header comment with this same reminder.)

Manually, add the nvim/ dir to your runtimepath instead:

vim.opt.rtp:append('/path/to/ts/nvim')

or as a local plugin spec (lazy.nvim):

{ dir = '/path/to/ts/nvim', ft = 'tsheet' }

Then, in a .tsheet buffer:

  • :TimesheetCheck — runs compile.py check in the background and loads any problems into the quickfix list (:copen, :cnext); warnings show up the same way but the buffer still reports "Timesheet OK (with warnings)".
  • :TimesheetBuild — runs compile.py build; on success, shows a confirmation that the JSON was copied to the clipboard (ready to paste into the fill bookmarklet).
  • :TimesheetFmt — aligns the day/days/path/comment columns of each contiguous block of entry lines (blank lines, comment lines, and the week line each start a new block).
  • Completion of day keywords and domain/project/customer/cir names while typing (against options.json), via omnifunc (<C-x><C-o>) and, if nvim-cmp is installed, a tsheet cmp source registered automatically for this filetype.

Run nvim/lua/tsheet/test/run.sh to run the (dependency-free, headless) test suite for this Neovim integration.

Development

Both bookmarklets are plain IIFEs; edit the *.src.js files directly, then generate the *.url.txt bookmarklet URLs:

node build.js

No minification — the bookmarklet body is the readable source verbatim (wrapped in javascript: and URI-encoded), well under any browser bookmark length limit.

Nix

A flake.nix provides the tooling (node, python3, neovim, clipboard tools):

nix develop                              # dev shell
nix build                                # result/*.url.txt, built from *.src.js
nix run .#build                          # regenerate *.url.txt in the working tree
nix run . -- check week.example.tsheet   # compile.py (check / build)
nix run .#test                           # headless neovim test suite
nix flake check                          # runs the neovim tests in the sandbox

CI

.gitlab-ci.yml and .ci-tron.json both run nix build (through ci/build.sh) and nix flake check. The GitLab runner is a shell executor, so the jobs call docker run nixos/nix themselves (and chown/remove everything afterwards). The built *.url.txt bookmarklets are kept as artifacts: dist/ on GitLab, $CI_TRON_ARTIFACTS_DIR on ci-tron.

On GitLab, pushing a tag vX.Y.Z also publishes a release with the bookmarklets attached (ci/release.sh, via the GitLab API).

Known gaps

  • Real navigator.clipboard permission prompts and an actual bookmark click haven't been exercised end-to-end (only simulated via injected JSON in place of clipboard content) — do one manual run of each bookmarklet before relying on them weekly.
  • If a level's list is large enough that select2 paginates it (loads more results only once you scroll the dropdown), only what's rendered on first open would be read. Not observed in testing — a 40-item Project list rendered in full immediately with no scrolling — but also not something this script actively handles, so an even larger list on a different account could still be truncated silently.
  • Selection matches by exact text. If a dropdown has two entries with identical display text but different underlying values, the first DOM match wins — not something observed, but not ruled out either.
  • Tree handling (point 4 above) only covers one level of nesting. If a real account has a category whose children are themselves categories (a deeper tree), those grandchildren wouldn't be reached — not observed, but the 40-item Project list that surfaced the one-level case makes it plausible a different account could have deeper structures.
  • The full 4-level cascade (down to a real CIR) was only verified for one specific chain (PreSales → Presales Support → All → AUTRE); the multi-hundred-item scale confirmed at the Project level hasn't been separately confirmed at the Customer or CIR level.
  • eurecia_extract_week_bookmarklet's "Validée" (submitted-week) path reads Domain/Project/Customer/CIR positionally by table-cell index, since those cells carry no id in that mode — verified against one real week's known data, but a different account/locale whose read-only table has a different column layout wouldn't be caught until the output looked wrong.