diff --git a/crates/heph-core/src/model.rs b/crates/heph-core/src/model.rs index 783f4cf..49ebee8 100644 --- a/crates/heph-core/src/model.rs +++ b/crates/heph-core/src/model.rs @@ -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` diff --git a/crates/heph-quickadd/src/app.rs b/crates/heph-quickadd/src/app.rs index a334b22..707c66a 100644 --- a/crates/heph-quickadd/src/app.rs +++ b/crates/heph-quickadd/src/app.rs @@ -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)), + // a1–a4 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 p1–p4 · #project · a date · every …") + egui::RichText::new("type a1–a4 · #project · a date · every …") .color(egui::Color32::from_gray(140)) .size(LABEL_SIZE), ); diff --git a/crates/heph-quickadd/src/main.rs b/crates/heph-quickadd/src/main.rs index 2e70b81..83763d1 100644 --- a/crates/heph-quickadd/src/main.rs +++ b/crates/heph-quickadd/src/main.rs @@ -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. diff --git a/crates/heph-tui/src/app.rs b/crates/heph-tui/src/app.rs index e60a969..51276ea 100644 --- a/crates/heph-tui/src/app.rs +++ b/crates/heph-tui/src/app.rs @@ -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 { - 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 { + 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 { pub search: Option, /// When `Some`, a delete is awaiting y/N confirmation. pub pending_delete: Option, + /// 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, redo_stack: Vec, @@ -471,6 +475,7 @@ impl App { 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 App { 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) }); } diff --git a/crates/heph-tui/src/main.rs b/crates/heph-tui/src/main.rs index b672d7b..34648fa 100644 --- a/crates/heph-tui/src/main.rs +++ b/crates/heph-tui/src/main.rs @@ -119,6 +119,16 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option 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(app: &mut App, key: KeyEvent) -> Option 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(app: &mut App, key: KeyEvent) -> Option 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(), diff --git a/crates/heph-tui/src/ui.rs b/crates/heph-tui/src/ui.rs index f6d2f37..bcd885e 100644 --- a/crates/heph-tui/src/ui.rs +++ b/crates/heph-tui/src/ui.rs @@ -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"; diff --git a/crates/heph-tui/tests/agenda.rs b/crates/heph-tui/tests/agenda.rs index 84ca739..81b32d8 100644 --- a/crates/heph-tui/tests/agenda.rs +++ b/crates/heph-tui/tests/agenda.rs @@ -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). diff --git a/crates/heph-tui/tests/navigation.rs b/crates/heph-tui/tests/navigation.rs index 1d1e7b5..83ec24e 100644 --- a/crates/heph-tui/tests/navigation.rs +++ b/crates/heph-tui/tests/navigation.rs @@ -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(app: &mut App, 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) diff --git a/crates/hephd/src/quickadd.rs b/crates/hephd/src/quickadd.rs index a6fccf6..826639c 100644 --- a/crates/hephd/src/quickadd.rs +++ b/crates/hephd/src/quickadd.rs @@ -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, } -fn priority_attention(token: &str) -> Option { +/// `a1`..`a4` → attention band, ordered by intensity (a1 = most urgent). +fn attention_token(token: &str) -> Option { 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 diff --git a/docs/changelog.d/feature-attention-a1-a4.feature.md b/docs/changelog.d/feature-attention-a1-a4.feature.md new file mode 100644 index 0000000..57d1efe --- /dev/null +++ b/docs/changelog.d/feature-attention-a1-a4.feature.md @@ -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. diff --git a/docs/how-to/heph-pwa.md b/docs/how-to/heph-pwa.md index 2a158e9..ab72be3 100644 --- a/docs/how-to/heph-pwa.md +++ b/docs/how-to/heph-pwa.md @@ -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 a1–a4, +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). diff --git a/heph-pwa/src/app.js b/heph-pwa/src/app.js index 4452c89..70ee947 100644 --- a/heph-pwa/src/app.js +++ b/heph-pwa/src/app.js @@ -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" }, "p1–p4 · #Project · today/+3d/fri · every week")); + preview.append(h("span", { class: "qa-hint" }, "a1–a4 · #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 (a1–a4) 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}`); diff --git a/heph-pwa/src/fmt.js b/heph-pwa/src/fmt.js index a3b98ad..9e84ddc 100644 --- a/heph-pwa/src/fmt.js +++ b/heph-pwa/src/fmt.js @@ -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). */ diff --git a/heph-pwa/src/quickadd.js b/heph-pwa/src/quickadd.js index b0e5c4d..3149064 100644 --- a/heph-pwa/src/quickadd.js +++ b/heph-pwa/src/quickadd.js @@ -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; diff --git a/heph-pwa/sw.js b/heph-pwa/sw.js index 5793eab..5990857 100644 --- a/heph-pwa/sw.js +++ b/heph-pwa/sw.js @@ -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", diff --git a/heph-pwa/test/parsers.test.mjs b/heph-pwa/test/parsers.test.mjs index cd984fc..a6695fd 100644 --- a/heph-pwa/test/parsers.test.mjs +++ b/heph-pwa/test/parsers.test.mjs @@ -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));