list: stop splitting sections by assignee
Assisted-by: Claude Opus 5.5 via Claude Code
diff --git a/docs/DESIGN.md b/docs/DESIGN.md
index d5fd54a..11ed185 100644
--- a/docs/DESIGN.md
+++ b/docs/DESIGN.md
@@ -99,8 +99,8 @@ There is no toolbar and no thread-level action. Each message's state, folder, an
- **Triage**: from the list or the thread view. Click the value to change it. No modals; an undo toast after each change.
- **Thread view**: received messages, sent messages, and comments in time order, as ruled rows of equal weight. Each row has the sender (and cc/bcc when present) and body on the left; a right gutter holds the date and, for received messages, state, folder, and assignees in fixed positions so they line up down the thread. Sent messages show the recipient after the sender and no values. Comments sit on a filled row. Events ("Sam moved to Do", "filed via another client"). Older messages collapse; their values stay clickable.
- **Lane views**: one list component showing individual messages, grouped by thread (only messages in that lane):
- - Inbox: assigned to me / to them / no one.
- - Do: Mine / Unassigned / Theirs, oldest first.
+ - Inbox: newest first.
+ - Do: oldest first.
- Wait: oldest first.
- Watch: compact, by latest update.
- Done: no list — search only.
diff --git a/src/lists.rs b/src/lists.rs
index cca6976..9501ec5 100644
--- a/src/lists.rs
+++ b/src/lists.rs
@@ -55,66 +55,26 @@ impl Group<'_> {
pub fn sections<'a>(store: &'a Store, me: &User, view: &View) -> Vec<Section<'a>> {
let received = || store.messages.iter().filter(|m| m.values().is_some());
let in_state = move |state| received().filter(move |m| m.state() == Some(state));
- let to_me = format!("→ {}", me.name.to_uppercase());
- let them: Vec<_> = store
- .users
- .iter()
- .filter(|u| u.slug != me.slug)
- .map(|u| u.name.to_uppercase())
- .collect();
- let to_them = format!("→ {}", them.join(", "));
let mut out = Vec::new();
match view {
View::ForMe => {
for state in State::LANES {
- let mine = in_state(state).filter(|m| m.is_assigned_to(&me.slug));
+ let msgs = in_state(state)
+ .filter(|m| {
+ m.is_assigned_to(&me.slug) || (state == State::Inbox && m.is_unassigned())
+ })
+ .collect();
out.push(section(
store,
- (state.name().to_uppercase(), to_me.clone()),
- mine.collect(),
+ (state.name().to_uppercase(), String::new()),
+ msgs,
Order::NewestFirst,
));
- if state == State::Inbox {
- out.push(section(
- store,
- (state.name().to_uppercase(), "NO ONE’S".to_owned()),
- in_state(state).filter(|m| m.is_unassigned()).collect(),
- Order::NewestFirst,
- ));
- }
- }
- }
- View::Lane(state @ (State::Inbox | State::Do)) => {
- let order = if *state == State::Do {
- Order::OldestFirst
- } else {
- Order::NewestFirst
- };
- let mine_msgs = in_state(*state)
- .filter(|m| m.is_assigned_to(&me.slug))
- .collect();
- let nobody_msgs = in_state(*state).filter(|m| m.is_unassigned()).collect();
- let theirs_msgs = in_state(*state)
- .filter(|m| !m.is_unassigned() && !m.is_assigned_to(&me.slug))
- .collect();
- let mine = section(store, (to_me, String::new()), mine_msgs, order);
- let nobody = section(
- store,
- ("NO ONE’S".to_owned(), String::new()),
- nobody_msgs,
- order,
- );
- let theirs = section(store, (to_them, String::new()), theirs_msgs, order);
- // Inbox reads me / them / no one; Do reads mine / unassigned / theirs.
- if *state == State::Do {
- out.extend([mine, nobody, theirs]);
- } else {
- out.extend([mine, theirs, nobody]);
}
}
View::Lane(state) => {
- let order = if *state == State::Watch {
+ let order = if matches!(state, State::Inbox | State::Watch) {
Order::NewestFirst
} else {
Order::OldestFirst
@@ -237,16 +197,19 @@ mod tests {
let sam = summary(&store, "sam", &View::ForMe);
assert_eq!(
heads(&store, "sam", &View::ForMe),
- ["INBOX|NO ONE’S", "DO|→ SAM", "WATCH|→ SAM"]
+ ["INBOX|", "DO|", "WATCH|"]
);
let (_, _, groups) = &sam[0];
// Newest thread first; the roofing thread shows only its two Inbox emails.
assert_eq!(groups[0], vec![fixtures::WATER]);
assert!(groups.contains(&vec![3, 4]));
+ // Alex's own Inbox messages share the section with unassigned ones.
+ let alex = summary(&store, "alex", &View::ForMe);
+ assert_eq!(heads(&store, "alex", &View::ForMe), ["INBOX|", "WAIT|"]);
assert_eq!(
- heads(&store, "alex", &View::ForMe),
- ["INBOX|→ ALEX", "INBOX|NO ONE’S", "WAIT|→ ALEX"]
+ alex[0].2,
+ [vec![7], vec![6], vec![10], vec![8], vec![9], vec![3, 4]]
);
}
@@ -269,22 +232,14 @@ mod tests {
#[test]
fn lanes() {
let store = fixtures::store();
- assert_eq!(
- heads(&store, "sam", &View::Lane(State::Inbox)),
- ["→ ALEX|", "NO ONE’S|"]
- );
- assert_eq!(
- heads(&store, "alex", &View::Lane(State::Inbox)),
- ["→ ALEX|", "NO ONE’S|"]
- );
- assert_eq!(
- heads(&store, "alex", &View::Lane(State::Do)),
- ["NO ONE’S|", "→ SAM|"]
- );
- assert_eq!(
- heads(&store, "sam", &View::Lane(State::Do)),
- ["→ SAM|", "NO ONE’S|"]
- );
+ // Every assignee shares one unheaded section: Inbox newest first, Do
+ // oldest first.
+ let inbox = summary(&store, "sam", &View::Lane(State::Inbox));
+ assert_eq!(heads(&store, "sam", &View::Lane(State::Inbox)), ["|"]);
+ assert_eq!(inbox[0].2[0], vec![fixtures::WATER]);
+ let todo = summary(&store, "alex", &View::Lane(State::Do));
+ assert_eq!(heads(&store, "alex", &View::Lane(State::Do)), ["|"]);
+ assert_eq!(todo[0].2, [vec![13], vec![5]]);
let wait = summary(&store, "sam", &View::Lane(State::Wait));
assert_eq!(wait[0].2, vec![vec![11]]);
@@ -295,37 +250,6 @@ mod tests {
assert!(sections(&store, me, &View::Lane(State::Done)).len() == 1);
}
- #[test]
- fn do_lane_orders_mine_unassigned_theirs() {
- let mut store = fixtures::store();
- store
- .edit("alex", 3, crate::store::Change::State(State::Do))
- .unwrap();
- store
- .edit(
- "alex",
- 3,
- crate::store::Change::ToggleAssignee("alex".into()),
- )
- .unwrap();
- assert_eq!(
- heads(&store, "alex", &View::Lane(State::Do)),
- ["→ ALEX|", "NO ONE’S|", "→ SAM|"]
- );
- // Inbox reads me / them / no one.
- store
- .edit(
- "alex",
- 4,
- crate::store::Change::ToggleAssignee("sam".into()),
- )
- .unwrap();
- assert_eq!(
- heads(&store, "alex", &View::Lane(State::Inbox)),
- ["→ ALEX|", "→ SAM|", "NO ONE’S|"]
- );
- }
-
#[test]
fn search_covers_every_message() {
let store = fixtures::store();
diff --git a/tests/api.rs b/tests/api.rs
index e2ddd5d..ea45555 100644
--- a/tests/api.rs
+++ b/tests/api.rs
@@ -80,7 +80,6 @@ async fn for_me_lists_messages_grouped_by_state_and_thread() {
let (status, body) = get(addr, SAM, "/").await;
assert_eq!(status, StatusCode::OK);
assert!(body.contains("<title>For me · Docket</title>"));
- assert!(body.contains("NO ONE’S"));
assert!(body.contains("Gutter repair estimate"));
assert!(body.contains("2 OF 4"));
assert!(body.contains("No thread open."));
@@ -93,7 +92,7 @@ async fn for_me_lists_messages_grouped_by_state_and_thread() {
async fn lanes_and_search() {
let addr = spawn().await;
for (path, needle) in [
- ("/inbox", "→ ALEX"),
+ ("/inbox", "Service interruption Oct 4"),
("/do", "Exemption renewal"),
("/wait", "Claim 4471"),
("/watch", "Shipped: furnace filters"),