Commit Graph

69 Commits

Author SHA1 Message Date
1b0b155226 One Check every feed button: the top bar's (#416)
All Subscriptions' header had its own, beside Mark everything read, doing
what the top bar's Refresh does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 17:26:53 +00:00
2c4d03668f Episodes can be kept on the device to listen offline (#412)
Offline mode (#389) kept lists, text and pictures, but the worker let
every /media/ request pass, so no episode played without a connection.

A downloaded episode's files pane has a keep button beside Play. It
fetches the copy that person plays, the file or, when DeadAir cuts
something for them, the version without its dead air (/media/{id}/deadair,
the save button's), whole into a cache of its own, ipx-episodes, and
asks once for persistent storage. A kept episode plays as
/media/...?offline, online or not, and the worker answers that from the
cache with the byte ranges an audio element asks for (206, 416, 200);
not kept there, it goes to the server as before. Pressing it again takes
it off; signing out clears it with everything else. Only someone's
choice is kept, so there is no budget: a full device is the quota's
refusal, said in words. The button is data-a="offline", not "keep",
which the Directory's show page already uses for subscribing.

Places saved from DeadAir's version were read in its clock only if ipx
had handed out that file's playlist since it started (state.playing). A
kept copy plays without one, so the player now names the file in the
position it saves (deadair: <enclosure>) whenever it plays DeadAir's
clock, and ipx loads that file's plan when it has none, for that item's
file only. Checked the beacon is queued by the worker offline: it is.

Tests: playing a kept episode offline, its place queued and sent once
back, and taking it off (the offline show plays quiet.mp3, the only real
MP3 among the fixtures); the worker's ranges; which copy is kept and
played, in page-unit; the server reading a flagged place in DeadAir's
clock without a playlist, and ignoring another item's file. The files
pane's one-row test counts five buttons and checks a phone's width too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 15:31:04 +00:00
8acf57961e Ten wrong passwords from one address hold it off sign-in for a quarter hour (#409)
POST /api/login answered every wrong password 401 for as long as someone
kept trying. Now ipx counts them by the client's address: the tenth in a
window holds that address off until the window, which starts at its first
wrong password, ends, answered 429 with how long is left. Counted by
address and not name, since by name anyone could lock anyone out; a right
password does not reset the count, or a guesser with an account of their
own could clear it between guesses.

Behind Caddy every peer is Caddy, so the address is the last one in
X-Forwarded-For, taken only from trusted_proxies. Caddy now sets that to
{client_ip}: Cf-Connecting-IP for the tunnel's requests, believed only
from TUNNEL_PEER (servers trusted_proxies), and the peer for everyone
else, replacing whatever the client sent. Checked with Caddy against an
echo upstream: a forged XFF and Cf-Connecting-IP from elsewhere reach ipx
as the peer, the tunnel's Cf-Connecting-IP as itself.

The sign-in load test sends each attempt from its own address, so it
still measures a flood from many, whose passwords are all checked.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RqkrVDqdiQEMWAkNGQ2tYA
2026-11-07 13:18:38 +00:00
af69b699a8 iPX opens and reads without a connection (#389)
With no network the home-screen app showed nothing: no service worker,
and the lists the page had fetched, full text included, lived only in
its memory.

A service worker (web/sw/sw.ts, TypeScript checked against a worker's
globals by tsconfig.sw.json, built by swc to /sw.js) goes to the network
first for the page and every API read and keeps the answers, so online
nothing is an old copy and #388's version check works as before;
offline the kept copy answers. Scripts named by hash come from what is
kept. Reads, flags and places in an episode that cannot reach the
server wait in an outbox, sent in order when the connection is back
(and new ones queue behind them meanwhile, so none lands out of turn);
a kept list served offline shows the reads waiting there.

Pictures: a worker cannot keep another site's pictures sensibly, since
browsers count each opaque answer as megabytes. ipx now remembers the
picture addresses in item text it sends (bounded, in memory) and
/api/art fetches those as it does artwork, through the guarded client;
the worker keeps them, for unread items, under a 200 MB budget, and
serves them offline in place of the publisher's. Online, pictures still
come from their publisher.

Sign-out, or another account signing in on the device, clears what was
kept. The browser suite blocks the worker except in the offline tests,
since it answers before page.route can.

The fixture JPEGs are placeholders a browser cannot draw; the offline
test uses a real PNG.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:57:31 +00:00
742f76969e An open page takes up a new version by itself after a deploy (#388)
The live stream already came back after a deploy and the lists caught
up, but the page kept running the script it had loaded, in a tab or the
home-screen app, until reloaded by hand. Back after a drop, the page now
asks for / again without the cache; another app.js named there is a new
version. With nothing to lose it reloads, and the item that was open,
carried across in sessionStorage whole since a read one is off the
Unread list, is opened again. While an episode plays, a dialog is open,
a search is set or a field is typed in, it offers a reload in a toast.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-07 00:04:55 +00:00
d3b8c4cd01 A short Unread list read through no longer says more to load (#385)
hasMore() compared nextOffset(), which on Unread counts only the loaded
items still unread, with S.total from the answer: once they were read it
was 0 against that total, so the page asked for a page that came back
empty and took its total, 0, with the items still on screen. Whether
more is left now comes from the last answer itself, its offset and items
against its total; reading takes an item off the server's list and off
nextOffset() alike, so what that answer said is left stays true.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-06 23:44:51 +00:00
5c1970e8ee Swipes and a reader that will not scroll are reported (#384)
A swipe that only bounces sends no request and throws nothing, so
#383's stop at the 50th item left nothing in the log or the traces, and
the reader that would not scroll in the same report was never found.

The page now reports each swipe in the reader (next, prev, list, end,
short; a tap is not one) with where the reader stood, under the tap's
trace, and scroll_stuck when a drag up or down with room to move the
reader left it where it was. That one is watched by where the finger
was, not by what it landed on, so a layer lying over the reader is
caught, and its state names what is under the finger, what is inert,
the dialog, the reader's transform, a #dpeek left behind, and how long
since the app came back. ipx logs swipe as info and scroll_stuck as a
warning, in a new free-text state field, and the dashboard counts both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:46:31 +00:00
385589ff77 Swiping past the 50th item fetches the next ones (#383)
The item list is fetched 50 at a time, and only the list's Load more
button asked for more. A swipe or j stops at the last item loaded, so
after 50 items the reader bounced on every swipe until a reload brought
a fresh page of unread ones.

Turning to within five of the end now fetches the next page behind. Its
offset counts what is still unread on the Unread tab: the items read
here have left the server's unread list, and Load more's flat 50 a page
skipped as many unread items as had been read. A refresh (a return from
the background, a live update) always asks for the first page, as the
kept list it fills is one, where it used to ask for whatever page Load
more had last reached. A page appended after new items arrived no longer
repeats ones already loaded.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 20:36:45 +00:00
72e4a97f87 ipx-web uses Caddy's global metrics option (#381)
The browser tests ran Debian's Caddy 2.6, which has no global metrics
option, so the Caddyfile nested it in servers, which production's 2.11
logs as deprecated at every start. The tests now run Caddy 2.11 from
Caddy's own repository (install.sh), the version the caddy:2 image has.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:23:27 +00:00
08b28f0598 A tap's span ends with its own requests' answers (#379)
traceparent keeps one trace for any request within a minute of the last,
and the tap span ran to the last answer in that trace, so a live update's
refresh 13 s later made a tap answered in 0.9 s read as 15 s (trace
a895551d20626fb6dc071d336262b772). Now a request counts towards the tap
only when one of the tap's own is in flight or it begins within two
seconds of the tap or its last answer; it still joins the trace.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 02:05:31 +00:00
5ee633e700 Changelog: no blank line inside Fixed (#378)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:48:02 +00:00
070b39e840 The page stops recursing when a tap ends long after its answer (#378)
endTap reported the tap before clearing it; report's traceparent starts a
new trace when the last is over a minute old, and newTrace ends the open tap
again, so hiding the page (switching apps on the phone) a minute after using
it recursed until the stack ran out. Timings are never throttled, so nothing
broke the loop. Introduced in #376.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-26 01:47:51 +00:00
dd91602a49 Metrics and traces for what had none, and alerts on them (#365-#377)
Reviewing what production's traces and metrics leave out turned up
twelve gaps; each is an issue.

Metrics. A Prometheus histogram for ipx and the directory both
(ipx_directory::metrics::Histograms), which ipx's request histogram
(#364) now uses too.
- ipx: each statement's time once it has a connection, by its first
  word, from SeaORM's metric callback; and the wait for one, for
  hand-written SQL in db.rs, measured as the call less the statement
  (the callback runs in the poll that returns the result, so the time
  read after the await is that statement's) (#367). SeaORM's own queries
  are timed but their wait is not seen: a shortcut, noted.
- ipx: counters of what the events say, counted in emit, which every
  event passes once: feed failures by error.type, new items, downloads
  saved and failed, DeadAir's seconds found (#368).
- ipx: the page's list_shown, play_start and stall timings (#369).
- directory: requests by method, route (a route layer puts the matched
  route on the response, or each feed's page was a row) and status;
  feeds checked and failed; snapshots taken (#370).
- ipx-web: Caddy's own metrics on METRICS_BIND (:9180), served apart from
  the port the tunnel reaches. `servers { metrics }`, which Caddy 2.11
  calls deprecated, because the browser tests' Caddy is 2.6 (#371).

Traces.
- A list's sync is list_sync with steps, an OPML upload opml_import
  (#372). Doing so showed sync_group reading every account and its
  subscription to the list once per listed feed; read once now (#377).
- Each command the worker runs, from the socket, the CLI or the page,
  is a command span with its job inside (#373).
- The daemon's start is a trace, from opening the database through the
  catalogue's repairs to "daemon started" (#374).
- The directory's lines carry trace_id and span_id: WithTrace moves to
  ipx_directory::otel and both use it (#375).
- A tap or key is its trace's root span (#376). The page names a root
  span id in each request's traceparent, where the parent was random and
  pointed at nothing, and reports the tap (ids, start, its last answer)
  when the next begins or the page is hidden. opentelemetry 0.33 gives a
  span builder no way to take ids, so the shared tracer provider has an
  id generator that takes given ids inside with_ids, on one thread.

Alerts. grafana/alerts.py, Grafana alerting provisioning beside the
dashboard (#365): ipx, the directory or ipx-web down, scans stopped, a
job over 30 minutes, the pool full, 5xx, 502s, Jev refused, Signals
stopped, the directory's refresher stopped, Tower's load and memory, the
monitoring down, Tempo not reading its blocks, iPX's lines not reaching
Loki. No contact point yet: firing shows in Grafana. Prometheus now
scrapes Tempo, Loki, Alloy and itself (#366), in the monitoring project's
config, not this repository's.

The dashboard has database and page-timing panels; the prod check knows
the new metrics, spans and alerts.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 16:18:06 +00:00
eae0ef5061 The page tells two feeds' items with one guid apart (#361)
Rows, the selection, the items turned past and the player were matched
by guid alone, so in All Subscriptions two feeds giving a post the same
guid (a blog's RSS and Atom) were one item to the page: clicking the
second selected and marked the first, and the second never read. They
are matched by feed and guid now (entryKey, the key readWrites already
used); data-guid stays on the row for the tests, data-key is matched.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 02:00:14 +00:00
2c7e23c346 An item's text takes four fifths of the reading pane, not 80 characters (#360)
Asked for as a share of the window. The pane is the window less the
feed list, so a share of the window would never bind; a share of the
pane does. On a phone the pane is the screen, and keeps all of it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:49:44 +00:00
cad14ec09d An item's text keeps to about 80 characters a line, not 70 (#360)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:44:09 +00:00
cbc0c48f46 An item's text keeps to about 70 characters a line (#360)
Across a wide window a line ran past a hundred characters. On trial:
reverted if it does not suit.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:40:58 +00:00
4249393f2a An interface font and a reading font in Settings (#359)
The font chosen before is the interface's, under the same cookie, so a
choice already made stays as it was. The reading font, ipx_rfont, sets
an item's title and text and is the interface's until chosen. Text read
in a serif gets a line height of 1.75 for 1.65, whichever setting put
it there. Each face's stack is a custom property, used by both.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:35:02 +00:00
75afaa291c Reading fonts in Settings: Atkinson Hyperlegible, Lexend, Literata, Source Serif, OpenDyslexic (#359)
All under the SIL Open Font License, taken from Fontsource's npm
packages with their licences beside them in web/fonts. Atkinson is the
Braille Institute's 2025 Next, one variable file. Each is its Latin
subset only, 33-118 KB, fetched once a browser chooses it; anything
outside Latin falls to the next font of the list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:26:02 +00:00
ea5c15cc58 The font's default is called Default (set by the theme) (#359)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:18:21 +00:00
6f38c0ad9b Choose a font in Settings, beside the theme (#359)
Kept as the theme is, in a cookie each browser its own, and put on the
page by boot.js before the first frame. Theme's own, the default, adds
no rule, so Modern's Inter, Classic's Lucida Grande and Glass's system
face are as they were. The others are on the device already or served
by ipx (Inter), so choosing one downloads nothing. Their rules are
last in app.css, where they tie with each theme's own body font and win.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-25 01:10:28 +00:00
0be8eb5cdf ipx 1.5.0, ipx-directory 1.4.0 and ipx-web 1.3.0
The security audit's 26 findings, three dependency advisories and the
performance issues found in traces (#318-#352), each already deployed
as a -dev build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 16:41:57 +00:00
df643536c0 ipx-web drops the header TRUSTED_HEADER names from anyone but the tunnel (#323)
ipx trusts [web] trusted_header from Caddy's address, and Caddy
stripped only the two Cf-Access-* names from requests not from
TUNNEL_PEER. With trusted_header set to anything else (X-Remote-User
for another proxy), a request through ipx-web carried it to ipx as
Caddy's and was believed: anyone could sign in as anyone.

TRUSTED_HEADER, defaulting to Cloudflare's name, is stripped the same
way. Production uses Cloudflare's header and sets nothing, so it
behaves as before. caddy adapt shows the delete list with and without.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 15:20:55 +00:00
708b69c083 Feed links are followed only when they are web addresses (#326)
An item's link and an enclosure's URL are the publisher's, stored as
written, and the page put them in href and window.open through esc(),
which escapes characters, not schemes: a javascript: or data: link
survived as Open the original, View in a new tab (for a file not on
disk), the Directory's item links and the v key. Show notes already
lose those schemes to ammonia; these four sinks were the exception.

webUrl() passes http(s) only, and each sink goes through it. A link
that fails it shows no link, as an item with none does.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 13:07:47 +00:00
84b4203daf A feed's page follows its row after a check (#316)
The header was drawn once, on opening the feed. feed_row put the new row in S.feeds and redrew the
feed list but not the open page, so a check that cleared an error left the error showing, with the
old "checked" time, until the feed was opened again. Seen on Eli Bendersky's website: a connect
timeout at 15:13, a clean forced check at 15:28, and the timeout still on the page.

Only the stat line and the error banner are redrawn: the whole page would close the open item.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:37:19 +00:00
0696583547 Reduced motion, touch hover, overscroll and artwork edges (#310 #311 #312 #313 #314 #315)
Fixes from the UI-polish review of the main window (2026-10-19).

Reduced motion took every animation away, and a turning icon was the only sign a check was
running. The refresh buttons now carry aria-busy and a "Checking for new items" label while
checking, and under reduced motion spinners pulse and toasts and sheets fade.

Every :hover rule is behind @media (hover:hover), since Safari keeps :hover on the last thing
tapped; the two phone resets that undid it for .btn and .ep had nothing left to do and are gone.
An item row's actions still show on hover without the query, so a tablet with no pointer can
reach them by tapping the row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-20 15:16:41 +00:00
6ec2c35291 Keyboard, screen reader and contrast fixes from the interface review (#298 #299 #300 #301 #302 #303 #304 #305 #306 #307 #308 #309)
Found reviewing the main window, its dialogs and the Directory in all 25 palettes (13 themes,
light and dark), in Playwright against the test daemon.

- #298: an item's title is a button, so Tab and a screen reader can open it; the feed list's
  rows, places, Signals and the Directory's shows are links to a screen reader (asLink), named
  by what they show, the open one aria-current. Not role=button, which would flatten a folder's
  triangle inside it.
- #299: .ep:focus-within shows the row's actions; Tab reached Play and Delete at opacity 0.
- #300: the read toggle is named Mark read / Mark unread, the dot hidden from screen readers.
- #301: the seek slider is named Position, its value read as "12:30 of 45:00".
- #302: ::placeholder in --faint; the browser's grey was 2.7-4.4:1 in 20 palettes. The search
  and filter boxes have names that do not depend on their placeholder.
- #303: #toasts is role=status; an error toast stays until dismissed, at most four at once.
- #304: api() says what to do when iPX cannot be reached or answers 500 (whose text is the
  daemon's internals, already in its log); play() failures are put in words and reported, an
  AbortError ignored; a failed download names its item.
- #305, #308: --field, the theme's --fg mixed into --bg in OKLab (--fieldmix, 60% and up to 80%
  for Catppuccin light), edges text boxes, selects, chips, the OPML box and switch tracks,
  phones included, and rings a light theme's switch knob. tests/contrast.js holds it to 3:1 on
  bg, panel, panel2 and raise in every palette. --line stays for rules. Fields lose outline:0,
  so they take the same focus ring as everything else.
- #306: openModal makes the card role=dialog aria-modal, labelled by its heading, focuses it,
  and makes the rest of the body inert until closeModal, which gives focus back to what opened
  it. Not <dialog>: its top layer would cover the toasts and their Dismiss.
- #307: the labels in Add a feed, Settings, Download latest and OPML point at their boxes.
- #309: Settings shows iPX's and the page's versions at its foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 20:12:34 +00:00
5bad68670a Release ipx 1.4.0, ipx-directory 1.3.0 and ipx-web 1.2.0
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:40:00 +00:00
995ee05557 Others' downloads stay off your page (#294)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 18:12:55 +00:00
4c1b78408f An x in a search box clears it (#291)
Once something is typed: at the box's right end, inside it. One click
handler for every box, which clears it and fires its input event, so each
box's own handler brings its list back whole. The browser's own x, drawn
by some browsers and not others, gives way to it.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:55:25 +00:00
d850c02501 DeadAir's pause slider without its heading (#290)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:51:33 +00:00
363a5a122d A feed's address stays at the top of its settings (#290)
Asked for on the mockups: the address first, under the name.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:43:27 +00:00
3d9b45ddd7 Changelog: the redesign under the existing Changed (#290)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:38:22 +00:00
b54b47897b Keep a feed out of Signals, and a feed's settings in named parts (#289, #290)
hide_from_signals on a subscription, on an OPML or creator covering every
show in it, as hide_from_all does for All Subscriptions: the judge does
not put its items to Jev, and each Signal's list and counts leave them
out. shown_in() is the one SQL rule for both.

The settings dialog had grown one captioned setting at a time into a
single column, one part boxed and the rest not. It is in named parts now,
the name in a column beside the controls and above them on a phone, ruled
apart; All Subscriptions and Signals carry the sidebar's icons. The
address, the same for everyone, moves to the foot.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:37:49 +00:00
b89c371662 Keep a feed, an OPML or a Patreon creator out of All Subscriptions (#288)
A switch in the feed's settings, In All Subscriptions, on its
subscription as hide_from_all. All Subscriptions' list and count leave
out a feed hidden on its own or through the OPML it is in, and the page's
unread count does the same. Signals read every feed as before, and
Currently Listening, which is All Subscriptions' in-progress filter, keeps
an episode under way whatever the feed's setting.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-19 14:19:54 +00:00
187729a278 ipx-web's spans are named for the request (#286)
Caddy named every span ipx-web, so Tempo's lists and trace view showed the
same name for each request. Caddy 2.11 takes placeholders in the span name:
now the method and path, as ipx names its own. The prod check's span list
shows a span's url.path when it has no route.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 20:04:10 +00:00
b4c0048f68 The HTML and Markdown choice drops the Markdown source view (#285)
Rendered Markdown is the one wanted beside the publisher's HTML. A browser
that had chosen the source view shows the HTML.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:45:18 +00:00
d5ffe5d042 Switch an item between its HTML and its Markdown (#285)
A feed that sends <source:markdown> beside its HTML (Scripting News) has its
Markdown kept in entries.markdown, added to existing databases by an ALTER.
A stored item gets it when a scan finds it told differently, the Markdown
counting as a change, the way corrections arrive (#141). The API sends it as
written and made HTML, cleaned by ammonia like the description.

The item's page has a choice between its meta line and its buttons: HTML,
Markdown or Markdown source, remembered in the browser.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 18:39:16 +00:00
5cf7a28c44 An OPML's page lists its shows' items together, not the shows (#283)
GET /api/feeds/{id}/entries for an OPML or a Patreon creator now lists the
items of the shows in it the person subscribes to, as All Subscriptions does
every feed's. The shows are looked up first and queried by list: an OR with a
subquery, or an IN over one, made a single feed's page a sequential scan of
entries (clarkesworld 10 ms to 26 ms or a second, EXPLAIN on production), so a
single feed keeps its `feed_id = $1`.

The page draws the OPML with its own header and the item list, tabs, search
and sort of any feed, with the feed column showing which show each is from.
A show's scan or change refreshes its OPML's list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-18 17:41:38 +00:00
7f61250852 Keep is a button on the show's page, a plus, not a switch in its settings (#280)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 22:02:55 +00:00
2c4ba7896e Keep a show from an OPML when the OPML drops it (#280)
A show inside a subscribed OPML went when the OPML stopped listing it
(orphaned only if it had downloads), and went for you when you left the
OPML. Keep, in its settings, is a per-person flag on the subscription:
when the OPML drops a kept show, or nobody subscribes to the OPML any more,
it becomes a catalogue entry outside the OPML, taking the OPML's settings
while it is there, and stays on the lists of those who kept it. Whoever had
it only through the OPML loses it as before. Leaving the OPML yourself
leaves what you kept on your list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 21:37:01 +00:00
2077ea5810 Signals: index the judge's query, six calls at once, one call per item on the server's key, tokens counted, a week's look-back an admin sets, edits that ask again only what was picked out (#274, #275, #276)
The judge's query read all of entries (605 MB, 566 ms) on every wake; an
index on each feed's items by date, as the query asks, and the rechecks
in a half of their own. Calls ran one at a time, about 150 ms each, so a
new Signal's month of about 8,000 items took twenty minutes: six in flight
now. On the server's key everyone's Signals on an item share one call.
TypeSafe's token counts go to /metrics and each jev span.

An item's text is cut at 1,000 characters instead of 2,000: on 55 real
items of eight podcast feeds and six Signals, on the development key,
1,000 changed none of 330 verdicts (nor did the same request twice) and
saved 12% of the input tokens, 1,266 to 1,112 a call; 500 changed one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 14:13:25 +00:00
9a41641eaa Signals: an admin chooses whether the server's TypeSafe key pays for everyone (#271)
With signals_on_server set and TYPESAFE_KEY present, the judge asks Jev
about everyone's Signals on the server's key; otherwise each person turns
Signals on in Settings with a key of their own, which is now checked with
TypeSafe before it is saved. The Signals folder shows only when they are on.

The browser suite's daemon inherited the shell's TYPESAFE_KEY, so fixture
feeds were sent to the real TypeSafe to be categorised; it is blanked now,
and an empty key counts as none.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-17 13:18:03 +00:00
47ad68c7da Release ipx 1.3.0, ipx-web 1.1.0 and ipx-directory 1.2.0
Signals (#254) in ipx and the page, and the compression and tracing
work of #239-#253 in all three.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 20:37:33 +00:00
f1a156c974 A Signal with no key links to typesafe.ai and to the key's field in Settings (#266)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:32:57 +00:00
f34423735a Signals' page copy says what happens, not that Jev does it; Auto spins while it picks (#263)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:28:52 +00:00
0c2c3a379e No + on the Signals folder's row, so it is like any folder's (#265)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:22:29 +00:00
2ed8781604 Jev picks a Signal's icon, on the server's key, and each Signal counts its items (#263)
Jev chooses among options and cannot name one, so the page searches Font Awesome's index for each
word of the Signal's name, words and sentence, the best six each, and offers the likeliest 40.
POST /api/signals/icon asks a choice question on the server's TYPESAFE_KEY and answers with one
of those offered, never another. "Let Jev pick" in the icon picker, and a Signal made without an
icon gets one a moment after. Tried against TypeSafe: for AI Development, laptop-code 0.45 just
ahead of robot 0.40.

Each Signal's row says "12 items · 3 unread", as a feed's row counts its own, in place of its
words; /api/signals gives `items` beside `unread`, by the same rules as its list.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 19:09:26 +00:00
b18c92d1f5 A Signal's own icon, from all of Font Awesome Free, searchable (#261)
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:36:08 +00:00
ce485a82ac Signals judge one subject, with a sentence of your own, not any one word (#259)
Jev was asked "Is this item about any of these: ai, development, programming?", so any one broad
word was enough and the Signal's name, which says what is meant, was never sent. AI Development
held 54 items: Sega pulling a game remaster (0.90), a Surface Laptop review (0.95), a C++ tutorial
(0.99). It is now asked about one subject, called by the Signal's name, described by its words
and, when those are not enough, its owner's sentence. Scored against TypeSafe over those 54 with
"AI tools for writing software: coding agents, AI dev tools, vibe coding, AI in code review.":
5 stay, Linux kernel AI code review highest at 0.94; the remaster 0.06, the C++ tutorial 0.02.

signals.description is new, and its first start deletes every verdict, which answered the old
question, so everything in the window is asked again. Any change to a Signal's name, words or
sentence drops its verdicts, since each is part of the question. The queue query names the
Signal's sentence apart from the item's description, which share a row; the test caught the
item's winning.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-16 18:12:29 +00:00