Hooked up calendar viewing to API, still needs a lot more polish

This commit is contained in:
2025-11-23 17:00:47 -05:00
parent b8bf809c14
commit 531371d059
11 changed files with 312 additions and 240 deletions

View File

@@ -1,13 +1,13 @@
export interface CalendarEvent {
name: string,
start: Date,
end: Date,
location: string,
color: string,
description: string,
creator: any | null, // user object
id: number | null
}
// export interface CalendarEvent {
// name: string,
// start: Date,
// end: Date,
// location: string,
// color: string,
// description: string,
// creator: any | null, // user object
// id: number | null
// }
export enum CalendarAttendance {
Attending = "attending",
@@ -21,6 +21,52 @@ export interface CalendarSignup {
state: CalendarAttendance
}
import { CalendarEventShort, CalendarEvent } from "@shared/types/calendar";
//@ts-ignore
const addr = import.meta.env.VITE_APIHOST;
export async function getMonthCalendarEvents(viewedMonth: Date): Promise<CalendarEventShort[]> {
const year = viewedMonth.getFullYear();
const month = viewedMonth.getMonth();
// Base range: first and last day of the month
const firstOfMonth = new Date(year, month, 1);
const lastOfMonth = new Date(year, month + 1, 0);
// --- Apply 10 day padding ---
const start = new Date(firstOfMonth);
start.setDate(start.getDate() - 10);
const end = new Date(lastOfMonth);
end.setDate(end.getDate() + 10);
end.setHours(23, 59, 59, 999);
const from = start.toISOString();
const to = end.toISOString();
const url = `${addr}/calendar?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`;
const res = await fetch(url);
if (!res.ok) {
throw new Error(`Failed to fetch events: ${res.status} ${res.statusText}`);
}
return res.json();
}
export async function getCalendarEvent(id: number): Promise<CalendarEvent> {
let res = await fetch(`${addr}/calendar/${id}`);
if(res.ok) {
return await res.json();
} else {
throw new Error(`Failed to fetch event: ${res.status} ${res.statusText}`);
}
}
export async function createCalendarEvent(eventData: CalendarEvent) {
}
@@ -34,7 +80,7 @@ export async function cancelCalendarEvent(eventID: number) {
}
export async function adminCancelCalendarEvent(eventID: number) {
}
export async function setCalendarEventAttendance(eventID: number, state: CalendarAttendance) {

View File

@@ -5,6 +5,9 @@ import dayGridPlugin from '@fullcalendar/daygrid'
import interactionPlugin from '@fullcalendar/interaction'
import { X, Clock, MapPin, User, ListTodo, ChevronLeft, ChevronRight, Plus } from 'lucide-vue-next'
import CreateCalendarEvent from '@/components/calendar/CreateCalendarEvent.vue'
import { getCalendarEvent, getMonthCalendarEvents } from '@/api/calendar'
import { CalendarEvent, CalendarEventShort } from '@shared/types/calendar'
import { Calendar } from '@fullcalendar/core'
const monthLabels = [
'January', 'February', 'March', 'April', 'May', 'June',
@@ -30,15 +33,18 @@ function buildFullDate(month: number, year: number): Date {
return new Date(year, month, 1); //default to first of month
}
watch([selectedMonth, selectedYear], () => {
watch([selectedMonth, selectedYear], async () => {
console.log('Selected date changed:', selectedMonth.value, selectedYear.value)
let monthEvents = await getMonthCalendarEvents(buildFullDate(selectedMonth.value, selectedYear.value));
events.value = monthEvents.map(toCalEvent);
console.log(events.value);
})
onMounted(() => {
// fetchEventsFor(selectedMonth.value, selectedYear.value)
onMounted(async () => {
let monthEvents = await getMonthCalendarEvents(buildFullDate(selectedMonth.value, selectedYear.value));
events.value = monthEvents.map(toCalEvent);
console.log(events.value);
})
@@ -54,28 +60,41 @@ function goToSelectedDate() {
type CalEvent = {
id: string
title: string
start: string
end?: string
start: Date
end?: Date
extendedProps?: Record<string, any>
}
const events = ref<CalEvent[]>([
{ id: '1', title: 'Squad Training', start: '2025-10-08T19:00:00', extendedProps: { trainer: 'Alex', location: 'Range A', color: '#88C4FF' } },
{ id: '2', title: 'Ops Briefing', start: '2025-10-09T20:30:00', extendedProps: { owner: 'CO', agenda: ['Weather', 'Route', 'Risks'], color: '#dba42c' } },
])
function toCalEvent(e: CalendarEventShort): CalEvent {
return {
id: e.id.toString(),
title: e.name,
start: e.start,
end: e.end,
extendedProps: {
color: e.color
}
}
}
const events = ref<CalEvent[]>([])
const panelOpen = ref(false)
const activeEvent = ref<CalEvent | null>(null)
const activeEvent = ref<CalendarEvent | null>(null)
const calendarRef = ref<InstanceType<typeof FullCalendar> | null>(null)
function onEventClick(arg: any) {
activeEvent.value = {
id: arg.event.id,
title: arg.event.title,
start: arg.event.startStr,
end: arg.event.endStr,
extendedProps: arg.event.extendedProps
}
async function onEventClick(arg: any) {
const targetEvent = arg.event.id;
activeEvent.value = await getCalendarEvent(targetEvent);
console.log(activeEvent.value);
// activeEvent.value = {
// id: arg.event.id,
// title: arg.event.title,
// start: arg.event.startStr,
// end: arg.event.endStr,
// extendedProps: arg.event.extendedProps
// }
panelOpen.value = true
}
@@ -123,6 +142,7 @@ const calendarOptions = ref({
// custom renderer -> one-line pill
eventContent(arg) {
console.log
const ext = arg.event.extendedProps || {}
const c = ext.color || arg.backgroundColor || arg.borderColor || ''
@@ -183,156 +203,143 @@ onMounted(() => {
onDatesSet()
})
const ext = computed(() => activeEvent.value?.extendedProps ?? {})
// const ext = computed(() => activeEvent.value?.extendedProps ?? {})
</script>
<template>
<CreateCalendarEvent ref="dialogRef"></CreateCalendarEvent>
<div class="flex">
<div class="flex-1 min-h-0 mt-5">
<div class="h-[80vh] min-h-0">
<!-- calendar header -->
<div class="flex items-center justify-between mx-5">
<!-- Left: title + pickers -->
<div class="flex items-center gap-2">
<!-- <h2 class="text-xl font-semibold tracking-tight">
{{ monthLabels[selectedMonth] }} {{ selectedYear }}
</h2> -->
<!-- Month dropdown -->
<select v-model.number="selectedMonth" @change="goToSelectedDate"
class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select month">
<option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card">
{{ m }}
</option>
</select>
<!-- Year dropdown -->
<select v-model.number="selectedYear" @change="goToSelectedDate"
class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year">
<option v-for="y in years" :key="y" :value="y" class="bg-card">
{{ y }}
</option>
</select>
<div>
<CreateCalendarEvent ref="dialogRef"></CreateCalendarEvent>
<div class="flex">
<div class="flex-1 min-h-0 mt-5">
<div class="h-[80vh] min-h-0">
<!-- calendar header -->
<div class="flex items-center justify-between mx-5">
<!-- Left: title + pickers -->
<div class="flex items-center gap-2">
<!-- <h2 class="text-xl font-semibold tracking-tight">
{{ monthLabels[selectedMonth] }} {{ selectedYear }}
</h2> -->
<!-- Month dropdown -->
<select v-model.number="selectedMonth" @change="goToSelectedDate"
class="ml-2 rounded-md border bg-transparent px-2 py-1 text-sm"
aria-label="Select month">
<option v-for="(m, i) in monthLabels" :key="m" :value="i" class="bg-card">
{{ m }}
</option>
</select>
<!-- Year dropdown -->
<select v-model.number="selectedYear" @change="goToSelectedDate"
class="rounded-md border bg-transparent px-2 py-1 text-sm" aria-label="Select year">
<option v-for="y in years" :key="y" :value="y" class="bg-card">
{{ y }}
</option>
</select>
</div>
<!-- Right: nav + today + create -->
<div class="flex items-center gap-2">
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Previous month" @click="goPrev">
<ChevronLeft class="h-4 w-4" />
</button>
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Next month" @click="goNext">
<ChevronRight class="h-4 w-4" />
</button>
<button class="cursor-pointer ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40"
@click="goToday">
Today
</button>
<button
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent">
<Plus class="h-4 w-4" />
Create
</button>
</div>
</div>
<!-- Right: nav + today + create -->
<div class="flex items-center gap-2">
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Previous month" @click="goPrev">
<ChevronLeft class="h-4 w-4" />
</button>
<button
class="cursor-pointer inline-flex h-8 w-8 items-center justify-center rounded-md border hover:bg-muted/40"
aria-label="Next month" @click="goNext">
<ChevronRight class="h-4 w-4" />
</button>
<button class="cursor-pointer ml-1 rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40"
@click="goToday">
Today
</button>
<button
class="cursor-pointer ml-1 inline-flex items-center gap-1.5 rounded-md bg-primary px-3 py-1.5 text-sm text-primary-foreground hover:opacity-90"
@click="onCreateEvent">
<Plus class="h-4 w-4" />
Create
</button>
</div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
<FullCalendar ref="calendarRef" :options="calendarOptions" />
</div>
</div>
<aside v-if="panelOpen" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<h2 class="text-lg font-semibold line-clamp-2">
{{ activeEvent?.title || 'Event' }}
</h2>
<button
class="inline-flex size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition"
aria-label="Close" @click="panelOpen = false">
<X class="size-4" />
</button>
</div>
<!-- Body -->
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6">
<!-- When -->
<section v-if="whenText" class="space-y-2">
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<Clock class="size-4 opacity-80" />
<span class="font-medium">{{ whenText }}</span>
</div>
</section>
<!-- Quick meta chips -->
<section class="flex flex-wrap gap-2">
<span v-if="ext.location"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<MapPin class="size-3.5 opacity-80" />
<span class="font-medium">{{ ext.location }}</span>
</span>
<span v-if="ext.owner" class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Owner: {{ ext.owner }}</span>
</span>
<span v-if="ext.trainer"
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Trainer: {{ ext.trainer }}</span>
</span>
</section>
<!-- Agenda (special-cased array) -->
<section v-if="Array.isArray(ext.agenda) && ext.agenda.length" class="space-y-2">
<div class="flex items-center gap-2 text-sm font-medium">
<ListTodo class="size-4 opacity-80" />
Agenda
</div>
<ul class="space-y-1.5">
<li v-for="(item, i) in ext.agenda" :key="i" class="flex items-start gap-2 text-sm">
<span class="mt-1.5 size-1.5 rounded-full bg-foreground/50"></span>
<span>{{ item }}</span>
</li>
</ul>
</section>
<!-- Generic details (extendedProps minus the ones above) -->
<section v-if="ext && Object.keys(ext).length" class="space-y-3">
<div class="text-sm font-medium opacity-80">Details</div>
<dl class="grid grid-cols-1 gap-y-3">
<template v-for="(val, key) in ext" :key="key">
<template v-if="!['agenda', 'owner', 'trainer', 'location'].includes(String(key))">
<div class="grid grid-cols-[120px_1fr] items-start gap-3">
<dt class="text-xs uppercase tracking-wide opacity-60">{{ key }}</dt>
<dd class="text-sm">
<span v-if="Array.isArray(val)">{{ val.join(', ') }}</span>
<span v-else>{{ String(val) }}</span>
</dd>
</div>
<aside v-if="panelOpen && activeEvent" class="3xl:w-lg 2xl:w-md border-l bg-card text-foreground flex flex-col"
:style="{ height: 'calc(100vh - 61px)', position: 'sticky', top: '64px' }">
<!-- Header -->
<div class="flex items-center justify-between gap-3 border-b px-4 py-3">
<h2 class="text-lg font-semibold line-clamp-2">
{{ activeEvent?.name || 'Event' }}
</h2>
<button
class="inline-flex size-8 items-center justify-center rounded-md border hover:bg-muted/40 transition"
aria-label="Close" @click="panelOpen = false">
<X class="size-4" />
</button>
</div>
<!-- Body -->
<div class="flex-1 min-h-0 overflow-y-auto px-4 py-4 space-y-6">
<!-- When -->
<section v-if="whenText" class="space-y-2">
<div class="inline-flex items-center gap-2 rounded-md border px-2.5 py-1.5 text-sm">
<Clock class="size-4 opacity-80" />
<span class="font-medium">{{ whenText }}</span>
</div>
</section>
<!-- Quick meta chips -->
<section class="flex flex-wrap gap-2">
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<MapPin class="size-3.5 opacity-80" />
<span class="font-medium">{{ activeEvent.location || "Unknown" }}</span>
</span>
<span
class="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs">
<User class="size-3.5 opacity-80" />
<span class="font-medium">Owner: {{ activeEvent.creator_name || "Unknown User" }}</span>
</span>
</section>
<!-- Agenda (special-cased array) -->
<!-- <section v-if="Array.isArray(ext.agenda) && ext.agenda.length" class="space-y-2">
<div class="flex items-center gap-2 text-sm font-medium">
<ListTodo class="size-4 opacity-80" />
Agenda
</div>
<ul class="space-y-1.5">
<li v-for="(item, i) in ext.agenda" :key="i" class="flex items-start gap-2 text-sm">
<span class="mt-1.5 size-1.5 rounded-full bg-foreground/50"></span>
<span>{{ item }}</span>
</li>
</ul>
</section> -->
<!-- Generic details (extendedProps minus the ones above) -->
<!-- <section v-if="ext && Object.keys(ext).length" class="space-y-3">
<div class="text-sm font-medium opacity-80">Details</div>
<dl class="grid grid-cols-1 gap-y-3">
<template v-for="(val, key) in ext" :key="key">
<template v-if="!['agenda', 'owner', 'trainer', 'location'].includes(String(key))">
<div class="grid grid-cols-[120px_1fr] items-start gap-3">
<dt class="text-xs uppercase tracking-wide opacity-60">{{ key }}</dt>
<dd class="text-sm">
<span v-if="Array.isArray(val)">{{ val.join(', ') }}</span>
<span v-else>{{ String(val) }}</span>
</dd>
</div>
</template>
</template>
</template>
</dl>
</section>
</div>
<!-- Footer (optional actions) -->
<div class="border-t px-4 py-3 flex items-center justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
Edit
</button>
<button
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
Open details
</button>
</div>
</aside>
</dl>
</section> -->
</div>
<!-- Footer (optional actions) -->
<div class="border-t px-4 py-3 flex items-center justify-end gap-2">
<button class="rounded-md border px-3 py-1.5 text-sm hover:bg-muted/40 transition">
Edit
</button>
<button
class="rounded-md bg-primary text-primary-foreground px-3 py-1.5 text-sm hover:opacity-90 transition">
Open details
</button>
</div>
</aside>
</div>
</div>
</template>