import { Component, EventEmitter, Input, Output } from '@angular/core'; import { VspSegmented } from './media.component'; import { VspDialog } from './overlay.component'; import { VspField, VspInput } from './field.component'; import { VspSelect } from './select.component'; import { VspButton } from './button.component'; import { toast } from './toast.component'; const MONTHS = [ 'January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December', ]; const DOW_FULL = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']; export type EventTone = 'info' | 'success' | 'warning' | 'danger' | 'violet'; const TONE: Record = { info: 'var(--accent)', success: 'var(--success)', warning: 'var(--warning)', danger: 'var(--danger)', violet: 'var(--accent-2)', }; const TONE_OPTIONS = [ { value: 'info', label: 'Blue' }, { value: 'success', label: 'Green' }, { value: 'warning', label: 'Amber' }, { value: 'danger', label: 'Red' }, { value: 'violet', label: 'Violet' }, ]; export interface CalendarEvent { id?: string; /** Day of the month (1–31). */ d: number; title: string; tone: EventTone; /** Display time, e.g. `"10:00"` or `"All day"`. */ time: string; /** Hour (8–18) used to place the event in the week grid. */ hour?: number; } interface InternalEvent extends CalendarEvent { id: string; hour: number; } interface Draft { days: number[]; hour: number; title: string; tone: EventTone; time: string; } type View = 'month' | 'week' | 'agenda'; interface Cell { day: number; muted?: boolean; } const DEFAULT_EVENTS: CalendarEvent[] = [ { d: 2, title: 'Q2 board review', tone: 'info', time: '10:00' }, { d: 5, title: 'Northwind renewal', tone: 'success', time: '09:30' }, { d: 5, title: 'Webhook deploy', tone: 'warning', time: '14:00' }, { d: 9, title: 'Payment retry · Cobalt', tone: 'danger', time: '08:00' }, { d: 12, title: 'Onboarding · Halcyon', tone: 'violet', time: '11:00' }, { d: 12, title: 'Team sync', tone: 'info', time: '15:30' }, { d: 12, title: 'Invoice run', tone: 'success', time: '17:00' }, { d: 18, title: 'Security audit', tone: 'warning', time: '13:00' }, { d: 21, title: 'Expansion call · Vertex', tone: 'success', time: '10:30' }, { d: 24, title: 'Quarterly close', tone: 'info', time: 'All day' }, { d: 24, title: 'Roadmap review', tone: 'violet', time: '16:00' }, { d: 28, title: 'SLA report due', tone: 'danger', time: '12:00' }, ]; /** * A month / week / agenda calendar with click-and-drag day selection and an * inline "new event" dialog. Events are kept in local state; `change` reports * every change so a host can persist them. */ @Component({ selector: 'vsp-event-calendar', imports: [VspSegmented, VspDialog, VspField, VspInput, VspSelect, VspButton], template: `
{{ months[vm.m] }} {{ vm.y }}
@if (view === 'month') {
@for (d of dowFull; track d) {
{{ d }}
}
@for (c of cells; track i; let i = $index) {
{{ c.day }} @for (e of evsFor(c).slice(0, 3); track e.id) {
{{ e.title }}
} @if (evsFor(c).length > 3) { +{{ evsFor(c).length - 3 }} more }
}
} @if (view === 'week') {
@for (d of weekDays; track i; let i = $index) {
{{ dowFull[d.getDay()] }}
{{ d.getDate() }}
} @for (h of hours; track h) {
{{ pad(h) }}:00
@for (d of weekDays; track i; let i = $index) {
@if (apptAt(d, h); as appt) {
{{ appt.title }}
}
} }
} @if (view === 'agenda') {
@for (day of agendaDays; track day) {
{{ dowFull[dowOf(day)] }}
{{ day }}
@for (e of sortedDay(day); track e.id) {
{{ e.title }} {{ e.time }}
}
}
} @if (add; as a) {
} Cancel Add event
`, }) export class VspEventCalendar { @Input() initialEvents: CalendarEvent[] = DEFAULT_EVENTS; @Output() change = new EventEmitter(); today = new Date(); view: View = 'month'; vm = { m: this.today.getMonth(), y: this.today.getFullYear() }; events: InternalEvent[] = []; sel: { a: number; b: number } | null = null; dragging = false; add: Draft | null = null; months = MONTHS; dowFull = DOW_FULL; toneOptions = TONE_OPTIONS; hours: number[] = []; constructor() { this.events = this.initialEvents.map((e, i) => ({ id: `e${i}`, hour: 9, ...e })); for (let h = 8; h <= 18; h++) this.hours.push(h); } tone(t: EventTone): string { return TONE[t]; } bg(t: EventTone, pct: number): string { return `color-mix(in oklab, ${TONE[t]} ${pct}%, transparent)`; } pad(h: number): string { return String(h).padStart(2, '0'); } cap(v: View): string { return v[0].toUpperCase() + v.slice(1); } setView(v: string): void { this.view = v.toLowerCase() as View; } goToday(): void { this.vm = { m: this.today.getMonth(), y: this.today.getFullYear() }; } private commit(next: InternalEvent[]): void { this.events = next; this.change.emit(next); } nav(delta: number): void { let m = this.vm.m + delta; let y = this.vm.y; if (m < 0) { m = 11; y--; } if (m > 11) { m = 0; y++; } this.vm = { m, y }; } openAdd(days: number[], hour?: number): void { this.add = { days, hour: hour ?? 9, title: '', tone: 'info', time: hour != null ? `${this.pad(hour)}:00` : '10:00', }; } saveAdd(): void { const a = this.add; if (!a) return; if (!a.title.trim()) { this.add = null; return; } const stamp = Date.now(); this.commit([ ...this.events, ...a.days.map((d, i) => ({ id: `n${stamp}${i}`, d, hour: a.hour, title: a.title, tone: a.tone, time: a.time, })), ]); this.add = null; toast({ tone: 'pos', title: a.days.length > 1 ? `${a.days.length} events added` : 'Event added', }); } slotClick(d: Date, h: number): void { if (!this.apptAt(d, h)) this.openAdd([d.getDate()], h); } get evByDay(): Record { const map: Record = {}; for (const e of this.events) (map[e.d] = map[e.d] || []).push(e); return map; } get cells(): Cell[] { const first = new Date(this.vm.y, this.vm.m, 1).getDay(); const dim = new Date(this.vm.y, this.vm.m + 1, 0).getDate(); const prevDim = new Date(this.vm.y, this.vm.m, 0).getDate(); const out: Cell[] = []; for (let i = first - 1; i >= 0; i--) out.push({ day: prevDim - i, muted: true }); for (let d = 1; d <= dim; d++) out.push({ day: d }); while (out.length % 7) out.push({ day: out.length - (first + dim) + 1, muted: true }); return out; } get weekDays(): Date[] { const d0 = new Date(this.vm.y, this.vm.m, this.today.getDate() - this.today.getDay()); const out: Date[] = []; for (let i = 0; i < 7; i++) { const d = new Date(d0); d.setDate(d0.getDate() + i); out.push(d); } return out; } get agendaDays(): number[] { return Object.keys(this.evByDay) .map(Number) .sort((a, b) => a - b); } get dialogDesc(): string { const a = this.add; if (!a) return ''; return `${MONTHS[this.vm.m]} ${a.days[0]}${a.days.length > 1 ? `–${a.days[a.days.length - 1]}` : ''}, ${this.vm.y}`; } evsFor(c: Cell): InternalEvent[] { return c.muted ? [] : this.evByDay[c.day] || []; } apptAt(d: Date, h: number): InternalEvent | undefined { return this.events.find( (e) => e.d === d.getDate() && e.hour === h && d.getMonth() === this.vm.m, ); } sortedDay(day: number): InternalEvent[] { return this.evByDay[day].slice().sort((a, b) => a.hour - b.hour); } dowOf(day: number): number { return new Date(this.vm.y, this.vm.m, day).getDay(); } isToday(d: number, muted?: boolean): boolean { return ( !muted && d === this.today.getDate() && this.vm.m === this.today.getMonth() && this.vm.y === this.today.getFullYear() ); } inSel(d: number): boolean { return ( !!this.sel && d >= Math.min(this.sel.a, this.sel.b) && d <= Math.max(this.sel.a, this.sel.b) ); } cellClass(c: Cell): string { return ['ui-cb-cell', c.muted && 'muted', !c.muted && this.inSel(c.day) && 'sel'] .filter(Boolean) .join(' '); } onLeave(): void { if (!this.dragging) this.sel = null; } cellDown(c: Cell): void { if (!c.muted) { this.dragging = true; this.sel = { a: c.day, b: c.day }; } } cellEnter(c: Cell): void { if (this.dragging && !c.muted && this.sel) this.sel = { a: this.sel.a, b: c.day }; } cellUp(c: Cell): void { if (this.dragging && this.sel) { this.dragging = false; const lo = Math.min(this.sel.a, c.day); const hi = Math.max(this.sel.a, c.day); const days: number[] = []; for (let d = lo; d <= hi; d++) days.push(d); this.openAdd(days); this.sel = null; } } }