Skip to content
Merged

Dev #37

Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
8d2d2f1
fix: delete records the NASA archive no longer lists
Aug 30, 2026
ff8bce4
spec: explore filters — search, ranges, method, star type in the URL …
aronnaxp Aug 30, 2026
eb7defe
fix: report removals the sweep may have committed before failing
aronnaxp Aug 30, 2026
bcd57cf
spec(#14): pin O/B/A/F/G/K/M teff cut points and unclassified-star se…
aronnaxp Aug 30, 2026
710192c
fix(explore): make the table, rows and modals keyboard accessible
aronnax-builder[bot] Aug 30, 2026
9843628
Merge pull request #37 from zackrylangford/feat/issue-34-sweep-remove…
zackrylangford Aug 30, 2026
28744c9
spec(#14): finalize — temperature bands decided by consensus, open qu…
aronnaxp Aug 30, 2026
c434ddf
spec(#14): address review — P&M band edges, sort in URL, verified Sus…
zackrylangford Aug 30, 2026
dcca4b2
spec(#14): sort state owned by useFilterParams in ExploreClient, pass…
aronnaxp Aug 30, 2026
3f99d38
Merge pull request #39 from zackrylangford/spec/issue-14-explore-filters
zackrylangford Aug 31, 2026
9dc6b01
fix(ui): hold focus in a ModalDialog with no focusable children
aronnaxp Aug 31, 2026
3180b27
feat(explore): filter foundation — planetFilters, useFilterParams, ?q…
aronnax-builder[bot] Aug 31, 2026
d1d16ba
feat(explore): range filters for radius, mass and orbital period (#45)
aronnax-builder[bot] Aug 31, 2026
e15da59
feat(explore): multi-select discovery method and one shared ordering …
aronnax-builder[bot] Aug 31, 2026
b7ac07a
Merge pull request #38 from zackrylangford/feat/issue-6-explore-keybo…
zackrylangford Aug 31, 2026
e50004c
feat(explore): star type filter, clear all, live results count and em…
aronnax-builder[bot] Aug 31, 2026
787e398
merge: bring dev (keyboard accessibility #6) into epic/41-explore-fil…
aronnaxp Aug 31, 2026
096e843
Merge pull request #52 from zackrylangford/chore/merge-dev-into-epic-41
aronnax-builder[bot] Aug 31, 2026
8234088
docs(roadmap): record archive-first direction and the filters build (…
aronnax-builder[bot] Aug 31, 2026
6fcb90c
Merge pull request #55 from zackrylangford/epic/41-explore-filters
zackrylangford Aug 31, 2026
f7ce03e
spec: shareable planet pages (draft) — issue #51
Sep 1, 2026
958f7d5
Split ExploreClient.test.tsx into three suites over a shared helper
aronnaxp Sep 1, 2026
14eb4f8
spec: finalize shareable planet pages — exact-name URLs confirmed (#51)
aronnaxp Sep 1, 2026
9bb0d00
spec: address round-1 review — table two-control layout, sitemap read…
aronnaxp Sep 1, 2026
f5b5109
spec(#51): fold in round-2 minor notes — paginated sitemap Scan, quic…
aronnaxp Sep 1, 2026
feffc49
Merge pull request #58 from zackrylangford/spec/issue-51-planet-pages
zackrylangford Sep 1, 2026
8d70964
Merge pull request #57 from zackrylangford/feat/issue-56-split-explor…
zackrylangford Sep 1, 2026
ebe5e90
feat(planet-pages): planet detail lookup and URL helper pair (#60) (#61)
aronnax-builder[bot] Sep 1, 2026
7047b75
Planet pages 2a: /planet/[name] route with grouped sections and not-f…
aronnax-builder[bot] Sep 1, 2026
2971021
Planet pages 2b: ESI badge and the plain-language Earth comparison (#65)
aronnax-builder[bot] Sep 1, 2026
5ceb3bf
Planet pages 3/7: planet-specific title, description and OG/Twitter t…
aronnax-builder[bot] Sep 1, 2026
331f6ab
Planet pages 4/7: link card, table, modal and discoveries names to /p…
aronnax-builder[bot] Sep 2, 2026
a4c0006
Planet pages 5/7: sitemap covering every archived planet URL (#71)
aronnax-builder[bot] Sep 2, 2026
7a46d81
Planet pages 6/6: per-planet OG image via opengraph-image.tsx (#73)
aronnax-builder[bot] Sep 2, 2026
a42c660
Reconcile explore views onto the planetStats formatters (#74)
aronnax-builder[bot] Sep 2, 2026
e9a9de5
chore(deps): refresh pnpm lockfile to clear all advisories
aronnaxp Sep 2, 2026
98bd5e4
fix: per-planet share-card alt, and the ROADMAP entry for epic #59 (#80)
aronnax-builder[bot] Sep 2, 2026
1279102
Merge pull request #78 from zackrylangford/feat/issue-77-lockfile-ref…
zackrylangford Sep 2, 2026
847ec86
Merge pull request #79 from zackrylangford/epic/59-planet-pages
zackrylangford Sep 2, 2026
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
131 changes: 131 additions & 0 deletions .specs/issue-14-explore-filters.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
# Spec: Explore Filters — search, ranges, method, star type, all in the URL
Issue: #14
Status: approved

## Problem Statement
ROADMAP item 3 says "Improve Advanced Filters", but there is little to improve: the explore
page's only controls are a search box and a single-select discovery-method dropdown that live
as component-local state inside `PlanetTable` — they apply only to the table view (grid view is
unfiltered), vanish on reload, and can't be shared. A visitor who narrows ~6,000 planets down
to "temperate, roughly Earth-sized, around a red dwarf" has no way to do it, and no way to send
that view to anyone. This is a build, not an enhancement (#14).

## Goals
- Filter the explore list by name/host search, radius, mass, orbital period, discovery method(s), and star type.
- Filter and sort state lives in the URL: shareable, bookmarkable, reload-safe.
- One module owns filter semantics; grid and table views always show the same filtered set.
- Controls are keyboard- and screen-reader-accessible, with a live results count.

## Out of Scope
- localStorage persistence — the URL is the single share primitive (per #14 discussion).
- The grid/table `view` toggle stays component-local and out of the URL — it's a presentation preference, not part of the selection; a shared URL reproduces *what* is shown, not *how*.
- Server-side filtering or API query params (see Technical Approach).
- Comparison tool, saved searches, 3D visualisation — downstream, part of the #36 direction discussion.
- Fixing #6 (keyboard access to table rows/sort headers) — separate task issue; should land before this epic's UI tasks.

## Acceptance Criteria
- [ ] Search box filters by planet name or host star (case-insensitive substring, current semantics), reflected in `?q=`, and applies to **both** grid and table views.
- [ ] Radius, mass, and orbital period each have a min/max range control on a log scale, reflected in the URL; a planet lacking that measurement is excluded **only while that filter is active**, and the UI says so (e.g. "planets without a measured mass are hidden").
- [ ] Discovery method is a multi-select whose options are the distinct values present in the data, reflected as `?method=` (comma-separated).
- [ ] Star type filter offers O/B/A/F/G/K/M classes with plain-language labels ("M — red dwarf"), reflected as `?star=`.
- [ ] Table sort key/direction is reflected as `?sort=` (e.g. `?sort=pl_rade.asc`); the default (`disc_year.desc`) is omitted from the URL; a shared URL reproduces both the filtered set and its ordering, and Back/Forward keeps the header ▲/▼ indicators in sync with the actual order.
- [ ] "Clear all" resets every filter and the URL; the results count ("N of M planets", M computed from the fetched list — never a hardcoded total) updates in an `aria-live=polite` region.
- [ ] Malformed or unknown URL params are silently ignored — the page never shows an error state because of a bad query string.
- [ ] Changing any filter clamps/resets pagination; no empty page-37-of-2 states.
- [ ] `lib/planetFilters.ts` is pure and unit-tested: URL codec round-trips, null-value semantics, and teff→class banding (band edges exactly as tabulated in Technical Approach, including boundary values and the sub-2,300 K/unclassified case).
- [ ] `applyFilters` over the full list is memoised in `ExploreClient` (`useMemo` on planets + filter state) — `PlanetTable` already memoises today, and the hoist must not silently drop that.
- [ ] `/api/planets` projection includes `pl_orbper` and `st_teff`; `route.test.ts` updated accordingly.
- [ ] All range/select controls are native HTML inputs or have full keyboard operation with visible focus; labels associated via `<label>`/`aria-labelledby`.

## Technical Approach
All frontend (`exoplanethub.com/`), plus a two-line data-plumbing change to the API projection.

**Where filtering happens — client side, over the already-fetched full dataset.**
Alternative: query params on `/api/planets` with DynamoDB `FilterExpression` — rejected: a
filtered Scan still reads the whole table (no cost win), and it shatters today's single
CDN-cacheable response into a cache miss per filter combination. ~6k rows × 11 summary fields
filters in memory in well under a frame.

**One pure module owns filter semantics: `lib/planetFilters.ts`.**
Exports a `FilterState` type, `parseFilters(searchParams)` / `serializeFilters(state)` (the URL
schema lives *only* here), `applyFilters(planets, state)`, and the teff→class band table.
A thin `useFilterParams()` hook (client) binds it to `useSearchParams` + `router.replace`
(debounced, `scroll: false`; `replace` not `push` so Back doesn't step through keystrokes —
alternative was `push` per discrete change, rejected as history spam for marginal benefit).
Filtering is applied once in `ExploreClient`; `PlanetTable` loses its local `search`/`typeFilter`
state and becomes a dumb renderer of the filtered list. This fixes the current grid/table
inconsistency and keeps the URL schema encapsulated in one file.
Alternative: the `nuqs` library — solid, but a dependency for one page when the custom hook is
~40 lines; rejected. Revisit if URL state spreads to other pages.
`planetFilters.ts` holds the codec, the predicate engine, and the band table — fine at this
size; if it passes ~200 lines, split the band table into its own file (`esiBands.ts` is the
precedent).

**URL schema** (public contract, so pinned here): `q` (string), `radius`, `mass`, `period` as
`min..max` with either end omissible (`radius=0.5..2`, `period=..365`), `method` and `star`
comma-separated, `sort` as `key.dir` (`sort=pl_rade.asc`; default `disc_year.desc` omitted).
Values are real units (Earth radii/masses, days), not slider positions — URLs stay
human-readable. Absent/malformed → filter inactive / default sort, never an error.
Sort lives in `FilterState`: `sortKey`/`sortOrder` move out of `PlanetTable`'s local state into
the single `useFilterParams()` in `ExploreClient`, which passes `sortKey`/`sortOrder`/`onSort`
to `PlanetTable` as props (the comparator still runs inside the table). One hook instance means
one debounced `router.replace` writer for the whole query string, and Back/Forward can't desync
the ▲/▼ indicator from the actual ordering. Alternative: keep sort in table state and persist
only its URL slice from there — rejected: two hook instances are two debounced writers racing on
the same URL (a pending `?q=` write and a sort click each serialize a snapshot missing the
other's slice; last write wins and drops one), and Back changes the URL without changing the
table's state. Alternative: leave sort out of the URL entirely — rejected because a shared URL
that reproduces the filters but not the ordering shows the recipient a different first page,
the Problem Statement's own complaint half-fixed.

**Range controls — paired min/max number inputs as the primary mechanism, with a log-scale
dual-thumb track built from two overlaid native `<input type="range">` as enhancement.**
These quantities span up to ~8 orders of magnitude (period: ~0.09 to ~8×10⁶ days in the current
dataset), so linear sliders are useless; the slider maps position↔value through log10, and the
track bounds are derived from the fetched data's actual min/max — never hardcoded, or the
longest-period planets fall off the end of the track. Native inputs keep keyboard and
screen-reader behaviour for free. Alternative: a custom ARIA slider widget — rejected; hand-rolled
ARIA sliders are where a11y bugs live, and number inputs already give precise entry.

**Star type — derive O/B/A/F/G/K/M from `st_teff` bands in the frontend.**
Alternative: sync NASA's `st_spectype` column — rejected: backend schema change plus full
resync, and the column is messy free text ("G2 V", "sdB", many nulls) that needs parsing anyway.
Temperature bands are deterministic, already per-planet, and the band table sits next to the
other filter logic. Decided in #14 (2026-08-30): Zack deferred to team consensus, and both the
architect and PM back temperature bands — no `st_spectype` column, no sync migration.

Cut points (pinned here so they aren't an implementation-time judgment call): class edges from
the Pecaut & Mamajek 2013 dwarf calibration (Mamajek's maintained "Modern Mean Dwarf Stellar
Color and Effective Temperature Sequence"). Alternative: the traditional Harvard/MK edges
(30,000/10,000/7,500/6,000/5,200/3,700/2,400) — rejected because they disagree with the modern
calibration at five of seven edges, and at K/M specifically they mislabel ~150 planets in the
current dataset (hosts in 3,700–3,900 K, M dwarfs per P&M) as "K — orange dwarf" on this spec's
own flagship "red dwarf" filter. Intervals are half-open, lower bound inclusive, in K:

| Class | `st_teff` range | Label |
|-------|-------------------|------------------|
| O | ≥ 33,000 | O — blue giant |
| B | 10,000 – 33,000 | B — blue-white |
| A | 7,300 – 10,000 | A — white |
| F | 6,000 – 7,300 | F — yellow-white |
| G | 5,300 – 6,000 | G — sun-like |
| K | 3,900 – 5,300 | K — orange dwarf |
| M | 2,300 – 3,900 | M — red dwarf |

A star below 2,300 K (brown-dwarf hosts, e.g. L/T types) or with no `st_teff` is *unclassified*
and follows the same rule as any missing measurement: hidden only while the star filter is
active, and the UI says so. (Extending M downward instead was rejected — labelling a brown
dwarf "red dwarf" is exactly the kind of quiet wrongness a curious visitor would catch.)

**Constraints/risks:** Verified on Next 16.3.3 (review of this spec): `useSearchParams` in
`ExploreClient` needs no Suspense boundary — `/explore` stays statically prerendered
(`○ (Static)` in `next build`) and reads params correctly after hydration.
Payload grows two numeric fields (~10%); harmless under the existing CDN caching. #6 should merge
before tasks 2–4 so new controls land on an already-keyboard-sound page.

## Task Breakdown
Order matches the shape approved in #14; each later task is a thin addition once task 1 exists.
1. Filter foundation: `lib/planetFilters.ts` + `useFilterParams`; hoist search box, method select, *and sort state* out of `PlanetTable` into `ExploreClient` (sort passed back to the table as `sortKey`/`sortOrder`/`onSort` props), wire `?q=` and `?sort=`, filter both views, clamp pagination (size: M)
2. Range filters for radius, mass, orbital period; add `pl_orbper` to `PLANET_SUMMARY_FIELDS`; log-scale control + number inputs (size: L)
3. Upgrade discovery method to multi-select with `?method=` (size: S)
4. Star type filter (`st_teff` into fields, teff→class bands) + "clear all" + live results count (size: M)
118 changes: 118 additions & 0 deletions .specs/issue-51-planet-pages.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,118 @@
# Spec: Shareable Planet Pages
Issue: #51
Status: approved

## Problem Statement
A planet currently exists only as `PlanetModal` inside the explore view — it has no URL, so it
cannot be linked, bookmarked, or previewed in a message, and search engines cannot discover
individual planets at all. The archive-first direction (#36) says the thing people share is a
planet; this gives every planet a permanent, server-rendered address with real `<title>`/OG
metadata, and room for the plain-language context a modal can't fit.

## Goals
- Every archived planet gets a canonical URL: `/planet/[name]`, server-rendered from DynamoDB.
- Shared links preview meaningfully ("Kepler-452 b — 1.6 R⊕, 385-day orbit"), not as a bare domain.
- The page shows the full field set the sync writes (star, system, discovery, ESI) with
plain-language framing for non-astronomers — richer than the modal, which stays as the quick look.
- Planets become discoverable: real links (`next/link`) throughout the site plus a sitemap.

## Out of Scope
- Comparison tool, 3D visualisation, favorites (Future Ideas; comparison builds on this later).
- Any backend/sync change — this reads existing data only.
- Retraction/tombstone handling for removed planets (#40's lane); a removed planet simply 404s for now.
- Redesigning the explore modal or changing explore's filter/URL behavior.
- User-generated content, images of planets (we have no imagery; the page is data + explanation).

## Acceptance Criteria
- [ ] `/planet/<name>` server-renders a full profile for every planet in the archive; the HTML
(not client JS) contains the planet data.
- [ ] Unknown planet name renders a 404 page with a link to `/explore?q=<name>` prefilled.
- [ ] Page shows grouped sections — Planet (radius, mass, density, temperature, insolation, orbital
period, semi-major axis), Star (hostname, spectral class via `starClassOf`, temperature,
radius, mass, age), System (distance, star/planet counts), Discovery (year, method, facility)
— plus the ESI badge with its band and a plain-language Earth comparison. (`st_logg` is
deliberately omitted: log-cgs surface gravity means nothing to a lay reader — an intentional
cut, not a gap to fill later.)
- [ ] Every nullable field degrades to a clear "—"/unknown state; no layout breakage (all fields
except `pl_name` and `last_updated` can be null).
- [ ] A provenance line cites NASA Exoplanet Archive and the item's `last_updated`.
- [ ] `<title>` and meta description are planet-specific; OG/Twitter tags produce a stat-bearing
link preview.
- [ ] Planet names in `PlanetTable`, `PlanetCard`, `PlanetModal`, and `LatestDiscoveries` link to
the planet page via `next/link` (renders a real `<a>` — middle-click and copy-link work).
In the table, the modal keeps a keyboard-reachable entry point via a separate quick-look
control (decision 5); mouse row-click still opens the modal. The modal gains a "View full
profile" link.
- [ ] `sitemap.xml` lists every planet page and revalidates on the same cadence as the archive cache.
- [ ] Page is keyboard/screen-reader accessible: one `<h1>` (planet name), hierarchical headings,
stats readable as label/value pairs.

## Technical Approach
Frontend only (Next.js App Router). Five decisions matter:

**1. URL is the exact `pl_name`, URI-encoded.** `/planet/Kepler-452%20b` (browsers display the
decoded form). One helper pair in a new module owns the mapping: `planetUrl(pl_name)` builds every
internal link and the route decodes `params.name` back — no other file encodes or decodes, so
internal links can never miss. *Alternative: pretty slugs (`kepler-452-b`) — rejected because
DynamoDB is keyed by exact `pl_name`, so slugs need a stored slug attribute, a sync-Lambda change,
a backfill, and a collision policy ("Kepler-452b" vs "Kepler-452 b"), all for cosmetics. Exact
names keep lookup a single GetItem and the URL unambiguous.*

**2. The page reads DynamoDB directly from the server component.** New `lib/planetDetail.ts`
(`import 'server-only'`) does a GetItem by `pl_name` and returns the full `Planet` item or `null` —
same pattern as `lib/latestDiscoveries.ts`. *Alternative: an `/api/planets/[name]` route the page
fetches — rejected as a pass-through layer with exactly one consumer; RSCs exist to skip it.* The
existing `/api/planets` summary projection is untouched; the page is the one place the full item
surfaces, so `PLANET_SUMMARY_FIELDS` doesn't grow.

**3. Dynamic rendering with ISR, not prebuilt pages.** `export const revalidate = 3600` on the
route segment — first hit renders and caches on Vercel, matching `/api/planets`' 1h CDN policy
against the ~6h sync. *Alternative: `generateStaticParams` prebuilding ~6,000 pages — rejected:
it puts a full table Scan in every build, bloats build time, and pages go stale until redeploy.*
The sitemap (task 5) is the one full-table read that remains, and that's deliberate: one paginated
Scan per revalidate window — the same cadence `/api/planets` already scans hourly — with a
projection limited to `pl_name` and `last_updated`, never full items. "Paginated" is load-bearing:
follow `scanAllPlanets()`'s `LastEvaluatedKey` loop rather than a single `ScanCommand`, which
truncates at 1MB and would silently drop planets as the archive grows. Cheap and periodic, unlike
the per-build Scan-plus-6,000-renders that made prebuilding a bad deal.

**4. Misses are defined out of existence internally, handled once externally.** Internal links are
generated from archive rows via `planetUrl`, so they always resolve while the planet exists. Only
external/stale URLs can miss: `getPlanetDetail` returning `null` triggers `notFound()`, and one
`not-found.tsx` offers the explore-search escape hatch. (When #40 lands tombstones, this same
boundary is where a "retracted" notice would slot in — nothing else changes.)

**5. The table row gets two controls; the pattern spreads nowhere else.** In `PlanetTable` today
the name `<button>` is the *only* keyboard-reachable way to open the modal — the `<tr>` click
handler is deliberately mouse-only. So the name becomes a `next/link` to the planet page (stopping
propagation so a mouse click on it navigates without also firing the row's modal handler), and each
row gains one explicit quick-look button (accessible label "Quick view: <name>") that opens the
modal. The button lives inside the existing name `<td>` alongside the link — not a new column,
which would force a matching `<th>` and widen the table for one icon-sized control. Keyboard users keep their modal entry point and gain a navigation one; mouse row-click still
opens the modal. *Alternative: leave the name as the modal button and reach the page only through
the modal's "View full profile" link — rejected: the table is the densest planet listing on the
site, and the middle-click/copy-link promise dies exactly there.* `PlanetCard` (plain `<h3>` with
a separate "Learn More" button) and `LatestDiscoveries` (bare `<h3>`) have no such conflict — their
names simply become links, and the two-control pattern must not spread to them.

Metadata via `generateMetadata` reusing the same fetch (deduped by React `cache()`). A per-planet
OG *image* (`opengraph-image.tsx` + `ImageResponse` rendering name/stats on brand background) is a
separate final task — links preview usefully from text metadata alone, so it can ship later without
blocking. Reuse `getESIBand`, `starClassOf`, and existing formatting conventions; no new band logic.

Risk: none of this touches explore state; the only shared surface is link markup inside
card/table/modal, and decision 5 pins exactly how the table preserves the modal's keyboard and
click flows that #41 just shipped.

## Task Breakdown
1. `lib/planetDetail.ts` (server-only GetItem returning full `Planet` | null) + `planetUrl()`/param
decode helper, with tests (size: S)
2. `/planet/[name]` route: page layout with grouped sections, ESI + star-class reuse, plain-language
Earth comparison, provenance line, null-field handling, `not-found.tsx` (size: L)
3. `generateMetadata`: planet-specific title, description, OG/Twitter tags, shared cached fetch (size: S)
4. Link-up: `next/link` names in `PlanetCard`, `PlanetModal` ("View full profile"), and
`LatestDiscoveries`; in `PlanetTable`, name→link plus the new quick-look modal control per
decision 5 (size: M)
5. `app/sitemap.ts`: one paginated runtime Scan (`LastEvaluatedKey` loop per `scanAllPlanets()`)
projected to `pl_name`/`last_updated` only, `revalidate` aligned with data cadence (size: S)
6. Per-planet OG image via `opengraph-image.tsx`/`ImageResponse` (size: M — can trail the rest)
Loading
Loading