mirror of
https://gitlab.com/ArkHost/HelixNotes.git
synced 2026-07-24 07:45:57 +02:00
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:
@@ -589,6 +589,254 @@ fn extract_title_fast(raw: &str) -> Option<String> {
|
||||
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(¬e_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(¬e_path, &meta, &new_body)?;
|
||||
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.index_note(¬e_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(¬e_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(¬e_path, &meta, &new_body)?;
|
||||
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.index_note(¬e_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(¬e_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(¬e_path, &meta, &new_body)?;
|
||||
|
||||
if let Ok(guard) = state.search_index.lock() {
|
||||
if let Some(search) = guard.as_ref() {
|
||||
let _ = search.index_note(¬e_path);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
// ── Search ──
|
||||
|
||||
#[tauri::command]
|
||||
|
||||
@@ -122,6 +122,10 @@ pub fn run() {
|
||||
commands::get_all_tags,
|
||||
commands::get_all_note_titles,
|
||||
commands::get_graph_data,
|
||||
commands::get_tasks,
|
||||
commands::set_task_done,
|
||||
commands::set_task_priority,
|
||||
commands::set_task_due,
|
||||
commands::search_notes,
|
||||
commands::reindex,
|
||||
commands::get_trash,
|
||||
|
||||
@@ -348,3 +348,15 @@ pub struct GraphEdge {
|
||||
pub target: usize,
|
||||
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>,
|
||||
}
|
||||
|
||||
@@ -13,6 +13,7 @@ import type {
|
||||
ImportResult,
|
||||
BackupEntry,
|
||||
VersionEntry,
|
||||
TaskItem,
|
||||
} from "./types";
|
||||
|
||||
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) ──
|
||||
|
||||
export async function setSyncSettings(
|
||||
|
||||
@@ -51,11 +51,11 @@
|
||||
const isMac = navigator.platform.startsWith('Mac');
|
||||
const isMobile = /android|iphone|ipad|ipod/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 { openNoteWindow } from '$lib/utils/window';
|
||||
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 {
|
||||
for (const nb of list) {
|
||||
@@ -217,14 +217,19 @@
|
||||
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) {
|
||||
// Selecting a real vault note exits viewer mode
|
||||
$viewerNote = null;
|
||||
taskNoteOpened = true;
|
||||
editor?.loadNote(path, content);
|
||||
if (isMobile) $mobileView = 'editor';
|
||||
}
|
||||
|
||||
function handleViewChanged() {
|
||||
taskNoteOpened = false;
|
||||
noteList?.refresh();
|
||||
if (isMobile) $mobileView = 'notelist';
|
||||
}
|
||||
@@ -235,6 +240,63 @@
|
||||
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() {
|
||||
try {
|
||||
const entry = await createDailyNote();
|
||||
@@ -757,7 +819,7 @@
|
||||
<Sidebar bind:this={sidebar} onViewChanged={handleViewChanged} />
|
||||
</div>
|
||||
<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">
|
||||
<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" />
|
||||
@@ -822,7 +884,7 @@
|
||||
{/if}
|
||||
|
||||
<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>
|
||||
|
||||
<ResizeHandle onResize={handleNotelistResize} />
|
||||
@@ -830,6 +892,14 @@
|
||||
|
||||
<div class="editor-panel">
|
||||
<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>
|
||||
@@ -872,6 +942,21 @@
|
||||
height: 100%;
|
||||
overflow: hidden;
|
||||
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 {
|
||||
|
||||
@@ -37,13 +37,17 @@
|
||||
import { formatRelativeTime, formatDate } from '$lib/utils/time';
|
||||
import { openNoteWindow } from '$lib/utils/window';
|
||||
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;
|
||||
onNoteMoved?: () => void;
|
||||
onBeforeNoteSwitch?: () => 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();
|
||||
|
||||
const modKey = navigator.platform.startsWith('Mac') ? '⌘' : 'Ctrl';
|
||||
@@ -281,6 +285,7 @@
|
||||
if ($viewMode === 'tag') return `#${$activeTag}`;
|
||||
if ($viewMode === 'quickaccess') return 'Quick Access';
|
||||
if ($viewMode === 'daily') return 'Daily Notes';
|
||||
if ($viewMode === 'tasks') return 'Tasks';
|
||||
if ($viewMode === 'trash') return 'Trash';
|
||||
return 'Notes';
|
||||
});
|
||||
@@ -298,6 +303,7 @@
|
||||
}
|
||||
|
||||
export async function refresh(invalidate = false) {
|
||||
if ($viewMode === 'tasks') { $notes = []; return; } // Tasks view fetches its own data
|
||||
if (invalidate) {
|
||||
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() {
|
||||
if ($viewMode === 'daily') {
|
||||
const today = new Date();
|
||||
@@ -836,6 +855,7 @@
|
||||
<div class="list-header">
|
||||
<span class="list-title">{viewTitle}</span>
|
||||
<div class="list-actions">
|
||||
{#if $viewMode !== 'tasks'}
|
||||
{#if isAndroid}
|
||||
<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">
|
||||
@@ -855,6 +875,7 @@
|
||||
</svg>
|
||||
</button>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -951,6 +972,9 @@
|
||||
{/if}
|
||||
|
||||
<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'}
|
||||
<div class="cal">
|
||||
<div class="cal-nav">
|
||||
@@ -985,7 +1009,7 @@
|
||||
</div>
|
||||
{/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">
|
||||
{#if $viewMode === 'trash'}
|
||||
<p>Trash is empty</p>
|
||||
|
||||
@@ -150,6 +150,13 @@
|
||||
onViewChanged();
|
||||
}
|
||||
|
||||
function selectTasks() {
|
||||
$viewMode = 'tasks';
|
||||
$activeNotebook = null;
|
||||
$activeTag = null;
|
||||
onViewChanged();
|
||||
}
|
||||
|
||||
function selectTrash() {
|
||||
$viewMode = 'trash';
|
||||
$activeNotebook = null;
|
||||
@@ -511,6 +518,18 @@
|
||||
<span>Quick Access</span>
|
||||
</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
|
||||
class="nav-item"
|
||||
class:active={$viewMode === 'daily'}
|
||||
|
||||
@@ -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
@@ -164,4 +164,16 @@ export type ViewMode =
|
||||
| "trash"
|
||||
| "search"
|
||||
| "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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user