Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 52 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -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
Expand Down
17 changes: 17 additions & 0 deletions app.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 =
Expand All @@ -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)

Expand Down
104 changes: 104 additions & 0 deletions tests/app_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -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(/<ul id="new-filter"[^>]*\shidden/, body)
assert_match(/<p id="no-new-stories"[^>]*\shidden/, body)
end

# The baseline is a set of story ids, not a timestamp: a story is often hours
# old by the time its score lifts it into the listing. That only works while
# every row carries its id.
def test_listing_rows_carry_story_ids_for_the_new_filter
create_item id: 42, title: 'A', score: 100

get '/'

assert_match(/<tr id="42"/, last_response.body)
end

# Hiding rows can strand a date divider above a different day, so the
# dividers are rebuilt from the visible rows on every filter change rather
# than inserted once at load.
def test_date_dividers_are_rebuilt_rather_than_inserted_once
create_item id: 1, title: 'A', score: 100

get '/'

assert_includes last_response.body, 'function renderDateRows'
assert_match(/renderDateRows\(\)/, last_response.body)
end

# --- keyboard navigation --------------------------------------------------

# j/k step up out of the listing into the filters, which the script finds by
# id. Renaming or dropping either container silently strands the walk at the
# first story, so the hooks are pinned here.
def test_filter_rows_expose_the_ids_keyboard_nav_walks
create_item id: 1, title: 'A', score: 100

get '/'
body = last_response.body

assert_includes body, '<ul id="min-score"'
assert_includes body, '<ul id="new-filter"'
assert_match(/visibleLinks\('#min-score a'\)/, body)
assert_match(/visibleLinks\('#new-filter a'\)/, body)
end

# The score pills are anchors, which answer to Enter but not Space, and UIkit
# strips their focus ring; both are patched over so the filters are usable
# from the keyboard alone.
def test_filters_are_operable_and_visible_from_the_keyboard
create_item id: 1, title: 'A', score: 100

get '/'
body = last_response.body

assert_match(/e\.key === ' '/, body)
assert_includes body, 'a:focus-visible'
end

# --- keyboard shortcuts overlay -------------------------------------------

# Each page documents the keys its own script binds, so the listing's filter
# shortcut must not leak onto a story page that has no filters.
def test_listing_cheatsheet_lists_the_filter_shortcut
create_item id: 1, title: 'A', score: 100

get '/'
body = last_response.body

assert_includes body, 'Keyboard shortcuts'
assert_includes body, 'Activate the focused filter'
assert_includes body, 'Down or up a row, keeping the column'
end

def test_story_cheatsheet_documents_comment_navigation_only
create_item id: 1, title: 'A', score: 100

get '/stories/1'
body = last_response.body

assert_includes body, 'Down or up a comment'
refute_includes body, 'Activate the focused filter'
end

# A 404 renders through the same layout; without a shortcut list there is
# nothing to show, and the partial must not be rendered at all.
def test_pages_without_shortcuts_omit_the_cheatsheet
get '/nope'

assert_equal 404, last_response.status
refute_includes last_response.body, 'Keyboard shortcuts'
end

# --- listing scope --------------------------------------------------------

# The listing used to rely on comments never carrying a score key.
Expand Down
39 changes: 39 additions & 0 deletions views/_shortcuts.erb
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
<!-- Keyboard cheatsheet, opened with ?.

The close button carries a literal x rather than UIkit's uk-close: that
glyph is an SVG injected by uikit-icons.js, which the layout does not load,
so the button would render empty. -->
<div id="shortcuts" uk-modal>
<div class="uk-modal-dialog uk-modal-body uk-margin-auto-vertical">
<button class="uk-close uk-modal-close-default" type="button" aria-label="Close">&times;</button>
<h3 class="uk-modal-title">Keyboard shortcuts</h3>
<table class="uk-table uk-table-small hn-keys">
<tbody>
<% shortcuts.each do |keys, description| %>
<tr>
<td class="uk-table-shrink uk-text-nowrap">
<% keys.each_with_index do |key, i| %><%= ' / ' if i.positive? %><code><%= h key %></code><% end %>
</td>
<td><%= h description %></td>
</tr>
<% end %>
</tbody>
</table>
</div>
</div>

<script>
(function () {
// The dialog is UIkit's, so without its JS there is nothing to open; the
// page is unstyled at that point anyway.
if (!window.UIkit) return

var dialog = document.querySelector('#shortcuts')

document.addEventListener('keydown', function (e) {
if (e.key !== '?') return
e.preventDefault()
UIkit.modal(dialog)[dialog.classList.contains('uk-open') ? 'hide' : 'show']()
})
})()
</script>
Loading
Loading