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

+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 space itself. The board has
`: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.
@@ -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
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
same). A new board starts with `backlog`, `doing` and `done`; `:list <name>`
adds columns, and `o` or the `+ card` button puts a card on the one under the
cursor.
same). A new board starts with `backlog`, `doing` and `done`, and the columns mean
something by where they stand rather than by their names: the first is the
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 |
| --- | --- |
@@ -104,14 +112,19 @@ cursor.
| `j` / `k` | down / up the cards of that list |
| `J` / `K` | move the card down / up its list |
| `H` / `L` | move the card to the list on the left / right |
| `Enter` | open the document the card links to |
| `t`, `Space` | tick the card off, or un-tick it |
| `o` | a new card on this list |
| `x` | delete the card |
| `Enter` | the card's notes: its document, made on first use; `q` comes back |
| `Space` | into the done column, or back out to the inbox |
| `o`, `c` | a new card on this list |
| `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 |
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
once its notes are gone.
+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