diff --git a/Cargo.lock b/Cargo.lock index 06a391d..e82e60c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -21,6 +21,7 @@ dependencies = [ "tauri-plugin-dialog", "tauri-plugin-log", "tauri-plugin-notification", + "tempfile", "time", "tokio", "uuid", diff --git a/crates/commands/src/focus.rs b/crates/commands/src/focus.rs index eed612d..3991382 100644 --- a/crates/commands/src/focus.rs +++ b/crates/commands/src/focus.rs @@ -1,6 +1,8 @@ use std::sync::Arc; -use adhd_ranch_domain::{Caps, Focus, FocusTimer, NewFocus, TaskText, TimerPreset, TimerStatus}; +use adhd_ranch_domain::{ + slugify, Caps, Focus, FocusTimer, NewFocus, TaskText, TimerPreset, TimerStatus, +}; use adhd_ranch_storage::FocusStore; use serde::{Deserialize, Serialize}; @@ -51,6 +53,24 @@ impl Commands { Ok(CreatedFocus { id: slug }) } + pub fn duplicate_focus(&self, focus_id: &str) -> Result { + let focuses = self.store.list()?; + let source = focuses + .iter() + .find(|focus| focus.id.0 == focus_id) + .ok_or_else(|| CommandError::NotFound(format!("focus not found: {focus_id}")))?; + let title = duplicate_title(&source.title, &focuses); + let new_focus = NewFocus::new(title, source.description.clone())?; + let slug = create_focus_in_store(&self.store, &self.clock, &self.id_gen, &new_focus, None)?; + for (index, task) in source.tasks.iter().enumerate() { + self.store.append_task(&slug, &task.text)?; + if task.done { + self.store.toggle_task(&slug, index, true)?; + } + } + Ok(CreatedFocus { id: slug }) + } + pub fn delete_focus(&self, focus_id: &str) -> Result<(), CommandError> { self.store.delete_focus(focus_id)?; Ok(()) @@ -139,6 +159,24 @@ impl Commands { } } +fn duplicate_title(title: &str, focuses: &[Focus]) -> String { + let existing_slugs = focuses + .iter() + .map(|focus| focus.id.0.as_str()) + .collect::>(); + let base = format!("{title} copy"); + if !existing_slugs.contains(slugify(&base).as_str()) { + return base; + } + for n in 2.. { + let candidate = format!("{base} {n}"); + if !existing_slugs.contains(slugify(&candidate).as_str()) { + return candidate; + } + } + unreachable!("unbounded duplicate title search should always find a title") +} + #[cfg(test)] mod tests { use super::*; @@ -240,6 +278,61 @@ mod tests { assert_eq!(focuses[0].tasks[0].text, "new life"); } + #[test] + fn duplicate_focus_copies_title_description_and_tasks() { + let (commands, _dir) = build_commands(1_000_000); + let created = commands + .create_focus(CreateFocusInput { + title: "Ship it".into(), + description: "release plan".into(), + timer_preset: Some(TimerPreset::Two), + }) + .unwrap(); + commands.append_task(&created.id, "first").unwrap(); + commands.append_task(&created.id, "second").unwrap(); + commands.toggle_task(&created.id, 1, true).unwrap(); + + let duplicated = commands.duplicate_focus(&created.id).unwrap(); + + assert_eq!(duplicated.id, "ship-it-copy"); + let focuses = commands.list_focuses().unwrap(); + let copy = focuses + .iter() + .find(|focus| focus.id.0 == duplicated.id) + .unwrap(); + assert_eq!(copy.title, "Ship it copy"); + assert_eq!(copy.description, "release plan"); + assert!(copy.timer.is_none()); + assert_eq!(copy.tasks.len(), 2); + assert_eq!(copy.tasks[0].text, "first"); + assert!(!copy.tasks[0].done); + assert_eq!(copy.tasks[1].text, "second"); + assert!(copy.tasks[1].done); + } + + #[test] + fn duplicate_focus_uses_numeric_suffix_when_copy_exists() { + let (commands, _dir) = build_commands(1_000_000); + let created = commands + .create_focus(CreateFocusInput { + title: "Ship it".into(), + description: String::new(), + timer_preset: None, + }) + .unwrap(); + commands.duplicate_focus(&created.id).unwrap(); + let duplicated = commands.duplicate_focus(&created.id).unwrap(); + + assert_eq!(duplicated.id, "ship-it-copy-2"); + } + + #[test] + fn duplicate_focus_unknown_id_returns_not_found() { + let (commands, _dir) = build_commands(1_000_000); + let err = commands.duplicate_focus("missing").unwrap_err(); + assert!(matches!(err, CommandError::NotFound(_))); + } + #[test] fn rename_focus_updates_title() { let (commands, _dir) = build_commands(0); diff --git a/crates/storage/src/focus_store.rs b/crates/storage/src/focus_store.rs index 0213d82..4fbc7b9 100644 --- a/crates/storage/src/focus_store.rs +++ b/crates/storage/src/focus_store.rs @@ -229,11 +229,7 @@ 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}" - ); - } + self.remove_task_timer_index(focus_id, index)?; Ok(()) } @@ -599,17 +595,15 @@ mod tests { } #[test] - fn delete_task_keeps_success_when_task_timer_cleanup_fails() { + fn delete_task_returns_error_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 err = store.delete_task("a", 1).unwrap_err(); - let content = fs::read_to_string(dir.path().join("a/focus.md")).unwrap(); - assert!(content.contains("- [ ] one")); - assert!(!content.contains("- [ ] two")); + assert!(matches!(err, FocusStoreError::Io(_))); } #[test] diff --git a/issues/done/052-task-timers-and-clock-dropdown-editing.md b/issues/done/052-task-timers-and-clock-dropdown-editing.md index b90baf1..2a5a9a7 100644 --- a/issues/done/052-task-timers-and-clock-dropdown-editing.md +++ b/issues/done/052-task-timers-and-clock-dropdown-editing.md @@ -10,7 +10,7 @@ Users can edit timers by clicking the clock/time control itself. Focus timers re Implementation notes: -- `Task` gains optional `timer: FocusTimer`. +- `Task` gains optional `timer: Option`. - 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. diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index c80ffed..d64a48e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -33,6 +33,9 @@ log = "0.4" serde = { workspace = true } serde_json = { workspace = true } +[dev-dependencies] +tempfile = "3" + [target.'cfg(target_os = "macos")'.dependencies] objc2 = "0.5" objc2-app-kit = { version = "0.2", features = ["NSWindow"] } diff --git a/src-tauri/icons/128x128.png b/src-tauri/icons/128x128.png index d5cf438..406efb3 100644 Binary files a/src-tauri/icons/128x128.png and b/src-tauri/icons/128x128.png differ diff --git a/src-tauri/icons/128x128@2x.png b/src-tauri/icons/128x128@2x.png index d6bf02e..d664429 100644 Binary files a/src-tauri/icons/128x128@2x.png and b/src-tauri/icons/128x128@2x.png differ diff --git a/src-tauri/icons/32x32.png b/src-tauri/icons/32x32.png index e069fbf..174fed3 100644 Binary files a/src-tauri/icons/32x32.png and b/src-tauri/icons/32x32.png differ diff --git a/src-tauri/icons/icon.icns b/src-tauri/icons/icon.icns index e46e3f4..5e304e0 100644 Binary files a/src-tauri/icons/icon.icns and b/src-tauri/icons/icon.icns differ diff --git a/src-tauri/icons/icon.ico b/src-tauri/icons/icon.ico index 1d48521..535e48c 100644 Binary files a/src-tauri/icons/icon.ico and b/src-tauri/icons/icon.ico differ diff --git a/src-tauri/icons/icon.png b/src-tauri/icons/icon.png index 1f9f7bc..d9b5a13 100644 Binary files a/src-tauri/icons/icon.png and b/src-tauri/icons/icon.png differ diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 6feebfa..08e483b 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -1,6 +1,7 @@ pub mod cap_notifier; pub mod menu; pub mod paths; +pub mod seed; pub mod timer_expiry; pub mod tray; pub mod window_always_on_top; @@ -47,6 +48,7 @@ pub fn run() { ui_bridge::accept_proposal, ui_bridge::reject_proposal, ui_bridge::create_focus, + ui_bridge::duplicate_focus, ui_bridge::create_proposal, ui_bridge::delete_focus, ui_bridge::append_task, @@ -97,6 +99,7 @@ pub fn run() { Arc::new(|| uuid::Uuid::now_v7().to_string()), settings.clone(), )); + seed::ensure_example_focus(&commands, &focuses_root)?; let cap_monitor = Arc::new(OverCapMonitor::new()); let notifier = Arc::new(TauriCapNotifier::new(app.handle().clone())); diff --git a/src-tauri/src/app/paths.rs b/src-tauri/src/app/paths.rs index 694a3a5..e5b97d2 100644 --- a/src-tauri/src/app/paths.rs +++ b/src-tauri/src/app/paths.rs @@ -1,9 +1,28 @@ +use std::ffi::OsString; use std::io; use std::path::PathBuf; pub fn data_root() -> io::Result { - let home = std::env::var_os("HOME") - .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME not set"))?; + data_root_from_env(|key| std::env::var_os(key)) +} + +fn data_root_from_env(mut var: impl FnMut(&str) -> Option) -> io::Result { + if let Some(root) = var("ADHD_RANCH_HOME") { + return Ok(PathBuf::from(root)); + } + + #[cfg(target_os = "windows")] + { + if let Some(appdata) = var("APPDATA") { + return Ok(PathBuf::from(appdata).join("adhd-ranch")); + } + if let Some(profile) = var("USERPROFILE") { + return Ok(PathBuf::from(profile).join(".adhd-ranch")); + } + } + + let home = var("HOME") + .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "home directory not found"))?; Ok(PathBuf::from(home).join(".adhd-ranch")) } @@ -26,3 +45,40 @@ pub fn decisions_file() -> io::Result { pub fn settings_file() -> io::Result { Ok(data_root()?.join("settings.yaml")) } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashMap; + + fn root(vars: &[(&str, &str)]) -> io::Result { + let vars: HashMap<&str, OsString> = + vars.iter().map(|(k, v)| (*k, OsString::from(v))).collect(); + data_root_from_env(|key| vars.get(key).cloned()) + } + + #[test] + fn explicit_root_overrides_platform_defaults() { + assert_eq!( + root(&[("ADHD_RANCH_HOME", "/tmp/ranch"), ("HOME", "/Users/ryan")]).unwrap(), + PathBuf::from("/tmp/ranch") + ); + } + + #[test] + fn unix_default_uses_home_dot_dir() { + assert_eq!( + root(&[("HOME", "/Users/ryan")]).unwrap(), + PathBuf::from("/Users/ryan").join(".adhd-ranch") + ); + } + + #[cfg(target_os = "windows")] + #[test] + fn windows_default_uses_appdata() { + assert_eq!( + root(&[("APPDATA", "C:\\Users\\ryan\\AppData\\Roaming")]).unwrap(), + PathBuf::from("C:\\Users\\ryan\\AppData\\Roaming").join("adhd-ranch") + ); + } +} diff --git a/src-tauri/src/app/seed.rs b/src-tauri/src/app/seed.rs new file mode 100644 index 0000000..c8fb0eb --- /dev/null +++ b/src-tauri/src/app/seed.rs @@ -0,0 +1,93 @@ +use std::path::Path; + +use adhd_ranch_commands::{Commands, CreateFocusInput}; + +const EXAMPLE_MARKER: &str = ".example-focus-seeded"; +const EXAMPLE_TITLE: &str = "Welcome to ADHD Ranch"; + +pub fn ensure_example_focus( + commands: &Commands, + focuses_root: &Path, +) -> Result<(), Box> { + let marker = focuses_root.join(EXAMPLE_MARKER); + let focuses = commands.list_focuses()?; + if !focuses.is_empty() { + mark_seeded(&marker)?; + return Ok(()); + } + if marker.exists() { + return Ok(()); + } + + let created = commands.create_focus(CreateFocusInput { + title: EXAMPLE_TITLE.to_string(), + description: "Example focus created on first launch so the ranch has one animal to show." + .to_string(), + timer_preset: None, + })?; + commands.append_task(&created.id, "Click the animal to open this task card")?; + commands.append_task( + &created.id, + "Use + New Focus from the tray to add your own work", + )?; + mark_seeded(&marker)?; + Ok(()) +} + +fn mark_seeded(marker: &Path) -> std::io::Result<()> { + std::fs::write(marker, b"1\n") +} + +#[cfg(test)] +mod tests { + use super::*; + use adhd_ranch_commands::Commands; + use adhd_ranch_domain::Settings; + use adhd_ranch_storage::{JsonlDecisionLog, JsonlProposalQueue, MarkdownFocusStore}; + use std::sync::Arc; + use tempfile::TempDir; + + fn commands(root: &Path) -> Commands { + let focuses_root = root.join("focuses"); + std::fs::create_dir_all(&focuses_root).unwrap(); + Commands::new( + Arc::new(MarkdownFocusStore::new(focuses_root)), + Arc::new(JsonlProposalQueue::new(root.join("proposals.jsonl"))), + Arc::new(JsonlDecisionLog::new(root.join("decisions.jsonl"))), + Arc::new(|| "2026-05-20T00:00:00Z".to_string()), + Arc::new(|| 1_779_235_200), + Arc::new(|| "example-id".to_string()), + Settings::default(), + ) + } + + #[test] + fn creates_example_focus_once_when_store_is_empty() { + let dir = TempDir::new().unwrap(); + let commands = commands(dir.path()); + let focuses_root = dir.path().join("focuses"); + + ensure_example_focus(&commands, &focuses_root).unwrap(); + ensure_example_focus(&commands, &focuses_root).unwrap(); + + let focuses = commands.list_focuses().unwrap(); + assert_eq!(focuses.len(), 1); + assert_eq!(focuses[0].title, EXAMPLE_TITLE); + assert_eq!(focuses[0].tasks.len(), 2); + assert!(focuses_root.join(EXAMPLE_MARKER).is_file()); + } + + #[test] + fn does_not_recreate_example_after_user_clears_all_focuses() { + let dir = TempDir::new().unwrap(); + let commands = commands(dir.path()); + let focuses_root = dir.path().join("focuses"); + + ensure_example_focus(&commands, &focuses_root).unwrap(); + let focus_id = commands.list_focuses().unwrap()[0].id.0.clone(); + commands.delete_focus(&focus_id).unwrap(); + ensure_example_focus(&commands, &focuses_root).unwrap(); + + assert!(commands.list_focuses().unwrap().is_empty()); + } +} diff --git a/src-tauri/src/ui_bridge/mod.rs b/src-tauri/src/ui_bridge/mod.rs index c381161..6380ee9 100644 --- a/src-tauri/src/ui_bridge/mod.rs +++ b/src-tauri/src/ui_bridge/mod.rs @@ -58,6 +58,18 @@ pub fn create_focus( .inspect_err(|e| log::error!("create_focus({title:?}): {e}")) } +#[tauri::command] +pub fn duplicate_focus( + focus_id: String, + state: State<'_, CommandsState>, +) -> Result { + state + .0 + .duplicate_focus(&focus_id) + .inspect(|f| log::info!("focus duplicated: {focus_id} -> {}", f.id)) + .inspect_err(|e| log::error!("duplicate_focus({focus_id:?}): {e}")) +} + #[tauri::command] pub fn delete_focus(focus_id: String, state: State<'_, CommandsState>) -> Result<(), CommandError> { state diff --git a/src/api/fixtureFocusWriter.ts b/src/api/fixtureFocusWriter.ts index bf429ec..ce565e2 100644 --- a/src/api/fixtureFocusWriter.ts +++ b/src/api/fixtureFocusWriter.ts @@ -9,6 +9,7 @@ export function createFixtureFocusWriter(opts: FixtureFocusWriterOptions = {}): const result = () => Promise.resolve(outcome); return { createFocus: result, + duplicateFocus: result, deleteFocus: result, renameFocus: result, appendTask: result, diff --git a/src/api/focusWriter.test.ts b/src/api/focusWriter.test.ts index 3e7d7e8..b70a4d0 100644 --- a/src/api/focusWriter.test.ts +++ b/src/api/focusWriter.test.ts @@ -70,6 +70,18 @@ describe("tauriFocusWriter", () => { }); }); + it("duplicateFocus forwards focusId", async () => { + mockInvoke.mockResolvedValueOnce({ id: "focus-1-copy" }); + const writer = createTauriFocusWriter(); + + const outcome = await writer.duplicateFocus("focus-1"); + + expect(outcome).toEqual({ ok: true }); + expect(mockInvoke).toHaveBeenCalledWith("duplicate_focus", { + focusId: "focus-1", + }); + }); + it("startTimer forwards custom preset object", async () => { mockInvoke.mockResolvedValueOnce(undefined); const writer = createTauriFocusWriter(); diff --git a/src/api/focusWriter.ts b/src/api/focusWriter.ts index 706b626..9f3fae3 100644 --- a/src/api/focusWriter.ts +++ b/src/api/focusWriter.ts @@ -13,6 +13,7 @@ export interface CreateFocusInput { export interface FocusWriter { createFocus(input: CreateFocusInput): Promise; + duplicateFocus(focusId: string): Promise; deleteFocus(focusId: string): Promise; renameFocus(focusId: string, title: string): Promise; appendTask(focusId: string, text: string): Promise; @@ -53,6 +54,9 @@ export function createTauriFocusWriter(): FocusWriter { timerPreset: timer_preset ?? null, }); }, + duplicateFocus(focusId) { + return runInvoke("duplicate_focus", { focusId }); + }, deleteFocus(focusId) { return runInvoke("delete_focus", { focusId }); }, diff --git a/src/api/tauriFocusReader.test.ts b/src/api/tauriFocusReader.test.ts index 65245f4..aa7b850 100644 --- a/src/api/tauriFocusReader.test.ts +++ b/src/api/tauriFocusReader.test.ts @@ -17,14 +17,21 @@ beforeEach(() => { }); describe("tauriFocusReader", () => { - it("preserves timer data from Rust focuses", async () => { + it("preserves focus and task timer data from Rust focuses", async () => { mockInvoke.mockResolvedValueOnce([ { id: "focus-1", title: "Timed focus", description: "", created_at: "", - tasks: [], + tasks: [ + { + id: "task-1", + text: "Timed task", + done: true, + timer: { duration_secs: 120, started_at: 1_700_000_010, status: "Expired" }, + }, + ], timer: { duration_secs: 480, started_at: 1_700_000_000, status: "Running" }, }, ]); @@ -37,5 +44,11 @@ describe("tauriFocusReader", () => { started_at: 1_700_000_000, status: "Running", }); + expect(focuses[0]?.tasks[0]).toEqual({ + id: "task-1", + text: "Timed task", + done: true, + timer: { duration_secs: 120, started_at: 1_700_000_010, status: "Expired" }, + }); }); }); diff --git a/src/api/tauriFocusReader.ts b/src/api/tauriFocusReader.ts index 80c2817..b1ced63 100644 --- a/src/api/tauriFocusReader.ts +++ b/src/api/tauriFocusReader.ts @@ -8,7 +8,12 @@ interface RustFocus { readonly title: string; readonly description: string; readonly created_at: string; - readonly tasks: readonly { id: string; text: string; done?: boolean }[]; + readonly tasks: readonly { + id: string; + text: string; + done?: boolean; + timer?: FocusTimer | null; + }[]; readonly timer?: FocusTimer | null; } @@ -18,7 +23,12 @@ function fromRust(raw: RustFocus): Focus { title: raw.title, description: raw.description, created_at: raw.created_at, - tasks: raw.tasks.map((t) => ({ id: t.id, text: t.text, done: t.done ?? false })), + tasks: raw.tasks.map((t) => ({ + id: t.id, + text: t.text, + done: t.done ?? false, + timer: t.timer ?? null, + })), timer: raw.timer ?? null, }; } diff --git a/src/components/AnimalDetail.test.tsx b/src/components/AnimalDetail.test.tsx index fa1fb47..8e22994 100644 --- a/src/components/AnimalDetail.test.tsx +++ b/src/components/AnimalDetail.test.tsx @@ -27,6 +27,7 @@ function renderDetail(overrides?: Partial { }); describe("AnimalDetail delete focus", () => { + it("duplicate button calls onDuplicateFocus and closes", async () => { + const { onDuplicateFocus, onClose } = renderDetail(); + + await userEvent.click(screen.getByLabelText("duplicate focus Ship it")); + + expect(onDuplicateFocus).toHaveBeenCalledWith("pig-a"); + expect(onClose).toHaveBeenCalled(); + }); + it("with confirmDelete=true shows inline confirm before deleting", async () => { const { onDeleteFocus } = renderDetail({ confirmDelete: true }); await userEvent.click(screen.getByLabelText("delete focus Ship it")); @@ -312,6 +322,7 @@ describe("AnimalDetail timer picker", () => { onRenameFocus={vi.fn()} onUpdateTask={vi.fn()} onToggleTask={vi.fn()} + onDuplicateFocus={vi.fn()} onDeleteFocus={vi.fn()} onStartTimer={vi.fn()} onClearTimer={vi.fn()} diff --git a/src/components/AnimalDetail.tsx b/src/components/AnimalDetail.tsx index 969a32a..27fd41f 100644 --- a/src/components/AnimalDetail.tsx +++ b/src/components/AnimalDetail.tsx @@ -18,6 +18,7 @@ export interface AnimalDetailProps { readonly onRenameFocus: (focusId: string, title: string) => void; readonly onUpdateTask: (focusId: string, index: number, text: string) => void; readonly onToggleTask: (focusId: string, index: number, done: boolean) => void; + readonly onDuplicateFocus: (focusId: string) => void; readonly onDeleteFocus: (focusId: string) => void; readonly onStartTimer: (focusId: string, preset: TimerPreset) => void; readonly onClearTimer: (focusId: string) => void; @@ -40,6 +41,7 @@ export function AnimalDetail({ onRenameFocus, onUpdateTask, onToggleTask, + onDuplicateFocus, onDeleteFocus, onStartTimer, onClearTimer, @@ -162,6 +164,17 @@ export function AnimalDetail({ onStart={(preset) => onStartTimer(focus.id, preset)} onClear={() => onClearTimer(focus.id)} /> + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "edit timer" })); + await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "custom"); + const input = screen.getByTestId("custom-timer-input"); + await userEvent.clear(input); + await userEvent.type(input, "25"); + await userEvent.click(screen.getByRole("button", { name: "outside" })); + + expect(onStart).toHaveBeenCalledWith({ Custom: 25 }); + }); + + it("commits only once when an outside click also blurs the dropdown", async () => { + const onStart = vi.fn(); + render( + <> + + + , + ); + + await userEvent.click(screen.getByRole("button", { name: "edit timer" })); + await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "ThirtyTwo"); + await userEvent.click(screen.getByRole("button", { name: "outside" })); + + expect(onStart).toHaveBeenCalledTimes(1); + expect(onStart).toHaveBeenCalledWith("ThirtyTwo"); + }); + + it("does not auto-commit when moving focus inside the dropdown", async () => { + const onStart = vi.fn(); + render(); + + await userEvent.click(screen.getByRole("button", { name: "edit timer" })); + await userEvent.selectOptions(screen.getByTestId("timer-preset-select"), "ThirtyTwo"); + await userEvent.click(screen.getByRole("button", { name: "Start" })); + + expect(onStart).toHaveBeenCalledTimes(1); + expect(onStart).toHaveBeenCalledWith("ThirtyTwo"); + }); +}); diff --git a/src/components/TimerDropdown.tsx b/src/components/TimerDropdown.tsx index 7d97920..4dc3363 100644 --- a/src/components/TimerDropdown.tsx +++ b/src/components/TimerDropdown.tsx @@ -1,4 +1,4 @@ -import { useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { type PresetSelection, isCustomValid, resolvePreset } from "../lib/timerPreset"; import type { FocusTimer, TimerPreset } from "../types/timer"; import { TimerPresetPicker } from "./TimerPresetPicker"; @@ -11,12 +11,14 @@ export interface TimerDropdownProps { } export function TimerDropdown({ timer, ariaLabel, onStart, onClear }: TimerDropdownProps) { + const rootRef = useRef(null); + const pointerCommitRef = useRef(false); const [open, setOpen] = useState(false); const [selection, setSelection] = useState("Eight"); const [customMinutes, setCustomMinutes] = useState(10); const [error, setError] = useState(null); - function start() { + const start = useCallback(() => { if (selection === "custom" && !isCustomValid(customMinutes)) { setError("custom timer must be at least 1 minute"); return; @@ -26,10 +28,40 @@ export function TimerDropdown({ timer, ariaLabel, onStart, onClear }: TimerDropd setError(null); onStart(preset); setOpen(false); - } + }, [customMinutes, onStart, selection]); + + useEffect(() => { + if (!open) return; + function handlePointerDown(e: PointerEvent) { + const target = e.target; + if (!(target instanceof Node)) return; + if (rootRef.current?.contains(target)) return; + pointerCommitRef.current = true; + start(); + window.setTimeout(() => { + pointerCommitRef.current = false; + }, 0); + } + window.addEventListener("pointerdown", handlePointerDown, true); + return () => window.removeEventListener("pointerdown", handlePointerDown, true); + }, [open, start]); return ( -
+
{ + const next = e.relatedTarget; + if (!open || (next instanceof Node && e.currentTarget.contains(next))) return; + if (pointerCommitRef.current) return; + start(); + }} + onKeyDown={(e) => { + if (!open || e.key !== "Enter") return; + e.preventDefault(); + start(); + }} + >