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
+302 -59

No files matched your search

+22 -9
View File
@@ -84,7 +84,7 @@ selected, and `:import` uploads a folder into the one being browsed
the cursor — a document, a folder, or a card on a board — and `:name!` renames the cursor — a document, a folder, or a card on a board — and `:name!` renames
the space itself. The board has the space itself. The board has
`:board`, `:card <title>` (`:card!` links the open document to it), `:board`, `:card <title>` (`:card!` links the open document to it),
`:due <yyyy-mm-dd|->`, `:done` and `:list <name>` (`:list!` deletes one). `:due <when|->`, `:done` and `:list <name>` (`:list!` deletes one).
On a touch device modal editing is turned off and the same actions are buttons. On a touch device modal editing is turned off and the same actions are buttons.
@@ -94,9 +94,17 @@ On a touch device modal editing is turned off and the same actions are buttons.
of the space being browsed, and swaps it back. Every space has one except the of the space being browsed, and swaps it back. Every space has one except the
diary — a journal has no backlog — and `:set board` / `:set noboard` changes diary — a journal has no backlog — and `:set board` / `:set noboard` changes
that for the space you are in (the `+board` button above the list does the that for the space you are in (the `+board` button above the list does the
same). A new board starts with `backlog`, `doing` and `done`; `:list <name>` same). A new board starts with `backlog`, `doing` and `done`, and the columns mean
adds columns, and `o` or the `+ card` button puts a card on the one under the something by where they stand rather than by their names: the first is the
cursor. inbox new cards land in, the last is done. A card is finished exactly when it
sits in the last column — moving it there stamps it, moving it out reopens it —
so a tick and a column can never disagree. `:list <name>` adds a column in front
of the last one, `:name!` on the board renames the column under the cursor, and
done cards older than a week fold away until `z` shows them.
`c` captures from anywhere: `:card <title>` outside the board puts the card in
this space's inbox (or the first space that has a board) and leaves you where
you were. On the board, `o` or `c` puts a card on the column under the cursor.
| key | action | | key | action |
| --- | --- | | --- | --- |
@@ -104,14 +112,19 @@ cursor.
| `j` / `k` | down / up the cards of that list | | `j` / `k` | down / up the cards of that list |
| `J` / `K` | move the card down / up its list | | `J` / `K` | move the card down / up its list |
| `H` / `L` | move the card to the list on the left / right | | `H` / `L` | move the card to the list on the left / right |
| `Enter` | open the document the card links to | | `Enter` | the card's notes: its document, made on first use; `q` comes back |
| `t`, `Space` | tick the card off, or un-tick it | | `Space` | into the done column, or back out to the inbox |
| `o` | a new card on this list | | `o`, `c` | a new card on this list |
| `x` | delete the card | | `D` | due date — `fri`, `tm`, `+3d`, `+2w`, `10-31`, `2026-10-31` |
| `R` | rename the card, or the column when it has none |
| `z` | show done cards older than a week |
| `x`, `dd` | delete the card |
| `q`, `Esc` | back to the tree | | `q`, `Esc` | back to the tree |
A card can point at a document: `:card!` while reading one makes a card for it, A card can point at a document: `:card!` while reading one makes a card for it,
and `Enter` on that card opens it again. The link is deliberately loose — and `Enter` on that card opens it again. `Enter` on a card without one makes a
document named after the card in the space's root and opens it to write in, and
closing that document returns to the board on the same card. The link is deliberately loose —
deleting the document leaves the card standing, because the work is still open deleting the document leaves the card standing, because the work is still open
once its notes are gone. once its notes are gone.
+14 -2
View File
@@ -237,12 +237,19 @@
void workspace.guard(() => workspace.openCard()) void workspace.guard(() => workspace.openCard())
return true return true
case 'o': case 'o':
case 'c':
cmdline = ':card ' cmdline = ':card '
return true return true
case 'R': 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 return true
case 't':
case ' ': case ' ':
void workspace.guard(() => workspace.toggleDone()) void workspace.guard(() => workspace.toggleDone())
return true return true
@@ -432,6 +439,11 @@
event.preventDefault() event.preventDefault()
void runCommand('new') void runCommand('new')
break break
case 'c':
// Capture: a thought goes on this space's inbox without leaving here.
event.preventDefault()
cmdline = ':card '
break
case 'O': case 'O':
// The name has to come from somewhere, and the command line is where // The name has to come from somewhere, and the command line is where
// names are typed — this only saves typing `:mkdir `. // names are typed — this only saves typing `:mkdir `.
+77 -7
View File
@@ -30,6 +30,19 @@
return card.due_at < start + 86400 ? 'today' : '' 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) { function pick(list: number, card: number) {
workspace.listCursor = list workspace.listCursor = list
workspace.cardCursor = card workspace.cardCursor = card
@@ -41,17 +54,18 @@
<span class="marker"></span> <span class="marker"></span>
<span class="name">{workspace.space?.slug ?? ''}</span> <span class="name">{workspace.space?.slug ?? ''}</span>
<span>board</span> <span>board</span>
<span class="count"> <span class="count">{total} {total === 1 ? 'card' : 'cards'}</span>
{workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards
</span>
</div> </div>
<div class="columns" bind:this={columns}> <div class="columns" bind:this={columns}>
{#each workspace.board?.lists ?? [] as list, listIndex (list.id)} {#each workspace.board?.lists ?? [] as list, listIndex (list.id)}
<section class="column" class:on={listIndex === workspace.listCursor}> {@const kind = role(listIndex)}
<header class="label"> <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="marker"></span>
<span class="name">{list.name}</span> <span class="name">{list.name}</span>
{#if kind}<span class="role">{kind}</span>{/if}
<span class="n">{list.cards.length}</span> <span class="n">{list.cards.length}</span>
</header> </header>
@@ -65,6 +79,7 @@
data-at="{listIndex}:{cardIndex}" data-at="{listIndex}:{cardIndex}"
onclick={() => pick(listIndex, cardIndex)} onclick={() => pick(listIndex, cardIndex)}
ondblclick={() => workspace.guard(() => workspace.openCard())} 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="title">
<span class="tick" class:ticked={card.done_at !== null}></span> <span class="tick" class:ticked={card.done_at !== null}></span>
@@ -76,15 +91,24 @@
<span class={urgency(card)}>due {formatDay(card.due_at)}</span> <span class={urgency(card)}>due {formatDay(card.due_at)}</span>
{/if} {/if}
{#if card.node_id !== null} {#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} {/if}
</span> </span>
{/if} {/if}
</button> </button>
</li> </li>
{:else} {: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} {/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> </ul>
</section> </section>
{:else} {:else}
@@ -93,6 +117,20 @@
</div> </div>
{/each} {/each}
</div> </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> </div>
<style> <style>
@@ -147,6 +185,34 @@
.column.on header .marker { background: var(--light); } .column.on header .marker { background: var(--light); }
.column.on header .name { color: var(--light); } .column.on header .name { color: var(--light); }
.n { font-variant-numeric: tabular-nums; } .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 { ul {
list-style: none; list-style: none;
@@ -203,4 +269,8 @@
@media (pointer: coarse) { @media (pointer: coarse) {
.card { padding: 10px; } .card { padding: 10px; }
} }
@media (max-width: 767px) {
.keys > span:not(.rule-hint) { display: none; }
.keys .rule-hint { margin-left: 0; }
}
</style> </style>
+9 -5
View File
@@ -20,6 +20,7 @@
['R', 'rename what is under the cursor — document, folder or card'], ['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'], ['m / p', 'pick a node up, then drop it into the folder you walk to'],
['b', "open this space's board"], ['b', "open this space's board"],
['c', "capture a card into this space's inbox, without leaving here"],
['Esc · q', 'close the document'], ['Esc · q', 'close the document'],
['/', 'full-text search, inside the current space'], ['/', 'full-text search, inside the current space'],
['n', 'clear the search'], ['n', 'clear the search'],
@@ -34,15 +35,18 @@
/* The board rebinds most keys, so it gets its own column rather than /* The board rebinds most keys, so it gets its own column rather than
footnotes on the list keys. */ footnotes on the list keys. */
const boardKeys: [string, string][] = [ const boardKeys: [string, string][] = [
['rule', 'the first list is the inbox, the last list is done'],
['h / l', 'to the list left / right'], ['h / l', 'to the list left / right'],
['j / k', 'down / up the cards'], ['j / k', 'down / up the cards'],
['J / K', 'move the card down / up its list'], ['J / K', 'move the card down / up its list'],
['H / L', 'move the card to the list left / right'], ['H / L', 'move the card to the list left / right'],
['Enter', 'open the document the card links to'], ['Space', 'into the done list, or back out to the inbox'],
['t · Space', 'tick the card off, or un-tick it'], ['Enter', "the card's notes — a document, made on first use; q comes back"],
['o', 'new card on this list'], ['o · c', 'new card on this list'],
['R', 'rename the card'], ['D', 'due date: fri, tm, +3d, 10-31 …'],
['x', 'delete the card (asks first)'], ['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'], ['q · Esc', 'back to the tree'],
] ]
+7
View File
@@ -24,6 +24,13 @@
'M12 17h.01', 'M12 17h.01',
], ],
back: ['M15 5l-7 7 7 7'], 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[]> } satisfies Record<string, string[]>
export type IconName = keyof typeof PATHS 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 } const help: Door = { icon: 'help', label: 'Help', keys: ['?'], run: () => runCommand('help'), desk: true }
if (workspace.view === 'board') { 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 [ return [
[ [
{ icon: 'doc-plus', label: 'New card on this list', keys: ['o'], run: () => hooks.openCommandLine(':card ') }, { 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: '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: '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], [cmd, help],
] ]
} }
+13 -12
View File
@@ -1,6 +1,6 @@
import { api, type BackupStatus } from './api' import { api, type BackupStatus } from './api'
import { workspace } from './store.svelte' import { workspace } from './store.svelte'
import { formatDay, parseDay, type ScrollAmount } from './util' import { formatDay, parseDay, parseDue, type ScrollAmount } from './util'
export type { ScrollAmount } export type { ScrollAmount }
@@ -127,6 +127,7 @@ export const commands: CommandSpec[] = [
return return
} }
if (workspace.editing) workspace.editing = false if (workspace.editing) workspace.editing = false
else if (workspace.returnTo === 'board') return void workspace.guard(() => workspace.returnToBoard())
else { else {
workspace.open = null workspace.open = null
workspace.pane = 'list' workspace.pane = 'list'
@@ -224,11 +225,9 @@ export const commands: CommandSpec[] = [
aliases: ['c'], aliases: ['c'],
args: '<title>', args: '<title>',
group: 'board', 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) => { run: async (arg) => {
if (!arg.trim()) return workspace.say('usage: :card <title>', 'error') 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())) await workspace.guard(() => workspace.createCard(arg.trim()))
}, },
bang: { bang: {
@@ -241,25 +240,23 @@ export const commands: CommandSpec[] = [
return workspace.say('no document to link — open one first', 'error') return workspace.say('no document to link — open one first', 'error')
} }
const title = arg.trim() || node.name.trim() || node.slug 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)) await workspace.guard(() => workspace.createCard(title, node.id))
}, },
}, },
}, },
{ {
name: 'due', name: 'due',
args: '<yyyy-mm-dd|->', args: '<when|->',
group: 'board', 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) => { run: async (arg) => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error') if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
const target = arg.trim() const target = arg.trim()
if (target === '-' || target === '') { if (target === '-' || target === '') {
return workspace.guard(() => workspace.patchCard({ due_at: null })) return workspace.guard(() => workspace.patchCard({ due_at: null }))
} }
const at = parseDay(target) const at = parseDue(target)
if (at === null) return workspace.say('usage: :due 2026-09-30', 'error') 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 })) await workspace.guard(() => workspace.patchCard({ due_at: at }))
workspace.say(`due ${formatDay(at)}`) workspace.say(`due ${formatDay(at)}`)
}, },
@@ -267,7 +264,7 @@ export const commands: CommandSpec[] = [
{ {
name: 'done', name: 'done',
group: 'board', 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: () => { run: () => {
if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error') if (workspace.view !== 'board') return workspace.say('no board open — :board', 'error')
return workspace.guard(() => workspace.toggleDone()) return workspace.guard(() => workspace.toggleDone())
@@ -474,8 +471,12 @@ export const commands: CommandSpec[] = [
await workspace.guard(() => workspace.renameNode(node.id, arg)) await workspace.guard(() => workspace.renameNode(node.id, arg))
}, },
bang: { 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) => { 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 const space = workspace.space
if (!space) return workspace.say('no space open', 'error') if (!space) return workspace.say('no space open', 'error')
if (!arg.trim()) return workspace.say('usage: :name! <text>', 'error') if (!arg.trim()) return workspace.say('usage: :name! <text>', 'error')
+119 -24
View File
@@ -48,9 +48,37 @@ class Workspace {
/** The board of the current space, loaded only while it is being looked at. */ /** The board of the current space, loaded only while it is being looked at. */
view = $state<View>('tree') 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) listCursor = $state(0)
cardCursor = $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 * 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 AUTOSAVE_MS = 2500
static readonly RECENT_DONE_DAYS = 7
#stashKey(id: number) { #stashKey(id: number) {
return `diary:draft:${id}` return `diary:draft:${id}`
@@ -311,6 +340,7 @@ class Workspace {
const from = this.spaces.findIndex((s) => s.id === this.spaceId) const from = this.spaces.findIndex((s) => s.id === this.spaceId)
const to = this.spaces.findIndex((s) => s.id === id) const to = this.spaces.findIndex((s) => s.id === id)
this.switchDirection = to >= from ? 1 : -1 this.switchDirection = to >= from ? 1 : -1
this.returnTo = null
this.open = null this.open = null
this.editing = false this.editing = false
this.pane = 'list' this.pane = 'list'
@@ -547,7 +577,7 @@ class Workspace {
const { removed } = await api.deleteNode(id) const { removed } = await api.deleteNode(id)
this.open = null this.open = null
this.editing = false this.editing = false
this.board = null this.rawBoard = null
this.view = 'tree' this.view = 'tree'
this.spaces = await api.listSpaces() this.spaces = await api.listSpaces()
const next = this.spaces.find((space) => space.slug === 'diary') ?? this.spaces[0] const next = this.spaces.find((space) => space.slug === 'diary') ?? this.spaces[0]
@@ -662,6 +692,7 @@ class Workspace {
closeBoard() { closeBoard() {
this.view = 'tree' this.view = 'tree'
this.returnTo = null
} }
async toggleBoardView() { async toggleBoardView() {
@@ -671,7 +702,7 @@ class Workspace {
async reloadBoard(spaceId = this.spaceId, keep = this.card?.id) { async reloadBoard(spaceId = this.spaceId, keep = this.card?.id) {
if (spaceId === null) return 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 // 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. // the cursor, so J J J walks one card down instead of shuffling three.
if (keep !== undefined) { if (keep !== undefined) {
@@ -723,27 +754,52 @@ class Workspace {
if (!card || !list) return if (!card || !list) return
const to = this.cardCursor + delta const to = this.cardCursor + delta
if (to < 0 || to >= list.cards.length) return 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) await this.reloadBoard(this.spaceId, card.id)
} }
/** `H`/`L`: the same card, one list over — the move a board is really for. */ /** `H`/`L`: the same card, one list over — the move a board is really for. */
async sendCard(delta: number) { async sendCard(delta: number) {
const card = this.card await this.sendCardTo(this.listCursor + delta)
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}`)
} }
/*
* 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(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) { async createCard(title: string, nodeId: number | null = null) {
if (this.view === 'board') {
const list = this.list const list = this.list
if (!list) return this.say('no list to put it on', 'error') if (!list) return this.say('no list to put it on', 'error')
const card = await api.createCard(list.id, { title, node_id: nodeId }) const card = await api.createCard(list.id, { title, node_id: nodeId })
await this.reloadBoard(this.spaceId, card.id) await this.reloadBoard(this.spaceId, card.id)
this.say(nodeId === null ? `carded "${card.title}"` : `carded "${card.title}" → #${nodeId}`) 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]) { async patchCard(patch: Parameters<typeof api.updateCard>[1]) {
@@ -753,12 +809,19 @@ class Workspace {
await this.reloadBoard(this.spaceId, card.id) await this.reloadBoard(this.spaceId, card.id)
} }
/** Space: into the done list, or back out of it to the inbox. */
async toggleDone() { async toggleDone() {
const card = this.card const card = this.card
const lists = this.board?.lists ?? []
if (!card) return this.say('no card selected', 'error') if (!card) return this.say('no card selected', 'error')
await api.updateCard(card.id, { done: card.done_at === null }) if (lists.length < 2) return this.say('a board needs two lists for one to mean done', 'error')
await this.reloadBoard(this.spaceId, card.id) const last = lists.length - 1
this.say(card.done_at === null ? `done: ${card.title}` : `reopened: ${card.title}`) if (this.listCursor === last) {
await this.sendCardTo(0)
this.say(`reopened: ${card.title}`)
} else {
await this.sendCardTo(last)
}
} }
async deleteCard() { async deleteCard() {
@@ -771,29 +834,61 @@ class Workspace {
async createList(name: string) { async createList(name: string) {
if (this.spaceId === null) return if (this.spaceId === null) return
this.board = await api.createList(this.spaceId, name) this.rawBoard = await api.createList(this.spaceId, name)
this.listCursor = this.board.lists.length - 1 // 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.cardCursor = 0
this.say(`new list ${name}`) 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() { async deleteList() {
const list = this.list const list = this.list
if (!list) return this.say('no list selected', 'error') 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.clampBoard()
this.say(`deleted list ${list.name}`) 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() { async openCard() {
const card = this.card const card = this.card
if (!card) return const space = this.spaceId
if (card.node_id === null) { if (!card || space === null) return
return this.say(`"${card.title}" is not linked to a document — :card! links one`, 'error') 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' 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. */ /** 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() if (on) await this.openBoard()
else { else {
this.closeBoard() this.closeBoard()
this.board = null this.rawBoard = null
} }
this.say(on ? `${space.slug} has a board` : `${space.slug} has no board any more`) 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) 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 * 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 * checks are needed: a touchscreen laptop reports touch points but still has a