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(/