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>
485 lines
15 KiB
Svelte
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">← 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"
|
|
>
|
|
←
|
|
</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"
|
|
>
|
|
→
|
|
</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>
|