@@ -1008,11 +1040,14 @@
.linkish { background: none; border: none; color: var(--accent-deep); font: inherit; font-size: 0.86rem;
cursor: pointer; text-decoration: underline; padding: 0; }
.homebar { font-size: 0.86rem; color: var(--muted); margin: 0 0 16px; }
- .briefscope { display: flex; gap: 8px; align-items: center; margin: 0 0 16px; }
- .bs-btn { font: inherit; font-size: 0.88rem; font-weight: 600; padding: 7px 16px; border: 1px solid var(--line);
- border-radius: 999px; background: var(--bg); color: var(--ink); cursor: pointer; }
- .bs-btn.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-deep); }
- .bs-change { margin-left: auto; }
+ .scopedial { display: flex; gap: 10px; align-items: center; flex-wrap: wrap; margin: 0 0 16px; }
+ .sd-label { font-size: 0.82rem; color: var(--muted); }
+ .sd-stops { display: inline-flex; border: 1px solid var(--line); border-radius: 999px; overflow: hidden; }
+ .sd-btn { font: inherit; font-size: 0.85rem; font-weight: 600; padding: 6px 14px; border: none;
+ background: var(--bg); color: var(--ink); cursor: pointer; border-right: 1px solid var(--line); }
+ .sd-stops .sd-btn:last-child { border-right: none; }
+ .sd-btn.on { background: var(--accent-soft); color: var(--accent-deep); }
+ .sd-change { margin-left: auto; }
.feed-section { grid-column: 1 / -1; margin: 8px 0 2px; font-family: var(--label); font-size: 0.78rem;
text-transform: uppercase; letter-spacing: 0.06em; color: var(--muted); }
.grid > .feed-section:first-child { margin-top: 0; }
diff --git a/goodnews/api.py b/goodnews/api.py
index 7740827..2a52673 100644
--- a/goodnews/api.py
+++ b/goodnews/api.py
@@ -2187,6 +2187,7 @@ def create_app() -> FastAPI:
prefs: str | None = Query(None),
exclude: str = Query("", description="comma-separated article ids the reader has dismissed"),
home: str | None = Query(None, max_length=8, description="local-first highlights: 'US' or 'US-NY'"),
+ scope: str = Query("nearby", pattern="^(nearby|region|country|world)$", description="radius dial"),
) -> BriefResponse:
# The default highlights are global (date-keyed, no session) → edge-cacheable
# so a new visitor's "Gathering the good news…" resolves from their POP, not
@@ -2203,13 +2204,13 @@ def create_app() -> FastAPI:
now = datetime.now(timezone.utc)
excl = {int(x) for x in exclude.split(",") if x.strip().lstrip("-").isdigit()}
with get_conn() as conn:
- if home_country:
- # The reader's home leads the landing: local good news first, blended out
- # to country/world so it's always a full, sexy set. Over-fetch to survive
- # dismissal/boundary filtering, then cap to limit.
+ if home_country and scope != "world":
+ # The reader's home + scope dial lead the landing: closest-first good news,
+ # blended outward so it's always a full set. Over-fetch to survive dismissal/
+ # boundary filtering, then cap to limit. scope='world' = the global brief.
meta = queries.brief(conn, brief_date=date, limit=1)
data = {"brief_date": meta["brief_date"], "title": "Close to home", "created_at": meta.get("created_at")}
- pool = queries.home_brief(conn, home_country, home_state, limit=limit + 12)
+ pool = queries.home_brief(conn, home_country, home_state, scope=scope, limit=limit + 12)
else:
data = queries.brief(conn, brief_date=date, limit=limit)
pool = data["items"]
diff --git a/goodnews/geo.py b/goodnews/geo.py
index 0cbbfc2..a7145be 100644
--- a/goodnews/geo.py
+++ b/goodnews/geo.py
@@ -38,6 +38,34 @@ US_STATES = {
"district of columbia": "DC", "washington dc": "DC", "washington d c": "DC",
}
+# US Census Bureau regions — the "Regional" grain for the scope dial. Standard,
+# explainable, not arbitrary. DC sits in the South (South Atlantic) per Census.
+US_REGIONS = {
+ "Northeast": {"CT", "ME", "MA", "NH", "RI", "VT", "NJ", "NY", "PA"},
+ "Midwest": {"IL", "IN", "MI", "OH", "WI", "IA", "KS", "MN", "MO", "NE", "ND", "SD"},
+ "South": {"DE", "FL", "GA", "MD", "NC", "SC", "VA", "DC", "WV", "AL", "KY", "MS",
+ "TN", "AR", "LA", "OK", "TX"},
+ "West": {"AZ", "CO", "ID", "MT", "NV", "NM", "UT", "WY", "AK", "CA", "HI", "OR", "WA"},
+}
+
+
+def region_of(state_code: str | None) -> str | None:
+ """The Census region name containing a US state code, or None."""
+ if not state_code:
+ return None
+ sc = state_code.upper()
+ for name, states in US_REGIONS.items():
+ if sc in states:
+ return name
+ return None
+
+
+def region_states(state_code: str | None) -> list[str]:
+ """Sorted state codes in the same Census region as a state (incl. it)."""
+ name = region_of(state_code)
+ return sorted(US_REGIONS[name]) if name else []
+
+
# Common countries + aliases (extensible). Anything not here returns None -> we drop
# the country rather than store garbage. breadth still captures national/global, etc.
COUNTRY_TO_ISO = {
diff --git a/goodnews/queries.py b/goodnews/queries.py
index 630b460..07650ba 100644
--- a/goodnews/queries.py
+++ b/goodnews/queries.py
@@ -259,24 +259,58 @@ def reindex_search(conn: sqlite3.Connection) -> int:
return conn.execute("SELECT COUNT(*) FROM article_search").fetchone()[0]
-def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | None = None,
- limit: int = 7, window_days: int = 3) -> list[dict]:
- """Local-first landing highlights. Leads with high/medium-confidence local good news,
- then blends out to your country and the world so the set is always full (never the
- sad thin-local look), and prefers already-summarized stories so the calm read stays
- rich. Brief-shaped rows (incl. summary) tagged with a section, best-first within tier.
+# Scope dial: the reader's "emotional radius". Each tier is a closest->widest lead
+# preference, not a hard filter; 'world' is the implicit final tier. State + region
+# are confidence-gated (high/medium) so a shaky location is never promoted as local.
+_STATE_SQL = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
+ "WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?))")
+_COUNTRY_SQL = "(EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?))"
+SCOPES = ("nearby", "region", "country", "world")
+
+
+def _region_sql(n: int) -> str:
+ placeholders = ",".join("?" * n)
+ return ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
+ f"WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code IN ({placeholders})))")
+
+
+def home_tiers(home_country: str, home_state: str | None, scope: str) -> list[tuple]:
+ """Ordered [(section_key, predicate_sql, params)] closest->widest for a home + scope.
+ Evaluated first-match (CASE WHEN / composed in order), so tiers needn't be SQL-exclusive.
+ 'world' is implicit (everything not matched). 'region'/'nearby' need a US state; otherwise
+ they gracefully fall back to country (country-only / non-US homes collapse to Country/World).
"""
- if home_state:
- near = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
- "WHERE p.article_id = a.id AND p.country_code = ? AND p.state_code = ?))")
- country = "EXISTS (SELECT 1 FROM article_places p WHERE p.article_id = a.id AND p.country_code = ?)"
- section_case = f"CASE WHEN {near} THEN 0 WHEN {country} THEN 1 ELSE 2 END"
- section_params = [home_country, home_state, home_country]
- else:
- near = ("(g.confidence IN ('high','medium') AND EXISTS (SELECT 1 FROM article_places p "
- "WHERE p.article_id = a.id AND p.country_code = ?))")
- section_case = f"CASE WHEN {near} THEN 0 ELSE 2 END" # no "country" tier without a state
- section_params = [home_country]
+ from .geo import region_states
+ rs = region_states(home_state) if home_state else []
+ tiers: list[tuple] = []
+ if scope == "nearby" and home_state:
+ tiers.append(("state", _STATE_SQL, [home_country, home_state]))
+ if rs:
+ tiers.append(("region", _region_sql(len(rs)), [home_country, *rs]))
+ tiers.append(("country", _COUNTRY_SQL, [home_country]))
+ elif scope == "region" and home_state and rs:
+ tiers.append(("region", _region_sql(len(rs)), [home_country, *rs])) # includes the state
+ tiers.append(("country", _COUNTRY_SQL, [home_country]))
+ else: # country scope, country-only / non-US home, or any fallback
+ tiers.append(("country", _COUNTRY_SQL, [home_country]))
+ return tiers
+
+
+def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | None = None,
+ scope: str = "nearby", limit: int = 7, window_days: int = 3) -> list[dict]:
+ """Scope-aware local-first landing highlights. Leads with the reader's chosen radius
+ (state / region / country) then blends outward so the set is always full — "closest
+ first", never three stale local stories. Prefers already-summarized stories so the
+ calm read stays rich. Brief-shaped rows tagged with a concrete section key.
+ """
+ tiers = home_tiers(home_country, home_state, scope)
+ whens, params = [], []
+ for i, (_key, pred, ps) in enumerate(tiers):
+ whens.append(f"WHEN {pred} THEN {i}")
+ params += ps
+ world_rank = len(tiers)
+ section_case = ("CASE " + " ".join(whens) + f" ELSE {world_rank} END") if whens else "0"
+ section_keys = [k for k, _, _ in tiers] + ["world"]
rows = conn.execute(
f"""
SELECT {_ARTICLE_COLUMNS},
@@ -294,12 +328,13 @@ def home_brief(conn: sqlite3.Connection, home_country: str, home_state: str | No
COALESCE(a.published_at, a.discovered_at) DESC
LIMIT ?
""",
- section_params + [f"-{window_days} days", limit],
+ params + [f"-{window_days} days", limit],
).fetchall()
out = []
for r in rows:
d = dict(r)
- d["__section"] = {0: "near", 1: "country", 2: "world"}.get(d.pop("section_rank", 2), "world")
+ rank = d.pop("section_rank", world_rank)
+ d["__section"] = section_keys[rank] if 0 <= rank < len(section_keys) else "world"
out.append(d)
return out
diff --git a/tests/test_geo_feed.py b/tests/test_geo_feed.py
index dc6eb3e..42fa0e2 100644
--- a/tests/test_geo_feed.py
+++ b/tests/test_geo_feed.py
@@ -82,20 +82,28 @@ def test_country_only_home_gates_near_on_confidence(app_db):
def test_home_brief_leads_with_local(app_db):
- # The landing's /api/brief?home=US-NY leads with high-confidence NY good news,
- # tags items by section, and titles it "Close to home". The low-conf NY story (#5)
- # is not elevated as local.
+ # /api/brief?home=US-NY defaults to the 'nearby' scope: leads with the 'state' tier
+ # (high-confidence NY), titles it "Close to home", and never elevates the low-conf
+ # NY story (#5). CA stories (a different census region) fall to 'country'.
r = TestClient(app_db).get("/api/brief?home=US-NY&limit=10").json()
assert r["title"] == "Close to home"
- near_ids = {it["id"] for it in r["items"] if it["section"] == "near"}
- assert near_ids == {1, 2, 3, 4} # the high-conf NY stories
- # near items all appear before any world item (local leads)
+ state_ids = {it["id"] for it in r["items"] if it["section"] == "state"}
+ assert state_ids == {1, 2, 3, 4} # high-conf NY only
+ a5 = next(it for it in r["items"] if it["id"] == 5)
+ assert a5["section"] == "country" # low-conf NY -> not 'state'
secs = [it["section"] for it in r["items"]]
- if "near" in secs and "world" in secs:
- assert max(i for i, s in enumerate(secs) if s == "near") < \
+ if "state" in secs and "world" in secs: # local leads, world trails
+ assert max(i for i, s in enumerate(secs) if s == "state") < \
min(i for i, s in enumerate(secs) if s == "world")
+def test_home_brief_scope_country_has_no_state_tier(app_db):
+ # The 'country' scope drops the local lead: all US stories sit in 'country', none 'state'.
+ r = TestClient(app_db).get("/api/brief?home=US-NY&scope=country&limit=10").json()
+ secs = {it["section"] for it in r["items"]}
+ assert "state" not in secs and "country" in secs
+
+
def test_no_home_is_unchanged_and_unsectioned(app_db):
r = TestClient(app_db).get("/api/feed?limit=50").json()
assert all(it["section"] is None for it in r["items"])