mirror of
https://github.com/narrrl/narl-workspace.git
synced 2026-11-04 00:47:01 +01:00
agenda, capture and finder: what needs doing comes to you
GET /api/agenda gathers unfinished cards from every board that are late, due within a week, or in hand, plus whether the diary has today; the reminder mail and the agenda share one diary check. t opens it (and it opens itself once a day when something is late or due), T writes today's entry, the bar counts late and due cards, Ctrl-P searches every space, and a document o made that is left empty is removed on the way out.
This commit is contained in:
1 parent
e1c8e22ea5
commit
9255d36b61
14 files changed
+686
-40
No files matched your search
+93
-4
@@ -1,6 +1,7 @@
|
||||
<script lang="ts">
|
||||
import { hooks, runCommand } from './lib/commands'
|
||||
import { embedSnippet } from './lib/markdown'
|
||||
import Agenda from './lib/Agenda.svelte'
|
||||
import Bar from './lib/Bar.svelte'
|
||||
import Board from './lib/Board.svelte'
|
||||
import CommandLine from './lib/CommandLine.svelte'
|
||||
@@ -26,6 +27,7 @@
|
||||
const shareKey = shareToken ? decodeURIComponent(location.hash.slice(1)) : ''
|
||||
|
||||
let cmdline = $state<string | null>(null)
|
||||
let cmdGlobal = $state(false)
|
||||
let fileInput = $state<HTMLInputElement>()
|
||||
let folderInput = $state<HTMLInputElement>()
|
||||
let importInput = $state<HTMLInputElement>()
|
||||
@@ -44,7 +46,10 @@
|
||||
hooks.pickFolder = () => folderInput?.click()
|
||||
hooks.pickArchive = () => importInput?.click()
|
||||
hooks.focusList = () => shell?.focus()
|
||||
hooks.openCommandLine = (initial) => (cmdline = initial)
|
||||
hooks.openCommandLine = (initial) => {
|
||||
cmdGlobal = false
|
||||
cmdline = initial
|
||||
}
|
||||
hooks.openOverview = () => void openOverview()
|
||||
})
|
||||
|
||||
@@ -76,6 +81,24 @@
|
||||
return () => window.removeEventListener('keydown', answer, true)
|
||||
})
|
||||
|
||||
/*
|
||||
* The agenda is what the bar counts from, so it is kept fresh without being
|
||||
* asked: on coming back to the tab, and every few minutes while it is open
|
||||
* — which is also when a new day can make it open itself.
|
||||
*/
|
||||
$effect(() => {
|
||||
if (!workspace.user) return
|
||||
const refresh = () => {
|
||||
if (document.visibilityState === 'visible') void workspace.loadAgenda().then(() => workspace.openAgendaIfDue())
|
||||
}
|
||||
const timer = setInterval(refresh, 5 * 60 * 1000)
|
||||
document.addEventListener('visibilitychange', refresh)
|
||||
return () => {
|
||||
clearInterval(timer)
|
||||
document.removeEventListener('visibilitychange', refresh)
|
||||
}
|
||||
})
|
||||
|
||||
// Guard against losing a half-written entry to a stray refresh.
|
||||
$effect(() => {
|
||||
if (!workspace.dirty) return
|
||||
@@ -117,6 +140,9 @@
|
||||
[['1…9'], 'switch space, Tab cycles'],
|
||||
[['w'], 'overview of every space'],
|
||||
[['b'], "this space's board"],
|
||||
[['t'], 'agenda — late, due, in hand'],
|
||||
[['c'], 'capture a card to the inbox'],
|
||||
[['⌃ P'], 'find in every space'],
|
||||
[['/'], 'search this space'],
|
||||
[['?'], 'help'],
|
||||
]
|
||||
@@ -186,6 +212,38 @@
|
||||
return false
|
||||
}
|
||||
|
||||
/** The agenda's keys: j/k down the list, Enter to the card, Space finishes it. */
|
||||
function agendaKey(event: KeyboardEvent): boolean {
|
||||
const cards = workspace.agendaCards
|
||||
const card = cards[workspace.agendaCursor]
|
||||
switch (event.key) {
|
||||
case 'j':
|
||||
case 'ArrowDown':
|
||||
workspace.agendaCursor = Math.min(workspace.agendaCursor + 1, Math.max(cards.length - 1, 0))
|
||||
return true
|
||||
case 'k':
|
||||
case 'ArrowUp':
|
||||
workspace.agendaCursor = Math.max(workspace.agendaCursor - 1, 0)
|
||||
return true
|
||||
case 'Enter':
|
||||
if (card) void workspace.guard(() => workspace.goToCard(card))
|
||||
return true
|
||||
case ' ':
|
||||
if (card) void workspace.guard(() => workspace.finishCard(card))
|
||||
return true
|
||||
case 'T':
|
||||
workspace.overlay = 'none'
|
||||
void runCommand('today')
|
||||
return true
|
||||
case 't':
|
||||
case 'q':
|
||||
workspace.overlay = 'none'
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/** Alt+h / Alt+l: focus moves between the panes, out of the editor too. */
|
||||
function focusPane(pane: 'list' | 'document') {
|
||||
if (pane === 'document' && !workspace.open) return
|
||||
@@ -298,6 +356,12 @@
|
||||
return
|
||||
}
|
||||
}
|
||||
if (workspace.overlay === 'agenda' && !event.ctrlKey && !event.metaKey && !event.altKey) {
|
||||
if (agendaKey(event)) {
|
||||
event.preventDefault()
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === 'Escape') {
|
||||
if (workspace.overlay !== 'none') {
|
||||
@@ -325,6 +389,15 @@
|
||||
|
||||
if (cmdline !== null || isTypingTarget(event.target)) return
|
||||
|
||||
// Ctrl-P: find anything, in any space — the one search that is not
|
||||
// confined to where you are.
|
||||
if (event.ctrlKey && !event.metaKey && !event.altKey && event.key === 'p') {
|
||||
event.preventDefault()
|
||||
cmdGlobal = true
|
||||
cmdline = '/'
|
||||
return
|
||||
}
|
||||
|
||||
// Ctrl-D / Ctrl-U page the open document, as they do in a pager.
|
||||
if (event.ctrlKey && !event.metaKey && !event.altKey && (event.key === 'd' || event.key === 'u')) {
|
||||
if (workspace.open && !workspace.editing) {
|
||||
@@ -349,6 +422,18 @@
|
||||
workspace.fullscreen = !workspace.fullscreen
|
||||
return
|
||||
}
|
||||
// The agenda and today's entry are a key away from anywhere, the board
|
||||
// included: they are the two questions asked most.
|
||||
if (key === 't') {
|
||||
event.preventDefault()
|
||||
void runCommand('agenda')
|
||||
return
|
||||
}
|
||||
if (key === 'T') {
|
||||
event.preventDefault()
|
||||
void runCommand('today')
|
||||
return
|
||||
}
|
||||
|
||||
if (workspace.view === 'board') {
|
||||
if (previous === 'g' && key === 'g') {
|
||||
@@ -371,7 +456,10 @@
|
||||
}
|
||||
if (key === ':' || key === '?') {
|
||||
event.preventDefault()
|
||||
if (key === ':') cmdline = ':'
|
||||
if (key === ':') {
|
||||
cmdGlobal = false
|
||||
cmdline = ':'
|
||||
}
|
||||
else void runCommand('help')
|
||||
}
|
||||
return
|
||||
@@ -497,6 +585,7 @@
|
||||
break
|
||||
case '/':
|
||||
event.preventDefault()
|
||||
cmdGlobal = false
|
||||
cmdline = '/'
|
||||
break
|
||||
case ':':
|
||||
@@ -581,7 +670,7 @@
|
||||
<StatusBar />
|
||||
|
||||
{#if cmdline !== null}
|
||||
<CommandLine initial={cmdline} close={() => (cmdline = null)} />
|
||||
<CommandLine initial={cmdline} global={cmdGlobal} close={() => ((cmdline = null), (cmdGlobal = false))} />
|
||||
{/if}
|
||||
|
||||
<Prompt />
|
||||
@@ -590,7 +679,7 @@
|
||||
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions, a11y_no_noninteractive_tabindex -->
|
||||
<div class="overlay" tabindex="-1" bind:this={overlayEl} onclick={(e) => e.target === e.currentTarget && (workspace.overlay = 'none')}
|
||||
>
|
||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else}<MediaBrowser />{/if}
|
||||
{#if workspace.overlay === 'help'}<Help />{:else if workspace.overlay === 'overview'}<Overview />{:else if workspace.overlay === 'agenda'}<Agenda />{:else}<MediaBrowser />{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
<script lang="ts">
|
||||
/*
|
||||
* `t`: what needs you, from every board at once — what is late, what is due
|
||||
* today and this week, what is already in hand — and whether the diary has
|
||||
* had today yet. The keys live in App.svelte with the others; this draws
|
||||
* and lets a pointer do the same.
|
||||
*/
|
||||
import type { AgendaCard } from './api'
|
||||
import { workspace } from './store.svelte'
|
||||
import { formatDay, formatWeekday } from './util'
|
||||
|
||||
let sheet = $state<HTMLDivElement>()
|
||||
|
||||
const titles = { overdue: 'late', today: 'today', week: 'this week', doing: 'in hand' } as const
|
||||
|
||||
const groups = $derived(workspace.agendaGroups.filter((group) => group.cards.length > 0))
|
||||
const total = $derived(workspace.agendaCards.length)
|
||||
|
||||
$effect(() => {
|
||||
const index = workspace.agendaCursor
|
||||
sheet?.querySelector<HTMLElement>(`[data-index="${index}"]`)?.scrollIntoView({ block: 'nearest' })
|
||||
})
|
||||
|
||||
/** Where a card sits in the walked order, across the groups. */
|
||||
function indexOf(card: AgendaCard) {
|
||||
return workspace.agendaCards.findIndex((c) => c.id === card.id)
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="sheet" bind:this={sheet}>
|
||||
<header>
|
||||
<span class="label rule"><span class="marker"></span><span class="title">agenda</span> {total} open</span>
|
||||
<button onclick={() => (workspace.overlay = 'none')}>esc</button>
|
||||
</header>
|
||||
|
||||
{#if workspace.agenda.diary_today === false}
|
||||
<p class="diary">
|
||||
<span class="dot"></span> nothing in the diary today
|
||||
<span class="kbd">T</span> writes it
|
||||
</p>
|
||||
{/if}
|
||||
|
||||
{#each groups as group (group.key)}
|
||||
<section class={group.key}>
|
||||
<h2>{titles[group.key]} <span class="n">{group.cards.length}</span></h2>
|
||||
<ul>
|
||||
{#each group.cards as card (card.id)}
|
||||
{@const index = indexOf(card)}
|
||||
<li>
|
||||
<button
|
||||
class="row"
|
||||
class:at={index === workspace.agendaCursor}
|
||||
data-index={index}
|
||||
onclick={() => workspace.guard(() => workspace.goToCard(card))}
|
||||
onpointerenter={() => (workspace.agendaCursor = index)}
|
||||
>
|
||||
<span class="text">{card.title}</span>
|
||||
<span class="where">{card.space_slug}/{card.list_name}</span>
|
||||
{#if card.due_at !== null}
|
||||
<span class="due">{formatWeekday(card.due_at)} {formatDay(card.due_at).slice(5)}</span>
|
||||
{/if}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
</section>
|
||||
{:else}
|
||||
<p class="clear">nothing late, nothing due this week, nothing in hand. <span class="kbd">c</span> captures the next thing.</p>
|
||||
{/each}
|
||||
|
||||
<footer class="label">
|
||||
<span class="kbd">j</span><span class="kbd">k</span> move
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">↵</span> to its board
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">␣</span> done
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">T</span> diary
|
||||
<span class="gap"></span>
|
||||
<span class="kbd">t</span><span class="kbd">esc</span> close
|
||||
</footer>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.sheet {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 16px;
|
||||
width: min(100%, 84ch);
|
||||
max-height: 100%;
|
||||
overflow-y: auto;
|
||||
background: color-mix(in oklab, var(--hull) 97%, transparent);
|
||||
border: 1px solid var(--surface-alt);
|
||||
border-radius: var(--frame-radius);
|
||||
padding: 20px 24px;
|
||||
}
|
||||
|
||||
header { display: flex; align-items: center; gap: 12px; }
|
||||
header .label { flex: 1; }
|
||||
header .marker { background: var(--light); }
|
||||
header .title { color: var(--light); }
|
||||
|
||||
.diary {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--rim);
|
||||
border-radius: var(--radius-m);
|
||||
font-size: 13px;
|
||||
color: var(--fg);
|
||||
}
|
||||
.diary .dot { width: 7px; height: 7px; border-radius: 999px; background: var(--light); }
|
||||
|
||||
section { min-width: 0; }
|
||||
h2 {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
margin: 0 0 6px;
|
||||
font-size: 10px;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.22em;
|
||||
text-transform: uppercase;
|
||||
color: var(--muted);
|
||||
}
|
||||
h2::after { content: ''; height: 1px; flex: 1; background: var(--overlay); }
|
||||
.overdue h2 { color: var(--alarm); }
|
||||
.today h2 { color: var(--light); }
|
||||
.n { font-variant-numeric: tabular-nums; letter-spacing: 0; }
|
||||
|
||||
ul { list-style: none; margin: 0; padding: 0; }
|
||||
|
||||
.row {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 12px;
|
||||
width: 100%;
|
||||
margin: 1px 0;
|
||||
padding: 6px 10px;
|
||||
border: 0;
|
||||
border-radius: var(--radius-s);
|
||||
text-align: left;
|
||||
font-size: 13px;
|
||||
letter-spacing: 0;
|
||||
text-transform: none;
|
||||
color: var(--fg);
|
||||
}
|
||||
.row:hover { background: var(--surface-alt); border-color: transparent; }
|
||||
.row:active { transform: none; }
|
||||
.row.at { background: var(--light); color: var(--ink); }
|
||||
.row.at :is(.where, .due) { color: var(--ink); }
|
||||
.text {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
}
|
||||
.where, .due { flex: none; font-size: 11px; color: var(--muted); font-variant-numeric: tabular-nums; }
|
||||
.overdue .due { color: var(--alarm); }
|
||||
.today .due { color: var(--light); }
|
||||
|
||||
.clear { margin: 0; font-size: 13px; color: var(--subtle); }
|
||||
|
||||
footer { display: flex; align-items: center; gap: 4px; font-size: 10px; }
|
||||
footer .kbd { margin-right: 2px; }
|
||||
footer .gap { width: 14px; }
|
||||
|
||||
@media (max-width: 767px), (pointer: coarse) {
|
||||
footer { display: none; }
|
||||
}
|
||||
@media (max-width: 767px) {
|
||||
.sheet { padding: 14px; gap: 14px; }
|
||||
.where { display: none; }
|
||||
}
|
||||
</style>
|
||||
+30
-5
@@ -6,11 +6,12 @@
|
||||
* that is genuinely faster to walk with a pointer than with `:cd`.
|
||||
*/
|
||||
import Clock from './Clock.svelte'
|
||||
import { hooks } from './commands'
|
||||
import { hooks, runCommand } from './commands'
|
||||
import { workspace } from './store.svelte'
|
||||
|
||||
// The space is the first crumb, and it is already named by its pill.
|
||||
const crumbs = $derived(workspace.path.slice(1))
|
||||
const due = $derived(workspace.dueCounts)
|
||||
|
||||
</script>
|
||||
|
||||
@@ -62,10 +63,19 @@
|
||||
|
||||
<span class="spacer"></span>
|
||||
|
||||
<span class="readout user">
|
||||
<span class="key">user</span>
|
||||
<span class="value">{workspace.user}</span>
|
||||
</span>
|
||||
<!-- What is late or due today, always in sight: the point is not having to
|
||||
remember to look. Quiet when there is nothing. -->
|
||||
{#if due.late + due.today > 0}
|
||||
<button class="readout due" title="agenda (t)" onclick={() => runCommand('agenda')}>
|
||||
{#if due.late > 0}<span class="key">late</span><span class="value late">{due.late}</span>{/if}
|
||||
{#if due.today > 0}<span class="key">today</span><span class="value now">{due.today}</span>{/if}
|
||||
</button>
|
||||
{:else}
|
||||
<span class="readout user">
|
||||
<span class="key">user</span>
|
||||
<span class="value">{workspace.user}</span>
|
||||
</span>
|
||||
{/if}
|
||||
<span class="divider wide"></span>
|
||||
<Clock />
|
||||
</header>
|
||||
@@ -83,6 +93,20 @@
|
||||
.crumbs { flex: 0 1 auto; }
|
||||
|
||||
.n { color: var(--muted); font-size: 10px; }
|
||||
|
||||
.due {
|
||||
align-items: center;
|
||||
padding: 2px 8px;
|
||||
border: 1px solid transparent;
|
||||
background: transparent;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.18em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.due:hover { border-color: var(--rim); background: transparent; }
|
||||
.due .late { color: var(--alarm); }
|
||||
.due .now { color: var(--light); }
|
||||
.due .value + .key { margin-left: 6px; }
|
||||
.pill:hover .n { color: var(--light-dim); }
|
||||
.pill[aria-current='true'] .n { color: var(--ink); opacity: 0.55; }
|
||||
.pill.add { color: var(--muted); }
|
||||
@@ -124,6 +148,7 @@
|
||||
*/
|
||||
@media (max-width: 767px) {
|
||||
.crumbs, .divider.path, .bar .user, .divider.wide { display: none; }
|
||||
.due { margin-left: auto; padding: 2px 4px; }
|
||||
.spaces { max-width: none; flex: 0 1 auto; gap: 2px; }
|
||||
.pill:not([aria-current='true']) .slug { display: none; }
|
||||
.pill:not([aria-current='true']) { min-width: 28px; height: 24px; }
|
||||
|
||||
@@ -5,10 +5,12 @@
|
||||
|
||||
interface Props {
|
||||
initial: string
|
||||
/** A `/` search across every space rather than this one — Ctrl-P. */
|
||||
global?: boolean
|
||||
close: () => void
|
||||
}
|
||||
|
||||
const { initial, close }: Props = $props()
|
||||
const { initial, global = false, close }: Props = $props()
|
||||
|
||||
let value = $state(untrack(() => initial))
|
||||
let input = $state<HTMLInputElement>()
|
||||
@@ -23,7 +25,7 @@
|
||||
$effect(() => {
|
||||
if (!isSearch) return
|
||||
const query = value.slice(1)
|
||||
const timer = setTimeout(() => void workspace.guard(() => workspace.search(query)), 120)
|
||||
const timer = setTimeout(() => void workspace.guard(() => workspace.search(query, global)), 120)
|
||||
return () => clearTimeout(timer)
|
||||
})
|
||||
|
||||
@@ -67,6 +69,7 @@
|
||||
{/if}
|
||||
|
||||
<form onsubmit={submit}>
|
||||
{#if isSearch}<span class="scope label">{global ? 'all spaces' : (workspace.space?.slug ?? '')}</span>{/if}
|
||||
<input bind:this={input} bind:value {onkeydown} spellcheck="false" autocomplete="off" />
|
||||
</form>
|
||||
</div>
|
||||
@@ -92,7 +95,8 @@
|
||||
animation: rise var(--t-slow) var(--ease-snap) both;
|
||||
}
|
||||
|
||||
form { display: flex; padding: 6px; }
|
||||
form { display: flex; align-items: center; gap: 8px; padding: 6px; }
|
||||
.scope { flex: none; padding-left: 6px; font-size: 10px; color: var(--muted); }
|
||||
|
||||
input {
|
||||
flex: 1;
|
||||
|
||||
@@ -19,6 +19,9 @@
|
||||
['i · a', 'edit the open document'],
|
||||
['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'],
|
||||
['t', 'agenda — late, due this week, in hand, on every board'],
|
||||
['T', "today's diary entry, started if there is none"],
|
||||
['Ctrl-P', 'find anything, in every space'],
|
||||
['b', "open this space's board"],
|
||||
['c', "capture a card into this space's inbox, without leaving here"],
|
||||
['Esc · q', 'close the document'],
|
||||
|
||||
@@ -34,7 +34,7 @@
|
||||
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: 'column-plus', label: 'New list', keys: [':list'], run: () => hooks.openCommandLine(':list '), desk: true },
|
||||
{ icon: 'list', label: 'Back to the tree', keys: ['b', 'q'], run: () => runCommand('board'), on: true },
|
||||
],
|
||||
[
|
||||
@@ -57,6 +57,10 @@
|
||||
{ icon: 'folder-plus', label: 'New folder', keys: ['⇧ O'], run: () => hooks.openCommandLine(':mkdir ') },
|
||||
...(workspace.space ? [board] : []),
|
||||
],
|
||||
[
|
||||
{ icon: 'agenda', label: 'Agenda — late, due, in hand', keys: ['t'], run: () => runCommand('agenda') },
|
||||
{ icon: 'capture', label: 'Capture a card to the inbox', keys: ['c'], run: () => hooks.openCommandLine(':card ') },
|
||||
],
|
||||
[
|
||||
{ icon: 'grid', label: 'Every space', keys: ['w'], run: () => hooks.openOverview() },
|
||||
{
|
||||
|
||||
@@ -36,6 +36,7 @@
|
||||
{#if workspace.query}
|
||||
<div class="filter">
|
||||
<span class="muted">/</span><span class="query">{workspace.query}</span>
|
||||
{#if workspace.globalSearch}<span class="muted scope">all spaces</span>{/if}
|
||||
<button title="clear search (n)" onclick={() => workspace.guard(() => workspace.search(''))}>esc</button>
|
||||
</div>
|
||||
{/if}
|
||||
@@ -105,6 +106,7 @@
|
||||
font-size: 12px;
|
||||
flex: none;
|
||||
}
|
||||
.scope { flex: none; font-size: 10px; letter-spacing: 0.18em; text-transform: uppercase; }
|
||||
.query { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--light); }
|
||||
|
||||
ul {
|
||||
|
||||
@@ -77,6 +77,20 @@ export interface Card {
|
||||
updated_at: number
|
||||
}
|
||||
|
||||
/** An open card as the agenda lists it, with the board it lives on. */
|
||||
export interface AgendaCard extends Card {
|
||||
space_id: number
|
||||
space_slug: string
|
||||
list_name: string
|
||||
role: 'inbox' | 'doing'
|
||||
}
|
||||
|
||||
export interface Agenda {
|
||||
cards: AgendaCard[]
|
||||
/** Whether the diary has an entry today; null when there is no diary. */
|
||||
diary_today: boolean | null
|
||||
}
|
||||
|
||||
export interface BoardList {
|
||||
id: number
|
||||
name: string
|
||||
@@ -213,6 +227,9 @@ export const api = {
|
||||
|
||||
unshare: (id: number) => request<{ ok: true }>(`/nodes/${id}/share`, { method: 'DELETE' }),
|
||||
|
||||
/** Open cards across every board: due within a week, overdue, or in hand. */
|
||||
agenda: () => request<Agenda>('/agenda'),
|
||||
|
||||
/** The board of a space. Fails for a space that has none, such as the diary. */
|
||||
board: (spaceId: number) => request<Board>(`/spaces/${spaceId}/board`),
|
||||
|
||||
|
||||
+24
-3
@@ -121,7 +121,12 @@ export const commands: CommandSpec[] = [
|
||||
aliases: ['q'],
|
||||
group: 'editing',
|
||||
help: 'close the editor, or the document when already reading',
|
||||
run: () => {
|
||||
run: async () => {
|
||||
// A document `o` made and nothing was written in is not kept.
|
||||
if (await workspace.abandonIfBlank()) {
|
||||
if (workspace.returnTo === 'board') await workspace.guard(() => workspace.returnToBoard())
|
||||
return hooks.focusList()
|
||||
}
|
||||
if (workspace.dirty) {
|
||||
workspace.say('unsaved changes — :w to write, :q! to discard', 'error')
|
||||
return
|
||||
@@ -331,6 +336,18 @@ export const commands: CommandSpec[] = [
|
||||
hooks.focusEditor()
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'agenda',
|
||||
aliases: ['ag'],
|
||||
group: 'board',
|
||||
help: 'what is late, due this week, or in hand — across every board',
|
||||
run: () =>
|
||||
workspace.guard(async () => {
|
||||
await workspace.loadAgenda()
|
||||
workspace.agendaCursor = 0
|
||||
workspace.overlay = workspace.overlay === 'agenda' ? 'none' : 'agenda'
|
||||
}),
|
||||
},
|
||||
{
|
||||
name: 'today',
|
||||
aliases: ['t'],
|
||||
@@ -616,8 +633,12 @@ export const commands: CommandSpec[] = [
|
||||
aliases: ['se'],
|
||||
args: '[text]',
|
||||
group: 'documents',
|
||||
help: 'full-text search (empty clears)',
|
||||
run: (arg) => workspace.guard(() => workspace.search(arg)),
|
||||
help: 'full-text search in this space (empty clears)',
|
||||
run: (arg) => workspace.guard(() => workspace.search(arg, false)),
|
||||
bang: {
|
||||
help: 'full-text search across every space — Ctrl-P',
|
||||
run: (arg) => workspace.guard(() => workspace.search(arg, true)),
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'clear',
|
||||
|
||||
+130
-3
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
api,
|
||||
ApiError,
|
||||
type Agenda,
|
||||
type AgendaCard,
|
||||
type Board,
|
||||
type Card,
|
||||
type Crumb,
|
||||
@@ -92,7 +94,23 @@ class Workspace {
|
||||
vimMode = $state('normal')
|
||||
flash = $state<Flash>(null)
|
||||
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview'>('none')
|
||||
overlay = $state<'none' | 'help' | 'media' | 'overview' | 'agenda'>('none')
|
||||
|
||||
/*
|
||||
* What is due or in hand on every board, kept loaded all the time: the bar
|
||||
* counts from it, so what is late is in sight without going to look.
|
||||
*/
|
||||
agenda = $state<Agenda>({ cards: [], diary_today: null })
|
||||
agendaCursor = $state(0)
|
||||
|
||||
/** A search across every space rather than the one being browsed — Ctrl-P. */
|
||||
globalSearch = $state(false)
|
||||
|
||||
/*
|
||||
* The document `o` just made. If it is left with neither a name nor a word
|
||||
* in it, it is removed on the way out rather than left as an empty row.
|
||||
*/
|
||||
#fresh: number | null = null
|
||||
|
||||
/*
|
||||
* A `[y/N]` question in the command line's place, instead of the browser's
|
||||
@@ -153,6 +171,31 @@ class Workspace {
|
||||
return this.list?.cards[this.cardCursor]
|
||||
}
|
||||
|
||||
/** The agenda in the order it is drawn and walked, bucketed by urgency. */
|
||||
get agendaGroups(): { key: 'overdue' | 'today' | 'week' | 'doing'; cards: AgendaCard[] }[] {
|
||||
const midnight = new Date()
|
||||
midnight.setHours(0, 0, 0, 0)
|
||||
const start = Math.floor(midnight.getTime() / 1000)
|
||||
const groups = { overdue: [] as AgendaCard[], today: [] as AgendaCard[], week: [] as AgendaCard[], doing: [] as AgendaCard[] }
|
||||
for (const card of this.agenda.cards) {
|
||||
if (card.due_at !== null && card.due_at < start) groups.overdue.push(card)
|
||||
else if (card.due_at !== null && card.due_at < start + 86400) groups.today.push(card)
|
||||
else if (card.due_at !== null && card.due_at < start + 8 * 86400) groups.week.push(card)
|
||||
else if (card.role === 'doing') groups.doing.push(card)
|
||||
}
|
||||
return (['overdue', 'today', 'week', 'doing'] as const).map((key) => ({ key, cards: groups[key] }))
|
||||
}
|
||||
|
||||
get agendaCards(): AgendaCard[] {
|
||||
return this.agendaGroups.flatMap((group) => group.cards)
|
||||
}
|
||||
|
||||
/** `late 1 · today 2` — what the bar shows. */
|
||||
get dueCounts(): { late: number; today: number } {
|
||||
const [overdue, today] = this.agendaGroups
|
||||
return { late: overdue.cards.length, today: today.cards.length }
|
||||
}
|
||||
|
||||
ask(text: string): Promise<boolean> {
|
||||
this.prompt?.resolve(false)
|
||||
return new Promise((resolve) => {
|
||||
@@ -242,14 +285,44 @@ class Workspace {
|
||||
clearTimeout(this.#autosave)
|
||||
this.#autosave = null
|
||||
}
|
||||
if (await this.abandonIfBlank()) return
|
||||
if (this.open && this.dirty) await this.guard(() => this.save({ quiet: true }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the document `o` made if nothing was ever put in it. True when it
|
||||
* went, so the caller knows there is nothing open any more.
|
||||
*/
|
||||
async abandonIfBlank(): Promise<boolean> {
|
||||
const open = this.open
|
||||
if (!open || open.id !== this.#fresh) return false
|
||||
if (this.draft.name.trim() || this.draft.body.trim()) return false
|
||||
this.#fresh = null
|
||||
if (this.#autosave) {
|
||||
clearTimeout(this.#autosave)
|
||||
this.#autosave = null
|
||||
}
|
||||
this.dropStash(open.id)
|
||||
this.open = null
|
||||
this.editing = false
|
||||
this.dirty = false
|
||||
this.pane = 'list'
|
||||
try {
|
||||
await api.deleteNode(open.id)
|
||||
} catch {
|
||||
/* already gone, or the server is away; an empty row is no great loss */
|
||||
}
|
||||
await this.refresh()
|
||||
return true
|
||||
}
|
||||
|
||||
async boot() {
|
||||
try {
|
||||
const { user } = await api.me()
|
||||
this.user = user
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
await this.loadAgenda()
|
||||
await this.openAgendaIfDue()
|
||||
} catch {
|
||||
this.user = null
|
||||
} finally {
|
||||
@@ -261,6 +334,8 @@ class Workspace {
|
||||
const { user } = await api.login(username, password)
|
||||
this.user = user
|
||||
await this.loadSpaces(Workspace.addressedPath())
|
||||
await this.loadAgenda()
|
||||
await this.openAgendaIfDue()
|
||||
}
|
||||
|
||||
async logout() {
|
||||
@@ -385,6 +460,7 @@ class Workspace {
|
||||
if (this.spaceId !== null && folder.path[0]?.id !== this.spaceId) this.#rememberSpot()
|
||||
this.parentId = id
|
||||
this.query = ''
|
||||
this.globalSearch = false
|
||||
this.path = folder.path
|
||||
this.spaceId = folder.path[0]?.id ?? id
|
||||
this.nodes = await api.listNodes({ parent: id })
|
||||
@@ -405,14 +481,15 @@ class Workspace {
|
||||
async refresh(keep?: number) {
|
||||
const keepId = keep ?? this.selected?.id
|
||||
this.nodes = this.query
|
||||
? await api.listNodes({ q: this.query, space: this.spaceId ?? undefined })
|
||||
? await api.listNodes({ q: this.query, space: this.globalSearch ? undefined : (this.spaceId ?? undefined) })
|
||||
: await api.listNodes({ parent: this.parentId ?? undefined })
|
||||
const index = this.nodes.findIndex((n) => n.id === keepId)
|
||||
this.cursor = index >= 0 ? index : Math.min(this.cursor, Math.max(this.nodes.length - 1, 0))
|
||||
}
|
||||
|
||||
async search(query: string) {
|
||||
async search(query: string, global = this.globalSearch) {
|
||||
this.query = query
|
||||
this.globalSearch = global && query !== ''
|
||||
this.cursor = 0
|
||||
await this.refresh()
|
||||
}
|
||||
@@ -456,6 +533,11 @@ class Workspace {
|
||||
async openSelected(edit = false) {
|
||||
const selected = this.selected
|
||||
if (!selected) return
|
||||
// A match from anywhere is gone to, not opened beside a list it is not in.
|
||||
if (this.globalSearch) {
|
||||
const node = await api.getNode(selected.id)
|
||||
return this.openPath(node.path.map((crumb) => crumb.slug).join('/'))
|
||||
}
|
||||
if (selected.kind === 'document') await this.openNode(selected.id, edit)
|
||||
else await this.goTo(selected.id)
|
||||
}
|
||||
@@ -466,8 +548,10 @@ class Workspace {
|
||||
if (parent === null) return
|
||||
const node = await api.createNode({ parent_id: parent, name, body: '' })
|
||||
this.query = ''
|
||||
this.globalSearch = false
|
||||
await this.refresh()
|
||||
await this.openNode(node.id, true)
|
||||
this.#fresh = node.id
|
||||
this.enterInsert = true
|
||||
this.say(`new document ${this.here}/${node.slug}`)
|
||||
}
|
||||
@@ -639,6 +723,47 @@ class Workspace {
|
||||
this.say(`moved ${node.name.trim() || node.slug} → ${index + 1} ${space.slug}`)
|
||||
}
|
||||
|
||||
async loadAgenda() {
|
||||
try {
|
||||
this.agenda = await api.agenda()
|
||||
} catch {
|
||||
/* the counter is a convenience; a failed refresh leaves the last one up */
|
||||
}
|
||||
this.agendaCursor = Math.min(this.agendaCursor, Math.max(this.agendaCards.length - 1, 0))
|
||||
}
|
||||
|
||||
/**
|
||||
* The agenda opens itself once a day when something is late or due — the
|
||||
* point is to be told, not to have to remember to ask.
|
||||
*/
|
||||
async openAgendaIfDue() {
|
||||
const today = new Date().toDateString()
|
||||
if (localStorage.getItem('diary:agenda-seen') === today) return
|
||||
const { late, today: due } = this.dueCounts
|
||||
if (late + due === 0) return
|
||||
localStorage.setItem('diary:agenda-seen', today)
|
||||
this.agendaCursor = 0
|
||||
this.overlay = 'agenda'
|
||||
}
|
||||
|
||||
/** Enter in the agenda: to the card, on its own board. */
|
||||
async goToCard(card: AgendaCard) {
|
||||
this.overlay = 'none'
|
||||
if (this.spaceId !== card.space_id) await this.enterSpace(card.space_id)
|
||||
await this.reloadBoard(card.space_id, card.id)
|
||||
this.view = 'board'
|
||||
}
|
||||
|
||||
/** Space in the agenda: into that board's done list, from wherever it is. */
|
||||
async finishCard(card: AgendaCard) {
|
||||
const done = (await api.board(card.space_id)).lists.at(-1)
|
||||
if (!done) return
|
||||
await api.moveCard(card.id, done.id, done.cards.length)
|
||||
await this.loadAgenda()
|
||||
if (this.view === 'board' && this.spaceId === card.space_id) await this.reloadBoard()
|
||||
this.say(`done: ${card.title}`)
|
||||
}
|
||||
|
||||
async loadOverview() {
|
||||
this.overview = await api.overview()
|
||||
}
|
||||
@@ -703,6 +828,7 @@ class Workspace {
|
||||
async reloadBoard(spaceId = this.spaceId, keep = this.card?.id) {
|
||||
if (spaceId === null) return
|
||||
this.rawBoard = await api.board(spaceId)
|
||||
void this.loadAgenda()
|
||||
// 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) {
|
||||
@@ -799,6 +925,7 @@ class Workspace {
|
||||
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 })
|
||||
await this.loadAgenda()
|
||||
this.say(`carded "${card.title}" → ${space.slug}/${inbox.name}`)
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user