@@ -0,0 +1,79 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* A task's KIND, shown on a list row — issue, spike, or a legacy plan.
|
||||
*
|
||||
* Sibling of PriorityBadge, and shaped like it on purpose: same geometry, and
|
||||
* the same rule that the DEFAULT value renders nothing. `work` is most tasks,
|
||||
* so badging it would put a chip on nearly every row and say nothing — the
|
||||
* same reason RuleListPane marks only `conditional`.
|
||||
*
|
||||
* Kind is not status. A task can be an in-progress issue or a done spike;
|
||||
* this answers "what kind of work is this", never "how is it going".
|
||||
*/
|
||||
import type { TaskKind } from "@/types/note";
|
||||
|
||||
const props = defineProps<{ kind?: TaskKind | null }>();
|
||||
|
||||
const LABELS: Record<string, string> = {
|
||||
issue: "Issue",
|
||||
spike: "Spike",
|
||||
plan: "Plan",
|
||||
};
|
||||
|
||||
const TITLES: Record<string, string> = {
|
||||
issue: "Corrective work — something was broken",
|
||||
spike: "Time-boxed investigation — the output is an answer, not a change",
|
||||
plan: "Legacy plan-task; plans are milestones now",
|
||||
};
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<span
|
||||
v-if="props.kind && LABELS[props.kind]"
|
||||
:class="['kind-badge', `kind-${props.kind}`]"
|
||||
:title="TITLES[props.kind]"
|
||||
>{{ LABELS[props.kind] }}</span>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.kind-badge {
|
||||
display: inline-block;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
/* 500, not the 600 StatusBadge and PriorityBadge use. The house style
|
||||
allows two weights, 400 and 500 — those two predate the constraint and
|
||||
copying them would spread it. */
|
||||
font-weight: 500;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.025em;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* Issue and spike are opposite in character — corrective vs exploratory — so
|
||||
they are split by TEMPERATURE, warm against cool, which survives being
|
||||
small and stays distinguishable without relying on reading the word.
|
||||
Neither uses the accent: one accent per app, and kind is not one of the
|
||||
places it is allowed.
|
||||
The text is the hue mixed toward --fs-text-primary rather than the raw
|
||||
semantic colour. Raw fails the contrast floor on the dark palette —
|
||||
measured: warning on its own 12% tint is 2.97:1, well under AA's 4.5.
|
||||
Mixing toward the text token also makes these follow the mode for free,
|
||||
since that token inverts. Measured both ways: issue 5.23:1 dark / 6.68:1
|
||||
light, spike 5.33:1 / 9.26:1. */
|
||||
.kind-issue {
|
||||
background: color-mix(in srgb, var(--fs-warning) 14%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-warning) 60%, var(--fs-text-primary));
|
||||
}
|
||||
.kind-spike {
|
||||
background: color-mix(in srgb, var(--fs-info) 14%, var(--fs-surface-raised));
|
||||
color: color-mix(in srgb, var(--fs-info) 50%, var(--fs-text-primary));
|
||||
}
|
||||
/* Retired since 0066 — deliberately hue-free so a legacy row reads as
|
||||
archival rather than as a fourth active kind competing for attention. */
|
||||
.kind-plan {
|
||||
background: var(--fs-surface-raised);
|
||||
color: var(--fs-text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
@@ -4,6 +4,8 @@ import { RouterLink } from "vue-router";
|
||||
import { apiGet, apiPatch, apiPost, apiDelete } from "@/api/client";
|
||||
import { useToastStore } from "@/stores/toast";
|
||||
import TaskLogSection from "@/components/TaskLogSection.vue";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import { Trash2, X } from "lucide-vue-next";
|
||||
import { relativeTimeOrDate } from "@/composables/useRelativeTime";
|
||||
@@ -28,6 +30,7 @@ interface Task {
|
||||
due_date: string | null;
|
||||
updated_at: string;
|
||||
body?: string;
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
const tasks = ref<Task[]>([]);
|
||||
@@ -242,6 +245,7 @@ defineExpose({ reload: loadAll });
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
@@ -267,6 +271,7 @@ defineExpose({ reload: loadAll });
|
||||
<button :class="['status-dot', `status-${task.status}`]" :title="`${task.status} — click to cycle`" @click="cycleStatus(task, $event)">{{ STATUS_ICON[task.status] ?? '○' }}</button>
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', PRIORITY_CLASS[task.priority] ?? '']"></span>
|
||||
<span class="task-title" :class="{ done: task.status === 'done' }">{{ task.title }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<span v-if="task.due_date" :class="['task-due', { overdue: isRowOverdue(task) }]">{{ task.due_date }}</span>
|
||||
<span class="task-age">{{ relativeTimeOrDate(task.updated_at) }}</span>
|
||||
</li>
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from "vue";
|
||||
import { apiGet } from "@/api/client";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
|
||||
interface TaskRow { id: number; title: string; status: string; priority: string }
|
||||
interface TaskRow { id: number; title: string; status: string; priority: string; task_kind?: TaskKind }
|
||||
interface MilestoneBlock { id: number; title: string; progress_pct: number; open_tasks: TaskRow[] }
|
||||
interface ActiveProject {
|
||||
id: number; title: string; color: string | null; last_activity: string;
|
||||
@@ -99,6 +101,7 @@ onMounted(async () => {
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<KindBadge :kind="t.task_kind" />
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
@@ -114,6 +117,7 @@ onMounted(async () => {
|
||||
>
|
||||
<span class="task-mark">{{ t.status === 'in_progress' ? '▸' : '○' }}</span>
|
||||
<span class="task-title">{{ t.title }}</span>
|
||||
<KindBadge :kind="t.task_kind" />
|
||||
<span v-if="t.priority !== 'none'" class="task-pri" :class="`pri-${t.priority}`">{{ t.priority }}</span>
|
||||
</router-link>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
import { ref, computed, watch, onMounted, onUnmounted, nextTick } from "vue";
|
||||
import { useRouter } from "vue-router";
|
||||
import { apiGet } from "@/api/client";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import GraphView from "@/views/GraphView.vue";
|
||||
import {
|
||||
FileText,
|
||||
@@ -35,7 +37,7 @@ interface KnowledgeItem {
|
||||
status?: string;
|
||||
priority?: string;
|
||||
due_date?: string;
|
||||
task_kind?: "work" | "plan";
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
// ─── Filter state ─────────────────────────────────────────────────────────────
|
||||
@@ -498,6 +500,12 @@ onUnmounted(() => {
|
||||
<span v-else-if="item.note_type === 'task'">{{ item.task_kind === 'plan' ? 'Plan' : 'Task' }}</span>
|
||||
<span v-else-if="item.note_type === 'process'">Process</span>
|
||||
</span>
|
||||
<!-- Kind sits BESIDE the type badge, not inside it: the type badge
|
||||
speaks the vocabulary of this view's type filter (note / task /
|
||||
plan / process), and kind is the other axis. `plan` is passed
|
||||
as null because the badge to the left already says it — two
|
||||
chips reading "Plan" would look like two facts. -->
|
||||
<KindBadge :kind="item.task_kind === 'plan' ? null : item.task_kind" />
|
||||
|
||||
<div class="k-card-body">
|
||||
<div class="k-card-title">{{ item.title }}</div>
|
||||
|
||||
@@ -8,6 +8,8 @@ import { useTasksStore } from "@/stores/tasks";
|
||||
import { relativeTime } from "@/composables/useRelativeTime";
|
||||
import { renderMarkdown } from "@/utils/markdown";
|
||||
import ShareDialog from "@/components/ShareDialog.vue";
|
||||
import KindBadge from "@/components/KindBadge.vue";
|
||||
import type { TaskKind } from "@/types/note";
|
||||
import ProjectDesignTab from "@/components/ProjectDesignTab.vue";
|
||||
import ProjectRulesTab from "@/components/rules/ProjectRulesTab.vue";
|
||||
import SystemsSection from "@/components/SystemsSection.vue";
|
||||
@@ -74,6 +76,7 @@ interface NoteItem {
|
||||
due_date?: string | null;
|
||||
updated_at: string;
|
||||
milestone_id?: number | null;
|
||||
task_kind?: TaskKind;
|
||||
}
|
||||
|
||||
const route = useRoute();
|
||||
@@ -1046,6 +1049,7 @@ async function confirmDelete() {
|
||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div class="task-card-footer">
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
@@ -1077,6 +1081,7 @@ async function confirmDelete() {
|
||||
:class="['task-card', `pri-${task.priority || 'none'}`]"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div class="task-card-footer">
|
||||
<div v-if="task.priority !== 'none' || task.due_date" class="task-meta">
|
||||
<span v-if="task.priority && task.priority !== 'none'" :class="['priority-dot', `dot-pri-${task.priority}`]" :title="task.priority"></span>
|
||||
@@ -1108,6 +1113,7 @@ async function confirmDelete() {
|
||||
class="task-card task-card-done"
|
||||
>
|
||||
<span class="task-title">{{ task.title || "Untitled" }}</span>
|
||||
<KindBadge :kind="task.task_kind" />
|
||||
<div v-if="task.due_date" class="task-meta">
|
||||
<span class="due-date">{{ task.due_date }}</span>
|
||||
</div>
|
||||
|
||||
@@ -39,8 +39,13 @@ def _open_order():
|
||||
|
||||
|
||||
def _task_row(n: Note) -> dict:
|
||||
# task_kind rides along so a dashboard row can show WHAT KIND of work it
|
||||
# is, not just how it is going. Omitting it made the kind badge render
|
||||
# nothing here while working everywhere else — the badge was correct and
|
||||
# the payload was short, which reads as "no issues in this list" rather
|
||||
# than as a missing field.
|
||||
return {"id": n.id, "title": n.title, "status": n.status,
|
||||
"priority": n.priority or "none"}
|
||||
"priority": n.priority or "none", "task_kind": n.task_kind}
|
||||
|
||||
|
||||
async def _safe(coro, empty):
|
||||
|
||||
@@ -16,11 +16,34 @@ def test_task_row_maps_fields():
|
||||
n.title = "Wire reminders"
|
||||
n.status = "in_progress"
|
||||
n.priority = None
|
||||
# Named, not left to MagicMock: an unset attribute is a truthy Mock, so
|
||||
# the assertion would pass on a field the row never really carried
|
||||
# (note 2109 — the reason fake_note exists).
|
||||
n.task_kind = "spike"
|
||||
assert _task_row(n) == {
|
||||
"id": 5, "title": "Wire reminders", "status": "in_progress", "priority": "none",
|
||||
"id": 5, "title": "Wire reminders", "status": "in_progress",
|
||||
"priority": "none", "task_kind": "spike",
|
||||
}
|
||||
|
||||
|
||||
def test_task_row_carries_the_kind_so_a_row_can_show_it():
|
||||
"""The field whose ABSENCE is invisible.
|
||||
|
||||
A dashboard row with no task_kind renders no kind badge, which looks
|
||||
exactly like a list containing no issues and no spikes. The badge is
|
||||
correct and the payload is short — so the guard belongs on the payload,
|
||||
where the omission actually was.
|
||||
"""
|
||||
from scribe.services.dashboard import _task_row
|
||||
n = MagicMock()
|
||||
n.id = 1
|
||||
n.title = "t"
|
||||
n.status = "todo"
|
||||
n.priority = "none"
|
||||
n.task_kind = "issue"
|
||||
assert _task_row(n)["task_kind"] == "issue"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_safe_returns_value_then_empty_on_error():
|
||||
from scribe.services.dashboard import _safe
|
||||
|
||||
Reference in New Issue
Block a user