mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-03 16:41:27 +01:00
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:
1 parent
d9b78170d8
commit
e1c8e22ea5
9 files changed
+306
-63
No files matched your search
@@ -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
@@ -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 `.
|
||||
|
||||
@@ -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>
|
||||
@@ -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'],
|
||||
]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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`)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user