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:
@@ -0,0 +1,171 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref, watch, computed } from "vue";
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
modelValue: Record<string, unknown> | null;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: "update:modelValue", val: Record<string, unknown> | null): void;
|
||||||
|
}>();
|
||||||
|
|
||||||
|
type RecurrenceType = "none" | "interval" | "calendar";
|
||||||
|
type IntervalUnit = "day" | "week" | "month" | "year";
|
||||||
|
type CalendarUnit = "month" | "year";
|
||||||
|
|
||||||
|
const rType = ref<RecurrenceType>("none");
|
||||||
|
const intervalEvery = ref(1);
|
||||||
|
const intervalUnit = ref<IntervalUnit>("week");
|
||||||
|
const calendarUnit = ref<CalendarUnit>("month");
|
||||||
|
const calendarDay = ref(1);
|
||||||
|
const calendarMonth = ref(1);
|
||||||
|
|
||||||
|
function ruleFromState(): Record<string, unknown> | null {
|
||||||
|
if (rType.value === "none") return null;
|
||||||
|
if (rType.value === "interval") {
|
||||||
|
return { type: "interval", every: intervalEvery.value, unit: intervalUnit.value };
|
||||||
|
}
|
||||||
|
// calendar
|
||||||
|
const rule: Record<string, unknown> = {
|
||||||
|
type: "calendar",
|
||||||
|
unit: calendarUnit.value,
|
||||||
|
day_of_month: calendarDay.value,
|
||||||
|
};
|
||||||
|
if (calendarUnit.value === "year") {
|
||||||
|
rule.month = calendarMonth.value;
|
||||||
|
}
|
||||||
|
return rule;
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadFromRule(rule: Record<string, unknown> | null) {
|
||||||
|
if (!rule) {
|
||||||
|
rType.value = "none";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const t = rule.type as string;
|
||||||
|
if (t === "interval") {
|
||||||
|
rType.value = "interval";
|
||||||
|
intervalEvery.value = (rule.every as number) ?? 1;
|
||||||
|
intervalUnit.value = (rule.unit as IntervalUnit) ?? "week";
|
||||||
|
} else if (t === "calendar") {
|
||||||
|
rType.value = "calendar";
|
||||||
|
calendarUnit.value = (rule.unit as CalendarUnit) ?? "month";
|
||||||
|
calendarDay.value = (rule.day_of_month as number) ?? 1;
|
||||||
|
calendarMonth.value = (rule.month as number) ?? 1;
|
||||||
|
} else {
|
||||||
|
rType.value = "none";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load initial value
|
||||||
|
loadFromRule(props.modelValue);
|
||||||
|
|
||||||
|
// Watch for external changes (e.g., task loaded)
|
||||||
|
watch(() => props.modelValue, (val) => {
|
||||||
|
loadFromRule(val);
|
||||||
|
}, { deep: true });
|
||||||
|
|
||||||
|
// Emit on any state change
|
||||||
|
function onChange() {
|
||||||
|
emit("update:modelValue", ruleFromState());
|
||||||
|
}
|
||||||
|
|
||||||
|
const monthNames = [
|
||||||
|
"January","February","March","April","May","June",
|
||||||
|
"July","August","September","October","November","December",
|
||||||
|
];
|
||||||
|
|
||||||
|
const calendarDayMax = computed(() =>
|
||||||
|
calendarUnit.value === "month" ? 28 : 28
|
||||||
|
);
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="rec-editor">
|
||||||
|
<select v-model="rType" class="sb-select" @change="onChange">
|
||||||
|
<option value="none">No recurrence</option>
|
||||||
|
<option value="interval">Every N days/weeks/months</option>
|
||||||
|
<option value="calendar">On a calendar date</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<div v-if="rType === 'interval'" class="rec-row">
|
||||||
|
<span class="rec-label">Every</span>
|
||||||
|
<input
|
||||||
|
v-model.number="intervalEvery"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
max="365"
|
||||||
|
class="rec-num-input"
|
||||||
|
@input="onChange"
|
||||||
|
/>
|
||||||
|
<select v-model="intervalUnit" class="sb-select rec-unit" @change="onChange">
|
||||||
|
<option value="day">day(s)</option>
|
||||||
|
<option value="week">week(s)</option>
|
||||||
|
<option value="month">month(s)</option>
|
||||||
|
<option value="year">year(s)</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="rType === 'calendar'" class="rec-row rec-col">
|
||||||
|
<div class="rec-row">
|
||||||
|
<span class="rec-label">Unit</span>
|
||||||
|
<select v-model="calendarUnit" class="sb-select" @change="onChange">
|
||||||
|
<option value="month">Monthly</option>
|
||||||
|
<option value="year">Yearly</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="rec-row">
|
||||||
|
<span class="rec-label">Day</span>
|
||||||
|
<input
|
||||||
|
v-model.number="calendarDay"
|
||||||
|
type="number"
|
||||||
|
min="1"
|
||||||
|
:max="calendarDayMax"
|
||||||
|
class="rec-num-input"
|
||||||
|
@input="onChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div v-if="calendarUnit === 'year'" class="rec-row">
|
||||||
|
<span class="rec-label">Month</span>
|
||||||
|
<select v-model.number="calendarMonth" class="sb-select" @change="onChange">
|
||||||
|
<option v-for="(name, i) in monthNames" :key="i + 1" :value="i + 1">{{ name }}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.rec-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
.rec-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
.rec-col {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
}
|
||||||
|
.rec-label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
color: var(--color-text-muted);
|
||||||
|
min-width: 2.5rem;
|
||||||
|
}
|
||||||
|
.rec-num-input {
|
||||||
|
width: 4rem;
|
||||||
|
padding: 0.25rem 0.4rem;
|
||||||
|
border: 1px solid var(--color-input-border, var(--color-border));
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
background: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-family: inherit;
|
||||||
|
}
|
||||||
|
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
|
||||||
|
.rec-unit { min-width: 6rem; }
|
||||||
|
</style>
|
||||||
@@ -12,6 +12,7 @@ const labels: Record<TaskStatus, string> = {
|
|||||||
todo: "Todo",
|
todo: "Todo",
|
||||||
in_progress: "In Progress",
|
in_progress: "In Progress",
|
||||||
done: "Done",
|
done: "Done",
|
||||||
|
cancelled: "Cancelled",
|
||||||
};
|
};
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -48,6 +49,10 @@ const labels: Record<TaskStatus, string> = {
|
|||||||
background: color-mix(in srgb, var(--color-status-done-bg) 78%, var(--color-status-done) 22%);
|
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%);
|
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 {
|
.clickable {
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,8 +12,8 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
|
|
||||||
// Filter / pagination / sort state
|
// Filter / pagination / sort state
|
||||||
const activeTagFilters = ref<string[]>([]);
|
const activeTagFilters = ref<string[]>([]);
|
||||||
const statusFilter = ref<TaskStatus | "">("");
|
const statusFilter = ref<TaskStatus[]>([]);
|
||||||
const priorityFilter = ref<TaskPriority | "">("");
|
const priorityFilter = ref<TaskPriority[]>([]);
|
||||||
const limit = ref(20);
|
const limit = ref(20);
|
||||||
const offset = ref(0);
|
const offset = ref(0);
|
||||||
const sortField = ref("updated_at");
|
const sortField = ref("updated_at");
|
||||||
@@ -28,9 +28,8 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
for (const t of activeTagFilters.value) {
|
for (const t of activeTagFilters.value) {
|
||||||
searchParams.append("tag", t);
|
searchParams.append("tag", t);
|
||||||
}
|
}
|
||||||
if (statusFilter.value) searchParams.set("status", statusFilter.value);
|
for (const s of statusFilter.value) searchParams.append("status", s);
|
||||||
if (priorityFilter.value)
|
for (const p of priorityFilter.value) searchParams.append("priority", p);
|
||||||
searchParams.set("priority", priorityFilter.value);
|
|
||||||
searchParams.set("sort", sortField.value);
|
searchParams.set("sort", sortField.value);
|
||||||
searchParams.set("order", sortOrder.value);
|
searchParams.set("order", sortOrder.value);
|
||||||
searchParams.set("limit", String(limit.value));
|
searchParams.set("limit", String(limit.value));
|
||||||
@@ -72,6 +71,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
project_id?: number | null;
|
project_id?: number | null;
|
||||||
milestone_id?: number | null;
|
milestone_id?: number | null;
|
||||||
parent_id?: number | null;
|
parent_id?: number | null;
|
||||||
|
recurrence_rule?: Record<string, unknown> | null;
|
||||||
}): Promise<Task> {
|
}): Promise<Task> {
|
||||||
try {
|
try {
|
||||||
return await apiPost<Task>("/api/tasks", data);
|
return await apiPost<Task>("/api/tasks", data);
|
||||||
@@ -84,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
async function updateTask(
|
async function updateTask(
|
||||||
id: number,
|
id: number,
|
||||||
data: Partial<
|
data: Partial<
|
||||||
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
|
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id" | "recurrence_rule">
|
||||||
>
|
>
|
||||||
): Promise<Task> {
|
): Promise<Task> {
|
||||||
try {
|
try {
|
||||||
@@ -129,14 +129,14 @@ export const useTasksStore = defineStore("tasks", () => {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setStatusFilter(status: TaskStatus | "") {
|
function setStatusFilter(statuses: TaskStatus[]) {
|
||||||
statusFilter.value = status;
|
statusFilter.value = statuses;
|
||||||
offset.value = 0;
|
offset.value = 0;
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|
||||||
function setPriorityFilter(priority: TaskPriority | "") {
|
function setPriorityFilter(priorities: TaskPriority[]) {
|
||||||
priorityFilter.value = priority;
|
priorityFilter.value = priorities;
|
||||||
offset.value = 0;
|
offset.value = 0;
|
||||||
refresh();
|
refresh();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 type TaskPriority = "none" | "low" | "medium" | "high";
|
||||||
|
|
||||||
export interface Note {
|
export interface Note {
|
||||||
@@ -13,6 +13,10 @@ export interface Note {
|
|||||||
status: TaskStatus | null;
|
status: TaskStatus | null;
|
||||||
priority: TaskPriority | null;
|
priority: TaskPriority | null;
|
||||||
due_date: string | null;
|
due_date: string | null;
|
||||||
|
started_at: string | null;
|
||||||
|
completed_at: string | null;
|
||||||
|
recurrence_rule: Record<string, unknown> | null;
|
||||||
|
recurrence_next_spawn_at: string | null;
|
||||||
is_task: boolean;
|
is_task: boolean;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_at: string;
|
updated_at: string;
|
||||||
|
|||||||
@@ -12,6 +12,7 @@ import { useTagSuggestions } from "@/composables/useTagSuggestions";
|
|||||||
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
import { useFloatingAssist } from "@/composables/useFloatingAssist";
|
||||||
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
import { apiPost, apiGet, apiPatch } from "@/api/client";
|
||||||
import type { TaskStatus, TaskPriority } from "@/types/task";
|
import type { TaskStatus, TaskPriority } from "@/types/task";
|
||||||
|
import type { Note } from "@/types/note";
|
||||||
import type { Editor } from "@tiptap/vue-3";
|
import type { Editor } from "@tiptap/vue-3";
|
||||||
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
|
||||||
import TiptapEditor from "@/components/TiptapEditor.vue";
|
import TiptapEditor from "@/components/TiptapEditor.vue";
|
||||||
@@ -23,6 +24,7 @@ import TaskLogSection from "@/components/TaskLogSection.vue";
|
|||||||
import DiffView from "@/components/DiffView.vue";
|
import DiffView from "@/components/DiffView.vue";
|
||||||
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
import ConfirmDialog from "@/components/ConfirmDialog.vue";
|
||||||
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
import VersionHistorySection from "@/components/VersionHistorySection.vue";
|
||||||
|
import RecurrenceEditor from "@/components/RecurrenceEditor.vue";
|
||||||
|
|
||||||
const route = useRoute();
|
const route = useRoute();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -40,6 +42,9 @@ const projectId = ref<number | null>(null);
|
|||||||
const milestoneId = ref<number | null>(null);
|
const milestoneId = ref<number | null>(null);
|
||||||
const parentId = ref<number | null>(null);
|
const parentId = ref<number | null>(null);
|
||||||
const parentTitle = ref("");
|
const parentTitle = ref("");
|
||||||
|
const startedAt = ref<string | null>(null);
|
||||||
|
const completedAt = ref<string | null>(null);
|
||||||
|
const recurrenceRule = ref<Record<string, unknown> | null>(null);
|
||||||
const parentSearchQuery = ref("");
|
const parentSearchQuery = ref("");
|
||||||
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
|
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
|
||||||
const parentSearchLoading = ref(false);
|
const parentSearchLoading = ref(false);
|
||||||
@@ -274,6 +279,10 @@ onMounted(async () => {
|
|||||||
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
parentId.value = (taskRec.parent_id as number | null) ?? null;
|
||||||
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
|
||||||
parentSearchQuery.value = parentTitle.value;
|
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;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
@@ -309,6 +318,7 @@ async function save() {
|
|||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
parent_id: parentId.value,
|
parent_id: parentId.value,
|
||||||
|
recurrence_rule: recurrenceRule.value,
|
||||||
};
|
};
|
||||||
if (isEditing.value) {
|
if (isEditing.value) {
|
||||||
await store.updateTask(taskId.value!, data);
|
await store.updateTask(taskId.value!, data);
|
||||||
@@ -373,6 +383,7 @@ async function doAutoSave() {
|
|||||||
project_id: projectId.value,
|
project_id: projectId.value,
|
||||||
milestone_id: milestoneId.value,
|
milestone_id: milestoneId.value,
|
||||||
parent_id: parentId.value,
|
parent_id: parentId.value,
|
||||||
|
recurrence_rule: recurrenceRule.value,
|
||||||
} as Record<string, unknown>);
|
} as Record<string, unknown>);
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
@@ -483,8 +494,19 @@ useEditorGuards(dirty, save);
|
|||||||
<option value="todo">Todo</option>
|
<option value="todo">Todo</option>
|
||||||
<option value="in_progress">In Progress</option>
|
<option value="in_progress">In Progress</option>
|
||||||
<option value="done">Done</option>
|
<option value="done">Done</option>
|
||||||
|
<option value="cancelled">Cancelled</option>
|
||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
<div v-if="startedAt || completedAt" class="sb-timestamps">
|
||||||
|
<div v-if="startedAt" class="sb-timestamp">
|
||||||
|
<span class="sb-ts-label">Started</span>
|
||||||
|
<span class="sb-ts-value">{{ new Date(startedAt).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
<div v-if="completedAt" class="sb-timestamp">
|
||||||
|
<span class="sb-ts-label">Completed</span>
|
||||||
|
<span class="sb-ts-value">{{ new Date(completedAt).toLocaleString() }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
<div class="sb-field">
|
<div class="sb-field">
|
||||||
<label class="sb-label">Priority</label>
|
<label class="sb-label">Priority</label>
|
||||||
<select v-model="priority" @change="markDirty" class="sb-select">
|
<select v-model="priority" @change="markDirty" class="sb-select">
|
||||||
@@ -498,6 +520,10 @@ useEditorGuards(dirty, save);
|
|||||||
<label class="sb-label">Due Date</label>
|
<label class="sb-label">Due Date</label>
|
||||||
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
|
<input v-model="dueDate" type="date" class="sb-input" @input="markDirty" />
|
||||||
</div>
|
</div>
|
||||||
|
<div class="sb-field">
|
||||||
|
<label class="sb-label">Recurrence</label>
|
||||||
|
<RecurrenceEditor v-model="recurrenceRule" @update:modelValue="markDirty" />
|
||||||
|
</div>
|
||||||
<div class="sb-field">
|
<div class="sb-field">
|
||||||
<label class="sb-label">Project</label>
|
<label class="sb-label">Project</label>
|
||||||
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
|
||||||
@@ -903,6 +929,28 @@ useEditorGuards(dirty, save);
|
|||||||
align-items: center;
|
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 */
|
/* Narrow screen: sidebar collapses */
|
||||||
@media (max-width: 720px) {
|
@media (max-width: 720px) {
|
||||||
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
.task-body { flex-direction: column; overflow-y: auto; overflow-x: hidden; }
|
||||||
|
|||||||
@@ -33,12 +33,14 @@ const statusCycle: Record<TaskStatus, TaskStatus> = {
|
|||||||
todo: "in_progress",
|
todo: "in_progress",
|
||||||
in_progress: "done",
|
in_progress: "done",
|
||||||
done: "todo",
|
done: "todo",
|
||||||
|
cancelled: "todo",
|
||||||
};
|
};
|
||||||
|
|
||||||
const statusDotClass: Record<TaskStatus, string> = {
|
const statusDotClass: Record<TaskStatus, string> = {
|
||||||
todo: "dot-todo",
|
todo: "dot-todo",
|
||||||
in_progress: "dot-in-progress",
|
in_progress: "dot-in-progress",
|
||||||
done: "dot-done",
|
done: "dot-done",
|
||||||
|
cancelled: "dot-cancelled",
|
||||||
};
|
};
|
||||||
|
|
||||||
function cycleSubTaskStatus(subTask: Note) {
|
function cycleSubTaskStatus(subTask: Note) {
|
||||||
@@ -137,8 +139,25 @@ const forwardStatus: Record<TaskStatus, TaskStatus | null> = {
|
|||||||
todo: "in_progress",
|
todo: "in_progress",
|
||||||
in_progress: "done",
|
in_progress: "done",
|
||||||
done: null,
|
done: null,
|
||||||
|
cancelled: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
function recurrenceSummary(rule: Record<string, unknown> | 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 advanceLabel = computed(() => {
|
||||||
const s = store.currentTask?.status as TaskStatus | undefined;
|
const s = store.currentTask?.status as TaskStatus | undefined;
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
@@ -318,6 +337,17 @@ const subTaskProgress = computed(() => {
|
|||||||
Due: {{ store.currentTask.due_date }}
|
Due: {{ store.currentTask.due_date }}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="task-meta-row" v-if="store.currentTask.started_at || store.currentTask.completed_at || store.currentTask.recurrence_rule">
|
||||||
|
<span v-if="store.currentTask.started_at" class="task-meta-item">
|
||||||
|
Started: {{ new Date(store.currentTask.started_at).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
<span v-if="store.currentTask.completed_at" class="task-meta-item">
|
||||||
|
Completed: {{ new Date(store.currentTask.completed_at).toLocaleString() }}
|
||||||
|
</span>
|
||||||
|
<span v-if="recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null)" class="task-meta-item task-meta-recurrence">
|
||||||
|
↻ {{ recurrenceSummary(store.currentTask.recurrence_rule as Record<string, unknown> | null) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
<div class="tags" v-if="store.currentTask.tags.length">
|
<div class="tags" v-if="store.currentTask.tags.length">
|
||||||
<TagPill
|
<TagPill
|
||||||
v-for="tag in store.currentTask.tags"
|
v-for="tag in store.currentTask.tags"
|
||||||
@@ -551,6 +581,20 @@ const subTaskProgress = computed(() => {
|
|||||||
color: var(--color-overdue);
|
color: var(--color-overdue);
|
||||||
font-weight: 600;
|
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 {
|
.tags {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 0.5rem;
|
gap: 0.5rem;
|
||||||
@@ -637,6 +681,9 @@ const subTaskProgress = computed(() => {
|
|||||||
.dot-done {
|
.dot-done {
|
||||||
background: var(--color-status-done, #22c55e);
|
background: var(--color-status-done, #22c55e);
|
||||||
}
|
}
|
||||||
|
.dot-cancelled {
|
||||||
|
background: var(--color-text-muted, #6b7280);
|
||||||
|
}
|
||||||
.sub-title {
|
.sub-title {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
font-size: 0.9rem;
|
font-size: 0.9rem;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
import { ref, computed, onMounted, onUnmounted, watch } from "vue";
|
||||||
import { useRoute, useRouter } from "vue-router";
|
import { useRoute, useRouter } from "vue-router";
|
||||||
import { useTasksStore } from "@/stores/tasks";
|
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 { apiGet } from "@/api/client";
|
||||||
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
import { useListKeyboardNavigation } from "@/composables/useListKeyboardNavigation";
|
||||||
import SearchBar from "@/components/SearchBar.vue";
|
import SearchBar from "@/components/SearchBar.vue";
|
||||||
@@ -122,7 +122,8 @@ onMounted(async () => {
|
|||||||
store.activeTagFilters = tags;
|
store.activeTagFilters = tags;
|
||||||
}
|
}
|
||||||
if (route.query.status) {
|
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;
|
store.limit = viewMode.value === "grouped" ? 100 : 200;
|
||||||
collapsedGroups.value.add("done");
|
collapsedGroups.value.add("done");
|
||||||
@@ -150,14 +151,20 @@ function onSearch(q: string) {
|
|||||||
store.setSearch(q);
|
store.setSearch(q);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onStatusFilterChange(e: Event) {
|
function toggleStatusChip(value: TaskStatus) {
|
||||||
const value = (e.target as HTMLSelectElement).value;
|
const current = [...store.statusFilter];
|
||||||
store.setStatusFilter(value as TaskStatus | "");
|
const idx = current.indexOf(value);
|
||||||
|
if (idx === -1) current.push(value);
|
||||||
|
else current.splice(idx, 1);
|
||||||
|
store.setStatusFilter(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onPriorityFilterChange(e: Event) {
|
function togglePriorityChip(value: TaskPriority) {
|
||||||
const value = (e.target as HTMLSelectElement).value;
|
const current = [...store.priorityFilter];
|
||||||
store.setPriorityFilter(value as any);
|
const idx = current.indexOf(value);
|
||||||
|
if (idx === -1) current.push(value);
|
||||||
|
else current.splice(idx, 1);
|
||||||
|
store.setPriorityFilter(current);
|
||||||
}
|
}
|
||||||
|
|
||||||
function onTagClick(tag: string) {
|
function onTagClick(tag: string) {
|
||||||
@@ -210,19 +217,20 @@ function toggleGroup(key: string) {
|
|||||||
|
|
||||||
<div class="controls">
|
<div class="controls">
|
||||||
<div class="filter-controls">
|
<div class="filter-controls">
|
||||||
<select :value="store.statusFilter" @change="onStatusFilterChange" class="filter-select">
|
<div class="filter-chip-group">
|
||||||
<option value="">All Statuses</option>
|
<button v-for="s in (['todo', 'in_progress', 'done', 'cancelled'] as TaskStatus[])" :key="s"
|
||||||
<option value="todo">Todo</option>
|
:class="['filter-chip', { active: store.statusFilter.includes(s) }]"
|
||||||
<option value="in_progress">In Progress</option>
|
@click="toggleStatusChip(s)">
|
||||||
<option value="done">Done</option>
|
{{ { todo: 'Todo', in_progress: 'In Progress', done: 'Done', cancelled: 'Cancelled' }[s] }}
|
||||||
</select>
|
</button>
|
||||||
<select :value="store.priorityFilter" @change="onPriorityFilterChange" class="filter-select">
|
</div>
|
||||||
<option value="">All Priorities</option>
|
<div class="filter-chip-group">
|
||||||
<option value="none">None</option>
|
<button v-for="p in (['low', 'medium', 'high'] as TaskPriority[])" :key="p"
|
||||||
<option value="low">Low</option>
|
:class="['filter-chip', { active: store.priorityFilter.includes(p) }]"
|
||||||
<option value="medium">Medium</option>
|
@click="togglePriorityChip(p)">
|
||||||
<option value="high">High</option>
|
{{ p.charAt(0).toUpperCase() + p.slice(1) }}
|
||||||
</select>
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="right-controls">
|
<div class="right-controls">
|
||||||
<div class="sort-controls">
|
<div class="sort-controls">
|
||||||
@@ -270,7 +278,7 @@ function toggleGroup(key: string) {
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="store.tasks.length === 0" class="empty-state">
|
<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-title">No tasks match your filters</p>
|
||||||
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
|
<p class="empty-subtitle">Try adjusting your search or removing filters.</p>
|
||||||
</template>
|
</template>
|
||||||
@@ -382,8 +390,29 @@ function toggleGroup(key: string) {
|
|||||||
}
|
}
|
||||||
.filter-controls {
|
.filter-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
gap: 0.5rem;
|
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 {
|
.right-controls {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|||||||
Reference in New Issue
Block a user