From fd4d182d306688931be4607b54ea74be0c6b471e Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 04:02:14 +0000 Subject: [PATCH 01/38] Draft design for extensions (issue #1) Extensions are npm packages (bundled, dependency-free) named with npm specs in devlog.json and pinned by a devlog.lock.json; installed by the app into user data with pacote, never into the devlog. Non-secret settings live in the devlog, secrets in the OS keychain. Consent per package hash; main half in a utilityProcess, renderer half in a sandboxed iframe. Covers the time export flow and ledger (#2), custom block kinds (#3), milestones and open questions. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAgWUf68JDhC5YPtWj1zT6 --- docs/EXTENSIONS.md | 235 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 docs/EXTENSIONS.md diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md new file mode 100644 index 0000000..d8c00c9 --- /dev/null +++ b/docs/EXTENSIONS.md @@ -0,0 +1,235 @@ +# Extensions: design (draft) + +Status: **draft for discussion**, nothing built yet. Tracks issue #1 (with #2 +time export and #3 custom block types). Open questions are collected at the +end. + +## Goals + +- Let a devlog opt into integrations (pushing tracked time to Jira and CMS, + Outlook-backed blocks, custom renderers) without growing the core app. +- **The devlog stays Just A Notebook.** It *names* the extensions it wants + and holds their non-secret settings; it never contains their code, their + secrets, or anything they need at runtime besides that. +- Opening or pulling a devlog must never run code that arrived through git. +- A leaked or public devlog repository must not leak any credential. +- Extensions go through the same data layer as the app (`@devlog/core`), so + they cannot break the file-format rules (append-only block files, marker + escaping, image paths). + +Non-goals: two-way sync with trackers (Devlog records what happened; it is +not a project-management tool), a public extension marketplace, running +untrusted third-party code safely (see *Trust*). + +## What an extension is + +An npm package whose `package.json` has a `devlog` field: + +```jsonc +{ + "name": "@sdeken/devlog-jira", + "version": "1.2.0", + "main": "dist/main.js", // one bundled file, no runtime dependencies + "engines": { "devlog": "^1.0.0" }, // extension API version it was built for + "devlog": { + "displayName": "Jira time export", + "renderer": "dist/renderer.js", // optional: block renderers (sandboxed) + "contributes": { + "exporters": [{ "id": "jira", "label": "Jira worklogs" }], + "canvasFields": [{ "key": "issue", "label": "Jira issue", "placeholder": "ACME-123" }], + "settings": [{ "key": "baseUrl", "label": "Jira URL" }], + "secrets": [{ "key": "token", "label": "API token" }] + }, + "permissions": { "network": ["*.atlassian.net"], "writeBlocks": false } + } +} +``` + +- **Bundled, dependency-free.** Authors bundle with esbuild or similar; the + app refuses a package with runtime `dependencies`. Installing is then + "fetch one tarball, check its hash, unpack", with no dependency tree to + resolve, audit or trust. +- **`contributes`** declares everything the app shows (exporters in the + review, fields in the canvas dialog, settings, secret prompts) so the UI can + be built without running the extension. +- **`permissions`** are shown to the user before first activation and + enforced where the API allows (see *Runtime*). +- Authors write against `@devlog/extension-api`: types plus a small test + harness, published from this repo (`packages/extension-api`). The runtime + object is injected by the app at activation. + +## How a devlog declares extensions + +In the existing manifest, `devlog.json`, with npm version specs: + +```json +{ + "format": 3, + "extensions": { + "@sdeken/devlog-jira": "^1.2.0", + "devlog-cms": "github:sdeken/devlog-cms#v0.3.1" + } +} +``` + +Plus a lockfile, `devlog.lock.json`, written by the app: the exact version, +tarball URL and integrity hash each spec resolved to. Every machine runs +byte-identical extension code, and an update is a visible, committed change +("devlog: update extensions"), never a silent drift. + +Why not a `package.json` in the devlog root: it would make the notebook look +like a JavaScript project, and anyone (or any tool) running `npm install` +there would put a `node_modules/` inside the notebook. `devlog.json` is +already the notebook's manifest. + +### Non-secret settings + +- **Per devlog** (Jira base URL, CMS project list): in `devlog.json` under + `"settings": { "@sdeken/devlog-jira": { … } }`, edited through the app. +- **Per canvas** (this client's Jira issue, CMS project): as namespaced + front-matter keys in the canvas's `canvas.md`, e.g. + `ext.@sdeken/devlog-jira.issue: ACME-123`. They follow the canvas through + renames and moves, and the canvas dialog shows them as ordinary fields + (from `contributes.canvasFields`). Task canvases inherit from the nearest + ancestor that sets one, like time already rolls up in the review. + *Prerequisite:* the `canvas.md` parser currently drops unknown keys; it + must preserve them. + +### Secrets and machine-local state + +Never in the repository. Kept in the app's user-data folder per extension, +encrypted with the OS keychain (Electron `safeStorage`: DPAPI on Windows, +Keychain on macOS, libsecret on Linux). The CMS password lives only there. +Extensions get `ctx.secrets.get/set` and `ctx.local` (a small JSON store) +and nothing else on disk. + +## Installing and updating + +- The app resolves specs and downloads tarballs itself with `pacote` (npm's + own fetcher: registry, scoped/private registries, `github:` and tarball + URLs), install scripts off, integrity verified against the lockfile. No + npm CLI needed on the machine. +- Packages are unpacked into `userData/extensions//`, shared by + every devlog that pins the same bytes; nothing is written into the devlog. +- **Update extensions** (a command) re-resolves the ranges, shows what + changed, rewrites the lockfile and commits it. Pulling a lockfile another + machine updated leads to a download plus a consent prompt, since the code + is new. +- **Development:** a machine-local override (in user data, never in the + devlog) maps a package name to a folder, for working on an extension + without publishing it. + +## Trust + +The honest model: **you trust what you install**, like a VS Code extension. +The app makes that trust explicit and hard to grant by accident: + +- The first activation of a given package *hash* in a given devlog asks, + showing the name, version, source, publisher and requested permissions. + Approvals are stored locally, so a devlog pulled onto a new machine, or a + lockfile changed by another machine, asks again. +- The API only exposes what was granted (no block writes without + `writeBlocks`, only the extension's own secrets). Main-process code + runs in a separate process (below), started with Node's permission model + so it has no file-system or child-process access beyond its own + directory. Network is allow-listed where the runtime can enforce it and + shown to the user either way. + +## Runtime + +Two halves, both optional: + +- **Main half** (`main`): exporters, commands, anything that talks to + the network. Runs in an Electron `utilityProcess` per extension: a crash + or hang cannot take the app down, and it can be restarted or disabled. It + talks to the app over a message channel exposing the API below. +- **Renderer half** (`devlog.renderer`): renderers for custom block kinds. + Runs in a sandboxed iframe (no Node, strict CSP, no network unless + granted), receives a block's markdown and metadata, and renders into its + own frame. If the extension is missing or disabled, the block falls back + to plain markdown. + +### API sketch (v1) + +```ts +export function activate(ctx: DevlogContext): void | Promise + +interface DevlogContext { + devlog: { + canvases(): Promise // with this extension's canvas fields + week(start: string): Promise // the review's rows: date × canvas, minutes, source + blocks(canvasId: string, date: string): Promise + addBlock?(canvasId: string, markdown: string, opts: { kind: string; meta?: Record }): Promise // needs writeBlocks + } + settings: { get(key: string): T | undefined } // devlog-level, from devlog.json + secrets: { get(key: string): Promise; set(key: string, value: string): Promise } + local: { get(key: string): T | undefined; set(key: string, value: unknown): Promise } + ui: { notify(message: string): void; confirm(message: string): Promise } + exporters: { register(id: string, exporter: Exporter): void } + commands: { register(id: string, label: string, run: () => Promise): void } +} + +interface Exporter { + /** Rows this exporter can take, with the target each maps to (issue key, project…). */ + plan(rows: TimeRow[], ledger: LedgerEntry[]): Promise + /** Send; return the external id per row so the ledger can prevent resubmission. */ + submit(planned: PlannedExport[]): Promise +} +``` + +## Time export (#2) + +1. In the weekly review, **Export** lists the exporters this devlog enables. +2. The app gives the exporter the review's rows (same rounding as the review) + and the ledger; the exporter plans rows → targets, subtracting what was + already sent. +3. Preview: day, target, hours, and optionally a comment built from that + day's blocks. Nothing is sent before the user confirms. +4. Submit; the result per row goes into the **export ledger**, which is + non-secret and lives in the devlog as append-only JSON lines + (`exports//YYYY/MM.jsonl`, union-merged like the activity + log): target, day, minutes, external id. Pressing Export again, or on + another machine, sends only the difference (or nothing). + +Jira: `POST /rest/api/3/issue/{key}/worklog` with an API token. CMS: depends +on what it accepts (API, form post, or browser automation as a last resort); +its exporter is where that knowledge lives. + +## Custom block kinds (#3) + +- Kinds are namespaced by extension (`x-outlook-meeting`), stored as normal + blocks: a `kind`, metadata on the record, readable markdown as the body. + Search and the index keep working, and nothing is lost if the extension + goes away. +- Blocks an extension creates are automatic blocks (read-only, muted brace), + like commit blocks today. + +## Milestones + +1. Core plumbing: `devlog.json` extensions + lockfile, preserving unknown + `canvas.md` keys, `safeStorage` secret store, `pacote` install into user + data, consent prompt, `utilityProcess` host, `@devlog/extension-api` with + a test harness. A trivial example extension (CSV exporter, #4) proves the + loop end to end. +2. Export flow in the review: preview, ledger, retries. Jira exporter. +3. CMS exporter. +4. Renderer half: sandboxed block renderers; then Outlook as the first real + custom block kind. + +## Open questions + +1. **Where do per-canvas mappings live?** Namespaced keys in `canvas.md` + (proposed) versus a mapping table in `devlog.json`. The former follows + the canvas and needs no ids; the latter keeps all of an extension's + settings in one place. +2. **Registry.** Public npm, GitHub Packages, or git URLs only? Your own + extensions could simply be `github:` specs with version tags, with no + registry at all. +3. **How strict is v1 isolation?** A separate process plus Node's + permission model is cheap; hard network allow-listing is not (it needs + the network to go through the app). Is "shown and consented, enforced + where cheap" enough for extensions you write yourself? +4. **Ledger granularity.** One ledger line per day × target (proposed), or + per submission batch? +5. **Scheduled exports.** Only by button (proposed), or also "every Friday + at 17:00, ask me"? From f3b31b587acf239d4f5782f4ae20a55f538d2879 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 12:53:30 +0000 Subject: [PATCH 02/38] Draft timesheet design: weekly synopsis between the time log and destinations Time reaches Jira (by task, with start times) and CMS (by client, weekly cap on the full-time client) through a weekly timesheet: sessions built from tracked time, rounded once to quarter hours, shuffled by hand, stored in a managed Timesheets canvas with one reply per submission as the send record. Destinations (extensions) declare mapping fields, grouping, dated rules and sending. EXTENSIONS.md now points at it. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAgWUf68JDhC5YPtWj1zT6 --- docs/EXTENSIONS.md | 55 +++++++--------- docs/TIMESHEETS.md | 159 +++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 182 insertions(+), 32 deletions(-) create mode 100644 docs/TIMESHEETS.md diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index d8c00c9..f158d7b 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -35,7 +35,7 @@ An npm package whose `package.json` has a `devlog` field: "displayName": "Jira time export", "renderer": "dist/renderer.js", // optional: block renderers (sandboxed) "contributes": { - "exporters": [{ "id": "jira", "label": "Jira worklogs" }], + "destinations": [{ "id": "jira", "label": "Jira worklogs" }], "canvasFields": [{ "key": "issue", "label": "Jira issue", "placeholder": "ACME-123" }], "settings": [{ "key": "baseUrl", "label": "Jira URL" }], "secrets": [{ "key": "token", "label": "API token" }] @@ -49,7 +49,7 @@ An npm package whose `package.json` has a `devlog` field: app refuses a package with runtime `dependencies`. Installing is then "fetch one tarball, check its hash, unpack", with no dependency tree to resolve, audit or trust. -- **`contributes`** declares everything the app shows (exporters in the +- **`contributes`** declares everything the app shows (destinations in the review, fields in the canvas dialog, settings, secret prompts) so the UI can be built without running the extension. - **`permissions`** are shown to the user before first activation and @@ -91,7 +91,8 @@ already the notebook's manifest. `ext.@sdeken/devlog-jira.issue: ACME-123`. They follow the canvas through renames and moves, and the canvas dialog shows them as ordinary fields (from `contributes.canvasFields`). Task canvases inherit from the nearest - ancestor that sets one, like time already rolls up in the review. + ancestor that sets one, like time already rolls up in the review. Mappings + and rules that change over time are dated (see `TIMESHEETS.md`). *Prerequisite:* the `canvas.md` parser currently drops unknown keys; it must preserve them. @@ -139,7 +140,7 @@ The app makes that trust explicit and hard to grant by accident: Two halves, both optional: -- **Main half** (`main`): exporters, commands, anything that talks to +- **Main half** (`main`): destinations, commands, anything that talks to the network. Runs in an Electron `utilityProcess` per extension: a crash or hang cannot take the app down, and it can be restarted or disabled. It talks to the app over a message channel exposing the API below. @@ -157,7 +158,7 @@ export function activate(ctx: DevlogContext): void | Promise interface DevlogContext { devlog: { canvases(): Promise // with this extension's canvas fields - week(start: string): Promise // the review's rows: date × canvas, minutes, source + timesheet(weekStart: string): Promise // the approved timesheet (see TIMESHEETS.md) blocks(canvasId: string, date: string): Promise addBlock?(canvasId: string, markdown: string, opts: { kind: string; meta?: Record }): Promise // needs writeBlocks } @@ -165,35 +166,26 @@ interface DevlogContext { secrets: { get(key: string): Promise; set(key: string, value: string): Promise } local: { get(key: string): T | undefined; set(key: string, value: unknown): Promise } ui: { notify(message: string): void; confirm(message: string): Promise } - exporters: { register(id: string, exporter: Exporter): void } + destinations: { register(id: string, destination: Destination): void } commands: { register(id: string, label: string, run: () => Promise): void } } -interface Exporter { - /** Rows this exporter can take, with the target each maps to (issue key, project…). */ - plan(rows: TimeRow[], ledger: LedgerEntry[]): Promise - /** Send; return the external id per row so the ledger can prevent resubmission. */ - submit(planned: PlannedExport[]): Promise +interface Destination { + /** Group an approved timesheet's entries into this system's lines (mapping, grouping, rules). */ + lines(sheet: Timesheet, settings: DatedSettings, sent: SentRecord[]): Promise + /** Send the lines; return an external id per line for the record. */ + submit(lines: DestinationLine[]): Promise } ``` ## Time export (#2) -1. In the weekly review, **Export** lists the exporters this devlog enables. -2. The app gives the exporter the review's rows (same rounding as the review) - and the ledger; the exporter plans rows → targets, subtracting what was - already sent. -3. Preview: day, target, hours, and optionally a comment built from that - day's blocks. Nothing is sent before the user confirms. -4. Submit; the result per row goes into the **export ledger**, which is - non-secret and lives in the devlog as append-only JSON lines - (`exports//YYYY/MM.jsonl`, union-merged like the activity - log): target, day, minutes, external id. Pressing Export again, or on - another machine, sends only the difference (or nothing). - -Jira: `POST /rest/api/3/issue/{key}/worklog` with an API token. CMS: depends -on what it accepts (API, form post, or browser automation as a last resort); -its exporter is where that knowledge lives. +See `TIMESHEETS.md`. Time goes through a weekly **timesheet** (core): a +synopsis you review and shuffle, rounded once to quarter hours, stored in a +managed *Timesheets* canvas together with a record of every submission. +Extensions contribute **destinations** (Jira by task with start times, CMS +by client with a weekly cap): mapping fields, grouping, destination rules, +and sending. ## Custom block kinds (#3) @@ -209,10 +201,11 @@ its exporter is where that knowledge lives. 1. Core plumbing: `devlog.json` extensions + lockfile, preserving unknown `canvas.md` keys, `safeStorage` secret store, `pacote` install into user data, consent prompt, `utilityProcess` host, `@devlog/extension-api` with - a test harness. A trivial example extension (CSV exporter, #4) proves the + a test harness. A trivial example extension proves the loop end to end. -2. Export flow in the review: preview, ledger, retries. Jira exporter. -3. CMS exporter. +2. Timesheets in core and the app: draft, grid, managed canvas, send + record (`TIMESHEETS.md`); CSV destination. +3. Jira and CMS destinations. 4. Renderer half: sandboxed block renderers; then Outlook as the first real custom block kind. @@ -229,7 +222,5 @@ its exporter is where that knowledge lives. permission model is cheap; hard network allow-listing is not (it needs the network to go through the app). Is "shown and consented, enforced where cheap" enough for extensions you write yourself? -4. **Ledger granularity.** One ledger line per day × target (proposed), or - per submission batch? -5. **Scheduled exports.** Only by button (proposed), or also "every Friday +4. **Scheduled exports.** Only by button (proposed), or also "every Friday at 17:00, ask me"? diff --git a/docs/TIMESHEETS.md b/docs/TIMESHEETS.md new file mode 100644 index 0000000..3438d20 --- /dev/null +++ b/docs/TIMESHEETS.md @@ -0,0 +1,159 @@ +# Timesheets: design (draft) + +Status: **draft for discussion**, nothing built yet. Replaces the simple +"export the review's rows" idea in `EXTENSIONS.md` (issue #2). + +## The problem + +Tracked time has to reach more than one outside system, and they disagree: + +| | Jira | CMS | +|---|---|---| +| Unit | a task (Jira issue) | a client | +| Needs | when the work happened (start + duration) | hours (per day or per week, TBD) | +| Ids | Jira issue keys | CMS client ids, unrelated to Jira's | +| Which clients | only some (the as-needed client) | all of them | +| Rules | none known | weekly cap: the full-time client is reported at most 40 h/week, even when more was worked | + +Across everything, **every reported duration is a multiple of 15 minutes** +(a firm rule). The rules change over time: clients come and go, and caps and +mappings change. + +So the time log cannot go straight to a destination. It goes through a +**timesheet**: a weekly synopsis you review and shuffle, then send to each +destination. The timesheet is also the permanent record of what was worked +and what was reported. + +## Shape + +``` +activity log + blocks ──► draft timesheet ──(you edit)──► approved timesheet + │ │ + rounding, sessions per-destination views + (Jira by task + times, + CMS by client, capped) + │ + preview ──► submit ──► recorded +``` + +- **The timesheet is core, destinations are extensions.** The synopsis comes + from Devlog's own tracking data, and several destinations share one + synopsis. It is useful on its own (history, CSV). Extensions (#1) + contribute destinations: how to group, which ids, which rules, how to send. +- **One timesheet per week** (Monday to Sunday, like the review). + +## The timesheet + +A list of **entries**, each one piece of work: + +| field | | +|---|---| +| date, start, minutes | when; `start` is local time, rounded to the quarter hour | +| canvas | the task (or client/project) canvas it belongs to | +| client | derived: the nearest ancestor canvas marked as a client | +| note | optional description, prefilled from that session's blocks | +| source | tracked / explicit (`[2h]` marker) / estimated / manual | +| worked | the unrounded minutes, kept for the record | + +### Building the draft + +1. Take the week's tracked task segments (what the timeline shows; removed + time already excluded, explicit durations already applied) and the + estimated time for untracked days. +2. **Merge into sessions:** consecutive segments on the same task with small + gaps (a few minutes of lock or idle) become one session. +3. **Round each session**: start to the nearest quarter hour, duration to + the nearest 15 minutes (never below 15 for a session that is kept). + Sessions that round to zero are listed separately so nothing disappears + silently. +4. Nudge starts so rounded sessions on the same day don't overlap. + +Rounding happens **once, here**. Every destination total is a sum of +rounded entries, so Jira and CMS always agree with each other and with the +timesheet, and every number is a multiple of 15 minutes by construction. + +### Editing ("shuffling") + +A grid for the week: drag an entry to another task or day, change start or +duration (in 15-minute steps), split an entry, merge two, add a manual one +(e.g. a call that wasn't tracked), drop one, edit the note. The grid shows, +live and per destination, what would be sent and which rules apply (for +example "CMS: Acme capped at 40 h, 3 h 15 m not reported"). + +Editing never changes the activity log or blocks. The timesheet is a +separate record, and the original tracked time stays available alongside it. + +## Destinations + +Each destination (from an extension) declares: + +- **Mapping:** which canvases it takes, and their external ids. These are + per-canvas fields, e.g. `jira.issue: ACME-123` on a task canvas and + `cms.client: 7731` on a client canvas, inherited down the tree. A canvas + with no mapping for a destination is not sent there (so the full-time + client never reaches Jira). An entry the destination should take but + can't map (a task with no Jira issue) is flagged in the grid, to fix + before sending. +- **Grouping:** how entries become lines. Jira: one worklog per entry + (issue, start, duration, note). CMS: per client per day (or week). +- **Rules:** adjustments applied to the grouped lines, shown before + sending: + - `weeklyCap` per client: report at most N hours in a week. Proposed + default: trim from the end of the week backwards; you can move the cut + to other days in the grid. + - more as needed (minimum per day, excluded days…), each a small pure + function over lines, unit-tested. +- **Send:** submit the lines; return an external id per line. + +### Rules change over time + +Destination settings (mappings, caps, which clients) are **dated**: each +change has a `from` date, and a week uses the settings in force on its +Monday. The approved timesheet records a snapshot of the rules it was sent +under, so an old week reads the same after the rules change. + +## Storage: a managed canvas + +The timesheets live in a Devlog-managed canvas, **Timesheets** (created on +first use, not a task, shown in the sidebar like any canvas): + +- **One block per week** (`kind=timesheet`, `week=2026-09-21`): the body is + a readable markdown table of the entries (date, start, duration, task, + client, note), so the record is human-readable in git with no app needed. + Edits while drafting are ordinary `edit` records (append-only, so the + history of the shuffling is kept too). +- **One reply per submission** (`kind=timesheet-sent`, + `destination=jira`): the lines sent, their external ids, and the rules + snapshot. This thread is the ledger: sending again compares against it, + so a second press, or a second machine, sends only what changed, and + corrections after the fact send differences. +- Timesheet blocks are automatic blocks (edited through the grid, not as + text). + +## Where the pieces live + +- `@devlog/core`: building the draft (sessions, rounding), the entry model, + reading and writing timesheet blocks, applying dated settings, generic + rules like `weeklyCap`. Pure and unit-tested. +- App: the weekly timesheet grid, the managed canvas, preview and submit UI. +- Extensions: Jira and CMS destinations (mapping fields, grouping, + destination-specific rules, sending, credentials). A built-in CSV + destination (#4) needs no extension. + +## Open questions + +1. **CMS granularity:** hours per client per *day* or per *week*? Does it + take a note or description? Start times? +2. **Capping:** when the full-time client passes 40 h, which hours go + unreported: the end of the week (proposed), spread evenly, or your pick + each time? Is the cap on *reported* CMS hours only (the timesheet keeps + the real total)? +3. **Jira mapping:** does every task under the as-needed client get its own + Jira issue, or is there a fallback issue per client for untracked + "misc" work? +4. **Jira start times:** rounded to the quarter hour too (proposed), or + the real start? +5. **Tiny sessions:** a session under 7½ minutes rounds to zero. Drop it + (listed, proposed), or fold it into the neighbouring session on the same + task? +6. **Week boundary for the cap:** does CMS's week also run Monday to Sunday? From 3781860317c46f6d24d4eff8cd8e56af46772424 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:13:29 +0000 Subject: [PATCH 03/38] Timesheet design: no business rules, firm rounding rule, suggested trims Per the decisions so far: destinations only map, group and send (no caps or other rules; the user finalises hours); the rounding rule is 15 minutes for anything above zero, then nearest quarter hour with halves rounding down; start times round to the quarter hour; rounding inflation is evened out by suggested trims on the longest sessions of the same client and day; CMS is per client per day, with ids found by walking up the canvas tree. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAgWUf68JDhC5YPtWj1zT6 --- docs/EXTENSIONS.md | 11 ++- docs/TIMESHEETS.md | 162 ++++++++++++++++++++++++++------------------- 2 files changed, 100 insertions(+), 73 deletions(-) diff --git a/docs/EXTENSIONS.md b/docs/EXTENSIONS.md index f158d7b..4b05681 100644 --- a/docs/EXTENSIONS.md +++ b/docs/EXTENSIONS.md @@ -91,8 +91,7 @@ already the notebook's manifest. `ext.@sdeken/devlog-jira.issue: ACME-123`. They follow the canvas through renames and moves, and the canvas dialog shows them as ordinary fields (from `contributes.canvasFields`). Task canvases inherit from the nearest - ancestor that sets one, like time already rolls up in the review. Mappings - and rules that change over time are dated (see `TIMESHEETS.md`). + ancestor that sets one, like time already rolls up in the review. *Prerequisite:* the `canvas.md` parser currently drops unknown keys; it must preserve them. @@ -171,8 +170,8 @@ interface DevlogContext { } interface Destination { - /** Group an approved timesheet's entries into this system's lines (mapping, grouping, rules). */ - lines(sheet: Timesheet, settings: DatedSettings, sent: SentRecord[]): Promise + /** Group a final timesheet's entries into this system's lines (mapping and grouping only). */ + lines(sheet: Timesheet, sent: SentRecord[]): Promise /** Send the lines; return an external id per line for the record. */ submit(lines: DestinationLine[]): Promise } @@ -184,8 +183,8 @@ See `TIMESHEETS.md`. Time goes through a weekly **timesheet** (core): a synopsis you review and shuffle, rounded once to quarter hours, stored in a managed *Timesheets* canvas together with a record of every submission. Extensions contribute **destinations** (Jira by task with start times, CMS -by client with a weekly cap): mapping fields, grouping, destination rules, -and sending. +by client per day): mapping fields, grouping and sending. No business +rules: you finalise the hours in the timesheet. ## Custom block kinds (#3) diff --git a/docs/TIMESHEETS.md b/docs/TIMESHEETS.md index 3438d20..aa86e56 100644 --- a/docs/TIMESHEETS.md +++ b/docs/TIMESHEETS.md @@ -10,28 +10,28 @@ Tracked time has to reach more than one outside system, and they disagree: | | Jira | CMS | |---|---|---| | Unit | a task (Jira issue) | a client | -| Needs | when the work happened (start + duration) | hours (per day or per week, TBD) | +| Needs | when the work happened (start + duration) | hours per client per day | | Ids | Jira issue keys | CMS client ids, unrelated to Jira's | | Which clients | only some (the as-needed client) | all of them | -| Rules | none known | weekly cap: the full-time client is reported at most 40 h/week, even when more was worked | -Across everything, **every reported duration is a multiple of 15 minutes** -(a firm rule). The rules change over time: clients come and go, and caps and -mappings change. +Every reported duration follows one firm rounding rule (below). Anything +else, such as how many hours a client should be billed in a week, changes +too often to encode. Devlog reports what was worked, rounded, and **you +finalise it**. So the time log cannot go straight to a destination. It goes through a -**timesheet**: a weekly synopsis you review and shuffle, then send to each +**timesheet**: a weekly synopsis you review and adjust, then send to each destination. The timesheet is also the permanent record of what was worked and what was reported. ## Shape ``` -activity log + blocks ──► draft timesheet ──(you edit)──► approved timesheet +activity log + blocks ──► draft timesheet ──(you adjust)──► final timesheet │ │ - rounding, sessions per-destination views - (Jira by task + times, - CMS by client, capped) + sessions, rounding, per-destination views + suggested trims (Jira: per task, with times + CMS: per client per day) │ preview ──► submit ──► recorded ``` @@ -39,8 +39,12 @@ activity log + blocks ──► draft timesheet ──(you edit)──► approv - **The timesheet is core, destinations are extensions.** The synopsis comes from Devlog's own tracking data, and several destinations share one synopsis. It is useful on its own (history, CSV). Extensions (#1) - contribute destinations: how to group, which ids, which rules, how to send. -- **One timesheet per week** (Monday to Sunday, like the review). + contribute destinations: which canvases they take, their ids, how lines + are grouped, and how to send them. Destinations contain **no business + rules** (caps, minimums): those are yours to apply while adjusting. +- **One timesheet per week**, Monday to Sunday like the review. CMS's own + weeks run Sunday to Saturday, but it takes hours per day, so where a week + is cut doesn't change what it receives. ## The timesheet @@ -55,6 +59,18 @@ A list of **entries**, each one piece of work: | source | tracked / explicit (`[2h]` marker) / estimated / manual | | worked | the unrounded minutes, kept for the record | +### Rounding (the firm rule) + +For a duration of `m` minutes: + +- `m = 0` → 0; +- `0 < m ≤ 22.5` → **15** (anything worked counts as at least a quarter hour); +- otherwise → the nearest multiple of 15, halves rounding down: 22.51–37.5 + → 30, 37.51–52.5 → 45, and so on. + +That is `m > 0 ? 15 · max(1, ⌈(m − 7.5) / 15⌉) : 0`. Start times round to the +nearest quarter hour. + ### Building the draft 1. Take the week's tracked task segments (what the timeline shows; removed @@ -62,23 +78,45 @@ A list of **entries**, each one piece of work: estimated time for untracked days. 2. **Merge into sessions:** consecutive segments on the same task with small gaps (a few minutes of lock or idle) become one session. -3. **Round each session**: start to the nearest quarter hour, duration to - the nearest 15 minutes (never below 15 for a session that is kept). - Sessions that round to zero are listed separately so nothing disappears - silently. -4. Nudge starts so rounded sessions on the same day don't overlap. - -Rounding happens **once, here**. Every destination total is a sum of -rounded entries, so Jira and CMS always agree with each other and with the -timesheet, and every number is a multiple of 15 minutes by construction. +3. **Round each session** with the rule above. Rounding happens once, here: + every destination total is a sum of rounded entries, so Jira, CMS and the + timesheet always agree, and every number is a multiple of 15 minutes by + construction. +4. Lay the rounded sessions out without overlaps: starts on quarter hours, + in the order they happened, each starting no earlier than the previous + one ends. + +### Rounding inflation, and suggested trims + +The rule inflates days full of short tasks: a dozen 2–5 minute tasks report +3 h for under an hour of work. It usually evens out because a long task +gets logged a bit short, but that's done by hand today. The draft does the +arithmetic and **suggests** the evening-out; you accept, change or ignore +each suggestion. + +- **Per client per day** (the CMS line): the target is the rounded total + actually worked for that client that day, `round(Σ worked)`. The draft's + line is `Σ round(each session)`. The difference is the inflation (or, + more rarely, a deficit). +- **Suggestion:** take the difference out of that client's longest sessions + that day, 15 minutes at a time (longest first, one step each in turn), + never taking a session below 15 minutes. A deficit adds to the longest + sessions the same way. Balancing within the same client and day keeps one + client's short tasks from being paid for out of another client's hours. +- If it can't be evened out (every session is already 15 minutes), the + grid says so: "Acme, Tue: 3 h reported for 55 min worked". +- The same numbers show per week, so a week can be checked at a glance. + +Suggestions are never applied silently. They appear as marked changes in +the grid. ### Editing ("shuffling") A grid for the week: drag an entry to another task or day, change start or duration (in 15-minute steps), split an entry, merge two, add a manual one (e.g. a call that wasn't tracked), drop one, edit the note. The grid shows, -live and per destination, what would be sent and which rules apply (for -example "CMS: Acme capped at 40 h, 3 h 15 m not reported"). +live and per destination, what would be sent, alongside the time actually +worked (for example "CMS · Acme · Tue: 9 h 15 m reported, 8 h 50 m worked"). Editing never changes the activity log or blocks. The timesheet is a separate record, and the original tracked time stays available alongside it. @@ -87,30 +125,28 @@ separate record, and the original tracked time stays available alongside it. Each destination (from an extension) declares: -- **Mapping:** which canvases it takes, and their external ids. These are - per-canvas fields, e.g. `jira.issue: ACME-123` on a task canvas and - `cms.client: 7731` on a client canvas, inherited down the tree. A canvas - with no mapping for a destination is not sent there (so the full-time - client never reaches Jira). An entry the destination should take but - can't map (a task with no Jira issue) is flagged in the grid, to fix - before sending. +- **Mapping:** which canvases it takes, and their external ids, as + per-canvas fields, looked up by **walking up the canvas tree** to the + nearest canvas that sets one: + - Jira: `jira.issue: ACME-123`, normally on each task canvas. A fallback + issue is just the key set on those tasks (or on a parent canvas, which + all tasks beneath it then inherit). + - CMS: `cms.client: 7731`, normally on the client canvas. Odd cases where + one real client has several CMS clients are sub-canvases (projects) + with their own `cms.client`, which wins for everything beneath them. + + A canvas with no mapping for a destination (nothing up the tree) is not + sent there, so the full-time client never reaches Jira. An entry under a + destination's canvases that can't be mapped (a task with no issue key) is + flagged in the grid before sending. - **Grouping:** how entries become lines. Jira: one worklog per entry - (issue, start, duration, note). CMS: per client per day (or week). -- **Rules:** adjustments applied to the grouped lines, shown before - sending: - - `weeklyCap` per client: report at most N hours in a week. Proposed - default: trim from the end of the week backwards; you can move the cut - to other days in the grid. - - more as needed (minimum per day, excluded days…), each a small pure - function over lines, unit-tested. + (issue, start, duration, note). CMS: one line per CMS client per day + (the sum of that day's entries). - **Send:** submit the lines; return an external id per line. -### Rules change over time - -Destination settings (mappings, caps, which clients) are **dated**: each -change has a `from` date, and a week uses the settings in force on its -Monday. The approved timesheet records a snapshot of the rules it was sent -under, so an old week reads the same after the rules change. +What was sent (lines, the ids they mapped to at the time, external ids) is +recorded with the timesheet, so an old week reads the same after mappings +change. No dated settings are needed. ## Storage: a managed canvas @@ -123,8 +159,8 @@ first use, not a task, shown in the sidebar like any canvas): Edits while drafting are ordinary `edit` records (append-only, so the history of the shuffling is kept too). - **One reply per submission** (`kind=timesheet-sent`, - `destination=jira`): the lines sent, their external ids, and the rules - snapshot. This thread is the ledger: sending again compares against it, + `destination=jira`): the lines sent, their external ids, and the ids the + entries mapped to at the time. This thread is the ledger: sending again compares against it, so a second press, or a second machine, sends only what changed, and corrections after the fact send differences. - Timesheet blocks are automatic blocks (edited through the grid, not as @@ -132,28 +168,20 @@ first use, not a task, shown in the sidebar like any canvas): ## Where the pieces live -- `@devlog/core`: building the draft (sessions, rounding), the entry model, - reading and writing timesheet blocks, applying dated settings, generic - rules like `weeklyCap`. Pure and unit-tested. +- `@devlog/core`: building the draft (sessions, rounding, layout), the + inflation arithmetic and suggested trims, the entry model, reading and + writing timesheet blocks, and resolving a canvas's mapping by walking up + the tree. Pure and unit-tested. - App: the weekly timesheet grid, the managed canvas, preview and submit UI. -- Extensions: Jira and CMS destinations (mapping fields, grouping, - destination-specific rules, sending, credentials). A built-in CSV - destination (#4) needs no extension. +- Extensions: Jira and CMS destinations (mapping fields, grouping, sending, + credentials). A built-in CSV destination (#4) needs no extension. ## Open questions -1. **CMS granularity:** hours per client per *day* or per *week*? Does it - take a note or description? Start times? -2. **Capping:** when the full-time client passes 40 h, which hours go - unreported: the end of the week (proposed), spread evenly, or your pick - each time? Is the cap on *reported* CMS hours only (the timesheet keeps - the real total)? -3. **Jira mapping:** does every task under the as-needed client get its own - Jira issue, or is there a fallback issue per client for untracked - "misc" work? -4. **Jira start times:** rounded to the quarter hour too (proposed), or - the real start? -5. **Tiny sessions:** a session under 7½ minutes rounds to zero. Drop it - (listed, proposed), or fold it into the neighbouring session on the same - task? -6. **Week boundary for the cap:** does CMS's week also run Monday to Sunday? +1. Does CMS take a description per line? If so, what goes in it: task titles + for the day? +2. The Jira worklog comment: the session's task title, the notes written + during it, or empty by default? +3. How big a gap still counts as the same session (a coffee break, a + meeting on another task in between)? Proposed: up to 15 minutes of + lock/idle, and never across a different task. From 81faf0605ad31b0db451d5316caafea75c4e6b53 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:25:44 +0000 Subject: [PATCH 04/38] Timesheet core: firm rounding rule, sessions, draft entries, suggested trims Pure functions in @devlog/core for the timesheet design: - roundWorkMinutes: anything above zero is 15 minutes, then the nearest quarter hour with exact halves rounding up; start times round to the nearest local quarter hour; - buildSessions: same-task segments with gaps of up to 30 minutes are one session (short locks count as work), split at longer gaps, at a switch to another task and at midnight; - draftEntries: each session rounded once, starts on quarter hours, no overlaps within a day; - balanceDays: per client (top-level canvas) per day, the rounded total worked versus what the entries report, with suggested 15-minute trims (or additions) on the longest entries, never below 15 minutes, and the residual when it cannot be evened out. Design doc updated with the decisions (halves round up, 30-minute gap rule, comments empty by default). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAgWUf68JDhC5YPtWj1zT6 --- docs/TIMESHEETS.md | 41 +++-- packages/core/src/format/canvases.ts | 6 + packages/core/src/index.ts | 1 + packages/core/src/timesheet.ts | 231 ++++++++++++++++++++++++++ packages/core/tests/timesheet.test.ts | 168 +++++++++++++++++++ 5 files changed, 431 insertions(+), 16 deletions(-) create mode 100644 packages/core/src/timesheet.ts create mode 100644 packages/core/tests/timesheet.test.ts diff --git a/docs/TIMESHEETS.md b/docs/TIMESHEETS.md index aa86e56..b12f93c 100644 --- a/docs/TIMESHEETS.md +++ b/docs/TIMESHEETS.md @@ -55,7 +55,7 @@ A list of **entries**, each one piece of work: | date, start, minutes | when; `start` is local time, rounded to the quarter hour | | canvas | the task (or client/project) canvas it belongs to | | client | derived: the nearest ancestor canvas marked as a client | -| note | optional description, prefilled from that session's blocks | +| note | optional; empty by default (see *Comments*) | | source | tracked / explicit (`[2h]` marker) / estimated / manual | | worked | the unrounded minutes, kept for the record | @@ -64,20 +64,24 @@ A list of **entries**, each one piece of work: For a duration of `m` minutes: - `m = 0` → 0; -- `0 < m ≤ 22.5` → **15** (anything worked counts as at least a quarter hour); -- otherwise → the nearest multiple of 15, halves rounding down: 22.51–37.5 - → 30, 37.51–52.5 → 45, and so on. +- `0 < m < 22.5` → **15** (anything worked counts as at least a quarter hour); +- otherwise → the nearest multiple of 15, exact halves rounding up: + 22.5–37.49… → 30, 37.5–52.49… → 45, and so on. -That is `m > 0 ? 15 · max(1, ⌈(m − 7.5) / 15⌉) : 0`. Start times round to the -nearest quarter hour. +That is `m > 0 ? 15 · max(1, ⌊m / 15 + ½⌋) : 0`. Start times round to the +nearest quarter hour, halves up. ### Building the draft 1. Take the week's tracked task segments (what the timeline shows; removed time already excluded, explicit durations already applied) and the estimated time for untracked days. -2. **Merge into sessions:** consecutive segments on the same task with small - gaps (a few minutes of lock or idle) become one session. +2. **Merge into sessions:** segments on the same task, with no other task + in between, are one session unless a gap between them is **longer than + 30 minutes** (lunch). Shorter gaps (the screen locked for a break) count + as work: a session runs from its first segment's start to its last + segment's end, so its worked time can be a little more than the tracked + time the review shows. Sessions are also split at midnight. 3. **Round each session** with the rule above. Rounding happens once, here: every destination total is a sum of rounded entries, so Jira, CMS and the timesheet always agree, and every number is a multiple of 15 minutes by @@ -94,7 +98,8 @@ gets logged a bit short, but that's done by hand today. The draft does the arithmetic and **suggests** the evening-out; you accept, change or ignore each suggestion. -- **Per client per day** (the CMS line): the target is the rounded total +- **Per client per day** (the CMS line; a client here is the top-level + canvas a task sits under): the target is the rounded total actually worked for that client that day, `round(Σ worked)`. The draft's line is `Σ round(each session)`. The difference is the inflation (or, more rarely, a deficit). @@ -144,6 +149,15 @@ Each destination (from an extension) declares: (the sum of that day's entries). - **Send:** submit the lines; return an external id per line. +### Comments + +- **CMS** takes an optional comment per client per day. It is sent empty + by default. Later, an LLM could draft it from that day's notes. +- **Jira** worklog comments are optional too, and meetings often have none. + Empty by default, with a per-entry "fill from notes" that takes the + top-level blocks written on the task during that session. Revisit once + it's clear how the notes get used in practice. + What was sent (lines, the ids they mapped to at the time, external ids) is recorded with the timesheet, so an old week reads the same after mappings change. No dated settings are needed. @@ -178,10 +192,5 @@ first use, not a task, shown in the sidebar like any canvas): ## Open questions -1. Does CMS take a description per line? If so, what goes in it: task titles - for the day? -2. The Jira worklog comment: the session's task title, the notes written - during it, or empty by default? -3. How big a gap still counts as the same session (a coffee break, a - meeting on another task in between)? Proposed: up to 15 minutes of - lock/idle, and never across a different task. +None blocking. To revisit with use: what, if anything, fills Jira and CMS +comments by default. diff --git a/packages/core/src/format/canvases.ts b/packages/core/src/format/canvases.ts index 7d044a7..08ee890 100644 --- a/packages/core/src/format/canvases.ts +++ b/packages/core/src/format/canvases.ts @@ -139,6 +139,12 @@ export function ancestorIds(canvases: CanvasMeta[], id: string): string[] { return out } +/** The top-level canvas a canvas sits under (itself when it is top level): the client, by convention. */ +export function topLevelCanvasId(canvases: CanvasMeta[], id: string): string { + const up = ancestorIds(canvases, id) + return up.length ? up[up.length - 1] : id +} + /** Ids of every canvas beneath `id` (any depth). */ export function descendantCanvasIds(canvases: CanvasMeta[], id: string): string[] { const out: string[] = [] diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 12206c2..eac9501 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -10,3 +10,4 @@ export * from './format/blocks' export * from './format/canvases' export * from './format/order' export * from './format/oplog' +export * from './timesheet' diff --git a/packages/core/src/timesheet.ts b/packages/core/src/timesheet.ts new file mode 100644 index 0000000..5f2c460 --- /dev/null +++ b/packages/core/src/timesheet.ts @@ -0,0 +1,231 @@ +/** + * Timesheets: turning tracked time into the entries reported to outside + * systems (see docs/TIMESHEETS.md). Pure functions, no I/O. + * + * 1. `buildSessions`: tracked segments → sessions (same task, gaps up to + * 30 minutes count as work, split at midnight and at a task switch). + * 2. `draftEntries`: sessions → entries, each rounded once with the firm + * rounding rule and laid out on quarter hours without overlaps. + * 3. `balanceDays`: per group (client) per day, compare what the rounded + * entries report with the rounded total actually worked, and suggest + * which entries to trim (or extend) to even it out. + */ +import { localDate } from './format/blocks' + +export const QUARTER_MINUTES = 15 +const QUARTER_MS = QUARTER_MINUTES * 60_000 +const MINUTE_MS = 60_000 + +/** + * The firm rounding rule: 0 stays 0; anything above 0 is at least 15 + * minutes; beyond that, the nearest multiple of 15 with exact halves + * rounding up (22.5 → 30, 37.5 → 45). + */ +export function roundWorkMinutes(minutes: number): number { + if (!(minutes > 0)) return 0 + return roundWorkMs(Math.round(minutes * MINUTE_MS)) / MINUTE_MS +} + +/** The rounding rule on milliseconds (exact integer arithmetic), returning milliseconds. */ +export function roundWorkMs(ms: number): number { + if (!(ms > 0)) return 0 + const quarters = Math.floor((2 * ms + QUARTER_MS) / (2 * QUARTER_MS)) // nearest, halves up + return Math.max(1, quarters) * QUARTER_MS +} + +/** An instant rounded to the nearest quarter hour of local time (halves up). */ +export function roundToQuarterHour(date: Date): Date { + const local = new Date(date) + const msIntoHour = local.getMinutes() * MINUTE_MS + local.getSeconds() * 1000 + local.getMilliseconds() + const quarters = Math.floor((2 * msIntoHour + QUARTER_MS) / (2 * QUARTER_MS)) + local.setMinutes(0, 0, 0) + return new Date(local.getTime() + quarters * QUARTER_MS) +} + +// --------------------------------------------------------------------------- +// Sessions +// --------------------------------------------------------------------------- + +/** A stretch of tracked time on one canvas (what the tracker replays into). */ +export interface WorkSegment { + canvasId: string + start: string + end: string +} + +export interface Session { + canvasId: string + /** Local date the session is on. */ + date: string + start: string + end: string + /** From first start to last end, short gaps included. */ + workedMinutes: number +} + +export interface SessionOptions { + /** Gaps up to this long on the same task still count as work. Default 30. */ + maxGapMinutes?: number +} + +/** + * Merge segments into sessions: consecutive segments on the same canvas, + * with no other canvas in between, are one session unless a gap between + * them is longer than `maxGapMinutes`. Sessions never cross local midnight. + */ +export function buildSessions(segments: WorkSegment[], opts: SessionOptions = {}): Session[] { + const maxGap = (opts.maxGapMinutes ?? 30) * MINUTE_MS + const pieces: Array<{ canvasId: string; start: number; end: number }> = [] + for (const seg of segments) { + let s = Date.parse(seg.start) + const e = Date.parse(seg.end) + if (!(e > s)) continue + // Split at local midnight. + while (s < e) { + const d = new Date(s) + const midnight = new Date(d.getFullYear(), d.getMonth(), d.getDate() + 1).getTime() + const pieceEnd = Math.min(e, midnight) + pieces.push({ canvasId: seg.canvasId, start: s, end: pieceEnd }) + s = pieceEnd + } + } + pieces.sort((a, b) => a.start - b.start || a.end - b.end) + + const out: Session[] = [] + let cur: { canvasId: string; start: number; end: number } | null = null + const close = (): void => { + if (!cur) return + out.push({ + canvasId: cur.canvasId, + date: localDate(new Date(cur.start)), + start: new Date(cur.start).toISOString(), + end: new Date(cur.end).toISOString(), + workedMinutes: (cur.end - cur.start) / MINUTE_MS + }) + cur = null + } + for (const p of pieces) { + if ( + cur && + p.canvasId === cur.canvasId && + p.start - cur.end <= maxGap && + localDate(new Date(p.start)) === localDate(new Date(cur.start)) + ) { + cur.end = Math.max(cur.end, p.end) + continue + } + close() + cur = { ...p } + } + close() + return out +} + +// --------------------------------------------------------------------------- +// Draft entries +// --------------------------------------------------------------------------- + +export type EntrySource = 'tracked' | 'manual' + +export interface TimesheetEntry { + id: string + date: string + /** Rounded start (ISO), on a local quarter hour. */ + start: string + /** Reported minutes: a multiple of 15, at least 15. */ + minutes: number + canvasId: string + /** Unrounded minutes actually worked, kept for the record. */ + worked: number + source: EntrySource + note?: string +} + +/** + * Round sessions into entries. Each duration is rounded once with the firm + * rule; starts go to the nearest quarter hour, then move later where needed + * so that entries on the same day never overlap (in the order they happened). + */ +export function draftEntries(sessions: Session[]): TimesheetEntry[] { + const sorted = [...sessions].sort((a, b) => a.start.localeCompare(b.start)) + const out: TimesheetEntry[] = [] + const dayEnd = new Map() + sorted.forEach((s, i) => { + const minutes = roundWorkMinutes(s.workedMinutes) + if (minutes === 0) return + let start = roundToQuarterHour(new Date(s.start)).getTime() + const busyUntil = dayEnd.get(s.date) + if (busyUntil !== undefined && start < busyUntil) start = busyUntil + dayEnd.set(s.date, start + minutes * MINUTE_MS) + out.push({ id: `e${i + 1}`, date: s.date, start: new Date(start).toISOString(), minutes, canvasId: s.canvasId, worked: s.workedMinutes, source: 'tracked' }) + }) + return out +} + +// --------------------------------------------------------------------------- +// Balancing rounding inflation +// --------------------------------------------------------------------------- + +export interface Adjustment { + entryId: string + from: number + to: number +} + +export interface DayBalance { + date: string + /** The grouping key, normally the client (top-level canvas). */ + group: string + /** Σ unrounded minutes worked. */ + worked: number + /** The rounded total actually worked: what the day should report. */ + target: number + /** Σ entry minutes as they stand. */ + reported: number + /** Suggested changes that bring `reported` to `target` (never below 15 per entry). */ + suggestions: Adjustment[] + /** What the suggestions could not even out (positive: still over-reported). */ + residual: number +} + +/** + * For each group and day, suggest how to even out rounding: take the + * difference between what the entries report and the rounded total worked + * out of (or add it to) the longest entries, 15 minutes at a time, never + * taking an entry below 15 minutes. Suggestions only; nothing is changed. + */ +export function balanceDays(entries: TimesheetEntry[], groupOf: (canvasId: string) => string): DayBalance[] { + const groups = new Map() + for (const e of entries) { + const key = `${e.date}\u0000${groupOf(e.canvasId)}` + groups.set(key, [...(groups.get(key) ?? []), e]) + } + const out: DayBalance[] = [] + for (const [key, list] of [...groups].sort(([a], [b]) => a.localeCompare(b))) { + const [date, group] = key.split('\u0000') + const worked = list.reduce((n, e) => n + e.worked, 0) + const target = roundWorkMinutes(worked) + const reported = list.reduce((n, e) => n + e.minutes, 0) + const proposed = new Map(list.map((e) => [e.id, e.minutes])) + // Longest first; ties by more time worked, then by id, so suggestions are stable. + const pick = (canShrink: boolean): TimesheetEntry | undefined => + list + .filter((e) => !canShrink || proposed.get(e.id)! > QUARTER_MINUTES) + .sort((a, b) => proposed.get(b.id)! - proposed.get(a.id)! || b.worked - a.worked || a.id.localeCompare(b.id))[0] + let diff = reported - target + while (diff > 0) { + const e = pick(true) + if (!e) break + proposed.set(e.id, proposed.get(e.id)! - QUARTER_MINUTES) + diff -= QUARTER_MINUTES + } + while (diff < 0) { + const e = pick(false)! + proposed.set(e.id, proposed.get(e.id)! + QUARTER_MINUTES) + diff += QUARTER_MINUTES + } + const suggestions = list.filter((e) => proposed.get(e.id) !== e.minutes).map((e) => ({ entryId: e.id, from: e.minutes, to: proposed.get(e.id)! })) + out.push({ date, group, worked, target, reported, suggestions, residual: diff }) + } + return out +} diff --git a/packages/core/tests/timesheet.test.ts b/packages/core/tests/timesheet.test.ts new file mode 100644 index 0000000..2a1fc18 --- /dev/null +++ b/packages/core/tests/timesheet.test.ts @@ -0,0 +1,168 @@ +import { describe, expect, it } from 'vitest' +import { + balanceDays, + buildSessions, + draftEntries, + roundToQuarterHour, + roundWorkMinutes, + topLevelCanvasId, + type TimesheetEntry, + type WorkSegment +} from '../src/index' +import type { CanvasMeta } from '../src/types' + +/** A local time on Tuesday 2026-09-22 (or another day), as ISO. */ +const at = (h: number, m = 0, s = 0, day = 22): string => new Date(2026, 8, day, h, m, s).toISOString() +const seg = (canvasId: string, start: string, end: string): WorkSegment => ({ canvasId, start, end }) +const localHM = (iso: string): string => { + const d = new Date(iso) + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}` +} + +describe('the rounding rule', () => { + it('counts anything above zero as a quarter hour, then rounds to the nearest quarter, halves up', () => { + const cases: Array<[number, number]> = [ + [0, 0], + [-3, 0], + [0.01, 15], + [0.5, 15], + [7.5, 15], + [22.49, 15], + [22.5, 30], + [30, 30], + [37.49, 30], + [37.5, 45], + [52.49, 45], + [52.5, 60], + [420, 420], + [427.4, 420], + [427.5, 435] + ] + for (const [m, want] of cases) expect(roundWorkMinutes(m), `${m} min`).toBe(want) + }) + + it('rounds start times to the nearest local quarter hour, halves up', () => { + expect(localHM(roundToQuarterHour(new Date(at(9, 7, 29))).toISOString())).toBe('09:00') + expect(localHM(roundToQuarterHour(new Date(at(9, 7, 30))).toISOString())).toBe('09:15') + expect(localHM(roundToQuarterHour(new Date(at(9, 53))).toISOString())).toBe('10:00') + expect(localHM(roundToQuarterHour(new Date(at(23, 55))).toISOString())).toBe('00:00') + }) +}) + +describe('sessions', () => { + it('counts short breaks on the same task as work and splits at longer ones', () => { + const sessions = buildSessions([ + seg('a', at(9), at(10)), + seg('a', at(10, 20), at(11)), // a 20-minute bathroom/coffee lock: still one session + seg('a', at(12, 0), at(13)), // a 60-minute lunch: a new session + seg('a', at(13, 30), at(13, 45)) // exactly 30 minutes: still the same session + ]) + expect(sessions.map((s) => [localHM(s.start), localHM(s.end), s.workedMinutes])).toEqual([ + ['09:00', '11:00', 120], + ['12:00', '13:45', 105] + ]) + expect(buildSessions([seg('a', at(9), at(10)), seg('a', at(10, 31), at(11))])).toHaveLength(2) + }) + + it('never joins across a switch to another task, and splits at midnight', () => { + const sessions = buildSessions([seg('a', at(9), at(9, 30)), seg('b', at(9, 35), at(9, 40)), seg('a', at(9, 45), at(10))]) + expect(sessions.map((s) => s.canvasId)).toEqual(['a', 'b', 'a']) + const late = buildSessions([seg('a', at(23, 30), at(0, 30, 0, 23))]) + expect(late.map((s) => [s.date, s.workedMinutes])).toEqual([ + ['2026-09-22', 30], + ['2026-09-23', 30] + ]) + }) +}) + +describe('draft entries', () => { + it('rounds each session once and lays starts out on quarter hours without overlaps', () => { + const entries = draftEntries( + buildSessions([ + seg('a', at(9, 2), at(9, 6)), // 4 min → 15, starts 09:00 + seg('b', at(9, 7), at(9, 10)), // 3 min → 15, would start 09:15 (09:07 rounds to 09:00, but a holds it) + seg('c', at(9, 11), at(10, 40)) // 89 min → 90, 09:11 rounds to 09:15 but b holds it → 09:30 + ]) + ) + expect(entries.map((e) => [e.canvasId, localHM(e.start), e.minutes, e.worked])).toEqual([ + ['a', '09:00', 15, 4], + ['b', '09:15', 15, 3], + ['c', '09:30', 90, 89] + ]) + for (const e of entries) expect(e.minutes % 15).toBe(0) + }) +}) + +describe('balancing rounding inflation', () => { + const entry = (id: string, canvasId: string, minutes: number, worked: number, date = '2026-09-22'): TimesheetEntry => ({ + id, + date, + start: at(9), + minutes, + canvasId, + worked, + source: 'tracked' + }) + const sameGroup = (): string => 'acme' + + it('suggests logging the long task shorter when many short tasks inflate the day', () => { + // A dozen 4-minute tasks and a 7-hour task, all for the same client: 7 h 48 m worked. + const entries = [...Array.from({ length: 12 }, (_, i) => entry(`s${i}`, `t${i}`, 15, 4)), entry('long', 'big', 420, 420)] + const [day] = balanceDays(entries, sameGroup) + expect(day).toMatchObject({ worked: 468, target: 465, reported: 600, residual: 0 }) + expect(day.suggestions).toEqual([{ entryId: 'long', from: 420, to: 285 }]) // 7 h → 4 h 45 m + }) + + it('spreads trims over the longest entries in turn and never goes below 15 minutes', () => { + const entries = [entry('a', 'x', 60, 55), entry('b', 'y', 60, 52), ...Array.from({ length: 6 }, (_, i) => entry(`s${i}`, `z${i}`, 15, 2))] + const [day] = balanceDays(entries, sameGroup) + // worked 119 → target 120; reported 210; trim 90 minutes from a and b, 15 at a time, longest first. + expect(day.target).toBe(120) + expect(day.suggestions).toEqual([ + { entryId: 'a', from: 60, to: 15 }, + { entryId: 'b', from: 60, to: 15 } + ]) + expect(day.residual).toBe(0) + }) + + it('says so when the inflation cannot be evened out', () => { + const entries = Array.from({ length: 5 }, (_, i) => entry(`s${i}`, `t${i}`, 15, 2)) + const [day] = balanceDays(entries, sameGroup) + expect(day).toMatchObject({ worked: 10, target: 15, reported: 75, suggestions: [], residual: 60 }) + }) + + it('adds to the longest entry when rounding under-reports', () => { + const entries = [entry('a', 'x', 30, 37), entry('b', 'y', 30, 36)] // 73 worked → 75, reported 60 + const [day] = balanceDays(entries, sameGroup) + expect(day.suggestions).toEqual([{ entryId: 'a', from: 30, to: 45 }]) + expect(day.residual).toBe(0) + }) + + it('balances each client and each day separately', () => { + const canvases = [ + { id: 'acme', parentId: null }, + { id: 'acme-web', parentId: 'acme' }, + { id: 'fix-login', parentId: 'acme-web' }, + { id: 'globex', parentId: null } + ] as CanvasMeta[] + const client = (id: string): string => topLevelCanvasId(canvases, id) + expect(client('fix-login')).toBe('acme') + expect(client('globex')).toBe('globex') + const entries = [ + entry('a1', 'fix-login', 15, 3), + entry('a2', 'acme-web', 15, 3), + entry('a3', 'acme', 120, 118), + entry('g1', 'globex', 240, 240), + entry('a4', 'fix-login', 60, 58, '2026-09-23') + ] + const days = balanceDays(entries, client) + expect(days.map((d) => [d.date, d.group, d.reported, d.target])).toEqual([ + ['2026-09-22', 'acme', 150, 120], + ['2026-09-22', 'globex', 240, 240], + ['2026-09-23', 'acme', 60, 60] + ]) + // Acme's short tasks are paid for out of Acme's long entry, never Globex's. + expect(days[0].suggestions).toEqual([{ entryId: 'a3', from: 120, to: 90 }]) + expect(days[1].suggestions).toEqual([]) + }) +}) From 39b0594a0465e4d5eec16915bc1e31e7aa196fc4 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 13:26:07 +0000 Subject: [PATCH 05/38] Include the timesheet module in the renderer's typecheck Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAgWUf68JDhC5YPtWj1zT6 --- tsconfig.web.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tsconfig.web.json b/tsconfig.web.json index 50e54f2..b0e6c6f 100644 --- a/tsconfig.web.json +++ b/tsconfig.web.json @@ -20,5 +20,5 @@ "@devlog/core/*": ["packages/core/src/*"] } }, - "include": ["src/renderer/**/*", "src/shared/**/*", "src/preload/index.d.ts", "packages/core/src/index.ts", "packages/core/src/types.ts", "packages/core/src/format/**/*"] + "include": ["src/renderer/**/*", "src/shared/**/*", "src/preload/index.d.ts", "packages/core/src/index.ts", "packages/core/src/types.ts", "packages/core/src/format/**/*", "packages/core/src/timesheet.ts"] } From f8e84c18151b8d0a3976eb2e5b260c7fc1d655c0 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 25 Sep 2026 16:07:20 +0000 Subject: [PATCH 06/38] Fast weekly review and summary; back/forward navigation; resizable todo panel - The cross-machine merge of tracked segments (flattenOverlaps) compared every segment with every other: a week of window-focus changes took over 20 seconds to compute, stalling the weekly review and summary. It is now a sweep over the boundaries (O(n log n)), skipped entirely when all time comes from one machine; tests check it matches the old results exactly. A week with ~1,200 focus changes a day now computes in about 0.1 s, and the smoke test checks both views open in under 3 s with a busy day from two machines. - Back / Forward (View menu; Alt+Left / Alt+Right, Cmd+[ / Cmd+] on macOS; the mouse's side buttons) walk the places visited: canvases, views, timeline days. Keys are caught before the editor sees them; deleted canvases are skipped. - The todo panel resizes by dragging its left edge (or arrow keys on the handle); the width is remembered, double-click resets it. - 0.5.1. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CAgWUf68JDhC5YPtWj1zT6 --- package-lock.json | 4 +- package.json | 2 +- scripts/smoke.mjs | 65 +++++++++++++++- src/main/index.ts | 2 + src/main/menu.ts | 6 ++ src/renderer/src/App.tsx | 74 ++++++++++++++++++ src/renderer/src/components/TodoPanel.tsx | 73 +++++++++++++++++- src/renderer/src/styles.css | 33 ++++++-- src/shared/activity.ts | 91 ++++++++++++++++++----- src/shared/ipc.ts | 2 +- tests/activity.test.ts | 38 ++++++++++ 11 files changed, 359 insertions(+), 31 deletions(-) diff --git a/package-lock.json b/package-lock.json index 846a118..0dfab96 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "devlog", - "version": "0.5.0", + "version": "0.5.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "devlog", - "version": "0.5.0", + "version": "0.5.1", "license": "MIT", "dependencies": { "@tiptap/core": "^3.31.3", diff --git a/package.json b/package.json index 3363438..4ce0d62 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "devlog", - "version": "0.5.0", + "version": "0.5.1", "description": "A git-backed developer log with a Slack-style WYSIWYG Markdown composer", "main": "./out/main/index.js", "author": "Stephen Deken", diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index 5de2579..fa5f8bf 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -525,7 +525,57 @@ try { check((await page.locator('.sum-children .sum-label').first().textContent()).includes('Website'), 'summary rows expand into projects') await page.screenshot({ path: path.join(shots, '02f-summary.png') }) - // 3h. Quick switcher jumps to a canvas by fuzzy name. + // 3g'. Speed: with a busy day of window switching from a second machine, the review and summary still open quickly. + const busyDir = path.join(repo, 'activity', 'busy-machine-0000', String(today.getFullYear()), String(today.getMonth() + 1).padStart(2, '0')) + await fs.mkdir(busyDir, { recursive: true }) + const dayStart = new Date(today.getFullYear(), today.getMonth(), today.getDate()).getTime() + const span = Math.max(60_000, Date.now() - dayStart - 60_000) + const busy = [JSON.stringify({ t: new Date(dayStart).toISOString(), type: 'start', canvasId: null })] + for (let i = 0; i < 6000; i++) { + const t = new Date(dayStart + Math.floor((span * (i + 1)) / 6001)).toISOString() + busy.push(JSON.stringify(i % 50 ? { t, type: 'focus', app: `app${i % 7}`, title: `window ${i % 23}` } : { t, type: 'heartbeat' })) + } + await fs.writeFile(path.join(busyDir, `${ymd}.jsonl`), `${busy.join('\n')}\n`) + for (const [label, sel] of [ + ['Weekly review', '.review-table'], + ['Summary', '.sum-list'] + ]) { + await page.locator('.sidebar-views .view-link', { hasText: 'Journal' }).click() + await page.waitForSelector('.page-head .crumb.is-current:has-text("Journal")') + const t0 = Date.now() + await page.locator('.sidebar-views .view-link', { hasText: label }).click() + await page.waitForSelector(sel, { timeout: 60_000 }) + const took = Date.now() - t0 + check(took < 3000, `${label} opens quickly with a busy day of activity from two machines (${took} ms)`) + } + await fs.rm(path.join(repo, 'activity', 'busy-machine-0000'), { recursive: true, force: true }) + + // 3g''. Back and forward: Alt+← / Alt+→ walk the places visited (Journal → Weekly review → Summary). + await page.locator('.sidebar-views .view-link', { hasText: 'Journal' }).click() + await page.waitForSelector('.page-head .crumb.is-current:has-text("Journal")') + await page.locator('.sidebar-views .view-link', { hasText: 'Weekly review' }).click() + await page.waitForSelector('.review-table', { timeout: 10_000 }) + await page.locator('.sidebar-views .view-link', { hasText: 'Summary' }).click() + await page.waitForSelector('.sum-list', { timeout: 10_000 }) + await page.keyboard.press('Alt+ArrowLeft') + await page.waitForSelector('.review-table', { timeout: 10_000 }) + check(true, 'Alt+← goes back to the weekly review') + await page.keyboard.press('Alt+ArrowLeft') + await page.waitForSelector('.page-head .crumb.is-current:has-text("Journal")', { timeout: 10_000 }) + check(true, 'Alt+← again goes back to the journal') + await page.keyboard.press('Alt+ArrowRight') + await page.waitForSelector('.review-table', { timeout: 10_000 }) + check(true, 'Alt+→ goes forward again') + await page.locator('.sidebar-views .view-link', { hasText: 'Journal' }).click() + await page.waitForSelector('.page-head .crumb.is-current:has-text("Journal")') + await page.locator('.composer-new .composer-editor').click() + await page.keyboard.press('Alt+ArrowLeft') + await page.waitForSelector('.review-table', { timeout: 10_000 }) + check(true, 'Alt+← works while typing in the composer') + await page.locator('.sidebar-views .view-link', { hasText: 'Journal' }).click() + await page.waitForSelector('.page-head .crumb.is-current:has-text("Journal")') + + await page.keyboard.press('Control+k') await page.waitForSelector('.switcher input', { timeout: 5_000 }) await page.keyboard.type('websit') @@ -626,6 +676,19 @@ try { check((await page.locator('.todo-expand-count').textContent()) === '2', 'collapsed, the panel still shows the open count') await page.locator('.todo-expand').click() await page.waitForSelector('.todo-panel:not(.is-collapsed)') + // Resizing: drag the panel's left edge; double-click resets it. + const panelWidth = async () => Math.round((await page.locator('.todo-panel').boundingBox()).width) + const panelBefore = await panelWidth() + const handle = await page.locator('.todo-resize').boundingBox() + await page.mouse.move(handle.x + handle.width / 2, handle.y + 200) + await page.mouse.down() + await page.mouse.move(handle.x + handle.width / 2 - 60, handle.y + 200, { steps: 6 }) + await page.mouse.up() + const wider = await panelWidth() + check(wider >= panelBefore + 50 && wider <= panelBefore + 70, `dragging the left edge widens the todo panel (${panelBefore} → ${wider} px)`) + check((await page.evaluate(() => localStorage.getItem('devlog:todos:width'))) === String(wider), 'the width is remembered') + await page.locator('.todo-resize').dblclick() + check((await panelWidth()) === 300, 'double-clicking the edge resets the width') await page.locator('.sidebar-views .view-link', { hasText: 'Journal' }).click() await page.waitForSelector('.page-head .crumb.is-current:has-text("Journal")') diff --git a/src/main/index.ts b/src/main/index.ts index b451b99..4a5c33f 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -608,6 +608,8 @@ if (!gotLock) { summary: menuCmd('summary'), switcher: menuCmd('switcher'), timeline: menuCmd('timeline'), + back: menuCmd('back'), + forward: menuCmd('forward'), stopTask: () => void tracker?.setTask(null), quit: quitApp, attachImage: async () => { diff --git a/src/main/menu.ts b/src/main/menu.ts index 2c8520b..281b4fb 100644 --- a/src/main/menu.ts +++ b/src/main/menu.ts @@ -12,6 +12,8 @@ export interface MenuActions { summary: () => void switcher: () => void timeline: () => void + back: () => void + forward: () => void stopTask: () => void quit: () => void } @@ -83,6 +85,10 @@ export function buildMenu(win: () => BrowserWindow | null, actions: MenuActions) { label: 'Go to Canvas…', accelerator: 'CmdOrCtrl+P', click: actions.switcher }, { label: 'Day Timeline', accelerator: 'CmdOrCtrl+Shift+T', click: actions.timeline }, { type: 'separator' }, + // The keys are handled in the page (App.tsx) so they work inside the editor; the menu only shows them. + { label: 'Back', accelerator: isMac ? 'Cmd+[' : 'Alt+Left', registerAccelerator: false, click: actions.back }, + { label: 'Forward', accelerator: isMac ? 'Cmd+]' : 'Alt+Right', registerAccelerator: false, click: actions.forward }, + { type: 'separator' }, { role: 'reload' }, { role: 'toggleDevTools' }, { type: 'separator' }, diff --git a/src/renderer/src/App.tsx b/src/renderer/src/App.tsx index 7fb1648..5b97164 100644 --- a/src/renderer/src/App.tsx +++ b/src/renderer/src/App.tsx @@ -28,6 +28,21 @@ const TIMELINE_DAYS = 10 type View = 'canvas' | 'review' | 'summary' | 'timeline' +/** A place in the app, for back / forward. */ +interface Place { + view: View + canvasId: string + timelineDate: string +} + +/** Only what the view shows counts: the open canvas matters on a canvas, the date on the timeline. */ +function samePlace(a: Place, b: Place): boolean { + if (a.view !== b.view) return false + if (a.view === 'canvas') return a.canvasId === b.canvasId + if (a.view === 'timeline') return a.timelineDate === b.timelineDate + return true +} + /** Replace or insert one day in an ascending timeline; drop it when empty. */ function mergeDay(days: Day[], day: Day): Day[] { const rest = days.filter((d) => d.date !== day.date) @@ -67,6 +82,62 @@ export function App(): React.JSX.Element { const searchRef = useRef(null) const canvasIdRef = useRef(canvasId) canvasIdRef.current = canvasId + const canvasesRef = useRef(canvases) + canvasesRef.current = canvases + + // Back / forward (Alt+←/→, the mouse's side buttons): every place visited, like a browser. + const nav = useRef<{ stack: Place[]; index: number; restoring: Place | null }>({ stack: [], index: -1, restoring: null }) + useEffect(() => { + const here: Place = { view, canvasId, timelineDate } + const h = nav.current + if (h.restoring) { + const target = h.restoring + h.restoring = null + if (samePlace(target, here)) return + } + if (h.index >= 0 && samePlace(h.stack[h.index], here)) return + h.stack = [...h.stack.slice(0, h.index + 1), here].slice(-100) + h.index = h.stack.length - 1 + }, [view, canvasId, timelineDate]) + const goRef = useRef<(delta: 1 | -1) => void>(() => undefined) + goRef.current = (delta) => { + const h = nav.current + let i = h.index + delta + // Skip places that no longer exist (a canvas deleted since). + while (i >= 0 && i < h.stack.length && h.stack[i].view === 'canvas' && !canvasesRef.current.some((c) => c.id === h.stack[i].canvasId)) i += delta + if (i < 0 || i >= h.stack.length) return + h.index = i + const place = h.stack[i] + h.restoring = place + setSearch('') + setView(place.view) + setCanvasId(place.canvasId) + setTimelineDate(place.timelineDate) + } + useEffect(() => { + const onMouse = (ev: MouseEvent): void => { + if (ev.button !== 3 && ev.button !== 4) return + ev.preventDefault() + goRef.current(ev.button === 3 ? -1 : 1) + } + // Captured on the window, so it runs before the editor sees the key. + const mac = navigator.platform.toLowerCase().includes('mac') + const onKey = (ev: KeyboardEvent): void => { + const plain = !ev.ctrlKey && !ev.shiftKey + const back = mac ? ev.metaKey && !ev.altKey && plain && ev.key === '[' : ev.altKey && !ev.metaKey && plain && ev.key === 'ArrowLeft' + const forward = mac ? ev.metaKey && !ev.altKey && plain && ev.key === ']' : ev.altKey && !ev.metaKey && plain && ev.key === 'ArrowRight' + if (!back && !forward) return + ev.preventDefault() + ev.stopPropagation() + goRef.current(back ? -1 : 1) + } + window.addEventListener('mouseup', onMouse) + window.addEventListener('keydown', onKey, true) + return () => { + window.removeEventListener('mouseup', onMouse) + window.removeEventListener('keydown', onKey, true) + } + }, []) const canvas = useMemo(() => canvases.find((c) => c.id === canvasId) ?? JOURNAL, [canvases, canvasId]) const targetCanvas = useMemo(() => canvases.find((c) => c.id === targetCanvasId) ?? JOURNAL, [canvases, targetCanvasId]) @@ -117,6 +188,7 @@ export function App(): React.JSX.Element { const offRepo = api.repo.onChanged((info) => { setRepo(info) setCanvasId(JOURNAL_ID) + nav.current = { stack: [], index: -1, restoring: null } }) const offSync = api.sync.onStatus((st) => setSync(st)) const offTracker = api.tracker.onStatus((st) => setTracker(st)) @@ -133,6 +205,8 @@ export function App(): React.JSX.Element { setTimelineDate(localDate(new Date())) setView('timeline') } + if (cmd === 'back') goRef.current(-1) + if (cmd === 'forward') goRef.current(1) }) const offAttach = api.onAttachImages((images) => { const sink = getActiveComposer() diff --git a/src/renderer/src/components/TodoPanel.tsx b/src/renderer/src/components/TodoPanel.tsx index d9e6e3f..feba114 100644 --- a/src/renderer/src/components/TodoPanel.tsx +++ b/src/renderer/src/components/TodoPanel.tsx @@ -20,6 +20,33 @@ interface Props { type Lists = Array<{ canvasId: string; entries: Entry[] }> const COLLAPSE_KEY = 'devlog:todos:collapsed' +const WIDTH_KEY = 'devlog:todos:width' +const DEFAULT_WIDTH = 300 +const MIN_WIDTH = 220 +const MAX_WIDTH = 720 + +/** Keep the panel between its minimum and half the window. */ +function clampWidth(w: number): number { + const max = Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(window.innerWidth * 0.5))) + return Math.round(Math.min(max, Math.max(MIN_WIDTH, w))) +} + +function readWidth(): number { + try { + const n = Number(localStorage.getItem(WIDTH_KEY)) + return n > 0 ? clampWidth(n) : DEFAULT_WIDTH + } catch { + return DEFAULT_WIDTH + } +} + +function writeWidth(w: number): void { + try { + localStorage.setItem(WIDTH_KEY, String(w)) + } catch { + /* ignore */ + } +} const SCOPE_KEY = 'devlog:todos:all' const DRAG_MIME = 'application/x-devlog-todo' const timeFmt = new Intl.DateTimeFormat(undefined, { month: 'short', day: 'numeric', hour: 'numeric', minute: '2-digit' }) @@ -227,6 +254,31 @@ function TodoItem({ */ export function TodoPanel({ canvases, canvasId, onOpenCanvas, onStreamChanged, onCanvasesChanged }: Props): React.JSX.Element { const [collapsed, setCollapsed] = useState(() => readFlag(COLLAPSE_KEY)) + const [width, setWidth] = useState(readWidth) + const widthRef = useRef(width) + widthRef.current = width + const setAndSaveWidth = (w: number): void => { + const next = clampWidth(w) + setWidth(next) + writeWidth(next) + } + // Drag the left edge to resize; the width is remembered on this machine. + const startResize = (ev: React.PointerEvent): void => { + if (ev.button !== 0) return + ev.preventDefault() + const startX = ev.clientX + const startWidth = widthRef.current + document.body.classList.add('is-resizing') + const move = (e: PointerEvent): void => setWidth(clampWidth(startWidth + (startX - e.clientX))) + const up = (): void => { + window.removeEventListener('pointermove', move) + window.removeEventListener('pointerup', up) + document.body.classList.remove('is-resizing') + writeWidth(widthRef.current) + } + window.addEventListener('pointermove', move) + window.addEventListener('pointerup', up) + } const [all, setAll] = useState(() => readFlag(SCOPE_KEY)) const [lists, setLists] = useState(null) const [openId, setOpenId] = useState(null) @@ -333,7 +385,26 @@ export function TodoPanel({ canvases, canvasId, onOpenCanvas, onStreamChanged, o ) return ( -