diff --git a/frontend/src/components/RecurrenceEditor.vue b/frontend/src/components/RecurrenceEditor.vue new file mode 100644 index 0000000..092426f --- /dev/null +++ b/frontend/src/components/RecurrenceEditor.vue @@ -0,0 +1,171 @@ + + + + + diff --git a/frontend/src/components/StatusBadge.vue b/frontend/src/components/StatusBadge.vue index 1167cbd..4e3ca2e 100644 --- a/frontend/src/components/StatusBadge.vue +++ b/frontend/src/components/StatusBadge.vue @@ -12,6 +12,7 @@ const labels: Record = { todo: "Todo", in_progress: "In Progress", done: "Done", + cancelled: "Cancelled", }; @@ -48,6 +49,10 @@ const labels: Record = { background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%); color: color-mix(in srgb, var(--color-status-done) 85%, #000 15%); } +.status-cancelled { + background: color-mix(in srgb, var(--color-bg-secondary) 78%, var(--color-text-muted) 22%); + color: var(--color-text-muted); +} .clickable { cursor: pointer; } diff --git a/frontend/src/stores/tasks.ts b/frontend/src/stores/tasks.ts index 93476da..231ee52 100644 --- a/frontend/src/stores/tasks.ts +++ b/frontend/src/stores/tasks.ts @@ -12,8 +12,8 @@ export const useTasksStore = defineStore("tasks", () => { // Filter / pagination / sort state const activeTagFilters = ref([]); - const statusFilter = ref(""); - const priorityFilter = ref(""); + const statusFilter = ref([]); + const priorityFilter = ref([]); const limit = ref(20); const offset = ref(0); const sortField = ref("updated_at"); @@ -28,9 +28,8 @@ export const useTasksStore = defineStore("tasks", () => { for (const t of activeTagFilters.value) { searchParams.append("tag", t); } - if (statusFilter.value) searchParams.set("status", statusFilter.value); - if (priorityFilter.value) - searchParams.set("priority", priorityFilter.value); + for (const s of statusFilter.value) searchParams.append("status", s); + for (const p of priorityFilter.value) searchParams.append("priority", p); searchParams.set("sort", sortField.value); searchParams.set("order", sortOrder.value); searchParams.set("limit", String(limit.value)); @@ -72,6 +71,7 @@ export const useTasksStore = defineStore("tasks", () => { project_id?: number | null; milestone_id?: number | null; parent_id?: number | null; + recurrence_rule?: Record | null; }): Promise { try { return await apiPost("/api/tasks", data); @@ -84,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => { async function updateTask( id: number, data: Partial< - Pick + Pick > ): Promise { try { @@ -129,14 +129,14 @@ export const useTasksStore = defineStore("tasks", () => { } } - function setStatusFilter(status: TaskStatus | "") { - statusFilter.value = status; + function setStatusFilter(statuses: TaskStatus[]) { + statusFilter.value = statuses; offset.value = 0; refresh(); } - function setPriorityFilter(priority: TaskPriority | "") { - priorityFilter.value = priority; + function setPriorityFilter(priorities: TaskPriority[]) { + priorityFilter.value = priorities; offset.value = 0; refresh(); } diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index 6258b54..55f182a 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -1,4 +1,4 @@ -export type TaskStatus = "todo" | "in_progress" | "done"; +export type TaskStatus = "todo" | "in_progress" | "done" | "cancelled"; export type TaskPriority = "none" | "low" | "medium" | "high"; export interface Note { @@ -13,6 +13,10 @@ export interface Note { status: TaskStatus | null; priority: TaskPriority | null; due_date: string | null; + started_at: string | null; + completed_at: string | null; + recurrence_rule: Record | null; + recurrence_next_spawn_at: string | null; is_task: boolean; created_at: string; updated_at: string; diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index 871e291..133b0b3 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -12,6 +12,7 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions"; import { useFloatingAssist } from "@/composables/useFloatingAssist"; import { apiPost, apiGet, apiPatch } from "@/api/client"; import type { TaskStatus, TaskPriority } from "@/types/task"; +import type { Note } from "@/types/note"; import type { Editor } from "@tiptap/vue-3"; import MarkdownToolbar from "@/components/MarkdownToolbar.vue"; import TiptapEditor from "@/components/TiptapEditor.vue"; @@ -23,6 +24,7 @@ import TaskLogSection from "@/components/TaskLogSection.vue"; import DiffView from "@/components/DiffView.vue"; import ConfirmDialog from "@/components/ConfirmDialog.vue"; import VersionHistorySection from "@/components/VersionHistorySection.vue"; +import RecurrenceEditor from "@/components/RecurrenceEditor.vue"; const route = useRoute(); const router = useRouter(); @@ -40,6 +42,9 @@ const projectId = ref(null); const milestoneId = ref(null); const parentId = ref(null); const parentTitle = ref(""); +const startedAt = ref(null); +const completedAt = ref(null); +const recurrenceRule = ref | null>(null); const parentSearchQuery = ref(""); const parentSearchResults = ref<{ id: number; title: string }[]>([]); const parentSearchLoading = ref(false); @@ -274,6 +279,10 @@ onMounted(async () => { parentId.value = (taskRec.parent_id as number | null) ?? null; parentTitle.value = (taskRec.parent_title as string | null) ?? ""; parentSearchQuery.value = parentTitle.value; + const noteTask = store.currentTask as unknown as Note; + startedAt.value = noteTask.started_at ?? null; + completedAt.value = noteTask.completed_at ?? null; + recurrenceRule.value = noteTask.recurrence_rule ?? null; savedTitle = title.value; savedBody = body.value; savedTags = [...tags.value]; @@ -309,6 +318,7 @@ async function save() { project_id: projectId.value, milestone_id: milestoneId.value, parent_id: parentId.value, + recurrence_rule: recurrenceRule.value, }; if (isEditing.value) { await store.updateTask(taskId.value!, data); @@ -373,6 +383,7 @@ async function doAutoSave() { project_id: projectId.value, milestone_id: milestoneId.value, parent_id: parentId.value, + recurrence_rule: recurrenceRule.value, } as Record); savedTitle = title.value; savedBody = body.value; @@ -483,8 +494,19 @@ useEditorGuards(dirty, save); + +
+
+ Started + {{ new Date(startedAt).toLocaleString() }} +
+
+ Completed + {{ new Date(completedAt).toLocaleString() }} +
+
+
+ + +
@@ -903,6 +929,28 @@ useEditorGuards(dirty, save); align-items: center; } +/* Lifecycle timestamps */ +.sb-timestamps { + display: flex; + flex-direction: column; + gap: 0.2rem; + margin-top: 0.25rem; +} +.sb-timestamp { + display: flex; + justify-content: space-between; + gap: 0.4rem; + font-size: 0.75rem; +} +.sb-ts-label { + color: var(--color-text-muted); + flex-shrink: 0; +} +.sb-ts-value { + color: var(--color-text-secondary); + text-align: right; +} + /* Narrow screen: sidebar collapses */ @media (max-width: 720px) { .task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; } diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 39adee4..7295be0 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -33,12 +33,14 @@ const statusCycle: Record = { todo: "in_progress", in_progress: "done", done: "todo", + cancelled: "todo", }; const statusDotClass: Record = { todo: "dot-todo", in_progress: "dot-in-progress", done: "dot-done", + cancelled: "dot-cancelled", }; function cycleSubTaskStatus(subTask: Note) { @@ -137,8 +139,25 @@ const forwardStatus: Record = { todo: "in_progress", in_progress: "done", done: null, + cancelled: null, }; +function recurrenceSummary(rule: Record | null): string | null { + if (!rule) return null; + if (rule.type === "interval") { + return `Every ${rule.every} ${rule.unit}(s)`; + } + if (rule.type === "calendar") { + if (rule.unit === "month") return `Monthly on day ${rule.day_of_month}`; + if (rule.unit === "year") { + const months = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"]; + const m = months[((rule.month as number) ?? 1) - 1]; + return `Yearly on ${m} ${rule.day_of_month}`; + } + } + return null; +} + const advanceLabel = computed(() => { const s = store.currentTask?.status as TaskStatus | undefined; if (!s) return null; @@ -318,6 +337,17 @@ const subTaskProgress = computed(() => { Due: {{ store.currentTask.due_date }}
+
+ + Started: {{ new Date(store.currentTask.started_at).toLocaleString() }} + + + Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }} + + + ↻ {{ recurrenceSummary(store.currentTask.recurrence_rule as Record | null) }} + +
{ color: var(--color-overdue); font-weight: 600; } +.task-meta-row { + display: flex; + flex-wrap: wrap; + gap: 0.75rem; + margin-bottom: 0.5rem; +} +.task-meta-item { + font-size: 0.78rem; + color: var(--color-text-muted); +} +.task-meta-recurrence { + color: var(--color-primary); + font-weight: 500; +} .tags { display: flex; gap: 0.5rem; @@ -637,6 +681,9 @@ const subTaskProgress = computed(() => { .dot-done { background: var(--color-status-done, #22c55e); } +.dot-cancelled { + background: var(--color-text-muted, #6b7280); +} .sub-title { flex: 1; font-size: 0.9rem; diff --git a/frontend/src/views/TasksListView.vue b/frontend/src/views/TasksListView.vue index 21cd4aa..a8de3fb 100644 --- a/frontend/src/views/TasksListView.vue +++ b/frontend/src/views/TasksListView.vue @@ -2,7 +2,7 @@ import { ref, computed, onMounted, onUnmounted, watch } from "vue"; import { useRoute, useRouter } from "vue-router"; import { useTasksStore } from "@/stores/tasks"; -import type { Task, TaskStatus } from "@/types/task"; +import type { Task, TaskStatus, TaskPriority } from "@/types/task"; import { apiGet } from "@/api/client"; import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation"; import SearchBar from "@/components/SearchBar.vue"; @@ -122,7 +122,8 @@ onMounted(async () => { store.activeTagFilters = tags; } if (route.query.status) { - store.statusFilter = route.query.status as TaskStatus; + const qs = route.query.status; + store.statusFilter = (Array.isArray(qs) ? qs : [qs]).filter(Boolean) as TaskStatus[]; } store.limit = viewMode.value === "grouped" ? 100 : 200; collapsedGroups.value.add("done"); @@ -150,14 +151,20 @@ function onSearch(q: string) { store.setSearch(q); } -function onStatusFilterChange(e: Event) { - const value = (e.target as HTMLSelectElement).value; - store.setStatusFilter(value as TaskStatus | ""); +function toggleStatusChip(value: TaskStatus) { + const current = [...store.statusFilter]; + const idx = current.indexOf(value); + if (idx === -1) current.push(value); + else current.splice(idx, 1); + store.setStatusFilter(current); } -function onPriorityFilterChange(e: Event) { - const value = (e.target as HTMLSelectElement).value; - store.setPriorityFilter(value as any); +function togglePriorityChip(value: TaskPriority) { + const current = [...store.priorityFilter]; + const idx = current.indexOf(value); + if (idx === -1) current.push(value); + else current.splice(idx, 1); + store.setPriorityFilter(current); } function onTagClick(tag: string) { @@ -210,19 +217,20 @@ function toggleGroup(key: string) {
- - +
+ +
+
+ +
@@ -270,7 +278,7 @@ function toggleGroup(key: string) {
-