diff --git a/crates/commands/src/caps.rs b/crates/commands/src/caps.rs index d5d6735..f19fd21 100644 --- a/crates/commands/src/caps.rs +++ b/crates/commands/src/caps.rs @@ -148,12 +148,31 @@ mod tests { fn delete_focus(&self, _focus_id: &str) -> Result<(), FocusStoreError> { unimplemented!() } + fn rename_focus(&self, _focus_id: &str, _title: &str) -> Result<(), FocusStoreError> { + unimplemented!() + } fn append_task(&self, _focus_id: &str, _text: &str) -> Result<(), FocusStoreError> { unimplemented!() } fn delete_task(&self, _focus_id: &str, _index: usize) -> Result<(), FocusStoreError> { unimplemented!() } + fn update_task( + &self, + _focus_id: &str, + _index: usize, + _text: &str, + ) -> Result<(), FocusStoreError> { + unimplemented!() + } + fn toggle_task( + &self, + _focus_id: &str, + _index: usize, + _done: bool, + ) -> Result<(), FocusStoreError> { + unimplemented!() + } } fn focus_with_tasks(id: &str, count: usize) -> Focus { @@ -166,6 +185,7 @@ mod tests { .map(|i| Task { id: format!("{id}:{i}"), text: format!("t{i}"), + done: false, }) .collect(), timer: None, diff --git a/crates/commands/src/focus.rs b/crates/commands/src/focus.rs index 9b91ec5..1dfad44 100644 --- a/crates/commands/src/focus.rs +++ b/crates/commands/src/focus.rs @@ -67,6 +67,38 @@ impl Commands { Ok(()) } + pub fn rename_focus(&self, focus_id: &str, title: &str) -> Result<(), CommandError> { + let trimmed = title.trim(); + if trimmed.is_empty() { + return Err(CommandError::from( + adhd_ranch_domain::DomainError::EmptyTitle, + )); + } + self.store.rename_focus(focus_id, trimmed)?; + Ok(()) + } + + pub fn update_task( + &self, + focus_id: &str, + index: usize, + text: &str, + ) -> Result<(), CommandError> { + let text = TaskText::new(text)?; + self.store.update_task(focus_id, index, text.as_str())?; + Ok(()) + } + + pub fn toggle_task( + &self, + focus_id: &str, + index: usize, + done: bool, + ) -> Result<(), CommandError> { + self.store.toggle_task(focus_id, index, done)?; + Ok(()) + } + pub fn caps(&self) -> Caps { self.settings.caps } @@ -144,6 +176,83 @@ mod tests { assert!(matches!(err, CommandError::BadRequest(_))); } + #[test] + fn rename_focus_updates_title() { + let (commands, _dir) = build_commands(0); + let created = commands + .create_focus(CreateFocusInput { + title: "Old".into(), + description: String::new(), + timer_preset: None, + }) + .unwrap(); + commands.rename_focus(&created.id, "New").unwrap(); + let focuses = commands.list_focuses().unwrap(); + assert_eq!(focuses[0].title, "New"); + } + + #[test] + fn rename_focus_blank_title_returns_bad_request() { + let (commands, _dir) = build_commands(0); + let created = commands + .create_focus(CreateFocusInput { + title: "Real".into(), + description: String::new(), + timer_preset: None, + }) + .unwrap(); + let err = commands.rename_focus(&created.id, " ").unwrap_err(); + assert!(matches!(err, CommandError::BadRequest(_))); + } + + #[test] + fn update_task_blank_text_returns_bad_request() { + let (commands, _dir) = build_commands(0); + let created = commands + .create_focus(CreateFocusInput { + title: "Has tasks".into(), + description: String::new(), + timer_preset: None, + }) + .unwrap(); + commands.append_task(&created.id, "first").unwrap(); + let err = commands.update_task(&created.id, 0, " ").unwrap_err(); + assert!(matches!(err, CommandError::BadRequest(_))); + } + + #[test] + fn update_task_replaces_text() { + let (commands, _dir) = build_commands(0); + let created = commands + .create_focus(CreateFocusInput { + title: "Has tasks".into(), + description: String::new(), + timer_preset: None, + }) + .unwrap(); + commands.append_task(&created.id, "old").unwrap(); + commands.update_task(&created.id, 0, "new").unwrap(); + let focuses = commands.list_focuses().unwrap(); + assert_eq!(focuses[0].tasks[0].text, "new"); + } + + #[test] + fn toggle_task_round_trip() { + let (commands, _dir) = build_commands(0); + let created = commands + .create_focus(CreateFocusInput { + title: "Has tasks".into(), + description: String::new(), + timer_preset: None, + }) + .unwrap(); + commands.append_task(&created.id, "thing").unwrap(); + commands.toggle_task(&created.id, 0, true).unwrap(); + commands.toggle_task(&created.id, 0, false).unwrap(); + let focuses = commands.list_focuses().unwrap(); + assert!(!focuses[0].tasks[0].done); + } + #[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 41dafa1..0e7ec7d 100644 --- a/crates/domain/src/caps.rs +++ b/crates/domain/src/caps.rs @@ -44,6 +44,7 @@ mod tests { .map(|i| Task { id: format!("{id}:{i}"), text: format!("t{i}"), + done: false, }) .collect(), timer: None, diff --git a/crates/domain/src/focus.rs b/crates/domain/src/focus.rs index cd030cb..365001c 100644 --- a/crates/domain/src/focus.rs +++ b/crates/domain/src/focus.rs @@ -27,6 +27,8 @@ pub struct FocusId(pub String); pub struct Task { pub id: String, pub text: String, + #[serde(default)] + pub done: bool, } #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] @@ -73,6 +75,7 @@ mod tests { tasks: vec![Task { id: "abc:0".into(), text: "step one".into(), + done: false, }], timer: None, }; diff --git a/crates/domain/src/parse.rs b/crates/domain/src/parse.rs index 1c240d9..be989d5 100644 --- a/crates/domain/src/parse.rs +++ b/crates/domain/src/parse.rs @@ -87,19 +87,24 @@ fn parse_tasks(body: &str, focus_id: &str) -> Vec { body.lines() .filter_map(|line| { let line = line.trim_start(); - let rest = line - .strip_prefix("- [ ]") - .or_else(|| line.strip_prefix("- [x]"))?; + let (done, rest) = if let Some(rest) = line.strip_prefix("- [x]") { + (true, rest) + } else if let Some(rest) = line.strip_prefix("- [ ]") { + (false, rest) + } else { + return None; + }; let text = rest.trim().to_string(); if text.is_empty() { return None; } - Some(text) + Some((done, text)) }) .enumerate() - .map(|(index, text)| Task { + .map(|(index, (done, text))| Task { id: format!("{focus_id}:{index}"), text, + done, }) .collect() } diff --git a/crates/http-api/src/router/focuses.rs b/crates/http-api/src/router/focuses.rs index 9e7a45a..1ea6353 100644 --- a/crates/http-api/src/router/focuses.rs +++ b/crates/http-api/src/router/focuses.rs @@ -11,9 +11,12 @@ use super::{ApiError, AppState, FocusCatalogEntry}; pub(super) fn routes() -> Router { Router::new() .route("/focuses", get(list).post(create)) - .route("/focuses/:id", delete(delete_focus)) + .route("/focuses/:id", delete(delete_focus).patch(rename_focus)) .route("/focuses/:id/tasks", post(append_task)) - .route("/focuses/:id/tasks/:idx", delete(delete_task)) + .route( + "/focuses/:id/tasks/:idx", + delete(delete_task).patch(patch_task), + ) } async fn list(State(state): State) -> Result>, ApiError> { @@ -72,3 +75,48 @@ async fn delete_task( .map_err(ApiError::from)?; Ok(StatusCode::NO_CONTENT) } + +#[derive(Debug, Deserialize)] +struct RenameFocusRequest { + title: String, +} + +async fn rename_focus( + State(state): State, + Path(id): Path, + Json(req): Json, +) -> Result { + state + .commands + .rename_focus(&id, &req.title) + .map_err(ApiError::from)?; + Ok(StatusCode::NO_CONTENT) +} + +#[derive(Debug, Deserialize)] +struct PatchTaskRequest { + #[serde(default)] + text: Option, + #[serde(default)] + done: Option, +} + +async fn patch_task( + State(state): State, + Path((id, idx)): Path<(String, usize)>, + Json(req): Json, +) -> Result { + if let Some(text) = req.text.as_deref() { + state + .commands + .update_task(&id, idx, text) + .map_err(ApiError::from)?; + } + if let Some(done) = req.done { + state + .commands + .toggle_task(&id, idx, done) + .map_err(ApiError::from)?; + } + Ok(StatusCode::NO_CONTENT) +} diff --git a/crates/http-api/src/router/tests.rs b/crates/http-api/src/router/tests.rs index db0e7af..e725ddd 100644 --- a/crates/http-api/src/router/tests.rs +++ b/crates/http-api/src/router/tests.rs @@ -98,6 +98,103 @@ async fn post_empty(app: &Router, uri: &str) -> axum::http::Response { .unwrap() } +async fn patch_json( + app: &Router, + uri: &str, + body: serde_json::Value, +) -> axum::http::Response { + app.clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri(uri) + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap() +} + +#[tokio::test] +async fn patch_focus_renames_title() { + let dir = TempDir::new().unwrap(); + let h = make_app(dir.path()); + write_focus( + &h.focuses_root, + "f1", + &focus_md("f1", "Old", "x", "2026-04-30T12:00:00Z"), + ); + let resp = patch_json(&h.app, "/focuses/f1", serde_json::json!({"title": "New"})).await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let content = std::fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap(); + assert!(content.contains("title: New")); + assert!(!content.contains("title: Old")); +} + +#[tokio::test] +async fn patch_focus_blank_title_returns_400() { + let dir = TempDir::new().unwrap(); + let h = make_app(dir.path()); + write_focus( + &h.focuses_root, + "f1", + &focus_md("f1", "Old", "x", "2026-04-30T12:00:00Z"), + ); + let resp = patch_json(&h.app, "/focuses/f1", serde_json::json!({"title": " "})).await; + assert_eq!(resp.status(), StatusCode::BAD_REQUEST); +} + +#[tokio::test] +async fn patch_task_updates_text() { + let dir = TempDir::new().unwrap(); + let h = make_app(dir.path()); + write_focus( + &h.focuses_root, + "f1", + &focus_md("f1", "T", "x", "2026-04-30T12:00:00Z"), + ); + let resp = patch_json( + &h.app, + "/focuses/f1/tasks/0", + serde_json::json!({"text": "rewrote"}), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let content = std::fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap(); + assert!(content.contains("- [ ] rewrote")); +} + +#[tokio::test] +async fn patch_task_toggles_done() { + let dir = TempDir::new().unwrap(); + let h = make_app(dir.path()); + write_focus( + &h.focuses_root, + "f1", + &focus_md("f1", "T", "x", "2026-04-30T12:00:00Z"), + ); + let resp = patch_json( + &h.app, + "/focuses/f1/tasks/0", + serde_json::json!({"done": true}), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let content = std::fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap(); + assert!(content.contains("- [x] one")); + + let resp = patch_json( + &h.app, + "/focuses/f1/tasks/0", + serde_json::json!({"done": false}), + ) + .await; + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + let content = std::fs::read_to_string(h.focuses_root.join("f1/focus.md")).unwrap(); + assert!(content.contains("- [ ] one")); +} + #[tokio::test] async fn health_returns_ok() { let dir = TempDir::new().unwrap(); diff --git a/crates/storage/src/focus_store.rs b/crates/storage/src/focus_store.rs index 0cabcf8..23ca01a 100644 --- a/crates/storage/src/focus_store.rs +++ b/crates/storage/src/focus_store.rs @@ -49,8 +49,11 @@ pub trait FocusStore: Send + Sync { timer: Option, ) -> Result; fn delete_focus(&self, focus_id: &str) -> Result<(), FocusStoreError>; + fn rename_focus(&self, focus_id: &str, title: &str) -> Result<(), FocusStoreError>; fn append_task(&self, focus_id: &str, text: &str) -> Result<(), FocusStoreError>; fn delete_task(&self, focus_id: &str, index: usize) -> Result<(), FocusStoreError>; + 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>; } pub struct MarkdownFocusStore { @@ -169,6 +172,46 @@ impl FocusStore for MarkdownFocusStore { Ok(()) } + fn rename_focus(&self, focus_id: &str, title: &str) -> Result<(), FocusStoreError> { + let current = self.read_focus(focus_id)?; + let mut out = String::with_capacity(current.len()); + let mut in_frontmatter = false; + let mut closed_frontmatter = false; + let mut replaced = false; + let trailing_newline = current.ends_with('\n'); + for (line_idx, line) in current.lines().enumerate() { + if line_idx == 0 && line == "---" { + in_frontmatter = true; + out.push_str(line); + out.push('\n'); + continue; + } + if in_frontmatter && !closed_frontmatter && line == "---" { + closed_frontmatter = true; + out.push_str(line); + out.push('\n'); + continue; + } + if in_frontmatter && !closed_frontmatter && !replaced { + if let Some((key, _)) = line.split_once(':') { + if key.trim() == "title" { + out.push_str(&format!("title: {title}")); + out.push('\n'); + replaced = true; + continue; + } + } + } + out.push_str(line); + out.push('\n'); + } + if !trailing_newline { + out.pop(); + } + atomic_write(&self.focus_md(focus_id), out.as_bytes())?; + Ok(()) + } + fn append_task(&self, focus_id: &str, text: &str) -> Result<(), FocusStoreError> { let mut next = self.read_focus(focus_id)?; if !next.ends_with('\n') { @@ -213,6 +256,81 @@ impl FocusStore for MarkdownFocusStore { atomic_write(&self.focus_md(focus_id), out.as_bytes())?; Ok(()) } + + fn update_task(&self, focus_id: &str, index: usize, text: &str) -> Result<(), FocusStoreError> { + rewrite_task_line(self, focus_id, index, |line| { + let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let body = line.trim_start(); + let (marker, _rest) = if let Some(rest) = body.strip_prefix("- [ ]") { + ("- [ ]", rest) + } else if let Some(rest) = body.strip_prefix("- [x]") { + ("- [x]", rest) + } else { + return line.to_string(); + }; + format!("{leading_ws}{marker} {text}", text = text.trim()) + }) + } + + fn toggle_task(&self, focus_id: &str, index: usize, done: bool) -> Result<(), FocusStoreError> { + rewrite_task_line(self, focus_id, index, |line| { + let leading_ws: String = line.chars().take_while(|c| c.is_whitespace()).collect(); + let body = line.trim_start(); + let new_marker = if done { "- [x]" } else { "- [ ]" }; + let rest = if let Some(rest) = body.strip_prefix("- [ ]") { + rest + } else if let Some(rest) = body.strip_prefix("- [x]") { + rest + } else { + return line.to_string(); + }; + format!("{leading_ws}{new_marker}{rest}") + }) + } +} + +fn rewrite_task_line( + store: &MarkdownFocusStore, + focus_id: &str, + index: usize, + transform: F, +) -> Result<(), FocusStoreError> +where + F: FnOnce(&str) -> String, +{ + let current = store.read_focus(focus_id)?; + let mut bullet_indices: Vec = Vec::new(); + for (line_idx, line) in current.lines().enumerate() { + let trimmed = line.trim_start(); + if trimmed.starts_with("- [ ]") || trimmed.starts_with("- [x]") { + bullet_indices.push(line_idx); + } + } + let target = + *bullet_indices + .get(index) + .ok_or_else(|| FocusStoreError::TaskIndexOutOfRange { + focus_id: focus_id.to_string(), + index, + })?; + + let mut out = String::with_capacity(current.len()); + let trailing_newline = current.ends_with('\n'); + let mut transform = Some(transform); + for (line_idx, line) in current.lines().enumerate() { + if line_idx == target { + let f = transform.take().unwrap(); + out.push_str(&f(line)); + } else { + out.push_str(line); + } + out.push('\n'); + } + if !trailing_newline { + out.pop(); + } + atomic_write(&store.focus_md(focus_id), out.as_bytes())?; + Ok(()) } #[cfg(test)] @@ -390,6 +508,101 @@ mod tests { assert!(matches!(err, FocusStoreError::AlreadyExists(slug) if slug == "customer-x-bug")); } + #[test] + fn toggle_task_marks_done() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["one", "two"])); + let store = MarkdownFocusStore::new(dir.path()); + + store.toggle_task("a", 0, true).unwrap(); + + let content = fs::read_to_string(dir.path().join("a/focus.md")).unwrap(); + assert!(content.contains("- [x] one")); + assert!(content.contains("- [ ] two")); + } + + #[test] + fn toggle_task_marks_undone() { + let dir = TempDir::new().unwrap(); + let body = "---\nid: a\ntitle: A\ndescription:\ncreated_at: 2026-04-30T12:00:00Z\n---\n- [x] done\n"; + write_focus(dir.path(), "a", body); + let store = MarkdownFocusStore::new(dir.path()); + + store.toggle_task("a", 0, false).unwrap(); + + let content = fs::read_to_string(dir.path().join("a/focus.md")).unwrap(); + assert!(content.contains("- [ ] done")); + assert!(!content.contains("- [x] done")); + } + + #[test] + fn toggle_task_errors_on_index_out_of_range() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["only"])); + let store = MarkdownFocusStore::new(dir.path()); + let err = store.toggle_task("a", 9, true).unwrap_err(); + assert!(matches!( + err, + FocusStoreError::TaskIndexOutOfRange { index: 9, .. } + )); + } + + #[test] + fn update_task_replaces_text_preserving_state() { + let dir = TempDir::new().unwrap(); + let body = "---\nid: a\ntitle: A\ndescription:\ncreated_at: 2026-04-30T12:00:00Z\n---\n- [ ] one\n- [x] two\n- [ ] three\n"; + write_focus(dir.path(), "a", body); + let store = MarkdownFocusStore::new(dir.path()); + + store.update_task("a", 1, "TWO RENAMED").unwrap(); + + let content = fs::read_to_string(dir.path().join("a/focus.md")).unwrap(); + assert!(content.contains("- [ ] one")); + assert!(content.contains("- [x] TWO RENAMED")); + assert!(content.contains("- [ ] three")); + assert!(!content.contains("- [x] two")); + } + + #[test] + fn update_task_errors_on_index_out_of_range() { + let dir = TempDir::new().unwrap(); + write_focus(dir.path(), "a", &focus_md("a", &["one"])); + let store = MarkdownFocusStore::new(dir.path()); + let err = store.update_task("a", 5, "x").unwrap_err(); + assert!(matches!( + err, + FocusStoreError::TaskIndexOutOfRange { index: 5, .. } + )); + } + + #[test] + fn rename_focus_updates_only_title() { + let dir = TempDir::new().unwrap(); + write_focus( + dir.path(), + "a", + &fixture("a", "Old Title", "2026-04-30T12:00:00Z", &["one", "two"]), + ); + let store = MarkdownFocusStore::new(dir.path()); + + store.rename_focus("a", "New Title").unwrap(); + + let focuses = store.list().unwrap(); + assert_eq!(focuses.len(), 1); + assert_eq!(focuses[0].title, "New Title"); + assert_eq!(focuses[0].id, adhd_ranch_domain::FocusId("a".into())); + assert_eq!(focuses[0].tasks.len(), 2); + assert!(dir.path().join("a/focus.md").is_file()); + } + + #[test] + fn rename_focus_errors_on_missing() { + let dir = TempDir::new().unwrap(); + let store = MarkdownFocusStore::new(dir.path()); + let err = store.rename_focus("ghost", "x").unwrap_err(); + assert!(matches!(err, FocusStoreError::NotFound(slug) if slug == "ghost")); + } + #[test] fn delete_focus_removes_dir() { let dir = TempDir::new().unwrap(); diff --git a/src-tauri/src/app/mod.rs b/src-tauri/src/app/mod.rs index 429f381..cca8600 100644 --- a/src-tauri/src/app/mod.rs +++ b/src-tauri/src/app/mod.rs @@ -50,6 +50,9 @@ pub fn run() { ui_bridge::delete_focus, ui_bridge::append_task, ui_bridge::delete_task, + ui_bridge::rename_focus, + ui_bridge::update_task, + ui_bridge::toggle_task, ui_bridge::get_caps, ui_bridge::update_pig_rects, ui_bridge::set_pig_drag_active, diff --git a/src-tauri/src/display/overlay.rs b/src-tauri/src/display/overlay.rs index aae9a2a..6d06a78 100644 --- a/src-tauri/src/display/overlay.rs +++ b/src-tauri/src/display/overlay.rs @@ -55,17 +55,32 @@ pub fn ensure_shown(app: &AppHandle, p: ShowParams<'_>) -> tauri::Result<() x, y ); - WebviewWindowBuilder::new(app, OVERLAY_LABEL, WebviewUrl::App(Default::default())) + let w = WebviewWindowBuilder::new(app, OVERLAY_LABEL, WebviewUrl::App(Default::default())) .decorations(false) .transparent(true) .resizable(false) .visible(false) .inner_size(width, height) .position(x, y) - .build()? + .build()?; + + // macOS demotes NSWindow level across key/resign-key transitions when + // the window is not an NSPanel. The popover's text inputs make the + // overlay key on click; re-apply on every focus event so the overlay + // stays floating. Registered only on first creation — ensure_shown is + // re-invoked on display config changes and would otherwise stack. + let win_evt = w.clone(); + w.on_window_event(move |event| { + if let tauri::WindowEvent::Focused(_) = event { + crate::app::window_always_on_top::apply(&win_evt, true); + } + }); + + w }; crate::app::window_always_on_top::apply(&window, true); + let show_result = window.show(); log::info!("overlay: show={show_result:?}"); diff --git a/src-tauri/src/ui_bridge/mod.rs b/src-tauri/src/ui_bridge/mod.rs index dfe7219..9092214 100644 --- a/src-tauri/src/ui_bridge/mod.rs +++ b/src-tauri/src/ui_bridge/mod.rs @@ -93,6 +93,47 @@ pub fn delete_task( .inspect_err(|e| log::error!("delete_task({focus_id:?}, {index}): {e}")) } +#[tauri::command] +pub fn rename_focus( + focus_id: String, + title: String, + state: State<'_, CommandsState>, +) -> Result<(), CommandError> { + state + .0 + .rename_focus(&focus_id, &title) + .inspect(|_| log::info!("focus renamed: {focus_id}")) + .inspect_err(|e| log::error!("rename_focus({focus_id:?}): {e}")) +} + +#[tauri::command] +pub fn update_task( + focus_id: String, + index: usize, + text: String, + state: State<'_, CommandsState>, +) -> Result<(), CommandError> { + state + .0 + .update_task(&focus_id, index, &text) + .inspect(|_| log::info!("task {index} updated in {focus_id}")) + .inspect_err(|e| log::error!("update_task({focus_id:?}, {index}): {e}")) +} + +#[tauri::command] +pub fn toggle_task( + focus_id: String, + index: usize, + done: bool, + state: State<'_, CommandsState>, +) -> Result<(), CommandError> { + state + .0 + .toggle_task(&focus_id, index, done) + .inspect(|_| log::info!("task {index} in {focus_id} toggled to {done}")) + .inspect_err(|e| log::error!("toggle_task({focus_id:?}, {index}, {done}): {e}")) +} + #[tauri::command] pub fn get_caps(state: State<'_, CommandsState>) -> Caps { state.0.caps() diff --git a/src/api/fixtureFocusReader.test.ts b/src/api/fixtureFocusReader.test.ts index c010f20..477acfc 100644 --- a/src/api/fixtureFocusReader.test.ts +++ b/src/api/fixtureFocusReader.test.ts @@ -6,7 +6,7 @@ describe("fixtureFocusReader", () => { it("returns the supplied focuses", async () => { const focuses: Focus[] = [ { id: "a", title: "A", description: "", tasks: [] }, - { id: "b", title: "B", description: "", tasks: [{ id: "t1", text: "do" }] }, + { id: "b", title: "B", description: "", tasks: [{ id: "t1", text: "do", done: false }] }, ]; const reader = createFixtureFocusReader(focuses); expect(await reader.list()).toEqual(focuses); diff --git a/src/api/focusWriter.ts b/src/api/focusWriter.ts index a08f68d..e5a3246 100644 --- a/src/api/focusWriter.ts +++ b/src/api/focusWriter.ts @@ -9,8 +9,11 @@ export interface FocusWriter { timer_preset?: TimerPreset | null; }): Promise<{ id: string }>; deleteFocus(focusId: string): Promise; + renameFocus(focusId: string, title: string): Promise; appendTask(focusId: string, text: string): Promise; deleteTask(focusId: string, index: number): Promise; + updateTask(focusId: string, index: number, text: string): Promise; + toggleTask(focusId: string, index: number, done: boolean): Promise; } function logErr(op: string) { @@ -38,5 +41,14 @@ export function createTauriFocusWriter(): FocusWriter { deleteTask(focusId: string, index: number) { return invoke("delete_task", { focusId, index }).catch(logErr("delete_task")); }, + renameFocus(focusId: string, title: string) { + return invoke("rename_focus", { focusId, title }).catch(logErr("rename_focus")); + }, + updateTask(focusId: string, index: number, text: string) { + return invoke("update_task", { focusId, index, text }).catch(logErr("update_task")); + }, + toggleTask(focusId: string, index: number, done: boolean) { + return invoke("toggle_task", { focusId, index, done }).catch(logErr("toggle_task")); + }, }; } diff --git a/src/api/tauriFocusReader.ts b/src/api/tauriFocusReader.ts index fed059d..fc2896b 100644 --- a/src/api/tauriFocusReader.ts +++ b/src/api/tauriFocusReader.ts @@ -8,7 +8,7 @@ interface RustFocus { readonly title: string; readonly description: string; readonly created_at: string; - readonly tasks: readonly { id: string; text: string }[]; + readonly tasks: readonly { id: string; text: string; done?: boolean }[]; } const FOCUSES_CHANGED = "focuses-changed"; @@ -18,7 +18,7 @@ function fromRust(raw: RustFocus): Focus { id: raw.id, title: raw.title, description: raw.description, - tasks: raw.tasks.map((t) => ({ id: t.id, text: t.text })), + tasks: raw.tasks.map((t) => ({ id: t.id, text: t.text, done: t.done ?? false })), }; } diff --git a/src/components/App.test.tsx b/src/components/App.test.tsx index bf0d800..752a419 100644 --- a/src/components/App.test.tsx +++ b/src/components/App.test.tsx @@ -48,8 +48,11 @@ function noopFocusWriter(): FocusWriter { return { createFocus: vi.fn().mockResolvedValue({ id: "any" }), deleteFocus: vi.fn().mockResolvedValue(undefined), + renameFocus: vi.fn().mockResolvedValue(undefined), appendTask: vi.fn().mockResolvedValue(undefined), deleteTask: vi.fn().mockResolvedValue(undefined), + updateTask: vi.fn().mockResolvedValue(undefined), + toggleTask: vi.fn().mockResolvedValue(undefined), }; } diff --git a/src/components/App.tsx b/src/components/App.tsx index 6e46279..ed30dcb 100644 --- a/src/components/App.tsx +++ b/src/components/App.tsx @@ -1,6 +1,7 @@ import { useState } from "react"; import type { FocusWriter } from "../api/focusWriter"; import type { FocusReader } from "../api/focuses"; +import { useConfirmDelete } from "../hooks/useConfirmDelete"; import { useDebugOverlay } from "../hooks/useDebugOverlay"; import { useFocuses } from "../hooks/useFocuses"; import { usePigMovement } from "../hooks/usePigMovement"; @@ -17,6 +18,7 @@ export function App({ focusReader, focusWriter }: AppProps) { const focusState = useFocuses(focusReader); const focuses = focusState.status === "ready" ? focusState.focuses : []; const [selectedId, setSelectedId] = useState(null); + const confirmDelete = useConfirmDelete(); const { pigs, startDrag, moveDrag, endDrag, setDragActive } = usePigMovement(focuses, selectedId); const { screenW, screenH } = useViewport(); const { visible: showDebug, topOffset: debugTopOffset } = useDebugOverlay(); @@ -42,6 +44,38 @@ export function App({ focusReader, focusWriter }: AppProps) { } } + async function handleRenameFocus(focusId: string, title: string) { + try { + await focusWriter.renameFocus(focusId, title); + } catch { + // focusWriter already logs the typed error + } + } + + async function handleUpdateTask(focusId: string, index: number, text: string) { + try { + await focusWriter.updateTask(focusId, index, text); + } catch { + // focusWriter already logs the typed error + } + } + + async function handleToggleTask(focusId: string, index: number, done: boolean) { + try { + await focusWriter.toggleTask(focusId, index, done); + } catch { + // focusWriter already logs the typed error + } + } + + async function handleDeleteFocus(focusId: string) { + try { + await focusWriter.deleteFocus(focusId); + } catch { + // focusWriter already logs the typed error + } + } + return (
{showDebug && ( @@ -85,9 +119,14 @@ export function App({ focusReader, focusWriter }: AppProps) { pigY={selectedPig.y} viewportW={screenW} viewportH={screenH} + confirmDelete={confirmDelete} onClose={() => setSelectedId(null)} onClearTask={handleClearTask} onAddTask={handleAddTask} + onRenameFocus={handleRenameFocus} + onUpdateTask={handleUpdateTask} + onToggleTask={handleToggleTask} + onDeleteFocus={handleDeleteFocus} /> )}
diff --git a/src/components/FocusCard.test.tsx b/src/components/FocusCard.test.tsx index f98da6e..185ec6c 100644 --- a/src/components/FocusCard.test.tsx +++ b/src/components/FocusCard.test.tsx @@ -7,7 +7,7 @@ const focus: Focus = { id: "f1", title: "Customer X bug", description: "", - tasks: [{ id: "t1", text: "ship" }], + tasks: [{ id: "t1", text: "ship", done: false }], }; describe("FocusCard", () => { diff --git a/src/components/FocusList.test.tsx b/src/components/FocusList.test.tsx index 3f0a2a0..3c38bde 100644 --- a/src/components/FocusList.test.tsx +++ b/src/components/FocusList.test.tsx @@ -9,15 +9,15 @@ const sample: Focus[] = [ title: "Customer X bug", description: "", tasks: [ - { id: "t1", text: "ship the fix" }, - { id: "t2", text: "verify on staging" }, + { id: "t1", text: "ship the fix", done: false }, + { id: "t2", text: "verify on staging", done: false }, ], }, { id: "b", title: "API refactor", description: "", - tasks: [{ id: "t3", text: "extract pipeline" }], + tasks: [{ id: "t3", text: "extract pipeline", done: false }], }, ]; diff --git a/src/components/PigDetail.test.tsx b/src/components/PigDetail.test.tsx index 50d8791..dca60ad 100644 --- a/src/components/PigDetail.test.tsx +++ b/src/components/PigDetail.test.tsx @@ -18,9 +18,14 @@ function renderDetail(overrides?: Partial pigY: 100, viewportW: 1920, viewportH: 1080, + confirmDelete: true, onClose: vi.fn(), onClearTask: vi.fn(), onAddTask: vi.fn(), + onRenameFocus: vi.fn(), + onUpdateTask: vi.fn(), + onToggleTask: vi.fn(), + onDeleteFocus: vi.fn(), ...overrides, }; render(); @@ -60,3 +65,91 @@ describe("PigDetail add-task input", () => { expect(onClose).toHaveBeenCalled(); }); }); + +describe("PigDetail title editing", () => { + it("renders title as editable input", () => { + renderDetail(); + expect(screen.getByLabelText("focus title")).toHaveValue("Ship it"); + }); + + it("commits new title on blur", async () => { + const { onRenameFocus } = renderDetail(); + const input = screen.getByLabelText("focus title"); + await userEvent.clear(input); + await userEvent.type(input, "New Title"); + input.blur(); + expect(onRenameFocus).toHaveBeenCalledWith("pig-a", "New Title"); + }); + + it("empty title reverts and shows error", async () => { + const { onRenameFocus } = renderDetail(); + const input = screen.getByLabelText("focus title") as HTMLInputElement; + await userEvent.clear(input); + await userEvent.tab(); + expect(onRenameFocus).not.toHaveBeenCalled(); + expect(await screen.findByText("Title cannot be empty")).toBeInTheDocument(); + }); +}); + +describe("PigDetail task editing", () => { + const focusWithTasks: Focus = { + id: "pig-a", + title: "Ship it", + description: "", + tasks: [ + { id: "t1", text: "alpha", done: false }, + { id: "t2", text: "beta", done: true }, + ], + }; + + it("renders each task as an editable input", () => { + renderDetail({ focus: focusWithTasks }); + expect(screen.getByLabelText("task text: alpha")).toHaveValue("alpha"); + expect(screen.getByLabelText("task text: beta")).toHaveValue("beta"); + }); + + it("commits new task text on Enter", async () => { + const { onUpdateTask } = renderDetail({ focus: focusWithTasks }); + const input = screen.getByLabelText("task text: alpha"); + await userEvent.clear(input); + await userEvent.type(input, "alpha-renamed{Enter}"); + expect(onUpdateTask).toHaveBeenCalledWith("pig-a", 0, "alpha-renamed"); + }); + + it("checkbox toggle calls onToggleTask", async () => { + const { onToggleTask } = renderDetail({ focus: focusWithTasks }); + await userEvent.click(screen.getByLabelText("toggle task: alpha")); + expect(onToggleTask).toHaveBeenCalledWith("pig-a", 0, true); + }); + + it("done task checkbox starts checked", () => { + renderDetail({ focus: focusWithTasks }); + expect(screen.getByLabelText("toggle task: beta")).toBeChecked(); + }); +}); + +describe("PigDetail 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(); + await userEvent.click(screen.getByText("Delete")); + expect(onDeleteFocus).toHaveBeenCalledWith("pig-a"); + }); + + it("with confirmDelete=true Cancel hides confirm without deleting", async () => { + 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(onDeleteFocus).not.toHaveBeenCalled(); + }); + + it("with confirmDelete=false deletes immediately", async () => { + const { onDeleteFocus, onClose } = renderDetail({ confirmDelete: false }); + await userEvent.click(screen.getByLabelText("delete focus Ship it")); + expect(onDeleteFocus).toHaveBeenCalledWith("pig-a"); + expect(onClose).toHaveBeenCalled(); + }); +}); diff --git a/src/components/PigDetail.tsx b/src/components/PigDetail.tsx index ed1cb14..59088f4 100644 --- a/src/components/PigDetail.tsx +++ b/src/components/PigDetail.tsx @@ -8,9 +8,14 @@ export interface PigDetailProps { readonly pigY: number; readonly viewportW: number; readonly viewportH: number; + readonly confirmDelete: boolean; readonly onClose: () => void; readonly onClearTask: (index: number) => void; readonly onAddTask: (text: string) => void; + 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 onDeleteFocus: (focusId: string) => void; } const CARD_W = 340; @@ -22,11 +27,24 @@ export function PigDetail({ pigY, viewportW, viewportH, + confirmDelete, onClose, onClearTask, onAddTask, + onRenameFocus, + onUpdateTask, + onToggleTask, + onDeleteFocus, }: PigDetailProps) { const [taskInput, setTaskInput] = useState(""); + const [titleDraft, setTitleDraft] = useState(focus.title); + const [titleError, setTitleError] = useState(false); + const [confirmingDelete, setConfirmingDelete] = useState(false); + + useEffect(() => { + setTitleDraft(focus.title); + setTitleError(false); + }, [focus.title]); useEffect(() => { const handler = (e: KeyboardEvent) => { @@ -40,6 +58,34 @@ export function PigDetail({ const x = Math.min(rawX, viewportW - CARD_W - 16); const y = Math.max(16, Math.min(pigY, viewportH - 200)); + function commitTitle() { + const trimmed = titleDraft.trim(); + if (trimmed === "") { + setTitleDraft(focus.title); + setTitleError(true); + return; + } + setTitleError(false); + if (trimmed !== focus.title) { + onRenameFocus(focus.id, trimmed); + } + } + + function handleDeleteClick() { + if (confirmDelete) { + setConfirmingDelete(true); + } else { + onDeleteFocus(focus.id); + onClose(); + } + } + + function handleConfirmDelete() { + setConfirmingDelete(false); + onDeleteFocus(focus.id); + onClose(); + } + return ( <>
-

{focus.title}

+ {confirmingDelete ? ( +
+ Delete "{focus.title}"? + + +
+ ) : ( +
+ { + setTitleDraft(e.target.value); + if (titleError) setTitleError(false); + }} + onBlur={commitTitle} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } else if (e.key === "Escape") { + setTitleDraft(focus.title); + setTitleError(false); + e.currentTarget.blur(); + } + }} + /> + +
+ )} + {titleError && ( +

+ Title cannot be empty +

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

No tasks yet.

) : (
    {focus.tasks.map((task, index) => ( -
  • - {task.text} - -
  • + ))}
)} @@ -87,3 +188,71 @@ export function PigDetail({ ); } + +interface TaskEditorProps { + readonly focusId: string; + readonly index: number; + readonly task: Focus["tasks"][number]; + readonly onUpdateTask: (focusId: string, index: number, text: string) => void; + readonly onToggleTask: (focusId: string, index: number, done: boolean) => void; + readonly onClearTask: (index: number) => void; +} + +function TaskEditor({ + focusId, + index, + task, + onUpdateTask, + onToggleTask, + onClearTask, +}: TaskEditorProps) { + const [draft, setDraft] = useState(task.text); + + useEffect(() => { + setDraft(task.text); + }, [task.text]); + + function commit() { + const trimmed = draft.trim(); + if (trimmed === "" || trimmed === task.text) { + setDraft(task.text); + return; + } + onUpdateTask(focusId, index, trimmed); + } + + return ( +
  • + onToggleTask(focusId, index, e.target.checked)} + /> + setDraft(e.target.value)} + onBlur={commit} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.currentTarget.blur(); + } else if (e.key === "Escape") { + setDraft(task.text); + e.currentTarget.blur(); + } + }} + /> + +
  • + ); +} diff --git a/src/hooks/useConfirmDelete.ts b/src/hooks/useConfirmDelete.ts new file mode 100644 index 0000000..1ba1ed1 --- /dev/null +++ b/src/hooks/useConfirmDelete.ts @@ -0,0 +1,14 @@ +import { useEffect, useState } from "react"; +import { getSettings } from "../api/settings"; + +export function useConfirmDelete(initial = true): boolean { + const [confirmDelete, setConfirmDelete] = useState(initial); + + useEffect(() => { + getSettings() + .then((s) => setConfirmDelete(s?.widget?.confirm_delete ?? initial)) + .catch(console.error); + }, [initial]); + + return confirmDelete; +} diff --git a/src/lib/capState.test.ts b/src/lib/capState.test.ts index 178a949..a62a26c 100644 --- a/src/lib/capState.test.ts +++ b/src/lib/capState.test.ts @@ -9,7 +9,11 @@ function makeFocus(id: string, taskCount: number): Focus { id, title: id, description: "", - tasks: Array.from({ length: taskCount }, (_, i) => ({ id: `${id}:${i}`, text: `t${i}` })), + tasks: Array.from({ length: taskCount }, (_, i) => ({ + id: `${id}:${i}`, + text: `t${i}`, + done: false, + })), }; } diff --git a/src/styles.css b/src/styles.css index dbd1f03..d7e1844 100644 --- a/src/styles.css +++ b/src/styles.css @@ -455,10 +455,83 @@ body, z-index: 10; } -.pig-detail-title { +.pig-detail-header { + display: flex; + align-items: center; + gap: 6px; margin: 0 0 8px; +} + +.pig-detail-title-input { + flex: 1; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: inherit; font-size: 13px; font-weight: 600; + font-family: inherit; + padding: 2px 4px; + outline: none; + min-width: 0; +} + +.pig-detail-title-input:hover { + border-color: rgba(255, 255, 255, 0.08); +} + +.pig-detail-title-input:focus { + border-color: rgba(255, 255, 255, 0.3); + background: rgba(255, 255, 255, 0.04); +} + +.pig-detail-title-error { + margin: 0 0 6px; + color: #ff8a80; + font-size: 11px; +} + +.pig-detail-delete { + background: transparent; + border: none; + color: inherit; + cursor: pointer; + font-size: 13px; + opacity: 0.5; + padding: 2px 4px; + flex-shrink: 0; +} + +.pig-detail-delete:hover { + opacity: 1; +} + +.pig-detail-delete-confirm { + display: flex; + align-items: center; + gap: 6px; + margin: 0 0 8px; + font-size: 12px; +} + +.pig-detail-delete-confirm span { + flex: 1; + min-width: 0; +} + +.pig-detail-delete-confirm button { + background: transparent; + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 4px; + color: inherit; + cursor: pointer; + padding: 2px 8px; + font-size: 11px; +} + +.pig-detail-delete-confirm-yes { + border-color: rgba(255, 138, 128, 0.6) !important; + color: #ff8a80 !important; } .pig-detail-empty { @@ -491,6 +564,39 @@ body, flex: 1; } +.pig-detail-task-check { + flex-shrink: 0; + margin: 0; + cursor: pointer; +} + +.pig-detail-task-input { + flex: 1; + background: transparent; + border: 1px solid transparent; + border-radius: 4px; + color: inherit; + font-size: 12px; + font-family: inherit; + padding: 2px 4px; + outline: none; + min-width: 0; +} + +.pig-detail-task-input:hover { + border-color: rgba(255, 255, 255, 0.08); +} + +.pig-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 { + text-decoration: line-through; + opacity: 0.5; +} + .pig-detail-task-clear { background: transparent; border: none; diff --git a/src/types/focus.ts b/src/types/focus.ts index 07c201e..a7ba09c 100644 --- a/src/types/focus.ts +++ b/src/types/focus.ts @@ -1,6 +1,7 @@ export interface Task { readonly id: string; readonly text: string; + readonly done: boolean; } export interface Focus {