"Since you last visited" cue + PWA install (add to home screen)

Two calm returning-reader features.

Since-last-visit (Highlights companion, not a nav lane — per Codex):
* queries.feed gains a `since` filter; GET /api/since?ts= returns the count +
  a few accepted/non-dup/visible articles discovered since the reader's last
  visit (boundary-respecting; invalid/future ts → 0, no error).
* Home stores last_seen in localStorage (reads prev, then stamps now); on
  Highlights, a gentle "Since you were last here, N new calm reads came in"
  note with a "See what's new" reveal of a compact inline section. Dismissible.
  No badges, no unread counts, no "missed" language.

PWA:
* Real PNG icons (192/512 + full-bleed maskable) rasterized from favicon.svg;
  manifest fixed (azure theme to match the brand, PNG icons); apple-touch-icon.
* Minimal service worker: precache the app shell, always-fresh API + /a/ pages.
* Gentle, dismissible install banner (beforeinstallprompt → Install; iOS → the
  Share → Add to Home Screen hint). Never nags.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
jay
2026-06-09 20:38:12 -04:00
parent 008364e922
commit d0fb153e46
11 changed files with 217 additions and 3 deletions
+21
View File
@@ -1422,6 +1422,27 @@ def create_app() -> FastAPI:
items=[Article.from_row(r) for r in rows],
)
@app.get("/api/since", response_model=FeedResponse)
def feed_since(ts: str = Query(...), prefs: str | None = Query(None)) -> FeedResponse:
# A calm welcome-back cue: accepted/non-dup/visible articles discovered
# since the reader's last visit (boundary-respecting). count = how many;
# items = a few to show inline. No nagging, no unread state stored.
try:
norm = ts.replace("Z", "+00:00")
dt = datetime.fromisoformat(norm)
since = (dt.astimezone(timezone.utc) if dt.tzinfo else dt).strftime("%Y-%m-%d %H:%M:%S")
except (ValueError, TypeError):
return FeedResponse(topic=None, flavor=None, count=0, items=[])
fp = prefs_from_json(prefs)
now = datetime.now(timezone.utc)
kw = _prefs_sql_kw(fp, now)
with get_conn() as conn:
rows = queries.feed(conn, sort="latest", since=since, limit=60, **kw)
if fp.avoid_terms:
rows = filter_articles(rows, fp, now)
rows = sorted(rows, key=lambda r: is_paywalled(r["canonical_url"]))
return FeedResponse(topic=None, flavor=None, count=len(rows), items=[Article.from_row(r) for r in rows[:8]])
@app.get("/api/brief", response_model=BriefResponse)
def brief(
date: str | None = Query(None),
+6
View File
@@ -64,6 +64,7 @@ def feed(
sort: str = "ranked",
follow_sources: list[int] | None = None,
follow_tags: list[str] | None = None,
since: str | None = None,
) -> list[dict]:
"""Return articles with categorical filters applied in SQL.
@@ -116,6 +117,11 @@ def feed(
clauses.append("a.source_id = ?")
params.append(source_id)
if since:
# "New since last visit": articles discovered after the reader's last visit.
clauses.append("a.discovered_at > ?")
params.append(since)
# "Following" feed: articles from a followed source OR carrying a followed tag.
# Passing either list (even empty) switches to following mode; no follows → none.
if follow_sources is not None or follow_tags is not None: