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:
@@ -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"
|
||||
>×</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>
|
||||
Reference in New Issue
Block a user