From 280072e8935e9e2efec55493a78bc08aa203e5a4 Mon Sep 17 00:00:00 2001 From: Eduardo Borjas Date: Fri, 12 Jun 2026 17:49:36 -0600 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20v3.7.0=20=E2=80=94=20session=20edit?= =?UTF-8?q?ing=20(dashboard=20[e]=20overlay=20+=20flowclock=20edit=20CLI)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Edit a logged session's essential values without hand-editing sessions.json. The start timestamp is immutable; end + break timeline recompute automatically. Two surfaces over one pure engine (recomputeSession / updateSession): - Dashboard: Sessions view (3) → [e] opens an Edit overlay (Goal/Name/Focus/ Break) with a read-only Start line and a live End→ preview. Mirrors the new-session form. [Tab]/[Enter] save/[Esc] cancel. - CLI: flowclock edit --focus --break --goal --name [--json]; id-prefix match, --break 0 clears breaks, empty string clears a field. Advertised in the agent manifest. Recompute semantics (confirmed with user): - Breaks are NOT auto-scaled on a focus edit. Reducing focus (the "fell asleep, timer kept running" case) trims the surplus from the LAST focus segment, cascading back only if needed; recorded breaks keep duration/category/order. - Break total edited independently/optionally: unchanged keeps breaks byte-for-byte; 0 removes them; a new total scales existing breaks (categories preserved) or appends one rest break. - end = start + focus + break, exact to the second. Legacy pauses cleared. 655 tests green (build/lint/typecheck clean). New: session-edit (14), editform (31), updateSession + edit CLI + dashboard edit-overlay coverage. Co-Authored-By: Claude Opus 4.8 --- CHANGELOG.md | 32 ++++ README.md | 10 +- package.json | 2 +- src/cli.ts | 43 +++++ src/commands/edit.ts | 80 +++++++++ src/lib/format.ts | 17 ++ src/lib/manifest.ts | 33 ++++ src/lib/session-edit.ts | 246 +++++++++++++++++++++++++++ src/lib/session.ts | 24 +++ src/tui/app.ts | 104 +++++++++++- src/tui/editform.ts | 277 +++++++++++++++++++++++++++++++ src/tui/views/help.ts | 5 +- src/version.ts | 2 +- test/cli.integration.test.ts | 37 +++++ test/dashboard.test.ts | 35 ++++ test/editform.test.ts | 310 +++++++++++++++++++++++++++++++++++ test/manifest.test.ts | 1 + test/session-edit.test.ts | 196 ++++++++++++++++++++++ test/session.test.ts | 25 +++ 19 files changed, 1471 insertions(+), 8 deletions(-) create mode 100644 src/commands/edit.ts create mode 100644 src/lib/session-edit.ts create mode 100644 src/tui/editform.ts create mode 100644 test/editform.test.ts create mode 100644 test/session-edit.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 4f4c867..3dd32b1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,38 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [3.7.0] - 2026-06-12 + +### Added + +- **Session editing — dashboard `[e]` overlay (view 3 · Sessions).** While a + session row or detail is focused, press **`e`** to open an "Edit session" + overlay that mirrors the new-session form. Editable fields: **Goal**, **Name**, + **Focus**, **Break**. A read-only **Start** line shows the immutable start + timestamp; a live-computed **End →** preview reflects the current + focus + break values. **`[Tab]`** advances the field, **`[Enter]`** saves and + closes, **`[Esc]`** cancels with no changes written. +- **`flowclock edit ` CLI command (headless / agent-friendly).** Edits a + logged session's essential values without touching the JSON file by hand. + Flags: `--focus ` · `--break ` · `--goal ` · `--name ` · + `--json`. `` accepts a unique prefix of the session id. `--break 0` clears + all recorded breaks. An empty string (`--goal ""` / `--name ""`) clears that + field. Outputs the full updated Session record; pair with `--json` for + machine-readable output. `flowclock manifest --json` now advertises `edit` for + agent discovery. +- **Recompute semantics (exact, immutable-start contract).** The session start + timestamp is immutable. `end = start + focus + break`, exact to the second. + Reducing focus (the "fell asleep, timer kept running" case) trims the surplus + from the **last** focus segment (tail), cascading backwards only if needed — + existing breaks keep their exact duration, category, and order; breaks are NOT + auto-scaled when focus changes. The break total is edited independently and + optionally: leaving it unchanged keeps recorded breaks byte-for-byte; `0` + removes all breaks; a new positive total scales existing breaks proportionally + (categories preserved) or, if there were none, appends one `rest` break. +- **Pure engine + two surfaces.** Recompute logic lives in + `src/lib/session-edit.ts` (`recomputeSession`), wrapped by `updateSession` in + `src/lib/session.ts`; the dashboard overlay is `src/tui/editform.ts`. + ## [3.6.0] - 2026-06-12 ### Changed diff --git a/README.md b/README.md index b4356e4..020a21d 100644 --- a/README.md +++ b/README.md @@ -280,7 +280,10 @@ While a session runs, the **global footer** is the single source of control hint - **`Enter`** — hide/show the controls row to remove visual noise once memorised. - **`p`** pause · **`b`** break · **`1`–`6`** category · **`r`** reset · **`q`** stop & save. -On the **Sessions** list, **`Supr`/`Delete`** opens a confirmation modal +On the **Sessions** list, **`e`** opens an Edit-session overlay to change the +focus duration, break total, goal, or name for the selected session; the start +timestamp is immutable and the end + break timeline recompute automatically. +**`Supr`/`Delete`** opens a confirmation modal (`[y] confirm · [n] cancel`) to delete the selected session from `sessions.json`. ### Command palette @@ -296,6 +299,7 @@ a permanent bar** — it appears only when invoked and disappears when you press [s] / [n] new session [Enter] detail / start · hide controls (live) [d] display style [t] theme (both saved) [z] zen (live) [Supr] delete session (Sessions · confirm) +[e] edit session (Sessions · focus/break/goal/name) [/] command palette [r] refresh [q] / [Esc] quit / close overlay ``` @@ -369,6 +373,7 @@ Every interactive flow has a non-interactive equivalent, so terminal AI agents ```bash flowclock log --duration 3000 --goal "Deep work" \ --target 1h --break-budget 20m --json # record a full session +flowclock edit --focus 90m --json # fix a session that ran while you slept flowclock dashboard --json | jq .data.game # the whole snapshot, no TTY flowclock stats --json | jq .data.game.flowScore # compose with pipes echo "$SESSION_JSON" | flowclock log --json # accept session JSON on stdin @@ -377,6 +382,8 @@ flowclock mcp # MCP server over stdio ``` - **`--json`** on every command, with a stable, versioned envelope. +- **`flowclock edit`** — the start timestamp is immutable; end and the break + timeline recompute automatically from the new focus + break values. - **`--yes`** skips prompts; **`--no-color`** / `NO_COLOR` for clean output. - **Deterministic exit codes** (below) — the contract agents branch on. - **No hidden TTY requirements:** non-TTY runs default to machine-friendly mode; the @@ -439,6 +446,7 @@ on-disk schema is **v3**; migrations are non-destructive. | **v3.5.0** ✅ | `classic`/`bold` redesigned as 5-row shade weights (`▒`/`▓`) for exact footprint parity with all styles — no more towering, goal covering, or silent fallback to `block` | | **v3.5.1** ✅ | `classic` lightened to `░` shade so block `█` / classic `░` / bold `▓` read as three distinct surfaces while keeping exact 5-row footprint parity | | **v3.6.0** ✅ | `classic`/`bold` redesigned as native 5-row fonts with distinct glyph shapes (cornered `classic` / heavy-slab `bold`, both solid) — replaces the v3.5.x shade-fill approach so the three solid styles read as clearly distinct while keeping exact footprint parity | +| **v3.7.0** ✅ | Session editing — in-dashboard `[e]` overlay (view 3 · Sessions) and `flowclock edit ` CLI; editable fields: focus, break total, goal, name; start immutable; end + break timeline recompute automatically | | **next** | `flowclock sync` — push `sessions.json` to a self-hosted/cloud endpoint; recurring goals; dashboard filters | | **later** | Per-goal analytics deep-dives, calendar heatmap, Homebrew tap | diff --git a/package.json b/package.json index c9789bd..964bf5a 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "flowclock-cli", - "version": "3.6.0", + "version": "3.7.0", "description": "Flowtime count-up terminal timer (HUD, not Pomodoro) with silent session logging — agent-native, like gh and vercel.", "type": "module", "license": "AGPL-3.0-or-later", diff --git a/src/cli.ts b/src/cli.ts index df031cb..b35fa41 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -10,6 +10,7 @@ import { jsonError, printJson, jsonRequested } from "./lib/output.js"; import { runStart } from "./commands/start.js"; import { parseDurationToS } from "./lib/format.js"; import { runLog } from "./commands/log.js"; +import { runEdit } from "./commands/edit.js"; import { runStats } from "./commands/stats.js"; import { runHistory } from "./commands/history.js"; import { runGoals } from "./commands/goals.js"; @@ -181,6 +182,48 @@ export function buildProgram(): Command { ), ); + // edit — surgically edit a logged session's essential values. + addGlobalFlags( + program + .command("edit") + .description( + "edit a logged session (focus/break/goal/name); end + timeline recompute automatically", + ) + .argument("", "session id (or a unique id prefix)") + .option("--focus ", "new active focus time, e.g. 1h30m, 90m, 45s") + .option("--break ", "new total break time, e.g. 20m (0 clears breaks)") + .option("--goal ", "new goal/intention (empty string clears)") + .option("--name ", "new session name/label (empty string clears)") + .action((id: string, opts, cmd: Command) => + guard("edit", cmd, (ctx) => { + let focusS: number | undefined; + let breakS: number | undefined; + + if (opts.focus !== undefined) { + try { + focusS = parseDurationToS(opts.focus as string); + } catch { + fail(ExitCode.USAGE, `invalid --focus: ${opts.focus as string} (use forms like 1h30m, 90m, 45s)`); + } + } + if (opts.break !== undefined) { + try { + breakS = parseDurationToS(opts.break as string); + } catch { + fail(ExitCode.USAGE, `invalid --break: ${opts.break as string} (use forms like 20m, 0)`); + } + } + + return runEdit(ctx, id, { + focusS, + breakS, + goal: opts.goal as string | undefined, + name: opts.name as string | undefined, + }); + }), + ), + ); + // stats addGlobalFlags( program diff --git a/src/commands/edit.ts b/src/commands/edit.ts new file mode 100644 index 0000000..786b445 --- /dev/null +++ b/src/commands/edit.ts @@ -0,0 +1,80 @@ +import type { CommandContext } from "../lib/context.js"; +import { readSessions, updateSession } from "../lib/session.js"; +import { sessionsPathFor } from "../lib/config.js"; +import type { SessionEditPatch } from "../lib/session-edit.js"; +import { humanDuration } from "../lib/format.js"; +import { jsonSuccess, printJson } from "../lib/output.js"; +import { ExitCode, fail } from "../lib/exit.js"; + +export interface EditOptions { + /** Pre-parsed new focus seconds (from --focus via cli.ts). */ + focusS?: number; + /** Pre-parsed new total break seconds (from --break via cli.ts). */ + breakS?: number; + /** New goal text, or empty string to clear. */ + goal?: string; + /** New label/name text, or empty string to clear. */ + name?: string; +} + +/** + * Resolve a session id from an exact match, else a unique prefix. Fails with a + * helpful message when nothing matches or the prefix is ambiguous. + */ +function resolveId(ids: string[], query: string): string { + if (ids.includes(query)) return query; + const matches = ids.filter((id) => id.startsWith(query)); + if (matches.length === 1) return matches[0]!; + if (matches.length === 0) { + fail(ExitCode.USAGE, `no session matches id ${JSON.stringify(query)}`); + } + fail( + ExitCode.USAGE, + `id ${JSON.stringify(query)} is ambiguous (${matches.length} matches) — use more characters`, + ); +} + +/** + * `flowclock edit --focus 90m --break 15m --goal "…" --name "…"`. + * + * Surgically edits a logged session's essential values. The start timestamp is + * immutable; the end timestamp and break timeline are recomputed automatically + * from the edited focus/break totals (same engine the dashboard uses). + */ +export function runEdit(ctx: CommandContext, id: string, opts: EditOptions): void { + const file = sessionsPathFor(ctx.config, ctx.paths); + + const patch: SessionEditPatch = {}; + if (opts.focusS !== undefined) patch.focusS = opts.focusS; + if (opts.breakS !== undefined) patch.breakS = opts.breakS; + if (opts.goal !== undefined) patch.goal = opts.goal === "" ? null : opts.goal; + if (opts.name !== undefined) patch.label = opts.name === "" ? null : opts.name; + + if (Object.keys(patch).length === 0) { + fail( + ExitCode.USAGE, + "nothing to edit — pass at least one of --focus, --break, --goal, --name", + ); + } + + const { sessions } = readSessions(file); + if (sessions.length === 0) { + fail(ExitCode.USAGE, "no sessions to edit"); + } + const resolved = resolveId(sessions.map((s) => s.id), id); + + const updated = updateSession(file, resolved, patch); + if (!updated) { + // resolveId guarantees a match, so this only fires on a concurrent delete. + fail(ExitCode.DATA, `session ${resolved} disappeared before it could be edited`); + } + + if (ctx.json) { + printJson(jsonSuccess("edit", updated)); + } else { + const parts = [`focus ${humanDuration(updated.durationS)}`]; + if (updated.breakS > 0) parts.push(`break ${humanDuration(updated.breakS)}`); + if (updated.goal) parts.push(`goal "${updated.goal}"`); + ctx.logger.info(`edited session ${updated.id} → ${parts.join(", ")}`); + } +} diff --git a/src/lib/format.ts b/src/lib/format.ts index 872cd56..30aa13f 100644 --- a/src/lib/format.ts +++ b/src/lib/format.ts @@ -10,6 +10,23 @@ export function humanDuration(totalSeconds: number): string { return `${sec}s`; } +/** + * Compact, parser-friendly duration like "1h30m00s" / "30m00s" / "45s". + * + * Unlike `humanDuration`, the output contains NO spaces, so it round-trips + * straight back through `parseDurationToS` — used to pre-fill editable duration + * fields (e.g. the dashboard edit form). `0` seconds renders as "0s". + */ +export function compactDuration(totalSeconds: number): string { + const s = Math.max(0, Math.floor(totalSeconds)); + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; + if (h > 0) return `${h}h${String(m).padStart(2, "0")}m${String(sec).padStart(2, "0")}s`; + if (m > 0) return `${m}m${String(sec).padStart(2, "0")}s`; + return `${sec}s`; +} + /** * Parse a human duration string into whole seconds. * diff --git a/src/lib/manifest.ts b/src/lib/manifest.ts index 4269d10..2859c74 100644 --- a/src/lib/manifest.ts +++ b/src/lib/manifest.ts @@ -201,6 +201,39 @@ export function buildManifest(): Manifest { jsonData: "The stored Session record (v3 schema).", examples: ["flowclock log --duration 600 --label deep-work --json"], }, + { + name: "edit", + summary: + "Surgically edit a logged session's essential values (focus/break/goal/name) by id or unique id prefix. The start is immutable; end + break timeline recompute automatically. Focus changes trim from the tail; breaks stay as recorded unless --break is given.", + args: [""], + flags: [ + { + name: "--focus", + type: "string", + description: "New active focus time, e.g. 1h30m, 90m, 45s.", + }, + { + name: "--break", + type: "string", + description: "New total break time, e.g. 20m (0 clears all breaks).", + }, + { + name: "--goal", + type: "string", + description: "New goal/intention (empty string clears it).", + }, + { + name: "--name", + type: "string", + description: "New session name/label (empty string clears it).", + }, + ], + jsonData: "The updated Session record (v3 schema) with recomputed end + breaks[].", + examples: [ + "flowclock edit 2026-06-12T03-15 --focus 90m --json", + "flowclock edit --break 0 --goal 'Deep work'", + ], + }, { name: "stats", summary: diff --git a/src/lib/session-edit.ts b/src/lib/session-edit.ts new file mode 100644 index 0000000..ac1edd3 --- /dev/null +++ b/src/lib/session-edit.ts @@ -0,0 +1,246 @@ +/** + * Session editing — pure, deterministic recompute of a logged session. + * + * The user edits the ESSENTIAL totals (focus seconds, break seconds) and the + * goal/name; everything else in the record — the end timestamp and the break + * interval timeline — is recomputed automatically from those totals. The start + * timestamp is immutable (it is the original anchor of the record). + * + * Design decisions (v3.7.0), confirmed with the user: + * + * - Breaks are NOT auto-scaled when only focus changes. When you reduce the + * focus time (the classic "I fell asleep and the timer kept counting" case), + * the surplus is trimmed from the LAST focus segment — the tail where the + * runaway time actually accumulated — cascading backwards only if the last + * segment cannot absorb it all. Existing breaks keep their exact duration, + * category and ordering. + * + * - Break total is edited independently and is optional. Leaving it unchanged + * keeps the recorded breaks byte-for-byte. Setting it to 0 removes all + * breaks. Setting a new positive total scales the existing break intervals + * proportionally (preserving their categories), or — if the session had no + * breaks — appends a single `rest` break of that length. + * + * - end = start + focusS + breakS, exactly. The function guarantees the + * rebuilt segments sum to the requested totals to the whole second, so the + * timeline (rebuilt by `sessionDetail`) is always internally consistent. + * + * No I/O, no side effects. `updateSession` (in session.ts) wraps this with the + * atomic read-modify-write. + */ + +import { + SessionSchema, + type Session, + type Break, + type BreakCategory, +} from "../schemas/session.js"; + +// --------------------------------------------------------------------------- +// Public interface +// --------------------------------------------------------------------------- + +/** + * A surgical edit to a session's essential values. Any field left `undefined` + * is preserved from the original record. `goal`/`label` accept `null` to clear. + */ +export interface SessionEditPatch { + /** New active focus seconds (>= 0). Omit to keep the original focus. */ + focusS?: number; + /** New total break seconds (>= 0). Omit to keep the recorded breaks intact. */ + breakS?: number; + /** New goal text, or `null` to clear. Omit to keep the original. */ + goal?: string | null; + /** New label/name text, or `null` to clear. Omit to keep the original. */ + label?: string | null; +} + +// --------------------------------------------------------------------------- +// Internal segment model +// --------------------------------------------------------------------------- + +/** Metadata carried by a break interval across an edit (categories survive). */ +interface BreakMeta { + durationS: number; + category: BreakCategory; + label: string | null; + suggestedS: number | null; +} + +/** + * Decompose a session into the aligned focus / break segment lists used by the + * timeline: `focus[i]` is the focus interval that PRECEDES `breaks[i]`, and the + * trailing `focus[breaks.length]` is the focus after the last break. The + * invariant `focus.length === breaks.length + 1` always holds. + * + * Focus durations are derived from the gaps between break timestamps; break + * durations come from the canonical `durationS` on each break record. + */ +function decompose(session: Session): { focus: number[]; breaks: BreakMeta[] } { + const startMs = Date.parse(session.start); + const endMs = Date.parse(session.end); + + const sorted = [...session.breaks].sort( + (a, b) => Date.parse(a.start) - Date.parse(b.start), + ); + + const focus: number[] = []; + const breaks: BreakMeta[] = []; + + let cursor = startMs; + for (const brk of sorted) { + const bStart = Date.parse(brk.start); + const bEnd = Date.parse(brk.end); + const gap = Math.max(0, Math.round((bStart - cursor) / 1000)); + focus.push(gap); + breaks.push({ + durationS: brk.durationS, + category: brk.category, + label: brk.label, + suggestedS: brk.suggestedS, + }); + cursor = bEnd; + } + // Trailing focus segment after the last break (or the whole session if none). + const tail = Math.max(0, Math.round((endMs - cursor) / 1000)); + focus.push(tail); + + // The gap-derived focus total can drift from the canonical `durationS` (clock + // rounding, or a session whose stored end predates a break edit). Reconcile + // the trailing segment so the focus segments sum to the canonical focus — the + // trim-from-end edit then operates on a faithful baseline. + const gapTotal = focus.reduce((a, b) => a + b, 0); + const drift = session.durationS - gapTotal; + focus[focus.length - 1] = Math.max(0, focus[focus.length - 1]! + drift); + + return { focus, breaks }; +} + +/** Trim (delta<0) or extend (delta>0) focus, applied from the LAST segment back. */ +function applyFocusDelta(focus: number[], delta: number): void { + if (delta >= 0) { + focus[focus.length - 1] = (focus[focus.length - 1] ?? 0) + delta; + return; + } + let remaining = -delta; + for (let i = focus.length - 1; i >= 0 && remaining > 0; i--) { + const take = Math.min(remaining, focus[i]!); + focus[i]! -= take; + remaining -= take; + } +} + +/** Scale break intervals to a new total, preserving categories, exact to the second. */ +function scaleBreaks(breaks: BreakMeta[], newTotal: number): BreakMeta[] { + const oldTotal = breaks.reduce((a, b) => a + b.durationS, 0); + if (breaks.length === 0) return []; + + let scaled: BreakMeta[]; + if (oldTotal <= 0) { + // Degenerate: zero-length breaks but a positive new total — load it onto the + // first interval so the count/categories are preserved. + scaled = breaks.map((b, i) => ({ ...b, durationS: i === 0 ? newTotal : 0 })); + } else { + scaled = breaks.map((b) => ({ + ...b, + durationS: Math.round((b.durationS * newTotal) / oldTotal), + })); + } + + // Fix rounding drift on the last interval so the sum is exact. + const got = scaled.reduce((a, b) => a + b.durationS, 0); + const last = scaled.length - 1; + scaled[last]!.durationS = Math.max(0, scaled[last]!.durationS + (newTotal - got)); + return scaled; +} + +// --------------------------------------------------------------------------- +// Main recompute +// --------------------------------------------------------------------------- + +/** + * Recompute a session after a surgical edit to its essential values. + * + * Returns a NEW validated session record; never mutates the input. Throws (via + * zod) only if the recomputed record is somehow invalid — which the totals + * clamping below is designed to prevent. + */ +export function recomputeSession( + original: Session, + patch: SessionEditPatch, +): Session { + const { focus, breaks } = decompose(original); + + // ── Break edit (applied first; it can restructure the focus segments) ────── + let newBreaks: BreakMeta[]; + if (patch.breakS === undefined) { + newBreaks = breaks; + } else { + const target = Math.max(0, Math.floor(patch.breakS)); + if (target === 0) { + // Drop all breaks → focus collapses to a single segment. + newBreaks = []; + const merged = focus.reduce((a, b) => a + b, 0); + focus.length = 0; + focus.push(merged); + } else if (breaks.length === 0) { + // No breaks before → append a single `rest` break after all focus. + newBreaks = [ + { durationS: target, category: "rest", label: null, suggestedS: null }, + ]; + const merged = focus.reduce((a, b) => a + b, 0); + focus.length = 0; + focus.push(merged, 0); // focus-before-break, focus-after-break(=0) + } else { + newBreaks = scaleBreaks(breaks, target); + } + } + + // ── Focus edit (trim/extend from the end) ────────────────────────────────── + const oldFocusTotal = focus.reduce((a, b) => a + b, 0); + const newFocusTotal = + patch.focusS === undefined + ? oldFocusTotal + : Math.max(0, Math.floor(patch.focusS)); + applyFocusDelta(focus, newFocusTotal - oldFocusTotal); + + // ── Rebuild the break timeline from start, interleaving focus and breaks ─── + const startMs = Date.parse(original.start); + const rebuilt: Break[] = []; + let cursor = startMs; + for (let i = 0; i < focus.length; i++) { + cursor += (focus[i] ?? 0) * 1000; + const brk = newBreaks[i]; + if (brk) { + const bStart = cursor; + const bEnd = cursor + brk.durationS * 1000; + rebuilt.push({ + start: new Date(bStart).toISOString(), + end: new Date(bEnd).toISOString(), + durationS: brk.durationS, + category: brk.category, + label: brk.label, + suggestedS: brk.suggestedS, + }); + cursor = bEnd; + } + } + + const breakTotal = newBreaks.reduce((a, b) => a + b.durationS, 0); + const endMs = startMs + (newFocusTotal + breakTotal) * 1000; + + const next: Session = { + ...original, + end: new Date(endMs).toISOString(), + durationS: newFocusTotal, + breaks: rebuilt, + breakS: breakTotal, + // Legacy `pauses` are superseded by canonical `breaks`; clear them so a + // re-read never re-derives stale breaks from them (see normalizeSession). + pauses: [], + goal: patch.goal === undefined ? original.goal : patch.goal, + label: patch.label === undefined ? original.label : patch.label, + }; + + return SessionSchema.parse(next); +} diff --git a/src/lib/session.ts b/src/lib/session.ts index 1d43073..eaee828 100644 --- a/src/lib/session.ts +++ b/src/lib/session.ts @@ -2,6 +2,7 @@ import { existsSync, readFileSync, renameSync } from "node:fs"; import { randomBytes } from "node:crypto"; import { writeFileAtomic } from "./fsutil.js"; import { SessionSchema, type Session } from "../schemas/session.js"; +import { recomputeSession, type SessionEditPatch } from "./session-edit.js"; import { ExitCode, fail } from "./exit.js"; /** @@ -90,6 +91,29 @@ export function deleteSession(file: string, id: string): Session[] { return remaining; } +/** + * Recompute and persist a surgical edit to one session, atomically. Looks the + * session up by id, applies `recomputeSession` (auto-recomputes end + break + * timeline from the edited focus/break totals), and rewrites the file. + * + * Returns the updated record, or `null` when no session matches the id (the + * file is left untouched in that case). + */ +export function updateSession( + file: string, + id: string, + patch: SessionEditPatch, +): Session | null { + const { sessions } = readSessions(file); + const idx = sessions.findIndex((s) => s.id === id); + if (idx === -1) return null; + + const updated = recomputeSession(sessions[idx]!, patch); + sessions[idx] = updated; + writeFileAtomic(file, JSON.stringify(sessions, null, 2) + "\n"); + return updated; +} + /** Filter/limit options shared by stats and history. */ export interface QueryOptions { since?: Date; diff --git a/src/tui/app.ts b/src/tui/app.ts index 65f991b..337c698 100644 --- a/src/tui/app.ts +++ b/src/tui/app.ts @@ -13,11 +13,11 @@ import type { Session } from "../schemas/session.js"; import type { ThemeName, DisplayStyle } from "../schemas/config.js"; import type { BreakCategory } from "../schemas/session.js"; import { buildSnapshot } from "../lib/snapshot.js"; -import { readSessions, appendSession, deleteSession } from "../lib/session.js"; +import { readSessions, appendSession, deleteSession, updateSession } from "../lib/session.js"; import { sessionsPathFor, saveConfig } from "../lib/config.js"; import { Timer } from "../lib/timer.js"; import { suggestBreakS } from "../lib/flowtime.js"; -import { humanDuration, parseDurationToS } from "../lib/format.js"; +import { humanDuration, compactDuration, parseDurationToS } from "../lib/format.js"; import { Screen } from "../lib/tui/screen.js"; import { startNavReader } from "../lib/tui/input.js"; import { splitV } from "../lib/tui/layout.js"; @@ -52,6 +52,13 @@ import { renderConfirm, } from "./confirm.js"; import type { ConfirmState } from "./confirm.js"; +import { + emptyEditFormState, + openEditFormState, + editFormApplyKey, + renderEditForm, +} from "./editform.js"; +import type { EditFormState, EditFormValues } from "./editform.js"; // --------------------------------------------------------------------------- // Types @@ -116,6 +123,7 @@ interface AppState { live: LiveSession | null; palette: PaletteState; form: SessionFormState; + edit: EditFormState; confirm: ConfirmState; summary: SummaryState | null; theme: ThemeName; @@ -348,6 +356,8 @@ export function buildFrame( footerHints = "[y] confirm · [n] cancel"; } else if (state.form.open) { footerHints = "[Tab] next field · [Enter] start · [Esc] cancel"; + } else if (state.edit.open) { + footerHints = "[Tab] next field · [Enter] save · [Esc] cancel"; } else if (state.palette.open) { footerHints = "[↑↓] select · [Enter] run · [Esc] cancel"; } else if (state.live && state.view === "session") { @@ -365,7 +375,7 @@ export function buildFrame( } else if (state.live) { footerHints = `[Tab]/[1-6] views · session ● ${state.live.timer.display()} running · [d] style · [Ctrl-C] quit`; } else if (state.view === "sessions") { - footerHints = "[↑↓] select · [Enter] detail · [Supr] delete · [Tab] views · [q] quit"; + footerHints = "[↑↓] select · [Enter] detail · [e] edit · [Supr] delete · [Tab] views · [q] quit"; } else { footerHints = "[Tab] views · [s] start · [/] commands · [d] style · [t] theme · [q] quit"; } @@ -395,6 +405,12 @@ export function buildFrame( return compositeOverlay(baseFrame, formOverlay, cols); } + // Edit-session form overlay + if (state.edit.open) { + const editOverlay = renderEditForm(state.edit, cols, rows, theme, color); + return compositeOverlay(baseFrame, editOverlay, cols); + } + // Palette overlay if (state.palette.open) { const paletteOverlay = renderPalette(state.palette, cols, rows, theme, color); @@ -481,6 +497,7 @@ export async function runDashboardApp( live: null, palette: emptyPaletteState(), form: emptySessionFormState(), + edit: emptyEditFormState(), confirm: emptyConfirmState(), summary: null, theme: ctx.config.theme, @@ -697,6 +714,59 @@ export async function runDashboardApp( render(); } + /** + * Open the edit form for the currently-selected session, pre-filled with its + * goal/name and its focus/break totals in a parser-friendly compact form. + */ + function openEditForm() { + const snap = buildSnapshot(state.sessions, ctx.config.dailyFocusGoalS); + const session = snap.recent[state.selectedIndex]; + if (!session) return; + state.edit = openEditFormState({ + sessionId: session.id, + startISO: session.start, + values: { + goal: session.goal ?? "", + label: session.label ?? "", + focus: compactDuration(session.durationS), + break: compactDuration(session.breakS), + }, + }); + render(); + } + + /** + * Persist an edit from the form's values. Parses the focus/break durations + * (blank = keep the original total); on a parse error, surfaces it in the + * form and stays open. The end timestamp + break timeline recompute + * automatically via `updateSession` → `recomputeSession`. + */ + function saveEditForm(sessionId: string, values: EditFormValues) { + let focusS: number | undefined; + let breakS: number | undefined; + try { + if (values.focus.trim()) focusS = parseDurationToS(values.focus); + if (values.break.trim()) breakS = parseDurationToS(values.break); + } catch (err) { + state.edit = { ...state.edit, error: (err as Error).message }; + render(); + return; + } + + updateSession(file, sessionId, { + focusS, + breakS, + goal: values.goal.trim() || null, + label: values.label.trim() || null, + }); + + const { sessions: fresh } = readSessions(file); + state.sessions = fresh; + state.edit = emptyEditFormState(); + clampSelection(); + render(); + } + /** Execute a palette command by name, then close the palette. */ function executePaletteCommand(commandName: string) { state.palette = emptyPaletteState(); @@ -835,6 +905,28 @@ export async function runDashboardApp( return; } + // ── (1c) Edit-session form: feed keys to its reducer ────────────────── + if (state.edit.open) { + // Ctrl-C closes the edit modal (never edits) without exiting the app. + if (key.name === "char" && key.char === CTRL_C) { + state.edit = emptyEditFormState(); + render(); + return; + } + const result = editFormApplyKey(state.edit, key); + state.edit = result.state; + if (result.action?.type === "cancel") { + render(); + return; + } + if (result.action?.type === "submit") { + saveEditForm(result.action.sessionId, result.action.values); + return; + } + render(); + return; + } + // ── (2) Palette: feed keys to reducer ──────────────────────────────── if (state.palette.open) { const result = paletteApplyKey(state.palette, key); @@ -1012,6 +1104,12 @@ export async function runDashboardApp( return; } + // Edit the selected session (sessions view, list or detail). + if (ch === "e" && state.view === "sessions") { + openEditForm(); + return; + } + // j/k navigation (vim-style, sessions view) if (ch === "j" && state.view === "sessions") { const snap = buildSnapshot(state.sessions, ctx.config.dailyFocusGoalS); diff --git a/src/tui/editform.ts b/src/tui/editform.ts new file mode 100644 index 0000000..293bcbe --- /dev/null +++ b/src/tui/editform.ts @@ -0,0 +1,277 @@ +/** + * Edit-session form — a pure, self-contained, transient overlay for the TUI + * dashboard. Lets the user modify an existing session's goal, name, focus + * target and break budget WITHOUT dropping to the shell. + * + * Mirrors sessionform.ts: types + pure functions only, no I/O. The caller + * (app.ts) does the wiring (parsing durations, updating the session, etc.). + */ + +import type { Key } from "../lib/tui/input.js"; +import type { ThemeName } from "../schemas/config.js"; +import { panel, truncate, padTo } from "../lib/tui/draw.js"; +import { paint, THEME_FG } from "../lib/theme.js"; +import { parseDurationToS } from "../lib/format.js"; + +// --------------------------------------------------------------------------- +// Fields +// --------------------------------------------------------------------------- + +export type EditFormField = "goal" | "label" | "focus" | "break"; + +export type EditFormValues = Record; + +interface FieldSpec { + key: EditFormField; + label: string; + hint: string; +} + +/** Ordered fields, top to bottom. */ +export const EDIT_FORM_FIELDS: FieldSpec[] = [ + { key: "goal", label: "Goal", hint: "goal/intention (optional, blank clears)" }, + { key: "label", label: "Name", hint: "session name (optional, blank clears)" }, + { key: "focus", label: "Focus", hint: "active focus time — e.g. 1h30m, 90m, 45s" }, + { key: "break", label: "Break", hint: "total break time — e.g. 20m, 0 to clear breaks" }, +]; + +const LABEL_WIDTH = 13; // "Break budget" is 12; keep parity with sessionform + +// --------------------------------------------------------------------------- +// State +// --------------------------------------------------------------------------- + +export interface EditFormState { + open: boolean; + /** The id of the session being edited (carried through to the submit action). */ + sessionId: string | null; + /** Immutable original start (ISO) — shown read-only and used for the End preview. */ + startISO: string | null; + values: EditFormValues; + active: number; // index into EDIT_FORM_FIELDS + error: string | null; +} + +/** Canonical empty / closed form state. */ +export function emptyEditFormState(): EditFormState { + return { + open: false, + sessionId: null, + startISO: null, + values: { goal: "", label: "", focus: "", break: "" }, + active: 0, + error: null, + }; +} + +/** Open a form pre-populated with the session to edit. */ +export function openEditFormState(opts: { + sessionId: string; + startISO: string; + values: EditFormValues; +}): EditFormState { + return { ...emptyEditFormState(), open: true, ...opts }; +} + +// --------------------------------------------------------------------------- +// Reducer +// --------------------------------------------------------------------------- + +export interface EditFormResult { + state: EditFormState; + action?: + | { type: "submit"; sessionId: string; values: EditFormValues } + | { type: "cancel" }; +} + +/** + * Pure reducer — never mutates `state`. + * + * escape → cancel (close, return emptyEditFormState) + * enter → submit the current values (with sessionId) + * tab / down → focus next field (wraps) + * up → focus previous field (wraps) + * backspace → delete last char of the active field + * printable char → append to the active field + */ +export function editFormApplyKey( + state: EditFormState, + key: Key, +): EditFormResult { + const n = EDIT_FORM_FIELDS.length; + const activeKey = EDIT_FORM_FIELDS[state.active]?.key ?? "goal"; + + switch (key.name) { + case "escape": + return { state: emptyEditFormState(), action: { type: "cancel" } }; + + case "enter": + return { + state, + action: { + type: "submit", + sessionId: state.sessionId ?? "", + values: { ...state.values }, + }, + }; + + case "tab": + case "down": + return { state: { ...state, active: (state.active + 1) % n, error: null } }; + + case "up": + return { state: { ...state, active: (state.active - 1 + n) % n, error: null } }; + + case "backspace": + return { + state: { + ...state, + values: { + ...state.values, + [activeKey]: state.values[activeKey].slice(0, -1), + }, + error: null, + }, + }; + + case "char": { + const ch = key.char; + const code = ch.codePointAt(0) ?? 0; + // Printable and not Ctrl-C + if (code >= 0x20 && ch !== "\x03") { + return { + state: { + ...state, + values: { ...state.values, [activeKey]: state.values[activeKey] + ch }, + error: null, + }, + }; + } + return { state }; + } + + default: + return { state }; + } +} + +// --------------------------------------------------------------------------- +// Rendering +// --------------------------------------------------------------------------- + +export interface EditFormOverlay { + rows: string[]; + top: number; + left: number; +} + +/** + * Compute the "End" timestamp from startISO + focus + break fields. + * Returns a locale string, or "—" on any error or missing data. + */ +function computeEndPreview( + startISO: string | null, + focusStr: string, + breakStr: string, +): string { + if (!startISO) return "—"; + try { + const startMs = new Date(startISO).getTime(); + const focusS = focusStr ? parseDurationToS(focusStr) : 0; + const breakS = breakStr ? parseDurationToS(breakStr) : 0; + const endMs = startMs + (focusS + breakS) * 1000; + return new Date(endMs).toLocaleString(); + } catch { + return "—"; + } +} + +/** + * Render the edit-session form as a centered bordered panel. + * + * Layout: + * [read-only start line] "Start " + * [field rows] "Label value▏" (active field shows the cursor bar) + * [end preview] "End → " + * (blank) + * active field hint + * error line (only when state.error is set) + * (blank) + * "[Tab] next · [Enter] save · [Esc] cancel" + */ +export function renderEditForm( + state: EditFormState, + cols: number, + rows: number, + theme: ThemeName, + color: boolean, +): EditFormOverlay { + const boxWidth = Math.min(56, Math.max(34, cols - 4)); + const innerW = boxWidth - 2; + + const clampedActive = Math.max(0, Math.min(state.active, EDIT_FORM_FIELDS.length - 1)); + + const body: string[] = []; + + // Read-only Start line (dimmed/plain — NOT the active-accent color) + const startDisplay = state.startISO + ? new Date(state.startISO).toLocaleString() + : "—"; + const startRaw = `${"Start".padEnd(LABEL_WIDTH)} ${startDisplay}`; + body.push(truncate(" " + startRaw, innerW)); + + // Editable field rows + EDIT_FORM_FIELDS.forEach((field, i) => { + const isActive = i === clampedActive; + const value = state.values[field.key]; + const cursor = isActive ? "▏" : ""; + const raw = `${field.label.padEnd(LABEL_WIDTH)} ${value}${cursor}`; + + let line: string; + if (isActive) { + const text = truncate("› " + raw, innerW); + line = color ? paint(text, theme, true) : text; + } else { + line = truncate(" " + raw, innerW); + } + body.push(line); + }); + + // End preview line (plain — not focusable) + const endDisplay = computeEndPreview( + state.startISO, + state.values.focus, + state.values.break, + ); + const endRaw = `${"End →".padEnd(LABEL_WIDTH)} ${endDisplay}`; + body.push(truncate(" " + endRaw, innerW)); + + // Spacer + active field hint + body.push(padTo("", innerW)); + const hint = EDIT_FORM_FIELDS[clampedActive]?.hint ?? ""; + body.push(truncate(" " + hint, innerW)); + + // Error (if any) + if (state.error) { + body.push(truncate(" ⚠ " + state.error, innerW)); + } + + // Spacer + footer + body.push(padTo("", innerW)); + body.push(truncate(" [Tab] next · [Enter] save · [Esc] cancel", innerW)); + + const boxHeight = body.length + 2; // 2 borders + + const panelRows = panel({ + title: "Edit session", + width: boxWidth, + height: boxHeight, + body, + color: color ? THEME_FG[theme] : undefined, + }); + + const left = Math.max(0, Math.floor((cols - boxWidth) / 2)); + const top = Math.max(0, Math.floor((rows - boxHeight) / 2)); + + return { rows: panelRows, top, left }; +} diff --git a/src/tui/views/help.ts b/src/tui/views/help.ts index efd764a..0bd46ba 100644 --- a/src/tui/views/help.ts +++ b/src/tui/views/help.ts @@ -49,14 +49,15 @@ export function renderHelp(rect: Rect, theme: ThemeName, color: boolean): string // Global body.push(padTo("Global:", innerW)); body.push(padTo("/ command palette · d display style (block/simple/outline/minimal/classic/bold) · t theme · r refresh · q/Esc quit", innerW)); - body.push(padTo("Sessions list: Supr deletes (with confirm). d/t saved as default; both also in the / palette.", innerW)); + body.push(padTo("Sessions list: Enter detail · e edit (focus/break/goal/name; end + timeline recompute) · Supr delete.", innerW)); + body.push(padTo("d/t saved as default; both also in the / palette.", innerW)); // Spacer body.push(""); // Commands body.push(padTo("Commands (also from your shell):", innerW)); - body.push(padTo("start · stats · history · goals · summary · breaks · theme · config · doctor · dashboard", innerW)); + body.push(padTo("start · log · edit · stats · history · goals · summary · breaks · theme · config · doctor · dashboard", innerW)); // Spacer body.push(""); diff --git a/src/version.ts b/src/version.ts index 8800a57..27b43bb 100644 --- a/src/version.ts +++ b/src/version.ts @@ -1,2 +1,2 @@ /** Kept in sync with package.json "version" (asserted by a test). */ -export const VERSION = "3.6.0"; +export const VERSION = "3.7.0"; diff --git a/test/cli.integration.test.ts b/test/cli.integration.test.ts index 7480213..58bb96b 100644 --- a/test/cli.integration.test.ts +++ b/test/cli.integration.test.ts @@ -93,6 +93,43 @@ describe("flowclock CLI", () => { ); }); + it("edits a logged session's focus and recomputes the end", () => { + // Log a 6h "runaway" session, then correct focus down to 90m. + const logged = JSON.parse( + run(["log", "--duration", "21600", "--goal", "Sleepy", "--json"]).stdout, + ); + const id = logged.data.id as string; + const edited = run(["edit", id, "--focus", "90m", "--json"]); + expect(edited.status).toBe(0); + const rec = JSON.parse(edited.stdout); + expect(rec.ok).toBe(true); + expect(rec.command).toBe("edit"); + expect(rec.data.durationS).toBe(5400); + // history reflects the corrected duration + const hist = JSON.parse(run(["history", "--json"]).stdout); + expect(hist.data.sessions[0].durationS).toBe(5400); + }); + + it("edit accepts a unique id prefix and edits the goal/name", () => { + const logged = JSON.parse(run(["log", "--duration", "600", "--json"]).stdout); + const id = logged.data.id as string; + const r = run(["edit", id.slice(0, 10), "--goal", "Renamed", "--name", "Tag", "--json"]); + expect(r.status).toBe(0); + const rec = JSON.parse(r.stdout); + expect(rec.data.goal).toBe("Renamed"); + expect(rec.data.label).toBe("Tag"); + }); + + it("edit with no fields exits USAGE (2)", () => { + const logged = JSON.parse(run(["log", "--duration", "600", "--json"]).stdout); + expect(run(["edit", logged.data.id, "--json"]).status).toBe(2); + }); + + it("edit with an unknown id exits USAGE (2)", () => { + run(["log", "--duration", "600", "--json"]); + expect(run(["edit", "no-such-id", "--focus", "10m", "--json"]).status).toBe(2); + }); + it("exits NO_TTY (5) when start has no TTY and no --duration", () => { expect(run(["start", "--json"]).status).toBe(5); }); diff --git a/test/dashboard.test.ts b/test/dashboard.test.ts index 8700779..af6a772 100644 --- a/test/dashboard.test.ts +++ b/test/dashboard.test.ts @@ -28,6 +28,7 @@ import { buildFrame, compositeOverlay } from "../src/tui/app.js"; import type { LiveSession, ViewName } from "../src/tui/app.js"; import { emptyPaletteState } from "../src/tui/palette.js"; import { emptySessionFormState } from "../src/tui/sessionform.js"; +import { emptyEditFormState, openEditFormState } from "../src/tui/editform.js"; import { emptyConfirmState, openConfirmState } from "../src/tui/confirm.js"; import type { ConfirmState } from "../src/tui/confirm.js"; @@ -645,6 +646,7 @@ describe("buildFrame (WS5)", () => { summary: null, theme: "neon" as const, form: emptySessionFormState(), + edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), zenLive: false, @@ -679,6 +681,7 @@ describe("buildFrame (WS5)", () => { summary: null, theme: "neon" as const, form: emptySessionFormState(), + edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), zenLive: false, @@ -713,6 +716,7 @@ describe("buildFrame (WS5)", () => { summary: null, theme: "neon" as const, form: emptySessionFormState(), + edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), zenLive: false, @@ -729,6 +733,7 @@ describe("buildFrame (WS5)", () => { zenLive?: boolean; hideControls?: boolean; confirm?: ConfirmState; + edit?: ReturnType; } = {}) { const live: LiveSession | null = opts.live === undefined @@ -745,6 +750,7 @@ describe("buildFrame (WS5)", () => { summary: null, theme: "neon" as const, form: emptySessionFormState(), + edit: opts.edit ?? emptyEditFormState(), displayStyle: "block" as const, confirm: opts.confirm ?? emptyConfirmState(), zenLive: opts.zenLive ?? false, @@ -791,6 +797,31 @@ describe("buildFrame (WS5)", () => { expect(out).toContain("Supr"); }); + it("sessions view footer advertises the [e] edit hint", () => { + const out = buildFrame(fcState({ view: "sessions", live: null }), 100, 24, makeFakeCtx()) + .map(stripAnsi) + .join("\n"); + expect(out).toContain("[e] edit"); + }); + + it("edit overlay renders the Edit session panel + save footer", () => { + const edit = openEditFormState({ + sessionId: "sess-x", + startISO: "2026-06-12T03:00:00.000Z", + values: { goal: "Korvex", label: "", focus: "1h30m00s", break: "20m00s" }, + }); + const out = buildFrame( + fcState({ view: "sessions", live: null, edit }), + 100, + 24, + makeFakeCtx(), + ).map(stripAnsi).join("\n"); + expect(out).toContain("Edit session"); + expect(out).toContain("Focus"); + expect(out).toContain("End →"); + expect(out).toContain("[Enter] save"); + }); + it("palette open — frame contains 'Commands' panel title", () => { const ctx = makeFakeCtx(); const state = { @@ -804,6 +835,7 @@ describe("buildFrame (WS5)", () => { summary: null, theme: "neon" as const, form: emptySessionFormState(), + edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), zenLive: false, @@ -827,6 +859,7 @@ describe("buildFrame (WS5)", () => { summary: null, theme: "neon" as const, form: emptySessionFormState(), + edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), zenLive: false, @@ -850,6 +883,7 @@ describe("buildFrame (WS5)", () => { summary: null, theme: "neon" as const, form: emptySessionFormState(), + edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), zenLive: false, @@ -873,6 +907,7 @@ describe("buildFrame (WS5)", () => { summary: null, theme: "neon" as const, form: emptySessionFormState(), + edit: emptyEditFormState(), displayStyle: "block" as const, confirm: emptyConfirmState(), zenLive: false, diff --git a/test/editform.test.ts b/test/editform.test.ts new file mode 100644 index 0000000..1b483e0 --- /dev/null +++ b/test/editform.test.ts @@ -0,0 +1,310 @@ +import { describe, it, expect } from "vitest"; +import { + EDIT_FORM_FIELDS, + emptyEditFormState, + openEditFormState, + editFormApplyKey, + renderEditForm, +} from "../src/tui/editform.js"; +import type { EditFormState, EditFormValues } from "../src/tui/editform.js"; +import { displayWidth } from "../src/lib/tui/draw.js"; +import type { Key } from "../src/lib/tui/input.js"; + +// --------------------------------------------------------------------------- +// Key construction helper (mirrors sessionform.test.ts) +// --------------------------------------------------------------------------- + +const ch = (c: string): Key => ({ name: "char", char: c }); + +function typeInto(state: EditFormState, text: string): EditFormState { + let s = state; + for (const c of text) s = editFormApplyKey(s, ch(c)).state; + return s; +} + +// --------------------------------------------------------------------------- +// Fixtures +// --------------------------------------------------------------------------- + +const SAMPLE_VALUES: EditFormValues = { + goal: "Deep work", + label: "Morning sprint", + focus: "1h30m", + break: "20m", +}; + +const SAMPLE_START_ISO = "2026-06-12T03:00:00.000Z"; + +function openSample(): EditFormState { + return openEditFormState({ + sessionId: "sess-42", + startISO: SAMPLE_START_ISO, + values: { ...SAMPLE_VALUES }, + }); +} + +// --------------------------------------------------------------------------- +// State helpers +// --------------------------------------------------------------------------- + +describe("emptyEditFormState", () => { + it("is closed with null ids and blank fields", () => { + const s = emptyEditFormState(); + expect(s.open).toBe(false); + expect(s.sessionId).toBeNull(); + expect(s.startISO).toBeNull(); + expect(s.active).toBe(0); + expect(s.error).toBeNull(); + expect(s.values).toEqual({ goal: "", label: "", focus: "", break: "" }); + }); +}); + +describe("openEditFormState", () => { + it("is open and carries through opts", () => { + const s = openSample(); + expect(s.open).toBe(true); + expect(s.sessionId).toBe("sess-42"); + expect(s.startISO).toBe(SAMPLE_START_ISO); + expect(s.values).toEqual(SAMPLE_VALUES); + expect(s.active).toBe(0); + expect(s.error).toBeNull(); + }); +}); + +// --------------------------------------------------------------------------- +// Reducer — purity + editing +// --------------------------------------------------------------------------- + +describe("editFormApplyKey", () => { + it("never mutates the input state", () => { + const s = openSample(); + const frozen = JSON.stringify(s); + editFormApplyKey(s, ch("x")); + expect(JSON.stringify(s)).toBe(frozen); + }); + + it("types printable chars into the active field", () => { + const s = openEditFormState({ + sessionId: "s1", + startISO: SAMPLE_START_ISO, + values: { goal: "", label: "", focus: "", break: "" }, + }); + const next = typeInto(s, "Sprint"); + expect(next.values.goal).toBe("Sprint"); + }); + + it("appends chars onto existing value in the active field", () => { + const s = openSample(); // goal starts as "Deep work" + const next = typeInto(s, "!"); + expect(next.values.goal).toBe("Deep work!"); + }); + + it("backspace deletes the last char of the active field", () => { + let s = openSample(); + s = editFormApplyKey(s, { name: "backspace" }).state; + // "Deep work" → "Deep wor" + expect(s.values.goal).toBe("Deep wor"); + }); + + it("backspace on empty field does not crash", () => { + const s = openEditFormState({ + sessionId: "s1", + startISO: SAMPLE_START_ISO, + values: { goal: "", label: "", focus: "", break: "" }, + }); + const next = editFormApplyKey(s, { name: "backspace" }).state; + expect(next.values.goal).toBe(""); + }); + + it("tab / down advance the active field and wrap", () => { + let s = openSample(); + for (let i = 0; i < EDIT_FORM_FIELDS.length; i++) { + expect(s.active).toBe(i); + s = editFormApplyKey(s, { name: "tab" }).state; + } + expect(s.active).toBe(0); // wrapped + }); + + it("down also advances the active field", () => { + const s = openSample(); + const next = editFormApplyKey(s, { name: "down" }).state; + expect(next.active).toBe(1); + }); + + it("up moves backwards and wraps to the last field", () => { + const s = editFormApplyKey(openSample(), { name: "up" }).state; + expect(s.active).toBe(EDIT_FORM_FIELDS.length - 1); + }); + + it("routes typing to whichever field is active", () => { + let s = openEditFormState({ + sessionId: "s2", + startISO: SAMPLE_START_ISO, + values: { goal: "", label: "", focus: "", break: "" }, + }); + s = typeInto(s, "obj"); // goal + s = editFormApplyKey(s, { name: "tab" }).state; // → label + s = typeInto(s, "name"); + s = editFormApplyKey(s, { name: "tab" }).state; // → focus + s = typeInto(s, "1h"); + s = editFormApplyKey(s, { name: "tab" }).state; // → break + s = typeInto(s, "5m"); + expect(s.values).toMatchObject({ goal: "obj", label: "name", focus: "1h", break: "5m" }); + }); + + it("ignores Ctrl-C as a printable char (no append)", () => { + const s = editFormApplyKey(openSample(), ch("\x03")); + expect(s.state.values.goal).toBe(SAMPLE_VALUES.goal); // unchanged + }); + + it("escape returns a cancel action and an empty closed state", () => { + const r = editFormApplyKey(openSample(), { name: "escape" }); + expect(r.action).toEqual({ type: "cancel" }); + expect(r.state.open).toBe(false); + expect(r.state.sessionId).toBeNull(); + expect(r.state.startISO).toBeNull(); + }); + + it("enter returns a submit action carrying sessionId and values", () => { + const r = editFormApplyKey(openSample(), { name: "enter" }); + expect(r.action?.type).toBe("submit"); + if (r.action?.type === "submit") { + expect(r.action.sessionId).toBe("sess-42"); + expect(r.action.values).toEqual(SAMPLE_VALUES); + } + }); + + it("submit with null sessionId coerces to empty string", () => { + // openEditFormState always sets sessionId, but test internal path + const state: EditFormState = { + ...emptyEditFormState(), + open: true, + sessionId: null, + values: { goal: "", label: "", focus: "", break: "" }, + }; + const r = editFormApplyKey(state, { name: "enter" }); + if (r.action?.type === "submit") { + expect(r.action.sessionId).toBe(""); + } + }); + + it("unknown keys leave state unchanged", () => { + const s = openSample(); + const next = editFormApplyKey(s, { name: "home" }).state; + expect(next).toEqual(s); + }); +}); + +// --------------------------------------------------------------------------- +// Render +// --------------------------------------------------------------------------- + +describe("renderEditForm", () => { + it("returns an overlay with rows, top, left", () => { + const o = renderEditForm(openSample(), 80, 24, "neon", false); + expect(Array.isArray(o.rows)).toBe(true); + expect(o.top).toBeGreaterThanOrEqual(0); + expect(o.left).toBeGreaterThanOrEqual(0); + }); + + it("contains the 'Edit session' title", () => { + const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); + expect(joined).toContain("Edit session"); + }); + + it("contains a read-only Start line", () => { + const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); + expect(joined).toContain("Start"); + // The locale string from the fixed ISO should appear somewhere + const expected = new Date(SAMPLE_START_ISO).toLocaleString(); + expect(joined).toContain(expected); + }); + + it("contains an End → preview line", () => { + const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); + expect(joined).toContain("End →"); + }); + + it("End → line reflects start + focus + break durations", () => { + // start: 2026-06-12T03:00:00Z, focus: 1h30m (5400s), break: 20m (1200s) → +6600s + const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); + const endMs = new Date(SAMPLE_START_ISO).getTime() + (5400 + 1200) * 1000; + const expectedEnd = new Date(endMs).toLocaleString(); + expect(joined).toContain(expectedEnd); + }); + + it("End → shows — when focus value is invalid (bad parse)", () => { + const s = openEditFormState({ + sessionId: "s3", + startISO: SAMPLE_START_ISO, + values: { goal: "", label: "", focus: "xyz", break: "" }, + }); + // The End preview line should contain a dash because "xyz" cannot be parsed + const lines = renderEditForm(s, 80, 24, "neon", false).rows; + const endLine = lines.find((r) => r.includes("End →")); + expect(endLine).toBeDefined(); + expect(endLine).toContain("—"); + }); + + it("Start shows — when startISO is null", () => { + const s: EditFormState = { ...emptyEditFormState(), open: true }; + // The Start line should appear with a dash + const lines = renderEditForm(s, 80, 24, "neon", false).rows; + const startLine = lines.find((r) => r.includes("Start")); + expect(startLine).toBeDefined(); + expect(startLine).toContain("—"); + }); + + it("shows field labels Goal, Name, Focus, Break", () => { + const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); + expect(joined).toContain("Goal"); + expect(joined).toContain("Name"); + expect(joined).toContain("Focus"); + expect(joined).toContain("Break"); + }); + + it("shows the footer with [Tab] and [Enter] save and [Esc]", () => { + const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); + expect(joined).toContain("[Tab] next"); + expect(joined).toContain("[Enter] save"); + expect(joined).toContain("[Esc] cancel"); + }); + + it("renders the typed / pre-populated value in the field", () => { + const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); + expect(joined).toContain("Deep work"); + expect(joined).toContain("1h30m"); + }); + + it("surfaces an error line when state.error is set", () => { + const s: EditFormState = { ...openSample(), error: "invalid duration" }; + const joined = renderEditForm(s, 80, 24, "neon", false).rows.join("\n"); + expect(joined).toContain("invalid duration"); + }); + + it("no overlay row exceeds the box width", () => { + const o = renderEditForm(openSample(), 80, 24, "neon", false); + const w = displayWidth(o.rows[0]!); + for (const row of o.rows) { + expect(displayWidth(row)).toBe(w); + } + }); + + it("color mode does not throw and stays within width", () => { + const o = renderEditForm(openSample(), 80, 24, "neon", true); + const w = displayWidth(o.rows[0]!); + for (const row of o.rows) { + expect(displayWidth(row)).toBeLessThanOrEqual(w); + } + }); + + it("works at narrow terminal width (34 cols minimum)", () => { + const o = renderEditForm(openSample(), 34, 24, "neon", false); + expect(o.rows.length).toBeGreaterThan(0); + }); + + it("active field has the cursor bar ▏", () => { + const joined = renderEditForm(openSample(), 80, 24, "neon", false).rows.join("\n"); + expect(joined).toContain("▏"); + }); +}); diff --git a/test/manifest.test.ts b/test/manifest.test.ts index 82a5a20..6a4597b 100644 --- a/test/manifest.test.ts +++ b/test/manifest.test.ts @@ -15,6 +15,7 @@ describe("manifest", () => { "config", "dashboard", "doctor", + "edit", "goals", "history", "log", diff --git a/test/session-edit.test.ts b/test/session-edit.test.ts new file mode 100644 index 0000000..d51b19d --- /dev/null +++ b/test/session-edit.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from "vitest"; +import { recomputeSession } from "../src/lib/session-edit.js"; +import { SessionSchema, type Session } from "../src/schemas/session.js"; + +/** Build a valid session with sensible defaults for editing tests. */ +function makeSession(over: Partial = {}): Session { + return SessionSchema.parse({ + id: "2026-06-12T03-00-00-000-aaaa", + start: "2026-06-12T03:00:00.000Z", + end: "2026-06-12T04:00:00.000Z", + durationS: 3600, + breaks: [], + breakS: 0, + goal: "Deep work", + label: "Korvex", + ...over, + }); +} + +/** A session with one 30m meal break: 1h focus, 30m break, 1h focus = end +2h30m. */ +function makeWithBreak(): Session { + return SessionSchema.parse({ + id: "2026-06-12T03-00-00-000-bbbb", + start: "2026-06-12T03:00:00.000Z", + end: "2026-06-12T05:30:00.000Z", + durationS: 7200, // 2h focus + breaks: [ + { + start: "2026-06-12T04:00:00.000Z", + end: "2026-06-12T04:30:00.000Z", + durationS: 1800, + category: "meal", + label: null, + suggestedS: null, + }, + ], + breakS: 1800, + goal: "Deep work", + label: "Korvex", + }); +} + +const focusOf = (s: Session) => s.durationS; +const endSec = (s: Session) => (Date.parse(s.end) - Date.parse(s.start)) / 1000; + +describe("recomputeSession — focus edit (no breaks)", () => { + it("trims focus and moves end earlier; start is immutable", () => { + const s = makeSession({ durationS: 21600, end: "2026-06-12T09:00:00.000Z" }); // fell asleep, 6h + const out = recomputeSession(s, { focusS: 5400 }); // real focus = 1h30m + expect(out.start).toBe(s.start); + expect(out.durationS).toBe(5400); + expect(out.breakS).toBe(0); + expect(out.breaks).toEqual([]); + expect(endSec(out)).toBe(5400); + expect(out.end).toBe("2026-06-12T04:30:00.000Z"); + }); + + it("extends focus and moves end later", () => { + const out = recomputeSession(makeSession(), { focusS: 7200 }); + expect(out.durationS).toBe(7200); + expect(endSec(out)).toBe(7200); + }); + + it("accepts focus of 0", () => { + const out = recomputeSession(makeSession(), { focusS: 0 }); + expect(out.durationS).toBe(0); + expect(endSec(out)).toBe(0); + expect(out.end).toBe(out.start); + }); + + it("clears legacy pauses on edit so re-read never re-derives stale breaks", () => { + const s = makeSession({ + pauses: [{ start: "2026-06-12T03:10:00.000Z", end: "2026-06-12T03:15:00.000Z", durationS: 300 }], + }); + const out = recomputeSession(s, { focusS: 1800 }); + expect(out.pauses).toEqual([]); + }); +}); + +describe("recomputeSession — focus edit keeps breaks intact (trim from end)", () => { + it("reducing focus keeps the meal break exactly, trims the tail focus", () => { + const s = makeWithBreak(); + // 6h tail-runaway: pretend end is far out with same single break. + const ranAway = recomputeSession(s, { focusS: 7200 + 14400 }); // extend tail by 4h first + expect(ranAway.breaks).toHaveLength(1); + // Now correct it back down to 2h total focus. + const fixed = recomputeSession(ranAway, { focusS: 7200 }); + expect(fixed.durationS).toBe(7200); + expect(fixed.breakS).toBe(1800); + expect(fixed.breaks).toHaveLength(1); + expect(fixed.breaks[0]!.category).toBe("meal"); + expect(fixed.breaks[0]!.durationS).toBe(1800); + // end = start + 2h focus + 30m break = 2h30m + expect(endSec(fixed)).toBe(7200 + 1800); + }); + + it("cascades the trim backwards when the last focus segment is too small", () => { + // focus split: 1h before break, 1h after. Trim to 30m total focus → last seg + // (1h) can only give 1h; need to remove 1h30m, so 1h from tail + 30m from head. + const s = makeWithBreak(); + const out = recomputeSession(s, { focusS: 1800 }); // 30m total focus + expect(out.durationS).toBe(1800); + expect(out.breakS).toBe(1800); + expect(out.breaks).toHaveLength(1); + // First focus segment shrank from 1h to 30m; break starts at +30m. + expect(out.breaks[0]!.start).toBe("2026-06-12T03:30:00.000Z"); + expect(endSec(out)).toBe(1800 + 1800); + }); +}); + +describe("recomputeSession — break edit", () => { + it("setting break to 0 removes all breaks and shifts end in", () => { + const out = recomputeSession(makeWithBreak(), { breakS: 0 }); + expect(out.breaks).toEqual([]); + expect(out.breakS).toBe(0); + expect(out.durationS).toBe(7200); // focus untouched + expect(endSec(out)).toBe(7200); + }); + + it("scaling break preserves category and hits the exact new total", () => { + const out = recomputeSession(makeWithBreak(), { breakS: 600 }); // 30m → 10m + expect(out.breaks).toHaveLength(1); + expect(out.breaks[0]!.category).toBe("meal"); + expect(out.breakS).toBe(600); + expect(out.breaks[0]!.durationS).toBe(600); + expect(endSec(out)).toBe(7200 + 600); + }); + + it("adds a single rest break when the session had none", () => { + const out = recomputeSession(makeSession(), { breakS: 900 }); + expect(out.breaks).toHaveLength(1); + expect(out.breaks[0]!.category).toBe("rest"); + expect(out.breakS).toBe(900); + expect(endSec(out)).toBe(3600 + 900); + }); + + it("omitting breakS keeps recorded breaks byte-for-byte", () => { + const s = makeWithBreak(); + const out = recomputeSession(s, { goal: "X" }); + expect(out.breaks[0]!.durationS).toBe(1800); + expect(out.breaks[0]!.start).toBe(s.breaks[0]!.start); + expect(out.breaks[0]!.end).toBe(s.breaks[0]!.end); + }); + + it("scales multiple breaks proportionally and sums exactly", () => { + const s = SessionSchema.parse({ + id: "x", + start: "2026-06-12T03:00:00.000Z", + end: "2026-06-12T06:00:00.000Z", + durationS: 7200, + breaks: [ + { start: "2026-06-12T04:00:00.000Z", end: "2026-06-12T04:20:00.000Z", durationS: 1200, category: "rest", label: null, suggestedS: null }, + { start: "2026-06-12T05:20:00.000Z", end: "2026-06-12T05:40:00.000Z", durationS: 1200, category: "walk", label: null, suggestedS: null }, + ], + breakS: 2400, + }); + const out = recomputeSession(s, { breakS: 1000 }); + expect(out.breakS).toBe(1000); + expect(out.breaks.reduce((a, b) => a + b.durationS, 0)).toBe(1000); + expect(out.breaks.map((b) => b.category)).toEqual(["rest", "walk"]); + }); +}); + +describe("recomputeSession — metadata + combined edits", () => { + it("edits goal and label, clears with null, preserves when undefined", () => { + const a = recomputeSession(makeSession(), { goal: "New goal" }); + expect(a.goal).toBe("New goal"); + expect(a.label).toBe("Korvex"); + const b = recomputeSession(makeSession(), { label: null }); + expect(b.label).toBeNull(); + expect(b.goal).toBe("Deep work"); + }); + + it("combined focus + break + goal edit stays internally consistent", () => { + const out = recomputeSession(makeWithBreak(), { focusS: 3600, breakS: 600, goal: "Z" }); + expect(out.durationS).toBe(3600); + expect(out.breakS).toBe(600); + expect(out.goal).toBe("Z"); + expect(endSec(out)).toBe(3600 + 600); + expect(out.id).toBe(makeWithBreak().id); // id preserved + }); + + it("invariant: end - start === focus + break for random edits", () => { + const base = makeWithBreak(); + for (const f of [0, 1, 599, 3600, 99999]) { + for (const b of [0, 1, 333, 1800, 7200]) { + const out = recomputeSession(base, { focusS: f, breakS: b }); + expect(focusOf(out)).toBe(f); + expect(out.breakS).toBe(b); + expect(endSec(out)).toBe(f + b); + // never-negative durations + for (const brk of out.breaks) expect(brk.durationS).toBeGreaterThanOrEqual(0); + } + } + }); +}); diff --git a/test/session.test.ts b/test/session.test.ts index 2e49d73..20f7bb1 100644 --- a/test/session.test.ts +++ b/test/session.test.ts @@ -11,6 +11,7 @@ import { join } from "node:path"; import { appendSession, deleteSession, + updateSession, readSessions, querySessions, makeSessionId, @@ -50,6 +51,30 @@ describe("session store", () => { expect(sessions[1]!.durationS).toBe(120); }); + it("updates a session by id, recomputes end, persists atomically", () => { + appendSession(file, sample(60, "2026-05-01T10:00:00.000Z")); + const b = appendSession(file, sample(21600, "2026-05-02T10:00:00.000Z")); // 6h runaway + const updated = updateSession(file, b.id, { focusS: 5400 }); // → 1h30m + expect(updated).not.toBeNull(); + expect(updated!.durationS).toBe(5400); + expect(updated!.end).toBe("2026-05-02T11:30:00.000Z"); + // start immutable; other session untouched; change persisted to disk + const { sessions } = readSessions(file); + expect(sessions).toHaveLength(2); + const onDisk = sessions.find((s) => s.id === b.id)!; + expect(onDisk.durationS).toBe(5400); + expect(onDisk.start).toBe(b.start); + }); + + it("updateSession returns null for an unknown id and leaves the file untouched", () => { + const a = appendSession(file, sample(60, "2026-05-01T10:00:00.000Z")); + const before = readSessions(file).sessions; + const res = updateSession(file, "no-such-id", { focusS: 10 }); + expect(res).toBeNull(); + expect(readSessions(file).sessions).toEqual(before); + expect(a.id).toBeTruthy(); + }); + it("deletes a session by id and persists the rest", () => { const a = appendSession(file, sample(60, "2026-05-01T10:00:00.000Z")); const b = appendSession(file, sample(120, "2026-05-02T10:00:00.000Z")); From 31e1dbedd99b735c50260118471fd2af49ac490d Mon Sep 17 00:00:00 2001 From: Eduardo Borjas Date: Fri, 12 Jun 2026 17:55:31 -0600 Subject: [PATCH 2/2] ci: scope dependency audit to production deps (--omit=dev) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The published package ships no build tooling — esbuild/tsup/vite are devDependencies and never installed by consumers (`npm ls esbuild --omit=dev` is empty; `npm audit --omit=dev` reports 0 vulnerabilities). Two freshly published esbuild advisories (dev-server file read on Windows; Deno-module RCE) are build-time only and cannot reach the published artifact, yet were failing CI and would block the tag-triggered release. Scope both the CI and release audit gates to production dependencies so the gate guards exactly what users install, without blocking releases on transitive dev-tool advisories. Full-tree `npm audit` remains available locally. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/ci.yml | 7 ++++++- .github/workflows/release.yml | 6 +++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 5ab6116..125cf52 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -33,7 +33,12 @@ jobs: run: npm ci --ignore-scripts - name: Dependency security audit - run: npm audit --audit-level=high + # Scope to production deps: the published package ships no build tooling + # (esbuild/tsup/vite are devDependencies, never installed by users), so + # dev-only advisories must not block a release. `--omit=dev` audits + # exactly what users install. Run a full `npm audit` locally for dev-tool + # advisories. + run: npm audit --audit-level=high --omit=dev - name: Lint run: npm run lint diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 010c242..ae4b1b0 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -28,7 +28,11 @@ jobs: run: npm ci --ignore-scripts - name: Dependency security audit - run: npm audit --audit-level=high + # Scope to production deps: the published package ships no build tooling + # (esbuild/tsup/vite are devDependencies, never installed by users), so + # dev-only advisories must not block a release. `--omit=dev` audits + # exactly what users install. + run: npm audit --audit-level=high --omit=dev - name: Lint and typecheck run: npm run lint && npm run typecheck