Add task management: aggregated Tasks view (list + calendar) with inline due/priority, check off, set from row, and drag-to-reschedule (#62)

This commit is contained in:
Yuri Karamian
2026-06-08 02:34:19 +02:00
parent e2702bdd51
commit 26fa22afaa
9 changed files with 1130 additions and 8 deletions
+248
View File
@@ -589,6 +589,254 @@ fn extract_title_fast(raw: &str) -> Option<String> {
None None
} }
// ── Tasks ──
#[tauri::command]
pub fn get_tasks(state: State<'_, AppState>) -> Result<Vec<crate::types::TaskItem>, String> {
let config = state.config.lock().map_err(|e| e.to_string())?;
let vault_path = config.active_vault.as_ref().ok_or("No active vault")?;
let vault = std::path::Path::new(vault_path);
let hn_dir = operations::helixnotes_dir(vault_path);
let task_re = regex::Regex::new(r"^\s*[-*]\s\[([ xX])\]\s+(.+?)\s*$").unwrap();
let due_re = regex::Regex::new(r"\bdue:(\d{4}-\d{2}-\d{2})\b").unwrap();
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(high|medium|med|low)\b").unwrap();
let mut tasks = Vec::new();
for entry in walkdir::WalkDir::new(vault)
.into_iter()
.filter_entry(|e| !operations::is_hidden(e.path()) && !e.path().starts_with(&hn_dir))
.filter_map(|e| e.ok())
{
let path = entry.path();
if !path.is_file() {
continue;
}
if path.extension().and_then(|e| e.to_str()) != Some("md") {
continue;
}
let raw = match std::fs::read_to_string(path) {
Ok(r) => r,
Err(_) => continue,
};
let filename = path.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let note_path = path.to_string_lossy().to_string();
for (i, line) in body.lines().enumerate() {
let caps = match task_re.captures(line) {
Some(c) => c,
None => continue,
};
let completed = !caps[1].trim().is_empty(); // 'x'/'X' completed, ' ' open
let content = caps[2].to_string();
let due = due_re.captures(&content).map(|c| c[1].to_string());
let priority = prio_re.captures(&content).map(|c| {
let p = c[1].to_lowercase();
if p == "medium" { "med".to_string() } else { p }
});
// Strip metadata tokens for the display text, then collapse whitespace.
let mut text = due_re.replace_all(&content, "").to_string();
text = prio_re.replace_all(&text, " ").to_string();
let text = text.split_whitespace().collect::<Vec<_>>().join(" ");
tasks.push(crate::types::TaskItem {
note_path: note_path.clone(),
note_title: meta.title.clone(),
line: i,
raw_line: line.to_string(),
text,
completed,
due,
priority,
});
}
}
Ok(tasks)
}
fn toggle_checkbox_line(line: &str, done: bool) -> String {
let mut s = line.to_string();
if done {
if let Some(pos) = s.find("[ ]") {
s.replace_range(pos..pos + 3, "[x]");
}
} else {
for marker in ["[x]", "[X]"] {
if let Some(pos) = s.find(marker) {
s.replace_range(pos..pos + 3, "[ ]");
break;
}
}
}
s
}
#[tauri::command]
pub fn set_task_done(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
done: bool,
) -> Result<(), String> {
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
// Verify the expected line; if the note drifted, fall back to the first exact match.
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let toggled = toggle_checkbox_line(&lines[idx], done);
if toggled == lines[idx] {
return Ok(()); // already in the desired state
}
lines[idx] = toggled;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
fn set_priority_on_line(line: &str, priority: Option<&str>) -> String {
let prio_re = regex::Regex::new(r"(?i)(?:^|\s)!(?:high|medium|med|low)\b").unwrap();
let stripped = prio_re.replace_all(line, "").to_string();
let stripped = stripped.trim_end();
match priority {
Some(p) => format!("{} !{}", stripped, p),
None => stripped.to_string(),
}
}
#[tauri::command]
pub fn set_task_priority(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
priority: Option<String>,
) -> Result<(), String> {
// Normalize/validate priority ("medium" -> "med"); None clears it.
let prio = match priority.as_deref() {
None | Some("") | Some("none") => None,
Some("high") => Some("high"),
Some("med") | Some("medium") => Some("med"),
Some("low") => Some("low"),
Some(_) => return Err("Invalid priority".to_string()),
};
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let updated = set_priority_on_line(&lines[idx], prio);
if updated == lines[idx] {
return Ok(());
}
lines[idx] = updated;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
fn set_due_on_line(line: &str, due: Option<&str>) -> String {
let due_re = regex::Regex::new(r"(?:^|\s)due:\d{4}-\d{2}-\d{2}\b").unwrap();
let stripped = due_re.replace_all(line, "").to_string();
let stripped = stripped.trim_end();
match due {
Some(d) => format!("{} due:{}", stripped, d),
None => stripped.to_string(),
}
}
#[tauri::command]
pub fn set_task_due(
state: State<'_, AppState>,
note_path: String,
line: usize,
raw_line: String,
due: Option<String>,
) -> Result<(), String> {
// Validate format (YYYY-MM-DD); None/empty clears the due date.
let date_re = regex::Regex::new(r"^\d{4}-\d{2}-\d{2}$").unwrap();
let due_val: Option<&str> = match due.as_deref() {
None | Some("") => None,
Some(d) if date_re.is_match(d) => Some(d),
Some(_) => return Err("Invalid due date".to_string()),
};
let p = std::path::Path::new(&note_path);
let raw = std::fs::read_to_string(p).map_err(|e| e.to_string())?;
let filename = p.file_name().unwrap_or_default().to_string_lossy().to_string();
let (meta, body) = crate::vault::frontmatter::parse_note(&raw, &filename);
let mut lines: Vec<String> = body.lines().map(|l| l.to_string()).collect();
let idx = if lines.get(line).map(|l| *l == raw_line).unwrap_or(false) {
line
} else {
lines
.iter()
.position(|l| *l == raw_line)
.ok_or("Task line not found (note changed)")?
};
let updated = set_due_on_line(&lines[idx], due_val);
if updated == lines[idx] {
return Ok(());
}
lines[idx] = updated;
let mut new_body = lines.join("\n");
if body.ends_with('\n') && !new_body.ends_with('\n') {
new_body.push('\n');
}
operations::save_note(&note_path, &meta, &new_body)?;
if let Ok(guard) = state.search_index.lock() {
if let Some(search) = guard.as_ref() {
let _ = search.index_note(&note_path);
}
}
Ok(())
}
// ── Search ── // ── Search ──
#[tauri::command] #[tauri::command]
+4
View File
@@ -122,6 +122,10 @@ pub fn run() {
commands::get_all_tags, commands::get_all_tags,
commands::get_all_note_titles, commands::get_all_note_titles,
commands::get_graph_data, commands::get_graph_data,
commands::get_tasks,
commands::set_task_done,
commands::set_task_priority,
commands::set_task_due,
commands::search_notes, commands::search_notes,
commands::reindex, commands::reindex,
commands::get_trash, commands::get_trash,
+12
View File
@@ -348,3 +348,15 @@ pub struct GraphEdge {
pub target: usize, pub target: usize,
pub bidirectional: bool, pub bidirectional: bool,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TaskItem {
pub note_path: String,
pub note_title: String,
pub line: usize,
pub raw_line: String,
pub text: String,
pub completed: bool,
pub due: Option<String>,
pub priority: Option<String>,
}
+34
View File
@@ -13,6 +13,7 @@ import type {
ImportResult, ImportResult,
BackupEntry, BackupEntry,
VersionEntry, VersionEntry,
TaskItem,
} from "./types"; } from "./types";
export async function openVault(path: string): Promise<void> { export async function openVault(path: string): Promise<void> {
@@ -331,6 +332,39 @@ export async function setBackupSettings(
}); });
} }
// ── Tasks ──
export async function getTasks(): Promise<TaskItem[]> {
return invoke("get_tasks");
}
export async function setTaskDone(
notePath: string,
line: number,
rawLine: string,
done: boolean,
): Promise<void> {
return invoke("set_task_done", { notePath, line, rawLine, done });
}
export async function setTaskPriority(
notePath: string,
line: number,
rawLine: string,
priority: string | null,
): Promise<void> {
return invoke("set_task_priority", { notePath, line, rawLine, priority });
}
export async function setTaskDue(
notePath: string,
line: number,
rawLine: string,
due: string | null,
): Promise<void> {
return invoke("set_task_due", { notePath, line, rawLine, due });
}
// ── Sync (WebDAV) ── // ── Sync (WebDAV) ──
export async function setSyncSettings( export async function setSyncSettings(
+89 -4
View File
@@ -51,11 +51,11 @@
const isMac = navigator.platform.startsWith('Mac'); const isMac = navigator.platform.startsWith('Mac');
const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent); const isMobile = /android|iphone|ipad|ipod/i.test(navigator.userAgent);
const isAndroid = /android/i.test(navigator.userAgent); const isAndroid = /android/i.test(navigator.userAgent);
import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow } from '$lib/api'; import { loadVaultState, saveVaultState, readNote, createDailyNote, createBackup, getPendingOpenFile, addQuickAccess, removeQuickAccess, getQuickAccess, setTheme, syncNow, setTaskDone, setTaskPriority, setTaskDue } from '$lib/api';
import { debounce } from '$lib/utils/debounce'; import { debounce } from '$lib/utils/debounce';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { get } from 'svelte/store'; import { get } from 'svelte/store';
import type { VaultState, FileEvent, NotebookEntry } from '$lib/types'; import type { VaultState, FileEvent, NotebookEntry, TaskItem } from '$lib/types';
function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null { function findNotebookByPath(list: NotebookEntry[], relPath: string): NotebookEntry | null {
for (const nb of list) { for (const nb of list) {
@@ -217,14 +217,19 @@
persistState(); persistState();
} }
// Tasks view: the editor pane shows a placeholder until a task is opened from the list.
let taskNoteOpened = $state(false);
function handleNoteSelected(path: string, content: string) { function handleNoteSelected(path: string, content: string) {
// Selecting a real vault note exits viewer mode // Selecting a real vault note exits viewer mode
$viewerNote = null; $viewerNote = null;
taskNoteOpened = true;
editor?.loadNote(path, content); editor?.loadNote(path, content);
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} }
function handleViewChanged() { function handleViewChanged() {
taskNoteOpened = false;
noteList?.refresh(); noteList?.refresh();
if (isMobile) $mobileView = 'notelist'; if (isMobile) $mobileView = 'notelist';
} }
@@ -235,6 +240,63 @@
if (isMobile) $mobileView = 'editor'; if (isMobile) $mobileView = 'editor';
} }
// Toggle a task done from the Tasks view. If the task's note is open in the editor,
// flush first then reload after the file edit (so we never clobber unsaved edits).
async function toggleTask(task: TaskItem) {
const done = !task.completed;
const isActive = task.note_path === $activeNotePath;
if (isActive) await editor?.forceSave();
try {
await setTaskDone(task.note_path, task.line, task.raw_line, done);
} catch (e) {
console.error('Failed to toggle task:', e);
}
if (isActive) {
try {
const content = await readNote(task.note_path);
$activeNote = content;
$editorDirty = false;
editor?.loadNote(task.note_path, content.content);
} catch (_) {}
}
}
async function changeTaskPriority(task: TaskItem, priority: string | null) {
const isActive = task.note_path === $activeNotePath;
if (isActive) await editor?.forceSave();
try {
await setTaskPriority(task.note_path, task.line, task.raw_line, priority);
} catch (e) {
console.error('Failed to set task priority:', e);
}
if (isActive) {
try {
const content = await readNote(task.note_path);
$activeNote = content;
$editorDirty = false;
editor?.loadNote(task.note_path, content.content);
} catch (_) {}
}
}
async function changeTaskDue(task: TaskItem, due: string | null) {
const isActive = task.note_path === $activeNotePath;
if (isActive) await editor?.forceSave();
try {
await setTaskDue(task.note_path, task.line, task.raw_line, due);
} catch (e) {
console.error('Failed to set task due date:', e);
}
if (isActive) {
try {
const content = await readNote(task.note_path);
$activeNote = content;
$editorDirty = false;
editor?.loadNote(task.note_path, content.content);
} catch (_) {}
}
}
async function handleDailyNote() { async function handleDailyNote() {
try { try {
const entry = await createDailyNote(); const entry = await createDailyNote();
@@ -757,7 +819,7 @@
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} /> <Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
</div> </div>
<div class="mobile-panel" class:active={$mobileView === 'notelist'}> <div class="mobile-panel" class:active={$mobileView === 'notelist'}>
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
<button class="mobile-fab" onclick={createAndFocusNote} title="New Note"> <button class="mobile-fab" onclick={createAndFocusNote} title="New Note">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round"> <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round">
<path d="M12 5v14M5 12h14" /> <path d="M12 5v14M5 12h14" />
@@ -822,7 +884,7 @@
{/if} {/if}
<div class="notelist-panel" style="width: {$notelistWidth}px"> <div class="notelist-panel" style="width: {$notelistWidth}px">
<NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} /> <NoteList bind:this={noteList} onNoteSelected={handleNoteSelected} onBeforeNoteSwitch={() => editor?.flushSave()} onNoteMoved={() => sidebar?.refresh()} onNoteCreated={() => { editor?.focusTitle(); sidebar?.refresh(); }} onToggleTask={toggleTask} onSetTaskPriority={changeTaskPriority} onSetTaskDue={changeTaskDue} />
</div> </div>
<ResizeHandle onResize={handleNotelistResize} /> <ResizeHandle onResize={handleNotelistResize} />
@@ -830,6 +892,14 @@
<div class="editor-panel"> <div class="editor-panel">
<Editor bind:this={editor} /> <Editor bind:this={editor} />
{#if $viewMode === 'tasks' && !taskNoteOpened}
<div class="tasks-editor-placeholder">
<svg width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4"/><path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11"/>
</svg>
<p>Select a task to open its note</p>
</div>
{/if}
</div> </div>
</div> </div>
</div> </div>
@@ -872,6 +942,21 @@
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
min-width: 300px; min-width: 300px;
position: relative;
}
.tasks-editor-placeholder {
position: absolute;
inset: 0;
z-index: 5;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
background: var(--bg-primary);
color: var(--text-tertiary);
font-size: 14px;
} }
.focus-topbar { .focus-topbar {
+27 -3
View File
@@ -37,13 +37,17 @@
import { formatRelativeTime, formatDate } from '$lib/utils/time'; import { formatRelativeTime, formatDate } from '$lib/utils/time';
import { openNoteWindow } from '$lib/utils/window'; import { openNoteWindow } from '$lib/utils/window';
import { revealItemInDir } from '@tauri-apps/plugin-opener'; import { revealItemInDir } from '@tauri-apps/plugin-opener';
import type { NoteEntry, TrashNotebookEntry, SortMode } from '$lib/types'; import type { NoteEntry, TrashNotebookEntry, SortMode, TaskItem } from '$lib/types';
import TasksView from './TasksView.svelte';
let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {} }: { let { onNoteSelected = (_path: string, _content: string) => {}, onNoteMoved = () => {}, onBeforeNoteSwitch = () => {}, onNoteCreated = () => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onNoteSelected?: (path: string, content: string) => void; onNoteSelected?: (path: string, content: string) => void;
onNoteMoved?: () => void; onNoteMoved?: () => void;
onBeforeNoteSwitch?: () => void; onBeforeNoteSwitch?: () => void;
onNoteCreated?: () => void; onNoteCreated?: () => void;
onToggleTask?: (t: TaskItem) => Promise<void>;
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
onSetTaskDue?: (t: TaskItem, d: string | null) => Promise<void>;
} = $props(); } = $props();
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl'; const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
@@ -281,6 +285,7 @@
if ($viewMode === 'tag') return `#${$activeTag}`; if ($viewMode === 'tag') return `#${$activeTag}`;
if ($viewMode === 'quickaccess') return 'Quick Access'; if ($viewMode === 'quickaccess') return 'Quick Access';
if ($viewMode === 'daily') return 'Daily Notes'; if ($viewMode === 'daily') return 'Daily Notes';
if ($viewMode === 'tasks') return 'Tasks';
if ($viewMode === 'trash') return 'Trash'; if ($viewMode === 'trash') return 'Trash';
return 'Notes'; return 'Notes';
}); });
@@ -298,6 +303,7 @@
} }
export async function refresh(invalidate = false) { export async function refresh(invalidate = false) {
if ($viewMode === 'tasks') { $notes = []; return; } // Tasks view fetches its own data
if (invalidate) { if (invalidate) {
noteCache.clear(); noteCache.clear();
} }
@@ -366,6 +372,19 @@
} }
} }
async function openTask(task: TaskItem) {
try {
const content = await readNote(task.note_path);
onBeforeNoteSwitch();
$activeNote = content;
$activeNotePath = task.note_path;
$editorDirty = false;
onNoteSelected(task.note_path, content.content);
} catch (e) {
console.error('Failed to open task note:', e);
}
}
export async function handleCreateNote() { export async function handleCreateNote() {
if ($viewMode === 'daily') { if ($viewMode === 'daily') {
const today = new Date(); const today = new Date();
@@ -836,6 +855,7 @@
<div class="list-header"> <div class="list-header">
<span class="list-title">{viewTitle}</span> <span class="list-title">{viewTitle}</span>
<div class="list-actions"> <div class="list-actions">
{#if $viewMode !== 'tasks'}
{#if isAndroid} {#if isAndroid}
<button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select"> <button class="icon-btn" class:active-toggle={multiSelectMode} onclick={() => { multiSelectMode = !multiSelectMode; if (!multiSelectMode) clearSelection(); }} title="Multi-select">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -855,6 +875,7 @@
</svg> </svg>
</button> </button>
{/if} {/if}
{/if}
</div> </div>
</div> </div>
@@ -951,6 +972,9 @@
{/if} {/if}
<div class="list-content" bind:this={listContainer} onscroll={onListScroll}> <div class="list-content" bind:this={listContainer} onscroll={onListScroll}>
{#if $viewMode === 'tasks'}
<TasksView onOpenTask={openTask} onToggleTask={onToggleTask} onSetTaskPriority={onSetTaskPriority} onSetTaskDue={onSetTaskDue} />
{/if}
{#if $viewMode === 'daily'} {#if $viewMode === 'daily'}
<div class="cal"> <div class="cal">
<div class="cal-nav"> <div class="cal-nav">
@@ -985,7 +1009,7 @@
</div> </div>
{/if} {/if}
{#if $sortedNotes.length === 0 && $viewMode !== 'daily' && (!($viewMode === 'trash') || trashNotebooks.length === 0)} {#if $sortedNotes.length === 0 && $viewMode !== 'daily' && $viewMode !== 'tasks' && (!($viewMode === 'trash') || trashNotebooks.length === 0)}
<div class="empty-state"> <div class="empty-state">
{#if $viewMode === 'trash'} {#if $viewMode === 'trash'}
<p>Trash is empty</p> <p>Trash is empty</p>
+19
View File
@@ -150,6 +150,13 @@
onViewChanged(); onViewChanged();
} }
function selectTasks() {
$viewMode = 'tasks';
$activeNotebook = null;
$activeTag = null;
onViewChanged();
}
function selectTrash() { function selectTrash() {
$viewMode = 'trash'; $viewMode = 'trash';
$activeNotebook = null; $activeNotebook = null;
@@ -511,6 +518,18 @@
<span>Quick Access</span> <span>Quick Access</span>
</button> </button>
<button
class="nav-item"
class:active={$viewMode === 'tasks'}
onclick={selectTasks}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M9 11l3 3L22 4" />
<path d="M21 12v7a2 2 0 01-2 2H5a2 2 0 01-2-2V5a2 2 0 012-2h11" />
</svg>
<span>Tasks</span>
</button>
<button <button
class="nav-item" class="nav-item"
class:active={$viewMode === 'daily'} class:active={$viewMode === 'daily'}
+684
View File
@@ -0,0 +1,684 @@
<script lang="ts">
import { onMount } from 'svelte';
import { listen } from '@tauri-apps/api/event';
import { getTasks } from '$lib/api';
import { debounce } from '$lib/utils/debounce';
import type { TaskItem, FileEvent } from '$lib/types';
let { onOpenTask = (_t: TaskItem) => {}, onToggleTask = async (_t: TaskItem) => {}, onSetTaskPriority = async (_t: TaskItem, _p: string | null) => {}, onSetTaskDue = async (_t: TaskItem, _d: string | null) => {} }: {
onOpenTask?: (t: TaskItem) => void;
onToggleTask?: (t: TaskItem) => Promise<void>;
onSetTaskPriority?: (t: TaskItem, p: string | null) => Promise<void>;
onSetTaskDue?: (t: TaskItem, d: string | null) => Promise<void>;
} = $props();
let tasks = $state<TaskItem[]>([]);
let loading = $state(true);
let hideCompleted = $state(true);
let sortBy = $state<'due' | 'priority' | 'note'>('due');
let unlisten: (() => void) | null = null;
async function load() {
try {
tasks = await getTasks();
} catch (e) {
console.error('Failed to load tasks:', e);
tasks = [];
}
loading = false;
}
const debouncedLoad = debounce(load, 400);
onMount(() => {
load();
listen<FileEvent>('file-changed', () => debouncedLoad()).then((u) => (unlisten = u));
return () => { unlisten?.(); };
});
// Local YYYY-MM-DD (en-CA gives ISO-style date)
const today = new Date().toLocaleDateString('en-CA');
const prioRank: Record<string, number> = { high: 0, med: 1, low: 2 };
function prank(p: string | null) { return p ? (prioRank[p] ?? 3) : 3; }
const filtered = $derived.by(() => {
let list = tasks.slice();
if (hideCompleted) list = list.filter((t) => !t.completed);
list.sort((a, b) => {
if (a.completed !== b.completed) return a.completed ? 1 : -1; // done last
if (sortBy === 'priority') {
const d = prank(a.priority) - prank(b.priority);
if (d) return d;
} else if (sortBy === 'note') {
const d = a.note_title.localeCompare(b.note_title);
if (d) return d;
} else {
const ad = a.due ?? '9999-99-99';
const bd = b.due ?? '9999-99-99';
if (ad !== bd) return ad < bd ? -1 : 1;
}
return a.text.localeCompare(b.text);
});
return list;
});
const openCount = $derived(tasks.filter((t) => !t.completed).length);
function dueClass(due: string | null): string {
if (!due) return '';
if (due < today) return 'overdue';
if (due === today) return 'duetoday';
return '';
}
async function toggle(t: TaskItem) {
await onToggleTask(t);
await load();
}
let prioMenuFor = $state<string | null>(null);
let dueMenuFor = $state<string | null>(null);
const rowKey = (t: TaskItem) => t.note_path + ':' + t.line + ':' + t.raw_line;
async function choosePriority(t: TaskItem, p: string | null) {
prioMenuFor = null;
await onSetTaskPriority(t, p);
await load();
}
async function chooseDue(t: TaskItem, d: string | null) {
dueMenuFor = null;
await onSetTaskDue(t, d);
await load();
}
// ── Calendar ──
let viewLayout = $state<'list' | 'calendar'>('list');
const calNow = new Date();
let calMonth = $state(calNow.getMonth());
let calYear = $state(calNow.getFullYear());
let selectedDate = $state(today);
const calMonthNames = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
const calDayNames = ['Su', 'Mo', 'Tu', 'We', 'Th', 'Fr', 'Sa'];
// Calendar tasks share the list's hide-completed filter (no sort).
const calBase = $derived(tasks.filter((t) => !hideCompleted || !t.completed));
const tasksByDate = $derived.by(() => {
const m = new Map<string, TaskItem[]>();
for (const t of calBase) {
if (!t.due) continue;
const arr = m.get(t.due);
if (arr) arr.push(t);
else m.set(t.due, [t]);
}
return m;
});
const undatedTasks = $derived(calBase.filter((t) => !t.due));
function byPriority(a: TaskItem, b: TaskItem) {
if (a.completed !== b.completed) return a.completed ? 1 : -1;
const d = prank(a.priority) - prank(b.priority);
return d || a.text.localeCompare(b.text);
}
const selectedDayTasks = $derived(
(selectedDate ? (tasksByDate.get(selectedDate) ?? []) : []).slice().sort(byPriority)
);
function calDays() {
const firstWeekday = new Date(calYear, calMonth, 1).getDay();
const lastDate = new Date(calYear, calMonth + 1, 0).getDate();
const cells: { day: number; date: string; current: boolean }[] = [];
for (let i = 0; i < firstWeekday; i++) cells.push({ day: 0, date: '', current: false });
for (let d = 1; d <= lastDate; d++) {
const date = `${calYear}-${String(calMonth + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}`;
cells.push({ day: d, date, current: date === today });
}
return cells;
}
function calPrev() {
if (calMonth === 0) { calMonth = 11; calYear -= 1; } else calMonth -= 1;
}
function calNext() {
if (calMonth === 11) { calMonth = 0; calYear += 1; } else calMonth += 1;
}
function calToday() {
const n = new Date();
calMonth = n.getMonth();
calYear = n.getFullYear();
selectedDate = today;
}
function agendaTitle(date: string): string {
if (date === today) return 'Today';
const [y, mo, d] = date.split('-').map(Number);
return new Date(y, mo - 1, d).toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' });
}
// Drag a task (from the agenda or "No due date") onto a day to (re)schedule it.
let draggedTask = $state<TaskItem | null>(null);
let dragOverDate = $state<string | null>(null);
async function dropOnDay(date: string) {
const t = draggedTask;
draggedTask = null;
dragOverDate = null;
if (!t || t.due === date) return;
await onSetTaskDue(t, date);
await load();
}
</script>
<div class="tasks-view">
{#if prioMenuFor || dueMenuFor}
<!-- svelte-ignore a11y_consider_explicit_label -->
<button class="prio-backdrop" onclick={() => { prioMenuFor = null; dueMenuFor = null; }} aria-label="Close menu"></button>
{/if}
<div class="tasks-controls">
<button class="tasks-ctl" class:active={hideCompleted} onclick={() => (hideCompleted = !hideCompleted)} title="Hide completed tasks">Hide done</button>
<div class="tasks-layout">
<button class="tasks-ctl" class:active={viewLayout === 'list'} onclick={() => (viewLayout = 'list')} title="List view">List</button>
<button class="tasks-ctl" class:active={viewLayout === 'calendar'} onclick={() => (viewLayout = 'calendar')} title="Calendar view">Calendar</button>
</div>
{#if viewLayout === 'list'}
<div class="tasks-sort">
{#each [['due', 'Due'], ['priority', 'Priority'], ['note', 'Note']] as [v, label]}
<button class="tasks-ctl" class:active={sortBy === v} onclick={() => (sortBy = v as 'due' | 'priority' | 'note')}>{label}</button>
{/each}
</div>
{/if}
</div>
{#snippet taskRow(t: TaskItem, dragMode: boolean)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="task-row"
class:done={t.completed}
class:draggable={dragMode}
draggable={dragMode}
ondragstart={(e) => { if (!dragMode) return; draggedTask = t; if (e.dataTransfer) { e.dataTransfer.effectAllowed = 'move'; e.dataTransfer.setData('text/plain', rowKey(t)); } }}
ondragend={() => { draggedTask = null; dragOverDate = null; }}
>
<button class="task-check" class:checked={t.completed} onclick={(e) => { e.stopPropagation(); toggle(t); }} title={t.completed ? 'Mark not done' : 'Mark done'} aria-label="toggle done"></button>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<!-- svelte-ignore a11y_click_events_have_key_events -->
<div class="task-main" onclick={() => onOpenTask(t)}>
<div class="task-line">
<span class="task-prio-wrap">
<button
class="task-prio prio-{t.priority ?? 'none'}"
class:set={!!t.priority}
title="Set priority"
aria-label="Set priority"
onclick={(e) => { e.stopPropagation(); dueMenuFor = null; prioMenuFor = prioMenuFor === rowKey(t) ? null : rowKey(t); }}
>{t.priority ?? '!'}</button>
{#if prioMenuFor === rowKey(t)}
<div class="prio-menu" role="menu">
<button class="prio-opt prio-high" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'high'); }}>High</button>
<button class="prio-opt prio-med" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'med'); }}>Med</button>
<button class="prio-opt prio-low" onclick={(e) => { e.stopPropagation(); choosePriority(t, 'low'); }}>Low</button>
<button class="prio-opt prio-clear" onclick={(e) => { e.stopPropagation(); choosePriority(t, null); }}>None</button>
</div>
{/if}
</span>
<span class="task-label">{t.text}</span>
<span class="task-due-wrap">
<button
class="task-due {dueClass(t.due)}"
class:set={!!t.due}
title="Set due date"
aria-label="Set due date"
onclick={(e) => { e.stopPropagation(); prioMenuFor = null; dueMenuFor = dueMenuFor === rowKey(t) ? null : rowKey(t); }}
>{t.due ?? 'due'}</button>
{#if dueMenuFor === rowKey(t)}
<div class="due-menu">
<input
class="due-input"
type="date"
value={t.due ?? ''}
onclick={(e) => e.stopPropagation()}
onchange={(e) => { e.stopPropagation(); chooseDue(t, (e.target as HTMLInputElement).value || null); }}
/>
{#if t.due}<button class="prio-opt prio-clear" onclick={(e) => { e.stopPropagation(); chooseDue(t, null); }}>Clear</button>{/if}
</div>
{/if}
</span>
</div>
<div class="task-note">{t.note_title}</div>
</div>
</div>
{/snippet}
{#if loading}
<div class="tasks-empty">Loading...</div>
{:else if viewLayout === 'calendar'}
<div class="tasks-cal-pane">
<div class="cal">
<div class="cal-nav">
<button class="cal-nav-btn" onclick={calPrev} title="Previous month" aria-label="Previous month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="15 18 9 12 15 6"/></svg>
</button>
<button class="cal-title" onclick={calToday} title="Jump to today">{calMonthNames[calMonth]} {calYear}</button>
<button class="cal-nav-btn" onclick={calNext} title="Next month" aria-label="Next month">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="9 18 15 12 9 6"/></svg>
</button>
</div>
<div class="cal-grid">
{#each calDayNames as name}<div class="cal-head">{name}</div>{/each}
{#each calDays() as cell}
{#if cell.day === 0}
<div class="cal-cell empty"></div>
{:else}
{@const cnt = (tasksByDate.get(cell.date) ?? []).length}
<button
class="cal-cell"
class:today={cell.current}
class:active={cell.date === selectedDate}
class:has-tasks={cnt > 0}
class:drop-over={dragOverDate === cell.date}
onclick={() => (selectedDate = cell.date)}
ondragover={(e) => { if (draggedTask) { e.preventDefault(); dragOverDate = cell.date; } }}
ondragleave={() => { if (dragOverDate === cell.date) dragOverDate = null; }}
ondrop={(e) => { e.preventDefault(); dropOnDay(cell.date); }}
>
<span class="cal-day">{cell.day}</span>
{#if cnt > 0}<span class="cal-count" class:overdue={cell.date < today}>{cnt}</span>{/if}
</button>
{/if}
{/each}
</div>
</div>
<div class="cal-agenda">
<div class="agenda-section">
<div class="agenda-head" class:overdue={selectedDate < today}>{agendaTitle(selectedDate)}</div>
{#if selectedDayTasks.length === 0}
<div class="tasks-empty-sm">Nothing due.</div>
{:else}
{#each selectedDayTasks as t (rowKey(t))}{@render taskRow(t, true)}{/each}
{/if}
</div>
{#if undatedTasks.length}
<div class="agenda-section">
<div class="agenda-head">No due date ({undatedTasks.length})</div>
{#each undatedTasks as t (rowKey(t))}{@render taskRow(t, true)}{/each}
</div>
{/if}
</div>
</div>
{:else if filtered.length === 0}
<div class="tasks-empty">No tasks. Add <code>- [ ] something</code> to any note.{#if openCount === 0 && tasks.length}{' '}All done!{/if}</div>
{:else}
<div class="tasks-list">
{#each filtered as t (rowKey(t))}{@render taskRow(t, false)}{/each}
</div>
{/if}
</div>
<style>
.tasks-view {
display: flex;
flex-direction: column;
height: 100%;
overflow: hidden;
}
.tasks-controls {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 10px;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
flex-shrink: 0;
}
.tasks-sort {
display: flex;
gap: 2px;
margin-left: auto;
}
.tasks-layout {
display: flex;
gap: 2px;
}
.tasks-ctl {
padding: 3px 8px;
border: 1px solid var(--border-color);
border-radius: 5px;
background: var(--bg-secondary);
color: var(--text-secondary);
font-size: 12px;
cursor: pointer;
}
.tasks-ctl.active {
background: var(--accent);
color: #fff;
border-color: var(--accent);
}
.tasks-list {
flex: 1;
overflow-y: auto;
padding: 4px 0;
}
.tasks-empty {
padding: 24px 16px;
color: var(--text-tertiary);
font-size: 13px;
text-align: center;
}
.task-row {
display: flex;
align-items: flex-start;
gap: 8px;
padding: 7px 12px;
border-bottom: 1px solid var(--border-color);
}
.task-row:hover {
background: var(--bg-hover);
}
.task-row.draggable { cursor: grab; }
.task-row.draggable:active { cursor: grabbing; }
.task-check {
width: 16px;
height: 16px;
margin-top: 2px;
border: 2px solid var(--border-color);
border-radius: 4px;
background: var(--bg-primary);
cursor: pointer;
flex-shrink: 0;
position: relative;
}
.task-check:hover {
border-color: var(--accent);
}
.task-check.checked {
background: var(--accent);
border-color: var(--accent);
}
.task-check.checked::after {
content: '';
position: absolute;
left: 3px;
top: 0;
width: 5px;
height: 9px;
border: solid #fff;
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.task-main {
flex: 1;
min-width: 0;
cursor: pointer;
}
.task-line {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 5px;
font-size: 13px;
color: var(--text-primary);
}
.task-row.done .task-label {
text-decoration: line-through;
color: var(--text-tertiary);
}
.task-label {
word-break: break-word;
}
.task-prio-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.task-prio {
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
padding: 1px 5px;
border-radius: 4px;
color: #fff;
border: none;
cursor: pointer;
line-height: 1.5;
}
.task-prio.prio-none {
background: transparent;
color: var(--text-tertiary);
border: 1px dashed var(--border-color);
opacity: 0;
transition: opacity 0.1s;
}
.task-row:hover .task-prio.prio-none { opacity: 1; }
.prio-high { background: #ef4444; }
.prio-med { background: #f59e0b; }
.prio-low { background: #64748b; }
.prio-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 3px;
z-index: 51;
display: flex;
flex-direction: column;
min-width: 84px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
overflow: hidden;
padding: 3px;
gap: 2px;
}
.prio-opt {
text-align: left;
padding: 4px 8px;
font-size: 12px;
border: none;
border-radius: 4px;
background: transparent;
color: var(--text-primary);
cursor: pointer;
}
.prio-opt:hover { background: var(--bg-hover); }
.prio-opt.prio-high { color: #ef4444; background: transparent; }
.prio-opt.prio-med { color: #f59e0b; background: transparent; }
.prio-opt.prio-low { color: #64748b; background: transparent; }
.prio-opt.prio-high:hover,
.prio-opt.prio-med:hover,
.prio-opt.prio-low:hover { background: var(--bg-hover); }
.prio-opt.prio-clear { color: var(--text-tertiary); }
.prio-backdrop {
position: fixed;
inset: 0;
z-index: 50;
background: transparent;
border: none;
padding: 0;
cursor: default;
}
.task-due-wrap {
position: relative;
display: inline-flex;
flex-shrink: 0;
}
.task-due {
font-size: 11px;
padding: 1px 6px;
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-secondary);
border: 1px solid var(--border-color);
cursor: pointer;
font-family: inherit;
line-height: 1.5;
}
.task-due:not(.set) {
background: transparent;
border-style: dashed;
color: var(--text-tertiary);
opacity: 0;
transition: opacity 0.1s;
}
.task-row:hover .task-due:not(.set) { opacity: 1; }
.task-due.overdue {
color: #ef4444;
border-color: #ef4444;
}
.task-due.duetoday {
color: #f59e0b;
border-color: #f59e0b;
}
.due-menu {
position: absolute;
top: 100%;
left: 0;
margin-top: 3px;
z-index: 51;
display: flex;
flex-direction: column;
gap: 4px;
background: var(--bg-primary);
border: 1px solid var(--border-color);
border-radius: 6px;
box-shadow: 0 4px 14px rgba(0, 0, 0, 0.18);
padding: 6px;
}
.due-input {
font-family: inherit;
font-size: 12px;
padding: 3px 5px;
border: 1px solid var(--border-color);
border-radius: 4px;
background: var(--bg-secondary);
color: var(--text-primary);
color-scheme: light dark;
}
.task-note {
font-size: 11px;
color: var(--text-tertiary);
margin-top: 2px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* Calendar */
.tasks-cal-pane {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
}
.cal {
padding: 10px 12px;
border-bottom: 1px solid var(--border-color);
flex-shrink: 0;
}
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
}
.cal-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
border: none;
border-radius: 5px;
background: transparent;
color: var(--text-secondary);
cursor: pointer;
}
.cal-nav-btn:hover { background: var(--bg-hover); }
.cal-title {
border: none;
background: transparent;
color: var(--text-primary);
font-size: 13px;
font-weight: 600;
cursor: pointer;
padding: 3px 8px;
border-radius: 5px;
}
.cal-title:hover { background: var(--bg-hover); }
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 2px;
}
.cal-head {
text-align: center;
font-size: 10px;
font-weight: 600;
color: var(--text-tertiary);
padding: 2px 0;
}
.cal-cell {
position: relative;
aspect-ratio: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 1px;
border: 1px solid transparent;
border-radius: 6px;
background: transparent;
color: var(--text-primary);
font-size: 12px;
cursor: pointer;
}
.cal-cell.empty { cursor: default; }
.cal-cell:not(.empty):hover { background: var(--bg-hover); }
.cal-cell.today { border-color: var(--accent); }
.cal-cell.active { background: var(--accent); color: #fff; }
.cal-cell.drop-over {
border: 1px dashed var(--accent);
background: var(--bg-hover);
}
.cal-count {
min-width: 15px;
height: 15px;
padding: 0 4px;
border-radius: 8px;
background: var(--accent);
color: #fff;
font-size: 9px;
font-weight: 700;
display: flex;
align-items: center;
justify-content: center;
line-height: 1;
}
.cal-count.overdue { background: #ef4444; }
.cal-cell.active .cal-count { background: rgba(255, 255, 255, 0.3); }
.cal-agenda {
flex: 1;
}
.agenda-section {
border-bottom: 1px solid var(--border-color);
padding-bottom: 4px;
}
.agenda-head {
padding: 8px 12px 4px;
font-size: 11px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.03em;
color: var(--text-tertiary);
}
.agenda-head.overdue { color: #ef4444; }
.tasks-empty-sm {
padding: 6px 12px 10px;
font-size: 12px;
color: var(--text-tertiary);
}
</style>
+13 -1
View File
@@ -164,4 +164,16 @@ export type ViewMode =
| "trash" | "trash"
| "search" | "search"
| "quickaccess" | "quickaccess"
| "daily"; | "daily"
| "tasks";
export interface TaskItem {
note_path: string;
note_title: string;
line: number;
raw_line: string;
text: string;
completed: boolean;
due: string | null;
priority: string | null;
}