Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
115 commits
Select commit Hold shift + click to select a range
037e436
fix(storage): 🐛 never show another account's data while a new scope l…
8liam Nov 8, 2026
a9e9d9b
fix(ui): 🩹 swap endless skeletons for retryable load errors
8liam Nov 8, 2026
5119e9a
fix(accounts): 🐛 close re-auth on success and show every account's st…
8liam Nov 8, 2026
91cd598
fix(items): 🐛 reset filters per account and only move visible rows
8liam Nov 8, 2026
315ecc1
fix(item-cell): 🩹 keep highlight chips on one line so rows stop overl…
8liam Nov 8, 2026
e031980
feat(pages): ✨ name the account on single-account pages and dialogs
8liam Nov 8, 2026
d2b2923
fix(organiser): 🐛 keep organiser rules per account
8liam Nov 8, 2026
b0202b0
fix(refresh): 🔧 refresh connected accounts with busy, scope and errors
8liam Nov 8, 2026
75a7ded
refactor(tradeup): ♻️ extract finder controls and results onto shared…
8liam Nov 8, 2026
9372587
refactor(banners): ♻️ move banner prop types into their .types.ts files
8liam Nov 8, 2026
ccaac03
fix(tradeup): 🩹 show outcome odds as unsigned chances
8liam Nov 8, 2026
add76f7
fix(items): 🩹 drop the native search clear glyph from the filter box
8liam Nov 8, 2026
afbc77e
feat(terminals): ✨ name the account when confirming an activation
8liam Nov 8, 2026
8b636df
fix(item-grid): 🩹 show one highlight chip plus +N on grid cards
8liam Nov 8, 2026
0eeb083
fix(accounts): 🔧 offer Reconnect and Sign in again right in the parti…
8liam Nov 8, 2026
f273057
fix(a11y): 🩹 read the chosen account's status from the switcher trigger
8liam Nov 8, 2026
2ba7035
docs(pr): 📝 add before/after screenshots of the multi-account UI fixes
8liam Nov 8, 2026
f2735bd
fix(item-highlights): 🩹 drop chip icons in narrow rows to leave room …
8liam Nov 8, 2026
de65daf
fix(site): 🚑️ stub useAppSnapshot so the demo builds with the new com…
8liam Nov 8, 2026
10340aa
refactor(accounts): ♻️ split pure naming helpers from the scope hooks
8liam Nov 8, 2026
4098b37
fix(select): 🩹 close the list on Tab and when focus leaves
8liam Nov 8, 2026
99a563d
fix(accounts): 🩹 keep the SteamID tail whole and stop doubling it for…
8liam Nov 8, 2026
b1020ab
fix(items): 🧩 name each row's owner with its SteamID tail, on phones too
8liam Nov 8, 2026
cc20377
fix(copy): 🩹 say 1 item and 1 row with a shared plural helper
8liam Nov 8, 2026
00381b2
fix(item-table): 🩹 floor column widths and hide Type, then Location, …
8liam Nov 8, 2026
b234f84
fix(a11y): 🩹 hide the decorative table skeleton from assistive tech
8liam Nov 8, 2026
cd99f13
feat(items): ✨ open every highlight from the tier badge, and fit phon…
8liam Nov 8, 2026
3a2acf1
fix(storage): 🚑️ flag stale data and dropped streams with a Retry banner
8liam Nov 8, 2026
890d08e
fix(organiser): 🩹 say 'add a rule' or 'nothing to move' instead of bl…
8liam Nov 8, 2026
a5a6d3f
fix(transfer-bar): 🩹 give Move the same primary blue as every other a…
8liam Nov 8, 2026
6b93547
refactor(transfer-bar): ♻️ move its last inline labels into config
8liam Nov 8, 2026
2a8af57
fix(account-gate): 🩹 darken the Open settings link past 4.5:1 contrast
8liam Nov 8, 2026
2d35434
fix(account-gate): 🩹 keep the page heading and explain why each page …
8liam Nov 8, 2026
2a4e480
fix(items): 🩹 give phone taps 36px+ targets on export, view and grid …
8liam Nov 8, 2026
67f3db0
fix(tradeup): 🩹 give the empty finder and calculator a headline and a…
8liam Nov 8, 2026
54faa5e
fix(a11y): 🩹 make the sign-in dialog title an h2, keeping h1 on first…
8liam Nov 8, 2026
88686fd
refactor(item-collection): ♻️ map each view mode to its renderer in c…
8liam Nov 8, 2026
a35a835
refactor(auth-gate): ♻️ list the first-load problem states in config
8liam Nov 8, 2026
34394f1
refactor(ui): ♻️ move inline classes of four components into config f…
8liam Nov 8, 2026
ebfc258
fix(storage): 🩹 read the shown data from an effect and start the bann…
8liam Nov 8, 2026
4f7ae85
fix(theme): 🩹 darken light muted text so it clears 4.5:1 on hovered rows
8liam Nov 8, 2026
8f8e301
fix(organiser): 🩹 keep the Run hint short instead of echoing the dry run
8liam Nov 8, 2026
f0d3d07
docs(pr): 📝 add round 2 screenshots at 960, 1100, 1440 and 390
8liam Nov 8, 2026
7cbaccd
fix(item-table): 🩹 start the wide layout at 960px and leave scrollbar…
8liam Nov 8, 2026
3f876c2
fix(highlight-disclosure): 🩹 stretch the phone tap area without growi…
8liam Nov 8, 2026
9d9d8a4
docs(pr): 📝 refresh round 2 screenshots and add the 768px view
8liam Nov 8, 2026
345cfbc
refactor(items): ♻️ share one item sort list between grid and table
8liam Nov 8, 2026
37b697e
fix(data-table): 🩹 pick columns from the table's own width, not the w…
8liam Nov 8, 2026
b264ed1
fix(shell): 🩹 let Settings scroll and main scroll what can't shrink
8liam Nov 8, 2026
1cf3a9a
fix(storage): 🩹 stack units above contents until the page fits both
8liam Nov 8, 2026
b2c1b8a
fix(storage): 🩹 show Reload units failures with a retry instead of dr…
8liam Nov 8, 2026
3bb3eca
fix(account-name): 🩹 drop the SteamID tail's fade so it clears 4.5:1
8liam Nov 8, 2026
63a450e
fix(account-row): 🩹 keep label focus while saving and lay rows out ev…
8liam Nov 8, 2026
93d8563
fix(accounts-panel): 🩹 move focus to Add account once a removed row i…
8liam Nov 8, 2026
60a3b4d
fix(highlight-disclosure): 🩹 keep the panel in tab order and clear of…
8liam Nov 8, 2026
6bb746d
fix(tiers-help): 🩹 give the help button a 36px touch target and narro…
8liam Nov 8, 2026
e58aa22
fix(a11y): 🩹 move focus somewhere stable when the focused control van…
8liam Nov 8, 2026
3cab2c6
fix(organiser): 🩹 say when no items match and fix the heading levels
8liam Nov 8, 2026
a441544
fix(button): 🩹 darken solid buttons on hover and give destructive a w…
8liam Nov 8, 2026
79256a2
fix(item-table): 🩹 fit item names to the row layout, not the viewport
8liam Nov 8, 2026
785773f
docs(pr): 📝 add round 3 screenshots for Storage, Settings and resizing
8liam Nov 8, 2026
11c8a1c
fix(ui): ♿ make populated views readable at compact widths
8liam Nov 8, 2026
12fb5d4
docs(pr): 📝 show populated and compact UI after round 4
8liam Nov 8, 2026
7120e36
fix(dashboard): 🩹 fit the hero and chart axis to the card at tablet w…
8liam Nov 8, 2026
d7c7a75
fix(tradeup): ♿ scroll the page as one region and focus the finder list
8liam Nov 8, 2026
7a00529
docs(pr): 📝 add round 5 Dashboard and Trade-up screenshots
8liam Nov 8, 2026
c37c3bf
Fix account removal focus and item grid semantics
8liam Nov 8, 2026
1cebb01
Size item grid rows for highlight chips
8liam Nov 8, 2026
5598853
Keep removal focus and clarify terminal expiry
8liam Nov 8, 2026
4563620
Show full grid cards and fit trade-up results on phones
8liam Nov 8, 2026
cf0344b
Show trade-up odds and disconnected page titles
8liam Nov 8, 2026
cc6f858
Keep contract names and account recovery focus visible
8liam Nov 8, 2026
63d4b94
Preserve account focus and fit narrow layouts
8liam Nov 8, 2026
0b6c97c
Show complete trade-up outcomes and compact sort direction
8liam Nov 8, 2026
b767f56
Fit holdings and item names on phones
8liam Nov 8, 2026
853d382
feat(cases): show rarity streaks and compare case drop odds
8liam Nov 8, 2026
4cec02d
fix(cases): clarify odds and expose monthly counts
8liam Nov 8, 2026
5077a9d
fix(cases): qualify rarity streaks and small samples
8liam Nov 8, 2026
31b787d
fix(cases): 🩹 show sync request pending and failure feedback
8liam Nov 8, 2026
e75b1f7
fix(cases): 🐛 use the disclosure's published odds precision and tidy …
8liam Nov 8, 2026
cc18dc5
fix(site): 🩹 match the cases demo to the app and fix demo contrast
8liam Nov 8, 2026
15dffc4
docs(pr): 📝 add cases and sync-error screenshots
8liam Nov 8, 2026
ce6e971
fix(cases): 🩹 keep Sync history focusable while busy and announce it
8liam Nov 8, 2026
e7fe196
fix(a11y): 🩹 return focus to the page heading after a successful retry
8liam Nov 8, 2026
82ae1a7
fix(items): 🩹 unmount the highlight panel before refocusing its button
8liam Nov 8, 2026
f019379
feat(cases): ✨ pair odds bars on one track and explain Up to streaks
8liam Nov 8, 2026
d33bf62
docs(pr): 📝 add round 4 cases, sync-pending and retry-focus screenshots
8liam Nov 8, 2026
41290ae
fix(cases): 🐛 add an unknown-rarity slice so the donut matches the total
8liam Nov 8, 2026
f9f1bc4
fix(load-error): 🩹 keep the error mounted while retrying so focus stays
8liam Nov 8, 2026
1390322
fix(account): 🩹 show the account chip on Items and Dashboard
8liam Nov 8, 2026
e1d2b11
fix(cases): 🩹 pluralise pulls and drop false precision on small samples
8liam Nov 8, 2026
70d46b6
feat(settings): ✨ lay out accounts and preferences in two columns whe…
8liam Nov 8, 2026
ecfc6b8
fix(items): 🩹 style the Terminal Activate action as a neutral link
8liam Nov 8, 2026
7b86a2f
refactor(ui): ♻️ move page-header and sync-banner classes into config
8liam Nov 8, 2026
1c4bc9e
fix(settings): 🩹 widen the layout and give accounts more room
8liam Nov 8, 2026
9c1dd7a
test(copy): 🧪 cover pull and opening pluralisation
8liam Nov 8, 2026
01af116
docs(pr): 📝 add round 5 settings, cases donut, chip and retry screens…
8liam Nov 8, 2026
12a551c
feat(items): 🧩 show location and float on compact rows, drop duplicat…
8liam Nov 8, 2026
ac8dee8
fix(shell): 🩹 move transfer banner below the page, keep focus on the …
8liam Nov 8, 2026
ca82836
feat(storage): 🧩 fade the unit strip edge and add a +N more cue
8liam Nov 8, 2026
7ae864f
fix(copy): 🩹 unify nav labels, honest partial-history copy and All ti…
8liam Nov 8, 2026
80639ac
fix(a11y): 🩹 separate bar list label, count, value and share
8liam Nov 8, 2026
938b7c7
fix(cases): 🩹 cap monthly bar width, show counts and align lower pane…
8liam Nov 8, 2026
4d91a00
fix(settings): 🩹 align the layout to the shared page width
8liam Nov 8, 2026
d46fad3
refactor(conventions): ♻️ move inline classes into config files
8liam Nov 8, 2026
fac856e
fix(terminals): 🩹 make Activate a button and spell out trade-up rarity
8liam Nov 8, 2026
5c79217
feat(shell): 🧩 add a root error page and cap the organiser value input
8liam Nov 8, 2026
ab907e6
fix(site): 🩹 drop the tradeup slot label patch the app now ships
8liam Nov 8, 2026
eef4271
refactor(items): ♻️ extract the compact detail join and cover it with…
8liam Nov 8, 2026
dd0c5b5
docs(pr): 📝 add round 6 compact row, strip cue, banner, settings and …
8liam Nov 8, 2026
cf3bbe7
fix(storage): 🩹 move focus to the revealed unit when +N more is pressed
8liam Nov 8, 2026
271fbb7
fix(items): 🩹 fold the pattern into the compact detail line
8liam Nov 8, 2026
c6bb5f1
fix(cases): 🩹 label dense monthly bars with the month number
8liam Nov 8, 2026
a12f316
chore(routes): 🧹 restore the generated route tree
8liam Nov 8, 2026
076e48e
docs(pr): 📝 refresh round 6 screenshots with patterns and dense rows
8liam Nov 8, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
Binary file added docs/pr-screenshots/after/01-switcher-open.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/02-items-all.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/04-reauth-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/05-settings-errored.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/06-add-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/07-remove-dialog.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/08-tradeup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/10-items-single.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/11-hidden-selection.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/12-mobile-items.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/13-light-highlights.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/14-reauth-qr.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/15-reauth-success.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/16-tradeup-finder.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/17-disconnected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/18-mobile-storage.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/18b-mobile-tradeup.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/19-help-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/20-filtered-empty.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/21-choose-account.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/22-organiser.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/23-grid.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/after/24-grid-mobile.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/pr-screenshots/before/02-items-all.png
Binary file added docs/pr-screenshots/before/04-reauth-dialog.png
Binary file added docs/pr-screenshots/before/06-add-dialog.png
Binary file added docs/pr-screenshots/before/07-remove-dialog.png
Binary file added docs/pr-screenshots/before/08-tradeup.png
Binary file added docs/pr-screenshots/before/10-items-single.png
Binary file added docs/pr-screenshots/before/12-mobile-items.png
Binary file added docs/pr-screenshots/before/23-grid.png
Binary file added docs/pr-screenshots/round11/dashboard-960.png
Binary file added docs/pr-screenshots/round11/items-list-320.png
Binary file added docs/pr-screenshots/round12/dashboard-chf-960.png
Binary file added docs/pr-screenshots/round12/items-list-360.png
Binary file added docs/pr-screenshots/round12/items-sort-320.png
Binary file added docs/pr-screenshots/round13/holdings-360-usd.png
Binary file added docs/pr-screenshots/round13/holdings-390-idr.png
Binary file added docs/pr-screenshots/round13/items-list-375.png
Binary file added docs/pr-screenshots/round13/tradeup-input-320.png
Binary file added docs/pr-screenshots/round14/cases-desktop.png
Binary file added docs/pr-screenshots/round14/cases-phone-odds.png
Binary file added docs/pr-screenshots/round16/cases-sample-390.png
Binary file added docs/pr-screenshots/round16/cases-unknown-390.png
Binary file added docs/pr-screenshots/round17/sync-error-390.png
Binary file added docs/pr-screenshots/round2/00-items-768.png
Binary file added docs/pr-screenshots/round2/01-items-960.png
Binary file added docs/pr-screenshots/round2/02-items-1100.png
Binary file added docs/pr-screenshots/round2/03-items-1440.png
Binary file added docs/pr-screenshots/round2/11-stale-banner.png
Binary file added docs/pr-screenshots/round2/12-select-open.png
Binary file added docs/pr-screenshots/round2/16-tradeup-empty.png
Binary file added docs/pr-screenshots/round2/17-choose-account.png
Binary file added docs/pr-screenshots/round3/organiser-no-match.png
Binary file added docs/pr-screenshots/round3/settings-768.png
Binary file added docs/pr-screenshots/round3/storage-1100.png
Binary file added docs/pr-screenshots/round3/storage-1440.png
Binary file added docs/pr-screenshots/round3/storage-390.png
Binary file added docs/pr-screenshots/round3/storage-768-before.png
Binary file added docs/pr-screenshots/round3/storage-768.png
Binary file added docs/pr-screenshots/round3/storage-960.png
Binary file added docs/pr-screenshots/round4/cases-390.png
Binary file added docs/pr-screenshots/round4/cases-odds-1440.png
Binary file added docs/pr-screenshots/round4/cases-odds-390.png
Binary file added docs/pr-screenshots/round4/dashboard-960.png
Binary file added docs/pr-screenshots/round4/phone-all-accounts.png
Binary file added docs/pr-screenshots/round4/sync-pending-390.png
Binary file added docs/pr-screenshots/round4/tradeup-390.png
Binary file added docs/pr-screenshots/round4/tradeup-768.png
Binary file added docs/pr-screenshots/round5/dashboard-hero-390.png
Binary file added docs/pr-screenshots/round5/settings-390.png
Binary file added docs/pr-screenshots/round5/settings-768.png
Binary file added docs/pr-screenshots/round5/settings-960.png
Binary file added docs/pr-screenshots/round5/settings-wide-1100.png
Binary file added docs/pr-screenshots/round5/settings-wide-1440.png
Binary file added docs/pr-screenshots/round5/tradeup-390.png
Binary file added docs/pr-screenshots/round5/tradeup-768.png
Binary file added docs/pr-screenshots/round6/r6-root-error-768.png
Binary file added docs/pr-screenshots/round6/r6-settings-1440.png
Binary file added docs/pr-screenshots/round6/r6-settings-960.png
Binary file added docs/pr-screenshots/round8/tradeup-320.png
Binary file added docs/pr-screenshots/round9/tradeup-odds-1024.png
Binary file added docs/pr-screenshots/round9/tradeup-odds-320.png
14 changes: 12 additions & 2 deletions server/accounts/account-manager.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,10 @@
import type { AccountLabel } from "../../src/lib/accounts/accounts.types.ts";
import { AUTH_STATUS } from "../../src/lib/storage/storage.config.ts";
import type { AccountState, AppSnapshot } from "../../src/lib/storage/storage.types.ts";
import type {
AccountState,
AppSnapshot,
LoginResult,
} from "../../src/lib/storage/storage.types.ts";
import type { Database } from "../db/db.types.ts";
import type { SavedSession } from "../steam/session-file.ts";
import type { SessionStore } from "../steam/session-store.ts";
Expand Down Expand Up @@ -34,6 +38,7 @@ export function createAccountManager({
const listeners = new Set<(snapshot: AppSnapshot) => void>();
let revision = 0;
let emitScheduled = false;
let lastLogin: LoginResult | null = null;

const login = createQrLogin((session) => void adopt(session));
login.subscribe(() => emit());
Expand Down Expand Up @@ -66,6 +71,11 @@ export function createAccountManager({
/** A QR login finished: store the token, register the account and connect it (or reconnect with the new token). */
async function adopt(session: SavedSession): Promise<void> {
const existing = gateways.get(session.steamId);
lastLogin = {
sequence: (lastLogin?.sequence ?? 0) + 1,
steamId: session.steamId,
added: existing === undefined,
};
existing?.disconnect();
unsubscribers.get(session.steamId)?.();
sessions.forAccount(session.steamId).save(session);
Expand Down Expand Up @@ -101,7 +111,7 @@ export function createAccountManager({
snapshot,
});
}
return { accounts, login: login.getState(), revision };
return { accounts, login: login.getState(), lastLogin, revision };
}

return {
Expand Down
31 changes: 31 additions & 0 deletions server/cases/case-stats.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,40 @@ test("measures the gold drought in both directions", () => {
assert.equal(stats.casesToFirstGold, 3); // knife was the 3rd opening ever
});

test("tracks the exact tier of each case pull, including never-pulled tiers", () => {
const stats = buildCaseStats(events, "case");
assert.deepEqual(
stats.caseRarities.map((rarity) => [
rarity.key,
rarity.count,
rarity.sinceLast,
rarity.hasDrop,
rarity.uncertain,
]),
[
["gold", 1, 1, true, false],
["covert", 1, 0, true, false],
["classified", 1, 2, true, false],
["restricted", 0, 4, false, false],
["milSpec", 1, 3, true, false],
],
);
});

test("marks streaks with unidentified drops as upper bounds", () => {
const unknown = opening(5, "Unknown skin", "b0c3d9", Date.UTC(2026, 3, 5));
const stats = buildCaseStats([unknown, ...events], "case");
assert.equal(stats.caseRarities.find((rarity) => rarity.key === "covert")?.sinceLast, 1);
assert.equal(stats.caseRarities.find((rarity) => rarity.key === "covert")?.uncertain, true);
assert.equal(stats.caseRarities.find((rarity) => rarity.key === "restricted")?.sinceLast, 5);
assert.equal(stats.caseRarities.find((rarity) => rarity.key === "restricted")?.uncertain, true);
});

test("handles an account with no openings", () => {
const stats = buildCaseStats([], "case");
assert.equal(stats.casesSinceGold, null);
assert.equal(stats.casesToFirstGold, null);
assert.ok(stats.caseRarities.every((rarity) => rarity.sinceLast === 0 && !rarity.hasDrop));
assert.deepEqual(stats.perMonth, []);
});

Expand Down Expand Up @@ -102,6 +132,7 @@ test("breaks the same history down by kind", () => {
assert.equal(capsules.kindCounts.capsule, 1);
assert.equal(capsules.topCases[0]?.name, "Test Capsule");
assert.equal(capsules.casesSinceGold, null);
assert.deepEqual(capsules.caseRarities, []);
assert.equal(buildCaseStats(all, "case").total, 4);
});

Expand Down
25 changes: 25 additions & 0 deletions server/cases/case-stats.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { IMAGE_SIZE_SUFFIX } from "../../src/lib/storage/storage.config.ts";
import { OPENING_KINDS } from "../../src/lib/cases/cases.config.ts";
import type {
CaseOpening,
CaseRarityStat,
CaseStats,
OpeningKind,
PulledItem,
Expand Down Expand Up @@ -124,6 +125,29 @@ export function buildCaseStats(events: readonly HistoryEvent[], kind: OpeningKin
color: bucket.color,
count: rarityCounts.get(bucket.key) ?? 0,
})).filter((slice) => slice.count > 0);
const caseRarities: CaseRarityStat[] =
kind === "case"
? RARITY_BUCKETS.filter((bucket) => bucket.key !== "other").map((bucket) => {
const latest = cases.findIndex((event) => {
const reward = event.gained[0];
return reward && bucketKey(reward) === bucket.key;
});
const sinceLast = latest < 0 ? openings.length : latest;
const uncertain = cases.slice(0, sinceLast).some((event) => {
const reward = event.gained[0];
return reward && bucketKey(reward) === "other";
});
return {
key: bucket.key,
label: bucket.label,
color: bucket.color,
count: rarityCounts.get(bucket.key) ?? 0,
sinceLast,
hasDrop: latest >= 0,
uncertain,
};
})
: [];

const perMonth = new Map<string, number>();
for (const opening of [...openings].reverse()) {
Expand All @@ -150,6 +174,7 @@ export function buildCaseStats(events: readonly HistoryEvent[], kind: OpeningKin
: goldIndexNewestFirst,
casesToFirstGold: goldIndexOldestFirst < 0 ? null : openings.length - goldIndexOldestFirst,
rarity,
caseRarities,
rarest: openings
.map((opening, index) => ({ opening, index, rank: rarityRank(opening) }))
.sort((a, b) => a.rank - b.rank || a.index - b.index) // ties keep newest first
Expand Down
6 changes: 4 additions & 2 deletions server/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
SSE_KEEP_ALIVE_MS,
STORAGE_API,
STORAGE_LIMITS,
TRADE_UP_FINDER,
} from "../src/lib/storage/storage.config.ts";
import type {
ApiError,
Expand Down Expand Up @@ -541,8 +542,9 @@ const server = createServer(async (req, res) => {
c.skin ? [{ view: c.view, skin: c.skin }] : [],
);
if (path === STORAGE_API.tradeUpFinder) {
const statTrak = url.searchParams.get("statTrak") ?? "all";
if (statTrak !== "all" && statTrak !== "normal" && statTrak !== "stattrak") {
const requested = url.searchParams.get("statTrak") ?? TRADE_UP_FINDER.defaultStatTrakFilter;
const statTrak = TRADE_UP_FINDER.statTrakFilters.find((filter) => filter === requested);
if (statTrak === undefined) {
throw new UserFacingError("Invalid StatTrak filter", HTTP.badRequest);
}
return sendJson(
Expand Down
12 changes: 9 additions & 3 deletions server/tradeup-finder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,11 @@ import {
type TradeUpFinderOptions,
type TradeUpFinderResponse,
} from "../src/lib/items/item.types.ts";
import { TRADE_UP, TRADE_UP_FINDER } from "../src/lib/storage/storage.config.ts";
import {
TRADE_UP,
TRADE_UP_FINDER,
TRADE_UP_STATTRAK_FILTER,
} from "../src/lib/storage/storage.config.ts";
import type { Catalog } from "./items/catalog.types.ts";
import type { PriceBook } from "./prices/prices.ts";
import { computeTradeUp, inputCountFor, isPriced, type TradeUpCandidate } from "./tradeup.ts";
Expand All @@ -23,8 +27,10 @@ export function findTradeUps(
if (!isPriced(candidate)) return false;
if (!options.includeStorage && candidate.view.locationId !== INVENTORY_LOCATION_ID)
return false;
if (options.statTrak === "normal" && candidate.skin.isStatTrak) return false;
if (options.statTrak === "stattrak" && !candidate.skin.isStatTrak) return false;
if (options.statTrak === TRADE_UP_STATTRAK_FILTER.normal && candidate.skin.isStatTrak)
return false;
if (options.statTrak === TRADE_UP_STATTRAK_FILTER.statTrak && !candidate.skin.isStatTrak)
return false;
if (options.rarityId === TRADE_UP.covertRarity) {
return (
candidate.skin.skinId !== null && catalog.specialsFor(candidate.skin.skinId).length > 0
Expand Down
13 changes: 0 additions & 13 deletions site/plugins/app-source.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,20 +71,7 @@ export const APP_PATCHES: readonly AppPatch[] = [
},
],
},
{
file: "components/tradeup-inputs/tradeup-inputs.component.tsx",
replacements: [
{
from: "title={`${item.name} · ${copy.remove}`}",
to: "title={`${item.name} · ${copy.remove}`}\n aria-label={`${item.name} · ${copy.remove}`}",
},
],
},
// Contrast and hover feedback on already-active controls.
{
file: "components/kind-pills/kind-pills.component.tsx",
replacements: [{ from: "tabular opacity-60", to: "tabular opacity-100" }],
},
{
file: "components/pill/pill.component.tsx",
replacements: [
Expand Down
31 changes: 31 additions & 0 deletions site/src/components/features/feature-demo.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,10 @@ const DEMOS: Record<string, () => ReactNode> = {
export default function FeatureDemo({ id }: { id: string }) {
const Demo = DEMOS[id];
const boxRef = useRef<HTMLDivElement>(null);
const dialogRef = useRef<HTMLDialogElement>(null);
const openRef = useRef<HTMLButtonElement>(null);
const [scale, setScale] = useState(DEMO_MIN_SCALE);
const [expanded, setExpanded] = useState(false);

useEffect(() => {
const el = boxRef.current;
Expand All @@ -33,6 +36,10 @@ export default function FeatureDemo({ id }: { id: string }) {
return () => ro.disconnect();
}, []);

useEffect(() => {
if (expanded) dialogRef.current?.showModal();
}, [expanded]);

if (!Demo) return null;
return (
<div className="fdemo" ref={boxRef} style={{ minHeight: DEMO_HEIGHT * scale }}>
Expand All @@ -42,6 +49,30 @@ export default function FeatureDemo({ id }: { id: string }) {
>
<Demo />
</div>
<button ref={openRef} type="button" className="fdemo__open" onClick={() => setExpanded(true)}>
Explore full {id} demo
</button>
{expanded && (
<dialog
ref={dialogRef}
className="fdemo__dialog"
aria-label={`${id} demo`}
onClose={() => {
setExpanded(false);
openRef.current?.focus();
}}
>
<div className="fdemo__dialog-head">
<span>OpenInventory · {id} demo</span>
<button type="button" onClick={() => dialogRef.current?.close()}>
Close demo
</button>
</div>
<div className="fdemo__dialog-body" data-lenis-prevent>
<Demo />
</div>
</dialog>
)}
</div>
);
}
Loading
Loading