Add Projects, Milestones, RAG auto-inject, push notifications, PWA, tag normalisation

## Projects & Milestones (Phases A + G)
- New models: Project, Milestone (Project → Milestone → Task hierarchy)
- notes table: project_id + milestone_id FKs; parent_id FK constraint activated
- Migrations: 0017 (projects), 0018 (push_subscriptions), 0019 (events), 0020 (milestones)
- Services: projects.py, milestones.py (CRUD + progress tracking)
- Routes: /api/projects + /api/projects/<id>/milestones
- LLM tools: create/list/get/update project; create/list milestone; project + milestone + parent_task params on note/task tools
- Frontend: ProjectListView (stacked milestone bars), ProjectView (milestone-grouped kanban), ProjectSelector, MilestoneSelector, NoteEditorView + TaskEditorView updated

## RAG Auto-injection (Phase B)
- Notes ≥0.60 cosine similarity auto-injected into system prompt (max 3, 800 chars each)
- excluded_note_ids param; ChatView "Auto-included" sidebar section

## Summarisation improvements (Phase C)
- Threshold 20→30, keep-recent 6→8, max_tokens 200→400
- Two-pass summarisation for histories >50 messages

## Browser push notifications (Phase E)
- PushSubscription model + migration; pywebpush dependency
- /api/push routes; VAPID config; fire-and-forget on generation complete
- Frontend: sw.js, push store, Settings toggle

## PWA manifest (Phase F)
- manifest.json, Apple meta tags, service worker registration in main.ts

## Tag normalisation
- All tags lowercased + deduplicated at backend (create_note/update_note) and frontend (TagInput sanitize)
- Note/Task types gain project_id + milestone_id fields; store signatures updated

## CalDAV
- Radicale embedded server reverted; back to user-configured external CalDAV

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-02 20:52:21 -05:00
parent 3d7be5888e
commit 012eb1d46b
52 changed files with 4319 additions and 62 deletions
+181 -3
View File
@@ -6,12 +6,14 @@ import { useNotesStore } from "@/stores/notes";
import { useToastStore } from "@/stores/toast";
import { renderMarkdown } from "@/utils/markdown";
import { useAssist } from "@/composables/useAssist";
import { apiPost } from "@/api/client";
import { apiPost, apiGet } from "@/api/client";
import type { TaskStatus, TaskPriority } from "@/types/task";
import type { Editor } from "@tiptap/vue-3";
import MarkdownToolbar from "@/components/MarkdownToolbar.vue";
import TiptapEditor from "@/components/TiptapEditor.vue";
import TagInput from "@/components/TagInput.vue";
import ProjectSelector from "@/components/ProjectSelector.vue";
import MilestoneSelector from "@/components/MilestoneSelector.vue";
const route = useRoute();
const router = useRouter();
@@ -25,6 +27,15 @@ const tags = ref<string[]>([]);
const status = ref<TaskStatus>("todo");
const priority = ref<TaskPriority>("none");
const dueDate = ref("");
const projectId = ref<number | null>(null);
const milestoneId = ref<number | null>(null);
const parentId = ref<number | null>(null);
const parentTitle = ref("");
const parentSearchQuery = ref("");
const parentSearchResults = ref<{ id: number; title: string }[]>([]);
const parentSearchLoading = ref(false);
const showParentDropdown = ref(false);
let parentSearchTimer: ReturnType<typeof setTimeout> | null = null;
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -144,6 +155,9 @@ let savedTags: string[] = [];
let savedStatus: TaskStatus = "todo";
let savedPriority: TaskPriority = "none";
let savedDueDate = "";
let savedProjectId: number | null = null;
let savedMilestoneId: number | null = null;
let savedParentId: number | null = null;
function markDirty() {
dirty.value =
@@ -152,7 +166,10 @@ function markDirty() {
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
status.value !== savedStatus ||
priority.value !== savedPriority ||
dueDate.value !== savedDueDate;
dueDate.value !== savedDueDate ||
projectId.value !== savedProjectId ||
milestoneId.value !== savedMilestoneId ||
parentId.value !== savedParentId;
}
function onBodyUpdate(newVal: string) {
@@ -160,6 +177,58 @@ function onBodyUpdate(newVal: string) {
markDirty();
}
function onParentSearchInput() {
if (parentSearchTimer) clearTimeout(parentSearchTimer);
if (!parentSearchQuery.value.trim()) {
parentSearchResults.value = [];
showParentDropdown.value = false;
return;
}
parentSearchTimer = setTimeout(async () => {
const q = parentSearchQuery.value.trim();
if (!q) return;
parentSearchLoading.value = true;
showParentDropdown.value = true;
try {
const data = await apiGet<{ notes: Array<{ id: number; title: string }> }>(
`/api/notes?q=${encodeURIComponent(q)}&type=task&limit=8`
);
parentSearchResults.value = data.notes.filter((t) =>
!taskId.value || t.id !== taskId.value
);
} catch {
parentSearchResults.value = [];
} finally {
parentSearchLoading.value = false;
}
}, 250);
}
function selectParentTask(task: { id: number; title: string }) {
parentId.value = task.id;
parentTitle.value = task.title;
parentSearchQuery.value = task.title;
showParentDropdown.value = false;
markDirty();
}
function onParentFocus() {
if (parentSearchQuery.value) showParentDropdown.value = true;
}
function hideParentDropdown() {
setTimeout(() => { showParentDropdown.value = false; }, 200);
}
function clearParentTask() {
parentId.value = null;
parentTitle.value = "";
parentSearchQuery.value = "";
parentSearchResults.value = [];
showParentDropdown.value = false;
markDirty();
}
onMounted(async () => {
if (taskId.value) {
await store.fetchTask(taskId.value);
@@ -170,12 +239,21 @@ onMounted(async () => {
status.value = store.currentTask.status as TaskStatus;
priority.value = store.currentTask.priority as TaskPriority;
dueDate.value = store.currentTask.due_date || "";
const taskRec = store.currentTask as Record<string, unknown>;
projectId.value = (taskRec.project_id as number | null) ?? null;
milestoneId.value = (taskRec.milestone_id as number | null) ?? null;
parentId.value = (taskRec.parent_id as number | null) ?? null;
parentTitle.value = (taskRec.parent_title as string | null) ?? "";
parentSearchQuery.value = parentTitle.value;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
}
}
});
@@ -191,6 +269,9 @@ async function save() {
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
};
if (isEditing.value) {
await store.updateTask(taskId.value!, data);
@@ -200,6 +281,9 @@ async function save() {
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
dirty.value = false;
toast.show("Task saved");
} else {
@@ -250,13 +334,19 @@ async function autoSave() {
status: status.value,
priority: priority.value,
due_date: dueDate.value || null,
});
project_id: projectId.value,
milestone_id: milestoneId.value,
parent_id: parentId.value,
} as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedStatus = status.value;
savedPriority = priority.value;
savedDueDate = dueDate.value;
savedProjectId = projectId.value;
savedMilestoneId = milestoneId.value;
savedParentId = parentId.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
@@ -344,6 +434,49 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
@input="markDirty"
/>
</div>
<div class="field">
<label>Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
</div>
<div class="field">
<label>Milestone</label>
<MilestoneSelector :projectId="projectId" v-model="milestoneId" @update:modelValue="markDirty" />
</div>
</div>
<div class="field-row">
<div class="field parent-field">
<label>Parent Task</label>
<div class="parent-search-wrapper">
<div class="parent-input-row">
<input
v-model="parentSearchQuery"
type="text"
class="field-input"
placeholder="Search tasks..."
@input="onParentSearchInput"
@focus="onParentFocus"
@blur="hideParentDropdown"
/>
<button
v-if="parentId"
class="btn-clear-parent"
@click="clearParentTask"
title="Clear parent task"
>&times;</button>
</div>
<div v-if="showParentDropdown" class="parent-dropdown">
<div v-if="parentSearchLoading" class="parent-dropdown-item parent-empty">Searching...</div>
<div
v-for="task in parentSearchResults"
:key="task.id"
class="parent-dropdown-item"
@mousedown.prevent="selectParentTask(task)"
>{{ task.title || "Untitled" }}</div>
<div v-if="!parentSearchLoading && parentSearchResults.length === 0" class="parent-dropdown-item parent-empty">No tasks found</div>
</div>
</div>
</div>
</div>
<TagInput
@@ -551,4 +684,49 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
color: var(--color-text);
font-size: 0.9rem;
}
/* Parent task search */
.parent-field { max-width: 360px; }
.parent-search-wrapper { position: relative; }
.parent-input-row { display: flex; align-items: center; gap: 0.25rem; }
.parent-input-row .field-input { flex: 1; }
.btn-clear-parent {
background: none;
border: none;
cursor: pointer;
color: var(--color-text-muted);
font-size: 1.1rem;
line-height: 1;
padding: 0 0.2rem;
flex-shrink: 0;
}
.btn-clear-parent:hover { color: var(--color-danger, #e74c3c); }
.parent-dropdown {
position: absolute;
top: calc(100% + 4px);
left: 0;
right: 0;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
box-shadow: 0 4px 12px var(--color-shadow);
z-index: 50;
max-height: 200px;
overflow-y: auto;
}
.parent-dropdown-item {
padding: 0.45rem 0.7rem;
font-size: 0.875rem;
cursor: pointer;
color: var(--color-text);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.parent-dropdown-item:hover { background: var(--color-bg-secondary); }
.parent-empty {
color: var(--color-text-muted);
cursor: default;
}
.parent-empty:hover { background: none; }
</style>