feat(frontend): description field in task editor + Goal block in viewer

Note type gains description and consolidated_at fields. TaskEditorView
adds a Goal textarea above the body editor (wired through dirty/save/
autosave paths). TaskViewerView renders Goal as a subordinate block
above the body, plus a subtle 'Auto-summarized from work logs' banner
when consolidated_at is set.

Also adds a consolidateTask client function for the upcoming
re-consolidate button (Task 11).
This commit is contained in:
2026-05-13 12:20:42 -04:00
parent 9191ab5b27
commit 8b0878f227
4 changed files with 125 additions and 1 deletions
+11
View File
@@ -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<Task>(`/api/tasks/${id}/consolidate`, {})
+2
View File
@@ -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;
+58 -1
View File
@@ -35,6 +35,8 @@ const toast = useToastStore();
const title = ref("");
const body = ref("");
const description = ref("");
const consolidatedAt = ref<string | null>(null);
const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("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<string, unknown>);
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()"
/>
</div><!-- /editor-header: title only -->
<div class="task-goal">
<label for="task-description" class="task-goal-label">Goal</label>
<textarea
id="task-description"
v-model="description"
placeholder="What are we trying to do here? (read-only context for the auto-summary)"
rows="2"
class="task-goal-input"
@input="markDirty"
></textarea>
</div>
</div><!-- /editor-header: title + goal -->
<!-- Two-column body: main (editor+log) | sidebar (metadata) -->
<div class="task-body">
@@ -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);
}
</style>
+54
View File
@@ -357,6 +357,22 @@ const subTaskProgress = computed(() => {
@click="onTagClick"
/>
</div>
<div
v-if="store.currentTask.description"
class="task-goal-display"
>
<h3 class="goal-label">Goal</h3>
<p class="goal-text">{{ store.currentTask.description }}</p>
</div>
<div
v-if="store.currentTask.consolidated_at"
class="auto-summary-banner"
>
<span class="auto-summary-icon" aria-hidden="true"></span>
Auto-summarized from work logs.
</div>
<div
class="body prose"
v-html="renderedBody"
@@ -807,4 +823,42 @@ const subTaskProgress = computed(() => {
.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;
}
</style>