diff --git a/CHANGELOG.md b/CHANGELOG.md index 93b3c45..2030313 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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` — 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` @@ -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 - diff --git a/CONTEXT.md b/CONTEXT.md index 43cb676..79868d3 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 @@ -53,7 +53,8 @@ Focus = a self-contained directory under `~/.adhd-ranch/focuses//`: ``` focuses// 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: @@ -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. @@ -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. diff --git a/Cargo.lock b/Cargo.lock index aaf9084..06a391d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -70,6 +70,7 @@ version = "0.0.0" dependencies = [ "adhd-ranch-domain", "fs2", + "log", "notify", "notify-debouncer-mini", "serde", diff --git a/PRD.md b/PRD.md index 63cdd17..c2d3ddf 100644 --- a/PRD.md +++ b/PRD.md @@ -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. @@ -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. @@ -74,9 +74,10 @@ Unchanged. Each Focus is a directory under `~/.adhd-ranch/focuses//` 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 @@ -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 @@ -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). diff --git a/README.md b/README.md index 1f7e7ed..97643e5 100644 --- a/README.md +++ b/README.md @@ -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//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//focus.md` whenever you want — the watcher reflects changes within a second. Adding `- [ ] something` adds a task, deleting a line removes it. ## Limits + alerts @@ -52,7 +53,9 @@ Missing keys fall back to defaults. Settings changed through the app are persist ~/.adhd-ranch/ focuses/ /focus.md YAML frontmatter + - [ ] bullets - /timer.json optional countdown timer sidecar + /timer.json optional focus countdown timer sidecar + /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 diff --git a/crates/commands/src/caps.rs b/crates/commands/src/caps.rs index b6fd304..a48286f 100644 --- a/crates/commands/src/caps.rs +++ b/crates/commands/src/caps.rs @@ -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 { @@ -199,6 +213,7 @@ mod tests { id: format!("{id}:{i}"), text: format!("t{i}"), done: false, + timer: None, }) .collect(), timer: None, diff --git a/crates/commands/src/focus.rs b/crates/commands/src/focus.rs index 7c23387..eed612d 100644 --- a/crates/commands/src/focus.rs +++ b/crates/commands/src/focus.rs @@ -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 } @@ -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); @@ -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; diff --git a/crates/domain/src/caps.rs b/crates/domain/src/caps.rs index 0e7ec7d..ff8d7c5 100644 --- a/crates/domain/src/caps.rs +++ b/crates/domain/src/caps.rs @@ -45,6 +45,7 @@ mod tests { id: format!("{id}:{i}"), text: format!("t{i}"), done: false, + timer: None, }) .collect(), timer: None, diff --git a/crates/domain/src/focus.rs b/crates/domain/src/focus.rs index 98eede9..5952d18 100644 --- a/crates/domain/src/focus.rs +++ b/crates/domain/src/focus.rs @@ -33,6 +33,8 @@ pub struct Task { pub text: String, #[serde(default)] pub done: bool, + #[serde(skip_serializing_if = "Option::is_none", default)] + pub timer: Option, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -82,6 +84,7 @@ mod tests { id: "abc:0".into(), text: "step one".into(), done: false, + timer: None, }], timer: None, }; diff --git a/crates/domain/src/parse.rs b/crates/domain/src/parse.rs index be989d5..f83178e 100644 --- a/crates/domain/src/parse.rs +++ b/crates/domain/src/parse.rs @@ -105,6 +105,7 @@ fn parse_tasks(body: &str, focus_id: &str) -> Vec { id: format!("{focus_id}:{index}"), text, done, + timer: None, }) .collect() } diff --git a/crates/storage/Cargo.toml b/crates/storage/Cargo.toml index 9aa8e5f..4ce7489 100644 --- a/crates/storage/Cargo.toml +++ b/crates/storage/Cargo.toml @@ -10,6 +10,7 @@ path = "src/lib.rs" [dependencies] adhd-ranch-domain = { path = "../domain" } fs2 = "0.4" +log = "0.4" notify = "6" notify-debouncer-mini = "0.4" serde = { workspace = true } diff --git a/crates/storage/src/focus_store.rs b/crates/storage/src/focus_store.rs index 364b8fc..c9dcd5f 100644 --- a/crates/storage/src/focus_store.rs +++ b/crates/storage/src/focus_store.rs @@ -2,7 +2,9 @@ use std::fs; use std::io; use std::path::{Path, PathBuf}; -use adhd_ranch_domain::{parse_focus_md, slugify, Focus, FocusTimer, NewFocus, ParseError}; +use adhd_ranch_domain::{ + parse_focus_md, slugify, Focus, FocusTimer, NewFocus, ParseError, TimerStatus, +}; use crate::atomic::atomic_write; use crate::focus_document::{FocusDocument, FocusDocumentError}; @@ -56,6 +58,14 @@ pub trait FocusStore: Send + Sync { fn update_task(&self, focus_id: &str, index: usize, text: &str) -> Result<(), FocusStoreError>; fn toggle_task(&self, focus_id: &str, index: usize, done: bool) -> Result<(), FocusStoreError>; fn update_timer(&self, focus_id: &str, timer: &FocusTimer) -> Result<(), FocusStoreError>; + fn clear_timer(&self, focus_id: &str) -> Result<(), FocusStoreError>; + fn update_task_timer( + &self, + focus_id: &str, + index: usize, + timer: &FocusTimer, + ) -> Result<(), FocusStoreError>; + fn clear_task_timer(&self, focus_id: &str, index: usize) -> Result<(), FocusStoreError>; } pub struct MarkdownFocusStore { @@ -75,6 +85,14 @@ impl MarkdownFocusStore { self.root.join(focus_id).join("focus.md") } + fn timer_json(&self, focus_id: &str) -> PathBuf { + self.root.join(focus_id).join("timer.json") + } + + fn task_timers_json(&self, focus_id: &str) -> PathBuf { + self.root.join(focus_id).join("task-timers.json") + } + fn read_focus(&self, focus_id: &str) -> Result { match fs::read_to_string(self.focus_md(focus_id)) { Ok(s) => Ok(s), @@ -122,6 +140,15 @@ impl FocusStore for MarkdownFocusStore { // recreating the focus. focus.timer = serde_json::from_str(&raw).ok(); } + let task_timers_path = entry.path().join("task-timers.json"); + if task_timers_path.is_file() { + let raw = fs::read_to_string(&task_timers_path)?; + if let Ok(timers) = serde_json::from_str::>>(&raw) { + for (task, timer) in focus.tasks.iter_mut().zip(timers) { + task.timer = timer; + } + } + } out.push(focus); } @@ -189,6 +216,9 @@ impl FocusStore for MarkdownFocusStore { .append_task(text) .into_raw(); atomic_write(&self.focus_md(focus_id), next.as_bytes())?; + if let Err(err) = self.clear_expired_timer(focus_id) { + log::warn!("failed to clear expired timer after appending task to {focus_id}: {err}"); + } Ok(()) } @@ -199,6 +229,11 @@ impl FocusStore for MarkdownFocusStore { .map_err(|e| map_document_error(focus_id, e))? .into_raw(); atomic_write(&self.focus_md(focus_id), next.as_bytes())?; + if let Err(err) = self.remove_task_timer_index(focus_id, index) { + log::warn!( + "failed to remove task timer {index} after deleting task from {focus_id}: {err}" + ); + } Ok(()) } @@ -236,6 +271,117 @@ impl FocusStore for MarkdownFocusStore { Err(e) => Err(e.into()), } } + + fn clear_timer(&self, focus_id: &str) -> Result<(), FocusStoreError> { + let dir = self.root.join(focus_id); + if !dir.is_dir() { + return Err(FocusStoreError::NotFound(focus_id.to_string())); + } + match fs::remove_file(self.timer_json(focus_id)) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } + } + + fn update_task_timer( + &self, + focus_id: &str, + index: usize, + timer: &FocusTimer, + ) -> Result<(), FocusStoreError> { + let task_count = self.task_count(focus_id)?; + if index >= task_count { + return Err(FocusStoreError::TaskIndexOutOfRange { + focus_id: focus_id.to_string(), + index, + }); + } + let mut timers = self.read_task_timers(focus_id)?; + timers.resize(task_count, None); + timers[index] = Some(timer.clone()); + self.write_task_timers(focus_id, &timers) + } + + fn clear_task_timer(&self, focus_id: &str, index: usize) -> Result<(), FocusStoreError> { + let task_count = self.task_count(focus_id)?; + if index >= task_count { + return Err(FocusStoreError::TaskIndexOutOfRange { + focus_id: focus_id.to_string(), + index, + }); + } + let mut timers = self.read_task_timers(focus_id)?; + timers.resize(task_count, None); + timers[index] = None; + self.write_task_timers(focus_id, &timers) + } +} + +impl MarkdownFocusStore { + fn task_count(&self, focus_id: &str) -> Result { + let raw = self.read_focus(focus_id)?; + let focus = parse_focus_md(&raw).map_err(|error| FocusStoreError::Parse { + path: self.focus_md(focus_id), + error, + })?; + Ok(focus.tasks.len()) + } + + fn read_task_timers(&self, focus_id: &str) -> Result>, FocusStoreError> { + match fs::read_to_string(self.task_timers_json(focus_id)) { + Ok(raw) => Ok(serde_json::from_str(&raw).unwrap_or_default()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(Vec::new()), + Err(err) => Err(err.into()), + } + } + + fn write_task_timers( + &self, + focus_id: &str, + timers: &[Option], + ) -> Result<(), FocusStoreError> { + let path = self.task_timers_json(focus_id); + if timers.iter().all(Option::is_none) { + return match fs::remove_file(path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + }; + } + let bytes = serde_json::to_vec(timers).map_err(io::Error::other)?; + atomic_write(&path, &bytes)?; + Ok(()) + } + + fn remove_task_timer_index(&self, focus_id: &str, index: usize) -> Result<(), FocusStoreError> { + let mut timers = self.read_task_timers(focus_id)?; + if index < timers.len() { + timers.remove(index); + self.write_task_timers(focus_id, &timers)?; + } + Ok(()) + } + + fn clear_expired_timer(&self, focus_id: &str) -> Result<(), FocusStoreError> { + let path = self.timer_json(focus_id); + let raw = match fs::read_to_string(&path) { + Ok(raw) => raw, + Err(err) if err.kind() == io::ErrorKind::NotFound => return Ok(()), + Err(err) => return Err(err.into()), + }; + let Ok(timer) = serde_json::from_str::(&raw) else { + return Ok(()); + }; + if timer.status != TimerStatus::Expired { + return Ok(()); + } + match fs::remove_file(&path) { + Ok(()) => Ok(()), + Err(err) if err.kind() == io::ErrorKind::NotFound => Ok(()), + Err(err) => Err(err.into()), + } + } } fn map_document_error(focus_id: &str, error: FocusDocumentError) -> FocusStoreError { @@ -347,6 +493,71 @@ mod tests { assert!(content.trim_end().ends_with("- [ ] new task")); } + #[test] + fn append_task_clears_expired_timer_sidecar() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["existing"])); + let timer_path = dir.path().join("a/timer.json"); + fs::write( + &timer_path, + serde_json::to_vec(&FocusTimer { + duration_secs: 60, + started_at: 1_000, + status: TimerStatus::Expired, + }) + .unwrap(), + ) + .unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + + store.append_task("a", "revive me").unwrap(); + + assert!(!timer_path.exists()); + let focuses = store.list().unwrap(); + assert!(focuses[0].timer.is_none()); + assert_eq!(focuses[0].tasks.len(), 2); + } + + #[test] + fn append_task_preserves_running_timer_sidecar() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["existing"])); + let timer_path = dir.path().join("a/timer.json"); + fs::write( + &timer_path, + serde_json::to_vec(&FocusTimer { + duration_secs: 60, + started_at: 1_000, + status: TimerStatus::Running, + }) + .unwrap(), + ) + .unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + + store.append_task("a", "keep timer").unwrap(); + + assert!(timer_path.exists()); + let focuses = store.list().unwrap(); + assert_eq!( + focuses[0].timer.as_ref().map(|timer| &timer.status), + Some(&TimerStatus::Running) + ); + } + + #[test] + fn append_task_keeps_success_when_timer_cleanup_fails() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["existing"])); + fs::create_dir(dir.path().join("a/timer.json")).unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + + store.append_task("a", "still appended").unwrap(); + + let content = fs::read_to_string(dir.path().join("a/focus.md")).unwrap(); + assert!(content.contains("- [ ] still appended")); + } + #[test] fn append_task_errors_on_missing_focus() { let dir = TempDir::new().unwrap(); @@ -387,6 +598,20 @@ mod tests { assert!(matches!(err, FocusStoreError::NotFound(_))); } + #[test] + fn delete_task_keeps_success_when_task_timer_cleanup_fails() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["one", "two"])); + fs::create_dir(dir.path().join("a/task-timers.json")).unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + + store.delete_task("a", 1).unwrap(); + + let content = fs::read_to_string(dir.path().join("a/focus.md")).unwrap(); + assert!(content.contains("- [ ] one")); + assert!(!content.contains("- [ ] two")); + } + #[test] fn create_focus_writes_frontmatter_and_returns_slug() { let dir = TempDir::new().unwrap(); @@ -631,6 +856,67 @@ mod tests { assert!(matches!(err, FocusStoreError::NotFound(_))); } + #[test] + fn clear_timer_removes_timer_sidecar() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + let timer = FocusTimer { + duration_secs: 60, + started_at: 1_000, + status: adhd_ranch_domain::TimerStatus::Running, + }; + let slug = store + .create_focus( + &NewFocus::new("With timer", "").unwrap(), + "id-1", + "2026-04-30T12:00:00Z", + Some(timer), + ) + .unwrap(); + + store.clear_timer(&slug).unwrap(); + + let focuses = store.list().unwrap(); + assert!(focuses[0].timer.is_none()); + } + + #[test] + fn task_timer_sidecar_maps_to_task_by_index() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["one", "two"])); + let store = MarkdownFocusStore::new(dir.path()); + let timer = FocusTimer { + duration_secs: 240, + started_at: 1_700_000_000, + status: adhd_ranch_domain::TimerStatus::Running, + }; + + store.update_task_timer("a", 1, &timer).unwrap(); + + let focuses = store.list().unwrap(); + assert!(focuses[0].tasks[0].timer.is_none()); + assert_eq!(focuses[0].tasks[1].timer, Some(timer)); + } + + #[test] + fn delete_task_removes_matching_task_timer_index() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["one", "two"])); + let store = MarkdownFocusStore::new(dir.path()); + let timer = FocusTimer { + duration_secs: 240, + started_at: 1_700_000_000, + status: adhd_ranch_domain::TimerStatus::Running, + }; + store.update_task_timer("a", 0, &timer).unwrap(); + + store.delete_task("a", 0).unwrap(); + + let focuses = store.list().unwrap(); + assert_eq!(focuses[0].tasks.len(), 1); + assert!(focuses[0].tasks[0].timer.is_none()); + } + #[test] fn list_without_timer_sidecar() { let dir = TempDir::new().unwrap(); diff --git a/issues/051-ranch-animal-vocabulary-seam.md b/issues/051-ranch-animal-vocabulary-seam.md index 2813ef3..d77a0f4 100644 --- a/issues/051-ranch-animal-vocabulary-seam.md +++ b/issues/051-ranch-animal-vocabulary-seam.md @@ -10,14 +10,14 @@ Prepare the frontend and shared type names for future animal types without chang Today several modules use `Pig` names for two different things: -- concrete pig assets or components, such as `PigSprite`, `PigDetail`, and the pig sprite sheet +- concrete pig assets or components, such as `PigSprite` and the pig sprite sheet - animal-neutral overlay behavior, such as movement state, hit rectangles, scale, drag/toss, and focus-to-animal projection The concrete pig names are fine while pigs are the only rendered animal. The shared behavior should move toward `RanchAnimal` vocabulary so future animal types can be added without a broad naming scramble. ### Target shape -- Keep `PigSprite` and `PigDetail` until there is a second concrete animal or a designed animal picker. +- Keep `PigSprite` while pigs are the only rendered animal; the detail surface is already animal-neutral as `AnimalDetail`. - Prefer `RanchAnimal` names for new shared movement, scaling, hit-test, and projection helpers. - Introduce aliases/adapters where needed so the refactor can be incremental and reviewable. - Do not change storage format, focus IDs, tray labels, or user-visible copy in this slice. diff --git a/issues/053-task-timer-expiry-workflow.md b/issues/053-task-timer-expiry-workflow.md new file mode 100644 index 0000000..b1479a7 --- /dev/null +++ b/issues/053-task-timer-expiry-workflow.md @@ -0,0 +1,38 @@ +# 053 — Task timer expiry workflow + +## Parent PRD + +PRD.md §FR3 (AnimalDetail timers) and issue 052. + +## What to build + +Extend timer expiry handling so Task timers have an explicit product behavior when they reach zero. Today Task timers are persisted and displayed in AnimalDetail, but the background 1 Hz expiry workflow only transitions Focus-level `timer.json` to `Expired` and only Focus timers drive notifications/tray expired state. + +Before implementation, decide the Task-timer semantics: + +- Whether expired Task timers should persist `status: Expired`. +- Whether Task timer expiry should fire macOS notifications. +- Whether expired Task timers should affect animal rendering, tray state, or only AnimalDetail display. +- Whether adding/updating/completing a Task should clear an expired Task timer. + +## Completion promise + +Task timers have a defined expiry behavior that is persisted, tested, and reflected consistently in AnimalDetail without confusing Focus-level expired animal behavior. + +## Acceptance criteria + +- [ ] Product semantics for Task timer expiry are documented in PRD.md and CONTEXT.md. +- [ ] Pure expiry detection covers Task timers without regressing Focus timer detection. +- [ ] Storage can update a Task timer status atomically by Focus ID + Task index. +- [ ] AnimalDetail renders expired Task timers from persisted state, not only elapsed wall-clock inference. +- [ ] Notification behavior is either implemented or explicitly documented as intentionally absent. +- [ ] Tests cover running, expired, missing, deleted-task, and out-of-range Task timer cases. +- [ ] `task check` green. + +## Blocked by + +052 + +## User stories addressed + +- "When a Task timer reaches zero, the app behaves predictably and does not leave the timer in a half-running state." diff --git a/issues/README.md b/issues/README.md index eb8ad23..d44ad45 100644 --- a/issues/README.md +++ b/issues/README.md @@ -16,6 +16,7 @@ These are not required before 031. Pick one when we choose to spend a slice on i - [047](047-storage-transaction-seam.md) — Storage transaction seam for Proposal lifecycle - [050](050-settings-update-workflow.md) — Settings update workflow module - [051](051-ranch-animal-vocabulary-seam.md) — RanchAnimal vocabulary seam for future animal types +- [053](053-task-timer-expiry-workflow.md) — Task timer expiry workflow Completed issue files live in `issues/done/`. Do not pick up files from `issues/done/` or `issues/icebox/`. diff --git a/issues/done/030-pig-growth-expired-visual-tray-list.md b/issues/done/030-pig-growth-expired-visual-tray-list.md index 9a3a53b..452c4bf 100644 --- a/issues/done/030-pig-growth-expired-visual-tray-list.md +++ b/issues/done/030-pig-growth-expired-visual-tray-list.md @@ -28,8 +28,10 @@ The only concrete animal today is `Pig`, but this behavior is not pig-specific. ### Expired animal visual -- When `timer.status === 'Expired'`: animal renders with red tint (CSS `filter: hue-rotate` or overlay) +- When `timer.status === 'Expired'`: animal renders with a ghostly transparent style - Subtle pulse/shake animation on expiry (CSS keyframe, one-shot on status change) +- Expired animals stop moving and face away +- Adding a new task to an expired Focus clears the expired timer sidecar, reviving the animal - `PigDetail` shows timer status + remaining time (or "Expired") ### Tray expired list @@ -49,7 +51,9 @@ Focus animals with timers visually grow over their timer window; expired animals - [x] Current pig sprite renders larger as elapsed time increases toward `duration_secs` - [x] Current pig sprite reaches ~3× base size at or after timer end - [x] Hit testing and `PigDetail` positioning account for scaled animal size -- [x] Expired animal has distinct visual style (red tint) +- [x] Expired animal has distinct visual style (ghostly transparency) +- [x] Expired animal stops moving and faces away +- [x] Adding a new task to an expired Focus revives the animal - [x] Expiry animation plays once on status change - [x] `PigDetail` shows "Expired" or remaining `mm:ss` - [x] Tray lists expired focuses under a divider; section absent when none diff --git a/issues/done/052-task-timers-and-clock-dropdown-editing.md b/issues/done/052-task-timers-and-clock-dropdown-editing.md new file mode 100644 index 0000000..b90baf1 --- /dev/null +++ b/issues/done/052-task-timers-and-clock-dropdown-editing.md @@ -0,0 +1,46 @@ +# 052 — Task timers and clock dropdown editing + +## Parent PRD + +PRD.md §FR3 (Pig UI / AnimalDetail) and §FR7 (Configuration timers). + +## What shipped + +Users can edit timers by clicking the clock/time control itself. Focus timers remain global to the Focus, while each Task can now carry its own independent timer. + +Implementation notes: + +- `Task` gains optional `timer: FocusTimer`. +- Focus timers continue to persist in `timer.json`. +- Task timers persist in `task-timers.json`, indexed to the parsed Task order. +- `Commands` and Tauri bridge expose start/clear operations for Focus timers and Task timers. +- `TimerDropdown` consolidates timer editing behind the clock/time control. +- `PigDetail` was renamed to `AnimalDetail`; CSS/test IDs now use `animal-detail`. + +## Completion promise + +AnimalDetail exposes compact clock/time dropdowns for Focus and Task timers, and Task timer state persists across restarts without changing the markdown Task syntax. + +## Acceptance criteria + +- [x] A Focus can still have one global timer. +- [x] Each Task can have its own independent timer. +- [x] No timer renders as a compact clock control. +- [x] Existing timers render as current time/expired status and open the same dropdown on click. +- [x] Timer dropdown supports preset and custom-minute starts/restarts. +- [x] Timer dropdown supports clearing existing timers. +- [x] Task timer storage does not alter `focus.md` checkbox syntax. +- [x] Deleting a Task removes the matching task timer entry. +- [x] `AnimalDetail` naming replaces `PigDetail` for the clicked-animal detail card. +- [x] Tests cover storage, commands, IPC wrappers, and AnimalDetail timer UI. + +## Known follow-up + +Task timers are persisted and displayed, but only Focus timers currently participate in the background expiry/notification workflow. See issue 053. + +## Validation + +- `task test` +- `npm run lint` +- `npm run typecheck` +- `npm run test -- AnimalDetail App` diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index b2e2c38..6feebfa 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -55,6 +55,9 @@ pub fn run() { ui_bridge::update_task, ui_bridge::toggle_task, ui_bridge::start_timer, + ui_bridge::clear_timer, + ui_bridge::start_task_timer, + ui_bridge::clear_task_timer, ui_bridge::get_caps, ui_bridge::update_pig_rects, ui_bridge::set_pig_drag_active, diff --git a/src-tauri/src/app/tray.rs b/src-tauri/src/app/tray.rs index 39e3f69..2cfc944 100644 --- a/src-tauri/src/app/tray.rs +++ b/src-tauri/src/app/tray.rs @@ -130,13 +130,13 @@ fn build_menu(handle: &AppHandle, focuses: &[Focus]) -> tauri::Result, focuses: &[Focus]) -> tauri::Result { - active: Vec<&'a Focus>, + list: Vec<&'a Focus>, expired: Vec<&'a Focus>, } fn partition_focuses_for_menu(focuses: &[Focus]) -> MenuFocusSections<'_> { - let mut active = Vec::new(); + let mut list = Vec::new(); let mut expired = Vec::new(); for focus in focuses { + list.push(focus); if matches!( focus.timer.as_ref().map(|timer| &timer.status), Some(TimerStatus::Expired) ) { expired.push(focus); - } else { - active.push(focus); } } - MenuFocusSections { active, expired } + MenuFocusSections { list, expired } } fn handle_delete(app: AppHandle, focus_id: String) { @@ -290,7 +289,7 @@ mod tests { } #[test] - fn partitions_expired_focuses_into_expired_section() { + fn keeps_expired_focuses_in_main_list_and_expired_section() { let focuses = vec![ focus("a", "Active", Some(TimerStatus::Running)), focus("b", "Expired", Some(TimerStatus::Expired)), @@ -301,11 +300,11 @@ mod tests { assert_eq!( sections - .active + .list .iter() .map(|focus| focus.title.as_str()) .collect::>(), - vec!["Active", "No timer"] + vec!["Active", "Expired", "No timer"] ); assert_eq!( sections diff --git a/src-tauri/src/ui_bridge/mod.rs b/src-tauri/src/ui_bridge/mod.rs index 4e8e398..c381161 100644 --- a/src-tauri/src/ui_bridge/mod.rs +++ b/src-tauri/src/ui_bridge/mod.rs @@ -147,6 +147,42 @@ pub fn start_timer( .inspect_err(|e| log::error!("start_timer({focus_id:?}): {e}")) } +#[tauri::command] +pub fn clear_timer(focus_id: String, state: State<'_, CommandsState>) -> Result<(), CommandError> { + state + .0 + .clear_timer(&focus_id) + .inspect(|_| log::info!("timer cleared on {focus_id}")) + .inspect_err(|e| log::error!("clear_timer({focus_id:?}): {e}")) +} + +#[tauri::command] +pub fn start_task_timer( + focus_id: String, + index: usize, + preset: TimerPreset, + state: State<'_, CommandsState>, +) -> Result<(), CommandError> { + state + .0 + .start_task_timer(&focus_id, index, preset) + .inspect(|_| log::info!("task timer started on {focus_id}:{index}")) + .inspect_err(|e| log::error!("start_task_timer({focus_id:?}, {index}): {e}")) +} + +#[tauri::command] +pub fn clear_task_timer( + focus_id: String, + index: usize, + state: State<'_, CommandsState>, +) -> Result<(), CommandError> { + state + .0 + .clear_task_timer(&focus_id, index) + .inspect(|_| log::info!("task timer cleared on {focus_id}:{index}")) + .inspect_err(|e| log::error!("clear_task_timer({focus_id:?}, {index}): {e}")) +} + #[tauri::command] pub fn get_caps(state: State<'_, CommandsState>) -> Caps { state.0.caps() diff --git a/src/api/fixtureFocusWriter.ts b/src/api/fixtureFocusWriter.ts index 627c43e..bf429ec 100644 --- a/src/api/fixtureFocusWriter.ts +++ b/src/api/fixtureFocusWriter.ts @@ -16,5 +16,8 @@ export function createFixtureFocusWriter(opts: FixtureFocusWriterOptions = {}): updateTask: result, toggleTask: result, startTimer: result, + clearTimer: result, + startTaskTimer: result, + clearTaskTimer: result, }; } diff --git a/src/api/focusWriter.test.ts b/src/api/focusWriter.test.ts index efc3838..3e7d7e8 100644 --- a/src/api/focusWriter.test.ts +++ b/src/api/focusWriter.test.ts @@ -81,4 +81,40 @@ describe("tauriFocusWriter", () => { preset: { Custom: 15 }, }); }); + + it("clearTimer forwards focusId", async () => { + mockInvoke.mockResolvedValueOnce(undefined); + const writer = createTauriFocusWriter(); + + await writer.clearTimer("focus-1"); + + expect(mockInvoke).toHaveBeenCalledWith("clear_timer", { + focusId: "focus-1", + }); + }); + + it("startTaskTimer forwards focusId + index + preset", async () => { + mockInvoke.mockResolvedValueOnce(undefined); + const writer = createTauriFocusWriter(); + + await writer.startTaskTimer("focus-1", 2, "Eight"); + + expect(mockInvoke).toHaveBeenCalledWith("start_task_timer", { + focusId: "focus-1", + index: 2, + preset: "Eight", + }); + }); + + it("clearTaskTimer forwards focusId + index", async () => { + mockInvoke.mockResolvedValueOnce(undefined); + const writer = createTauriFocusWriter(); + + await writer.clearTaskTimer("focus-1", 2); + + expect(mockInvoke).toHaveBeenCalledWith("clear_task_timer", { + focusId: "focus-1", + index: 2, + }); + }); }); diff --git a/src/api/focusWriter.ts b/src/api/focusWriter.ts index 7018012..706b626 100644 --- a/src/api/focusWriter.ts +++ b/src/api/focusWriter.ts @@ -20,6 +20,9 @@ export interface FocusWriter { updateTask(focusId: string, index: number, text: string): Promise; toggleTask(focusId: string, index: number, done: boolean): Promise; startTimer(focusId: string, preset: TimerPreset): Promise; + clearTimer(focusId: string): Promise; + startTaskTimer(focusId: string, index: number, preset: TimerPreset): Promise; + clearTaskTimer(focusId: string, index: number): Promise; } function toFailure(e: unknown): WriteOutcome { @@ -71,5 +74,14 @@ export function createTauriFocusWriter(): FocusWriter { startTimer(focusId, preset) { return runInvoke("start_timer", { focusId, preset }); }, + clearTimer(focusId) { + return runInvoke("clear_timer", { focusId }); + }, + startTaskTimer(focusId, index, preset) { + return runInvoke("start_task_timer", { focusId, index, preset }); + }, + clearTaskTimer(focusId, index) { + return runInvoke("clear_task_timer", { focusId, index }); + }, }; } diff --git a/src/api/tauriFocusReader.test.ts b/src/api/tauriFocusReader.test.ts new file mode 100644 index 0000000..65245f4 --- /dev/null +++ b/src/api/tauriFocusReader.test.ts @@ -0,0 +1,41 @@ +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { createTauriFocusReader } from "./tauriFocusReader"; + +vi.mock("@tauri-apps/api/core", () => ({ + invoke: vi.fn(), +})); + +vi.mock("@tauri-apps/api/event", () => ({ + listen: vi.fn().mockResolvedValue(() => {}), +})); + +const mockInvoke = vi.mocked(invoke); + +beforeEach(() => { + mockInvoke.mockReset(); +}); + +describe("tauriFocusReader", () => { + it("preserves timer data from Rust focuses", async () => { + mockInvoke.mockResolvedValueOnce([ + { + id: "focus-1", + title: "Timed focus", + description: "", + created_at: "", + tasks: [], + timer: { duration_secs: 480, started_at: 1_700_000_000, status: "Running" }, + }, + ]); + + const focuses = await createTauriFocusReader().read(); + + expect(mockInvoke).toHaveBeenCalledWith("list_focuses"); + expect(focuses[0]?.timer).toEqual({ + duration_secs: 480, + started_at: 1_700_000_000, + status: "Running", + }); + }); +}); diff --git a/src/api/tauriFocusReader.ts b/src/api/tauriFocusReader.ts index e78ff3a..80c2817 100644 --- a/src/api/tauriFocusReader.ts +++ b/src/api/tauriFocusReader.ts @@ -1,4 +1,5 @@ import type { Focus } from "../types/focus"; +import type { FocusTimer } from "../types/generated/FocusTimer"; import type { PolledReader } from "./polledReader"; import { createTauriReader } from "./tauriReader"; @@ -8,6 +9,7 @@ interface RustFocus { readonly description: string; readonly created_at: string; readonly tasks: readonly { id: string; text: string; done?: boolean }[]; + readonly timer?: FocusTimer | null; } function fromRust(raw: RustFocus): Focus { @@ -17,6 +19,7 @@ function fromRust(raw: RustFocus): Focus { description: raw.description, created_at: raw.created_at, tasks: raw.tasks.map((t) => ({ id: t.id, text: t.text, done: t.done ?? false })), + timer: raw.timer ?? null, }; } diff --git a/src/components/PigDetail.test.tsx b/src/components/AnimalDetail.test.tsx similarity index 70% rename from src/components/PigDetail.test.tsx rename to src/components/AnimalDetail.test.tsx index a33a644..aabfe15 100644 --- a/src/components/PigDetail.test.tsx +++ b/src/components/AnimalDetail.test.tsx @@ -3,7 +3,7 @@ import userEvent from "@testing-library/user-event"; import { useState } from "react"; import { describe, expect, it, vi } from "vitest"; import type { Focus } from "../types/focus"; -import { PigDetail } from "./PigDetail"; +import { AnimalDetail } from "./AnimalDetail"; const baseFocus: Focus = { id: "pig-a", @@ -13,11 +13,11 @@ const baseFocus: Focus = { tasks: [], }; -function renderDetail(overrides?: Partial>) { +function renderDetail(overrides?: Partial>) { const props = { focus: baseFocus, - pigX: 100, - pigY: 100, + animalX: 100, + animalY: 100, viewportW: 1920, viewportH: 1080, confirmDelete: true, @@ -29,13 +29,16 @@ function renderDetail(overrides?: Partial onToggleTask: vi.fn(), onDeleteFocus: vi.fn(), onStartTimer: vi.fn(), + onClearTimer: vi.fn(), + onStartTaskTimer: vi.fn(), + onClearTaskTimer: vi.fn(), ...overrides, }; - render(); + render(); return props; } -describe("PigDetail add-task input", () => { +describe("AnimalDetail add-task input", () => { it("renders an add task input with placeholder", () => { renderDetail(); expect(screen.getByPlaceholderText("Add task…")).toBeInTheDocument(); @@ -69,7 +72,7 @@ describe("PigDetail add-task input", () => { }); }); -describe("PigDetail title editing", () => { +describe("AnimalDetail title editing", () => { it("renders title as editable input", () => { renderDetail(); expect(screen.getByLabelText("focus title")).toHaveValue("Ship it"); @@ -80,7 +83,7 @@ describe("PigDetail title editing", () => { const input = screen.getByLabelText("focus title"); await userEvent.clear(input); await userEvent.type(input, "New Title"); - input.blur(); + await userEvent.tab(); expect(onRenameFocus).toHaveBeenCalledWith("pig-a", "New Title"); }); @@ -113,7 +116,7 @@ describe("PigDetail title editing", () => { }); }); -describe("PigDetail task editing", () => { +describe("AnimalDetail task editing", () => { const focusWithTasks: Focus = { id: "pig-a", title: "Ship it", @@ -152,12 +155,12 @@ describe("PigDetail task editing", () => { }); }); -describe("PigDetail delete focus", () => { +describe("AnimalDetail delete focus", () => { it("with confirmDelete=true shows inline confirm before deleting", async () => { const { onDeleteFocus } = renderDetail({ confirmDelete: true }); await userEvent.click(screen.getByLabelText("delete focus Ship it")); expect(onDeleteFocus).not.toHaveBeenCalled(); - expect(screen.getByTestId("pig-detail-delete-confirm")).toBeInTheDocument(); + expect(screen.getByTestId("animal-detail-delete-confirm")).toBeInTheDocument(); await userEvent.click(screen.getByText("Delete")); expect(onDeleteFocus).toHaveBeenCalledWith("pig-a"); }); @@ -166,7 +169,7 @@ describe("PigDetail delete focus", () => { const { onDeleteFocus } = renderDetail({ confirmDelete: true }); await userEvent.click(screen.getByLabelText("delete focus Ship it")); await userEvent.click(screen.getByText("Cancel")); - expect(screen.queryByTestId("pig-detail-delete-confirm")).not.toBeInTheDocument(); + expect(screen.queryByTestId("animal-detail-delete-confirm")).not.toBeInTheDocument(); expect(onDeleteFocus).not.toHaveBeenCalled(); }); @@ -178,17 +181,20 @@ describe("PigDetail delete focus", () => { }); }); -describe("PigDetail timer picker", () => { +describe("AnimalDetail timer picker", () => { it("shows remaining time for a running timer", () => { const nowSpy = vi.spyOn(Date, "now").mockReturnValue(1_030_000); - renderDetail({ - focus: { - ...baseFocus, - timer: { duration_secs: 120, started_at: 1_000, status: "Running" }, - }, - }); - expect(screen.getByText("01:30 remaining")).toBeInTheDocument(); - nowSpy.mockRestore(); + try { + renderDetail({ + focus: { + ...baseFocus, + timer: { duration_secs: 120, started_at: 1_000, status: "Running" }, + }, + }); + expect(screen.getByRole("button", { name: /edit focus timer/i })).toHaveTextContent("01:30"); + } finally { + nowSpy.mockRestore(); + } }); it("shows Expired for an expired timer", () => { @@ -198,25 +204,33 @@ describe("PigDetail timer picker", () => { timer: { duration_secs: 120, started_at: 1_000, status: "Expired" }, }, }); - expect(screen.getByText("Expired")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /edit focus timer/i })).toHaveTextContent("Expired"); }); - it("renders Start button + preset select", () => { + it("renders a clock icon when no timer is set", () => { renderDetail(); - expect(screen.getByRole("button", { name: "Start" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: /edit focus timer/i })).toBeInTheDocument(); + expect(screen.queryByTestId("timer-preset-select")).not.toBeInTheDocument(); + }); + + it("opens the timer dropdown from the clock button", async () => { + renderDetail(); + await userEvent.click(screen.getByRole("button", { name: /edit focus timer/i })); expect(screen.getByTestId("timer-preset-select")).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Start" })).toBeInTheDocument(); }); - it("Start with named preset calls onStartTimer with preset literal and closes", async () => { - const { onStartTimer, onClose } = renderDetail(); + it("Start with named preset calls onStartTimer with preset literal", async () => { + const { onStartTimer } = renderDetail(); + await userEvent.click(screen.getByRole("button", { name: /edit focus timer/i })); await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "ThirtyTwo"); await userEvent.click(screen.getByRole("button", { name: "Start" })); expect(onStartTimer).toHaveBeenCalledWith("pig-a", "ThirtyTwo"); - expect(onClose).toHaveBeenCalled(); }); it("Start with custom preset wraps minutes", async () => { const { onStartTimer } = renderDetail(); + await userEvent.click(screen.getByRole("button", { name: /edit focus timer/i })); await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "custom"); const input = screen.getByTestId("custom-timer-input"); await userEvent.clear(input); @@ -227,6 +241,7 @@ describe("PigDetail timer picker", () => { it("rejects custom < 1 minute", async () => { const { onStartTimer, onClose } = renderDetail(); + await userEvent.click(screen.getByRole("button", { name: /edit focus timer/i })); await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "custom"); const input = screen.getByTestId("custom-timer-input"); await userEvent.clear(input); @@ -237,6 +252,23 @@ describe("PigDetail timer picker", () => { expect(screen.getByText(/at least 1 minute/i)).toBeInTheDocument(); }); + it("clears custom validation errors when toggling the timer dropdown", async () => { + renderDetail(); + const trigger = screen.getByRole("button", { name: /edit focus timer/i }); + await userEvent.click(trigger); + await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "custom"); + const input = screen.getByTestId("custom-timer-input"); + await userEvent.clear(input); + await userEvent.type(input, "0"); + await userEvent.click(screen.getByRole("button", { name: "Start" })); + expect(screen.getByText(/at least 1 minute/i)).toBeInTheDocument(); + + await userEvent.click(trigger); + await userEvent.click(trigger); + + expect(screen.queryByText(/at least 1 minute/i)).not.toBeInTheDocument(); + }); + it("button reads Restart when timer is Running", () => { renderDetail({ focus: { @@ -244,7 +276,19 @@ describe("PigDetail timer picker", () => { timer: { duration_secs: 600, started_at: 0, status: "Running" }, }, }); + expect(screen.queryByRole("button", { name: "Restart" })).not.toBeInTheDocument(); + }); + + it("shows Restart and Clear inside the dropdown when timer is Running", async () => { + renderDetail({ + focus: { + ...baseFocus, + timer: { duration_secs: 600, started_at: 0, status: "Running" }, + }, + }); + await userEvent.click(screen.getByRole("button", { name: /edit focus timer/i })); expect(screen.getByRole("button", { name: "Restart" })).toBeInTheDocument(); + expect(screen.getByRole("button", { name: "Clear" })).toBeInTheDocument(); }); it("resets picker state when focus changes", async () => { @@ -255,10 +299,10 @@ describe("PigDetail timer picker", () => { - { onToggleTask={vi.fn()} onDeleteFocus={vi.fn()} onStartTimer={vi.fn()} + onClearTimer={vi.fn()} + onStartTaskTimer={vi.fn()} + onClearTaskTimer={vi.fn()} /> ); } render(); + await userEvent.click(screen.getByRole("button", { name: /edit focus timer/i })); await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "Sixteen"); expect(screen.getByTestId("timer-preset-select")).toHaveValue("Sixteen"); await userEvent.click(screen.getByTestId("swap")); + await userEvent.click(screen.getByRole("button", { name: /edit focus timer/i })); expect(screen.getByTestId("timer-preset-select")).toHaveValue("Eight"); }); + + it("starts a task timer from the task clock dropdown", async () => { + const focusWithTask: Focus = { + ...baseFocus, + tasks: [{ id: "t1", text: "alpha", done: false }], + }; + const { onStartTaskTimer } = renderDetail({ focus: focusWithTask }); + + await userEvent.click(screen.getByRole("button", { name: /edit task timer: alpha/i })); + await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "Two"); + await userEvent.click(screen.getByRole("button", { name: "Start" })); + + expect(onStartTaskTimer).toHaveBeenCalledWith("pig-a", 0, "Two"); + }); }); diff --git a/src/components/PigDetail.tsx b/src/components/AnimalDetail.tsx similarity index 63% rename from src/components/PigDetail.tsx rename to src/components/AnimalDetail.tsx index d48e324..969a32a 100644 --- a/src/components/PigDetail.tsx +++ b/src/components/AnimalDetail.tsx @@ -1,14 +1,13 @@ import { useEffect, useState } from "react"; import { PIG_SIZE } from "../hooks/usePigMovement"; -import { type PresetSelection, isCustomValid, resolvePreset } from "../lib/timerPreset"; import type { Focus } from "../types/focus"; import type { TimerPreset } from "../types/timer"; -import { TimerPresetPicker } from "./TimerPresetPicker"; +import { TimerDropdown } from "./TimerDropdown"; -export interface PigDetailProps { +export interface AnimalDetailProps { readonly focus: Focus; - readonly pigX: number; - readonly pigY: number; + readonly animalX: number; + readonly animalY: number; readonly animalSize?: number; readonly viewportW: number; readonly viewportH: number; @@ -21,13 +20,16 @@ export interface PigDetailProps { readonly onToggleTask: (focusId: string, index: number, done: boolean) => void; readonly onDeleteFocus: (focusId: string) => void; readonly onStartTimer: (focusId: string, preset: TimerPreset) => void; + readonly onClearTimer: (focusId: string) => void; + readonly onStartTaskTimer: (focusId: string, index: number, preset: TimerPreset) => void; + readonly onClearTaskTimer: (focusId: string, index: number) => void; } const CARD_W = 340; -export function PigDetail({ +export function AnimalDetail({ focus, - pigX, - pigY, + animalX, + animalY, animalSize = PIG_SIZE, viewportW, viewportH, @@ -40,39 +42,19 @@ export function PigDetail({ onToggleTask, onDeleteFocus, onStartTimer, -}: PigDetailProps) { + onClearTimer, + onStartTaskTimer, + onClearTaskTimer, +}: AnimalDetailProps) { const [taskInput, setTaskInput] = useState(""); const [titleDraft, setTitleDraft] = useState(focus.title); const [titleError, setTitleError] = useState(false); const [confirmingDelete, setConfirmingDelete] = useState(false); - const [timerSelection, setTimerSelection] = useState("Eight"); - const [customMinutes, setCustomMinutes] = useState(10); - const [timerError, setTimerError] = useState(null); - - function handleStartTimer() { - if (timerSelection === "custom" && !isCustomValid(customMinutes)) { - setTimerError("custom timer must be at least 1 minute"); - return; - } - const preset = resolvePreset(timerSelection, customMinutes); - if (preset === null) return; - setTimerError(null); - onStartTimer(focus.id, preset); - onClose(); - } - useEffect(() => { setTitleDraft(focus.title); setTitleError(false); }, [focus.title]); - // biome-ignore lint/correctness/useExhaustiveDependencies: re-run when switching to a different focus so picker state doesn't leak. - useEffect(() => { - setTimerSelection("Eight"); - setCustomMinutes(10); - setTimerError(null); - }, [focus.id]); - useEffect(() => { const handler = (e: KeyboardEvent) => { if (e.key === "Escape") onClose(); @@ -81,9 +63,9 @@ export function PigDetail({ return () => window.removeEventListener("keydown", handler); }, [onClose]); - const rawX = pigX + animalSize + 8; + const rawX = animalX + animalSize + 8; const x = Math.min(rawX, viewportW - CARD_W - 16); - const y = Math.max(16, Math.min(pigY, viewportH - 200)); + const y = Math.max(16, Math.min(animalY, viewportH - 200)); function commitTitle(): boolean { const trimmed = titleDraft.trim(); @@ -114,45 +96,43 @@ export function PigDetail({ onClose(); } - const timerStatus = describeTimerStatus(focus.timer ?? null); - return ( <>
{ if (e.key === "Escape") onClose(); }} role="presentation" /> -
+
{confirmingDelete ? (
Delete "{focus.title}"?
) : ( -
+
{ @@ -175,9 +155,16 @@ export function PigDetail({ } }} /> + onStartTimer(focus.id, preset)} + onClear={() => onClearTimer(focus.id)} + />
)} {titleError && ( -

+

Title cannot be empty

)} {focus.tasks.length === 0 ? ( -

No tasks yet.

+

No tasks yet.

) : ( -
    +
      {focus.tasks.map((task, index) => ( ))}
    )} setTaskInput(e.target.value)} @@ -219,47 +208,11 @@ export function PigDetail({ } }} /> -
    - {timerStatus && {timerStatus}} - { - setTimerSelection(v); - if (timerError) setTimerError(null); - }} - onCustomMinutesChange={(v) => { - setCustomMinutes(v); - if (timerError) setTimerError(null); - }} - /> - - {timerError && ( -

    - {timerError} -

    - )} -
); } -function describeTimerStatus(timer: Focus["timer"] | null): string | null { - if (!timer) return null; - if (timer.status === "Expired") return "Expired"; - const elapsedSecs = Math.max(0, Math.floor(Date.now() / 1000) - timer.started_at); - const remainingSecs = Math.max(0, timer.duration_secs - elapsedSecs); - const minutes = Math.floor(remainingSecs / 60) - .toString() - .padStart(2, "0"); - const seconds = (remainingSecs % 60).toString().padStart(2, "0"); - return `${minutes}:${seconds} remaining`; -} - interface TaskEditorProps { readonly focusId: string; readonly index: number; @@ -267,6 +220,8 @@ interface TaskEditorProps { readonly onUpdateTask: (focusId: string, index: number, text: string) => void; readonly onToggleTask: (focusId: string, index: number, done: boolean) => void; readonly onClearTask: (index: number) => void; + readonly onStartTaskTimer: (focusId: string, index: number, preset: TimerPreset) => void; + readonly onClearTaskTimer: (focusId: string, index: number) => void; } function TaskEditor({ @@ -276,6 +231,8 @@ function TaskEditor({ onUpdateTask, onToggleTask, onClearTask, + onStartTaskTimer, + onClearTaskTimer, }: TaskEditorProps) { const [draft, setDraft] = useState(task.text); @@ -293,16 +250,16 @@ function TaskEditor({ } return ( -
  • +
  • onToggleTask(focusId, index, e.target.checked)} /> setDraft(e.target.value)} @@ -316,9 +273,16 @@ function TaskEditor({ } }} /> + onStartTaskTimer(focusId, index, preset)} + onClear={() => onClearTaskTimer(focusId, index)} + />
  • diff --git a/src/components/PigSprite.tsx b/src/components/PigSprite.tsx index c381ff1..8afd93e 100644 --- a/src/components/PigSprite.tsx +++ b/src/components/PigSprite.tsx @@ -111,7 +111,10 @@ export function PigSprite({ backgroundPosition: `-${col * size}px -${row * size}px`, width: size, height: size, - filter: expired ? "hue-rotate(125deg)" : undefined, + filter: expired + ? "grayscale(1) saturate(0.15) brightness(1.55) drop-shadow(0 0 8px rgba(210, 240, 255, 0.55))" + : undefined, + opacity: expired ? 0.48 : undefined, }} /> {name} diff --git a/src/components/TimerDropdown.tsx b/src/components/TimerDropdown.tsx new file mode 100644 index 0000000..7d97920 --- /dev/null +++ b/src/components/TimerDropdown.tsx @@ -0,0 +1,98 @@ +import { useState } from "react"; +import { type PresetSelection, isCustomValid, resolvePreset } from "../lib/timerPreset"; +import type { FocusTimer, TimerPreset } from "../types/timer"; +import { TimerPresetPicker } from "./TimerPresetPicker"; + +export interface TimerDropdownProps { + readonly timer?: FocusTimer | null; + readonly ariaLabel: string; + readonly onStart: (preset: TimerPreset) => void; + readonly onClear?: () => void; +} + +export function TimerDropdown({ timer, ariaLabel, onStart, onClear }: TimerDropdownProps) { + const [open, setOpen] = useState(false); + const [selection, setSelection] = useState("Eight"); + const [customMinutes, setCustomMinutes] = useState(10); + const [error, setError] = useState(null); + + function start() { + if (selection === "custom" && !isCustomValid(customMinutes)) { + setError("custom timer must be at least 1 minute"); + return; + } + const preset = resolvePreset(selection, customMinutes); + if (preset === null) return; + setError(null); + onStart(preset); + setOpen(false); + } + + return ( +
    + + {open && ( +
    + { + setSelection(value); + if (error) setError(null); + }} + onCustomMinutesChange={(value) => { + setCustomMinutes(value); + if (error) setError(null); + }} + /> +
    + + {timer && onClear && ( + + )} +
    + {error && ( +

    + {error} +

    + )} +
    + )} +
    + ); +} + +export function formatTimer(timer: FocusTimer): string { + if (timer.status === "Expired") return "Expired"; + const elapsedSecs = Math.max(0, Math.floor(Date.now() / 1000) - timer.started_at); + const remainingSecs = timer.duration_secs - elapsedSecs; + if (remainingSecs <= 0) return "Expired"; + const minutes = Math.floor(remainingSecs / 60) + .toString() + .padStart(2, "0"); + const seconds = (remainingSecs % 60).toString().padStart(2, "0"); + return `${minutes}:${seconds}`; +} diff --git a/src/hooks/useAppState.test.tsx b/src/hooks/useAppState.test.tsx index bac1ae3..83ed196 100644 --- a/src/hooks/useAppState.test.tsx +++ b/src/hooks/useAppState.test.tsx @@ -19,7 +19,7 @@ function failingProposalReader(error: Error): PolledReader } describe("useAppState", () => { - it("starts loading with default caps", () => { + it("starts loading with default caps", async () => { const focuses: Focus[] = []; const proposals: Proposal[] = []; const { result } = renderHook(() => @@ -31,6 +31,9 @@ describe("useAppState", () => { ); expect(result.current.status).toBe("loading"); expect(result.current.caps).toEqual(DEFAULT_CAPS); + await waitFor(() => { + expect(result.current.status).toBe("ready"); + }); }); it("becomes ready when both focuses and proposals resolve", async () => { diff --git a/src/hooks/useFocusController.ts b/src/hooks/useFocusController.ts new file mode 100644 index 0000000..f44a4e9 --- /dev/null +++ b/src/hooks/useFocusController.ts @@ -0,0 +1,68 @@ +import { useMemo } from "react"; +import type { FocusWriter, WriteOutcome } from "../api/focusWriter"; +import type { TimerPreset } from "../types/timer"; + +export type ReportWriteFailure = (op: string, outcome: WriteOutcome) => void; + +export interface FocusController { + readonly deleteFocus: (focusId: string) => Promise; + readonly renameFocus: (focusId: string, title: string) => Promise; + readonly appendTask: (focusId: string, text: string) => Promise; + readonly deleteTask: (focusId: string, index: number) => Promise; + readonly updateTask: (focusId: string, index: number, text: string) => Promise; + readonly toggleTask: (focusId: string, index: number, done: boolean) => Promise; + readonly startTimer: (focusId: string, preset: TimerPreset) => Promise; + readonly clearTimer: (focusId: string) => Promise; + readonly startTaskTimer: ( + focusId: string, + index: number, + preset: TimerPreset, + ) => Promise; + readonly clearTaskTimer: (focusId: string, index: number) => Promise; +} + +export function useFocusController( + focusWriter: FocusWriter, + onWriteFailure: ReportWriteFailure, +): FocusController { + return useMemo(() => { + async function run(op: string, write: () => Promise): Promise { + const outcome = await write(); + onWriteFailure(op, outcome); + return outcome; + } + + return { + deleteFocus(focusId: string) { + return run("delete_focus", () => focusWriter.deleteFocus(focusId)); + }, + renameFocus(focusId: string, title: string) { + return run("rename_focus", () => focusWriter.renameFocus(focusId, title)); + }, + appendTask(focusId: string, text: string) { + return run("append_task", () => focusWriter.appendTask(focusId, text)); + }, + deleteTask(focusId: string, index: number) { + return run("delete_task", () => focusWriter.deleteTask(focusId, index)); + }, + updateTask(focusId: string, index: number, text: string) { + return run("update_task", () => focusWriter.updateTask(focusId, index, text)); + }, + toggleTask(focusId: string, index: number, done: boolean) { + return run("toggle_task", () => focusWriter.toggleTask(focusId, index, done)); + }, + startTimer(focusId: string, preset: TimerPreset) { + return run("start_timer", () => focusWriter.startTimer(focusId, preset)); + }, + clearTimer(focusId: string) { + return run("clear_timer", () => focusWriter.clearTimer(focusId)); + }, + startTaskTimer(focusId: string, index: number, preset: TimerPreset) { + return run("start_task_timer", () => focusWriter.startTaskTimer(focusId, index, preset)); + }, + clearTaskTimer(focusId: string, index: number) { + return run("clear_task_timer", () => focusWriter.clearTaskTimer(focusId, index)); + }, + }; + }, [focusWriter, onWriteFailure]); +} diff --git a/src/hooks/useOpenFocusDetailRequest.ts b/src/hooks/useOpenFocusDetailRequest.ts new file mode 100644 index 0000000..75d2b8c --- /dev/null +++ b/src/hooks/useOpenFocusDetailRequest.ts @@ -0,0 +1,19 @@ +import { useEffect } from "react"; +import { subscribeOpenFocusDetail } from "../api/pig"; +import type { Focus } from "../types/focus"; + +export function useOpenFocusDetailRequest( + focuses: readonly Focus[], + openFocusDetail: (focusId: string) => void, +) { + useEffect(() => { + const unsubscribe = subscribeOpenFocusDetail((focusId) => { + if (focuses.some((focus) => focus.id === focusId)) { + openFocusDetail(focusId); + } + }).catch(() => () => {}); + return () => { + unsubscribe.then((fn) => fn()); + }; + }, [focuses, openFocusDetail]); +} diff --git a/src/hooks/usePigMovement.test.ts b/src/hooks/usePigMovement.test.ts index 020098c..1b2db40 100644 --- a/src/hooks/usePigMovement.test.ts +++ b/src/hooks/usePigMovement.test.ts @@ -34,6 +34,15 @@ const makePig = (overrides?: Partial): PigState => ({ ...overrides, }); +const focus = (overrides?: Partial): Focus => ({ + id: "a", + title: "Alpha", + description: "", + created_at: "", + tasks: [], + ...overrides, +}); + function makeSamples(points: { x: number; y: number; t: number }[]): PointerSample[] { return points; } @@ -169,17 +178,11 @@ describe("usePigMovement", () => { it("refreshes an existing animal label when its focus title changes", async () => { const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(0); const cancelRafSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); - const focus: Focus = { - id: "a", - title: "Original name", - description: "", - created_at: "", - tasks: [], - }; + const originalFocus = focus({ title: "Original name" }); const { result, rerender, unmount } = renderHook( ({ focuses }: { focuses: readonly Focus[] }) => usePigMovement(focuses, null), - { initialProps: { focuses: [focus] } }, + { initialProps: { focuses: [originalFocus] } }, ); await waitFor(() => expect(result.current.pigs[0]?.name).toBe("Original name")); @@ -188,7 +191,7 @@ describe("usePigMovement", () => { y: result.current.pigs[0]?.y, }; - rerender({ focuses: [{ ...focus, title: "Updated name" }] }); + rerender({ focuses: [{ ...originalFocus, title: "Updated name" }] }); await waitFor(() => expect(result.current.pigs[0]?.name).toBe("Updated name")); expect(result.current.pigs[0]).toMatchObject(firstPosition); @@ -197,4 +200,44 @@ describe("usePigMovement", () => { rafSpy.mockRestore(); cancelRafSpy.mockRestore(); }); + + it("stops expired animals and faces them away", async () => { + const rafSpy = vi.spyOn(window, "requestAnimationFrame").mockReturnValue(0); + const cancelRafSpy = vi.spyOn(window, "cancelAnimationFrame").mockImplementation(() => {}); + const runningFocus = focus({ + timer: { duration_secs: 120, started_at: 1_000, status: "Running" }, + }); + + const { result, rerender, unmount } = renderHook( + ({ focuses }: { focuses: readonly Focus[] }) => usePigMovement(focuses, null), + { initialProps: { focuses: [runningFocus] } }, + ); + + await waitFor(() => expect(result.current.pigs[0]?.id).toBe("a")); + const firstPosition = { + x: result.current.pigs[0]?.x, + y: result.current.pigs[0]?.y, + }; + + rerender({ + focuses: [ + { + ...runningFocus, + timer: { duration_secs: 120, started_at: 1_000, status: "Expired" }, + }, + ], + }); + + await waitFor(() => expect(result.current.pigs[0]?.direction).toBe("back")); + expect(result.current.pigs[0]).toMatchObject({ + ...firstPosition, + vx: 0, + vy: 0, + direction: "back", + }); + + unmount(); + rafSpy.mockRestore(); + cancelRafSpy.mockRestore(); + }); }); diff --git a/src/hooks/usePigMovement.ts b/src/hooks/usePigMovement.ts index 11d279d..ede03c1 100644 --- a/src/hooks/usePigMovement.ts +++ b/src/hooks/usePigMovement.ts @@ -92,6 +92,15 @@ function initPig(focus: Focus, displaySpace: DisplaySpace, now: number): PigStat }; } +function isExpiredFocus(focus: Focus): boolean { + return focus.timer?.status === "Expired"; +} + +function restExpiredAnimal(animal: PigState): PigState { + if (animal.vx === 0 && animal.vy === 0 && animal.direction === "back") return animal; + return { ...animal, vx: 0, vy: 0, direction: "back" }; +} + export function buildHitRects( pigs: PigState[], dpr: number, @@ -260,10 +269,12 @@ export function usePigMovement( const usingFallback = displaySpace === fallbackDisplaySpaceRef.current; if (existing && (!fallbackIds.has(f.id) || usingFallback)) { if (fallbackIds.has(f.id)) nextFallbackIds.add(f.id); - return existing.name === f.title ? existing : { ...existing, name: f.title }; + const named = existing.name === f.title ? existing : { ...existing, name: f.title }; + return isExpiredFocus(f) ? restExpiredAnimal(named) : named; } const pig = initPig(f, displaySpace, now); + if (isExpiredFocus(f)) return restExpiredAnimal(pig); if (usingFallback) { nextFallbackIds.add(f.id); } @@ -298,9 +309,13 @@ export function usePigMovement( lastTimeRef.current = now; const displaySpace = displaySpaceRef.current; + const expiredFocusIds = new Set( + focuses.filter((focus) => focus.timer?.status === "Expired").map((focus) => focus.id), + ); const updated = pigsRef.current.map((p) => { // Skip tick for dragged pig — position is driven by pointer events. if (p.id === dragIdRef.current) return p; + if (expiredFocusIds.has(p.id)) return restExpiredAnimal(p); return advanceRanchAnimal({ animal: p, displaySpace, @@ -324,7 +339,7 @@ export function usePigMovement( rafRef.current = requestAnimationFrame(loop); return () => cancelAnimationFrame(rafRef.current); - }, []); + }, [focuses]); return { pigs, startDrag, moveDrag, endDrag, setDragActive }; } diff --git a/src/styles.css b/src/styles.css index b8fcfe7..ab249f2 100644 --- a/src/styles.css +++ b/src/styles.css @@ -454,14 +454,14 @@ body, text-overflow: ellipsis; } -.pig-detail-backdrop { +.animal-detail-backdrop { position: fixed; inset: 0; pointer-events: auto; background: transparent; } -.pig-detail { +.animal-detail { position: absolute; pointer-events: auto; background: #141414; @@ -471,18 +471,17 @@ body, min-width: 340px; color: #f5f5f7; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; - box-shadow: 0 4px 24px rgba(0, 0, 0, 0.5); z-index: 10; } -.pig-detail-header { +.animal-detail-header { display: flex; align-items: center; gap: 6px; margin: 0 0 8px; } -.pig-detail-title-input { +.animal-detail-title-input { flex: 1; background: transparent; border: 1px solid transparent; @@ -496,22 +495,22 @@ body, min-width: 0; } -.pig-detail-title-input:hover { +.animal-detail-title-input:hover { border-color: rgba(255, 255, 255, 0.08); } -.pig-detail-title-input:focus { +.animal-detail-title-input:focus { border-color: rgba(255, 255, 255, 0.3); background: transparent; } -.pig-detail-title-error { +.animal-detail-title-error { margin: 0 0 6px; color: #ff8a80; font-size: 11px; } -.pig-detail-delete { +.animal-detail-delete { background: transparent; border: none; color: inherit; @@ -522,11 +521,11 @@ body, flex-shrink: 0; } -.pig-detail-delete:hover { +.animal-detail-delete:hover { opacity: 1; } -.pig-detail-delete-confirm { +.animal-detail-delete-confirm { display: flex; align-items: center; gap: 6px; @@ -534,12 +533,12 @@ body, font-size: 12px; } -.pig-detail-delete-confirm span { +.animal-detail-delete-confirm span { flex: 1; min-width: 0; } -.pig-detail-delete-confirm button { +.animal-detail-delete-confirm button { background: transparent; border: 1px solid rgba(255, 255, 255, 0.18); border-radius: 4px; @@ -549,18 +548,18 @@ body, font-size: 11px; } -.pig-detail-delete-confirm-yes { +.animal-detail-delete-confirm-yes { border-color: rgba(255, 138, 128, 0.6) !important; color: #ff8a80 !important; } -.pig-detail-empty { +.animal-detail-empty { margin: 0; font-size: 12px; opacity: 0.6; } -.pig-detail-tasks { +.animal-detail-tasks { list-style: none; margin: 0 0 10px; padding: 0; @@ -571,26 +570,26 @@ body, overflow-y: auto; } -.pig-detail-task { +.animal-detail-task { display: flex; align-items: center; justify-content: space-between; gap: 6px; } -.pig-detail-task-text { +.animal-detail-task-text { font-size: 12px; opacity: 0.9; flex: 1; } -.pig-detail-task-check { +.animal-detail-task-check { flex-shrink: 0; margin: 0; cursor: pointer; } -.pig-detail-task-input { +.animal-detail-task-input { flex: 1; background: transparent; border: 1px solid transparent; @@ -603,21 +602,21 @@ body, min-width: 0; } -.pig-detail-task-input:hover { +.animal-detail-task-input:hover { border-color: rgba(255, 255, 255, 0.08); } -.pig-detail-task-input:focus { +.animal-detail-task-input:focus { border-color: rgba(255, 255, 255, 0.3); background: rgba(255, 255, 255, 0.04); } -.pig-detail-task--done .pig-detail-task-input { +.animal-detail-task--done .animal-detail-task-input { text-decoration: line-through; opacity: 0.5; } -.pig-detail-task-clear { +.animal-detail-task-clear { background: transparent; border: none; color: inherit; @@ -628,11 +627,101 @@ body, flex-shrink: 0; } -.pig-detail-task-clear:hover { +.animal-detail-task-clear:hover { opacity: 1; } -.pig-detail-add-task { +.timer-dropdown { + position: relative; + flex-shrink: 0; +} + +.timer-trigger { + min-width: 24px; + height: 22px; + padding: 0 5px; + border: 1px solid rgba(255, 255, 255, 0.12); + border-radius: 5px; + background: rgba(255, 255, 255, 0.04); + color: inherit; + cursor: pointer; + font: inherit; + font-size: 11px; + line-height: 20px; + text-align: center; + white-space: nowrap; +} + +.timer-trigger--set { + min-width: 48px; + color: #ffd166; + border-color: rgba(255, 209, 102, 0.28); +} + +.timer-trigger:hover, +.timer-trigger[aria-expanded="true"] { + border-color: rgba(255, 255, 255, 0.32); + background: rgba(255, 255, 255, 0.08); +} + +.timer-dropdown-menu { + position: absolute; + right: 0; + top: calc(100% + 4px); + z-index: 30; + display: flex; + flex-direction: column; + gap: 6px; + width: 150px; + padding: 8px; + border: 1px solid rgba(255, 255, 255, 0.14); + border-radius: 7px; + background: #1d1d20; + box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45); +} + +.timer-dropdown-menu select, +.timer-dropdown-menu input { + width: 100%; + min-width: 0; + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.16); + border-radius: 5px; + color: #f5f5f7; + font: inherit; + font-size: 12px; + padding: 3px 5px; +} + +.timer-dropdown-actions { + display: flex; + gap: 6px; +} + +.timer-dropdown-actions button { + flex: 1; + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 5px; + color: inherit; + cursor: pointer; + font-size: 11px; + padding: 3px 6px; +} + +.timer-dropdown-clear { + color: #ff8a80 !important; + border-color: rgba(255, 138, 128, 0.35) !important; +} + +.timer-dropdown-error { + margin: 0; + color: #ff8a80; + font-size: 11px; + line-height: 1.25; +} + +.animal-detail-add-task { width: 100%; margin-top: 10px; background: rgba(255, 255, 255, 0.06); @@ -645,22 +734,22 @@ body, box-sizing: border-box; } -.pig-detail-add-task::placeholder { +.animal-detail-add-task::placeholder { opacity: 0.45; } -.pig-detail-add-task:focus { +.animal-detail-add-task:focus { border-color: rgba(255, 255, 255, 0.3); } -.pig-detail-timer { +.animal-detail-timer { display: flex; align-items: center; gap: 6px; margin-top: 10px; } -.pig-detail-timer-status { +.animal-detail-timer-status { color: #ff8a80; font-size: 12px; font-weight: 600; diff --git a/src/types/generated/Task.ts b/src/types/generated/Task.ts index 431935c..7b3122f 100644 --- a/src/types/generated/Task.ts +++ b/src/types/generated/Task.ts @@ -1,3 +1,4 @@ // This file was generated by [ts-rs](https://github.com/Aleph-Alpha/ts-rs). Do not edit this file manually. +import type { FocusTimer } from "./FocusTimer"; -export type Task = { id: string, text: string, done: boolean, }; +export type Task = { id: string, text: string, done: boolean, timer?: FocusTimer | null, };