synops/frontend/src/lib/components/traits/CalendarTrait.svelte
vegard e8a1a80652 Valider fase 22: STDB-migrering fullført, ingen rester i aktiv kode
Validering av fase 22 (SpacetimeDB-migrering) bekrefter:

1. WebSocket-sanntid fungerer:
   - maskinrommet lytter på PG NOTIFY-kanaler (node_changed, edge_changed,
     access_changed, mixer_channel_changed)
   - Enrichment av events med fulle rader fra PG
   - Broadcast via tokio::broadcast til WebSocket-klienter
   - Tilgangskontroll filtrerer events per bruker
   - Frontend kobler til /ws med JWT, mottar initial_sync + inkrementelle events

2. PG LISTEN/NOTIFY-triggere verifisert i database:
   - 4 notify-funksjoner: notify_node_change, notify_edge_change,
     notify_access_change, notify_mixer_channel_change
   - 4 triggere: nodes_notify, edges_notify, node_access_notify,
     mixer_channels_notify

3. Ingen STDB-rester i aktiv kode/konfig:
   - maskinrommet/src/: rent
   - Cargo.toml: ingen spacetimedb-avhengigheter
   - docker-compose.yml: ingen spacetimedb-tjeneste
   - Caddyfile: ingen spacetimedb-proxy
   - Eneste funn: frontend/src/lib/spacetime/ katalognavn —
     omdøpt til frontend/src/lib/realtime/ (32 filer oppdatert)
   - Historiske referanser i docs/arkiv og scripts/synops.md er OK
2026-03-18 16:31:16 +00:00

923 lines
22 KiB
Svelte

<script lang="ts">
import type { Node, Edge } from '$lib/realtime';
import { edgeStore, nodeStore, nodeVisibility, connectionState } from '$lib/realtime';
import { createNode, createEdge, updateEdge } from '$lib/api';
import { setDragPayload, checkCalendarCompat, type DragPayload } from '$lib/transfer';
import type { BlockReceiver, PlacementIntent } from '$lib/components/blockshell/types';
interface Props {
collection?: Node;
config: Record<string, unknown>;
userId?: string;
accessToken?: string;
/** Called when a drop is received on this panel */
onReceiveDrop?: (payload: DragPayload, intent: PlacementIntent) => void;
}
let { collection, config, userId, accessToken, onReceiveDrop }: Props = $props();
const connected = $derived(connectionState.current === 'connected');
/**
* BlockReceiver implementation for Calendar.
* Accepts communication and content nodes as new events/scheduled items.
* Kanban cards get a scheduled edge.
* Ref: docs/retninger/arbeidsflaten.md § Kompatibilitetsmatrise
*/
export const receiver: BlockReceiver = {
canReceive(payload: DragPayload) {
return checkCalendarCompat(payload);
},
receive(payload: DragPayload) {
const today = new Date().toISOString().slice(0, 10);
const mode = payload.sourcePanel === 'kanban' ? 'lettvekts-triage' as const : 'innholdstransfer' as const;
const intent: PlacementIntent = {
mode,
contextId: collection?.id ?? '',
contextType: 'calendar',
position: { date: today, all_day: true },
};
onReceiveDrop?.(payload, intent);
return intent;
}
};
// =========================================================================
// 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 = ['Ma', 'Ti', 'On', 'To', 'Fr', 'Lø', 'Sø'];
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;
}
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}`;
}
const calendarDays = $derived.by((): CalendarDay[] => {
const firstOfMonth = new Date(viewYear, viewMonth, 1);
let startDow = firstOfMonth.getDay() - 1;
if (startDow < 0) startDow = 6;
const days: CalendarDay[] = [];
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;
});
// =========================================================================
// Scheduled events from WebSocket store
// =========================================================================
interface ScheduledEvent {
node: Node;
edge: Edge;
scheduledAt: Date;
dateKey: string;
timeStr: string;
}
const scheduledEvents = $derived.by((): ScheduledEvent[] => {
if (!connected || !userId) return [];
const events: ScheduledEvent[] = [];
for (const edge of edgeStore.byType('scheduled')) {
const node = nodeStore.get(edge.sourceId);
if (!node || nodeVisibility(node, userId) === '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();
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 });
}
events.sort((a, b) => a.scheduledAt.getTime() - b.scheduledAt.getTime());
return events;
});
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;
});
// =========================================================================
// Internal drag-and-drop (move events between dates)
// =========================================================================
let draggedEvent = $state<ScheduledEvent | null>(null);
let dragOverDate = $state<string | null>(null);
function handleDragStart(e: DragEvent, event: ScheduledEvent) {
draggedEvent = event;
if (!e.dataTransfer) return;
e.dataTransfer.effectAllowed = 'move';
// Set cross-panel payload for dragging events to other panels
setDragPayload(e.dataTransfer, {
nodeId: event.node.id,
nodeKind: event.node.nodeKind,
sourcePanel: 'calendar',
});
}
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;
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 || !userId || !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 {
scheduledDate = new Date(y, m - 1, d, 12, 0);
}
const { node_id } = await createNode(accessToken, {
node_kind: 'content',
title: newEventTitle.trim(),
visibility: 'hidden'
});
await createEdge(accessToken, {
source_id: userId,
target_id: node_id,
edge_type: 'owner'
});
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 eventColorClass(node: Node): string {
switch (node.nodeKind) {
case 'communication': return 'cal-event-comm';
case 'media': return 'cal-event-media';
case 'collection': return 'cal-event-coll';
default: return 'cal-event-default';
}
}
</script>
<!--
CalendarTrait — fullverdig BlockShell-panel for kalender.
Viser månedsgrid med hendelser, drag-and-drop mellom datoer,
inline opprettelse, og støtter mottak av noder fra andre paneler.
Forelder (collection page) wrapper dette i BlockShell.
-->
<div class="cal-trait">
{#if !connected}
<div class="cal-empty">
<p>Venter på tilkobling…</p>
</div>
{:else}
<!-- Month navigation -->
<div class="cal-nav">
<button onclick={prevMonth} class="cal-nav-btn" aria-label="Forrige måned">&larr;</button>
<div class="cal-nav-center">
<span class="cal-nav-title">{monthNames[viewMonth]} {viewYear}</span>
<button onclick={goToday} class="cal-today-btn">I dag</button>
</div>
<button onclick={nextMonth} class="cal-nav-btn" aria-label="Neste måned">&rarr;</button>
</div>
<!-- Calendar grid -->
<div class="cal-grid-wrapper">
<!-- Day headers -->
<div class="cal-day-headers">
{#each dayNames as day}
<div class="cal-day-header">{day}</div>
{/each}
</div>
<!-- Day cells -->
<div class="cal-grid">
{#each calendarDays as day (day.dateKey)}
{@const dayEvents = eventsByDate.get(day.dateKey) ?? []}
{@const isDropTarget = dragOverDate === day.dateKey}
<div
class="cal-day
{day.isCurrentMonth ? '' : 'cal-day-other'}
{day.isToday ? 'cal-day-today' : ''}
{isDropTarget ? 'cal-day-drop' : ''}"
ondragover={(e: DragEvent) => handleDragOver(e, day.dateKey)}
ondragleave={handleDragLeave}
ondrop={(e: DragEvent) => handleDrop(e, day.dateKey)}
role="gridcell"
>
<!-- Date number + add button -->
<div class="cal-day-num-row">
<span class="cal-day-num {day.isToday ? 'cal-day-num-today' : ''}">
{day.dayOfMonth}
</span>
{#if day.isCurrentMonth && accessToken}
<button
onclick={() => startAddEvent(day.dateKey)}
class="cal-add-btn"
title="Legg til hendelse"
>+</button>
{/if}
</div>
<!-- Events -->
{#each dayEvents as event (event.node.id)}
<div
class="cal-event {eventColorClass(event.node)} {draggedEvent?.node.id === event.node.id ? 'cal-event-dragging' : ''}"
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="cal-event-time">{event.timeStr}</span>
{/if}
<span class="cal-event-title">{event.node.title || 'Uten tittel'}</span>
</div>
{/each}
<!-- Inline add form -->
{#if addingToDate === day.dateKey}
<div class="cal-add-form">
<input
type="text"
bind:value={newEventTitle}
onkeydown={handleEventKeydown}
placeholder="Tittel…"
class="cal-add-input"
disabled={isCreating}
/>
<input
type="time"
bind:value={newEventTime}
class="cal-add-input cal-add-time"
/>
<div class="cal-add-actions">
<button
onclick={handleCreateEvent}
disabled={isCreating || !newEventTitle.trim()}
class="cal-add-submit"
>
{isCreating ? '…' : 'Legg til'}
</button>
<button
onclick={() => { addingToDate = null; newEventTitle = ''; newEventTime = ''; }}
class="cal-add-cancel"
>
Avbryt
</button>
</div>
</div>
{/if}
</div>
{/each}
</div>
</div>
<!-- Event list for current month -->
{@const monthEvents = scheduledEvents.filter(e => {
const d = e.scheduledAt;
return d.getMonth() === viewMonth && d.getFullYear() === viewYear;
})}
{#if monthEvents.length > 0}
<div class="cal-list">
<div class="cal-list-header">Hendelser denne måneden</div>
{#each monthEvents as event (event.node.id)}
<div
class="cal-list-item"
draggable="true"
ondragstart={(e: DragEvent) => handleDragStart(e, event)}
ondragend={handleDragEnd}
>
<span class="cal-list-date">
{event.scheduledAt.getDate()}. {monthNames[event.scheduledAt.getMonth()].slice(0, 3).toLowerCase()}
</span>
{#if event.timeStr}
<span class="cal-list-time">{event.timeStr}</span>
{/if}
<span class="cal-list-title">{event.node.title || 'Uten tittel'}</span>
</div>
{/each}
</div>
{/if}
{/if}
</div>
<style>
/* ================================================================= */
/* Root — fills BlockShell content area */
/* ================================================================= */
.cal-trait {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
overflow: hidden;
}
/* ================================================================= */
/* Empty / loading state */
/* ================================================================= */
.cal-empty {
display: flex;
align-items: center;
justify-content: center;
flex: 1;
color: #9ca3af;
font-size: 13px;
}
/* ================================================================= */
/* Month navigation */
/* ================================================================= */
.cal-nav {
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 8px;
flex-shrink: 0;
border-bottom: 1px solid #f3f4f6;
}
.cal-nav-btn {
display: flex;
align-items: center;
justify-content: center;
width: 28px;
height: 28px;
border: 1px solid #e5e7eb;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
color: #6b7280;
flex-shrink: 0;
}
.cal-nav-btn:hover {
background: #f9fafb;
color: #374151;
}
.cal-nav-center {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
}
.cal-nav-title {
font-size: 13px;
font-weight: 600;
color: #1f2937;
white-space: nowrap;
}
.cal-today-btn {
padding: 2px 6px;
border: none;
background: #f3f4f6;
border-radius: 4px;
font-size: 10px;
color: #6b7280;
cursor: pointer;
}
.cal-today-btn:hover {
background: #e5e7eb;
}
/* ================================================================= */
/* Calendar grid */
/* ================================================================= */
.cal-grid-wrapper {
flex: 1;
min-height: 0;
overflow-y: auto;
overflow-x: hidden;
}
.cal-day-headers {
display: grid;
grid-template-columns: repeat(7, 1fr);
border-bottom: 1px solid #e5e7eb;
background: #f9fafb;
position: sticky;
top: 0;
z-index: 1;
}
.cal-day-header {
text-align: center;
font-size: 10px;
font-weight: 600;
color: #9ca3af;
padding: 4px 0;
text-transform: uppercase;
letter-spacing: 0.03em;
}
.cal-grid {
display: grid;
grid-template-columns: repeat(7, 1fr);
}
/* ================================================================= */
/* Day cell */
/* ================================================================= */
.cal-day {
min-height: 56px;
border-bottom: 1px solid #f3f4f6;
border-right: 1px solid #f3f4f6;
padding: 2px;
overflow: hidden;
}
.cal-day-other {
background: #fafbfc;
}
.cal-day-today {
background: #eff6ff;
}
.cal-day-drop {
box-shadow: inset 0 0 0 2px #60a5fa;
background: #eff6ff;
}
.cal-day-num-row {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0 2px;
}
.cal-day-num {
font-size: 11px;
font-weight: 500;
color: #6b7280;
line-height: 1;
}
.cal-day-other .cal-day-num {
color: #d1d5db;
}
.cal-day-num-today {
background: #2563eb;
color: white;
border-radius: 9999px;
padding: 1px 4px;
}
.cal-add-btn {
border: none;
background: transparent;
color: #d1d5db;
font-size: 11px;
cursor: pointer;
line-height: 1;
padding: 0 2px;
}
.cal-add-btn:hover {
color: #2563eb;
}
/* ================================================================= */
/* Event chip */
/* ================================================================= */
.cal-event {
display: flex;
align-items: center;
gap: 2px;
margin-top: 1px;
padding: 1px 3px;
border-radius: 3px;
border: 1px solid;
cursor: grab;
overflow: hidden;
font-size: 10px;
line-height: 1.3;
transition: opacity 0.15s;
}
.cal-event:active {
cursor: grabbing;
}
.cal-event-dragging {
opacity: 0.4;
}
.cal-event-default {
background: #fef3c7;
border-color: #fcd34d;
color: #92400e;
}
.cal-event-comm {
background: #dbeafe;
border-color: #93c5fd;
color: #1e40af;
}
.cal-event-media {
background: #ede9fe;
border-color: #c4b5fd;
color: #5b21b6;
}
.cal-event-coll {
background: #d1fae5;
border-color: #6ee7b7;
color: #065f46;
}
.cal-event-time {
font-weight: 600;
flex-shrink: 0;
}
.cal-event-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* ================================================================= */
/* Inline add form */
/* ================================================================= */
.cal-add-form {
margin-top: 2px;
padding: 3px;
background: white;
border: 1px solid #bfdbfe;
border-radius: 4px;
}
.cal-add-input {
width: 100%;
padding: 2px 4px;
border: 1px solid #e5e7eb;
border-radius: 3px;
font-size: 10px;
outline: none;
box-sizing: border-box;
}
.cal-add-input:focus {
border-color: #60a5fa;
}
.cal-add-time {
margin-top: 2px;
}
.cal-add-actions {
display: flex;
gap: 3px;
margin-top: 2px;
}
.cal-add-submit {
padding: 1px 6px;
border: none;
border-radius: 3px;
font-size: 10px;
font-weight: 500;
background: #2563eb;
color: white;
cursor: pointer;
}
.cal-add-submit:hover {
background: #1d4ed8;
}
.cal-add-submit:disabled {
opacity: 0.5;
cursor: default;
}
.cal-add-cancel {
padding: 1px 6px;
border: none;
border-radius: 3px;
font-size: 10px;
background: transparent;
color: #6b7280;
cursor: pointer;
}
.cal-add-cancel:hover {
background: #f3f4f6;
}
/* ================================================================= */
/* Event list (below grid) */
/* ================================================================= */
.cal-list {
flex-shrink: 0;
border-top: 1px solid #e5e7eb;
max-height: 140px;
overflow-y: auto;
}
.cal-list-header {
padding: 4px 8px;
font-size: 10px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.03em;
color: #9ca3af;
position: sticky;
top: 0;
background: white;
}
.cal-list-item {
display: flex;
align-items: center;
gap: 6px;
padding: 4px 8px;
font-size: 12px;
cursor: grab;
transition: background 0.1s;
}
.cal-list-item:hover {
background: #f9fafb;
}
.cal-list-item:active {
cursor: grabbing;
}
.cal-list-date {
font-size: 10px;
color: #9ca3af;
flex-shrink: 0;
}
.cal-list-time {
font-size: 10px;
font-weight: 600;
color: #6b7280;
flex-shrink: 0;
}
.cal-list-title {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
color: #1f2937;
}
/* ================================================================= */
/* Responsive within bounded container */
/* ================================================================= */
/* Small panels: compact everything */
@container (max-width: 360px) {
.cal-nav {
padding: 4px 6px;
}
.cal-nav-title {
font-size: 11px;
}
.cal-nav-btn {
width: 24px;
height: 24px;
font-size: 10px;
}
.cal-day {
min-height: 40px;
padding: 1px;
}
.cal-day-num {
font-size: 9px;
}
.cal-day-header {
font-size: 8px;
padding: 2px 0;
}
.cal-event {
font-size: 8px;
padding: 0 2px;
}
.cal-add-form {
padding: 2px;
}
.cal-list {
max-height: 100px;
}
.cal-list-item {
font-size: 10px;
padding: 3px 6px;
}
}
/* Medium panels: slightly tighter */
@container (max-width: 500px) {
.cal-day {
min-height: 48px;
}
.cal-event {
font-size: 9px;
}
}
/* Mobile viewport fallback */
@media (max-width: 480px) {
.cal-day {
min-height: 40px;
padding: 1px;
}
.cal-day-num {
font-size: 9px;
}
.cal-day-header {
font-size: 8px;
}
.cal-event {
font-size: 8px;
}
.cal-list {
max-height: 100px;
}
}
</style>