diff --git a/docs/features/dagbok.md b/docs/features/dagbok.md new file mode 100644 index 0000000..2ee0ad1 --- /dev/null +++ b/docs/features/dagbok.md @@ -0,0 +1,66 @@ +# Feature: Dagbok (Privat journal) +**Filsti:** `docs/features/dagbok.md` + +## 1. Konsept +En personlig dagbok-visning som samler brukerens private noder — innhold +som ikke er delt med andre via edges. Fungerer som en kronologisk logg +over tanker, notater og idéer som kun er synlige for eieren. + +## 2. Status +**Implementert med nodes+edges (mars 2026).** Sanntid via SpacetimeDB. + +### Implementert +- Frontend: `/diary` route med dagbok-visning +- Filtrering: viser kun noder som er opprettet av brukeren og ikke har + delte edges (ingen non-system edges til andre brukere/noder) +- Ekskluderte node-typer: `communication`, `agent`, `person`, `team` +- Gruppering etter dato med norske datoetiketter ("I dag", "I går", ellers fullt format) +- Kronologisk sortering (nyeste først) +- Tidsstempel per innlegg +- Inline oppretting av nye dagbokinnlegg (tittel + innhold) +- Nye innlegg får `visibility: 'hidden'` og `owner`-edge fra bruker +- Dagbok-lenke med tellebadge i mottak-siden +- Responsivt design (max-w-3xl, mobilklar) + +### Gjenstår +- Redigeringsmodus for eksisterende innlegg +- Rik tekst-editor (gjenbruk NodeEditor-komponenten) +- Sletting av innlegg +- Søk/filtrering i dagboken +- Eksport-funksjon + +## 3. Datamodell + +Dagboken bruker ingen egne tabeller eller edge-typer. Den er en +**visning** (query) over eksisterende noder og edges. + +### Hva er et dagbokinnlegg? +En node som oppfyller alle tre kriterier: +1. `created_by = ` +2. `node_kind` er ikke `communication`, `agent`, `person` eller `team` +3. Ingen non-system edges der den andre enden er en annen bruker/node + (kun `owner`-edge fra bruker, system-edges, og selv-refererende edges er tillatt) + +### Oppretting +``` +POST /intentions/create_node + { node_kind: "content", title: "...", content: "...", visibility: "hidden" } +POST /intentions/create_edge + { source_id: , target_id: , edge_type: "owner" } +``` + +## 4. Frontend + +### Route +`/diary` → `frontend/src/routes/diary/+page.svelte` + +### Datakilde +SpacetimeDB sanntidsabonnement via `nodeStore` og `edgeStore`. +Ingen backend-query — all filtrering skjer i frontend basert på +SpacetimeDB-data som allerede er lastet. + +### UI-struktur +- Header med tilbake-lenke til mottak og innlegg-teller +- Ny-innlegg-knapp (utvides til skjema med tittel + tekstfelt) +- Innlegg gruppert per dato, sortert nyeste først +- Hvert innlegg viser tidsstempel, tittel, og innholdsutdrag diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 80b38e0..5ce2249 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -135,6 +135,31 @@ return edgeStore.byType('scheduled').length; }); + /** Count private diary entries for badge display */ + const diaryCount = $derived.by(() => { + if (!connected || !nodeId) return 0; + let count = 0; + for (const node of nodeStore.all) { + if (node.createdBy !== nodeId) continue; + if (node.nodeKind === 'communication' || node.nodeKind === 'agent' || + node.nodeKind === 'person' || node.nodeKind === 'team') continue; + // Check for shared edges (same logic as diary page) + let shared = false; + for (const edge of edgeStore.bySource(node.id)) { + if (edge.system || edge.targetId === node.id || edge.targetId === nodeId) continue; + shared = true; break; + } + if (!shared) { + for (const edge of edgeStore.byTarget(node.id)) { + if (edge.system || edge.sourceId === node.id || edge.sourceId === nodeId) continue; + shared = true; break; + } + } + if (!shared) count++; + } + return count; + }); + let isCreatingBoard = $state(false); /** Create a new kanban board */ @@ -285,6 +310,12 @@

Mottak

{#if connected && accessToken}
+ + Dagbok{#if diaryCount > 0} ({diaryCount}){/if} + + import { page } from '$app/stores'; + import { connectionState, nodeStore, edgeStore, nodeVisibility } from '$lib/spacetime'; + import type { Node } from '$lib/spacetime'; + import { createNode, createEdge } from '$lib/api'; + + const session = $derived($page.data.session as Record | undefined); + const nodeId = $derived(session?.nodeId as string | undefined); + const accessToken = $derived(session?.accessToken as string | undefined); + const connected = $derived(connectionState.current === 'connected'); + + // ========================================================================= + // Diary entries: private nodes with no shared edges, sorted by time + // ========================================================================= + + /** + * A diary entry is a node that: + * 1. Was created by the current user + * 2. Has no non-system edges connecting it to other users/nodes + * (only owner edge from user → node is allowed) + * 3. Is not a special kind (communication, agent) + */ + const diaryEntries = $derived.by((): Node[] => { + if (!connected || !nodeId) return []; + + const entries: Node[] = []; + + for (const node of nodeStore.all) { + // Must be created by current user + if (node.createdBy !== nodeId) continue; + + // Skip special node kinds that aren't diary material + if (node.nodeKind === 'communication' || node.nodeKind === 'agent' || + node.nodeKind === 'person' || node.nodeKind === 'team') continue; + + // Check if this node is "private" — no shared edges + // Allowed edges: owner from user, system edges, self-referential edges + if (hasSharedEdges(node.id)) continue; + + entries.push(node); + } + + // Sort by created_at descending (newest first) + entries.sort((a, b) => { + const ta = a.createdAt?.microsSinceUnixEpoch ?? 0n; + const tb = b.createdAt?.microsSinceUnixEpoch ?? 0n; + return tb > ta ? 1 : tb < ta ? -1 : 0; + }); + + return entries; + }); + + /** + * Check if a node has edges that "share" it with other users/nodes. + * Returns true if the node has non-private edges. + * + * Private means: only edges where the other end is the current user, + * the node itself, or system edges. + */ + function hasSharedEdges(targetNodeId: string): boolean { + if (!nodeId) return false; + + // Check edges FROM this node + for (const edge of edgeStore.bySource(targetNodeId)) { + if (edge.system) continue; + // Self-referential edge (e.g. scheduled edge pointing to itself) + if (edge.targetId === targetNodeId) continue; + // Edge to the owner (user) + if (edge.targetId === nodeId) continue; + // Any other edge means it's shared + return true; + } + + // Check edges TO this node + for (const edge of edgeStore.byTarget(targetNodeId)) { + if (edge.system) continue; + // Self-referential + if (edge.sourceId === targetNodeId) continue; + // Edge from the owner (user), e.g. owner edge + if (edge.sourceId === nodeId) continue; + // Any other edge means it's shared + return true; + } + + return false; + } + + // ========================================================================= + // Date grouping + // ========================================================================= + + interface DiaryGroup { + dateLabel: string; + dateKey: string; + entries: Node[]; + } + + const monthNames = [ + 'januar', 'februar', 'mars', 'april', 'mai', 'juni', + 'juli', 'august', 'september', 'oktober', 'november', 'desember' + ]; + const dayNames = ['søndag', 'mandag', 'tirsdag', 'onsdag', 'torsdag', 'fredag', 'lørdag']; + + function nodeDate(node: Node): Date { + const micros = node.createdAt?.microsSinceUnixEpoch ?? 0n; + return new Date(Number(micros / 1000n)); + } + + function formatDateKey(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`; + } + + function formatDateLabel(d: Date): string { + const today = new Date(); + const todayKey = formatDateKey(today); + const dateKey = formatDateKey(d); + + if (dateKey === todayKey) return 'I dag'; + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + if (dateKey === formatDateKey(yesterday)) return 'I går'; + + return `${dayNames[d.getDay()]} ${d.getDate()}. ${monthNames[d.getMonth()]} ${d.getFullYear()}`; + } + + /** Group diary entries by date */ + const groupedEntries = $derived.by((): DiaryGroup[] => { + const groups = new Map(); + + for (const node of diaryEntries) { + const d = nodeDate(node); + const key = formatDateKey(d); + const existing = groups.get(key); + if (existing) { + existing.entries.push(node); + } else { + groups.set(key, { label: formatDateLabel(d), entries: [node] }); + } + } + + return [...groups.entries()].map(([dateKey, { label, entries }]) => ({ + dateLabel: label, + dateKey, + entries + })); + }); + + // ========================================================================= + // Create new diary entry + // ========================================================================= + + let newTitle = $state(''); + let newContent = $state(''); + let isCreating = $state(false); + let showForm = $state(false); + + async function handleCreate() { + if (!accessToken || !nodeId || !newTitle.trim() || isCreating) return; + + isCreating = true; + try { + const { node_id } = await createNode(accessToken, { + node_kind: 'content', + title: newTitle.trim(), + content: newContent.trim() || undefined, + visibility: 'hidden' + }); + + // Create owner edge + await createEdge(accessToken, { + source_id: nodeId, + target_id: node_id, + edge_type: 'owner' + }); + + newTitle = ''; + newContent = ''; + showForm = false; + } catch (err) { + console.error('Feil ved oppretting av dagbokinnlegg:', err); + } finally { + isCreating = false; + } + } + + function handleKeydown(e: KeyboardEvent) { + if (e.key === 'Escape') { + showForm = false; + newTitle = ''; + newContent = ''; + } + } + + // ========================================================================= + // Time formatting + // ========================================================================= + + function formatTime(node: Node): string { + const d = nodeDate(node); + return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`; + } + + /** Truncate content to a short excerpt */ + function excerpt(content: string, maxLen = 200): string { + if (!content) return ''; + if (content.length <= maxLen) return content; + return content.slice(0, maxLen).trimEnd() + '…'; + } + + /** Kind label in Norwegian */ + function kindLabel(kind: string): string { + const labels: Record = { + content: 'Innhold', + collection: 'Samling', + topic: 'Tema', + media: 'Media' + }; + return labels[kind] ?? kind; + } + + +
+ +
+
+
+ ← Mottak +

Dagbok

+
+
+ {#if connected} + Tilkoblet + {:else} + {connectionState.current} + {/if} + {diaryEntries.length} innlegg +
+
+
+ +
+ {#if !connected} +

Venter på tilkobling…

+ {:else} + + {#if accessToken && nodeId} + {#if !showForm} + + {:else} +
+ + +
+ + +
+
+ {/if} + {/if} + + + {#if diaryEntries.length === 0} +
+

Ingen dagbokinnlegg ennå.

+

Private noder uten delte edges vises her.

+
+ {:else} +
+ {#each groupedEntries as group (group.dateKey)} +
+

+ {group.dateLabel} +

+
    + {#each group.entries as node (node.id)} +
  • +
    +
    +
    + {formatTime(node)} +

    + {node.title || 'Uten tittel'} +

    +
    + {#if node.content} +

    + {excerpt(node.content)} +

    + {/if} +
    + {#if node.nodeKind !== 'content'} + + {kindLabel(node.nodeKind)} + + {/if} +
    +
  • + {/each} +
+
+ {/each} +
+ {/if} + {/if} +
+
diff --git a/tasks.md b/tasks.md index 8641ee9..2645811 100644 --- a/tasks.md +++ b/tasks.md @@ -111,8 +111,7 @@ Uavhengige faser kan fortsatt plukkes. - [x] 9.1 Kanban-visning: noder med board-edge, gruppert på status-edge. Drag-and-drop for statusendring. - [x] 9.2 Kalender-visning: noder med `scheduled`-edge, på tidslinje. -- [~] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid. - > Påbegynt: 2026-03-17T22:28 +- [x] 9.3 Dagbok-visning: private noder (ingen delte edges), sortert på tid. - [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning. ## Fase 10: AI og beriking