Skip to content

Rewrite Cue as a native Expo app over a shared core - #24

Open
arun279 wants to merge 679 commits into
mainfrom
feat/expo-native
Open

arun279 wants to merge 679 commits into
mainfrom
feat/expo-native

Conversation

@arun279

@arun279 arun279 commented Aug 24, 2026 •

Copy link
Copy Markdown
Owner

Cue becomes a native app. iOS and Android ship as one React Native app built with Expo, over a shared TypeScript core. When this merges, the Capacitor shells, the web UI and their tooling are gone; packages/core holds the domain, the sync contract and the Trakt data layer once, and packages/native is the app.

What ships

  • Screens: Up Next, show detail, the episode sheet, Library, Calendar, Search, Movie detail, History, Profile, Settings and Onboarding (device-code sign-in with PKCE).
  • Platform behaviour: native tabs (a search tab and Liquid Glass bars on iOS 26, a Material search bar and navigation bar on Android), system back on Android, swipe-to-dismiss sheets on iOS, haptics through a small local Swift and Kotlin module, large text supported up to the largest accessibility size.
  • Episode reminders as local notifications: one alert per show per air day for the Up Next set, a daily summary option, a per-show mute with Undo, and a permission card in Calendar.
  • Updates: JavaScript updates through EAS Update, published by hand from the Publish update workflow; no telemetry. The update runtime leaves out the app version and build number, so a published update reaches tester and store builds as well as preview builds. A test resolves the runtime with and without those numbers and requires the same value.
  • Device builds: the preview profile in packages/native/eas.json builds with EAS internal distribution, an ad hoc IPA for registered iPhones and an APK for Android, on the preview update channel. The README lists the one-time owner steps: EAS sign-in, the Trakt client id in the EAS preview environment, device registration and signing.
  • Continuity: the same bundle id and package name as the shipped app, so the Trakt token and the pending write queue carry over on update.

Tester builds have shipped from release/expo throughout; the latest is 2.0.0 (2101) on TestFlight internal testing and Firebase App Distribution. That build predates the runtime change, so it gets no published updates; the next tester build will.

How it is checked

Every pull request runs lint, format, spelling, types, dead code, duplication, cognitive complexity (15 per function), dependency rules, the audit, core coverage on changed lines, render counts, request budgets per user flow, selected-state contrast in both themes (3:1 for state and glyphs, 4.5:1 for text, from WCAG 2.2), the size delta gate, the native fingerprint, both native builds, and on the Android emulator the 12 shared Maestro flows and the dark screenshot traversal. Three flows (pull to refresh, swipe to mark and stop, the lapsed drawer) are not in a CI suite yet.

Store builds and published updates contain no UI test harness. Metro replaces the harness with an empty module unless EXPO_PUBLIC_UI_HARNESS=1, a dependency rule keeps every harness module behind that entry, and both pnpm check and Publish update fail if a harness module appears in an exported bundle. Publish update ships the same export it checked.

The iOS lane (three flow shards, the dark traversal with large-text captures, contact sheets from both platforms) runs on every push to this branch, to main and to release branches, nightly, whenever the iOS fingerprint changes, and on any pull request branch whose last iOS run did not pass. It gates two timings on the simulator. Startup: the median of five launches must be at most 1,000 ms, timed from the JavaScript bundle load to the first Up Next render that has library data. Tap feedback: for mark and for undo, the 75th percentile of 15 taps must be at most 100 ms.

With cached native builds, a pull request takes 13 to 16 minutes when the iOS lane is skipped and about 20 minutes when it runs, plus any wait for a macOS runner. A change to native inputs rebuilds both apps; the last one took 99 minutes, most of it queued for macOS runners.

Releases ship only a commit whose CI passed, and the iOS release lane waits for App Store Connect processing.

Before merging

  • The main ruleset requires check, audit, actionlint, standardrb, android and e2e. android and e2e belonged to the deleted web and Capacitor jobs and never report. Replace them with the checks the release gate requires: render-performance, native-android, native-ios, native-e2e, android-e2e, ui-screenshots-ios-dark, ui-screenshots-android-dark, codeql (javascript-typescript) and codeql (actions).
  • The ruleset enforces linear history with squash or rebase. This branch contains merge commits, so use Squash and merge.
  • footprint and the coverage gate read the lines at the end of this description; without them both fail.
  • After merging, close or rebase any Dependabot pull request that was opened against the old main tree. None are open today.
  • After merging, Publish update can be run from the Actions tab, since a manual workflow appears only once it is on the default branch. Cut a tester build before the first update, because 2101 cannot receive one.

Product-Growth: +17,061 lines of product code, all new because main has no native app. This pull request also deletes the web and Capacitor app in src/, 18,591 lines by the same count, so product code shrinks by 1,530 lines overall.
Comment-Load: +152 comment lines. 96 are doc comments in the Swift and Kotlin native module, and the rest are one-line platform, Trakt and WCAG facts. The deleted src/ tree carried 4,139.
Binary-Size: main has no native app, so each delta is the whole artifact: Hermes bundles of 5.36 MB on iOS and 5.40 MB on Android, a 41.96 MB tester APK and a 20.15 MB Play download estimate. That is the React Native and Expo runtime plus the app, and it replaces the Capacitor app.
Coverage-Rationale: against main every core line counts as changed, and core code written before the changed-lines gate existed has lines without tests. Every pull request into this branch since that gate landed has covered its own changed lines.

@arun279
arun279 force-pushed the feat/expo-native branch 3 times, most recently from 25e77e7 to 5b9f030 Compare August 24, 2026 23:14
@github-advanced-security

Copy link
Copy Markdown

You are seeing this message because GitHub Code Scanning has recently been set up for this repository, or this pull request contains the workflow file for the Code Scanning tool.

What Enabling Code Scanning Means:

  • The 'Security' tab will display more code scanning analysis results (e.g., for the default branch).
  • Depending on your configuration and choice of analysis tool, future pull requests will be annotated with code scanning analysis results.
  • You will be able to see the analysis results for the pull request's branch on this overview once the scans have completed and the checks have passed.

For more information about GitHub Code Scanning, check out the documentation.

@arun279 arun279 changed the title Split the workspace behind a shared core, and add the native Expo app Rewrite Cue as a native Expo app over a shared core Sep 6, 2026
@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: engine hardening for the native scaffold.

The legacy token is adopted only when the previous shell actually stored one; the account modal gained its Done item with a router-level test that the modal closes; PlistBuddy failures in the iOS privacy script surface through the script's own exit path and the aps-environment key follows the build configuration; the expo-crypto digest shim has its own test. Verified with root pnpm check, native jest on both platforms, and a full CI run.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: the deterministic checks.

Biome runs with warnings as errors and a cognitive complexity gate at 15, every suppression carrying a written reason. size-limit budgets cover the web initial load, all web JS and CSS, and both Hermes bundles. The release lanes assert the APK and the exported IPA against download ceilings out of the built artifacts. CodeQL scans source and workflows and gates the mobile release. The footprint job measures base and head in one run and recreates its comment on every push. Every gate was shown to fail under a planted violation before it was kept.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: the sync contract.

Read failures are typed rather than collapsed to one flag. The read pool owns rate limits, honouring Retry-After and publishing one observable pause that every screen reads; the query layer owns transport and 5xx retries, so each failure kind has exactly one bounded ladder. The strip's states, in precedence: offline, rate limited with a live countdown, retrying, unreachable with the cause named and a Retry action, pending. A mark is green for the 5 second undo window, the same number the snackbar uses, then advancing, checked and disabled with a quiet dot while the write is queued, until Trakt confirms and the next episode takes the row. The fake Trakt gained fault modes (429 with Retry-After, 5xx, delay, hold, drop) and a reset control, and the mock lane drives both defects end to end.

Merge-scoped footprint against the previous tip: product +1173, tests +1789, web initial load +1.2 kB, Hermes bundles +47.8 kB iOS and +32.2 kB Android, complexity profile unchanged.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: the native design foundation.

packages/native/src/ui holds the tokens (dynamic colour pairs on iOS, scheme-resolved on Android, gated token for token against the web stylesheet's two theme blocks), eleven type roles per platform bound to Dynamic Type styles and Material 3 tokens with a test anchored to Apple's Large sizes and the Material scale, the snackbar hosts for the root, the sheet and the account modal, the app-idle marker, the accessibility id vocabulary, and the first primitives (row, check control, section header, empty state, sync strip). The splash stays up until the stores and fonts settle. Screen readers get the longer snackbar window.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Two fixed behaviours, recorded on the web app running against the repository's fake Trakt (scripts/mock-trakt) with faults injected through its /__fault control plane. iPhone class viewport, Chromium. The native screens render these from the same contract, packages/core/src/sync-contract.ts, so the strings below are what both targets say.

Marking an episode

cue-marking-an-episode.mp4

First take: the check on the queue row is tapped and turns green, and the snackbar offers Undo ("Midnight Cartography S2 E3 marked"). The row advances to S2 E4 in the same frame, on the clock rather than on a round trip. The write is held by the fake for 7 seconds, so the undo window (UNDO_WINDOW_MS, 5s) closes first: the check becomes the checked and disabled state with the quiet dot, labelled "Watched. Not synced yet." The write lands, the dot clears and the row settles on the confirmed next episode, "S2 E4 · The Undertow". Second take: the same tap, then Undo inside the window, and the row goes back to "S2 E3 · Half Measures".

green check, snackbar offers Undo

checked and disabled with the quiet dot, write still queued

dot cleared, confirmed next episode in place

The sync strip under faults

cue-sync-strip-under-faults.mp4

Healthy is silence: no strip. One 429 with Retry-After: 3 on the library read gives "Trakt is limiting requests. Retrying in 3s.", counting down, with no Retry button because the retry is automatic, and it retracts on its own when the window reopens. Two 503s give "Couldn't refresh from Trakt. Retrying…", which clears when the third attempt succeeds. A 503 that stays on spends the read ladder, and the strip settles on the cause with a manual retry: "Trakt is having trouble. Showing your cached data." plus Retry. Tapping Retry with the fault cleared takes the strip away. The queue stays on screen the whole time; a failed refresh is a note over cached content, never the screen's error state.

rate limited, counting down, no Retry

couldn't refresh, still retrying

ladder spent, cause named, Retry offered

Two things in the recordings are worth a look before the native screens copy them:

  • The unreachable line does not fit. .sync-strip__text is a single line ellipsis clamp and the Retry button takes the rest of the row, so at this width the user reads "Trakt is having trouble. Showing your cache..." and the half that promises the cached data is the half that gets cut.
  • In clip 2, The Quiet Frontier is marked at its last aired episode, S2 E10, and the projected row reads "S2 E11 · 0 left" while the confirming read is blocked. Season 2 has ten episodes, so that coordinate does not exist. It resolves the moment a read lands and the show leaves the queue, but until then the row names an episode that is not there.

@arun279

arun279 commented Sep 6, 2026

Copy link
Copy Markdown
Owner Author

Merged: size and quality budgets anchored to published norms.

A committed quality baseline ratchets the count of cognitive-complexity suppressions (21 today) and the worst measured complexity (71 today) downward only, with zero suppressions allowed under the native app, and the check refuses a baseline that outruns the measurement. TypeScript suppressions are rejected across every package's source, tests, app routes and modules. Android release builds now ship with R8 and resource shrinking; CI builds the App Bundle, estimates the Play download for an arm64 xxhdpi device with bundletool (17.4 MB today, gated at 20 MB), gates the bundle itself, and derives the universal APK for the permission gate. The old 200 MB universal-APK ceiling is gone. The footprint comment reports comment density per package.

Two gates written for the deleted web app (Vite size budgets, Lighthouse against vite preview) were dropped before merge, since the web UI is retired with the shells.

@arun279

arun279 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Maestro launch harness and the fake Trakt's missing controls.

The fake Trakt gains six selectable seed states behind its reset control and the fault modes the parity flows need (a one-shot 401, a refused refresh, a 429 mid fan-out, a held and a dropped write, a failing history page, a pre-seeded op-log), each proven observable from a client. A Maestro launch flow asserts the onboarding screen, the connect button, the device code and the arrival on Up Next, with a test that every id a flow names exists in the app's id vocabulary. CI's iOS job now uploads the simulator app and a new native-e2e job on macOS installs it, boots a simulator, starts the fake and runs the flow. Getting that job green found and fixed a real launch defect: an unsigned simulator binary carries no entitlements, so every Keychain call failed and the auth store never left loading; the build is signed for the simulator now, the packaging step asserts the entitlements section is present, a token store that cannot answer drops to onboarding with a message instead of hanging, and the boot gates render a one-point frame so a hierarchy dump names whichever gate is holding.

@arun279

arun279 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

feat/sync-follow-up has landed on this branch. In plain words, what changed:

One mark no longer refetches the whole library. Marking an episode used to invalidate the Up
Next aggregate, which re-read every show's progress, the hidden set, the watchlist and a history
page. Measured against the fake Trakt on the seeded 8-show account, driving the built app in a
real browser and counting the fake's own journal: one mark cost 9 requests (1 POST + 8 GET) and
now costs 3 (1 POST + 2 GET)
- the write, the marked show's own progress snapshot, and the
history page the home screen's "Previously" section renders from. The eight GETs it replaces were the whole
library aggregate; extrapolating that arithmetic, one mark on a 60-show backlog would have cost
about 65 requests and now costs 3. Cold start (19), Sync now (1), a foreground return
(1) and the idle poll (1) are unchanged, and the mock lane now holds all five flows to a ceiling
counted by the fake rather than by the app.

A read's Retry-After is honored. Trakt's GET window is five minutes and it can name a wait
of minutes; Cue clamped every wait to 30 seconds and then told the reader a deadline that was not
Trakt's. Reads now pause for as long as Trakt asks, up to that window, plus a small margin. The
write queue keeps its 30 second ceiling, because the POST period is one second and a longer wait
there is anomalous.

A mark never names an episode that has not aired. The optimistic advance projects the next
coordinate only inside the season the snapshot's own aired frontier ends in and only below it. A
client cannot infer where a season boundary falls or what follows the last aired episode, so past
either the row advances carrying no coordinate at all, still checked and still locked, until
Trakt names the real next one. The queue's own exclusions apply to that row exactly as they do to
a projected one, so finishing a show still ends on "you're all caught up".

Requests are bounded, and the fake can let go. Every Trakt request now aborts after 15
seconds and surfaces as a transient failure instead of hanging forever, and the fake Trakt's
hold fault - built for this and never used - can now be released, so the test can watch a held
request finish rather than only watch it hang.

Cloudflare's answers stop reading as your connection. A 429 or 403 issued in front of the API
carries no CORS headers, so in a browser it reaches the app as a bare fetch rejection and drew
"Can't reach Trakt" over a working connection. On the web, a rejection for Trakt's own origin is
now a server failure; native is unchanged, and a request the app itself timed out stays a network
failure, because that one really is the connection.

The exact-alarm claim is corrected, not just deleted. expo-notifications has not declared
SCHEDULE_EXACT_ALARM since before SDK 57, so the Expo config no longer blocks a permission
nothing merges. The Capacitor line, which is what ships today, genuinely does strip it in its own
manifest, its privacy-claims test pins that, and the README still says so.

The sync strip wraps. At phone width "Can't reach Trakt. Showing your cached data." was cut
after "Can't reach Trakt." by an ellipsis, losing the half that says your data is fine. The strip
grows and wraps instead, with Retry still on the row.

@arun279

arun279 commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner Author

Merged feat/size-gates. What is gated now is the size of what a person downloads, rather than the size of the files CI happens to produce.

Android. The release bundle is measured with bundletool at the device configuration Play Console itself reports against, XXXHDPI ARMv8, which means screen density 640 rather than the 480 that was there before. A second run measures every SDK, ABI, density and language combination and takes the largest. Both are held under 20 MB. On the merged tip CI reports 16,904,619 bytes at Play's reference configuration and 17,657,277 bytes across every configuration. The old ceiling on the App Bundle's own bytes is gone: every Play limit is stated on the compressed download, so an upload ceiling had no anchor, and the all-dimensions run does the job it was given properly.

The font nobody imported. Expo Router's Android tab icons pulled in @expo-google-fonts/material-symbols through expo-symbols, and 962,968 bytes of it were in every Android build. The tabs now use generated Android drawables, and the font is excluded through Expo's own EXPO_ROUTER_DISABLE_NATIVE_TABS_MD, set once in the Metro config so every bundle the project builds agrees, including the one Gradle embeds. That also drops the 111,392 byte glyph table that came with it. Nothing was deleted from node_modules. Against the merge base the Android bytecode is 93.5 kB smaller and the Play download 495.6 kB smaller.

Budgets that can only go down. Every .size-limit.json entry now records the measurement it was cut from, the date, and a reduction target 20 percent below it, which is web.dev's method for choosing an interim budget. A ratchet reads the committed history of the file and fails any raise, so a limit cannot be edited upward in the pull request that needs it raised.

entry ceiling measured reduction target
web initial load 170 kB 138,056 110,445
web all JavaScript and CSS 285 kB 229,484 183,587
expo iOS bundle 4450 kB 3,609,323 2,887,458
expo Android bundle 4830 kB 3,803,699 3,042,959

The Play download budget is a decision rather than an anchor: 20 MB sits between Expo's minimal-app floor and the comparables, and Google's published curve prices the gap at roughly a point of install conversion per 6 MB. It re-anchors against the Play Console peer group once there is a production release.

Growth needs a reason. The footprint job compares the merge base and the head for both Hermes bundles and the Play estimate, and fails above 64,000 bytes of growth. The only way past it is a Binary-Size: <rationale> line in the pull request body, which is Chromium's shape for the same problem. A merge base without these packages reports n/a, which is this pull request's own case against main.

Assets are enumerated. Every asset in the built export is listed with its bytes in .native-assets.json, read out of the export's own manifest rather than a source directory. An arrival nobody declared and a disappearance both fail. 38 files, 1,486,280 bytes today.

Attribution. The footprint comment carries an Expo Atlas table of the largest contributing packages per platform, and the Atlas file uploads as an artifact, so a failing delta names its cause instead of leaving you to guess.

pnpm check also runs the native size check now, so a push sees both Hermes bundles and the asset inventory before CI does.

Two things worth knowing from the review before the merge.

iOS has no download gate yet. Xcode documents its thinning export option as applying to non App Store exports only, so the App Thinning Size Report the branch tried to assert on is never produced by the release lane's App Store export, and every iOS build would have failed on the missing file. That assertion is removed and the IPA smoke ceiling stays. A real number needs either an ad hoc provisioning profile in CI for a second export of the same archive, or App Store Connect's App File Sizes after the first upload.

The generated Android tab icons were invisible. Material Symbols path data is authored in the SVG viewBox 0 -960 960 960, every coordinate negative, and a VectorDrawable viewport starts at the origin, so all four icons were painted above the canvas. They are wrapped in a translation group now. Nothing in the suite would have caught it; an Android visual lane would.

@arun279

arun279 commented Sep 9, 2026 •

Copy link
Copy Markdown
Owner Author

The core's cognitive-complexity debt is retired. Nine functions carried a suppression; all nine are
split by responsibility and nothing under packages/core suppresses the rule any more.

scripts/quality-budget.json reads 12 suppressions and a worst function of 41, down from 21 and
71 when this started (20 and 71 on the branch, since the Up Next fix had already retired one). The
twelve that remain are all in packages/web/src/ui, which item 20 deletes. The footprint job on this
PR reports functions over 15 at 20 to 12, worst 71 to 41, mean 4.98 to 4.60.

What was split into what:

  • useMarkSeason's toggleEpisode (71) is a two-line dispatch over unmarkEpisode and
    markEpisode; the unmark side splits again into submitQueuedEpisodeUnmark, which enqueues the
    inverse of a still-queued mark, and submitLiveEpisodeUnmark plus submitEpisodePlayRemoval,
    which resolve the real plays and settle a rewatch or a single play. Worst resulting function 13.
  • useMarkSeason's unmarkSeason (17) separates resolveSeasonUnmark from submitSeasonUnmark.
    Worst 10.
  • The runtime's reconcile (37) becomes createReconcile over reconcileHidden, reconcileMovie,
    reconcileAdditiveEpisode, reconcileAdditiveSeason and reconcileMark, each with its own
    authoritative Trakt read. Worst 5.
  • createAuthStore's pollLoop (32) keeps pacing; pollAttempt owns attempt ownership and
    applyDevicePoll owns what an outcome does to the store. Worst 11.
  • groupUpNext (24) separates classifyUpNextShow from toUpNextItem, over the queue rules this
    branch's Up Next fix established. Worst 12.
  • assembleEpisodeDetail (20) flattens progress once, then picks ordered neighbours. Worst 8.
  • The authorized fetch (19) separates the idempotent read retry from the write deferral. Worst 6.
  • assembleLibrary (18) and assembleMovieLibrary (18) build watched and watchlist-only entries
    independently, then merge. Worst 6 and 5.

Behaviour is unchanged: every test that covered these paths passed before each split and passes now.
Reconcile and device-code polling had no direct coverage and gained behavioural tests written against
the old code first; reconcile now also proves the other half, that an operation an authoritative read
cannot find stays queued and is re-dispatched.

Comments: 415 comment lines removed, 71 added, net -344 across the eight files, taking core
density from 28.04 to 25.32 percent. What went was restatement, provenance and workaround narration.
What stayed, or came back after review, is the facts the code cannot state: the cross-account replay
rule and clear order at sign-out, the Trakt token lifetime the refresh throttle is sized against and
its persist-before-publish order, the reconcile anchor's contract with startup reconcile, the
Specials paging order, why the rewatch paths carry no Undo, and the two TODO markers recording open
defects that the first pass had deleted.

Item 22's other target, core comment density at or under 14 percent, is not met and stays open: this
pass touched only the files it split.

Verified before merge: root pnpm check exit 0 on the merge itself, the mock lane four times at 20
of 20, Playwright chromium twice at 240 passed, and the budget ratchet mutation-tested in both
directions (a raise and an under-record both fail). All fourteen checks are green on this tip.

native-e2e took three attempts to get there, and none of the failures were this work: it had been
failing on every branch since some time between 14:52 and 15:45 UTC today, and feat/expo-native at
e25554b, with none of this work on it, failed the same way. Two symptoms, an app that exits in dyld
before its first frame and a cold sync that outlasts the assertion's window, both pointed at the
runner rather than at the app, and the fake Trakt's own log for the second one shows the device token
granted and the whole Up Next read sequence served while the assertion was still waiting. It passes
now on an unchanged tip. Worth knowing it can do this.

@arun279

arun279 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Merged: a deterministic simulator build.

The native end-to-end job had gone red on every branch: React Native decides at pod install, through a live request to Maven, whether to use its prebuilt core, and when that request fails it silently builds from source without embedding React.framework while the Expo module frameworks still link it, so the app died in the dynamic linker at launch. Expo's precompiled modules are now off so the two halves are always built the same way, and the packaging step walks every embedded framework's load commands and refuses an artifact whose transitive frameworks are not in the bundle.

@arun279

arun279 commented Sep 9, 2026

Copy link
Copy Markdown
Owner Author

Merged: Up Next on the native app.

The queue rows are built on the foundation primitives with the three mark states (unwatched, just marked for the undo window, advancing), swipe to mark at the 96 pt threshold with the threshold and success haptics, pull to refresh that ends immediately during a rate-limit pause, the sync strip in flow, the marquee, "On the way" capped at three rows, the History footer, the lapsed drawer, and the empty, loading, degraded and offline states, in light and dark and at the largest accessibility text size. Two build defects were found and fixed on the way (an Expo UI release that does not compile against the pinned SDK, now pinned level with a test; a snack message built from a DOM element inside the shared store, which crashes a native text tree), and five visual defects were found by operating the screen on the simulator and fixed with tests. The placeholder onboarding screen is now readable on both appearances. Clips and stills below.

clip-1-mark-undo-swipe.mp4
clip-2-refresh-and-strip.mp4

light-01-onboarding

dark-01-onboarding

light-03-up-next

dark-03-up-next

light-04-marked

dark-04-marked

light-06-lapsed-drawer

light-08-strip-unreachable

dark-09-strip-rate-limited

ax5-03-up-next

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged feat/core-cleanup: a cleanup pass over the shared core and the native wiring. In plain words:

The native app syncs on its own now. useActivitiesPoll was written for the AppVisibility port and mounted only by the web shell, so on a phone nothing refreshed in the background: a mark made on another device never arrived until you pulled to refresh. It is mounted at the native root, with a composition test that fails if the mount is removed.

The core no longer needs browser globals. A 40-line module used to install btoa, crypto.getRandomValues, crypto.randomUUID and crypto.subtle.digest onto globalThis at boot, because the core was written against Web Crypto and Hermes has none of it. That is gone. Identity and digest are a three-method port each platform implements (expo-crypto on device, Web Crypto in the browser), PKCE encodes base64url itself instead of borrowing btoa, and the linter now refuses crypto, btoa, atob and URLSearchParams under the core, so the next one fails a check rather than the device. The polyfill file went from 40 lines to 8, the base64-js dependency went away, and so did the only as unknown as in the tree.

The accessibility ids are one vocabulary again. TEST_IDS claimed to be the only place ids were spelled while 108 of its 138 entries had no reference and 15 screens spelled 58 ids inline. It is 89 entries now, every one of them used, and the check runs in three directions: every id a flow selects must be declared, no screen may spell a testID literal, and no declared id may be dead. Inline literals: 15 files before, 0 now.

Render counts are gated. Three surfaces (the queue row, the check control, the Up Next screen) are measured with Reassure on every CI run and pinned at 2 renders with zero allowed deviation, so an accidental extra state update fails the build instead of arriving as a stutter. Durations are printed but do not gate: one machine's timing is not a threshold. render-performance is a required check for the release gate.

The tests that hold the core to its coverage floors live in the core. 26 test files and their support moved from packages/web/test to packages/core/test, including the only tests for the Trakt client, transport, endpoints and repositories. Deleting the web app will no longer take the core's gate with it. Fifteen files that test a web screen or a web platform adapter stayed with the web app, so the core package builds and tests itself without it.

Duplicated concepts collapsed. One DAY_MS, one local-day-key formatter, one declaration per storage key, one QueryStatus shape across the read hooks instead of eleven hand-rolled subsets, and one lock map for "a write is outstanding" instead of six containers. Four of those are held in place by a check that names the single file each belongs to, and each was proved by reverting the change and watching the check fail.

Also: a browser-hidden response is its own failure kind rather than a 503 Trakt never sent; pagination without headers keeps going until a short page instead of stopping after page one; concurrent identical reads share one request; write retry pacing keeps a 100 ms margin under Trakt's limit; and 420, 423 and 426 are named permanent account failures.

Numbers, from the footprint. packages/*/src went from 29,582 lines to 28,972: 610 lines deleted, 513 added and 1,123 removed. Of that, 391 are code lines and 164 are comment lines, so core comment density fell from 25.64 to 25.16 percent and native from 21.04 to 20.49. The seven deleted screens moved into the Expo route files, so counting packages/native/app too the net is 127 lines smaller. Nine files are gone: two single-caller hook layers and seven native screen modules the routes now own. Across everything, 1,804 lines added and 1,315 removed over 158 files.

Nothing regressed: worst cognitive complexity 41 with 11 functions over 15, unchanged. Web initial load 139.1 kB brotli against a 170 kB limit, all JavaScript and CSS 230.9 kB against 285 kB, iOS bundle 5.12 MB against 5250 kB, Android 5.15 MB against 5400 kB, Play download 17.30 MB against 20 MB. 1,001 vitest tests and 288 jest tests pass and every coverage floor holds.

Still owed: a startup timing gate. The simulator was unreachable where this was built, so there is no launch measurement and therefore no defensible ceiling to ratchet. That is the last item from the performance work.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the native release lane, and a settle wait in the launch flow.

The mobile release workflow gains a line choice, expo by default and capacitor for rollback. The Expo line prebuilds both platforms in the workflow, then fastlane builds the workspace with the existing signing, asserts the built IPA's version and build number against the values the config wrote, checks TestFlight for a strictly greater build number within the marketing version, and uploads to the internal group; on Android it builds the release App Bundle with R8 and resource shrinking, derives the universal APK with bundletool for the permission gate and for Firebase App Distribution, and checks the latest Firebase release the same way. The Expo line starts at version 2.0.0 so it never collides with the Capacitor 1.x builds. The launch flow now waits explicitly for the device-code screen after the connect tap instead of relying on the driver's automatic settle, which raced the screen transition.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the push entitlement removed, 235 lines deleted and 20 added.

expo-notifications wrote an APNs push entitlement into the iOS build, and the distribution profile has no push capability, so the first iOS archive failed. The module is out of the native app until per-episode notifications are built (local notifications need no push entitlement), along with its plugin, its permission suppressions and the entitlement check in the privacy script.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Capacitor shells removed, 4,300 lines deleted and 147 added (net -4,153). Bundles: web initial load -3.3 kB (135.8 kB), web all js and css -5.2 kB (225.6 kB), Expo iOS bundle -43 B (5.03 MB), Expo Android bundle -23 B (5.06 MB), Play download -87 B (16.85 MB). Functions over cognitive complexity 15 unchanged at 11, worst 41, all in the web UI.

The Capacitor iOS and Android shells, their five packages, the platform seams, the ios and android CI jobs and the Capacitor release lanes are gone; the Expo app is the only native codebase. The reader that migrates the old app's data on first launch stays until its sunset.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the Android launch crash fixed, 118 lines added and 3 deleted. Bundles: web unchanged, Expo iOS bundle -99 B, Expo Android bundle +8 B, Play download -11 B.

Build 1201 died at boot with "EXPO_PUBLIC_TRAKT_CLIENT_ID is not set". The release workflow set the variable on the prebuild step only, and Metro inlines it while bundling inside the Gradle and Xcode builds, which ran without it; both platforms shipped an empty client id. The Fastlane steps and the CI Android bundle now receive it, a workflow test requires it on every bundle-producing step, and a new android-e2e job installs the signed CI build on an API 35 emulator and fails on any fatal exception at launch.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the web app removed, 30,797 lines deleted and 891 added (net -29,906). Bundles: Expo iOS bundle -1.8 kB (5.03 MB), Expo Android bundle -1.9 kB (5.06 MB), Play download -100 B (16.85 MB); the web size ceilings left with the web build. Functions over cognitive complexity 15: 0 (was 11, all in the web UI); worst function now 15.

The DOM application, its unit tests, its Playwright suite and its Vite tooling are gone; the Expo app is the only UI over the shared core. Twelve network-behaviour checks that only the browser suite exercised (per-flow request budgets from the fake Trakt's journal, the fault modes, token refresh, the rate-limit pause) now run headless in the core's harness against the same fake Trakt, each proven by a mutation. The repository-level CI tests moved to a root test project. Coverage percentages rose in every category.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the core's read layer reshaped, 1,662 lines deleted and 1,403 added (net -259; core source -787, native screens and tests +528). Bundles: Expo iOS bundle -10.1 kB, Expo Android bundle -10.0 kB, Play download -3.0 kB.

Reads are query option factories under the core's queries/ (14, each taking the runtime first), module state is read through selectors under stores/, and the pure derivations the page-shaped aggregates were hiding live under domain/; the hooks directory went from 3,932 lines to 2,591 and keeps only behaviour hooks. Screens compose their own reads. Two new gates: every use-named file exports exactly one hook and nothing else, and queries/ cannot import React; the scoped-revalidation rule now also covers queries, stores and native routes. Four mutations bit. Coverage thresholds rose (overall lines 79.8 to 86.9 percent) and the render-count gate now measures real subscriptions through a seeded query client instead of mocked hooks.

@arun279

arun279 commented Sep 10, 2026

Copy link
Copy Markdown
Owner Author

Merged: the startup-time gate, 348 lines added and 12 deleted. Bundles: Expo iOS bundle +478 B, Expo Android bundle +567 B, Play download +155 B.

The simulator lane now measures a returning user's launch: the app publishes the time from process start to its idle marker (React Native's own startup timing) in the accessibility tree, and a second-launch Maestro flow reads it and asserts it against a ceiling kept in a ratchet file that carries its measurement, run and date and can only be edited downward. The ceiling is 600 ms from a measured 466.5 ms plus the observed run-to-run band; a planted three-second block at boot failed the flow on a throwaway PR, and raising the ceiling fails the ratchet test. The runner-timed launch-to-idle duration is reported in the job summary.

@arun279

arun279 commented Sep 19, 2026 •

Copy link
Copy Markdown
Owner Author

Merged: honest sizes, 487 lines added and 387 deleted (net +100), plus a harness fix. Sizes: Firebase tester APK 102.84 MB to 36.97 MB (arm64 only; the Play bundle keeps every architecture), Expo iOS bundle +107 B, Expo Android bundle -34 B, Play download estimate +16.2 kB (launcher icon and splash), iOS Release simulator app first reported at 92.80 MB.

The footprint comment carries the tester APK and the iOS simulator app beside the Play estimate, each labelled with what it measures; the tester APK and the IPA have downward-only ceilings; the launcher icon, adaptive icon and splash are back; the release workflow runs only on manual dispatch and strict vX.Y.Z tags. The network harness tests advanced fake time from the fake server's journal, before the client had registered its retry timers, which failed under load; they now wait on the client's own state.

@arun279

arun279 commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Merged: the first Android run fixed, 421 lines added and 266 deleted (net +155), plus two harness commits. PR #24 now stands at +27,576 / -37,771 over main. Sizes: bundles and the tester APK unchanged beyond three small PNG placeholders the fake Trakt now serves.

The marquee card rendered as an empty block on Android because its border width reset to an invalid value when artwork arrived and the rounded clip then hid every child; it is set explicitly now. The tab bar lost its icons to release resource shrinking (they are looked up by name at run time) and hid inactive labels in Material's automatic mode; a keep file and labeled mode fix both. Library, Calendar and Search show a styled coming-soon state instead of raw text. The emulator job signs in against the fake Trakt, uploads screenshots of all four tabs in light and dark on every run, asserts all four tab labels, and takes four minutes. Before and after screenshots are on PR #51. The network harness tests now wait on the client's own events instead of polling.

@arun279

arun279 commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Merged: pull request validation in minutes, 468 lines added and 416 deleted (net +52; workflow and script text alone net -20). Sizes unchanged (tester APK 37.93 MB, Play estimate 17.24 MB, Expo bundles 5.01 and 5.04 MB).

A JavaScript-only push now validates in 12 minutes 26 seconds, green, down from 37 to 45 minutes: a fingerprint job hashes the native inputs, the iOS and Android builds are restored from cache when the hash is unchanged, fresh JavaScript is re-embedded into the cached apps, and the simulator and emulator flows run against that. A native change still rebuilds (about 18 minutes measured). The footprint job no longer builds anything (29 seconds). Every check remains a gate. The wrong Node version is refused at install, pre-push and check. The Android emulator sign-in failure was Maestro reusing a host-chosen port that was taken inside the emulator; the port is pinned.

@arun279

arun279 commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Merged: show detail and the episode sheet, 2,775 lines added and 153 deleted (net +2,622: two screens, their components, Jest and render-count tests, two Maestro flows, core domain functions and the undo fix). Sizes against the previous tip: Expo iOS bundle 5.01 to 5.13 MB, Expo Android bundle 5.04 to 5.17 MB, Firebase tester APK 37.93 to 38.57 MB, Play download estimate 17.24 to 17.74 MB. Binary-Size: two native modules the design names arrive with these screens, expo-image and expo-web-browser.

Show detail has the opaque bar, the continue bar in four variants, seasons with Specials last, About, a native overflow menu and the season confirm in its platform shapes. The episode sheet has the mark row, plays badge, spoiler guard, unaired and missing-still variants and a replacing pager; its body does not scroll, because a scroll view under this kind of sheet does not attach reliably, so the still is the elastic element and large text opens the sheet at its tall height. Undo now repaints the episode immediately (this also fixes Up Next's undo). The fake Trakt keeps a device grant pending until approved, as Trakt does, which removes the sign-in flake from every flow. Stills in light, dark and the largest text size and a clip per flow are on PR #43.

@arun279

arun279 commented Sep 19, 2026

Copy link
Copy Markdown
Owner Author

Merged: gates with a basis, 899 lines added and 1,504 deleted (net -605). Sizes unchanged. PR validation 13 minutes 31 seconds.

Gone: the complexity table, comment densities, top-ten dependency tables, most line tables, the simulator app size, every measured-plus-headroom size ceiling and the 64 kB allowance, with the scripts that measured them. The footprint comment now carries net product, test and product-comment lines and the sizes a user receives, base against head. Any size increase needs a Binary-Size line, net product growth a Product-Growth line, added product comments a Comment-Load line, and a missing base fails instead of passing. Startup is the median of five returning-user launches at or under 1,000 ms; tap to visible feedback for mark and undo is the median of five at or under 100 ms; the emulator run fails on any ANR; coverage requires lines changed in the core to be covered instead of percentage floors. Render counts stay gated at zero deviation; render durations are not gated or reported, because Reassure's own verdict flipped between two attempts on identical code on hosted runners (the full output is an artifact). Distributor limits (Play 500 MB base module, Firebase 2,048 MiB) live only in the release workflow.

… one sign-out (#208)

- Search's "Added ✓" and "+ Watchlist" pills draw the platform's check and plus symbols.
- Sign out lives on Profile only; Settings no longer repeats it. Profile shows the Trakt username.
- Library's sort is a bar item beside the avatar: a native bar menu on iOS, a sort icon
  button with a dropdown on Android. BarItems takes the menu and redeclares the bar only
  when the menu's look changes.
- Row titles in Up Next, History, Calendar and Search stop at two lines; detail shows them
  whole and the row's spoken label keeps the full title.
- A filter with no match in the current chip names the other chips it matches in, as
  buttons that switch to them.
- Chevrons and the remaining hand-drawn icons (sort, bell, swipe check and stop, avatar
  placeholder) come from SF Symbols and Material icons through PlatformIcon. A lint rule
  keeps react-native-svg to the Cue mark, and a test rejects typed check and cross glyphs.
- The iOS role override on Toggle's Switch is a registered platform-owned exception judged
  against react-native 0.86.3, so a React Native upgrade reopens it
  (react/react-native#59333, #59334).
… Trakt reads (#209)

* Count episodes left from the next-up episode, and recover from failed Trakt reads

- "N left" counts the aired, unwatched episodes from the next-up episode on, from the
  progress read's season tree, so seasons skipped before it no longer count. Entries and
  show progress carry the count; the persisted cache buster is bumped.
- Whenever Trakt answers a poll or a pull, active reads that failed are fetched again.
- The library load retries each failed member read on its own, with jittered backoff,
  instead of failing and re-reading everything; it publishes the failure it is retrying
  past, and Up Next says "Still trying to reach Trakt" instead of a long skeleton.
- A 429 without Retry-After backs off per read with full jitter rather than pausing every
  read for one second; a write honors Retry-After up to five minutes.
- A read whose token refresh failed on Trakt's side or offline fails that way, not as a
  sign-out; detail screens name the actual failure; a dead refresh token ends the
  session once.

* Cover the library load's retry signal and the poll's teardown guard

- A behaviour test drives the real library load against a Trakt whose hidden list fails
  once: the screen hears it is still trying, with the failure, until the read lands, and
  stops saying so once its own fetch is cancelled.
- A poll whose session ends while a failed read is fetched again never advances the
  activity baseline.
- While the load retries, the status names the failure it is retrying past rather than
  an older one.

* Share the held-read teardown setup across the activities poll tests
… for them (#211)

A Perfetto trace of an Android cold start (perf probe run 39911621692)
shows the first network request loading the system WebView on an OkHttp
thread, from 2388 to 2430 ms, while four other request threads wait
about 35 ms each on it. React Native's NetworkingModule uses
CookieJar.NO_COOKIES only when a request's withCredentials is false.
Otherwise its cookie jar forwards to WebView's CookieManager, and the
first CookieManager.getInstance() loads Chromium. React Native's XHR
defaults withCredentials to true, and whatwg-fetch changes it only for
credentials "include" or "omit", so every fetch with the default
"same-origin" went through that jar.

Trakt authenticates with a bearer token and Cue never reads a cookie, so
the Trakt client, the OAuth calls and the runtime's authorized fetch now
send credentials "omit". The harness perf channel does the same, so a
probe measures the app without it.

The persisted shape witness is reseeded: only a function and two
defaults changed, no type.
…eres (#213)

* Calendar: one row per season drop, and watchlist shows only for premieres

When a show releases several episodes at the same moment, the Calendar
now shows a single row for them, such as "S2 E1–E8 · 8 episodes", and
reads it out as "season 2, episodes 1 to 8, 8 episodes". Episode
reminders and the daily summary read the same rows, so a season drop is
still one notification and the summary names its range.

By default the Calendar and On the way now list every episode of the
shows you have played, but only premieres (episode 1 of a season) of
shows you have never played, such as ones that are only on your
watchlist. Whether you have played a show comes from the library Cue
already loads, so this adds no Trakt requests. A new Settings switch
under Content, "Every watchlist episode", brings back all episodes of
watchlist shows. It is off by default.

* Bump the persisted cache buster for the new calendar row shape

* Calendar: wait for the library before listing episodes

Which episodes the Calendar lists depends on the shows you have played,
which comes from the library. On a first sync the calendar can arrive
before the library, and the screen used to show every episode and then
remove some once the library loaded. It now keeps its loading
placeholders until both have arrived. The library is saved between
launches, so this only affects the first sync. On the way in Up Next
already waits, because Up Next shows it only once the library has
loaded.

* Reminders: still alert for a show's later episode that day

A show's alert for the day was timed to its first episode. If that
episode had already aired when Cue planned its reminders, the whole
alert was skipped, so a later episode the same day got no alert. For
example, with episodes at 10:00 and 21:00 and Cue opened at noon, the
21:00 episode was never announced.

The alert now goes off at the next episode that has not aired yet, and
names only the episodes still to come. When every episode that day has
aired, there is still no alert. The persisted cache buster is bumped
because the reminders module is one of the files it tracks.

* Reminders: one alert per drop, at that drop's air time

A show used to get one alert per day, timed to its first episode and
naming every episode that day. With episodes at 20:00 and 21:00, the
20:00 alert said both were out, which was false for the second one.

Reminders now follow the same grouping as the Calendar rows: one alert
per drop, where a drop is a show's episodes airing at the same moment.
Each alert goes off at its drop's air time and names only that drop's
episodes, so a full season released at once is still one alert. Drops
that have already aired get no alert. The persisted cache buster is
bumped because the reminders module is one of the files it tracks.

* Settings: give the watchlist switch its own Calendar section

The "Every watchlist episode" switch sat right under the note that
explains the TV shows and Movies switches, so it read as part of that
note. It now has its own section, headed Calendar, right after
Notifications, whose alerts are about the same episodes.
… offline (#214)

* Use TanStack Query's online and focus managers, and pause reads while offline

Nothing told TanStack Query when the device went offline. Its onlineManager
only listens for the browser's online and offline window events, which React
Native does not have, and nothing in the app called it. Every read therefore
ran while offline and used up its retries before showing an error, instead of
pausing and resuming on reconnect. focusManager had the same gap for the
background.

- Feed onlineManager from expo-network and focusManager from AppState, as
  TanStack's React Native guide describes. This replaces the Network and
  AppVisibility ports, their native adapters and their providers.
- useIsOffline reads onlineManager, so the offline strip and search share
  one source of truth with the query layer.
- The activities poll is now a query with a 60 second refetchInterval plus
  focus and reconnect refetches. It still flushes pending writes first and
  commits activities only after the invalidated reads succeed. Reconnect
  still flushes pending writes while the app is in the background.
- A read paused offline with nothing cached now counts as loading, so a
  screen shows its skeleton and the offline strip rather than an empty state.
- refetchFailedReads skips reads already being fetched, so a poll that once
  failed cannot cancel and restart itself.

* Tighten network, focus and baseline handling after review

- Subscribe to network changes before reading the launch state, and ignore
  the launch read if a change event has already arrived, so a stale launch
  answer cannot overwrite a newer event.
- Treat the device as offline when either isConnected or
  isInternetReachable is false. A missing or null value still counts as
  online.
- Seed focus from AppState.currentState when the listener is installed, so
  an app launched in the background starts unfocused.
- Flush pending writes from the reconnect listener only in the background.
  In the foreground the poll query already flushes on reconnect.
- Advance the activities baseline only when every active, enabled read it
  invalidated has finished successfully and is idle. A read paused by a
  dropped connection now keeps the baseline, so the next poll sees the
  change again.

* Count any launch state but background as focused, and trace focus on pull

- Seed focusManager with AppState.currentState !== "background". iOS
  launches the app in the inactive state, and React Native reports
  "unknown" for any state it does not map, so an active-only seed could
  leave the app unfocused until a change event arrives. Change events still
  count only "active" as focused.
- The activities poll now requires a runtime, like the other hooks
  RoutedApp calls. It only ever renders under RuntimeBoot, so the
  no-session branch could not run.
- The pull-to-refresh harness trace now records focus and network changes,
  so an end-to-end pull that never starts shows whether either one moved.
* Remove the UI test harness so the tested app behaves like the store app

The Maestro lanes ran a special build: Metro swapped in readiness and timing
markers, a perf command channel, a scroll and pull trace, and an Undo
snackbar that never dismissed itself. The store build carried stand-ins
instead, so the app under test differed from the app users get.

Delete the harness modules, the Metro swap and the Babel plugin that
stripped perf hooks, and keep each call site's store behaviour. Undo
snackbars now always dismiss on their timer, as in the store app. The fixed
test clock (EXPO_PUBLIC_CLOCK_START) stays, because the fake Trakt server's
seed counts from it.

Delete the tests that only exercised harness code and maestro-ids.test.ts,
drop test ids nothing reads any more, and add Jest coverage for two
behaviours only the deleted device flows checked: a show marked from the
lapsed drawer files back into the queue, and the TV-off empty state leads
to Settings.

* Replace the custom device-lane CI with a standard Expo smoke suite

Pull request CI now runs what ordinary Expo apps run: lint, types, Jest and
Vitest in check, plus six Maestro smoke flows on an Android emulator and an
iOS simulator. The flows sign in through the device code against the fake
Trakt server, mark the next episode from Up Next, visit every tab, open a
show and a movie and mark an episode, search, and sign out. Each one asserts
the state its taps produce and takes screenshots of what it visits.

Each e2e job fingerprints the native project with the @expo/fingerprint CLI
and keeps the release build in actions/cache under that hash. On a hit,
@expo/repack-app swaps the commit's JavaScript into the cached build; on a
miss the job builds and saves the cache straight away, so a failing flow
does not throw the build away. The emulator comes from
reactivecircus/android-emulator-runner with its documented AVD cache, the
simulator from xcrun simctl, and Maestro from its official installer at a
pinned version. Nothing retries.

Delete what only served the old lanes: the tap proxy and patched Maestro
runner, lane health and first-attempt checks, the simulator daemon,
spotlight and accessibility tweaks (except disabling apsd and chronod, which
slowed the first launch), the JavaScript re-embed scripts, the pixel diff
and PR media, the perf probe, the native size measurements, the old flows,
and the test/ci tests and config entries for all of them. The footprint job
becomes a report-only PR comment of line counts and JavaScript bundle sizes.
CI can be dispatched by hand, which is how the suite's determinism is
checked.

* Remove the braces audit ignore, which no longer applies

pnpm audit no longer reports GHSA-vfj7-8cjw-p6xm against the locked braces,
so check-stale-audit-ignores fails on any change that touches the lockfile.

* Scroll to Season 1 in the detail smoke flow

Show detail opens with the next episode's season expanded and lists seasons
newest first, so Season 1 starts below the fold and Season 3's episodes sit
above it.

* Keep the detail smoke flow to opening a show and marking an episode

On Android, Maestro did not find Season 1's episode rows after the season was
expanded, although the screen showed them. Expanding a season is covered by
season-row.test.tsx, so the smoke flow no longer scrolls to and expands
Season 1.

* Run the Android smoke flows on the Automated Test Device image

A run failed because Android's launcher (Quickstep) stopped responding over
Cue's welcome screen on a freshly booted emulator. Cue itself had drawn its
first screen in 2.8 s. The ATD system image is Android's image for automated
tests: it removes Launcher3QuickStep, SystemUI and other apps tests do not use
(developer.android.com/studio/test/managed-devices#gmd-atd).
android-emulator-runner tests itself on aosp_atd at API 34.

* Go back to the default emulator image, with four cores

The ATD image captured every Android screenshot blank, because it turns off
hardware rendering. Instead the emulator gets the runner's four vCPUs
(android-emulator-runner's cores input; the default is two), since the
launcher stopped responding on a software-rendered emulator. Repeat runs will
show whether that is enough.

* Disable the emulator's launcher before the Android smoke flows

Two of three runs failed on "Quickstep isn't responding": Android's launcher,
not Cue, stopped responding over Cue's screen. Four emulator cores did not
change that. Android's Automated Test Device image leaves Launcher3QuickStep
out because tests do not need a launcher
(developer.android.com/studio/test/managed-devices#gmd-atd), but that image
also turns off hardware rendering and captured every screenshot blank. So the
default image keeps rendering and the flows disable the launcher package.

* Keep the app running between smoke flows and pin the iOS runtime

In 50 repeat runs, two iOS taps went to SpringBoard's app switcher right after
Maestro stopped and relaunched Cue between flows, so later flows now launch
with stopApp: false and switch to Up Next. The simulator now runs the iOS
runtime matching the selected Xcode SDK; it had picked iOS 26.4. The sign-out
flow stops at the confirmation dialog: the confirm tap was cancelled by the
simulator in one run, and profile.test.tsx covers confirming sign-out. The
Android emulator package is cached with actions/cache
(docs.github.com/actions/reference/workflows-and-actions/dependency-caching),
since one run failed unpacking a fresh download before the emulator started.

* Give the sign-out dialog a 60 s wait in the smoke flow

In one of 50 repeat runs the tap reached Cue and the dialog appeared, but the
simulator's accessibility service stalled for about 33 s, past the 30 s wait.

* Re-run the iOS smoke suite once from a clean state when it fails

After the fixable causes were removed, 50 repeat runs passed 50/50 on Android
and 48/50 on iOS; the remaining iOS failure is the simulator cancelling a
synthesized tap, which has no documented fix (Maestro's iOS tap retry is a
no-op, mobile-dev-inc/Maestro#3685). As EAS Maestro jobs do with retries: 1,
a failed iOS run is repeated once after resetting the fake Trakt server and
reinstalling the app. Both runs' screenshots and logs are uploaded, and a real
defect still fails both. Android stays a single run.

* Swipe to mark on Android, and tighten the Android job's caches

The Android smoke flow now swipes a queue row to mark it, so a gesture library
change that broke or inverted the swipe fails on a device rather than only in
Jest's fake. Gradle's cache is restored only when the native build cache
missed, instead of 1.6 GB on every run, and the emulator package cache is keyed
on the runner image version. Also drops an unused permission, an upload path
nothing reads, a stale comment basis and an unused spelling entry.

* Update zod to 4.6.7 for GHSA-mrhx-c7xc-m3w5

Zod before 4.6.6 can exhaust memory validating a large array against an
uncapped array schema; Cue's core parses Trakt responses with it. A pnpm
override moves every copy to 4.6.6 or later, including Expo CLI's zod 3, which
only uses basic schema builders (string, object, enum, array, literal, union)
in its dev-tools plugin. The braces audit ignore no longer applies and is
removed.
* Add one Trakt fake built on MSW, checked against recorded Trakt

The fake lives in packages/core/test/trakt-fake: the account, its read
bodies, the write endpoints, OAuth sessions, the rate limiter and every
route the app calls, as MSW handlers. Tests can run it in process, and
serve.ts puts the same handlers behind a small node:http server for the
smoke flows.

It follows recordings of real Trakt made on a throwaway account:
refresh rejections answer 400 invalid_grant, a pending device poll
answers 400 with an empty body, 404 and 401 bodies are plain text, pages
default to 100 rows and cap at 250, watchlist writes move only their own
type's activity and answer with the list's item count, a season mark
splits added from updated and echoes a missing season's whole entry, and
removing a movie removes every play of it. Behavior that cannot be
recorded follows Trakt's documentation: 7-day tokens, the device token
statuses, and one authed POST per second and 500 GETs per 5 minutes on
the fake's clock.

recordings.json merges the recorded read shapes, the earlier behavior
transcripts and the new recordings. conformance.test.ts drives the fake
through scenarios.ts and compares statuses, content types, body shapes
and write semantics with them, and fails when a recorded read answers
404.

* Run the core tests against the one Trakt fake

The tests that wrote their own MSW Trakt responses now read the fake's
default account, check what the app sent through the fake's request log,
and get failures from its one-shot helpers: fail, hold and drop. Bodies
that are deliberately off-spec stay as decode tests over a stubbed fetch.

The fake's own internals tests, the request journal, the in-process
server wrapper and the third fake inside read-budget.integration are
gone. That file's cold-sync cases that the fake can express moved into
scenario-budget; the ones about watched rows the fake does not produce
(a restarted show, more plays than aired episodes, an unstamped row, a
broken progress body) are unit tests in read-budget.test.ts. A short
fake.test.ts covers what the fake does from Trakt's documentation: token
lifetime, the device token statuses and the rate limits.

The fake now stamps each play with its own watched_at, can peek at a read
without spending a request, and resets to either Multiple Plays setting.

scenario-budget waits out a rate-limit pause the way a person would. A
heavy library's first sync sends more than the 500 GETs Trakt allows in
five minutes, so with the limit enforced it pauses before it settles;
that scenario was already marked over budget.

TRAKT_API_BASE is no longer exported from oauth.ts and client.ts, since
no test imports it. The persisted cache shape witness is reseeded for
that; no persisted shape changed.

* Serve the fake to the smoke flows and delete scripts/mock-trakt

The smoke jobs start the fake with `node packages/core/test/trakt-fake/serve.ts`,
which Node 22 runs directly by stripping the types. It answers on
127.0.0.1:8787 as before and keeps the two controls the flows and the
iOS re-run use, POST /__approve and POST /__reset. Its artwork points at
three placeholder images it serves itself, so screenshots still show
posters without reaching Trakt's image host.

The old server, its fault, scale and journal modules and its seed are
deleted. `pnpm fake:trakt` replaces `pnpm mock:trakt`, and the README
describes the one fake and how it is checked.

* Allow MSW imports only inside the Trakt fake

oxlint's no-restricted-imports now rejects `msw` and `msw/*` everywhere
except packages/core/test/trakt-fake, so a test cannot grow its own
hand-written Trakt responses again; it has to go through the fake.

* Calendar: tell premieres by Trakt's episode type

For a show you have never played, the Calendar and On the way list only
premieres. A premiere used to mean episode 1 of a season. It now means
an episode Trakt types as series_premiere or season_premiere, which
Trakt sends on every calendar row (seen on a real account). A season
numbered on from the last one, such as an anime whose second season
starts at episode 62, now counts. Episode 1 is still the rule when a row
carries no type.

Calendar entries keep the type, so the persisted cache buster is bumped.

* Write the fake's seed word lists one line each

* Run the progress budget's selection tests through the fake

The fake now models Trakt's "restart show": a show can carry a reset
date, its watched row sends reset_at, and progress counts only plays
after it. A show spec can also override two watched-row fields the play
counter cannot derive, a missing last_watched_at and a different
aired_episodes, so the cases about unusual watched rows read the fake
instead of a stub fetch.

This also restores the case lost when read-budget.integration was
folded: 20 recently watched shows that are caught up, ahead of 80 with
episodes left. The cold sync still spends all 60 progress reads on shows
with episodes left; it fails if the budget is cut before caught-up shows
are filtered out.

The broken progress body case moved to the tolerant decoding tests,
which are the only ones that stub fetch with off-spec bodies.

* Fail read-retry's hidden list through the fake's fault helper

* Restore the request total, entry count and rotated-token checks the migration weakened

* Stamp the fake's activities to the second, with no bump past earlier ones

Real Trakt stamps each activity with the time of the write, cut to the second. The fake used to push every stamp at least 1 ms past the previous one; no test depended on that.

* Keep every device code valid for its own 600 seconds

A new device code used to replace the previous one, so polling an
earlier code answered 404. Each code now stays valid until its own
expires_in runs out on the fake's clock, and approving or denying acts
on the latest code, the one on screen.

fake.test.ts drops the case that only checked the fault helper answered
what it was told.

* Allow only erasable syntax in the fake, and tidy scenarios.ts

The core test tsconfig turns on erasableSyntaxOnly, since serve.ts and
the modules it imports run under Node's type stripping. The scenario
recorder's constructor no longer uses parameter properties, its
pass-through call method is gone, and a stale doc comment is removed.
The file's header no longer claims the scenarios were run against real
Trakt as written.

* Document the fake and where each of its recordings came from

The fake's own README now describes its modules, the rules for tests
that use it (only the tolerant decoding tests stub fetch, with off-spec
bodies), how conformance works, and the provenance of each group in
recordings.json: the date, the throwaway account and the paced,
guarded recorder. The repository README points to it.
* Leave the aired frontier unknown for a backlog show without a progress read

A show past the 60-show progress budget took its last aired episode from the
last episode the user watched. For a show with episodes still to watch that
frontier is behind the real one, so the recent calendar counted episodes
Trakt's aired_episodes already counted, flagged the show as having new
episodes, and the Up Next snapshot read its season tree and specials progress
to place it. On a large library that was two reads for every backlog show
with an episode in the last 33 days, on every launch.

The last watched episode is the last aired one only when nothing is left to
watch. For any other show without a progress read the frontier is now null,
which the calendar reconcile already treats as unknown and leaves alone.
Caught-up shows keep their frontier, so a new season of a show the user
finished is still found and placed.

* Filter calendars by the library's hidden flags

Each calendar window read the hidden and dropped lists itself, so a first sync
read them three times: once for the library and once for each of the two
calendar windows. Stopping a show in the app also re-read both calendar
windows, with their hidden lists, only to filter one show out. The library
already marks every hidden or dropped show, and every show on the user's
calendar is in the library.

The calendar read now returns only its episodes. The Calendar tab, Up Next's
coming-up list, episode reminders and the recently-aired reconcile filter by
the library's hidden flags through a new useHiddenShowIds hook, and stopping
a show no longer invalidates the calendars. A show stopped or dropped on
another device now leaves the calendar as soon as the library reloads,
instead of when the calendar's hour-long cache next refreshes.

* Read the shows watchlist once, through the library

Search's add-to-watchlist button kept its own query over the shows
watchlist, so a first sync read every watchlist page twice: once for the
library and once for that query. The button already treated every library
show as listed, and the library holds every watchlisted show, so the second
read added nothing.

Show membership now comes from the library alone. The movies watchlist query
stays, because the movie library is not loaded until the Library tab asks for
it.

* Share a pooled read with callers still waiting for a slot

The Trakt client joined identical GETs only once a request had been sent.
Every pooled read takes one of six slots before it reaches the client, so
when two screens asked for the browse rails while Trakt was slow, the second
screen's reads that were still queued for a slot found the first ones
finished and sent their own: six requests for four rails.

Identical pooled reads now share one promise from the moment they are asked
for, so a caller waiting for a slot joins the read already queued or in
flight. This replaces the client's own in-flight map, which only covered
reads that had already left.

* Keep library shows' season trees across launches

When the recent calendar shows a new episode of a show the user had caught
up on, Up Next reads that show's season tree to find the next episode. The
trees were not persisted, so every launch read them all again, two requests
each, even when the persisted library and calendar were fresh and nothing
had changed on Trakt.

A season tree is now persisted for a show in the library, by the same rule
as the show art a library card paints. It keeps its one-hour content stale
time, so a launch within the hour reuses it and a later one refreshes it.

* Assert the launch scenarios' read counts and bump the cache buster

The scenario replay now fails if Trakt's rate limit refuses any read, if a
scenario that writes nothing fetches the same URL twice, or if a warm launch
on a fresh cache reads anything beyond the activities poll and the Search
tab's four rails. The calendar hidden-flag change is no longer listed as
pending in the known-over table.

The calendar data no longer carries hidden show ids, so the persisted cache
buster is bumped and older caches are discarded.

* Keep only the movie watchlist query

Show watchlist membership now comes from the library, so the watchlist
query, its runtime loader and Search's add and remove paths only ever
handle movies. They are narrowed to movies, and the unused shows paths
are removed.

* Hold the calendar until the library says which shows are hidden

The calendar filters stopped and dropped shows by the library's flags, but
with "every watchlist episode" on it did not wait for the library, so a
hidden show could show until the library arrived. useCalendarFocus now owns
both sets the calendar needs from the library, played and hidden, and holds
the Calendar on its loading plates until the library answers, whatever that
setting is. If the library read fails, the calendar shows every row
unfiltered, as it already did for played shows.

Up Next's coming-up list takes the hidden set from the same hook, and
reminders take it from the library snapshot they already read, so the
separate useHiddenShowIds hook is gone.

* Test the pooled read sharing directly

Pooled reads are shared only between callers asking for the same thing, a
failure reaches every caller of a shared read, and the next call after it
reads afresh. These replace the client-level coalescing test removed when
the sharing moved into the pool.

* Refresh show trees when watched history changes on another device

A show's season tree, progress and episode details each merge in the user's
per-episode watched state, but an activity change only refetched the
library and history. With season trees now kept across launches, a tree
could show an episode as unwatched after it was watched on another device.
Changes to watched history, progress or hidden shows now invalidate every
show's progress, season tree and episode queries, so the ones on screen are
read again.

Season trees are now persisted only while a screen uses them: Up Next
placing a card, or a detail screen that is open. A detail visit no longer
adds its tree to the persisted cache for good.

The replay gains two scenarios. One opens a show, writes a play for it
straight to the fake as another device would, relaunches warm, and checks
that the tree is read again and shows the episode watched. The other checks
that a backlog show past the progress budget stays out of Up Next even when
an episode aired lately, and that Library counts its episodes left. The
cache witness is reseeded; no persisted shape changed.

* Share the calendar's held-library test steps

The two tests that hold the library read and check the Calendar waits for it
now share one helper instead of repeating the same steps.
Remove repository scripts and tests that reimplemented checks already
provided by pnpm, oxlint, actions/cache, TanStack Query and Reassure:

- The changed-coverage gate and its Coverage-Rationale escape. Nothing
  else read the coverage report, so Vitest no longer collects it.
- write-buster.mjs and the generated persist-buster.ts. The persisted
  query cache is now busted by the running expo-updates update id, or
  the native version and build number when updates are off, so each
  build or update starts with one cold sync.
- check-node-version.mjs. engineStrict makes pnpm install and pnpm run
  fail under any Node other than engines.node.
- jest-cache-key.mjs, replaced by a hashFiles() cache key.
- check-quality-budget.mjs, replaced by oxlint ban-ts-comment and
  no-warning-comments over suppression directives.
- The Vitest stall guard, the IPC, git-env, local Jest worker, spell
  coverage and executable-script tests. Test git processes now run
  without the hook's GIT_* variables, stripped in the Vitest config.
  The job timeout table is now a check that every job sets one.
- check-native-assets.mjs and .native-assets.json.
- The render-count gate, its baselines, its base-tree checkout and the
  use-tree-node action. The Reassure tests assert each scenario's
  render count themselves.
… Search's rails on first visit (#221)

* Refresh a show's watched state only when its library entry changed

Since season trees are kept across launches, any change to watched history
or progress invalidated every show's progress, season tree and episode
queries, so the trees Up Next places cards with were read again. That
included changes this device made itself: after a mark and its undo, the next
poll saw the activity stamp move and re-read every tree on screen, 80 reads
for the large test library (89 GETs became 169).

Trakt stamps activity to the second, so the stamp cannot tell this device's
write from another device's write in the same second. The library reload the
poll already does can: each show's count of episodes watched and its last
play are compared with the cached entry, which already carries the app's own
optimistic marks and undos. Only shows that differ have their progress, tree
and episode queries refreshed. A play this device sent matches what Trakt
returns once both are read to the minute, which is how Trakt stores play
times. A change to the hidden list still refreshes every show, because a
hidden season changes a show's tree without changing its library entry.

The fake now moves a show's last watched time when a second play is added
with Multiple Plays on, as Trakt's watched list does.

Tests replay a mark, an undo and the next poll with exact reads per route,
and check that an open show's tree is read again when another app writes it
in the same second as this device marks a different show, when another app
writes the show this device just marked before the next poll, and when
another app adds a second play, and that it is not read again after this
device's own mark. The cache witness is reseeded; no
persisted shape changed.

* Load the Search tab's browse rails on its first visit

The Search tab mounts as soon as the opening tab settles, and its browse
rails were read on mount, so every warm launch spent four reads on the
trending and popular rails whether or not Search was opened. A warm launch
with a fresh cache made five reads instead of only the activities poll.

The rails query is now enabled once the Search screen is first focused,
through expo-router's useFocusEffect, and stays enabled after that, so
leaving Search and coming back does not read them again. Until the rails
arrive, including the moment before the first focus registers, Search
shows its existing browse skeleton rather than empty space.

The scenario replay's stand-in for the tab roots no longer reads the rails,
and a warm launch now asserts exactly one read: the activities poll. A
native test checks that the rails wait for the first visit, show the
skeleton while they load and are not read again on the next visit; the tab
switching test allows Search to render once, on that first visit.

* Let the library query refresh the shows a reload finds changed

The rule that keeps query-invalidation.ts behind library-cache.ts exists so a
new mark surface cannot refresh a show's detail reads without also moving its
Up Next row. The library query reaches the same module for the opposite job:
after a reload it refreshes the detail reads of each show whose entry
changed. It is now allowed alongside library-cache.ts.

* Match the Trakt fake to recorded plays and last play times

A recording of real Trakt with Multiple Plays on settled how a show's watched
row and progress move when an episode has several plays:

- a play dated now moves last_watched_at forward, a backdated one does not,
  and plays goes up either way: last_watched_at is the newest play by date
- removing the newest play by history id rolls last_watched_at back to the
  previous play, and plays drops
- an unaired special's play counts in plays but never moves last_watched_at,
  and progress ignores it
- an aired special that is the show's only play sets last_watched_at, and
  removing it takes the show off the watched list

The fake now keeps every play of an episode rather than at most two, counts
them all in the show's plays, and recomputes the show's last play after each
write. Two scenarios repeat the recorded steps and their transcripts are in
recordings.json. A shelf show with specials now has twelve, so some of them
have not aired yet. The README notes the provenance and the two cases still
unrecorded.

* Say browsing needs a connection when Search first opens offline

With the rails loading on the first visit, opening Search for the first time
while offline paused the rails read with nothing cached, and the browse area
showed its skeleton for as long as the device stayed offline. It now shows
the same "Search needs a connection." state a query shows offline.

The tab switching test now requires exactly one render, from Search, on the
first round of visits. Search sets its opened flag only while it is unset:
setting the same state again on a later visit still rendered the screen once
more.

* Compare libraries after each committed reload, and compare plays too

The per-show comparison ran inside the library query's fetch. It also ran
for a fetch that a mark then cancelled, and it compared server rows read
before a write landed with the cache's optimistic state, so a show could be
refreshed while its own write was still on the way. During an undo that
re-read the tree before the removal reached Trakt and showed the episode
watched again.

The comparison now lives in a QueryCache subscriber in the runtime, mounted
with the activities poll. It reacts only to a fetched, committed result of
the library query and compares it with the library it replaces. Shows named
by a queued or in-flight write, directly or through an episode in a cached
tree or episode page, are left to that write's own refresh. The dependency
rule exception for the library query is gone.

Each library entry now carries the show's plays from the watched list, and
the comparison uses plays alongside the count of episodes watched and the
last play. Real Trakt keeps last_watched_at at the newest play by date, so a
backdated play, a second play in the same minute, or removing one of several
plays changes only plays. A mark from Up Next adds one play and its undo
restores the entry it replaced. After any of this device's own writes lands,
the refreshed progress now also sets the entry's last play, and its plays
become unknown until the library next loads, because the app does not track
how many plays its own write added or removed. Plays are compared only when
both sides know them.

Tests: a reload that lands during an undo's write leaves the tree and its
checkmark alone; marking and undoing from Up Next, or toggling an episode on
the show's page, re-reads no tree at the next poll; another app's backdated
play, same-minute play and removal of one of several plays each refresh the
tree. Unit tests cover the subscriber (manual edits and cancelled reloads
ignored, written shows skipped) and the comparison.

* Share the mark, undo and poll steps of the season tree tests

The season tree tests repeated the same setup: mark a queued show from Up Next with its page open, press undo, and count the tree reads of the next poll. They now share three helpers, which jscpd asked for.
@github-actions

Copy link
Copy Markdown

Pull request footprint

measurement base to head
Product code lines in core/src, native/src, native/app, and native/modules +18404
Test lines in test, packages/*/{test,__tests__,e2e}, and .maestro +20624
CI and tooling lines in .github and scripts -288
Product comment lines identified by a comment prefix +200
JavaScript bundle at head size
Expo ios bundle 5629 kB
Expo android bundle 5667 kB

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants