feat: task management enhancements (cancelled status, recurrence, timestamps)

- Add 'cancelled' status to TaskStatus type, StatusBadge, TaskCard,
  TaskEditorView, TaskViewerView, TasksListView
- Add RecurrenceEditor component (none / interval / calendar rules)
- TaskEditorView: wire RecurrenceEditor, show started_at/completed_at
  timestamps read-only, include recurrence_rule in save payload
- TaskViewerView: show recurrence summary, timestamps in meta row
- tasks.ts: statusFilter/priorityFilter as arrays, add recurrence_rule
  to updateTask and createTask payloads

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-28 00:51:36 -04:00
parent 37e2420192
commit ac90548823
7 changed files with 337 additions and 33 deletions
+51 -22
View File
@@ -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) {
<div class="controls">
<div class="filter-controls">
<select :value="store.statusFilter" @change="onStatusFilterChange" class="filter-select">
<option value="">All Statuses</option>
<option value="todo">Todo</option>
<option value="in_progress">In Progress</option>
<option value="done">Done</option>
</select>
<select :value="store.priorityFilter" @change="onPriorityFilterChange" class="filter-select">
<option value="">All Priorities</option>
<option value="none">None</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
<div class="filter-chip-group">
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
@click="toggleStatusChip(s)">
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
</button>
</div>
<div class="filter-chip-group">
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
@click="togglePriorityChip(p)">
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
</button>
</div>
</div>
<div class="right-controls">
<div class="sort-controls">
@@ -270,7 +278,7 @@ function toggleGroup(key: string) {
</div>
<div v-else-if="store.tasks.length === 0" class="empty-state">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter || store.priorityFilter">
<template v-if="store.searchQuery || store.activeTagFilters.length || store.statusFilter.length || store.priorityFilter.length">
<p class="empty-title">No tasks match your filters</p>
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
</template>
@@ -382,8 +390,29 @@ function toggleGroup(key: string) {
}
.filter-controls {
display: flex;
flex-wrap: wrap;
gap: 0.5rem;
}
.filter-chip-group {
display: flex;
flex-wrap: wrap;
gap: 4px;
}
.filter-chip {
padding: 3px 10px;
border-radius: 999px;
border: 1px solid var(--color-input-border);
background: transparent;
color: var(--color-text-muted);
cursor: pointer;
font-size: 0.78rem;
transition: background 0.15s, color 0.15s, border-color 0.15s;
}
.filter-chip.active {
background: var(--color-primary);
color: #fff;
border-color: var(--color-primary);
}
.right-controls {
display: flex;
align-items: center;