feat: event cards in chat/briefing, upcoming events on dashboard, briefing uses internal store

- ToolCallCard: event list items replaced with rich clickable cards (color dot,
  title, time, location); clicking opens EventSlideOver for edit/delete; single
  create/update events in header are also clickable; updated all event types to
  use start_dt/end_dt fields from internal store
- HomeView: new upcoming events widget shows today + next 7 days as a card grid
  above the hero project; clicking any card opens EventSlideOver inline
- briefing_pipeline: _gather_internal now queries the internal events store for
  today's events; CalDAV events are still appended (deduped) if configured

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-25 15:39:13 -04:00
parent df1194ee96
commit e0c3836fab
3 changed files with 348 additions and 25 deletions
+172 -3
View File
@@ -1,15 +1,17 @@
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import { apiGet } from "@/api/client";
import { apiGet, listEvents } from "@/api/client";
import type { Note } from "@/types/note";
import type { Task, TaskListResponse, TaskStatus } from "@/types/task";
import type { ToolCallRecord, Message } from "@/types/chat";
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 ToolCallCard from "@/components/ToolCallCard.vue";
import DashboardChatInput from "@/components/DashboardChatInput.vue";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useTasksStore } from "@/stores/tasks";
import { useChatStore } from "@/stores/chat";
@@ -62,6 +64,11 @@ 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);
// ─── Milestone color palette ──────────────────────────────────────────────────
function milestoneColor(index: number): string {
@@ -78,8 +85,14 @@ function milestoneColor(index: number): string {
// ─── Data loading ─────────────────────────────────────────────────────────────
onMounted(async () => {
// Phase 1: projects list + cross-project recent items + orphaned items — all parallel
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes] =
// Phase 1: projects list + cross-project recent items + orphaned items + events — all parallel
const today = new Date();
const todayStr = today.toISOString().slice(0, 10) + "T00:00:00";
const nextWeek = new Date(today);
nextWeek.setDate(today.getDate() + 7);
const nextWeekStr = nextWeek.toISOString().slice(0, 10) + "T23:59:59";
const [projectsRes, recentRes, orphanTasksRes, orphanNotesRes, eventsRes] =
await Promise.allSettled([
apiGet<{ projects: DashProject[] }>("/api/projects?status=active"),
apiGet<{ notes: RecentItem[] }>(
@@ -91,6 +104,7 @@ onMounted(async () => {
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
@@ -113,6 +127,8 @@ onMounted(async () => {
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;
@@ -242,6 +258,49 @@ function clearDashboardResponse() {
dashboardFinalContent.value = "";
dashboardFinalToolCalls.value = [];
}
// ─── 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.all_day) return "All day";
if (!event.start_dt) return "";
try {
const d = new Date(event.start_dt);
const today = new Date();
const tomorrow = new Date(today);
tomorrow.setDate(today.getDate() + 1);
const isToday =
d.getFullYear() === today.getFullYear() &&
d.getMonth() === today.getMonth() &&
d.getDate() === today.getDate();
const isTomorrow =
d.getFullYear() === tomorrow.getFullYear() &&
d.getMonth() === tomorrow.getMonth() &&
d.getDate() === tomorrow.getDate();
const timeStr = d.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" });
if (isToday) return `Today ${timeStr}`;
if (isTomorrow) return `Tomorrow ${timeStr}`;
return d.toLocaleDateString(undefined, { weekday: "short", month: "short", day: "numeric" }) + " " + timeStr;
} catch {
return event.start_dt;
}
}
</script>
<template>
@@ -290,6 +349,33 @@ function clearDashboardResponse() {
</div>
</div>
<!-- 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>
@@ -466,6 +552,17 @@ function clearDashboardResponse() {
</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>
@@ -917,4 +1014,76 @@ function clearDashboardResponse() {
.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, #6366f1);
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card));
}
.upcoming-event-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--color-primary, #6366f1);
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>