Jev picks a Signal's icon, on the server's key, and each Signal counts its items (#263)

Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each
word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40.
POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one
of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an
icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just
ahead of robot 0.40.

Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its
words; /api/signals gives `items` beside `unread`, by the same rules as its list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-16 19:09:26 +00:00
parent d0ed953e84
commit 2ed8781604
9 changed files with 231 additions and 17 deletions

View File

@@ -2552,6 +2552,7 @@ pub struct SignalRow {
pub keywords: Vec<String>,
pub description: Option<String>,
pub icon: Option<String>,
pub items: i64,
pub unread: i64,
}
@@ -2604,15 +2605,22 @@ impl Db {
let mut a = Args::default();
let user = a.p(user_id);
let one = id.map(|i| format!("AND g.id = {}", a.p(i))).unwrap_or_default();
// The items it shows, and of those the unread: what its list counts, by the same rules.
let hits = |unread: &str| {
format!(
"(SELECT count(*) FROM signal_verdicts v
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
WHERE v.signal_id = g.id AND v.hit AND {unread} AND {})",
not_hidden(&user)
)
};
let sql = format!(
"SELECT g.id, g.name, g.keywords, g.description, g.icon,
(SELECT count(*) FROM signal_verdicts v
JOIN entries e ON e.feed_id = v.feed_id AND e.guid = v.guid
JOIN subscriptions s ON s.user_id = g.user_id AND s.feed_id = v.feed_id
LEFT JOIN entry_state st ON st.user_id = g.user_id AND st.feed_id = v.feed_id AND st.guid = v.guid
WHERE v.signal_id = g.id AND v.hit AND NOT coalesce(st.read, false) AND {}) AS unread
"SELECT g.id, g.name, g.keywords, g.description, g.icon, {} AS items, {} AS unread
FROM signals g WHERE g.user_id = {user} {one} ORDER BY lower(g.name), g.id",
not_hidden(&user)
hits("true"),
hits("NOT coalesce(st.read, false)")
);
self.rows(&sql, a.0)
.await?
@@ -2624,6 +2632,7 @@ impl Db {
keywords: keywords(r.try_get("", "keywords")?).unwrap_or_default(),
description: r.try_get("", "description")?,
icon: r.try_get("", "icon")?,
items: r.try_get("", "items")?,
unread: r.try_get("", "unread")?,
})
})
@@ -3403,11 +3412,12 @@ mod tests {
let page = db.signal_entries(1, f1, Filter::All, None, 0, 50, &order).await.unwrap();
assert_eq!(page.iter().map(|e| e.guid.as_str()).collect::<Vec<_>>(), ["x"]);
assert_eq!(db.signal_count(1, food, Filter::All, None).await.unwrap(), 1);
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| s.unread).collect::<Vec<_>>(), [1, 1]);
assert_eq!(db.signals(1).await.unwrap().iter().map(|s| (s.items, s.unread)).collect::<Vec<_>>(), [(1, 1), (1, 1)]);
db.exec_for_test("INSERT INTO entry_state (user_id, feed_id, guid, read, flagged, position) VALUES (1,'f','x',true,false,0)")
.await
.unwrap();
assert_eq!(db.signal(1, f1).await.unwrap().unwrap().unread, 0, "read in its feed is read in the Signal");
let f = db.signal(1, f1).await.unwrap().unwrap();
assert_eq!((f.items, f.unread), (1, 0), "read in its feed is read in the Signal, and still one of its items");
assert!(db.signal(2, f1).await.unwrap().is_none(), "someone else's Signal is not found");
assert!(!db.set_signal(2, f1, SignalChange { name: Some("mine"), ..Default::default() }).await.unwrap());