diff --git a/frontend/src/components/NoteCard.vue b/frontend/src/components/NoteCard.vue index e2c7a41..1e4d17a 100644 --- a/frontend/src/components/NoteCard.vue +++ b/frontend/src/components/NoteCard.vue @@ -5,7 +5,7 @@ import TagPill from "@/components/TagPill.vue"; import { relativeTime } from "@/composables/useRelativeTime"; import { renderPreview } from "@/utils/markdown"; -const props = defineProps<{ note: Note }>(); +const props = defineProps<{ note: Note; compact?: boolean }>(); const emit = defineEmits<{ "tag-click": [tag: string] }>(); const router = useRouter(); @@ -19,21 +19,40 @@ function goEdit() { @@ -51,6 +70,36 @@ function goEdit() { .note-card:hover { box-shadow: 0 2px 8px var(--color-shadow); } + +/* Compact single-row layout */ +.note-card.compact { + display: flex; + align-items: center; + gap: 0.6rem; + padding: 0.45rem 0.75rem; +} +.note-title-compact { + font-size: 0.9rem; + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.note-tags-compact { + display: flex; + gap: 0.3rem; + flex-shrink: 0; +} +.timestamp-compact { + font-size: 0.72rem; + color: var(--color-text-muted); + flex-shrink: 0; + white-space: nowrap; +} + +/* Full layout */ .note-top { display: flex; align-items: center; @@ -75,7 +124,7 @@ function goEdit() { border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer; - transition: color 0.15s, border-color 0.15s, background 0.15s; + transition: color 0.15s, border-color 0.15s; } .btn-edit:hover { color: var(--color-primary); diff --git a/frontend/src/components/TaskCard.vue b/frontend/src/components/TaskCard.vue index 70cdf09..4838a50 100644 --- a/frontend/src/components/TaskCard.vue +++ b/frontend/src/components/TaskCard.vue @@ -7,7 +7,11 @@ import TagPill from "@/components/TagPill.vue"; import { relativeTime } from "@/composables/useRelativeTime"; import { renderPreview } from "@/utils/markdown"; -const props = defineProps<{ task: Task }>(); +const props = defineProps<{ + task: Task; + compact?: boolean; + projectTitle?: string; +}>(); const router = useRouter(); const emit = defineEmits<{ "tag-click": [tag: string]; @@ -20,6 +24,18 @@ const statusCycle: Record = { done: "todo", }; +const statusDotClass: Record = { + todo: "dot-todo", + in_progress: "dot-in-progress", + done: "dot-done", +}; + +const statusTitle: Record = { + todo: "Todo — click to mark In Progress", + in_progress: "In Progress — click to mark Done", + done: "Done — click to mark Todo", +}; + function cycleStatus() { emit("status-toggle", props.task.id, statusCycle[props.task.status!]); } @@ -36,30 +52,51 @@ function isOverdue(): boolean { @@ -77,6 +114,93 @@ function isOverdue(): boolean { .task-card:hover { box-shadow: 0 2px 8px var(--color-shadow); } + +/* Compact single-row layout */ +.task-card.compact { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.4rem 0.75rem; +} + +/* Status dot */ +.status-dot { + flex-shrink: 0; + width: 12px; + height: 12px; + border-radius: 50%; + border: none; + cursor: pointer; + padding: 0; + transition: transform 0.1s, opacity 0.1s; +} +.status-dot:hover { + transform: scale(1.25); + opacity: 0.8; +} +.dot-todo { + background: var(--color-status-todo, #94a3b8); + border: 2px solid var(--color-status-todo, #94a3b8); + background: transparent; + border: 2px solid var(--color-text-muted); +} +.dot-in-progress { + background: var(--color-status-in-progress, #3b82f6); +} +.dot-done { + background: var(--color-status-done, #22c55e); +} + +.task-title-compact { + font-size: 0.9rem; + font-weight: 500; + flex: 1; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.project-crumb { + font-size: 0.75rem; + color: var(--color-text-muted); + background: var(--color-bg-secondary); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + padding: 0.1rem 0.4rem; + white-space: nowrap; + flex-shrink: 0; +} +.due-compact { + font-size: 0.75rem; + color: var(--color-text-muted); + white-space: nowrap; + flex-shrink: 0; +} +.due-compact.overdue { + color: var(--color-danger, #e74c3c); + font-weight: 600; +} +.btn-edit-compact { + flex-shrink: 0; + padding: 0.15rem 0.45rem; + font-size: 0.75rem; + background: none; + color: var(--color-text-muted); + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + cursor: pointer; + opacity: 0; + transition: opacity 0.15s, color 0.15s; +} +.task-card.compact:hover .btn-edit-compact { + opacity: 1; +} +.btn-edit-compact:hover { + color: var(--color-primary); + border-color: var(--color-primary); +} + +/* Full layout */ .task-top { display: flex; align-items: center; @@ -101,7 +225,7 @@ function isOverdue(): boolean { border: 1px solid var(--color-border); border-radius: var(--radius-sm); cursor: pointer; - transition: color 0.15s, border-color 0.15s, background 0.15s; + transition: color 0.15s, border-color 0.15s; } .btn-edit:hover { color: var(--color-primary); diff --git a/frontend/src/views/HomeView.vue b/frontend/src/views/HomeView.vue index dfcfa75..065c576 100644 --- a/frontend/src/views/HomeView.vue +++ b/frontend/src/views/HomeView.vue @@ -12,6 +12,8 @@ import type { TaskStatus } from "@/types/task"; import { useTasksStore } from "@/stores/tasks"; import { useChatStore } from "@/stores/chat"; +// ─── Task data ─────────────────────────────────────────────────────────────── + const recentNotes = ref([]); const overdueTasks = ref([]); const dueTodayTasks = ref([]); @@ -22,21 +24,70 @@ const otherTasks = ref([]); const loading = ref(true); const tasksStore = useTasksStore(); +// ─── Project data ───────────────────────────────────────────────────────────── + +interface MilestoneSummary { + id: number; + title: string; + status: string; + pct: number; + total: number; + completed: number; +} +interface DashProject { + id: number; + title: string; + status: string; + summary?: { + task_counts: { todo?: number; in_progress?: number; done?: number }; + milestone_summary: MilestoneSummary[]; + }; +} + +const activeProjects = ref([]); +const projectMap = ref>(new Map()); + +async function loadProjects() { + try { + const data = await apiGet<{ projects: DashProject[] }>("/api/projects?status=active"); + const projects = data.projects.slice(0, 6); + projectMap.value = new Map(data.projects.map((p) => [p.id, p.title])); + // Fetch summaries in parallel + await Promise.allSettled( + projects.map(async (p) => { + try { + const full = await apiGet(`/api/projects/${p.id}`); + p.summary = full.summary; + } catch { /* non-fatal */ } + }) + ); + activeProjects.value = projects; + } catch { /* non-fatal */ } +} + +function milestoneColor(index: number): string { + const palette = [ + "var(--color-primary)", + "var(--color-success, #22c55e)", + "#c98a00", + "var(--color-danger, #e74c3c)", + "#8b5cf6", + ]; + return palette[index % palette.length]; +} + +// ─── Sorting helpers ────────────────────────────────────────────────────────── + const PRIORITY_ORDER: Record = { - high: 3, - medium: 2, - low: 1, - none: 0, + high: 3, medium: 2, low: 1, none: 0, }; function dateStr(offsetDays: number = 0): string { const d = new Date(); d.setDate(d.getDate() + offsetDays); return ( - d.getFullYear() + - "-" + - String(d.getMonth() + 1).padStart(2, "0") + - "-" + + d.getFullYear() + "-" + + String(d.getMonth() + 1).padStart(2, "0") + "-" + String(d.getDate()).padStart(2, "0") ); } @@ -54,19 +105,17 @@ function sortByPriorityThenDate(tasks: Task[]): Task[] { } function sortOtherTasks(tasks: Task[]): Task[] { - const withDate = tasks - .filter((t) => t.due_date) - .sort((a, b) => a.due_date!.localeCompare(b.due_date!)); - const withoutDate = tasks - .filter((t) => !t.due_date) - .sort((a, b) => { - const pa = PRIORITY_ORDER[a.priority ?? "none"] ?? 0; - const pb = PRIORITY_ORDER[b.priority ?? "none"] ?? 0; - return pb - pa; - }); + const withDate = tasks.filter((t) => t.due_date).sort((a, b) => a.due_date!.localeCompare(b.due_date!)); + const withoutDate = tasks.filter((t) => !t.due_date).sort((a, b) => { + const pa = PRIORITY_ORDER[a.priority ?? "none"] ?? 0; + const pb = PRIORITY_ORDER[b.priority ?? "none"] ?? 0; + return pb - pa; + }); return [...withDate, ...withoutDate].slice(0, 10); } +// ─── Data loading ───────────────────────────────────────────────────────────── + onMounted(async () => { const today = dateStr(0); const nextWeek = dateStr(7); @@ -74,79 +123,48 @@ onMounted(async () => { const [notesRes, overdueRes, dueSoonRes, highPriRes, inProgressRes, otherRes] = await Promise.allSettled([ apiGet("/api/notes?sort=updated_at&order=desc&limit=8"), - apiGet( - `/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=20` - ), - apiGet( - `/api/tasks?due_after=${today}&due_before=${nextWeek}&sort=due_date&order=asc&limit=20` - ), - apiGet( - `/api/tasks?priority=high&sort=updated_at&order=desc&limit=10` - ), - apiGet( - `/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=10` - ), - apiGet( - `/api/tasks?sort=updated_at&order=desc&limit=50` - ), + apiGet(`/api/tasks?due_before=${today}&sort=due_date&order=asc&limit=20`), + apiGet(`/api/tasks?due_after=${today}&due_before=${nextWeek}&sort=due_date&order=asc&limit=20`), + apiGet(`/api/tasks?priority=high&sort=updated_at&order=desc&limit=10`), + apiGet(`/api/tasks?status=in_progress&sort=updated_at&order=desc&limit=10`), + apiGet(`/api/tasks?sort=updated_at&order=desc&limit=50`), ]); - if (notesRes.status === "fulfilled") { - recentNotes.value = notesRes.value.notes; - } + if (notesRes.status === "fulfilled") recentNotes.value = notesRes.value.notes; - // Build seen-set incrementally so each section deduplicates against all above it const seen = new Set(); - if (overdueRes.status === "fulfilled") { - overdueTasks.value = sortByPriorityThenDate( - overdueRes.value.tasks.filter((t) => t.status !== "done") - ); + overdueTasks.value = sortByPriorityThenDate(overdueRes.value.tasks.filter((t) => t.status !== "done")); for (const t of overdueTasks.value) seen.add(t.id); } - if (dueSoonRes.status === "fulfilled") { - const notDone = dueSoonRes.value.tasks.filter( - (t) => t.status !== "done" && !seen.has(t.id) - ); - const todayList: Task[] = []; - const weekList: Task[] = []; + const notDone = dueSoonRes.value.tasks.filter((t) => t.status !== "done" && !seen.has(t.id)); + const todayList: Task[] = [], weekList: Task[] = []; for (const t of notDone) { - if (t.due_date === today) { - todayList.push(t); - } else { - weekList.push(t); - } + (t.due_date === today ? todayList : weekList).push(t); } dueTodayTasks.value = sortByPriorityThenDate(todayList); dueThisWeekTasks.value = sortByPriorityThenDate(weekList); for (const t of dueTodayTasks.value) seen.add(t.id); for (const t of dueThisWeekTasks.value) seen.add(t.id); } - if (highPriRes.status === "fulfilled") { - highPriorityTasks.value = highPriRes.value.tasks.filter( - (t) => t.status !== "done" && !seen.has(t.id) - ); + highPriorityTasks.value = highPriRes.value.tasks.filter((t) => t.status !== "done" && !seen.has(t.id)); for (const t of highPriorityTasks.value) seen.add(t.id); } - if (inProgressRes.status === "fulfilled") { - inProgressTasks.value = inProgressRes.value.tasks.filter( - (t) => !seen.has(t.id) - ); + inProgressTasks.value = inProgressRes.value.tasks.filter((t) => !seen.has(t.id)); for (const t of inProgressTasks.value) seen.add(t.id); } - if (otherRes.status === "fulfilled") { - const remaining = otherRes.value.tasks.filter( - (t) => t.status !== "done" && !seen.has(t.id) - ); - otherTasks.value = sortOtherTasks(remaining); + otherTasks.value = sortOtherTasks(otherRes.value.tasks.filter((t) => t.status !== "done" && !seen.has(t.id))); } loading.value = false; nextTick(() => chatInputRef.value?.focus()); + + // Load projects in parallel (non-blocking) + loadProjects(); }); const noTasks = computed( @@ -160,12 +178,8 @@ const noTasks = computed( ); const allTaskLists = [ - () => overdueTasks, - () => dueTodayTasks, - () => dueThisWeekTasks, - () => highPriorityTasks, - () => inProgressTasks, - () => otherTasks, + () => overdueTasks, () => dueTodayTasks, () => dueThisWeekTasks, + () => highPriorityTasks, () => inProgressTasks, () => otherTasks, ]; function removeFromAllLists(id: number) { @@ -183,22 +197,19 @@ function onStatusToggle(id: number, status: TaskStatus) { for (const getList of allTaskLists) { const list = getList(); const idx = list.value.findIndex((t) => t.id === updated.id); - if (idx !== -1) { - list.value[idx] = updated; - break; - } + if (idx !== -1) { list.value[idx] = updated; break; } } } }); } +// ─── Chat widget ────────────────────────────────────────────────────────────── + const chatInputRef = ref<{ focus: () => void } | null>(null); const chatStore = useChatStore(); chatStore.fetchStatus().then(() => { - if (chatStore.defaultModel) { - chatStore.warmModel(chatStore.defaultModel); - } + if (chatStore.defaultModel) chatStore.warmModel(chatStore.defaultModel); }); const dashboardConvId = ref(null); @@ -254,7 +265,7 @@ function clearDashboardResponse() { @@ -438,10 +489,8 @@ function clearDashboardResponse() { padding: 0 1rem; } -/* Chat section */ -.chat-section { - margin-bottom: 1rem; -} +/* Chat */ +.chat-section { margin-bottom: 1rem; } .quick-actions { display: flex; flex-wrap: wrap; @@ -462,10 +511,7 @@ function clearDashboardResponse() { border-color: var(--color-primary); color: var(--color-primary); } -.quick-action-chip:disabled { - opacity: 0.4; - cursor: default; -} +.quick-action-chip:disabled { opacity: 0.4; cursor: default; } /* Inline response */ .dashboard-response { @@ -490,17 +536,11 @@ function clearDashboardResponse() { .dashboard-response-text { font-size: 0.9rem; line-height: 1.55; - color: var(--color-text); white-space: pre-wrap; word-break: break-word; } -.dashboard-response-text.streaming { - color: var(--color-text-muted); -} -.thinking-dots { - display: inline-block; - animation: blink 1.2s infinite; -} +.dashboard-response-text.streaming { color: var(--color-text-muted); } +.thinking-dots { display: inline-block; animation: blink 1.2s infinite; } .dashboard-status-line { display: flex; align-items: center; @@ -534,9 +574,7 @@ function clearDashboardResponse() { text-decoration: none; font-weight: 500; } -.btn-open-chat:hover { - text-decoration: underline; -} +.btn-open-chat:hover { text-decoration: underline; } .btn-open-chat.prominent { background: var(--color-primary); color: #fff; @@ -544,10 +582,7 @@ function clearDashboardResponse() { border-radius: var(--radius-sm); font-size: 0.9rem; } -.btn-open-chat.prominent:hover { - text-decoration: none; - opacity: 0.9; -} +.btn-open-chat.prominent:hover { text-decoration: none; opacity: 0.9; } .btn-clear-response { font-size: 0.8rem; color: var(--color-text-muted); @@ -556,11 +591,9 @@ function clearDashboardResponse() { cursor: pointer; padding: 0; } -.btn-clear-response:hover { - color: var(--color-text); -} +.btn-clear-response:hover { color: var(--color-text); } -/* Two-column grid */ +/* Main grid */ .dashboard-grid { display: grid; grid-template-columns: 3fr 2fr; @@ -573,28 +606,20 @@ function clearDashboardResponse() { display: flex; justify-content: space-between; align-items: center; - margin-bottom: 1rem; -} -.col-header h2 { - margin: 0; - font-size: 1.1rem; - font-weight: 600; + margin-bottom: 0.75rem; } +.col-header h2 { margin: 0; font-size: 1.1rem; font-weight: 600; } .see-all { color: var(--color-primary); text-decoration: none; font-size: 0.85rem; } -.see-all:hover { - text-decoration: underline; -} +.see-all:hover { text-decoration: underline; } -/* Task sections */ -.section { - margin-bottom: 1.5rem; -} +/* Task sections — compact rows */ +.section { margin-bottom: 1.25rem; } .section-title { - margin: 0 0 0.6rem; + margin: 0 0 0.4rem; font-size: 0.7rem; font-weight: 700; text-transform: uppercase; @@ -605,31 +630,100 @@ function clearDashboardResponse() { border-left: 3px solid var(--color-danger, #e74c3c); padding-left: 0.75rem; } -.section-overdue .section-title { - color: var(--color-danger, #e74c3c); -} +.section-overdue .section-title { color: var(--color-danger, #e74c3c); } .section-high-priority { border-left: 3px solid var(--color-warning, #f59e0b); padding-left: 0.75rem; } -.cards { +.task-rows { display: flex; flex-direction: column; - gap: 0.6rem; -} -.loading { - color: var(--color-text-secondary); + gap: 0.2rem; } -/* Empty states */ -.empty-state { - text-align: center; - padding: 1.5rem 0; +/* Notes mini-grid: 2-per-row within the column */ +.notes-mini-grid { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 0.6rem; } -.empty-text { + +/* Projects widget */ +.projects-section { + margin-top: 2rem; +} +.projects-strip { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 0.75rem; +} +.project-mini-card { + display: block; + padding: 0.75rem; + border: 1px solid var(--color-border); + border-radius: var(--radius-md); + background: var(--color-bg-card); + text-decoration: none; + color: inherit; + transition: box-shadow 0.15s; +} +.project-mini-card:hover { + box-shadow: 0 2px 8px var(--color-shadow); +} +.project-mini-title { + font-size: 0.9rem; + font-weight: 600; + margin-bottom: 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} +.ms-row { + display: flex; + align-items: center; + gap: 0.4rem; + margin-bottom: 0.3rem; +} +.ms-label { + font-size: 0.72rem; color: var(--color-text-muted); - margin: 0 0 0.75rem; + width: 5rem; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex-shrink: 0; } +.ms-track { + flex: 1; + height: 5px; + background: var(--color-bg-secondary); + border-radius: 999px; + overflow: hidden; +} +.ms-fill { + height: 100%; + border-radius: 999px; + transition: width 0.3s; +} +.ms-pct { + font-size: 0.7rem; + color: var(--color-text-muted); + width: 2.2rem; + text-align: right; + flex-shrink: 0; +} +.project-mini-counts { + font-size: 0.8rem; + color: var(--color-text-secondary); + display: flex; + flex-direction: column; + gap: 0.15rem; +} +.muted { color: var(--color-text-muted); } + +.loading { color: var(--color-text-secondary); } +.empty-state { text-align: center; padding: 1.5rem 0; } +.empty-text { color: var(--color-text-muted); margin: 0 0 0.75rem; } .btn-cta { display: inline-block; padding: 0.45rem 1rem; @@ -644,8 +738,8 @@ function clearDashboardResponse() { /* Mobile */ @media (max-width: 768px) { - .dashboard-grid { - grid-template-columns: 1fr; - } + .dashboard-grid { grid-template-columns: 1fr; } + .notes-mini-grid { grid-template-columns: 1fr; } + .projects-strip { grid-template-columns: 1fr; } } diff --git a/frontend/src/views/NotesListView.vue b/frontend/src/views/NotesListView.vue index eb62cf1..940f282 100644 --- a/frontend/src/views/NotesListView.vue +++ b/frontend/src/views/NotesListView.vue @@ -1,5 +1,5 @@