Compare commits

...

2 commits

Author SHA1 Message Date
2ba830c7b4 Dagbok-visning: privat journal sortert på tid (oppgave 9.3)
Ny /diary-route som viser brukerens private noder — de som kun har
owner-edge og ingen delte edges til andre. Gruppert etter dato,
nyeste først, med inline oppretting av nye innlegg.

Dagbok-knapp med tellebadge lagt til i mottak-siden.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 22:34:13 +00:00
c505c9ebfc Starter oppgave 9.3 2026-03-17 22:28:49 +00:00
4 changed files with 433 additions and 1 deletions

66
docs/features/dagbok.md Normal file
View file

@ -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 = <brukerens node-ID>`
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: <bruker>, target_id: <ny_node>, 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

View file

@ -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 @@
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
{#if connected && accessToken}
<div class="flex gap-2">
<a
href="/diary"
class="rounded-lg bg-violet-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-violet-700"
>
Dagbok{#if diaryCount > 0} ({diaryCount}){/if}
</a>
<a
href="/calendar"
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"

View file

@ -0,0 +1,335 @@
<script lang="ts">
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<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');
// =========================================================================
// 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<string, { label: string; entries: Node[] }>();
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<string, string> = {
content: 'Innhold',
collection: 'Samling',
topic: 'Tema',
media: 'Media'
};
return labels[kind] ?? kind;
}
</script>
<div class="min-h-screen bg-gray-50">
<!-- Header -->
<header class="border-b border-gray-200 bg-white">
<div class="mx-auto flex max-w-3xl 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">Dagbok</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">{diaryEntries.length} innlegg</span>
</div>
</div>
</header>
<main class="mx-auto max-w-3xl px-4 py-6">
{#if !connected}
<p class="text-sm text-gray-400">Venter på tilkobling…</p>
{:else}
<!-- New entry button / form -->
{#if accessToken && nodeId}
{#if !showForm}
<button
onclick={() => showForm = true}
class="mb-6 w-full rounded-lg border-2 border-dashed border-gray-300 bg-white p-4 text-sm text-gray-500 hover:border-gray-400 hover:text-gray-700 transition-colors"
>
Nytt dagbokinnlegg…
</button>
{:else}
<div class="mb-6 rounded-lg border border-gray-200 bg-white p-4 shadow-sm" onkeydown={handleKeydown}>
<input
type="text"
bind:value={newTitle}
placeholder="Tittel…"
class="w-full rounded border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none"
disabled={isCreating}
/>
<textarea
bind:value={newContent}
placeholder="Skriv her…"
rows={4}
class="mt-2 w-full rounded border border-gray-200 px-3 py-2 text-sm focus:border-blue-400 focus:outline-none resize-y"
disabled={isCreating}
></textarea>
<div class="mt-3 flex gap-2">
<button
onclick={handleCreate}
disabled={isCreating || !newTitle.trim()}
class="rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50"
>
{isCreating ? 'Lagrer…' : 'Lagre'}
</button>
<button
onclick={() => { showForm = false; newTitle = ''; newContent = ''; }}
class="rounded-lg px-4 py-2 text-sm text-gray-500 hover:bg-gray-100"
>
Avbryt
</button>
</div>
</div>
{/if}
{/if}
<!-- Diary entries grouped by date -->
{#if diaryEntries.length === 0}
<div class="text-center py-12">
<p class="text-gray-400 text-sm">Ingen dagbokinnlegg ennå.</p>
<p class="text-gray-300 text-xs mt-1">Private noder uten delte edges vises her.</p>
</div>
{:else}
<div class="space-y-6">
{#each groupedEntries as group (group.dateKey)}
<div>
<h2 class="mb-2 text-sm font-semibold text-gray-500 uppercase tracking-wide">
{group.dateLabel}
</h2>
<ul class="space-y-2">
{#each group.entries as node (node.id)}
<li class="rounded-lg border border-gray-200 bg-white p-4 shadow-sm">
<div class="flex items-start justify-between gap-2">
<div class="min-w-0 flex-1">
<div class="flex items-center gap-2">
<span class="shrink-0 text-xs text-gray-400">{formatTime(node)}</span>
<h3 class="font-medium text-gray-900 truncate">
{node.title || 'Uten tittel'}
</h3>
</div>
{#if node.content}
<p class="mt-1 text-sm text-gray-500 whitespace-pre-line">
{excerpt(node.content)}
</p>
{/if}
</div>
{#if node.nodeKind !== 'content'}
<span class="shrink-0 rounded-full bg-gray-100 px-2 py-0.5 text-xs text-gray-500">
{kindLabel(node.nodeKind)}
</span>
{/if}
</div>
</li>
{/each}
</ul>
</div>
{/each}
</div>
{/if}
{/if}
</main>
</div>

View file

@ -111,7 +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.
- [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