Compare commits
2 commits
ea0671933d
...
2ba830c7b4
| Author | SHA1 | Date | |
|---|---|---|---|
| 2ba830c7b4 | |||
| c505c9ebfc |
4 changed files with 433 additions and 1 deletions
66
docs/features/dagbok.md
Normal file
66
docs/features/dagbok.md
Normal 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
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
335
frontend/src/routes/diary/+page.svelte
Normal file
335
frontend/src/routes/diary/+page.svelte
Normal 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">← 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>
|
||||
2
tasks.md
2
tasks.md
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue