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>
This commit is contained in:
parent
c505c9ebfc
commit
2ba830c7b4
4 changed files with 433 additions and 2 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;
|
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);
|
let isCreatingBoard = $state(false);
|
||||||
|
|
||||||
/** Create a new kanban board */
|
/** Create a new kanban board */
|
||||||
|
|
@ -285,6 +310,12 @@
|
||||||
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
<h2 class="text-xl font-semibold text-gray-800">Mottak</h2>
|
||||||
{#if connected && accessToken}
|
{#if connected && accessToken}
|
||||||
<div class="flex gap-2">
|
<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
|
<a
|
||||||
href="/calendar"
|
href="/calendar"
|
||||||
class="rounded-lg bg-amber-600 px-3 py-1.5 text-sm font-medium text-white hover:bg-amber-700"
|
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>
|
||||||
3
tasks.md
3
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.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.
|
- [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.
|
||||||
> Påbegynt: 2026-03-17T22:28
|
|
||||||
- [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
- [ ] 9.4 Kunnskapsgraf: topic-noder, `mentions`-edges. Visuell graf-visning.
|
||||||
|
|
||||||
## Fase 10: AI og beriking
|
## Fase 10: AI og beriking
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue