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}
+
+{:else}
+
+
+
+
+ {#each WEEKDAYS as day}
+
{day}
+ {/each}
+
+
+
+ {#each calendarDays as day}
+
+ {/each}
+
+
+
+ {#if showAdd}
+ { showAdd = false; }} role="presentation">
+
+
+ {/if}
+
+ {#if editingEvent}
+ { editingEvent = null; }} role="presentation">
+
+
+ {/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 });
+};