feat(attention): set bands directly as a1–a4 instead of cycling

Retire the `A` attention cycle and the duplicate `b` push-to-blue gesture
in heph-tui. Attention is now picked directly: press `a` then `1`–`4`
(a1=red, a2=orange, a3=white, a4=blue, ordered by intensity). Cycling past
blue used to make a task vanish from the current view with no way back —
direct selection never does. Quick-add moves from `a` to `n`.

Surface the a1–a4 nomenclature everywhere instead of colour words or the
old p1–p4 priorities: heph-tui status/legend, the heph-quickadd chip + hint,
and the PWA chip/hint plus a new band-picker (replacing its cycle button).
The shared quick-add parser now accepts `a1`–`a4` (a1=red … a4=blue) and no
longer recognizes `p1`–`p4`. Colour mappings are unchanged; only the words.

Add Attention::ui_label() in heph-core so both Rust surfaces share the
mapping; bump the PWA service-worker cache; update the PWA how-to.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Erich Blume 2026-06-09 07:50:53 -07:00
commit ebb2366236
16 changed files with 204 additions and 124 deletions

View file

@ -106,6 +106,18 @@ impl Attention {
other => return Err(Error::Integrity(format!("unknown attention: {other}"))),
})
}
/// The UI nomenclature (`a1`..`a4`), ordered by intensity — surfaces show
/// these instead of the colour words. The colour *mapping* is unchanged:
/// a1 = red, a2 = orange, a3 = white, a4 = blue.
pub fn ui_label(self) -> &'static str {
match self {
Attention::Red => "a1",
Attention::Orange => "a2",
Attention::White => "a3",
Attention::Blue => "a4",
}
}
}
/// A committed task's lifecycle state (tech-spec §4.3). `done` and `dropped`

View file

@ -43,46 +43,46 @@ const HINT_DELAY: f64 = 2.0;
/// `#project`). Unresolved `#tags` just stay in the title, so these are safe even
/// though they reference projects a given store may not have.
const HINTS: &[&str] = &[
"Water plants tomorrow p2 #Chores every 3 days",
"Call the dentist fri p1",
"Water plants tomorrow a2 #Chores every 3 days",
"Call the dentist fri a1",
"Email Sarah the report today",
"Buy milk #Errands",
"Renew passport +30d p2",
"Review pull requests p3 #Work",
"Renew passport +30d a2",
"Review pull requests a4 #Work",
"Take out recycling every other wed",
"Pay rent every 1st p1",
"Pay rent every 1st a1",
"Stretch every day",
"Submit timesheet every friday #Work",
"Water the garden every 2 days",
"Back up the laptop every week p3",
"Book flights +1w p2 #Travel",
"Doctor appointment 2026-07-15 p1",
"Back up the laptop every week a4",
"Book flights +1w a2 #Travel",
"Doctor appointment 2026-07-15 a1",
"Read a chapter today #Reading",
"Standup notes every weekday #Work",
"Change the air filter every 3 months",
"File taxes every April 15 p1",
"File taxes every April 15 a1",
"Clean the gutters every 6 months #Home",
"Wish Mom happy birthday every May 4 p1",
"Wish Mom happy birthday every May 4 a1",
"Vacuum the house every saturday #Chores",
"Replace toothbrush every 3 months",
"Prep slides for monday p2 #Work",
"Prep slides for monday a2 #Work",
"Walk the dog every day",
"Refill prescription every 30 days p2 #Health",
"Refill prescription every 30 days a2 #Health",
"Grocery run +2d #Errands",
"Mow the lawn every week #Home",
"Schedule a 1:1 with Alex thu p3 #Work",
"Send the invoice every 15th p2",
"Schedule a 1:1 with Alex thu a4 #Work",
"Send the invoice every 15th a2",
"Defrost the freezer every 6 months",
"Update the resume +14d p3",
"Update the resume +14d a4",
"Check smoke detectors every 6 months #Home",
"Plan the sprint every other monday #Work",
"Order coffee beans every 2 weeks",
"Call grandma every sunday p2",
"Call grandma every sunday a2",
"Rotate the car tires every 6 months #Car",
"Weekly review every friday p2",
"Weekly review every friday a2",
"Pick up dry cleaning tomorrow #Errands",
"Pay the credit card every 28th p1",
"Tidy the inbox every day p4",
"Pay the credit card every 28th a1",
"Tidy the inbox every day a3",
];
/// Pick a hint pseudo-randomly, never the same one twice in a row. No `rand`
@ -550,18 +550,14 @@ impl QuickAdd {
let mut any = false;
if let Some(att) = parsed.attention {
let (label, color) = match att {
heph_core::Attention::Red => {
("⚑ red", egui::Color32::from_rgb(0xe0, 0x6c, 0x60))
}
heph_core::Attention::Orange => {
("⚑ orange", egui::Color32::from_rgb(0xe5, 0xc0, 0x7b))
}
heph_core::Attention::Blue => {
("⚑ blue", egui::Color32::from_rgb(0x61, 0xaf, 0xef))
}
heph_core::Attention::White => ("⚑ white", egui::Color32::from_gray(200)),
// a1a4 nomenclature; the colour mapping is unchanged.
let color = match att {
heph_core::Attention::Red => egui::Color32::from_rgb(0xe0, 0x6c, 0x60),
heph_core::Attention::Orange => egui::Color32::from_rgb(0xe5, 0xc0, 0x7b),
heph_core::Attention::Blue => egui::Color32::from_rgb(0x61, 0xaf, 0xef),
heph_core::Attention::White => egui::Color32::from_gray(200),
};
let label = format!("{}", att.ui_label());
ui.label(egui::RichText::new(label).color(color).size(LABEL_SIZE));
any = true;
}
@ -597,7 +593,7 @@ impl QuickAdd {
if !any {
ui.label(
egui::RichText::new("type p1p4 · #project · a date · every …")
egui::RichText::new("type a1a4 · #project · a date · every …")
.color(egui::Color32::from_gray(140))
.size(LABEL_SIZE),
);

View file

@ -1,7 +1,7 @@
//! `heph-quickadd` — the global quick-capture popover (tech-spec §8).
//!
//! A tiny always-warm egui agent: ⌘' shows a single-line capture field that
//! parses Todoist-style inline syntax (`p2 #Chores tomorrow every 3 days`) and
//! parses Todoist-style inline syntax (`a2 #Chores tomorrow every 3 days`) and
//! creates a task over the `hephd` unix socket. It is **supervised by hephd**
//! (spawned in local mode on macOS), so the user installs/manages exactly one
//! service — there is no separate launch agent.

View file

@ -289,15 +289,16 @@ fn fuzzy_match(query: &str, cand: &str) -> bool {
true
}
/// The attention cycle for the `A` gesture: default → top-of-mind → consequence
/// → on-deck → back. Mirrors the §6.2 white/orange/red/blue progression.
pub fn next_attention(current: Option<Attention>) -> Attention {
match current {
Some(Attention::White) => Attention::Orange,
Some(Attention::Orange) => Attention::Red,
Some(Attention::Red) => Attention::Blue,
Some(Attention::Blue) => Attention::White,
None => Attention::White,
/// Map an attention-chord digit (`1`..`4`) to its band, ordered by intensity:
/// 1 = a1 (red), 2 = a2 (orange), 3 = a3 (white), 4 = a4 (blue). Any other
/// character is not an attention key.
pub fn attention_for_digit(c: char) -> Option<Attention> {
match c {
'1' => Some(Attention::Red),
'2' => Some(Attention::Orange),
'3' => Some(Attention::White),
'4' => Some(Attention::Blue),
_ => None,
}
}
@ -429,6 +430,9 @@ pub struct App<B: Backend> {
pub search: Option<SearchView>,
/// When `Some`, a delete is awaiting y/N confirmation.
pub pending_delete: Option<PendingDelete>,
/// When `true`, an attention chord is in progress: `a` was pressed and the
/// next `1`..`4` sets the highlighted task's band (any other key cancels).
pub pending_attention: bool,
/// Reversible triage history (`u` undoes, Ctrl-z redoes).
undo_stack: Vec<UndoEntry>,
redo_stack: Vec<UndoEntry>,
@ -471,6 +475,7 @@ impl<B: Backend> App<B> {
sort_mode: SortMode::Default,
search: None,
pending_delete: None,
pending_attention: false,
undo_stack: Vec::new(),
redo_stack: Vec::new(),
status: String::new(),
@ -722,26 +727,46 @@ impl<B: Backend> App<B> {
self.mutate(format!("skipped: {}", t.title), |b| b.skip(&t.node_id));
}
/// Cycle the highlighted task's attention band (§6.2 white→orange→red→blue).
pub fn cycle_attention_selected(&mut self) {
let Some(t) = self.selected_task().cloned() else {
/// Begin an attention chord: arm `pending_attention` so the next `1`..`4`
/// sets the highlighted task's band directly (§6.2). No-op (with a hint) if
/// nothing is highlighted. The chord replaces the old `A` cycle / `b` blue
/// gestures — picking a band directly never makes the task vanish out of
/// reach the way cycling past blue did.
pub fn begin_attention(&mut self) {
if self.selected_task().is_none() {
return;
};
let next = next_attention(t.attention);
self.push_undo((&t).into(), TriageAction::Attention(next));
self.mutate(format!("{}: {}", next.as_str(), t.title), |b| {
b.set_attention(&t.node_id, next)
});
}
self.pending_attention = true;
self.status = "attention: 1=a1 2=a2 3=a3 4=a4 (esc cancels)".into();
}
/// Push the highlighted task to On Deck (blue) — the pressure-relief valve.
pub fn push_to_blue_selected(&mut self) {
/// Resolve an armed attention chord with the pressed key. `1`..`4` set the
/// band; anything else cancels. Returns whether the key was consumed.
pub fn resolve_attention(&mut self, c: char) {
self.pending_attention = false;
let Some(att) = attention_for_digit(c) else {
self.status = "attention: cancelled".into();
return;
};
self.set_attention_selected(att);
}
/// Cancel an armed attention chord (e.g. on Esc / focus change).
pub fn cancel_attention(&mut self) {
if self.pending_attention {
self.pending_attention = false;
self.status = "attention: cancelled".into();
}
}
/// Set the highlighted task's attention band directly (the `a`+digit chord).
pub fn set_attention_selected(&mut self, att: Attention) {
let Some(t) = self.selected_task().cloned() else {
return;
};
self.push_undo((&t).into(), TriageAction::Attention(Attention::Blue));
self.mutate(format!("→ on deck: {}", t.title), |b| {
b.set_attention(&t.node_id, Attention::Blue)
self.push_undo((&t).into(), TriageAction::Attention(att));
self.mutate(format!("{}: {}", att.ui_label(), t.title), |b| {
b.set_attention(&t.node_id, att)
});
}

View file

@ -119,6 +119,16 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
return None;
}
// An armed attention chord (`a` then a digit) captures the next key: `1`..`4`
// set the band, anything else cancels.
if app.pending_attention {
match key.code {
KeyCode::Char(c) => app.resolve_attention(c),
_ => app.cancel_attention(),
}
return None;
}
// While collecting input, all keys go to the prompt.
if matches!(app.mode, Mode::Input(_)) {
match key.code {
@ -179,7 +189,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('l') | KeyCode::Right => app.focus_tasks(),
// Enter: drill sidebar→tasks, or open the selected task's context in nvim.
KeyCode::Enter => return app.enter().map(Action::EditContext),
KeyCode::Char('a') => app.begin_add(),
KeyCode::Char('n') => app.begin_add(),
KeyCode::Char('/') => app.begin_search(),
KeyCode::Char('s') => app.toggle_sort(),
KeyCode::Char('u') => app.undo(),
@ -191,8 +201,7 @@ fn handle_key<B: heph_tui::Backend>(app: &mut App<B>, key: KeyEvent) -> Option<A
KeyCode::Char('x') => app.complete_selected(),
KeyCode::Char('d') => app.drop_selected(),
KeyCode::Char('S') => app.skip_selected(),
KeyCode::Char('A') => app.cycle_attention_selected(),
KeyCode::Char('b') => app.push_to_blue_selected(),
KeyCode::Char('a') => app.begin_attention(),
KeyCode::Char('e') => app.begin_reschedule(),
KeyCode::Char('m') => app.begin_move(),
KeyCode::Char('D') => app.begin_delete(),

View file

@ -19,11 +19,11 @@ use crate::fmt::{fmt_age, fmt_date, now_ms, project_color, today_local};
// Task-pane gestures (the focused pane shows its own hints, §8.1).
const HINTS: &str =
" j/k move ⏎ edit x done d drop S skip e date A attn b→blue m move D del u undo / search q quit";
" j/k move ⏎ edit n add x done d drop S skip e date a 1-4 attn m move D del u undo / search q quit";
// Sidebar gestures: navigation + per-project actions (no task triage here).
const SIDEBAR_HINTS: &str =
" j/k move ⏎ open a add D del-project u undo s sort / search Tab tasks q quit";
" j/k move ⏎ open n add D del-project u undo s sort / search Tab tasks q quit";
const SEARCH_HINTS: &str = " j/k move Enter open Esc exit search";

View file

@ -175,8 +175,8 @@ fn quick_add_captures_a_task_that_appears_in_the_view() {
assert!(app.tasks.is_empty());
app.begin_add();
// Single-line NL: p1 → red, so it lands in Top of Mind (the default view).
type_and_submit(&mut app, "Call the plumber p1");
// Single-line NL: a1 → red, so it lands in Top of Mind (the default view).
type_and_submit(&mut app, "Call the plumber a1");
assert!(app.status.contains("added"), "status: {}", app.status);
assert!(
@ -304,7 +304,11 @@ fn pushing_to_blue_moves_a_task_out_of_top_of_mind() {
let mut app = App::new(ClientBackend::new(client(&socket))).unwrap();
assert_eq!(app.tasks.len(), 1);
app.push_to_blue_selected();
// `a` then `4` sets a4 (blue) directly — the chord that replaced push-to-blue.
app.begin_attention();
assert!(app.pending_attention);
app.resolve_attention('4');
assert!(!app.pending_attention);
assert!(app.tasks.is_empty(), "blue task should leave Top of Mind");
// It now appears under On Deck (the last of the five views).

View file

@ -218,13 +218,14 @@ fn move_task_clamps_at_the_ends() {
}
#[test]
fn attention_cycles_white_orange_red_blue() {
use heph_tui::app::next_attention;
assert_eq!(next_attention(Some(Attention::White)), Attention::Orange);
assert_eq!(next_attention(Some(Attention::Orange)), Attention::Red);
assert_eq!(next_attention(Some(Attention::Red)), Attention::Blue);
assert_eq!(next_attention(Some(Attention::Blue)), Attention::White);
assert_eq!(next_attention(None), Attention::White);
fn attention_digits_map_by_intensity() {
use heph_tui::app::attention_for_digit;
assert_eq!(attention_for_digit('1'), Some(Attention::Red));
assert_eq!(attention_for_digit('2'), Some(Attention::Orange));
assert_eq!(attention_for_digit('3'), Some(Attention::White));
assert_eq!(attention_for_digit('4'), Some(Attention::Blue));
assert_eq!(attention_for_digit('5'), None);
assert_eq!(attention_for_digit('a'), None);
}
fn type_and_submit<B: Backend>(app: &mut App<B>, s: &str) {
@ -248,12 +249,12 @@ fn quick_add_files_under_the_current_project_when_no_tag_given() {
assert_eq!(app.task_pane_title(), "Camano");
app.begin_add();
type_and_submit(&mut app, "Fix the dock p2");
type_and_submit(&mut app, "Fix the dock a2");
let created = &rec.borrow().created;
assert_eq!(created.len(), 1);
assert_eq!(created[0].0, "Fix the dock");
assert_eq!(created[0].1, Some(Attention::Orange)); // p2
assert_eq!(created[0].1, Some(Attention::Orange)); // a2
assert_eq!(created[0].2, None); // no do-date
assert_eq!(created[0].3, None); // no recurrence
assert_eq!(created[0].4.as_deref(), Some("p1")); // current project (Camano)

View file

@ -1,12 +1,13 @@
//! Single-line natural-language quick-add (tech-spec §8.1) — Todoist-style
//! capture: `Water plants tomorrow p2 #Chores every 3 days`.
//! capture: `Water plants tomorrow a2 #Chores every 3 days`.
//!
//! Pure and deterministic: `today` and the known projects are passed in, so the
//! whole parser is unit-testable. Recognized inline tokens are extracted and the
//! remainder is the title (order preserved). The recognized forms mirror the
//! owner's Todoist usage ([[design]] §6.2.1):
//!
//! - **Priority** `p1`..`p4` → attention (p1 red, p2 orange, p3 blue, p4 white).
//! - **Attention** `a1`..`a4` → attention band, ordered by intensity
//! (a1 red, a2 orange, a3 white, a4 blue).
//! - **Project** `#Name` — resolved against existing projects, greedily matching
//! multi-word titles (`#Camano Chores`). An unresolved `#tag` is left in the
//! title verbatim (no surprise project creation).
@ -40,12 +41,13 @@ pub struct Parsed {
pub project_id: Option<String>,
}
fn priority_attention(token: &str) -> Option<Attention> {
/// `a1`..`a4` → attention band, ordered by intensity (a1 = most urgent).
fn attention_token(token: &str) -> Option<Attention> {
match token.to_ascii_lowercase().as_str() {
"p1" => Some(Attention::Red),
"p2" => Some(Attention::Orange),
"p3" => Some(Attention::Blue),
"p4" => Some(Attention::White),
"a1" => Some(Attention::Red),
"a2" => Some(Attention::Orange),
"a3" => Some(Attention::White),
"a4" => Some(Attention::Blue),
_ => None,
}
}
@ -62,7 +64,7 @@ pub fn parse(input: &str, today: NaiveDate, projects: &[Project]) -> Parsed {
while i < tokens.len() {
let tok = &tokens[i];
if let Some(a) = priority_attention(tok) {
if let Some(a) = attention_token(tok) {
out.attention = Some(a);
i += 1;
continue;
@ -170,12 +172,20 @@ mod tests {
}
#[test]
fn priority_maps_to_attention() {
assert_eq!(p("Email boss p1").attention, Some(Attention::Red));
assert_eq!(p("Email boss p2").attention, Some(Attention::Orange));
assert_eq!(p("Email boss p3").attention, Some(Attention::Blue));
assert_eq!(p("Email boss p4").attention, Some(Attention::White));
assert_eq!(p("Email boss p1").title, "Email boss");
fn attention_token_maps_to_attention() {
assert_eq!(p("Email boss a1").attention, Some(Attention::Red));
assert_eq!(p("Email boss a2").attention, Some(Attention::Orange));
assert_eq!(p("Email boss a3").attention, Some(Attention::White));
assert_eq!(p("Email boss a4").attention, Some(Attention::Blue));
assert_eq!(p("Email boss a1").title, "Email boss");
}
#[test]
fn old_priority_tokens_are_no_longer_recognized() {
// p1..p4 are retired in favour of a1..a4 — they stay in the title.
let r = p("Email boss p1");
assert_eq!(r.attention, None);
assert_eq!(r.title, "Email boss p1");
}
#[test]
@ -215,7 +225,7 @@ mod tests {
#[test]
fn everything_at_once() {
let r = p("Plan trip p2 friday #Work every week");
let r = p("Plan trip a2 friday #Work every week");
assert_eq!(r.title, "Plan trip");
assert_eq!(r.attention, Some(Attention::Orange));
assert_eq!(r.do_date, Some(ms(2026, 6, 5))); // the coming Friday

View file

@ -0,0 +1 @@
Attention is now set directly instead of cycled, and surfaces it as `a1``a4` (a1=red, a2=orange, a3=white, a4=blue) rather than the colour words. In heph-tui press `a` then `1``4` to set a band (the old `A` cycle and `b` push-to-blue are retired; quick-add moves to `n`); heph-quickadd and the PWA show the same `a1``a4` labels, and the PWA's Attn action now pops a band picker. Quick-add inline syntax changes from `p1``p4` to `a1``a4` across every capture surface. The colour mappings are unchanged.

View file

@ -71,7 +71,7 @@ into preview chips before you submit:
| Token | Example | Effect |
|-------|---------|--------|
| `p1``p4` | `p1` | attention: red / orange / blue / white |
| `a1``a4` | `a1` | attention band by intensity: a1=red, a2=orange, a3=white, a4=blue |
| `#Project` | `#Camano Chores` | file under a project (greedy multi-word match) |
| date | `today` `tomorrow` `+3d` `fri` `2026-07-01` | do-date |
| `every …` | `every 3 days` `every other wed` `every workday` | recurrence (RRULE) |
@ -96,8 +96,9 @@ platform. A server-side transcription proxy could be added later if needed.)
## Triage
Tap a task to expand its actions, mirroring the TUI keys: **Done** (`x`),
**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (cycle attention, `A`),
**Date** (reschedule, `e`), **Move** (project picker, `m`), **Delete**
**Drop** (`d`), **Skip** (`S`, recurring only), **Attn** (pick a band a1a4,
the TUI's `a` then a digit), **Date** (reschedule, `e`), **Move** (project
picker, `m`), **Delete**
(tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the
task's canonical-context body + recent log tail (read-only).

View file

@ -1,6 +1,6 @@
// heph-pwa — a mobile-first browser mirror of heph-tui. Browse the built-in
// views and projects, triage tasks, and (the primary use case) capture new
// tasks fast with the same quick-add syntax as the TUI's `a` / Cmd-' popover.
// tasks fast with the same quick-add syntax as the TUI's `n` / Cmd-' popover.
//
// Online-only thin client: every action is an RPC to the configured hub (see
// rpc.js). Context/KB is read-only here (no nvim editing surface).
@ -10,11 +10,12 @@ import * as oauth from "./oauth.js";
import { parse as quickParse } from "./quickadd.js";
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
import {
ATTENTION_BANDS,
ATTENTION_COLORS,
attentionLabel,
fmtRelative,
hasFlag,
isOverdue,
nextAttention,
projectColor,
} from "./fmt.js";
@ -231,7 +232,7 @@ function taskDetail(t) {
actionBtn("✓ Done", () => triage(t, "done")),
actionBtn("⤓ Drop", () => triage(t, "dropped")),
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
actionBtn("⚑ Attn", () => cycleAttention(t)),
actionBtn("⚑ Attn", () => openAttention(t)),
actionBtn("📅 Date", () => openReschedule(t)),
actionBtn("📁 Move", () => openMove(t)),
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
@ -353,7 +354,7 @@ function openQuickAdd() {
const input = h("input", {
class: "qa-input",
type: "text",
placeholder: "Buy milk tomorrow p2 #Work every week",
placeholder: "Buy milk tomorrow a2 #Work every week",
autocomplete: "off",
autocapitalize: "sentences",
enterkeyhint: "done",
@ -364,12 +365,12 @@ function openQuickAdd() {
const parsed = quickParse(input.value, today(), state.projects);
preview.innerHTML = "";
if (!input.value.trim()) {
preview.append(h("span", { class: "qa-hint" }, "p1p4 · #Project · today/+3d/fri · every week"));
preview.append(h("span", { class: "qa-hint" }, "a1a4 · #Project · today/+3d/fri · every week"));
return;
}
preview.append(h("span", { class: "qa-title" }, parsed.title || "(no title)"));
if (parsed.attention) {
preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + parsed.attention));
preview.append(h("span", { class: "qa-tag", style: `color:${ATTENTION_COLORS[parsed.attention]}` }, "⚑ " + attentionLabel(parsed.attention)));
}
if (parsed.doDate != null) preview.append(h("span", { class: "qa-tag" }, "📅 " + fmtRelative(parsed.doDate)));
if (parsed.projectId) preview.append(h("span", { class: "qa-tag" }, "📁 " + projectTitle(parsed.projectId)));
@ -610,11 +611,22 @@ async function doSkip(t) {
}
}
async function cycleAttention(t) {
const next = nextAttention(t.attention);
// Pick an attention band directly (a1a4) rather than cycling — cycling could
// skip past the band you wanted, and pushing to a4 (blue) used to drop the task
// out of the view you were on with no way back. Mirrors the TUI's `a`+digit chord.
function openAttention(t) {
const list = h("div", { class: "picker-list" });
for (const band of ATTENTION_BANDS) {
list.append(pickerItem(attentionLabel(band), () => setAttention(t, band), ATTENTION_COLORS[band]));
}
openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Attention for "${t.title}"`), list));
}
async function setAttention(t, band) {
closeModal();
try {
await state.client.setAttention(t.node_id, next);
toast(`Attention: ${next}`);
await state.client.setAttention(t.node_id, band);
toast(`Attention: ${attentionLabel(band)}`);
reload();
} catch (e) {
toast(`Failed: ${e.message}`);

View file

@ -9,15 +9,16 @@ export const ATTENTION_COLORS = {
white: "var(--att-white)",
};
/** The cycle order used by the attention toggle (matches the TUI's `A` key). */
export const ATTENTION_CYCLE = [null, "white", "orange", "red", "blue"];
/**
* The attention bands a user can pick, in `a1`..`a4` order (by intensity).
* Each entry is the storage color string; the label is its index + 1.
*/
export const ATTENTION_BANDS = ["red", "orange", "white", "blue"];
/** Next attention in the cycle: none → white → orange → red → blue → white. */
export function nextAttention(att) {
const i = ATTENTION_CYCLE.indexOf(att ?? null);
// After blue (last), wrap to white (index 1), not back to none.
const next = i < 0 ? 1 : (i + 1) % ATTENTION_CYCLE.length;
return ATTENTION_CYCLE[next === 0 ? 1 : next] ?? "white";
/** Attention color string → its `a1`..`a4` UI label (or "" if unset). */
export function attentionLabel(att) {
const i = ATTENTION_BANDS.indexOf(att);
return i < 0 ? "" : `a${i + 1}`;
}
/** Whether an attention band shows a flag glyph (red/orange/blue; not white). */

View file

@ -1,10 +1,11 @@
// Single-line natural-language quick-add — a faithful JS port of hephd's
// `quickadd.rs` (tech-spec §8.1). Todoist-style capture:
// `Water plants tomorrow p2 #Chores every 3 days`
// `Water plants tomorrow a2 #Chores every 3 days`
//
// Recognized inline tokens are extracted and the remainder is the title (order
// preserved). This mirrors the owner's Todoist usage ([[design]] §6.2.1):
// - Priority p1..p4 → attention (p1 red, p2 orange, p3 blue, p4 white)
// - Attention a1..a4 → attention band, ordered by intensity
// (a1 red, a2 orange, a3 white, a4 blue)
// - Project #Name → resolved against existing projects, greedily matching
// multi-word titles (#Camano Chores). Unresolved #tags
// stay in the title verbatim (no surprise project).
@ -13,13 +14,13 @@
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
/** p1..p4 → attention color string (matching the RPC serialization), or null. */
function priorityAttention(token) {
/** a1..a4 → attention color string (matching the RPC serialization), or null. */
function attentionToken(token) {
switch (token.toLowerCase()) {
case "p1": return "red";
case "p2": return "orange";
case "p3": return "blue";
case "p4": return "white";
case "a1": return "red";
case "a2": return "orange";
case "a3": return "white";
case "a4": return "blue";
default: return null;
}
}
@ -76,7 +77,7 @@ export function parse(input, todayDate, projects = []) {
while (i < tokens.length) {
const tok = tokens[i];
const att = priorityAttention(tok);
const att = attentionToken(tok);
if (att !== null) {
out.attention = att;
i += 1;

View file

@ -1,7 +1,7 @@
// Service worker: cache the app shell so heph launches offline. Data is never
// cached — every /rpc call must hit the live hub (and POSTs aren't cacheable
// anyway). Bump CACHE when shell assets change to evict the old set.
const CACHE = "heph-pwa-v4";
const CACHE = "heph-pwa-v5";
const SHELL = [
"./",
"./index.html",

View file

@ -134,12 +134,19 @@ test("plain title", () => {
assert.equal(r.projectId, null);
});
test("priority maps to attention", () => {
assert.equal(p("Email boss p1").attention, "red");
assert.equal(p("Email boss p2").attention, "orange");
assert.equal(p("Email boss p3").attention, "blue");
assert.equal(p("Email boss p4").attention, "white");
assert.equal(p("Email boss p1").title, "Email boss");
test("attention token maps to attention", () => {
assert.equal(p("Email boss a1").attention, "red");
assert.equal(p("Email boss a2").attention, "orange");
assert.equal(p("Email boss a3").attention, "white");
assert.equal(p("Email boss a4").attention, "blue");
assert.equal(p("Email boss a1").title, "Email boss");
});
test("old priority tokens are no longer recognized", () => {
// p1..p4 are retired in favour of a1..a4 — they stay in the title.
const r = p("Email boss p1");
assert.equal(r.attention, null);
assert.equal(r.title, "Email boss p1");
});
test("relative date is extracted", () => {
@ -169,7 +176,7 @@ test("recurrence phrase is extracted", () => {
});
test("everything at once", () => {
const r = p("Plan trip p2 friday #Work every week");
const r = p("Plan trip a2 friday #Work every week");
assert.equal(r.title, "Plan trip");
assert.equal(r.attention, "orange");
assert.equal(r.doDate, ms(2026, 6, 5));