From 7f19d3ef8e4ba59e657c7b4e885c60febbc7fcaa Mon Sep 17 00:00:00 2001 From: Eric Boehs Date: Mon, 31 Aug 2026 11:47:30 -0500 Subject: [PATCH] feat: new-since-last-visit filter with keyboard-reachable filters The listing now marks stories that were not present on the previous load and can be narrowed to just those. The filter runs in the browser on purpose: the pages are served public with one ETag shared by every visitor, so per-visitor state cannot live on the server without sessions or giving up caching. The baseline is the set of story ids from the last load rather than a posting timestamp -- a story is often hours old by the time its score lifts it into the listing, and filtering on when it was posted would hide exactly those arrivals. Refreshes within five minutes of each other keep the same marks instead of clearing them before they have been read, and the seen map expires after seven days so it cannot grow without bound. The control ships hidden and is revealed only once the script has confirmed it can write to storage, so a visitor without JS never sees a dead toggle. Date dividers are now rebuilt from the visible rows on every filter change. Inserting them once left them stranded above stories from another day as soon as rows were hidden, and the rebuild also retires the textContent dedupe hack. j/k reach the filters: past the first story they step up to the new-since toggle and then the score pills, landing on the score already in effect, where h/l walks the row and space applies it. h/l is clamped inside a filter row so it cannot drop focus into the heading behind it, keystrokes skip hidden rows, and ? opens a cheatsheet of the keys each page actually binds. UIkit strips the focus ring from subnav links, which left the whole walk invisible, so it is restored for keyboard focus only. --- README.md | 53 ++++++++- app.rb | 17 +++ tests/app_test.rb | 104 +++++++++++++++++ views/_shortcuts.erb | 39 +++++++ views/index.erb | 270 +++++++++++++++++++++++++++++++++++++------ views/layout.erb | 49 ++++++++ views/story.erb | 3 + 7 files changed, 499 insertions(+), 36 deletions(-) create mode 100644 views/_shortcuts.erb diff --git a/README.md b/README.md index a91d8ce..c1cb22b 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # HackiNews -A personal Hacker News reader. Stories are cached in Postgres and can be filtered by score or title. +A personal Hacker News reader. Stories are cached in Postgres and can be filtered by score, title, or by what has arrived since your last visit. ## Requirements @@ -41,6 +41,57 @@ bundle exec ruby worker.rb # loop bundle exec rake worker # one shot ``` +## New since last visit + +The listing marks stories that were not there on your previous load, and the +pill under the score filter narrows the page to just those. It runs in the +browser: the pages are served with public cache headers and a single ETag, so +per-visitor state cannot live on the server without sessions or giving up +caching. The baseline is the set of story ids from the last load (kept in +`localStorage` under `hn.visit.v1` for seven days), not a posting timestamp, +because stories are often hours old by the time their score lifts them into the +listing. Rapid refreshes within five minutes keep the same marks rather than +clearing them before they have been read. + +To exercise it without waiting for HN, run this in the console on the listing to +forget the top five stories and push the baseline past the grace window, then +reload -- they come back marked new: + +```js +;(() => { + const KEY = 'hn.visit.v1', N = 5 + const s = JSON.parse(localStorage.getItem(KEY) || '{}') + s.seen = s.seen || {} + s.lastVisit = Date.now() - 10 * 60 * 1000 + s.cutoff = s.lastVisit - 1000 + Object.keys(s.seen).forEach(id => { s.seen[id] = s.lastVisit - 60000 }) + Array.from(document.querySelectorAll('tr[date]')).slice(0, N).forEach(r => delete s.seen[r.id]) + localStorage.setItem(KEY, JSON.stringify(s)) + location.reload() +})() +``` + +`N = 0` gives the empty case, and `localStorage.removeItem('hn.visit.v1')` +resets to a first visit, where nothing is new. + +## Keyboard + +On the listing: + +| Key | Does | +| --- | --- | +| `j` / `k` | Down / up a row, holding the column (comments, points, title) | +| `h` / `l` | Previous / next link | +| `space` | Activate the focused filter | +| `?` | Show the cheatsheet | + +`k` past the first story steps up into the filters -- the new-since-last-visit +pill, then the score pills, landing on the score already in effect -- where +`h`/`l` moves along that row and `space` applies it. `j` walks back down into +the listing. + +Story pages take the same keys, minus the filters; `?` there lists what applies. + ## Tests ```bash diff --git a/app.rb b/app.rb index e77e043..e962c97 100644 --- a/app.rb +++ b/app.rb @@ -17,6 +17,21 @@ class App < Sinatra::Base MIN_SCORES = [0, 50, 100, 200, 300, 400, 500, 750, 1000].freeze + # Rendered into the ? cheatsheet. Kept beside the routes rather than in the + # templates so each page documents the keys its own script actually binds. + LISTING_SHORTCUTS = [ + [%w[j k], 'Down or up a row, keeping the column'], + [%w[h l], 'Previous or next link'], + [%w[space], 'Activate the focused filter'], + [%w[?], 'This help'] + ].freeze + + STORY_SHORTCUTS = [ + [%w[j k], 'Down or up a comment'], + [%w[h l], 'Previous or next link'], + [%w[?], 'This help'] + ].freeze + configure :development do set :host_authorization, permitted_hosts: [] end @@ -99,6 +114,7 @@ def cache_for(records) end get '/' do + @shortcuts = LISTING_SHORTCUTS @q = params['q'] @min_score = (params['min_score'] || 50).to_i @next_min_score = @@ -117,6 +133,7 @@ def cache_for(records) STORY_ID_RANGE = (1..2_147_483_647) get '/stories/:id' do + @shortcuts = STORY_SHORTCUTS id = Integer(params[:id], exception: false) halt 404 unless id && STORY_ID_RANGE.cover?(id) diff --git a/tests/app_test.rb b/tests/app_test.rb index 14d73e7..b685841 100644 --- a/tests/app_test.rb +++ b/tests/app_test.rb @@ -415,6 +415,110 @@ def test_membership_change_invalidates_the_homepage_cache assert_equal 200, last_response.status, 'membership changed, must not 304' end + # --- new since last visit ------------------------------------------------- + + # The filter runs in the browser because the listing is cached publicly under + # one ETag for everyone; the server only ships the control. It must arrive + # hidden so a visitor without JS -- or without writable storage, where the + # script bails -- never sees a toggle that does nothing. + def test_new_since_last_visit_control_is_hidden_until_scripted + create_item id: 1, title: 'A', score: 100 + + get '/' + body = last_response.body + + assert_includes body, 'New since last visit' + assert_match(/