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:
@@ -1,7 +1,9 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from "vue";
|
||||
import { apiPost } from "@/api/client";
|
||||
import { apiPost, getEvent } from "@/api/client";
|
||||
import type { EventEntry } from "@/api/client";
|
||||
import type { ToolCallRecord } from "@/types/chat";
|
||||
import EventSlideOver from "@/components/EventSlideOver.vue";
|
||||
|
||||
const props = defineProps<{
|
||||
toolCall: ToolCallRecord;
|
||||
@@ -67,19 +69,19 @@ const suggestedTags = computed(() => props.toolCall.result.suggested_tags ?? [])
|
||||
const eventData = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event") return null;
|
||||
return data as { title: string; start: string; end: string };
|
||||
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
|
||||
});
|
||||
|
||||
const updatedEvent = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event_updated") return null;
|
||||
return data as { title: string; start: string; end: string };
|
||||
return data as { id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string };
|
||||
});
|
||||
|
||||
const deletedEvent = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "event_deleted") return null;
|
||||
return data as { title: string };
|
||||
return data as { id?: number; title: string };
|
||||
});
|
||||
|
||||
const calendarList = computed(() => {
|
||||
@@ -111,7 +113,7 @@ const todoCount = computed(() => {
|
||||
const eventList = computed(() => {
|
||||
const data = props.toolCall.result.data;
|
||||
if (!data || props.toolCall.result.type !== "events") return null;
|
||||
return (data.events as Array<{ title: string; start: string; end: string; location?: string }> | undefined) ?? [];
|
||||
return (data.events as Array<{ id?: number; title: string; start_dt?: string; end_dt?: string; location?: string; color?: string }> | undefined) ?? [];
|
||||
});
|
||||
|
||||
const eventCount = computed(() => {
|
||||
@@ -251,6 +253,26 @@ async function applyTag(tag: string) {
|
||||
applyingTag.value = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ── Event slide-over ─────────────────────────────────────────────────────────
|
||||
|
||||
const eventSlideOverOpen = ref(false);
|
||||
const eventSlideOverEntry = ref<EventEntry | null>(null);
|
||||
|
||||
async function openEventSlideOver(id: number | undefined) {
|
||||
if (!id) return;
|
||||
try {
|
||||
const entry = await getEvent(id);
|
||||
eventSlideOverEntry.value = entry;
|
||||
eventSlideOverOpen.value = true;
|
||||
} catch {
|
||||
// silently fail — event may have been deleted
|
||||
}
|
||||
}
|
||||
|
||||
function closeEventSlideOver() {
|
||||
eventSlideOverOpen.value = false;
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -316,12 +338,26 @@ async function applyTag(tag: string) {
|
||||
</span>
|
||||
</template>
|
||||
<template v-else-if="eventData">
|
||||
<span class="tool-event-title">{{ eventData.title }}</span>
|
||||
<span class="tool-event-time">{{ formatEventTime(eventData.start) }}</span>
|
||||
<button v-if="eventData.id" class="tool-event-btn" @click.stop="openEventSlideOver(eventData.id)">
|
||||
<span class="tool-event-dot" v-if="eventData.color" :style="{ background: eventData.color }"></span>
|
||||
<span class="tool-event-title">{{ eventData.title }}</span>
|
||||
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<span class="tool-event-title">{{ eventData.title }}</span>
|
||||
<span v-if="eventData.start_dt" class="tool-event-time">{{ formatEventTime(eventData.start_dt) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="updatedEvent">
|
||||
<span class="tool-event-title">{{ updatedEvent.title }}</span>
|
||||
<span class="tool-event-time">{{ formatEventTime(updatedEvent.start) }}</span>
|
||||
<button v-if="updatedEvent.id" class="tool-event-btn" @click.stop="openEventSlideOver(updatedEvent.id)">
|
||||
<span class="tool-event-dot" v-if="updatedEvent.color" :style="{ background: updatedEvent.color }"></span>
|
||||
<span class="tool-event-title">{{ updatedEvent.title }}</span>
|
||||
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
|
||||
</button>
|
||||
<template v-else>
|
||||
<span class="tool-event-title">{{ updatedEvent.title }}</span>
|
||||
<span v-if="updatedEvent.start_dt" class="tool-event-time">{{ formatEventTime(updatedEvent.start_dt) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
<template v-else-if="deletedEvent">
|
||||
<span class="tool-deleted">{{ deletedEvent.title }}</span>
|
||||
@@ -410,11 +446,21 @@ async function applyTag(tag: string) {
|
||||
</template>
|
||||
|
||||
<template v-else-if="eventList !== null && eventList.length > 0">
|
||||
<div class="tool-event-list">
|
||||
<div v-for="(ev, i) in eventList.slice(0, 5)" :key="i" class="tool-event-item">
|
||||
<span class="tool-event-item-title">{{ ev.title }}</span>
|
||||
<span class="tool-event-item-time">{{ formatEventTime(ev.start) }}</span>
|
||||
</div>
|
||||
<div class="tool-event-cards">
|
||||
<button
|
||||
v-for="(ev, i) in eventList.slice(0, 5)"
|
||||
:key="i"
|
||||
class="tool-event-card"
|
||||
:class="{ clickable: !!ev.id }"
|
||||
@click="openEventSlideOver(ev.id)"
|
||||
>
|
||||
<span class="tool-event-card-dot" :style="ev.color ? { background: ev.color } : {}"></span>
|
||||
<span class="tool-event-card-body">
|
||||
<span class="tool-event-card-title">{{ ev.title }}</span>
|
||||
<span v-if="ev.start_dt" class="tool-event-card-time">{{ formatEventTime(ev.start_dt) }}</span>
|
||||
<span v-if="ev.location" class="tool-event-card-loc">{{ ev.location }}</span>
|
||||
</span>
|
||||
</button>
|
||||
<div v-if="eventList.length > 5" class="tool-event-more">+{{ eventList.length - 5 }} more</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -469,6 +515,17 @@ async function applyTag(tag: string) {
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Event slide-over (portal-like, fixed positioned) -->
|
||||
<EventSlideOver
|
||||
v-if="eventSlideOverOpen"
|
||||
:event="eventSlideOverEntry"
|
||||
initial-date=""
|
||||
@close="closeEventSlideOver"
|
||||
@created="closeEventSlideOver"
|
||||
@updated="closeEventSlideOver"
|
||||
@deleted="closeEventSlideOver"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -671,4 +728,82 @@ async function applyTag(tag: string) {
|
||||
color: var(--color-danger, #e74c3c);
|
||||
border-color: var(--color-danger, #e74c3c);
|
||||
}
|
||||
|
||||
/* ── Event header click button ── */
|
||||
.tool-event-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.3rem;
|
||||
background: none;
|
||||
border: none;
|
||||
padding: 0;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tool-event-btn:hover .tool-event-title {
|
||||
text-decoration: underline;
|
||||
color: var(--color-primary);
|
||||
}
|
||||
.tool-event-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* ── Event cards (list) ── */
|
||||
.tool-event-cards { display: flex; flex-direction: column; gap: 0.25rem; }
|
||||
.tool-event-card {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.45rem;
|
||||
padding: 0.3rem 0.45rem;
|
||||
border-radius: 6px;
|
||||
border: 1px solid var(--color-border);
|
||||
background: var(--color-bg-card, #16161a);
|
||||
text-align: left;
|
||||
font-family: inherit;
|
||||
cursor: default;
|
||||
transition: border-color 0.15s, background 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.tool-event-card.clickable { cursor: pointer; }
|
||||
.tool-event-card.clickable:hover {
|
||||
border-color: var(--color-primary, #6366f1);
|
||||
background: color-mix(in srgb, var(--color-primary, #6366f1) 6%, var(--color-bg-card, #16161a));
|
||||
}
|
||||
.tool-event-card-dot {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
border-radius: 50%;
|
||||
background: var(--color-primary, #6366f1);
|
||||
flex-shrink: 0;
|
||||
margin-top: 3px;
|
||||
}
|
||||
.tool-event-card-body {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.1rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.tool-event-card-title {
|
||||
font-size: 0.8rem;
|
||||
font-weight: 600;
|
||||
color: var(--color-text);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.tool-event-card-time {
|
||||
font-size: 0.72rem;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
.tool-event-card-loc {
|
||||
font-size: 0.7rem;
|
||||
color: var(--color-text-muted);
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -162,17 +162,36 @@ async def _gather_internal(user_id: int) -> dict:
|
||||
logger.warning("Failed to gather tasks for briefing", exc_info=True)
|
||||
overdue, due_today, high_priority = [], [], []
|
||||
|
||||
# Calendar events today
|
||||
calendar_events = []
|
||||
# Calendar events today — internal store
|
||||
calendar_events: list[str] = []
|
||||
try:
|
||||
from fabledassistant.services.events import list_events as list_internal_events
|
||||
today_date = date.today()
|
||||
day_start = datetime(today_date.year, today_date.month, today_date.day, 0, 0, 0)
|
||||
day_end = datetime(today_date.year, today_date.month, today_date.day, 23, 59, 59)
|
||||
internal_events = await list_internal_events(
|
||||
user_id=user_id, date_from=day_start, date_to=day_end
|
||||
)
|
||||
for e in internal_events:
|
||||
if e.all_day:
|
||||
time_str = "all day"
|
||||
elif e.start_dt:
|
||||
time_str = e.start_dt.strftime("%-I:%M %p")
|
||||
else:
|
||||
time_str = "unknown time"
|
||||
calendar_events.append(f"{e.title} at {time_str}")
|
||||
except Exception:
|
||||
logger.warning("Failed to gather internal calendar events for briefing", exc_info=True)
|
||||
# Also pull CalDAV events (deduped)
|
||||
try:
|
||||
if await is_caldav_configured(user_id):
|
||||
events = await list_events(user_id, start=today, end=today)
|
||||
calendar_events = [
|
||||
f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
|
||||
for e in (events or [])
|
||||
]
|
||||
caldav_evs = await list_events(user_id, start=today, end=today)
|
||||
for e in (caldav_evs or []):
|
||||
summary = f"{e.get('summary', 'Event')} at {e.get('dtstart', 'unknown time')}"
|
||||
if summary not in calendar_events:
|
||||
calendar_events.append(summary)
|
||||
except Exception:
|
||||
logger.warning("Failed to gather calendar events for briefing", exc_info=True)
|
||||
logger.warning("Failed to gather CalDAV calendar events for briefing", exc_info=True)
|
||||
|
||||
# Projects: active projects
|
||||
projects_summary = []
|
||||
|
||||
Reference in New Issue
Block a user