generated from eblume/project-template
Compare commits
23 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c9bb2cbe64 | |||
|
|
1a8752f124 | ||
| 02a8dd5180 | |||
| 11aa25c9f4 | |||
|
|
4bf255b211 | ||
| b2ddb41a46 | |||
| 9a487cbe3b | |||
| 00da36c637 | |||
|
|
c8512b2b50 | ||
| 36bd27226f | |||
| 1f81a2e6d9 | |||
| a0be0f1085 | |||
|
|
5f3e3225ec | ||
| 052f624e6f | |||
| 936c2635ef | |||
| 271c609c14 | |||
| 0036c1a284 | |||
| b24a148add | |||
| 4baa8e1c9d | |||
| c3111d498b | |||
| ca8f7d1ab2 | |||
|
|
b75d7a8d7a | ||
| fac39386d0 |
45 changed files with 4487 additions and 79 deletions
43
CHANGELOG.md
43
CHANGELOG.md
|
|
@ -12,6 +12,49 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||
|
||||
<!-- towncrier release notes start -->
|
||||
|
||||
## [v1.2.3] - 2026-06-06
|
||||
|
||||
### Features
|
||||
|
||||
- heph-tui's status line now shows a live sync indicator for spokes: how long since the last successful sync (`⟳ 5m`), a red `⚠ auth` when the hub is rejecting the token (re-login needed), `⚠ offline` when the hub is unreachable, and a `⚠ N conflicts` chip when merge conflicts are pending. The daemon tracks this health and exposes it via `sync.status` (also visible in `heph sync --status`), so a silently-broken spoke is obvious at a glance instead of buried in the log.
|
||||
|
||||
### Documentation
|
||||
|
||||
- [[set-up-sync-hub]] now documents recommended Authentik token-validity settings (access + refresh token lifetime) to avoid frequent re-logins, with an iOS PWA storage-eviction caveat; [[host-heph-pwa]] points the PWA's login note at it.
|
||||
|
||||
|
||||
## [v1.2.2] - 2026-06-06
|
||||
|
||||
### Features
|
||||
|
||||
- Recurring tasks now show their schedule in plain language (`every other week`, `weekdays`, `yearly on Apr 15`) instead of a raw RRULE — in both the TUI detail pane and the mobile PWA. The TUI's project sidebar gained subproject indentation, per-project outstanding-task counts, a wider pane, and scrolling when the list overflows.
|
||||
|
||||
### Documentation
|
||||
|
||||
- New explanation card [[hub-spoke-data-evolution]] covering why heph's op-based sync lets most new features ship without a coordinated migration, and the narrow case (a new required SQLite column) that does need a hub-first rollout.
|
||||
|
||||
|
||||
## [v1.2.1] - 2026-06-05
|
||||
|
||||
### Features
|
||||
|
||||
- heph-pwa: added a **Login with Authentik** button — a proper browser OIDC sign-in (Authorization Code + PKCE) that replaces the manual bearer-token paste. The hub exposes an unauthenticated `GET /config` (`{issuer, client_id}`) so the app is zero-config when served from the hub; the PWA discovers the IdP endpoints, runs the PKCE redirect, exchanges the code for a token, and silently refreshes it (`offline_access`). The manual token field remains as a fallback. Requires the PWA origin registered as a redirect URI on the Authentik `heph` provider.
|
||||
|
||||
|
||||
## [v1.2.0] - 2026-06-04
|
||||
|
||||
### Features
|
||||
|
||||
- New **heph-pwa** mobile app: an installable, phone-first PWA that mirrors heph-tui — browse the built-in views and projects, triage tasks, and capture new tasks fast with the same quick-add syntax (`p1-4`, `#Project`, `today/+3d/fri`, `every …`) and live preview. Voice capture via on-device dictation. The hub (`hephd --mode server`) gains CORS and an optional `--web-root` so it can serve the app same-origin straight from the daemon.
|
||||
|
||||
|
||||
## [v1.1.1] - 2026-06-04
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Fix `hephd --self-update` never detecting releases: the release poll used the daemon's `reqwest` client, which is built without a TLS backend (`default-features = false`), so every HTTPS request to the forge failed (`release check failed: requesting forge releases/latest`). The poll now uses `ureq` — already a dependency, with a rustls/ring TLS stack that needs no system libraries (and no cmake/`aws-lc-sys`). Hub sync is unaffected (it is plain HTTP).
|
||||
|
||||
|
||||
## [v1.1.0] - 2026-06-04
|
||||
|
||||
### Features
|
||||
|
|
|
|||
|
|
@ -38,7 +38,7 @@ pub use filter::{builtin as builtin_view, ListFilter, ViewSpec, BUILTIN_VIEWS};
|
|||
pub use hlc::{Hlc, HlcClock};
|
||||
pub use model::{
|
||||
deterministic_id, Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node,
|
||||
NodeKind, SchedulePatch, SyncCursors, Task, TaskState,
|
||||
NodeKind, ProjectOverview, SchedulePatch, SyncCursors, Task, TaskState,
|
||||
};
|
||||
pub use oplog::Op;
|
||||
pub use ranking::{rank, Dimension, RankedTask, RANKING};
|
||||
|
|
|
|||
|
|
@ -314,6 +314,24 @@ pub struct Health {
|
|||
pub sync_status: String,
|
||||
}
|
||||
|
||||
/// A project plus the two facts a sidebar needs to render it as a counted,
|
||||
/// indented tree (§8.1): its parent project (via a `parent` link, if any) and
|
||||
/// the number of outstanding tasks filed **directly** under it. Pure read-side —
|
||||
/// both derive from existing data, so this carries no schema or sync change (see
|
||||
/// [[hub-spoke-data-evolution]]).
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
|
||||
pub struct ProjectOverview {
|
||||
/// The project node id.
|
||||
pub id: String,
|
||||
/// The project's title.
|
||||
pub title: String,
|
||||
/// The parent project's node id, or `None` for a top-level project.
|
||||
pub parent_id: Option<String>,
|
||||
/// Outstanding tasks filed directly in this project (children counted under
|
||||
/// their own row, not summed here).
|
||||
pub outstanding: usize,
|
||||
}
|
||||
|
||||
/// An ambiguous merge surfaced to the user (a discarded LWW value, tech-spec
|
||||
/// §12). The winning value is already in the store; this records what was
|
||||
/// dropped so `heph conflicts` can show and settle it.
|
||||
|
|
|
|||
|
|
@ -32,8 +32,8 @@ use crate::error::{Error, Result};
|
|||
use crate::filter::ListFilter;
|
||||
use crate::hlc::Hlc;
|
||||
use crate::model::{
|
||||
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
|
||||
SyncCursors, Task, TaskState,
|
||||
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview,
|
||||
SchedulePatch, SyncCursors, Task, TaskState,
|
||||
};
|
||||
use crate::oplog::Op;
|
||||
use crate::ranking::RankedTask;
|
||||
|
|
@ -297,6 +297,10 @@ impl Store for LocalStore {
|
|||
tasks::health(&self.conn, &self.owner_id)
|
||||
}
|
||||
|
||||
fn project_overview(&self) -> Result<Vec<ProjectOverview>> {
|
||||
tasks::project_overview(&self.conn, &self.owner_id)
|
||||
}
|
||||
|
||||
fn search(&self, query: &str) -> Result<Vec<Node>> {
|
||||
nodes::search(&self.conn, &self.owner_id, query)
|
||||
}
|
||||
|
|
@ -498,6 +502,67 @@ mod tests {
|
|||
assert!(store.project_scope("Nope").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_overview_carries_parent_and_direct_outstanding_count() {
|
||||
use crate::model::{LinkType, NewNode, NewTask, NodeKind, TaskState};
|
||||
let mut store = store_at(1);
|
||||
let mk_proj = |store: &mut LocalStore, title: &str| {
|
||||
store
|
||||
.create_node(NewNode {
|
||||
kind: NodeKind::Project,
|
||||
title: title.into(),
|
||||
body: None,
|
||||
})
|
||||
.unwrap()
|
||||
.id
|
||||
};
|
||||
let mk_task = |store: &mut LocalStore, title: &str, project: Option<&str>| {
|
||||
store
|
||||
.create_task(NewTask {
|
||||
title: title.into(),
|
||||
attention: None,
|
||||
do_date: None,
|
||||
late_on: None,
|
||||
recurrence: None,
|
||||
project_id: project.map(String::from),
|
||||
})
|
||||
.unwrap()
|
||||
.node_id
|
||||
};
|
||||
|
||||
let work = mk_proj(&mut store, "Work");
|
||||
let sub = mk_proj(&mut store, "Work Sub");
|
||||
mk_proj(&mut store, "Garden");
|
||||
// Work Sub is a child of Work (child holds the `parent` link → parent).
|
||||
store.add_link(&sub, &work, LinkType::Parent).unwrap();
|
||||
|
||||
// Two outstanding + one done in Work; one outstanding in the subproject.
|
||||
mk_task(&mut store, "ship", Some(&work));
|
||||
mk_task(&mut store, "review", Some(&work));
|
||||
let done = mk_task(&mut store, "archived", Some(&work));
|
||||
store.set_task_state(&done, TaskState::Done).unwrap();
|
||||
mk_task(&mut store, "nested", Some(&sub));
|
||||
// An unfiled task counts toward no project.
|
||||
mk_task(&mut store, "loose", None);
|
||||
|
||||
let overview = store.project_overview().unwrap();
|
||||
// Title-sorted.
|
||||
let titles: Vec<_> = overview.iter().map(|p| p.title.as_str()).collect();
|
||||
assert_eq!(titles, ["Garden", "Work", "Work Sub"]);
|
||||
|
||||
let by_title = |t: &str| overview.iter().find(|p| p.title == t).unwrap();
|
||||
assert_eq!(by_title("Work").outstanding, 2, "done task excluded");
|
||||
assert_eq!(by_title("Work").parent_id, None);
|
||||
assert_eq!(
|
||||
by_title("Work Sub").outstanding,
|
||||
1,
|
||||
"direct only, not summed"
|
||||
);
|
||||
assert_eq!(by_title("Work Sub").parent_id, Some(work.clone()));
|
||||
assert_eq!(by_title("Garden").outstanding, 0);
|
||||
assert_eq!(by_title("Garden").parent_id, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolve_project_is_fuzzy_only_when_unambiguous() {
|
||||
use crate::model::{NewNode, NodeKind};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@
|
|||
//! A committed task is a `task` node plus a `tasks` row. On creation it also
|
||||
//! gets a canonical context `doc` and a `canonical-context` link (tech-spec §6).
|
||||
|
||||
use std::collections::HashMap;
|
||||
|
||||
use rusqlite::{Connection, OptionalExtension, Row};
|
||||
|
||||
use serde_json::json;
|
||||
|
|
@ -12,7 +14,7 @@ use crate::error::{Error, Result};
|
|||
use crate::extract;
|
||||
use crate::filter::ListFilter;
|
||||
use crate::model::{
|
||||
Attention, Health, LinkType, NewTask, NodeKind, SchedulePatch, Task, TaskState,
|
||||
Attention, Health, LinkType, NewTask, NodeKind, ProjectOverview, SchedulePatch, Task, TaskState,
|
||||
};
|
||||
use crate::oplog::op_type;
|
||||
use crate::ranking::{self, RankedTask};
|
||||
|
|
@ -482,6 +484,57 @@ pub(super) fn health(conn: &Connection, owner: &str) -> Result<Health> {
|
|||
})
|
||||
}
|
||||
|
||||
/// Every project (owner-scoped, non-tombstoned) with its parent project (via a
|
||||
/// `parent` link) and its direct outstanding-task count — the shape a sidebar
|
||||
/// renders as a counted, indented tree (§8.1). Pure read-side: counts come from
|
||||
/// a single `GROUP BY` over the `in-project` links, parents from the `parent`
|
||||
/// links, both already in the store. Title-sorted for a stable sibling order.
|
||||
pub(super) fn project_overview(conn: &Connection, owner: &str) -> Result<Vec<ProjectOverview>> {
|
||||
// Direct outstanding count per project: each task's project is its first
|
||||
// `in-project` link target (mirrors `list`/`load_candidates`).
|
||||
let mut count_stmt = conn.prepare(
|
||||
"SELECT (SELECT dst_id FROM links
|
||||
WHERE src_id = n.id AND type = 'in-project' AND tombstoned = 0
|
||||
ORDER BY created_at, id LIMIT 1) AS project_id,
|
||||
COUNT(*)
|
||||
FROM nodes n JOIN tasks t ON t.node_id = n.id
|
||||
WHERE n.owner_id = ?1 AND n.tombstoned = 0 AND t.state = 'outstanding'
|
||||
GROUP BY project_id",
|
||||
)?;
|
||||
let mut counts: HashMap<String, usize> = HashMap::new();
|
||||
let rows = count_stmt.query_map([owner], |r| {
|
||||
Ok((r.get::<_, Option<String>>(0)?, r.get::<_, i64>(1)?))
|
||||
})?;
|
||||
for row in rows {
|
||||
let (project_id, count) = row?;
|
||||
if let Some(pid) = project_id {
|
||||
counts.insert(pid, count as usize);
|
||||
}
|
||||
}
|
||||
|
||||
// Parent of each project: the dst of its (first) `parent` link.
|
||||
let mut parent_stmt = conn.prepare(
|
||||
"SELECT dst_id FROM links
|
||||
WHERE src_id = ?1 AND type = 'parent' AND tombstoned = 0
|
||||
ORDER BY created_at, id LIMIT 1",
|
||||
)?;
|
||||
|
||||
let mut out = Vec::new();
|
||||
for node in nodes::list(conn, owner, Some(NodeKind::Project))? {
|
||||
let parent_id = parent_stmt
|
||||
.query_row([&node.id], |r| r.get::<_, String>(0))
|
||||
.optional()?;
|
||||
out.push(ProjectOverview {
|
||||
outstanding: counts.get(&node.id).copied().unwrap_or(0),
|
||||
id: node.id,
|
||||
title: node.title,
|
||||
parent_id,
|
||||
});
|
||||
}
|
||||
out.sort_by(|a, b| a.title.cmp(&b.title));
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Load every non-tombstoned committed task for `owner` as a ranking candidate,
|
||||
/// joining in its project and canonical-context link targets.
|
||||
fn load_candidates(conn: &Connection, owner: &str) -> Result<Vec<RankedTask>> {
|
||||
|
|
|
|||
|
|
@ -7,8 +7,8 @@
|
|||
use crate::error::Result;
|
||||
use crate::filter::ListFilter;
|
||||
use crate::model::{
|
||||
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, SchedulePatch,
|
||||
SyncCursors, Task, TaskState,
|
||||
Attention, Conflict, Health, Link, LinkType, NewNode, NewTask, Node, NodeKind, ProjectOverview,
|
||||
SchedulePatch, SyncCursors, Task, TaskState,
|
||||
};
|
||||
use crate::oplog::Op;
|
||||
use crate::ranking::RankedTask;
|
||||
|
|
@ -142,6 +142,12 @@ pub trait Store {
|
|||
/// Working-set health — orange/active/on-deck/conflict counts (tech-spec §7).
|
||||
fn health(&self) -> Result<Health>;
|
||||
|
||||
/// Every project with its parent (via a `parent` link) and its direct
|
||||
/// outstanding-task count — the shape a sidebar renders as a counted,
|
||||
/// indented tree (§8.1). Read-only over existing data; no schema or sync
|
||||
/// change (see [[hub-spoke-data-evolution]]).
|
||||
fn project_overview(&self) -> Result<Vec<ProjectOverview>>;
|
||||
|
||||
/// Full-text search over title + body (FTS5), owner-scoped, best-match
|
||||
/// first, tombstones excluded (tech-spec §6). `query` is FTS5 MATCH syntax.
|
||||
fn search(&self, query: &str) -> Result<Vec<Node>>;
|
||||
|
|
|
|||
|
|
@ -7,9 +7,9 @@ use std::collections::HashMap;
|
|||
|
||||
use anyhow::Result;
|
||||
use chrono::NaiveDate;
|
||||
use heph_core::{Attention, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
|
||||
use heph_core::{Attention, ProjectOverview, RankedTask, SchedulePatch, TaskState, BUILTIN_VIEWS};
|
||||
|
||||
use crate::backend::{Backend, Project, SearchHit};
|
||||
use crate::backend::{Backend, Project, SearchHit, SyncStatus};
|
||||
use crate::fmt::{days_overdue, today_local};
|
||||
|
||||
/// How the task list is ordered (toggled in the UI, §8.1).
|
||||
|
|
@ -313,8 +313,18 @@ pub enum Focus {
|
|||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum SidebarEntry {
|
||||
Header(String),
|
||||
View { name: String, title: String },
|
||||
Project { id: String, title: String },
|
||||
View {
|
||||
name: String,
|
||||
title: String,
|
||||
},
|
||||
/// A project row. `depth` is its nesting level (0 = top-level) for indent;
|
||||
/// `count` is its direct outstanding-task count, shown as a trailing chip.
|
||||
Project {
|
||||
id: String,
|
||||
title: String,
|
||||
depth: u16,
|
||||
count: usize,
|
||||
},
|
||||
}
|
||||
|
||||
impl SidebarEntry {
|
||||
|
|
@ -323,6 +333,70 @@ impl SidebarEntry {
|
|||
}
|
||||
}
|
||||
|
||||
/// Turn the daemon's flat (title-sorted) project overview into sidebar rows in
|
||||
/// tree order — each project followed by its descendants, carrying the nesting
|
||||
/// `depth` and outstanding `count` the renderer needs.
|
||||
fn project_entries(overview: Vec<ProjectOverview>) -> Vec<SidebarEntry> {
|
||||
let order = order_projects(&overview);
|
||||
let mut overview: Vec<Option<ProjectOverview>> = overview.into_iter().map(Some).collect();
|
||||
order
|
||||
.into_iter()
|
||||
.map(|(i, depth)| {
|
||||
let p = overview[i].take().expect("each index visited once");
|
||||
SidebarEntry::Project {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
depth,
|
||||
count: p.outstanding,
|
||||
}
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Depth-first display order over the project forest: returns `(index, depth)`
|
||||
/// pairs, each project ahead of its children, siblings in the input's title
|
||||
/// order. A project whose parent is missing (tombstoned, or not in the set)
|
||||
/// renders at the top level; cycles can't loop (each node is emitted once).
|
||||
fn order_projects(overview: &[ProjectOverview]) -> Vec<(usize, u16)> {
|
||||
use std::collections::HashSet;
|
||||
let ids: HashSet<&str> = overview.iter().map(|p| p.id.as_str()).collect();
|
||||
let mut children: HashMap<&str, Vec<usize>> = HashMap::new();
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
for (i, p) in overview.iter().enumerate() {
|
||||
match &p.parent_id {
|
||||
Some(pid) if ids.contains(pid.as_str()) => {
|
||||
children.entry(pid.as_str()).or_default().push(i);
|
||||
}
|
||||
_ => roots.push(i),
|
||||
}
|
||||
}
|
||||
let mut out = Vec::with_capacity(overview.len());
|
||||
let mut visited = vec![false; overview.len()];
|
||||
// Stack of (index, depth); push siblings reversed so we pop in title order.
|
||||
let mut stack: Vec<(usize, u16)> = roots.iter().rev().map(|&i| (i, 0)).collect();
|
||||
while let Some((i, depth)) = stack.pop() {
|
||||
if visited[i] {
|
||||
continue;
|
||||
}
|
||||
visited[i] = true;
|
||||
out.push((i, depth));
|
||||
if let Some(kids) = children.get(overview[i].id.as_str()) {
|
||||
for &k in kids.iter().rev() {
|
||||
if !visited[k] {
|
||||
stack.push((k, depth + 1));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Defensive: any node trapped in a parent-cycle still gets one top-level row.
|
||||
for (i, seen) in visited.iter().enumerate() {
|
||||
if !seen {
|
||||
out.push((i, 0));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// The selected sidebar source, as owned values (so reloads don't hold a borrow
|
||||
/// on `self.sidebar` while calling the backend).
|
||||
enum Target {
|
||||
|
|
@ -359,6 +433,8 @@ pub struct App<B: Backend> {
|
|||
undo_stack: Vec<UndoEntry>,
|
||||
redo_stack: Vec<UndoEntry>,
|
||||
pub status: String,
|
||||
/// Latest sync health for the status-line indicator (refreshed on a tick).
|
||||
pub sync: SyncStatus,
|
||||
pub should_quit: bool,
|
||||
}
|
||||
|
||||
|
|
@ -376,9 +452,7 @@ impl<B: Backend> App<B> {
|
|||
});
|
||||
}
|
||||
sidebar.push(SidebarEntry::Header("Projects".into()));
|
||||
for Project { id, title } in backend.projects()? {
|
||||
sidebar.push(SidebarEntry::Project { id, title });
|
||||
}
|
||||
sidebar.extend(project_entries(backend.project_overview()?));
|
||||
|
||||
let sidebar_cursor = sidebar
|
||||
.iter()
|
||||
|
|
@ -400,12 +474,23 @@ impl<B: Backend> App<B> {
|
|||
undo_stack: Vec::new(),
|
||||
redo_stack: Vec::new(),
|
||||
status: String::new(),
|
||||
sync: SyncStatus::default(),
|
||||
should_quit: false,
|
||||
};
|
||||
app.reload();
|
||||
app.refresh_sync();
|
||||
Ok(app)
|
||||
}
|
||||
|
||||
/// Refresh the sync-health snapshot for the status line. Best-effort: a
|
||||
/// failed read leaves the previous snapshot in place (a stale indicator
|
||||
/// beats a flicker), so this never disrupts navigation.
|
||||
pub fn refresh_sync(&mut self) {
|
||||
if let Ok(status) = self.backend.sync_status() {
|
||||
self.sync = status;
|
||||
}
|
||||
}
|
||||
|
||||
/// The title shown above the task list (the selected source).
|
||||
pub fn task_pane_title(&self) -> String {
|
||||
match self.sidebar.get(self.sidebar_cursor) {
|
||||
|
|
@ -423,7 +508,7 @@ impl<B: Backend> App<B> {
|
|||
/// The title of a project node id, resolved from the sidebar.
|
||||
pub fn project_name(&self, id: &str) -> Option<String> {
|
||||
self.sidebar.iter().find_map(|e| match e {
|
||||
SidebarEntry::Project { id: pid, title } if pid == id => Some(title.clone()),
|
||||
SidebarEntry::Project { id: pid, title, .. } if pid == id => Some(title.clone()),
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
|
@ -469,7 +554,7 @@ impl<B: Backend> App<B> {
|
|||
self.sidebar
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
SidebarEntry::Project { id, title } => Some((id.clone(), title.clone())),
|
||||
SidebarEntry::Project { id, title, .. } => Some((id.clone(), title.clone())),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
|
|
@ -742,7 +827,7 @@ impl<B: Backend> App<B> {
|
|||
/// become unfiled (they move to the Inbox), not deleted.
|
||||
pub fn begin_delete_project(&mut self) {
|
||||
match self.sidebar.get(self.sidebar_cursor) {
|
||||
Some(SidebarEntry::Project { id, title }) => {
|
||||
Some(SidebarEntry::Project { id, title, .. }) => {
|
||||
self.pending_delete = Some(PendingDelete::Project {
|
||||
project_id: id.clone(),
|
||||
title: title.clone(),
|
||||
|
|
@ -881,10 +966,8 @@ impl<B: Backend> App<B> {
|
|||
.filter(|e| !matches!(e, SidebarEntry::Project { .. }))
|
||||
.cloned()
|
||||
.collect();
|
||||
if let Ok(projects) = self.backend.projects() {
|
||||
for Project { id, title } in projects {
|
||||
rebuilt.push(SidebarEntry::Project { id, title });
|
||||
}
|
||||
if let Ok(overview) = self.backend.project_overview() {
|
||||
rebuilt.extend(project_entries(overview));
|
||||
}
|
||||
self.sidebar = rebuilt;
|
||||
// Restore the cursor: same entry if present, else the nearest selectable
|
||||
|
|
@ -923,7 +1006,7 @@ impl<B: Backend> App<B> {
|
|||
self.sidebar
|
||||
.iter()
|
||||
.filter_map(|e| match e {
|
||||
SidebarEntry::Project { id, title } => Some(Project {
|
||||
SidebarEntry::Project { id, title, .. } => Some(Project {
|
||||
id: id.clone(),
|
||||
title: title.clone(),
|
||||
}),
|
||||
|
|
@ -1213,4 +1296,67 @@ mod sort_tests {
|
|||
// Alpha group (red before blue), then Beta, then project-less tasks last.
|
||||
assert_eq!(ids(&tasks), vec!["a_red", "a_blue", "b_white", "none_red"]);
|
||||
}
|
||||
|
||||
fn po(id: &str, title: &str, parent: Option<&str>, outstanding: usize) -> ProjectOverview {
|
||||
ProjectOverview {
|
||||
id: id.into(),
|
||||
title: title.into(),
|
||||
parent_id: parent.map(str::to_string),
|
||||
outstanding,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_entries_nest_children_under_parents_with_depth_and_count() {
|
||||
// Input arrives title-sorted from the daemon.
|
||||
let overview = vec![
|
||||
po("g", "Garden", None, 0),
|
||||
po("w", "Work", None, 2),
|
||||
po("ws", "Work Sub", Some("w"), 1),
|
||||
po("wsx", "Work Sub Sub", Some("ws"), 5),
|
||||
];
|
||||
let rows: Vec<(String, u16, usize)> = project_entries(overview)
|
||||
.into_iter()
|
||||
.map(|e| match e {
|
||||
SidebarEntry::Project {
|
||||
title,
|
||||
depth,
|
||||
count,
|
||||
..
|
||||
} => (title, depth, count),
|
||||
_ => unreachable!("project_entries yields only Project rows"),
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(
|
||||
rows,
|
||||
vec![
|
||||
("Garden".into(), 0, 0),
|
||||
("Work".into(), 0, 2),
|
||||
("Work Sub".into(), 1, 1),
|
||||
("Work Sub Sub".into(), 2, 5),
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_entries_treat_a_missing_parent_as_top_level() {
|
||||
// A child whose parent isn't in the set (e.g. tombstoned) still shows.
|
||||
let overview = vec![po("orphan", "Orphan", Some("gone"), 3)];
|
||||
let rows = project_entries(overview);
|
||||
assert!(matches!(
|
||||
rows.as_slice(),
|
||||
[SidebarEntry::Project {
|
||||
depth: 0,
|
||||
count: 3,
|
||||
..
|
||||
}]
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn order_projects_does_not_loop_on_a_parent_cycle() {
|
||||
// a→b→a is pathological but must still terminate, each row once.
|
||||
let overview = vec![po("a", "A", Some("b"), 0), po("b", "B", Some("a"), 0)];
|
||||
assert_eq!(order_projects(&overview).len(), 2);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,10 +22,55 @@ pub struct SearchHit {
|
|||
pub kind: String,
|
||||
}
|
||||
|
||||
/// Sync health for the status line (the `sync.status` RPC). On a standalone
|
||||
/// instance `hub_url` is `None` and `health` is absent; the conflict count is
|
||||
/// always present.
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
|
||||
pub struct SyncStatus {
|
||||
/// The hub this device syncs with, or `None` if standalone (no indicator).
|
||||
pub hub_url: Option<String>,
|
||||
/// Pending merge conflicts awaiting resolution.
|
||||
#[serde(default)]
|
||||
pub conflicts: usize,
|
||||
/// Observed health of the background sync loop (spoke only).
|
||||
#[serde(default)]
|
||||
pub health: Option<SyncHealth>,
|
||||
}
|
||||
|
||||
/// The spoke's observed sync health (mirrors `hephd`'s `SyncHealth`).
|
||||
#[derive(Debug, Clone, Default, PartialEq, Eq, serde::Deserialize)]
|
||||
pub struct SyncHealth {
|
||||
/// Epoch ms of the last successful exchange ("last synced"), if any.
|
||||
pub last_success_ms: Option<i64>,
|
||||
/// Epoch ms of the last attempt (success or failure), if any.
|
||||
pub last_attempt_ms: Option<i64>,
|
||||
/// The last error message, cleared on the next success.
|
||||
pub last_error: Option<String>,
|
||||
/// Whether the most recent attempt failed authentication (needs re-login).
|
||||
#[serde(default)]
|
||||
pub auth_failure: bool,
|
||||
}
|
||||
|
||||
/// Everything the agenda surface asks of the daemon.
|
||||
pub trait Backend {
|
||||
/// All project nodes (for the sidebar), title-sorted.
|
||||
fn projects(&mut self) -> Result<Vec<Project>>;
|
||||
/// Projects enriched with parent + direct outstanding-task count, for the
|
||||
/// indented, counted sidebar tree (§8.1). The default derives a flat list
|
||||
/// from [`projects`](Self::projects); the real backend forwards the
|
||||
/// dedicated `project.overview` RPC.
|
||||
fn project_overview(&mut self) -> Result<Vec<heph_core::ProjectOverview>> {
|
||||
Ok(self
|
||||
.projects()?
|
||||
.into_iter()
|
||||
.map(|p| heph_core::ProjectOverview {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
parent_id: None,
|
||||
outstanding: 0,
|
||||
})
|
||||
.collect())
|
||||
}
|
||||
/// Run a built-in filter view (`tom|ondeck|chores|work|tasks`, §8.2).
|
||||
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>>;
|
||||
/// Run a raw [`ListFilter`] (used for per-project scope).
|
||||
|
|
@ -40,6 +85,11 @@ pub trait Backend {
|
|||
/// A task's canonical-context doc id (where its description/checklist live),
|
||||
/// for opening a task search-hit at the useful node. `None` if it has none.
|
||||
fn context_of(&mut self, task_id: &str) -> Result<Option<String>>;
|
||||
/// Sync health for the status line. The default is a standalone instance
|
||||
/// (no hub, no conflicts); the real backend forwards `sync.status`.
|
||||
fn sync_status(&mut self) -> Result<SyncStatus> {
|
||||
Ok(SyncStatus::default())
|
||||
}
|
||||
|
||||
// --- triage mutations (T2) ---
|
||||
|
||||
|
|
@ -103,6 +153,11 @@ impl Backend for ClientBackend {
|
|||
Ok(projects)
|
||||
}
|
||||
|
||||
fn project_overview(&mut self) -> Result<Vec<heph_core::ProjectOverview>> {
|
||||
let v = self.call("project.overview", json!({}))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn view(&mut self, name: &str) -> Result<Vec<RankedTask>> {
|
||||
let v = self.call("view", json!({ "name": name }))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
|
|
@ -149,6 +204,11 @@ impl Backend for ClientBackend {
|
|||
.map(|l| l.dst_id))
|
||||
}
|
||||
|
||||
fn sync_status(&mut self) -> Result<SyncStatus> {
|
||||
let v = self.call("sync.status", json!({}))?;
|
||||
Ok(serde_json::from_value(v)?)
|
||||
}
|
||||
|
||||
fn set_state(&mut self, task_id: &str, state: &str) -> Result<()> {
|
||||
self.call("task.set_state", json!({ "id": task_id, "state": state }))?;
|
||||
Ok(())
|
||||
|
|
|
|||
|
|
@ -25,6 +25,29 @@ pub fn today_local() -> NaiveDate {
|
|||
Local::now().date_naive()
|
||||
}
|
||||
|
||||
/// Now, in epoch milliseconds (the reference for [`fmt_age`]).
|
||||
pub fn now_ms() -> i64 {
|
||||
Local::now().timestamp_millis()
|
||||
}
|
||||
|
||||
/// A compact "how long ago" for the sync indicator: `Ns` under a minute, then
|
||||
/// `Nm` / `Nh` / `Nd`. Second-granularity under a minute makes the chip a visible
|
||||
/// heartbeat (the sync loop runs every 30s) and surfaces a missed beat as the age
|
||||
/// climbing, rather than hiding under a flat "just now". Clamped at zero so a
|
||||
/// little clock skew never shows a negative age.
|
||||
pub fn fmt_age(now_ms: i64, then_ms: i64) -> String {
|
||||
let secs = (now_ms - then_ms).max(0) / 1000;
|
||||
if secs < 60 {
|
||||
format!("{secs}s")
|
||||
} else if secs < 3_600 {
|
||||
format!("{}m", secs / 60)
|
||||
} else if secs < 86_400 {
|
||||
format!("{}h", secs / 3_600)
|
||||
} else {
|
||||
format!("{}d", secs / 86_400)
|
||||
}
|
||||
}
|
||||
|
||||
/// How many days past its do-date a task is (0 if not overdue, no do-date, or
|
||||
/// future-dated). The "how overdue" signal the agenda sort ranks on (§8.1).
|
||||
pub fn days_overdue(do_date: Option<i64>, today: NaiveDate) -> i64 {
|
||||
|
|
@ -102,6 +125,19 @@ mod tests {
|
|||
assert_eq!(fmt_date(ms(day(2027, 1, 1)), today), "2027-01-01");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn age_is_compact_and_clamped() {
|
||||
let now = 1_000_000_000_000;
|
||||
assert_eq!(fmt_age(now, now), "0s");
|
||||
assert_eq!(fmt_age(now, now - 30_000), "30s");
|
||||
assert_eq!(fmt_age(now, now - 59_000), "59s");
|
||||
assert_eq!(fmt_age(now, now - 5 * 60_000), "5m");
|
||||
assert_eq!(fmt_age(now, now - 3 * 3_600_000), "3h");
|
||||
assert_eq!(fmt_age(now, now - 2 * 86_400_000), "2d");
|
||||
// Clock skew (then in the future) never shows a negative age.
|
||||
assert_eq!(fmt_age(now, now + 10_000), "0s");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn project_color_is_stable_distinct_and_neutral_when_absent() {
|
||||
assert_eq!(project_color(None), Color::DarkGray);
|
||||
|
|
|
|||
|
|
@ -61,14 +61,22 @@ fn run<B: heph_tui::Backend>(
|
|||
mut app: App<B>,
|
||||
socket: &std::path::Path,
|
||||
) -> Result<()> {
|
||||
// Poll with a timeout so the sync indicator's age advances and a sync
|
||||
// failure surfaces within a couple of seconds even while the user is idle.
|
||||
let tick = std::time::Duration::from_secs(2);
|
||||
loop {
|
||||
terminal.draw(|f| ui::render(f, &app))?;
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
if let Some(action) = handle_key(&mut app, key) {
|
||||
perform(terminal, &mut app, socket, action)?;
|
||||
if event::poll(tick)? {
|
||||
if let Event::Key(key) = event::read()? {
|
||||
if key.kind == KeyEventKind::Press {
|
||||
if let Some(action) = handle_key(&mut app, key) {
|
||||
perform(terminal, &mut app, socket, action)?;
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Idle tick: refresh the sync-health snapshot for the status line.
|
||||
app.refresh_sync();
|
||||
}
|
||||
if app.should_quit {
|
||||
return Ok(());
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
use heph_core::Attention;
|
||||
use ratatui::{
|
||||
layout::{Constraint, Direction, Layout, Margin, Rect},
|
||||
layout::{Alignment, Constraint, Direction, Layout, Margin, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{
|
||||
|
|
@ -14,8 +14,8 @@ use ratatui::{
|
|||
};
|
||||
|
||||
use crate::app::{App, Focus, InputState, Mode, MoveOption, MoveState, SidebarEntry, SortMode};
|
||||
use crate::backend::Backend;
|
||||
use crate::fmt::{fmt_date, project_color, today_local};
|
||||
use crate::backend::{Backend, SyncStatus};
|
||||
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 =
|
||||
|
|
@ -37,7 +37,7 @@ pub fn render<B: Backend>(frame: &mut Frame, app: &App<B>) {
|
|||
let panes = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Length(22),
|
||||
Constraint::Length(28),
|
||||
Constraint::Min(28),
|
||||
Constraint::Length(38),
|
||||
])
|
||||
|
|
@ -151,8 +151,25 @@ fn pane_border(focused: bool) -> Style {
|
|||
}
|
||||
}
|
||||
|
||||
/// The (label, trailing-count) styles for a sidebar row given its selection
|
||||
/// state: a full-width cyan bar when focus-selected, reversed when selected in
|
||||
/// the unfocused pane, otherwise plain with a dimmed count.
|
||||
fn sidebar_row_styles(selected: bool, focused: bool) -> (Style, Style) {
|
||||
if selected {
|
||||
let s = if focused {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
Style::default().add_modifier(Modifier::REVERSED)
|
||||
};
|
||||
(s, s)
|
||||
} else {
|
||||
(Style::default(), Style::default().fg(Color::DarkGray))
|
||||
}
|
||||
}
|
||||
|
||||
fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
||||
let focused = app.focus == Focus::Sidebar;
|
||||
let width = area.width.saturating_sub(2) as usize; // inside borders
|
||||
let items: Vec<ListItem> = app
|
||||
.sidebar
|
||||
.iter()
|
||||
|
|
@ -166,17 +183,38 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
.fg(Color::DarkGray)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
))),
|
||||
SidebarEntry::View { title, .. } | SidebarEntry::Project { title, .. } => {
|
||||
let mut style = Style::default();
|
||||
if selected {
|
||||
style = if focused {
|
||||
style.fg(Color::Black).bg(Color::Cyan)
|
||||
} else {
|
||||
style.add_modifier(Modifier::REVERSED)
|
||||
};
|
||||
}
|
||||
SidebarEntry::View { title, .. } => {
|
||||
let (style, _) = sidebar_row_styles(selected, focused);
|
||||
ListItem::new(Line::from(Span::styled(format!(" {title}"), style)))
|
||||
}
|
||||
SidebarEntry::Project {
|
||||
title,
|
||||
depth,
|
||||
count,
|
||||
..
|
||||
} => {
|
||||
// Indent two columns per nesting level (one base level so a
|
||||
// top-level project still clears the pane border).
|
||||
let indent = " ".repeat(1 + *depth as usize);
|
||||
// A right-aligned outstanding-task count (blank when zero).
|
||||
let count_str = if *count > 0 {
|
||||
format!(" {count}")
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
let label_w = width.saturating_sub(count_str.chars().count());
|
||||
let title_room = label_w.saturating_sub(indent.chars().count());
|
||||
let title_trunc: String = title.chars().take(title_room).collect();
|
||||
let mut label = format!("{indent}{title_trunc}");
|
||||
let pad = label_w.saturating_sub(label.chars().count());
|
||||
label.push_str(&" ".repeat(pad));
|
||||
|
||||
let (label_style, count_style) = sidebar_row_styles(selected, focused);
|
||||
ListItem::new(Line::from(vec![
|
||||
Span::styled(label, label_style),
|
||||
Span::styled(count_str, count_style),
|
||||
]))
|
||||
}
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
|
|
@ -187,7 +225,29 @@ fn render_sidebar<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
.border_style(pane_border(focused))
|
||||
.title(" Views "),
|
||||
);
|
||||
frame.render_widget(list, area);
|
||||
// Drive scroll-to-visible off the cursor so projects below the fold stay
|
||||
// reachable; the row's own highlight remains the selection cue.
|
||||
let mut state = ListState::default();
|
||||
state.select(Some(app.sidebar_cursor));
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
|
||||
// A scrollbar once the entries can't all fit at once (position tracks the
|
||||
// cursor — an honest "where am I in the list" signal).
|
||||
let inner_h = area.height.saturating_sub(2) as usize;
|
||||
if app.sidebar.len() > inner_h {
|
||||
let mut sb = ScrollbarState::new(app.sidebar.len()).position(app.sidebar_cursor);
|
||||
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
|
||||
.begin_symbol(None)
|
||||
.end_symbol(None);
|
||||
frame.render_stateful_widget(
|
||||
bar,
|
||||
area.inner(Margin {
|
||||
vertical: 1,
|
||||
horizontal: 0,
|
||||
}),
|
||||
&mut sb,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A dimmed `──── Project ────` group header for the project sort mode, padded
|
||||
|
|
@ -239,7 +299,7 @@ fn task_detail_lines<B: Backend>(
|
|||
}
|
||||
}
|
||||
if let Some(rrule) = &t.recurrence {
|
||||
field("recurs:", rrule.clone());
|
||||
field("recurs:", hephd::datespec::humanize_rrule(rrule));
|
||||
}
|
||||
if let Some(d) = t.do_date {
|
||||
field("do:", fmt_date(d, today));
|
||||
|
|
@ -478,5 +538,130 @@ fn render_status<B: Backend>(frame: &mut Frame, app: &App<B>, area: Rect) {
|
|||
} else {
|
||||
Style::default().fg(Color::DarkGray)
|
||||
};
|
||||
frame.render_widget(Paragraph::new(Line::from(Span::styled(text, style))), area);
|
||||
let left = Paragraph::new(Line::from(Span::styled(text, style)));
|
||||
|
||||
// A right-aligned sync indicator (spoke only); the hints take the rest.
|
||||
let indicator = sync_indicator(&app.sync, now_ms());
|
||||
if indicator.is_empty() {
|
||||
frame.render_widget(left, area);
|
||||
return;
|
||||
}
|
||||
let ind_w: usize = indicator.iter().map(|s| s.content.chars().count()).sum();
|
||||
let cols =
|
||||
Layout::horizontal([Constraint::Min(1), Constraint::Length(ind_w as u16 + 1)]).split(area);
|
||||
frame.render_widget(left, cols[0]);
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(indicator)).alignment(Alignment::Right),
|
||||
cols[1],
|
||||
);
|
||||
}
|
||||
|
||||
/// The status-line sync indicator (empty on a standalone instance): a sync-state
|
||||
/// chip — `⚠ auth` when re-login is needed, `⟳ <age>` since the last successful
|
||||
/// sync, `⚠ offline` when erroring, `⟳ …` before the first sync — plus a
|
||||
/// conflict chip when any merge conflicts are pending.
|
||||
fn sync_indicator(sync: &SyncStatus, now: i64) -> Vec<Span<'static>> {
|
||||
if sync.hub_url.is_none() {
|
||||
return Vec::new();
|
||||
}
|
||||
let dim = Style::default().fg(Color::DarkGray);
|
||||
let red = Style::default().fg(Color::Red).add_modifier(Modifier::BOLD);
|
||||
let yellow = Style::default().fg(Color::Yellow);
|
||||
|
||||
let health = sync.health.clone().unwrap_or_default();
|
||||
let mut spans = vec![if health.auth_failure {
|
||||
Span::styled("⚠ auth", red)
|
||||
} else if let Some(ts) = health.last_success_ms {
|
||||
Span::styled(format!("⟳ {}", fmt_age(now, ts)), dim)
|
||||
} else if health.last_error.is_some() {
|
||||
Span::styled("⚠ offline", yellow)
|
||||
} else {
|
||||
Span::styled("⟳ …", dim)
|
||||
}];
|
||||
|
||||
if sync.conflicts > 0 {
|
||||
let label = if sync.conflicts == 1 {
|
||||
"1 conflict".to_string()
|
||||
} else {
|
||||
format!("{} conflicts", sync.conflicts)
|
||||
};
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::styled(format!("⚠ {label}"), red));
|
||||
}
|
||||
spans
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::backend::SyncHealth;
|
||||
|
||||
fn render(sync: &SyncStatus, now: i64) -> String {
|
||||
sync_indicator(sync, now)
|
||||
.iter()
|
||||
.map(|s| s.content.to_string())
|
||||
.collect()
|
||||
}
|
||||
|
||||
const NOW: i64 = 1_000_000_000_000;
|
||||
|
||||
fn spoke(health: SyncHealth, conflicts: usize) -> SyncStatus {
|
||||
SyncStatus {
|
||||
hub_url: Some("http://hub:8787".into()),
|
||||
conflicts,
|
||||
health: Some(health),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn standalone_shows_no_indicator() {
|
||||
assert!(sync_indicator(&SyncStatus::default(), NOW).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn indicator_reflects_each_sync_state() {
|
||||
// Recently synced → a dim age chip.
|
||||
let ok = spoke(
|
||||
SyncHealth {
|
||||
last_success_ms: Some(NOW - 5 * 60_000),
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_eq!(render(&ok, NOW), "⟳ 5m");
|
||||
|
||||
// Auth failure wins over age (it's the actionable state).
|
||||
let auth = spoke(
|
||||
SyncHealth {
|
||||
last_success_ms: Some(NOW - 60_000),
|
||||
auth_failure: true,
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_eq!(render(&auth, NOW), "⚠ auth");
|
||||
|
||||
// Errored with no prior success → offline.
|
||||
let offline = spoke(
|
||||
SyncHealth {
|
||||
last_error: Some("error sending request".into()),
|
||||
..Default::default()
|
||||
},
|
||||
0,
|
||||
);
|
||||
assert_eq!(render(&offline, NOW), "⚠ offline");
|
||||
|
||||
// Before the first sync.
|
||||
assert_eq!(render(&spoke(SyncHealth::default(), 0), NOW), "⟳ …");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn conflicts_chip_appends_and_pluralizes() {
|
||||
let h = SyncHealth {
|
||||
last_success_ms: Some(NOW),
|
||||
..Default::default()
|
||||
};
|
||||
assert_eq!(render(&spoke(h.clone(), 1), NOW), "⟳ 0s ⚠ 1 conflict");
|
||||
assert_eq!(render(&spoke(h, 3), NOW), "⟳ 0s ⚠ 3 conflicts");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -98,6 +98,12 @@ fn agenda_renders_views_projects_and_tasks() {
|
|||
// The red/orange tasks carry a flag glyph in the leading column (§8.1).
|
||||
assert!(s.contains('⚑'), "attention flag glyph missing:\n{s}");
|
||||
assert!(s.contains("Preview"), "preview pane missing:\n{s}");
|
||||
// A standalone daemon (no hub) shows no sync indicator — the `sync.status`
|
||||
// RPC round-trips and reports `hub_url: null`.
|
||||
assert!(
|
||||
!s.contains('⟳'),
|
||||
"sync indicator should be hidden without a hub:\n{s}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
|
@ -206,7 +212,12 @@ fn recurring_task_shows_glyph_and_selected_detail_block() {
|
|||
assert!(s.contains('↻'), "recurrence glyph missing:\n{s}");
|
||||
// ...and the selected task's inline detail block (cursor starts on row 0).
|
||||
assert!(s.contains("recurs:"), "no recurrence detail:\n{s}");
|
||||
assert!(s.contains("FREQ=DAILY"), "no rrule in detail:\n{s}");
|
||||
// The RRULE is humanized for display (§8.1), not shown raw.
|
||||
assert!(s.contains("daily"), "recurrence not humanized:\n{s}");
|
||||
assert!(
|
||||
!s.contains("FREQ=DAILY"),
|
||||
"raw rrule leaked into detail:\n{s}"
|
||||
);
|
||||
assert!(s.contains("project:"), "no project detail:\n{s}");
|
||||
assert!(s.contains("Routines"), "project name missing:\n{s}");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,6 +49,13 @@ pub trait TokenVerifier: Send + Sync {
|
|||
/// Validate `bearer` (the raw token, no `Bearer ` prefix) and return its
|
||||
/// claims, or an [`AuthError`].
|
||||
fn verify(&self, bearer: &str) -> Result<Claims, AuthError>;
|
||||
|
||||
/// The public OIDC parameters a browser client (the `heph-pwa`) needs to
|
||||
/// start a login: `(issuer, client_id)`. Neither is a secret. `None` for
|
||||
/// non-OIDC verifiers (e.g. test stubs).
|
||||
fn oidc_config(&self) -> Option<(&str, &str)> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
/// What an OIDC provider's discovery document tells us (we only need the JWKS).
|
||||
|
|
@ -156,4 +163,9 @@ impl TokenVerifier for OidcVerifier {
|
|||
.map_err(|e| AuthError::Invalid(e.to_string()))?;
|
||||
Ok(data.claims)
|
||||
}
|
||||
|
||||
fn oidc_config(&self) -> Option<(&str, &str)> {
|
||||
// The audience is the OIDC client id (Authentik sets `aud` to it).
|
||||
Some((&self.issuer, &self.audience))
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -288,6 +288,172 @@ fn parse_month_day(s: &str) -> Option<(u32, u32)> {
|
|||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reverse datespec: humanize an RRULE for display (§8.1).
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Render an RFC-5545 RRULE back into the compact human phrasing the owner would
|
||||
/// have typed — the inverse of [`parse_recurrence`] for the forms it produces:
|
||||
/// `daily`, `every 3 days`, `every other day`, `weekly`, `every other week`,
|
||||
/// `weekdays`, `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`,
|
||||
/// `monthly on the 5th`, `yearly on Apr 15`. Any rule that uses parts we don't
|
||||
/// model (`COUNT`, `UNTIL`, `BYSETPOS`, ordinal `BYDAY` like `2MO`, …) is
|
||||
/// returned **verbatim** so nothing is silently hidden from the reader.
|
||||
pub fn humanize_rrule(rrule: &str) -> String {
|
||||
humanize_known(rrule).unwrap_or_else(|| rrule.trim().to_string())
|
||||
}
|
||||
|
||||
/// The fallible core: `None` whenever the rule contains anything we don't model,
|
||||
/// so [`humanize_rrule`] can fall back to the raw text.
|
||||
fn humanize_known(rrule: &str) -> Option<String> {
|
||||
let mut freq: Option<String> = None;
|
||||
let mut interval: u32 = 1;
|
||||
let mut byday: Option<String> = None;
|
||||
let mut bymonth: Option<u32> = None;
|
||||
let mut bymonthday: Option<i32> = None;
|
||||
for part in rrule.trim().split(';') {
|
||||
let part = part.trim();
|
||||
if part.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let (k, v) = part.split_once('=')?;
|
||||
match k.trim().to_uppercase().as_str() {
|
||||
"FREQ" => freq = Some(v.trim().to_uppercase()),
|
||||
"INTERVAL" => interval = v.trim().parse().ok()?,
|
||||
"BYDAY" => byday = Some(v.trim().to_uppercase()),
|
||||
"BYMONTH" => bymonth = Some(v.trim().parse().ok()?),
|
||||
"BYMONTHDAY" => bymonthday = Some(v.trim().parse().ok()?),
|
||||
// A part we don't render → don't risk a misleading summary.
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
|
||||
match freq?.as_str() {
|
||||
"DAILY" => {
|
||||
if byday.is_some() || bymonth.is_some() || bymonthday.is_some() {
|
||||
return None;
|
||||
}
|
||||
Some(every_unit(interval, "day", "days", "daily"))
|
||||
}
|
||||
"WEEKLY" => {
|
||||
if bymonth.is_some() || bymonthday.is_some() {
|
||||
return None;
|
||||
}
|
||||
match byday {
|
||||
None => Some(every_unit(interval, "week", "weeks", "weekly")),
|
||||
Some(days) => {
|
||||
if interval == 1 && is_weekday_set(&days) {
|
||||
return Some("weekdays".into());
|
||||
}
|
||||
let names = weekday_names(&days)?;
|
||||
if names.len() == 1 {
|
||||
let day = names[0];
|
||||
Some(match interval {
|
||||
1 => format!("every {day}"),
|
||||
2 => format!("every other {day}"),
|
||||
n => format!("every {n} weeks on {day}"),
|
||||
})
|
||||
} else {
|
||||
let joined = names.join(", ");
|
||||
Some(match interval {
|
||||
1 => format!("weekly on {joined}"),
|
||||
2 => format!("every other week on {joined}"),
|
||||
n => format!("every {n} weeks on {joined}"),
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"MONTHLY" => {
|
||||
if byday.is_some() || bymonth.is_some() {
|
||||
return None;
|
||||
}
|
||||
match bymonthday {
|
||||
None => Some(every_unit(interval, "month", "months", "monthly")),
|
||||
Some(d @ 1..=31) => {
|
||||
let day = ordinal(d as u32);
|
||||
Some(match interval {
|
||||
1 => format!("monthly on the {day}"),
|
||||
2 => format!("every other month on the {day}"),
|
||||
n => format!("every {n} months on the {day}"),
|
||||
})
|
||||
}
|
||||
Some(_) => None, // negative / out-of-range day-of-month → raw
|
||||
}
|
||||
}
|
||||
"YEARLY" => {
|
||||
if byday.is_some() {
|
||||
return None;
|
||||
}
|
||||
match (bymonth, bymonthday) {
|
||||
(None, None) => Some(every_unit(interval, "year", "years", "yearly")),
|
||||
(Some(m @ 1..=12), Some(d @ 1..=31)) => {
|
||||
let mon = MONTH_ABBR[(m - 1) as usize];
|
||||
Some(match interval {
|
||||
1 => format!("yearly on {mon} {d}"),
|
||||
2 => format!("every other year on {mon} {d}"),
|
||||
n => format!("every {n} years on {mon} {d}"),
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
const MONTH_ABBR: [&str; 12] = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
|
||||
/// `preset` for `n == 1`, `every other <singular>` for 2, `every N <plural>` otherwise.
|
||||
fn every_unit(n: u32, singular: &str, plural: &str, preset: &str) -> String {
|
||||
match n {
|
||||
1 => preset.to_string(),
|
||||
2 => format!("every other {singular}"),
|
||||
n => format!("every {n} {plural}"),
|
||||
}
|
||||
}
|
||||
|
||||
/// `1st`, `2nd`, `3rd`, `4th`, … `11th`, `21st`, `22nd`.
|
||||
fn ordinal(n: u32) -> String {
|
||||
let suffix = match (n % 10, n % 100) {
|
||||
(_, 11..=13) => "th",
|
||||
(1, _) => "st",
|
||||
(2, _) => "nd",
|
||||
(3, _) => "rd",
|
||||
_ => "th",
|
||||
};
|
||||
format!("{n}{suffix}")
|
||||
}
|
||||
|
||||
/// `MO,TU,WE,TH,FR` in any order (and only those), the inverse of the `weekdays`
|
||||
/// preset.
|
||||
fn is_weekday_set(byday: &str) -> bool {
|
||||
let mut days: Vec<&str> = byday.split(',').map(str::trim).collect();
|
||||
days.sort_unstable();
|
||||
days == ["FR", "MO", "TH", "TU", "WE"]
|
||||
}
|
||||
|
||||
/// `BYDAY` tokens → capitalized weekday abbreviations, order preserved. `None` if
|
||||
/// any token isn't a bare weekday (e.g. an ordinal `2MO`), so the caller falls
|
||||
/// back to the raw rule.
|
||||
fn weekday_names(byday: &str) -> Option<Vec<&'static str>> {
|
||||
byday
|
||||
.split(',')
|
||||
.map(|t| match t.trim() {
|
||||
"MO" => Some("Mon"),
|
||||
"TU" => Some("Tue"),
|
||||
"WE" => Some("Wed"),
|
||||
"TH" => Some("Thu"),
|
||||
"FR" => Some("Fri"),
|
||||
"SA" => Some("Sat"),
|
||||
"SU" => Some("Sun"),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
|
@ -404,4 +570,71 @@ mod tests {
|
|||
);
|
||||
assert!(parse_recurrence("every blue moon").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanize_inverts_the_natural_language_forms() {
|
||||
let cases = [
|
||||
("FREQ=DAILY", "daily"),
|
||||
("FREQ=DAILY;INTERVAL=2", "every other day"),
|
||||
("FREQ=DAILY;INTERVAL=3", "every 3 days"),
|
||||
("FREQ=WEEKLY", "weekly"),
|
||||
("FREQ=WEEKLY;INTERVAL=2", "every other week"),
|
||||
("FREQ=MONTHLY", "monthly"),
|
||||
("FREQ=MONTHLY;INTERVAL=6", "every 6 months"),
|
||||
("FREQ=YEARLY", "yearly"),
|
||||
("FREQ=WEEKLY;BYDAY=FR", "every Fri"),
|
||||
("FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"),
|
||||
("FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"),
|
||||
("FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"),
|
||||
("FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"),
|
||||
("FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"),
|
||||
("FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"),
|
||||
("FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"),
|
||||
("FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"),
|
||||
("FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"),
|
||||
];
|
||||
for (rrule, want) in cases {
|
||||
assert_eq!(humanize_rrule(rrule), want, "humanizing {rrule}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanize_round_trips_through_parse_recurrence() {
|
||||
// For the interval/weekday forms the display text is itself a valid input:
|
||||
// owner types text → we store an RRULE → we show it back → it re-parses to
|
||||
// the same rule. (The `yearly on Apr 15` / `monthly on the 5th` forms are
|
||||
// tuned for reading, not re-typing — the stored RRULE, never this string,
|
||||
// is what gets parsed — so they're covered by the exact-output test above.)
|
||||
for input in [
|
||||
"every 3 days",
|
||||
"every other day",
|
||||
"every other wed",
|
||||
"weekdays",
|
||||
"every fri",
|
||||
"every 6 months",
|
||||
"every 2 weeks",
|
||||
] {
|
||||
let rrule = parse_recurrence(input).unwrap();
|
||||
let shown = humanize_rrule(&rrule);
|
||||
assert_eq!(
|
||||
parse_recurrence(&shown).unwrap(),
|
||||
rrule,
|
||||
"{input:?} → {rrule:?} → shown {shown:?} must re-parse to the same rule"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn humanize_falls_back_to_raw_for_unmodeled_rules() {
|
||||
// COUNT/UNTIL/BYSETPOS and ordinal BYDAY would be misleading if dropped.
|
||||
for raw in [
|
||||
"FREQ=DAILY;COUNT=5",
|
||||
"FREQ=WEEKLY;UNTIL=20261231T000000Z",
|
||||
"FREQ=MONTHLY;BYDAY=2MO",
|
||||
"FREQ=MONTHLY;BYMONTHDAY=-1",
|
||||
"not an rrule at all",
|
||||
] {
|
||||
assert_eq!(humanize_rrule(raw), raw, "should pass {raw} through");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -60,6 +60,12 @@ struct Cli {
|
|||
#[arg(long)]
|
||||
http_addr: Option<String>,
|
||||
|
||||
/// Directory of static files to serve for non-API paths (server mode). Point
|
||||
/// this at the `heph-pwa/` shell to host the mobile app same-origin from the
|
||||
/// hub. Unset: the hub serves only its API routes.
|
||||
#[arg(long)]
|
||||
web_root: Option<PathBuf>,
|
||||
|
||||
/// Server to proxy to (client mode only; required there).
|
||||
#[arg(long)]
|
||||
server_url: Option<String>,
|
||||
|
|
@ -190,7 +196,10 @@ async fn main() -> Result<()> {
|
|||
anyhow::bail!("--oidc-issuer and --oidc-audience must be set together")
|
||||
}
|
||||
};
|
||||
let app = sync::router(daemon.store(), verifier);
|
||||
if let Some(root) = cli.web_root.as_deref() {
|
||||
tracing::info!(web_root = %root.display(), "hub serving static PWA shell");
|
||||
}
|
||||
let app = sync::router_with_web(daemon.store(), verifier, cli.web_root.clone());
|
||||
let http_listener = TcpListener::bind(&addr)
|
||||
.await
|
||||
.with_context(|| format!("binding hub HTTP endpoint {addr}"))?;
|
||||
|
|
@ -222,14 +231,17 @@ async fn main() -> Result<()> {
|
|||
|
||||
tracing::info!(socket = %socket.display(), mode = ?cli.mode, "hephd listening");
|
||||
|
||||
// macOS local mode: supervise the global quick-capture popover (⌘'). hephd
|
||||
// already runs as a `gui/$uid` LaunchAgent, so its child inherits the Aqua
|
||||
// session the hotkey/GUI need — no separate launch agent. Opt-in via
|
||||
// HEPH_QUICKADD=1 (the installed plist sets it) so dev/test runs that spawn a
|
||||
// local daemon never pop a window. The helper self-exits when this daemon
|
||||
// goes away, so killing hephd (even `kill -9`) leaves nothing behind.
|
||||
// macOS store-owning modes: supervise the global quick-capture popover (⌘').
|
||||
// hephd already runs as a `gui/$uid` LaunchAgent, so its child inherits the
|
||||
// Aqua session the hotkey/GUI need — no separate launch agent. Both `local`
|
||||
// and `server` own the local store on the device (server is local + an HTTP
|
||||
// hub), so both should drive the desktop popover; only `client` (a thin
|
||||
// remote proxy) does not. Opt-in via HEPH_QUICKADD=1 (the installed plist
|
||||
// sets it) so dev/test runs that spawn a daemon never pop a window. The
|
||||
// helper self-exits when this daemon goes away, so killing hephd (even
|
||||
// `kill -9`) leaves nothing behind.
|
||||
#[cfg(target_os = "macos")]
|
||||
if cli.mode == Mode::Local && quickadd_enabled() {
|
||||
if matches!(cli.mode, Mode::Local | Mode::Server) && quickadd_enabled() {
|
||||
spawn_quickadd_supervisor(socket.clone());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -221,6 +221,10 @@ impl Store for RemoteStore {
|
|||
self.call_as("health", json!({}))
|
||||
}
|
||||
|
||||
fn project_overview(&self) -> Result<Vec<heph_core::ProjectOverview>> {
|
||||
self.call_as("project.overview", json!({}))
|
||||
}
|
||||
|
||||
fn search(&self, query: &str) -> Result<Vec<Node>> {
|
||||
self.call_as("search", json!({ "query": query }))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,6 +352,7 @@ pub fn dispatch(store: &mut dyn Store, method: &str, params: Value) -> Result<Va
|
|||
let p: NodeListParams = parse(params)?;
|
||||
json!(store.list_nodes(p.kind)?)
|
||||
}
|
||||
"project.overview" => json!(store.project_overview()?),
|
||||
"task.create" => {
|
||||
let p: NewTask = parse(params)?;
|
||||
json!(store.create_task(p)?)
|
||||
|
|
|
|||
|
|
@ -70,19 +70,17 @@ pub fn parse_latest_tag(body: &str) -> Result<String> {
|
|||
Ok(rel.tag_name)
|
||||
}
|
||||
|
||||
/// Fetch the latest release tag from the forge over HTTP (reusing the daemon's
|
||||
/// shared `reqwest::Client`). Network/HTTP/JSON failures surface as `Err` for
|
||||
/// the caller to log-and-continue.
|
||||
pub async fn fetch_latest_tag(http: &reqwest::Client, url: &str) -> Result<String> {
|
||||
let body = http
|
||||
.get(url)
|
||||
.send()
|
||||
.await
|
||||
/// Fetch the latest release tag from the forge over HTTPS, blocking. Uses
|
||||
/// `ureq` (already a dependency, with a rustls/ring TLS backend that needs no
|
||||
/// system libs) rather than the daemon's `reqwest` client, which is built
|
||||
/// without TLS — the forge poll is the only production HTTPS-over-HTTP-client
|
||||
/// path (hub sync is plain HTTP). Network/HTTP/JSON failures surface as `Err`.
|
||||
pub fn fetch_latest_tag(url: &str) -> Result<String> {
|
||||
let body = ureq::get(url)
|
||||
.call()
|
||||
.context("requesting forge releases/latest")?
|
||||
.error_for_status()
|
||||
.context("forge releases/latest returned an error status")?
|
||||
.text()
|
||||
.await
|
||||
.body_mut()
|
||||
.read_to_string()
|
||||
.context("reading forge releases/latest body")?;
|
||||
parse_latest_tag(&body)
|
||||
}
|
||||
|
|
@ -93,25 +91,33 @@ pub trait ReleaseSource: Send + Sync + 'static {
|
|||
fn latest_tag(&self) -> impl std::future::Future<Output = Result<String>> + Send;
|
||||
}
|
||||
|
||||
/// The production source: the forge's `releases/latest` over HTTP.
|
||||
/// The production source: the forge's `releases/latest` over HTTPS (via `ureq`).
|
||||
pub struct ForgeReleaseSource {
|
||||
http: reqwest::Client,
|
||||
url: String,
|
||||
}
|
||||
|
||||
impl ForgeReleaseSource {
|
||||
/// Source backed by the daemon's shared client, hitting [`RELEASES_LATEST_URL`].
|
||||
pub fn new(http: reqwest::Client) -> Self {
|
||||
/// Source hitting [`RELEASES_LATEST_URL`].
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
http,
|
||||
url: RELEASES_LATEST_URL.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Default for ForgeReleaseSource {
|
||||
fn default() -> Self {
|
||||
Self::new()
|
||||
}
|
||||
}
|
||||
|
||||
impl ReleaseSource for ForgeReleaseSource {
|
||||
async fn latest_tag(&self) -> Result<String> {
|
||||
fetch_latest_tag(&self.http, &self.url).await
|
||||
// `ureq` is blocking; keep it off the async runtime.
|
||||
let url = self.url.clone();
|
||||
tokio::task::spawn_blocking(move || fetch_latest_tag(&url))
|
||||
.await
|
||||
.context("release-fetch task panicked")?
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -10,9 +10,10 @@
|
|||
//! ops with the configured hub (tech-spec §6.1, §12).
|
||||
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use anyhow::Result;
|
||||
use serde::Serialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
|
||||
use tokio::net::{UnixListener, UnixStream};
|
||||
|
|
@ -32,6 +33,23 @@ struct SpokeAuth {
|
|||
client_id: String,
|
||||
}
|
||||
|
||||
/// A spoke's observed sync health, updated after every exchange (background loop
|
||||
/// or manual `sync.now`). Surfaced by `sync.status` so clients can show whether
|
||||
/// sync is actually working instead of trusting silence (tech-spec §3.1 / the
|
||||
/// `Spoke sync health` task). All times are epoch ms; `None` means "not yet".
|
||||
#[derive(Clone, Default, Serialize)]
|
||||
struct SyncHealth {
|
||||
/// When we last attempted an exchange.
|
||||
last_attempt_ms: Option<i64>,
|
||||
/// When we last completed one without error (the "last synced" time).
|
||||
last_success_ms: Option<i64>,
|
||||
/// The last error message, cleared on the next success.
|
||||
last_error: Option<String>,
|
||||
/// Whether the most recent attempt failed authentication (a 401) — the
|
||||
/// "re-auth needed" signal, distinct from a transient network blip.
|
||||
auth_failure: bool,
|
||||
}
|
||||
|
||||
/// The shared, cheaply-cloneable context each connection serves from.
|
||||
#[derive(Clone)]
|
||||
struct Ctx {
|
||||
|
|
@ -43,6 +61,41 @@ struct Ctx {
|
|||
auth: Option<SpokeAuth>,
|
||||
/// Opt-in self-update config (`Some` ⇒ enabled, tech-spec self-update card).
|
||||
self_update: Option<SelfUpdateConfig>,
|
||||
/// Live sync health, shared between the background loop and `sync.status`.
|
||||
sync_health: Arc<Mutex<SyncHealth>>,
|
||||
}
|
||||
|
||||
/// Epoch-ms wall clock (the daemon may read it; only `heph-core` is clock-pure).
|
||||
fn now_ms() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_millis() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// True if `e` carries an HTTP 401 — i.e. the hub rejected our bearer token.
|
||||
fn is_auth_error(e: &anyhow::Error) -> bool {
|
||||
e.downcast_ref::<reqwest::Error>()
|
||||
.and_then(|re| re.status())
|
||||
.is_some_and(|s| s == reqwest::StatusCode::UNAUTHORIZED)
|
||||
}
|
||||
|
||||
/// Fold one exchange outcome into the shared [`SyncHealth`].
|
||||
fn record_sync_outcome(health: &Arc<Mutex<SyncHealth>>, result: &Result<sync::SyncReport>) {
|
||||
let now = now_ms();
|
||||
let mut h = health.lock().expect("sync_health mutex poisoned");
|
||||
h.last_attempt_ms = Some(now);
|
||||
match result {
|
||||
Ok(_) => {
|
||||
h.last_success_ms = Some(now);
|
||||
h.last_error = None;
|
||||
h.auth_failure = false;
|
||||
}
|
||||
Err(e) => {
|
||||
h.auth_failure = is_auth_error(e);
|
||||
h.last_error = Some(e.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Ctx {
|
||||
|
|
@ -87,6 +140,7 @@ impl Daemon {
|
|||
.expect("building the daemon HTTP client"),
|
||||
auth: None,
|
||||
self_update: None,
|
||||
sync_health: Arc::new(Mutex::new(SyncHealth::default())),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
|
@ -135,7 +189,7 @@ impl Daemon {
|
|||
let Some(cfg) = self.ctx.self_update.clone() else {
|
||||
return;
|
||||
};
|
||||
let source = selfupdate::ForgeReleaseSource::new(self.ctx.http.clone());
|
||||
let source = selfupdate::ForgeReleaseSource::new();
|
||||
let installer: std::sync::Arc<dyn selfupdate::Installer> =
|
||||
std::sync::Arc::new(selfupdate::CargoInstaller);
|
||||
let restarter: std::sync::Arc<dyn selfupdate::Restarter> =
|
||||
|
|
@ -170,7 +224,10 @@ impl Daemon {
|
|||
loop {
|
||||
tick.tick().await;
|
||||
let bearer = ctx.bearer().await;
|
||||
match sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await {
|
||||
let result =
|
||||
sync::sync_once(ctx.store.clone(), &hub, &ctx.http, bearer.as_deref()).await;
|
||||
record_sync_outcome(&ctx.sync_health, &result);
|
||||
match result {
|
||||
Ok(report) => tracing::debug!(?report, "background sync"),
|
||||
Err(e) => tracing::warn!("background sync failed: {e}"),
|
||||
}
|
||||
|
|
@ -265,7 +322,9 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
});
|
||||
};
|
||||
let bearer = ctx.bearer().await;
|
||||
match sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await {
|
||||
let result = sync::sync_once(ctx.store.clone(), &hub_url, &ctx.http, bearer.as_deref()).await;
|
||||
record_sync_outcome(&ctx.sync_health, &result);
|
||||
match result {
|
||||
Ok(report) => Ok(json!(report)),
|
||||
Err(e) => Err(RpcError {
|
||||
code: INTERNAL_ERROR,
|
||||
|
|
@ -274,11 +333,28 @@ async fn sync_now(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
}
|
||||
}
|
||||
|
||||
/// `sync.status` — the hub url and the current per-hub cursors.
|
||||
/// `sync.status` — the hub url, the current per-hub cursors, the observed sync
|
||||
/// health (last-success time / last error / auth-failure flag), and the pending
|
||||
/// merge-conflict count. A spoke that is silently failing is visible here (and,
|
||||
/// via it, in the TUI status line).
|
||||
async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
||||
// Conflict count is meaningful even on a hub / standalone instance.
|
||||
let store = ctx.store.clone();
|
||||
let conflicts = tokio::task::spawn_blocking(move || {
|
||||
let guard = store.lock().expect("store mutex poisoned");
|
||||
guard.conflicts_list().map(|c| c.len())
|
||||
})
|
||||
.await
|
||||
.map_err(|e| RpcError {
|
||||
code: INTERNAL_ERROR,
|
||||
message: format!("sync.status task failed: {e}"),
|
||||
})?
|
||||
.map_err(RpcError::from)?;
|
||||
|
||||
let Some(hub_url) = ctx.hub_url.clone() else {
|
||||
return Ok(json!({ "hub_url": Value::Null }));
|
||||
return Ok(json!({ "hub_url": Value::Null, "conflicts": conflicts }));
|
||||
};
|
||||
|
||||
let store = ctx.store.clone();
|
||||
let hub = hub_url.clone();
|
||||
let cursors = tokio::task::spawn_blocking(move || {
|
||||
|
|
@ -291,5 +367,17 @@ async fn sync_status(ctx: &Ctx) -> Result<Value, RpcError> {
|
|||
message: format!("sync.status task failed: {e}"),
|
||||
})?
|
||||
.map_err(RpcError::from)?;
|
||||
Ok(json!({ "hub_url": hub_url, "cursors": cursors }))
|
||||
|
||||
let health = ctx
|
||||
.sync_health
|
||||
.lock()
|
||||
.expect("sync_health mutex poisoned")
|
||||
.clone();
|
||||
|
||||
Ok(json!({
|
||||
"hub_url": hub_url,
|
||||
"cursors": cursors,
|
||||
"conflicts": conflicts,
|
||||
"health": health,
|
||||
}))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,12 @@
|
|||
//! - `POST /rpc` — the full daemon API ([`crate::rpc::dispatch`]) over HTTP, for
|
||||
//! a no-replica `client`-mode [`crate::remote::RemoteStore`] to proxy against.
|
||||
//!
|
||||
//! All routes carry permissive CORS headers and answer the browser preflight
|
||||
//! (`OPTIONS`), so a browser surface (the `heph-pwa` mobile app) can call `/rpc`
|
||||
//! cross-origin. When the hub is given a `web_root`, unmatched paths fall back to
|
||||
//! serving that directory's static files (the PWA shell), so the app can be
|
||||
//! hosted same-origin straight from the hub.
|
||||
//!
|
||||
//! Exchange is **incremental by HLC cursor** (`sync_state`, [`heph_core::SyncCursors`]):
|
||||
//! each side transfers only the tail it hasn't sent/seen. Merge is idempotent,
|
||||
//! so a re-pushed op the hub already has is a harmless no-op. When the hub is
|
||||
|
|
@ -17,13 +23,14 @@
|
|||
//! OIDC bearer token whose `sub` owns the hub (tech-spec §13); spokes attach
|
||||
//! that token via the `bearer` argument to [`sync_once`].
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
use anyhow::Result;
|
||||
use axum::extract::{Query, Request, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::http::{header, HeaderValue, Method, StatusCode, Uri};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::Response as AxumResponse;
|
||||
use axum::response::{IntoResponse, Response as AxumResponse};
|
||||
use axum::routing::{get, post};
|
||||
use axum::{Json, Router};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
|
@ -44,6 +51,9 @@ pub type SharedStore = Arc<Mutex<dyn Store + Send>>;
|
|||
struct HubState {
|
||||
store: SharedStore,
|
||||
verifier: Option<Arc<dyn TokenVerifier>>,
|
||||
/// When set, unmatched paths serve static files from this directory (the
|
||||
/// `heph-pwa` shell), so the app can be hosted same-origin from the hub.
|
||||
web_root: Option<PathBuf>,
|
||||
}
|
||||
|
||||
/// A batch of ops in flight (push body / pull response).
|
||||
|
|
@ -102,15 +112,134 @@ fn apply_batch(
|
|||
/// `verifier` is `Some`, every route requires a valid OIDC bearer token whose
|
||||
/// `sub` owns this hub (tech-spec §13); `None` leaves the hub open (local dev).
|
||||
pub fn router(store: SharedStore, verifier: Option<Arc<dyn TokenVerifier>>) -> Router {
|
||||
let state = HubState { store, verifier };
|
||||
router_with_web(store, verifier, None)
|
||||
}
|
||||
|
||||
/// [`router`] plus an optional `web_root`: when `Some(dir)`, paths that don't
|
||||
/// match an API route serve static files from `dir` (the `heph-pwa` shell),
|
||||
/// with a `index.html` fallback so the single-page app can deep-link. Static
|
||||
/// files are served without authentication — they are only the app shell; all
|
||||
/// data still flows through the auth-gated `/rpc` and `/sync/*` routes.
|
||||
pub fn router_with_web(
|
||||
store: SharedStore,
|
||||
verifier: Option<Arc<dyn TokenVerifier>>,
|
||||
web_root: Option<PathBuf>,
|
||||
) -> Router {
|
||||
let state = HubState {
|
||||
store,
|
||||
verifier,
|
||||
web_root,
|
||||
};
|
||||
Router::new()
|
||||
.route("/sync/pull", get(pull))
|
||||
.route("/sync/push", post(push))
|
||||
.route("/rpc", post(rpc_call))
|
||||
.route_layer(middleware::from_fn_with_state(state.clone(), require_auth))
|
||||
// Unauthenticated: the public OIDC params (issuer + client id) a browser
|
||||
// client reads to start a PKCE login. Added after the auth `route_layer`
|
||||
// so it is NOT gated — the app needs it *before* it has a token.
|
||||
.route("/config", get(config))
|
||||
// The static shell is unauthenticated and lives behind the API routes.
|
||||
.fallback(serve_static)
|
||||
// Outermost: stamp CORS headers on every response and short-circuit the
|
||||
// browser's `OPTIONS` preflight (before it reaches auth or routing).
|
||||
.layer(middleware::from_fn(cors))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Permissive-CORS middleware. Answers the browser preflight (`OPTIONS`) with a
|
||||
/// 204 and stamps `Access-Control-*` headers on every response. The hub is a
|
||||
/// personal endpoint guarded by bearer tokens (not cookies), so a wildcard
|
||||
/// origin is safe — there are no ambient credentials for `*` to expose.
|
||||
async fn cors(request: Request, next: Next) -> AxumResponse {
|
||||
let is_preflight = request.method() == Method::OPTIONS;
|
||||
let mut response = if is_preflight {
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
} else {
|
||||
next.run(request).await
|
||||
};
|
||||
let h = response.headers_mut();
|
||||
h.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_ORIGIN,
|
||||
HeaderValue::from_static("*"),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_METHODS,
|
||||
HeaderValue::from_static("GET, POST, OPTIONS"),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCESS_CONTROL_ALLOW_HEADERS,
|
||||
HeaderValue::from_static("authorization, content-type"),
|
||||
);
|
||||
h.insert(
|
||||
header::ACCESS_CONTROL_MAX_AGE,
|
||||
HeaderValue::from_static("86400"),
|
||||
);
|
||||
response
|
||||
}
|
||||
|
||||
/// Public OIDC parameters for a browser client (the `heph-pwa`) to start a PKCE
|
||||
/// login: `{ "issuer", "client_id" }`. Unauthenticated — neither value is a
|
||||
/// secret. Returns an empty object `{}` when the hub runs without OIDC, so the
|
||||
/// app can detect that and fall back to a manually pasted token.
|
||||
async fn config(State(state): State<HubState>) -> Json<Value> {
|
||||
let body = state
|
||||
.verifier
|
||||
.as_ref()
|
||||
.and_then(|v| v.oidc_config())
|
||||
.map(|(issuer, client_id)| serde_json::json!({ "issuer": issuer, "client_id": client_id }))
|
||||
.unwrap_or_else(|| serde_json::json!({}));
|
||||
Json(body)
|
||||
}
|
||||
|
||||
/// Serve the PWA shell from `web_root` for any non-API path. Returns 404 when no
|
||||
/// `web_root` is configured. Unknown paths fall back to `index.html` so the SPA
|
||||
/// can own its own routing. Path traversal (`..`) is rejected.
|
||||
async fn serve_static(State(state): State<HubState>, uri: Uri) -> AxumResponse {
|
||||
let Some(root) = state.web_root.as_ref() else {
|
||||
return StatusCode::NOT_FOUND.into_response();
|
||||
};
|
||||
let rel = uri.path().trim_start_matches('/');
|
||||
if rel.split('/').any(|seg| seg == "..") {
|
||||
return StatusCode::BAD_REQUEST.into_response();
|
||||
}
|
||||
let rel = if rel.is_empty() { "index.html" } else { rel };
|
||||
|
||||
let direct = root.join(rel);
|
||||
let index = root.join("index.html");
|
||||
// File reads run on the blocking pool (tokio's `fs` feature is off, and DB /
|
||||
// disk I/O never runs on an async worker, tech-spec §3).
|
||||
let read = tokio::task::spawn_blocking(move || {
|
||||
match std::fs::read(&direct) {
|
||||
Ok(bytes) => Some((content_type(&direct), bytes)),
|
||||
// SPA fallback: serve index.html for unknown (extension-less) routes.
|
||||
Err(_) => std::fs::read(&index)
|
||||
.ok()
|
||||
.map(|bytes| ("text/html; charset=utf-8", bytes)),
|
||||
}
|
||||
})
|
||||
.await;
|
||||
match read {
|
||||
Ok(Some((ctype, bytes))) => ([(header::CONTENT_TYPE, ctype)], bytes).into_response(),
|
||||
_ => StatusCode::NOT_FOUND.into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Best-effort content type from a file extension (the handful the PWA serves).
|
||||
fn content_type(path: &std::path::Path) -> &'static str {
|
||||
match path.extension().and_then(|e| e.to_str()) {
|
||||
Some("html") => "text/html; charset=utf-8",
|
||||
Some("js" | "mjs") => "text/javascript; charset=utf-8",
|
||||
Some("css") => "text/css; charset=utf-8",
|
||||
Some("json" | "webmanifest") => "application/json; charset=utf-8",
|
||||
Some("svg") => "image/svg+xml",
|
||||
Some("png") => "image/png",
|
||||
Some("ico") => "image/x-icon",
|
||||
Some("woff2") => "font/woff2",
|
||||
_ => "application/octet-stream",
|
||||
}
|
||||
}
|
||||
|
||||
/// Reject any request lacking a valid bearer token whose `sub` owns this hub.
|
||||
/// A no-op when the hub has no verifier configured (open dev mode).
|
||||
async fn require_auth(
|
||||
|
|
|
|||
213
crates/hephd/tests/web_serve.rs
Normal file
213
crates/hephd/tests/web_serve.rs
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
//! The hub's browser-facing surface (for the `heph-pwa` mobile app): permissive
|
||||
//! CORS on every response, an `OPTIONS` preflight answer, and—when a `web_root`
|
||||
//! is configured—static serving of the app shell with an `index.html` SPA
|
||||
//! fallback. A tiny raw-HTTP client keeps this dependency-free and lets us drive
|
||||
//! arbitrary methods (`OPTIONS`) and inspect response headers directly.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::net::TcpStream;
|
||||
use std::sync::mpsc;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::thread;
|
||||
use std::time::Duration;
|
||||
|
||||
use heph_core::{FixedClock, LocalStore};
|
||||
use hephd::auth::{AuthError, Claims, TokenVerifier};
|
||||
use hephd::sync::{self, SharedStore};
|
||||
|
||||
const NOW: i64 = 1_704_067_200_000; // 2024-01-01T00:00:00Z
|
||||
|
||||
/// A verifier that never admits a token but advertises OIDC params, so we can
|
||||
/// drive the unauthenticated `/config` route without a live IdP.
|
||||
struct StubOidc;
|
||||
impl TokenVerifier for StubOidc {
|
||||
fn verify(&self, _bearer: &str) -> Result<Claims, AuthError> {
|
||||
Err(AuthError::Missing)
|
||||
}
|
||||
fn oidc_config(&self) -> Option<(&str, &str)> {
|
||||
Some(("https://idp.example/application/o/heph/", "heph"))
|
||||
}
|
||||
}
|
||||
|
||||
/// One parsed HTTP response: status line code, lowercased headers, and body.
|
||||
struct Resp {
|
||||
status: u16,
|
||||
headers: Vec<(String, String)>,
|
||||
body: String,
|
||||
}
|
||||
|
||||
impl Resp {
|
||||
fn header(&self, name: &str) -> Option<&str> {
|
||||
let name = name.to_ascii_lowercase();
|
||||
self.headers
|
||||
.iter()
|
||||
.find(|(k, _)| *k == name)
|
||||
.map(|(_, v)| v.as_str())
|
||||
}
|
||||
}
|
||||
|
||||
/// Issue one HTTP/1.1 request over a fresh connection (`Connection: close`, so
|
||||
/// we can read the whole response to EOF) and parse the response.
|
||||
fn request(addr: &str, method: &str, path: &str) -> Resp {
|
||||
let mut stream = TcpStream::connect(addr).unwrap();
|
||||
let req = format!("{method} {path} HTTP/1.1\r\nHost: {addr}\r\nConnection: close\r\n\r\n");
|
||||
stream.write_all(req.as_bytes()).unwrap();
|
||||
let mut raw = String::new();
|
||||
stream.read_to_string(&mut raw).unwrap();
|
||||
|
||||
let (head, body) = raw.split_once("\r\n\r\n").unwrap_or((&raw, ""));
|
||||
let mut lines = head.split("\r\n");
|
||||
let status = lines
|
||||
.next()
|
||||
.and_then(|l| l.split_whitespace().nth(1))
|
||||
.and_then(|c| c.parse().ok())
|
||||
.unwrap();
|
||||
let headers = lines
|
||||
.filter_map(|l| l.split_once(": "))
|
||||
.map(|(k, v)| (k.to_ascii_lowercase(), v.to_string()))
|
||||
.collect();
|
||||
Resp {
|
||||
status,
|
||||
headers,
|
||||
body: body.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the hub router (with the given `web_root`) over a temp `LocalStore` on
|
||||
/// an ephemeral port; return its `host:port`. The server thread + temp dirs live
|
||||
/// for the test's duration.
|
||||
fn start(web_root: Option<std::path::PathBuf>) -> String {
|
||||
start_with(None, web_root)
|
||||
}
|
||||
|
||||
/// As [`start`], but with an explicit token verifier (to exercise the `/config`
|
||||
/// route, which reports the verifier's OIDC params).
|
||||
fn start_with(
|
||||
verifier: Option<Arc<dyn TokenVerifier>>,
|
||||
web_root: Option<std::path::PathBuf>,
|
||||
) -> String {
|
||||
let (tx, rx) = mpsc::channel();
|
||||
thread::spawn(move || {
|
||||
let rt = tokio::runtime::Builder::new_current_thread()
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
rt.block_on(async move {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let store =
|
||||
LocalStore::open(dir.path().join("heph.db"), Box::new(FixedClock(NOW))).unwrap();
|
||||
let shared: SharedStore = Arc::new(Mutex::new(store));
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
tx.send(listener.local_addr().unwrap()).unwrap();
|
||||
let _keep = dir;
|
||||
let app = sync::router_with_web(shared, verifier, web_root);
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
});
|
||||
rx.recv_timeout(Duration::from_secs(5)).unwrap().to_string()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cors_headers_on_rpc_and_preflight_answered() {
|
||||
let addr = start(None);
|
||||
|
||||
// The browser preflight gets a 204 with the CORS allowances, without auth.
|
||||
let pre = request(&addr, "OPTIONS", "/rpc");
|
||||
assert_eq!(pre.status, 204);
|
||||
assert_eq!(pre.header("access-control-allow-origin"), Some("*"));
|
||||
assert!(pre
|
||||
.header("access-control-allow-headers")
|
||||
.unwrap()
|
||||
.contains("authorization"));
|
||||
assert!(pre
|
||||
.header("access-control-allow-methods")
|
||||
.unwrap()
|
||||
.contains("POST"));
|
||||
|
||||
// A regular GET also carries the origin header (so XHR can read the body).
|
||||
let get = request(&addr, "GET", "/sync/pull");
|
||||
assert_eq!(get.header("access-control-allow-origin"), Some("*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn serves_static_shell_with_index_fallback() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
std::fs::write(
|
||||
dir.path().join("index.html"),
|
||||
"<!doctype html><title>heph</title>",
|
||||
)
|
||||
.unwrap();
|
||||
std::fs::write(dir.path().join("app.js"), "export const x = 1;\n").unwrap();
|
||||
let addr = start(Some(dir.path().to_path_buf()));
|
||||
|
||||
// Root serves index.html as HTML.
|
||||
let root = request(&addr, "GET", "/");
|
||||
assert_eq!(root.status, 200);
|
||||
assert!(root.body.contains("<title>heph</title>"));
|
||||
assert_eq!(
|
||||
root.header("content-type"),
|
||||
Some("text/html; charset=utf-8")
|
||||
);
|
||||
|
||||
// A real asset is served with a JS content type.
|
||||
let js = request(&addr, "GET", "/app.js");
|
||||
assert_eq!(js.status, 200);
|
||||
assert!(js.body.contains("export const x"));
|
||||
assert_eq!(
|
||||
js.header("content-type"),
|
||||
Some("text/javascript; charset=utf-8")
|
||||
);
|
||||
|
||||
// An unknown (extension-less) route falls back to index.html for the SPA.
|
||||
let deep = request(&addr, "GET", "/inbox");
|
||||
assert_eq!(deep.status, 200);
|
||||
assert!(deep.body.contains("<title>heph</title>"));
|
||||
|
||||
// Path traversal never escapes web_root (whether the client/proxy normalizes
|
||||
// the `..` away or our guard rejects it, the crate's Cargo.toml never leaks).
|
||||
let escape = request(&addr, "GET", "/../../Cargo.toml");
|
||||
assert!(
|
||||
!escape.body.contains("[package]"),
|
||||
"must not serve files outside web_root"
|
||||
);
|
||||
|
||||
// The temp dir must outlive the server thread's reads.
|
||||
drop(dir);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_web_root_yields_404_for_static_paths() {
|
||||
let addr = start(None);
|
||||
let resp = request(&addr, "GET", "/inbox");
|
||||
assert_eq!(resp.status, 404);
|
||||
// Even the 404 carries CORS headers (it passed through the layer).
|
||||
assert_eq!(resp.header("access-control-allow-origin"), Some("*"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_is_empty_without_oidc() {
|
||||
let addr = start(None);
|
||||
let resp = request(&addr, "GET", "/config");
|
||||
assert_eq!(resp.status, 200);
|
||||
assert_eq!(resp.body.trim(), "{}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn config_reports_oidc_params_unauthenticated() {
|
||||
// Even on an authed hub, /config is reachable without a token (it is added
|
||||
// after the auth layer) and reports the issuer + public client id.
|
||||
let addr = start_with(Some(Arc::new(StubOidc)), None);
|
||||
let resp = request(&addr, "GET", "/config");
|
||||
assert_eq!(resp.status, 200);
|
||||
assert!(
|
||||
resp.body
|
||||
.contains("\"issuer\":\"https://idp.example/application/o/heph/\""),
|
||||
"body was: {}",
|
||||
resp.body
|
||||
);
|
||||
assert!(
|
||||
resp.body.contains("\"client_id\":\"heph\""),
|
||||
"body was: {}",
|
||||
resp.body
|
||||
);
|
||||
}
|
||||
1
docs/changelog.d/+sync-age-seconds.feature.md
Normal file
1
docs/changelog.d/+sync-age-seconds.feature.md
Normal file
|
|
@ -0,0 +1 @@
|
|||
heph-tui's sync indicator now shows the last-sync age in seconds under a minute (`⟳ 26s`) instead of a flat `just now`, so the chip reads as a live heartbeat and a missed sync (the loop runs every 30s) shows up as the age climbing.
|
||||
|
|
@ -12,3 +12,4 @@ Background context and design decisions.
|
|||
|
||||
- [[design]] — Hephaestus design document: vision, data model, architecture, sync, and roadmap
|
||||
- [[task-lifecycle]] — the two-axis task model (lifecycle state × attention), drop vs delete, and where each task shows up
|
||||
- [[hub-spoke-data-evolution]] — why op-based sync lets most new features skip migrations, and when a coordinated SQLite migration is actually required
|
||||
|
|
|
|||
87
docs/explanation/hub-spoke-data-evolution.md
Normal file
87
docs/explanation/hub-spoke-data-evolution.md
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
---
|
||||
title: Hub + Spoke Data Evolution
|
||||
modified: 2026-06-05
|
||||
tags:
|
||||
- explanation
|
||||
- sync
|
||||
---
|
||||
|
||||
# Hub + Spoke Data Evolution
|
||||
|
||||
How the data model evolves safely when nodes run different versions across the
|
||||
hub/spoke deployment (indri is the hub; see [[set-up-sync-hub]] and
|
||||
[[host-heph-pwa]]). The short version: **sync is op-based, not schema-based**, so
|
||||
most new features need no coordinated migration — but adding a SQLite *column*
|
||||
does.
|
||||
|
||||
## Two independent layers
|
||||
|
||||
heph keeps two layers that evolve on different clocks:
|
||||
|
||||
1. **The op-log (synced).** Every change is an operation — `node.create`,
|
||||
`node.set`, `task.set`, `link.add`, `link.remove`, … — carrying an HLC, an
|
||||
origin device, and a JSON payload. Spokes push/pull ops to/from the hub; both
|
||||
sides run the **same** merge logic from `heph-core` (`sqlite/apply.rs`). This
|
||||
is the only thing that crosses the wire.
|
||||
2. **The SQLite schema (local, per node).** Each node materializes ops into local
|
||||
tables. The schema version is tracked by SQLite's `PRAGMA user_version` and
|
||||
advanced by the ordered, append-only migration list in
|
||||
`heph-core/src/sqlite/migrations.rs`. **No schema or migration state is ever
|
||||
synced.** A spoke can sit on an older schema than the hub indefinitely.
|
||||
|
||||
Because the wire format is ops — not rows — a node only has to understand the
|
||||
*ops* its peers emit, not their table layout.
|
||||
|
||||
## What forward/backward compatibility already buys you
|
||||
|
||||
The merge engine is deliberately lenient:
|
||||
|
||||
- **Unknown op types are stored but not applied** (`apply.rs`) — a spoke that
|
||||
receives a newer op type keeps it in the log (so a later upgrade can replay it)
|
||||
but doesn't choke on it.
|
||||
- **Unknown payload fields are ignored.** Field extraction is by name
|
||||
(`str_field` / `i64_field`), so a payload with extra keys an older node doesn't
|
||||
recognize just drops the extras.
|
||||
- **Links are schema-free.** A link's `type` is a string column. A brand-new link
|
||||
kind (a new `LinkType`) needs no migration — every version reads it as text and
|
||||
applies OR-set add/remove identically.
|
||||
|
||||
## The rule of thumb
|
||||
|
||||
| Change | Needs coordinated migration? |
|
||||
|--------|------------------------------|
|
||||
| New `LinkType` (e.g. a new relationship between nodes) | **No** — just emit `link.add` with the new `type` string |
|
||||
| New optional/nullable scalar carried in an op payload | **No, if** every node's `apply` reads it defensively and tolerates its absence |
|
||||
| New *read-side* feature over existing data (counts, hierarchy from existing `parent` links) | **No** — pure local queries, no op or schema change |
|
||||
| New **required** SQLite column that `apply` must write on every relevant op | **Yes** — old spokes lack the column and the `UPDATE` fails |
|
||||
| Renaming/removing a column other nodes' `apply` paths reference | **Yes** |
|
||||
|
||||
## When a migration *is* required, do it hub-first
|
||||
|
||||
If a change genuinely needs a new column that the apply path writes:
|
||||
|
||||
1. Ship the migration to **every** node (hub and all spokes) **before** any node
|
||||
emits an op that depends on the new column. The migration list is
|
||||
append-only and ordered, so rolling the new `hephd` out everywhere is the
|
||||
gate.
|
||||
2. Keep new columns **nullable / defaulted** so an op that predates the column
|
||||
still applies, and so a node that hasn't yet upgraded degrades to "field
|
||||
absent" rather than erroring.
|
||||
3. Prefer encoding the new fact as a **link or an op-payload field** over a new
|
||||
column whenever you can — that keeps the change in the no-migration column of
|
||||
the table above.
|
||||
|
||||
## Worked example: indented, counted projects
|
||||
|
||||
The sidebar's subproject indentation and per-project task counts (see
|
||||
[[install-heph]] and the agenda surface in [[design]] §8.1) are a pure read-side
|
||||
feature:
|
||||
|
||||
- **Nesting** is read from `parent` links that already exist — created by
|
||||
`heph project add <name> --parent <parent>` — via the existing
|
||||
`project_subtree` traversal.
|
||||
- **Counts** are a read-only `SELECT … GROUP BY` over the `tasks`/`links` tables.
|
||||
|
||||
No new column, no new op type, no migration — it works against a hub and a spoke
|
||||
on any schema version that already understands `parent` links. That is the case
|
||||
the rule of thumb is meant to make obvious.
|
||||
119
docs/how-to/heph-pwa.md
Normal file
119
docs/how-to/heph-pwa.md
Normal file
|
|
@ -0,0 +1,119 @@
|
|||
---
|
||||
title: heph-pwa (mobile app)
|
||||
modified: 2026-06-04
|
||||
tags:
|
||||
- how-to
|
||||
---
|
||||
|
||||
# heph-pwa — the mobile app
|
||||
|
||||
`heph-pwa` is a phone-first, installable web app that mirrors [[v1-prototype-tech-spec|heph-tui]]:
|
||||
browse the built-in views and projects, triage tasks, and — the primary use
|
||||
case — **capture tasks fast** with the same quick-add syntax as the TUI's `a` /
|
||||
Cmd-' popover. Context/KB is **read-only** here (no Neovim editing surface).
|
||||
|
||||
It is a thin, online-only client: every read and write is a JSON-RPC call to a
|
||||
**server-mode `hephd`** (the sync hub, see [[set-up-sync-hub]]). There is no
|
||||
local replica or background sync — when the hub is unreachable, the app shows an
|
||||
error rather than queueing offline.
|
||||
|
||||
> **Why a PWA and not native iOS?** A native Swift app cannot be signed, built,
|
||||
> or installed without an Apple Developer account. A PWA delivers the primary
|
||||
> use case today — installable to the home screen, full-screen, with home-screen
|
||||
> launch and offline app-shell — and keeps the door open to a native wrapper
|
||||
> later. This was a deliberate first-cut choice; revisit if a native app becomes
|
||||
> worthwhile.
|
||||
|
||||
## Serve it from the hub
|
||||
|
||||
The hub can serve the app shell same-origin (no CORS or separate static host
|
||||
needed). Point `hephd` at the `heph-pwa/` directory:
|
||||
|
||||
```bash
|
||||
hephd --mode server \
|
||||
--http-addr 0.0.0.0:8787 \
|
||||
--web-root /path/to/hephaestus/heph-pwa \
|
||||
--oidc-issuer https://auth.example.com/... \
|
||||
--oidc-audience heph-mobile
|
||||
```
|
||||
|
||||
- `--web-root` is optional. Unset, the hub serves only its API routes (unchanged
|
||||
behavior). Set, it serves the static shell for any non-API path, with an
|
||||
`index.html` SPA fallback. The shell is unauthenticated (it's just HTML/JS);
|
||||
all data still flows through the auth-gated `/rpc`.
|
||||
- Every hub response now carries permissive CORS headers and answers the browser
|
||||
`OPTIONS` preflight, so you can alternatively host the shell anywhere (any
|
||||
static server, GitHub Pages, etc.) and still call the hub cross-origin.
|
||||
|
||||
Then open `https://<hub-host>:8787/` on your phone and **Add to Home Screen**.
|
||||
|
||||
## Connect
|
||||
|
||||
On first launch the app opens **Settings**:
|
||||
|
||||
- **Hub URL** — the server-mode `hephd` base URL (e.g. `https://hub.example.com:8787`).
|
||||
When served from the hub, use that same origin.
|
||||
- **Token** — a bearer token, if the hub requires OIDC (`--oidc-issuer`/`-audience`).
|
||||
Leave blank for an unauthenticated hub (local network / dev). Tap **Test** to
|
||||
verify the connection (it calls the `version` RPC).
|
||||
|
||||
Settings persist in the browser's local storage.
|
||||
|
||||
> The device-code OIDC login flow (RFC 8628) the CLI/daemon use is **not** yet
|
||||
> wired into the PWA — for now paste a bearer token obtained out-of-band. Wiring
|
||||
> the in-app device flow is the obvious next step.
|
||||
|
||||
## Quick-add
|
||||
|
||||
Tap **+** (or press Cmd-' / Ctrl-' on a keyboard) to capture. The single input
|
||||
accepts the exact [[v1-prototype-tech-spec|tech-spec §8.1]] syntax, parsed live
|
||||
into preview chips before you submit:
|
||||
|
||||
| Token | Example | Effect |
|
||||
|-------|---------|--------|
|
||||
| `p1`–`p4` | `p1` | attention: red / orange / blue / white |
|
||||
| `#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) |
|
||||
|
||||
Unmatched `#tags` stay in the title verbatim. With no `#Project` token, the task
|
||||
files into the currently selected project (or Inbox). The parser is a faithful
|
||||
JS port of the Rust `quickadd`/`datespec` modules, covered by parity tests
|
||||
(`heph-pwa/test/parsers.test.mjs`, run with `node --test`).
|
||||
|
||||
## Voice
|
||||
|
||||
The quick-add field supports voice two ways:
|
||||
|
||||
- **iOS / iPadOS:** use the **microphone key on the on-screen keyboard** — Apple
|
||||
dictation works in the text field for free, no app permission needed.
|
||||
- **Chrome / Android / desktop:** a 🎤 button appears when the Web Speech API is
|
||||
available and dictates straight into the field.
|
||||
|
||||
(Anthropic has no speech-to-text endpoint, so transcription leans on the
|
||||
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**
|
||||
(tombstone, `D`). Done/Drop show an **Undo**. The expanded view also shows the
|
||||
task's canonical-context body + recent log tail (read-only).
|
||||
|
||||
Search (🔍 or `/`) runs full-text search across tasks and docs.
|
||||
|
||||
## Known limitations (first cut)
|
||||
|
||||
- Online-only; no offline write queue or CRDT replica.
|
||||
- No in-app OIDC device-code login yet (paste a token).
|
||||
- Context/KB is read-only (no wiki-link navigation or editing).
|
||||
- Undo covers Done/Drop only.
|
||||
|
||||
## Related
|
||||
|
||||
- [[host-heph-pwa]] — serve this app from the hub (indri) with OIDC, in the hub/spoke deployment
|
||||
- [[set-up-sync-hub]] — stand up the server-mode hub the app talks to
|
||||
- [[run-the-daemon]] — run `hephd` as a managed service
|
||||
- [[v1-prototype-tech-spec]] — data model, RPC API, quick-add spec
|
||||
- [[design]] — vision and rationale
|
||||
135
docs/how-to/host-heph-pwa.md
Normal file
135
docs/how-to/host-heph-pwa.md
Normal file
|
|
@ -0,0 +1,135 @@
|
|||
---
|
||||
title: Host heph-pwa from the hub
|
||||
modified: 2026-06-04
|
||||
tags:
|
||||
- how-to
|
||||
---
|
||||
|
||||
# Host heph-pwa from the hub
|
||||
|
||||
How to serve the [[heph-pwa]] mobile app from the canonical **hub** (`indri`) in
|
||||
the hub-and-spoke deployment, with OIDC auth — the production counterpart of the
|
||||
unauthenticated single-machine demo. Assumes the `heph-pwa` work is **merged and
|
||||
released**, so the installed `hephd` already has `--web-root` and CORS.
|
||||
|
||||
> Read [[set-up-sync-hub]] first — this builds directly on the hub it stands up
|
||||
> (server mode, Authentik OIDC, Tailscale transport).
|
||||
|
||||
## What the app needs from the hub
|
||||
|
||||
The PWA is a thin, online-only client: it loads its static shell over HTTP and
|
||||
makes JSON-RPC calls to the hub's `/rpc`. So the hub must (1) serve the shell
|
||||
files and (2) accept the app's authenticated RPC calls. Both are already in
|
||||
`hephd --mode server`:
|
||||
|
||||
- `--web-root <dir>` serves the shell for any non-API path (with an `index.html`
|
||||
SPA fallback). The shell is unauthenticated — it is only HTML/JS; all data
|
||||
still flows through the OIDC-gated `/rpc`.
|
||||
- Every response carries permissive CORS headers and answers the `OPTIONS`
|
||||
preflight, so the shell may instead be hosted anywhere and still call the hub
|
||||
cross-origin.
|
||||
|
||||
## 1. Put the shell on the hub
|
||||
|
||||
The release does not yet bundle the app, so fetch the `heph-pwa/` directory at
|
||||
the **same version tag** the hub runs (keeping shell and hub in lockstep matters
|
||||
— see *Upgrades* below), and copy it to a stable path:
|
||||
|
||||
```bash
|
||||
# on indri, matching the running hephd version (e.g. v1.4.0)
|
||||
git clone --depth 1 --branch v1.4.0 \
|
||||
https://forge.ops.eblu.me/eblume/hephaestus.git /tmp/heph-src
|
||||
sudo mkdir -p /var/lib/heph/web
|
||||
sudo cp -r /tmp/heph-src/heph-pwa/. /var/lib/heph/web/
|
||||
```
|
||||
|
||||
> **Future improvement:** have the release workflow package a `heph-pwa-<version>.tar.gz`
|
||||
> asset (as it already does for docs), so this step becomes "download + extract"
|
||||
> and the lockstep is automatic. Until then, pin the clone to the hub's tag.
|
||||
|
||||
## 2. Add `--web-root` to the hub service
|
||||
|
||||
Extend the hub invocation from [[set-up-sync-hub]] with `--web-root` (everything
|
||||
else — issuer, audience, db — unchanged):
|
||||
|
||||
```bash
|
||||
hephd --mode server \
|
||||
--http-addr 0.0.0.0:8787 \
|
||||
--db /var/lib/heph/heph.db \
|
||||
--web-root /var/lib/heph/web \
|
||||
--oidc-issuer https://authentik.ops.eblu.me/application/o/heph/ \
|
||||
--oidc-audience <heph-client-id>
|
||||
```
|
||||
|
||||
In the systemd unit (or launchd plist), add the two `--web-root` arguments and
|
||||
`systemctl restart hephd`. Self-update is compatible now that the release ships
|
||||
the flag — just refresh the web-root on each upgrade (next section).
|
||||
|
||||
## 3. Terminate TLS (recommended)
|
||||
|
||||
Serve the app over **HTTPS** so it is a *secure context*: only then do the
|
||||
service worker (offline launch), proper PWA install, and the Web Speech mic
|
||||
work. (On iOS, "Add to Home Screen" and keyboard dictation work over plain HTTP
|
||||
too, so HTTPS is a polish step, not a blocker.) Two good options:
|
||||
|
||||
- **Tailscale serve** — tailnet-only, automatic MagicDNS cert, no public
|
||||
exposure:
|
||||
|
||||
```bash
|
||||
tailscale serve --bg --https=443 http://127.0.0.1:8787
|
||||
# app is then at https://indri.<tailnet>.ts.net/
|
||||
```
|
||||
|
||||
Bind `hephd` to `127.0.0.1:8787` in this case and let Tailscale be the only
|
||||
thing exposing it.
|
||||
|
||||
- **Reverse proxy** (Caddy / nginx) terminating a real cert, if the hub should
|
||||
be reachable beyond the tailnet. Proxy all paths (`/`, `/rpc`, `/sync/*`) to
|
||||
`hephd`.
|
||||
|
||||
Either way the app is same-origin with the hub, so no CORS is involved and the
|
||||
app defaults its hub URL to its own origin.
|
||||
|
||||
## 4. Connect a phone
|
||||
|
||||
1. Ensure the phone is on the tailnet (or can reach the proxy).
|
||||
2. Open the hub URL (`https://indri.<tailnet>.ts.net/`) and **Add to Home Screen**.
|
||||
3. The app defaults its **Hub URL** to the origin it loaded from — no typing.
|
||||
4. **Sign in:** open **Settings → Login with Authentik**. The app reads the
|
||||
hub's `GET /config` for the issuer + client id (zero-config) and runs an
|
||||
Authorization-Code + PKCE redirect to Authentik; after you approve it lands
|
||||
back on the app, signed in, and silently refreshes the token from then on.
|
||||
(A manual **Bearer token** field remains as a fallback for hubs without
|
||||
OIDC, or for pasting a one-off token.)
|
||||
|
||||
> Re-prompted for login too often? The fix is the Authentik provider's
|
||||
> **refresh token validity**, not the app — see the token-lifetime note in
|
||||
> [[set-up-sync-hub]]. (On iOS, Safari may also purge an un-installed PWA's
|
||||
> storage after ~7 idle days; Add to Home Screen mitigates it.)
|
||||
|
||||
**Prerequisite — register the PWA redirect URI.** Browser PKCE needs the app's
|
||||
origin registered on the Authentik `heph` provider's **Redirect URIs** (Authentik
|
||||
also keys token-endpoint CORS off those origins). Add the PWA origin(s) with a
|
||||
trailing slash, e.g. `https://heph.ops.eblu.me/` (and `http://localhost:8787/`
|
||||
for local dev). In blumeops this is the `redirect_uris` list on the heph
|
||||
provider blueprint.
|
||||
|
||||
## Upgrades
|
||||
|
||||
On each hub upgrade, refresh the shell so it matches the running `hephd`:
|
||||
|
||||
```bash
|
||||
git -C /tmp/heph-src fetch --depth 1 origin v1.5.0 && git -C /tmp/heph-src checkout v1.5.0
|
||||
sudo cp -r /tmp/heph-src/heph-pwa/. /var/lib/heph/web/
|
||||
```
|
||||
|
||||
The service worker is versioned (`CACHE = "heph-pwa-vN"`), so an updated shell
|
||||
evicts the old cache on next load. Hard-refresh once if a phone seems stuck on a
|
||||
stale version.
|
||||
|
||||
## Related
|
||||
|
||||
- [[heph-pwa]] — the app itself (features, quick-add, voice, triage)
|
||||
- [[set-up-sync-hub]] — stand up the hub + Authentik OIDC this doc extends
|
||||
- [[run-the-daemon]] — run `hephd` as a managed service
|
||||
- [[v1-prototype-tech-spec]] — RPC API and auth model
|
||||
|
|
@ -21,3 +21,5 @@ Task-oriented guides for common operations.
|
|||
- [[set-up-sync-hub]] — Stand up the canonical hub (indri) and connect an existing device as an offline-capable spoke
|
||||
- [[import-todoist]] — Seed a heph store from your Todoist projects + tasks (`mise run import-todoist`)
|
||||
- [[self-update]] — Opt-in `hephd` self-update: poll the forge for new releases and auto-update
|
||||
- [[heph-pwa]] — The mobile app: an installable PWA mirror of heph-tui (browse, triage, fast quick-add, voice)
|
||||
- [[host-heph-pwa]] — Serve the mobile app from the hub (indri) with OIDC, in the hub/spoke deployment
|
||||
|
|
|
|||
|
|
@ -51,6 +51,26 @@ need:
|
|||
- **Issuer** — e.g. `https://authentik.ops.eblu.me/application/o/heph/`
|
||||
- **Client id** — the device-code client id (this is also the token *audience*).
|
||||
|
||||
### Token lifetime (avoid frequent re-logins)
|
||||
|
||||
Token lifetimes are set on the Authentik **provider**, not in heph — heph honors
|
||||
whatever `expires_in` Authentik returns and silently refreshes using the
|
||||
`offline_access` refresh token (both the CLI/daemon and the PWA do this). To
|
||||
avoid re-authenticating often, set generous validities on the heph provider:
|
||||
|
||||
- **Access token validity** — e.g. `hours=24`. The hub validates `exp` and keeps
|
||||
no revocation list, so this is the window in which a leaked token stays usable;
|
||||
on a Tailscale-only hub, 24–48h is a reasonable trade.
|
||||
- **Refresh token validity** — e.g. `days=30`+. This is the setting that stops
|
||||
the re-logins: while the refresh token is valid, the spoke **and** the PWA
|
||||
renew silently with no browser round-trip. A short refresh window is the usual
|
||||
cause of "I have to log in constantly".
|
||||
|
||||
> **iOS PWA caveat:** Safari can purge an *un-installed* PWA's `localStorage`
|
||||
> (where its tokens live) after ~7 idle days regardless of these settings.
|
||||
> Installing the app to the home screen mitigates it, but expect the occasional
|
||||
> re-login on iOS.
|
||||
|
||||
## 2. Bring up the hub on `indri`
|
||||
|
||||
**Seed it from `gilbert` (Path A).** Quiesce `gilbert` (`heph daemon stop`),
|
||||
|
|
@ -98,10 +118,16 @@ and background-syncs on its interval.
|
|||
## 4. Verify
|
||||
|
||||
```bash
|
||||
heph sync --status # last push/pull cursors, hub url
|
||||
heph sync --status # hub url, last push/pull cursors, sync health
|
||||
heph sync # force a cycle now
|
||||
```
|
||||
|
||||
`heph sync --status` also reports **sync health** — the time of the last
|
||||
successful exchange, any last error, and whether the spoke is currently failing
|
||||
to authenticate. The same signal is surfaced live in `heph-tui`'s status line
|
||||
(last-sync age · pending conflicts · an auth-failure flag), so a silently-broken
|
||||
spoke is visible at a glance rather than buried in the daemon log.
|
||||
|
||||
Make a change on `gilbert`, force a sync, and confirm it appears via the hub.
|
||||
|
||||
## Current gaps (finalized by the blumeops deployment)
|
||||
|
|
|
|||
63
heph-pwa/README.md
Normal file
63
heph-pwa/README.md
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
# heph-pwa
|
||||
|
||||
A phone-first, installable **Progressive Web App** that mirrors `heph-tui`:
|
||||
browse the built-in views and projects, triage tasks, and — the primary use
|
||||
case — capture tasks fast with the same quick-add syntax as the TUI's `a` /
|
||||
Cmd-' popover. Context/KB is read-only here.
|
||||
|
||||
Full guide: [`docs/how-to/heph-pwa.md`](../docs/how-to/heph-pwa.md).
|
||||
|
||||
## What it is
|
||||
|
||||
- **Thin, online-only client.** Every read/write is a JSON-RPC call to a
|
||||
server-mode `hephd` (the sync hub). No local replica, no offline write queue.
|
||||
- **Buildless.** Plain ES modules, no bundler, no `npm install`. Serve the
|
||||
directory and go.
|
||||
- **Same parser as the TUI.** `src/quickadd.js` + `src/datespec.js` are faithful
|
||||
ports of the Rust `hephd::quickadd` / `hephd::datespec` modules, verified by
|
||||
parity tests against the original Rust unit cases.
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
index.html # app shell
|
||||
styles.css # dark, terminal-flavored, touch-tuned
|
||||
manifest.webmanifest # PWA manifest (installable)
|
||||
sw.js # service worker — caches the app shell for offline launch
|
||||
icons/ # app icons (svg + rasterized png, incl. maskable)
|
||||
src/
|
||||
app.js # UI controller: views, list, quick-add, triage, search, voice
|
||||
rpc.js # hephd JSON-RPC-over-HTTP client + settings (localStorage)
|
||||
quickadd.js # quick-add parser (port of quickadd.rs)
|
||||
datespec.js # date + recurrence parser (port of datespec.rs)
|
||||
fmt.js # display helpers (date chips, attention colors, bullets)
|
||||
test/
|
||||
parsers.test.mjs # parity tests for the parser ports
|
||||
```
|
||||
|
||||
## Run it
|
||||
|
||||
Serve from the hub (recommended — same-origin, no CORS):
|
||||
|
||||
```bash
|
||||
hephd --mode server --http-addr 0.0.0.0:8787 --web-root /path/to/heph-pwa
|
||||
# then open http://<host>:8787/ on your phone and Add to Home Screen
|
||||
```
|
||||
|
||||
Or from any static server (the hub now sends CORS headers, so cross-origin
|
||||
`/rpc` calls work); set the hub URL in the app's Settings screen.
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
node --test heph-pwa/test/parsers.test.mjs
|
||||
```
|
||||
|
||||
## Status / next steps
|
||||
|
||||
First cut (C1). Known gaps, roughly in priority order:
|
||||
|
||||
- In-app OIDC device-code login (today: paste a bearer token in Settings).
|
||||
- Offline write queue / CRDT replica (today: online-only).
|
||||
- Read-only context could grow wiki-link navigation.
|
||||
- A native Swift wrapper, if/when an Apple Developer account is in play.
|
||||
BIN
heph-pwa/icons/icon-180.png
Normal file
BIN
heph-pwa/icons/icon-180.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.3 KiB |
BIN
heph-pwa/icons/icon-192.png
Normal file
BIN
heph-pwa/icons/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
BIN
heph-pwa/icons/icon-512.png
Normal file
BIN
heph-pwa/icons/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 10 KiB |
BIN
heph-pwa/icons/icon-maskable.png
Normal file
BIN
heph-pwa/icons/icon-maskable.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
14
heph-pwa/icons/icon.svg
Normal file
14
heph-pwa/icons/icon.svg
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="512" height="512" viewBox="0 0 512 512">
|
||||
<rect width="512" height="512" rx="112" fill="#15181d"/>
|
||||
<!-- anvil: the forge of Hephaestus -->
|
||||
<g fill="#6db3f2">
|
||||
<!-- horn + body -->
|
||||
<path d="M120 214 h300 a16 16 0 0 1 16 16 v8 a40 40 0 0 1 -40 40 h-70
|
||||
l-14 40 h-92 l-14 -40 h-44 a48 48 0 0 1 -48 -48 v-8
|
||||
a8 8 0 0 1 8 -8 z"/>
|
||||
<!-- waist -->
|
||||
<rect x="206" y="338" width="100" height="34" rx="6"/>
|
||||
<!-- base -->
|
||||
<rect x="150" y="372" width="212" height="40" rx="12"/>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 578 B |
24
heph-pwa/index.html
Normal file
24
heph-pwa/index.html
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<meta
|
||||
name="viewport"
|
||||
content="width=device-width, initial-scale=1, viewport-fit=cover, maximum-scale=1"
|
||||
/>
|
||||
<meta name="theme-color" content="#15181d" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent" />
|
||||
<meta name="apple-mobile-web-app-title" content="heph" />
|
||||
<title>heph</title>
|
||||
<link rel="manifest" href="./manifest.webmanifest" />
|
||||
<link rel="icon" href="./icons/icon.svg" type="image/svg+xml" />
|
||||
<link rel="apple-touch-icon" href="./icons/icon-180.png" />
|
||||
<link rel="stylesheet" href="./styles.css" />
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
<noscript>heph needs JavaScript enabled.</noscript>
|
||||
<script type="module" src="./src/app.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
17
heph-pwa/manifest.webmanifest
Normal file
17
heph-pwa/manifest.webmanifest
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
{
|
||||
"name": "heph",
|
||||
"short_name": "heph",
|
||||
"description": "Capture and triage hephaestus tasks from your phone.",
|
||||
"start_url": "./",
|
||||
"scope": "./",
|
||||
"display": "standalone",
|
||||
"orientation": "portrait",
|
||||
"background_color": "#15181d",
|
||||
"theme_color": "#15181d",
|
||||
"icons": [
|
||||
{ "src": "./icons/icon.svg", "sizes": "any", "type": "image/svg+xml", "purpose": "any" },
|
||||
{ "src": "./icons/icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "./icons/icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any" },
|
||||
{ "src": "./icons/icon-maskable.png", "sizes": "512x512", "type": "image/png", "purpose": "maskable" }
|
||||
]
|
||||
}
|
||||
895
heph-pwa/src/app.js
Normal file
895
heph-pwa/src/app.js
Normal file
|
|
@ -0,0 +1,895 @@
|
|||
// 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.
|
||||
//
|
||||
// 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).
|
||||
|
||||
import { Client, loadSettings, saveSettings, RpcError } from "./rpc.js";
|
||||
import * as oauth from "./oauth.js";
|
||||
import { parse as quickParse } from "./quickadd.js";
|
||||
import { today, parseDate, toEpochMs, humanizeRecurrence } from "./datespec.js";
|
||||
import {
|
||||
ATTENTION_COLORS,
|
||||
fmtRelative,
|
||||
hasFlag,
|
||||
isOverdue,
|
||||
nextAttention,
|
||||
projectColor,
|
||||
} from "./fmt.js";
|
||||
|
||||
// The built-in views, in the TUI sidebar order (filter.rs BUILTIN_VIEWS).
|
||||
const VIEWS = [
|
||||
{ id: "tom", title: "Top of Mind" },
|
||||
{ id: "tasks", title: "Tasks" },
|
||||
{ id: "work", title: "Work Tasks" },
|
||||
{ id: "chores", title: "Chores" },
|
||||
{ id: "ondeck", title: "On Deck" },
|
||||
{ id: "inbox", title: "Inbox" },
|
||||
];
|
||||
|
||||
const state = {
|
||||
settings: loadSettings(),
|
||||
client: null,
|
||||
target: { type: "view", id: "tom", title: "Top of Mind" },
|
||||
tasks: [],
|
||||
projects: [],
|
||||
expandedId: null,
|
||||
loading: false,
|
||||
error: null,
|
||||
search: null, // null, or { query, results }
|
||||
lastUndo: null, // { label, run }
|
||||
};
|
||||
|
||||
// Build the RPC client from the current settings, wiring an OIDC silent-refresh
|
||||
// hook: on a 401 the client calls this to renew the token (oauth.js) and retry
|
||||
// once before surfacing the error.
|
||||
function makeClient() {
|
||||
return new Client({
|
||||
baseUrl: state.settings.baseUrl,
|
||||
token: state.settings.token,
|
||||
refresh: async () => {
|
||||
const tok = await oauth.ensureFreshToken(true);
|
||||
applyToken(tok || "");
|
||||
return tok;
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Adopt `token` as the active bearer: persist it and rebuild the client.
|
||||
function applyToken(token) {
|
||||
state.settings.token = token || "";
|
||||
saveSettings(state.settings);
|
||||
state.client = makeClient();
|
||||
}
|
||||
|
||||
state.client = makeClient();
|
||||
|
||||
// --- tiny DOM helper --------------------------------------------------------
|
||||
|
||||
/** h("div", {class:"x", onclick:fn}, child, child...) → HTMLElement. */
|
||||
function h(tag, props = {}, ...children) {
|
||||
const el = document.createElement(tag);
|
||||
for (const [k, v] of Object.entries(props || {})) {
|
||||
if (v == null || v === false) continue;
|
||||
if (k === "class") el.className = v;
|
||||
else if (k === "html") el.innerHTML = v;
|
||||
else if (k.startsWith("on") && typeof v === "function") {
|
||||
el.addEventListener(k.slice(2).toLowerCase(), v);
|
||||
} else el.setAttribute(k, v === true ? "" : String(v));
|
||||
}
|
||||
for (const c of children.flat()) {
|
||||
if (c == null || c === false) continue;
|
||||
el.append(c.nodeType ? c : document.createTextNode(String(c)));
|
||||
}
|
||||
return el;
|
||||
}
|
||||
|
||||
const $ = (sel) => document.querySelector(sel);
|
||||
|
||||
function toast(message, action) {
|
||||
const root = $("#toast");
|
||||
root.innerHTML = "";
|
||||
const node = h(
|
||||
"div",
|
||||
{ class: "toast-body" },
|
||||
h("span", {}, message),
|
||||
action &&
|
||||
h(
|
||||
"button",
|
||||
{
|
||||
class: "toast-action",
|
||||
onclick: () => {
|
||||
root.innerHTML = "";
|
||||
action.run();
|
||||
},
|
||||
},
|
||||
action.label,
|
||||
),
|
||||
);
|
||||
root.append(node);
|
||||
if (!action) setTimeout(() => (root.innerHTML === "" ? null : (root.innerHTML = "")), 2600);
|
||||
}
|
||||
|
||||
// --- data -------------------------------------------------------------------
|
||||
|
||||
async function reload() {
|
||||
if (!state.client.configured) {
|
||||
state.error = "Set your hub URL in Settings to begin.";
|
||||
render();
|
||||
openSettings();
|
||||
return;
|
||||
}
|
||||
state.loading = true;
|
||||
state.error = null;
|
||||
render();
|
||||
try {
|
||||
const [tasks, projects] = await Promise.all([
|
||||
state.target.type === "view"
|
||||
? state.client.view(state.target.id)
|
||||
: state.client.list({ scope: [state.target.id] }),
|
||||
state.client.projects(),
|
||||
]);
|
||||
state.tasks = tasks;
|
||||
state.projects = projects;
|
||||
state.error = null;
|
||||
} catch (e) {
|
||||
state.error = e instanceof RpcError ? e.message : String(e);
|
||||
} finally {
|
||||
state.loading = false;
|
||||
render();
|
||||
}
|
||||
}
|
||||
|
||||
function projectTitle(id) {
|
||||
if (!id) return null;
|
||||
return state.projects.find((p) => p.id === id)?.title || id;
|
||||
}
|
||||
|
||||
async function refreshProjects() {
|
||||
try {
|
||||
state.projects = await state.client.projects();
|
||||
} catch {
|
||||
/* keep stale list */
|
||||
}
|
||||
}
|
||||
|
||||
// --- rendering --------------------------------------------------------------
|
||||
|
||||
function render() {
|
||||
renderHeader();
|
||||
renderMain();
|
||||
}
|
||||
|
||||
function renderHeader() {
|
||||
$("#view-title").textContent = state.search ? "Search" : state.target.title;
|
||||
}
|
||||
|
||||
function attentionDot(att) {
|
||||
return h("span", {
|
||||
class: "flag",
|
||||
style: hasFlag(att) ? `color:${ATTENTION_COLORS[att]}` : "color:transparent",
|
||||
}, hasFlag(att) ? "⚑" : "·");
|
||||
}
|
||||
|
||||
function dateChip(t) {
|
||||
const now = Date.now();
|
||||
if (isOverdue(t.late_on, now)) {
|
||||
return h("span", { class: "chip overdue" }, `late ${fmtRelative(t.late_on, now)}`);
|
||||
}
|
||||
if (t.do_date != null) {
|
||||
return h("span", { class: "chip" }, fmtRelative(t.do_date, now));
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function taskRow(t) {
|
||||
const expanded = state.expandedId === t.node_id;
|
||||
const row = h(
|
||||
"div",
|
||||
{ class: "row" + (expanded ? " expanded" : "") },
|
||||
h(
|
||||
"div",
|
||||
{
|
||||
class: "row-head",
|
||||
onclick: () => {
|
||||
state.expandedId = expanded ? null : t.node_id;
|
||||
render();
|
||||
if (!expanded) loadPreview(t);
|
||||
},
|
||||
},
|
||||
attentionDot(t.attention),
|
||||
h("span", { class: "bullet", style: `color:${projectColor(t.project_id)}` }, "●"),
|
||||
h("span", { class: "title" }, t.title),
|
||||
t.recurrence && h("span", { class: "recur" }, "↻"),
|
||||
dateChip(t),
|
||||
),
|
||||
expanded && taskDetail(t),
|
||||
);
|
||||
return row;
|
||||
}
|
||||
|
||||
function taskDetail(t) {
|
||||
const meta = [];
|
||||
if (t.project_id) meta.push(["project", projectTitle(t.project_id)]);
|
||||
if (t.recurrence) meta.push(["recurs", humanizeRecurrence(t.recurrence)]);
|
||||
if (t.do_date != null) meta.push(["do", fmtRelative(t.do_date)]);
|
||||
if (t.late_on != null) meta.push(["late", fmtRelative(t.late_on)]);
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ class: "detail" },
|
||||
meta.length &&
|
||||
h(
|
||||
"div",
|
||||
{ class: "meta" },
|
||||
meta.map(([k, v]) => h("div", { class: "meta-row" }, h("span", { class: "meta-k" }, k), h("span", {}, v))),
|
||||
),
|
||||
h(
|
||||
"div",
|
||||
{ class: "actions" },
|
||||
actionBtn("✓ Done", () => triage(t, "done")),
|
||||
actionBtn("⤓ Drop", () => triage(t, "dropped")),
|
||||
t.recurrence && actionBtn("↻ Skip", () => doSkip(t)),
|
||||
actionBtn("⚑ Attn", () => cycleAttention(t)),
|
||||
actionBtn("📅 Date", () => openReschedule(t)),
|
||||
actionBtn("📁 Move", () => openMove(t)),
|
||||
actionBtn("🗑 Delete", () => doDelete(t), "danger"),
|
||||
),
|
||||
h("pre", { class: "preview", id: `preview-${t.node_id}` }, "…"),
|
||||
);
|
||||
}
|
||||
|
||||
function actionBtn(label, onclick, extra = "") {
|
||||
return h("button", { class: `act ${extra}`, onclick }, label);
|
||||
}
|
||||
|
||||
async function loadPreview(t) {
|
||||
const pre = $(`#preview-${t.node_id}`);
|
||||
if (!pre) return;
|
||||
try {
|
||||
const ctxId = t.canonical_context_id || (await state.client.contextOf(t.node_id));
|
||||
const [body, log] = await Promise.all([
|
||||
ctxId ? state.client.nodeBody(ctxId) : Promise.resolve(""),
|
||||
state.client.logTail(t.node_id, 5).catch(() => []),
|
||||
]);
|
||||
const parts = [];
|
||||
if (body.trim()) parts.push(body.trim());
|
||||
if (log && log.length) parts.push("— log —\n" + log.join("\n"));
|
||||
pre.textContent = parts.join("\n\n") || "(no context yet)";
|
||||
} catch (e) {
|
||||
pre.textContent = `(could not load context: ${e.message})`;
|
||||
}
|
||||
}
|
||||
|
||||
function renderMain() {
|
||||
const main = $("#main");
|
||||
main.innerHTML = "";
|
||||
|
||||
if (state.search) {
|
||||
main.append(searchPane());
|
||||
return;
|
||||
}
|
||||
if (state.error) {
|
||||
main.append(h("div", { class: "notice error" }, state.error));
|
||||
}
|
||||
if (state.loading && state.tasks.length === 0) {
|
||||
main.append(h("div", { class: "notice" }, "Loading…"));
|
||||
return;
|
||||
}
|
||||
if (!state.loading && state.tasks.length === 0 && !state.error) {
|
||||
main.append(h("div", { class: "notice" }, "Nothing here. Tap + to capture a task."));
|
||||
return;
|
||||
}
|
||||
const list = h("div", { class: "list" }, state.tasks.map(taskRow));
|
||||
main.append(list);
|
||||
}
|
||||
|
||||
// --- drawer (views + projects) ---------------------------------------------
|
||||
|
||||
function renderDrawer() {
|
||||
const body = $("#drawer-body");
|
||||
body.innerHTML = "";
|
||||
body.append(h("div", { class: "drawer-section" }, "Views"));
|
||||
for (const v of VIEWS) {
|
||||
body.append(drawerItem(v.title, state.target.type === "view" && state.target.id === v.id, () => {
|
||||
state.target = { type: "view", id: v.id, title: v.title };
|
||||
closeDrawer();
|
||||
reload();
|
||||
}));
|
||||
}
|
||||
body.append(h("div", { class: "drawer-section" }, "Projects"));
|
||||
if (state.projects.length === 0) {
|
||||
body.append(h("div", { class: "drawer-empty" }, "(none yet)"));
|
||||
}
|
||||
for (const p of state.projects) {
|
||||
body.append(drawerItem(p.title, state.target.type === "project" && state.target.id === p.id, () => {
|
||||
state.target = { type: "project", id: p.id, title: p.title };
|
||||
closeDrawer();
|
||||
reload();
|
||||
}, projectColor(p.id)));
|
||||
}
|
||||
}
|
||||
|
||||
function drawerItem(label, active, onclick, dot) {
|
||||
return h(
|
||||
"div",
|
||||
{ class: "drawer-item" + (active ? " active" : ""), onclick },
|
||||
dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "),
|
||||
h("span", {}, label),
|
||||
);
|
||||
}
|
||||
|
||||
function openDrawer() {
|
||||
renderDrawer();
|
||||
$("#drawer").classList.add("open");
|
||||
$("#backdrop").classList.add("show");
|
||||
}
|
||||
function closeDrawer() {
|
||||
$("#drawer").classList.remove("open");
|
||||
$("#backdrop").classList.remove("show");
|
||||
}
|
||||
|
||||
// --- modal scaffolding ------------------------------------------------------
|
||||
|
||||
function openModal(node) {
|
||||
const root = $("#modal-root");
|
||||
root.innerHTML = "";
|
||||
root.append(h("div", { class: "modal-backdrop", onclick: closeModal }, h("div", { class: "modal", onclick: (e) => e.stopPropagation() }, node)));
|
||||
root.classList.add("show");
|
||||
}
|
||||
function closeModal() {
|
||||
$("#modal-root").classList.remove("show");
|
||||
$("#modal-root").innerHTML = "";
|
||||
}
|
||||
function modalOpen() {
|
||||
return $("#modal-root").classList.contains("show");
|
||||
}
|
||||
|
||||
// --- quick-add (the primary use case) --------------------------------------
|
||||
|
||||
function openQuickAdd() {
|
||||
closeDrawer();
|
||||
const input = h("input", {
|
||||
class: "qa-input",
|
||||
type: "text",
|
||||
placeholder: "Buy milk tomorrow p2 #Work every week",
|
||||
autocomplete: "off",
|
||||
autocapitalize: "sentences",
|
||||
enterkeyhint: "done",
|
||||
});
|
||||
const preview = h("div", { class: "qa-preview" });
|
||||
|
||||
const updatePreview = () => {
|
||||
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"));
|
||||
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));
|
||||
}
|
||||
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)));
|
||||
if (parsed.recurrence) preview.append(h("span", { class: "qa-tag" }, "↻ " + humanizeRecurrence(parsed.recurrence)));
|
||||
};
|
||||
input.addEventListener("input", updatePreview);
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
submitQuickAdd(input.value);
|
||||
} else if (e.key === "Escape") {
|
||||
closeModal();
|
||||
}
|
||||
});
|
||||
|
||||
const mic = voiceButton(input, updatePreview);
|
||||
|
||||
const node = h(
|
||||
"div",
|
||||
{ class: "qa" },
|
||||
h("div", { class: "qa-row" }, input, mic),
|
||||
preview,
|
||||
h(
|
||||
"div",
|
||||
{ class: "qa-foot" },
|
||||
state.target.type === "project"
|
||||
? h("span", { class: "qa-dest" }, "→ " + state.target.title)
|
||||
: h("span", { class: "qa-dest" }, "→ Inbox (unless #Project given)"),
|
||||
h("button", { class: "qa-add", onclick: () => submitQuickAdd(input.value) }, "Add"),
|
||||
),
|
||||
);
|
||||
openModal(node);
|
||||
updatePreview();
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
async function submitQuickAdd(raw) {
|
||||
const text = raw.trim();
|
||||
if (!text) return;
|
||||
const parsed = quickParse(text, today(), state.projects);
|
||||
if (!parsed.title) {
|
||||
toast("Needs a title.");
|
||||
return;
|
||||
}
|
||||
const projectId =
|
||||
parsed.projectId || (state.target.type === "project" ? state.target.id : null);
|
||||
closeModal();
|
||||
try {
|
||||
await state.client.createTask({
|
||||
title: parsed.title,
|
||||
attention: parsed.attention,
|
||||
doDate: parsed.doDate,
|
||||
recurrence: parsed.recurrence,
|
||||
projectId,
|
||||
});
|
||||
toast(`Added: ${parsed.title}`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Add failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- voice input ------------------------------------------------------------
|
||||
|
||||
// Web Speech API where available (desktop Chrome, Android). On iOS Safari the
|
||||
// API is absent, but the on-screen keyboard's dictation mic works in the text
|
||||
// field for free — so we simply omit the button there.
|
||||
function voiceButton(input, onUpdate) {
|
||||
const SR = window.SpeechRecognition || window.webkitSpeechRecognition;
|
||||
if (!SR) return null;
|
||||
let rec = null;
|
||||
const btn = h("button", { class: "qa-mic", title: "Dictate" }, "🎤");
|
||||
btn.addEventListener("click", () => {
|
||||
if (rec) {
|
||||
rec.stop();
|
||||
return;
|
||||
}
|
||||
rec = new SR();
|
||||
rec.lang = navigator.language || "en-US";
|
||||
rec.interimResults = true;
|
||||
btn.classList.add("listening");
|
||||
let base = input.value ? input.value + " " : "";
|
||||
rec.onresult = (ev) => {
|
||||
let text = "";
|
||||
for (const r of ev.results) text += r[0].transcript;
|
||||
input.value = base + text;
|
||||
onUpdate();
|
||||
};
|
||||
rec.onend = () => {
|
||||
rec = null;
|
||||
btn.classList.remove("listening");
|
||||
input.focus();
|
||||
};
|
||||
rec.onerror = () => toast("Voice input unavailable.");
|
||||
rec.start();
|
||||
});
|
||||
return btn;
|
||||
}
|
||||
|
||||
// --- reschedule -------------------------------------------------------------
|
||||
|
||||
function openReschedule(t) {
|
||||
const input = h("input", {
|
||||
class: "qa-input",
|
||||
type: "text",
|
||||
placeholder: "today · tomorrow · +3d · fri · 2026-07-01 · (blank to clear)",
|
||||
value: t.do_date != null ? fmtRelative(t.do_date) : "",
|
||||
autocomplete: "off",
|
||||
enterkeyhint: "done",
|
||||
});
|
||||
const apply = async () => {
|
||||
const v = input.value.trim();
|
||||
let doDate = null;
|
||||
if (v) {
|
||||
try {
|
||||
doDate = toEpochMs(parseDate(v, today()));
|
||||
} catch {
|
||||
toast("Unrecognized date.");
|
||||
return;
|
||||
}
|
||||
}
|
||||
closeModal();
|
||||
try {
|
||||
await state.client.setSchedule(t.node_id, { doDate });
|
||||
toast(doDate ? `Rescheduled: ${fmtRelative(doDate)}` : "Do-date cleared");
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Failed: ${e.message}`);
|
||||
}
|
||||
};
|
||||
input.addEventListener("keydown", (e) => {
|
||||
if (e.key === "Enter") (e.preventDefault(), apply());
|
||||
if (e.key === "Escape") closeModal();
|
||||
});
|
||||
openModal(
|
||||
h(
|
||||
"div",
|
||||
{ class: "qa" },
|
||||
h("div", { class: "modal-title" }, "Reschedule"),
|
||||
input,
|
||||
h("div", { class: "qa-foot" }, h("button", { class: "qa-add", onclick: apply }, "Set")),
|
||||
),
|
||||
);
|
||||
setTimeout(() => input.focus(), 50);
|
||||
}
|
||||
|
||||
// --- move / project picker --------------------------------------------------
|
||||
|
||||
function openMove(t) {
|
||||
const filter = h("input", { class: "qa-input", type: "text", placeholder: "Filter or new project…", autocomplete: "off" });
|
||||
const list = h("div", { class: "picker-list" });
|
||||
|
||||
const renderOptions = () => {
|
||||
const q = filter.value.trim().toLowerCase();
|
||||
list.innerHTML = "";
|
||||
list.append(pickerItem("(Unfile)", () => move(t, null)));
|
||||
for (const p of state.projects) {
|
||||
if (q && !p.title.toLowerCase().includes(q)) continue;
|
||||
list.append(pickerItem(p.title, () => move(t, p.id), projectColor(p.id)));
|
||||
}
|
||||
const exact = state.projects.some((p) => p.title.toLowerCase() === q);
|
||||
if (q && !exact) {
|
||||
list.append(pickerItem(`+ New project "${filter.value.trim()}"`, () => createAndMove(t, filter.value.trim())));
|
||||
}
|
||||
};
|
||||
filter.addEventListener("input", renderOptions);
|
||||
filter.addEventListener("keydown", (e) => e.key === "Escape" && closeModal());
|
||||
|
||||
openModal(h("div", { class: "qa" }, h("div", { class: "modal-title" }, `Move "${t.title}"`), filter, list));
|
||||
renderOptions();
|
||||
setTimeout(() => filter.focus(), 50);
|
||||
}
|
||||
|
||||
function pickerItem(label, onclick, dot) {
|
||||
return h(
|
||||
"div",
|
||||
{ class: "picker-item", onclick },
|
||||
dot ? h("span", { class: "bullet", style: `color:${dot}` }, "●") : h("span", { class: "bullet" }, " "),
|
||||
h("span", {}, label),
|
||||
);
|
||||
}
|
||||
|
||||
async function move(t, projectId) {
|
||||
closeModal();
|
||||
try {
|
||||
await state.client.setProject(t.node_id, projectId);
|
||||
toast(projectId ? `Moved to ${projectTitle(projectId)}` : "Unfiled");
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function createAndMove(t, name) {
|
||||
closeModal();
|
||||
try {
|
||||
const id = await state.client.createProject(name);
|
||||
await refreshProjects();
|
||||
await state.client.setProject(t.node_id, id);
|
||||
toast(`Moved to ${name}`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- triage actions ---------------------------------------------------------
|
||||
|
||||
async function triage(t, newState) {
|
||||
state.expandedId = null;
|
||||
try {
|
||||
await state.client.setState(t.node_id, newState);
|
||||
const verb = newState === "done" ? "Done" : "Dropped";
|
||||
toast(`${verb}: ${t.title}`, {
|
||||
label: "Undo",
|
||||
run: async () => {
|
||||
try {
|
||||
await state.client.setState(t.node_id, "outstanding");
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Undo failed: ${e.message}`);
|
||||
}
|
||||
},
|
||||
});
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function doSkip(t) {
|
||||
try {
|
||||
await state.client.skip(t.node_id);
|
||||
toast(`Skipped: ${t.title}`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function cycleAttention(t) {
|
||||
const next = nextAttention(t.attention);
|
||||
try {
|
||||
await state.client.setAttention(t.node_id, next);
|
||||
toast(`Attention: ${next}`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function doDelete(t) {
|
||||
if (!confirm(`Delete "${t.title}"? This removes it for good (use Drop to triage instead).`)) {
|
||||
return;
|
||||
}
|
||||
state.expandedId = null;
|
||||
try {
|
||||
await state.client.tombstone(t.node_id);
|
||||
toast(`Deleted: ${t.title}`);
|
||||
reload();
|
||||
} catch (e) {
|
||||
toast(`Failed: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// --- search -----------------------------------------------------------------
|
||||
|
||||
function openSearch() {
|
||||
state.search = { query: "", results: [] };
|
||||
render();
|
||||
setTimeout(() => $("#search-input")?.focus(), 50);
|
||||
}
|
||||
|
||||
function closeSearch() {
|
||||
state.search = null;
|
||||
render();
|
||||
}
|
||||
|
||||
function searchPane() {
|
||||
const input = h("input", {
|
||||
id: "search-input",
|
||||
class: "search-input",
|
||||
type: "search",
|
||||
placeholder: "Search tasks & docs…",
|
||||
value: state.search.query,
|
||||
autocomplete: "off",
|
||||
enterkeyhint: "search",
|
||||
});
|
||||
let timer = null;
|
||||
const run = async () => {
|
||||
state.search.query = input.value;
|
||||
const q = input.value.trim();
|
||||
if (!q) {
|
||||
state.search.results = [];
|
||||
renderSearchResults();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
state.search.results = await state.client.search(q);
|
||||
} catch (e) {
|
||||
state.search.results = [];
|
||||
toast(e.message);
|
||||
}
|
||||
renderSearchResults();
|
||||
};
|
||||
input.addEventListener("input", () => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(run, 200);
|
||||
});
|
||||
input.addEventListener("keydown", (e) => e.key === "Escape" && closeSearch());
|
||||
|
||||
return h(
|
||||
"div",
|
||||
{ class: "search-pane" },
|
||||
h("div", { class: "search-bar" }, input, h("button", { class: "search-close", onclick: closeSearch }, "✕")),
|
||||
h("div", { class: "search-results", id: "search-results" }),
|
||||
);
|
||||
}
|
||||
|
||||
function renderSearchResults() {
|
||||
const root = $("#search-results");
|
||||
if (!root) return;
|
||||
root.innerHTML = "";
|
||||
if (!state.search.results.length) {
|
||||
root.append(h("div", { class: "notice" }, state.search.query ? "No matches." : "Type to search."));
|
||||
return;
|
||||
}
|
||||
for (const hit of state.search.results) {
|
||||
root.append(
|
||||
h(
|
||||
"div",
|
||||
{ class: "search-hit" },
|
||||
h("span", { class: "hit-kind" }, `[${hit.kind}]`),
|
||||
h("span", {}, hit.title),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// --- settings ---------------------------------------------------------------
|
||||
|
||||
function openSettings() {
|
||||
const url = h("input", { class: "qa-input", type: "url", placeholder: "https://hub.example.com:8787", value: state.settings.baseUrl, autocomplete: "off", inputmode: "url" });
|
||||
const tok = h("input", { class: "qa-input", type: "password", placeholder: "Bearer token (optional)", value: state.settings.token, autocomplete: "off" });
|
||||
const test = h("div", { class: "settings-test" });
|
||||
const setTest = (msg, ok) => {
|
||||
test.textContent = msg;
|
||||
test.className = "settings-test" + (ok === true ? " ok" : ok === false ? " bad" : "");
|
||||
};
|
||||
|
||||
const save = async () => {
|
||||
state.settings.baseUrl = url.value.trim();
|
||||
state.settings.token = tok.value.trim();
|
||||
saveSettings(state.settings);
|
||||
state.client = makeClient();
|
||||
closeModal();
|
||||
reload();
|
||||
};
|
||||
const check = async () => {
|
||||
setTest("Checking…", null);
|
||||
const probe = new Client({ baseUrl: url.value.trim(), token: tok.value.trim() });
|
||||
try {
|
||||
const v = await probe.call("version", {});
|
||||
setTest(`✓ Connected (hephd ${v.version})`, true);
|
||||
} catch (e) {
|
||||
setTest(`✗ ${e.message}`, false);
|
||||
}
|
||||
};
|
||||
// Login with Authentik: read the hub's /config for the issuer + client id,
|
||||
// then start the PKCE redirect (this navigates away and returns to init()).
|
||||
const login = async () => {
|
||||
const hub = url.value.trim() || state.settings.baseUrl;
|
||||
if (!hub) return setTest("✗ Set the hub URL first.", false);
|
||||
setTest("Contacting hub…", null);
|
||||
const cfg = await oauth.fetchHubConfig(hub);
|
||||
if (!cfg) {
|
||||
return setTest("✗ Hub has no OIDC (/config) — paste a token, or enable OIDC on the hub.", false);
|
||||
}
|
||||
state.settings.baseUrl = hub;
|
||||
saveSettings(state.settings); // persist before we navigate away
|
||||
try {
|
||||
await oauth.beginLogin(cfg);
|
||||
} catch (e) {
|
||||
setTest(`✗ ${e.message}`, false);
|
||||
}
|
||||
};
|
||||
const logout = () => {
|
||||
oauth.clearAuth();
|
||||
applyToken("");
|
||||
closeModal();
|
||||
reload();
|
||||
};
|
||||
|
||||
const authRow = oauth.loggedIn()
|
||||
? h(
|
||||
"div",
|
||||
{ class: "settings-auth" },
|
||||
h("span", { class: "settings-test ok" }, "✓ Signed in with Authentik"),
|
||||
h("button", { class: "act", onclick: logout }, "Log out"),
|
||||
)
|
||||
: h("button", { class: "qa-add settings-login", onclick: login }, "Login with Authentik");
|
||||
|
||||
openModal(
|
||||
h(
|
||||
"div",
|
||||
{ class: "qa" },
|
||||
h("div", { class: "modal-title" }, "Settings"),
|
||||
h("label", { class: "settings-label" }, "Hub URL"),
|
||||
url,
|
||||
h("label", { class: "settings-label" }, "Sign-in"),
|
||||
authRow,
|
||||
h(
|
||||
"details",
|
||||
{ class: "settings-manual" },
|
||||
h("summary", {}, "Or paste a bearer token"),
|
||||
tok,
|
||||
),
|
||||
test,
|
||||
h(
|
||||
"div",
|
||||
{ class: "qa-foot settings-foot" },
|
||||
h("button", { class: "act", onclick: check }, "Test"),
|
||||
h("button", { class: "qa-add", onclick: save }, "Save"),
|
||||
),
|
||||
h("div", { class: "settings-hint" }, "“Login with Authentik” needs OIDC enabled on the hub. Leave sign-in empty for a hub running without OIDC."),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
// --- keyboard ---------------------------------------------------------------
|
||||
|
||||
function onKeydown(e) {
|
||||
const typing = ["INPUT", "TEXTAREA", "SELECT"].includes(document.activeElement?.tagName);
|
||||
// Cmd/Ctrl + ' opens quick-add anywhere (mirrors the global popover).
|
||||
if ((e.metaKey || e.ctrlKey) && e.key === "'") {
|
||||
e.preventDefault();
|
||||
openQuickAdd();
|
||||
return;
|
||||
}
|
||||
if (typing || modalOpen()) {
|
||||
if (e.key === "Escape" && modalOpen()) closeModal();
|
||||
return;
|
||||
}
|
||||
if (e.key === "a") (e.preventDefault(), openQuickAdd());
|
||||
else if (e.key === "/") (e.preventDefault(), openSearch());
|
||||
else if (e.key === "r") reload();
|
||||
else if (e.key === "Escape") state.search ? closeSearch() : closeDrawer();
|
||||
}
|
||||
|
||||
// --- shell + init -----------------------------------------------------------
|
||||
|
||||
function buildShell() {
|
||||
const app = $("#app");
|
||||
app.append(
|
||||
h(
|
||||
"header",
|
||||
{ class: "appbar" },
|
||||
h("button", { class: "icon-btn", title: "Menu", onclick: openDrawer }, "☰"),
|
||||
h("div", { id: "view-title", class: "appbar-title" }, state.target.title),
|
||||
h("button", { class: "icon-btn", title: "Search", onclick: openSearch }, "🔍"),
|
||||
h("button", { class: "icon-btn", title: "Settings", onclick: openSettings }, "⚙"),
|
||||
),
|
||||
h("main", { id: "main" }),
|
||||
h("button", { id: "fab", class: "fab", title: "Quick add (Cmd-’)", onclick: openQuickAdd }, "+"),
|
||||
h("div", { id: "backdrop", class: "backdrop", onclick: closeDrawer }),
|
||||
h(
|
||||
"aside",
|
||||
{ id: "drawer", class: "drawer" },
|
||||
h("div", { class: "drawer-head" }, "heph"),
|
||||
h("div", { id: "drawer-body", class: "drawer-body" }),
|
||||
),
|
||||
h("div", { id: "modal-root", class: "modal-root" }),
|
||||
h("div", { id: "toast", class: "toast" }),
|
||||
);
|
||||
}
|
||||
|
||||
async function init() {
|
||||
buildShell();
|
||||
document.addEventListener("keydown", onKeydown);
|
||||
|
||||
// The PWA shares the daemon's store with the TUI / desktop popover, but only
|
||||
// re-fetches on a view switch or an action. So another surface marking a task
|
||||
// done leaves a stale list on screen until then. Re-fetch the current view
|
||||
// whenever the app regains focus (switching back to the phone, unlock, tab
|
||||
// re-show) — but not while a modal or search is mid-interaction.
|
||||
document.addEventListener("visibilitychange", () => {
|
||||
if (
|
||||
document.visibilityState === "visible" &&
|
||||
state.client.configured &&
|
||||
!modalOpen() &&
|
||||
!state.search
|
||||
) {
|
||||
reload();
|
||||
}
|
||||
});
|
||||
|
||||
// OIDC: finish a redirect callback (back from Authentik), or refresh an
|
||||
// existing session, so the first reload() already carries a valid bearer.
|
||||
if (oauth.isCallback()) {
|
||||
try {
|
||||
applyToken(await oauth.completeLogin());
|
||||
toast("Signed in.");
|
||||
} catch (e) {
|
||||
toast(`Sign-in failed: ${e.message}`);
|
||||
}
|
||||
} else if (oauth.loggedIn()) {
|
||||
const tok = await oauth.ensureFreshToken();
|
||||
if (tok) applyToken(tok);
|
||||
}
|
||||
|
||||
render();
|
||||
reload();
|
||||
|
||||
if ("serviceWorker" in navigator) {
|
||||
try {
|
||||
await navigator.serviceWorker.register("./sw.js");
|
||||
} catch {
|
||||
/* offline shell is best-effort */
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
init();
|
||||
361
heph-pwa/src/datespec.js
Normal file
361
heph-pwa/src/datespec.js
Normal file
|
|
@ -0,0 +1,361 @@
|
|||
// Human-friendly date and recurrence parsing — a faithful JS port of hephd's
|
||||
// `datespec.rs` (tech-spec §1, §8, §8.1) so the PWA's quick-add accepts the
|
||||
// exact same forms as the CLI/TUI and produces identical RRULEs and do-dates.
|
||||
//
|
||||
// Dates are date-grained and stored as epoch ms at *local midnight* (matching
|
||||
// `to_epoch_ms`). All pure functions take an explicit `today` so they stay
|
||||
// deterministically testable; the thin wrappers read the local clock.
|
||||
|
||||
/** A local-midnight Date for today (time component stripped). */
|
||||
export function today() {
|
||||
const n = new Date();
|
||||
return new Date(n.getFullYear(), n.getMonth(), n.getDate());
|
||||
}
|
||||
|
||||
/** Local-midnight epoch ms for a Date (the form do_date/late_on are stored in). */
|
||||
export function toEpochMs(date) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate()).getTime();
|
||||
}
|
||||
|
||||
function addDays(date, n) {
|
||||
return new Date(date.getFullYear(), date.getMonth(), date.getDate() + n);
|
||||
}
|
||||
function addMonths(date, n) {
|
||||
return new Date(date.getFullYear(), date.getMonth() + n, date.getDate());
|
||||
}
|
||||
|
||||
// JS getDay(): 0=Sun..6=Sat.
|
||||
const WEEKDAYS = {
|
||||
mon: 1, monday: 1,
|
||||
tue: 2, tues: 2, tuesday: 2,
|
||||
wed: 3, weds: 3, wednesday: 3,
|
||||
thu: 4, thur: 4, thurs: 4, thursday: 4,
|
||||
fri: 5, friday: 5,
|
||||
sat: 6, saturday: 6,
|
||||
sun: 0, sunday: 0,
|
||||
};
|
||||
const BYDAY = { 0: "SU", 1: "MO", 2: "TU", 3: "WE", 4: "TH", 5: "FR", 6: "SA" };
|
||||
|
||||
/** Weekday name (full or common abbreviation) → JS day index, or null. */
|
||||
function parseWeekday(s) {
|
||||
return Object.prototype.hasOwnProperty.call(WEEKDAYS, s) ? WEEKDAYS[s] : null;
|
||||
}
|
||||
|
||||
/** The soonest date on/after `today` whose weekday is `wd` (JS day index). */
|
||||
function soonestWeekday(today, wd) {
|
||||
let d = today;
|
||||
for (let i = 0; i < 7; i++) {
|
||||
if (d.getDay() === wd) return d;
|
||||
d = addDays(d, 1);
|
||||
}
|
||||
return today;
|
||||
}
|
||||
|
||||
function parseOffset(rest, today) {
|
||||
rest = rest.trim();
|
||||
const m = rest.match(/^(\d+)\s*([a-z]*)$/);
|
||||
if (!m) throw new Error(`not a relative date offset: +${rest}`);
|
||||
const n = parseInt(m[1], 10);
|
||||
switch (m[2]) {
|
||||
case "": case "d": case "day": case "days": return addDays(today, n);
|
||||
case "w": case "wk": case "week": case "weeks": return addDays(today, n * 7);
|
||||
case "m": case "mo": case "month": case "months": return addMonths(today, n);
|
||||
default: throw new Error(`unknown offset unit "${m[2]}" (use d, w, or m)`);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a human date spec relative to `today` (a local-midnight Date) into a
|
||||
* local-midnight Date. Accepts: today/now, tomorrow/tom, yesterday; +Nd/+Nw/+Nm
|
||||
* (bare +N = days); weekday names (soonest on/after today); ISO YYYY-MM-DD.
|
||||
* Throws on anything unrecognized.
|
||||
*/
|
||||
export function parseDate(input, todayDate) {
|
||||
const s = input.trim().toLowerCase();
|
||||
if (s === "") throw new Error("empty date");
|
||||
switch (s) {
|
||||
case "today": case "now": return todayDate;
|
||||
case "tomorrow": case "tom": return addDays(todayDate, 1);
|
||||
case "yesterday": return addDays(todayDate, -1);
|
||||
}
|
||||
const wd = parseWeekday(s);
|
||||
if (wd !== null) return soonestWeekday(todayDate, wd);
|
||||
if (s.startsWith("+")) return parseOffset(s.slice(1), todayDate);
|
||||
|
||||
// ISO YYYY-MM-DD (strict; construct as local midnight).
|
||||
const iso = s.match(/^(\d{4})-(\d{2})-(\d{2})$/);
|
||||
if (iso) {
|
||||
const [, y, mo, d] = iso;
|
||||
const date = new Date(Number(y), Number(mo) - 1, Number(d));
|
||||
if (
|
||||
date.getFullYear() === Number(y) &&
|
||||
date.getMonth() === Number(mo) - 1 &&
|
||||
date.getDate() === Number(d)
|
||||
) {
|
||||
return date;
|
||||
}
|
||||
}
|
||||
throw new Error(
|
||||
`unrecognized date: "${input}" (try today, tomorrow, +3d, fri, or YYYY-MM-DD)`,
|
||||
);
|
||||
}
|
||||
|
||||
/** parseDate to epoch ms, or null if unparseable (convenience for quick-add). */
|
||||
export function parseDateMsOrNull(input, todayDate) {
|
||||
try {
|
||||
return toEpochMs(parseDate(input, todayDate));
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Recurrence
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MONTHS = {
|
||||
jan: 1, feb: 2, mar: 3, apr: 4, may: 5, jun: 6,
|
||||
jul: 7, aug: 8, sep: 9, oct: 10, nov: 11, dec: 12,
|
||||
};
|
||||
|
||||
function parseMonthDay(s) {
|
||||
const toks = s.split(/\s+/).filter(Boolean);
|
||||
if (toks.length !== 2) return null;
|
||||
const month = (t) => MONTHS[t.slice(0, 3)] ?? null;
|
||||
const day = (t) => {
|
||||
const m = t.match(/^(\d+)/);
|
||||
return m ? parseInt(m[1], 10) : null;
|
||||
};
|
||||
let m = month(toks[0]);
|
||||
let d = day(toks[1]);
|
||||
if (m !== null && d !== null) return [m, d];
|
||||
d = day(toks[0]);
|
||||
m = month(toks[1]);
|
||||
if (m !== null && d !== null) return [m, d];
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseMonthdayOrdinal(s) {
|
||||
const m = s.match(/^(\d+)(st|nd|rd|th)$/);
|
||||
if (!m) return null;
|
||||
const d = parseInt(m[1], 10);
|
||||
return d >= 1 && d <= 31 ? d : null;
|
||||
}
|
||||
|
||||
function intervalForm(n, unit) {
|
||||
const wd = parseWeekday(unit);
|
||||
if (wd !== null) {
|
||||
return n === 1
|
||||
? `FREQ=WEEKLY;BYDAY=${BYDAY[wd]}`
|
||||
: `FREQ=WEEKLY;INTERVAL=${n};BYDAY=${BYDAY[wd]}`;
|
||||
}
|
||||
let freq;
|
||||
switch (unit) {
|
||||
case "day": case "days": freq = "DAILY"; break;
|
||||
case "week": case "weeks": freq = "WEEKLY"; break;
|
||||
case "month": case "months": freq = "MONTHLY"; break;
|
||||
case "year": case "years": freq = "YEARLY"; break;
|
||||
default:
|
||||
throw new Error(
|
||||
`unrecognized recurrence "${unit}" (try daily/weekly/monthly/yearly, ` +
|
||||
`'every 3 days', 'every fri', or a raw RRULE)`,
|
||||
);
|
||||
}
|
||||
return n === 1 ? `FREQ=${freq}` : `FREQ=${freq};INTERVAL=${n}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a recurrence spec into an RFC-5545 RRULE. Accepts a raw RRULE (anything
|
||||
* containing FREQ=), presets (daily/weekly/monthly/yearly/weekdays), and the
|
||||
* common natural-language forms (§6.2.1): every N (day|week|month|year)s, every
|
||||
* <weekday>, every other <weekday|unit>, every workday, every <Month> <day>,
|
||||
* every <Nth>. A trailing "at <time>" is ignored. Throws if unrecognized.
|
||||
*/
|
||||
export function parseRecurrence(spec) {
|
||||
const raw = spec.trim();
|
||||
if (raw.toUpperCase().includes("FREQ=")) return raw;
|
||||
|
||||
let s = raw.toLowerCase();
|
||||
const at = s.indexOf(" at ");
|
||||
if (at !== -1) s = s.slice(0, at);
|
||||
s = s.trim();
|
||||
|
||||
switch (s) {
|
||||
case "daily": case "day": return "FREQ=DAILY";
|
||||
case "weekly": case "week": return "FREQ=WEEKLY";
|
||||
case "monthly": case "month": return "FREQ=MONTHLY";
|
||||
case "yearly": case "annually": case "year": return "FREQ=YEARLY";
|
||||
case "weekdays": case "workdays": case "workday": case "weekday":
|
||||
return "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR";
|
||||
}
|
||||
|
||||
const body = (s.startsWith("every ") ? s.slice("every ".length) : s).trim();
|
||||
|
||||
if (body.startsWith("other ")) return intervalForm(2, body.slice("other ".length).trim());
|
||||
if (body === "workday" || body === "weekday" || body === "workdays" || body === "weekdays") {
|
||||
return "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR";
|
||||
}
|
||||
const wd = parseWeekday(body);
|
||||
if (wd !== null) return `FREQ=WEEKLY;BYDAY=${BYDAY[wd]}`;
|
||||
|
||||
const md = parseMonthDay(body);
|
||||
if (md) return `FREQ=YEARLY;BYMONTH=${md[0]};BYMONTHDAY=${md[1]}`;
|
||||
|
||||
const ord = parseMonthdayOrdinal(body);
|
||||
if (ord !== null) return `FREQ=MONTHLY;BYMONTHDAY=${ord}`;
|
||||
|
||||
const toks = body.split(/\s+/).filter(Boolean);
|
||||
const first = toks[0] ?? "";
|
||||
const asNum = /^\d+$/.test(first) ? parseInt(first, 10) : null;
|
||||
if (asNum !== null) return intervalForm(asNum, toks[1] ?? "");
|
||||
return intervalForm(1, first);
|
||||
}
|
||||
|
||||
/** parseRecurrence, but returns null instead of throwing. */
|
||||
export function parseRecurrenceOrNull(spec) {
|
||||
try {
|
||||
return parseRecurrence(spec);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Reverse: humanize an RRULE for display (§8.1) — a faithful port of hephd's
|
||||
// `datespec::humanize_rrule`.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const MONTH_ABBR = [
|
||||
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
||||
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
||||
];
|
||||
const DAY_ABBR = { MO: "Mon", TU: "Tue", WE: "Wed", TH: "Thu", FR: "Fri", SA: "Sat", SU: "Sun" };
|
||||
|
||||
function everyUnit(n, singular, plural, preset) {
|
||||
if (n === 1) return preset;
|
||||
if (n === 2) return `every other ${singular}`;
|
||||
return `every ${n} ${plural}`;
|
||||
}
|
||||
|
||||
function ordinal(n) {
|
||||
const tens = n % 100;
|
||||
if (tens >= 11 && tens <= 13) return `${n}th`;
|
||||
switch (n % 10) {
|
||||
case 1: return `${n}st`;
|
||||
case 2: return `${n}nd`;
|
||||
case 3: return `${n}rd`;
|
||||
default: return `${n}th`;
|
||||
}
|
||||
}
|
||||
|
||||
function isWeekdaySet(byday) {
|
||||
const days = byday.split(",").map((s) => s.trim()).sort();
|
||||
return days.join(",") === "FR,MO,TH,TU,WE";
|
||||
}
|
||||
|
||||
/** BYDAY tokens → capitalized weekday abbreviations, order preserved, or null
|
||||
* if any token isn't a bare weekday (e.g. an ordinal `2MO`). */
|
||||
function weekdayNames(byday) {
|
||||
const out = [];
|
||||
for (const tok of byday.split(",")) {
|
||||
const name = DAY_ABBR[tok.trim()];
|
||||
if (!name) return null;
|
||||
out.push(name);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
/**
|
||||
* Render an RFC-5545 RRULE back into the compact phrasing `parseRecurrence`
|
||||
* accepts — `daily`, `every 3 days`, `every other week`, `weekdays`,
|
||||
* `every Fri`, `every other Wed`, `weekly on Mon, Wed, Fri`, `monthly on the
|
||||
* 5th`, `yearly on Apr 15`. Any rule using parts we don't model (COUNT, UNTIL,
|
||||
* ordinal BYDAY, …) is returned **verbatim** so nothing is silently hidden.
|
||||
*/
|
||||
export function humanizeRecurrence(rrule) {
|
||||
const known = humanizeKnown(rrule);
|
||||
return known === null ? rrule.trim() : known;
|
||||
}
|
||||
|
||||
function humanizeKnown(rrule) {
|
||||
let freq = null;
|
||||
let interval = 1;
|
||||
let byday = null;
|
||||
let bymonth = null;
|
||||
let bymonthday = null;
|
||||
for (const rawPart of rrule.trim().split(";")) {
|
||||
const part = rawPart.trim();
|
||||
if (part === "") continue;
|
||||
const eq = part.indexOf("=");
|
||||
if (eq === -1) return null;
|
||||
const k = part.slice(0, eq).trim().toUpperCase();
|
||||
const v = part.slice(eq + 1).trim();
|
||||
switch (k) {
|
||||
case "FREQ": freq = v.toUpperCase(); break;
|
||||
case "INTERVAL": {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n) || n < 1) return null;
|
||||
interval = n;
|
||||
break;
|
||||
}
|
||||
case "BYDAY": byday = v.toUpperCase(); break;
|
||||
case "BYMONTH": {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n)) return null;
|
||||
bymonth = n;
|
||||
break;
|
||||
}
|
||||
case "BYMONTHDAY": {
|
||||
const n = Number(v);
|
||||
if (!Number.isInteger(n)) return null;
|
||||
bymonthday = n;
|
||||
break;
|
||||
}
|
||||
default: return null; // a part we don't render → don't risk a wrong summary
|
||||
}
|
||||
}
|
||||
|
||||
switch (freq) {
|
||||
case "DAILY":
|
||||
if (byday !== null || bymonth !== null || bymonthday !== null) return null;
|
||||
return everyUnit(interval, "day", "days", "daily");
|
||||
case "WEEKLY": {
|
||||
if (bymonth !== null || bymonthday !== null) return null;
|
||||
if (byday === null) return everyUnit(interval, "week", "weeks", "weekly");
|
||||
if (interval === 1 && isWeekdaySet(byday)) return "weekdays";
|
||||
const names = weekdayNames(byday);
|
||||
if (names === null) return null;
|
||||
if (names.length === 1) {
|
||||
const day = names[0];
|
||||
if (interval === 1) return `every ${day}`;
|
||||
if (interval === 2) return `every other ${day}`;
|
||||
return `every ${interval} weeks on ${day}`;
|
||||
}
|
||||
const joined = names.join(", ");
|
||||
if (interval === 1) return `weekly on ${joined}`;
|
||||
if (interval === 2) return `every other week on ${joined}`;
|
||||
return `every ${interval} weeks on ${joined}`;
|
||||
}
|
||||
case "MONTHLY": {
|
||||
if (byday !== null || bymonth !== null) return null;
|
||||
if (bymonthday === null) return everyUnit(interval, "month", "months", "monthly");
|
||||
if (bymonthday < 1 || bymonthday > 31) return null;
|
||||
const day = ordinal(bymonthday);
|
||||
if (interval === 1) return `monthly on the ${day}`;
|
||||
if (interval === 2) return `every other month on the ${day}`;
|
||||
return `every ${interval} months on the ${day}`;
|
||||
}
|
||||
case "YEARLY": {
|
||||
if (byday !== null) return null;
|
||||
if (bymonth === null && bymonthday === null) {
|
||||
return everyUnit(interval, "year", "years", "yearly");
|
||||
}
|
||||
if (bymonth < 1 || bymonth > 12 || bymonthday < 1 || bymonthday > 31) return null;
|
||||
const mon = MONTH_ABBR[bymonth - 1];
|
||||
if (interval === 1) return `yearly on ${mon} ${bymonthday}`;
|
||||
if (interval === 2) return `every other year on ${mon} ${bymonthday}`;
|
||||
return `every ${interval} years on ${mon} ${bymonthday}`;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
71
heph-pwa/src/fmt.js
Normal file
71
heph-pwa/src/fmt.js
Normal file
|
|
@ -0,0 +1,71 @@
|
|||
// Display helpers — the PWA mirror of heph-tui's fmt.rs: relative date chips,
|
||||
// attention colors/flags, and a stable per-project bullet color.
|
||||
|
||||
/** Attention color string → the CSS custom-property color used for flags/dots. */
|
||||
export const ATTENTION_COLORS = {
|
||||
red: "var(--att-red)",
|
||||
orange: "var(--att-orange)",
|
||||
blue: "var(--att-blue)",
|
||||
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"];
|
||||
|
||||
/** 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";
|
||||
}
|
||||
|
||||
/** Whether an attention band shows a flag glyph (red/orange/blue; not white). */
|
||||
export function hasFlag(att) {
|
||||
return att === "red" || att === "orange" || att === "blue";
|
||||
}
|
||||
|
||||
function startOfDay(ms) {
|
||||
const d = new Date(ms);
|
||||
return new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
|
||||
}
|
||||
|
||||
/**
|
||||
* Compact, relative date label for an epoch-ms date (heph-tui fmt.rs):
|
||||
* today/tomorrow/yesterday, else MM-DD within the current year, else YYYY-MM-DD.
|
||||
*/
|
||||
export function fmtRelative(ms, nowMs = Date.now()) {
|
||||
if (ms == null) return "";
|
||||
const day = startOfDay(ms);
|
||||
const today = startOfDay(nowMs);
|
||||
const oneDay = 86_400_000;
|
||||
if (day === today) return "today";
|
||||
if (day === today + oneDay) return "tomorrow";
|
||||
if (day === today - oneDay) return "yesterday";
|
||||
const d = new Date(ms);
|
||||
const mm = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const dd = String(d.getDate()).padStart(2, "0");
|
||||
if (d.getFullYear() === new Date(nowMs).getFullYear()) return `${mm}-${dd}`;
|
||||
return `${d.getFullYear()}-${mm}-${dd}`;
|
||||
}
|
||||
|
||||
/** True when `lateOn` is strictly in the past — the sole urgency signal (§7). */
|
||||
export function isOverdue(lateOn, nowMs = Date.now()) {
|
||||
return lateOn != null && nowMs > lateOn;
|
||||
}
|
||||
|
||||
/** A stable hue (0–359) for a project id, so its bullet color is deterministic. */
|
||||
export function projectHue(id) {
|
||||
if (!id) return null;
|
||||
let h = 0;
|
||||
for (let i = 0; i < id.length; i++) {
|
||||
h = (h * 31 + id.charCodeAt(i)) >>> 0;
|
||||
}
|
||||
return h % 360;
|
||||
}
|
||||
|
||||
/** CSS color for a project's bullet, or a neutral default when unfiled. */
|
||||
export function projectColor(id) {
|
||||
const hue = projectHue(id);
|
||||
return hue == null ? "var(--bullet-none)" : `hsl(${hue} 55% 62%)`;
|
||||
}
|
||||
204
heph-pwa/src/oauth.js
Normal file
204
heph-pwa/src/oauth.js
Normal file
|
|
@ -0,0 +1,204 @@
|
|||
// Browser OIDC sign-in for the PWA: Authorization Code + PKCE (RFC 7636) against
|
||||
// the hub's IdP (Authentik). Unlike the CLI's device-code flow, a browser SPA
|
||||
// uses a redirect + PKCE — no client secret, no polling. The resulting access
|
||||
// token is the same bearer the hub's OidcVerifier checks (iss / aud=client_id /
|
||||
// RS256 / exp), so once signed in the app talks to /rpc exactly as a pasted
|
||||
// token would. We also keep a refresh token (offline_access) to renew silently.
|
||||
//
|
||||
// Zero-config: the hub serves GET /config -> { issuer, client_id }, so the app
|
||||
// learns the IdP without the user typing anything when served from the hub.
|
||||
|
||||
const AUTH_KEY = "heph-pwa:auth"; // localStorage: { issuer, clientId, access, refresh, expiresAt }
|
||||
const PKCE_KEY = "heph-pwa:pkce"; // sessionStorage: in-flight { verifier, state, ... }
|
||||
|
||||
// --- persistence ------------------------------------------------------------
|
||||
|
||||
export function loadAuth() {
|
||||
try {
|
||||
return JSON.parse(localStorage.getItem(AUTH_KEY) || "null");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
function saveAuth(a) {
|
||||
localStorage.setItem(AUTH_KEY, JSON.stringify(a));
|
||||
}
|
||||
export function clearAuth() {
|
||||
localStorage.removeItem(AUTH_KEY);
|
||||
}
|
||||
export function loggedIn() {
|
||||
return !!loadAuth();
|
||||
}
|
||||
|
||||
// --- PKCE helpers -----------------------------------------------------------
|
||||
|
||||
function b64url(bytes) {
|
||||
return btoa(String.fromCharCode(...new Uint8Array(bytes)))
|
||||
.replace(/\+/g, "-")
|
||||
.replace(/\//g, "_")
|
||||
.replace(/=+$/, "");
|
||||
}
|
||||
function randomString(nbytes = 32) {
|
||||
const a = new Uint8Array(nbytes);
|
||||
crypto.getRandomValues(a);
|
||||
return b64url(a);
|
||||
}
|
||||
async function challengeOf(verifier) {
|
||||
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
|
||||
return b64url(digest);
|
||||
}
|
||||
|
||||
// The redirect URI is the app's own base directory (query/hash stripped), so it
|
||||
// is stable across the login start and the callback. Register this exact value
|
||||
// (with trailing slash) on the Authentik provider, e.g. https://heph.ops.eblu.me/.
|
||||
function redirectUri() {
|
||||
return new URL(".", location.href).href;
|
||||
}
|
||||
|
||||
// --- discovery --------------------------------------------------------------
|
||||
|
||||
async function discover(issuer) {
|
||||
const url = issuer.replace(/\/+$/, "") + "/.well-known/openid-configuration";
|
||||
const r = await fetch(url);
|
||||
if (!r.ok) throw new Error(`OIDC discovery failed (HTTP ${r.status}).`);
|
||||
const d = await r.json();
|
||||
if (!d.authorization_endpoint || !d.token_endpoint) {
|
||||
throw new Error("OIDC discovery is missing authorization/token endpoints.");
|
||||
}
|
||||
return { authorize: d.authorization_endpoint, token: d.token_endpoint };
|
||||
}
|
||||
|
||||
/** Read the hub's public OIDC params. Returns { issuer, clientId } or null. */
|
||||
export async function fetchHubConfig(baseUrl) {
|
||||
try {
|
||||
const r = await fetch(baseUrl.replace(/\/+$/, "") + "/config");
|
||||
if (!r.ok) return null;
|
||||
const d = await r.json();
|
||||
if (!d.issuer || !d.client_id) return null;
|
||||
return { issuer: d.issuer, clientId: d.client_id };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- login (redirect away) --------------------------------------------------
|
||||
|
||||
/** Begin a PKCE login: stash the verifier+state and redirect to the IdP. */
|
||||
export async function beginLogin({ issuer, clientId }) {
|
||||
const { authorize } = await discover(issuer);
|
||||
const verifier = randomString(48);
|
||||
const state = randomString(16);
|
||||
const redirect_uri = redirectUri();
|
||||
sessionStorage.setItem(
|
||||
PKCE_KEY,
|
||||
JSON.stringify({ verifier, state, issuer, clientId, redirect_uri }),
|
||||
);
|
||||
const params = new URLSearchParams({
|
||||
response_type: "code",
|
||||
client_id: clientId,
|
||||
redirect_uri,
|
||||
scope: "openid offline_access",
|
||||
state,
|
||||
code_challenge: await challengeOf(verifier),
|
||||
code_challenge_method: "S256",
|
||||
});
|
||||
location.assign(`${authorize}?${params}`);
|
||||
}
|
||||
|
||||
// --- callback (back from the IdP) -------------------------------------------
|
||||
|
||||
/** True when the current URL is an OAuth redirect callback. */
|
||||
export function isCallback() {
|
||||
const p = new URLSearchParams(location.search);
|
||||
return (p.has("code") && p.has("state")) || p.has("error");
|
||||
}
|
||||
|
||||
/** Exchange the callback code for tokens. Always cleans the URL. Returns the
|
||||
* access token on success; throws on failure. */
|
||||
export async function completeLogin() {
|
||||
const p = new URLSearchParams(location.search);
|
||||
const cleanUrl = () => history.replaceState(null, "", redirectUri());
|
||||
let pkce = null;
|
||||
try {
|
||||
pkce = JSON.parse(sessionStorage.getItem(PKCE_KEY) || "null");
|
||||
} catch {
|
||||
pkce = null;
|
||||
}
|
||||
sessionStorage.removeItem(PKCE_KEY);
|
||||
|
||||
if (p.get("error")) {
|
||||
cleanUrl();
|
||||
throw new Error(p.get("error_description") || p.get("error"));
|
||||
}
|
||||
if (!pkce || pkce.state !== p.get("state")) {
|
||||
cleanUrl();
|
||||
throw new Error("Login state mismatch — please try again.");
|
||||
}
|
||||
|
||||
const { token } = await discover(pkce.issuer);
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "authorization_code",
|
||||
code: p.get("code"),
|
||||
client_id: pkce.clientId,
|
||||
redirect_uri: pkce.redirect_uri,
|
||||
code_verifier: pkce.verifier,
|
||||
});
|
||||
const r = await fetch(token, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
cleanUrl();
|
||||
if (!r.ok) throw new Error(`Token exchange failed (HTTP ${r.status}).`);
|
||||
const t = await r.json();
|
||||
saveAuth({
|
||||
issuer: pkce.issuer,
|
||||
clientId: pkce.clientId,
|
||||
access: t.access_token,
|
||||
refresh: t.refresh_token || null,
|
||||
expiresAt: Date.now() + (t.expires_in || 3600) * 1000,
|
||||
});
|
||||
return t.access_token;
|
||||
}
|
||||
|
||||
// --- token lifecycle --------------------------------------------------------
|
||||
|
||||
/** Return a usable access token, refreshing if it is near expiry (or `force`).
|
||||
* Returns null when not logged in or when a refresh fails (caller re-prompts). */
|
||||
export async function ensureFreshToken(force = false) {
|
||||
const a = loadAuth();
|
||||
if (!a) return null;
|
||||
const stillFresh = a.expiresAt - Date.now() > 60_000;
|
||||
if (!force && stillFresh) return a.access;
|
||||
if (!a.refresh) return force ? null : a.access;
|
||||
try {
|
||||
const { token } = await discover(a.issuer);
|
||||
const body = new URLSearchParams({
|
||||
grant_type: "refresh_token",
|
||||
refresh_token: a.refresh,
|
||||
client_id: a.clientId,
|
||||
});
|
||||
const r = await fetch(token, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/x-www-form-urlencoded" },
|
||||
body,
|
||||
});
|
||||
if (!r.ok) {
|
||||
// Refresh token rejected (expired/revoked) — drop the session so the UI
|
||||
// shows "signed out" and the user can log in again.
|
||||
clearAuth();
|
||||
return null;
|
||||
}
|
||||
const t = await r.json();
|
||||
saveAuth({
|
||||
...a,
|
||||
access: t.access_token,
|
||||
refresh: t.refresh_token || a.refresh,
|
||||
expiresAt: Date.now() + (t.expires_in || 3600) * 1000,
|
||||
});
|
||||
return t.access_token;
|
||||
} catch {
|
||||
// Network blip — keep the (possibly stale) token; the RPC layer will retry.
|
||||
return a.access;
|
||||
}
|
||||
}
|
||||
113
heph-pwa/src/quickadd.js
Normal file
113
heph-pwa/src/quickadd.js
Normal file
|
|
@ -0,0 +1,113 @@
|
|||
// 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`
|
||||
//
|
||||
// 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)
|
||||
// - Project #Name → resolved against existing projects, greedily matching
|
||||
// multi-word titles (#Camano Chores). Unresolved #tags
|
||||
// stay in the title verbatim (no surprise project).
|
||||
// - Do-date a datespec token: today/tomorrow/+3d/fri/ISO
|
||||
// - Recurrence an `every …` phrase (the longest suffix that parses)
|
||||
|
||||
import { parseDate, toEpochMs, parseRecurrenceOrNull } from "./datespec.js";
|
||||
|
||||
/** p1..p4 → attention color string (matching the RPC serialization), or null. */
|
||||
function priorityAttention(token) {
|
||||
switch (token.toLowerCase()) {
|
||||
case "p1": return "red";
|
||||
case "p2": return "orange";
|
||||
case "p3": return "blue";
|
||||
case "p4": return "white";
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Greedily match `first` (+ following words) against a known project title,
|
||||
* case-insensitively, longest-first. Returns [projectId, extraWordsTaken] or
|
||||
* null. `projects` is an array of { id, title }.
|
||||
*/
|
||||
function matchProject(first, rest, projects) {
|
||||
const maxExtra = Math.min(rest.length, 4);
|
||||
for (let extra = maxExtra; extra >= 0; extra--) {
|
||||
const candidate = [first, ...rest.slice(0, extra)].join(" ");
|
||||
const p = projects.find((p) => p.title.toLowerCase() === candidate.toLowerCase());
|
||||
if (p) return [p.id, extra];
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/** Find the first `every` token and consume the longest suffix that parses. */
|
||||
function extractRecurrence(tokens, out) {
|
||||
const start = tokens.findIndex((t) => t.toLowerCase() === "every");
|
||||
if (start === -1) return;
|
||||
for (let end = tokens.length; end > start + 1; end--) {
|
||||
const phrase = tokens.slice(start, end).join(" ");
|
||||
const rrule = parseRecurrenceOrNull(phrase);
|
||||
if (rrule) {
|
||||
out.recurrence = rrule;
|
||||
tokens.splice(start, end - start);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a quick-add line against `today` (a local-midnight Date) and the known
|
||||
* `projects` (array of { id, title }). Returns:
|
||||
* { title, attention|null, doDate(ms)|null, recurrence(RRULE)|null, projectId|null }
|
||||
*/
|
||||
export function parse(input, todayDate, projects = []) {
|
||||
const tokens = input.split(/\s+/).filter(Boolean);
|
||||
const out = {
|
||||
title: "",
|
||||
attention: null,
|
||||
doDate: null,
|
||||
recurrence: null,
|
||||
projectId: null,
|
||||
};
|
||||
|
||||
extractRecurrence(tokens, out);
|
||||
|
||||
const title = [];
|
||||
let i = 0;
|
||||
while (i < tokens.length) {
|
||||
const tok = tokens[i];
|
||||
|
||||
const att = priorityAttention(tok);
|
||||
if (att !== null) {
|
||||
out.attention = att;
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (tok.startsWith("#")) {
|
||||
const stripped = tok.slice(1);
|
||||
const matched = matchProject(stripped, tokens.slice(i + 1), projects);
|
||||
if (matched) {
|
||||
out.projectId = matched[0];
|
||||
i += 1 + matched[1];
|
||||
continue;
|
||||
}
|
||||
// Unresolved #tag: keep the word (with the #) in the title.
|
||||
}
|
||||
|
||||
if (out.doDate === null) {
|
||||
try {
|
||||
out.doDate = toEpochMs(parseDate(tok, todayDate));
|
||||
i += 1;
|
||||
continue;
|
||||
} catch {
|
||||
// not a date token; fall through to title
|
||||
}
|
||||
}
|
||||
|
||||
title.push(tok);
|
||||
i += 1;
|
||||
}
|
||||
|
||||
out.title = title.join(" ");
|
||||
return out;
|
||||
}
|
||||
182
heph-pwa/src/rpc.js
Normal file
182
heph-pwa/src/rpc.js
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
// hephd JSON-RPC-over-HTTP client for the PWA. The PWA is a thin, online-only
|
||||
// client (no local CRDT replica): every read and write is a POST to the hub's
|
||||
// `/rpc` endpoint, exactly mirroring heph-tui's socket Backend (backend.rs).
|
||||
//
|
||||
// Connection settings (hub base URL + optional bearer token) live in
|
||||
// localStorage so the install remembers them across launches.
|
||||
|
||||
const SETTINGS_KEY = "heph-pwa:settings";
|
||||
|
||||
export function loadSettings() {
|
||||
let s = {};
|
||||
try {
|
||||
s = JSON.parse(localStorage.getItem(SETTINGS_KEY) || "{}");
|
||||
} catch {
|
||||
s = {};
|
||||
}
|
||||
let baseUrl = s.baseUrl || "";
|
||||
// Served from the hub? Default the hub URL to our own origin so the app is
|
||||
// zero-config out of the box (the Settings screen still lets you override,
|
||||
// e.g. when the shell is hosted separately from the hub).
|
||||
if (!baseUrl && typeof location !== "undefined" && /^https?:/.test(location.origin)) {
|
||||
baseUrl = location.origin;
|
||||
}
|
||||
return { baseUrl, token: s.token || "" };
|
||||
}
|
||||
|
||||
export function saveSettings(settings) {
|
||||
localStorage.setItem(
|
||||
SETTINGS_KEY,
|
||||
JSON.stringify({ baseUrl: settings.baseUrl || "", token: settings.token || "" }),
|
||||
);
|
||||
}
|
||||
|
||||
/** Thrown for transport/auth/method failures, carrying an HTTP-ish status. */
|
||||
export class RpcError extends Error {
|
||||
constructor(message, status = 0) {
|
||||
super(message);
|
||||
this.name = "RpcError";
|
||||
this.status = status;
|
||||
}
|
||||
}
|
||||
|
||||
export class Client {
|
||||
constructor(settings) {
|
||||
this.settings = settings;
|
||||
}
|
||||
|
||||
get configured() {
|
||||
return !!this.settings.baseUrl;
|
||||
}
|
||||
|
||||
/** Low-level call: returns the `result` value, or throws RpcError. On a 401,
|
||||
* tries the optional `settings.refresh()` hook once (OIDC silent refresh) and
|
||||
* retries before surfacing the error. */
|
||||
async call(method, params = {}, _retried = false) {
|
||||
if (!this.configured) {
|
||||
throw new RpcError("No hub configured — open Settings and set the hub URL.", 0);
|
||||
}
|
||||
const base = this.settings.baseUrl.replace(/\/+$/, "");
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (this.settings.token) headers["Authorization"] = `Bearer ${this.settings.token}`;
|
||||
|
||||
let resp;
|
||||
try {
|
||||
resp = await fetch(`${base}/rpc`, {
|
||||
method: "POST",
|
||||
headers,
|
||||
body: JSON.stringify({ method, params }),
|
||||
});
|
||||
} catch (e) {
|
||||
throw new RpcError(`Cannot reach hub at ${base} (${e.message}).`, 0);
|
||||
}
|
||||
if (resp.status === 401) {
|
||||
if (!_retried && typeof this.settings.refresh === "function") {
|
||||
const fresh = await this.settings.refresh();
|
||||
if (fresh) {
|
||||
this.settings.token = fresh;
|
||||
return this.call(method, params, true);
|
||||
}
|
||||
}
|
||||
throw new RpcError("Unauthorized — sign in again (Settings).", 401);
|
||||
}
|
||||
if (resp.status === 403) throw new RpcError("Forbidden — this token does not own the hub.", 403);
|
||||
if (!resp.ok) throw new RpcError(`Hub returned HTTP ${resp.status}.`, resp.status);
|
||||
|
||||
const body = await resp.json();
|
||||
if (body.error) throw new RpcError(body.error.message || "RPC error", 200);
|
||||
return body.result;
|
||||
}
|
||||
|
||||
// --- Reads (mirror heph-tui's Backend) ---------------------------------
|
||||
|
||||
/** Built-in named view (tom|tasks|work|chores|ondeck|inbox) → RankedTask[]. */
|
||||
view(name) {
|
||||
return this.call("view", { name });
|
||||
}
|
||||
|
||||
/** Raw filter listing → RankedTask[]. */
|
||||
list(filter) {
|
||||
return this.call("list", filter);
|
||||
}
|
||||
|
||||
/** Projects, title-sorted → [{ id, title }]. */
|
||||
async projects() {
|
||||
const nodes = await this.call("node.list", { kind: "project" });
|
||||
return nodes.map((n) => ({ id: n.id, title: n.title }));
|
||||
}
|
||||
|
||||
async nodeBody(id) {
|
||||
const node = await this.call("node.get", { id });
|
||||
return node && node.body ? node.body : "";
|
||||
}
|
||||
|
||||
logTail(taskId, n = 5) {
|
||||
return this.call("log.tail", { task_id: taskId, n });
|
||||
}
|
||||
|
||||
/** Full-text search → [{ id, title, kind }]. */
|
||||
async search(query) {
|
||||
const nodes = await this.call("search", { query });
|
||||
return nodes.map((n) => ({ id: n.id, title: n.title, kind: n.kind }));
|
||||
}
|
||||
|
||||
health() {
|
||||
return this.call("health", {});
|
||||
}
|
||||
|
||||
// --- Writes ------------------------------------------------------------
|
||||
|
||||
/** Create a task. attention/doDate/recurrence/projectId may be null. */
|
||||
createTask({ title, attention = null, doDate = null, recurrence = null, projectId = null }) {
|
||||
return this.call("task.create", {
|
||||
title,
|
||||
attention,
|
||||
do_date: doDate,
|
||||
recurrence,
|
||||
project_id: projectId,
|
||||
});
|
||||
}
|
||||
|
||||
setState(id, state) {
|
||||
return this.call("task.set_state", { id, state });
|
||||
}
|
||||
|
||||
setAttention(id, attention) {
|
||||
return this.call("task.set_attention", { id, attention });
|
||||
}
|
||||
|
||||
/** Patch schedule scalars. Pass undefined to leave a field unchanged; pass
|
||||
* null to clear it; pass a value to set it (double-option semantics). */
|
||||
setSchedule(id, patch) {
|
||||
const params = { id };
|
||||
if ("doDate" in patch) params.do_date = patch.doDate;
|
||||
if ("lateOn" in patch) params.late_on = patch.lateOn;
|
||||
if ("recurrence" in patch) params.recurrence = patch.recurrence;
|
||||
return this.call("task.set_schedule", params);
|
||||
}
|
||||
|
||||
setProject(id, projectId) {
|
||||
return this.call("task.set_project", { id, project_id: projectId });
|
||||
}
|
||||
|
||||
skip(id) {
|
||||
return this.call("task.skip", { id });
|
||||
}
|
||||
|
||||
tombstone(id) {
|
||||
return this.call("node.tombstone", { id });
|
||||
}
|
||||
|
||||
async createProject(title) {
|
||||
const node = await this.call("node.create", { kind: "project", title });
|
||||
return node.id;
|
||||
}
|
||||
|
||||
/** The canonical context doc id for a task, if any (links.outgoing). */
|
||||
async contextOf(taskId) {
|
||||
const links = await this.call("links.outgoing", { id: taskId });
|
||||
const ctx = links.find((l) => l.link_type === "canonical-context");
|
||||
return ctx ? ctx.dst_id : null;
|
||||
}
|
||||
}
|
||||
524
heph-pwa/styles.css
Normal file
524
heph-pwa/styles.css
Normal file
|
|
@ -0,0 +1,524 @@
|
|||
/* heph-pwa — a dark, terminal-flavored mirror of heph-tui, tuned for touch. */
|
||||
|
||||
:root {
|
||||
--bg: #15181d;
|
||||
--bg-elev: #1c2027;
|
||||
--bg-row: #1a1e24;
|
||||
--border: #2a2f38;
|
||||
--fg: #e6e9ef;
|
||||
--fg-dim: #8b94a3;
|
||||
--accent: #6db3f2;
|
||||
--att-red: #ff6b6b;
|
||||
--att-orange: #ffb454;
|
||||
--att-blue: #6db3f2;
|
||||
--att-white: #e6e9ef;
|
||||
--bullet-none: #5a6373;
|
||||
--danger: #ff6b6b;
|
||||
--safe-top: env(safe-area-inset-top, 0px);
|
||||
--safe-bottom: env(safe-area-inset-bottom, 0px);
|
||||
}
|
||||
|
||||
* {
|
||||
box-sizing: border-box;
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
|
||||
html,
|
||||
body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
background: var(--bg);
|
||||
color: var(--fg);
|
||||
font: 16px/1.4 -apple-system, BlinkMacSystemFont, "Segoe UI", system-ui, sans-serif;
|
||||
overscroll-behavior-y: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
/* --- App bar --- */
|
||||
.appbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: calc(var(--safe-top) + 6px) 8px 6px;
|
||||
background: var(--bg-elev);
|
||||
border-bottom: 1px solid var(--border);
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 5;
|
||||
}
|
||||
.appbar-title {
|
||||
flex: 1;
|
||||
font-weight: 600;
|
||||
font-size: 18px;
|
||||
padding-left: 4px;
|
||||
}
|
||||
.icon-btn {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--fg);
|
||||
font-size: 20px;
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
.icon-btn:active {
|
||||
background: var(--bg-row);
|
||||
}
|
||||
|
||||
/* --- Main / list --- */
|
||||
#main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
padding-bottom: calc(var(--safe-bottom) + 96px);
|
||||
}
|
||||
.notice {
|
||||
padding: 32px 20px;
|
||||
text-align: center;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.notice.error {
|
||||
color: var(--att-orange);
|
||||
}
|
||||
|
||||
.list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.row {
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.row-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 12px 14px;
|
||||
min-height: 28px;
|
||||
}
|
||||
.row.expanded {
|
||||
background: var(--bg-row);
|
||||
}
|
||||
.flag {
|
||||
width: 14px;
|
||||
text-align: center;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.bullet {
|
||||
flex: 0 0 auto;
|
||||
font-size: 12px;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.row.expanded .title {
|
||||
white-space: normal;
|
||||
}
|
||||
.recur {
|
||||
color: #c678dd;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.chip {
|
||||
flex: 0 0 auto;
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
.chip.overdue {
|
||||
color: var(--att-red);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
/* --- Task detail --- */
|
||||
.detail {
|
||||
padding: 4px 14px 14px 36px;
|
||||
}
|
||||
.meta {
|
||||
margin-bottom: 10px;
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
}
|
||||
.meta-k {
|
||||
width: 64px;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
}
|
||||
.act {
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
border-radius: 8px;
|
||||
padding: 8px 10px;
|
||||
font-size: 14px;
|
||||
}
|
||||
.act:active {
|
||||
background: var(--border);
|
||||
}
|
||||
.act.danger {
|
||||
color: var(--danger);
|
||||
border-color: #4a2a2a;
|
||||
}
|
||||
.preview {
|
||||
margin: 12px 0 0;
|
||||
padding: 10px;
|
||||
background: #0f1216;
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
font: 13px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
color: var(--fg-dim);
|
||||
white-space: pre-wrap;
|
||||
word-break: break-word;
|
||||
max-height: 240px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
/* --- FAB --- */
|
||||
.fab {
|
||||
position: fixed;
|
||||
right: 18px;
|
||||
bottom: calc(var(--safe-bottom) + 18px);
|
||||
width: 60px;
|
||||
height: 60px;
|
||||
border-radius: 30px;
|
||||
border: 0;
|
||||
background: var(--accent);
|
||||
color: #0c1014;
|
||||
font-size: 34px;
|
||||
line-height: 1;
|
||||
box-shadow: 0 6px 18px rgba(0, 0, 0, 0.45);
|
||||
z-index: 6;
|
||||
}
|
||||
.fab:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* --- Drawer --- */
|
||||
.backdrop {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
transition: opacity 0.18s;
|
||||
z-index: 9;
|
||||
}
|
||||
.backdrop.show {
|
||||
opacity: 1;
|
||||
pointer-events: auto;
|
||||
}
|
||||
.drawer {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
bottom: 0;
|
||||
width: 78%;
|
||||
max-width: 320px;
|
||||
background: var(--bg-elev);
|
||||
border-right: 1px solid var(--border);
|
||||
transform: translateX(-100%);
|
||||
transition: transform 0.2s ease;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.drawer.open {
|
||||
transform: translateX(0);
|
||||
}
|
||||
.drawer-head {
|
||||
padding: calc(var(--safe-top) + 16px) 16px 12px;
|
||||
font-weight: 700;
|
||||
font-size: 20px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.drawer-body {
|
||||
overflow-y: auto;
|
||||
padding-bottom: var(--safe-bottom);
|
||||
}
|
||||
.drawer-section {
|
||||
padding: 14px 16px 6px;
|
||||
font-size: 12px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.drawer-empty {
|
||||
padding: 4px 16px 8px;
|
||||
color: var(--fg-dim);
|
||||
font-size: 14px;
|
||||
}
|
||||
.drawer-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 16px;
|
||||
}
|
||||
.drawer-item.active {
|
||||
background: var(--bg-row);
|
||||
box-shadow: inset 3px 0 0 var(--accent);
|
||||
}
|
||||
.drawer-item:active {
|
||||
background: var(--border);
|
||||
}
|
||||
|
||||
/* --- Modals --- */
|
||||
.modal-root {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: none;
|
||||
}
|
||||
.modal-root.show {
|
||||
display: block;
|
||||
}
|
||||
.modal-backdrop {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
background: rgba(0, 0, 0, 0.55);
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: center;
|
||||
padding: calc(var(--safe-top) + 12vh) 12px 12px;
|
||||
}
|
||||
.modal {
|
||||
width: 100%;
|
||||
max-width: 560px;
|
||||
background: var(--bg-elev);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 14px;
|
||||
padding: 14px;
|
||||
box-shadow: 0 10px 40px rgba(0, 0, 0, 0.5);
|
||||
}
|
||||
.modal-title {
|
||||
font-weight: 600;
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.qa {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
.qa-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
}
|
||||
.qa-input,
|
||||
.search-input {
|
||||
flex: 1;
|
||||
width: 100%;
|
||||
background: #0f1216;
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
border-radius: 10px;
|
||||
padding: 13px 12px;
|
||||
font-size: 17px; /* ≥16px so iOS doesn't zoom on focus */
|
||||
}
|
||||
.qa-input:focus,
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent);
|
||||
}
|
||||
.qa-mic {
|
||||
flex: 0 0 auto;
|
||||
width: 46px;
|
||||
height: 46px;
|
||||
border-radius: 10px;
|
||||
border: 1px solid var(--border);
|
||||
background: var(--bg-row);
|
||||
font-size: 20px;
|
||||
}
|
||||
.qa-mic.listening {
|
||||
border-color: var(--att-red);
|
||||
animation: pulse 1s infinite;
|
||||
}
|
||||
@keyframes pulse {
|
||||
50% {
|
||||
background: #3a2326;
|
||||
}
|
||||
}
|
||||
.qa-preview {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
min-height: 22px;
|
||||
}
|
||||
.qa-hint {
|
||||
color: var(--fg-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.qa-title {
|
||||
font-weight: 600;
|
||||
}
|
||||
.qa-tag {
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
background: var(--bg-row);
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 2px 6px;
|
||||
}
|
||||
.qa-foot {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 10px;
|
||||
}
|
||||
.qa-dest {
|
||||
color: var(--fg-dim);
|
||||
font-size: 13px;
|
||||
}
|
||||
.qa-add {
|
||||
background: var(--accent);
|
||||
color: #0c1014;
|
||||
border: 0;
|
||||
border-radius: 10px;
|
||||
padding: 11px 22px;
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.picker-list {
|
||||
max-height: 50vh;
|
||||
overflow-y: auto;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
.picker-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 12px 6px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.picker-item:active {
|
||||
background: var(--bg-row);
|
||||
}
|
||||
|
||||
.settings-label {
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
margin-bottom: -4px;
|
||||
}
|
||||
.settings-foot {
|
||||
justify-content: flex-end;
|
||||
}
|
||||
.settings-test {
|
||||
font-size: 13px;
|
||||
min-height: 18px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.settings-test.ok {
|
||||
color: #7ec77e;
|
||||
}
|
||||
.settings-test.bad {
|
||||
color: var(--att-red);
|
||||
}
|
||||
.settings-hint {
|
||||
font-size: 12px;
|
||||
color: var(--fg-dim);
|
||||
}
|
||||
.settings-login {
|
||||
align-self: stretch;
|
||||
}
|
||||
.settings-auth {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
}
|
||||
.settings-auth .settings-test {
|
||||
flex: 1;
|
||||
}
|
||||
.settings-manual > summary {
|
||||
font-size: 13px;
|
||||
color: var(--fg-dim);
|
||||
cursor: pointer;
|
||||
margin-bottom: 6px;
|
||||
}
|
||||
.settings-manual[open] > summary {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* --- Search --- */
|
||||
.search-pane {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
.search-bar {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
padding: 10px 12px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.search-close {
|
||||
background: var(--bg-row);
|
||||
border: 1px solid var(--border);
|
||||
color: var(--fg);
|
||||
border-radius: 10px;
|
||||
width: 46px;
|
||||
font-size: 18px;
|
||||
}
|
||||
.search-results {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.search-hit {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
.hit-kind {
|
||||
color: var(--fg-dim);
|
||||
font: 13px ui-monospace, monospace;
|
||||
flex: 0 0 auto;
|
||||
}
|
||||
|
||||
/* --- Toast --- */
|
||||
.toast {
|
||||
position: fixed;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: calc(var(--safe-bottom) + 90px);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
z-index: 30;
|
||||
pointer-events: none;
|
||||
padding: 0 12px;
|
||||
}
|
||||
.toast-body {
|
||||
pointer-events: auto;
|
||||
background: #2a2f38;
|
||||
color: var(--fg);
|
||||
border-radius: 10px;
|
||||
padding: 11px 14px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 14px;
|
||||
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.5);
|
||||
max-width: 560px;
|
||||
}
|
||||
.toast-action {
|
||||
background: none;
|
||||
border: 0;
|
||||
color: var(--accent);
|
||||
font-weight: 700;
|
||||
font-size: 15px;
|
||||
}
|
||||
56
heph-pwa/sw.js
Normal file
56
heph-pwa/sw.js
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
// 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 SHELL = [
|
||||
"./",
|
||||
"./index.html",
|
||||
"./styles.css",
|
||||
"./manifest.webmanifest",
|
||||
"./src/app.js",
|
||||
"./src/rpc.js",
|
||||
"./src/oauth.js",
|
||||
"./src/quickadd.js",
|
||||
"./src/datespec.js",
|
||||
"./src/fmt.js",
|
||||
"./icons/icon.svg",
|
||||
];
|
||||
|
||||
self.addEventListener("install", (e) => {
|
||||
e.waitUntil(caches.open(CACHE).then((c) => c.addAll(SHELL)).then(() => self.skipWaiting()));
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (e) => {
|
||||
e.waitUntil(
|
||||
caches
|
||||
.keys()
|
||||
.then((keys) => Promise.all(keys.filter((k) => k !== CACHE).map((k) => caches.delete(k))))
|
||||
.then(() => self.clients.claim()),
|
||||
);
|
||||
});
|
||||
|
||||
self.addEventListener("fetch", (e) => {
|
||||
const req = e.request;
|
||||
// Only cache same-origin GETs (the shell). Everything else (RPC, cross-origin)
|
||||
// goes straight to the network. Skip URLs with a query string too, so the OAuth
|
||||
// redirect callback (`/?code=…&state=…`) is never cached or served from cache.
|
||||
const u = new URL(req.url);
|
||||
if (req.method !== "GET" || u.origin !== self.location.origin || u.search) {
|
||||
return;
|
||||
}
|
||||
e.respondWith(
|
||||
caches.match(req).then(
|
||||
(hit) =>
|
||||
hit ||
|
||||
fetch(req)
|
||||
.then((resp) => {
|
||||
if (resp.ok) {
|
||||
const copy = resp.clone();
|
||||
caches.open(CACHE).then((c) => c.put(req, copy));
|
||||
}
|
||||
return resp;
|
||||
})
|
||||
.catch(() => caches.match("./index.html")),
|
||||
),
|
||||
);
|
||||
});
|
||||
184
heph-pwa/test/parsers.test.mjs
Normal file
184
heph-pwa/test/parsers.test.mjs
Normal file
|
|
@ -0,0 +1,184 @@
|
|||
// Parity tests for the JS parser ports — the exact cases from hephd's
|
||||
// quickadd.rs / datespec.rs unit tests. Run: `node --test heph-pwa/test/`.
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
import {
|
||||
parseDate,
|
||||
parseRecurrence,
|
||||
humanizeRecurrence,
|
||||
toEpochMs,
|
||||
} from "../src/datespec.js";
|
||||
import { parse } from "../src/quickadd.js";
|
||||
|
||||
const d = (y, m, day) => new Date(y, m - 1, day);
|
||||
const ms = (y, m, day) => toEpochMs(d(y, m, day));
|
||||
|
||||
// datespec.rs uses 2026-06-02 (a Tuesday) as `today`.
|
||||
const DTODAY = d(2026, 6, 2);
|
||||
|
||||
test("parse_date keywords and offsets", () => {
|
||||
assert.deepEqual(parseDate("today", DTODAY), d(2026, 6, 2));
|
||||
assert.deepEqual(parseDate("tomorrow", DTODAY), d(2026, 6, 3));
|
||||
assert.deepEqual(parseDate("yesterday", DTODAY), d(2026, 6, 1));
|
||||
assert.deepEqual(parseDate("+3d", DTODAY), d(2026, 6, 5));
|
||||
assert.deepEqual(parseDate("+2w", DTODAY), d(2026, 6, 16));
|
||||
assert.deepEqual(parseDate("+1m", DTODAY), d(2026, 7, 2));
|
||||
assert.deepEqual(parseDate("+5", DTODAY), d(2026, 6, 7));
|
||||
});
|
||||
|
||||
test("parse_date weekdays are soonest on/after today", () => {
|
||||
assert.deepEqual(parseDate("tue", DTODAY), d(2026, 6, 2)); // today
|
||||
assert.deepEqual(parseDate("fri", DTODAY), d(2026, 6, 5));
|
||||
assert.deepEqual(parseDate("mon", DTODAY), d(2026, 6, 8)); // wraps
|
||||
});
|
||||
|
||||
test("parse_date iso and errors", () => {
|
||||
assert.deepEqual(parseDate("2026-12-25", DTODAY), d(2026, 12, 25));
|
||||
assert.throws(() => parseDate("someday", DTODAY));
|
||||
assert.throws(() => parseDate("", DTODAY));
|
||||
});
|
||||
|
||||
test("recurrence presets and raw", () => {
|
||||
assert.equal(parseRecurrence("daily"), "FREQ=DAILY");
|
||||
assert.equal(parseRecurrence("weekly"), "FREQ=WEEKLY");
|
||||
assert.equal(parseRecurrence("monthly"), "FREQ=MONTHLY");
|
||||
assert.equal(parseRecurrence("yearly"), "FREQ=YEARLY");
|
||||
assert.equal(parseRecurrence("weekdays"), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
|
||||
assert.equal(parseRecurrence("FREQ=DAILY;INTERVAL=2"), "FREQ=DAILY;INTERVAL=2");
|
||||
});
|
||||
|
||||
test("recurrence natural language", () => {
|
||||
assert.equal(parseRecurrence("every day"), "FREQ=DAILY");
|
||||
assert.equal(parseRecurrence("every 3 days"), "FREQ=DAILY;INTERVAL=3");
|
||||
assert.equal(parseRecurrence("every 2 weeks"), "FREQ=WEEKLY;INTERVAL=2");
|
||||
assert.equal(parseRecurrence("every 6 months"), "FREQ=MONTHLY;INTERVAL=6");
|
||||
assert.equal(parseRecurrence("every fri"), "FREQ=WEEKLY;BYDAY=FR");
|
||||
assert.equal(parseRecurrence("every other wed"), "FREQ=WEEKLY;INTERVAL=2;BYDAY=WE");
|
||||
assert.equal(parseRecurrence("every other day"), "FREQ=DAILY;INTERVAL=2");
|
||||
assert.equal(parseRecurrence("every workday at 08:00"), "FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR");
|
||||
assert.equal(parseRecurrence("every April 15"), "FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15");
|
||||
assert.equal(parseRecurrence("every 5th"), "FREQ=MONTHLY;BYMONTHDAY=5");
|
||||
assert.equal(parseRecurrence("every 22nd"), "FREQ=MONTHLY;BYMONTHDAY=22");
|
||||
assert.throws(() => parseRecurrence("every blue moon"));
|
||||
});
|
||||
|
||||
// Parity with hephd's `humanize_rrule` unit tests (datespec.rs).
|
||||
test("humanize inverts the natural-language forms", () => {
|
||||
const cases = [
|
||||
["FREQ=DAILY", "daily"],
|
||||
["FREQ=DAILY;INTERVAL=2", "every other day"],
|
||||
["FREQ=DAILY;INTERVAL=3", "every 3 days"],
|
||||
["FREQ=WEEKLY", "weekly"],
|
||||
["FREQ=WEEKLY;INTERVAL=2", "every other week"],
|
||||
["FREQ=MONTHLY", "monthly"],
|
||||
["FREQ=MONTHLY;INTERVAL=6", "every 6 months"],
|
||||
["FREQ=YEARLY", "yearly"],
|
||||
["FREQ=WEEKLY;BYDAY=FR", "every Fri"],
|
||||
["FREQ=WEEKLY;INTERVAL=2;BYDAY=WE", "every other Wed"],
|
||||
["FREQ=WEEKLY;INTERVAL=3;BYDAY=WE", "every 3 weeks on Wed"],
|
||||
["FREQ=WEEKLY;BYDAY=MO,TU,WE,TH,FR", "weekdays"],
|
||||
["FREQ=WEEKLY;BYDAY=MO,WE,FR", "weekly on Mon, Wed, Fri"],
|
||||
["FREQ=YEARLY;BYMONTH=4;BYMONTHDAY=15", "yearly on Apr 15"],
|
||||
["FREQ=MONTHLY;BYMONTHDAY=5", "monthly on the 5th"],
|
||||
["FREQ=MONTHLY;BYMONTHDAY=22", "monthly on the 22nd"],
|
||||
["FREQ=MONTHLY;BYMONTHDAY=1", "monthly on the 1st"],
|
||||
["FREQ=MONTHLY;BYMONTHDAY=13", "monthly on the 13th"],
|
||||
];
|
||||
for (const [rrule, want] of cases) {
|
||||
assert.equal(humanizeRecurrence(rrule), want, `humanizing ${rrule}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("humanize round-trips the re-typeable forms through parseRecurrence", () => {
|
||||
for (const input of [
|
||||
"every 3 days",
|
||||
"every other day",
|
||||
"every other wed",
|
||||
"weekdays",
|
||||
"every fri",
|
||||
"every 6 months",
|
||||
"every 2 weeks",
|
||||
]) {
|
||||
const rrule = parseRecurrence(input);
|
||||
assert.equal(parseRecurrence(humanizeRecurrence(rrule)), rrule, `round-trip ${input}`);
|
||||
}
|
||||
});
|
||||
|
||||
test("humanize falls back to raw for unmodeled rules", () => {
|
||||
for (const raw of [
|
||||
"FREQ=DAILY;COUNT=5",
|
||||
"FREQ=WEEKLY;UNTIL=20261231T000000Z",
|
||||
"FREQ=MONTHLY;BYDAY=2MO",
|
||||
"FREQ=MONTHLY;BYMONTHDAY=-1",
|
||||
"not an rrule at all",
|
||||
]) {
|
||||
assert.equal(humanizeRecurrence(raw), raw, `passthrough ${raw}`);
|
||||
}
|
||||
});
|
||||
|
||||
// quickadd.rs uses 2026-06-03 as `today` with projects Work + Camano Chores.
|
||||
const QTODAY = d(2026, 6, 3);
|
||||
const PROJECTS = [
|
||||
{ id: "work", title: "Work" },
|
||||
{ id: "camano", title: "Camano Chores" },
|
||||
];
|
||||
const p = (input) => parse(input, QTODAY, PROJECTS);
|
||||
|
||||
test("plain title", () => {
|
||||
const r = p("Buy milk");
|
||||
assert.equal(r.title, "Buy milk");
|
||||
assert.equal(r.attention, null);
|
||||
assert.equal(r.doDate, null);
|
||||
assert.equal(r.recurrence, null);
|
||||
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("relative date is extracted", () => {
|
||||
const r = p("Call dentist tomorrow");
|
||||
assert.equal(r.title, "Call dentist");
|
||||
assert.equal(r.doDate, ms(2026, 6, 4));
|
||||
});
|
||||
|
||||
test("single + multi-word projects resolve", () => {
|
||||
assert.equal(p("Standup #Work").projectId, "work");
|
||||
assert.equal(p("Standup #Work").title, "Standup");
|
||||
const r = p("Sweep deck #Camano Chores");
|
||||
assert.equal(r.title, "Sweep deck");
|
||||
assert.equal(r.projectId, "camano");
|
||||
});
|
||||
|
||||
test("unresolved tag stays in title", () => {
|
||||
const r = p("Buy #groceries milk");
|
||||
assert.equal(r.title, "Buy #groceries milk");
|
||||
assert.equal(r.projectId, null);
|
||||
});
|
||||
|
||||
test("recurrence phrase is extracted", () => {
|
||||
const r = p("Water plants every 3 days");
|
||||
assert.equal(r.title, "Water plants");
|
||||
assert.equal(r.recurrence, "FREQ=DAILY;INTERVAL=3");
|
||||
});
|
||||
|
||||
test("everything at once", () => {
|
||||
const r = p("Plan trip p2 friday #Work every week");
|
||||
assert.equal(r.title, "Plan trip");
|
||||
assert.equal(r.attention, "orange");
|
||||
assert.equal(r.doDate, ms(2026, 6, 5));
|
||||
assert.equal(r.projectId, "work");
|
||||
assert.equal(r.recurrence, "FREQ=WEEKLY");
|
||||
});
|
||||
|
||||
test("non-recurrence every stays in title", () => {
|
||||
const r = p("Review every report");
|
||||
assert.equal(r.title, "Review every report");
|
||||
assert.equal(r.recurrence, null);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue