dev #22

Merged
yami merged 21 commits from dev into main 2026-08-11 13:15:28 -04:00
Owner
No description provided.
yami added 21 commits 2026-08-11 13:15:21 -04:00
Loot Raid Plan update
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 2m45s
69faa7058c
refactor: scope loot roadmap overrides by tier to prevent stale state contamination
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 47s
9755938b9e
Measured against the deployed asset set:

  Material Symbols (full variable font)   1,126,168 B
  Material Symbols (68 icons in use)         20,668 B   -98%
  job icons, 21x 256px PNG                  599,890 B
  job icons, 21x 64px WebP                   42,054 B   -93%
  guide screenshots                       3,154,956 B
  guide screenshots as WebP                 645,088 B   -80%
  og-image.jpg                              373,769 B
  og-image.jpg recompressed                  40,000 B   -89%

The icon font alone was roughly ten times the size of the entire brotli'd
JS and CSS of the app. Requesting it via icon_names= cuts it to the 68
icons actually rendered.

That subset is a footgun: an icon used in src/ but missing from the list
renders as its literal name ("settings") rather than a glyph, silently.
scripts/check-icon-subset.js fails with the missing names; it is wired
into npm run check separately.

Job icons were hot-linked to raw.githubusercontent.com, costing a DNS
lookup and TLS handshake to a third-party origin for images above the fold
on every roster, and shipping 256px sources for something rendered at
20-32 CSS px. Now served from our own origin at 64px.

og-image.jpg keeps its 1024x1024 dimensions; only the encoding changed,
since its aspect ratio is a design decision rather than a size problem.

Also adds a preconnect to xivapi.com, which serves gear and materia icons
on nearly every roster view.

Two guide assets have no references anywhere in src and were carried over
as-is: static/guides/recording-gear-from-book/ duplicates
recording-gear-from-a-book/, and 04-resolution sits beside the referenced
04-resolutions. Both look like leftovers from a rename.
The sidebar fetched the saved-static uuid list and then called getStatic()
once per entry, pulling a full StaticDTO (every player, every gear set, the
whole gear option catalog per slot) to read a name and a tier. It now uses
the new GET api/User/SavedStaticSummaries: one small response instead of
1 + N heavy ones. The saved-state checks on both the layout and the page
read from that same cached call, so the separate uuid-list request is gone
from the statics route entirely.

getSavedStaticSummaries falls back to the old uuid-list-then-getStatic path
on HTTP 404, so this can deploy before or after the API without the sidebar
going blank. Deploying the API first is still preferable.

@microsoft/signalr (55,943 B raw / 12,801 B brotli) was a static import of
signalr.service, which put it in the critical bundle of every /statics route
even though nothing renders from it. It now loads on the first start() call,
leaving a 732 B wrapper in the route. Because listener registration is
synchronous and callers subscribe in onMount, before the module resolves,
handlers are buffered and replayed onto the connection once it exists.
start() also now treats Connecting and Reconnecting as no-ops rather than
only Connected, since starting twice throws.

loadStaticPage awaited getUserSavedStatics before its Promise.all despite
nothing depending on it, adding a serial round-trip ahead of the request
that actually renders the page. Folded into the batch.

getTiers is now cached for 30 minutes; it was refetched on every static page
load for data that changes once a patch.

getCachedOrFetch ran loader.toString(), a regex over the function source and
a URL construction on every cache miss, assigned the result to an unused
variable and threw it away.
chore(deps): drop unused dependencies, wire icon-subset check into check
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 41s
5666119e7d
@lucide/svelte had zero imports; only lucide-svelte/icons/* deep paths are
used, so the two icon packages were not both in play. @sveltejs/adapter-auto
was likewise unused alongside adapter-node.

npm run check now also runs scripts/check-icon-subset.js, which fails when
an icon used in src/ is missing from the icon_names= list in app.html.
check:icons runs it on its own.

Three further dependencies have no imports in src/ and were left in place
rather than removed: @tanstack/table-core, clsx and zod. CODEBASE_OVERVIEW.md
still lists zod as the validation library, so either the doc is stale or
something is mid-migration.
fix(assets): add icon names the subset scrape missed
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 40s
e5b0644c0c
The Share button rendered the word "share" at icon size instead of the
glyph. Its name never reached the icon_names= list because the extractor
only recognised icon names written literally in markup:

  <span class="material-symbols-outlined text-sm">{shareCopied ? 'check' : 'share'}</span>

Names also arrive through expressions like that one, and through helper
functions such as statusIcon() in src/lib/utils/gear.ts. Both forms were
skipped silently, so the guard reported a clean run while six icons were
broken: share, expand_less, event_busy, shopping_bag, swords and
radio_button_unchecked. Only Share had been noticed.

check-icon-subset.js now harvests quoted string literals out of icon
expressions, reads helper-supplied names from scripts/icons.extra.json,
and reports every expression it cannot resolve statically so a new helper
surfaces instead of shipping a broken glyph. `--list` prints the canonical
list to paste into app.html, replacing the grep in the comment there that
had the same blind spot as the original extractor.

Subset is now 74 icons, 23,072 B against 1,126,168 B for the full font.
Merge branch 'dev' of https://git.shiraki.ca/yami/xivloot-rewrite into dev
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 40s
9f0bacb534
Pairs with the API change that moves gear options out of StaticDTO.

getStatic and getSingletonPlayerInfo now request includeGearOptions=false,
and options come from the new cacheable GET api/Gear/Options/{tier}/{job}.
The roster page fetches one set per distinct job on the roster rather than
receiving a copy inside every player; players sharing a job share one
options object.

An API predating the split ignores the flag and still inlines the options,
so both pages prefer an inline copy when present and only fetch when it is
absent. That keeps the frontend deployable before or after the API.

The slot-renaming and mapping logic the two pages had duplicated is now
normalizeGearOptions in $lib/utils/gear, shared by the inline and fetched
paths. The member page keeps seeding every slot with an empty array, since
callers index by slot and expect an array rather than undefined.

Measured on an 8-job party with a realistic tier catalog, the static payload
drops from 151,498 B raw / 6,613 B brotli to 25,722 B / 811 B. That applies
to every realtime refetch, which on a raid night is one per recorded drop.
The API now sends no-cache for gear options, so the browser revalidates
every request and an admin gear edit is visible immediately. A 30 minute
in-memory cache in front of that would have quietly reinstated the staleness
window it was just removed for, since a hit here skips revalidation entirely.

Cut to 60 seconds. That still collapses the burst a single page load makes —
one request per distinct job on the roster — and rapid back-and-forth
navigation, without caching across a session. Beyond that window the cost of
being fresh is a bodyless 304, not the full catalog.
perf(statics): render the list page from summaries, not full statics
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 40s
e2194ea9ec
The page fetched the saved-static uuid list and then a full StaticDTO per
entry, plus another per recently-visited static, to show counts and role
pucks. It now makes two requests total regardless of how many statics a user
has: getSavedStaticSummaries for the saved ones and getStaticSummaries for
recents, which live in localStorage and so are not covered by the saved
endpoint.

Against the small test fixture a full StaticDTO is 10,434 B per static while
a summary is ~200 B; with a realistic tier gear catalog the full payload
measured 151,498 B. Summary size grows with roster size rather than with the
gear catalog, so a full party lands nearer 600 B. The request-count drop
from 1+N+M to 2 matters as much as the bytes.

StaticSummary now carries memberCount, altCount, openClaimCount, the non-alt
roster slots (id, name, job) and every player id. The recents filter tests
playerIds against the local claimed-player set, exactly as it did against
playersInfoList before.

summaryFromStaticInfo mirrors the server's StaticSummaryBuilder for the
fallback path, so a frontend deployed ahead of the API still renders the page
from full statics rather than showing empty counts.
feat: Static leader availability reset FE
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 39s
688d8dc934
feat: Added ability for users to see which jobs are available at each timeslot
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 39s
3c5adf70f3
DropRecordModal (~890 lines) and PlayerDetailModal (~1330 lines) were static
imports, so Rollup compiled both into the roster route's chunk and every
roster load downloaded and parsed them — despite neither rendering until an
explicit user action. PlayerDetailModal was inlined into the member-detail
route's chunk as well, so it shipped twice.

Eager weight of the roster route, transitively over static imports:

  before   32 modules   121,504 B brotli
  after    33 modules   104,150 B brotli

The two modals become their own chunks (10,114 B and 6,762 B brotli),
fetched on demand.

They are gated on having loaded, not on being open. Both already wrap their
entire markup in {#if open}, so mounting them early renders nothing, and
keeping them mounted preserves script state across open/close — a
half-finished set of drop assignments still survives closing the modal,
which gating on `open` would have silently discarded.

Both are also warmed on requestIdleCallback after mount, so the first click
does not wait on a fetch; the effects covering dropModalOpen/editingPlayer
handle a user who gets there first. A failed import clears its guard so a
later attempt retries rather than leaving the button dead.
None of the three is imported anywhere in src/. They were declared direct
dependencies but never used, so this only affects install and build time —
the shipped bundle was already free of them via tree-shaking.

clsx stays in node_modules regardless: svelte itself depends on it. Our
declaration was redundant, not load-bearing.

CODEBASE_OVERVIEW.md claimed "Validation via zod", which was the reason for
leaving these in place earlier. Corrected, and the frontend summary now also
notes the dynamic SignalR import and the Material Symbols subset, both of
which post-date it.
perf(statics): server-render the statics routes
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 39s
9b60930244
The statics routes opted out of SSR, so the sequence was: empty shell,
download JS, hydrate, mount, run an effect, and only then issue the first
API call. The roster payload now arrives with the HTML.

Verified against a local stack (Postgres + API + adapter-node build), not
just a typecheck:

  before  server HTML contained no roster at all
  after   static name, every player and job present in the response

What moved server-side:
- +layout.server.ts resolves the signed-in user, mirroring both branches of
  authStore.init() (Discord JWT, then Identity) and fetching claimed players.
- statics/[staticId]/+page.server.ts loads the roster and owner info.
- lib/server/api.ts forwards the request's Cookie header. SvelteKit's
  event.fetch only attaches cookies same-host, and the API is a sibling host,
  so a plain fetch would have gone out unauthenticated.

Everything degrades to null rather than throwing: a failed server-side call
costs a slower first paint, not a broken route. The page keeps its client
loader for realtime refetches, and primeStatic seeds the HTTP cache with the
payload the server already fetched so navigation does not request it twice.

SECURITY — the reason this needed a real stack to build safely:

authStore is a module-level singleton, so on the server one instance is
shared by every request in the process. Hydrating it during SSR wrote the
first visitor's user into module state and `initialized` short-circuited
every later call, so a logged-out request was served the previous user's
account menu, email included. Caught by requesting the same page with and
without a cookie and diffing.

hydrate() is therefore browser-only. The server renders the signed-out shell
exactly as before, and the client adopts the session on mount — from data
already in hand rather than the two-to-four probe requests init() used to
make. Rendering the signed-in shell server-side needs per-request state
(reading page.data in components) instead of this singleton; that is a
separate change.

Verified: interleaved cookie/no-cookie requests stay isolated, all statics
routes return 200, no server-side errors, roster renders for both.
docs(scheduling): rewrite the API reference against what is actually built
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 38s
7397ace659
The doc opened with "none of the endpoints below exist on the backend yet"
and read as a build order. That has not been true for some time: six
entities, sixteen endpoints, both new fields, and the SignalR broadcasts all
exist and work. Following it as written would have meant reimplementing
working code.

Rewritten against the backend as verified, not as remembered:

- New status section up top, splitting shipped from genuinely outstanding.
- §1 fields marked shipped, with a note on how ClaimedByAccountId is
  actually resolved (via StaticMemberships, not a scan of every account).
- §3 notes the migration that created the tables, and corrects the claim
  that SessionCategory CRUD was unbuilt — it exists.
- §4 records that all sixteen routes are implemented. Endpoint count
  corrected from 15; there are 6 + 6 + 2 + 2.
- §5 documents that the broadcasts fire, and flags that the payload arity
  does not match the handler signatures in signalr.service.ts. Harmless only
  because every handler ignores its arguments and refetches.
- §6 is now the only outstanding work: the Discord bot OAuth callback and
  the reminder dispatch job. Both are genuinely missing.
- §7 checklist reflects reality, and records that backend test coverage is
  ResetStaticAvailability alone.

ResetStaticAvailability's entry now documents the owner-only rule, the
empty-ownerIdString rejection, and the null broadcast payload, plus why it
went unnoticed: the frontend's fire-and-forget writes hide a missing
endpoint completely.

docs/http-api-scheduling.html is a separate rendered copy and still carries
the old framing. Flagged in the doc rather than silently edited — it should
be regenerated or deleted.
feat: removed yellow highlight when dragging players in loot roadmap
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 40s
d42502ca0f
feat: tie loot roadmap to tier loot breakdown order
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 41s
884a3c0ec8
removed discord option when adding new session
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 39s
695723966f
fix(auth): let init() re-probe when the server could not resolve the user
All checks were successful
Docker Build & Push / build-and-push (push) Successful in 42s
fe19837126
hydrate() set `initialized = true` unconditionally, including when the
server-side load returned a null user. init() bails on `initialized`, so
that combination stranded a signed-in user in a signed-out UI with nothing
left to re-probe — a full reload would fail the same way.

A null user from +layout.server.ts is ambiguous: it means either "genuinely
signed out" or "the server could not reach the API to find out". Only the
first is safe to act on, and they are indistinguishable at that point, so
hydrate() now no-ops on null and lets init() probe as it always did.

Costs an anonymous visitor the two cheap probes this was avoiding, in
exchange for a server-side resolution failure being self-correcting rather
than sticky. The signed-in path is unchanged and still skips them.

Verified against a local stack: with the API stopped mid-SSR the page still
returns 200 with data.user null rather than 500ing; with it running the user
resolves server-side, the roster renders, and a cookie-less request still
sees no trace of the signed-in account.
yami merged commit d1792be7f0 into main 2026-08-11 13:15:28 -04:00
Sign in to join this conversation.
No reviewers
No labels
No milestone
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
yami/xivloot-rewrite!22
No description provided.