Repository navigation
Security
- Closed a CodeQL
js/xss-through-domfinding (alert #2) in the epic colour picker —renderTagRow()'s quick colour picker (src/js/02-utils.js) read the raw<input type="color">value and wrote it straight intocat.color(persisted state) and the swatch'sstyle.background, bypassinggetCat()'s existingsafeCssColor()sanitisation entirely; theinnerHTMLsink for the picker's ownvalueattribute relied on that same upstream sanitisation, which CodeQL's dataflow analysis doesn't credit as a barrier across the object spread. All three sites now callsafeCssColor()explicitly. A real<input type="color">clamps its.valueto valid hex at the DOM level, so this isn't exploitable through normal use today — it closes the gap CodeQL flags and guards against any future code path (e.g. a non-native picker, or a refactor) that could feed an unclamped value through the same handlers. 3 new regression tests intest/unit.mjs(VM-sandboxed, since a real browser can't hold an invalid value in a colour input to prove the "before" case). This alert had been open since PR #87 (2026-05); a prior attempt (PR #265) was closed as superseded without merging.
Removed
- Dead V5 timer-bar CSS in
_timer.scss— the.timer-barlayout (circle ticker, task-name block, action pills, note row, utility pills, paused-state variants, and the.timer-task/.timer-elapsedcompat stubs left over from an even earlier layout) was fully superseded by the hero card redesign and had zero references left in any.jsor.htmlfile — confirmed with a full cross-check, not just the.tb-*prefix grep the backlog note was based on, which also caught.timer-pulse,.timer-paused-lbl,.timer-pause/.timer-stop, and.timer-distractas dead too. Kept.tb-mood-panel/.tb-mood-item(still used by the hero card's own mood picker, interleaved in the middle of the dead block) and@keyframes pulse(still used by.entry.is-timing .edot's animation)._timer.scssgoes from 691 to 344 lines; compiledstyles.cssshrinks by ~4KB gzipped. No behaviour change — verified visually in the browser (mood panel opens with the right styling,.edot's pulse animation still fires) plus the full build/lint/test suite.
Fixed
- Log view entries sort by start time, not insertion order —
viewEntries()(src/js/02-utils.js) produced its newest-first ordering by reversing insertion order, which only matches chronological order when entries are logged in the order they happened. An entry added retroactively — filling in a missed morning slot after the rest of the day was already recorded — therefore landed wherever it happened to be typed rather than at the position its start time called for. It now sorts bytsdescending, so an entry always appears where its start time says it belongs regardless of when it was added. Knock-on effect:exportTxt()derives its oldest-first ordering by re-reversingviewEntries(), so the exported timesheet is now genuinely chronological too, rather than reversed-insertion-order. 2 new regression tests cover thetsordering independent of insertion sequence, and the existing viewed-date filter. - Gap check (end-of-week report and end-of-day export warnings) no longer flags non-billable entries for a missing note or proof link —
findGapReportEntries()andfindExportWarnings()(pure-fns-export.js) previously required every finished, non-cancelled entry to carry a note or link regardless of billing status, so meetings, admin work, and other entries marked internal via the 💰/💸 toggle showed up in the weekly gap report and the export's warnings section even though there's nothing to bill and so nothing to prove. Both functions now skip an entry whose resolved_billablestatus isfalse; callers (exportTxt()in05a-export.js,openGapReportOverlay()in12c-gapreport.js) resolve that status via the existingisEntryBillable()three-tier lookup before calling in, since the pure module has no access to the category/task state that lookup needs. smoke-tests.cjscouldn't run in sandboxes with a pre-provisioned Chromium that lags Playwright's pinned version —chromium.launch()always resolved the browser Playwright's own installer would have downloaded for the currently pinnedplaywrightversion, with no way to point it at a different binary already on disk. In an offline/sandboxed dev environment where a Chromium build exists at an older revision thannode_modules/playwright-core/browsers.jsonexpects, andnpx playwright installcan't reach the network to fetch a matching one, this failed withbrowserType.launch: Executable doesn't exist at .../chrome-headless-shell. Real CI is unaffected —ci.yml,auto-fix-ci.yml, andauto-chore.ymlall runnpx playwright install --with-deps chromiumbefore tests, which keeps the download in sync withpackage.json.chromium.launch()now accepts an optionalPLAYWRIGHT_CHROMIUM_EXECUTABLE_PATHenv var; unset (the default everywhere else), behaviour is unchanged.auto-chore.yml's exit-code checks could misfire on an empty string — the "comment on the issue" step'selif [ "${{ steps.claude.outputs.exit_code }}" != "0" ]and the "fail the job" step'sif: steps.claude.outputs.exit_code != '0'both treat an empty/unsetexit_codeoutput as "nonzero," since'' != '0'is true. In the current workflow theclaudestep always sets this output before finishing (wrapped inset +e/set -e), so the gap wasn't reachable in practice — but a hard runner kill mid-step (cancellation, timeout) before that finalechoruns would have produced a spurious 🔴 "CLI crashed" comment and an unwarranted red job, misreporting what actually happened. Both checks now require the output to be non-empty first. Flagged as a nitpick on PR #296's own review and tracked as #297; picked up separately since it editsauto-chore.ymlitself, which the automation can never push (see the workflow's own header comment).auto-chore.ymlfailed silently when a push failed, and briefly shipped with an invalidpermissions:entry — found by dogfooding the workflow on a real test issue (#297) asking it to modifyauto-chore.ymlitself: the defaultGITHUB_TOKENrefused the push ("refusing to allow a GitHub App to create or update workflow ... withoutworkflowspermission"), and because that push step failed, the "comment on the issue with the outcome" step was skipped too (GitHub Actions skips subsequent steps by default after a failure) — leaving the issue with zero visibility into what happened. The comment step now runs regardless of an earlier step's outcome (!cancelled()), with a distinct message for "committed but failed to push" versus the existing "declined to automate" and "CLI itself crashed" cases. A same-day follow-up (#299) first tried addingworkflows: writeto the job'spermissions:block —workflowsis not a real GitHub Actions token permission scope, and the invalid key made the entire workflow file unparseable, breaking every chore trigger, not just ones touching workflow files. Corrected here: there is nopermissions:entry that grants this — pushing changes to.github/workflows/*is a hard limit of the defaultGITHUB_TOKEN, now documented as a known limitation in the workflow file's header comment. The distinct failure comment above is what actually surfaces this case.auto-chore.ymlno longer silently swallows a Claude CLI crash —> chore-output.txt 2>&1 || truediscarded the CLI's real exit code, so a crash or auth failure looked identical to Claude correctly declining to act: same green run, same generic issue comment. The step now captures the actual exit code; a nonzero exit posts a distinct 🔴 comment and fails the job outright, while a zero exit with no commit still reports the original "declined to automate" message. Also added aconcurrency: auto-chore-<issue-number>group so a fast label-remove-then-reapply can't race two overlapping runs past the existing-branch/PR guard into duplicate PRs, and a${SLUG:-untitled}fallback for issue titles with no ASCII alphanumeric characters (previously produced a branch name with a trailing dash). Found via/pr-reviewon #291 after it had already merged.
Added
- CI now lints the workflow files themselves (
actionlint) — an invalid key in a workflow does not fail loudly: GitHub refuses to parse the file, schedules zero jobs, and reports no error anywhere, so the run reads as "nothing to do". That has silently broken this repo twice — PR #256 (if: secrets.CLAUDE_REVIEWER_APP_ID != '', a context unavailable in step-levelif:, which lefta11y-audit.ymldead for weeks without ever posting a check) and PR #299 (workflows: writein apermissions:block, which killed every chore trigger until #300). Both were found only by checkinggh api .../jobsby hand and noticing an empty list. Thelintjob now runs actionlint, pinned to v1.7.12 and SHA256-verified before it executes. Because a linter that silently stops enforcing is worse than none,npm run test:actionlintalso re-runs actionlint against two fixtures reproducing those exact bugs and fails if either rule stops firing — matching on actionlint's message text rather than its rule tag, since tags have been renamed upstream before. PureparseActionlintFindings(),interpretFixtureRun(),interpretWorkflowsRun()andinterpretSelfTest()in.github/scripts/lib/actionlint-selftest.mjs, 20 unit tests that need no binary installed. Linting ofrun:block contents (shellcheck, pyflakes) is deliberately left off for now — that is a separate, much larger cleanup. Twoactions/create-github-app-tokeninput findings are also muted: actionlint v1.7.12 ships a stale schema for that action and reportsclient-idas undefined andapp-idas required, when the action's ownaction.ymldeprecatesapp-idin favour ofclient-id— acting on those 18 findings would have moved every workflow onto a deprecated input. The suppression names that one action explicitly, and a unit test asserts no ignore pattern can swallow either fixture's expected finding. - The end-of-day export now shows when work happened, not just how long — the plaintext report grouped entries into totals only, so a task worked in two separate sessions (say, morning and afternoon) rendered as one time-blind block, and a proof link recorded on an entry (existing
entry.linkfield) was captured but never actually shown in the file — leaving worklog-vs-report cross-checks to memory. Each task's individual tracked sessions now render as indentedHH:MM–HH:MMlines, and its proof link(s) as an indentedlink:line, same convention as the existingnote:line. The header also gains aBreaks (untracked): Xh Ymline (workday span minus tracked time) so a day with no real break stops disappearing into the totals. Newsessionsfield ongroupEntriesByCategory()'s per-task output; new purebuildEntryLinkMap(). - End-of-day export warnings section — a day's totals can look fine while hiding real anomalies (undocumented work, a suspiciously long unbroken stretch, no break in a full day). The export now appends a
⚠ Warningssection flagging entries with neither a note nor a link, single blocks over 4h (reusing the existingisLongRunningTimer()threshold), and a ≥6h day with under 15 minutes of untracked time — so the person logging the day finds the gap before anyone else has to ask about it. Omitted entirely on a clean day. New purefindExportWarnings()inpure-fns-export.js, unit tested. - Restarting a timer carries its proof link forward and asks about the note — every "restart with timer" action (the log's ▶ button, the kanban board's "▸ track" button, and the "+ track recent" chips) used to start a completely blank entry, discarding any proof link or note recorded on the previous run of the same task. All three now go through a shared
createRestartedEntry()(05-entries.js): the most recent matching entry's proof link is carried over silently, while its note — which may no longer describe what you're about to do — opens the new entry's editor with a "same note as last time?" banner (Yes, keep it / No, clear) instead of being copied automatically. 11 new unit tests cover the lookup, the copy/prompt logic, and the confirmation banner's rendering. - Auto-chore GitHub Actions workflow — applying the new
choreissue label now triggersauto-chore.yml, which checks out a freshchore/issue-<N>-<slug>branch, runs Claude against the new/chore-startskill to implement the issue (only when it judges the change small and mechanical — it comments and makes no commit otherwise), verifies with build/lint/test, and — if a commit resulted — pushes the branch and opens a PR that closes the issue. The PR then goes through the existing chatgpt-pr-review / pr-review pipeline like any human-opened PR; nothing merges without that review and explicit approval. A guard skips issues that already have a branch or PR so relabeling doesn't start duplicate runs. - CI now guards commitlint against dependency skew — the
commit-msghook is the only thing enforcing Conventional Commits and it runs solely on contributors' machines, so when #251 bumped@commitlint/config-conventionalwithout a matching@commitlint/cli, every localgit commitfailed withERR_PACKAGE_PATH_NOT_EXPORTEDand nothing noticed for weeks (QA 2026-08-03, priority 3). #290 pinned the transitive dependency; this adds the missing guard. A newnpm run test:commitlintruns commitlint against one conforming and one non-conforming sample message, asserting that the preset still loads and that rules are still enforced — a preset that loads but matches nothing would otherwise wave any message through. Wired into thelintjob inci.yml, so the breakage now fails a required check instead of surfacing on someone's next clean checkout. Deliberately does not lint the PR's real commit messages: the point is detecting dependency breakage, not policing contributors. PureinterpretSelfTest(),isPresetResolutionFailure()andselectCommitlintBinPath()in.github/scripts/lib/commitlint-selftest.mjs, 36 unit tests.
Changed
- End-of-day export's pasteable summary line redesigned around per-ticket totals — previously grouped by category as
Category (task, task)with no per-item duration and the Jira key stripped from each task name, which suited a client-facing read but not the line's real job: checking logged hours against ticket worklogs. Now one semicolon-separatedLabel (duration)item per distinct task, full-day total (a task worked in two sessions still collapses to a single line, matching a Jira worklog), Jira key kept intact, and internal (non-billable) items marked, internalso that split — already in the header totals — is checkable line by line too. RemovesbuildBillableSummaryParts(),stripJiraPrefix(), andmergeAdjacentEntries()(the last was dead once summary totals moved to whole-day-per-task instead of gap-based adjacency merging, which had a latent bug: an unrelated entry logged between two sessions of the same task broke the adjacency and silently produced two line items instead of one). New purebuildTimesheetSummaryLine()inpure-fns-export.js, unit tested.
Config defaults at this release
These are the compile-time defaults baked into src/js/00-config.js.
Runtime overrides (weather location etc.) are set per-machine in config.local.ps1.
| Constant | Default value |
|---|---|
WEATHER_LAT |
60.1887 |
WEATHER_LON |
24.927 |
WEATHER_NAME |
'Helsinki' |
JIRA_BASE |
'https://your-instance.atlassian.net/browse' |
AUTO_PAUSE_ON_TAB_SWITCH |
false |