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
+1
View File
@@ -73,6 +73,7 @@ router.afterEach(() => {
<div class="nav-links" :class="{ open: mobileMenuOpen }">
<router-link to="/notes" class="nav-link">Notes</router-link>
<router-link to="/projects" class="nav-link">Projects</router-link>
<router-link to="/tasks" class="nav-link">Tasks</router-link>
<router-link to="/chat" class="nav-link">Chat</router-link>
<router-link to="/settings" class="nav-link">Settings</router-link>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { ref, watch } from "vue";
import { apiGet } from "@/api/client";
interface Milestone {
id: number;
title: string;
status: string;
}
const props = defineProps<{
projectId: number | null;
modelValue: number | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", val: number | null): void;
}>();
const milestones = ref<Milestone[]>([]);
const loading = ref(false);
watch(
() => props.projectId,
async (pid) => {
milestones.value = [];
if (!pid) {
emit("update:modelValue", null);
return;
}
loading.value = true;
try {
const data = await apiGet<{ milestones: Milestone[] }>(
`/api/projects/${pid}/milestones`
);
milestones.value = data.milestones.filter((m) => m.status === "active");
} catch {
milestones.value = [];
} finally {
loading.value = false;
}
},
{ immediate: true }
);
function onChange(e: Event) {
const val = (e.target as HTMLSelectElement).value;
emit("update:modelValue", val ? Number(val) : null);
}
</script>
<template>
<select
class="milestone-select"
:value="modelValue ?? ''"
:disabled="!projectId || loading"
@change="onChange"
>
<option value="">{{ projectId ? "No milestone" : "(Select project first)" }}</option>
<option v-for="m in milestones" :key="m.id" :value="m.id">
{{ m.title }}
</option>
</select>
</template>
<style scoped>
.milestone-select {
padding: 0.4rem 0.6rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.875rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.milestone-select:focus {
outline: none;
border-color: var(--color-primary);
}
.milestone-select:disabled {
opacity: 0.5;
cursor: default;
}
</style>
@@ -0,0 +1,67 @@
<script setup lang="ts">
import { ref, onMounted } from "vue";
import { apiGet } from "@/api/client";
interface Project {
id: number;
title: string;
status: string;
}
defineProps<{
modelValue: number | null;
}>();
const emit = defineEmits<{
(e: "update:modelValue", value: number | null): void;
}>();
const projects = ref<Project[]>([]);
let cacheExpiry = 0;
async function loadProjects() {
const now = Date.now();
if (projects.value.length > 0 && now < cacheExpiry) return;
try {
const data = await apiGet<{ projects: Project[] }>("/api/projects?status=active");
projects.value = data.projects;
cacheExpiry = now + 60_000; // 60 second cache
} catch {
// Silently fail — selector will just be empty
}
}
onMounted(loadProjects);
function onChange(e: Event) {
const value = (e.target as HTMLSelectElement).value;
emit("update:modelValue", value ? Number(value) : null);
}
</script>
<template>
<select class="project-selector" :value="modelValue ?? ''" @change="onChange">
<option value="">No project</option>
<option v-for="project in projects" :key="project.id" :value="project.id">
{{ project.title }}
</option>
</select>
</template>
<style scoped>
.project-selector {
padding: 0.4rem 0.5rem;
border: 1px solid var(--color-input-border);
border-radius: var(--radius-sm);
background: var(--color-bg-card);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.project-selector:focus {
outline: none;
border-color: var(--color-primary);
}
</style>
+1 -1
View File
@@ -18,7 +18,7 @@ const inputRef = ref<HTMLInputElement | null>(null);
let fetchDebounce: ReturnType<typeof setTimeout> | null = null;
function sanitize(raw: string): string {
return raw.trim().replace(/\s+/g, "-").replace(/^#+/, "");
return raw.trim().toLowerCase().replace(/\s+/g, "-").replace(/^#+/, "");
}
function addTag(raw: string) {
+9
View File
@@ -9,3 +9,12 @@ const app = createApp(App);
app.use(createPinia());
app.use(router);
app.mount("#app");
// Register service worker for push notifications and PWA
if ('serviceWorker' in navigator) {
window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js').catch(() => {
// SW registration failed — app still works, just no push notifications
})
})
}
+10
View File
@@ -60,6 +60,16 @@ const router = createRouter({
name: "note-edit",
component: () => import("@/views/NoteEditorView.vue"),
},
{
path: "/projects",
name: "projects",
component: () => import("@/views/ProjectListView.vue"),
},
{
path: "/projects/:id",
name: "project-view",
component: () => import("@/views/ProjectView.vue"),
},
{
path: "/tasks",
name: "tasks",
+2
View File
@@ -123,6 +123,7 @@ export const useChatStore = defineStore("chat", () => {
includeNoteIds?: number[],
think = false,
contextNoteTitle?: string,
excludeNoteIds?: number[],
) {
if (!currentConversation.value) return;
@@ -169,6 +170,7 @@ export const useChatStore = defineStore("chat", () => {
content,
context_note_id: contextNoteId,
include_note_ids: includeNoteIds?.length ? includeNoteIds : undefined,
excluded_note_ids: excludeNoteIds?.length ? excludeNoteIds : undefined,
think,
},
);
+2 -1
View File
@@ -78,6 +78,7 @@ export const useNotesStore = defineStore("notes", () => {
title: string;
body: string;
tags?: string[];
project_id?: number | null;
}): Promise<Note> {
try {
return await apiPost<Note>("/api/notes", data);
@@ -89,7 +90,7 @@ export const useNotesStore = defineStore("notes", () => {
async function updateNote(
id: number,
data: Partial<Pick<Note, "title" | "body" | "tags">>
data: Partial<Pick<Note, "title" | "body" | "tags" | "project_id">>
): Promise<Note> {
try {
const note = await apiPut<Note>(`/api/notes/${id}`, data);
+108
View File
@@ -0,0 +1,108 @@
import { defineStore } from 'pinia'
import { ref } from 'vue'
function urlBase64ToUint8Array(base64String: string): Uint8Array {
const padding = '='.repeat((4 - (base64String.length % 4)) % 4)
const base64 = (base64String + padding).replace(/-/g, '+').replace(/_/g, '/')
const rawData = window.atob(base64)
const outputArray = new Uint8Array(rawData.length)
for (let i = 0; i < rawData.length; ++i) {
outputArray[i] = rawData.charCodeAt(i)
}
return outputArray
}
export const usePushStore = defineStore('push', () => {
const isSupported = ref(
'serviceWorker' in navigator && 'PushManager' in window && 'Notification' in window
)
const permission = ref<NotificationPermission>(
'Notification' in window ? Notification.permission : 'denied'
)
const isSubscribed = ref(false)
const loading = ref(false)
const error = ref<string | null>(null)
async function checkSubscription(): Promise<void> {
if (!isSupported.value) return
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
isSubscribed.value = !!sub
} catch {
isSubscribed.value = false
}
}
async function subscribe(): Promise<void> {
if (!isSupported.value) {
error.value = 'Push notifications not supported in this browser'
return
}
loading.value = true
error.value = null
try {
// Request permission
permission.value = await Notification.requestPermission()
if (permission.value !== 'granted') {
error.value = 'Notification permission denied'
return
}
// Fetch VAPID public key
const resp = await fetch('/api/push/vapid-public-key')
if (!resp.ok) {
error.value = 'Push notifications not configured on server'
return
}
const { publicKey } = await resp.json()
// Register service worker
const reg = await navigator.serviceWorker.register('/sw.js')
await navigator.serviceWorker.ready
// Subscribe
const sub = await reg.pushManager.subscribe({
userVisibleOnly: true,
applicationServerKey: urlBase64ToUint8Array(publicKey),
})
// Save to server
const saveResp = await fetch('/api/push/subscribe', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(sub.toJSON()),
})
if (!saveResp.ok) throw new Error('Failed to save subscription')
isSubscribed.value = true
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to subscribe'
} finally {
loading.value = false
}
}
async function unsubscribe(): Promise<void> {
loading.value = true
error.value = null
try {
const reg = await navigator.serviceWorker.ready
const sub = await reg.pushManager.getSubscription()
if (sub) {
await fetch('/api/push/subscribe', {
method: 'DELETE',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ endpoint: sub.endpoint }),
})
await sub.unsubscribe()
}
isSubscribed.value = false
} catch (e: unknown) {
error.value = e instanceof Error ? e.message : 'Failed to unsubscribe'
} finally {
loading.value = false
}
}
return { isSupported, permission, isSubscribed, loading, error, checkSubscription, subscribe, unsubscribe }
})
+4 -1
View File
@@ -69,6 +69,9 @@ export const useTasksStore = defineStore("tasks", () => {
status?: TaskStatus;
priority?: TaskPriority;
due_date?: string | null;
project_id?: number | null;
milestone_id?: number | null;
parent_id?: number | null;
}): Promise<Task> {
try {
return await apiPost<Task>("/api/tasks", data);
@@ -81,7 +84,7 @@ export const useTasksStore = defineStore("tasks", () => {
async function updateTask(
id: number,
data: Partial<
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date">
Pick<Task, "title" | "body" | "tags" | "status" | "priority" | "due_date" | "project_id" | "milestone_id" | "parent_id">
>
): Promise<Task> {
try {
+2 -1
View File
@@ -54,7 +54,8 @@ export interface ConversationDetail extends Conversation {
export interface ContextMeta {
context_note_id: number | null;
context_note_title: string | null;
auto_notes: { id: number; title: string; score?: number | null }[];
auto_notes: { id: number; title: string; score?: number | null; auto_injected?: boolean }[];
auto_injected_notes?: { id: number; title: string; score?: number | null }[];
}
export interface OllamaStatus {
+2
View File
@@ -7,6 +7,8 @@ export interface Note {
body: string;
tags: string[];
parent_id: number | null;
project_id: number | null;
milestone_id: number | null;
status: TaskStatus | null;
priority: TaskPriority | null;
due_date: string | null;
+78 -16
View File
@@ -37,9 +37,15 @@ let noteSearchTimer: ReturnType<typeof setTimeout> | null = null;
const includedNoteIds = ref<Set<number>>(new Set());
const includedNotes = ref<{ id: number; title: string }[]>([]);
// Suggested notes — auto-found by search, not yet included
// Suggested notes — auto-found by search, not yet included (score 0.450.59)
const suggestedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
// Auto-injected notes — injected automatically because score >= 0.60
const autoInjectedNotes = ref<{ id: number; title: string; score?: number | null }[]>([]);
// Note IDs excluded from auto-injection on next message
const excludedNoteIds = ref<number[]>([]);
let prevConvId: number | null = null;
const convId = computed(() => {
@@ -98,6 +104,8 @@ watch(convId, async (newId) => {
includedNoteIds.value = new Set();
includedNotes.value = [];
suggestedNotes.value = [];
autoInjectedNotes.value = [];
excludedNoteIds.value = [];
store.lastContextMeta = null;
if (newId) {
// Skip re-fetch if this conversation is already loaded (avoids race
@@ -118,14 +126,35 @@ watch(
);
watch(
() => store.lastContextMeta?.auto_notes,
(newNotes) => {
if (!newNotes) return;
() => store.lastContextMeta,
(meta) => {
if (!meta) return;
const alreadyIncluded = includedNoteIds.value;
const alreadyAutoInjected = new Set(autoInjectedNotes.value.map((n) => n.id));
const alreadySuggested = new Set(suggestedNotes.value.map((n) => n.id));
for (const note of newNotes) {
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id)) {
suggestedNotes.value.push(note);
// Process auto_injected_notes (explicitly marked as injected)
const injectedFromMeta = meta.auto_injected_notes ?? [];
for (const note of injectedFromMeta) {
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
autoInjectedNotes.value.push(note);
alreadyAutoInjected.add(note.id);
}
}
// Process auto_notes — auto_injected flag separates injected from suggested
const autoNotes = meta.auto_notes ?? [];
for (const note of autoNotes) {
if (note.auto_injected) {
if (!alreadyAutoInjected.has(note.id) && !alreadyIncluded.has(note.id)) {
autoInjectedNotes.value.push(note);
alreadyAutoInjected.add(note.id);
}
} else {
if (!alreadyIncluded.has(note.id) && !alreadySuggested.has(note.id) && !alreadyAutoInjected.has(note.id)) {
suggestedNotes.value.push(note);
}
}
}
}
@@ -182,6 +211,8 @@ async function sendMessage() {
undefined,
includedNoteIds.value.size ? [...includedNoteIds.value] : undefined,
true, // enable thinking in the full chat view
undefined,
excludedNoteIds.value.length ? excludedNoteIds.value : undefined,
);
sending.value = false;
@@ -303,6 +334,14 @@ function includeNote(note: { id: number; title: string }) {
includedNoteIds.value = new Set([...includedNoteIds.value, note.id]);
includedNotes.value.push(note);
suggestedNotes.value = suggestedNotes.value.filter((n) => n.id !== note.id);
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== note.id);
}
function excludeAutoNote(noteId: number) {
autoInjectedNotes.value = autoInjectedNotes.value.filter((n) => n.id !== noteId);
if (!excludedNoteIds.value.includes(noteId)) {
excludedNoteIds.value = [...excludedNoteIds.value, noteId];
}
}
function removeIncludedNote(noteId: number) {
@@ -461,23 +500,31 @@ onUnmounted(() => {
</div>
<!-- Context sidebar -->
<aside v-if="includedNotes.length || suggestedNotes.length" class="context-sidebar">
<!-- IN CONTEXT section -->
<template v-if="includedNotes.length">
<div class="context-sidebar-header">In Context</div>
<!-- Explicitly included notes -->
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<aside v-if="autoInjectedNotes.length || includedNotes.length || suggestedNotes.length" class="context-sidebar">
<!-- AUTO-INCLUDED section -->
<template v-if="autoInjectedNotes.length">
<div class="context-sidebar-header">Auto-included</div>
<div v-for="note in autoInjectedNotes" :key="note.id" class="context-note context-note-auto">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">&times;</button>
<span
v-if="note.score != null"
class="context-note-score"
:class="{
'score-high': note.score >= 0.75,
'score-medium': note.score >= 0.60 && note.score < 0.75,
'score-low': note.score < 0.60,
}"
:title="`Relevance score: ${note.score}`"
>{{ Math.round(note.score * 100) }}%</span>
<button class="context-note-remove" @click="excludeAutoNote(note.id)" title="Exclude from auto-injection">&times;</button>
</div>
</template>
<!-- SUGGESTED section -->
<template v-if="suggestedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': includedNotes.length }">Suggested</div>
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length }">Suggested</div>
<div v-for="note in suggestedNotes" :key="note.id" class="context-note context-note-suggested">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
@@ -495,6 +542,18 @@ onUnmounted(() => {
<button class="context-note-add" @click="includeNote(note)" title="Add to context">+</button>
</div>
</template>
<!-- IN CONTEXT section -->
<template v-if="includedNotes.length">
<div class="context-sidebar-header" :class="{ 'context-sidebar-header-gap': autoInjectedNotes.length || suggestedNotes.length }">In Context</div>
<!-- Explicitly included notes -->
<div v-for="note in includedNotes" :key="note.id" class="context-note context-note-included">
<router-link :to="`/notes/${note.id}`" class="context-note-name">
{{ note.title }}
</router-link>
<button class="context-note-remove" @click="removeIncludedNote(note.id)" title="Remove from context">&times;</button>
</div>
</template>
</aside>
</div>
@@ -814,6 +873,9 @@ onUnmounted(() => {
.context-note-included {
border-color: var(--color-primary);
}
.context-note-auto {
border-color: var(--color-success);
}
.context-note-suggested {
opacity: 0.85;
}
+41 -3
View File
@@ -10,6 +10,7 @@ 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";
const route = useRoute();
const router = useRouter();
@@ -19,6 +20,7 @@ const toast = useToastStore();
const title = ref("");
const body = ref("");
const tags = ref<string[]>([]);
const projectId = ref<number | null>(null);
const dirty = ref(false);
const saving = ref(false);
const showPreview = ref(false);
@@ -136,12 +138,14 @@ function dismissTagSuggestions() {
let savedTitle = "";
let savedBody = "";
let savedTags: string[] = [];
let savedProjectId: number | null = null;
function markDirty() {
dirty.value =
title.value !== savedTitle ||
body.value !== savedBody ||
JSON.stringify(tags.value) !== JSON.stringify(savedTags);
JSON.stringify(tags.value) !== JSON.stringify(savedTags) ||
projectId.value !== savedProjectId;
}
function onBodyUpdate(newVal: string) {
@@ -156,9 +160,11 @@ onMounted(async () => {
title.value = store.currentNote.title;
body.value = store.currentNote.body;
tags.value = [...(store.currentNote.tags || [])];
projectId.value = store.currentNote.project_id ?? null;
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
}
}
});
@@ -172,10 +178,12 @@ async function save() {
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
});
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
dirty.value = false;
toast.show("Note saved");
} else {
@@ -183,6 +191,7 @@ async function save() {
title: title.value,
body: body.value,
tags: tags.value,
project_id: projectId.value,
});
dirty.value = false;
toast.show("Note created");
@@ -223,10 +232,11 @@ async function autoSave() {
if (!isEditing.value || !dirty.value || saving.value) return;
saving.value = true;
try {
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value });
await store.updateNote(noteId.value!, { title: title.value, body: body.value, tags: tags.value, project_id: projectId.value } as Record<string, unknown>);
savedTitle = title.value;
savedBody = body.value;
savedTags = [...tags.value];
savedProjectId = projectId.value;
dirty.value = false;
toast.show("Auto-saved");
} catch {
@@ -290,6 +300,13 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
@input="markDirty"
/>
<div class="field-row-meta">
<div class="meta-field">
<label class="meta-label">Project</label>
<ProjectSelector v-model="projectId" @update:modelValue="markDirty" />
</div>
</div>
<TagInput
v-model="tags"
:fetchTags="(q: string) => store.fetchAllTags(q)"
@@ -480,4 +497,25 @@ onUnmounted(() => window.removeEventListener("beforeunload", onBeforeUnload));
</main>
</template>
<style src="@/assets/editor-shared.css" />
<style src="@/assets/editor-shared.css" />
<style scoped>
.field-row-meta {
display: flex;
gap: 1rem;
flex-wrap: wrap;
margin-bottom: 0.25rem;
}
.meta-field {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 180px;
flex: 1;
max-width: 300px;
}
.meta-label {
font-size: 0.8rem;
color: var(--color-text-secondary);
font-weight: 500;
}
</style>
+581
View File
@@ -0,0 +1,581 @@
<script setup lang="ts">
import { ref, computed, onMounted } from "vue";
import { useRouter } from "vue-router";
import { apiGet, apiPost } from "@/api/client";
import { useToastStore } from "@/stores/toast";
interface MilestoneSummary {
id: number;
title: string;
status: string;
pct: number;
total: number;
completed: number;
}
interface Project {
id: number;
title: string;
description: string | null;
goal: string | null;
status: "active" | "completed" | "archived";
color: string | null;
created_at: string;
updated_at: string;
summary?: {
task_counts: { todo?: number; in_progress?: number; done?: number };
note_count: number;
milestone_summary: MilestoneSummary[];
};
}
const router = useRouter();
const toast = useToastStore();
const projects = ref<Project[]>([]);
const loading = ref(false);
const error = ref<string | null>(null);
const activeTab = ref<"all" | "active" | "completed" | "archived">("all");
// New project modal
const showNewProjectModal = ref(false);
const newTitle = ref("");
const newDescription = ref("");
const newGoal = ref("");
const creating = ref(false);
const filteredProjects = computed(() => {
if (activeTab.value === "all") return projects.value;
return projects.value.filter((p) => p.status === activeTab.value);
});
async function loadProjects() {
loading.value = true;
error.value = null;
try {
const data = await apiGet<{ projects: Project[] }>("/api/projects");
projects.value = data.projects;
// Fetch summaries (including milestone_summary) in parallel
await Promise.allSettled(
projects.value.map(async (p) => {
try {
const full = await apiGet<Project>(`/api/projects/${p.id}`);
p.summary = full.summary;
} catch {
// non-fatal
}
})
);
} catch {
error.value = "Failed to load projects.";
} finally {
loading.value = false;
}
}
function milestoneColor(index: number): string {
const palette = [
"var(--color-primary)",
"var(--color-success)",
"#c98a00",
"#8b5cf6",
"#ef4444",
"#06b6d4",
];
return palette[index % palette.length];
}
onMounted(loadProjects);
function openNewProjectModal() {
newTitle.value = "";
newDescription.value = "";
newGoal.value = "";
showNewProjectModal.value = true;
}
function closeModal() {
showNewProjectModal.value = false;
}
async function createProject() {
if (!newTitle.value.trim()) return;
creating.value = true;
try {
const project = await apiPost<Project>("/api/projects", {
title: newTitle.value.trim(),
description: newDescription.value.trim() || undefined,
goal: newGoal.value.trim() || undefined,
});
projects.value.unshift(project);
showNewProjectModal.value = false;
toast.show("Project created");
router.push(`/projects/${project.id}`);
} catch {
toast.show("Failed to create project", "error");
} finally {
creating.value = false;
}
}
function statusLabel(status: Project["status"]): string {
if (status === "active") return "Active";
if (status === "completed") return "Completed";
if (status === "archived") return "Archived";
return status;
}
function truncate(text: string | null, max = 120): string {
if (!text) return "";
return text.length > max ? text.slice(0, max) + "..." : text;
}
</script>
<template>
<main class="projects-list">
<div class="page-header">
<h1>Projects</h1>
<button class="btn-primary" @click="openNewProjectModal">+ New Project</button>
</div>
<!-- Filter tabs -->
<div class="filter-tabs">
<button
v-for="tab in ['all', 'active', 'completed', 'archived'] as const"
:key="tab"
:class="['tab-btn', { active: activeTab === tab }]"
@click="activeTab = tab"
>
{{ tab.charAt(0).toUpperCase() + tab.slice(1) }}
</button>
</div>
<p v-if="loading" class="loading-msg">Loading...</p>
<p v-else-if="error" class="error-msg">{{ error }}</p>
<div v-else-if="filteredProjects.length === 0" class="empty-state">
<p class="empty-title">No projects yet</p>
<p class="empty-subtitle">Create your first project to organize tasks and notes.</p>
<button class="btn-cta" @click="openNewProjectModal">+ New Project</button>
</div>
<div v-else class="projects-grid">
<div
v-for="project in filteredProjects"
:key="project.id"
class="project-card"
@click="router.push(`/projects/${project.id}`)"
>
<div class="card-header">
<span class="project-title">{{ project.title }}</span>
<span
:class="['status-badge', `status-${project.status}`]"
>{{ statusLabel(project.status) }}</span>
</div>
<p v-if="project.goal" class="project-goal">
<span class="field-label">Goal:</span> {{ truncate(project.goal) }}
</p>
<p v-if="project.description" class="project-desc">{{ truncate(project.description) }}</p>
<!-- Milestone progress bars -->
<div
v-if="project.summary?.milestone_summary?.length"
class="milestone-bars"
>
<div
v-for="(ms, i) in project.summary.milestone_summary"
:key="ms.id"
class="milestone-bar-row"
:title="`${ms.title} — ${ms.pct}% (${ms.completed}/${ms.total} tasks)`"
>
<span class="milestone-bar-label">{{ ms.title }}</span>
<div class="milestone-bar-track">
<div
class="milestone-bar-fill"
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
></div>
</div>
<span class="milestone-bar-pct">{{ ms.pct }}%</span>
</div>
</div>
<div class="card-footer">
<span class="meta-date">Updated {{ new Date(project.updated_at).toLocaleDateString() }}</span>
</div>
</div>
</div>
<!-- New Project Modal -->
<teleport to="body">
<div v-if="showNewProjectModal" class="modal-overlay" @click.self="closeModal">
<div class="modal-card">
<h3 class="modal-title">New Project</h3>
<div class="modal-field">
<label>Title <span class="required">*</span></label>
<input
v-model="newTitle"
type="text"
class="modal-input"
placeholder="Project title"
autofocus
@keydown.enter="createProject"
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<label>Goal</label>
<input
v-model="newGoal"
type="text"
class="modal-input"
placeholder="What are you trying to achieve?"
@keydown.escape="closeModal"
/>
</div>
<div class="modal-field">
<label>Description</label>
<textarea
v-model="newDescription"
class="modal-textarea"
placeholder="Optional description..."
rows="3"
@keydown.escape="closeModal"
></textarea>
</div>
<div class="modal-actions">
<button class="modal-btn" @click="closeModal">Cancel</button>
<button
class="modal-btn modal-btn-primary"
@click="createProject"
:disabled="!newTitle.trim() || creating"
>
{{ creating ? "Creating..." : "Create" }}
</button>
</div>
</div>
</div>
</teleport>
</main>
</template>
<style scoped>
.projects-list {
max-width: 1200px;
margin: 2rem auto;
padding: 0 1rem;
}
.page-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1rem;
}
.page-header h1 {
margin: 0;
}
.btn-primary {
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.9rem;
font-family: inherit;
}
.btn-primary:hover {
opacity: 0.9;
}
.filter-tabs {
display: flex;
gap: 0.25rem;
margin-bottom: 1.25rem;
border-bottom: 1px solid var(--color-border);
padding-bottom: 0;
}
.tab-btn {
padding: 0.4rem 0.85rem;
background: none;
border: none;
border-bottom: 2px solid transparent;
cursor: pointer;
font-size: 0.875rem;
color: var(--color-text-secondary);
font-family: inherit;
margin-bottom: -1px;
}
.tab-btn:hover {
color: var(--color-primary);
}
.tab-btn.active {
color: var(--color-primary);
border-bottom-color: var(--color-primary);
font-weight: 600;
}
.loading-msg,
.error-msg {
color: var(--color-text-muted);
font-size: 0.9rem;
margin-top: 1rem;
}
.error-msg {
color: var(--color-danger);
}
.empty-state {
text-align: center;
margin-top: 3rem;
}
.empty-title {
font-size: 1.1rem;
font-weight: 600;
margin: 0 0 0.25rem;
color: var(--color-text);
}
.empty-subtitle {
color: var(--color-text-muted);
margin: 0 0 1rem;
font-size: 0.95rem;
}
.btn-cta {
display: inline-block;
padding: 0.45rem 1rem;
background: var(--color-primary);
color: #fff;
border: none;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
cursor: pointer;
font-family: inherit;
}
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 1rem;
}
.project-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1rem 1.1rem;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.project-card:hover {
border-color: var(--color-primary);
box-shadow: 0 2px 8px var(--color-shadow);
}
.card-header {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 0.5rem;
}
.project-title {
font-size: 1rem;
font-weight: 600;
color: var(--color-text);
min-width: 0;
flex: 1;
word-break: break-word;
}
.status-badge {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.15rem 0.45rem;
border-radius: 999px;
flex-shrink: 0;
white-space: nowrap;
}
.status-active {
background: color-mix(in srgb, var(--color-success) 15%, transparent);
color: var(--color-success);
}
.status-completed {
background: color-mix(in srgb, var(--color-primary) 15%, transparent);
color: var(--color-primary);
}
.status-archived {
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
.project-goal {
font-size: 0.875rem;
color: var(--color-text);
margin: 0;
line-height: 1.4;
}
.field-label {
font-weight: 600;
color: var(--color-text-secondary);
}
.project-desc {
font-size: 0.82rem;
color: var(--color-text-secondary);
margin: 0;
line-height: 1.45;
}
.milestone-bars {
display: flex;
flex-direction: column;
gap: 0.25rem;
margin-top: 0.1rem;
}
.milestone-bar-row {
display: flex;
align-items: center;
gap: 0.4rem;
font-size: 0.72rem;
}
.milestone-bar-label {
color: var(--color-text-secondary);
min-width: 0;
flex: 0 0 30%;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.milestone-bar-track {
flex: 1;
height: 5px;
background: var(--color-border);
border-radius: 999px;
overflow: hidden;
}
.milestone-bar-fill {
height: 100%;
border-radius: 999px;
transition: width 0.3s ease;
}
.milestone-bar-pct {
color: var(--color-text-muted);
flex-shrink: 0;
min-width: 2.5rem;
text-align: right;
}
.card-footer {
margin-top: auto;
}
.meta-date {
font-size: 0.75rem;
color: var(--color-text-muted);
}
/* Modal */
.modal-overlay {
position: fixed;
inset: 0;
background: var(--color-overlay, rgba(0,0,0,0.45));
display: flex;
align-items: center;
justify-content: center;
z-index: 200;
}
.modal-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 1.5rem;
width: 100%;
max-width: 480px;
box-shadow: 0 8px 32px var(--color-shadow);
display: flex;
flex-direction: column;
gap: 1rem;
}
.modal-title {
margin: 0;
font-size: 1.1rem;
}
.modal-field {
display: flex;
flex-direction: column;
gap: 0.3rem;
}
.modal-field label {
font-size: 0.875rem;
font-weight: 600;
color: var(--color-text);
}
.required {
color: var(--color-danger);
}
.modal-input,
.modal-textarea {
padding: 0.45rem 0.7rem;
border: 1px solid var(--color-border);
border-radius: var(--radius-sm);
background: var(--color-bg);
color: var(--color-text);
font-size: 0.9rem;
font-family: inherit;
box-sizing: border-box;
width: 100%;
}
.modal-input:focus,
.modal-textarea:focus {
outline: none;
border-color: var(--color-primary);
}
.modal-textarea {
resize: vertical;
}
.modal-actions {
display: flex;
justify-content: flex-end;
gap: 0.5rem;
}
.modal-btn {
padding: 0.4rem 0.9rem;
border: 1px solid var(--color-border);
background: var(--color-bg-secondary);
color: var(--color-text);
border-radius: var(--radius-sm);
cursor: pointer;
font-size: 0.875rem;
font-family: inherit;
}
.modal-btn:hover {
background: var(--color-bg);
}
.modal-btn-primary {
background: var(--color-primary);
border-color: var(--color-primary);
color: #fff;
}
.modal-btn-primary:hover:not(:disabled) {
opacity: 0.9;
}
.modal-btn-primary:disabled {
opacity: 0.5;
cursor: default;
}
@media (max-width: 600px) {
.projects-grid {
grid-template-columns: 1fr;
}
.modal-card {
margin: 1rem;
max-width: calc(100vw - 2rem);
}
}
</style>
File diff suppressed because it is too large Load Diff
+93
View File
@@ -4,10 +4,12 @@ import { useSettingsStore } from "@/stores/settings";
import { useAuthStore } from "@/stores/auth";
import { useToastStore } from "@/stores/toast";
import { apiGet, apiPost, apiPut } from "@/api/client";
import { usePushStore } from "@/stores/push";
const store = useSettingsStore();
const authStore = useAuthStore();
const toastStore = useToastStore();
const pushStore = usePushStore();
const assistantName = ref("");
const defaultModel = ref("");
const installedModels = ref<string[]>([]);
@@ -73,6 +75,7 @@ const searchError = ref("");
onMounted(async () => {
await store.fetchSettings();
pushStore.checkSubscription();
assistantName.value = store.assistantName;
newEmail.value = authStore.user?.email ?? "";
@@ -640,6 +643,59 @@ function hostname(url: string): string {
</template>
</section>
<!-- Push Notifications half width -->
<section class="settings-section">
<h2>Push Notifications</h2>
<p class="section-desc">
Receive browser push notifications when a response is ready.
</p>
<template v-if="!pushStore.isSupported">
<p class="push-unsupported">Push notifications are not supported in this browser.</p>
</template>
<template v-else>
<div class="push-status-row">
<span class="push-status-label">Permission:</span>
<span
:class="['push-permission-badge', {
'perm-granted': pushStore.permission === 'granted',
'perm-denied': pushStore.permission === 'denied',
'perm-default': pushStore.permission === 'default',
}]"
>
{{ pushStore.permission === 'granted' ? 'Granted' : pushStore.permission === 'denied' ? 'Denied' : 'Not asked' }}
</span>
</div>
<div class="push-status-row">
<span class="push-status-label">Status:</span>
<span :class="['push-sub-badge', { 'sub-active': pushStore.isSubscribed }]">
{{ pushStore.isSubscribed ? 'Subscribed' : 'Not subscribed' }}
</span>
</div>
<p v-if="pushStore.error" class="push-error">{{ pushStore.error }}</p>
<div class="actions" style="margin-top: 0.75rem;">
<button
v-if="!pushStore.isSubscribed"
class="btn-save"
@click="pushStore.subscribe()"
:disabled="pushStore.loading || pushStore.permission === 'denied'"
>
{{ pushStore.loading ? 'Enabling...' : 'Enable Notifications' }}
</button>
<button
v-else
class="btn-secondary"
@click="pushStore.unsubscribe()"
:disabled="pushStore.loading"
>
{{ pushStore.loading ? 'Disabling...' : 'Disable Notifications' }}
</button>
</div>
<p v-if="pushStore.permission === 'denied'" class="field-hint" style="margin-top: 0.5rem;">
Notifications are blocked. Allow them in your browser site settings to re-enable.
</p>
</template>
</section>
<!-- Application URL (admin) full width -->
<section v-if="authStore.isAdmin" class="settings-section full-width">
<h2>Application URL</h2>
@@ -1022,6 +1078,43 @@ function hostname(url: string): string {
max-width: 480px;
}
/* Push notifications */
.push-unsupported {
font-size: 0.875rem;
color: var(--color-text-muted);
font-style: italic;
}
.push-status-row {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.4rem;
font-size: 0.875rem;
}
.push-status-label {
color: var(--color-text-secondary);
font-weight: 500;
}
.push-permission-badge,
.push-sub-badge {
font-size: 0.72rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
padding: 0.1rem 0.4rem;
border-radius: 999px;
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
}
.perm-granted { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.perm-denied { background: color-mix(in srgb, var(--color-danger, #e74c3c) 15%, transparent); color: var(--color-danger, #e74c3c); }
.sub-active { background: color-mix(in srgb, var(--color-success) 15%, transparent); color: var(--color-success); }
.push-error {
font-size: 0.82rem;
color: var(--color-danger, #e74c3c);
margin: 0.25rem 0 0;
}
/* Responsive — collapse to 1 column on narrow screens */
@media (max-width: 640px) {
.settings-grid {
+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>