board: one model — first list inbox, last list done

Space sends a card to the done list or back to the inbox instead of
ticking it in place. Enter on a card with no document starts one named
after it, and q comes back to the board on the same card. :card captures
into the inbox without leaving the current view (c anywhere), :due takes
fri, tm, +3d and the like, :name! renames a list, done cards older than a
week fold away behind z, and the board spells out its keys. The phone dock
gains send, done, due and notes doors.
This commit is contained in:
nvrl committed 2026-10-19 14:45:35 +02:00
1 parent d9b78170d8
commit e1c8e22ea5
9 files changed
+306 -63

No files matched your search

+14 -2
View File
@@ -237,12 +237,19 @@
void workspace.guard(() => workspace.openCard())
return true
case 'o':
case 'c':
cmdline = ':card '
return true
case 'R':
cmdline = ':name '
// On an empty list there is no card to rename, so it is the list.
cmdline = workspace.card ? ':name ' : ':name! '
return true
case 'D':
cmdline = ':due '
return true
case 'z':
workspace.showOldDone = !workspace.showOldDone
return true
case 't':
case ' ':
void workspace.guard(() => workspace.toggleDone())
return true
@@ -432,6 +439,11 @@
event.preventDefault()
void runCommand('new')
break
case 'c':
// Capture: a thought goes on this space's inbox without leaving here.
event.preventDefault()
cmdline = ':card '
break
case 'O':
// The name has to come from somewhere, and the command line is where
// names are typed — this only saves typing `:mkdir `.
+77 -7
View File
@@ -30,6 +30,19 @@
return card.due_at < start + 86400 ? 'today' : ''
}
const total = $derived(workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0)
const lastIndex = $derived((workspace.board?.lists.length ?? 0) - 1)
/** Done cards older than a week, kept off the board until `z` asks. */
const hidden = $derived(
(workspace.rawBoard?.lists.at(-1)?.cards.length ?? 0) - (workspace.board?.lists.at(-1)?.cards.length ?? 0),
)
/** What a column is for, by where it stands — the names are yours to choose. */
function role(index: number): 'inbox' | 'done' | '' {
if (lastIndex < 1) return ''
return index === 0 ? 'inbox' : index === lastIndex ? 'done' : ''
}
function pick(list: number, card: number) {
workspace.listCursor = list
workspace.cardCursor = card
@@ -41,17 +54,18 @@
<span class="marker"></span>
<span class="name">{workspace.space?.slug ?? ''}</span>
<span>board</span>
<span class="count">
{workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards
</span>
<span class="count">{total} {total === 1 ? 'card' : 'cards'}</span>
</div>
<div class="columns" bind:this={columns}>
{#each workspace.board?.lists ?? [] as list, listIndex (list.id)}
<section class="column" class:on={listIndex === workspace.listCursor}>
<header class="label">
{@const kind = role(listIndex)}
<section class="column" class:on={listIndex === workspace.listCursor} class:finished={kind === 'done'}>
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
<header class="label" onclick={() => pick(listIndex, 0)}>
<span class="marker"></span>
<span class="name">{list.name}</span>
{#if kind}<span class="role">{kind}</span>{/if}
<span class="n">{list.cards.length}</span>
</header>
@@ -65,6 +79,7 @@
data-at="{listIndex}:{cardIndex}"
onclick={() => pick(listIndex, cardIndex)}
ondblclick={() => workspace.guard(() => workspace.openCard())}
title={card.node_id === null ? 'Enter starts a document for it' : 'Enter opens its document'}
>
<span class="title">
<span class="tick" class:ticked={card.done_at !== null}></span>
@@ -76,15 +91,24 @@
<span class={urgency(card)}>due {formatDay(card.due_at)}</span>
{/if}
{#if card.node_id !== null}
<span class="doc" title="Enter opens it">→ {card.node_name ?? `#${card.node_id}`}</span>
<span class="doc">→ {card.node_name ?? `#${card.node_id}`}</span>
{/if}
</span>
{/if}
</button>
</li>
{:else}
<li class="empty muted">no cards — o adds one</li>
<li class="empty muted">
{#if kind === 'done'}nothing finished this week{:else if kind === 'inbox'}empty — c captures from anywhere{:else}no cards — o adds one{/if}
</li>
{/each}
{#if kind === 'done' && (hidden > 0 || workspace.showOldDone)}
<li>
<button class="older" onclick={() => (workspace.showOldDone = !workspace.showOldDone)}>
{workspace.showOldDone ? 'hide older' : `+${hidden} older`} <span class="kbd">z</span>
</button>
</li>
{/if}
</ul>
</section>
{:else}
@@ -93,6 +117,20 @@
</div>
{/each}
</div>
<!-- The board's keys are not the list's, so they are spelled out where they apply. -->
<footer class="label keys">
<span><span class="kbd">h</span><span class="kbd">j</span><span class="kbd">k</span><span class="kbd">l</span> move</span>
<span><span class="kbd">H</span><span class="kbd">L</span> send</span>
<span><span class="kbd">␣</span> done</span>
<span><span class="kbd">↵</span> notes</span>
<span><span class="kbd">o</span> new</span>
<span><span class="kbd">D</span> due</span>
<span><span class="kbd">R</span> rename</span>
<span><span class="kbd">dd</span> delete</span>
<span><span class="kbd">q</span> tree</span>
<span class="rule-hint">first list is the inbox · last list is done</span>
</footer>
</div>
<style>
@@ -147,6 +185,34 @@
.column.on header .marker { background: var(--light); }
.column.on header .name { color: var(--light); }
.n { font-variant-numeric: tabular-nums; }
.role { color: var(--muted); font-size: 10px; letter-spacing: 0.18em; }
.column header { cursor: default; }
.column.finished:not(.on) { opacity: 0.7; }
.older {
display: flex;
gap: 6px;
align-items: center;
width: 100%;
margin-top: 4px;
padding: 4px 10px;
border: 0;
font-size: 11px;
color: var(--muted);
}
.older:hover { color: var(--light); background: transparent; }
.keys {
flex: none;
display: flex;
flex-wrap: wrap;
gap: 6px 16px;
align-items: center;
padding: 8px 22px 12px;
font-size: 11px;
}
.keys > span { display: flex; gap: 4px; align-items: center; }
.keys .rule-hint { margin-left: auto; color: var(--muted); text-transform: none; letter-spacing: 0.04em; }
ul {
list-style: none;
@@ -203,4 +269,8 @@
@media (pointer: coarse) {
.card { padding: 10px; }
}
@media (max-width: 767px) {
.keys > span:not(.rule-hint) { display: none; }
.keys .rule-hint { margin-left: 0; }
}
</style>
+9 -5
View File
@@ -20,6 +20,7 @@
['R', 'rename what is under the cursor — document, folder or card'],
['m / p', 'pick a node up, then drop it into the folder you walk to'],
['b', "open this space's board"],
['c', "capture a card into this space's inbox, without leaving here"],
['Esc · q', 'close the document'],
['/', 'full-text search, inside the current space'],
['n', 'clear the search'],
@@ -34,15 +35,18 @@
/* The board rebinds most keys, so it gets its own column rather than
footnotes on the list keys. */
const boardKeys: [string, string][] = [
['rule', 'the first list is the inbox, the last list is done'],
['h / l', 'to the list left / right'],
['j / k', 'down / up the cards'],
['J / K', 'move the card down / up its list'],
['H / L', 'move the card to the list left / right'],
['Enter', 'open the document the card links to'],
['t · Space', 'tick the card off, or un-tick it'],
['o', 'new card on this list'],
['R', 'rename the card'],
['x', 'delete the card (asks first)'],
['Space', 'into the done list, or back out to the inbox'],
['Enter', "the card's notes — a document, made on first use; q comes back"],
['o · c', 'new card on this list'],
['D', 'due date: fri, tm, +3d, 10-31 …'],
['R', 'rename the card, or the list when it is empty'],
['z', 'show done cards older than a week'],
['x · dd', 'delete the card (asks y/N)'],
['q · Esc', 'back to the tree'],
]
+7
View File
@@ -24,6 +24,13 @@
'M12 17h.01',
],
back: ['M15 5l-7 7 7 7'],
left: ['M19 12H5', 'M11 6l-6 6 6 6'],
right: ['M5 12h14', 'M13 6l6 6-6 6'],
check: ['M5 12.5l4.5 4.5L19 7'],
calendar: ['M5 6h14a1 1 0 0 1 1 1v12a1 1 0 0 1-1 1H5a1 1 0 0 1-1-1V7a1 1 0 0 1 1-1z', 'M4 10h16', 'M8 4v4', 'M16 4v4'],
doc: ['M14 3H7a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h10a2 2 0 0 0 2-2V8z', 'M14 3v5h5', 'M9 13h6', 'M9 17h4'],
agenda: ['M12 3a9 9 0 1 0 0 18a9 9 0 1 0 0-18z', 'M12 7v5l3 2'],
capture: ['M12 5v14', 'M5 12h14'],
} satisfies Record<string, string[]>
export type IconName = keyof typeof PATHS
+10
View File
@@ -28,12 +28,22 @@
const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help'), desk: true }
if (workspace.view === 'board') {
// The card verbs are doors too, so a phone — which has no H, L or
// Space — can still move work along.
const card = !!workspace.card
return [
[
{ icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') },
{ icon: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list ') },
{ icon: 'list', label: 'Back to the tree', keys: ['b', 'q'], run: () => runCommand('board'), on: true },
],
[
{ icon: 'left', label: 'Send the card left', keys: ['H'], run: () => workspace.guard(() => workspace.sendCard(-1)), dim: !card },
{ icon: 'check', label: 'Done, or back to the inbox', keys: ['Space'], run: () => workspace.guard(() => workspace.toggleDone()), dim: !card },
{ icon: 'right', label: 'Send the card right', keys: ['L'], run: () => workspace.guard(() => workspace.sendCard(1)), dim: !card },
{ icon: 'calendar', label: 'Due date', keys: ['D'], run: () => hooks.openCommandLine(':due '), dim: !card },
{ icon: 'doc', label: "The card's notes", keys: ['Enter'], run: () => workspace.guard(() => workspace.openCard()), dim: !card },
],
[cmd, help],
]
}
+13 -12
View File
@@ -1,6 +1,6 @@
import { api, type BackupStatus } from './api'
import { workspace } from './store.svelte'
import { formatDay, parseDay, type ScrollAmount } from './util'
import { formatDay, parseDay, parseDue, type ScrollAmount } from './util'
export type { ScrollAmount }
@@ -127,6 +127,7 @@ export const commands: CommandSpec[] = [
return
}
if (workspace.editing) workspace.editing = false
else if (workspace.returnTo === 'board') return void workspace.guard(() => workspace.returnToBoard())
else {
workspace.open = null
workspace.pane = 'list'
@@ -224,11 +225,9 @@ export const commands: CommandSpec[] = [
aliases: ['c'],
args: '<title>',
group: 'board',
help: 'add a card to the list under the cursor',
help: "add a card — on the board to the list under the cursor, elsewhere to this space's inbox",
run: async (arg) => {
if (!arg.trim()) return workspace.say('usage: :card <title>', 'error')
if (workspace.view !== 'board') await workspace.guard(() => workspace.openBoard())
if (workspace.view !== 'board') return
await workspace.guard(() => workspace.createCard(arg.trim()))
},
bang: {
@@ -241,25 +240,23 @@ export const commands: CommandSpec[] = [
return workspace.say('no document to link — open one first', 'error')
}
const title = arg.trim() || node.name.trim() || node.slug
if (workspace.view !== 'board') await workspace.guard(() => workspace.openBoard())
if (workspace.view !== 'board') return
await workspace.guard(() => workspace.createCard(title, node.id))
},
},
},
{
name: 'due',
args: '<yyyy-mm-dd|->',
args: '<when|->',
group: 'board',
help: 'set the due date of the selected card, - clears it',
help: 'due date of the card: today, tm, fri, +3d, +2w, 10-31, 2026-10-31 — - clears',
run: async (arg) => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
const target = arg.trim()
if (target === '-' || target === '') {
return workspace.guard(() => workspace.patchCard({ due_at: null }))
}
const at = parseDay(target)
if (at === null) return workspace.say('usage: :due 2026-09-30', 'error')
const at = parseDue(target)
if (at === null) return workspace.say(`not a date: ${target} — try fri, +3d or 2026-09-30`, 'error')
await workspace.guard(() => workspace.patchCard({ due_at: at }))
workspace.say(`due ${formatDay(at)}`)
},
@@ -267,7 +264,7 @@ export const commands: CommandSpec[] = [
{
name: 'done',
group: 'board',
help: 'tick the selected card off, or un-tick it',
help: 'move the selected card into the done list, or back out to the inbox',
run: () => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
return workspace.guard(() => workspace.toggleDone())
@@ -474,8 +471,12 @@ export const commands: CommandSpec[] = [
await workspace.guard(() => workspace.renameNode(node.id, arg))
},
bang: {
help: 'rename the space you are in',
help: 'rename the space you are in — on the board, the list under the cursor',
run: async (arg) => {
if (workspace.view === 'board') {
if (!arg.trim()) return workspace.say('usage: :name! <list name>', 'error')
return workspace.guard(() => workspace.renameList(arg.trim()))
}
const space = workspace.space
if (!space) return workspace.say('no space open', 'error')
if (!arg.trim()) return workspace.say('usage: :name! <text>', 'error')
+123 -28
View File
@@ -48,9 +48,37 @@ class Workspace {
/** The board of the current space, loaded only while it is being looked at. */
view = $state<View>('tree')
board = $state<Board | null>(null)
/** The board as the server sent it. */
rawBoard = $state<Board | null>(null)
/*
* The board as it is walked and drawn: the done list keeps only what was
* finished this past week unless `z` asks for the rest, so the cursor
* indices and what is on screen are always the same thing.
*/
board = $derived.by((): Board | null => {
const raw = this.rawBoard
if (!raw || this.showOldDone) return raw
const since = Math.floor(Date.now() / 1000) - Workspace.RECENT_DONE_DAYS * 86400
const last = raw.lists.length - 1
return {
...raw,
lists: raw.lists.map((list, index) =>
index === last && index > 0
? { ...list, cards: list.cards.filter((card) => (card.done_at ?? 0) >= since) }
: list,
),
}
})
listCursor = $state(0)
cardCursor = $state(0)
/** `z` on the board: show done cards older than a week too. */
showOldDone = $state(false)
/*
* Set when a card's document was opened from the board, so closing the
* document goes back to the board — on the same card — rather than leaving
* you in a tree you never asked for.
*/
returnTo = $state<'board' | null>(null)
/*
* The node picked up by `m`, waiting for the `p` that drops it into whatever
@@ -155,6 +183,7 @@ class Workspace {
*/
static readonly AUTOSAVE_MS = 2500
static readonly RECENT_DONE_DAYS = 7
#stashKey(id: number) {
return `diary:draft:${id}`
@@ -311,6 +340,7 @@ class Workspace {
const from = this.spaces.findIndex((s) => s.id === this.spaceId)
const to = this.spaces.findIndex((s) => s.id === id)
this.switchDirection = to >= from ? 1 : -1
this.returnTo = null
this.open = null
this.editing = false
this.pane = 'list'
@@ -547,7 +577,7 @@ class Workspace {
const { removed } = await api.deleteNode(id)
this.open = null
this.editing = false
this.board = null
this.rawBoard = null
this.view = 'tree'
this.spaces = await api.listSpaces()
const next = this.spaces.find((space) => space.slug === 'diary') ?? this.spaces[0]
@@ -662,6 +692,7 @@ class Workspace {
closeBoard() {
this.view = 'tree'
this.returnTo = null
}
async toggleBoardView() {
@@ -671,7 +702,7 @@ class Workspace {
async reloadBoard(spaceId = this.spaceId, keep = this.card?.id) {
if (spaceId === null) return
this.board = await api.board(spaceId)
this.rawBoard = await api.board(spaceId)
// Follow the card that was just moved rather than whatever now sits under
// the cursor, so J J J walks one card down instead of shuffling three.
if (keep !== undefined) {
@@ -723,27 +754,52 @@ class Workspace {
if (!card || !list) return
const to = this.cardCursor + delta
if (to < 0 || to >= list.cards.length) return
await api.moveCard(card.id, list.id, to)
// The neighbour's own position, not its index: older done cards may be
// hidden above it.
await api.moveCard(card.id, list.id, list.cards[to].position)
await this.reloadBoard(this.spaceId, card.id)
}
/** `H`/`L`: the same card, one list over — the move a board is really for. */
async sendCard(delta: number) {
const card = this.card
const lists = this.board?.lists ?? []
const target = lists[this.listCursor + delta]
if (!card || !target) return
await api.moveCard(card.id, target.id, target.cards.length)
await this.reloadBoard(this.spaceId, card.id)
this.say(`"${card.title}" → ${target.name}`)
await this.sendCardTo(this.listCursor + delta)
}
async createCard(title: string, nodeId: number | null = null) {
const list = this.list
if (!list) return this.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId })
/*
* The last list is "done": the server stamps a card finished as it arrives
* there and clears the stamp as it leaves, so moving is all there is to it.
*/
async sendCardTo(index: number) {
const card = this.card
const lists = this.board?.lists ?? []
const target = lists[index]
if (!card || !target || index === this.listCursor) return
await api.moveCard(card.id, target.id, target.cards.length)
await this.reloadBoard(this.spaceId, card.id)
this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
this.say(index === lists.length - 1 ? `done: ${card.title}` : `"${card.title}" → ${target.name}`)
}
/**
* A card lands in the first list — the inbox — of this space's board, or of
* the first space that has one, without leaving the view you are in: writing
* a thought down must not cost the place you were at. On the board itself
* it goes on the list under the cursor instead.
*/
async createCard(title: string, nodeId: number | null = null) {
if (this.view === 'board') {
const list = this.list
if (!list) return this.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId })
await this.reloadBoard(this.spaceId, card.id)
return this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`)
}
const space = this.space?.has_board ? this.space : this.spaces.find((s) => s.has_board)
if (!space) return this.say('no space has a board — :set board turns one on', 'error')
const inbox = (await api.board(space.id)).lists[0]
if (!inbox) return this.say(`${space.slug} has no lists — :board, then :list <name>`, 'error')
const card = await api.createCard(inbox.id, { title, node_id: nodeId })
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
}
async patchCard(patch: Parameters<typeof api.updateCard>[1]) {
@@ -753,12 +809,19 @@ class Workspace {
await this.reloadBoard(this.spaceId, card.id)
}
/** Space: into the done list, or back out of it to the inbox. */
async toggleDone() {
const card = this.card
const lists = this.board?.lists ?? []
if (!card) return this.say('no card selected', 'error')
await api.updateCard(card.id, { done: card.done_at === null })
await this.reloadBoard(this.spaceId, card.id)
this.say(card.done_at === null ? `done: ${card.title}` : `reopened: ${card.title}`)
if (lists.length < 2) return this.say('a board needs two lists for one to mean done', 'error')
const last = lists.length - 1
if (this.listCursor === last) {
await this.sendCardTo(0)
this.say(`reopened: ${card.title}`)
} else {
await this.sendCardTo(last)
}
}
async deleteCard() {
@@ -771,29 +834,61 @@ class Workspace {
async createList(name: string) {
if (this.spaceId === null) return
this.board = await api.createList(this.spaceId, name)
this.listCursor = this.board.lists.length - 1
this.rawBoard = await api.createList(this.spaceId, name)
// It goes in front of the done list, so it is not simply the last one.
const names = this.rawBoard.lists.map((list) => list.name)
this.listCursor = Math.max(names.lastIndexOf(name), 0)
this.cardCursor = 0
this.say(`new list ${name}`)
}
async renameList(name: string) {
const list = this.list
if (!list) return this.say('no list selected', 'error')
this.rawBoard = await api.renameList(list.id, name)
this.clampBoard()
this.say(`list renamed to ${name}`)
}
async deleteList() {
const list = this.list
if (!list) return this.say('no list selected', 'error')
this.board = await api.deleteList(list.id)
this.rawBoard = await api.deleteList(list.id)
this.clampBoard()
this.say(`deleted list ${list.name}`)
}
/** Open the document a card points at, leaving the board behind. */
/**
* Open the document a card points at. A card without one gets one, named
* after it, in the space's root — so Enter always leads to a place to write
* the notes down. `q` on that document comes back here.
*/
async openCard() {
const card = this.card
if (!card) return
if (card.node_id === null) {
return this.say(`"${card.title}" is not linked to a document — :card! links one`, 'error')
const space = this.spaceId
if (!card || space === null) return
let nodeId = card.node_id
if (nodeId === null) {
const node = await api.createNode({ parent_id: space, name: card.title, body: '' })
await api.updateCard(card.id, { node_id: node.id })
nodeId = node.id
}
this.view = 'tree'
await this.openNode(card.node_id)
if (this.parentId !== space) await this.goTo(space)
else await this.refresh()
await this.openNode(nodeId, card.node_id === null)
if (card.node_id === null) this.enterInsert = true
this.returnTo = 'board'
}
/** Back to the board a card's document was opened from. */
async returnToBoard() {
this.returnTo = null
this.open = null
this.editing = false
this.pane = 'list'
await this.reloadBoard()
this.view = 'board'
}
/** Turn a space's board on or off. The diary ships with it off. */
@@ -805,7 +900,7 @@ class Workspace {
if (on) await this.openBoard()
else {
this.closeBoard()
this.board = null
this.rawBoard = null
}
this.say(on ? `${space.slug} has a board` : `${space.slug} has no board any more`)
}
+31
View File
@@ -52,6 +52,37 @@ export function parseDay(input: string): number | null {
return Number.isNaN(date.getTime()) ? null : Math.floor(date.getTime() / 1000)
}
const WEEKDAYS = ['sunday', 'monday', 'tuesday', 'wednesday', 'thursday', 'friday', 'saturday']
/**
* A due date the way it is said rather than written: `today`, `tm` or
* `tomorrow`, a weekday (`fri` is the next Friday, never today), `+3d`, `+2w`,
* `10-31` (this year, or next if that has passed) or a full `2026-10-31`.
* Noon, like `parseDay`, so no timezone shift can tip it into another day.
*/
export function parseDue(input: string, now = new Date()): number | null {
const text = input.trim().toLowerCase()
const day = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 12, 0, 0)
const at = (d: Date) => Math.floor(d.getTime() / 1000)
const shifted = (days: number) => at(new Date(day.getFullYear(), day.getMonth(), day.getDate() + days, 12))
if (text === 'today' || text === 'td') return at(day)
if (text === 'tomorrow' || text === 'tm') return shifted(1)
const weekday = text.length >= 2 ? WEEKDAYS.findIndex((name) => name.startsWith(text)) : -1
if (weekday >= 0) return shifted(((weekday - day.getDay() + 6) % 7) + 1)
const offset = /^\+(\d+)([dw])$/.exec(text)
if (offset) return shifted(Number(offset[1]) * (offset[2] === 'w' ? 7 : 1))
const monthDay = /^(\d{1,2})-(\d{1,2})$/.exec(text)
if (monthDay) {
const [m, d] = [Number(monthDay[1]) - 1, Number(monthDay[2])]
let date = new Date(day.getFullYear(), m, d, 12)
if (date.getMonth() !== m) return null
if (date < day) date = new Date(day.getFullYear() + 1, m, d, 12)
return at(date)
}
return parseDay(text)
}
/**
* True on phones and tablets, where modal editing would be a burden. Both
* checks are needed: a touchscreen laptop reports touch points but still has a