feat(leaderboards): add the global leaderboards page (#2961)
* feat(leaderboards): add rank medal styling for top positions Gold, silver and bronze medals with a subtle glow for ranks 1 to 3, a lighter accent for ranks 4 to 10, theme-overridable colors and reduced-motion support. LBX-1267. * feat(leaderboards): add animated frame around top three rows Spinning gold, silver and bronze ring for ranks 1 to 3, static under reduced motion, and export the rank tier helpers. LBX-1267. * fix(leaderboards): derive the top three ring tint from the theme color The soft ring tint and highlight now follow --rank-*-color overrides instead of fixed SCSS values. LBX-1267. * fix(leaderboards): center the medal rank and follow row text color Draws the medal number inside the SVG at the disc center and lets plain rank numbers inherit the row text color so they stay readable on light themes. LBX-1267. * refactor(leaderboards): name medal tuning values and mark props read-only Addresses review: shine stagger, rank accent cutoff and animation durations are named constants, and RankMedal props are Readonly. LBX-1267. * perf(leaderboards): animate medal shine and top three ring with transforms only The shine moves with translateX instead of left, and the ring rotates a static conic gradient inside a masked container instead of animating a custom property. Also strengthens the top 10 accent, drops an unused label param and rewrites the SDD in English. LBX-1267. * perf(leaderboards): size the top three ring spinner to the row diagonal The spinner is the row width plus a fixed overscan instead of 150%, which still covers the corners while halving the GPU layer on wide rows. LBX-1267. * feat(leaderboards): show the per-game ranking on the game page Adds the Ranking sidebar section with week and all-time boards, the viewer row and next-position hint, profile banners, empty, error and signed-out states, and the full ranking modal. Friends scope and extra metrics stay behind GAME_LEADERBOARDS_EXTENDED until LBX-1264. LBX-1266. * fix(leaderboards): keep ranking rows readable on light themes Progress track, fill and gap dots follow the row text color, and banner rows always keep light text and a stronger track over the image. LBX-1266. * fix(leaderboards): format values with the metric of the loaded board Switching metrics no longer renders the previous board's values with the new unit while the next board loads. LBX-1266. * feat(leaderboards): use filter dropdowns and pin the viewer row Scope and period become dropdown filters aligned with the list, metrics use the sliding switch, and the viewer row stays pinned at the bottom of the card even inside the top five. LBX-1266. * fix(leaderboards): match the signed-out prompt to the ranking states design Dashed 4px box with 12px text and a compact light Sign in button, as in the Ranking states reference. LBX-1266. * feat(leaderboards): link the full ranking to the leaderboards page View full ranking now opens /leaderboards?tab=games&shop=&objectId= instead of a modal. Also keeps loaded boards tied to their game and query, avoids a duplicate viewer row on empty friends boards, and never shows a 0 min gap. LBX-1266. * feat(leaderboards): render the top three ring element in ranking rows Uses RankFrameRing from LBX-1267 so the ring animates with transforms only. LBX-1266. * docs(leaderboards): rewrite the game page leaderboard SDD in English Matches the repository language and brings the document up to date with the dropdown filters, pinned viewer row, page link and review fixes. LBX-1266. * feat(leaderboards): add the global leaderboards page Add the Leaderboards page with the Ranking, By game and Your stats tabs, weekly and all-time boards, global and friends scopes, the weekly reset countdown, badges and the viewer's pinned row, reusing the per-game ranking row and medal. Data goes through one cached renderer client and metrics the API does not serve yet are hidden. * feat(leaderboards): open the linked game from the game page ranking * fix(leaderboards): scope the cache per account and address review findings Key cached responses by account and drop writes from requests started before a cache clear. Use the loaded board for the game heading, open a linked game even when the games list fails, advance the fallback week on reset, give game cards a minimum width, keep the Top N count in sync with Show more, and name the heatmap thresholds. Sort request params with localeCompare and compare the pending request explicitly. * refactor(leaderboards): address code analysis findings on the leaderboards page * fix(leaderboards): address review on the game page leaderboard Restores the game_details show_more string removed by mistake, keys cached and loaded boards by the signed-in account, stops the filter dropdown animation under reduced motion and names the skeleton row count. LBX-1266. * feat(leaderboards): gate the leaderboards page behind GLOBAL_LEADERBOARDS * fix(leaderboards): address SonarCloud findings on the game page leaderboard Sorts cache key params with localeCompare, checks the in-flight promise explicitly, uses an optional chain, marks component props Readonly and gives skeleton rows stable keys. LBX-1266. * feat(leaderboards): load the metric list from the backend Read the active metrics from GET /leaderboards/metrics for the ranking cards, the per-game chips, Your week and the badges, instead of a fixed list. Names come from the translations by metric id with the backend label as fallback, values are formatted by value type, icons fall back to a generic one, and streak and review extras follow the board data. * refactor(leaderboards): drop the metric id alias and key placeholders by id * fix(leaderboards): keep the page filters side by side * feat(leaderboards): add medal labels in Russian and Spanish Ships the leaderboard namespace in ru and es alongside en and pt-BR. LBX-1267. * feat(leaderboards): add game page leaderboard strings in Russian and Spanish Ships the leaderboard namespace keys used by the card in ru and es alongside en and pt-BR, with Russian plural forms for days. LBX-1266. * fix(leaderboards): address review on the leaderboards page Show the games empty state when a linked game has no ranked games, keep errored metrics visible with retry, keep firing the week end while the countdown is past the reset, round heatmap durations from the total, share the duration formatting with the game page, read the game page board through the shared cache with stale eviction and a size cap, keep the retry callback stable and add ru and es strings. * feat(leaderboards): gate the full ranking link behind GLOBAL_LEADERBOARDS The View full ranking button only shows when the global leaderboards page is enabled, so this change never links to a missing route. LBX-1266. * feat(leaderboards): show the per-game ranking on the game page Adds the Ranking sidebar section with week and all-time boards, the viewer row and next-position hint, profile banners, empty, error and signed-out states, and the full ranking modal. Friends scope and extra metrics stay behind GAME_LEADERBOARDS_EXTENDED until LBX-1264. LBX-1266. * fix(leaderboards): keep ranking rows readable on light themes Progress track, fill and gap dots follow the row text color, and banner rows always keep light text and a stronger track over the image. LBX-1266. * fix(leaderboards): format values with the metric of the loaded board Switching metrics no longer renders the previous board's values with the new unit while the next board loads. LBX-1266. * feat(leaderboards): use filter dropdowns and pin the viewer row Scope and period become dropdown filters aligned with the list, metrics use the sliding switch, and the viewer row stays pinned at the bottom of the card even inside the top five. LBX-1266. * fix(leaderboards): match the signed-out prompt to the ranking states design Dashed 4px box with 12px text and a compact light Sign in button, as in the Ranking states reference. LBX-1266. * feat(leaderboards): link the full ranking to the leaderboards page View full ranking now opens /leaderboards?tab=games&shop=&objectId= instead of a modal. Also keeps loaded boards tied to their game and query, avoids a duplicate viewer row on empty friends boards, and never shows a 0 min gap. LBX-1266. * feat(leaderboards): render the top three ring element in ranking rows Uses RankFrameRing from LBX-1267 so the ring animates with transforms only. LBX-1266. * docs(leaderboards): rewrite the game page leaderboard SDD in English Matches the repository language and brings the document up to date with the dropdown filters, pinned viewer row, page link and review fixes. LBX-1266. * fix(leaderboards): address review on the game page leaderboard Restores the game_details show_more string removed by mistake, keys cached and loaded boards by the signed-in account, stops the filter dropdown animation under reduced motion and names the skeleton row count. LBX-1266. * fix(leaderboards): address SonarCloud findings on the game page leaderboard Sorts cache key params with localeCompare, checks the in-flight promise explicitly, uses an optional chain, marks component props Readonly and gives skeleton rows stable keys. LBX-1266. * feat(leaderboards): add game page leaderboard strings in Russian and Spanish Ships the leaderboard namespace keys used by the card in ru and es alongside en and pt-BR, with Russian plural forms for days. LBX-1266. * feat(leaderboards): gate the full ranking link behind GLOBAL_LEADERBOARDS The View full ranking button only shows when the global leaderboards page is enabled, so this change never links to a missing route. LBX-1266. * fix(leaderboards): address second review round on the leaderboards page * fix(leaderboards): address review findings on the leaderboards page * fix(leaderboards): render one decimal for playtime hours * fix(leaderboards): address review on loading, auth, ranks and cache Loading shows until the first board arrives instead of flashing an error, only extended requests authenticate, the nudge handles tied ranks, the cache evicts expired and overflowing entries, hours keep one decimal under 10 h, the sign-in copy matches the flag, the full ranking link carries the current selection, and the unused periodEnd and the internal SDD are removed. LBX-1266. * fix(leaderboards): keep the game board while the list reloads and hide raw badge progress * fix(leaderboards): retry the board anonymously when the session is stale A signed-in request that fails with UserNotLoggedInError is retried without auth, so a stale session never shows the error state on a public board. LBX-1266. * fix(leaderboards): evict unparseable responses, keep the viewer delta and show failed stats ranks * refactor(leaderboards): extract the previous rank lookup in the board view
This commit is contained in:
parent
55bd033ce2
commit
c5e9670dbd
40 changed files with 6470 additions and 221 deletions
2
.gitignore
vendored
2
.gitignore
vendored
|
|
@ -29,3 +29,5 @@ TO-DO.md
|
|||
.cache/
|
||||
native/torrent-bridge/build/
|
||||
native/torrent-bridge/vcpkg_installed/
|
||||
*.local.ts
|
||||
src/renderer/public/*.local/
|
||||
|
|
|
|||
144
docs/global-leaderboards-page-sdd.md
Normal file
144
docs/global-leaderboards-page-sdd.md
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
# Global leaderboards page in the launcher (LBX-1265)
|
||||
|
||||
SDD from 2026-10-21. Status: approved, scope = the full design screen.
|
||||
|
||||
Builds on the game page leaderboard (LBX-1266), reusing its ranking row, medal and value formatter. The design has three tabs: **Ranking**, **By game** and **Your stats**. Backend: the leaderboard framework (LBX-1259) and the global weekly boards (LBX-1260).
|
||||
|
||||
## Expected result
|
||||
|
||||
A **Leaderboards** page in the launcher, reached from the sidebar, that implements the design screen exactly, including what goes beyond the task text: the three tabs, badges, rank change, player totals, the review preview and the streak indicators. Where the API does not provide the data yet, the dependency is listed below.
|
||||
|
||||
## Task acceptance criteria
|
||||
|
||||
| Linear criterion | How this SDD meets it | Status |
|
||||
| ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------- |
|
||||
| Sidebar/header entry to a Leaderboards page with tabs per metric (playtime, games, friends, achievements, souvenirs, reviews, review upvotes, sessions, streak, as available) | "Leaderboards" item with a trophy icon in `components/sidebar/routes.tsx`, title in `header.tsx` and the `/leaderboards` route in `main.tsx`. On the Ranking tab, the "Leaders of the week" cards are the metric tabs (`role="tab"`) and show each metric's current leader. | Met |
|
||||
| Global/Friends toggle and This week/Last week toggle; a countdown to the weekly reset | Two `SegmentedControl`s in the header: Global/Friends and **Week/All time, as in the design** (decided; replaces Linear's "Last week" with "All time"). Subtitle "Week 43 · Oct 20–26 · Resets in 3d 14h", from `weekEnd` (fallback: next Monday 00:00 UTC), updated every 30 s. On All time the first place badge becomes "Record holder", the ▲▼ change disappears from rows and the subtitle explains the all-time board. Logged out users do not see Friends. | Met, with the decided change |
|
||||
| Rows: rank, avatar (with frame if equipped), name (links to profile), value formatted per metric. The viewer's own row is pinned at the bottom when outside the top N | Top 3 podium + `LeaderboardRow` from 4th place on (medal, avatar, name, bar, value, ▲▼ change). Clicking opens `/profile/:id`. `buildLeaderboardRows` pins the viewer's row with the "…" gap when they are outside the visible rows or the returned top. **Avatar frame: does not exist in the product yet (confirmed), out until it does.** | Met |
|
||||
| Loading skeletons, empty board state, and error state (the API returns an empty board on timeout) | Skeletons for the podium, rows, cards and every block of the other tabs. Empty state per window and scope (no friends, with "Find friends"). Error with "Try again". A timeout arrives as an empty board and falls into the empty state. | Met |
|
||||
| Strings in `src/locales/en/translation.json` | `leaderboards` namespace plus a `leaderboards` key in `sidebar` and `header`, in `en`, `pt-BR`, `ru` and `es`. | Met |
|
||||
|
||||
## Screen scope (everything is in)
|
||||
|
||||
### Ranking tab
|
||||
|
||||
- "Leaders of the week" cards: one per metric, with icon, label, leader avatar, name and value, on a single row; the selected one has a light border.
|
||||
- Metric board: icon + title + "Top 13 of 12,480 players"; podium (heights 288/260/240, gold/silver/bronze metal band with shine, profile banner, avatar 64/52, "Leader of the week" badge on 1st, ▲▼ change on 2nd and 3rd, regular weight values); rows 4–13 with optional banner, cyan to violet bar relative to 1st and ▲▼ change; the viewer's row highlighted with the "You" tag and a full strength bar; "Show more".
|
||||
- Reviews: "Most upvoted review" card above the podium (cover, game, avatar, blue star score, capitalized relative date, two line text, cyan like icon with votes, "View on the {game} page" on its own line); clicking a podium card switches the review; hovering a row shows the review tooltip.
|
||||
- Streak: cyan to violet flame for an active streak, grey value for an ended streak.
|
||||
- "Your badges": "5 of 14 earned", cards with cover, chip (TOP 1%, rank, days), title, subtitle, date or a cyan to violet progress bar ("4 h to go"), animated ring per tier; the streak badge uses the cyan to violet border and chip; "Show more".
|
||||
|
||||
### By game tab
|
||||
|
||||
- "Your games' rankings" with search: one card per game on a single row (cover, your rank "#6 of 12,480", change, leader).
|
||||
- Selected game: hero with the game art, "{game} ranking", "{N} players · You are #6", "Open game page", metric chips, podium and rows as on the Ranking tab and "Show more" (no "View full ranking": the tab already is the full ranking, decided).
|
||||
|
||||
- Deep link from the game page (LBX-1266): `/leaderboards?tab=games&shop=&objectId=` opens this tab with that game selected; a game outside "Your games" still opens, with title and art from `getGameAssets`.
|
||||
|
||||
### Your stats tab
|
||||
|
||||
- "Your week": banner + avatar, and one row per metric with ▲▼ change next to your rank.
|
||||
- "Daily streak": current and record, a 15 week heatmap (levels with increasing brightness from dim cyan to glowing violet, 0 h to 3 h+), day tooltip with hours and games; same height as "Your week".
|
||||
- "Streaks among friends": list with presence and days, empty state "No friends to compare with" with "Find friends", "See all friends".
|
||||
- "Your badges" (same block as on the Ranking tab).
|
||||
|
||||
## API dependencies
|
||||
|
||||
| Screen data | Source | Status |
|
||||
| ------------------------------------------------------------------------------- | ---------------------------- | ----------------------------------------------------------------------------------- |
|
||||
| Global playtime/games/friends/achievements/souvenirs boards, `window=week\|all` | `GET /leaderboards/:metric` | LBX-1260 |
|
||||
| Sessions and streak boards | LBX-1262, LBX-1263 | SDD ready, no code |
|
||||
| Reviews board + the leader's review (game, score, text, votes) | no task | **needs a backend task** |
|
||||
| `backgroundImageUrl` on the user | framework | decided in LBX-1260 |
|
||||
| Total eligible players per board | no field | **needs to be added to the framework** (`total` in the response) |
|
||||
| ▲▼ change (rank in the previous week) | no field | **needs to be added to the framework** (`previousRank` per entry and on the viewer) |
|
||||
| Active/ended streak per entry | LBX-1263 | to confirm in the contract |
|
||||
| "Your games" list with your rank, total and leader | no endpoint | **needs an endpoint** (on top of the LBX-1264 per-game boards) |
|
||||
| Per-game board with metrics | LBX-1264 | SDD ready |
|
||||
| 15 week daily heatmap and streak record | LBX-1262/LBX-1263 | to confirm in the contract |
|
||||
| Friends' streaks with presence | LBX-1263 + existing presence | to confirm |
|
||||
| Badges (earned, progress) | no task | **needs a backend task** |
|
||||
|
||||
Each block switches to the real API as its dependency ships.
|
||||
|
||||
## Proposed contracts
|
||||
|
||||
| Endpoint | Response |
|
||||
| ------------------------------------------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `GET /leaderboards/metrics?scope=global\|game` | `{ metrics: [{ id, valueType: "duration"\|"count"\|"days", label }] }`, in display order; `label` in English |
|
||||
| `GET /leaderboards/:metric?window=week\|all&scope&take` | framework response + `total` (eligible players), `previousRank` per entry and on `viewer`, `streakActive` (streak only) and `review` `{ gameTitle, shop, objectId, coverImageUrl, score, createdAt, text }` (reviews only) |
|
||||
| `GET /leaderboards/me/games?window&scope` | `{ games: [{ shop, objectId, title, coverImageUrl, heroImageUrl, rank, previousRank, total, leader }] }` |
|
||||
| `GET /games/:shop/:objectId/leaderboards/:metric?range&scope&take` | LBX-1264 route + `previousRank` per entry |
|
||||
| `GET /users/me/play-activity?weeks=15` | `{ currentStreak, bestStreak, playedToday, days: [{ date, seconds, games }] }` |
|
||||
| `GET /users/me/friends/streaks` | `{ friends: [{ user, days, bestDays, active, online }] }` (includes the viewer) |
|
||||
| `GET /users/me/leaderboard-badges` | `{ earned, total, badges: [{ id, kind, tier, metric, scope, gameTitle, coverImageUrl, value, week, currentRank, earnedAt, progress }] }`; `kind` = `top_percent\|weekly_leader\|top_rank\|friends_podium\|streak_record`, `tier` = `gold\|silver\|bronze\|streak`. Texts are built in the launcher with i18n |
|
||||
|
||||
## Decisions
|
||||
|
||||
1. **Scope = the design screen**, including what is not in the task text (decided).
|
||||
2. **Avatar frame** stays out because it does not exist in the product yet (decided).
|
||||
3. **Tabs = leader cards** on the Ranking tab. The page fetches every metric's board when it opens (in parallel, with the renderer cache and request dedupe, plus the backend Redis cache).
|
||||
4. **Metrics come from the backend**: `GET /leaderboards/metrics?scope=global|game` returns the active metrics in display order, each with `id`, `valueType` (`duration`, `count` or `days`) and an English `label`. The launcher hardcodes no metric list: cards, chips and "Your week" follow that response, so a new metric (Sessions, for example) shows up without a launcher release. The name comes from the translations by `id` (`metric_{id}`) in every launcher language, falling back to the backend `label` until a translation exists; values are formatted by `valueType` in the app language; icons are mapped by `id` with a generic fallback. Streak flames and the review preview show when a board carries `streakActive` or `review` data, not by metric id.
|
||||
5. **Shared code**: the page imports from `pages/game-details/leaderboard/` (types, `buildLeaderboardRows`, `LeaderboardRow`, `SegmentedControl`, styles). Moving them to `components/leaderboard/` is a refactor after LBX-1266 merges.
|
||||
6. **Pagination**: the API returns up to 100 entries; the screen shows 13 and "Show more" adds 10 without a new request.
|
||||
7. **Order and eligibility** come from the backend (profile photo required on global, subscriber tie-break; LBX-1259 addendum A). The launcher does not reorder.
|
||||
8. **Period = Week / All time**, as in the design. The launcher sends `window=week|all`.
|
||||
9. **Gamification colour**: the cyan to violet gradient (`#22c7ee` → `#b05cff`) marks progress and streaks: flames, heatmap, streak badge, badge progress bars, ranking bars, and the like and star icons on reviews. Medals, ▲▼ colours and the leader trophy keep their own colours.
|
||||
10. **`GLOBAL_LEADERBOARDS` feature flag** (decided after review): while it is off, the sidebar item and the game page "View full ranking" link are hidden and `/leaderboards` redirects home without fetching. The API turns it on once the endpoints listed above ship.
|
||||
|
||||
## Performance and backend behaviour
|
||||
|
||||
- One renderer response cache (`createResponseCache`, 60 s, deduping identical in-flight requests). Switching to an already visited tab, period or scope does not refetch nor flash a skeleton, because the screen reads the cache synchronously.
|
||||
- The countdown lives in `WeekSubtitle`; its 30 s tick does not re-render the page.
|
||||
- When the week resets with the page open, the cache is cleared and every block reloads, retrying with backoff (30 s up to 5 min) while the API still returns the old week. The tabs remount only when the week start day changes.
|
||||
- Heatmap days and "today" use UTC, matching the backend weeks (Monday 00:00 UTC), and "today" moves at UTC midnight while the page stays open.
|
||||
- If `/leaderboards/metrics?scope=game` returns no metric, the By game board shows an empty state instead of an error.
|
||||
- The game page "View full ranking" link shows only to signed-in users, since the By game tab needs a session.
|
||||
- A missing `previousRank` hides the rank change instead of showing "no change".
|
||||
- A metric is hidden only when `/leaderboards/metrics` does not list it. If a listed metric's board fails, its card stays visible with a load error and the board shows the error with "Try again", which requests it again. The game page leaderboard reads through the same response cache, so one `clear()` at the weekly reset covers every board; stale entries are deleted on read and the cache keeps at most 200 responses.
|
||||
- Animations: the podium shine and the earned badge ring (`RankFrameRing`) use only `transform` (GPU).
|
||||
|
||||
## States
|
||||
|
||||
| State | When | What shows |
|
||||
| ------------- | -------------------------------------- | --------------------------------------------------------------------------------- |
|
||||
| loading | first load of a block | block skeleton |
|
||||
| ready | there is data | content |
|
||||
| empty | empty list (includes timeout) | icon + text for the window |
|
||||
| empty_friends | Friends with nobody besides the viewer | text + "Find friends" |
|
||||
| error | network/parse failure | message + "Try again" |
|
||||
| logged out | no session | Global only, no pinned row, no By game, Your stats or badges, with a sign-in hint |
|
||||
|
||||
## Changes per file
|
||||
|
||||
| File | Change |
|
||||
| --------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
|
||||
| `pages/leaderboards/leaderboards.tsx` | page, header, toggles, page tabs |
|
||||
| `pages/leaderboards/week-subtitle.tsx` | week label, countdown and week reset |
|
||||
| `pages/leaderboards/ranking-tab.tsx` | leader cards, board, selected review |
|
||||
| `pages/leaderboards/by-game-tab.tsx` | games list, hero, per-game board |
|
||||
| `pages/leaderboards/stats-tab.tsx` | your week, daily streak + heatmap, streaks among friends |
|
||||
| `pages/leaderboards/board-view.tsx` | podium + rows + show more shared by the tabs |
|
||||
| `pages/leaderboards/leaderboard-podium.tsx` | top 3 podium |
|
||||
| `pages/leaderboards/leaderboard-bits.tsx` | rank change, streak flame, block states |
|
||||
| `pages/leaderboards/badges-section.tsx` | your badges |
|
||||
| `pages/leaderboards/metric-icon.tsx` | metric icons |
|
||||
| `pages/leaderboards/global-leaderboard.ts` (+ `.test.ts`) | metrics, request, normalizers, states, ISO week, countdown, change, heatmap, badges |
|
||||
| `pages/leaderboards/leaderboards-api.ts` | API access, viewer, cached resource hook |
|
||||
| `helpers/response-cache.ts` (+ `.test.ts`) | shared response cache: per account keys, TTL, dedupe, generation on clear, stale eviction, size cap |
|
||||
| `services/leaderboards-cache.ts` | the single cache instance used by the Leaderboards page and the game page board |
|
||||
| `hooks/use-global-leaderboards-feature.ts` | `GLOBAL_LEADERBOARDS` flag for the sidebar, the route and the game page link |
|
||||
| `pages/leaderboards/use-global-leaderboards.ts` | global boards for every metric |
|
||||
| `pages/leaderboards/use-global-leaderboard-value.ts` | value formatting per metric |
|
||||
| `pages/leaderboards/leaderboards.scss` | BEM styles |
|
||||
| `pages/game-details/leaderboard/leaderboard-controls.tsx` | exports `SegmentedControl` |
|
||||
| `pages/game-details/leaderboard/leaderboard-row.tsx`, `leaderboard.scss` | optional rank addon, value prefix, muted value and tooltip slots |
|
||||
| `main.tsx`, `components/sidebar/routes.tsx`, `components/header/header.tsx` | route, sidebar item with the design trophy icon, title |
|
||||
| `src/locales/{en,pt-BR,es,ru}/translation.json` | strings |
|
||||
|
||||
## Tests and acceptance
|
||||
|
||||
- `global-leaderboard.test.ts` (`node:test`, same pattern as `game-leaderboard.test.ts`): request, normalizers, metric definitions, rows below the podium, states, rank change, viewer ranking, ISO week, countdown, heatmap and badge progress. `response-cache.test.ts` covers keys, TTL, dedupe, uncached errors, clear, account isolation and housekeeping.
|
||||
- Goal: 80% or more of the new logic lines. Components are validated in the launcher.
|
||||
- Manual test in the launcher: the three tabs, toggles, countdown, podium, review, streak, badges, "Show more", pinned row, profile link, logged out.
|
||||
- Once the backend ships: validate each block against a local `hydra-api`.
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"achievements": "🏆 Games to beat"
|
||||
},
|
||||
"sidebar": {
|
||||
"leaderboards": "Leaderboards",
|
||||
"catalogue": "Catalogue",
|
||||
"library": "Library",
|
||||
"downloads": "Downloads",
|
||||
|
|
@ -144,6 +145,7 @@
|
|||
"custom_assets_not_sync": "Your custom artwork will not be synced with your account."
|
||||
},
|
||||
"header": {
|
||||
"leaderboards": "Leaderboards",
|
||||
"minimize": "Minimize",
|
||||
"maximize": "Maximize",
|
||||
"restore": "Restore",
|
||||
|
|
@ -2595,5 +2597,129 @@
|
|||
"medal_gold": "1st place, gold",
|
||||
"medal_silver": "2nd place, silver",
|
||||
"medal_bronze": "3rd place, bronze"
|
||||
},
|
||||
"leaderboards": {
|
||||
"title": "Leaderboards",
|
||||
"subtitle_week": "Week {{number}} · {{range}} · Resets in {{countdown}}",
|
||||
"subtitle_all": "Records accumulated since each player joined Hydra",
|
||||
"countdown_days": "{{days}}d {{hours}}h",
|
||||
"countdown_hours": "{{hours}}h {{minutes}}m",
|
||||
"countdown_minutes": "{{minutes}}m",
|
||||
"scope_label": "Ranking scope",
|
||||
"scope_global": "Global",
|
||||
"scope_friends": "Friends",
|
||||
"period_label": "Period",
|
||||
"period_week": "Week",
|
||||
"period_all": "All time",
|
||||
"tabs_label": "Ranking type",
|
||||
"tab_ranking": "Ranking",
|
||||
"tab_games": "By game",
|
||||
"tab_stats": "Your stats",
|
||||
"metric_label": "Metric",
|
||||
"metric_playtime": "Playtime",
|
||||
"metric_games": "Games",
|
||||
"metric_friends": "Friends",
|
||||
"metric_achievements": "Achievements",
|
||||
"metric_souvenirs": "Souvenirs",
|
||||
"metric_sessions": "Sessions",
|
||||
"metric_streak": "Streak",
|
||||
"metric_reviews": "Reviews",
|
||||
"leaders_title": "Leaders of the week",
|
||||
"leaders_all_title": "All-time record holders",
|
||||
"no_leader": "No one yet",
|
||||
"leader_error": "Couldn't load",
|
||||
"metal_gold": "Gold",
|
||||
"metal_silver": "Silver",
|
||||
"metal_bronze": "Bronze",
|
||||
"leader_of_week": "Leader of the week",
|
||||
"record_holder": "Record holder",
|
||||
"top_of_total": "Top {{count}} of {{total}} players",
|
||||
"players_count_one": "{{count}} player",
|
||||
"players_count_other": "{{count}} players",
|
||||
"value_hours": "{{amount}} h",
|
||||
"value_minutes": "{{amount}} min",
|
||||
"value_games_one": "{{amount}} game",
|
||||
"value_games_other": "{{amount}} games",
|
||||
"value_friends_one": "{{amount}} friend",
|
||||
"value_friends_other": "{{amount}} friends",
|
||||
"value_achievements": "{{amount}}",
|
||||
"value_souvenirs": "{{amount}}",
|
||||
"value_streak_one": "{{amount}} day",
|
||||
"value_streak_other": "{{amount}} days",
|
||||
"value_reviews_one": "{{amount}} vote",
|
||||
"value_reviews_other": "{{amount}} votes",
|
||||
"delta_label": "Change since last week: {{count}}",
|
||||
"streak_active": "Streak in progress",
|
||||
"show_more": "Show more",
|
||||
"error_title": "Couldn't load this",
|
||||
"error_description": "Check your connection and try again.",
|
||||
"retry": "Try again",
|
||||
"empty_week_title": "No one has scored this week yet",
|
||||
"empty_week_description": "Play to be the first on this week's board.",
|
||||
"empty_all_title": "No records yet",
|
||||
"empty_all_description": "Play to be the first on this board.",
|
||||
"empty_friends_title": "No friends to compare with",
|
||||
"empty_friends_description": "Add friends to see how you rank against them.",
|
||||
"game_metrics_empty_title": "No game rankings yet",
|
||||
"game_metrics_empty_description": "Per-game rankings will show up here soon.",
|
||||
"empty_friends_game_title": "None of your friends play this game",
|
||||
"empty_friend_streaks_description": "Add friends to see who plays every day.",
|
||||
"find_friends": "Find friends",
|
||||
"sign_in_hint": "Sign in to see your position, your games and your friends' ranking.",
|
||||
"review_score": "{{score}}/5",
|
||||
"review_top_week": "Most upvoted review of the week",
|
||||
"review_top_all": "Most upvoted review of all time",
|
||||
"review_top_of": "Most upvoted review by {{name}}",
|
||||
"review_open_game": "View on the {{game}} page",
|
||||
"my_games_title": "Your games' rankings",
|
||||
"search_game": "Search game",
|
||||
"search_game_placeholder": "Search a game in your library",
|
||||
"games_empty_title": "No game rankings yet",
|
||||
"games_empty_description": "Play a game to show up on its ranking.",
|
||||
"games_search_empty": "No games match your search.",
|
||||
"ordinal": "#{{rank}}",
|
||||
"of_total": "of {{total}}",
|
||||
"leader_name": "Leader: {{name}}",
|
||||
"game_ranking_title": "{{game}} ranking",
|
||||
"game_ranking_subtitle": "{{total}} players · You are #{{rank}}",
|
||||
"open_game_page": "Open game page",
|
||||
"mine_week_title": "Your week",
|
||||
"mine_all_title": "Your history",
|
||||
"daily_streak": "Daily streak",
|
||||
"streak_current": "Current",
|
||||
"streak_best": "Record",
|
||||
"days_unit_one": "day",
|
||||
"days_unit_other": "days",
|
||||
"last_weeks": "Last {{count}} weeks",
|
||||
"legend_min": "0 h",
|
||||
"legend_max": "3 h+",
|
||||
"today_date": "Today, {{date}}",
|
||||
"not_played": "Didn't play",
|
||||
"duration_hours_minutes": "{{hours}} h {{minutes}} min",
|
||||
"duration_minutes": "{{minutes}} min",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"days_count_one": "{{count}} day",
|
||||
"days_count_other": "{{count}} days",
|
||||
"see_all_friends": "See all friends",
|
||||
"friends_streak": "Streaks among friends",
|
||||
"badges_title": "Your badges",
|
||||
"badges_count": "{{earned}} of {{total}} earned",
|
||||
"badge_top_percent": "Top {{percent}}% in {{game}}",
|
||||
"badge_weekly_leader": "Weekly leader in {{game}}",
|
||||
"badge_top_rank": "Top {{rank}} in {{game}}",
|
||||
"badge_friends_podium": "Podium among friends",
|
||||
"badge_streak_record_one": "{{count}}-day streak",
|
||||
"badge_streak_record_other": "{{count}}-day streak",
|
||||
"badge_personal_record": "Personal record",
|
||||
"you_are_at": "You are #{{rank}}",
|
||||
"week_number": "Week {{number}}",
|
||||
"chip_top_percent": "TOP {{percent}}%",
|
||||
"chip_top_rank": "TOP {{rank}}",
|
||||
"chip_days_one": "{{count}} DAY",
|
||||
"chip_days_other": "{{count}} DAYS",
|
||||
"badge_earned_on": "Earned on {{date}}",
|
||||
"badge_since": "Since {{date}}",
|
||||
"badge_remaining": "{{amount}} to go"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"achievements": "🏆 Juegos para platinar"
|
||||
},
|
||||
"sidebar": {
|
||||
"leaderboards": "Clasificaciones",
|
||||
"catalogue": "Catálogo",
|
||||
"library": "Librería",
|
||||
"downloads": "Descargas",
|
||||
|
|
@ -147,6 +148,7 @@
|
|||
"custom_assets_not_sync": "Tus ilustraciones personalizadas no se sincronizarán con tu cuenta."
|
||||
},
|
||||
"header": {
|
||||
"leaderboards": "Clasificaciones",
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar",
|
||||
"restore": "Restaurar",
|
||||
|
|
@ -2323,5 +2325,129 @@
|
|||
"medal_gold": "1.er puesto, oro",
|
||||
"medal_silver": "2.º puesto, plata",
|
||||
"medal_bronze": "3.er puesto, bronce"
|
||||
},
|
||||
"leaderboards": {
|
||||
"title": "Clasificaciones",
|
||||
"subtitle_week": "Semana {{number}} · {{range}} · Se reinicia en {{countdown}}",
|
||||
"subtitle_all": "Récords acumulados desde que cada jugador se unió a Hydra",
|
||||
"countdown_days": "{{days}} d {{hours}} h",
|
||||
"countdown_hours": "{{hours}} h {{minutes}} min",
|
||||
"countdown_minutes": "{{minutes}} min",
|
||||
"scope_label": "Alcance de la clasificación",
|
||||
"scope_global": "Global",
|
||||
"scope_friends": "Amigos",
|
||||
"period_label": "Período",
|
||||
"period_week": "Semana",
|
||||
"period_all": "Histórico",
|
||||
"tabs_label": "Tipo de clasificación",
|
||||
"tab_ranking": "Clasificación",
|
||||
"tab_games": "Por juego",
|
||||
"tab_stats": "Tus estadísticas",
|
||||
"metric_label": "Métrica",
|
||||
"metric_playtime": "Tiempo de juego",
|
||||
"metric_games": "Juegos",
|
||||
"metric_friends": "Amigos",
|
||||
"metric_achievements": "Logros",
|
||||
"metric_souvenirs": "Recuerdos",
|
||||
"metric_sessions": "Sesiones",
|
||||
"metric_streak": "Racha",
|
||||
"metric_reviews": "Reseñas",
|
||||
"leaders_title": "Líderes de la semana",
|
||||
"leaders_all_title": "Plusmarquistas históricos",
|
||||
"no_leader": "Nadie todavía",
|
||||
"leader_error": "No se pudo cargar",
|
||||
"metal_gold": "Oro",
|
||||
"metal_silver": "Plata",
|
||||
"metal_bronze": "Bronce",
|
||||
"leader_of_week": "Líder de la semana",
|
||||
"record_holder": "Plusmarquista",
|
||||
"top_of_total": "Top {{count}} de {{total}} jugadores",
|
||||
"players_count_one": "{{count}} jugador",
|
||||
"players_count_other": "{{count}} jugadores",
|
||||
"value_hours": "{{amount}} h",
|
||||
"value_minutes": "{{amount}} min",
|
||||
"value_games_one": "{{amount}} juego",
|
||||
"value_games_other": "{{amount}} juegos",
|
||||
"value_friends_one": "{{amount}} amigo",
|
||||
"value_friends_other": "{{amount}} amigos",
|
||||
"value_achievements": "{{amount}}",
|
||||
"value_souvenirs": "{{amount}}",
|
||||
"value_streak_one": "{{amount}} día",
|
||||
"value_streak_other": "{{amount}} días",
|
||||
"value_reviews_one": "{{amount}} voto",
|
||||
"value_reviews_other": "{{amount}} votos",
|
||||
"delta_label": "Cambio desde la semana pasada: {{count}}",
|
||||
"streak_active": "Racha en curso",
|
||||
"show_more": "Mostrar más",
|
||||
"error_title": "No se pudo cargar",
|
||||
"error_description": "Revisa tu conexión e inténtalo de nuevo.",
|
||||
"retry": "Reintentar",
|
||||
"empty_week_title": "Nadie ha puntuado esta semana todavía",
|
||||
"empty_week_description": "Juega para ser el primero de la clasificación semanal.",
|
||||
"empty_all_title": "Aún no hay récords",
|
||||
"empty_all_description": "Juega para ser el primero de esta clasificación.",
|
||||
"empty_friends_title": "No hay amigos con quien comparar",
|
||||
"empty_friends_description": "Añade amigos para ver tu posición frente a ellos.",
|
||||
"game_metrics_empty_title": "Aún no hay rankings por juego",
|
||||
"game_metrics_empty_description": "Los rankings por juego aparecerán aquí pronto.",
|
||||
"empty_friends_game_title": "Ninguno de tus amigos juega a este juego",
|
||||
"empty_friend_streaks_description": "Añade amigos para ver quién juega cada día.",
|
||||
"find_friends": "Buscar amigos",
|
||||
"sign_in_hint": "Inicia sesión para ver tu posición, tus juegos y la clasificación de tus amigos.",
|
||||
"review_score": "{{score}}/5",
|
||||
"review_top_week": "Reseña más votada de la semana",
|
||||
"review_top_all": "Reseña más votada de la historia",
|
||||
"review_top_of": "Reseña más votada de {{name}}",
|
||||
"review_open_game": "Ver en la página de {{game}}",
|
||||
"my_games_title": "Clasificaciones de tus juegos",
|
||||
"search_game": "Buscar juego",
|
||||
"search_game_placeholder": "Buscar un juego en tu biblioteca",
|
||||
"games_empty_title": "Aún no hay clasificaciones de juegos",
|
||||
"games_empty_description": "Juega a un juego para aparecer en su clasificación.",
|
||||
"games_search_empty": "Ningún juego coincide con tu búsqueda.",
|
||||
"ordinal": "{{rank}}.º",
|
||||
"of_total": "de {{total}}",
|
||||
"leader_name": "Líder: {{name}}",
|
||||
"game_ranking_title": "Clasificación de {{game}}",
|
||||
"game_ranking_subtitle": "{{total}} jugadores · Estás en el {{rank}}.º puesto",
|
||||
"open_game_page": "Abrir página del juego",
|
||||
"mine_week_title": "Tu semana",
|
||||
"mine_all_title": "Tu historial",
|
||||
"daily_streak": "Racha diaria",
|
||||
"streak_current": "Actual",
|
||||
"streak_best": "Récord",
|
||||
"days_unit_one": "día",
|
||||
"days_unit_other": "días",
|
||||
"last_weeks": "Últimas {{count}} semanas",
|
||||
"legend_min": "0 h",
|
||||
"legend_max": "3 h+",
|
||||
"today_date": "Hoy, {{date}}",
|
||||
"not_played": "No jugó",
|
||||
"duration_hours_minutes": "{{hours}} h {{minutes}} min",
|
||||
"duration_minutes": "{{minutes}} min",
|
||||
"online": "En línea",
|
||||
"offline": "Desconectado",
|
||||
"days_count_one": "{{count}} día",
|
||||
"days_count_other": "{{count}} días",
|
||||
"see_all_friends": "Ver todos los amigos",
|
||||
"friends_streak": "Rachas entre amigos",
|
||||
"badges_title": "Tus insignias",
|
||||
"badges_count": "{{earned}} de {{total}} conseguidas",
|
||||
"badge_top_percent": "Top {{percent}}% en {{game}}",
|
||||
"badge_weekly_leader": "Líder semanal en {{game}}",
|
||||
"badge_top_rank": "Top {{rank}} en {{game}}",
|
||||
"badge_friends_podium": "Podio entre amigos",
|
||||
"badge_streak_record_one": "Racha de {{count}} día",
|
||||
"badge_streak_record_other": "Racha de {{count}} días",
|
||||
"badge_personal_record": "Récord personal",
|
||||
"you_are_at": "Estás en el {{rank}}.º puesto",
|
||||
"week_number": "Semana {{number}}",
|
||||
"chip_top_percent": "TOP {{percent}}%",
|
||||
"chip_top_rank": "TOP {{rank}}",
|
||||
"chip_days_one": "{{count}} DÍA",
|
||||
"chip_days_other": "{{count}} DÍAS",
|
||||
"badge_earned_on": "Conseguida el {{date}}",
|
||||
"badge_since": "Desde {{date}}",
|
||||
"badge_remaining": "Faltan {{amount}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"start_typing": "Comece a digitar para pesquisar…"
|
||||
},
|
||||
"sidebar": {
|
||||
"leaderboards": "Rankings",
|
||||
"catalogue": "Catálogo",
|
||||
"library": "Biblioteca",
|
||||
"downloads": "Downloads",
|
||||
|
|
@ -150,6 +151,7 @@
|
|||
"custom_assets_not_sync": "Suas artes personalizadas não serão sincronizadas com sua conta."
|
||||
},
|
||||
"header": {
|
||||
"leaderboards": "Rankings",
|
||||
"minimize": "Minimizar",
|
||||
"maximize": "Maximizar",
|
||||
"restore": "Restaurar",
|
||||
|
|
@ -2599,5 +2601,129 @@
|
|||
"medal_gold": "1º lugar, ouro",
|
||||
"medal_silver": "2º lugar, prata",
|
||||
"medal_bronze": "3º lugar, bronze"
|
||||
},
|
||||
"leaderboards": {
|
||||
"title": "Rankings",
|
||||
"subtitle_week": "Semana {{number}} · {{range}} · Reinicia em {{countdown}}",
|
||||
"subtitle_all": "Recordes acumulados desde que cada jogador entrou no Hydra",
|
||||
"countdown_days": "{{days}} d {{hours}} h",
|
||||
"countdown_hours": "{{hours}} h {{minutes}} min",
|
||||
"countdown_minutes": "{{minutes}} min",
|
||||
"scope_label": "Escopo",
|
||||
"scope_global": "Global",
|
||||
"scope_friends": "Amigos",
|
||||
"period_label": "Período",
|
||||
"period_week": "Semana",
|
||||
"period_all": "Todos os tempos",
|
||||
"tabs_label": "Tipo de ranking",
|
||||
"tab_ranking": "Ranking",
|
||||
"tab_games": "Por jogo",
|
||||
"tab_stats": "Suas estatísticas",
|
||||
"metric_label": "Métrica",
|
||||
"metric_playtime": "Tempo de jogo",
|
||||
"metric_games": "Jogos",
|
||||
"metric_friends": "Amigos",
|
||||
"metric_achievements": "Conquistas",
|
||||
"metric_souvenirs": "Souvenirs",
|
||||
"metric_sessions": "Sessões",
|
||||
"metric_streak": "Sequência",
|
||||
"metric_reviews": "Reviews",
|
||||
"leaders_title": "Líderes da semana",
|
||||
"leaders_all_title": "Recordistas de todos os tempos",
|
||||
"no_leader": "Ninguém ainda",
|
||||
"leader_error": "Não carregou",
|
||||
"metal_gold": "Ouro",
|
||||
"metal_silver": "Prata",
|
||||
"metal_bronze": "Bronze",
|
||||
"leader_of_week": "Líder da semana",
|
||||
"record_holder": "Recordista",
|
||||
"top_of_total": "Top {{count}} de {{total}} jogadores",
|
||||
"players_count_one": "{{count}} jogador",
|
||||
"players_count_other": "{{count}} jogadores",
|
||||
"value_hours": "{{amount}} h",
|
||||
"value_minutes": "{{amount}} min",
|
||||
"value_games_one": "{{amount}} jogo",
|
||||
"value_games_other": "{{amount}} jogos",
|
||||
"value_friends_one": "{{amount}} amigo",
|
||||
"value_friends_other": "{{amount}} amigos",
|
||||
"value_achievements": "{{amount}}",
|
||||
"value_souvenirs": "{{amount}}",
|
||||
"value_streak_one": "{{amount}} dia",
|
||||
"value_streak_other": "{{amount}} dias",
|
||||
"value_reviews_one": "{{amount}} voto",
|
||||
"value_reviews_other": "{{amount}} votos",
|
||||
"delta_label": "Variação desde a semana passada: {{count}}",
|
||||
"streak_active": "Sequência em andamento",
|
||||
"show_more": "Mostrar mais",
|
||||
"error_title": "Não foi possível carregar",
|
||||
"error_description": "Verifique sua conexão e tente novamente.",
|
||||
"retry": "Tentar novamente",
|
||||
"empty_week_title": "Ninguém pontuou nesta semana ainda",
|
||||
"empty_week_description": "Jogue para ser o primeiro do ranking da semana.",
|
||||
"empty_all_title": "Nenhum recorde ainda",
|
||||
"empty_all_description": "Jogue para ser o primeiro deste ranking.",
|
||||
"empty_friends_title": "Nenhum amigo para comparar",
|
||||
"empty_friends_description": "Adicione amigos para ver sua posição entre eles.",
|
||||
"game_metrics_empty_title": "Nenhum ranking por jogo ainda",
|
||||
"game_metrics_empty_description": "Os rankings por jogo vão aparecer aqui em breve.",
|
||||
"empty_friends_game_title": "Nenhum amigo seu joga este jogo",
|
||||
"empty_friend_streaks_description": "Adicione amigos para ver quem joga todo dia.",
|
||||
"find_friends": "Encontrar amigos",
|
||||
"sign_in_hint": "Entre para ver sua posição, seus jogos e o ranking dos seus amigos.",
|
||||
"review_score": "{{score}}/5",
|
||||
"review_top_week": "Review mais votada da semana",
|
||||
"review_top_all": "Review mais votada de todos os tempos",
|
||||
"review_top_of": "Review mais votada de {{name}}",
|
||||
"review_open_game": "Ver na página de {{game}}",
|
||||
"my_games_title": "Rankings dos seus jogos",
|
||||
"search_game": "Buscar jogo",
|
||||
"search_game_placeholder": "Buscar jogo na biblioteca",
|
||||
"games_empty_title": "Nenhum ranking de jogo ainda",
|
||||
"games_empty_description": "Jogue algum jogo para aparecer no ranking dele.",
|
||||
"games_search_empty": "Nenhum jogo encontrado.",
|
||||
"ordinal": "{{rank}}º",
|
||||
"of_total": "de {{total}}",
|
||||
"leader_name": "Líder: {{name}}",
|
||||
"game_ranking_title": "Ranking de {{game}}",
|
||||
"game_ranking_subtitle": "{{total}} jogadores · Você está em {{rank}}º",
|
||||
"open_game_page": "Abrir página do jogo",
|
||||
"mine_week_title": "Sua semana",
|
||||
"mine_all_title": "Seu histórico",
|
||||
"daily_streak": "Sequência diária",
|
||||
"streak_current": "Atual",
|
||||
"streak_best": "Recorde",
|
||||
"days_unit_one": "dia",
|
||||
"days_unit_other": "dias",
|
||||
"last_weeks": "Últimas {{count}} semanas",
|
||||
"legend_min": "0 h",
|
||||
"legend_max": "3 h+",
|
||||
"today_date": "Hoje, {{date}}",
|
||||
"not_played": "Não jogou",
|
||||
"duration_hours_minutes": "{{hours}} h {{minutes}} min",
|
||||
"duration_minutes": "{{minutes}} min",
|
||||
"online": "Online",
|
||||
"offline": "Offline",
|
||||
"days_count_one": "{{count}} dia",
|
||||
"days_count_other": "{{count}} dias",
|
||||
"see_all_friends": "Ver todos os amigos",
|
||||
"friends_streak": "Sequência entre amigos",
|
||||
"badges_title": "Suas insígnias",
|
||||
"badges_count": "{{earned}} de {{total}} conquistadas",
|
||||
"badge_top_percent": "Top {{percent}}% em {{game}}",
|
||||
"badge_weekly_leader": "Líder semanal em {{game}}",
|
||||
"badge_top_rank": "Top {{rank}} em {{game}}",
|
||||
"badge_friends_podium": "Pódio entre amigos",
|
||||
"badge_streak_record_one": "Sequência de {{count}} dia",
|
||||
"badge_streak_record_other": "Sequência de {{count}} dias",
|
||||
"badge_personal_record": "Recorde pessoal",
|
||||
"you_are_at": "Você está em {{rank}}º",
|
||||
"week_number": "Semana {{number}}",
|
||||
"chip_top_percent": "TOP {{percent}}%",
|
||||
"chip_top_rank": "TOP {{rank}}",
|
||||
"chip_days_one": "{{count}} DIA",
|
||||
"chip_days_other": "{{count}} DIAS",
|
||||
"badge_earned_on": "Conquistada em {{date}}",
|
||||
"badge_since": "Desde {{date}}",
|
||||
"badge_remaining": "faltam {{amount}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -22,6 +22,7 @@
|
|||
"achievements": "🏆 Игры с достижениями"
|
||||
},
|
||||
"sidebar": {
|
||||
"leaderboards": "Рейтинги",
|
||||
"catalogue": "Каталог",
|
||||
"library": "Библиотека",
|
||||
"downloads": "Загрузки",
|
||||
|
|
@ -147,6 +148,7 @@
|
|||
"custom_assets_not_sync": "Ваши пользовательские изображения не будут синхронизированы с вашей учётной записью."
|
||||
},
|
||||
"header": {
|
||||
"leaderboards": "Рейтинги",
|
||||
"minimize": "Свернуть",
|
||||
"maximize": "Развернуть",
|
||||
"restore": "Восстановить",
|
||||
|
|
@ -2358,5 +2360,147 @@
|
|||
"medal_gold": "1-е место, золото",
|
||||
"medal_silver": "2-е место, серебро",
|
||||
"medal_bronze": "3-е место, бронза"
|
||||
},
|
||||
"leaderboards": {
|
||||
"title": "Рейтинги",
|
||||
"subtitle_week": "Неделя {{number}} · {{range}} · Сброс через {{countdown}}",
|
||||
"subtitle_all": "Рекорды, накопленные с момента регистрации каждого игрока в Hydra",
|
||||
"countdown_days": "{{days}} д {{hours}} ч",
|
||||
"countdown_hours": "{{hours}} ч {{minutes}} мин",
|
||||
"countdown_minutes": "{{minutes}} мин",
|
||||
"scope_label": "Охват рейтинга",
|
||||
"scope_global": "Глобальный",
|
||||
"scope_friends": "Друзья",
|
||||
"period_label": "Период",
|
||||
"period_week": "Неделя",
|
||||
"period_all": "За всё время",
|
||||
"tabs_label": "Тип рейтинга",
|
||||
"tab_ranking": "Рейтинг",
|
||||
"tab_games": "По играм",
|
||||
"tab_stats": "Ваша статистика",
|
||||
"metric_label": "Показатель",
|
||||
"metric_playtime": "Время в игре",
|
||||
"metric_games": "Игры",
|
||||
"metric_friends": "Друзья",
|
||||
"metric_achievements": "Достижения",
|
||||
"metric_souvenirs": "Сувениры",
|
||||
"metric_sessions": "Сессии",
|
||||
"metric_streak": "Серия",
|
||||
"metric_reviews": "Обзоры",
|
||||
"leaders_title": "Лидеры недели",
|
||||
"leaders_all_title": "Рекордсмены за всё время",
|
||||
"no_leader": "Пока никого",
|
||||
"leader_error": "Не удалось загрузить",
|
||||
"metal_gold": "Золото",
|
||||
"metal_silver": "Серебро",
|
||||
"metal_bronze": "Бронза",
|
||||
"leader_of_week": "Лидер недели",
|
||||
"record_holder": "Рекордсмен",
|
||||
"top_of_total": "Топ {{count}} из {{total}} игроков",
|
||||
"players_count_one": "{{count}} игрок",
|
||||
"players_count_few": "{{count}} игрока",
|
||||
"players_count_many": "{{count}} игроков",
|
||||
"players_count_other": "{{count}} игрока",
|
||||
"value_hours": "{{amount}} ч",
|
||||
"value_minutes": "{{amount}} мин",
|
||||
"value_games_one": "{{amount}} игра",
|
||||
"value_games_few": "{{amount}} игры",
|
||||
"value_games_many": "{{amount}} игр",
|
||||
"value_games_other": "{{amount}} игры",
|
||||
"value_friends_one": "{{amount}} друг",
|
||||
"value_friends_few": "{{amount}} друга",
|
||||
"value_friends_many": "{{amount}} друзей",
|
||||
"value_friends_other": "{{amount}} друга",
|
||||
"value_achievements": "{{amount}}",
|
||||
"value_souvenirs": "{{amount}}",
|
||||
"value_streak_one": "{{amount}} день",
|
||||
"value_streak_few": "{{amount}} дня",
|
||||
"value_streak_many": "{{amount}} дней",
|
||||
"value_streak_other": "{{amount}} дня",
|
||||
"value_reviews_one": "{{amount}} голос",
|
||||
"value_reviews_few": "{{amount}} голоса",
|
||||
"value_reviews_many": "{{amount}} голосов",
|
||||
"value_reviews_other": "{{amount}} голоса",
|
||||
"delta_label": "Изменение с прошлой недели: {{count}}",
|
||||
"streak_active": "Серия продолжается",
|
||||
"show_more": "Показать ещё",
|
||||
"error_title": "Не удалось загрузить",
|
||||
"error_description": "Проверьте подключение и попробуйте снова.",
|
||||
"retry": "Попробовать снова",
|
||||
"empty_week_title": "На этой неделе пока никто не набрал очков",
|
||||
"empty_week_description": "Играйте, чтобы первым попасть в рейтинг недели.",
|
||||
"empty_all_title": "Рекордов пока нет",
|
||||
"empty_all_description": "Играйте, чтобы первым попасть в этот рейтинг.",
|
||||
"empty_friends_title": "Нет друзей для сравнения",
|
||||
"empty_friends_description": "Добавьте друзей, чтобы сравнить своё место с ними.",
|
||||
"game_metrics_empty_title": "Рейтингов по играм пока нет",
|
||||
"game_metrics_empty_description": "Рейтинги по играм скоро появятся здесь.",
|
||||
"empty_friends_game_title": "Никто из ваших друзей не играет в эту игру",
|
||||
"empty_friend_streaks_description": "Добавьте друзей, чтобы видеть, кто играет каждый день.",
|
||||
"find_friends": "Найти друзей",
|
||||
"sign_in_hint": "Войдите, чтобы видеть своё место, свои игры и рейтинг друзей.",
|
||||
"review_score": "{{score}}/5",
|
||||
"review_top_week": "Самый популярный обзор недели",
|
||||
"review_top_all": "Самый популярный обзор за всё время",
|
||||
"review_top_of": "Самый популярный обзор {{name}}",
|
||||
"review_open_game": "Открыть страницу {{game}}",
|
||||
"my_games_title": "Рейтинги ваших игр",
|
||||
"search_game": "Поиск игры",
|
||||
"search_game_placeholder": "Найти игру в библиотеке",
|
||||
"games_empty_title": "Пока нет рейтингов игр",
|
||||
"games_empty_description": "Сыграйте в игру, чтобы попасть в её рейтинг.",
|
||||
"games_search_empty": "Игры не найдены.",
|
||||
"ordinal": "#{{rank}}",
|
||||
"of_total": "из {{total}}",
|
||||
"leader_name": "Лидер: {{name}}",
|
||||
"game_ranking_title": "Рейтинг {{game}}",
|
||||
"game_ranking_subtitle": "{{total}} игроков · Вы на {{rank}} месте",
|
||||
"open_game_page": "Открыть страницу игры",
|
||||
"mine_week_title": "Ваша неделя",
|
||||
"mine_all_title": "Ваша история",
|
||||
"daily_streak": "Ежедневная серия",
|
||||
"streak_current": "Текущая",
|
||||
"streak_best": "Рекорд",
|
||||
"days_unit_one": "день",
|
||||
"days_unit_few": "дня",
|
||||
"days_unit_many": "дней",
|
||||
"days_unit_other": "дня",
|
||||
"last_weeks": "Последние {{count}} недель",
|
||||
"legend_min": "0 ч",
|
||||
"legend_max": "3 ч+",
|
||||
"today_date": "Сегодня, {{date}}",
|
||||
"not_played": "Не играл",
|
||||
"duration_hours_minutes": "{{hours}} ч {{minutes}} мин",
|
||||
"duration_minutes": "{{minutes}} мин",
|
||||
"online": "В сети",
|
||||
"offline": "Не в сети",
|
||||
"days_count_one": "{{count}} день",
|
||||
"days_count_few": "{{count}} дня",
|
||||
"days_count_many": "{{count}} дней",
|
||||
"days_count_other": "{{count}} дня",
|
||||
"see_all_friends": "Все друзья",
|
||||
"friends_streak": "Серии друзей",
|
||||
"badges_title": "Ваши значки",
|
||||
"badges_count": "Получено {{earned}} из {{total}}",
|
||||
"badge_top_percent": "Топ {{percent}}% в {{game}}",
|
||||
"badge_weekly_leader": "Лидер недели в {{game}}",
|
||||
"badge_top_rank": "Топ {{rank}} в {{game}}",
|
||||
"badge_friends_podium": "Пьедестал среди друзей",
|
||||
"badge_streak_record_one": "Серия из {{count}} дня",
|
||||
"badge_streak_record_few": "Серия из {{count}} дней",
|
||||
"badge_streak_record_many": "Серия из {{count}} дней",
|
||||
"badge_streak_record_other": "Серия из {{count}} дня",
|
||||
"badge_personal_record": "Личный рекорд",
|
||||
"you_are_at": "Вы на {{rank}} месте",
|
||||
"week_number": "Неделя {{number}}",
|
||||
"chip_top_percent": "ТОП {{percent}}%",
|
||||
"chip_top_rank": "ТОП {{rank}}",
|
||||
"chip_days_one": "{{count}} ДЕНЬ",
|
||||
"chip_days_few": "{{count}} ДНЯ",
|
||||
"chip_days_many": "{{count}} ДНЕЙ",
|
||||
"chip_days_other": "{{count}} ДНЯ",
|
||||
"badge_earned_on": "Получен {{date}}",
|
||||
"badge_since": "С {{date}}",
|
||||
"badge_remaining": "Осталось {{amount}}"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -41,6 +41,7 @@ const pathTitle: Record<string, string> = {
|
|||
"/catalogue": "catalogue",
|
||||
"/library": "library",
|
||||
"/downloads": "downloads",
|
||||
"/leaderboards": "leaderboards",
|
||||
"/settings": "settings",
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -6,6 +6,29 @@ import {
|
|||
BookIcon,
|
||||
} from "@primer/octicons-react";
|
||||
|
||||
function LeaderboardsIcon() {
|
||||
return (
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
style={{
|
||||
display: "inline-block",
|
||||
verticalAlign: "text-bottom",
|
||||
overflow: "visible",
|
||||
}}
|
||||
>
|
||||
<path d="M5 2.5h6V6a3 3 0 0 1-6 0zM5 4H3v1a2 2 0 0 0 2 2M11 4h2v1a2 2 0 0 1-2 2M8 9v3M5.5 13.5h5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export const routes = [
|
||||
{
|
||||
path: "/",
|
||||
|
|
@ -27,6 +50,12 @@ export const routes = [
|
|||
nameKey: "downloads",
|
||||
render: () => <DownloadIcon />,
|
||||
},
|
||||
{
|
||||
path: "/leaderboards",
|
||||
nameKey: "leaderboards",
|
||||
isGlobalLeaderboards: true,
|
||||
render: () => <LeaderboardsIcon />,
|
||||
},
|
||||
{
|
||||
path: "/settings",
|
||||
nameKey: "settings",
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ import {
|
|||
useDownload,
|
||||
useLibrary,
|
||||
useToast,
|
||||
useGlobalLeaderboardsFeature,
|
||||
} from "@renderer/hooks";
|
||||
import { selectIsLibraryLoading } from "@renderer/features";
|
||||
import { routes } from "./routes";
|
||||
|
|
@ -86,6 +87,8 @@ const readStoredSidebarPlatforms = (): string[] => {
|
|||
|
||||
export function Sidebar() {
|
||||
const { t } = useTranslation(["sidebar", "library"]);
|
||||
const { isEnabled: isGlobalLeaderboardsEnabled } =
|
||||
useGlobalLeaderboardsFeature();
|
||||
const { library, updateLibrary } = useLibrary();
|
||||
const isLibraryLoading = useAppSelector(selectIsLibraryLoading);
|
||||
const runningGameId = useAppSelector(
|
||||
|
|
@ -515,23 +518,28 @@ export function Sidebar() {
|
|||
<div className="sidebar__content">
|
||||
<section className="sidebar__section">
|
||||
<ul className="sidebar__menu">
|
||||
{routes.map(({ nameKey, path, render }) => (
|
||||
<li
|
||||
key={nameKey}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active": location.pathname === path,
|
||||
})}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={() => handleSidebarItemClick(path)}
|
||||
{routes
|
||||
.filter(
|
||||
(route) =>
|
||||
!route.isGlobalLeaderboards || isGlobalLeaderboardsEnabled
|
||||
)
|
||||
.map(({ nameKey, path, render }) => (
|
||||
<li
|
||||
key={nameKey}
|
||||
className={cn("sidebar__menu-item", {
|
||||
"sidebar__menu-item--active": location.pathname === path,
|
||||
})}
|
||||
>
|
||||
{render()}
|
||||
<span>{t(nameKey)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
<button
|
||||
type="button"
|
||||
className="sidebar__menu-item-button"
|
||||
onClick={() => handleSidebarItemClick(path)}
|
||||
>
|
||||
{render()}
|
||||
<span>{t(nameKey)}</span>
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
|
||||
{window.electron.platform === "linux" && homebrewFolderExists && (
|
||||
<li className="sidebar__menu-item sidebar__menu-item--decky">
|
||||
|
|
|
|||
141
src/renderer/src/helpers/response-cache.test.ts
Normal file
141
src/renderer/src/helpers/response-cache.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
createResponseCache,
|
||||
getRequestKey,
|
||||
type ApiGet,
|
||||
} from "./response-cache.js";
|
||||
|
||||
const options = { params: { window: "week", take: 100 }, needsAuth: true };
|
||||
|
||||
describe("getRequestKey", () => {
|
||||
it("is stable regardless of param order and separates auth and account", () => {
|
||||
assert.equal(
|
||||
getRequestKey("/x", { params: { b: 2, a: 1 }, needsAuth: true }),
|
||||
getRequestKey("/x", { params: { a: 1, b: 2 }, needsAuth: true })
|
||||
);
|
||||
assert.notEqual(
|
||||
getRequestKey("/x", { params: { a: 1 }, needsAuth: true }),
|
||||
getRequestKey("/x", { params: { a: 1 }, needsAuth: false })
|
||||
);
|
||||
assert.notEqual(
|
||||
getRequestKey("/x", options, "account-a"),
|
||||
getRequestKey("/x", options, "account-b")
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("createResponseCache", () => {
|
||||
it("caches by request, dedupes in flight requests and expires", async () => {
|
||||
let calls = 0;
|
||||
let clock = 0;
|
||||
const get: ApiGet = async () => {
|
||||
calls += 1;
|
||||
return { calls };
|
||||
};
|
||||
const cache = createResponseCache(get, () => clock, 1000);
|
||||
|
||||
assert.equal(cache.peek("/a", options), undefined);
|
||||
await Promise.all([cache.get("/a", options), cache.get("/a", options)]);
|
||||
assert.equal(calls, 1);
|
||||
assert.deepEqual(cache.peek("/a", options), { calls: 1 });
|
||||
|
||||
await cache.get("/b", options);
|
||||
assert.equal(calls, 2);
|
||||
|
||||
clock = 1001;
|
||||
assert.equal(cache.peek("/a", options), undefined);
|
||||
await cache.get("/a", options);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
it("does not cache failures and can be cleared", async () => {
|
||||
let calls = 0;
|
||||
const get: ApiGet = async () => {
|
||||
calls += 1;
|
||||
if (calls === 1) throw new Error("boom");
|
||||
return "ok";
|
||||
};
|
||||
const cache = createResponseCache(get);
|
||||
|
||||
await assert.rejects(cache.get("/a", options));
|
||||
assert.equal(await cache.get("/a", options), "ok");
|
||||
|
||||
cache.clear();
|
||||
assert.equal(cache.peek("/a", options), undefined);
|
||||
await cache.get("/a", options);
|
||||
assert.equal(calls, 3);
|
||||
});
|
||||
|
||||
it("evicts a single response", async () => {
|
||||
let calls = 0;
|
||||
const cache = createResponseCache(async () => {
|
||||
calls += 1;
|
||||
return calls;
|
||||
});
|
||||
|
||||
await cache.get("/a", options);
|
||||
await cache.get("/b", options);
|
||||
cache.evict("/a", options);
|
||||
|
||||
assert.equal(cache.peek("/a", options), undefined);
|
||||
assert.equal(cache.peek("/b", options), 2);
|
||||
assert.equal(await cache.get("/a", options), 3);
|
||||
});
|
||||
|
||||
it("keeps each account's responses apart", async () => {
|
||||
let calls = 0;
|
||||
const cache = createResponseCache(async () => {
|
||||
calls += 1;
|
||||
return calls;
|
||||
});
|
||||
|
||||
await cache.get("/a", options, "account-a");
|
||||
assert.equal(cache.peek("/a", options, "account-b"), undefined);
|
||||
await cache.get("/a", options, "account-b");
|
||||
assert.equal(calls, 2);
|
||||
});
|
||||
|
||||
it("ignores responses from before a clear", async () => {
|
||||
let resolveOld: (value: unknown) => void = () => undefined;
|
||||
let calls = 0;
|
||||
const get: ApiGet = () => {
|
||||
calls += 1;
|
||||
if (calls === 1) {
|
||||
return new Promise((resolve) => {
|
||||
resolveOld = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve("fresh");
|
||||
};
|
||||
const cache = createResponseCache(get);
|
||||
|
||||
const old = cache.get("/a", options);
|
||||
cache.clear();
|
||||
assert.equal(await cache.get("/a", options), "fresh");
|
||||
|
||||
resolveOld("stale");
|
||||
await old;
|
||||
assert.equal(cache.peek("/a", options), "fresh");
|
||||
});
|
||||
|
||||
it("drops stale entries and keeps at most maxEntries", async () => {
|
||||
let clock = 0;
|
||||
const cache = createResponseCache(
|
||||
async (path) => path,
|
||||
() => clock,
|
||||
1000,
|
||||
2
|
||||
);
|
||||
|
||||
await cache.get("/a", options);
|
||||
await cache.get("/b", options);
|
||||
await cache.get("/c", options);
|
||||
assert.equal(cache.peek("/a", options), undefined);
|
||||
assert.equal(cache.peek("/c", options), "/c");
|
||||
|
||||
clock = 1001;
|
||||
assert.equal(cache.peek("/c", options), undefined);
|
||||
});
|
||||
});
|
||||
103
src/renderer/src/helpers/response-cache.ts
Normal file
103
src/renderer/src/helpers/response-cache.ts
Normal file
|
|
@ -0,0 +1,103 @@
|
|||
export const RESPONSE_CACHE_TTL_MS = 60_000;
|
||||
export const MAX_CACHED_RESPONSES = 200;
|
||||
|
||||
export interface ApiRequestOptions {
|
||||
params: Record<string, string | number>;
|
||||
needsAuth: boolean;
|
||||
}
|
||||
|
||||
export type ApiGet = (
|
||||
path: string,
|
||||
options: ApiRequestOptions
|
||||
) => Promise<unknown>;
|
||||
|
||||
export const getRequestKey = (
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
accountId: string | null = null
|
||||
) => {
|
||||
const params = Object.entries(options.params)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join("&");
|
||||
|
||||
return `${accountId ?? "guest"}|${path}?${params}|auth=${options.needsAuth}`;
|
||||
};
|
||||
|
||||
export const createResponseCache = (
|
||||
get: ApiGet,
|
||||
now: () => number = Date.now,
|
||||
ttl = RESPONSE_CACHE_TTL_MS,
|
||||
maxEntries = MAX_CACHED_RESPONSES
|
||||
) => {
|
||||
const cache = new Map<string, { at: number; response: unknown }>();
|
||||
const inFlight = new Map<string, Promise<unknown>>();
|
||||
let generation = 0;
|
||||
|
||||
const peek = (
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
accountId: string | null = null
|
||||
) => {
|
||||
const key = getRequestKey(path, options, accountId);
|
||||
const cached = cache.get(key);
|
||||
if (!cached) return undefined;
|
||||
if (now() - cached.at < ttl) return cached.response;
|
||||
|
||||
cache.delete(key);
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const store = (key: string, response: unknown) => {
|
||||
cache.delete(key);
|
||||
cache.set(key, { at: now(), response });
|
||||
|
||||
while (cache.size > maxEntries) {
|
||||
const oldest = cache.keys().next().value;
|
||||
if (oldest === undefined) break;
|
||||
cache.delete(oldest);
|
||||
}
|
||||
};
|
||||
|
||||
const request = (
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
accountId: string | null = null
|
||||
): Promise<unknown> => {
|
||||
const key = getRequestKey(path, options, accountId);
|
||||
const cached = peek(path, options, accountId);
|
||||
if (cached !== undefined) return Promise.resolve(cached);
|
||||
|
||||
const pending = inFlight.get(key);
|
||||
if (pending !== undefined) return pending;
|
||||
|
||||
const requestGeneration = generation;
|
||||
const promise: Promise<unknown> = get(path, options)
|
||||
.then((response) => {
|
||||
if (requestGeneration === generation) store(key, response);
|
||||
return response;
|
||||
})
|
||||
.finally(() => {
|
||||
if (inFlight.get(key) === promise) inFlight.delete(key);
|
||||
});
|
||||
|
||||
inFlight.set(key, promise);
|
||||
return promise;
|
||||
};
|
||||
|
||||
const evict = (
|
||||
path: string,
|
||||
options: ApiRequestOptions,
|
||||
accountId: string | null = null
|
||||
) => {
|
||||
cache.delete(getRequestKey(path, options, accountId));
|
||||
};
|
||||
|
||||
const clear = () => {
|
||||
generation += 1;
|
||||
cache.clear();
|
||||
inFlight.clear();
|
||||
};
|
||||
|
||||
return { get: request, peek, evict, clear };
|
||||
};
|
||||
|
|
@ -9,6 +9,7 @@ export * from "./redux";
|
|||
export * from "./use-user-details";
|
||||
export * from "./use-format";
|
||||
export * from "./use-feature";
|
||||
export * from "./use-global-leaderboards-feature";
|
||||
export * from "./use-download-options-listener";
|
||||
export * from "./use-game-card";
|
||||
export * from "./use-search-history";
|
||||
|
|
|
|||
|
|
@ -47,6 +47,7 @@ export function useFeature() {
|
|||
|
||||
return {
|
||||
isFeatureEnabled,
|
||||
isFeaturesLoaded: features !== null,
|
||||
Feature,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
10
src/renderer/src/hooks/use-global-leaderboards-feature.ts
Normal file
10
src/renderer/src/hooks/use-global-leaderboards-feature.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
import { useFeature } from "./use-feature";
|
||||
|
||||
export function useGlobalLeaderboardsFeature() {
|
||||
const { isFeatureEnabled, isFeaturesLoaded, Feature } = useFeature();
|
||||
|
||||
return {
|
||||
isEnabled: isFeatureEnabled(Feature.GlobalLeaderboards),
|
||||
isLoaded: isFeaturesLoaded,
|
||||
};
|
||||
}
|
||||
|
|
@ -38,6 +38,7 @@ import Achievements from "./pages/achievements/achievements";
|
|||
import ThemeEditor from "./pages/theme-editor/theme-editor";
|
||||
import Library from "./pages/library/library";
|
||||
import Notifications from "./pages/notifications/notifications";
|
||||
import Leaderboards from "./pages/leaderboards/leaderboards";
|
||||
import { AchievementNotification } from "./pages/achievements/notification/achievement-notification";
|
||||
import { AchievementNotificationOverlay } from "./components/achievements/notification/achievement-notification-overlay";
|
||||
import GameLauncher from "./pages/game-launcher/game-launcher";
|
||||
|
|
@ -148,6 +149,7 @@ ReactDOM.createRoot(document.getElementById("root")!).render(
|
|||
<Route path="/profile/:userId" element={<Profile />} />
|
||||
<Route path="/achievements" element={<Achievements />} />
|
||||
<Route path="/notifications" element={<Notifications />} />
|
||||
<Route path="/leaderboards" element={<Leaderboards />} />
|
||||
</Route>
|
||||
|
||||
<Route path="/theme-editor" element={<ThemeEditor />} />
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import { describe, it } from "node:test";
|
|||
import {
|
||||
buildLeaderboardRequest,
|
||||
buildLeaderboardRows,
|
||||
createLeaderboardFetcher,
|
||||
fetchGameLeaderboard,
|
||||
formatDurationParts,
|
||||
getDurationParts,
|
||||
buildGameLeaderboardPath,
|
||||
getLeaderboardNudge,
|
||||
getLeaderboardState,
|
||||
|
|
@ -407,111 +409,40 @@ describe("buildLeaderboardRequest", () => {
|
|||
});
|
||||
});
|
||||
|
||||
describe("createLeaderboardFetcher", () => {
|
||||
describe("fetchGameLeaderboard", () => {
|
||||
const response = {
|
||||
users: [{ rank: 1, playTimeInSeconds: 60, user: user("a") }],
|
||||
};
|
||||
|
||||
const setup = () => {
|
||||
let clock = 0;
|
||||
it("requests the board and normalizes the response", async () => {
|
||||
const calls: string[] = [];
|
||||
const get: LeaderboardGet = async (path) => {
|
||||
const leaderboard = await fetchGameLeaderboard(async (path) => {
|
||||
calls.push(path);
|
||||
return response;
|
||||
};
|
||||
const fetchLeaderboard = createLeaderboardFetcher(get, () => clock, 1000);
|
||||
|
||||
return {
|
||||
calls,
|
||||
fetchLeaderboard,
|
||||
advance: (ms: number) => {
|
||||
clock += ms;
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
it("reuses the cached board inside the TTL and refetches after", async () => {
|
||||
const { calls, fetchLeaderboard, advance } = setup();
|
||||
|
||||
await fetchLeaderboard(query());
|
||||
advance(999);
|
||||
await fetchLeaderboard(query());
|
||||
assert.equal(calls.length, 1);
|
||||
|
||||
advance(1);
|
||||
await fetchLeaderboard(query());
|
||||
assert.equal(calls.length, 2);
|
||||
});
|
||||
|
||||
it("deduplicates concurrent requests for the same board", async () => {
|
||||
const { calls, fetchLeaderboard } = setup();
|
||||
|
||||
const [first, second] = await Promise.all([
|
||||
fetchLeaderboard(query()),
|
||||
fetchLeaderboard(query()),
|
||||
]);
|
||||
}, query());
|
||||
|
||||
assert.equal(calls.length, 1);
|
||||
assert.equal(first, second);
|
||||
});
|
||||
|
||||
it("keeps separate cache entries per period and login state", async () => {
|
||||
const { calls, fetchLeaderboard } = setup();
|
||||
|
||||
await fetchLeaderboard(query());
|
||||
await fetchLeaderboard(query({ period: "week" }));
|
||||
await fetchLeaderboard(query({ isLoggedIn: true, viewerId: "a" }));
|
||||
|
||||
assert.equal(calls.length, 3);
|
||||
});
|
||||
|
||||
it("keeps separate cache entries per account", async () => {
|
||||
const { calls, fetchLeaderboard } = setup();
|
||||
|
||||
await fetchLeaderboard(query({ isLoggedIn: true, viewerId: "a" }));
|
||||
await fetchLeaderboard(query({ isLoggedIn: true, viewerId: "b" }));
|
||||
await fetchLeaderboard(query({ isLoggedIn: true, viewerId: "a" }));
|
||||
|
||||
assert.equal(calls.length, 2);
|
||||
assert.equal(leaderboard.entries[0].value, 60);
|
||||
});
|
||||
|
||||
it("does not call the API for unsupported games", async () => {
|
||||
const { calls, fetchLeaderboard } = setup();
|
||||
let calls = 0;
|
||||
const get: LeaderboardGet = async () => {
|
||||
calls += 1;
|
||||
return response;
|
||||
};
|
||||
|
||||
const custom = await fetchLeaderboard(query({ shop: "custom" }));
|
||||
const missing = await fetchLeaderboard(query({ objectId: "" }));
|
||||
const custom = await fetchGameLeaderboard(get, query({ shop: "custom" }));
|
||||
const missing = await fetchGameLeaderboard(get, query({ objectId: "" }));
|
||||
|
||||
assert.equal(calls.length, 0);
|
||||
assert.equal(calls, 0);
|
||||
assert.deepEqual(custom.entries, []);
|
||||
assert.deepEqual(missing.entries, []);
|
||||
});
|
||||
|
||||
it("drops expired and overflowing entries", async () => {
|
||||
let clock = 0;
|
||||
const calls: string[] = [];
|
||||
const fetchLeaderboard = createLeaderboardFetcher(
|
||||
async (path, options) => {
|
||||
calls.push(`${path}:${options.params.range}`);
|
||||
return response;
|
||||
},
|
||||
() => clock,
|
||||
1000,
|
||||
1
|
||||
);
|
||||
|
||||
await fetchLeaderboard(query());
|
||||
await fetchLeaderboard(query({ period: "week" }));
|
||||
await fetchLeaderboard(query());
|
||||
assert.equal(calls.length, 3);
|
||||
|
||||
clock += 1000;
|
||||
await fetchLeaderboard(query());
|
||||
assert.equal(calls.length, 4);
|
||||
});
|
||||
|
||||
it("retries without auth when the session is stale", async () => {
|
||||
const auth: boolean[] = [];
|
||||
const fetchLeaderboard = createLeaderboardFetcher(
|
||||
const result = await fetchGameLeaderboard(
|
||||
async (_path, options) => {
|
||||
auth.push(options.needsAuth);
|
||||
if (options.needsAuth) {
|
||||
|
|
@ -520,10 +451,7 @@ describe("createLeaderboardFetcher", () => {
|
|||
);
|
||||
}
|
||||
return response;
|
||||
}
|
||||
);
|
||||
|
||||
const result = await fetchLeaderboard(
|
||||
},
|
||||
query({ isExtended: true, isLoggedIn: true, viewerId: "a" })
|
||||
);
|
||||
|
||||
|
|
@ -533,13 +461,12 @@ describe("createLeaderboardFetcher", () => {
|
|||
|
||||
it("does not retry other errors", async () => {
|
||||
let calls = 0;
|
||||
const fetchLeaderboard = createLeaderboardFetcher(async () => {
|
||||
calls += 1;
|
||||
throw new Error("boom");
|
||||
});
|
||||
|
||||
await assert.rejects(
|
||||
fetchLeaderboard(
|
||||
fetchGameLeaderboard(
|
||||
async () => {
|
||||
calls += 1;
|
||||
throw new Error("boom");
|
||||
},
|
||||
query({ isExtended: true, isLoggedIn: true, viewerId: "a" })
|
||||
),
|
||||
/boom/
|
||||
|
|
@ -547,19 +474,67 @@ describe("createLeaderboardFetcher", () => {
|
|||
assert.equal(calls, 1);
|
||||
});
|
||||
|
||||
it("does not cache failures", async () => {
|
||||
let attempt = 0;
|
||||
const fetchLeaderboard = createLeaderboardFetcher(async () => {
|
||||
attempt += 1;
|
||||
if (attempt === 1) throw new Error("boom");
|
||||
return response;
|
||||
it("propagates request failures", async () => {
|
||||
await assert.rejects(
|
||||
fetchGameLeaderboard(async () => {
|
||||
throw new Error("boom");
|
||||
}, query()),
|
||||
/boom/
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getDurationParts", () => {
|
||||
it("shows minutes below the limit with a one minute floor", () => {
|
||||
assert.deepEqual(getDurationParts(20, 120), { unit: "minutes", amount: 1 });
|
||||
assert.deepEqual(getDurationParts(0, 120), { unit: "minutes", amount: 0 });
|
||||
assert.deepEqual(getDurationParts(3000, 120), {
|
||||
unit: "minutes",
|
||||
amount: 50,
|
||||
});
|
||||
});
|
||||
|
||||
await assert.rejects(fetchLeaderboard(query()), /boom/);
|
||||
const result = await fetchLeaderboard(query());
|
||||
it("switches to hours at the limit", () => {
|
||||
assert.deepEqual(getDurationParts(7200, 120), { unit: "hours", amount: 2 });
|
||||
});
|
||||
|
||||
assert.equal(attempt, 2);
|
||||
assert.equal(result.entries.length, 1);
|
||||
it("keeps one decimal for hours", () => {
|
||||
assert.deepEqual(getDurationParts(9060, 120), {
|
||||
unit: "hours",
|
||||
amount: 2.5,
|
||||
});
|
||||
assert.deepEqual(getDurationParts(12540, 120), {
|
||||
unit: "hours",
|
||||
amount: 3.5,
|
||||
});
|
||||
});
|
||||
|
||||
it("rounds to whole hours from ten hours up", () => {
|
||||
assert.deepEqual(getDurationParts(37800, 120), {
|
||||
unit: "hours",
|
||||
amount: 11,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("formatDurationParts", () => {
|
||||
it("renders one decimal for hours and none for minutes", () => {
|
||||
assert.deepEqual(formatDurationParts(9060, 120, "en"), {
|
||||
unit: "hours",
|
||||
amount: "2.5",
|
||||
});
|
||||
assert.deepEqual(formatDurationParts(9060, 120, "pt-BR"), {
|
||||
unit: "hours",
|
||||
amount: "2,5",
|
||||
});
|
||||
assert.deepEqual(formatDurationParts(7200, 120, "en"), {
|
||||
unit: "hours",
|
||||
amount: "2",
|
||||
});
|
||||
assert.deepEqual(formatDurationParts(3000, 120, "en"), {
|
||||
unit: "minutes",
|
||||
amount: "50",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -2,9 +2,8 @@ import type { GameShop } from "@types";
|
|||
|
||||
export const LEADERBOARD_TAKE = 100;
|
||||
export const LEADERBOARD_CARD_SIZE = 5;
|
||||
export const LEADERBOARD_CACHE_TTL_MS = 60_000;
|
||||
export const LEADERBOARD_CACHE_MAX_ENTRIES = 50;
|
||||
export const LEADERBOARD_MIN_PROGRESS = 4;
|
||||
const FRACTIONAL_HOURS_LIMIT = 10;
|
||||
|
||||
export const LEADERBOARD_PERIODS = ["week", "all"] as const;
|
||||
export const LEADERBOARD_SCOPES = ["global", "friends"] as const;
|
||||
|
|
@ -341,16 +340,6 @@ export const buildLeaderboardRequest = (
|
|||
};
|
||||
};
|
||||
|
||||
export const getLeaderboardCacheKey = (query: LeaderboardQuery) => {
|
||||
const { path, options } = buildLeaderboardRequest(query);
|
||||
const params = Object.entries(options.params)
|
||||
.map(([key, value]) => `${key}=${value}`)
|
||||
.sort((a, b) => a.localeCompare(b))
|
||||
.join("&");
|
||||
|
||||
return `${path}?${params}|viewer=${query.viewerId ?? "anonymous"}`;
|
||||
};
|
||||
|
||||
const SIGNED_OUT_ERROR_NAME = "UserNotLoggedInError";
|
||||
const SIGNED_OUT_ERROR_MESSAGE = "user not logged in";
|
||||
|
||||
|
|
@ -359,54 +348,68 @@ export const isSignedOutError = (error: unknown) =>
|
|||
(error.name === SIGNED_OUT_ERROR_NAME ||
|
||||
error.message.includes(SIGNED_OUT_ERROR_MESSAGE));
|
||||
|
||||
export const createLeaderboardFetcher = (
|
||||
export const fetchGameLeaderboard = async (
|
||||
get: LeaderboardGet,
|
||||
now: () => number = Date.now,
|
||||
ttl = LEADERBOARD_CACHE_TTL_MS,
|
||||
maxEntries = LEADERBOARD_CACHE_MAX_ENTRIES
|
||||
) => {
|
||||
const cache = new Map<string, { at: number; leaderboard: Leaderboard }>();
|
||||
const inFlight = new Map<string, Promise<Leaderboard>>();
|
||||
query: LeaderboardQuery
|
||||
): Promise<Leaderboard> => {
|
||||
if (!isLeaderboardShop(query.shop) || !query.objectId) {
|
||||
return { entries: [], viewer: null };
|
||||
}
|
||||
|
||||
return (query: LeaderboardQuery): Promise<Leaderboard> => {
|
||||
if (!isLeaderboardShop(query.shop) || !query.objectId) {
|
||||
return Promise.resolve({ entries: [], viewer: null });
|
||||
}
|
||||
const { path, options } = buildLeaderboardRequest(query);
|
||||
const response = await get(path, options).catch((error: unknown) => {
|
||||
if (!options.needsAuth || !isSignedOutError(error)) throw error;
|
||||
return get(path, { ...options, needsAuth: false });
|
||||
});
|
||||
|
||||
const key = getLeaderboardCacheKey(query);
|
||||
const cached = cache.get(key);
|
||||
if (cached && now() - cached.at < ttl) {
|
||||
return Promise.resolve(cached.leaderboard);
|
||||
}
|
||||
if (cached) cache.delete(key);
|
||||
return normalizeLeaderboardResponse(response);
|
||||
};
|
||||
|
||||
const pending = inFlight.get(key);
|
||||
if (pending !== undefined) return pending;
|
||||
export const getDurationParts = (
|
||||
seconds: number,
|
||||
maxMinutes: number
|
||||
): { unit: "minutes" | "hours"; amount: number } => {
|
||||
const minutes = seconds / 60;
|
||||
|
||||
const { path, options } = buildLeaderboardRequest(query);
|
||||
const request = get(path, options)
|
||||
.catch((error: unknown) => {
|
||||
if (!options.needsAuth || !isSignedOutError(error)) throw error;
|
||||
return get(path, { ...options, needsAuth: false });
|
||||
})
|
||||
.then((response) => {
|
||||
const leaderboard = normalizeLeaderboardResponse(response);
|
||||
cache.set(key, { at: now(), leaderboard });
|
||||
if (cache.size > maxEntries) {
|
||||
const oldestKey = cache.keys().next().value;
|
||||
if (oldestKey !== undefined) cache.delete(oldestKey);
|
||||
}
|
||||
return leaderboard;
|
||||
})
|
||||
.finally(() => {
|
||||
inFlight.delete(key);
|
||||
});
|
||||
if (minutes < maxMinutes) {
|
||||
return {
|
||||
unit: "minutes",
|
||||
amount: seconds > 0 ? Math.max(1, Math.round(minutes)) : 0,
|
||||
};
|
||||
}
|
||||
|
||||
inFlight.set(key, request);
|
||||
return request;
|
||||
const hours = minutes / 60;
|
||||
|
||||
return {
|
||||
unit: "hours",
|
||||
amount:
|
||||
hours < FRACTIONAL_HOURS_LIMIT
|
||||
? Math.round(hours * 10) / 10
|
||||
: Math.round(hours),
|
||||
};
|
||||
};
|
||||
|
||||
const durationFormatters = new Map<string, Intl.NumberFormat>();
|
||||
|
||||
const getDurationFormatter = (language: string) => {
|
||||
let formatter = durationFormatters.get(language);
|
||||
if (!formatter) {
|
||||
formatter = new Intl.NumberFormat(language, { maximumFractionDigits: 1 });
|
||||
durationFormatters.set(language, formatter);
|
||||
}
|
||||
|
||||
return formatter;
|
||||
};
|
||||
|
||||
export const formatDurationParts = (
|
||||
seconds: number,
|
||||
maxMinutes: number,
|
||||
language: string
|
||||
): { unit: "minutes" | "hours"; amount: string } => {
|
||||
const { unit, amount } = getDurationParts(seconds, maxMinutes);
|
||||
return { unit, amount: getDurationFormatter(language).format(amount) };
|
||||
};
|
||||
|
||||
export const buildGameLeaderboardPath = (
|
||||
shop: GameShop,
|
||||
objectId: string,
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ interface SegmentedControlProps<T extends string> {
|
|||
onChange: (value: T) => void;
|
||||
}
|
||||
|
||||
function SegmentedControl<T extends string>({
|
||||
export function SegmentedControl<T extends string>({
|
||||
label,
|
||||
options,
|
||||
value,
|
||||
|
|
|
|||
|
|
@ -15,12 +15,20 @@ export interface LeaderboardRowProps {
|
|||
row: LeaderboardRowData;
|
||||
value: string;
|
||||
showProgress?: boolean;
|
||||
rankAddon?: React.ReactNode;
|
||||
valuePrefix?: React.ReactNode;
|
||||
valueMuted?: boolean;
|
||||
tooltip?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function LeaderboardRow({
|
||||
row,
|
||||
value,
|
||||
showProgress = true,
|
||||
rankAddon,
|
||||
valuePrefix,
|
||||
valueMuted = false,
|
||||
tooltip,
|
||||
}: Readonly<LeaderboardRowProps>) {
|
||||
const { t } = useTranslation("leaderboard");
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -29,7 +37,17 @@ export function LeaderboardRow({
|
|||
const banner = entry.user.backgroundImageUrl;
|
||||
|
||||
return (
|
||||
<li className="leaderboard-row__item">
|
||||
<li
|
||||
className={cn("leaderboard-row__item", {
|
||||
"leaderboard-row__item--tooltip": tooltip,
|
||||
})}
|
||||
>
|
||||
{tooltip && (
|
||||
<div className="leaderboard-row__tooltip" role="tooltip">
|
||||
{tooltip}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{gapBefore && (
|
||||
<div className="leaderboard-row__gap" aria-hidden="true">
|
||||
<span />
|
||||
|
|
@ -58,6 +76,7 @@ export function LeaderboardRow({
|
|||
{isMedalTier(tier) && <RankFrameRing />}
|
||||
<span className="leaderboard-row__rank">
|
||||
<RankMedal rank={entry.rank} highlighted={isViewer} />
|
||||
{rankAddon}
|
||||
</span>
|
||||
|
||||
<Avatar
|
||||
|
|
@ -86,7 +105,14 @@ export function LeaderboardRow({
|
|||
)}
|
||||
</span>
|
||||
|
||||
<span className="leaderboard-row__value">{value}</span>
|
||||
<span
|
||||
className={cn("leaderboard-row__value", {
|
||||
"leaderboard-row__value--muted": valueMuted,
|
||||
})}
|
||||
>
|
||||
{valuePrefix}
|
||||
{value}
|
||||
</span>
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -229,7 +229,7 @@ export function LeaderboardSection() {
|
|||
|
||||
<div className="leaderboard-section__footer">
|
||||
{renderNudge()}
|
||||
{isGlobalLeaderboardsEnabled && (
|
||||
{isGlobalLeaderboardsEnabled && isLoggedIn && (
|
||||
<button
|
||||
type="button"
|
||||
className="leaderboard-section__see-all"
|
||||
|
|
|
|||
|
|
@ -372,7 +372,9 @@ $leaderboard-ease: cubic-bezier(0.34, 1.4, 0.64, 1);
|
|||
width: 28px;
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 1px;
|
||||
}
|
||||
|
||||
&__body {
|
||||
|
|
@ -430,13 +432,40 @@ $leaderboard-ease: cubic-bezier(0.34, 1.4, 0.64, 1);
|
|||
background-color: color-mix(in srgb, currentColor 85%, transparent);
|
||||
}
|
||||
|
||||
&__item--tooltip {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
&__tooltip {
|
||||
position: absolute;
|
||||
z-index: 5;
|
||||
right: calc(globals.$spacing-unit * 1.5);
|
||||
bottom: calc(100% + globals.$spacing-unit);
|
||||
display: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
&__item--tooltip:hover &__tooltip,
|
||||
&__item--tooltip:focus-within &__tooltip {
|
||||
display: block;
|
||||
}
|
||||
|
||||
&__value {
|
||||
flex-shrink: 0;
|
||||
min-width: 56px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: flex-end;
|
||||
gap: 4px;
|
||||
text-align: right;
|
||||
font-size: globals.$body-font-size;
|
||||
font-weight: 700;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
|
||||
&--muted {
|
||||
color: #838383;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,8 +3,10 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||
import { logger } from "@renderer/logger";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import type { GameShop } from "@types";
|
||||
import { leaderboardsCache } from "@renderer/services/leaderboards-cache";
|
||||
import {
|
||||
createLeaderboardFetcher,
|
||||
buildLeaderboardRequest,
|
||||
fetchGameLeaderboard,
|
||||
getLeaderboardBoardKey,
|
||||
isLeaderboardShop,
|
||||
selectLoadedLeaderboard,
|
||||
|
|
@ -16,16 +18,6 @@ import {
|
|||
type LeaderboardUser,
|
||||
} from "./game-leaderboard";
|
||||
|
||||
let fetchLeaderboard: ReturnType<typeof createLeaderboardFetcher> | null = null;
|
||||
|
||||
const getFetcher = () => {
|
||||
fetchLeaderboard ??= createLeaderboardFetcher((path, options) =>
|
||||
globalThis.window.electron.hydraApi.get<unknown>(path, options)
|
||||
);
|
||||
|
||||
return fetchLeaderboard;
|
||||
};
|
||||
|
||||
export interface UseGameLeaderboardOptions {
|
||||
shop: GameShop;
|
||||
objectId: string | undefined;
|
||||
|
|
@ -95,7 +87,10 @@ export function useGameLeaderboard({
|
|||
setIsLoading(true);
|
||||
setFailedRequestKey(null);
|
||||
|
||||
getFetcher()(query)
|
||||
fetchGameLeaderboard(
|
||||
(path, options) => leaderboardsCache.get(path, options, viewerId),
|
||||
query
|
||||
)
|
||||
.then((leaderboard) => {
|
||||
if (requestId === requestIdRef.current) {
|
||||
setLoaded({
|
||||
|
|
@ -112,6 +107,8 @@ export function useGameLeaderboard({
|
|||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
const { path, options } = buildLeaderboardRequest(query);
|
||||
leaderboardsCache.evict(path, options, viewerId);
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
logger.error("Failed to fetch game leaderboard", error);
|
||||
setLoaded(null);
|
||||
|
|
|
|||
|
|
@ -1,45 +1,26 @@
|
|||
import { useCallback, useMemo } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import type { LeaderboardMetric } from "./game-leaderboard";
|
||||
|
||||
const FRACTIONAL_HOURS_LIMIT = 10;
|
||||
const HOUR_FRACTION_DIGITS = 1;
|
||||
import {
|
||||
formatDurationParts,
|
||||
type LeaderboardMetric,
|
||||
} from "./game-leaderboard";
|
||||
|
||||
export function useLeaderboardValue() {
|
||||
const { t, i18n } = useTranslation("leaderboard");
|
||||
const { numberFormatter } = useFormat();
|
||||
const hourFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.NumberFormat(i18n.language, {
|
||||
maximumFractionDigits: HOUR_FRACTION_DIGITS,
|
||||
}),
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
return useCallback(
|
||||
(metric: LeaderboardMetric, value: number) => {
|
||||
if (metric === "playtime") {
|
||||
const minutes = value / 60;
|
||||
|
||||
if (minutes < MAX_MINUTES_TO_SHOW_IN_PLAYTIME) {
|
||||
return t("value_minutes", {
|
||||
amount: numberFormatter.format(
|
||||
value > 0 ? Math.max(1, Math.round(minutes)) : 0
|
||||
),
|
||||
});
|
||||
}
|
||||
|
||||
const hours = minutes / 60;
|
||||
|
||||
return t("value_hours", {
|
||||
amount:
|
||||
hours < FRACTIONAL_HOURS_LIMIT
|
||||
? hourFormatter.format(hours)
|
||||
: numberFormatter.format(Math.round(hours)),
|
||||
});
|
||||
const { unit, amount } = formatDurationParts(
|
||||
value,
|
||||
MAX_MINUTES_TO_SHOW_IN_PLAYTIME,
|
||||
i18n.language
|
||||
);
|
||||
return t(`value_${unit}`, { amount });
|
||||
}
|
||||
|
||||
if (metric === "streak") {
|
||||
|
|
@ -48,6 +29,6 @@ export function useLeaderboardValue() {
|
|||
|
||||
return numberFormatter.format(value);
|
||||
},
|
||||
[hourFormatter, numberFormatter, t]
|
||||
[i18n.language, numberFormatter, t]
|
||||
);
|
||||
}
|
||||
|
|
|
|||
252
src/renderer/src/pages/leaderboards/badges-section.tsx
Normal file
252
src/renderer/src/pages/leaderboards/badges-section.tsx
Normal file
|
|
@ -0,0 +1,252 @@
|
|||
import { useState } from "react";
|
||||
import cn from "classnames";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button, RankFrameRing } from "@renderer/components";
|
||||
import { useDate, useFormat } from "@renderer/hooks";
|
||||
import {
|
||||
BADGES_PAGE_SIZE,
|
||||
buildMetricsRequest,
|
||||
getBadgeProgress,
|
||||
normalizeBadges,
|
||||
normalizeMetricDefinitions,
|
||||
type LeaderboardBadge,
|
||||
type MetricDefinition,
|
||||
} from "./global-leaderboard";
|
||||
import { useApiResource } from "./leaderboards-api";
|
||||
import { BlockError, StreakFlame } from "./leaderboard-bits";
|
||||
import { useMetricText } from "./use-global-leaderboard-value";
|
||||
|
||||
const BADGE_SKELETON_ROWS = 6;
|
||||
const BADGE_SKELETON_KEYS = Array.from(
|
||||
{ length: BADGE_SKELETON_ROWS },
|
||||
(_, index) => `badge-skeleton-${index}`
|
||||
);
|
||||
|
||||
export function BadgesSection() {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
const { formatDate } = useDate();
|
||||
const { numberFormatter } = useFormat();
|
||||
const metricText = useMetricText();
|
||||
const globalMetricsRequest = buildMetricsRequest("global");
|
||||
const gameMetricsRequest = buildMetricsRequest("game");
|
||||
const globalMetrics = useApiResource(
|
||||
globalMetricsRequest.path,
|
||||
globalMetricsRequest.options,
|
||||
normalizeMetricDefinitions
|
||||
);
|
||||
const gameMetrics = useApiResource(
|
||||
gameMetricsRequest.path,
|
||||
gameMetricsRequest.options,
|
||||
normalizeMetricDefinitions
|
||||
);
|
||||
const findDefinition = (id: string): MetricDefinition | null =>
|
||||
[...(globalMetrics.data ?? []), ...(gameMetrics.data ?? [])].find(
|
||||
(definition) => definition.id === id
|
||||
) ?? null;
|
||||
const findMetric = (id: string | null): MetricDefinition | null =>
|
||||
id ? (findDefinition(id) ?? { id, valueType: "count", label: id }) : null;
|
||||
const [expanded, setExpanded] = useState(false);
|
||||
|
||||
const badges = useApiResource(
|
||||
"/users/me/leaderboard-badges",
|
||||
{ params: {}, needsAuth: true },
|
||||
normalizeBadges
|
||||
);
|
||||
|
||||
const metricLabel = (badge: LeaderboardBadge) => {
|
||||
const metric = findMetric(badge.metric);
|
||||
return metric ? metricText.label(metric) : "";
|
||||
};
|
||||
|
||||
const getTitle = (badge: LeaderboardBadge) => {
|
||||
const game = badge.gameTitle ?? "";
|
||||
|
||||
switch (badge.kind) {
|
||||
case "top_percent":
|
||||
return t("badge_top_percent", { percent: badge.value, game });
|
||||
case "weekly_leader":
|
||||
return t("badge_weekly_leader", { game });
|
||||
case "top_rank":
|
||||
return t("badge_top_rank", { rank: badge.value, game });
|
||||
case "friends_podium":
|
||||
return t("badge_friends_podium");
|
||||
case "streak_record":
|
||||
return t("badge_streak_record", { count: badge.value });
|
||||
}
|
||||
};
|
||||
|
||||
const getSubtitle = (badge: LeaderboardBadge) => {
|
||||
const metric = metricLabel(badge);
|
||||
|
||||
if (badge.kind === "streak_record") return t("badge_personal_record");
|
||||
if (badge.kind === "friends_podium") {
|
||||
return [badge.gameTitle, metric].filter(Boolean).join(" · ");
|
||||
}
|
||||
if (badge.kind === "weekly_leader") {
|
||||
if (!badge.earnedAt && badge.currentRank) {
|
||||
return `${metric} · ${t("you_are_at", { rank: badge.currentRank })}`;
|
||||
}
|
||||
if (badge.week)
|
||||
return `${metric} · ${t("week_number", { number: badge.week })}`;
|
||||
}
|
||||
|
||||
const scopeLabel = t(`scope_${badge.scope}`);
|
||||
return `${metric} · ${scopeLabel}`;
|
||||
};
|
||||
|
||||
const getChip = (badge: LeaderboardBadge) => {
|
||||
switch (badge.kind) {
|
||||
case "top_percent":
|
||||
return t("chip_top_percent", { percent: badge.value });
|
||||
case "top_rank":
|
||||
return badge.currentRank && badge.earnedAt
|
||||
? `#${badge.currentRank}`
|
||||
: t("chip_top_rank", { rank: badge.value });
|
||||
case "streak_record":
|
||||
return t("chip_days", { count: badge.value });
|
||||
default:
|
||||
return t("ordinal", { rank: badge.value });
|
||||
}
|
||||
};
|
||||
|
||||
const getMeta = (badge: LeaderboardBadge) => {
|
||||
if (badge.earnedAt) {
|
||||
return t(
|
||||
badge.kind === "weekly_leader" || badge.kind === "streak_record"
|
||||
? "badge_earned_on"
|
||||
: "badge_since",
|
||||
{ date: formatDate(badge.earnedAt) }
|
||||
);
|
||||
}
|
||||
if (!badge.progress) return "";
|
||||
|
||||
const remaining = Math.max(
|
||||
0,
|
||||
badge.progress.target - badge.progress.current
|
||||
);
|
||||
if (!badge.metric) {
|
||||
return t("badge_remaining", {
|
||||
amount: numberFormatter.format(Math.ceil(remaining)),
|
||||
});
|
||||
}
|
||||
|
||||
const metric = findDefinition(badge.metric);
|
||||
if (!metric) return "";
|
||||
const amount = metricText.format(metric, Math.ceil(remaining));
|
||||
|
||||
return t("badge_remaining", { amount });
|
||||
};
|
||||
|
||||
const renderBadge = (badge: LeaderboardBadge) => {
|
||||
const locked = !badge.earnedAt;
|
||||
const ring = !locked && badge.tier !== "streak";
|
||||
|
||||
return (
|
||||
<div
|
||||
key={badge.id}
|
||||
className={cn(
|
||||
"leaderboards__badge",
|
||||
`leaderboards__badge--${badge.tier}`,
|
||||
{
|
||||
"leaderboards__badge--locked": locked,
|
||||
"leaderboards__badge--ring": ring,
|
||||
[`rank-frame rank-frame--${badge.tier}`]: ring,
|
||||
}
|
||||
)}
|
||||
>
|
||||
{ring && <RankFrameRing />}
|
||||
<div className="leaderboards__badge-art">
|
||||
<div
|
||||
className="leaderboards__badge-cover"
|
||||
style={
|
||||
badge.coverImageUrl
|
||||
? ({
|
||||
"--cover": `url(${JSON.stringify(badge.coverImageUrl)})`,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
{badge.kind === "streak_record" && (
|
||||
<StreakFlame size={24} gradient={!locked} />
|
||||
)}
|
||||
</div>
|
||||
<span className="leaderboards__badge-chip">{getChip(badge)}</span>
|
||||
</div>
|
||||
<div className="leaderboards__badge-body">
|
||||
<span className="leaderboards__badge-title">{getTitle(badge)}</span>
|
||||
<span className="leaderboards__badge-subtitle">
|
||||
{getSubtitle(badge)}
|
||||
</span>
|
||||
{locked ? (
|
||||
<div className="leaderboards__badge-progress">
|
||||
<span className="leaderboards__badge-bar" aria-hidden="true">
|
||||
<span style={{ width: `${getBadgeProgress(badge)}%` }} />
|
||||
</span>
|
||||
<span className="leaderboards__badge-meta">{getMeta(badge)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<span className="leaderboards__badge-meta leaderboards__badge-meta--earned">
|
||||
{getMeta(badge)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderContent = () => {
|
||||
if (badges.isLoading && !badges.data) {
|
||||
return (
|
||||
<div className="leaderboards__badges">
|
||||
{BADGE_SKELETON_KEYS.map((key) => (
|
||||
<Skeleton key={key} className="leaderboards__badge-skeleton" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (badges.hasError || !badges.data) {
|
||||
return <BlockError onRetry={badges.retry} />;
|
||||
}
|
||||
|
||||
const list = expanded
|
||||
? badges.data.badges
|
||||
: badges.data.badges.slice(0, BADGES_PAGE_SIZE);
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="leaderboards__badges">{list.map(renderBadge)}</div>
|
||||
{!expanded && badges.data.badges.length > BADGES_PAGE_SIZE && (
|
||||
<div className="leaderboards__more">
|
||||
<Button theme="outline" onClick={() => setExpanded(true)}>
|
||||
{t("show_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<section
|
||||
className="leaderboards__badges-section"
|
||||
aria-labelledby="badges-title"
|
||||
>
|
||||
<div className="leaderboards__section-header">
|
||||
<h2 id="badges-title" className="leaderboards__heading-2">
|
||||
{t("badges_title")}
|
||||
</h2>
|
||||
{badges.data && (
|
||||
<span className="leaderboards__board-count">
|
||||
{t("badges_count", {
|
||||
earned: badges.data.earned,
|
||||
total: badges.data.total,
|
||||
})}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{renderContent()}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
138
src/renderer/src/pages/leaderboards/board-view.tsx
Normal file
138
src/renderer/src/pages/leaderboards/board-view.tsx
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
import {
|
||||
buildLeaderboardRows,
|
||||
type LeaderboardEntry,
|
||||
type LeaderboardUser,
|
||||
} from "../game-details/leaderboard/game-leaderboard";
|
||||
import { LeaderboardRow } from "../game-details/leaderboard/leaderboard-row";
|
||||
import {
|
||||
getRankDelta,
|
||||
getRowsBelowPodium,
|
||||
GLOBAL_LEADERBOARD_PAGE_SIZE,
|
||||
GLOBAL_LEADERBOARD_PAGE_STEP,
|
||||
GLOBAL_LEADERBOARD_PODIUM_SIZE,
|
||||
type GlobalLeaderboard,
|
||||
} from "./global-leaderboard";
|
||||
import { RankDelta, StreakFlame } from "./leaderboard-bits";
|
||||
import { LeaderboardPodium, type StreakStatus } from "./leaderboard-podium";
|
||||
|
||||
export interface BoardViewProps {
|
||||
leaderboard: GlobalLeaderboard;
|
||||
viewerUser: LeaderboardUser | null;
|
||||
formatValue: (value: number) => string;
|
||||
leaderLabel: string;
|
||||
showDelta: boolean;
|
||||
isStreak?: boolean;
|
||||
isLoading?: boolean;
|
||||
selectedId?: string | null;
|
||||
onSelect?: (entry: LeaderboardEntry) => void;
|
||||
renderTooltip?: (entry: LeaderboardEntry) => React.ReactNode;
|
||||
onVisibleChange?: (visible: number) => void;
|
||||
}
|
||||
|
||||
export function BoardView({
|
||||
leaderboard,
|
||||
viewerUser,
|
||||
formatValue,
|
||||
leaderLabel,
|
||||
showDelta,
|
||||
isStreak = false,
|
||||
isLoading = false,
|
||||
selectedId,
|
||||
onSelect,
|
||||
renderTooltip,
|
||||
onVisibleChange,
|
||||
}: Readonly<BoardViewProps>) {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
const [visible, setVisible] = useState(GLOBAL_LEADERBOARD_PAGE_SIZE);
|
||||
|
||||
useEffect(() => {
|
||||
setVisible(GLOBAL_LEADERBOARD_PAGE_SIZE);
|
||||
}, [leaderboard]);
|
||||
|
||||
useEffect(() => {
|
||||
onVisibleChange?.(visible);
|
||||
}, [onVisibleChange, visible]);
|
||||
|
||||
const getPreviousRank = (entry: LeaderboardEntry) => {
|
||||
const listed = leaderboard.extras[entry.user.id]?.previousRank;
|
||||
if (listed !== null && listed !== undefined) return listed;
|
||||
|
||||
return entry.user.id === viewerUser?.id
|
||||
? leaderboard.viewerPreviousRank
|
||||
: null;
|
||||
};
|
||||
|
||||
const getDelta = (entry: LeaderboardEntry) =>
|
||||
showDelta ? getRankDelta(entry.rank, getPreviousRank(entry)) : null;
|
||||
|
||||
const getStreak = (entry: LeaderboardEntry): StreakStatus => {
|
||||
if (!isStreak) return null;
|
||||
const active = leaderboard.extras[entry.user.id]?.streakActive;
|
||||
if (active === null || active === undefined) return null;
|
||||
|
||||
return active ? "active" : "ended";
|
||||
};
|
||||
|
||||
const allRows = buildLeaderboardRows(leaderboard, viewerUser, visible);
|
||||
const listedCount = Math.min(visible, leaderboard.entries.length);
|
||||
const rows = getRowsBelowPodium(allRows, listedCount);
|
||||
const hasMore = leaderboard.entries.length > visible;
|
||||
|
||||
return (
|
||||
<>
|
||||
<LeaderboardPodium
|
||||
entries={leaderboard.entries.slice(0, GLOBAL_LEADERBOARD_PODIUM_SIZE)}
|
||||
viewerId={viewerUser?.id ?? null}
|
||||
leaderLabel={leaderLabel}
|
||||
formatValue={formatValue}
|
||||
getDelta={getDelta}
|
||||
getStreak={getStreak}
|
||||
selectedId={selectedId}
|
||||
onSelect={onSelect}
|
||||
/>
|
||||
|
||||
{rows.length > 0 && (
|
||||
<ol className="leaderboards__list" aria-busy={isLoading}>
|
||||
{rows.map((row) => {
|
||||
const streak = getStreak(row.entry);
|
||||
|
||||
return (
|
||||
<LeaderboardRow
|
||||
key={row.entry.user.id}
|
||||
row={row}
|
||||
value={formatValue(row.entry.value)}
|
||||
rankAddon={
|
||||
showDelta ? <RankDelta delta={getDelta(row.entry)} /> : null
|
||||
}
|
||||
valuePrefix={
|
||||
streak === "active" ? (
|
||||
<StreakFlame label={t("streak_active")} />
|
||||
) : null
|
||||
}
|
||||
valueMuted={streak === "ended"}
|
||||
tooltip={renderTooltip?.(row.entry)}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</ol>
|
||||
)}
|
||||
|
||||
{hasMore && (
|
||||
<div className="leaderboards__more">
|
||||
<Button
|
||||
theme="outline"
|
||||
onClick={() =>
|
||||
setVisible((value) => value + GLOBAL_LEADERBOARD_PAGE_STEP)
|
||||
}
|
||||
>
|
||||
{t("show_more")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
451
src/renderer/src/pages/leaderboards/by-game-tab.tsx
Normal file
451
src/renderer/src/pages/leaderboards/by-game-tab.tsx
Normal file
|
|
@ -0,0 +1,451 @@
|
|||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import {
|
||||
LinkExternalIcon,
|
||||
PeopleIcon,
|
||||
SearchIcon,
|
||||
TrophyIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Avatar } from "@renderer/components";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import type { GameShop } from "@types";
|
||||
import type { LeaderboardUser } from "../game-details/leaderboard/game-leaderboard";
|
||||
import {
|
||||
buildMetricsRequest,
|
||||
getBoardState,
|
||||
getRankDelta,
|
||||
getViewerRanking,
|
||||
GLOBAL_LEADERBOARD_TAKE,
|
||||
normalizeGlobalLeaderboardResponse,
|
||||
normalizeMetricDefinitions,
|
||||
normalizeMyGames,
|
||||
type GlobalLeaderboardPeriod,
|
||||
type GlobalLeaderboardScope,
|
||||
type MyGameRanking,
|
||||
} from "./global-leaderboard";
|
||||
import { useApiResource } from "./leaderboards-api";
|
||||
import { BoardView } from "./board-view";
|
||||
import { useMetricText } from "./use-global-leaderboard-value";
|
||||
import {
|
||||
BlockError,
|
||||
BlockMessage,
|
||||
BoardSkeleton,
|
||||
RankDelta,
|
||||
} from "./leaderboard-bits";
|
||||
|
||||
const GAME_SKELETON_ROWS = 4;
|
||||
const GAME_SKELETON_KEYS = Array.from(
|
||||
{ length: GAME_SKELETON_ROWS },
|
||||
(_, index) => `game-skeleton-${index}`
|
||||
);
|
||||
|
||||
export interface LinkedGame {
|
||||
shop: string;
|
||||
objectId: string;
|
||||
}
|
||||
|
||||
export interface ByGameTabProps {
|
||||
period: GlobalLeaderboardPeriod;
|
||||
scope: GlobalLeaderboardScope;
|
||||
viewerUser: LeaderboardUser | null;
|
||||
initialGame?: LinkedGame | null;
|
||||
initialMetric?: string | null;
|
||||
}
|
||||
|
||||
interface SelectedGame {
|
||||
shop: string;
|
||||
objectId: string;
|
||||
title: string;
|
||||
coverImageUrl: string | null;
|
||||
heroImageUrl: string | null;
|
||||
rank: number | null;
|
||||
total: number | null;
|
||||
}
|
||||
|
||||
const backgroundStyle = (url: string | null, variable: string) =>
|
||||
url
|
||||
? ({ [variable]: `url(${JSON.stringify(url)})` } as React.CSSProperties)
|
||||
: undefined;
|
||||
|
||||
const gameKey = (game: LinkedGame) => `${game.shop}:${game.objectId}`;
|
||||
|
||||
function useLinkedGameAssets(game: LinkedGame | null) {
|
||||
const [assets, setAssets] = useState<SelectedGame | null>(null);
|
||||
const shop = game?.shop ?? null;
|
||||
const objectId = game?.objectId ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
if (!shop || !objectId) {
|
||||
setAssets(null);
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
globalThis.window.electron
|
||||
.getGameAssets(objectId, shop as GameShop)
|
||||
.then((result) => {
|
||||
if (cancelled || !result) return;
|
||||
setAssets({
|
||||
shop,
|
||||
objectId,
|
||||
title: result.title,
|
||||
coverImageUrl: result.coverImageUrl,
|
||||
heroImageUrl: result.libraryHeroImageUrl,
|
||||
rank: null,
|
||||
total: null,
|
||||
});
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) setAssets(null);
|
||||
});
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [objectId, shop]);
|
||||
|
||||
return assets?.shop === shop && assets.objectId === objectId ? assets : null;
|
||||
}
|
||||
|
||||
export function ByGameTab({
|
||||
period,
|
||||
scope,
|
||||
viewerUser,
|
||||
initialGame = null,
|
||||
initialMetric = null,
|
||||
}: Readonly<ByGameTabProps>) {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
const navigate = useNavigate();
|
||||
const metricText = useMetricText();
|
||||
const { numberFormatter } = useFormat();
|
||||
const [search, setSearch] = useState("");
|
||||
const [selectedKey, setSelectedKey] = useState<string | null>(
|
||||
initialGame ? gameKey(initialGame) : null
|
||||
);
|
||||
const [requestedMetric, setRequestedMetric] = useState<string | null>(
|
||||
initialMetric
|
||||
);
|
||||
const metricsRequest = buildMetricsRequest("game");
|
||||
const metricDefinitions = useApiResource(
|
||||
metricsRequest.path,
|
||||
metricsRequest.options,
|
||||
normalizeMetricDefinitions
|
||||
);
|
||||
const gameMetrics = metricDefinitions.data ?? [];
|
||||
const hasNoGameMetrics =
|
||||
!metricDefinitions.isLoading &&
|
||||
!metricDefinitions.hasError &&
|
||||
metricDefinitions.data?.length === 0;
|
||||
const metric =
|
||||
gameMetrics.find((definition) => definition.id === requestedMetric) ??
|
||||
gameMetrics[0] ??
|
||||
null;
|
||||
|
||||
const games = useApiResource(
|
||||
"/leaderboards/me/games",
|
||||
{ params: { window: period, scope }, needsAuth: true },
|
||||
normalizeMyGames
|
||||
);
|
||||
|
||||
const lastGamesRef = useRef(games.data);
|
||||
if (games.data) lastGamesRef.current = games.data;
|
||||
const shownGames =
|
||||
games.data ?? (games.isLoading ? lastGamesRef.current : null);
|
||||
const allGames = useMemo(() => shownGames ?? [], [shownGames]);
|
||||
const listed = allGames.find((game) => gameKey(game) === selectedKey);
|
||||
const linkedGame =
|
||||
initialGame && selectedKey === gameKey(initialGame) && !listed
|
||||
? initialGame
|
||||
: null;
|
||||
const linkedAssets = useLinkedGameAssets(linkedGame);
|
||||
const boardGame: LinkedGame | null =
|
||||
listed ?? linkedGame ?? allGames[0] ?? null;
|
||||
const selected: SelectedGame | MyGameRanking | null =
|
||||
listed ?? linkedAssets ?? (linkedGame ? null : (allGames[0] ?? null));
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const term = search.trim().toLocaleLowerCase();
|
||||
if (!term) return allGames;
|
||||
|
||||
return allGames.filter((game) =>
|
||||
game.title.toLocaleLowerCase().includes(term)
|
||||
);
|
||||
}, [allGames, search]);
|
||||
|
||||
const board = useApiResource(
|
||||
boardGame && metric
|
||||
? `/games/${boardGame.shop}/${encodeURIComponent(boardGame.objectId)}/leaderboards/${encodeURIComponent(metric.id)}`
|
||||
: null,
|
||||
{
|
||||
params: { range: period, scope, take: GLOBAL_LEADERBOARD_TAKE },
|
||||
needsAuth: true,
|
||||
},
|
||||
normalizeGlobalLeaderboardResponse
|
||||
);
|
||||
|
||||
const isWeek = period === "week";
|
||||
const viewerId = viewerUser?.id ?? null;
|
||||
const boardState = getBoardState({
|
||||
isLoading: board.isLoading || metricDefinitions.isLoading,
|
||||
hasError: board.hasError || metricDefinitions.hasError,
|
||||
leaderboard: board.data,
|
||||
scope,
|
||||
viewerId,
|
||||
});
|
||||
|
||||
const formatValue = (value: number) =>
|
||||
metric ? metricText.format(metric, value) : String(value);
|
||||
|
||||
const viewerRanking = getViewerRanking(board.data, viewerId);
|
||||
const heroRank = board.data
|
||||
? (viewerRanking?.rank ?? null)
|
||||
: (selected?.rank ?? null);
|
||||
const heroTotal = board.data
|
||||
? (board.data.total ?? null)
|
||||
: (selected?.total ?? null);
|
||||
let heroSubtitle = "";
|
||||
if (heroRank !== null && heroTotal !== null) {
|
||||
heroSubtitle = t("game_ranking_subtitle", {
|
||||
total: numberFormatter.format(heroTotal),
|
||||
rank: heroRank,
|
||||
});
|
||||
} else if (heroRank !== null) {
|
||||
heroSubtitle = t("you_are_at", { rank: heroRank });
|
||||
} else if (heroTotal !== null) {
|
||||
heroSubtitle = t("players_count", { count: heroTotal });
|
||||
}
|
||||
|
||||
const metricChips = (
|
||||
<fieldset className="leaderboards__chips">
|
||||
<legend className="leaderboards__sr-only">{t("metric_label")}</legend>
|
||||
{gameMetrics.map((definition) => (
|
||||
<button
|
||||
key={definition.id}
|
||||
type="button"
|
||||
aria-pressed={definition.id === metric?.id}
|
||||
className={cn("leaderboards__chip", {
|
||||
"leaderboards__chip--selected": definition.id === metric?.id,
|
||||
})}
|
||||
onClick={() => setRequestedMetric(definition.id)}
|
||||
>
|
||||
{metricText.label(definition)}
|
||||
</button>
|
||||
))}
|
||||
</fieldset>
|
||||
);
|
||||
|
||||
const renderGames = () => {
|
||||
if (games.isLoading && !games.data) {
|
||||
return (
|
||||
<div className="leaderboards__games">
|
||||
{GAME_SKELETON_KEYS.map((key) => (
|
||||
<Skeleton key={key} className="leaderboards__game-skeleton" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (games.hasError) return <BlockError onRetry={games.retry} />;
|
||||
|
||||
if (allGames.length === 0) {
|
||||
return (
|
||||
<BlockMessage
|
||||
icon={<TrophyIcon size={24} />}
|
||||
title={t("games_empty_title")}
|
||||
description={t("games_empty_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (filtered.length === 0 && search.trim()) {
|
||||
return <BlockMessage title={t("games_search_empty")} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="leaderboards__games">
|
||||
{filtered.map((game) => {
|
||||
const isSelected = boardGame && gameKey(boardGame) === gameKey(game);
|
||||
|
||||
return (
|
||||
<button
|
||||
key={gameKey(game)}
|
||||
type="button"
|
||||
aria-pressed={Boolean(isSelected)}
|
||||
className={cn("leaderboards__game", {
|
||||
"leaderboards__game--selected": isSelected,
|
||||
})}
|
||||
onClick={() => setSelectedKey(gameKey(game))}
|
||||
>
|
||||
<span
|
||||
className="leaderboards__game-cover"
|
||||
style={backgroundStyle(game.coverImageUrl, "--cover")}
|
||||
/>
|
||||
<span className="leaderboards__game-body">
|
||||
<span className="leaderboards__game-title">{game.title}</span>
|
||||
<span className="leaderboards__game-rank">
|
||||
<span className="leaderboards__game-position">
|
||||
{t("ordinal", { rank: game.rank })}
|
||||
</span>
|
||||
<span className="leaderboards__game-total">
|
||||
{t("of_total", {
|
||||
total: numberFormatter.format(game.total),
|
||||
})}
|
||||
</span>
|
||||
{isWeek && (
|
||||
<RankDelta
|
||||
delta={getRankDelta(game.rank, game.previousRank)}
|
||||
/>
|
||||
)}
|
||||
</span>
|
||||
{game.leader && (
|
||||
<span className="leaderboards__game-leader">
|
||||
<Avatar
|
||||
size={16}
|
||||
src={game.leader.profileImageUrl}
|
||||
alt={game.leader.displayName}
|
||||
/>
|
||||
{t("leader_name", { name: game.leader.displayName })}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBoard = () => {
|
||||
if (hasNoGameMetrics) {
|
||||
return (
|
||||
<BlockMessage
|
||||
icon={<TrophyIcon size={24} />}
|
||||
title={t("game_metrics_empty_title")}
|
||||
description={t("game_metrics_empty_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (boardState === "loading") return <BoardSkeleton />;
|
||||
if (boardState === "error") {
|
||||
return (
|
||||
<BlockError
|
||||
onRetry={() => {
|
||||
metricDefinitions.retry();
|
||||
board.retry();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (boardState === "empty") {
|
||||
return (
|
||||
<BlockMessage
|
||||
icon={<TrophyIcon size={24} />}
|
||||
title={t(isWeek ? "empty_week_title" : "empty_all_title")}
|
||||
description={t(
|
||||
isWeek ? "empty_week_description" : "empty_all_description"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (boardState === "empty_friends") {
|
||||
return (
|
||||
<BlockMessage
|
||||
icon={<PeopleIcon size={24} />}
|
||||
title={t("empty_friends_game_title")}
|
||||
description={t("empty_friends_description")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!board.data || !boardGame) return null;
|
||||
|
||||
return (
|
||||
<BoardView
|
||||
leaderboard={board.data}
|
||||
viewerUser={viewerUser}
|
||||
formatValue={formatValue}
|
||||
leaderLabel={t(isWeek ? "leader_of_week" : "record_holder")}
|
||||
showDelta={isWeek}
|
||||
isLoading={board.isLoading}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="leaderboards__my-games">
|
||||
<div className="leaderboards__section-header">
|
||||
<h2 className="leaderboards__heading-2">{t("my_games_title")}</h2>
|
||||
<label className="leaderboards__search">
|
||||
<SearchIcon size={14} />
|
||||
<span className="leaderboards__sr-only">{t("search_game")}</span>
|
||||
<input
|
||||
type="search"
|
||||
value={search}
|
||||
placeholder={t("search_game_placeholder")}
|
||||
onChange={(event) => setSearch(event.target.value)}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{renderGames()}
|
||||
</section>
|
||||
|
||||
{boardGame && (
|
||||
<section
|
||||
className="leaderboards__board leaderboards__board--game"
|
||||
aria-labelledby="game-board-title"
|
||||
>
|
||||
<div
|
||||
className={cn("leaderboards__hero", {
|
||||
"leaderboards__hero--image": selected?.heroImageUrl,
|
||||
})}
|
||||
style={backgroundStyle(selected?.heroImageUrl ?? null, "--hero")}
|
||||
>
|
||||
<span
|
||||
className="leaderboards__hero-cover"
|
||||
style={backgroundStyle(
|
||||
selected?.coverImageUrl ?? null,
|
||||
"--cover"
|
||||
)}
|
||||
/>
|
||||
<div className="leaderboards__hero-text">
|
||||
<h2 id="game-board-title" className="leaderboards__hero-title">
|
||||
{t("game_ranking_title", { game: selected?.title ?? "" })}
|
||||
</h2>
|
||||
<span className="leaderboards__hero-subtitle">
|
||||
{heroSubtitle}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="leaderboards__hero-link"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
buildGameDetailsPath({
|
||||
shop: boardGame.shop as GameShop,
|
||||
objectId: boardGame.objectId,
|
||||
title: selected?.title ?? "",
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("open_game_page")}
|
||||
<LinkExternalIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!hasNoGameMetrics && metricChips}
|
||||
{renderBoard()}
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
454
src/renderer/src/pages/leaderboards/global-leaderboard.test.ts
Normal file
454
src/renderer/src/pages/leaderboards/global-leaderboard.test.ts
Normal file
|
|
@ -0,0 +1,454 @@
|
|||
import assert from "node:assert/strict";
|
||||
import { describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
buildGlobalLeaderboardRequest,
|
||||
buildHeatmap,
|
||||
buildMetricsRequest,
|
||||
hasReviewData,
|
||||
hasStreakData,
|
||||
normalizeMetricDefinitions,
|
||||
getBadgeProgress,
|
||||
getBoardState,
|
||||
getCountdownParts,
|
||||
getIsoWeekNumber,
|
||||
getPlayLevel,
|
||||
getRankDelta,
|
||||
getRowsBelowPodium,
|
||||
getUtcWeekRange,
|
||||
getViewerRanking,
|
||||
normalizeBadges,
|
||||
normalizeFriendStreaks,
|
||||
normalizeGlobalLeaderboardResponse,
|
||||
normalizeMyGames,
|
||||
normalizePlayActivity,
|
||||
resolveWeekRange,
|
||||
type GlobalLeaderboardQuery,
|
||||
} from "./global-leaderboard.js";
|
||||
|
||||
const rawUser = (id: string) => ({
|
||||
id,
|
||||
displayName: id.toUpperCase(),
|
||||
profileImageUrl: `https://cdn/${id}.png`,
|
||||
backgroundImageUrl: null,
|
||||
});
|
||||
|
||||
const rawEntry = (rank: number, id: string, extra: object = {}) => ({
|
||||
rank,
|
||||
value: 1000 - rank,
|
||||
user: rawUser(id),
|
||||
...extra,
|
||||
});
|
||||
|
||||
const query: GlobalLeaderboardQuery = {
|
||||
metric: "playtime",
|
||||
period: "week",
|
||||
scope: "friends",
|
||||
isLoggedIn: true,
|
||||
};
|
||||
|
||||
describe("buildGlobalLeaderboardRequest", () => {
|
||||
it("builds the metric path with window, scope and take", () => {
|
||||
assert.deepEqual(buildGlobalLeaderboardRequest(query), {
|
||||
path: "/leaderboards/playtime",
|
||||
options: {
|
||||
params: { window: "week", scope: "friends", take: 100 },
|
||||
needsAuth: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("forces the global scope without auth when logged out", () => {
|
||||
const { options } = buildGlobalLeaderboardRequest({
|
||||
...query,
|
||||
period: "all",
|
||||
isLoggedIn: false,
|
||||
});
|
||||
|
||||
assert.deepEqual(options, {
|
||||
params: { window: "all", scope: "global", take: 100 },
|
||||
needsAuth: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("normalizeGlobalLeaderboardResponse", () => {
|
||||
it("reads entries, period, total and per entry extras", () => {
|
||||
const board = normalizeGlobalLeaderboardResponse({
|
||||
entries: [
|
||||
rawEntry(1, "a", { previousRank: 3, streakActive: true }),
|
||||
rawEntry(2, "b", {
|
||||
review: {
|
||||
gameTitle: "Brawlhalla",
|
||||
shop: "steam",
|
||||
objectId: "291550",
|
||||
score: 4,
|
||||
text: "Great",
|
||||
},
|
||||
}),
|
||||
],
|
||||
viewer: { rank: 40, value: 12, previousRank: 44 },
|
||||
weekStart: "2026-10-19T00:00:00.000Z",
|
||||
weekEnd: "2026-10-26T00:00:00.000Z",
|
||||
total: 12480,
|
||||
});
|
||||
|
||||
assert.equal(board.entries.length, 2);
|
||||
assert.equal(board.periodStart, "2026-10-19T00:00:00.000Z");
|
||||
assert.equal(board.periodEnd, "2026-10-26T00:00:00.000Z");
|
||||
assert.equal(board.total, 12480);
|
||||
assert.equal(board.viewerPreviousRank, 44);
|
||||
assert.deepEqual(board.extras.a, {
|
||||
previousRank: 3,
|
||||
streakActive: true,
|
||||
review: null,
|
||||
});
|
||||
assert.equal(board.extras.b.review?.gameTitle, "Brawlhalla");
|
||||
assert.equal(board.extras.b.review?.coverImageUrl, null);
|
||||
assert.equal(board.extras.b.previousRank, null);
|
||||
});
|
||||
|
||||
it("accepts the legacy users shape and ignores invalid extras", () => {
|
||||
const board = normalizeGlobalLeaderboardResponse({
|
||||
users: [rawEntry(1, "a", { previousRank: 0, streakActive: "yes" }), 5],
|
||||
periodEnd: "2026-10-26T00:00:00.000Z",
|
||||
total: -1,
|
||||
});
|
||||
|
||||
assert.equal(board.entries[0].user.id, "a");
|
||||
assert.equal(board.total, null);
|
||||
assert.equal(board.viewerPreviousRank, null);
|
||||
assert.deepEqual(board.extras.a, {
|
||||
previousRank: null,
|
||||
streakActive: null,
|
||||
review: null,
|
||||
});
|
||||
});
|
||||
|
||||
it("throws on a non object response", () => {
|
||||
assert.throws(() => normalizeGlobalLeaderboardResponse(null), TypeError);
|
||||
assert.throws(() => normalizeGlobalLeaderboardResponse([]), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("metric definitions", () => {
|
||||
it("builds the metrics request per scope without auth", () => {
|
||||
assert.deepEqual(buildMetricsRequest("game"), {
|
||||
path: "/leaderboards/metrics",
|
||||
options: { params: { scope: "game" }, needsAuth: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("normalizes definitions, keeping order and dropping invalid items", () => {
|
||||
const metrics = normalizeMetricDefinitions({
|
||||
metrics: [
|
||||
{ id: "playtime", valueType: "duration", label: "Playtime" },
|
||||
{ id: "sessions", valueType: "weird" },
|
||||
{ id: "playtime", valueType: "count" },
|
||||
{ id: "" },
|
||||
"bad",
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(metrics, [
|
||||
{ id: "playtime", valueType: "duration", label: "Playtime" },
|
||||
{ id: "sessions", valueType: "count", label: "sessions" },
|
||||
]);
|
||||
assert.throws(() => normalizeMetricDefinitions({}), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("board data detection", () => {
|
||||
it("detects streak and review data from the entries", () => {
|
||||
const streak = normalizeGlobalLeaderboardResponse({
|
||||
entries: [rawEntry(1, "a", { streakActive: false })],
|
||||
});
|
||||
const review = normalizeGlobalLeaderboardResponse({
|
||||
entries: [rawEntry(1, "a", { review: { text: "Nice", score: 5 } })],
|
||||
});
|
||||
const plain = normalizeGlobalLeaderboardResponse({
|
||||
entries: [rawEntry(1, "a")],
|
||||
});
|
||||
|
||||
assert.equal(hasStreakData(streak), true);
|
||||
assert.equal(hasReviewData(streak), false);
|
||||
assert.equal(hasReviewData(review), true);
|
||||
assert.equal(hasStreakData(plain), false);
|
||||
assert.equal(hasStreakData(null), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getRowsBelowPodium", () => {
|
||||
it("splits by position so tied ranks are never dropped", () => {
|
||||
const board = normalizeGlobalLeaderboardResponse({
|
||||
entries: [
|
||||
rawEntry(1, "a"),
|
||||
rawEntry(2, "b"),
|
||||
rawEntry(3, "c"),
|
||||
rawEntry(3, "d"),
|
||||
rawEntry(5, "e"),
|
||||
],
|
||||
});
|
||||
const ids = board.entries.map((entry) => entry.user.id);
|
||||
|
||||
assert.deepEqual(getRowsBelowPodium(ids, ids.length), ["d", "e"]);
|
||||
});
|
||||
|
||||
it("keeps a pinned row after a short list", () => {
|
||||
assert.deepEqual(getRowsBelowPodium(["a", "b", "viewer"], 2), ["viewer"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBoardState", () => {
|
||||
const board = normalizeGlobalLeaderboardResponse({
|
||||
entries: [rawEntry(1, "me"), rawEntry(2, "b")],
|
||||
});
|
||||
const onlyViewer = normalizeGlobalLeaderboardResponse({
|
||||
entries: [rawEntry(1, "me")],
|
||||
});
|
||||
const empty = normalizeGlobalLeaderboardResponse({ entries: [] });
|
||||
const base = {
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
scope: "global" as const,
|
||||
viewerId: "me",
|
||||
};
|
||||
|
||||
it("covers every state", () => {
|
||||
assert.equal(
|
||||
getBoardState({ ...base, isLoading: true, leaderboard: null }),
|
||||
"loading"
|
||||
);
|
||||
assert.equal(getBoardState({ ...base, leaderboard: null }), "error");
|
||||
assert.equal(
|
||||
getBoardState({ ...base, hasError: true, leaderboard: board }),
|
||||
"error"
|
||||
);
|
||||
assert.equal(getBoardState({ ...base, leaderboard: empty }), "empty");
|
||||
assert.equal(getBoardState({ ...base, leaderboard: board }), "ready");
|
||||
assert.equal(
|
||||
getBoardState({ ...base, scope: "friends", leaderboard: onlyViewer }),
|
||||
"empty_friends"
|
||||
);
|
||||
assert.equal(
|
||||
getBoardState({ ...base, isLoading: true, leaderboard: board }),
|
||||
"ready"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("rank deltas and viewer ranking", () => {
|
||||
it("computes the delta from the previous rank", () => {
|
||||
assert.equal(getRankDelta(3, 5), 2);
|
||||
assert.equal(getRankDelta(5, 3), -2);
|
||||
assert.equal(getRankDelta(4, null), null);
|
||||
});
|
||||
|
||||
it("reads the viewer from the list or from the viewer field", () => {
|
||||
const listed = normalizeGlobalLeaderboardResponse({
|
||||
entries: [rawEntry(1, "a"), rawEntry(2, "me", { previousRank: 4 })],
|
||||
});
|
||||
assert.deepEqual(getViewerRanking(listed, "me"), {
|
||||
rank: 2,
|
||||
value: 998,
|
||||
delta: 2,
|
||||
});
|
||||
|
||||
const outside = normalizeGlobalLeaderboardResponse({
|
||||
entries: [rawEntry(1, "a")],
|
||||
viewer: { rank: 50, value: 3, previousRank: 45 },
|
||||
});
|
||||
assert.deepEqual(getViewerRanking(outside, "me"), {
|
||||
rank: 50,
|
||||
value: 3,
|
||||
delta: -5,
|
||||
});
|
||||
|
||||
const missing = normalizeGlobalLeaderboardResponse({
|
||||
entries: [rawEntry(1, "a")],
|
||||
});
|
||||
assert.equal(getViewerRanking(missing, "me"), null);
|
||||
assert.equal(getViewerRanking(null, "me"), null);
|
||||
assert.equal(getViewerRanking(listed, null), null);
|
||||
});
|
||||
});
|
||||
|
||||
describe("week helpers", () => {
|
||||
it("starts the week on Monday 00:00 UTC", () => {
|
||||
const sunday = Date.UTC(2026, 9, 25, 23, 59);
|
||||
const monday = Date.UTC(2026, 9, 26, 0, 0);
|
||||
|
||||
assert.equal(
|
||||
getUtcWeekRange(sunday).start.toISOString(),
|
||||
"2026-10-19T00:00:00.000Z"
|
||||
);
|
||||
assert.equal(
|
||||
getUtcWeekRange(monday).start.toISOString(),
|
||||
"2026-10-26T00:00:00.000Z"
|
||||
);
|
||||
assert.equal(
|
||||
getUtcWeekRange(monday).end.toISOString(),
|
||||
"2026-11-02T00:00:00.000Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("prefers the period from the response and falls back to the clock", () => {
|
||||
const now = Date.UTC(2026, 9, 21);
|
||||
const fromResponse = resolveWeekRange(
|
||||
{
|
||||
periodStart: "2026-10-12T00:00:00.000Z",
|
||||
periodEnd: "2026-10-19T00:00:00.000Z",
|
||||
},
|
||||
now
|
||||
);
|
||||
assert.equal(fromResponse.end.toISOString(), "2026-10-19T00:00:00.000Z");
|
||||
|
||||
const fallback = resolveWeekRange(
|
||||
{ periodStart: "nope", periodEnd: null },
|
||||
now
|
||||
);
|
||||
assert.equal(fallback.start.toISOString(), "2026-10-19T00:00:00.000Z");
|
||||
assert.equal(
|
||||
resolveWeekRange(null, now).end.toISOString(),
|
||||
"2026-10-26T00:00:00.000Z"
|
||||
);
|
||||
});
|
||||
|
||||
it("returns the ISO week number", () => {
|
||||
assert.equal(getIsoWeekNumber(new Date(Date.UTC(2026, 9, 21))), 43);
|
||||
assert.equal(getIsoWeekNumber(new Date(Date.UTC(2026, 0, 1))), 1);
|
||||
assert.equal(getIsoWeekNumber(new Date(Date.UTC(2021, 0, 3))), 53);
|
||||
assert.equal(getIsoWeekNumber(new Date(Date.UTC(2020, 11, 31))), 53);
|
||||
});
|
||||
|
||||
it("splits the countdown and never goes negative", () => {
|
||||
const end = new Date(Date.UTC(2026, 9, 26));
|
||||
const now = end.getTime() - ((3 * 24 + 14) * 60 + 5) * 60_000;
|
||||
|
||||
assert.deepEqual(getCountdownParts(end, now), {
|
||||
days: 3,
|
||||
hours: 14,
|
||||
minutes: 5,
|
||||
});
|
||||
assert.deepEqual(getCountdownParts(end, end.getTime() + 1), {
|
||||
days: 0,
|
||||
hours: 0,
|
||||
minutes: 0,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("play activity", () => {
|
||||
it("maps seconds to heat levels", () => {
|
||||
assert.equal(getPlayLevel(0), 0);
|
||||
assert.equal(getPlayLevel(1800), 1);
|
||||
assert.equal(getPlayLevel(7200), 2);
|
||||
assert.equal(getPlayLevel(4 * 3600), 3);
|
||||
});
|
||||
|
||||
it("builds 15 weeks of cells ending in the current week", () => {
|
||||
const now = Date.UTC(2026, 9, 21, 12);
|
||||
const cells = buildHeatmap(
|
||||
[{ date: "2026-10-21", seconds: 5000, games: ["Brawlhalla"] }],
|
||||
now
|
||||
);
|
||||
|
||||
assert.equal(cells.length, 105);
|
||||
assert.equal(cells[0].date.toISOString(), "2026-07-13T00:00:00.000Z");
|
||||
|
||||
const today = cells.find((cell) => cell.isToday);
|
||||
assert.equal(today?.level, 2);
|
||||
assert.deepEqual(today?.games, ["Brawlhalla"]);
|
||||
assert.equal(cells.filter((cell) => cell.isFuture).length, 4);
|
||||
});
|
||||
|
||||
it("normalizes the activity response", () => {
|
||||
const activity = normalizePlayActivity({
|
||||
currentStreak: 5,
|
||||
bestStreak: 12,
|
||||
playedToday: true,
|
||||
days: [{ date: "2026-10-21", seconds: 60, games: ["A", 3] }, null],
|
||||
});
|
||||
|
||||
assert.equal(activity.currentStreak, 5);
|
||||
assert.deepEqual(activity.days, [
|
||||
{ date: "2026-10-21", seconds: 60, games: ["A"] },
|
||||
]);
|
||||
assert.throws(() => normalizePlayActivity({}), TypeError);
|
||||
});
|
||||
});
|
||||
|
||||
describe("profile normalizers", () => {
|
||||
it("normalizes my games and drops invalid items", () => {
|
||||
const games = normalizeMyGames({
|
||||
games: [
|
||||
{
|
||||
shop: "steam",
|
||||
objectId: "291550",
|
||||
title: "Brawlhalla",
|
||||
rank: 6,
|
||||
previousRank: 8,
|
||||
total: 12480,
|
||||
leader: rawUser("a"),
|
||||
},
|
||||
{ shop: "steam", objectId: "1", rank: 0 },
|
||||
"bad",
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(games.length, 1);
|
||||
assert.equal(games[0].leader?.id, "a");
|
||||
assert.equal(games[0].coverImageUrl, null);
|
||||
assert.throws(() => normalizeMyGames({}), TypeError);
|
||||
});
|
||||
|
||||
it("sorts friend streaks by active first, then by days", () => {
|
||||
const friends = normalizeFriendStreaks({
|
||||
friends: [
|
||||
{ user: rawUser("ended"), days: 15, active: false },
|
||||
{ user: rawUser("short"), days: 2, active: true },
|
||||
{ user: rawUser("long"), days: 9, active: true, online: true },
|
||||
{ user: null },
|
||||
],
|
||||
});
|
||||
|
||||
assert.deepEqual(
|
||||
friends.map((item) => item.user.id),
|
||||
["long", "short", "ended"]
|
||||
);
|
||||
assert.equal(friends[0].online, true);
|
||||
assert.throws(() => normalizeFriendStreaks(null), TypeError);
|
||||
});
|
||||
|
||||
it("normalizes badges, counts and progress", () => {
|
||||
const badges = normalizeBadges({
|
||||
badges: [
|
||||
{
|
||||
id: "a",
|
||||
kind: "top_percent",
|
||||
tier: "gold",
|
||||
value: 1,
|
||||
earnedAt: "2026-09-03T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
id: "b",
|
||||
kind: "weekly_leader",
|
||||
tier: "unknown",
|
||||
scope: "friends",
|
||||
value: 1,
|
||||
progress: { current: 11, target: 20 },
|
||||
},
|
||||
{ id: "c", kind: "nope" },
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(badges.badges.length, 2);
|
||||
assert.equal(badges.earned, 1);
|
||||
assert.equal(badges.total, 2);
|
||||
assert.equal(badges.badges[1].tier, "gold");
|
||||
assert.equal(badges.badges[1].scope, "friends");
|
||||
assert.equal(getBadgeProgress(badges.badges[1]), 55);
|
||||
assert.equal(getBadgeProgress(badges.badges[0]), 0);
|
||||
assert.throws(() => normalizeBadges({}), TypeError);
|
||||
});
|
||||
});
|
||||
599
src/renderer/src/pages/leaderboards/global-leaderboard.ts
Normal file
599
src/renderer/src/pages/leaderboards/global-leaderboard.ts
Normal file
|
|
@ -0,0 +1,599 @@
|
|||
import {
|
||||
normalizeLeaderboardResponse,
|
||||
type Leaderboard,
|
||||
type LeaderboardRequestOptions,
|
||||
type LeaderboardUser,
|
||||
} from "../game-details/leaderboard/game-leaderboard.js";
|
||||
|
||||
export const GLOBAL_LEADERBOARD_TAKE = 100;
|
||||
export const GLOBAL_LEADERBOARD_PAGE_SIZE = 13;
|
||||
export const GLOBAL_LEADERBOARD_PAGE_STEP = 10;
|
||||
export const GLOBAL_LEADERBOARD_PODIUM_SIZE = 3;
|
||||
export const PLAY_ACTIVITY_WEEKS = 15;
|
||||
export const BADGES_PAGE_SIZE = 8;
|
||||
export const SECONDS_PER_MINUTE = 60;
|
||||
export const MINUTES_PER_HOUR = 60;
|
||||
export const SECONDS_PER_HOUR = SECONDS_PER_MINUTE * MINUTES_PER_HOUR;
|
||||
export const PLAY_LEVEL_LOW_SECONDS = SECONDS_PER_HOUR;
|
||||
export const PLAY_LEVEL_HIGH_SECONDS = 3 * SECONDS_PER_HOUR;
|
||||
|
||||
const WEEK_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
export const DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export const METRIC_VALUE_TYPES = ["duration", "count", "days"] as const;
|
||||
export const METRIC_SCOPES = ["global", "game"] as const;
|
||||
|
||||
export const GLOBAL_LEADERBOARD_PERIODS = ["week", "all"] as const;
|
||||
export const GLOBAL_LEADERBOARD_SCOPES = ["global", "friends"] as const;
|
||||
export const LEADERBOARD_PAGE_TABS = ["ranking", "games", "stats"] as const;
|
||||
|
||||
export type MetricValueType = (typeof METRIC_VALUE_TYPES)[number];
|
||||
export type MetricScope = (typeof METRIC_SCOPES)[number];
|
||||
|
||||
export interface MetricDefinition {
|
||||
id: string;
|
||||
valueType: MetricValueType;
|
||||
label: string;
|
||||
}
|
||||
export type GlobalLeaderboardPeriod =
|
||||
(typeof GLOBAL_LEADERBOARD_PERIODS)[number];
|
||||
export type GlobalLeaderboardScope = (typeof GLOBAL_LEADERBOARD_SCOPES)[number];
|
||||
export type LeaderboardPageTab = (typeof LEADERBOARD_PAGE_TABS)[number];
|
||||
|
||||
export interface GlobalLeaderboardQuery {
|
||||
metric: string;
|
||||
period: GlobalLeaderboardPeriod;
|
||||
scope: GlobalLeaderboardScope;
|
||||
isLoggedIn: boolean;
|
||||
}
|
||||
|
||||
export interface LeaderboardReview {
|
||||
gameTitle: string;
|
||||
shop: string;
|
||||
objectId: string;
|
||||
coverImageUrl: string | null;
|
||||
score: number;
|
||||
createdAt: string | null;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export interface LeaderboardEntryExtras {
|
||||
previousRank: number | null;
|
||||
streakActive: boolean | null;
|
||||
review: LeaderboardReview | null;
|
||||
}
|
||||
|
||||
export interface GlobalLeaderboard extends Leaderboard {
|
||||
periodStart: string | null;
|
||||
periodEnd: string | null;
|
||||
total: number | null;
|
||||
viewerPreviousRank: number | null;
|
||||
extras: Record<string, LeaderboardEntryExtras>;
|
||||
}
|
||||
|
||||
export type BoardState =
|
||||
| "loading"
|
||||
| "error"
|
||||
| "ready"
|
||||
| "empty"
|
||||
| "empty_friends";
|
||||
|
||||
export interface WeekRange {
|
||||
start: Date;
|
||||
end: Date;
|
||||
}
|
||||
|
||||
export interface MyGameRanking {
|
||||
shop: string;
|
||||
objectId: string;
|
||||
title: string;
|
||||
coverImageUrl: string | null;
|
||||
heroImageUrl: string | null;
|
||||
rank: number;
|
||||
previousRank: number | null;
|
||||
total: number;
|
||||
leader: LeaderboardUser | null;
|
||||
}
|
||||
|
||||
export interface PlayActivityDay {
|
||||
date: string;
|
||||
seconds: number;
|
||||
games: string[];
|
||||
}
|
||||
|
||||
export interface PlayActivity {
|
||||
currentStreak: number;
|
||||
bestStreak: number;
|
||||
playedToday: boolean;
|
||||
days: PlayActivityDay[];
|
||||
}
|
||||
|
||||
export interface FriendStreak {
|
||||
user: LeaderboardUser;
|
||||
days: number;
|
||||
bestDays: number;
|
||||
active: boolean;
|
||||
online: boolean;
|
||||
}
|
||||
|
||||
export const BADGE_KINDS = [
|
||||
"top_percent",
|
||||
"weekly_leader",
|
||||
"top_rank",
|
||||
"friends_podium",
|
||||
"streak_record",
|
||||
] as const;
|
||||
|
||||
export const BADGE_TIERS = ["gold", "silver", "bronze", "streak"] as const;
|
||||
|
||||
export type BadgeKind = (typeof BADGE_KINDS)[number];
|
||||
export type BadgeTier = (typeof BADGE_TIERS)[number];
|
||||
|
||||
export interface LeaderboardBadge {
|
||||
id: string;
|
||||
kind: BadgeKind;
|
||||
tier: BadgeTier;
|
||||
metric: string | null;
|
||||
scope: GlobalLeaderboardScope;
|
||||
gameTitle: string | null;
|
||||
coverImageUrl: string | null;
|
||||
value: number;
|
||||
week: number | null;
|
||||
currentRank: number | null;
|
||||
earnedAt: string | null;
|
||||
progress: { current: number; target: number } | null;
|
||||
}
|
||||
|
||||
export interface LeaderboardBadges {
|
||||
earned: number;
|
||||
total: number;
|
||||
badges: LeaderboardBadge[];
|
||||
}
|
||||
|
||||
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
||||
Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
||||
|
||||
const toPositiveInteger = (value: unknown) =>
|
||||
typeof value === "number" && Number.isInteger(value) && value > 0
|
||||
? value
|
||||
: null;
|
||||
|
||||
const toNonNegativeNumber = (value: unknown, fallback = 0) =>
|
||||
typeof value === "number" && Number.isFinite(value) && value >= 0
|
||||
? value
|
||||
: fallback;
|
||||
|
||||
const toStringOrNull = (value: unknown) =>
|
||||
typeof value === "string" && value ? value : null;
|
||||
|
||||
const normalizeUser = (value: unknown): LeaderboardUser | null => {
|
||||
if (!isRecord(value) || typeof value.id !== "string" || !value.id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
id: value.id,
|
||||
displayName: typeof value.displayName === "string" ? value.displayName : "",
|
||||
profileImageUrl: toStringOrNull(value.profileImageUrl),
|
||||
backgroundImageUrl: toStringOrNull(value.backgroundImageUrl),
|
||||
};
|
||||
};
|
||||
|
||||
const normalizeReview = (value: unknown): LeaderboardReview | null => {
|
||||
if (!isRecord(value) || typeof value.text !== "string") return null;
|
||||
|
||||
return {
|
||||
gameTitle: typeof value.gameTitle === "string" ? value.gameTitle : "",
|
||||
shop: typeof value.shop === "string" ? value.shop : "",
|
||||
objectId: typeof value.objectId === "string" ? value.objectId : "",
|
||||
coverImageUrl: toStringOrNull(value.coverImageUrl),
|
||||
score: toNonNegativeNumber(value.score),
|
||||
createdAt: toStringOrNull(value.createdAt),
|
||||
text: value.text,
|
||||
};
|
||||
};
|
||||
|
||||
export const buildGlobalLeaderboardRequest = (
|
||||
query: GlobalLeaderboardQuery
|
||||
): { path: string; options: LeaderboardRequestOptions } => {
|
||||
const scope = query.isLoggedIn ? query.scope : "global";
|
||||
|
||||
return {
|
||||
path: `/leaderboards/${encodeURIComponent(query.metric)}`,
|
||||
options: {
|
||||
params: { window: query.period, scope, take: GLOBAL_LEADERBOARD_TAKE },
|
||||
needsAuth: query.isLoggedIn,
|
||||
},
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeGlobalLeaderboardResponse = (
|
||||
response: unknown
|
||||
): GlobalLeaderboard => {
|
||||
if (!isRecord(response)) {
|
||||
throw new TypeError("Invalid leaderboard response");
|
||||
}
|
||||
|
||||
const rawEntries = response.entries ?? response.users;
|
||||
const leaderboard = normalizeLeaderboardResponse({
|
||||
users: rawEntries,
|
||||
viewer: response.viewer,
|
||||
});
|
||||
|
||||
const extras: Record<string, LeaderboardEntryExtras> = {};
|
||||
if (Array.isArray(rawEntries)) {
|
||||
rawEntries.forEach((raw) => {
|
||||
if (!isRecord(raw)) return;
|
||||
const user = normalizeUser(raw.user);
|
||||
if (!user) return;
|
||||
|
||||
extras[user.id] = {
|
||||
previousRank: toPositiveInteger(raw.previousRank),
|
||||
streakActive:
|
||||
typeof raw.streakActive === "boolean" ? raw.streakActive : null,
|
||||
review: normalizeReview(raw.review),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
...leaderboard,
|
||||
periodStart: toStringOrNull(response.weekStart ?? response.periodStart),
|
||||
periodEnd: toStringOrNull(response.weekEnd ?? response.periodEnd),
|
||||
total:
|
||||
typeof response.total === "number" && response.total >= 0
|
||||
? response.total
|
||||
: null,
|
||||
viewerPreviousRank: isRecord(response.viewer)
|
||||
? toPositiveInteger(response.viewer.previousRank)
|
||||
: null,
|
||||
extras,
|
||||
};
|
||||
};
|
||||
|
||||
export const getRowsBelowPodium = <T>(
|
||||
rows: T[],
|
||||
listedCount: number,
|
||||
podiumSize = GLOBAL_LEADERBOARD_PODIUM_SIZE
|
||||
) => [...rows.slice(podiumSize, listedCount), ...rows.slice(listedCount)];
|
||||
|
||||
export const getBoardState = ({
|
||||
isLoading,
|
||||
hasError,
|
||||
leaderboard,
|
||||
scope,
|
||||
viewerId,
|
||||
}: {
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
leaderboard: Pick<Leaderboard, "entries"> | null;
|
||||
scope: GlobalLeaderboardScope;
|
||||
viewerId: string | null;
|
||||
}): BoardState => {
|
||||
if (isLoading && !leaderboard) return "loading";
|
||||
if (hasError || !leaderboard) return "error";
|
||||
|
||||
const others = leaderboard.entries.filter(
|
||||
(entry) => entry.user.id !== viewerId
|
||||
);
|
||||
|
||||
if (scope === "friends" && others.length === 0) return "empty_friends";
|
||||
if (leaderboard.entries.length === 0) return "empty";
|
||||
|
||||
return "ready";
|
||||
};
|
||||
|
||||
export const buildMetricsRequest = (
|
||||
scope: MetricScope
|
||||
): { path: string; options: LeaderboardRequestOptions } => ({
|
||||
path: "/leaderboards/metrics",
|
||||
options: { params: { scope }, needsAuth: false },
|
||||
});
|
||||
|
||||
export const normalizeMetricDefinitions = (
|
||||
response: unknown
|
||||
): MetricDefinition[] => {
|
||||
const items = isRecord(response) ? response.metrics : response;
|
||||
if (!Array.isArray(items)) throw new TypeError("Invalid metrics response");
|
||||
|
||||
const seen = new Set<string>();
|
||||
|
||||
return items.flatMap((item): MetricDefinition[] => {
|
||||
if (!isRecord(item) || typeof item.id !== "string" || !item.id) return [];
|
||||
if (seen.has(item.id)) return [];
|
||||
seen.add(item.id);
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
valueType: (METRIC_VALUE_TYPES as readonly unknown[]).includes(
|
||||
item.valueType
|
||||
)
|
||||
? (item.valueType as MetricValueType)
|
||||
: "count",
|
||||
label:
|
||||
typeof item.label === "string" && item.label ? item.label : item.id,
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
export const hasStreakData = (leaderboard: GlobalLeaderboard | null) =>
|
||||
Boolean(leaderboard) &&
|
||||
Object.values(leaderboard?.extras ?? {}).some(
|
||||
(extras) => extras.streakActive !== null
|
||||
);
|
||||
|
||||
export const hasReviewData = (leaderboard: GlobalLeaderboard | null) =>
|
||||
Boolean(leaderboard) &&
|
||||
Object.values(leaderboard?.extras ?? {}).some(
|
||||
(extras) => extras.review !== null
|
||||
);
|
||||
|
||||
export const getRankDelta = (
|
||||
rank: number,
|
||||
previousRank: number | null
|
||||
): number | null => (previousRank === null ? null : previousRank - rank);
|
||||
|
||||
export const getViewerRanking = (
|
||||
leaderboard: GlobalLeaderboard | null,
|
||||
viewerId: string | null
|
||||
) => {
|
||||
if (!leaderboard || !viewerId) return null;
|
||||
|
||||
const listed = leaderboard.entries.find(
|
||||
(entry) => entry.user.id === viewerId
|
||||
);
|
||||
if (listed) {
|
||||
return {
|
||||
rank: listed.rank,
|
||||
value: listed.value,
|
||||
delta: getRankDelta(
|
||||
listed.rank,
|
||||
leaderboard.extras[viewerId]?.previousRank ?? null
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
if (!leaderboard.viewer) return null;
|
||||
|
||||
return {
|
||||
rank: leaderboard.viewer.rank,
|
||||
value: leaderboard.viewer.value,
|
||||
delta: getRankDelta(
|
||||
leaderboard.viewer.rank,
|
||||
leaderboard.viewerPreviousRank
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const getUtcWeekRange = (now: number): WeekRange => {
|
||||
const date = new Date(now);
|
||||
const daysSinceMonday = (date.getUTCDay() + 6) % 7;
|
||||
const start = Date.UTC(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate() - daysSinceMonday
|
||||
);
|
||||
|
||||
return { start: new Date(start), end: new Date(start + WEEK_MS) };
|
||||
};
|
||||
|
||||
export const resolveWeekRange = (
|
||||
leaderboard: Pick<GlobalLeaderboard, "periodStart" | "periodEnd"> | null,
|
||||
now: number
|
||||
): WeekRange => {
|
||||
const fallback = getUtcWeekRange(now);
|
||||
const start = leaderboard?.periodStart
|
||||
? new Date(leaderboard.periodStart)
|
||||
: null;
|
||||
const end = leaderboard?.periodEnd ? new Date(leaderboard.periodEnd) : null;
|
||||
|
||||
return {
|
||||
start: start && !Number.isNaN(start.getTime()) ? start : fallback.start,
|
||||
end: end && !Number.isNaN(end.getTime()) ? end : fallback.end,
|
||||
};
|
||||
};
|
||||
|
||||
export const getIsoWeekNumber = (date: Date) => {
|
||||
const target = new Date(
|
||||
Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate())
|
||||
);
|
||||
target.setUTCDate(target.getUTCDate() - ((target.getUTCDay() + 6) % 7) + 3);
|
||||
const firstThursday = new Date(Date.UTC(target.getUTCFullYear(), 0, 4));
|
||||
firstThursday.setUTCDate(
|
||||
firstThursday.getUTCDate() - ((firstThursday.getUTCDay() + 6) % 7) + 3
|
||||
);
|
||||
|
||||
return 1 + Math.round((target.getTime() - firstThursday.getTime()) / WEEK_MS);
|
||||
};
|
||||
|
||||
export const getCountdownParts = (end: Date, now: number) => {
|
||||
const totalMinutes = Math.floor(Math.max(0, end.getTime() - now) / 60_000);
|
||||
|
||||
return {
|
||||
days: Math.floor(totalMinutes / 1440),
|
||||
hours: Math.floor((totalMinutes % 1440) / 60),
|
||||
minutes: totalMinutes % 60,
|
||||
};
|
||||
};
|
||||
|
||||
export const getPlayLevel = (seconds: number) => {
|
||||
if (seconds <= 0) return 0;
|
||||
if (seconds < PLAY_LEVEL_LOW_SECONDS) return 1;
|
||||
if (seconds < PLAY_LEVEL_HIGH_SECONDS) return 2;
|
||||
|
||||
return 3;
|
||||
};
|
||||
|
||||
export interface HeatmapCell {
|
||||
date: Date;
|
||||
seconds: number;
|
||||
games: string[];
|
||||
level: number;
|
||||
isFuture: boolean;
|
||||
isToday: boolean;
|
||||
}
|
||||
|
||||
export const buildHeatmap = (
|
||||
days: PlayActivityDay[],
|
||||
now: number,
|
||||
weeks = PLAY_ACTIVITY_WEEKS
|
||||
): HeatmapCell[] => {
|
||||
const byDate = new Map(days.map((day) => [day.date.slice(0, 10), day]));
|
||||
const currentWeek = getUtcWeekRange(now).start.getTime();
|
||||
const firstDay = currentWeek - (weeks - 1) * WEEK_MS;
|
||||
const today = new Date(now).toISOString().slice(0, 10);
|
||||
|
||||
return Array.from({ length: weeks * 7 }, (_, index) => {
|
||||
const date = new Date(firstDay + index * DAY_MS);
|
||||
const key = date.toISOString().slice(0, 10);
|
||||
const day = byDate.get(key);
|
||||
const seconds = day?.seconds ?? 0;
|
||||
|
||||
return {
|
||||
date,
|
||||
seconds,
|
||||
games: day?.games ?? [],
|
||||
level: getPlayLevel(seconds),
|
||||
isFuture: key > today,
|
||||
isToday: key === today,
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
export const normalizeMyGames = (response: unknown): MyGameRanking[] => {
|
||||
const items = isRecord(response) ? response.games : response;
|
||||
if (!Array.isArray(items)) throw new TypeError("Invalid games response");
|
||||
|
||||
return items.flatMap((item) => {
|
||||
if (!isRecord(item)) return [];
|
||||
const rank = toPositiveInteger(item.rank);
|
||||
if (
|
||||
typeof item.shop !== "string" ||
|
||||
typeof item.objectId !== "string" ||
|
||||
!rank
|
||||
) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return [
|
||||
{
|
||||
shop: item.shop,
|
||||
objectId: item.objectId,
|
||||
title: typeof item.title === "string" ? item.title : "",
|
||||
coverImageUrl: toStringOrNull(item.coverImageUrl),
|
||||
heroImageUrl: toStringOrNull(item.heroImageUrl),
|
||||
rank,
|
||||
previousRank: toPositiveInteger(item.previousRank),
|
||||
total: toNonNegativeNumber(item.total, rank),
|
||||
leader: normalizeUser(item.leader),
|
||||
},
|
||||
];
|
||||
});
|
||||
};
|
||||
|
||||
export const normalizePlayActivity = (response: unknown): PlayActivity => {
|
||||
if (!isRecord(response) || !Array.isArray(response.days)) {
|
||||
throw new TypeError("Invalid play activity response");
|
||||
}
|
||||
|
||||
return {
|
||||
currentStreak: toNonNegativeNumber(response.currentStreak),
|
||||
bestStreak: toNonNegativeNumber(response.bestStreak),
|
||||
playedToday: response.playedToday === true,
|
||||
days: response.days.flatMap((day) =>
|
||||
isRecord(day) && typeof day.date === "string"
|
||||
? [
|
||||
{
|
||||
date: day.date,
|
||||
seconds: toNonNegativeNumber(day.seconds),
|
||||
games: Array.isArray(day.games)
|
||||
? day.games.filter(
|
||||
(game): game is string => typeof game === "string"
|
||||
)
|
||||
: [],
|
||||
},
|
||||
]
|
||||
: []
|
||||
),
|
||||
};
|
||||
};
|
||||
|
||||
export const normalizeFriendStreaks = (response: unknown): FriendStreak[] => {
|
||||
const items = isRecord(response) ? response.friends : response;
|
||||
if (!Array.isArray(items)) throw new TypeError("Invalid streaks response");
|
||||
|
||||
return items
|
||||
.flatMap((item) => {
|
||||
if (!isRecord(item)) return [];
|
||||
const user = normalizeUser(item.user);
|
||||
if (!user) return [];
|
||||
|
||||
return [
|
||||
{
|
||||
user,
|
||||
days: toNonNegativeNumber(item.days),
|
||||
bestDays: toNonNegativeNumber(item.bestDays),
|
||||
active: item.active === true,
|
||||
online: item.online === true,
|
||||
},
|
||||
];
|
||||
})
|
||||
.sort((a, b) => Number(b.active) - Number(a.active) || b.days - a.days);
|
||||
};
|
||||
|
||||
export const normalizeBadges = (response: unknown): LeaderboardBadges => {
|
||||
if (!isRecord(response) || !Array.isArray(response.badges)) {
|
||||
throw new TypeError("Invalid badges response");
|
||||
}
|
||||
|
||||
const badges = response.badges.flatMap((item): LeaderboardBadge[] => {
|
||||
if (!isRecord(item) || typeof item.id !== "string") return [];
|
||||
if (!(BADGE_KINDS as readonly unknown[]).includes(item.kind)) return [];
|
||||
|
||||
const progress = isRecord(item.progress)
|
||||
? {
|
||||
current: toNonNegativeNumber(item.progress.current),
|
||||
target: toNonNegativeNumber(item.progress.target, 1) || 1,
|
||||
}
|
||||
: null;
|
||||
|
||||
return [
|
||||
{
|
||||
id: item.id,
|
||||
kind: item.kind as BadgeKind,
|
||||
tier: (BADGE_TIERS as readonly unknown[]).includes(item.tier)
|
||||
? (item.tier as BadgeTier)
|
||||
: "gold",
|
||||
metric: toStringOrNull(item.metric),
|
||||
scope: item.scope === "friends" ? "friends" : "global",
|
||||
gameTitle: toStringOrNull(item.gameTitle),
|
||||
coverImageUrl: toStringOrNull(item.coverImageUrl),
|
||||
value: toNonNegativeNumber(item.value),
|
||||
week: toPositiveInteger(item.week),
|
||||
currentRank: toPositiveInteger(item.currentRank),
|
||||
earnedAt: toStringOrNull(item.earnedAt),
|
||||
progress,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return {
|
||||
earned: toNonNegativeNumber(
|
||||
response.earned,
|
||||
badges.filter((badge) => badge.earnedAt).length
|
||||
),
|
||||
total: toNonNegativeNumber(response.total, badges.length),
|
||||
badges,
|
||||
};
|
||||
};
|
||||
|
||||
export const getBadgeProgress = (badge: LeaderboardBadge) => {
|
||||
if (!badge.progress) return 0;
|
||||
|
||||
return Math.min(
|
||||
100,
|
||||
Math.round((badge.progress.current / badge.progress.target) * 100)
|
||||
);
|
||||
};
|
||||
131
src/renderer/src/pages/leaderboards/leaderboard-bits.tsx
Normal file
131
src/renderer/src/pages/leaderboards/leaderboard-bits.tsx
Normal file
|
|
@ -0,0 +1,131 @@
|
|||
import { useId } from "react";
|
||||
import cn from "classnames";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Button } from "@renderer/components";
|
||||
|
||||
const ROW_SKELETON_ROWS = 5;
|
||||
const ROW_SKELETON_KEYS = Array.from(
|
||||
{ length: ROW_SKELETON_ROWS },
|
||||
(_, index) => `row-skeleton-${index}`
|
||||
);
|
||||
|
||||
export function RankDelta({ delta }: Readonly<{ delta: number | null }>) {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
if (delta === null) return null;
|
||||
|
||||
let label = "–";
|
||||
if (delta > 0) label = `▲${delta}`;
|
||||
else if (delta < 0) label = `▼${Math.abs(delta)}`;
|
||||
|
||||
return (
|
||||
<span
|
||||
className={cn("rank-delta", {
|
||||
"rank-delta--up": delta > 0,
|
||||
"rank-delta--down": delta < 0,
|
||||
})}
|
||||
aria-label={t("delta_label", { count: delta })}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function StreakFlame({
|
||||
size = 16,
|
||||
flicker = false,
|
||||
gradient = true,
|
||||
label,
|
||||
}: Readonly<{
|
||||
size?: number;
|
||||
flicker?: boolean;
|
||||
gradient?: boolean;
|
||||
label?: string;
|
||||
}>) {
|
||||
const gradientId = useId();
|
||||
|
||||
return (
|
||||
<>
|
||||
<svg
|
||||
className={cn("streak-flame", { "streak-flame--flicker": flicker })}
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{gradient && (
|
||||
<defs>
|
||||
<linearGradient id={gradientId} x1="0" y1="1" x2="1" y2="0">
|
||||
<stop offset="0%" stopColor="#22c7ee" />
|
||||
<stop offset="100%" stopColor="#b05cff" />
|
||||
</linearGradient>
|
||||
</defs>
|
||||
)}
|
||||
<path
|
||||
fill={gradient ? `url(#${gradientId})` : undefined}
|
||||
d="M8 14c-2.8 0-4.5-1.9-4.5-4.3 0-2.6 2.2-3.9 2.7-6.7 1.6 1 2.3 2.4 2.3 3.6.8-.4 1.3-1.2 1.4-2 1.6 1.3 2.6 3 2.6 5.1C12.5 12.1 10.8 14 8 14z"
|
||||
/>
|
||||
</svg>
|
||||
{label && <span className="leaderboards__sr-only">{label}</span>}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlockMessage({
|
||||
icon,
|
||||
title,
|
||||
description,
|
||||
action,
|
||||
}: Readonly<{
|
||||
icon?: React.ReactNode;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
action?: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<div className="leaderboards__message">
|
||||
{icon}
|
||||
<p className="leaderboards__message-title">{title}</p>
|
||||
{description && (
|
||||
<p className="leaderboards__message-description">{description}</p>
|
||||
)}
|
||||
{action}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function BlockError({ onRetry }: Readonly<{ onRetry: () => void }>) {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
|
||||
return (
|
||||
<BlockMessage
|
||||
title={t("error_title")}
|
||||
description={t("error_description")}
|
||||
action={
|
||||
<Button theme="outline" size="small" onClick={onRetry}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
export function BoardSkeleton() {
|
||||
return (
|
||||
<div className="leaderboards__skeleton" aria-busy="true">
|
||||
<div className="leaderboard-podium">
|
||||
{[260, 288, 240].map((height) => (
|
||||
<Skeleton
|
||||
key={height}
|
||||
height={height}
|
||||
className="leaderboards__skeleton-podium"
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
{ROW_SKELETON_KEYS.map((key) => (
|
||||
<Skeleton key={key} className="leaderboards__skeleton-row" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
137
src/renderer/src/pages/leaderboards/leaderboard-podium.tsx
Normal file
137
src/renderer/src/pages/leaderboards/leaderboard-podium.tsx
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
import cn from "classnames";
|
||||
import { TrophyIcon } from "@primer/octicons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Avatar, getRankTier } from "@renderer/components";
|
||||
import type { LeaderboardEntry } from "../game-details/leaderboard/game-leaderboard";
|
||||
import { RankDelta, StreakFlame } from "./leaderboard-bits";
|
||||
|
||||
export type StreakStatus = "active" | "ended" | null;
|
||||
|
||||
export interface LeaderboardPodiumProps {
|
||||
entries: LeaderboardEntry[];
|
||||
viewerId: string | null;
|
||||
leaderLabel: string;
|
||||
formatValue: (value: number) => string;
|
||||
getDelta?: (entry: LeaderboardEntry) => number | null;
|
||||
getStreak?: (entry: LeaderboardEntry) => StreakStatus;
|
||||
selectedId?: string | null;
|
||||
onSelect?: (entry: LeaderboardEntry) => void;
|
||||
}
|
||||
|
||||
const PODIUM_ORDER = [1, 0, 2];
|
||||
|
||||
export function LeaderboardPodium({
|
||||
entries,
|
||||
viewerId,
|
||||
leaderLabel,
|
||||
formatValue,
|
||||
getDelta,
|
||||
getStreak,
|
||||
selectedId,
|
||||
onSelect,
|
||||
}: Readonly<LeaderboardPodiumProps>) {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
const { t: tLeaderboard } = useTranslation("leaderboard");
|
||||
const navigate = useNavigate();
|
||||
|
||||
const ordered = PODIUM_ORDER.flatMap((index) =>
|
||||
entries[index] ? [entries[index]] : []
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="leaderboard-podium">
|
||||
{ordered.map((entry) => {
|
||||
const tier = getRankTier(entry.rank);
|
||||
const banner = entry.user.backgroundImageUrl;
|
||||
const isViewer = entry.user.id === viewerId;
|
||||
const streak = getStreak?.(entry) ?? null;
|
||||
|
||||
return (
|
||||
<button
|
||||
key={entry.user.id}
|
||||
type="button"
|
||||
className={cn(
|
||||
"leaderboard-podium__card",
|
||||
`leaderboard-podium__card--${tier}`,
|
||||
{
|
||||
"leaderboard-podium__card--selected":
|
||||
onSelect && selectedId === entry.user.id,
|
||||
}
|
||||
)}
|
||||
onClick={() =>
|
||||
onSelect ? onSelect(entry) : navigate(`/profile/${entry.user.id}`)
|
||||
}
|
||||
aria-pressed={onSelect ? selectedId === entry.user.id : undefined}
|
||||
aria-current={isViewer ? "true" : undefined}
|
||||
>
|
||||
<span
|
||||
className="leaderboard-podium__band"
|
||||
role="img"
|
||||
aria-label={tLeaderboard(`medal_${tier}`, { rank: entry.rank })}
|
||||
>
|
||||
<span className="leaderboard-podium__rank">{entry.rank}</span>
|
||||
<span className="leaderboard-podium__metal">
|
||||
{t(`metal_${tier}`)}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn("leaderboard-podium__body", {
|
||||
"leaderboard-podium__body--banner": banner,
|
||||
})}
|
||||
style={
|
||||
banner
|
||||
? ({
|
||||
"--leaderboard-podium-banner": `url(${JSON.stringify(banner)})`,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
size={entry.rank === 1 ? 64 : 52}
|
||||
src={entry.user.profileImageUrl}
|
||||
alt={entry.user.displayName}
|
||||
className="leaderboard-podium__avatar"
|
||||
/>
|
||||
|
||||
<span className="leaderboard-podium__name-line">
|
||||
<span className="leaderboard-podium__name">
|
||||
{entry.user.displayName}
|
||||
</span>
|
||||
{isViewer && (
|
||||
<span className="leaderboard-podium__you">
|
||||
{tLeaderboard("you")}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={cn("leaderboard-podium__value", {
|
||||
"leaderboard-podium__value--muted": streak === "ended",
|
||||
})}
|
||||
>
|
||||
{streak === "active" && (
|
||||
<StreakFlame label={t("streak_active")} />
|
||||
)}
|
||||
{formatValue(entry.value)}
|
||||
</span>
|
||||
|
||||
{entry.rank === 1 ? (
|
||||
<span className="leaderboard-podium__leader">
|
||||
<TrophyIcon size={14} />
|
||||
{leaderLabel}
|
||||
</span>
|
||||
) : (
|
||||
<span className="leaderboard-podium__foot">
|
||||
<RankDelta delta={getDelta?.(entry) ?? null} />
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
157
src/renderer/src/pages/leaderboards/leaderboards-api.ts
Normal file
157
src/renderer/src/pages/leaderboards/leaderboards-api.ts
Normal file
|
|
@ -0,0 +1,157 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { logger } from "@renderer/logger";
|
||||
import { useUserDetails } from "@renderer/hooks";
|
||||
import { getRequestKey } from "@renderer/helpers/response-cache";
|
||||
import { leaderboardsCache } from "@renderer/services/leaderboards-cache";
|
||||
import type {
|
||||
LeaderboardRequestOptions,
|
||||
LeaderboardUser,
|
||||
} from "../game-details/leaderboard/game-leaderboard";
|
||||
|
||||
export function useLeaderboardViewer() {
|
||||
const { userDetails } = useUserDetails();
|
||||
|
||||
const viewerUser = useMemo<LeaderboardUser | null>(
|
||||
() =>
|
||||
userDetails
|
||||
? {
|
||||
id: userDetails.id,
|
||||
displayName: userDetails.displayName,
|
||||
profileImageUrl: userDetails.profileImageUrl,
|
||||
backgroundImageUrl: userDetails.backgroundImageUrl,
|
||||
}
|
||||
: null,
|
||||
[userDetails]
|
||||
);
|
||||
|
||||
return { viewerUser, isLoggedIn: Boolean(viewerUser) };
|
||||
}
|
||||
|
||||
export interface ApiResource<T> {
|
||||
data: T | null;
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
retry: () => void;
|
||||
}
|
||||
|
||||
const readCached = <T>(
|
||||
path: string | null,
|
||||
options: LeaderboardRequestOptions,
|
||||
accountId: string | null,
|
||||
normalize: (response: unknown) => T
|
||||
): T | null => {
|
||||
if (!path) return null;
|
||||
const cached = leaderboardsCache.peek(path, options, accountId);
|
||||
if (cached === undefined) return null;
|
||||
|
||||
try {
|
||||
return normalize(cached);
|
||||
} catch {
|
||||
leaderboardsCache.evict(path, options, accountId);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
export function useApiResource<T>(
|
||||
path: string | null,
|
||||
options: LeaderboardRequestOptions,
|
||||
normalize: (response: unknown) => T
|
||||
): ApiResource<T> {
|
||||
const { viewerUser } = useLeaderboardViewer();
|
||||
const accountId = viewerUser?.id ?? null;
|
||||
const requestKey = path ? getRequestKey(path, options, accountId) : null;
|
||||
const [state, setState] = useState(() => {
|
||||
const data = readCached(path, options, accountId, normalize);
|
||||
return {
|
||||
key: requestKey,
|
||||
data,
|
||||
isLoading: Boolean(path) && data === null,
|
||||
hasError: false,
|
||||
};
|
||||
});
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const requestIdRef = useRef(0);
|
||||
const latest = useRef({ path, options, accountId, normalize });
|
||||
latest.current = { path, options, accountId, normalize };
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const {
|
||||
path: currentPath,
|
||||
options: currentOptions,
|
||||
accountId: currentAccountId,
|
||||
} = latest.current;
|
||||
|
||||
if (!currentPath) {
|
||||
setState({ key: null, data: null, isLoading: false, hasError: false });
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = readCached(
|
||||
currentPath,
|
||||
currentOptions,
|
||||
currentAccountId,
|
||||
latest.current.normalize
|
||||
);
|
||||
if (cached !== null) {
|
||||
setState({
|
||||
key: requestKey,
|
||||
data: cached,
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setState((current) => ({
|
||||
key: requestKey,
|
||||
data: current.key === requestKey ? current.data : null,
|
||||
isLoading: true,
|
||||
hasError: false,
|
||||
}));
|
||||
|
||||
leaderboardsCache
|
||||
.get(currentPath, currentOptions, currentAccountId)
|
||||
.then((response) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
setState({
|
||||
key: requestKey,
|
||||
data: latest.current.normalize(response),
|
||||
isLoading: false,
|
||||
hasError: false,
|
||||
});
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
leaderboardsCache.evict(currentPath, currentOptions, currentAccountId);
|
||||
logger.error("Failed to fetch leaderboard data", error);
|
||||
setState({
|
||||
key: requestKey,
|
||||
data: null,
|
||||
isLoading: false,
|
||||
hasError: true,
|
||||
});
|
||||
});
|
||||
}, [attempt, requestKey]);
|
||||
|
||||
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
const isCurrent = state.key === requestKey;
|
||||
|
||||
if (!isCurrent) {
|
||||
const pending = readCached(path, options, accountId, normalize);
|
||||
return {
|
||||
data: pending,
|
||||
isLoading: Boolean(path) && pending === null,
|
||||
hasError: false,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
data: state.data,
|
||||
isLoading: state.isLoading,
|
||||
hasError: state.hasError,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
1555
src/renderer/src/pages/leaderboards/leaderboards.scss
Normal file
1555
src/renderer/src/pages/leaderboards/leaderboards.scss
Normal file
File diff suppressed because it is too large
Load diff
278
src/renderer/src/pages/leaderboards/leaderboards.tsx
Normal file
278
src/renderer/src/pages/leaderboards/leaderboards.tsx
Normal file
|
|
@ -0,0 +1,278 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import { GlobeIcon, PeopleIcon, TrophyIcon } from "@primer/octicons-react";
|
||||
import { SkeletonTheme } from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Navigate, useSearchParams } from "react-router-dom";
|
||||
|
||||
import { SegmentedControl } from "../game-details/leaderboard/leaderboard-controls";
|
||||
import {
|
||||
buildMetricsRequest,
|
||||
GLOBAL_LEADERBOARD_PERIODS,
|
||||
GLOBAL_LEADERBOARD_SCOPES,
|
||||
LEADERBOARD_PAGE_TABS,
|
||||
normalizeMetricDefinitions,
|
||||
resolveWeekRange,
|
||||
type GlobalLeaderboardPeriod,
|
||||
type GlobalLeaderboardScope,
|
||||
type LeaderboardPageTab,
|
||||
} from "./global-leaderboard";
|
||||
import { useGlobalLeaderboardsFeature } from "@renderer/hooks";
|
||||
import { leaderboardsCache } from "@renderer/services/leaderboards-cache";
|
||||
import { useApiResource, useLeaderboardViewer } from "./leaderboards-api";
|
||||
import { BadgesSection } from "./badges-section";
|
||||
import { ByGameTab, type LinkedGame } from "./by-game-tab";
|
||||
import { RankingTab } from "./ranking-tab";
|
||||
import { StatsTab } from "./stats-tab";
|
||||
import { useGlobalLeaderboards } from "./use-global-leaderboards";
|
||||
import { WeekSubtitle } from "./week-subtitle";
|
||||
import "../game-details/leaderboard/leaderboard.scss";
|
||||
import "./leaderboards.scss";
|
||||
|
||||
function LeaderboardsPage() {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
const [searchParams] = useSearchParams();
|
||||
const tabParam = searchParams.get("tab");
|
||||
const shopParam = searchParams.get("shop");
|
||||
const objectIdParam = searchParams.get("objectId");
|
||||
const periodParam = searchParams.get("period");
|
||||
const scopeParam = searchParams.get("scope");
|
||||
const metricParam = searchParams.get("metric");
|
||||
const linkedTab = (
|
||||
LEADERBOARD_PAGE_TABS as readonly (string | null)[]
|
||||
).includes(tabParam)
|
||||
? (tabParam as LeaderboardPageTab)
|
||||
: null;
|
||||
const linkedGame = useMemo<LinkedGame | null>(
|
||||
() =>
|
||||
shopParam && objectIdParam
|
||||
? { shop: shopParam, objectId: objectIdParam }
|
||||
: null,
|
||||
[objectIdParam, shopParam]
|
||||
);
|
||||
const [requestedTab, setRequestedTab] = useState<LeaderboardPageTab>(
|
||||
linkedTab ?? "ranking"
|
||||
);
|
||||
const [requestedMetric, setRequestedMetric] = useState<string | null>(null);
|
||||
const [period, setPeriod] = useState<GlobalLeaderboardPeriod>(() =>
|
||||
(GLOBAL_LEADERBOARD_PERIODS as readonly (string | null)[]).includes(
|
||||
periodParam
|
||||
)
|
||||
? (periodParam as GlobalLeaderboardPeriod)
|
||||
: "week"
|
||||
);
|
||||
const [requestedScope, setRequestedScope] = useState<GlobalLeaderboardScope>(
|
||||
() =>
|
||||
(GLOBAL_LEADERBOARD_SCOPES as readonly (string | null)[]).includes(
|
||||
scopeParam
|
||||
)
|
||||
? (scopeParam as GlobalLeaderboardScope)
|
||||
: "global"
|
||||
);
|
||||
const [generation, setGeneration] = useState(0);
|
||||
|
||||
const { viewerUser, isLoggedIn } = useLeaderboardViewer();
|
||||
const scope = isLoggedIn ? requestedScope : "global";
|
||||
const tab = isLoggedIn ? requestedTab : "ranking";
|
||||
|
||||
useEffect(() => {
|
||||
if (linkedTab) setRequestedTab(linkedTab);
|
||||
}, [linkedTab, linkedGame]);
|
||||
const tabs = isLoggedIn
|
||||
? LEADERBOARD_PAGE_TABS
|
||||
: LEADERBOARD_PAGE_TABS.slice(0, 1);
|
||||
|
||||
const metricsRequest = buildMetricsRequest("global");
|
||||
const metricDefinitions = useApiResource(
|
||||
metricsRequest.path,
|
||||
metricsRequest.options,
|
||||
normalizeMetricDefinitions
|
||||
);
|
||||
const metricIds = useMemo(
|
||||
() => (metricDefinitions.data ?? []).map((definition) => definition.id),
|
||||
[metricDefinitions.data]
|
||||
);
|
||||
|
||||
const { results, retry } = useGlobalLeaderboards({
|
||||
metrics: metricIds,
|
||||
period,
|
||||
scope,
|
||||
isLoggedIn,
|
||||
accountId: viewerUser?.id ?? null,
|
||||
});
|
||||
|
||||
const availableMetrics = useMemo(
|
||||
() => metricDefinitions.data ?? [],
|
||||
[metricDefinitions.data]
|
||||
);
|
||||
const metric =
|
||||
availableMetrics.find((definition) => definition.id === requestedMetric) ??
|
||||
availableMetrics[0] ??
|
||||
null;
|
||||
|
||||
const loadedBoard =
|
||||
Object.values(results).find((result) => result?.leaderboard)?.leaderboard ??
|
||||
null;
|
||||
const lastBoardRef = useRef(loadedBoard);
|
||||
if (loadedBoard) lastBoardRef.current = loadedBoard;
|
||||
const referenceBoard = loadedBoard ?? lastBoardRef.current;
|
||||
const [weekClock, setWeekClock] = useState(Date.now);
|
||||
const weekRange = useMemo(
|
||||
() => resolveWeekRange(referenceBoard, weekClock),
|
||||
[referenceBoard, weekClock]
|
||||
);
|
||||
|
||||
const { retry: retryMetrics } = metricDefinitions;
|
||||
const retryAll = useCallback(() => {
|
||||
retryMetrics();
|
||||
retry();
|
||||
}, [retryMetrics, retry]);
|
||||
|
||||
const weekKey = weekRange.start.toISOString().slice(0, 10);
|
||||
const lastWeekKeyRef = useRef(weekKey);
|
||||
|
||||
useEffect(() => {
|
||||
if (lastWeekKeyRef.current === weekKey) return;
|
||||
lastWeekKeyRef.current = weekKey;
|
||||
setGeneration((value) => value + 1);
|
||||
}, [weekKey]);
|
||||
|
||||
const handleWeekEnd = useCallback(() => {
|
||||
leaderboardsCache.clear();
|
||||
setWeekClock(Date.now());
|
||||
retry();
|
||||
}, [retry]);
|
||||
|
||||
const scopeIcons: Record<GlobalLeaderboardScope, React.ReactNode> = {
|
||||
global: <GlobeIcon size={14} />,
|
||||
friends: <PeopleIcon size={14} />,
|
||||
};
|
||||
|
||||
const openMetric = (value: string) => {
|
||||
setRequestedMetric(value);
|
||||
setRequestedTab("ranking");
|
||||
};
|
||||
|
||||
return (
|
||||
<SkeletonTheme baseColor="#1c1c1c" highlightColor="#444">
|
||||
<section className="leaderboards">
|
||||
<header className="leaderboards__header">
|
||||
<div className="leaderboards__heading">
|
||||
<h1 className="leaderboards__title">{t("title")}</h1>
|
||||
<WeekSubtitle
|
||||
weekRange={weekRange}
|
||||
isAllTime={period === "all"}
|
||||
onWeekEnd={handleWeekEnd}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="leaderboards__toggles">
|
||||
{isLoggedIn && (
|
||||
<SegmentedControl
|
||||
label={t("scope_label")}
|
||||
value={scope}
|
||||
onChange={setRequestedScope}
|
||||
options={GLOBAL_LEADERBOARD_SCOPES.map((id) => ({
|
||||
id,
|
||||
label: t(`scope_${id}`),
|
||||
icon: scopeIcons[id],
|
||||
}))}
|
||||
/>
|
||||
)}
|
||||
|
||||
<SegmentedControl
|
||||
label={t("period_label")}
|
||||
value={period}
|
||||
onChange={setPeriod}
|
||||
options={GLOBAL_LEADERBOARD_PERIODS.map((id) => ({
|
||||
id,
|
||||
label: t(`period_${id}`),
|
||||
}))}
|
||||
/>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div
|
||||
className="leaderboards__tabs"
|
||||
role="tablist"
|
||||
aria-label={t("tabs_label")}
|
||||
>
|
||||
{tabs.map((id) => (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={id === tab}
|
||||
className={cn("leaderboards__tab", {
|
||||
"leaderboards__tab--selected": id === tab,
|
||||
})}
|
||||
onClick={() => setRequestedTab(id)}
|
||||
>
|
||||
{t(`tab_${id}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div key={generation} className="leaderboards__content">
|
||||
{tab === "ranking" && (
|
||||
<RankingTab
|
||||
results={results}
|
||||
metrics={availableMetrics}
|
||||
metric={metric}
|
||||
isMetricsLoading={metricDefinitions.isLoading}
|
||||
hasMetricsError={metricDefinitions.hasError}
|
||||
onMetricChange={setRequestedMetric}
|
||||
period={period}
|
||||
scope={scope}
|
||||
viewerUser={viewerUser}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "games" && (
|
||||
<ByGameTab
|
||||
key={
|
||||
linkedGame
|
||||
? `${linkedGame.shop}:${linkedGame.objectId}`
|
||||
: "list"
|
||||
}
|
||||
period={period}
|
||||
scope={scope}
|
||||
viewerUser={viewerUser}
|
||||
initialGame={linkedGame}
|
||||
initialMetric={linkedGame ? metricParam : null}
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "stats" && (
|
||||
<StatsTab
|
||||
results={results}
|
||||
metrics={availableMetrics}
|
||||
period={period}
|
||||
viewerUser={viewerUser}
|
||||
onOpenMetric={openMetric}
|
||||
onRetry={retryAll}
|
||||
/>
|
||||
)}
|
||||
|
||||
{isLoggedIn ? (
|
||||
<BadgesSection />
|
||||
) : (
|
||||
<p className="leaderboards__sign-in">
|
||||
<TrophyIcon size={14} />
|
||||
{t("sign_in_hint")}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</SkeletonTheme>
|
||||
);
|
||||
}
|
||||
|
||||
export default function Leaderboards() {
|
||||
const { isEnabled, isLoaded } = useGlobalLeaderboardsFeature();
|
||||
|
||||
if (!isEnabled) return isLoaded ? <Navigate to="/" replace /> : null;
|
||||
|
||||
return <LeaderboardsPage />;
|
||||
}
|
||||
42
src/renderer/src/pages/leaderboards/metric-icon.tsx
Normal file
42
src/renderer/src/pages/leaderboards/metric-icon.tsx
Normal file
|
|
@ -0,0 +1,42 @@
|
|||
const FALLBACK_PATH = "M3 13.5V9M8 13.5V3M13 13.5V6.5M2 13.5h12";
|
||||
|
||||
const paths: Record<string, string> = {
|
||||
playtime: "M8 2a6 6 0 1 0 0 12A6 6 0 1 0 8 2zM8 5v3l2 1.5",
|
||||
games:
|
||||
"M5 4.5h6a3.5 3.5 0 0 1 0 7H5a3.5 3.5 0 0 1 0-7zM4.5 8h3M6 6.5v3M10.5 7.5h.01M12 9h.01",
|
||||
friends:
|
||||
"M6 3a2.5 2.5 0 1 0 0 5 2.5 2.5 0 1 0 0-5zM1.5 13.5c0-2.5 2-4 4.5-4s4.5 1.5 4.5 4M11.5 3a2 2 0 1 0 0 4 2 2 0 1 0 0-4zM11.5 9.5c1.8 0 3 1.3 3 3.5",
|
||||
achievements:
|
||||
"M5 2.5h6V6a3 3 0 0 1-6 0zM5 4H3v1a2 2 0 0 0 2 2M11 4h2v1a2 2 0 0 1-2 2M8 9v3M5.5 13.5h5",
|
||||
souvenirs:
|
||||
"M2.5 6h11v3h-11zM3 9v4.5h10V9M8 6v7.5M8 6C7 4 5.5 2.8 4.8 3.5S5.5 6 8 6zM8 6c1-2 2.5-3.2 3.2-2.5S10.5 6 8 6z",
|
||||
streak:
|
||||
"M8 14c-2.8 0-4.5-1.9-4.5-4.3 0-2.6 2.2-3.9 2.7-6.7 1.6 1 2.3 2.4 2.3 3.6.8-.4 1.3-1.2 1.4-2 1.6 1.3 2.6 3 2.6 5.1C12.5 12.1 10.8 14 8 14z",
|
||||
sessions: "M8 2a6 6 0 1 0 0 12A6 6 0 1 0 8 2zM6.5 5.5v5l4-2.5z",
|
||||
reviews:
|
||||
"M5 7v6.5H2.5V7zM5 7l2.5-4.5c1 0 1.7.8 1.5 1.8L8.6 7h4a1.4 1.4 0 0 1 1.4 1.6l-.8 4A1.4 1.4 0 0 1 11.8 14H5",
|
||||
};
|
||||
|
||||
export function MetricIcon({
|
||||
metric,
|
||||
size = 16,
|
||||
}: Readonly<{
|
||||
metric: string;
|
||||
size?: number;
|
||||
}>) {
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 16 16"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.4"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
<path d={paths[metric] ?? FALLBACK_PATH} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
387
src/renderer/src/pages/leaderboards/ranking-tab.tsx
Normal file
387
src/renderer/src/pages/leaderboards/ranking-tab.tsx
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import { useEffect, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import {
|
||||
ArrowRightIcon,
|
||||
ClockIcon,
|
||||
PeopleIcon,
|
||||
StarIcon,
|
||||
ThumbsupIcon,
|
||||
TrophyIcon,
|
||||
} from "@primer/octicons-react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Avatar, Button } from "@renderer/components";
|
||||
import { useDate, useFormat } from "@renderer/hooks";
|
||||
import { buildGameDetailsPath } from "@renderer/helpers";
|
||||
import type { GameShop } from "@types";
|
||||
import type {
|
||||
LeaderboardEntry,
|
||||
LeaderboardUser,
|
||||
} from "../game-details/leaderboard/game-leaderboard";
|
||||
import {
|
||||
getBoardState,
|
||||
GLOBAL_LEADERBOARD_PAGE_SIZE,
|
||||
hasReviewData,
|
||||
hasStreakData,
|
||||
type GlobalLeaderboardPeriod,
|
||||
type MetricDefinition,
|
||||
type GlobalLeaderboardScope,
|
||||
type LeaderboardReview,
|
||||
} from "./global-leaderboard";
|
||||
import { BoardView } from "./board-view";
|
||||
import { BlockError, BlockMessage, BoardSkeleton } from "./leaderboard-bits";
|
||||
import { MetricIcon } from "./metric-icon";
|
||||
import {
|
||||
EMPTY_BOARD_RESULT,
|
||||
type GlobalBoardResults,
|
||||
} from "./use-global-leaderboards";
|
||||
import { useMetricText } from "./use-global-leaderboard-value";
|
||||
|
||||
const METRIC_CARD_PLACEHOLDERS = Array.from(
|
||||
{ length: 7 },
|
||||
(_, position) => `metric-placeholder-${position}`
|
||||
);
|
||||
|
||||
export interface RankingTabProps {
|
||||
results: GlobalBoardResults;
|
||||
metrics: MetricDefinition[];
|
||||
metric: MetricDefinition | null;
|
||||
isMetricsLoading: boolean;
|
||||
hasMetricsError: boolean;
|
||||
onMetricChange: (metric: string) => void;
|
||||
period: GlobalLeaderboardPeriod;
|
||||
scope: GlobalLeaderboardScope;
|
||||
viewerUser: LeaderboardUser | null;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
function ReviewScore({ review }: Readonly<{ review: LeaderboardReview }>) {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
const { formatDistance } = useDate();
|
||||
const distance = review.createdAt
|
||||
? formatDistance(review.createdAt, new Date(), { addSuffix: true })
|
||||
: "";
|
||||
|
||||
return (
|
||||
<span className="leaderboards__review-score">
|
||||
<StarIcon size={12} />
|
||||
{t("review_score", { score: review.score })}
|
||||
{distance && ` · ${distance.charAt(0).toUpperCase()}${distance.slice(1)}`}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export function RankingTab({
|
||||
results,
|
||||
metrics,
|
||||
metric,
|
||||
isMetricsLoading,
|
||||
hasMetricsError,
|
||||
onMetricChange,
|
||||
period,
|
||||
scope,
|
||||
viewerUser,
|
||||
onRetry,
|
||||
}: Readonly<RankingTabProps>) {
|
||||
const { t } = useTranslation("leaderboards");
|
||||
const { numberFormatter } = useFormat();
|
||||
const metricText = useMetricText();
|
||||
const navigate = useNavigate();
|
||||
const [reviewerId, setReviewerId] = useState<string | null>(null);
|
||||
const [visibleRows, setVisibleRows] = useState(GLOBAL_LEADERBOARD_PAGE_SIZE);
|
||||
|
||||
const current = (metric && results[metric.id]) || EMPTY_BOARD_RESULT;
|
||||
const leaderboard = current.leaderboard;
|
||||
const isWeek = period === "week";
|
||||
const viewerId = viewerUser?.id ?? null;
|
||||
|
||||
useEffect(() => {
|
||||
setReviewerId(null);
|
||||
}, [leaderboard]);
|
||||
|
||||
const state = getBoardState({
|
||||
isLoading: current.isLoading || isMetricsLoading,
|
||||
hasError: current.hasError || hasMetricsError,
|
||||
leaderboard,
|
||||
scope,
|
||||
viewerId,
|
||||
});
|
||||
|
||||
const format = (value: number) =>
|
||||
metric ? metricText.format(metric, value) : String(value);
|
||||
const isReviews = hasReviewData(leaderboard);
|
||||
const leaderId = leaderboard?.entries[0]?.user.id ?? null;
|
||||
const selectedReviewerId = reviewerId ?? leaderId;
|
||||
const selectedEntry =
|
||||
leaderboard?.entries.find(
|
||||
(entry) => entry.user.id === selectedReviewerId
|
||||
) ?? null;
|
||||
const selectedReview = selectedEntry
|
||||
? (leaderboard?.extras[selectedEntry.user.id]?.review ?? null)
|
||||
: null;
|
||||
|
||||
const renderLeaderCard = (definition: MetricDefinition) => {
|
||||
const { id } = definition;
|
||||
const result = results[id] ?? EMPTY_BOARD_RESULT;
|
||||
const leader = result.leaderboard?.entries[0] ?? null;
|
||||
|
||||
let leaderContent: React.ReactNode = (
|
||||
<span className="leaderboards__card-empty">{t("no_leader")}</span>
|
||||
);
|
||||
|
||||
if (result.hasError) {
|
||||
leaderContent = (
|
||||
<span className="leaderboards__card-empty">{t("leader_error")}</span>
|
||||
);
|
||||
} else if (result.isLoading && !result.leaderboard) {
|
||||
leaderContent = (
|
||||
<Skeleton className="leaderboards__card-skeleton" width="100%" />
|
||||
);
|
||||
} else if (leader) {
|
||||
leaderContent = (
|
||||
<>
|
||||
<Avatar
|
||||
size={28}
|
||||
src={leader.user.profileImageUrl}
|
||||
alt={leader.user.displayName}
|
||||
/>
|
||||
<span className="leaderboards__card-leader-text">
|
||||
<span className="leaderboards__card-leader-name">
|
||||
{leader.user.displayName}
|
||||
</span>
|
||||
<span className="leaderboards__card-leader-value">
|
||||
{metricText.format(definition, leader.value)}
|
||||
</span>
|
||||
</span>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
key={id}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={id === metric?.id}
|
||||
className={cn("leaderboards__card", {
|
||||
"leaderboards__card--selected": id === metric?.id,
|
||||
})}
|
||||
onClick={() => onMetricChange(id)}
|
||||
>
|
||||
<span className="leaderboards__card-label">
|
||||
<MetricIcon metric={id} />
|
||||
<span className="leaderboards__card-label-text">
|
||||
{metricText.label(definition)}
|
||||
</span>
|
||||
</span>
|
||||
<span className="leaderboards__card-leader">{leaderContent}</span>
|
||||
</button>
|
||||
);
|
||||
};
|
||||
|
||||
const renderReviewTooltip = (entry: LeaderboardEntry) => {
|
||||
const review = leaderboard?.extras[entry.user.id]?.review;
|
||||
if (!isReviews || !review) return null;
|
||||
|
||||
return (
|
||||
<div className="leaderboards__review-tip">
|
||||
{review.coverImageUrl && (
|
||||
<img
|
||||
className="leaderboards__review-tip-cover"
|
||||
src={review.coverImageUrl}
|
||||
alt=""
|
||||
/>
|
||||
)}
|
||||
<div className="leaderboards__review-tip-body">
|
||||
<span className="leaderboards__review-tip-game">
|
||||
{review.gameTitle}
|
||||
</span>
|
||||
<ReviewScore review={review} />
|
||||
<p className="leaderboards__review-tip-text">{review.text}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const renderSelectedReview = () => {
|
||||
if (!isReviews || !selectedEntry || !selectedReview) return null;
|
||||
|
||||
const topTitle = t(isWeek ? "review_top_week" : "review_top_all");
|
||||
const title =
|
||||
selectedEntry.user.id === leaderId
|
||||
? topTitle
|
||||
: t("review_top_of", { name: selectedEntry.user.displayName });
|
||||
|
||||
return (
|
||||
<article className="leaderboards__review">
|
||||
<div className="leaderboards__review-header">
|
||||
<TrophyIcon size={16} />
|
||||
<span>{title}</span>
|
||||
</div>
|
||||
<div className="leaderboards__review-content">
|
||||
{selectedReview.coverImageUrl && (
|
||||
<img
|
||||
className="leaderboards__review-cover"
|
||||
src={selectedReview.coverImageUrl}
|
||||
alt={selectedReview.gameTitle}
|
||||
/>
|
||||
)}
|
||||
<div className="leaderboards__review-body">
|
||||
<h3 className="leaderboards__review-game">
|
||||
{selectedReview.gameTitle}
|
||||
</h3>
|
||||
<div className="leaderboards__review-author">
|
||||
<Avatar
|
||||
size={28}
|
||||
src={selectedEntry.user.profileImageUrl}
|
||||
alt={selectedEntry.user.displayName}
|
||||
/>
|
||||
<span className="leaderboards__review-name">
|
||||
{selectedEntry.user.displayName}
|
||||
</span>
|
||||
<ReviewScore review={selectedReview} />
|
||||
</div>
|
||||
<p
|
||||
className="leaderboards__review-text"
|
||||
title={selectedReview.text}
|
||||
>
|
||||
{selectedReview.text}
|
||||
</p>
|
||||
<div className="leaderboards__review-footer">
|
||||
<span className="leaderboards__review-votes">
|
||||
<ThumbsupIcon size={14} />
|
||||
{format(selectedEntry.value)}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="leaderboards__link"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
buildGameDetailsPath({
|
||||
shop: selectedReview.shop as GameShop,
|
||||
objectId: selectedReview.objectId,
|
||||
title: selectedReview.gameTitle,
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
{t("review_open_game", { game: selectedReview.gameTitle })}
|
||||
<ArrowRightIcon size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
);
|
||||
};
|
||||
|
||||
const renderBoard = () => {
|
||||
if (state === "loading") return <BoardSkeleton />;
|
||||
if (state === "error") return <BlockError onRetry={onRetry} />;
|
||||
|
||||
if (state === "empty") {
|
||||
return (
|
||||
<BlockMessage
|
||||
icon={<ClockIcon size={24} />}
|
||||
title={t(isWeek ? "empty_week_title" : "empty_all_title")}
|
||||
description={t(
|
||||
isWeek ? "empty_week_description" : "empty_all_description"
|
||||
)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (state === "empty_friends") {
|
||||
return (
|
||||
<BlockMessage
|
||||
icon={<PeopleIcon size={24} />}
|
||||
title={t("empty_friends_title")}
|
||||
description={t("empty_friends_description")}
|
||||
action={
|
||||
<Button
|
||||
theme="outline"
|
||||
size="small"
|
||||
onClick={() => globalThis.window.electron.openFriendsWindow()}
|
||||
>
|
||||
{t("find_friends")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
if (!leaderboard) return null;
|
||||
|
||||
return (
|
||||
<>
|
||||
{renderSelectedReview()}
|
||||
<BoardView
|
||||
leaderboard={leaderboard}
|
||||
viewerUser={viewerUser}
|
||||
formatValue={format}
|
||||
leaderLabel={t(isWeek ? "leader_of_week" : "record_holder")}
|
||||
showDelta={isWeek}
|
||||
isStreak={hasStreakData(leaderboard)}
|
||||
isLoading={current.isLoading}
|
||||
selectedId={isReviews ? selectedReviewerId : undefined}
|
||||
onSelect={
|
||||
isReviews ? (entry) => setReviewerId(entry.user.id) : undefined
|
||||
}
|
||||
renderTooltip={isReviews ? renderReviewTooltip : undefined}
|
||||
onVisibleChange={setVisibleRows}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const visibleCount = leaderboard
|
||||
? Math.min(leaderboard.entries.length, visibleRows)
|
||||
: 0;
|
||||
|
||||
return (
|
||||
<>
|
||||
<section className="leaderboards__leaders">
|
||||
<h2 className="leaderboards__section-title">
|
||||
{t(isWeek ? "leaders_title" : "leaders_all_title")}
|
||||
</h2>
|
||||
<div
|
||||
className="leaderboards__cards"
|
||||
role="tablist"
|
||||
aria-label={t("metric_label")}
|
||||
>
|
||||
{isMetricsLoading && metrics.length === 0
|
||||
? METRIC_CARD_PLACEHOLDERS.map((placeholder) => (
|
||||
<Skeleton
|
||||
key={placeholder}
|
||||
className="leaderboards__card-placeholder"
|
||||
/>
|
||||
))
|
||||
: metrics.map(renderLeaderCard)}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section className="leaderboards__board" aria-labelledby="board-title">
|
||||
<div className="leaderboards__board-header">
|
||||
{metric && <MetricIcon metric={metric.id} size={20} />}
|
||||
<h2 id="board-title" className="leaderboards__board-title">
|
||||
{metric ? metricText.label(metric) : ""}
|
||||
</h2>
|
||||
{state === "ready" && leaderboard && (
|
||||
<span className="leaderboards__board-count">
|
||||
{scope === "global" && leaderboard.total !== null
|
||||
? t("top_of_total", {
|
||||
count: visibleCount,
|
||||
total: numberFormatter.format(leaderboard.total),
|
||||
})
|
||||
: t("players_count", { count: leaderboard.entries.length })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{renderBoard()}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
417
src/renderer/src/pages/leaderboards/stats-tab.tsx
Normal file
417
src/renderer/src/pages/leaderboards/stats-tab.tsx
Normal file
|
|
@ -0,0 +1,417 @@
|
|||
import { useEffect, useMemo, useState } from "react";
|
||||
import cn from "classnames";
|
||||
import { PeopleIcon } from "@primer/octicons-react";
|
||||
import Skeleton from "react-loading-skeleton";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Avatar, Button } from "@renderer/components";
|
||||
import type { LeaderboardUser } from "../game-details/leaderboard/game-leaderboard";
|
||||
import {
|
||||
buildHeatmap,
|
||||
DAY_MS,
|
||||
getViewerRanking,
|
||||
normalizeFriendStreaks,
|
||||
normalizePlayActivity,
|
||||
PLAY_ACTIVITY_WEEKS,
|
||||
MINUTES_PER_HOUR,
|
||||
SECONDS_PER_MINUTE,
|
||||
type MetricDefinition,
|
||||
type GlobalLeaderboardPeriod,
|
||||
type HeatmapCell,
|
||||
} from "./global-leaderboard";
|
||||
import { useApiResource } from "./leaderboards-api";
|
||||
import {
|
||||
BlockError,
|
||||
BlockMessage,
|
||||
RankDelta,
|
||||
StreakFlame,
|
||||
} from "./leaderboard-bits";
|
||||
import { MetricIcon } from "./metric-icon";
|
||||
import {
|
||||
EMPTY_BOARD_RESULT,
|
||||
type GlobalBoardResults,
|
||||
} from "./use-global-leaderboards";
|
||||
import { useMetricText } from "./use-global-leaderboard-value";
|
||||
|
||||
const FRIEND_SKELETON_ROWS = 6;
|
||||
const FRIEND_SKELETON_KEYS = Array.from(
|
||||
{ length: FRIEND_SKELETON_ROWS },
|
||||
(_, index) => `friend-skeleton-${index}`
|
||||
);
|
||||
|
||||
export interface StatsTabProps {
|
||||
results: GlobalBoardResults;
|
||||
metrics: MetricDefinition[];
|
||||
period: GlobalLeaderboardPeriod;
|
||||
viewerUser: LeaderboardUser | null;
|
||||
onOpenMetric: (metric: string) => void;
|
||||
onRetry: () => void;
|
||||
}
|
||||
|
||||
const DAY_LABEL_DATES = [5, 6, 7, 8, 9, 10, 11].map(
|
||||
(day) => new Date(Date.UTC(2024, 1, day))
|
||||
);
|
||||
|
||||
function useUtcDay() {
|
||||
const [day, setDay] = useState(
|
||||
() => Math.floor(Date.now() / DAY_MS) * DAY_MS
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
const timeout = setTimeout(
|
||||
() => setDay(Math.floor(Date.now() / DAY_MS) * DAY_MS),
|
||||
day + DAY_MS - Date.now() + 1000
|
||||
);
|
||||
return () => clearTimeout(timeout);
|
||||
}, [day]);
|
||||
|
||||
return day;
|
||||
}
|
||||
|
||||
export function StatsTab({
|
||||
results,
|
||||
metrics,
|
||||
period,
|
||||
viewerUser,
|
||||
onOpenMetric,
|
||||
onRetry,
|
||||
}: Readonly<StatsTabProps>) {
|
||||
const { t, i18n } = useTranslation("leaderboards");
|
||||
const metricText = useMetricText();
|
||||
const viewerId = viewerUser?.id ?? null;
|
||||
const isWeek = period === "week";
|
||||
|
||||
const activity = useApiResource(
|
||||
"/users/me/play-activity",
|
||||
{ params: { weeks: PLAY_ACTIVITY_WEEKS }, needsAuth: true },
|
||||
normalizePlayActivity
|
||||
);
|
||||
const friends = useApiResource(
|
||||
"/users/me/friends/streaks",
|
||||
{ params: {}, needsAuth: true },
|
||||
normalizeFriendStreaks
|
||||
);
|
||||
|
||||
const utcDay = useUtcDay();
|
||||
const hasFailedRanking = metrics.some((metric) => {
|
||||
const result = results[metric.id];
|
||||
return Boolean(
|
||||
result && !result.isLoading && result.hasError && !result.leaderboard
|
||||
);
|
||||
});
|
||||
|
||||
const heatmap = useMemo(
|
||||
() => buildHeatmap(activity.data?.days ?? [], utcDay),
|
||||
[activity.data, utcDay]
|
||||
);
|
||||
|
||||
const dayLabels = useMemo(() => {
|
||||
const formatter = new Intl.DateTimeFormat(i18n.language, {
|
||||
weekday: "narrow",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
return DAY_LABEL_DATES.map((date) => ({
|
||||
key: date.toISOString(),
|
||||
label: formatter.format(date),
|
||||
}));
|
||||
}, [i18n.language]);
|
||||
|
||||
const cellDateFormatter = useMemo(
|
||||
() =>
|
||||
new Intl.DateTimeFormat(i18n.language, {
|
||||
weekday: "short",
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
}),
|
||||
[i18n.language]
|
||||
);
|
||||
|
||||
const cellTitle = (cell: HeatmapCell) => {
|
||||
const date = cellDateFormatter.format(cell.date);
|
||||
const label = cell.isToday ? t("today_date", { date }) : date;
|
||||
const totalMinutes = Math.max(
|
||||
1,
|
||||
Math.round(cell.seconds / SECONDS_PER_MINUTE)
|
||||
);
|
||||
const hours = Math.floor(totalMinutes / MINUTES_PER_HOUR);
|
||||
const minutes = totalMinutes % MINUTES_PER_HOUR;
|
||||
let time = t("not_played");
|
||||
if (cell.seconds > 0) {
|
||||
time =
|
||||
hours > 0
|
||||
? t("duration_hours_minutes", { hours, minutes })
|
||||
: t("duration_minutes", { minutes });
|
||||
}
|
||||
|
||||
return [label, time, cell.games.join(", ")].filter(Boolean).join(" · ");
|
||||
};
|
||||
|
||||
const atRecord =
|
||||
activity.data !== null &&
|
||||
activity.data.currentStreak > 0 &&
|
||||
activity.data.currentStreak >= activity.data.bestStreak;
|
||||
|
||||
const renderStreak = () => {
|
||||
if (activity.isLoading && !activity.data) {
|
||||
return <Skeleton height={220} className="leaderboards__block-skeleton" />;
|
||||
}
|
||||
if (activity.hasError || !activity.data) {
|
||||
return <BlockError onRetry={activity.retry} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="leaderboards__streak-numbers">
|
||||
<div className="leaderboards__streak-number">
|
||||
<span className="leaderboards__streak-label">
|
||||
{t("streak_current")}
|
||||
</span>
|
||||
<span className="leaderboards__streak-value">
|
||||
<strong>{activity.data.currentStreak}</strong>
|
||||
{t("days_unit", { count: activity.data.currentStreak })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="leaderboards__streak-number">
|
||||
<span className="leaderboards__streak-label">
|
||||
{t("streak_best")}
|
||||
</span>
|
||||
<span className="leaderboards__streak-value">
|
||||
<strong>{activity.data.bestStreak}</strong>
|
||||
{t("days_unit", { count: activity.data.bestStreak })}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="leaderboards__heatmap-block">
|
||||
<div className="leaderboards__heatmap-header">
|
||||
<span className="leaderboards__heatmap-title">
|
||||
{t("last_weeks", { count: PLAY_ACTIVITY_WEEKS })}
|
||||
</span>
|
||||
<span className="leaderboards__heatmap-legend">
|
||||
{t("legend_min")}
|
||||
{[0, 1, 2, 3].map((level) => (
|
||||
<span
|
||||
key={level}
|
||||
className={`leaderboards__heat leaderboards__heat--${level}`}
|
||||
/>
|
||||
))}
|
||||
{t("legend_max")}
|
||||
</span>
|
||||
</div>
|
||||
<div className="leaderboards__heatmap">
|
||||
{dayLabels.map(({ key, label }) => (
|
||||
<span key={key} className="leaderboards__heatmap-day">
|
||||
{label}
|
||||
</span>
|
||||
))}
|
||||
{heatmap.map((cell) => (
|
||||
<span
|
||||
key={cell.date.toISOString()}
|
||||
title={cell.isFuture ? undefined : cellTitle(cell)}
|
||||
className={cn(
|
||||
"leaderboards__heat",
|
||||
`leaderboards__heat--${cell.level}`,
|
||||
{
|
||||
"leaderboards__heat--future": cell.isFuture,
|
||||
"leaderboards__heat--today": cell.isToday,
|
||||
}
|
||||
)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
const renderFriends = () => {
|
||||
if (friends.isLoading && !friends.data) {
|
||||
return (
|
||||
<div className="leaderboards__friend-streaks">
|
||||
{FRIEND_SKELETON_KEYS.map((key) => (
|
||||
<Skeleton key={key} className="leaderboards__friend-skeleton" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (friends.hasError || !friends.data) {
|
||||
return <BlockError onRetry={friends.retry} />;
|
||||
}
|
||||
|
||||
const others = friends.data.filter((item) => item.user.id !== viewerId);
|
||||
if (others.length === 0) {
|
||||
return (
|
||||
<BlockMessage
|
||||
icon={<PeopleIcon size={20} />}
|
||||
title={t("empty_friends_title")}
|
||||
description={t("empty_friend_streaks_description")}
|
||||
action={
|
||||
<Button
|
||||
theme="outline"
|
||||
size="small"
|
||||
onClick={() => globalThis.window.electron.openFriendsWindow()}
|
||||
>
|
||||
{t("find_friends")}
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="leaderboards__friend-streaks">
|
||||
{friends.data.map((item) => (
|
||||
<div
|
||||
key={item.user.id}
|
||||
className={cn("leaderboards__friend-streak", {
|
||||
"leaderboards__friend-streak--viewer":
|
||||
item.user.id === viewerId,
|
||||
})}
|
||||
>
|
||||
<span className="leaderboards__friend-avatar">
|
||||
<Avatar
|
||||
size={24}
|
||||
src={item.user.profileImageUrl}
|
||||
alt={item.user.displayName}
|
||||
/>
|
||||
<span
|
||||
className={cn("leaderboards__presence", {
|
||||
"leaderboards__presence--online": item.online,
|
||||
})}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
</span>
|
||||
<span className="leaderboards__friend-text">
|
||||
<span className="leaderboards__friend-name">
|
||||
{item.user.displayName}
|
||||
</span>
|
||||
<span className="leaderboards__friend-presence">
|
||||
{t(item.online ? "online" : "offline")}
|
||||
</span>
|
||||
</span>
|
||||
<span className="leaderboards__friend-days">
|
||||
{item.active && (
|
||||
<StreakFlame
|
||||
size={14}
|
||||
flicker={item.days > 0 && item.days === item.bestDays}
|
||||
label={t("streak_active")}
|
||||
/>
|
||||
)}
|
||||
{t("days_count", { count: item.days })}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
className="leaderboards__link leaderboards__link--padded"
|
||||
onClick={() => globalThis.window.electron.openFriendsWindow()}
|
||||
>
|
||||
{t("see_all_friends")}
|
||||
</button>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="leaderboards__stats-grid">
|
||||
<section className="leaderboards__mine" aria-labelledby="mine-title">
|
||||
<div
|
||||
className={cn("leaderboards__mine-header", {
|
||||
"leaderboards__mine-header--banner":
|
||||
viewerUser?.backgroundImageUrl,
|
||||
})}
|
||||
style={
|
||||
viewerUser?.backgroundImageUrl
|
||||
? ({
|
||||
"--mine-banner": `url(${JSON.stringify(viewerUser.backgroundImageUrl)})`,
|
||||
} as React.CSSProperties)
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Avatar
|
||||
size={48}
|
||||
src={viewerUser?.profileImageUrl}
|
||||
alt={viewerUser?.displayName}
|
||||
/>
|
||||
<div className="leaderboards__mine-heading">
|
||||
<h2 id="mine-title" className="leaderboards__mine-title">
|
||||
{t(isWeek ? "mine_week_title" : "mine_all_title")}
|
||||
</h2>
|
||||
<span className="leaderboards__mine-name">
|
||||
{viewerUser?.displayName}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="leaderboards__mine-list">
|
||||
{metrics.map((metric) => {
|
||||
const result = results[metric.id] ?? EMPTY_BOARD_RESULT;
|
||||
const ranking = getViewerRanking(result.leaderboard, viewerId);
|
||||
const isPending = result.isLoading && !result.leaderboard;
|
||||
const isFailed =
|
||||
!result.isLoading && result.hasError && !result.leaderboard;
|
||||
|
||||
return (
|
||||
<li key={metric.id}>
|
||||
<button
|
||||
type="button"
|
||||
className="leaderboards__mine-row"
|
||||
onClick={() => onOpenMetric(metric.id)}
|
||||
>
|
||||
<MetricIcon metric={metric.id} />
|
||||
<span className="leaderboards__mine-label">
|
||||
{metricText.label(metric)}
|
||||
</span>
|
||||
{isPending && <Skeleton width={48} />}
|
||||
{isFailed && (
|
||||
<span className="leaderboards__mine-error">
|
||||
{t("leader_error")}
|
||||
</span>
|
||||
)}
|
||||
{!isPending && !isFailed && (
|
||||
<>
|
||||
<span className="leaderboards__mine-delta">
|
||||
{isWeek && ranking && (
|
||||
<RankDelta delta={ranking.delta} />
|
||||
)}
|
||||
</span>
|
||||
<span className="leaderboards__mine-rank">
|
||||
{ranking ? t("ordinal", { rank: ranking.rank }) : "–"}
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
{hasFailedRanking && (
|
||||
<div className="leaderboards__mine-retry">
|
||||
<Button theme="outline" size="small" onClick={onRetry}>
|
||||
{t("retry")}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="leaderboards__panel" aria-labelledby="streak-title">
|
||||
<div className="leaderboards__panel-title">
|
||||
<StreakFlame size={16} flicker={atRecord} />
|
||||
<h2 id="streak-title" className="leaderboards__heading-3">
|
||||
{t("daily_streak")}
|
||||
</h2>
|
||||
</div>
|
||||
{renderStreak()}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<section className="leaderboards__panel">
|
||||
<h2 className="leaderboards__heading-3">{t("friends_streak")}</h2>
|
||||
{renderFriends()}
|
||||
</section>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
@ -0,0 +1,45 @@
|
|||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
|
||||
import { useFormat } from "@renderer/hooks";
|
||||
import { formatDurationParts } from "../game-details/leaderboard/game-leaderboard";
|
||||
import type { MetricDefinition } from "./global-leaderboard";
|
||||
|
||||
export function useMetricText() {
|
||||
const { t, i18n } = useTranslation("leaderboards");
|
||||
const { numberFormatter } = useFormat();
|
||||
|
||||
const label = useCallback(
|
||||
(metric: MetricDefinition) =>
|
||||
t(`metric_${metric.id}`, { defaultValue: metric.label }),
|
||||
[t]
|
||||
);
|
||||
|
||||
const format = useCallback(
|
||||
(metric: MetricDefinition, value: number) => {
|
||||
if (metric.valueType === "duration") {
|
||||
const { unit, amount } = formatDurationParts(
|
||||
value,
|
||||
MAX_MINUTES_TO_SHOW_IN_PLAYTIME,
|
||||
i18n.language
|
||||
);
|
||||
return t(`value_${unit}`, { amount });
|
||||
}
|
||||
|
||||
if (metric.valueType === "days") {
|
||||
return t("days_count", { count: value });
|
||||
}
|
||||
|
||||
const amount = numberFormatter.format(value);
|
||||
return t(`value_${metric.id}`, {
|
||||
count: value,
|
||||
amount,
|
||||
defaultValue: amount,
|
||||
});
|
||||
},
|
||||
[i18n.language, numberFormatter, t]
|
||||
);
|
||||
|
||||
return { label, format };
|
||||
}
|
||||
151
src/renderer/src/pages/leaderboards/use-global-leaderboards.ts
Normal file
151
src/renderer/src/pages/leaderboards/use-global-leaderboards.ts
Normal file
|
|
@ -0,0 +1,151 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import { logger } from "@renderer/logger";
|
||||
import {
|
||||
buildGlobalLeaderboardRequest,
|
||||
normalizeGlobalLeaderboardResponse,
|
||||
type GlobalLeaderboard,
|
||||
type GlobalLeaderboardPeriod,
|
||||
type GlobalLeaderboardScope,
|
||||
} from "./global-leaderboard";
|
||||
import { leaderboardsCache } from "@renderer/services/leaderboards-cache";
|
||||
|
||||
export interface GlobalBoardResult {
|
||||
leaderboard: GlobalLeaderboard | null;
|
||||
isLoading: boolean;
|
||||
hasError: boolean;
|
||||
}
|
||||
|
||||
export type GlobalBoardResults = Partial<Record<string, GlobalBoardResult>>;
|
||||
|
||||
interface BoardsQuery {
|
||||
metrics: string[];
|
||||
period: GlobalLeaderboardPeriod;
|
||||
scope: GlobalLeaderboardScope;
|
||||
isLoggedIn: boolean;
|
||||
accountId: string | null;
|
||||
}
|
||||
|
||||
export const EMPTY_BOARD_RESULT: GlobalBoardResult = {
|
||||
leaderboard: null,
|
||||
isLoading: true,
|
||||
hasError: false,
|
||||
};
|
||||
|
||||
const readCachedBoard = (
|
||||
metric: string,
|
||||
query: BoardsQuery
|
||||
): GlobalLeaderboard | null => {
|
||||
const { path, options } = buildGlobalLeaderboardRequest({
|
||||
metric,
|
||||
period: query.period,
|
||||
scope: query.scope,
|
||||
isLoggedIn: query.isLoggedIn,
|
||||
});
|
||||
const cached = leaderboardsCache.peek(path, options, query.accountId);
|
||||
if (cached === undefined) return null;
|
||||
|
||||
try {
|
||||
return normalizeGlobalLeaderboardResponse(cached);
|
||||
} catch {
|
||||
leaderboardsCache.evict(path, options, query.accountId);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
const readCachedBoards = (query: BoardsQuery): GlobalBoardResults =>
|
||||
Object.fromEntries(
|
||||
query.metrics.map((metric) => {
|
||||
const leaderboard = readCachedBoard(metric, query);
|
||||
return [
|
||||
metric,
|
||||
{ leaderboard, isLoading: leaderboard === null, hasError: false },
|
||||
];
|
||||
})
|
||||
);
|
||||
|
||||
export function useGlobalLeaderboards({
|
||||
metrics,
|
||||
period,
|
||||
scope,
|
||||
isLoggedIn,
|
||||
accountId,
|
||||
}: BoardsQuery) {
|
||||
const metricsKey = metrics.join(",");
|
||||
const queryKey = `${metricsKey}|${period}|${scope}|${isLoggedIn}|${accountId}`;
|
||||
const [state, setState] = useState(() => ({
|
||||
key: queryKey,
|
||||
results: readCachedBoards({
|
||||
metrics,
|
||||
period,
|
||||
scope,
|
||||
isLoggedIn,
|
||||
accountId,
|
||||
}),
|
||||
}));
|
||||
const [attempt, setAttempt] = useState(0);
|
||||
const requestIdRef = useRef(0);
|
||||
const metricsRef = useRef(metrics);
|
||||
metricsRef.current = metrics;
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++requestIdRef.current;
|
||||
const query = {
|
||||
metrics: metricsRef.current,
|
||||
period,
|
||||
scope,
|
||||
isLoggedIn,
|
||||
accountId,
|
||||
};
|
||||
const initial = readCachedBoards(query);
|
||||
setState({ key: queryKey, results: initial });
|
||||
|
||||
query.metrics.forEach((metric) => {
|
||||
if (initial[metric]?.leaderboard) return;
|
||||
|
||||
const { path, options } = buildGlobalLeaderboardRequest({
|
||||
metric,
|
||||
period,
|
||||
scope,
|
||||
isLoggedIn,
|
||||
});
|
||||
|
||||
leaderboardsCache
|
||||
.get(path, options, accountId)
|
||||
.then((response) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
const leaderboard = normalizeGlobalLeaderboardResponse(response);
|
||||
setState((current) => ({
|
||||
key: current.key,
|
||||
results: {
|
||||
...current.results,
|
||||
[metric]: { leaderboard, isLoading: false, hasError: false },
|
||||
},
|
||||
}));
|
||||
})
|
||||
.catch((error) => {
|
||||
if (requestId !== requestIdRef.current) return;
|
||||
leaderboardsCache.evict(path, options, accountId);
|
||||
logger.error("Failed to fetch global leaderboard", error);
|
||||
setState((current) => ({
|
||||
key: current.key,
|
||||
results: {
|
||||
...current.results,
|
||||
[metric]: { leaderboard: null, isLoading: false, hasError: true },
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
}, [accountId, attempt, isLoggedIn, metricsKey, period, queryKey, scope]);
|
||||
|
||||
const retry = useCallback(() => setAttempt((value) => value + 1), []);
|
||||
const pending = useMemo(
|
||||
() => readCachedBoards({ metrics, period, scope, isLoggedIn, accountId }),
|
||||
[accountId, isLoggedIn, metrics, period, scope]
|
||||
);
|
||||
|
||||
return {
|
||||
results: state.key === queryKey ? state.results : pending,
|
||||
retry,
|
||||
};
|
||||
}
|
||||
74
src/renderer/src/pages/leaderboards/week-subtitle.tsx
Normal file
74
src/renderer/src/pages/leaderboards/week-subtitle.tsx
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import {
|
||||
getCountdownParts,
|
||||
getIsoWeekNumber,
|
||||
type WeekRange,
|
||||
} from "./global-leaderboard";
|
||||
|
||||
const COUNTDOWN_TICK_MS = 30_000;
|
||||
const WEEK_END_RETRY_BASE_MS = 30_000;
|
||||
const WEEK_END_RETRY_MAX_MS = 5 * 60_000;
|
||||
|
||||
export interface WeekSubtitleProps {
|
||||
weekRange: WeekRange;
|
||||
isAllTime: boolean;
|
||||
onWeekEnd: () => void;
|
||||
}
|
||||
|
||||
export function WeekSubtitle({
|
||||
weekRange,
|
||||
isAllTime,
|
||||
onWeekEnd,
|
||||
}: Readonly<WeekSubtitleProps>) {
|
||||
const { t, i18n } = useTranslation("leaderboards");
|
||||
const [now, setNow] = useState(Date.now);
|
||||
const onWeekEndRef = useRef(onWeekEnd);
|
||||
onWeekEndRef.current = onWeekEnd;
|
||||
const endTime = weekRange.end.getTime();
|
||||
|
||||
useEffect(() => {
|
||||
let attempts = 0;
|
||||
let nextAttemptAt = endTime;
|
||||
const tick = () => {
|
||||
const current = Date.now();
|
||||
setNow(current);
|
||||
if (current < nextAttemptAt) return;
|
||||
|
||||
onWeekEndRef.current();
|
||||
nextAttemptAt =
|
||||
current +
|
||||
Math.min(WEEK_END_RETRY_MAX_MS, WEEK_END_RETRY_BASE_MS * 2 ** attempts);
|
||||
attempts += 1;
|
||||
};
|
||||
|
||||
const interval = setInterval(tick, COUNTDOWN_TICK_MS);
|
||||
return () => clearInterval(interval);
|
||||
}, [endTime]);
|
||||
|
||||
if (isAllTime) {
|
||||
return <span className="leaderboards__subtitle">{t("subtitle_all")}</span>;
|
||||
}
|
||||
|
||||
const dateFormatter = new Intl.DateTimeFormat(i18n.language, {
|
||||
day: "numeric",
|
||||
month: "short",
|
||||
timeZone: "UTC",
|
||||
});
|
||||
const lastDay = new Date(endTime - 1);
|
||||
const { days, hours, minutes } = getCountdownParts(weekRange.end, now);
|
||||
let countdown = t("countdown_minutes", { minutes });
|
||||
if (days > 0) countdown = t("countdown_days", { days, hours });
|
||||
else if (hours > 0) countdown = t("countdown_hours", { hours, minutes });
|
||||
|
||||
return (
|
||||
<span className="leaderboards__subtitle">
|
||||
{t("subtitle_week", {
|
||||
number: getIsoWeekNumber(weekRange.start),
|
||||
range: dateFormatter.formatRange(weekRange.start, lastDay),
|
||||
countdown,
|
||||
})}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
6
src/renderer/src/services/leaderboards-cache.ts
Normal file
6
src/renderer/src/services/leaderboards-cache.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
import { createResponseCache, type ApiGet } from "../helpers/response-cache";
|
||||
|
||||
const leaderboardsGet: ApiGet = (path, options) =>
|
||||
globalThis.window.electron.hydraApi.get<unknown>(path, options);
|
||||
|
||||
export const leaderboardsCache = createResponseCache(leaderboardsGet);
|
||||
Loading…
Reference in a new issue