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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions Cargo.lock

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

95 changes: 94 additions & 1 deletion crates/commands/src/focus.rs
Original file line number Diff line number Diff line change
@@ -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};

Expand Down Expand Up @@ -51,6 +53,24 @@ impl Commands {
Ok(CreatedFocus { id: slug })
}

pub fn duplicate_focus(&self, focus_id: &str) -> Result<CreatedFocus, CommandError> {
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(())
Expand Down Expand Up @@ -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::<std::collections::HashSet<_>>();
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::*;
Expand Down Expand Up @@ -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);
Expand Down
14 changes: 4 additions & 10 deletions crates/storage/src/focus_store.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(())
}

Expand Down Expand Up @@ -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]
Expand Down
2 changes: 1 addition & 1 deletion issues/done/052-task-timers-and-clock-dropdown-editing.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<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.
Expand Down
3 changes: 3 additions & 0 deletions src-tauri/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"] }
Expand Down
Binary file modified src-tauri/icons/128x128.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/128x128@2x.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/32x32.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified src-tauri/icons/icon.icns
Binary file not shown.
Binary file modified src-tauri/icons/icon.ico
Binary file not shown.
Binary file modified src-tauri/icons/icon.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
3 changes: 3 additions & 0 deletions src-tauri/src/app/mod.rs
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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()));
Expand Down
60 changes: 58 additions & 2 deletions src-tauri/src/app/paths.rs
Original file line number Diff line number Diff line change
@@ -1,9 +1,28 @@
use std::ffi::OsString;
use std::io;
use std::path::PathBuf;

pub fn data_root() -> io::Result<PathBuf> {
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<OsString>) -> io::Result<PathBuf> {
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"))
}

Expand All @@ -26,3 +45,40 @@ pub fn decisions_file() -> io::Result<PathBuf> {
pub fn settings_file() -> io::Result<PathBuf> {
Ok(data_root()?.join("settings.yaml"))
}

#[cfg(test)]
mod tests {
use super::*;
use std::collections::HashMap;

fn root(vars: &[(&str, &str)]) -> io::Result<PathBuf> {
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")
);
}
}
93 changes: 93 additions & 0 deletions src-tauri/src/app/seed.rs
Original file line number Diff line number Diff line change
@@ -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<dyn std::error::Error>> {
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());
}
}
12 changes: 12 additions & 0 deletions src-tauri/src/ui_bridge/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<CreatedFocus, CommandError> {
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
Expand Down
1 change: 1 addition & 0 deletions src/api/fixtureFocusWriter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
Loading
Loading