From d924645dd3cd0649241fcb288774eac0eb591631 Mon Sep 17 00:00:00 2001 From: vegard Date: Sun, 15 Mar 2026 02:56:16 +0100 Subject: [PATCH] =?UTF-8?q?Kalender:=20PG-adapter,=20API-ruter,=20m=C3=A5n?= =?UTF-8?q?edsvisning=20med=20fargekoder?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- migrations/0003_calendar.sql | 51 ++ migrations/seed_dev.sql | 30 + web/src/lib/blocks/CalendarBlock.svelte | 622 +++++++++++++++++- web/src/lib/calendar/create.svelte.ts | 6 + web/src/lib/calendar/index.ts | 2 + web/src/lib/calendar/pg.svelte.ts | 101 +++ web/src/lib/calendar/types.ts | 50 ++ .../api/calendar/[calendarId]/+server.ts | 50 ++ .../calendar/[calendarId]/events/+server.ts | 46 ++ .../[calendarId]/events/[eventId]/+server.ts | 53 ++ 10 files changed, 1000 insertions(+), 11 deletions(-) create mode 100644 migrations/0003_calendar.sql create mode 100644 web/src/lib/calendar/create.svelte.ts create mode 100644 web/src/lib/calendar/index.ts create mode 100644 web/src/lib/calendar/pg.svelte.ts create mode 100644 web/src/lib/calendar/types.ts create mode 100644 web/src/routes/api/calendar/[calendarId]/+server.ts create mode 100644 web/src/routes/api/calendar/[calendarId]/events/+server.ts create mode 100644 web/src/routes/api/calendar/[calendarId]/events/[eventId]/+server.ts diff --git a/migrations/0003_calendar.sql b/migrations/0003_calendar.sql new file mode 100644 index 0000000..0abd173 --- /dev/null +++ b/migrations/0003_calendar.sql @@ -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'; diff --git a/migrations/seed_dev.sql b/migrations/seed_dev.sql index d7a5327..b84dfde 100644 --- a/migrations/seed_dev.sql +++ b/migrations/seed_dev.sql @@ -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", diff --git a/web/src/lib/blocks/CalendarBlock.svelte b/web/src/lib/blocks/CalendarBlock.svelte index b82bcc1..73d5b91 100644 --- a/web/src/lib/blocks/CalendarBlock.svelte +++ b/web/src/lib/blocks/CalendarBlock.svelte @@ -1,25 +1,625 @@ -
- 📅 -

Kalender

-

Kommer snart

-
+{#if !calendarId} +

Ingen kalender konfigurert for denne blokken.

+{:else if cal?.loading} +

Laster...

+{:else} +
+
+ + + +
+ +
+ {#each WEEKDAYS as day} +
{day}
+ {/each} +
+ +
+ {#each calendarDays as day} + + {/each} + + {/each} +
+
+ + {#if showAdd} + + {/if} + + {#if editingEvent} + + {/if} + + {#if cal?.error} +
{cal.error}
+ {/if} +{/if} diff --git a/web/src/lib/calendar/create.svelte.ts b/web/src/lib/calendar/create.svelte.ts new file mode 100644 index 0000000..75bc3a6 --- /dev/null +++ b/web/src/lib/calendar/create.svelte.ts @@ -0,0 +1,6 @@ +import type { CalendarConnection } from './types'; +import { createPgCalendar } from './pg.svelte'; + +export function createCalendar(calendarId: string): CalendarConnection { + return createPgCalendar(calendarId); +} diff --git a/web/src/lib/calendar/index.ts b/web/src/lib/calendar/index.ts new file mode 100644 index 0000000..8d38fb9 --- /dev/null +++ b/web/src/lib/calendar/index.ts @@ -0,0 +1,2 @@ +export type { CalendarEvent, Calendar, CalendarConnection } from './types'; +export { createCalendar } from './create.svelte'; diff --git a/web/src/lib/calendar/pg.svelte.ts b/web/src/lib/calendar/pg.svelte.ts new file mode 100644 index 0000000..775c7d1 --- /dev/null +++ b/web/src/lib/calendar/pg.svelte.ts @@ -0,0 +1,101 @@ +import type { Calendar, CalendarEvent, CalendarConnection } from './types'; + +export function createPgCalendar(calendarId: string): CalendarConnection { + let _calendar = $state(null); + let _events = $state([]); + let _error = $state(''); + let _loading = $state(true); + let _viewDate = $state(new Date()); + let _interval: ReturnType | 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); + } + }; +} diff --git a/web/src/lib/calendar/types.ts b/web/src/lib/calendar/types.ts new file mode 100644 index 0000000..72ecadf --- /dev/null +++ b/web/src/lib/calendar/types.ts @@ -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; + updateEvent( + eventId: string, + updates: { + title?: string; + description?: string | null; + starts_at?: string; + ends_at?: string | null; + all_day?: boolean; + color?: string | null; + } + ): Promise; + deleteEvent(eventId: string): Promise; + destroy(): void; +} diff --git a/web/src/routes/api/calendar/[calendarId]/+server.ts b/web/src/routes/api/calendar/[calendarId]/+server.ts new file mode 100644 index 0000000..2c0dd8e --- /dev/null +++ b/web/src/routes/api/calendar/[calendarId]/+server.ts @@ -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 + }); +}; diff --git a/web/src/routes/api/calendar/[calendarId]/events/+server.ts b/web/src/routes/api/calendar/[calendarId]/events/+server.ts new file mode 100644 index 0000000..f832de0 --- /dev/null +++ b/web/src/routes/api/calendar/[calendarId]/events/+server.ts @@ -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 }); +}; diff --git a/web/src/routes/api/calendar/[calendarId]/events/[eventId]/+server.ts b/web/src/routes/api/calendar/[calendarId]/events/[eventId]/+server.ts new file mode 100644 index 0000000..5528437 --- /dev/null +++ b/web/src/routes/api/calendar/[calendarId]/events/[eventId]/+server.ts @@ -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 }); +};