synops/frontend/src/routes/calendar/+page.svelte
vegard ea0671933d Kalender-visning: månedsrutenett med scheduled-edges (oppgave 9.2)
Ny rute /calendar som viser alle noder med scheduled-edge i et
månedsbasert kalenderrutenett. Bruker edge-metadata { at: ISO8601 }
for tidspunkt, med T12:00:00-konvensjon for heldagshendelser.

Funksjoner:
- Månedsnavigering med «I dag»-snarvei
- Drag-and-drop for å flytte hendelser mellom datoer (updateEdge)
- Inline-oppretting med tittel og valgfritt klokkeslett
- Fargekoding etter node_kind
- Hendelsesliste under rutenett for gjeldende måned
- Kalender-lenke med hendelsesteller på mottak-siden
- Sanntid via SpacetimeDB (edgeStore.byType('scheduled'))

Arkitekturvalg: Bruker scheduled-edges direkte fra SpacetimeDB
i stedet for legacy calendar_events-tabellen. En node blir en
kalenderoppføring ved å ha en scheduled-edge — konsistent med
«hva edges gjør med noder»-prinsippet.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:27:28 +00:00

485 lines
15 KiB
Svelte

<script lang="ts">
import { page } from '$app/stores';
import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime';
import type { Node, Edge } from '$lib/spacetime';
import { createNode, createEdge, updateEdge } from '$lib/api';
const session = $derived($page.data.session as Record<string, unknown> | undefined);
const nodeId = $derived(session?.nodeId as string | undefined);
const accessToken = $derived(session?.accessToken as string | undefined);
const connected = $derived(connectionState.current === 'connected');
// =========================================================================
// Calendar state
// =========================================================================
const today = new Date();
let viewYear = $state(today.getFullYear());
let viewMonth = $state(today.getMonth()); // 0-indexed
const monthNames = [
'Januar', 'Februar', 'Mars', 'April', 'Mai', 'Juni',
'Juli', 'August', 'September', 'Oktober', 'November', 'Desember'
];
const dayNames = ['Man', 'Tir', 'Ons', 'Tor', 'Fre', 'Lør', 'Søn'];
function prevMonth() {
if (viewMonth === 0) {
viewMonth = 11;
viewYear--;
} else {
viewMonth--;
}
}
function nextMonth() {
if (viewMonth === 11) {
viewMonth = 0;
viewYear++;
} else {
viewMonth++;
}
}
function goToday() {
viewYear = today.getFullYear();
viewMonth = today.getMonth();
}
// =========================================================================
// Calendar grid computation
// =========================================================================
interface CalendarDay {
date: Date;
dayOfMonth: number;
isCurrentMonth: boolean;
isToday: boolean;
dateKey: string; // YYYY-MM-DD for grouping
}
/** Build the 6-row grid of days for the current month view */
const calendarDays = $derived.by((): CalendarDay[] => {
const firstOfMonth = new Date(viewYear, viewMonth, 1);
// Monday=0 ... Sunday=6 (ISO week)
let startDow = firstOfMonth.getDay() - 1;
if (startDow < 0) startDow = 6;
const days: CalendarDay[] = [];
// Start from the Monday before (or on) the 1st
const startDate = new Date(viewYear, viewMonth, 1 - startDow);
const todayKey = formatDateKey(today);
for (let i = 0; i < 42; i++) {
const d = new Date(startDate);
d.setDate(startDate.getDate() + i);
const dateKey = formatDateKey(d);
days.push({
date: d,
dayOfMonth: d.getDate(),
isCurrentMonth: d.getMonth() === viewMonth && d.getFullYear() === viewYear,
isToday: dateKey === todayKey,
dateKey
});
}
return days;
});
function formatDateKey(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
// =========================================================================
// Scheduled events from SpacetimeDB
// =========================================================================
interface ScheduledEvent {
node: Node;
edge: Edge;
scheduledAt: Date;
dateKey: string;
timeStr: string; // HH:MM or "Heldag"
}
/** All scheduled events visible to the user */
const scheduledEvents = $derived.by((): ScheduledEvent[] => {
if (!connected || !nodeId) return [];
const events: ScheduledEvent[] = [];
for (const edge of edgeStore.byType('scheduled')) {
const node = nodeStore.get(edge.sourceId);
if (!node || nodeVisibility(node, nodeId) === 'hidden') continue;
let at: string | undefined;
try {
const meta = JSON.parse(edge.metadata ?? '{}');
at = meta.at;
} catch { /* ignore */ }
if (!at) continue;
const scheduledAt = new Date(at);
if (isNaN(scheduledAt.getTime())) continue;
const dateKey = formatDateKey(scheduledAt);
const hours = scheduledAt.getHours();
const minutes = scheduledAt.getMinutes();
// If time is 12:00, it's likely an all-day event (convention from kalender.md)
const isAllDay = hours === 12 && minutes === 0;
const timeStr = isAllDay
? ''
: `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
events.push({ node, edge, scheduledAt, dateKey, timeStr });
}
// Sort by time
events.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
return events;
});
/** Events grouped by dateKey for fast lookup */
const eventsByDate = $derived.by((): Map<string, ScheduledEvent[]> => {
const map = new Map<string, ScheduledEvent[]>();
for (const ev of scheduledEvents) {
const list = map.get(ev.dateKey);
if (list) {
list.push(ev);
} else {
map.set(ev.dateKey, [ev]);
}
}
return map;
});
// =========================================================================
// Drag and drop
// =========================================================================
let draggedEvent = $state<ScheduledEvent | null>(null);
let dragOverDate = $state<string | null>(null);
function handleDragStart(e: DragEvent, event: ScheduledEvent) {
draggedEvent = event;
if (e.dataTransfer) {
e.dataTransfer.effectAllowed = 'move';
e.dataTransfer.setData('text/plain', event.node.id);
}
}
function handleDragOver(e: DragEvent, dateKey: string) {
e.preventDefault();
if (e.dataTransfer) e.dataTransfer.dropEffect = 'move';
dragOverDate = dateKey;
}
function handleDragLeave() {
dragOverDate = null;
}
async function handleDrop(e: DragEvent, dateKey: string) {
e.preventDefault();
dragOverDate = null;
if (!draggedEvent || !accessToken) return;
if (draggedEvent.dateKey === dateKey) {
draggedEvent = null;
return;
}
const event = draggedEvent;
draggedEvent = null;
// Parse the target date and preserve the original time
const [y, m, d] = dateKey.split('-').map(Number);
const newDate = new Date(y, m - 1, d,
event.scheduledAt.getHours(),
event.scheduledAt.getMinutes()
);
try {
await updateEdge(accessToken, {
edge_id: event.edge.id,
metadata: { at: newDate.toISOString() }
});
} catch (err) {
console.error('Feil ved flytting av hendelse:', err);
}
}
function handleDragEnd() {
draggedEvent = null;
dragOverDate = null;
}
// =========================================================================
// Create new scheduled event
// =========================================================================
let addingToDate = $state<string | null>(null);
let newEventTitle = $state('');
let newEventTime = $state('');
let isCreating = $state(false);
function startAddEvent(dateKey: string) {
addingToDate = dateKey;
newEventTitle = '';
newEventTime = '';
}
async function handleCreateEvent() {
if (!accessToken || !nodeId || !addingToDate || !newEventTitle.trim() || isCreating) return;
isCreating = true;
try {
const [y, m, d] = addingToDate.split('-').map(Number);
let scheduledDate: Date;
if (newEventTime) {
const [h, min] = newEventTime.split(':').map(Number);
scheduledDate = new Date(y, m - 1, d, h, min);
} else {
// All-day: use T12:00:00 convention
scheduledDate = new Date(y, m - 1, d, 12, 0);
}
// Create node
const { node_id } = await createNode(accessToken, {
node_kind: 'content',
title: newEventTitle.trim(),
visibility: 'hidden'
});
// Create owner edge
await createEdge(accessToken, {
source_id: nodeId,
target_id: node_id,
edge_type: 'owner'
});
// Create scheduled edge
await createEdge(accessToken, {
source_id: node_id,
target_id: node_id,
edge_type: 'scheduled',
metadata: { at: scheduledDate.toISOString() }
});
newEventTitle = '';
newEventTime = '';
addingToDate = null;
} catch (err) {
console.error('Feil ved oppretting av hendelse:', err);
} finally {
isCreating = false;
}
}
function handleEventKeydown(e: KeyboardEvent) {
if (e.key === 'Enter') {
e.preventDefault();
handleCreateEvent();
} else if (e.key === 'Escape') {
addingToDate = null;
newEventTitle = '';
newEventTime = '';
}
}
// =========================================================================
// Event colors based on node_kind
// =========================================================================
function eventColor(node: Node): string {
switch (node.nodeKind) {
case 'communication': return 'bg-blue-100 border-blue-300 text-blue-800';
case 'media': return 'bg-purple-100 border-purple-300 text-purple-800';
case 'collection': return 'bg-green-100 border-green-300 text-green-800';
default: return 'bg-amber-100 border-amber-300 text-amber-800';
}
}
</script>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="border-b border-gray-200 bg-white">
<div class="flex items-center justify-between px-4 py-3">
<div class="flex items-center gap-3">
<a href="/" class="text-sm text-gray-400 hover:text-gray-600">&larr; Mottak</a>
<h1 class="text-lg font-semibold text-gray-900">Kalender</h1>
</div>
<div class="flex items-center gap-3">
{#if connected}
<span class="text-xs text-green-600">Tilkoblet</span>
{:else}
<span class="text-xs text-gray-400">{connectionState.current}</span>
{/if}
<span class="text-xs text-gray-400">{scheduledEvents.length} hendelser</span>
</div>
</div>
</header>
<main class="p-4">
{#if !connected}
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
{:else}
<!-- Month navigation -->
<div class="mb-4 flex items-center justify-between">
<button
onclick={prevMonth}
class="rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm border border-gray-200 hover:bg-gray-50"
>
&larr;
</button>
<div class="flex items-center gap-3">
<h2 class="text-xl font-semibold text-gray-800">
{monthNames[viewMonth]} {viewYear}
</h2>
<button
onclick={goToday}
class="rounded bg-gray-100 px-2 py-1 text-xs text-gray-600 hover:bg-gray-200"
>
I dag
</button>
</div>
<button
onclick={nextMonth}
class="rounded-lg bg-white px-3 py-1.5 text-sm font-medium text-gray-700 shadow-sm border border-gray-200 hover:bg-gray-50"
>
&rarr;
</button>
</div>
<!-- Calendar grid -->
<div class="rounded-lg border border-gray-200 bg-white shadow-sm overflow-hidden">
<!-- Day headers -->
<div class="grid grid-cols-7 border-b border-gray-200 bg-gray-50">
{#each dayNames as day}
<div class="px-2 py-2 text-center text-xs font-medium text-gray-500">
{day}
</div>
{/each}
</div>
<!-- Day cells -->
<div class="grid grid-cols-7">
{#each calendarDays as day, i (day.dateKey)}
{@const dayEvents = eventsByDate.get(day.dateKey) ?? []}
{@const isDropTarget = dragOverDate === day.dateKey}
<div
class="min-h-24 border-b border-r border-gray-100 p-1
{day.isCurrentMonth ? '' : 'bg-gray-50'}
{day.isToday ? 'bg-blue-50' : ''}
{isDropTarget ? 'ring-2 ring-inset ring-blue-400 bg-blue-50' : ''}"
ondragover={(e: DragEvent) => handleDragOver(e, day.dateKey)}
ondragleave={handleDragLeave}
ondrop={(e: DragEvent) => handleDrop(e, day.dateKey)}
role="gridcell"
>
<!-- Date number -->
<div class="flex items-center justify-between px-1">
<span class="text-xs font-medium {day.isCurrentMonth ? 'text-gray-700' : 'text-gray-300'} {day.isToday ? 'rounded-full bg-blue-600 text-white px-1.5 py-0.5' : ''}">
{day.dayOfMonth}
</span>
{#if day.isCurrentMonth && accessToken}
<button
onclick={() => startAddEvent(day.dateKey)}
class="text-gray-300 hover:text-blue-500 text-xs leading-none"
title="Legg til hendelse"
>
+
</button>
{/if}
</div>
<!-- Events -->
<div class="mt-0.5 space-y-0.5">
{#each dayEvents as event (event.node.id)}
<div
class="cursor-grab rounded border px-1 py-0.5 text-xs truncate {eventColor(event.node)} {draggedEvent?.node.id === event.node.id ? 'opacity-50' : ''}"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, event)}
ondragend={handleDragEnd}
title="{event.timeStr ? event.timeStr + ' ' : ''}{event.node.title || 'Uten tittel'}"
>
{#if event.timeStr}
<span class="font-medium">{event.timeStr}</span>
{/if}
{event.node.title || 'Uten tittel'}
</div>
{/each}
</div>
<!-- Inline add form -->
{#if addingToDate === day.dateKey}
<div class="mt-1 rounded border border-blue-300 bg-white p-1">
<input
type="text"
bind:value={newEventTitle}
onkeydown={handleEventKeydown}
placeholder="Tittel…"
class="w-full rounded border border-gray-200 px-1 py-0.5 text-xs focus:border-blue-400 focus:outline-none"
disabled={isCreating}
/>
<input
type="time"
bind:value={newEventTime}
class="mt-0.5 w-full rounded border border-gray-200 px-1 py-0.5 text-xs focus:border-blue-400 focus:outline-none"
/>
<div class="mt-1 flex gap-1">
<button
onclick={handleCreateEvent}
disabled={isCreating || !newEventTitle.trim()}
class="rounded bg-blue-600 px-1.5 py-0.5 text-xs font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isCreating ? '…' : 'Legg til'}
</button>
<button
onclick={() => { addingToDate = null; newEventTitle = ''; newEventTime = ''; }}
class="rounded px-1.5 py-0.5 text-xs text-gray-500 hover:bg-gray-100"
>
Avbryt
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- Event list for current month -->
{#if scheduledEvents.length > 0}
{@const monthEvents = scheduledEvents.filter(e => {
const d = e.scheduledAt;
return d.getMonth() === viewMonth && d.getFullYear() === viewYear;
})}
{#if monthEvents.length > 0}
<div class="mt-4">
<h3 class="mb-2 text-sm font-medium text-gray-600">Hendelser denne måneden</h3>
<ul class="space-y-1">
{#each monthEvents as event (event.node.id)}
<li class="flex items-center gap-2 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm shadow-sm">
<span class="shrink-0 text-xs text-gray-400">
{event.scheduledAt.getDate()}. {monthNames[event.scheduledAt.getMonth()].slice(0, 3).toLowerCase()}
</span>
{#if event.timeStr}
<span class="shrink-0 text-xs font-medium text-gray-600">{event.timeStr}</span>
{/if}
<span class="truncate text-gray-900">{event.node.title || 'Uten tittel'}</span>
<span class="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{event.node.nodeKind}
</span>
</li>
{/each}
</ul>
</div>
{/if}
{/if}
{/if}
</main>
</div>