Slim task frontmatter: remove timestamps, add version counter + self-healing dedup

Remove created_at/updated_at from Task struct and frontmatter. Add a
version counter (u64) that increments on every write, defaults to 1 for
old files. list_tasks now groups by UUID and auto-deletes stale
duplicates (keeping highest version), preventing sync-induced dupes from
surfacing in the UI. has_time and parent are omitted from frontmatter
when false/null.

Update CLI, Tauri frontend types, and Svelte components to match.
This commit is contained in:
Tristan Michael 2026-04-05 19:22:01 -07:00
parent eaab66609c
commit 5e33416b22
7 changed files with 113 additions and 29 deletions

View file

@ -18,7 +18,7 @@
if (!title.trim()) return; if (!title.trim()) return;
const created = await app.createTask(title.trim(), description.trim() || undefined); const created = await app.createTask(title.trim(), description.trim() || undefined);
if (dueDate && created) { if (dueDate && created) {
await app.updateTask({ ...created, due_date: dueDate, has_time: dueDateHasTime, updated_at: new Date().toISOString() }); await app.updateTask({ ...created, due_date: dueDate, has_time: dueDateHasTime });
} }
title = ""; title = "";
description = ""; description = "";

View file

@ -35,7 +35,7 @@
function debouncedSave(fields: Partial<Task>) { function debouncedSave(fields: Partial<Task>) {
clearTimeout(saveTimer); clearTimeout(saveTimer);
saveTimer = setTimeout(() => { saveTimer = setTimeout(() => {
app.updateTask({ ...task, ...fields, updated_at: new Date().toISOString() }); app.updateTask({ ...task, ...fields });
}, 400); }, 400);
} }
@ -48,7 +48,7 @@
} }
function handleDateChange(iso: string | null, hasTime: boolean = false) { function handleDateChange(iso: string | null, hasTime: boolean = false) {
app.updateTask({ ...task, due_date: iso, has_time: hasTime, updated_at: new Date().toISOString() }); app.updateTask({ ...task, due_date: iso, has_time: hasTime });
} }
async function handleToggle() { async function handleToggle() {

View file

@ -5,8 +5,7 @@ export interface Task {
status: "backlog" | "completed"; status: "backlog" | "completed";
due_date: string | null; due_date: string | null;
has_time: boolean; has_time: boolean;
created_at: string; version: number;
updated_at: string;
parent_id: string | null; parent_id: string | null;
} }

View file

@ -192,7 +192,6 @@ pub fn edit(task_id_str: String, workspace: Option<String>) -> Result<()> {
let mut updated_task = task.clone(); let mut updated_task = task.clone();
updated_task.title = title; updated_task.title = title;
updated_task.description = description; updated_task.description = description;
updated_task.updated_at = Utc::now();
repo.update_task(list_id, updated_task.clone()) repo.update_task(list_id, updated_task.clone())
.context("Failed to update task")?; .context("Failed to update task")?;

View file

@ -19,15 +19,13 @@ pub struct Task {
pub due_date: Option<DateTime<Utc>>, pub due_date: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default)]
pub has_time: bool, pub has_time: bool,
pub created_at: DateTime<Utc>, pub version: u64,
pub updated_at: DateTime<Utc>,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub parent_id: Option<Uuid>, pub parent_id: Option<Uuid>,
} }
impl Task { impl Task {
pub fn new(title: String) -> Self { pub fn new(title: String) -> Self {
let now = Utc::now();
Self { Self {
id: Uuid::new_v4(), id: Uuid::new_v4(),
title, title,
@ -35,8 +33,7 @@ impl Task {
status: TaskStatus::Backlog, status: TaskStatus::Backlog,
due_date: None, due_date: None,
has_time: false, has_time: false,
created_at: now, version: 0,
updated_at: now,
parent_id: None, parent_id: None,
} }
} }
@ -58,12 +55,10 @@ impl Task {
pub fn complete(&mut self) { pub fn complete(&mut self) {
self.status = TaskStatus::Completed; self.status = TaskStatus::Completed;
self.updated_at = Utc::now();
} }
pub fn uncomplete(&mut self) { pub fn uncomplete(&mut self) {
self.status = TaskStatus::Backlog; self.status = TaskStatus::Backlog;
self.updated_at = Utc::now();
} }
} }

View file

@ -24,8 +24,9 @@ impl TaskRepository {
} }
// Task operations // Task operations
pub fn create_task(&mut self, list_id: Uuid, task: Task) -> Result<Task> { pub fn create_task(&mut self, list_id: Uuid, mut task: Task) -> Result<Task> {
self.storage.write_task(list_id, &task)?; self.storage.write_task(list_id, &task)?;
task.version += 1;
Ok(task) Ok(task)
} }

View file

@ -49,6 +49,9 @@ impl ListMetadata {
} }
} }
fn is_false(v: &bool) -> bool { !v }
fn default_version() -> u64 { 1 }
/// Frontmatter for task markdown files /// Frontmatter for task markdown files
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskFrontmatter { pub struct TaskFrontmatter {
@ -56,10 +59,10 @@ pub struct TaskFrontmatter {
pub status: TaskStatus, pub status: TaskStatus,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub due: Option<DateTime<Utc>>, pub due: Option<DateTime<Utc>>,
#[serde(default)] #[serde(default, skip_serializing_if = "is_false")]
pub has_time: bool, pub has_time: bool,
pub created: DateTime<Utc>, #[serde(default = "default_version")]
pub updated: DateTime<Utc>, pub version: u64,
#[serde(skip_serializing_if = "Option::is_none")] #[serde(skip_serializing_if = "Option::is_none")]
pub parent: Option<Uuid>, pub parent: Option<Uuid>,
} }
@ -71,8 +74,7 @@ impl From<&Task> for TaskFrontmatter {
status: task.status, status: task.status,
due: task.due_date, due: task.due_date,
has_time: task.has_time, has_time: task.has_time,
created: task.created_at, version: task.version,
updated: task.updated_at,
parent: task.parent_id, parent: task.parent_id,
} }
} }
@ -219,7 +221,8 @@ impl FileSystemStorage {
} }
fn write_markdown_with_frontmatter(&self, task: &Task) -> Result<String> { fn write_markdown_with_frontmatter(&self, task: &Task) -> Result<String> {
let frontmatter = TaskFrontmatter::from(task); let mut frontmatter = TaskFrontmatter::from(task);
frontmatter.version = task.version + 1;
let yaml = serde_yaml::to_string(&frontmatter)?; let yaml = serde_yaml::to_string(&frontmatter)?;
let mut content = String::new(); let mut content = String::new();
@ -277,8 +280,7 @@ impl Storage for FileSystemStorage {
status: frontmatter.status, status: frontmatter.status,
due_date: frontmatter.due, due_date: frontmatter.due,
has_time: frontmatter.has_time, has_time: frontmatter.has_time,
created_at: frontmatter.created, version: frontmatter.version,
updated_at: frontmatter.updated,
parent_id: frontmatter.parent, parent_id: frontmatter.parent,
}); });
} }
@ -343,7 +345,7 @@ impl Storage for FileSystemStorage {
let list_dir = self.list_dir_path(list_id)?; let list_dir = self.list_dir_path(list_id)?;
let list_metadata = self.read_list_metadata(list_id)?; let list_metadata = self.read_list_metadata(list_id)?;
let mut tasks = Vec::new(); let mut file_tasks: Vec<(PathBuf, Task)> = Vec::new();
let entries = fs::read_dir(&list_dir)?; let entries = fs::read_dir(&list_dir)?;
for entry in entries { for entry in entries {
@ -366,15 +368,32 @@ impl Storage for FileSystemStorage {
status: frontmatter.status, status: frontmatter.status,
due_date: frontmatter.due, due_date: frontmatter.due,
has_time: frontmatter.has_time, has_time: frontmatter.has_time,
created_at: frontmatter.created, version: frontmatter.version,
updated_at: frontmatter.updated,
parent_id: frontmatter.parent, parent_id: frontmatter.parent,
}; };
tasks.push(task); file_tasks.push((path, task));
} }
} }
// Self-healing dedup: group by UUID, keep highest version, delete stale files
let mut by_id: HashMap<Uuid, Vec<(PathBuf, Task)>> = HashMap::new();
for entry in file_tasks {
by_id.entry(entry.1.id).or_default().push(entry);
}
let mut tasks = Vec::new();
for (_id, mut entries) in by_id {
if entries.len() > 1 {
entries.sort_by(|a, b| b.1.version.cmp(&a.1.version));
for (stale_path, _) in entries.drain(1..) {
let _ = fs::remove_file(&stale_path);
}
}
let (_, task) = entries.into_iter().next().unwrap();
tasks.push(task);
}
// Sort by task_order // Sort by task_order
let order_map: HashMap<Uuid, usize> = list_metadata.task_order let order_map: HashMap<Uuid, usize> = list_metadata.task_order
.iter() .iter()
@ -557,10 +576,11 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir); let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---\n\nSome description"; let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\nversion: 3\n---\n\nSome description";
let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap(); let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); assert_eq!(fm.id.to_string(), "550e8400-e29b-41d4-a716-446655440000");
assert_eq!(fm.status, TaskStatus::Backlog); assert_eq!(fm.status, TaskStatus::Backlog);
assert_eq!(fm.version, 3);
assert_eq!(desc, "Some description"); assert_eq!(desc, "Some description");
} }
@ -569,7 +589,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir); let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: completed\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\n---"; let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: completed\nversion: 1\n---";
let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap(); let (fm, desc) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.status, TaskStatus::Completed); assert_eq!(fm.status, TaskStatus::Completed);
assert!(desc.is_empty()); assert!(desc.is_empty());
@ -621,7 +641,7 @@ mod tests {
let temp_dir = TempDir::new().unwrap(); let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir); let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ndue: 2026-06-15T12:00:00Z\ncreated: 2026-01-01T00:00:00Z\nupdated: 2026-01-01T00:00:00Z\nparent: 660e8400-e29b-41d4-a716-446655440001\n---\n\nNotes"; let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\ndue: 2026-06-15T12:00:00Z\nversion: 2\nparent: 660e8400-e29b-41d4-a716-446655440001\n---\n\nNotes";
let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap(); let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert!(fm.due.is_some()); assert!(fm.due.is_some());
assert!(fm.parent.is_some()); assert!(fm.parent.is_some());
@ -832,4 +852,74 @@ mod tests {
let tasks = storage.list_tasks(list.id).unwrap(); let tasks = storage.list_tasks(list.id).unwrap();
assert!(tasks.is_empty()); assert!(tasks.is_empty());
} }
#[test]
fn test_missing_version_defaults_to_1() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\n---\n\nOld task";
let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert_eq!(fm.version, 1);
}
#[test]
fn test_missing_has_time_defaults_to_false() {
let temp_dir = TempDir::new().unwrap();
let storage = init_storage(&temp_dir);
let content = "---\nid: 550e8400-e29b-41d4-a716-446655440000\nstatus: backlog\nversion: 1\n---\n";
let (fm, _) = storage.parse_markdown_with_frontmatter(content).unwrap();
assert!(!fm.has_time);
}
#[test]
fn test_version_increments_on_write() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Tasks".to_string()).unwrap();
let task = Task::new("Versioned".to_string());
assert_eq!(task.version, 0);
storage.write_task(list.id, &task).unwrap();
let read_back = storage.read_task(list.id, task.id).unwrap();
assert_eq!(read_back.version, 1);
// Write again — version should increment again
storage.write_task(list.id, &read_back).unwrap();
let read_again = storage.read_task(list.id, task.id).unwrap();
assert_eq!(read_again.version, 2);
}
#[test]
fn test_dedup_keeps_highest_version() {
let temp_dir = TempDir::new().unwrap();
let mut storage = init_storage(&temp_dir);
let list = storage.create_list("Dedup".to_string()).unwrap();
let task = Task::new("Original".to_string());
let task_id = task.id;
storage.write_task(list.id, &task).unwrap();
// Simulate a sync duplicate: manually write a second file with the same UUID but lower version
let list_dir = storage.list_dir_path(list.id).unwrap();
let stale_content = format!(
"---\nid: {}\nstatus: backlog\nversion: 1\n---\n\nStale copy",
task_id
);
let stale_path = list_dir.join("Original_old.md");
fs::write(&stale_path, &stale_content).unwrap();
let tasks = storage.list_tasks(list.id).unwrap();
assert_eq!(tasks.len(), 1);
assert_eq!(tasks[0].id, task_id);
// The winner should be the one written by write_task (version 1), not the manually created stale copy (also version 1 but alphabetically second)
// Actually both are version 1, so the first sorted wins — but the stale file should be cleaned up
// Let's verify only one .md file remains
let md_count = fs::read_dir(&list_dir).unwrap()
.filter(|e| e.as_ref().unwrap().path().extension().and_then(|s| s.to_str()) == Some("md"))
.count();
assert_eq!(md_count, 1);
}
} }