Files
FabledScribe/frontend/src/views/CalendarView.vue
T
bvandeusen 70ab3f38c6
CI & Build / Python lint (push) Successful in 3s
CI & Build / TypeScript typecheck (push) Successful in 35s
CI & Build / Python tests (push) Successful in 51s
CI & Build / Build & push image (push) Successful in 1m1s
chore: remove pre-pivot dead code + finish Scribe rebrand (#599 t1-3)
- Header wordmark Fabled -> Scribe; fable:calendar-changed event ->
  scribe:calendar-changed; SettingsView CSS comment.
- Drop dead Project.auto_summary + summary_updated_at columns (migration
  0063) -- the Ollama-era summarizer is gone; model + 2 frontend types +
  projects test updated.
- Remove pivot vestiges: diagnostics _curator_busy()/curator_busy
  heartbeat field, tz BRIEFING_DAY_START_HOUR/user_briefing_date dead
  aliases, the ignored 'model' param on get_embedding (+ its test).

ruff src/ clean; CI is the gate. Part of scribe plan #599.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 16:16:44 -04:00

764 lines
22 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from "vue";
import FullCalendar from "@fullcalendar/vue3";
import dayGridPlugin from "@fullcalendar/daygrid";
import timeGridPlugin from "@fullcalendar/timegrid";
import interactionPlugin from "@fullcalendar/interaction";
import type { CalendarOptions, EventClickArg, EventDropArg } from "@fullcalendar/core";
import type { DateClickArg, EventResizeDoneArg } from "@fullcalendar/interaction";
import { listEvents, updateEvent, type EventEntry } from "@/api/client";
import EventSlideOver from "@/components/EventSlideOver.vue";
import { useToastStore } from "@/stores/toast";
import { fmtTime, fmtDateTime, fmtDayLabel } from "@/utils/dateFormat";
import { MapPin } from "lucide-vue-next";
const toast = useToastStore();
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null);
// Slide-over state
const slideOverEvent = ref<EventEntry | null>(null);
const slideOverOpen = ref(false);
const slideOverDate = ref<string>("");
function openCreate(date: string) {
slideOverEvent.value = null;
slideOverDate.value = date;
slideOverOpen.value = true;
}
function openEdit(event: EventEntry) {
slideOverEvent.value = event;
slideOverDate.value = "";
slideOverOpen.value = true;
}
function closeSlideOver() {
slideOverOpen.value = false;
}
// Event entry cache keyed by id
const eventCache = new Map<number, EventEntry>();
function toFcEvent(e: EventEntry) {
// For all-day events pass date-only strings so FullCalendar never shifts
// the date through timezone conversion (UTC midnight → previous day in UTC-X).
return {
id: String(e.id),
title: e.title,
start: e.all_day ? e.start_dt.slice(0, 10) : e.start_dt,
end: e.all_day ? (e.end_dt?.slice(0, 10) ?? undefined) : (e.end_dt ?? undefined),
allDay: e.all_day,
backgroundColor: e.color || undefined,
borderColor: e.color || undefined,
extendedProps: { entryId: e.id },
};
}
// ── Upcoming events list ───────────────────────────────────────────────────
const upcomingEvents = ref<EventEntry[]>([]);
async function loadUpcoming() {
const now = new Date();
const end = new Date(now.getTime() + 28 * 86_400_000); // 4 weeks
try {
const entries = await listEvents(now.toISOString(), end.toISOString());
upcomingEvents.value = entries.sort(
(a, b) => new Date(a.start_dt).getTime() - new Date(b.start_dt).getTime()
);
} catch { /* silent */ }
}
onMounted(loadUpcoming);
// ── Month/year picker ──────────────────────────────────────────────────────
const currentViewYear = ref(new Date().getFullYear());
const currentViewMonth = ref(new Date().getMonth());
const pickerOpen = ref(false);
const pickerYear = ref(new Date().getFullYear());
const pickerStyle = ref<Record<string, string>>({});
const pickerEl = ref<HTMLElement | null>(null);
const MONTH_NAMES = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"] as const;
function handleDatesSet(arg: { view: { currentStart: Date } }) {
const d = arg.view.currentStart;
currentViewYear.value = d.getFullYear();
currentViewMonth.value = d.getMonth();
}
function jumpTo(year: number, month: number) {
calendarRef.value?.getApi().gotoDate(new Date(year, month, 1));
pickerOpen.value = false;
}
// ── Event popover ──────────────────────────────────────────────────────────
const popover = ref<EventEntry | null>(null);
const popoverStyle = ref<Record<string, string>>({});
const popoverEl = ref<HTMLElement | null>(null);
function showPopover(entry: EventEntry, clickEvent: MouseEvent) {
popover.value = entry;
nextTickPositionPopover(clickEvent);
}
function nextTickPositionPopover(clickEvent: MouseEvent) {
// Position after DOM update
requestAnimationFrame(() => {
const vw = window.innerWidth;
const vh = window.innerHeight;
const pw = 280;
const ph = 220; // approximate
let left = clickEvent.clientX + 8;
let top = clickEvent.clientY + 8;
if (left + pw > vw - 16) left = clickEvent.clientX - pw - 8;
if (top + ph > vh - 16) top = clickEvent.clientY - ph - 8;
popoverStyle.value = {
position: "fixed",
left: `${Math.max(8, left)}px`,
top: `${Math.max(8, top)}px`,
zIndex: "9999",
};
});
}
function closePopover() {
popover.value = null;
}
function onPopoverEdit() {
if (popover.value) {
openEdit(popover.value);
closePopover();
}
}
// Close popover / open picker on outside or title click
function onDocClick(e: MouseEvent) {
const target = e.target as HTMLElement;
// Title click → toggle month/year picker
const titleEl = target.closest(".fc-toolbar-title");
if (titleEl) {
if (!pickerOpen.value) {
pickerYear.value = currentViewYear.value;
const rect = titleEl.getBoundingClientRect();
const left = Math.max(8, Math.min(rect.left + rect.width / 2 - 140, window.innerWidth - 296));
pickerStyle.value = {
position: "fixed",
top: `${rect.bottom + 6}px`,
left: `${left}px`,
zIndex: "9999",
};
}
pickerOpen.value = !pickerOpen.value;
return;
}
// Close picker on outside click
if (pickerOpen.value && pickerEl.value && !pickerEl.value.contains(target)) {
pickerOpen.value = false;
}
// Close event popover on outside click
if (popover.value && popoverEl.value && !popoverEl.value.contains(target)) {
closePopover();
}
}
function onCalendarChanged() {
calendarRef.value?.getApi().refetchEvents();
}
onMounted(() => {
document.addEventListener("mousedown", onDocClick);
document.addEventListener("scribe:calendar-changed", onCalendarChanged);
});
onUnmounted(() => {
document.removeEventListener("mousedown", onDocClick);
document.removeEventListener("scribe:calendar-changed", onCalendarChanged);
});
// ── Calendar callbacks ─────────────────────────────────────────────────────
async function loadEvents(
fetchInfo: { startStr: string; endStr: string },
successCallback: (events: object[]) => void,
failureCallback: (error: Error) => void,
) {
try {
const entries = await listEvents(fetchInfo.startStr, fetchInfo.endStr);
eventCache.clear();
for (const e of entries) eventCache.set(e.id, e);
successCallback(entries.map(toFcEvent));
loadUpcoming();
} catch (err) {
failureCallback(err instanceof Error ? err : new Error(String(err)));
}
}
function handleDateClick(arg: DateClickArg) {
closePopover();
openCreate(arg.dateStr);
}
function handleEventClick(arg: EventClickArg) {
const id = arg.event.extendedProps.entryId as number;
const entry = eventCache.get(id);
if (entry) showPopover(entry, arg.jsEvent as MouseEvent);
}
async function handleEventDrop(arg: EventDropArg) {
const id = arg.event.extendedProps.entryId as number;
const start_dt = arg.event.startStr;
const end_dt = arg.event.endStr || undefined;
try {
const updated = await updateEvent(id, { start_dt, end_dt, all_day: arg.event.allDay });
eventCache.set(id, updated);
loadUpcoming();
} catch {
arg.revert();
toast.show("Failed to move event", "error");
}
}
async function handleEventResize(arg: EventResizeDoneArg) {
const id = arg.event.extendedProps.entryId as number;
const start_dt = arg.event.startStr;
const end_dt = arg.event.endStr || undefined;
try {
const updated = await updateEvent(id, { start_dt, end_dt });
eventCache.set(id, updated);
loadUpcoming();
} catch {
arg.revert();
toast.show("Failed to resize event", "error");
}
}
function onCreated(entry: EventEntry) {
eventCache.set(entry.id, entry);
calendarRef.value?.getApi().addEvent(toFcEvent(entry));
closeSlideOver();
loadUpcoming();
}
function onUpdated(entry: EventEntry) {
eventCache.set(entry.id, entry);
const api = calendarRef.value?.getApi();
if (api) {
const existing = api.getEventById(String(entry.id));
if (existing) {
existing.remove();
api.addEvent(toFcEvent(entry));
}
}
closeSlideOver();
loadUpcoming();
}
function onDeleted(id: number) {
eventCache.delete(id);
const api = calendarRef.value?.getApi();
if (api) api.getEventById(String(id))?.remove();
closeSlideOver();
upcomingEvents.value = upcomingEvents.value.filter((e) => e.id !== id);
}
const calendarOptions: CalendarOptions = {
plugins: [dayGridPlugin, timeGridPlugin, interactionPlugin],
initialView: "dayGridMonth",
timeZone: "local",
editable: true,
selectable: false,
headerToolbar: {
left: "prev,next today",
center: "title",
right: "dayGridMonth,timeGridWeek,timeGridDay",
},
events: loadEvents,
datesSet: handleDatesSet,
dateClick: handleDateClick,
eventClick: handleEventClick,
eventDrop: handleEventDrop,
eventResize: handleEventResize,
height: "auto",
};
// Group upcoming events by day label
const upcomingGrouped = computed(() => {
const groups: { label: string; date: string; events: EventEntry[] }[] = [];
for (const e of upcomingEvents.value) {
const label = fmtDayLabel(e.start_dt);
const existing = groups.find((g) => g.label === label);
if (existing) {
existing.events.push(e);
} else {
groups.push({ label, date: e.start_dt, events: [e] });
}
}
return groups;
});
</script>
<template>
<div class="calendar-view">
<div class="cal-header">
<h1 class="cal-title">Calendar</h1>
<button class="btn-new-event" @click="openCreate(new Date().toISOString().slice(0, 10))">
+ New Event
</button>
</div>
<div class="fc-wrapper">
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
<!-- ── Upcoming events strip ───────────────────────────────────────── -->
<div v-if="upcomingEvents.length" class="upcoming-section">
<h2 class="upcoming-title">Upcoming</h2>
<div class="upcoming-groups">
<div v-for="group in upcomingGrouped" :key="group.label" class="upcoming-group">
<div class="upcoming-day-label">{{ group.label }}</div>
<div class="upcoming-cards">
<div
v-for="ev in group.events"
:key="ev.id"
class="upcoming-card"
:style="ev.color ? { '--ev-color': ev.color } : {}"
@click="openEdit(ev)"
>
<div class="upcoming-card-accent"></div>
<div class="upcoming-card-body">
<div class="upcoming-card-title">{{ ev.title }}</div>
<div class="upcoming-card-time">
<template v-if="ev.all_day">All day</template>
<template v-else>
{{ fmtTime(ev.start_dt) }}
<span v-if="ev.end_dt"> {{ fmtTime(ev.end_dt) }}</span>
</template>
</div>
<div v-if="ev.location" class="upcoming-card-meta">
<MapPin :size="16" style="flex-shrink:0" />
{{ ev.location }}
</div>
<div v-if="ev.description" class="upcoming-card-desc">{{ ev.description }}</div>
</div>
</div>
</div>
</div>
</div>
</div>
<!-- ── Event popover ───────────────────────────────────────────────── -->
<Teleport to="body">
<div
v-if="popover"
ref="popoverEl"
class="event-popover"
:style="popoverStyle"
>
<div class="popover-accent" :style="popover.color ? { background: popover.color } : {}"></div>
<div class="popover-content">
<div class="popover-title">{{ popover.title }}</div>
<div class="popover-time">
<template v-if="popover.all_day">
{{ fmtDateTime(popover.start_dt, true) }}
</template>
<template v-else>
{{ fmtDateTime(popover.start_dt, false) }}
<span v-if="popover.end_dt"> {{ fmtTime(popover.end_dt) }}</span>
</template>
</div>
<div v-if="popover.location" class="popover-meta">
<MapPin :size="16" style="flex-shrink:0;margin-top:1px" />
{{ popover.location }}
</div>
<div v-if="popover.description" class="popover-desc">{{ popover.description }}</div>
<div class="popover-actions">
<button class="popover-btn popover-btn--edit" @click="onPopoverEdit">Edit</button>
<button class="popover-btn popover-btn--close" @click="closePopover">Close</button>
</div>
</div>
</div>
</Teleport>
<!-- ── Month/year picker ──────────────────────────────────────────── -->
<Teleport to="body">
<div v-if="pickerOpen" ref="pickerEl" class="month-picker" :style="pickerStyle">
<div class="picker-year-row">
<button class="picker-year-btn" @click="pickerYear--" aria-label="Previous year"></button>
<span class="picker-year-label">{{ pickerYear }}</span>
<button class="picker-year-btn" @click="pickerYear++" aria-label="Next year"></button>
</div>
<div class="picker-months">
<button
v-for="(name, i) in MONTH_NAMES"
:key="i"
class="picker-month"
:class="{ active: pickerYear === currentViewYear && i === currentViewMonth }"
@click="jumpTo(pickerYear, i)"
>{{ name }}</button>
</div>
</div>
</Teleport>
<EventSlideOver
v-if="slideOverOpen"
:event="slideOverEvent"
:initial-date="slideOverDate"
@close="closeSlideOver"
@created="onCreated"
@updated="onUpdated"
@deleted="onDeleted"
/>
</div>
</template>
<style scoped>
.calendar-view {
max-width: var(--page-max-width);
margin: 0 auto;
padding: 1.5rem 1.5rem 3rem;
}
.cal-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 1.25rem;
}
.cal-title {
font-size: 1.5rem;
font-weight: 500;
margin: 0;
color: var(--color-text, #e8e9f0);
}
/* New event: Moss action-primary — workflow action, not a brand moment */
.btn-new-event {
background: var(--color-action-primary);
color: #fff;
border: none;
border-radius: 8px;
padding: 0.55rem 1.1rem;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s;
}
.btn-new-event:hover { background: var(--color-action-primary-hover); }
.fc-wrapper {
background: var(--color-surface);
border: 1px solid var(--color-border, #2a2b30);
border-radius: var(--radius-lg, 18px);
padding: 1rem;
overflow: hidden;
}
/* FullCalendar dark theme overrides */
:deep(.fc) {
color: var(--color-text, #e8e9f0);
font-family: inherit;
}
:deep(.fc-toolbar-title) {
/* FullCalendar renders this as <h2>, which would otherwise pick up the
theme.css h1/h2 → Fraunces rule. At 1.1rem it's below the doc's
"Fraunces only at ≥18px" threshold, so explicit Inter. */
font-family: 'Inter', system-ui, sans-serif;
font-size: 1.1rem;
font-weight: 500;
cursor: pointer;
border-radius: 6px;
padding: 2px 8px;
transition: background 0.15s;
user-select: none;
}
:deep(.fc-toolbar-title:hover) {
background: rgba(255,255,255,0.07);
}
:deep(.fc-button) {
background: var(--color-input-bg, var(--color-bg));
border: 1px solid var(--color-border, #2a2b30);
color: var(--color-text-muted, #888);
font-size: 0.82rem;
padding: 0.3rem 0.7rem;
box-shadow: none;
}
:deep(.fc-button:hover),
:deep(.fc-button-active) {
background: var(--color-primary) !important;
border-color: var(--color-primary) !important;
color: #fff !important;
}
:deep(.fc-button:focus) { box-shadow: none !important; }
:deep(.fc-daygrid-day-number),
:deep(.fc-col-header-cell-cushion) {
color: var(--color-text-muted, #888);
text-decoration: none;
font-size: 0.82rem;
}
:deep(.fc-daygrid-day.fc-day-today) {
background: var(--color-primary-tint);
}
:deep(.fc-event) {
border-radius: 4px;
border: none;
padding: 1px 4px;
font-size: 0.78rem;
cursor: pointer;
}
:deep(.fc-event-main) { color: #fff; }
:deep(.fc-event:not([style*="background"])) {
background: var(--color-primary);
}
:deep(.fc-daygrid-day-frame) { min-height: 5rem; }
:deep(.fc-scrollgrid) { border-color: var(--color-border, #2a2b30); }
:deep(.fc-scrollgrid td),
:deep(.fc-scrollgrid th) { border-color: var(--color-border, #2a2b30); }
:deep(.fc-daygrid-day) { cursor: pointer; }
:deep(.fc-daygrid-day:hover) { background: rgba(255,255,255,0.03); }
/* ── Month/year picker ──────────────────────────────────────────────────── */
.month-picker {
background: var(--color-bg-card);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.5);
width: 280px;
padding: 12px 14px;
}
.picker-year-row {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 12px;
}
.picker-year-label {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text, #e8e9f0);
}
.picker-year-btn {
background: none;
border: 1px solid var(--color-border, #2a2b30);
border-radius: 6px;
color: var(--color-text-muted, #888);
cursor: pointer;
padding: 2px 10px;
font-size: 1rem;
line-height: 1.4;
transition: color 0.15s, border-color 0.15s;
}
.picker-year-btn:hover {
color: var(--color-text, #e8e9f0);
border-color: rgba(255,255,255,0.25);
}
.picker-months {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 4px;
}
.picker-month {
padding: 7px 4px;
border: none;
border-radius: 7px;
background: transparent;
color: var(--color-text, #e8e9f0);
cursor: pointer;
font-size: 0.84rem;
text-align: center;
transition: background 0.12s, color 0.12s;
}
.picker-month:hover {
background: rgba(255,255,255,0.08);
}
.picker-month.active {
background: rgba(91, 74, 138,0.22);
color: var(--color-primary);
font-weight: 500;
}
/* ── Upcoming strip ─────────────────────────────────────────────────────── */
.upcoming-section {
margin-top: 2rem;
}
.upcoming-title {
font-size: 1rem;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.06em;
font-size: 0.78rem;
margin: 0 0 1rem;
}
.upcoming-groups {
display: flex;
flex-direction: column;
gap: 1.5rem;
}
.upcoming-day-label {
font-size: 0.8rem;
font-weight: 500;
color: var(--color-text-muted, #888);
text-transform: uppercase;
letter-spacing: 0.05em;
margin-bottom: 0.5rem;
}
.upcoming-cards {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.upcoming-card {
display: flex;
align-items: stretch;
gap: 0;
background: var(--color-surface);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 10px;
overflow: hidden;
cursor: pointer;
transition: border-color 0.15s, background 0.15s;
}
.upcoming-card:hover {
border-color: color-mix(in srgb, var(--color-primary) 40%, transparent);
background: var(--color-bg-secondary);
}
.upcoming-card-accent {
width: 4px;
flex-shrink: 0;
background: var(--ev-color, #5B4A8A);
}
.upcoming-card-body {
padding: 0.6rem 0.85rem;
flex: 1;
min-width: 0;
}
.upcoming-card-title {
font-size: 0.9rem;
font-weight: 500;
color: var(--color-text, #e8e9f0);
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.upcoming-card-time {
font-size: 0.78rem;
color: var(--color-text-muted, #888);
margin-top: 0.15rem;
}
.upcoming-card-meta {
display: flex;
align-items: flex-start;
gap: 0.3rem;
font-size: 0.78rem;
color: var(--color-text-muted, #888);
margin-top: 0.25rem;
}
.upcoming-card-desc {
font-size: 0.8rem;
color: var(--color-text-secondary, #aaa);
margin-top: 0.3rem;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
overflow: hidden;
}
/* ── Event popover ──────────────────────────────────────────────────────── */
.event-popover {
background: var(--color-bg-card);
border: 1px solid var(--color-border, #2a2b30);
border-radius: 12px;
box-shadow: 0 8px 32px rgba(0,0,0,0.45);
width: 280px;
overflow: hidden;
display: flex;
flex-direction: column;
}
.popover-accent {
height: 4px;
background: var(--color-primary);
}
.popover-content {
padding: 0.85rem 1rem 0.75rem;
}
.popover-title {
font-size: 0.95rem;
font-weight: 500;
color: var(--color-text, #e8e9f0);
margin-bottom: 0.35rem;
}
.popover-time {
font-size: 0.8rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.popover-meta {
display: flex;
align-items: flex-start;
gap: 0.3rem;
font-size: 0.8rem;
color: var(--color-text-muted, #888);
margin-bottom: 0.4rem;
}
.popover-desc {
font-size: 0.82rem;
color: var(--color-text-secondary, #aaa);
line-height: 1.45;
margin-bottom: 0.6rem;
display: -webkit-box;
-webkit-line-clamp: 4;
-webkit-box-orient: vertical;
overflow: hidden;
}
.popover-actions {
display: flex;
gap: 0.5rem;
padding-top: 0.5rem;
border-top: 1px solid var(--color-border, #2a2b30);
}
.popover-btn {
flex: 1;
padding: 0.35rem 0;
border: none;
border-radius: 6px;
font-size: 0.82rem;
font-weight: 500;
cursor: pointer;
transition: opacity 0.15s;
}
.popover-btn:hover { opacity: 0.85; }
.popover-btn--edit {
background: var(--color-primary);
color: #fff;
}
.popover-btn--close {
background: var(--color-input-bg, var(--color-bg));
color: var(--color-text-muted, #888);
border: 1px solid var(--color-border, #2a2b30);
}
</style>