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:
@@ -705,3 +705,14 @@ export interface ProfileObservationEntry {
|
|||||||
|
|
||||||
export const listProfileObservations = () =>
|
export const listProfileObservations = () =>
|
||||||
apiGet<{ observations: ProfileObservationEntry[] }>('/api/profile/observations')
|
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`, {})
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ export interface Note {
|
|||||||
id: number;
|
id: number;
|
||||||
title: string;
|
title: string;
|
||||||
body: string;
|
body: string;
|
||||||
|
description: string | null;
|
||||||
|
consolidated_at: string | null;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
parent_id: number | null;
|
parent_id: number | null;
|
||||||
parent_title?: string | null;
|
parent_title?: string | null;
|
||||||
|
|||||||
@@ -35,6 +35,8 @@ const toast = useToastStore();
|
|||||||
|
|
||||||
const title = ref("");
|
const title = ref("");
|
||||||
const body = ref("");
|
const body = ref("");
|
||||||
|
const description = ref("");
|
||||||
|
const consolidatedAt = ref<string | null>(null);
|
||||||
const tags = ref<string[]>([]);
|
const tags = ref<string[]>([]);
|
||||||
const status = ref<TaskStatus>("todo");
|
const status = ref<TaskStatus>("todo");
|
||||||
const priority = ref<TaskPriority>("none");
|
const priority = ref<TaskPriority>("none");
|
||||||
@@ -186,6 +188,7 @@ const { suggestedTags, appliedTags, suggestingTags, fetchTagSuggestions, applyTa
|
|||||||
|
|
||||||
let savedTitle = "";
|
let savedTitle = "";
|
||||||
let savedBody = "";
|
let savedBody = "";
|
||||||
|
let savedDescription = "";
|
||||||
let savedTags: string[] = [];
|
let savedTags: string[] = [];
|
||||||
let savedStatus: TaskStatus = "todo";
|
let savedStatus: TaskStatus = "todo";
|
||||||
let savedPriority: TaskPriority = "none";
|
let savedPriority: TaskPriority = "none";
|
||||||
@@ -198,6 +201,7 @@ function markDirty() {
|
|||||||
dirty.value =
|
dirty.value =
|
||||||
title.value !== savedTitle ||
|
title.value !== savedTitle ||
|
||||||
body.value !== savedBody ||
|
body.value !== savedBody ||
|
||||||
|
description.value !== savedDescription ||
|
||||||
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
|
||||||
status.value !== savedStatus ||
|
status.value !== savedStatus ||
|
||||||
priority.value !== savedPriority ||
|
priority.value !== savedPriority ||
|
||||||
@@ -270,6 +274,8 @@ onMounted(async () => {
|
|||||||
if (store.currentTask) {
|
if (store.currentTask) {
|
||||||
title.value = store.currentTask.title;
|
title.value = store.currentTask.title;
|
||||||
body.value = store.currentTask.body;
|
body.value = store.currentTask.body;
|
||||||
|
description.value = store.currentTask.description ?? "";
|
||||||
|
consolidatedAt.value = store.currentTask.consolidated_at ?? null;
|
||||||
tags.value = [...(store.currentTask.tags || [])];
|
tags.value = [...(store.currentTask.tags || [])];
|
||||||
status.value = store.currentTask.status as TaskStatus;
|
status.value = store.currentTask.status as TaskStatus;
|
||||||
priority.value = store.currentTask.priority as TaskPriority;
|
priority.value = store.currentTask.priority as TaskPriority;
|
||||||
@@ -286,6 +292,7 @@ onMounted(async () => {
|
|||||||
recurrenceRule.value = noteTask.recurrence_rule ?? null;
|
recurrenceRule.value = noteTask.recurrence_rule ?? null;
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedDescription = description.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedStatus = status.value;
|
savedStatus = status.value;
|
||||||
savedPriority = priority.value;
|
savedPriority = priority.value;
|
||||||
@@ -312,6 +319,7 @@ async function save() {
|
|||||||
const data = {
|
const data = {
|
||||||
title: title.value,
|
title: title.value,
|
||||||
body: body.value,
|
body: body.value,
|
||||||
|
description: description.value,
|
||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
status: status.value,
|
status: status.value,
|
||||||
priority: priority.value,
|
priority: priority.value,
|
||||||
@@ -325,6 +333,7 @@ async function save() {
|
|||||||
await store.updateTask(taskId.value!, data);
|
await store.updateTask(taskId.value!, data);
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedDescription = description.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedStatus = status.value;
|
savedStatus = status.value;
|
||||||
savedPriority = priority.value;
|
savedPriority = priority.value;
|
||||||
@@ -377,6 +386,7 @@ async function doAutoSave() {
|
|||||||
await store.updateTask(taskId.value!, {
|
await store.updateTask(taskId.value!, {
|
||||||
title: title.value,
|
title: title.value,
|
||||||
body: body.value,
|
body: body.value,
|
||||||
|
description: description.value,
|
||||||
tags: tags.value,
|
tags: tags.value,
|
||||||
status: status.value,
|
status: status.value,
|
||||||
priority: priority.value,
|
priority: priority.value,
|
||||||
@@ -388,6 +398,7 @@ async function doAutoSave() {
|
|||||||
} as Record<string, unknown>);
|
} as Record<string, unknown>);
|
||||||
savedTitle = title.value;
|
savedTitle = title.value;
|
||||||
savedBody = body.value;
|
savedBody = body.value;
|
||||||
|
savedDescription = description.value;
|
||||||
savedTags = [...tags.value];
|
savedTags = [...tags.value];
|
||||||
savedStatus = status.value;
|
savedStatus = status.value;
|
||||||
savedPriority = priority.value;
|
savedPriority = priority.value;
|
||||||
@@ -431,7 +442,19 @@ useEditorGuards(dirty, save);
|
|||||||
@keydown.ctrl.e.prevent="tiptapEditor?.commands.focus()"
|
@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) -->
|
<!-- Two-column body: main (editor+log) | sidebar (metadata) -->
|
||||||
<div class="task-body">
|
<div class="task-body">
|
||||||
@@ -971,4 +994,38 @@ useEditorGuards(dirty, save);
|
|||||||
overflow-y: visible;
|
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>
|
</style>
|
||||||
@@ -357,6 +357,22 @@ const subTaskProgress = computed(() => {
|
|||||||
@click="onTagClick"
|
@click="onTagClick"
|
||||||
/>
|
/>
|
||||||
</div>
|
</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
|
<div
|
||||||
class="body prose"
|
class="body prose"
|
||||||
v-html="renderedBody"
|
v-html="renderedBody"
|
||||||
@@ -807,4 +823,42 @@ const subTaskProgress = computed(() => {
|
|||||||
.skel-line { height: 0.9rem; }
|
.skel-line { height: 0.9rem; }
|
||||||
.skel-line--short { width: 50%; }
|
.skel-line--short { width: 50%; }
|
||||||
.skel-line--medium { width: 78%; }
|
.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>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user