Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
32 changes: 32 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <id>` CLI command (headless / agent-friendly).** Edits a
logged session's essential values without touching the JSON file by hand.
Flags: `--focus <dur>` · `--break <dur>` · `--goal <text>` · `--name <text>` ·
`--json`. `<id>` 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
Expand Down
10 changes: 9 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
```
Expand Down Expand Up @@ -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 <id> --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
Expand All @@ -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
Expand Down Expand Up @@ -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 <id>` 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 |

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
43 changes: 43 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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("<id>", "session id (or a unique id prefix)")
.option("--focus <dur>", "new active focus time, e.g. 1h30m, 90m, 45s")
.option("--break <dur>", "new total break time, e.g. 20m (0 clears breaks)")
.option("--goal <text>", "new goal/intention (empty string clears)")
.option("--name <text>", "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
Expand Down
80 changes: 80 additions & 0 deletions src/commands/edit.ts
Original file line number Diff line number Diff line change
@@ -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 <id> --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(", ")}`);
}
}
17 changes: 17 additions & 0 deletions src/lib/format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*
Expand Down
33 changes: 33 additions & 0 deletions src/lib/manifest.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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: ["<id>"],
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 <id> --break 0 --goal 'Deep work'",
],
},
{
name: "stats",
summary:
Expand Down
Loading
Loading