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 `, `:done` and `:list ` (`:list!` deletes one).
+`:due `, `:done` and `:list ` (`: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 `
-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 ` 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 ` 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 @@
{workspace.space?.slug ?? ''}board
-
- {workspace.board?.lists.reduce((n, list) => n + list.cards.length, 0) ?? 0} cards
-
+ {total} {total === 1 ? 'card' : 'cards'}
{/each}
+
+
+
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
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: '',
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 ', '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: '',
+ args: '',
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! ', '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! ', '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('tree')
- board = $state(null)
+ /** The board as the server sent it. */
+ rawBoard = $state(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 `, '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[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