feat(briefing): add 2-week upcoming events list below weather card

Fetch and display the next 14 days of events in the briefing right column,
grouped by day with color dots, time, and location. Fills the empty space
below the weather card.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-17 17:14:20 -04:00
parent 619f069358
commit cac62c6caf
+146
View File
@@ -16,7 +16,9 @@ import {
postRssReaction,
deleteRssReaction,
getNewsItems,
listEvents,
type BriefingConversation,
type EventEntry,
} from '@/api/client'
import type { NewsItem } from '@/types/news'
@@ -103,6 +105,55 @@ async function refreshWeather() {
finally { refreshingWeather.value = false }
}
// Upcoming events (right column, below weather)
const upcomingEvents = ref<EventEntry[]>([])
interface GroupedDay {
label: string
dateKey: string
events: EventEntry[]
}
const groupedEvents = computed<GroupedDay[]>(() => {
const groups = new Map<string, EventEntry[]>()
const today = new Date()
today.setHours(0, 0, 0, 0)
for (const ev of upcomingEvents.value) {
const d = new Date(ev.start_dt)
const key = d.toISOString().slice(0, 10)
if (!groups.has(key)) groups.set(key, [])
groups.get(key)!.push(ev)
}
const result: GroupedDay[] = []
for (const [key, events] of groups) {
const d = new Date(key + 'T00:00:00')
const diff = Math.round((d.getTime() - today.getTime()) / 86_400_000)
let label: string
if (diff === 0) label = 'Today'
else if (diff === 1) label = 'Tomorrow'
else label = d.toLocaleDateString(undefined, { weekday: 'short', month: 'short', day: 'numeric' })
result.push({ label, dateKey: key, events })
}
return result
})
function formatEventTime(ev: EventEntry): string {
if (ev.all_day) return 'All day'
const d = new Date(ev.start_dt)
return d.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })
}
async function loadEvents() {
try {
const now = new Date()
const end = new Date(now)
end.setDate(end.getDate() + 14)
upcomingEvents.value = await listEvents(now.toISOString(), end.toISOString())
} catch { /* silent */ }
}
// News panel (right column)
const newsItems = ref<NewsItem[]>([])
@@ -126,6 +177,7 @@ async function loadAll() {
loadWeather(),
loadNews(),
loadCurrentConditions(),
loadEvents(),
])
conversations.value = convList
if (today) {
@@ -314,6 +366,27 @@ onMounted(async () => {
/>
</div>
<!-- Upcoming events -->
<div class="events-section" v-if="groupedEvents.length">
<div class="panel-label-row">
<div class="panel-label">Upcoming</div>
<router-link to="/calendar" class="events-cal-link">Calendar </router-link>
</div>
<div class="events-list">
<div v-for="group in groupedEvents" :key="group.dateKey" class="events-day-group">
<div class="events-day-label">{{ group.label }}</div>
<div v-for="ev in group.events" :key="ev.id" class="event-row">
<span class="event-dot" :style="ev.color ? { background: ev.color } : {}"></span>
<span class="event-body">
<span class="event-title">{{ ev.title }}</span>
<span class="event-time">{{ formatEventTime(ev) }}</span>
<span v-if="ev.location" class="event-loc">{{ ev.location }}</span>
</span>
</div>
</div>
</div>
</div>
<!-- News section (scrollable) -->
<div v-if="settingsStore.rssEnabled" class="news-section">
<div class="panel-label-row">
@@ -541,6 +614,79 @@ onMounted(async () => {
font-weight: 600;
}
/* ─── Upcoming events ─────────────────────────────────────── */
.events-section {
padding: 0.75rem 1rem;
border-top: 1px solid var(--color-border);
}
.events-cal-link {
font-size: 0.75rem;
color: var(--color-text-muted);
text-decoration: none;
}
.events-cal-link:hover { color: var(--color-primary); }
.events-list {
display: flex;
flex-direction: column;
gap: 0.6rem;
}
.events-day-group {
display: flex;
flex-direction: column;
gap: 0.2rem;
}
.events-day-label {
font-size: 0.72rem;
font-weight: 700;
color: var(--color-text-muted);
text-transform: uppercase;
letter-spacing: 0.03em;
padding-bottom: 0.15rem;
}
.event-row {
display: flex;
align-items: flex-start;
gap: 0.4rem;
padding: 0.25rem 0.4rem;
border-radius: 6px;
}
.event-row:hover {
background: color-mix(in srgb, var(--color-primary) 6%, transparent);
}
.event-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--color-primary);
flex-shrink: 0;
margin-top: 5px;
}
.event-body {
display: flex;
flex-direction: column;
gap: 0.05rem;
min-width: 0;
}
.event-title {
font-size: 0.82rem;
font-weight: 600;
color: var(--color-text);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.event-time {
font-size: 0.72rem;
color: var(--color-text-muted);
}
.event-loc {
font-size: 0.7rem;
color: var(--color-text-muted);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.news-section {
flex: 1;
overflow-y: auto;