Overhaul dashboard, notes, and tasks views for density and usability

NoteCard: add compact prop — single-row title/tags/timestamp, no body preview
TaskCard: add compact prop — single-row with status dot, priority badge,
  title, optional project breadcrumb, due date; edit button appears on hover.
  Add projectTitle prop for cross-view context.

NotesListView: auto-fill CSS grid layout (2–4 columns); compact list toggle;
  view mode persisted to localStorage.

TasksListView: compact rows throughout; group-by-project toggle with
  collapsible sections, task counts, and "Open project" links; fetches
  project list for group labels and task breadcrumbs; limit raised to 100
  in grouped mode; view mode persisted to localStorage.

HomeView dashboard: task sections use compact rows (project breadcrumb shown);
  notes column uses 2-per-row mini-grid; new "Active Projects" widget below
  main grid shows milestone progress bars for up to 6 active projects.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 18:57:57 -05:00
parent eed6aedc9e
commit 56d5268fad
5 changed files with 793 additions and 246 deletions
+66 -17
View File
@@ -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() {
</script>
<template>
<router-link :to="`/notes/${note.id}`" class="note-card">
<div class="note-top">
<h3 class="note-title">{{ note.title || "Untitled" }}</h3>
<button class="btn-edit" title="Edit" @click.prevent.stop="goEdit">Edit</button>
</div>
<div v-if="note.body" class="note-preview prose" v-html="renderPreview(note.body)"></div>
<div class="note-meta">
<TagPill
v-for="tag in note.tags"
:key="tag"
:tag="tag"
@click.stop="onTagClick(tag)"
/>
<span class="timestamp">{{ relativeTime(note.updated_at) }}</span>
</div>
<router-link :to="`/notes/${note.id}`" :class="['note-card', { compact }]">
<!-- Compact: single row -->
<template v-if="compact">
<span class="note-title-compact">{{ note.title || "Untitled" }}</span>
<div class="note-tags-compact">
<TagPill
v-for="tag in note.tags.slice(0, 3)"
:key="tag"
:tag="tag"
@click.stop="onTagClick(tag)"
/>
</div>
<span class="timestamp-compact">{{ relativeTime(note.updated_at) }}</span>
</template>
<!-- Full: original layout -->
<template v-else>
<div class="note-top">
<h3 class="note-title">{{ note.title || "Untitled" }}</h3>
<button class="btn-edit" title="Edit" @click.prevent.stop="goEdit">Edit</button>
</div>
<div v-if="note.body" class="note-preview prose" v-html="renderPreview(note.body)"></div>
<div class="note-meta">
<TagPill
v-for="tag in note.tags"
:key="tag"
:tag="tag"
@click.stop="onTagClick(tag)"
/>
<span class="timestamp">{{ relativeTime(note.updated_at) }}</span>
</div>
</template>
</router-link>
</template>
@@ -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);
+147 -23
View File
@@ -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<TaskStatus, TaskStatus> = {
done: "todo",
};
const statusDotClass: Record<TaskStatus, string> = {
todo: "dot-todo",
in_progress: "dot-in-progress",
done: "dot-done",
};
const statusTitle: Record<TaskStatus, string> = {
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 {
</script>
<template>
<router-link :to="`/tasks/${task.id}`" class="task-card">
<div class="task-top">
<StatusBadge
:status="task.status!"
clickable
<router-link :to="`/tasks/${task.id}`" :class="['task-card', { compact }]">
<!-- Compact: single row -->
<template v-if="compact">
<button
:class="['status-dot', statusDotClass[task.status!]]"
:title="statusTitle[task.status!]"
@click.prevent.stop="cycleStatus"
/>
></button>
<PriorityBadge :priority="task.priority!" />
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
<button class="btn-edit" title="Edit" @click.prevent.stop="goEdit">Edit</button>
</div>
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
<div class="task-meta">
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
Due: {{ task.due_date }}
<span class="task-title-compact">{{ task.title || "Untitled" }}</span>
<span v-if="projectTitle" class="project-crumb">{{ projectTitle }}</span>
<span v-if="task.due_date" :class="['due-compact', { overdue: isOverdue() }]">
{{ task.due_date }}
</span>
<TagPill
v-for="tag in task.tags"
:key="tag"
:tag="tag"
@click.stop="emit('tag-click', tag)"
/>
<span class="timestamp">{{ relativeTime(task.updated_at) }}</span>
</div>
<button class="btn-edit-compact" title="Edit" @click.prevent.stop="goEdit">Edit</button>
</template>
<!-- Full: original layout -->
<template v-else>
<div class="task-top">
<StatusBadge
:status="task.status!"
clickable
@click.prevent.stop="cycleStatus"
/>
<PriorityBadge :priority="task.priority!" />
<h3 class="task-title">{{ task.title || "Untitled" }}</h3>
<button class="btn-edit" title="Edit" @click.prevent.stop="goEdit">Edit</button>
</div>
<div v-if="task.body" class="task-preview prose" v-html="renderPreview(task.body)"></div>
<div class="task-meta">
<span v-if="task.due_date" :class="['due-date', { overdue: isOverdue() }]">
Due: {{ task.due_date }}
</span>
<TagPill
v-for="tag in task.tags"
:key="tag"
:tag="tag"
@click.stop="emit('tag-click', tag)"
/>
<span class="timestamp">{{ relativeTime(task.updated_at) }}</span>
</div>
</template>
</router-link>
</template>
@@ -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);