feat(knowledge): add task cards with status/priority/due-date display

This commit is contained in:
2026-04-08 10:32:51 -04:00
parent dd16b39218
commit 34729cf1cf
+98 -24
View File
@@ -15,7 +15,7 @@ const chatStore = useChatStore();
interface KnowledgeItem {
id: number;
note_type: "note" | "person" | "place" | "list";
note_type: "note" | "person" | "place" | "list" | "task";
title: string;
snippet: string;
tags: string[];
@@ -33,6 +33,10 @@ interface KnowledgeItem {
checked_count?: number;
list_items?: { text: string; checked: boolean }[];
body?: string;
// Task-specific
status?: string;
priority?: string;
due_date?: string;
}
interface UpcomingEvent {
@@ -44,7 +48,7 @@ interface UpcomingEvent {
// ─── Filter state ─────────────────────────────────────────────────────────────
const activeType = ref<"" | "note" | "person" | "place" | "list">("");
const activeType = ref<"" | "note" | "person" | "place" | "list" | "task">("");
const activeTag = ref("");
const sortMode = ref<"modified" | "created" | "alpha" | "type">("modified");
const searchQuery = ref("");
@@ -69,7 +73,18 @@ const newNoteMenuOpen = ref(false);
function createNew(type: string) {
newNoteMenuOpen.value = false;
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
if (type === "task") {
router.push("/tasks/new");
} else {
router.push(type === "note" ? "/notes/new" : `/notes/new?type=${type}`);
}
}
function onClickOutsideNewNote(e: MouseEvent) {
const wrap = document.querySelector('.new-note-wrap');
if (wrap && !wrap.contains(e.target as Node)) {
newNoteMenuOpen.value = false;
}
}
// ─── Two-tier pagination ──────────────────────────────────────────────────────
@@ -307,8 +322,17 @@ async function toggleListItem(item: KnowledgeItem, index: number) {
// ─── Navigation helpers ───────────────────────────────────────────────────────
function isOverdue(item: KnowledgeItem): boolean {
if (!item.due_date || item.status === 'done' || item.status === 'cancelled') return false;
return new Date(item.due_date) < new Date(new Date().toDateString());
}
function openItem(item: KnowledgeItem) {
router.push(`/notes/${item.id}`);
if (item.note_type === 'task') {
router.push(`/tasks/${item.id}`);
} else {
router.push(`/notes/${item.id}`);
}
}
function formatDate(iso: string): string {
@@ -344,6 +368,7 @@ function setupObserver() {
// ─── Lifecycle ────────────────────────────────────────────────────────────────
onMounted(async () => {
document.addEventListener('click', onClickOutsideNewNote);
await reset();
fetchTags();
fetchCounts();
@@ -353,6 +378,7 @@ onMounted(async () => {
});
onUnmounted(() => {
document.removeEventListener('click', onClickOutsideNewNote);
if (searchDebounce) clearTimeout(searchDebounce);
observer?.disconnect();
});
@@ -392,10 +418,9 @@ onUnmounted(() => {
<aside class="filter-panel">
<!-- New note button -->
<div class="new-note-wrap">
<button class="btn-new-note" @click="createNew('note')">+ New note</button>
<button class="btn-new-chevron" @click="newNoteMenuOpen = !newNoteMenuOpen" :class="{ open: newNoteMenuOpen }" title="Create specific type"></button>
<button class="btn-new-note" @click="newNoteMenuOpen ? createNew('note') : (newNoteMenuOpen = true)">+ New note</button>
<div v-if="newNoteMenuOpen" class="new-note-menu">
<button @click="createNew('note')">Note</button>
<button @click="createNew('task')">Task</button>
<button @click="createNew('person')">Person</button>
<button @click="createNew('place')">Place</button>
<button @click="createNew('list')">List</button>
@@ -413,11 +438,11 @@ onUnmounted(() => {
<span v-if="typeCounts.total > 1" class="filter-count">{{ typeCounts.total }}</span>
</button>
<button
v-for="[val, label, key] in ([['note','Notes','note'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
v-for="[val, label, key] in ([['note','Notes','note'],['task','Tasks','task'],['person','People','person'],['place','Places','place'],['list','Lists','list']] as [string,string,string][])"
:key="val"
class="filter-btn"
:class="{ active: activeType === val }"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list')"
@click="activeType = (val as '' | 'note' | 'person' | 'place' | 'list' | 'task')"
>
<span class="filter-btn-label">{{ label }}</span>
<span v-if="typeCounts[key as keyof KnowledgeCounts] > 1" class="filter-count">{{ typeCounts[key as keyof KnowledgeCounts] }}</span>
@@ -533,6 +558,26 @@ onUnmounted(() => {
</span>
</div>
<!-- Task specifics -->
<div v-else-if="item.note_type === 'task'" class="k-card-task">
<div class="task-badges">
<span class="status-badge" :class="`status--${item.status}`">
{{ item.status === 'in_progress' ? 'in progress' : item.status }}
</span>
<span
v-if="item.priority && item.priority !== 'none'"
class="priority-badge"
:class="`priority--${item.priority}`"
>{{ item.priority }}</span>
</div>
<span
v-if="item.due_date"
class="task-due"
:class="{ 'task-overdue': isOverdue(item) }"
>{{ formatDate(item.due_date) }}</span>
<p v-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
<!-- Note snippet -->
<p v-else-if="item.snippet" class="k-card-snippet">{{ item.snippet }}</p>
</div>
@@ -720,9 +765,8 @@ onUnmounted(() => {
.btn-new-note {
flex: 1;
padding: 7px 10px;
border-radius: 8px 0 0 8px;
border-radius: 8px;
border: 1px solid rgba(99, 102, 241, 0.4);
border-right: none;
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
@@ -732,19 +776,6 @@ onUnmounted(() => {
transition: background 0.15s;
}
.btn-new-note:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron {
padding: 7px 9px;
border-radius: 0 8px 8px 0;
border: 1px solid rgba(99, 102, 241, 0.4);
background: rgba(99, 102, 241, 0.12);
color: var(--color-primary, #818cf8);
cursor: pointer;
font-size: 0.78rem;
line-height: 1;
transition: background 0.15s, transform 0.15s;
}
.btn-new-chevron:hover { background: rgba(99, 102, 241, 0.2); }
.btn-new-chevron.open { transform: scaleY(-1); }
.new-note-menu {
position: absolute;
top: calc(100% + 4px);
@@ -921,6 +952,7 @@ onUnmounted(() => {
.k-card--place { border-left: 3px solid #f59e0b; }
.k-card--list { border-left: 3px solid #38bdf8; }
.k-card--note { border-left: 3px solid #6366f1; }
.k-card--task { border-left: 3px solid #a78bfa; }
/* Type badge */
.type-badge {
@@ -938,6 +970,7 @@ onUnmounted(() => {
.badge--person { background: rgba(16,185,129,0.15); color: #34d399; }
.badge--place { background: rgba(245,158,11,0.15); color: #fbbf24; }
.badge--list { background: rgba(56,189,248,0.15); color: #7dd3fc; }
.badge--task { background: rgba(167,139,250,0.15); color: #a78bfa; }
.k-card-body { flex: 1; padding-right: 40px; }
.k-card-title {
@@ -1040,6 +1073,47 @@ onUnmounted(() => {
}
.k-card-date { font-size: 0.72rem; color: var(--color-muted); white-space: nowrap; }
/* ── Task card ──────────────────────────────────────────── */
.k-card-task {
display: flex;
flex-direction: column;
gap: 6px;
}
.task-badges {
display: flex;
gap: 5px;
flex-wrap: wrap;
}
.status-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.status--todo { background: var(--color-status-todo-bg); color: var(--color-status-todo); }
.status--in_progress { background: var(--color-status-in-progress-bg); color: var(--color-status-in-progress); }
.status--done { background: var(--color-status-done-bg); color: var(--color-status-done); }
.status--cancelled { background: var(--color-status-todo-bg); color: var(--color-status-todo); text-decoration: line-through; }
.priority-badge {
font-size: 0.7rem;
padding: 1px 7px;
border-radius: 8px;
font-weight: 600;
}
.priority--low { background: var(--color-priority-low-bg); color: var(--color-priority-low); }
.priority--normal { background: var(--color-priority-medium-bg); color: var(--color-priority-medium); }
.priority--high { background: var(--color-priority-high-bg); color: var(--color-priority-high); }
.task-due {
font-size: 0.78rem;
color: var(--color-text-muted);
}
.task-overdue {
color: var(--color-overdue);
font-weight: 500;
}
/* ── Empty / loading ─────────────────────────────────────── */
.knowledge-empty {
flex: 1;