Merge tasks into notes: a task is just a note with task attributes

A task is now a note with status/priority/due_date columns set (status IS NOT NULL).
This eliminates the separate tasks table, companion note system, cascade deletes,
bidirectional title sync, and _skip_cascade flags.

Migration (0004):
- Add status, priority, due_date columns to notes table
- Migrate task data from companion notes and orphan tasks
- Drop tasks table and old enum types

Backend:
- models/note.py: Add TaskStatus/TaskPriority enums, task columns, is_task property
- models/task.py: Deleted (merged into note.py)
- models/__init__.py: Re-export enums from note.py, remove Task import
- services/notes.py: Remove companion/cascade logic, add is_task filter,
  convert_note_to_task, convert_task_to_note, simplified backlinks
- services/tasks.py: Rewritten as thin wrappers around notes service
- routes/notes.py: Add is_task filter (default false), task fields in CRUD,
  convert-to-note endpoint
- routes/tasks.py: description→body (with fallback), remove note_id filter

Frontend:
- types/note.ts: Add TaskStatus, TaskPriority, task fields to Note interface
- types/task.ts: Task is now a re-export alias for Note
- stores/notes.ts: Simplify convertToTask, add convertToNote
- stores/tasks.ts: description→body in createTask/updateTask
- TaskEditorView: description→body, remove companion note UI
- TaskViewerView: description→body, remove companion note link, add Convert to Note
- NoteViewerView: Remove companion task UI, simplify convert-to-task
- TaskCard: description→body, non-null assertions for status/priority

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-10 17:56:12 -05:00
parent e2338918b0
commit 807cde30be
17 changed files with 439 additions and 532 deletions
+17 -50
View File
@@ -6,9 +6,7 @@ import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAutocomplete } from "@/composables/useAutocomplete";
import { apiGet } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Note } from "@/types/note";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
const route = useRoute();
@@ -18,12 +16,10 @@ const notesStore = useNotesStore();
const toast = useToastStore();
const title = ref("");
const description = ref("");
const body = ref("");
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
const noteId = ref<number | null>(null);
const companionNote = ref<Note | null>(null);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -34,7 +30,7 @@ const taskId = computed(() =>
);
const isEditing = computed(() => taskId.value !== null);
const renderedPreview = computed(() => renderMarkdown(description.value));
const renderedPreview = computed(() => renderMarkdown(body.value));
// Autocomplete
const {
@@ -47,10 +43,10 @@ const {
accept: acAccept,
dismiss: acDismiss,
onKeydown: acOnKeydown,
} = useAutocomplete(textareaRef, description, (q) => notesStore.fetchAllTags(q));
} = useAutocomplete(textareaRef, body, (q) => notesStore.fetchAllTags(q));
let savedTitle = "";
let savedDescription = "";
let savedBody = "";
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
@@ -58,7 +54,7 @@ let savedDueDate = "";
function markDirty() {
dirty.value =
title.value !== savedTitle ||
description.value !== savedDescription ||
body.value !== savedBody ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate;
@@ -71,7 +67,7 @@ function autoGrow() {
el.style.height = Math.max(el.scrollHeight, 200) + "px";
}
function onDescriptionInput() {
function onBodyInput() {
markDirty();
autoGrow();
detectTrigger();
@@ -93,10 +89,10 @@ function insertAtCursor(before: string, after: string, placeholder: string) {
el.focus();
const start = el.selectionStart;
const end = el.selectionEnd;
const selected = description.value.slice(start, end);
const selected = body.value.slice(start, end);
const insert = selected || placeholder;
description.value =
description.value.slice(0, start) + before + insert + after + description.value.slice(end);
body.value =
body.value.slice(0, start) + before + insert + after + body.value.slice(end);
markDirty();
nextTick(() => {
const newStart = start + before.length;
@@ -113,24 +109,15 @@ onMounted(async () => {
await store.fetchTask(taskId.value);
if (store.currentTask) {
title.value = store.currentTask.title;
description.value = store.currentTask.description;
status.value = store.currentTask.status;
priority.value = store.currentTask.priority;
body.value = store.currentTask.body;
status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || "";
noteId.value = store.currentTask.note_id;
savedTitle = title.value;
savedDescription = description.value;
savedBody = body.value;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
if (noteId.value) {
try {
companionNote.value = await apiGet<Note>(`/api/notes/${noteId.value}`);
} catch {
companionNote.value = null;
}
}
}
}
nextTick(autoGrow);
@@ -142,7 +129,7 @@ async function save() {
try {
const data = {
title: title.value,
description: description.value,
body: body.value,
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
@@ -150,7 +137,7 @@ async function save() {
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
savedTitle = title.value;
savedDescription = description.value;
savedBody = body.value;
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
@@ -254,10 +241,10 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
<div class="textarea-wrapper" v-show="!showPreview">
<textarea
ref="textareaRef"
v-model="description"
v-model="body"
placeholder="Describe this task... Use #tags inline"
class="body-input"
@input="onDescriptionInput"
@input="onBodyInput"
@keydown="onTextareaKeydown"
@blur="onTextareaBlur"
></textarea>
@@ -314,13 +301,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</div>
</div>
<div v-if="companionNote" class="field companion-note">
<label>Companion Note</label>
<router-link :to="`/notes/${companionNote.id}`" class="note-link">
{{ companionNote.title || "Untitled" }}
</router-link>
</div>
<!-- Delete confirmation -->
<teleport to="body">
<div v-if="showDeleteConfirm" class="modal-overlay" @click="showDeleteConfirm = false">
@@ -476,19 +456,6 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-text);
font-size: 0.9rem;
}
.companion-note {
flex-direction: row;
align-items: center;
gap: 0.5rem;
}
.note-link {
color: var(--color-primary);
text-decoration: none;
font-size: 0.9rem;
}
.note-link:hover {
text-decoration: underline;
}
.modal-overlay {
position: fixed;
inset: 0;