diff --git a/README.md b/README.md index 882648c..77626b4 100644 --- a/README.md +++ b/README.md @@ -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 ` (`: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. diff --git a/web/src/App.svelte b/web/src/App.svelte index 7a54edb..3a09596 100644 --- a/web/src/App.svelte +++ b/web/src/App.svelte @@ -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 `. diff --git a/web/src/lib/Board.svelte b/web/src/lib/Board.svelte index 7198ef9..c5a2f31 100644 --- a/web/src/lib/Board.svelte +++ b/web/src/lib/Board.svelte @@ -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> diff --git a/web/src/lib/Help.svelte b/web/src/lib/Help.svelte index 91f1bd7..b268f52 100644 --- a/web/src/lib/Help.svelte +++ b/web/src/lib/Help.svelte @@ -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'], ] diff --git a/web/src/lib/Icon.svelte b/web/src/lib/Icon.svelte index 193c556..94217e1 100644 --- a/web/src/lib/Icon.svelte +++ b/web/src/lib/Icon.svelte @@ -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 diff --git a/web/src/lib/Rail.svelte b/web/src/lib/Rail.svelte index 5c0dcff..2eae66b 100644 --- a/web/src/lib/Rail.svelte +++ b/web/src/lib/Rail.svelte @@ -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], ] } diff --git a/web/src/lib/commands.ts b/web/src/lib/commands.ts index 4c9af27..4fe57ff 100644 --- a/web/src/lib/commands.ts +++ b/web/src/lib/commands.ts @@ -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') diff --git a/web/src/lib/store.svelte.ts b/web/src/lib/store.svelte.ts index ae7c009..04d67a7 100644 --- a/web/src/lib/store.svelte.ts +++ b/web/src/lib/store.svelte.ts @@ -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`) } diff --git a/web/src/lib/util.ts b/web/src/lib/util.ts index dc1d3aa..bc1d5d1 100644 --- a/web/src/lib/util.ts +++ b/web/src/lib/util.ts @@ -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