diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index 980cc66..baf3bba 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -705,3 +705,14 @@ export interface ProfileObservationEntry { export const listProfileObservations = () => apiGet<{ observations: ProfileObservationEntry[] }>('/api/profile/observations') + + +// ── Tasks ──────────────────────────────────────────────────────────────────── + +import type { Note as Task } from '../types/note' + +/** Manually trigger a consolidation pass for a task. Returns the freshly- + * updated task with new body + consolidated_at. Bypasses the user's + * auto_consolidate_tasks setting. */ +export const consolidateTask = (id: number) => + apiPost(`/api/tasks/${id}/consolidate`, {}) diff --git a/frontend/src/types/note.ts b/frontend/src/types/note.ts index 1e7c414..bceb8e2 100644 --- a/frontend/src/types/note.ts +++ b/frontend/src/types/note.ts @@ -6,6 +6,8 @@ export interface Note { id: number; title: string; body: string; + description: string | null; + consolidated_at: string | null; tags: string[]; parent_id: number | null; parent_title?: string | null; diff --git a/frontend/src/views/TaskEditorView.vue b/frontend/src/views/TaskEditorView.vue index 1521181..d60f618 100644 --- a/frontend/src/views/TaskEditorView.vue +++ b/frontend/src/views/TaskEditorView.vue @@ -35,6 +35,8 @@ const toast = useToastStore(); const title = ref(""); const body = ref(""); +const description = ref(""); +const consolidatedAt = ref(null); const tags = ref([]); const status = ref("todo"); const priority = ref("none"); @@ -186,6 +188,7 @@ const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTa let savedTitle = ""; let savedBody = ""; +let savedDescription = ""; let savedTags: string[] = []; let savedStatus: TaskStatus = "todo"; let savedPriority: TaskPriority = "none"; @@ -198,6 +201,7 @@ function markDirty() { dirty.value = title.value !== savedTitle || body.value !== savedBody || + description.value !== savedDescription || JSON.stringify(tags.value) !== JSON.stringify(savedTags) || status.value !== savedStatus || priority.value !== savedPriority || @@ -270,6 +274,8 @@ onMounted(async () => { if (store.currentTask) { title.value = store.currentTask.title; body.value = store.currentTask.body; + description.value = store.currentTask.description ?? ""; + consolidatedAt.value = store.currentTask.consolidated_at ?? null; tags.value = [...(store.currentTask.tags || [])]; status.value = store.currentTask.status as TaskStatus; priority.value = store.currentTask.priority as TaskPriority; @@ -286,6 +292,7 @@ onMounted(async () => { recurrenceRule.value = noteTask.recurrence_rule ?? null; savedTitle = title.value; savedBody = body.value; + savedDescription = description.value; savedTags = [...tags.value]; savedStatus = status.value; savedPriority = priority.value; @@ -312,6 +319,7 @@ async function save() { const data = { title: title.value, body: body.value, + description: description.value, tags: tags.value, status: status.value, priority: priority.value, @@ -325,6 +333,7 @@ async function save() { await store.updateTask(taskId.value!, data); savedTitle = title.value; savedBody = body.value; + savedDescription = description.value; savedTags = [...tags.value]; savedStatus = status.value; savedPriority = priority.value; @@ -377,6 +386,7 @@ async function doAutoSave() { await store.updateTask(taskId.value!, { title: title.value, body: body.value, + description: description.value, tags: tags.value, status: status.value, priority: priority.value, @@ -388,6 +398,7 @@ async function doAutoSave() { } as Record); savedTitle = title.value; savedBody = body.value; + savedDescription = description.value; savedTags = [...tags.value]; savedStatus = status.value; savedPriority = priority.value; @@ -431,7 +442,19 @@ useEditorGuards(dirty, save); @keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()" /> - +
+ + +
+ +
@@ -971,4 +994,38 @@ useEditorGuards(dirty, save); overflow-y: visible; } } + +/* ── Goal (description) input ─────────────────────────────────────────────── */ +.task-goal { + display: flex; + flex-direction: column; + gap: 0.25rem; + margin: 0.5rem 0 0.25rem; +} +.task-goal-label { + font-family: var(--font-display, "Fraunces", serif); + font-style: italic; + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); +} +.task-goal-input { + width: 100%; + resize: vertical; + min-height: 2.4rem; + padding: 0.5rem 0.6rem; + font: inherit; + font-size: 0.95rem; + line-height: 1.4; + color: var(--color-text, inherit); + background: var(--color-input-bg, rgba(255, 255, 255, 0.03)); + border: 1px solid var(--color-border, rgba(255, 255, 255, 0.08)); + border-radius: var(--radius-md, 8px); +} +.task-goal-input:focus { + outline: none; + border-color: var(--color-primary, #6366f1); +} \ No newline at end of file diff --git a/frontend/src/views/TaskViewerView.vue b/frontend/src/views/TaskViewerView.vue index 20439d8..f9065ef 100644 --- a/frontend/src/views/TaskViewerView.vue +++ b/frontend/src/views/TaskViewerView.vue @@ -357,6 +357,22 @@ const subTaskProgress = computed(() => { @click="onTagClick" />
+
+

Goal

+

{{ store.currentTask.description }}

+
+ +
+ + Auto-summarized from work logs. +
+
{ .skel-line { height: 0.9rem; } .skel-line--short { width: 50%; } .skel-line--medium { width: 78%; } + +/* ── Goal block + auto-summary banner ─────────────────────────────────────── */ +.task-goal-display { + border-left: 2px solid var(--color-border, rgba(255, 255, 255, 0.12)); + padding: 0.4rem 0 0.4rem 0.9rem; + margin: 0.75rem 0 1.25rem; + background: rgba(255, 255, 255, 0.02); +} +.goal-label { + font-family: var(--font-display, "Fraunces", serif); + font-style: italic; + font-size: 0.78rem; + font-weight: 500; + letter-spacing: 0.04em; + text-transform: uppercase; + color: var(--color-text-muted, rgba(255, 255, 255, 0.5)); + margin: 0 0 0.25rem; +} +.goal-text { + margin: 0; + font-size: 0.95rem; + line-height: 1.45; + color: var(--color-text, inherit); + white-space: pre-wrap; +} +.auto-summary-banner { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.78rem; + font-style: italic; + color: var(--color-text-muted, rgba(255, 255, 255, 0.55)); + margin: 0 0 0.75rem; +} +.auto-summary-icon { + color: var(--color-primary, #6366f1); + font-size: 0.85rem; +}