generated from eblume/project-template
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:
parent
b34371af87
commit
ebb2366236
16 changed files with 204 additions and 124 deletions
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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).
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
1
docs/changelog.d/feature-attention-a1-a4.feature.md
Normal file
1
docs/changelog.d/feature-attention-a1-a4.feature.md
Normal 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.
|
||||
|
|
@ -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).
|
||||
|
||||
|
|
|
|||
|
|
@ -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}`);
|
||||
|
|
|
|||
|
|
@ -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). */
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue