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
14 changes: 13 additions & 1 deletion CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,19 @@ All notable changes to adhd-ranch. Follows [Keep a Changelog](https://keepachang

## [Unreleased]

### Added — 052 task timers and clock dropdown editing (PR #60, in flight)

- `Task.timer: Option<FocusTimer>` — Tasks can carry independent countdown timers.
- `task-timers.json` — optional per-Focus sidecar storing Task timers by task index; deleting a Task removes the matching timer entry.
- Tauri/command API for starting and clearing Focus timers and Task timers from the detail UI.
- `TimerDropdown` — compact clock/time control used by the Focus title and each Task row in `AnimalDetail`.
- `AnimalDetail` replaces `PigDetail` for the clicked-animal card naming, including CSS/test IDs.

### Changed — 052

- Timer editing is now accessed by clicking the clock icon or current time instead of showing an always-visible picker.
- Removed the heavy offset shadow behind `AnimalDetail` that produced a rounded/bubbly artifact around the card.

### Added — 034 focus/task invariants in domain (PR #40, in flight)

- `crates/domain/src/error.rs` — new `DomainError` enum: `EmptyTitle`, `EmptyTaskText`
Expand Down Expand Up @@ -153,4 +166,3 @@ Regular Mac app pivot. Replaces the tray-popover model with a draggable floating
- `settings.yaml`: caps, alerts, widget config
- `.app` + `.dmg` packaging; tag-driven GitHub releases
- CI: lint + typecheck + tests on push

18 changes: 10 additions & 8 deletions CONTEXT.md
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,11 @@ A pending suggestion from the in-session agent at `/checkpoint` time. Three kind

### FocusTimer

An optional countdown attached to a Focus at creation time. Stores `duration_secs`, `started_at` (unix timestamp), and `status` (`Running` | `Expired`). Drives pig scale growth (1.0× at creation → 3.0× at expiry) and expiry alerts. Ephemeral in the sense that pinned/frozen state is not persisted, but the timer itself survives restarts.
An optional countdown attached to a Focus or Task. Stores `duration_secs`, `started_at` (unix timestamp), and `status` (`Running` | `Expired`). Focus-level timers drive pig scale growth (1.0× at creation → 3.0× at expiry) and expired-focus alerts. Task-level timers are persisted and displayed in `AnimalDetail`, but do not yet feed the background expiry notification workflow. Ephemeral in the sense that pinned/frozen state is not persisted, but timers themselves survive restarts.

### TimerPreset

A named duration choice offered at Focus creation: 2m, 4m, 8m, 16m, 32m, or Custom (free integer minutes). Maps to `duration_secs` in `FocusTimer`.
A named duration choice offered for Focus and Task timers: 2m, 4m, 8m, 16m, 32m, or Custom (free integer minutes). Maps to `duration_secs` in `FocusTimer`.

### TaskText

Expand All @@ -53,7 +53,8 @@ Focus = a self-contained directory under `~/.adhd-ranch/focuses/<slug>/`:
```
focuses/<slug>/
focus.md # frontmatter (id, title, description, created_at) + Tasks body
timer.json # optional; present only when Focus was created with a TimerPreset
timer.json # optional Focus timer
task-timers.json # optional Task timers, indexed to task order
```

Top-level state:
Expand Down Expand Up @@ -82,7 +83,8 @@ created_at: 2026-04-30T12:00:00Z

- Tasks = top-level checkbox bullets in body. One bullet = one Task. Plain text only — no metadata fields.
- `description` is the load-bearing field for routing — agent reads it to decide if a summary belongs.
- `timer.json` sidecar: `{ "duration_secs": N, "started_at": T, "status": "Running"|"Expired" }`. Written atomically; if write fails, focus dir is rolled back. Loaded alongside `focus.md` on every `list()` call.
- `timer.json` sidecar: `{ "duration_secs": N, "started_at": T, "status": "Running"|"Expired" }`. Written atomically; if write fails during focus creation, focus dir is rolled back. Loaded alongside `focus.md` on every `list()` call.
- `task-timers.json` sidecar: JSON array of optional `FocusTimer` values. Array index matches the parsed task index; deleting a Task removes the matching timer entry. Corrupted or missing task timer sidecars degrade to no task timers.
- User hand-edits anywhere; file watcher reflects changes.
- Atomic write via tmpfile + rename. `flock` per file.

Expand All @@ -98,11 +100,11 @@ created_at: 2026-04-30T12:00:00Z
Steps 1–8 are implemented. Display spanning uses a Rust-emitted DisplaySpace model so monitor geometry policy is local to the display module, while RanchAnimal movement consumes normalized visible monitor regions instead of the raw overlay span.

1. **Pigs roam the screen.** One pig per Focus, wandering at 60px/s with random direction changes; minimum velocity floor so pigs never look frozen. 4-direction pixel-art sprite sheet (016). Hit-box is 16px larger than sprite (018).
2. **Click a pig.** Pig freezes. `PigDetail` card opens near the pig (340px, opaque dark background, 16px padding): Focus title + scrollable Task list with `✗` per Task + "Add task…" input at bottom. Enter appends a task inline. Click-outside or Escape closes; pig resumes (019).
3. **Drag a pig.** Click-and-hold then move > 4px enters drag mode — pig follows cursor. Release sends pig flying in that direction; friction decelerates it; bounces at screen edges. Pure click (< 4px movement) still opens PigDetail (020).
2. **Click a pig.** Pig freezes. `AnimalDetail` card opens near the pig (340px, opaque dark background, 16px padding): Focus title + scrollable Task list with `✗` per Task + "Add task…" input at bottom. Clock/time controls on the Focus title and each Task open compact timer dropdowns. Enter appends a task inline. Click-outside or Escape closes; pig resumes (019, 052).
3. **Drag a pig.** Click-and-hold then move > 4px enters drag mode — pig follows cursor. Release sends pig flying in that direction; friction decelerates it; bounces at screen edges. Pure click (< 4px movement) still opens AnimalDetail (020).
4. **Clear a task.** Tap `✗` → `delete_task` Tauri command → markdown updated → pig's task list reflects change.
5. **Add a task.** Type in "Add task…" input in PigDetail → Enter → `append_task` Tauri command → markdown updated.
6. **Create a Focus.** *(014)* Menu bar item → "+ New Focus" → small webview form → `create_focus` → new pig spawns. Timer dropdown (No timer / 2m / 4m / 8m / 16m / 32m / Custom) optionally attaches a `FocusTimer` (028).
5. **Add a task.** Type in "Add task…" input in AnimalDetail → Enter → `append_task` Tauri command → markdown updated.
6. **Create a Focus.** *(014)* Menu bar item → "+ New Focus" → small webview form → `create_focus` → new pig spawns. Timer dropdown (No timer / 2m / 4m / 8m / 16m / 32m / Custom) optionally attaches a `FocusTimer` (028). Focus and Task timers can later be started or cleared from `AnimalDetail` (052).
7. **Delete a Focus.** *(015)* Menu bar item → Focus submenu → "Delete…" → `delete_focus` → pig disappears. (Optional confirmation tracked in issue `#027`.)
8. **Configure displays.** *(017, 049)* Tray Displays section — check/uncheck monitors. Enabled monitors share one spanning overlay window; RanchAnimals spawn in the primary display region and move only inside normalized visible monitor regions. Persists in `settings.yaml`. The display module owns monitor geometry, and React owns movement over the emitted DisplaySpace model.

Expand Down
1 change: 1 addition & 0 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

17 changes: 9 additions & 8 deletions PRD.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Solo developer (initially: the author) who:
## Goals (v1.2)

1. Pixel pig sprites roam a fullscreen transparent overlay — one pig per Focus.
2. Clicking a pig shows its name and Task list in a small popover. Tasks can be cleared from the popover.
2. Clicking a pig shows its name and Task list in the `AnimalDetail` card. Tasks can be cleared from that card, and Focus/Task timers can be edited from clock/time controls.
3. Manual Focus creation via menu bar item (simple native-style list). No agent flow in v1.2.
4. Markdown is the source of truth — user can hand-edit any Focus file; pig count updates live via file watcher.
5. Hard caps (5 Focuses, 7 Tasks per Focus) with overload alerts.
Expand All @@ -49,7 +49,7 @@ Solo developer (initially: the author) who:
## User stories

- **US1.** I glance at my screen and see three pixel pigs wandering in the corners. I know immediately: three things are on my plate. I don't have to open anything.
- **US2.** I click a pig. A small dark card appears near the pig: its name and a short task list. I tap `✗` next to a task. It disappears. Card closes when I click elsewhere.
- **US2.** I click a pig. A small dark card appears near the pig: its name and a short task list. I tap `✗` next to a task. It disappears. I click a clock/time control to start or clear a timer. Card closes when I click elsewhere.
- **US3.** I finish a Focus. I open the menu bar item, find it in the list, and delete it. Pig disappears from the screen.
- **US4.** I add a Focus: click the menu bar item → "+ New Focus" → enter name + description. A new pig spawns and starts wandering.
- **US5.** I hand-edit `~/.adhd-ranch/focuses/customer-x-bug/focus.md` in vim, append `- [ ] release staging`. Save. Pig's task list reflects it within seconds.
Expand All @@ -74,9 +74,10 @@ Unchanged. Each Focus is a directory under `~/.adhd-ranch/focuses/<slug>/` conta
- Pigs wander the full screen: slow drift (~35 px/s), smooth random direction changes every 3–8 s, gentle boundary steering (40px margin from edges).
- Animation: 4 frames per direction (left/right), ticked at ~150ms (≈6.7fps).
- Sprite: real pixel-art pig sprite sheet (4 directions × 4 frames in one PNG).
- Clicking a pig opens `PigDetail` popover near the pig (edge-clamped): Focus title + task list + `✗` per task.
- `PigDetail` closes on click-outside.
- **Timer growth (028 + 030 done):** If a Focus has a `FocusTimer`, its current animal projection grows from 1× to 3× sprite size linearly over the timer window. Focuses without a timer stay at 1×. Expired animals show a distinct visual style and appear in the tray's Expired section. The only concrete animal today is still the pig sprite, so `PigSprite` and `PigDetail` remain valid component names until a broader animal-vocabulary refactor lands.
- Clicking a pig opens the `AnimalDetail` panel near the pig (edge-clamped): Focus title + task list + `✗` per task.
- Focus and Task timer editing is accessed by clicking the clock icon or current remaining time. No timer renders as a small clock; a running/expired timer renders as its current time/expired status.
- `AnimalDetail` closes on click-outside.
- **Timer growth (028 + 030 done):** If a Focus has a `FocusTimer`, its current animal projection grows from 1× to 3× sprite size linearly over the timer window. Focuses without a timer stay at 1×. Expired animals become ghostly, stop moving, face away, and appear in the tray's Expired section. Adding a new task to an expired Focus clears the expired timer and revives the animal. Task timers are independent per Task and currently affect only the `AnimalDetail` timer display. The only concrete animal today is still the pig sprite; the detail surface is animal-neutral as `AnimalDetail`.

### FR4 — Menu bar item

Expand Down Expand Up @@ -119,7 +120,7 @@ displays:
enabled: 0
```

Timer presets available at Focus creation: No timer / 2m / 4m / 8m / 16m / 32m / Custom (free integer minutes).
Timer presets available at Focus creation and in `AnimalDetail` clock dropdowns: No timer / 2m / 4m / 8m / 16m / 32m / Custom (free integer minutes). `AnimalDetail` allows start/restart/clear for the Focus timer and each Task timer.

### FR8 — Audit log

Expand Down Expand Up @@ -161,9 +162,9 @@ Retained. Every accepted/rejected proposal appended to `~/.adhd-ranch/decisions.

1. **Phase 0 (done):** Tauri skeleton, storage, HTTP API, markdown read/write, caps, file watcher, proposals queue.
2. **Phase 1 (done):** Custom titlebar, app menu, always-on-top, regular Mac app.
3. **Phase 2 (done):** Transparent fullscreen window, click-through Rust polling thread, `PigSprite` placeholder, `usePigMovement`, `PigDetail` popover, tray icon + live focus list, typed errors, structured logging.
3. **Phase 2 (done):** Transparent fullscreen window, click-through Rust polling thread, `PigSprite` placeholder, `usePigMovement`, animal detail card, tray icon + live focus list, typed errors, structured logging.
4. **Phase 3 (done):** ~~New-focus creation from tray (014)~~, ~~delete from tray (015)~~, ~~configurable display spanning (017)~~, ~~real sprite sheet (016)~~.
5. **Phase 3 polish (done):** ~~Larger pig hitbox + `buildHitRects` (018)~~, ~~PigDetail redesign — opaque, 340px, inline task add (019)~~, ~~drag-and-toss pig physics with friction (020)~~.
5. **Phase 3 polish (done):** ~~Larger pig hitbox + `buildHitRects` (018)~~, ~~AnimalDetail redesign — opaque, 340px, inline task add (019)~~, ~~drag-and-toss pig physics with friction (020)~~.
6. **Phase 3 polish (done):** ~~Display subsystem refactor (024)~~, ~~Pig freeze regression fix + keep-still toggle (025)~~, ~~Settings/preferences consolidation (026)~~, ~~timer growth + expired tray list (030)~~, ~~DisplaySpace seam for RanchAnimal movement (049)~~.
7. **Icebox:** all-monitors default on first launch (021), wrangle pig / wrangle all (022).
8. **Phase 4 — Agent flow (v1.3):** Restore `/checkpoint` command + proposal queue UI (tray submenu or modal).
7 changes: 5 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ See `PRD.md`, `CONTEXT.md`, and `CLAUDE.md` for the full design and the programm
1. Open the tray menu. Click **+ New Focus** and give it a title + short description. The description is retained for the deferred v1.3 routing agent.
2. A pig appears for each Focus and wanders on the overlay.
3. Click a pig to open its detail card. Add, edit, complete, or clear Tasks from there.
4. Hand-edit `~/.adhd-ranch/focuses/<slug>/focus.md` whenever you want — the watcher reflects changes within a second. Adding `- [ ] something` adds a task, deleting a line removes it.
4. Click the clock/time control on the Focus title or on any Task to start, restart, or clear a timer. Focus timers also drive animal growth and expired-focus alerts.
5. Hand-edit `~/.adhd-ranch/focuses/<slug>/focus.md` whenever you want — the watcher reflects changes within a second. Adding `- [ ] something` adds a task, deleting a line removes it.

## Limits + alerts

Expand Down Expand Up @@ -52,7 +53,9 @@ Missing keys fall back to defaults. Settings changed through the app are persist
~/.adhd-ranch/
focuses/
<slug>/focus.md YAML frontmatter + - [ ] bullets
<slug>/timer.json optional countdown timer sidecar
<slug>/timer.json optional focus countdown timer sidecar
<slug>/task-timers.json
optional task countdown timer sidecar, indexed to task order
proposals.jsonl pending proposals, one per line
decisions.jsonl audit log of accept/reject (with edited flag)
settings.yaml optional caps + notification/widget/display config
Expand Down
15 changes: 15 additions & 0 deletions crates/commands/src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,20 @@ mod tests {
) -> Result<(), FocusStoreError> {
unimplemented!()
}
fn clear_timer(&self, _focus_id: &str) -> Result<(), FocusStoreError> {
unimplemented!()
}
fn update_task_timer(
&self,
_focus_id: &str,
_index: usize,
_timer: &adhd_ranch_domain::FocusTimer,
) -> Result<(), FocusStoreError> {
unimplemented!()
}
fn clear_task_timer(&self, _focus_id: &str, _index: usize) -> Result<(), FocusStoreError> {
unimplemented!()
}
}

fn focus_with_tasks(id: &str, count: usize) -> Focus {
Expand All @@ -199,6 +213,7 @@ mod tests {
id: format!("{id}:{i}"),
text: format!("t{i}"),
done: false,
timer: None,
})
.collect(),
timer: None,
Expand Down
100 changes: 100 additions & 0 deletions crates/commands/src/focus.rs
Original file line number Diff line number Diff line change
Expand Up @@ -109,6 +109,31 @@ impl Commands {
Ok(())
}

pub fn clear_timer(&self, focus_id: &str) -> Result<(), CommandError> {
self.store.clear_timer(focus_id)?;
Ok(())
}

pub fn start_task_timer(
&self,
focus_id: &str,
index: usize,
preset: TimerPreset,
) -> Result<(), CommandError> {
let timer = FocusTimer {
duration_secs: preset.duration_secs(),
started_at: (self.clock_secs)(),
status: TimerStatus::Running,
};
self.store.update_task_timer(focus_id, index, &timer)?;
Ok(())
}

pub fn clear_task_timer(&self, focus_id: &str, index: usize) -> Result<(), CommandError> {
self.store.clear_task_timer(focus_id, index)?;
Ok(())
}

pub fn caps(&self) -> Caps {
self.settings.caps
}
Expand Down Expand Up @@ -186,6 +211,35 @@ mod tests {
assert!(matches!(err, CommandError::BadRequest(_)));
}

#[test]
fn append_task_revives_expired_focus() {
let (commands, _dir) = build_commands(1_700_000_000);
let created = commands
.create_focus(CreateFocusInput {
title: "Dead focus".into(),
description: String::new(),
timer_preset: None,
})
.unwrap();
commands
.store
.update_timer(
&created.id,
&FocusTimer {
duration_secs: 60,
started_at: 1_000,
status: TimerStatus::Expired,
},
)
.unwrap();

commands.append_task(&created.id, "new life").unwrap();

let focuses = commands.list_focuses().unwrap();
assert!(focuses[0].timer.is_none());
assert_eq!(focuses[0].tasks[0].text, "new life");
}

#[test]
fn rename_focus_updates_title() {
let (commands, _dir) = build_commands(0);
Expand Down Expand Up @@ -295,6 +349,52 @@ mod tests {
assert!(matches!(err, CommandError::NotFound(_)));
}

#[test]
fn clear_timer_removes_focus_timer() {
let (commands, _dir) = build_commands(1_700_000_500);
let created = commands
.create_focus(CreateFocusInput {
title: "Timed".into(),
description: String::new(),
timer_preset: Some(TimerPreset::Two),
})
.unwrap();

commands.clear_timer(&created.id).unwrap();

let focuses = commands.list_focuses().unwrap();
assert!(focuses[0].timer.is_none());
}

#[test]
fn start_task_timer_sets_running_timer_on_task() {
let started_at = 1_700_000_500_i64;
let (commands, _dir) = build_commands(started_at);
let created = commands
.create_focus(CreateFocusInput {
title: "Task timers".into(),
description: String::new(),
timer_preset: None,
})
.unwrap();
commands.append_task(&created.id, "one").unwrap();
commands.append_task(&created.id, "two").unwrap();

commands
.start_task_timer(&created.id, 1, TimerPreset::Four)
.unwrap();

let focuses = commands.list_focuses().unwrap();
assert!(focuses[0].tasks[0].timer.is_none());
let timer = focuses[0].tasks[1]
.timer
.as_ref()
.expect("task timer should be Some");
assert_eq!(timer.duration_secs, 240);
assert_eq!(timer.started_at, started_at);
assert_eq!(timer.status, TimerStatus::Running);
}

#[test]
fn create_focus_with_preset_stores_timer_with_correct_duration() {
let started_at = 1_700_000_000_i64;
Expand Down
1 change: 1 addition & 0 deletions crates/domain/src/caps.rs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ mod tests {
id: format!("{id}:{i}"),
text: format!("t{i}"),
done: false,
timer: None,
})
.collect(),
timer: None,
Expand Down
Loading
Loading