Interests dashboard page, Articles interest filter, and interest links (steps 5-7)

Every interest name in the web UI links admins to the Articles page
filtered and sorted by that interest's stored matches; the Interests page
lists weights derived from current ratings and manages categories.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01K9PrjtUS16PAQve8D4bHgc
This commit is contained in:
2026-09-13 05:49:25 +00:00
co-authored by Claude Fable 5.1
parent 22cfb34788
commit 861ae6cb5e
18 changed files with 797 additions and 34 deletions
+40 -4
View File
@@ -232,7 +232,24 @@ fn facet_label(token: &str) -> Option<String> {
pub struct Understanding {
pub kicker: Option<String>,
pub topics: Option<String>,
pub interests: Option<String>,
pub interests: Vec<InterestRef>,
}
#[derive(Clone, Debug, Eq, PartialEq)]
pub struct InterestRef {
pub name: String,
pub href: String,
}
impl Understanding {
/// EPUBs keep interest names as plain text, without dashboard links.
pub fn interests_line(&self) -> String {
self.interests
.iter()
.map(|interest| interest.name.as_str())
.collect::<Vec<_>>()
.join(" \u{00b7} ")
}
}
/// Reader-facing assessment details, split so templates can give each part the
@@ -257,7 +274,14 @@ pub fn understanding(pick: &Pick) -> Understanding {
});
(kicker, topics)
});
let interests = (!pick.top_interests.is_empty()).then(|| pick.top_interests.join(" \u{00b7} "));
let interests = pick
.top_interests
.iter()
.map(|name| InterestRef {
name: name.clone(),
href: crate::interests::articles_href(name),
})
.collect();
Understanding {
kicker,
@@ -811,7 +835,16 @@ mod tests {
Understanding {
kicker: Some("Software engineering · Analysis".into()),
topics: Some("copy-on-write · ZFS".into()),
interests: Some("Filesystems · Rust".into()),
interests: vec![
InterestRef {
name: "Filesystems".into(),
href: "/dashboard/articles?interest=Filesystems".into(),
},
InterestRef {
name: "Rust".into(),
href: "/dashboard/articles?interest=Rust".into(),
},
],
}
);
}
@@ -833,7 +866,10 @@ mod tests {
assert_eq!(
understanding(&pick),
Understanding {
interests: Some("Rust".into()),
interests: vec![InterestRef {
name: "Rust".into(),
href: "/dashboard/articles?interest=Rust".into(),
}],
..Understanding::default()
}
);
+3 -3
View File
@@ -10,13 +10,13 @@
{% if understanding.kicker.is_some() || understanding.topics.is_some() %}
<p class="rubric">{% if let Some(kicker) = understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %} &#160; {{ topics }}{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
{% endif %}
{% if why.is_some() || understanding.interests.is_some() %}
{% if why.is_some() || !understanding.interests.is_empty() %}
<div class="why">
{% if let Some(text) = why %}
<p class="why-line"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %}
{% if let Some(interests) = understanding.interests %}
<p class="why-matches">Matches: {{ interests }}</p>
{% if !understanding.interests.is_empty() %}
<p class="why-matches">Matches: {{ understanding.interests_line() }}</p>
{% endif %}
</div>
{% endif %}
+3 -3
View File
@@ -13,13 +13,13 @@
{% if entry.understanding.kicker.is_some() || entry.understanding.topics.is_some() %}
<p class="rubric">{% if let Some(kicker) = entry.understanding.kicker %}<span class="kicker">{{ kicker }}</span>{% if let Some(topics) = entry.understanding.topics %} &#160; {{ topics }}{% endif %}{% else %}{% if let Some(topics) = entry.understanding.topics %}{{ topics }}{% endif %}{% endif %}</p>
{% endif %}
{% if entry.why.is_some() || entry.understanding.interests.is_some() %}
{% if entry.why.is_some() || !entry.understanding.interests.is_empty() %}
<div class="index-why">
{% if let Some(text) = entry.why %}
<p class="why-line"><em>Why it&#39;s here: {{ text }}</em></p>
{% endif %}
{% if let Some(interests) = entry.understanding.interests %}
<p class="why-matches">Matches: {{ interests }}</p>
{% if !entry.understanding.interests.is_empty() %}
<p class="why-matches">Matches: {{ entry.understanding.interests_line() }}</p>
{% endif %}
</div>
{% endif %}
+8
View File
@@ -64,6 +64,14 @@ pub struct Rates {
const INTEREST_COLUMNS: &str = "id, name, category, created_at, categorized_at";
/// Dashboard article search for one interest name.
pub fn articles_href(name: &str) -> String {
format!(
"/dashboard/articles?interest={}",
crate::web::encode_component(name)
)
}
/// Parse an OPML export for the one-time interests importer.
pub fn parse_opml(raw: &str) -> Vec<String> {
let mut seen = BTreeSet::new();
+179 -16
View File
@@ -53,6 +53,7 @@ pub fn routes() -> Router<AppState> {
#[derive(Debug, Default, Deserialize)]
pub struct ArticlesQuery {
pub q: Option<String>,
pub interest: Option<String>,
pub feed: Option<String>,
pub stage: Option<String>,
pub reason: Option<String>,
@@ -68,8 +69,9 @@ pub struct ArticlesQuery {
const RATED: [&str; 7] = ["any", "loved", "good", "down", "slop", "cleared", "none"];
const PUBLISHED: [&str; 2] = ["yes", "no"];
const ARTICLE_SORTS: [(&str, &str); 7] = [
const ARTICLE_SORTS: [(&str, &str); 8] = [
("first_seen", "x.first_seen DESC, x.id DESC"),
("match", "x.match_cos DESC, x.id DESC"),
("utility", "x.utility DESC, x.id DESC"),
("quality", "x.quality DESC, x.id DESC"),
("fit", "x.fit DESC, x.id DESC"),
@@ -82,6 +84,7 @@ const ARTICLE_SORTS: [(&str, &str); 7] = [
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct ArticleFilters {
pub q: Option<String>,
pub interest: Option<InterestFilter>,
pub feed: Option<i64>,
pub stage: Option<String>,
pub reason: Option<String>,
@@ -93,8 +96,14 @@ pub struct ArticleFilters {
pub sort: &'static str,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct InterestFilter {
pub id: Option<i64>,
pub name: String,
}
impl ArticleFilters {
pub fn from_query(query: &ArticlesQuery) -> Self {
pub fn from_query(query: &ArticlesQuery, interest: Option<InterestFilter>) -> Self {
let owned = |value: Option<&str>| value.map(str::to_string);
let date = |value: Option<&str>| {
non_empty(value)
@@ -103,8 +112,19 @@ impl ArticleFilters {
};
let mut kinds: Vec<&str> = TRIAGE_KINDS.to_vec();
kinds.push(PROVIDER_REJECTED);
let sort = ARTICLE_SORTS
.iter()
.find(|(name, _)| Some(*name) == query.sort.as_deref())
.map(|(name, _)| *name)
.filter(|sort| *sort != "match" || interest.is_some())
.unwrap_or(if interest.is_some() {
"match"
} else {
ARTICLE_SORTS[0].0
});
Self {
q: owned(non_empty(query.q.as_deref())),
interest,
feed: non_empty(query.feed.as_deref()).and_then(|feed| feed.parse::<i64>().ok()),
stage: owned(allow_listed(query.stage.as_deref(), &STAGES)),
reason: owned(allow_listed(query.reason.as_deref(), &REASONS)),
@@ -113,11 +133,7 @@ impl ArticleFilters {
from: date(query.from.as_deref()),
to: date(query.to.as_deref()),
kind: owned(allow_listed(query.kind.as_deref(), &kinds)),
sort: ARTICLE_SORTS
.iter()
.find(|(name, _)| Some(*name) == query.sort.as_deref())
.map(|(name, _)| *name)
.unwrap_or(ARTICLE_SORTS[0].0),
sort,
}
}
@@ -182,6 +198,10 @@ impl ArticleFilters {
fn params(&self) -> Vec<(&'static str, Option<String>)> {
vec![
("q", self.q.clone()),
(
"interest",
self.interest.as_ref().map(|interest| interest.name.clone()),
),
("feed", self.feed.map(|feed| feed.to_string())),
("stage", self.stage.clone()),
("reason", self.reason.clone()),
@@ -219,6 +239,13 @@ pub struct ArticleListRow {
pub rating: Option<String>,
pub rating_class: &'static str,
pub published: Option<String>,
pub interests: Vec<ArticleInterest>,
}
#[derive(Debug, Clone)]
pub struct ArticleInterest {
pub name: String,
pub href: String,
}
const ARTICLE_INNER: &str =
@@ -226,12 +253,14 @@ const ARTICLE_INNER: &str =
a.word_count, e.feed_id, COALESCE(e.feed_title, '') AS feed_title,
l.run_id, l.stage, l.excluded_reason, l.utility, r.date AS run_date,
t.score AS triage, t.kind AS triage_kind, d.score AS quality, d.fit AS fit,
{match_cos} AS match_cos,
(SELECT re.label FROM rating_events re
WHERE re.article_id = a.id AND re.kind = 'explicit'
ORDER BY re.event_at DESC, re.id DESC LIMIT 1) AS rating,
(SELECT ia.issue_date FROM issue_articles ia
WHERE ia.article_id = a.id ORDER BY ia.issue_date DESC LIMIT 1) AS published
FROM articles a
{interest_join}
LEFT JOIN entries e ON e.id = a.best_entry_id
LEFT JOIN candidate_runs l ON l.article_id = a.id
AND l.run_id = (SELECT MAX(c2.run_id) FROM candidate_runs c2
@@ -245,10 +274,16 @@ pub async fn list_articles(
config: &Config,
filters: &ArticleFilters,
page: u32,
) -> Result<(Vec<ArticleListRow>, Pagination), sqlx::Error> {
) -> anyhow::Result<(Vec<ArticleListRow>, Pagination)> {
let (clauses, binds) = filters.where_clauses();
let count_sql = format!("SELECT COUNT(*) FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses}");
let total: i64 = bind_all(dynamic_query(count_sql), &binds)
let (inner, interest_binds) = article_inner(filters);
let all_binds = interest_binds
.iter()
.cloned()
.chain(binds.iter().cloned())
.collect::<Vec<_>>();
let count_sql = format!("SELECT COUNT(*) FROM ({inner}) x WHERE 1 = 1{clauses}");
let total: i64 = bind_all(dynamic_query(count_sql), &all_binds)
.fetch_one(db.pool())
.await?
.get(0);
@@ -258,15 +293,15 @@ pub async fn list_articles(
total,
};
let select_sql = format!(
"SELECT * FROM ({ARTICLE_INNER}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?",
"SELECT * FROM ({inner}) x WHERE 1 = 1{clauses} ORDER BY {} LIMIT ? OFFSET ?",
filters.order_by()
);
let rows = bind_all(dynamic_query(select_sql), &binds)
let rows = bind_all(dynamic_query(select_sql), &all_binds)
.bind(i64::from(ARTICLES_PER_PAGE))
.bind(pagination.offset())
.fetch_all(db.pool())
.await?;
let rows = rows
let mut rows: Vec<ArticleListRow> = rows
.iter()
.map(|row| {
let id: ArticleId = row.get("id");
@@ -290,12 +325,45 @@ pub async fn list_articles(
rating_class: widget_label(rating.as_deref()),
rating,
published: row.get("published"),
interests: Vec::new(),
}
})
.collect();
let ids = rows.iter().map(|row| row.id).collect::<Vec<_>>();
let mut by_article: HashMap<ArticleId, Vec<ArticleInterest>> = HashMap::new();
for matched in crate::interests::matches_for_articles(db, &ids).await? {
by_article
.entry(matched.article_id)
.or_default()
.push(ArticleInterest {
href: crate::interests::articles_href(&matched.name),
name: matched.name,
});
}
for row in &mut rows {
row.interests = by_article.remove(&row.id).unwrap_or_default();
}
Ok((rows, pagination))
}
fn article_inner(filters: &ArticleFilters) -> (String, Vec<Bind>) {
match &filters.interest {
Some(interest) => (
ARTICLE_INNER.replace("{match_cos}", "ai.cos").replace(
"{interest_join}",
"JOIN article_interests ai ON ai.article_id = a.id AND ai.interest_id = ?",
),
vec![Bind::Int(interest.id.unwrap_or(-1))],
),
None => (
ARTICLE_INNER
.replace("{match_cos}", "NULL")
.replace("{interest_join}", ""),
Vec::new(),
),
}
}
#[derive(Template)]
#[template(path = "dashboard/articles.html")]
struct ArticlesTemplate {
@@ -308,6 +376,7 @@ struct ArticlesTemplate {
rated: Vec<&'static str>,
kinds: Vec<&'static str>,
sorts: Vec<&'static str>,
interest_options: Vec<crate::interests::Interest>,
pager: Pager,
}
@@ -319,11 +388,24 @@ async fn list(
) -> Result<Response, WebError> {
let viewer = auth.user().await.map(Viewer::from);
let config = state.config();
let filters = ArticleFilters::from_query(&query);
let interest_options = crate::interests::list(&state.db)
.await
.map_err(WebError::Internal)?;
let requested_interest = non_empty(query.interest.as_deref()).map(str::to_string);
let interest = requested_interest.map(|name| {
let found = interest_options
.iter()
.find(|interest| interest.name.eq_ignore_ascii_case(&name));
InterestFilter {
id: found.map(|interest| interest.id),
name: found.map(|interest| interest.name.clone()).unwrap_or(name),
}
});
let filters = ArticleFilters::from_query(&query, interest);
let page_no = page_number(query.page);
let (articles, pagination) = list_articles(&state.db, &config, &filters, page_no)
.await
.map_err(db_err)?;
.map_err(WebError::Internal)?;
let pager = Pager::new(pagination, "/dashboard/articles", &filters.params());
let mut kinds: Vec<&'static str> = TRIAGE_KINDS.to_vec();
kinds.push(PROVIDER_REJECTED);
@@ -342,6 +424,7 @@ async fn list(
rated: RATED.to_vec(),
kinds,
sorts: ARTICLE_SORTS.iter().map(|(name, _)| *name).collect(),
interest_options,
pager,
})
.into_response())
@@ -845,7 +928,7 @@ mod tests {
fn query(f: impl FnOnce(&mut ArticlesQuery)) -> ArticleFilters {
let mut query = ArticlesQuery::default();
f(&mut query);
ArticleFilters::from_query(&query)
ArticleFilters::from_query(&query, None)
}
#[tokio::test]
@@ -939,6 +1022,86 @@ mod tests {
assert_eq!(rows[0].id, 1);
}
#[test]
fn the_match_sort_needs_an_interest_and_round_trips_in_the_pager_links() {
let without = query(|q| q.sort = Some("match".into()));
assert_eq!(without.sort, "first_seen");
assert!(without.params().contains(&("sort", None)));
let with = ArticlesQuery {
interest: Some("Rust".into()),
..ArticlesQuery::default()
};
let with = ArticleFilters::from_query(
&with,
Some(InterestFilter {
id: Some(7),
name: "Rust".into(),
}),
);
assert_eq!(with.sort, "match", "the interest filter sorts by cosine");
let params = with.params();
assert!(params.contains(&("interest", Some("Rust".into()))));
assert!(params.contains(&("sort", Some("match".into()))));
}
#[tokio::test]
async fn interest_filter_finds_matches_unknown_is_empty_and_badges_link() {
let seed = seed().await;
let crate::interests::AddOutcome::Added(interest_id) = crate::interests::add(
&seed.db,
"Rust & Systems",
Some("Software"),
"2026-09-12T12:00:00Z".parse().unwrap(),
)
.await
.unwrap() else {
unreachable!();
};
sqlx::query(
"INSERT INTO article_interests (article_id, interest_id, cos, z, run_id)
VALUES (1, ?, 0.82, 2.1, ?)",
)
.bind(interest_id)
.bind(seed.run_id)
.execute(seed.db.pool())
.await
.unwrap();
let app = app_with_users(&seed.db).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let filtered = get(
&app,
"/dashboard/articles?interest=rust+%26+systems",
Some(&admin),
)
.await;
assert_eq!(filtered.status(), axum::http::StatusCode::OK);
let filtered = crate::web::dashboard::tests::response_text(filtered).await;
assert!(filtered.contains("Article 1 about prose"), "{filtered}");
assert!(!filtered.contains("Article 2 about graphs"), "{filtered}");
assert!(filtered.contains("value=\"match\" selected"), "{filtered}");
assert!(
filtered.contains(
"href=\"/dashboard/articles?interest=Rust+%26+Systems\">Rust &#38; Systems</a>"
),
"{filtered}"
);
let unknown = get(
&app,
"/dashboard/articles?interest=not-a-real-interest",
Some(&admin),
)
.await;
assert_eq!(unknown.status(), axum::http::StatusCode::OK);
let unknown = crate::web::dashboard::tests::response_text(unknown).await;
assert!(
unknown.contains("No articles match this filter."),
"{unknown}"
);
}
#[tokio::test]
async fn article_detail_shows_assessments_run_history_and_rating_events() {
let seed = seed().await;
+14 -2
View File
@@ -69,6 +69,12 @@ struct WhyArticle {
title: String,
}
#[derive(Debug)]
struct WhyInterest {
name: String,
href: String,
}
/// One table row.
#[derive(Debug)]
struct FeedRow {
@@ -79,7 +85,7 @@ struct FeedRow {
/// The candidate's title, or its feed URL when it has none.
label: String,
host: String,
interests: Vec<String>,
interests: Vec<WhyInterest>,
articles: Vec<WhyArticle>,
article_count: usize,
first_seen: String,
@@ -383,7 +389,13 @@ fn row(
) -> FeedRow {
let mut scored = evidence_of(candidate, evidence);
let score = discovery::score(&scored);
let interests = discovery::why(&scored);
let interests = discovery::why(&scored)
.into_iter()
.map(|name| WhyInterest {
href: crate::interests::articles_href(&name),
name,
})
.collect();
// Best evidence first; the articles nothing is known about come last, in
// title order.
+485
View File
@@ -0,0 +1,485 @@
//! Dashboard: standing interests and their rating-derived weights.
use std::cmp::Ordering;
use std::collections::BTreeSet;
use askama::Template;
use axum::Router;
use axum::extract::{Extension, Form, Path, Query, State};
use axum::response::{IntoResponse, Redirect, Response};
use axum::routing::{get, post};
use axum_login::tower_sessions::Session;
use jiff::Timestamp;
use serde::Deserialize;
use super::jobs::set_flash;
use crate::curate::signals;
use crate::interests as interest_store;
use crate::server::AppState;
use crate::types::ArticleId;
use crate::web::session::{AuthSession, Viewer};
use crate::web::{Html, Page, WebError, encode_component, take_flash};
const PATH: &str = "/dashboard/interests";
const SORTS: [&str; 4] = ["weight", "name", "matches", "added"];
/// Routes contributed by the interests page.
pub fn routes() -> Router<AppState> {
Router::new()
.route(PATH, get(index).post(add))
.route("/dashboard/interests/{id}/category", post(set_category))
.route("/dashboard/interests/{id}/delete", post(delete))
}
#[derive(Debug, Default, Deserialize)]
struct InterestsQuery {
category: Option<String>,
sort: Option<String>,
}
#[derive(Debug, Deserialize)]
struct AddForm {
name: String,
#[serde(default)]
category: String,
}
#[derive(Debug, Deserialize)]
struct CategoryForm {
#[serde(default)]
category: String,
}
#[derive(Debug)]
struct CategoryOption {
name: String,
}
#[derive(Debug)]
struct InterestRow {
id: i64,
name: String,
href: String,
category: Option<String>,
category_href: String,
weight: String,
weight_value: f64,
up: String,
down: String,
rated_matches: usize,
matched_articles: i64,
added: String,
created_at: String,
}
#[derive(Template)]
#[template(path = "dashboard/interests.html")]
struct InterestsTemplate {
page: Page,
rows: Vec<InterestRow>,
categories: Vec<CategoryOption>,
selected_category: String,
selected_sort: String,
sorts: Vec<&'static str>,
total: usize,
uncategorized: usize,
lookback_days: i64,
half_life_days: String,
affinity_gate: String,
attributable: usize,
affinity_full: usize,
jobs_enabled: bool,
}
async fn index(
State(state): State<AppState>,
auth: AuthSession,
Extension(session): Extension<Session>,
Query(query): Query<InterestsQuery>,
) -> Result<Response, WebError> {
let viewer = auth
.user()
.await
.map(Viewer::from)
.ok_or_else(|| WebError::Unauthenticated { next: PATH.into() })?;
let config = state.config();
let ranking = &config.curation.ranking;
let db = &state.db;
let now = Timestamp::now();
let interests = interest_store::list(db).await.map_err(WebError::Internal)?;
let total = interests.len();
let uncategorized = interests
.iter()
.filter(|interest| interest.category.is_none())
.count();
let mut category_names = interests
.iter()
.filter_map(|interest| interest.category.clone())
.collect::<BTreeSet<_>>()
.into_iter()
.collect::<Vec<_>>();
category_names.sort_by(|left, right| {
left.to_lowercase()
.cmp(&right.to_lowercase())
.then_with(|| left.cmp(right))
});
let categories = category_names
.iter()
.map(|name| CategoryOption { name: name.clone() })
.collect::<Vec<_>>();
let selected_category = query
.category
.as_deref()
.map(str::trim)
.filter(|category| !category.is_empty())
.map(str::to_string)
.unwrap_or_default();
let selected_sort = query
.sort
.as_deref()
.filter(|sort| SORTS.contains(sort))
.unwrap_or("weight")
.to_string();
let ratings = db.current_ratings(ranking.rating_lookback_days).await?;
let rated_ids = ratings
.iter()
.map(|rating| rating.article_id)
.collect::<Vec<_>>();
let matches = interest_store::matches_for_articles(db, &rated_ids)
.await
.map_err(WebError::Internal)?;
let rated = ratings
.iter()
.map(|rating| {
let age_days = (now.as_second() - rating.event_at.as_second()).max(0) as f64 / 86_400.0;
(
rating.article_id,
rating.value,
signals::decay(age_days, ranking.rating_half_life_days),
)
})
.collect::<Vec<(ArticleId, f64, f64)>>();
let matched = matches
.iter()
.map(|row| (row.article_id, row.interest_id, row.z))
.collect::<Vec<_>>();
let rates = interest_store::rates(&rated, &matched);
let counts = interest_store::match_counts(db)
.await
.map_err(WebError::Internal)?;
let mut rows = interests
.into_iter()
.filter(|interest| match selected_category.as_str() {
"" => true,
"uncategorized" => interest.category.is_none(),
category => interest
.category
.as_deref()
.is_some_and(|current| current.eq_ignore_ascii_case(category)),
})
.map(|interest| {
let rate = rates
.by_interest
.get(&interest.id)
.copied()
.unwrap_or_default();
let category_href = interest
.category
.as_deref()
.map(|category| format!("{PATH}?category={}", encode_component(category)))
.unwrap_or_else(|| format!("{PATH}?category=uncategorized"));
InterestRow {
id: interest.id,
href: interest_store::articles_href(&interest.name),
name: interest.name,
category: interest.category,
category_href,
weight: if rate.n > 0 {
format!("{:.2}", rate.weight())
} else {
"—".into()
},
weight_value: rate.weight(),
up: format!("{:.2}", rate.up),
down: format!("{:.2}", rate.down),
rated_matches: rate.n,
matched_articles: counts.get(&interest.id).copied().unwrap_or(0),
added: super::fmt_stored_time(Some(&interest.created_at), &config),
created_at: interest.created_at,
}
})
.collect::<Vec<_>>();
sort_rows(&mut rows, &selected_sort);
let mut page = Page::new("Interests", Some(viewer), "interests");
page.flash = take_flash(&session).await?;
Ok(Html(InterestsTemplate {
page,
rows,
categories,
selected_category,
selected_sort,
sorts: SORTS.to_vec(),
total,
uncategorized,
lookback_days: ranking.rating_lookback_days,
half_life_days: format!("{}", ranking.rating_half_life_days),
affinity_gate: format!(
"{:.2}",
signals::gate(
rates.attributable,
ranking.affinity_floor,
ranking.affinity_full
)
),
attributable: rates.attributable,
affinity_full: ranking.affinity_full,
jobs_enabled: config.server.jobs_enabled,
})
.into_response())
}
fn sort_rows(rows: &mut [InterestRow], sort: &str) {
rows.sort_by(|left, right| {
let selected = match sort {
"name" => Ordering::Equal,
"matches" => right.matched_articles.cmp(&left.matched_articles),
"added" => right.created_at.cmp(&left.created_at),
_ => right
.weight_value
.partial_cmp(&left.weight_value)
.unwrap_or(Ordering::Equal),
};
selected.then_with(|| {
left.name
.to_lowercase()
.cmp(&right.name.to_lowercase())
.then_with(|| left.name.cmp(&right.name))
})
});
}
fn form_category(value: &str) -> Option<&str> {
let value = value.trim();
(!value.is_empty()).then_some(value)
}
async fn add(
State(state): State<AppState>,
Extension(session): Extension<Session>,
Form(form): Form<AddForm>,
) -> Result<Response, WebError> {
match interest_store::add(
&state.db,
&form.name,
form_category(&form.category),
Timestamp::now(),
)
.await
{
Ok(interest_store::AddOutcome::Added(_)) => {
set_flash(&session, "success", format!("Added {}.", form.name.trim())).await?;
}
Ok(interest_store::AddOutcome::Duplicate) => {
set_flash(
&session,
"error",
format!("{} already exists.", form.name.trim()),
)
.await?;
}
Err(error) => set_flash(&session, "error", error.to_string()).await?,
}
Ok(Redirect::to(PATH).into_response())
}
async fn set_category(
State(state): State<AppState>,
Extension(session): Extension<Session>,
Path(id): Path<i64>,
Form(form): Form<CategoryForm>,
) -> Result<Response, WebError> {
interest_store::set_category(
&state.db,
id,
form_category(&form.category),
Timestamp::now(),
)
.await
.map_err(WebError::Internal)?;
set_flash(&session, "success", "Category saved.".into()).await?;
Ok(Redirect::to(PATH).into_response())
}
async fn delete(
State(state): State<AppState>,
Extension(session): Extension<Session>,
Path(id): Path<i64>,
) -> Result<Response, WebError> {
let name: Option<String> = sqlx::query_scalar("SELECT name FROM interests WHERE id = ?")
.bind(id)
.fetch_optional(state.db.pool())
.await
.map_err(super::db_err)?;
interest_store::delete(&state.db, id)
.await
.map_err(WebError::Internal)?;
set_flash(
&session,
"success",
name.map(|name| format!("Deleted {name}."))
.unwrap_or_else(|| "Interest already deleted.".into()),
)
.await?;
Ok(Redirect::to(PATH).into_response())
}
#[cfg(test)]
mod tests {
use axum::body::Body;
use axum::http::{Method, Request, StatusCode, header};
use tower::ServiceExt;
use super::*;
use crate::interests::AddOutcome;
use crate::web::dashboard::tests::{
app_with_users, assert_admin_only, login_cookie, response_text, seed,
};
async fn post_form(
app: &axum::Router,
uri: &str,
cookie: Option<&str>,
body: &str,
) -> Response {
let mut request = Request::builder()
.method(Method::POST)
.uri(uri)
.header(header::CONTENT_TYPE, "application/x-www-form-urlencoded")
.header("sec-fetch-site", "same-origin");
if let Some(cookie) = cookie {
request = request.header(header::COOKIE, cookie);
}
app.clone()
.oneshot(request.body(Body::from(body.to_string())).unwrap())
.await
.unwrap()
}
#[tokio::test]
async fn page_sorts_rating_weights_descending_and_is_admin_only() {
let seed = seed().await;
let now = Timestamp::now();
let AddOutcome::Added(rust) = interest_store::add(&seed.db, "Rust", Some("Software"), now)
.await
.unwrap()
else {
unreachable!();
};
interest_store::add(&seed.db, "Cooking", None, now)
.await
.unwrap();
sqlx::query(
"INSERT INTO article_interests (article_id, interest_id, cos, z, run_id)
VALUES (1, ?, 0.8, 3.0, ?)",
)
.bind(rust)
.bind(seed.run_id)
.execute(seed.db.pool())
.await
.unwrap();
let app = app_with_users(&seed.db).await;
let body = assert_admin_only(&app, PATH).await;
let table = body.split_once("<tbody>").unwrap().1;
assert!(table.find(">Rust</a>").unwrap() < table.find(">Cooking</a>").unwrap());
assert!(
table.contains("/dashboard/articles?interest=Rust"),
"{table}"
);
assert!(body.contains("affinity gate"), "{body}");
let reader = login_cookie(&app, "reader", "correct horse battery").await;
for (uri, body) in [
(PATH.to_string(), "name=New&category="),
(
format!("/dashboard/interests/{rust}/category"),
"category=Other",
),
(format!("/dashboard/interests/{rust}/delete"), ""),
] {
let forbidden = post_form(&app, &uri, Some(&reader), body).await;
assert_eq!(forbidden.status(), StatusCode::FORBIDDEN, "{uri}");
}
}
#[tokio::test]
async fn add_reports_a_case_insensitive_duplicate() {
let seed = seed().await;
interest_store::add(&seed.db, "Rust", None, Timestamp::now())
.await
.unwrap();
let app = app_with_users(&seed.db).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let response = post_form(&app, PATH, Some(&admin), "name=rust&category=").await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let body =
response_text(crate::web::dashboard::tests::get(&app, PATH, Some(&admin)).await).await;
assert!(body.contains("rust already exists."), "{body}");
assert_eq!(interest_store::list(&seed.db).await.unwrap().len(), 1);
}
#[tokio::test]
async fn delete_action_removes_matches_and_the_embedding() {
let seed = seed().await;
let AddOutcome::Added(id) = interest_store::add(&seed.db, "Rust", None, Timestamp::now())
.await
.unwrap()
else {
unreachable!();
};
sqlx::query(
"INSERT INTO article_interests (article_id, interest_id, cos, z)
VALUES (1, ?, 0.8, 2.0)",
)
.bind(id)
.execute(seed.db.pool())
.await
.unwrap();
sqlx::query(
"INSERT INTO interest_embeddings
(interest, model, dimension, embedding, created_at)
VALUES ('Rust', 'test', 1, X'00000000', '2026-09-12T00:00:00Z')",
)
.execute(seed.db.pool())
.await
.unwrap();
let app = app_with_users(&seed.db).await;
let admin = login_cookie(&app, "admin", "correct horse battery").await;
let response = post_form(
&app,
&format!("/dashboard/interests/{id}/delete"),
Some(&admin),
"",
)
.await;
assert_eq!(response.status(), StatusCode::SEE_OTHER);
let interests: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM interests")
.fetch_one(seed.db.pool())
.await
.unwrap();
let matches: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM article_interests")
.fetch_one(seed.db.pool())
.await
.unwrap();
let embeddings: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM interest_embeddings")
.fetch_one(seed.db.pool())
.await
.unwrap();
assert_eq!((interests, matches, embeddings), (0, 0, 0));
}
}
+18
View File
@@ -11,6 +11,7 @@
pub mod articles;
pub mod feeds;
pub mod interests;
pub mod jobs;
pub mod profile;
pub mod ratings;
@@ -45,6 +46,7 @@ pub fn router() -> Router<AppState> {
.merge(runs::routes())
.merge(articles::routes())
.merge(ratings::routes())
.merge(interests::routes())
.merge(feeds::routes())
.merge(profile::routes())
.merge(settings::routes())
@@ -277,6 +279,7 @@ pub struct SignalLine {
#[derive(Debug, Clone)]
pub struct InterestLine {
pub name: String,
pub href: String,
pub z: String,
pub cos: String,
}
@@ -344,6 +347,7 @@ impl SignalsView {
.iter()
.map(|interest| InterestLine {
name: interest.name.clone(),
href: crate::interests::articles_href(&interest.name),
z: format!("{:.2}", interest.z),
cos: format!("{:.3}", interest.cos),
})
@@ -429,6 +433,8 @@ struct OverviewTemplate {
ratings_total: i64,
access_requests: i64,
feed_candidates: i64,
interests_total: usize,
uncategorized_interests: usize,
unrated: Vec<UnratedPick>,
active_jobs: Vec<JobLine>,
finished_jobs: Vec<JobLine>,
@@ -456,6 +462,14 @@ async fn overview(
.await
.map_err(db_err)?;
let feed_candidates = crate::discovery::count(db, "candidate").await?;
let interests = crate::interests::list(db)
.await
.map_err(WebError::Internal)?;
let interests_total = interests.len();
let uncategorized_interests = interests
.iter()
.filter(|interest| interest.category.is_none())
.count();
let unrated = unrated_picks(db).await?;
let (active_jobs, finished_jobs) = jobs_summary(db, &config).await?;
let sparklines = overview_sparklines(db).await?;
@@ -475,6 +489,8 @@ async fn overview(
ratings_total,
access_requests,
feed_candidates,
interests_total,
uncategorized_interests,
unrated,
active_jobs,
finished_jobs,
@@ -1226,6 +1242,8 @@ pub(crate) mod tests {
"/dashboard/articles/1".to_string(),
"/dashboard/ratings".to_string(),
"/dashboard/ratings?tab=events".to_string(),
"/dashboard/interests".to_string(),
"/dashboard/interests?category=uncategorized&sort=name".to_string(),
"/dashboard/profile".to_string(),
"/dashboard/stats?days=14".to_string(),
"/dashboard/settings".to_string(),
+3
View File
@@ -2012,6 +2012,8 @@ mod tests {
let article_id = source.lineup.picks[0].article.id;
let dashboard_href = format!("/dashboard/articles/{article_id}");
let interest_href = crate::interests::articles_href("Filesystems");
assert!(!issue.contains(&interest_href));
assert!(!issue.contains(&dashboard_href));
let article = app
.clone()
@@ -2741,6 +2743,7 @@ mod tests {
assert!(admin_issue.contains("Was this a good pick?"));
assert!(admin_issue.contains("value=\"loved\" data-label=\"loved\" class=\"active\""));
assert!(admin_issue.contains(&dashboard_href));
assert!(admin_issue.contains(&crate::interests::articles_href("Filesystems")));
let admin_article = app
.clone()
+1
View File
@@ -322,6 +322,7 @@ impl Page {
| "runs"
| "articles"
| "ratings"
| "interests"
| "profile"
| "stats"
| "jobs"
+1 -1
View File
@@ -2,7 +2,7 @@
<div class="scroll-x"><table class="signals"><thead><tr><th>signal</th><th class="num">raw</th><th class="num">norm</th><th class="num">weight</th><th>present</th></tr></thead>
<tbody>{% for line in signals.lines %}<tr{% if !line.present %} class="muted"{% endif %}><td>{{ line.name }}</td><td class="num">{{ line.raw }}</td><td class="num">{{ line.norm }}</td><td class="num">{{ line.weight }}</td><td>{% if line.present %}yes{% else %}<span class="muted">absent</span>{% endif %}</td></tr>{% endfor %}</tbody></table></div>
<p class="muted">Preliminary blend <span class="tabular-nums text-ink">{{ signals.blend }}</span>{% if let Some(cos) = signals.top1_cos %} · interest top-1 cosine <span class="tabular-nums">{{ cos }}</span>{% endif %}{% if signals.exploration %} · <span class="badge">exploration</span>{% endif %}{% if signals.auto_include %} · <span class="badge">auto-include</span>{% endif %}{% if signals.slop_author %} · <span class="badge down">slop author</span>{% endif %}</p>
{% if !signals.top_interests.is_empty() %}<p class="page-eyebrow">Top interests</p><ul>{% for interest in signals.top_interests %}<li>{{ interest.name }} <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.top_interests.is_empty() %}<p class="page-eyebrow">Top interests</p><ul>{% for interest in signals.top_interests %}<li><a href="{{ interest.href }}">{{ interest.name }}</a> <span class="muted">· z {{ interest.z }} · cos {{ interest.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.neighbours.is_empty() %}<p class="page-eyebrow">Nearest rated neighbours</p><ul>{% for neighbour in signals.neighbours %}<li><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span> <a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a> <span class="muted">· cos {{ neighbour.cos }}</span></li>{% endfor %}</ul>{% endif %}
{% if !signals.notes.is_empty() %}<ul class="muted">{% for note in signals.notes %}<li>{{ note }}</li>{% endfor %}</ul>{% endif %}
</div>{% endif %}
+1 -1
View File
@@ -1 +1 @@
{% if understanding.kicker.is_some() || understanding.topics.is_some() %}<p class="mt-3 font-sans text-sm text-muted">{% if let Some(kicker) = understanding.kicker %}<span class="text-[0.72rem] uppercase tracking-[0.12em]">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %}<span class="ml-3">{{ topics }}</span>{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}<span>{{ topics }}</span>{% endif %}{% endif %}</p>{% endif %}{% if why.is_some() || understanding.interests.is_some() %}<div class="mt-4 border-l-2 border-accent pl-3">{% if let Some(why) = why %}<p class="italic text-ink-2">Why it's here: {{ why }}</p>{% endif %}{% if let Some(interests) = understanding.interests %}<p class="mt-1 font-sans text-sm text-muted">Matches: {{ interests }}</p>{% endif %}</div>{% endif %}
{% if understanding.kicker.is_some() || understanding.topics.is_some() %}<p class="mt-3 font-sans text-sm text-muted">{% if let Some(kicker) = understanding.kicker %}<span class="text-[0.72rem] uppercase tracking-[0.12em]">{{ kicker }}</span>{% if let Some(topics) = understanding.topics %}<span class="ml-3">{{ topics }}</span>{% endif %}{% else %}{% if let Some(topics) = understanding.topics %}<span>{{ topics }}</span>{% endif %}{% endif %}</p>{% endif %}{% if why.is_some() || !understanding.interests.is_empty() %}<div class="mt-4 border-l-2 border-accent pl-3">{% if let Some(why) = why %}<p class="italic text-ink-2">Why it's here: {{ why }}</p>{% endif %}{% if !understanding.interests.is_empty() %}<p class="mt-1 font-sans text-sm text-muted">Matches: {% for interest in understanding.interests %}{% if page.is_admin() %}<a href="{{ interest.href }}">{{ interest.name }}</a>{% else %}{{ interest.name }}{% endif %}{% if !loop.last %} · {% endif %}{% endfor %}</p>{% endif %}</div>{% endif %}
+1 -1
View File
@@ -58,7 +58,7 @@
<h2>Neighbours and interests</h2>
{% if let Some(signals) = latest_signals %}{% if signals.top_interests.is_empty() && signals.neighbours.is_empty() %}<p class="muted text-sm">The latest run recorded no interests or neighbours for this article.</p>{% else %}<div class="cards">
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td>{{ interest.name }}</td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
<section class="card"><h3>Top interests</h3>{% if signals.top_interests.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>interest</th><th class="num">z</th><th class="num">cos</th></tr></thead><tbody>{% for interest in signals.top_interests %}<tr><td><a href="{{ interest.href }}">{{ interest.name }}</a></td><td class="num">{{ interest.z }}</td><td class="num">{{ interest.cos }}</td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
<section class="card"><h3>Nearest rated neighbours</h3>{% if signals.neighbours.is_empty() %}<p class="muted">None.</p>{% else %}<div class="scroll-x"><table><thead><tr><th>label</th><th class="num">cos</th><th>article</th></tr></thead><tbody>{% for neighbour in signals.neighbours %}<tr><td><span class="badge {{ neighbour.label }}">{{ neighbour.label }}</span></td><td class="num">{{ neighbour.cos }}</td><td class="cell-wrap"><a href="/dashboard/articles/{{ neighbour.article_id }}">{{ neighbour.title }}</a></td></tr>{% endfor %}</tbody></table></div>{% endif %}</section>
</div>{% endif %}{% else %}<p class="muted text-sm">No signals recorded.</p>{% endif %}
+4 -2
View File
@@ -6,6 +6,7 @@
<form class="filters" method="get" action="/dashboard/articles">
<label>Title or URL <input type="search" name="q" value="{% if let Some(q) = filters.q %}{{ q }}{% endif %}" placeholder="contains…"></label>
<label>Feed id <input type="number" name="feed" value="{{ feed_value }}" min="1"></label>
<label>Interest <select name="interest"><option value="">any</option>{% for interest in interest_options %}<option value="{{ interest.name }}"{% if let Some(current) = filters.interest %}{% if current.name.as_str() == interest.name.as_str() %} selected{% endif %}{% endif %}>{{ interest.name }}</option>{% endfor %}</select></label>
<label>Stage <select name="stage"><option value="">any</option>{% for name in stages %}<option value="{{ name }}"{% if let Some(current) = filters.stage %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<label>Reason <select name="reason"><option value="">any</option>{% for name in reasons %}<option value="{{ name }}"{% if let Some(current) = filters.reason %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
<label>Rated <select name="rated"><option value="">all</option>{% for name in rated %}<option value="{{ name }}"{% if let Some(current) = filters.rated %}{% if current.as_str() == *name %} selected{% endif %}{% endif %}>{{ name }}</option>{% endfor %}</select></label>
@@ -18,11 +19,12 @@
</form>
{% include "dashboard/_pager.html" %}
{% if articles.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter rows on this page" aria-label="Filter rows on this page" data-table-filter>{% endif %}<div class="scroll-x tall"><table class="articles" data-filter>
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
<thead><tr><th>first seen</th><th>title</th><th>feed</th><th>interests</th><th class="num">words</th><th>last stage</th><th>reason</th><th class="num">utility</th><th class="num">triage</th><th class="num">quality</th><th class="num">fit</th><th>rating</th><th>published</th></tr></thead>
<tbody>{% for article in articles %}<tr>
<td class="cell-tight text-muted">{{ article.first_seen }}</td>
<td class="cell-wrap"><a href="{{ article.href }}">{{ article.title }}</a></td>
<td class="cell-tight">{% if let Some(feed_id) = article.feed_id %}<a href="/dashboard/articles?feed={{ feed_id }}">{{ article.feed }}</a>{% else %}{{ article.feed }}{% endif %}</td>
<td class="cell-wrap">{% for interest in article.interests %}<a class="badge" href="{{ interest.href }}">{{ interest.name }}</a>{% if !loop.last %} {% endif %}{% endfor %}</td>
<td class="num">{{ article.words }}</td>
<td class="cell-tight">{% if let Some(stage) = article.stage %}<span class="badge {{ stage }}">{{ stage }}</span>{% if let Some(run_id) = article.run_id %} <a class="muted text-xs" href="/dashboard/runs/{{ run_id }}">{% if let Some(date) = article.run_date %}{{ date }}{% else %}run {{ run_id }}{% endif %}</a>{% endif %}{% else %}<span class="muted text-xs">never considered</span>{% endif %}</td>
<td class="cell-tight text-muted">{% if let Some(reason) = article.reason %}{{ reason }}{% endif %}</td>
@@ -32,6 +34,6 @@
<td class="num">{{ article.fit }}</td>
<td class="cell-tight">{% if let Some(rating) = article.rating %}<span class="badge {{ article.rating_class }}">{{ rating }}</span>{% endif %}</td>
<td class="cell-tight">{% if let Some(date) = article.published %}<a href="/issues/{{ date }}">{{ date }}</a>{% endif %}</td>
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="12" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
</tr>{% endfor %}{% if articles.is_empty() %}<tr><td colspan="13" class="text-muted">No articles match this filter.</td></tr>{% endif %}</tbody></table></div>
{% include "dashboard/_pager.html" %}
</section>{% endblock %}
+1 -1
View File
@@ -11,7 +11,7 @@
<tbody>{% for row in rows %}<tr>
<td class="num tabular-nums">{{ row.score }}</td>
<td class="cell-wrap"><a class="line-clamp-2" href="{{ row.feed_url }}" rel="noopener" target="_blank" title="{{ row.feed_url }}">{{ row.label }}</a><span class="muted block truncate text-xs" title="{{ row.host }}">{{ row.host }}</span></td>
<td class="cell-wrap">{% for interest in row.interests %}<span class="badge">{{ interest }}</span> {% endfor %}{% for article in row.articles %}<div class="line-clamp-1 text-xs"><a href="/dashboard/articles/{{ article.id }}" title="{{ article.title }}">{{ article.title }}</a></div>{% endfor %}</td>
<td class="cell-wrap">{% for interest in row.interests %}<a class="badge" href="{{ interest.href }}">{{ interest.name }}</a> {% endfor %}{% for article in row.articles %}<div class="line-clamp-1 text-xs"><a href="/dashboard/articles/{{ article.id }}" title="{{ article.title }}">{{ article.title }}</a></div>{% endfor %}</td>
<td class="num">{{ row.article_count }}</td>
<td class="cell-tight text-xs text-muted"><span title="First seen {{ row.first_seen }} · last seen {{ row.last_seen }}">{{ row.seen }}</span></td>
<td>{% if status == "candidate" %}<div class="flex flex-wrap items-start gap-2"><form class="form-inline flex-nowrap" method="post" action="/dashboard/feeds/{{ row.id }}/add"><select class="min-w-0 max-w-36 truncate" name="category_id" aria-label="Category for {{ row.label }}"{% if categories.is_empty() %} disabled{% endif %}>{% for category in categories %}<option value="{{ category.id }}"{% if category.id == selected_category %} selected{% endif %}>{{ category.title }}</option>{% endfor %}</select><button class="btn-primary" type="submit"{% if categories.is_empty() %} disabled title="Miniflux categories are unavailable"{% endif %}>Add</button></form><form method="post" action="/dashboard/feeds/{{ row.id }}/dismiss"><button class="btn" type="submit">Dismiss</button></form></div>{% else %}<span class="muted text-xs">{{ row.decided_at }}{% if let Some(href) = row.miniflux_href %} · <a href="{{ href }}" rel="noopener" target="_blank">in Miniflux</a>{% endif %}</span>{% endif %}</td>
@@ -0,0 +1,33 @@
{% extends "layout.html" %}{% block content %}<section class="dashboard interests">
<header class="page-head"><div>
<h1>Interests</h1>
<p class="page-desc">{{ total }} standing interests. Weights use the last {{ lookback_days }} days of ratings with a {{ half_life_days }}-day half-life · affinity gate {{ affinity_gate }} · {{ attributable }} of {{ affinity_full }} attributable ratings.</p>
</div><div class="page-actions"><span class="badge">{{ uncategorized }} uncategorized</span><form method="post" action="/dashboard/jobs/interests-categorize" class="form-inline"><button class="btn" type="submit"{% if !jobs_enabled %} disabled{% endif %}>Categorize now</button>{% if !jobs_enabled %} <span class="meta text-xs">Jobs are disabled on this server (<code>server.jobs_enabled = false</code>); run <code>daily-epub job run interests-categorize</code> instead.</span>{% endif %}</form></div></header>
<section class="card"><h2>Add an interest</h2><form class="filters" method="post" action="/dashboard/interests">
<label>Interest <input name="name" type="text" minlength="1" maxlength="80" required placeholder="e.g. Rust macros"></label>
<label>Category <select name="category"><option value="">let the categorizer decide</option>{% for category in categories %}<option value="{{ category.name }}">{{ category.name }}</option>{% endfor %}</select></label>
<div class="filter-actions"><button class="btn btn-primary" type="submit">Add</button></div>
</form></section>
<form class="filters" method="get" action="/dashboard/interests">
<label>Category <select name="category"><option value="">all</option><option value="uncategorized"{% if selected_category == "uncategorized" %} selected{% endif %}>uncategorized</option>{% for category in categories %}<option value="{{ category.name }}"{% if selected_category == category.name.as_str() %} selected{% endif %}>{{ category.name }}</option>{% endfor %}</select></label>
<label>Sort <select name="sort">{% for sort in sorts %}<option value="{{ sort }}"{% if selected_sort == *sort %} selected{% endif %}>{{ sort }}</option>{% endfor %}</select></label>
<div class="filter-actions"><button class="btn" type="submit">Apply</button> <a class="btn-quiet" href="/dashboard/interests">Reset</a></div>
</form>
{% if rows.len() > 1 %}<input type="search" class="table-filter" placeholder="Filter interests" aria-label="Filter interests" data-table-filter>{% endif %}
<div class="scroll-x tall"><table data-filter>
<thead><tr><th>interest</th><th>category</th><th class="num">weight</th><th class="num">up</th><th class="num">down</th><th class="num">rated matches</th><th class="num">matched articles</th><th>added</th><th>actions</th></tr></thead>
<tbody>{% for row in rows %}<tr>
<td class="cell-wrap"><a href="{{ row.href }}">{{ row.name }}</a></td>
<td class="cell-tight">{% if let Some(category) = row.category %}<a href="{{ row.category_href }}">{{ category }}</a>{% else %}<a class="muted" href="{{ row.category_href }}">uncategorized</a>{% endif %}</td>
<td class="num tabular-nums">{{ row.weight }}</td>
<td class="num tabular-nums">{{ row.up }}</td>
<td class="num tabular-nums">{{ row.down }}</td>
<td class="num tabular-nums">{{ row.rated_matches }}</td>
<td class="num tabular-nums">{{ row.matched_articles }}</td>
<td class="cell-tight text-muted">{{ row.added }}</td>
<td><div class="flex flex-wrap items-start gap-2"><form class="form-inline flex-nowrap" method="post" action="/dashboard/interests/{{ row.id }}/category"><select class="min-w-0 max-w-44 truncate" name="category" aria-label="Category for {{ row.name }}"><option value="">uncategorized</option>{% for category in categories %}<option value="{{ category.name }}"{% if let Some(current) = row.category %}{% if current.as_str() == category.name.as_str() %} selected{% endif %}{% endif %}>{{ category.name }}</option>{% endfor %}</select><button class="btn" type="submit">Save</button></form><form method="post" action="/dashboard/interests/{{ row.id }}/delete" onsubmit="return confirm('Delete this interest? Its article matches and cached embedding will also be removed.')"><button class="btn" type="submit">Delete</button></form></div></td>
</tr>{% endfor %}{% if rows.is_empty() %}<tr><td colspan="9" class="text-muted">No interests match this filter.</td></tr>{% endif %}</tbody>
</table></div>
</section>{% endblock %}
@@ -11,6 +11,7 @@
<div class="tile"><span class="tile-label">Unrated picks</span><span class="tile-num">{{ unrated.len() }}</span><span class="tile-delta">from the last three issues</span></div>
<div class="tile"><span class="tile-label">Active jobs</span><span class="tile-num">{{ active_jobs.len() }}</span><span class="tile-delta"><a href="/dashboard/jobs">all jobs</a></span></div>
<div class="tile"><span class="tile-label">Feed candidates</span><span class="tile-num">{{ feed_candidates }}</span><span class="tile-delta"><a href="/dashboard/feeds">review feeds</a></span></div>
<div class="tile"><span class="tile-label">Interests</span><span class="tile-num">{{ interests_total }}</span><span class="tile-delta"><a href="/dashboard/interests?category=uncategorized">{{ uncategorized_interests }} uncategorized</a></span></div>
<div class="tile"><span class="tile-label">Access requests</span><span class="tile-num">{{ access_requests }}</span><span class="tile-delta"><a href="/dashboard/users">review requests</a></span></div>
</div>
+1
View File
@@ -47,6 +47,7 @@
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "runs" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/runs"{% if page.active_nav == "runs" %} aria-current="page"{% endif %}>Runs</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "articles" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/articles"{% if page.active_nav == "articles" %} aria-current="page"{% endif %}>Articles</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "ratings" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/ratings"{% if page.active_nav == "ratings" %} aria-current="page"{% endif %}>Ratings</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "interests" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/interests"{% if page.active_nav == "interests" %} aria-current="page"{% endif %}>Interests</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "feeds" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/feeds"{% if page.active_nav == "feeds" %} aria-current="page"{% endif %}>Feeds</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "profile" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/profile"{% if page.active_nav == "profile" %} aria-current="page"{% endif %}>Profile</a>
<a class="flex min-h-10 min-w-10 items-center justify-center border-b-2 no-underline hover:text-ink {% if page.active_nav == "stats" %}border-accent text-ink{% else %}border-transparent text-muted{% endif %}" href="/dashboard/stats"{% if page.active_nav == "stats" %} aria-current="page"{% endif %}>Stats</a>