Files
FabledScribe/frontend/src/views/HomeView.vue
T

943 lines
29 KiB
Vue

<script setup lang="ts">
import { ref, onMounted, onUnmounted } from "vue";
import { apiGet, listEvents } from "@/api/client";
import { useBackgroundRefresh } from "@/composables/useBackgroundRefresh";
import { milestoneColor } from "@/utils/palette";
import { fmtRelativeDateTime } from "@/utils/dateFormat";
import type { Note } from "@/types/note";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { EventEntry } from "@/api/client";
import NoteCard from "@/components/NoteCard.vue";
import TaskCard from "@/components/TaskCard.vue";
import StatusBadge from "@/components/StatusBadge.vue";
import PriorityBadge from "@/components/PriorityBadge.vue";
import ChatPanel from "@/components/ChatPanel.vue";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useTasksStore } from "@/stores/tasks";
import { useChatStore } from "@/stores/chat";
// ─── Types ────────────────────────────────────────────────────────────────────
interface MilestoneSummary {
id: number;
title: string;
status: string;
pct: number;
total: number;
completed: number;
}
interface DashProject {
id: number;
title: string;
status: string;
updated_at: string;
summary?: {
task_counts: { todo?: number; in_progress?: number; done?: number };
milestone_summary: MilestoneSummary[];
};
}
// A recent item from the hero project (note or task — both come from /api/notes?all=true)
interface RecentItem {
id: number;
title: string;
status: string | null;
project_id: number | null;
updated_at: string;
}
// ─── State ────────────────────────────────────────────────────────────────────
const loading = ref(true);
const tasksStore = useTasksStore();
// Hero project — most recently active project
const heroProject = ref<DashProject | null>(null);
const heroRecentItems = ref<RecentItem[]>([]);
const heroNextUp = ref<Task | null>(null);
// All other active projects (hero excluded)
const activeProjects = ref<DashProject[]>([]);
// Orphaned items (no project_id)
const orphanTasks = ref<Task[]>([]);
const orphanNotes = ref<Note[]>([]);
const inboxOpen = ref(true);
// Upcoming events (today + next 7 days)
const upcomingEvents = ref<EventEntry[]>([]);
const eventSlideOverOpen = ref(false);
const editingEvent = ref<EventEntry | null>(null);
// ─── Background refresh (no-flicker) ─────────────────────────────────────────
// Never touches `loading` so existing content stays on screen while fetching.
function _dateRange() {
const today = new Date()
const nextWeek = new Date(today)
nextWeek.setDate(today.getDate() + 7)
// Use full ISO strings so the server sees the correct UTC equivalent of
// local midnight / end-of-day rather than a naive UTC-midnight guess.
return {
todayStr: today.toISOString(),
nextWeekStr: nextWeek.toISOString(),
}
}
function _backgroundRefresh() {
if (document.hidden || loading.value) return
const { todayStr, nextWeekStr } = _dateRange()
Promise.allSettled([
listEvents(todayStr, nextWeekStr),
apiGet<TaskListResponse>('/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8'),
apiGet<{ notes: Note[] }>('/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6'),
]).then(([eventsRes, tasksRes, notesRes]) => {
if (eventsRes.status === 'fulfilled') upcomingEvents.value = eventsRes.value
if (tasksRes.status === 'fulfilled') orphanTasks.value = tasksRes.value.tasks
if (notesRes.status === 'fulfilled') orphanNotes.value = notesRes.value.notes
})
if (heroProject.value) {
apiGet<TaskListResponse>(
`/api/tasks?project_id=${heroProject.value.id}&status=todo&sort=updated_at&order=desc&limit=1`
)
.then((r) => { heroNextUp.value = r.tasks[0] ?? null })
.catch(() => {})
}
}
// ─── Data loading ─────────────────────────────────────────────────────────────
onMounted(async () => {
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
const { todayStr, nextWeekStr } = _dateRange();
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
await Promise.allSettled([
apiGet<{ projects: DashProject[] }>("/api/projects?status=active"),
apiGet<{ notes: RecentItem[] }>(
"/api/notes?all=true&sort=updated_at&order=desc&limit=30"
),
apiGet<TaskListResponse>(
"/api/tasks?no_project=true&sort=updated_at&order=desc&limit=8"
),
apiGet<{ notes: Note[] }>(
"/api/notes?type=note&no_project=true&sort=updated_at&order=desc&limit=6"
),
listEvents(todayStr, nextWeekStr),
]);
// Determine hero project: the project whose item was most recently touched
let heroId: number | null = null;
if (recentRes.status === "fulfilled") {
const withProject = recentRes.value.notes.find(
(n) => n.project_id != null
);
heroId = withProject?.project_id ?? null;
}
if (projectsRes.status === "fulfilled") {
const all = projectsRes.value.projects;
const hero = all.find((p) => p.id === heroId) ?? all[0] ?? null;
heroProject.value = hero;
activeProjects.value = all.filter((p) => p.id !== hero?.id);
}
if (orphanTasksRes.status === "fulfilled")
orphanTasks.value = orphanTasksRes.value.tasks;
if (orphanNotesRes.status === "fulfilled")
orphanNotes.value = orphanNotesRes.value.notes;
if (eventsRes.status === "fulfilled")
upcomingEvents.value = eventsRes.value;
loading.value = false;
// Focus chat input after data loads
chatPanelRef.value?.focus();
loadProjects();
});
useBackgroundRefresh(_backgroundRefresh, 90_000, () => !loading.value);
async function loadProjects() {
if (!heroProject.value) return;
const hid = heroProject.value.id;
// Phase 2: hero details + all project summaries — parallel
const [recentItemsRes, nextUpRes, heroSummaryRes] = await Promise.allSettled([
apiGet<{ notes: RecentItem[] }>(
`/api/notes?all=true&project_id=${hid}&sort=updated_at&order=desc&limit=5`
),
apiGet<TaskListResponse>(
`/api/tasks?project_id=${hid}&status=todo&sort=updated_at&order=desc&limit=1`
),
apiGet<DashProject>(`/api/projects/${hid}`),
]);
if (recentItemsRes.status === "fulfilled")
heroRecentItems.value = recentItemsRes.value.notes;
if (nextUpRes.status === "fulfilled")
heroNextUp.value = nextUpRes.value.tasks[0] ?? null;
if (heroSummaryRes.status === "fulfilled")
heroProject.value = { ...heroProject.value!, ...heroSummaryRes.value };
// Load summaries for remaining projects
await Promise.allSettled(
activeProjects.value.map(async (p) => {
try {
const full = await apiGet<DashProject>(`/api/projects/${p.id}`);
const idx = activeProjects.value.findIndex((x) => x.id === p.id);
if (idx !== -1)
activeProjects.value[idx] = { ...activeProjects.value[idx], ...full };
} catch {
/* non-fatal */
}
})
);
}
// ─── Status toggle (orphaned tasks) ──────────────────────────────────────────
function onStatusToggle(id: number, status: TaskStatus) {
tasksStore.patchStatus(id, status).then((updated) => {
if (updated.status === "done") {
orphanTasks.value = orphanTasks.value.filter((t) => t.id !== id);
} else {
const idx = orphanTasks.value.findIndex((t) => t.id === id);
if (idx !== -1) orphanTasks.value[idx] = updated;
}
});
}
// ─── Chat widget ──────────────────────────────────────────────────────────────
const chatPanelRef = ref<InstanceType<typeof ChatPanel> | null>(null);
function onFocusChatShortcut() {
chatPanelRef.value?.focus();
}
onMounted(() => {
document.addEventListener("shortcut:focus-chat", onFocusChatShortcut);
});
onUnmounted(() => {
document.removeEventListener("shortcut:focus-chat", onFocusChatShortcut);
});
const chatStore = useChatStore();
chatStore.fetchStatus().then(() => {
if (chatStore.defaultModel) chatStore.warmModel(chatStore.defaultModel);
});
const QUICK_ACTIONS = [
"What's due today?",
"Events this week?",
"Any overdue tasks?",
"My high priority tasks?",
];
async function onQuickAction(query: string) {
await chatPanelRef.value?.send(query);
}
// ─── Upcoming events slide-over ───────────────────────────────────────────────
function openEvent(event: EventEntry) {
editingEvent.value = event;
eventSlideOverOpen.value = true;
}
function onEventUpdated(event: EventEntry) {
const idx = upcomingEvents.value.findIndex((e) => e.id === event.id);
if (idx !== -1) upcomingEvents.value[idx] = event;
eventSlideOverOpen.value = false;
}
function onEventDeleted(id: number) {
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
eventSlideOverOpen.value = false;
}
function formatUpcomingTime(event: EventEntry): string {
if (!event.start_dt) return "";
return fmtRelativeDateTime(event.start_dt, event.all_day);
}
</script>
<template>
<main class="home">
<!-- ── Chat widget ─────────────────────────────────────────── -->
<section class="chat-section">
<div class="quick-actions">
<button
v-for="q in QUICK_ACTIONS"
:key="q"
class="quick-action-chip"
:disabled="chatStore.streaming || !chatStore.chatReady"
@click="onQuickAction(q)"
>{{ q }}</button>
</div>
<ChatPanel ref="chatPanelRef" variant="widget" />
</section>
<!-- ── Upcoming events ────────────────────────────────────── -->
<div v-if="!loading && upcomingEvents.length" class="upcoming-events-section">
<div class="section-header">
<h2>Upcoming</h2>
<router-link to="/calendar" class="see-all">Calendar </router-link>
</div>
<div class="upcoming-events-list">
<button
v-for="ev in upcomingEvents.slice(0, 6)"
:key="ev.id"
class="upcoming-event-card"
@click="openEvent(ev)"
>
<span class="upcoming-event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
<span class="upcoming-event-body">
<span class="upcoming-event-title">{{ ev.title }}</span>
<span class="upcoming-event-time">{{ formatUpcomingTime(ev) }}</span>
<span v-if="ev.location" class="upcoming-event-loc">{{ ev.location }}</span>
</span>
</button>
<div v-if="upcomingEvents.length > 6" class="upcoming-events-more">
+{{ upcomingEvents.length - 6 }} more
<router-link to="/calendar" class="see-all-inline">view all</router-link>
</div>
</div>
</div>
<!-- ── Skeleton while loading ─────────────────────────────── -->
<template v-if="loading">
<div class="skeleton-hero"></div>
<div class="skeleton-grid">
<div class="skeleton-card" v-for="i in 3" :key="i"></div>
</div>
</template>
<template v-else>
<!-- ── Hero: Last Active Project ──────────────────────── -->
<div v-if="heroProject" class="hero-card">
<div class="hero-top">
<div class="hero-identity">
<span class="hero-label">Last active</span>
<router-link :to="`/projects/${heroProject.id}`" class="hero-title">
{{ heroProject.title }}
</router-link>
</div>
<router-link :to="`/workspace/${heroProject.id}`" class="btn-workspace-hero">
Open Workspace
</router-link>
</div>
<!-- Milestone bars -->
<div
v-if="heroProject.summary?.milestone_summary?.length"
class="hero-milestones"
>
<div
v-for="(ms, i) in heroProject.summary.milestone_summary
.filter((m) => m.status !== 'archived')
.slice(0, 3)"
:key="ms.id"
class="ms-row"
>
<span class="ms-label">{{ ms.title }}</span>
<div class="ms-track">
<div
class="ms-fill"
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
></div>
</div>
<span class="ms-pct">{{ Math.round(ms.pct) }}%</span>
</div>
</div>
<!-- Recent items -->
<div v-if="heroRecentItems.length" class="hero-recent">
<span class="hero-section-label">Recent</span>
<div class="hero-items">
<router-link
v-for="item in heroRecentItems"
:key="item.id"
:to="item.status != null ? `/tasks/${item.id}` : `/notes/${item.id}`"
class="hero-item"
>
<span class="hero-item-icon">{{ item.status != null ? '◉' : '◈' }}</span>
<span class="hero-item-title">{{ item.title || 'Untitled' }}</span>
<StatusBadge
v-if="item.status"
:status="(item.status as TaskStatus)"
class="hero-item-badge"
/>
</router-link>
</div>
</div>
<!-- Next up -->
<div v-if="heroNextUp" class="hero-next-up">
<span class="hero-section-label">Next up</span>
<router-link :to="`/tasks/${heroNextUp.id}`" class="hero-next-link">
<PriorityBadge :priority="heroNextUp.priority ?? 'none'" />
{{ heroNextUp.title }}
</router-link>
</div>
</div>
<!-- ── Active Projects grid ────────────────────────────── -->
<div v-if="activeProjects.length" class="projects-section">
<div class="section-header">
<h2>Projects</h2>
<router-link to="/projects" class="see-all">See all →</router-link>
</div>
<div class="projects-grid">
<div
v-for="project in activeProjects"
:key="project.id"
class="project-card"
>
<div class="project-card-top">
<router-link
:to="`/projects/${project.id}`"
class="project-card-title"
>
{{ project.title }}
</router-link>
<router-link
:to="`/workspace/${project.id}`"
class="btn-workspace-sm"
title="Open Workspace"
>▶</router-link>
</div>
<!-- Urgency badges -->
<div class="project-urgency">
<span
v-if="(project.summary?.task_counts?.in_progress ?? 0) > 0"
class="urgency-badge urgency-in-progress"
>{{ project.summary!.task_counts.in_progress }} in progress</span>
<span
v-if="(project.summary?.task_counts?.todo ?? 0) > 0"
class="urgency-badge urgency-todo"
>{{ project.summary!.task_counts.todo }} todo</span>
<span
v-if="project.summary && !project.summary.task_counts.in_progress && !project.summary.task_counts.todo"
class="urgency-badge urgency-clear"
>All clear</span>
<span v-if="!project.summary" class="urgency-badge urgency-loading">Loading…</span>
</div>
<!-- Milestone bars (up to 2) -->
<template v-if="project.summary?.milestone_summary?.length">
<div
v-for="(ms, i) in project.summary.milestone_summary
.filter((m) => m.status !== 'archived')
.slice(0, 2)"
:key="ms.id"
class="ms-row"
>
<span class="ms-label">{{ ms.title }}</span>
<div class="ms-track">
<div
class="ms-fill"
:style="{ width: ms.pct + '%', background: milestoneColor(i) }"
></div>
</div>
<span class="ms-pct">{{ Math.round(ms.pct) }}%</span>
</div>
</template>
</div>
</div>
</div>
<!-- ── Inbox: orphaned items ───────────────────────────── -->
<div
v-if="orphanTasks.length || orphanNotes.length"
class="inbox-section"
>
<button class="inbox-header" @click="inboxOpen = !inboxOpen">
<span class="inbox-toggle">{{ inboxOpen ? '▼' : '▶' }}</span>
<h2>
Inbox
<span class="inbox-count">{{ orphanTasks.length + orphanNotes.length }}</span>
</h2>
<span class="inbox-sub">Notes and tasks without a project</span>
</button>
<div v-if="inboxOpen" class="inbox-items">
<TaskCard
v-for="task in orphanTasks"
:key="task.id"
:task="task"
compact
@status-toggle="onStatusToggle"
/>
<NoteCard
v-for="note in orphanNotes"
:key="note.id"
:note="note"
/>
</div>
</div>
</template>
</main>
<!-- Event slide-over -->
<EventSlideOver
v-if="eventSlideOverOpen"
:event="editingEvent"
initial-date=""
@close="eventSlideOverOpen = false"
@created="eventSlideOverOpen = false"
@updated="onEventUpdated"
@deleted="onEventDeleted"
/>
</template>
<style scoped>
.home {
max-width: var(--page-max-width);
margin: 2rem auto;
padding: 0 var(--page-padding-x);
}
/* ─── Chat widget ────────────────────────────────────────────── */
.chat-section {
max-width: 720px;
margin: 0 auto 1.5rem;
}
.quick-actions {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 0.4rem;
margin-bottom: 0.75rem;
}
.quick-action-chip {
padding: 0.3rem 0.75rem;
border: 1px solid var(--color-border);
border-radius: 999px;
background: var(--color-bg-secondary);
color: var(--color-text);
font-size: 0.8rem;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.quick-action-chip:hover:not(:disabled) {
border-color: var(--color-primary);
color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 8%, transparent);
}
.quick-action-chip:disabled { opacity: 0.4; cursor: default; }
/* ─── Skeleton loading ───────────────────────────────────────── */
.skeleton-hero {
height: 180px;
border-radius: var(--radius-lg);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
margin-bottom: 1.75rem;
}
.skeleton-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
margin-bottom: 1.75rem;
}
.skeleton-card {
height: 120px;
border-radius: var(--radius-md);
background: linear-gradient(90deg, var(--color-bg-secondary) 25%, var(--color-border) 50%, var(--color-bg-secondary) 75%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.4s ease infinite;
}
@keyframes skeleton-shimmer {
0% { background-position: 200% 0; }
100% { background-position: -200% 0; }
}
/* ─── Hero card ──────────────────────────────────────────────── */
.hero-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
padding: 1.25rem 1.5rem;
margin-bottom: 1.75rem;
box-shadow: 0 2px 16px rgba(124, 58, 237, 0.08), 0 1px 4px rgba(0, 0, 0, 0.06);
}
.hero-top {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
margin-bottom: 1rem;
}
.hero-identity {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.hero-label {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.08em;
color: var(--color-text-muted);
}
.hero-title {
font-size: 1.4rem;
font-weight: 700;
color: var(--color-text);
text-decoration: none;
letter-spacing: -0.02em;
line-height: 1.2;
}
.hero-title:hover { color: var(--color-primary); }
.btn-workspace-hero {
display: inline-flex;
align-items: center;
gap: 0.4rem;
padding: 0.55rem 1.1rem;
background: var(--gradient-cta);
color: #fff;
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.9rem;
font-weight: 600;
white-space: nowrap;
box-shadow: 0 1px 6px rgba(124, 58, 237, 0.3);
transition: opacity 0.15s, box-shadow 0.15s;
flex-shrink: 0;
}
.btn-workspace-hero:hover {
opacity: 0.9;
box-shadow: 0 3px 14px rgba(124, 58, 237, 0.45);
}
.hero-milestones { margin-bottom: 0.75rem; }
.hero-section-label {
font-size: 0.68rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.07em;
color: var(--color-text-muted);
display: block;
margin-bottom: 0.4rem;
}
.hero-recent { margin-bottom: 0.75rem; }
.hero-items {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.hero-item {
display: flex;
align-items: center;
gap: 0.5rem;
padding: 0.3rem 0.5rem;
border-radius: var(--radius-sm);
text-decoration: none;
color: var(--color-text-secondary);
font-size: 0.88rem;
transition: background 0.12s, color 0.12s;
}
.hero-item:hover {
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
color: var(--color-text);
}
.hero-item-icon { opacity: 0.45; font-size: 0.75rem; flex-shrink: 0; }
.hero-item-title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.hero-item-badge { flex-shrink: 0; }
.hero-next-up {
display: flex;
align-items: center;
gap: 0.5rem;
}
.hero-next-link {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--color-text);
text-decoration: none;
font-size: 0.9rem;
font-weight: 500;
}
.hero-next-link:hover { color: var(--color-primary); }
/* ─── Projects grid ──────────────────────────────────────────── */
.projects-section { margin-bottom: 1.75rem; }
.section-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.75rem;
}
.section-header h2 { margin: 0; font-size: 1rem; font-weight: 600; }
.see-all { font-size: 0.85rem; color: var(--color-primary); text-decoration: none; }
.see-all:hover { text-decoration: underline; }
.projects-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
gap: 0.75rem;
}
.project-card {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
padding: 0.85rem 1rem;
transition: box-shadow 0.18s, border-color 0.15s, transform 0.18s;
}
.project-card:hover {
box-shadow: 0 4px 16px rgba(0, 0, 0, 0.10);
border-color: var(--color-primary);
transform: translateY(-2px);
}
.project-card-top {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 0.5rem;
gap: 0.5rem;
}
.project-card-title {
font-size: 0.9rem;
font-weight: 600;
color: var(--color-text);
text-decoration: none;
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.project-card-title:hover { color: var(--color-primary); }
.btn-workspace-sm {
display: inline-flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
background: color-mix(in srgb, var(--color-primary) 12%, transparent);
color: var(--color-primary);
border-radius: var(--radius-sm);
text-decoration: none;
font-size: 0.75rem;
flex-shrink: 0;
transition: background 0.15s, color 0.15s;
}
.btn-workspace-sm:hover {
background: var(--color-primary);
color: #fff;
}
.project-urgency {
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
margin-bottom: 0.5rem;
}
.urgency-badge {
font-size: 0.72rem;
padding: 0.1rem 0.45rem;
border-radius: var(--radius-pill);
font-weight: 500;
}
.urgency-in-progress {
background: color-mix(in srgb, #7c3aed 15%, transparent);
color: #7c3aed;
}
.urgency-todo {
background: color-mix(in srgb, var(--color-text-muted) 12%, transparent);
color: var(--color-text-muted);
}
.urgency-clear {
background: color-mix(in srgb, #22c55e 12%, transparent);
color: #16a34a;
}
.urgency-loading { color: var(--color-text-muted); font-style: italic; }
/* ─── Inbox ──────────────────────────────────────────────────── */
.inbox-section {
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
overflow: hidden;
margin-bottom: 1.75rem;
}
.inbox-header {
width: 100%;
display: flex;
align-items: center;
gap: 0.6rem;
padding: 0.75rem 1rem;
background: none;
border: none;
cursor: pointer;
text-align: left;
color: var(--color-text);
transition: background 0.15s;
}
.inbox-header:hover { background: var(--color-bg-secondary); }
.inbox-toggle { font-size: 0.75rem; color: var(--color-text-muted); }
.inbox-header h2 {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.4rem;
}
.inbox-count {
background: color-mix(in srgb, var(--color-text-muted) 15%, transparent);
color: var(--color-text-muted);
font-size: 0.75rem;
padding: 0.05rem 0.4rem;
border-radius: var(--radius-pill);
}
.inbox-sub {
font-size: 0.8rem;
color: var(--color-text-muted);
margin-left: auto;
}
.inbox-items {
padding: 0.5rem 0.75rem 0.75rem;
display: flex;
flex-direction: column;
gap: 0.25rem;
}
/* ─── Shared: milestone bars ─────────────────────────────────── */
.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);
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;
}
/* ─── Mobile ─────────────────────────────────────────────────── */
@media (max-width: 680px) {
.home { padding: 0 var(--page-padding-x); margin: 1rem auto; }
.hero-top { flex-direction: column; align-items: flex-start; }
.btn-workspace-hero { width: 100%; justify-content: center; }
.projects-grid { grid-template-columns: 1fr; }
.inbox-sub { display: none; }
}
/* ─── Upcoming events ────────────────────────────────────────── */
.upcoming-events-section {
margin-bottom: 1.75rem;
}
.upcoming-events-list {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
gap: 0.5rem;
}
.upcoming-event-card {
display: flex;
align-items: flex-start;
gap: 0.5rem;
padding: 0.55rem 0.75rem;
background: var(--color-bg-card);
border: 1px solid var(--color-border);
border-radius: var(--radius-md);
cursor: pointer;
text-align: left;
font-family: inherit;
transition: border-color 0.15s, background 0.15s;
width: 100%;
}
.upcoming-event-card:hover {
border-color: var(--color-primary);
background: color-mix(in srgb, var(--color-primary) 6%, var(--color-bg-card));
}
.upcoming-event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 4px;
}
.upcoming-event-body {
display: flex;
flex-direction: column;
gap: 0.1rem;
min-width: 0;
}
.upcoming-event-title {
font-size: 0.85rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-event-time {
font-size: 0.75rem;
color: var(--color-text-muted);
}
.upcoming-event-loc {
font-size: 0.72rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-events-more {
font-size: 0.8rem;
color: var(--color-text-muted);
padding: 0.25rem 0;
grid-column: 1 / -1;
}
.see-all-inline {
color: var(--color-primary);
text-decoration: none;
}
.see-all-inline:hover { text-decoration: underline; }
</style>