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>
This commit is contained in:
parent
895f517d9d
commit
ea0671933d
4 changed files with 514 additions and 11 deletions
|
|
@ -5,17 +5,24 @@
|
|||
Månedsbasert kalendervisning for redaksjonell planlegging. Hendelser er nodes i kunnskapsgrafen og kan kobles til episoder, temaer, aktører og kanban-kort. Komplementerer Kanban ("hva" vs "når").
|
||||
|
||||
## 2. Status
|
||||
**PG-adapter ferdig og deployet (mars 2025).** Abonnement, ICS-eksport og SpacetimeDB-sync gjenstår.
|
||||
**Kalendervisning implementert (mars 2026).** Bruker `scheduled`-edges i stedet for
|
||||
separat `calendar_events`-tabell. Abonnement, ICS-eksport og SpacetimeDB-sync gjenstår.
|
||||
|
||||
### Implementert
|
||||
- Migrering `0003_calendar.sql`: `calendars` + `calendar_events` (begge FK→nodes)
|
||||
- Hendelser er nodes — tilgangsstyrt via `node_access`-matrise
|
||||
- Heldagshendelser (`T12:00:00` for tidssone-trygghet) vs. tidshendelser med klokkeslett
|
||||
- Fargekoder per hendelse (7 forhåndsdefinerte) + standard kalenderfarge
|
||||
- REST API: GET med tidsvindu-filtrering, POST/PATCH/DELETE hendelser
|
||||
- PG polling-adapter med 5 sek intervall
|
||||
- CalendarBlock.svelte: månedsrutenett, navigering, opprett/rediger-modal, Escape-lukking
|
||||
- `linked_node`-kolonne for fremtidig kobling til kanban-kort, episoder etc.
|
||||
- **Fase 1 (v1, mars 2025):** PG-adapter med `calendars` + `calendar_events` (legacy)
|
||||
- **Fase 2 (v2, mars 2026):** Edge-basert kalender med `scheduled`-edges
|
||||
- Rute: `/calendar` — månedsbasert rutenett
|
||||
- Hendelser er noder med `scheduled`-edge (`metadata.at` = ISO 8601 tidspunkt)
|
||||
- Heldagshendelser bruker `T12:00:00`-konvensjon (tidssone-trygg)
|
||||
- Tidsbaserte hendelser viser klokkeslett i rutenett
|
||||
- Drag-and-drop for å flytte hendelser mellom datoer
|
||||
- Inline-oppretting: klikk + på en dato, angi tittel og valgfritt klokkeslett
|
||||
- Fargekoding basert på `node_kind` (innhold, kommunikasjon, media, samling)
|
||||
- Månedsnavigering med «I dag»-knapp
|
||||
- Hendelsesliste under rutenett for gjeldende måned
|
||||
- Lenke fra mottak-siden med hendelsesteller
|
||||
- Tilgang via `nodeVisibility` (respekterer `node_access`-matrise)
|
||||
- Sanntidsoppdatering via SpacetimeDB-subscriptions
|
||||
|
||||
### Gjenstår — Fase 2
|
||||
- Kobling til kanban-kort (vis deadline på kalender)
|
||||
|
|
|
|||
|
|
@ -129,6 +129,12 @@
|
|||
} catch { return false; }
|
||||
}
|
||||
|
||||
/** Count scheduled events for badge display */
|
||||
const scheduledCount = $derived.by(() => {
|
||||
if (!connected) return 0;
|
||||
return edgeStore.byType('scheduled').length;
|
||||
});
|
||||
|
||||
let isCreatingBoard = $state(false);
|
||||
|
||||
/** Create a new kanban board */
|
||||
|
|
@ -279,6 +285,12 @@
|
|||
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
||||
{#if connected && accessToken}
|
||||
<div class="flex gap-2">
|
||||
<a
|
||||
href="/calendar"
|
||||
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
|
||||
>
|
||||
Kalender{#if scheduledCount > 0} ({scheduledCount}){/if}
|
||||
</a>
|
||||
<button
|
||||
onclick={handleNewBoard}
|
||||
disabled={isCreatingBoard}
|
||||
|
|
|
|||
485
frontend/src/routes/calendar/+page.svelte
Normal file
485
frontend/src/routes/calendar/+page.svelte
Normal file
|
|
@ -0,0 +1,485 @@
|
|||
<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>
|
||||
3
tasks.md
3
tasks.md
|
|
@ -110,8 +110,7 @@ Uavhengige faser kan fortsatt plukkes.
|
|||
## Fase 9: Flere visninger
|
||||
|
||||
- [x] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring.
|
||||
- [~] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
|
||||
> Påbegynt: 2026-03-17T22:20
|
||||
- [x] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje.
|
||||
- [ ] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid.
|
||||
- [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue