Add invitation system, table of contents, actionable dashboard, and settings improvements

Invitation system (Phase 5.7):
- invitation_tokens table (migration 0012) with SHA256-hashed tokens, 7-day expiry
- Admin CRUD endpoints: POST/GET/DELETE /api/admin/invitations
- Branded invitation email with registration link
- /register-invite frontend view with token validation and account creation
- Admin UI: invite form + pending invitations table with revoke
- Configurable base URL setting for email links (replaces hardcoded Config.BASE_URL)

Table of contents + markdown improvements (Phase 5.8):
- TableOfContents component: sticky sidebar, heading parsing, smooth-scroll
- Custom marked renderer adds id attributes to headings for anchor links
- stripFirstLineTags() prevents leading #tags from rendering as headings
- NoteViewerView/TaskViewerView flex layout with TOC sidebar (hidden ≤1200px)
- Model catalog refresh: added llama3.2/3.3, gemma3, qwen3, phi4, deepseek-r1,
  qwen2.5-coder, dolphin3; added Reasoning category; removed discontinued models
- Settings model section split into Installed/Available tabs

Actionable dashboard (Phase 5.9):
- due_before/due_after query params on /api/tasks (exclusive < / inclusive >=)
- HomeView rewritten: overdue (red accent), due today, in progress, chats, notes
- 5 parallel API calls via Promise.allSettled
- Client-side done filtering and in-progress deduplication
- Task sections hidden when empty; status toggle removes done tasks

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-14 08:46:51 -05:00
parent d354da5b51
commit e02b681e91
20 changed files with 1506 additions and 295 deletions
+122 -43
View File
@@ -13,47 +13,96 @@ import { useChatStore } from "@/stores/chat";
const router = useRouter();
const recentNotes = ref<Note[]>([]);
const recentTasks = ref<Task[]>([]);
const overdueTasks = ref<Task[]>([]);
const dueTodayTasks = ref<Task[]>([]);
const inProgressTasks = ref<Task[]>([]);
const recentChats = ref<Conversation[]>([]);
const loading = ref(true);
const tasksStore = useTasksStore();
function todayStr(): string {
const d = new Date();
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
function tomorrowStr(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
return d.getFullYear() + "-" + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0");
}
onMounted(async () => {
try {
const notesData = await apiGet<NoteListResponse>(
"/api/notes?sort=updated_at&order=desc&limit=5"
);
recentNotes.value = notesData.notes;
} catch (e) {
console.error("Failed to load recent notes:", e);
const today = todayStr();
const tomorrow = tomorrowStr();
const [notesRes, overdueRes, dueTodayRes, inProgressRes, chatsRes] =
await Promise.allSettled([
apiGet<NoteListResponse>("/api/notes?sort=updated_at&order=desc&limit=5"),
apiGet<TaskListResponse>(
`/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=15`
),
apiGet<TaskListResponse>(
`/api/tasks?due_after=${today}&due_before=${tomorrow}&sort=priority&order=desc&limit=10`
),
apiGet<TaskListResponse>(
`/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=5`
),
apiGet<{ conversations: Conversation[]; total: number }>(
"/api/chat/conversations?limit=3&offset=0"
),
]);
if (notesRes.status === "fulfilled") {
recentNotes.value = notesRes.value.notes;
}
if (chatsRes.status === "fulfilled") {
recentChats.value = chatsRes.value.conversations;
}
try {
const tasksData = await apiGet<TaskListResponse>(
"/api/tasks?sort=updated_at&order=desc&limit=5"
// Filter out done tasks from overdue and due-today
if (overdueRes.status === "fulfilled") {
overdueTasks.value = overdueRes.value.tasks.filter(
(t) => t.status !== "done"
);
}
if (dueTodayRes.status === "fulfilled") {
dueTodayTasks.value = dueTodayRes.value.tasks.filter(
(t) => t.status !== "done"
);
recentTasks.value = tasksData.tasks;
} catch (e) {
console.error("Failed to load recent tasks:", e);
}
try {
const chatData = await apiGet<{ conversations: Conversation[]; total: number }>(
"/api/chat/conversations?limit=3&offset=0"
// Deduplicate in-progress against overdue and due-today
if (inProgressRes.status === "fulfilled") {
const seen = new Set<number>();
for (const t of overdueTasks.value) seen.add(t.id);
for (const t of dueTodayTasks.value) seen.add(t.id);
inProgressTasks.value = inProgressRes.value.tasks.filter(
(t) => !seen.has(t.id)
);
recentChats.value = chatData.conversations;
} catch (e) {
console.error("Failed to load recent chats:", e);
}
loading.value = false;
});
function removeFromAllLists(id: number) {
overdueTasks.value = overdueTasks.value.filter((t) => t.id !== id);
dueTodayTasks.value = dueTodayTasks.value.filter((t) => t.id !== id);
inProgressTasks.value = inProgressTasks.value.filter((t) => t.id !== id);
}
function onStatusToggle(id: number, status: TaskStatus) {
tasksStore.patchStatus(id, status).then((updated) => {
const idx = recentTasks.value.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
recentTasks.value[idx] = updated;
if (updated.status === "done") {
removeFromAllLists(updated.id);
} else {
// Update in whichever list contains it
for (const list of [overdueTasks, dueTodayTasks, inProgressTasks]) {
const idx = list.value.findIndex((t) => t.id === updated.id);
if (idx !== -1) {
list.value[idx] = updated;
break;
}
}
}
});
}
@@ -73,6 +122,51 @@ async function newChat() {
<p v-if="loading" class="loading">Loading...</p>
<template v-else>
<section v-if="overdueTasks.length" class="section section-overdue">
<div class="section-header">
<h2>Overdue</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in overdueTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="dueTodayTasks.length" class="section">
<div class="section-header">
<h2>Due Today</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in dueTodayTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section v-if="inProgressTasks.length" class="section">
<div class="section-header">
<h2>In Progress</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div class="cards">
<TaskCard
v-for="task in inProgressTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recent Chats</h2>
@@ -97,7 +191,7 @@ async function newChat() {
<section class="section">
<div class="section-header">
<h2>Recent Notes</h2>
<h2>Recently Edited</h2>
<router-link to="/notes" class="see-all">See all</router-link>
</div>
<div v-if="recentNotes.length" class="cards">
@@ -112,25 +206,6 @@ async function newChat() {
<router-link to="/notes/new" class="btn-cta">+ New Note</router-link>
</div>
</section>
<section class="section">
<div class="section-header">
<h2>Recent Tasks</h2>
<router-link to="/tasks" class="see-all">See all</router-link>
</div>
<div v-if="recentTasks.length" class="cards">
<TaskCard
v-for="task in recentTasks"
:key="task.id"
:task="task"
@status-toggle="onStatusToggle"
/>
</div>
<div v-else class="empty-state">
<p class="empty-text">No tasks yet.</p>
<router-link to="/tasks/new" class="btn-cta">+ New Task</router-link>
</div>
</section>
</template>
</main>
</template>
@@ -150,6 +225,10 @@ async function newChat() {
.section {
margin-bottom: 2rem;
}
.section-overdue {
border-left: 3px solid var(--color-overdue, #e74c3c);
padding-left: 0.75rem;
}
.section-header {
display: flex;
justify-content: space-between;