Kalender: PG-adapter, API-ruter, månedsvisning med fargekoder

- Migrering 0003: calendars + calendar_events (nodes i kunnskapsgrafen)
- REST API: GET kalender med tidsvindu, POST/PATCH/DELETE hendelser
- PG polling-adapter med adapter-factory
- CalendarBlock: månedsrutenett, heldags vs. tidshendelser, fargevelger
- Seed: kalender for begge workspaces, kalenderside i sidekonfig

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
vegard 2026-03-15 02:56:16 +01:00
parent 87eb2d4919
commit d924645dd3
10 changed files with 1000 additions and 11 deletions

View file

@ -0,0 +1,51 @@
-- Sidelinja: Kalender-hendelser
-- Avhenger av: 0001_initial_schema.sql (nodes, workspaces)
BEGIN;
-- ============================================================
-- Kalendere (tilhører en workspace via nodes)
-- ============================================================
CREATE TABLE calendars (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
parent_id UUID NOT NULL REFERENCES nodes(id), -- Rot-node for workspace
name TEXT NOT NULL,
color TEXT, -- Standard fargekode for hendelser
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ============================================================
-- Kalender-hendelser
-- ============================================================
CREATE TABLE calendar_events (
id UUID PRIMARY KEY REFERENCES nodes(id) ON DELETE CASCADE,
calendar_id UUID NOT NULL REFERENCES calendars(id) ON DELETE CASCADE,
title TEXT NOT NULL,
description TEXT,
starts_at TIMESTAMPTZ NOT NULL,
ends_at TIMESTAMPTZ, -- NULL = heldagshendelse uten sluttid
all_day BOOLEAN NOT NULL DEFAULT false,
color TEXT, -- Overstyrer kalender-farge
linked_node UUID REFERENCES nodes(id) ON DELETE SET NULL, -- Kobling til kanban-kort, episode, etc.
created_by TEXT REFERENCES users(authentik_id),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_calendar_events_calendar ON calendar_events(calendar_id);
CREATE INDEX idx_calendar_events_time ON calendar_events(calendar_id, starts_at);
CREATE TRIGGER trg_calendar_events_updated_at BEFORE UPDATE ON calendar_events
FOR EACH ROW EXECUTE FUNCTION set_updated_at();
-- ============================================================
-- Utvid node_type
-- ============================================================
-- NB: ALTER TYPE ... ADD VALUE kan ikke kjøres inne i transaksjon i PG < 16
-- Derfor committes dette etter hovedtransaksjonen
COMMIT;
ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'calendar';
ALTER TYPE node_type ADD VALUE IF NOT EXISTS 'calendar_event';

View file

@ -60,6 +60,12 @@ INSERT INTO nodes (id, workspace_id, node_type) VALUES
INSERT INTO channels (id, parent_id, name) VALUES
('b0000000-0000-0000-0000-000000000012', 'b0000000-0000-0000-0000-000000000010', 'Styret');
-- Kalender for Liberalistene
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('b0000000-0000-0000-0000-000000000030', 'b0000000-0000-0000-0000-000000000001', 'calendar');
INSERT INTO calendars (id, parent_id, name, color) VALUES
('b0000000-0000-0000-0000-000000000030', 'b0000000-0000-0000-0000-000000000010', 'Foreningskalender', '#f59e0b');
-- Kanban-brett for Liberalistene
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('b0000000-0000-0000-0000-000000000020', 'b0000000-0000-0000-0000-000000000001', 'kanban_board');
@ -85,6 +91,15 @@ UPDATE workspaces SET settings = jsonb_set(
{"id": "kanban-lib-1", "type": "kanban", "title": "Oppgaver", "props": {"boardId": "b0000000-0000-0000-0000-000000000020"}}
]
},
{
"slug": "kalender",
"title": "Kalender",
"icon": "📅",
"layout": "single",
"blocks": [
{"id": "cal-lib-1", "type": "calendar", "title": "Foreningskalender", "props": {"calendarId": "b0000000-0000-0000-0000-000000000030"}}
]
},
{
"slug": "generelt",
"title": "Generelt",
@ -101,6 +116,12 @@ UPDATE workspaces SET settings = jsonb_set(
-- Sidelinja: Kanban-brett for redaksjonen
-- =============================================
-- Kalender for Sidelinja
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('a0000000-0000-0000-0000-000000000030', 'a0000000-0000-0000-0000-000000000001', 'calendar');
INSERT INTO calendars (id, parent_id, name, color) VALUES
('a0000000-0000-0000-0000-000000000030', 'a0000000-0000-0000-0000-000000000010', 'Redaksjonskalender', '#3b82f6');
-- Kanban-brett for redaksjonen
INSERT INTO nodes (id, workspace_id, node_type) VALUES
('a0000000-0000-0000-0000-000000000020', 'a0000000-0000-0000-0000-000000000001', 'kanban_board');
@ -127,6 +148,15 @@ UPDATE workspaces SET settings = jsonb_set(
{"id": "kanban-1", "type": "kanban", "title": "Planlegging", "props": {"boardId": "a0000000-0000-0000-0000-000000000020"}}
]
},
{
"slug": "kalender",
"title": "Kalender",
"icon": "📅",
"layout": "single",
"blocks": [
{"id": "cal-1", "type": "calendar", "title": "Redaksjonskalender", "props": {"calendarId": "a0000000-0000-0000-0000-000000000030"}}
]
},
{
"slug": "research",
"title": "Research",

View file

@ -1,25 +1,625 @@
<script lang="ts">
import { onMount } from 'svelte';
import { createCalendar } from '$lib/calendar/create.svelte';
import type { CalendarConnection, CalendarEvent } from '$lib/calendar/types';
let { props = {} }: { props?: Record<string, unknown> } = $props();
const calendarId = props.calendarId as string | undefined;
let cal = $state<CalendarConnection | null>(null);
const EVENT_COLORS = [
{ value: '#3b82f6', label: 'Blå' },
{ value: '#10b981', label: 'Grønn' },
{ value: '#f59e0b', label: 'Gul' },
{ value: '#ef4444', label: 'Rød' },
{ value: '#8b5cf6', label: 'Lilla' },
{ value: '#ec4899', label: 'Rosa' },
{ value: '#6b7280', label: 'Grå' }
];
// Ny hendelse
let showAdd = $state(false);
let addDate = $state('');
let addTitle = $state('');
let addAllDay = $state(true);
let addStartTime = $state('09:00');
let addEndTime = $state('10:00');
let addColor = $state<string | null>(null);
// Redigering
let editingEvent = $state<CalendarEvent | null>(null);
let editTitle = $state('');
let editDescription = $state('');
let editStartsAt = $state('');
let editEndsAt = $state('');
let editAllDay = $state(false);
let editColor = $state<string | null>(null);
let viewDate = $derived(cal?.viewDate ?? new Date());
let events = $derived(cal?.events ?? []);
let year = $derived(viewDate.getFullYear());
let month = $derived(viewDate.getMonth());
let monthName = $derived(viewDate.toLocaleString('nb-NO', { month: 'long', year: 'numeric' }));
// Bygg kalenderrutenett
let calendarDays = $derived.by(() => {
const firstDay = new Date(year, month, 1);
const lastDay = new Date(year, month + 1, 0);
// Mandag = 0
let startDow = firstDay.getDay() - 1;
if (startDow < 0) startDow = 6;
const days: { date: Date; inMonth: boolean; dateStr: string }[] = [];
// Dager fra forrige måned
for (let i = startDow - 1; i >= 0; i--) {
const d = new Date(year, month, -i);
days.push({ date: d, inMonth: false, dateStr: fmt(d) });
}
// Dager i måneden
for (let i = 1; i <= lastDay.getDate(); i++) {
const d = new Date(year, month, i);
days.push({ date: d, inMonth: true, dateStr: fmt(d) });
}
// Fyll ut til 42 (6 rader)
while (days.length < 42) {
const d = new Date(year, month + 1, days.length - startDow - lastDay.getDate() + 1);
days.push({ date: d, inMonth: false, dateStr: fmt(d) });
}
return days;
});
function fmt(d: Date): string {
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
function isToday(dateStr: string): boolean {
return dateStr === fmt(new Date());
}
function eventsForDate(dateStr: string): CalendarEvent[] {
return events.filter((e) => {
const d = new Date(e.starts_at);
const eDate = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
return eDate === dateStr;
});
}
function formatTime(isoStr: string): string {
const d = new Date(isoStr);
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
}
function prevMonth() {
cal?.setViewDate(new Date(year, month - 1, 1));
}
function nextMonth() {
cal?.setViewDate(new Date(year, month + 1, 1));
}
function goToday() {
cal?.setViewDate(new Date());
}
function openAdd(dateStr: string) {
addDate = dateStr;
addTitle = '';
addAllDay = true;
addStartTime = '09:00';
addEndTime = '10:00';
addColor = null;
showAdd = true;
}
async function handleAdd() {
if (!cal || !addTitle.trim()) return;
let starts_at: string;
let ends_at: string | null = null;
if (addAllDay) {
starts_at = `${addDate}T12:00:00`;
} else {
starts_at = new Date(`${addDate}T${addStartTime}`).toISOString();
ends_at = new Date(`${addDate}T${addEndTime}`).toISOString();
}
await cal.addEvent({
title: addTitle.trim(),
starts_at,
ends_at,
all_day: addAllDay,
color: addColor
});
showAdd = false;
}
function openEdit(event: CalendarEvent) {
editingEvent = event;
editTitle = event.title;
editDescription = event.description ?? '';
editAllDay = event.all_day;
editStartsAt = event.starts_at.slice(0, 16);
editEndsAt = event.ends_at?.slice(0, 16) ?? '';
editColor = event.color;
}
async function saveEdit() {
if (!cal || !editingEvent) return;
await cal.updateEvent(editingEvent.id, {
title: editTitle.trim() || editingEvent.title,
description: editDescription.trim() || null,
starts_at: editStartsAt ? new Date(editStartsAt).toISOString() : undefined,
ends_at: editEndsAt ? new Date(editEndsAt).toISOString() : null,
all_day: editAllDay,
color: editColor
});
editingEvent = null;
}
async function handleDeleteEvent() {
if (!cal || !editingEvent) return;
await cal.deleteEvent(editingEvent.id);
editingEvent = null;
}
$effect(() => {
if (editingEvent || showAdd) {
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
editingEvent = null;
showAdd = false;
}
};
window.addEventListener('keydown', handler);
return () => window.removeEventListener('keydown', handler);
}
});
onMount(() => {
if (calendarId) {
cal = createCalendar(calendarId);
}
return () => cal?.destroy();
});
const WEEKDAYS = ['Man', 'Tir', 'Ons', 'Tor', 'Fre', 'Lør', 'Søn'];
</script>
<div class="placeholder">
<span class="icon">📅</span>
<p class="label">Kalender</p>
<p class="hint">Kommer snart</p>
{#if !calendarId}
<div class="no-cal"><p>Ingen kalender konfigurert for denne blokken.</p></div>
{:else if cal?.loading}
<div class="no-cal"><p>Laster...</p></div>
{:else}
<div class="calendar-wrapper">
<div class="calendar-header">
<button type="button" class="nav-btn" onclick={prevMonth}>&larr;</button>
<button type="button" class="month-name" onclick={goToday}>{monthName}</button>
<button type="button" class="nav-btn" onclick={nextMonth}>&rarr;</button>
</div>
<div class="weekday-row">
{#each WEEKDAYS as day}
<div class="weekday">{day}</div>
{/each}
</div>
<div class="days-grid">
{#each calendarDays as day}
<button
type="button"
class="day-cell"
class:out-of-month={!day.inMonth}
class:today={isToday(day.dateStr)}
onclick={() => openAdd(day.dateStr)}
>
<span class="day-number">{day.date.getDate()}</span>
{#each eventsForDate(day.dateStr) as event}
<button
type="button"
class="event-pill"
class:all-day={event.all_day}
style:background={event.color || cal?.calendar?.color || '#3b82f6'}
onclick={(e) => { e.stopPropagation(); openEdit(event); }}
>
{#if !event.all_day}<span class="event-time">{formatTime(event.starts_at)}</span>{/if}
{event.title}
</button>
{/each}
</button>
{/each}
</div>
</div>
{#if showAdd}
<div class="modal-backdrop" onclick={() => { showAdd = false; }} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1">
<h3>Ny hendelse — {addDate}</h3>
<input
type="text"
class="edit-input"
placeholder="Tittel"
bind:value={addTitle}
onkeydown={(e) => { if (e.key === 'Enter') handleAdd(); }}
/>
<label class="checkbox-row">
<input type="checkbox" bind:checked={addAllDay} />
Hele dagen
</label>
{#if !addAllDay}
<div class="time-row">
<input type="time" bind:value={addStartTime} class="edit-input" />
<span class="time-sep"></span>
<input type="time" bind:value={addEndTime} class="edit-input" />
</div>
{/if}
<div class="color-picker">
{#each EVENT_COLORS as c}
<button
type="button"
class="color-dot"
class:selected={addColor === c.value}
style:background={c.value}
title={c.label}
onclick={() => { addColor = addColor === c.value ? null : c.value; }}
></button>
{/each}
</div>
<div class="modal-actions">
<button type="button" onclick={handleAdd} disabled={!addTitle.trim()}>Opprett</button>
<button type="button" class="cancel" onclick={() => { showAdd = false; }}>Avbryt</button>
</div>
</div>
</div>
{/if}
{#if editingEvent}
<div class="modal-backdrop" onclick={() => { editingEvent = null; }} role="presentation">
<div class="modal" onclick={(e) => e.stopPropagation()} role="dialog" tabindex="-1">
<input
type="text"
class="edit-input title-input"
bind:value={editTitle}
placeholder="Tittel"
/>
<textarea
class="edit-input"
bind:value={editDescription}
placeholder="Beskrivelse (valgfritt)"
rows="2"
></textarea>
<label class="checkbox-row">
<input type="checkbox" bind:checked={editAllDay} />
Hele dagen
</label>
{#if !editAllDay}
<div class="time-row">
<input type="datetime-local" bind:value={editStartsAt} class="edit-input" />
<span class="time-sep"></span>
<input type="datetime-local" bind:value={editEndsAt} class="edit-input" />
</div>
{/if}
<div class="color-picker">
{#each EVENT_COLORS as c}
<button
type="button"
class="color-dot"
class:selected={editColor === c.value}
style:background={c.value}
title={c.label}
onclick={() => { editColor = editColor === c.value ? null : c.value; }}
></button>
{/each}
</div>
<div class="modal-actions">
<button type="button" onclick={saveEdit}>Lagre</button>
<button type="button" class="delete" onclick={handleDeleteEvent}>Slett</button>
<button type="button" class="cancel" onclick={() => { editingEvent = null; }}>Avbryt</button>
</div>
</div>
</div>
{/if}
{#if cal?.error}
<div class="error">{cal.error}</div>
{/if}
{/if}
<style>
.placeholder {
.calendar-wrapper {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.calendar-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.25rem 0 0.5rem;
}
.month-name {
font-size: 0.9rem;
font-weight: 600;
color: #e1e4e8;
background: none;
border: none;
cursor: pointer;
padding: 0.25rem 0.5rem;
border-radius: 4px;
font-family: inherit;
}
.month-name:hover {
background: #1e2235;
}
.nav-btn {
background: none;
border: 1px solid #2d3148;
border-radius: 4px;
color: #8b92a5;
width: 28px;
height: 28px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
font-size: 0.8rem;
}
.nav-btn:hover {
border-color: #3b82f6;
color: #3b82f6;
}
.weekday-row {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
}
.weekday {
font-size: 0.65rem;
color: #8b92a5;
text-align: center;
padding: 0.2rem 0;
font-weight: 600;
}
.days-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
gap: 1px;
flex: 1;
min-height: 0;
}
.day-cell {
background: #0f1117;
border: 1px solid transparent;
border-radius: 4px;
padding: 0.15rem;
min-height: 3.5rem;
cursor: pointer;
display: flex;
flex-direction: column;
align-items: stretch;
text-align: left;
font-family: inherit;
transition: border-color 0.15s;
}
.day-cell:hover {
border-color: #2d3148;
}
.day-cell.out-of-month {
opacity: 0.35;
}
.day-cell.today {
border-color: #3b82f6;
}
.day-number {
font-size: 0.7rem;
color: #8b92a5;
padding: 0.1rem 0.2rem;
}
.today .day-number {
color: #3b82f6;
font-weight: 700;
}
.event-pill {
font-size: 0.6rem;
color: white;
padding: 0.1rem 0.25rem;
border-radius: 3px;
margin-top: 1px;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
cursor: pointer;
border: none;
text-align: left;
font-family: inherit;
line-height: 1.3;
}
.event-pill.all-day {
border-radius: 3px;
}
.event-time {
font-weight: 600;
margin-right: 0.2rem;
opacity: 0.85;
}
.event-pill:hover {
filter: brightness(1.2);
}
.color-picker {
display: flex;
gap: 0.4rem;
align-items: center;
}
.color-dot {
width: 22px;
height: 22px;
border-radius: 50%;
border: 2px solid transparent;
cursor: pointer;
transition: border-color 0.15s, transform 0.1s;
}
.color-dot:hover {
transform: scale(1.15);
}
.color-dot.selected {
border-color: #e1e4e8;
transform: scale(1.2);
}
/* Modaler */
.modal-backdrop {
position: fixed;
inset: 0;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
}
.modal {
background: #161822;
border: 1px solid #2d3148;
border-radius: 10px;
padding: 1rem;
width: 340px;
max-width: 90vw;
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.modal h3 {
font-size: 0.9rem;
color: #e1e4e8;
margin: 0;
}
.edit-input {
background: #0f1117;
border: 1px solid #2d3148;
border-radius: 6px;
color: #e1e4e8;
padding: 0.5rem;
font-size: 0.8rem;
font-family: inherit;
width: 100%;
box-sizing: border-box;
}
.edit-input:focus {
outline: none;
border-color: #3b82f6;
}
.edit-input::placeholder {
color: #8b92a5;
}
.title-input {
font-size: 0.9rem;
font-weight: 600;
}
textarea.edit-input {
resize: vertical;
line-height: 1.4;
}
.checkbox-row {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.8rem;
color: #8b92a5;
cursor: pointer;
}
.time-row {
display: flex;
align-items: center;
gap: 0.5rem;
}
.time-sep {
color: #8b92a5;
font-size: 0.8rem;
}
.modal-actions {
display: flex;
gap: 0.5rem;
}
.modal-actions button {
flex: 1;
padding: 0.4rem;
border-radius: 6px;
border: none;
font-size: 0.8rem;
cursor: pointer;
font-family: inherit;
background: #3b82f6;
color: white;
}
.modal-actions button.delete {
background: #dc2626;
}
.modal-actions button.cancel {
background: #1e2235;
color: #8b92a5;
}
.modal-actions button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.no-cal {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
min-height: 200px;
color: #8b92a5;
gap: 0.5rem;
font-size: 0.85rem;
}
.error {
font-size: 0.75rem;
color: #f87171;
padding: 0.25rem 0.5rem;
}
.icon { font-size: 2rem; }
.label { font-weight: 600; color: #e1e4e8; }
.hint { font-size: 0.8rem; }
</style>

View file

@ -0,0 +1,6 @@
import type { CalendarConnection } from './types';
import { createPgCalendar } from './pg.svelte';
export function createCalendar(calendarId: string): CalendarConnection {
return createPgCalendar(calendarId);
}

View file

@ -0,0 +1,2 @@
export type { CalendarEvent, Calendar, CalendarConnection } from './types';
export { createCalendar } from './create.svelte';

View file

@ -0,0 +1,101 @@
import type { Calendar, CalendarEvent, CalendarConnection } from './types';
export function createPgCalendar(calendarId: string): CalendarConnection {
let _calendar = $state<Calendar | null>(null);
let _events = $state<CalendarEvent[]>([]);
let _error = $state('');
let _loading = $state(true);
let _viewDate = $state(new Date());
let _interval: ReturnType<typeof setInterval> | null = null;
function getMonthRange(date: Date): { from: string; to: string } {
const year = date.getFullYear();
const month = date.getMonth();
// Hent fra 6 dager før månedens start (for kalendervisning) til 6 dager etter slutt
const from = new Date(year, month, -6);
const to = new Date(year, month + 1, 7);
return {
from: from.toISOString(),
to: to.toISOString()
};
}
async function fetchEvents() {
try {
const { from, to } = getMonthRange(_viewDate);
const res = await fetch(
`/api/calendar/${calendarId}?from=${encodeURIComponent(from)}&to=${encodeURIComponent(to)}`
);
if (!res.ok) {
_error = `Feil: ${res.status}`;
return;
}
const data = await res.json();
_calendar = { id: data.id, name: data.name, color: data.color, events: data.events };
_events = data.events;
_error = '';
} catch (e) {
_error = e instanceof Error ? e.message : 'Ukjent feil';
} finally {
_loading = false;
}
}
fetchEvents();
_interval = setInterval(fetchEvents, 5000);
return {
get calendar() { return _calendar; },
get events() { return _events; },
get error() { return _error; },
get loading() { return _loading; },
get viewDate() { return _viewDate; },
setViewDate(date: Date) {
_viewDate = date;
_loading = true;
fetchEvents();
},
async addEvent(event) {
const res = await fetch(`/api/calendar/${calendarId}/events`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(event)
});
if (!res.ok) {
_error = `Feil: ${res.status}`;
return;
}
await fetchEvents();
},
async updateEvent(eventId, updates) {
const res = await fetch(`/api/calendar/${calendarId}/events/${eventId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
if (!res.ok) {
_error = `Feil: ${res.status}`;
return;
}
await fetchEvents();
},
async deleteEvent(eventId) {
const res = await fetch(`/api/calendar/${calendarId}/events/${eventId}`, {
method: 'DELETE'
});
if (!res.ok) {
_error = `Feil: ${res.status}`;
return;
}
await fetchEvents();
},
destroy() {
if (_interval) clearInterval(_interval);
}
};
}

View file

@ -0,0 +1,50 @@
export interface CalendarEvent {
id: string;
calendar_id: string;
title: string;
description: string | null;
starts_at: string;
ends_at: string | null;
all_day: boolean;
color: string | null;
linked_node: string | null;
created_by: string | null;
created_at: string;
}
export interface Calendar {
id: string;
name: string;
color: string | null;
events: CalendarEvent[];
}
export interface CalendarConnection {
readonly calendar: Calendar | null;
readonly events: CalendarEvent[];
readonly error: string;
readonly loading: boolean;
readonly viewDate: Date;
setViewDate(date: Date): void;
addEvent(event: {
title: string;
starts_at: string;
ends_at?: string | null;
all_day?: boolean;
description?: string | null;
color?: string | null;
}): Promise<void>;
updateEvent(
eventId: string,
updates: {
title?: string;
description?: string | null;
starts_at?: string;
ends_at?: string | null;
all_day?: boolean;
color?: string | null;
}
): Promise<void>;
deleteEvent(eventId: string): Promise<void>;
destroy(): void;
}

View file

@ -0,0 +1,50 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** GET /api/calendar/:calendarId?from=...&to=... — Hent kalender med hendelser i tidsvindu */
export const GET: RequestHandler = async ({ params, url, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const calendarId = params.calendarId;
const [calendar] = await sql`
SELECT c.id, c.name, c.color FROM calendars c
JOIN nodes n ON n.id = c.id
WHERE c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!calendar) error(404, 'Kalender ikke funnet');
const from = url.searchParams.get('from');
const to = url.searchParams.get('to');
let events;
if (from && to) {
events = await sql`
SELECT e.id, e.calendar_id, e.title, e.description,
e.starts_at, e.ends_at, e.all_day, e.color,
e.linked_node, e.created_by, e.created_at
FROM calendar_events e
WHERE e.calendar_id = ${calendarId}
AND e.starts_at < ${to}
AND (e.ends_at IS NULL OR e.ends_at > ${from} OR e.starts_at >= ${from})
ORDER BY e.starts_at ASC
`;
} else {
events = await sql`
SELECT e.id, e.calendar_id, e.title, e.description,
e.starts_at, e.ends_at, e.all_day, e.color,
e.linked_node, e.created_by, e.created_at
FROM calendar_events e
WHERE e.calendar_id = ${calendarId}
ORDER BY e.starts_at ASC
`;
}
return json({
id: calendar.id,
name: calendar.name,
color: calendar.color,
events
});
};

View file

@ -0,0 +1,46 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** POST /api/calendar/:calendarId/events — Opprett hendelse */
export const POST: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const calendarId = params.calendarId;
const body = await request.json();
if (!body.title?.trim() || !body.starts_at) {
error(400, 'title og starts_at er påkrevd');
}
// Verifiser tilgang
const [calendar] = await sql`
SELECT c.id FROM calendars c
JOIN nodes n ON n.id = c.id
WHERE c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!calendar) error(404, 'Kalender ikke funnet');
const [event] = await sql`
WITH new_node AS (
INSERT INTO nodes (workspace_id, node_type)
VALUES (${locals.workspace.id}, 'calendar_event')
RETURNING id
)
INSERT INTO calendar_events (id, calendar_id, title, description, starts_at, ends_at, all_day, color, created_by)
SELECT
new_node.id,
${calendarId},
${body.title.trim()},
${body.description?.trim() || null},
${body.starts_at},
${body.ends_at || null},
${body.all_day ?? false},
${body.color || null},
${locals.user.id}
FROM new_node
RETURNING *
`;
return json(event, { status: 201 });
};

View file

@ -0,0 +1,53 @@
import { json, error } from '@sveltejs/kit';
import type { RequestHandler } from './$types';
import { sql } from '$lib/server/db';
/** PATCH /api/calendar/:calendarId/events/:eventId — Oppdater hendelse */
export const PATCH: RequestHandler = async ({ params, request, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const { calendarId, eventId } = params;
const updates = await request.json();
// Verifiser tilgang
const [event] = await sql`
SELECT e.id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
JOIN nodes n ON n.id = c.id
WHERE e.id = ${eventId} AND c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!event) error(404, 'Hendelse ikke funnet');
const [updated] = await sql`
UPDATE calendar_events SET
title = COALESCE(${updates.title ?? null}, title),
description = CASE WHEN ${updates.description !== undefined} THEN ${updates.description ?? null} ELSE description END,
starts_at = COALESCE(${updates.starts_at ?? null}, starts_at),
ends_at = CASE WHEN ${updates.ends_at !== undefined} THEN ${updates.ends_at ?? null} ELSE ends_at END,
all_day = COALESCE(${updates.all_day ?? null}, all_day),
color = CASE WHEN ${updates.color !== undefined} THEN ${updates.color ?? null} ELSE color END
WHERE id = ${eventId}
RETURNING *
`;
return json(updated);
};
/** DELETE /api/calendar/:calendarId/events/:eventId — Slett hendelse */
export const DELETE: RequestHandler = async ({ params, locals }) => {
if (!locals.workspace || !locals.user) error(401);
const { calendarId, eventId } = params;
const [event] = await sql`
SELECT e.id FROM calendar_events e
JOIN calendars c ON c.id = e.calendar_id
JOIN nodes n ON n.id = c.id
WHERE e.id = ${eventId} AND c.id = ${calendarId} AND n.workspace_id = ${locals.workspace.id}
`;
if (!event) error(404, 'Hendelse ikke funnet');
await sql`DELETE FROM nodes WHERE id = ${eventId}`;
return new Response(null, { status: 204 });
};