Skip to content

Commit 509c49e

Browse files
committed
feat: add Board Builder specification guide for Nostr events
1 parent fe28a84 commit 509c49e

File tree

2 files changed

+359
-3
lines changed

2 files changed

+359
-3
lines changed

docs/GUIDES/BOARDSBUILDER-SPEC.md

Lines changed: 352 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,352 @@
1+
# BOARDSBUILDER-SPEC
2+
3+
Kurzanleitung fuer Entwickler, die ein Kanban-Board aus Nostr-Events in einer eigenen Web-Anwendung darstellen wollen.
4+
5+
---
6+
7+
## Ziel
8+
9+
Ein Board vollstaendig rekonstruieren inklusive:
10+
- Board-Metadaten und Spalten
11+
- Karten
12+
- Kommentare
13+
- Fremdaenderungen (Column-Patches)
14+
- Loeschungen
15+
16+
---
17+
18+
## Event-Typen (Pflicht)
19+
20+
- `30301` Board-Definition (Owner Source of Truth)
21+
- `30302` Cards (addressable, board-gebunden ueber `#a`)
22+
- `8571` Column-Patches (Reorder, Column-Updates, `del`, `del-card`)
23+
- `5` NIP-09 Deletions (Board/Card)
24+
- `1` Kommentare (pro Card, ueber `#a`)
25+
26+
Ohne `8571` und `5` ist das Ergebnis in kollaborativen Boards unvollstaendig.
27+
28+
---
29+
30+
## Ladefilter
31+
32+
`<boardRef>` = `30301:<ownerPubkey>:<boardId>`
33+
34+
1. Board:
35+
```json
36+
{ "kinds": [30301], "authors": ["<ownerPubkey>"], "#d": ["<boardId>"] }
37+
```
38+
39+
2. Cards:
40+
```json
41+
{ "kinds": [30302], "#a": ["<boardRef>"] }
42+
```
43+
44+
3. Column-Patches (robust):
45+
```json
46+
{ "kinds": [8571], "#a": ["<boardRef>"] }
47+
{ "kinds": [8571], "#d": ["<boardId>"] }
48+
```
49+
50+
4. Deletions:
51+
```json
52+
{ "kinds": [5] }
53+
```
54+
55+
5. Kommentare (pro Karte):
56+
```json
57+
{ "kinds": [1], "#a": ["30302:<cardAuthor>:<cardId>"] }
58+
```
59+
60+
---
61+
62+
## Merge-Reihenfolge (wichtig)
63+
64+
1. `30301` anwenden (Spaltenbasis erzeugen)
65+
2. `30302` Cards einhaengen (`s` = columnId, `rank` merken)
66+
3. `8571` Patches anwenden:
67+
- `order` fuer Spaltenreihenfolge
68+
- `col` fuer Name/Farbe
69+
- `del` fuer Spalten-Loeschung
70+
- `del-card` fuer Karten-Loeschung
71+
4. `5` Deletions anwenden (autorisierte `a`-Tags)
72+
5. Kommentare (`1`) je Card mergen
73+
6. Final pro Spalte nach `rank` sortieren
74+
75+
Empfehlung fuer LWW:
76+
- primär `updated_at_ms` (Patch) bzw. `ts` (Card-Tag)
77+
- fallback `created_at`
78+
- bei Gleichstand deterministischer Tie-Breaker (`event.id`)
79+
80+
---
81+
82+
## Minimal-Algorithmus (Pseudo)
83+
84+
```ts
85+
board = load30301(ownerPubkey, boardId);
86+
cards = load30302(boardRef);
87+
patches = load8571(boardRef, boardId);
88+
deletions = loadKind5();
89+
90+
applyBoard(board);
91+
applyCards(cards);
92+
applyPatches(sortByLww(patches));
93+
applyDeletions(deletions);
94+
95+
for (const card of allCards(board)) {
96+
const comments = loadComments(card.author, card.id);
97+
mergeComments(card, comments);
98+
}
99+
100+
sortCardsPerColumnByRank(board);
101+
render(board);
102+
```
103+
104+
---
105+
106+
## Live-Updates (optional, empfohlen)
107+
108+
Abonniere fuer Echtzeit:
109+
- `30301` (Owner-Board-Updates)
110+
- `30302` (`#a=<boardRef>`)
111+
- `8571` (`#a=<boardRef>` und `#d=<boardId>`)
112+
- `5` (Deletions)
113+
- `1` je geoeffneter Card
114+
115+
---
116+
117+
## Referenz im Repo
118+
119+
- `src/lib/stores/boardstore/nostr.ts`
120+
- `src/lib/stores/boardstore/nostr/subscriptions.ts`
121+
- `src/lib/stores/boardstore/nostr/handlers/columnOrderPatch.ts`
122+
- `src/lib/stores/boardstore/nostr/handlers/card.ts`
123+
- `src/lib/utils/nostrEvents.ts`
124+
125+
---
126+
127+
## NDK Example: Komplettes Board zusammensetzen
128+
129+
```ts
130+
import type NDK from '@nostr-dev-kit/ndk';
131+
132+
type UiCard = {
133+
id: string;
134+
heading: string;
135+
content?: string;
136+
author?: string;
137+
rank?: number;
138+
eventId?: string;
139+
comments: Array<{ id: string; text: string; author: string; createdAt?: number }>;
140+
};
141+
142+
type UiColumn = {
143+
id: string;
144+
name: string;
145+
color?: string;
146+
cards: UiCard[];
147+
};
148+
149+
type UiBoard = {
150+
id: string;
151+
owner: string;
152+
name: string;
153+
description?: string;
154+
columns: UiColumn[];
155+
};
156+
157+
const KINDS = {
158+
BOARD: 30301,
159+
CARD: 30302,
160+
PATCH: 8571,
161+
COMMENT: 1,
162+
DELETE: 5,
163+
} as const;
164+
165+
const asMs = (value?: number): number => (typeof value === 'number' ? value * 1000 : 0);
166+
167+
export async function buildBoardFromNostr(
168+
ndk: NDK,
169+
ownerPubkey: string,
170+
boardId: string
171+
): Promise<UiBoard | null> {
172+
const boardRef = `30301:${ownerPubkey}:${boardId}`;
173+
174+
// 1) Basis-Events laden
175+
const [boardEvent, cardEvents, patchByA, patchByD, deleteEvents] = await Promise.all([
176+
ndk.fetchEvent({
177+
kinds: [KINDS.BOARD],
178+
authors: [ownerPubkey],
179+
'#d': [boardId],
180+
} as any),
181+
ndk.fetchEvents({
182+
kinds: [KINDS.CARD],
183+
'#a': [boardRef],
184+
} as any),
185+
ndk.fetchEvents({
186+
kinds: [KINDS.PATCH],
187+
'#a': [boardRef],
188+
} as any),
189+
ndk.fetchEvents({
190+
kinds: [KINDS.PATCH],
191+
'#d': [boardId],
192+
} as any),
193+
ndk.fetchEvents({
194+
kinds: [KINDS.DELETE],
195+
} as any),
196+
]);
197+
198+
if (!boardEvent) return null;
199+
200+
// 2) Board aus 30301 aufbauen
201+
const title = boardEvent.tags.find((t: string[]) => t[0] === 'title')?.[1] || 'Unnamed board';
202+
const description = boardEvent.tags.find((t: string[]) => t[0] === 'description')?.[1] || '';
203+
204+
const columns: UiColumn[] = boardEvent.tags
205+
.filter((t: string[]) => t[0] === 'col')
206+
.map((t: string[]) => ({
207+
id: t[1],
208+
name: t[2] || 'Column',
209+
color: t[4] || undefined,
210+
cards: [],
211+
}))
212+
.sort((a, b) => {
213+
const ta = boardEvent.tags.find((t: string[]) => t[0] === 'col' && t[1] === a.id);
214+
const tb = boardEvent.tags.find((t: string[]) => t[0] === 'col' && t[1] === b.id);
215+
return Number(ta?.[3] ?? 0) - Number(tb?.[3] ?? 0);
216+
});
217+
218+
const board: UiBoard = {
219+
id: boardId,
220+
owner: ownerPubkey,
221+
name: title,
222+
description,
223+
columns,
224+
};
225+
226+
const columnById = new Map(board.columns.map((c) => [c.id, c]));
227+
228+
// 3) Cards (30302) einhaengen
229+
for (const event of cardEvents) {
230+
const d = event.tags.find((t: string[]) => t[0] === 'd')?.[1];
231+
const s = event.tags.find((t: string[]) => t[0] === 's')?.[1]; // columnId
232+
const heading = event.tags.find((t: string[]) => t[0] === 'title')?.[1] || 'Untitled';
233+
const content = event.tags.find((t: string[]) => t[0] === 'description')?.[1] || event.content || '';
234+
const rankRaw = event.tags.find((t: string[]) => t[0] === 'rank')?.[1];
235+
const rank = rankRaw !== undefined ? Number(rankRaw) : undefined;
236+
237+
if (!d || !s) continue;
238+
const col = columnById.get(s);
239+
if (!col) continue;
240+
241+
col.cards.push({
242+
id: d,
243+
heading,
244+
content,
245+
author: event.pubkey,
246+
rank: Number.isFinite(rank) ? rank : undefined,
247+
eventId: event.id,
248+
comments: [],
249+
});
250+
}
251+
252+
// 4) Patches (8571) zusammenfuehren (OR aus #a + #d, dann dedupe per event.id)
253+
const patchMap = new Map<string, any>();
254+
for (const e of [...patchByA, ...patchByD]) {
255+
if (e?.id) patchMap.set(e.id, e);
256+
}
257+
258+
const patches = [...patchMap.values()].sort((a, b) => {
259+
const aMs = Number(a.tags.find((t: string[]) => t[0] === 'updated_at_ms')?.[1] || 0) || asMs(a.created_at);
260+
const bMs = Number(b.tags.find((t: string[]) => t[0] === 'updated_at_ms')?.[1] || 0) || asMs(b.created_at);
261+
return aMs - bMs;
262+
});
263+
264+
for (const patch of patches) {
265+
const tags: string[][] = patch.tags || [];
266+
267+
// 4.1 Column metadata updates
268+
for (const t of tags.filter((x) => x[0] === 'col')) {
269+
const [_, colId, name, color] = t;
270+
const col = columnById.get(colId);
271+
if (!col) continue;
272+
if (name) col.name = name;
273+
if (color) col.color = color;
274+
}
275+
276+
// 4.2 Deleted columns
277+
const deletedCols = new Set(tags.filter((x) => x[0] === 'del').map((x) => x[1]));
278+
if (deletedCols.size > 0) {
279+
board.columns = board.columns.filter((c) => !deletedCols.has(c.id));
280+
for (const id of deletedCols) columnById.delete(id);
281+
}
282+
283+
// 4.3 Deleted cards
284+
const deletedCards = new Set(tags.filter((x) => x[0] === 'del-card').map((x) => x[1]));
285+
if (deletedCards.size > 0) {
286+
for (const col of board.columns) {
287+
col.cards = col.cards.filter((card) => !deletedCards.has(card.id));
288+
}
289+
}
290+
291+
// 4.4 Column order
292+
const orderTag = tags.find((x) => x[0] === 'order');
293+
if (orderTag && orderTag.length > 1) {
294+
const wanted = orderTag.slice(1);
295+
const byId = new Map(board.columns.map((c) => [c.id, c]));
296+
const ordered = wanted.map((id) => byId.get(id)).filter(Boolean) as UiColumn[];
297+
const rest = board.columns.filter((c) => !wanted.includes(c.id));
298+
board.columns = [...ordered, ...rest];
299+
}
300+
}
301+
302+
// 5) Deletions (Kind 5) anwenden (Board/Card)
303+
for (const event of deleteEvents) {
304+
const aTags = (event.tags || []).filter((t: string[]) => t[0] === 'a').map((t: string[]) => t[1]);
305+
for (const ref of aTags) {
306+
if (!ref) continue;
307+
if (ref.startsWith(`30301:${ownerPubkey}:${boardId}`)) {
308+
return null; // Board geloescht
309+
}
310+
if (ref.startsWith('30302:')) {
311+
const cardId = ref.split(':').slice(2).join(':');
312+
for (const col of board.columns) {
313+
col.cards = col.cards.filter((c) => c.id !== cardId);
314+
}
315+
}
316+
}
317+
}
318+
319+
// 6) Kommentare pro Card laden
320+
for (const col of board.columns) {
321+
for (const card of col.cards) {
322+
const cardRef = `30302:${card.author || ownerPubkey}:${card.id}`;
323+
const comments = await ndk.fetchEvents({
324+
kinds: [KINDS.COMMENT],
325+
'#a': [cardRef],
326+
} as any);
327+
328+
card.comments = [...comments]
329+
.map((e) => ({
330+
id: e.id || `${card.id}-${e.created_at || 0}`,
331+
text: e.content || '',
332+
author: e.pubkey,
333+
createdAt: e.created_at,
334+
}))
335+
.sort((a, b) => (a.createdAt || 0) - (b.createdAt || 0));
336+
}
337+
}
338+
339+
// 7) Final: Rank-Sort innerhalb jeder Spalte
340+
for (const col of board.columns) {
341+
col.cards.sort((a, b) => {
342+
const ar = a.rank ?? Number.MAX_SAFE_INTEGER;
343+
const br = b.rank ?? Number.MAX_SAFE_INTEGER;
344+
if (ar !== br) return ar - br;
345+
// deterministic fallback
346+
return (a.eventId || '').localeCompare(b.eventId || '');
347+
});
348+
}
349+
350+
return board;
351+
}
352+
```

0 commit comments

Comments
 (0)