feat(leaderboards): activity heatmap, leader card grid and game-style review panel (#3004)

* feat(leaderboards): year-long activity heatmap, leader card grid and game-style review panel

- Your stats opens with a full-width 53-week activity heatmap (month and
  weekday labels, total, Less/More legend, current and best streak in the
  header). It falls back to 26 weeks when the API still caps play activity.
- Leader cards wrap in a grid so labels, names and values are not cut off.
- The highlighted review on the Reviews board uses the game page review
  layout; the board, podium, hover tooltip and row selection are unchanged.
- Locked badges name their target ("Faltam 4 h para o Top 10").
- Page header toggles wrap under the title on narrow windows.

* fix(leaderboards): address review on the rankings polish

- Size the heatmap from the weeks the API returned, so the 26-week fallback
  no longer shows 27 empty weeks.
- Retry play activity with 26 weeks only on a 400 rejection (new
  hydraApi.getResponse IPC), remembered for the session.
- Restore the highlighted review title above the card and clamp its text
  to 5 lines.
- Add the new strings in es and ru, show #rank on earned position badges,
  drop the dead heatmap onFocus and explain the pt-BR zero plural keys.

* fix(leaderboards): use an optional chain in heatmap months and drop the duplicated vote count colors

* docs(leaderboards): pick the badge tier before the rank in the SDD
This commit is contained in:
Eduardo Salvador 2026-10-28 13:24:55 -03:00 committed by GitHub
parent 2e026988e6
commit 82f5be383a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
17 changed files with 1072 additions and 362 deletions

View file

@ -0,0 +1,129 @@
# Rankings page polish and Epic game boards
SDD written on October 28, 2026. Status: approved and reviewed locally against staging data by Eduardo on October 28, 2026. No Linear task.
Base: launcher `03e1ea87d` (`origin/main`), branch `feature/leaderboard-ui-polish`. Backend `cf10006e` (`origin/main`), branch `fix/epic-game-leaderboard`.
## Expected result
The Rankings page looks like the rest of the launcher instead of a separate product:
1. "Your stats" shows play activity as a full-width, year-long heatmap, read like a contribution graph: one column per week, month names on top, weekday names on the left, a "Less / More" legend and a total under it. The colors stay the same.
2. The "Leaders of the week" cards no longer cut off every label and name.
3. The Reviews metric keeps its ranking (podium, rows, selection, hover tooltip and "Show more"); only the highlighted review above it is redrawn like a review on the game page.
4. Epic games show everyone who played them on the per-game boards, including playtime imported from the Epic library, the same way Steam games already do.
## Requested changes
| Request (Eduardo, October 28) | How it is met |
| --------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Heatmap in "Your stats" like the reference, keeping our colors | New full-width "Activity" card at the top of "Your stats" with 53 weeks, month and weekday labels, legend and total. The four `leaderboards__heat--*` levels and colors stay as they are. |
| Epic playtime is not counted, only Steam | Backend per-game boards match Epic games by catalogue, not by the exact `objectId`, so imported Epic rows count. |
| Reviews on the Rankings page do not match the game page reviews | The highlighted review above the Reviews ranking is redrawn with the game page review styles (`review-item.scss`). The ranking itself does not change. |
| Leader cards and the page feel off compared with the rest | Leader cards move to a wrapping grid (4 per row at desktop width) so labels, names and values fit. |
## Diagnosis: Epic boards
Checked on production on October 28, 2026:
- `GET /games/steam/730/leaderboards/playtime?range=all` tops at 23,199,240 s (6,444 h), so imported Steam playtime counts.
- `GET /games/epic/fn:48ff3f41680e403bb2717737f68731c5/leaderboards/playtime?range=all` (Fortnite) tops at 34,225 s (9.5 h). A library with 1,859 h of imported Fortnite playtime does not show up.
Cause: the Epic catalogue identifies a game by its offer (`namespace:offerId`, here `fn:48ff…`). Games imported from the Epic library are stored as `namespace:catalogItemId` (`epicGameObjectId` in `epic-snapshot-publication.service.ts`). The two are linked through `GameCatalogue.epicExecutableObjectId`, published `epic_regional_identity` store mappings and `Game.gameCatalogueId`. The per-game boards (`game-playtime.board.ts`, `game-sessions.board.ts`, `game-souvenirs.board.ts` and `gameAggregateBranchSql`) filter on `g."objectId" = ${objectId}`, so only rows stored under the offer id are ranked. Imported rows never are. Steam has a single id per game, so it is not affected.
## Decisions
| Topic | Decision |
| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Epic identity | For `shop = "epic"`, the board resolves the published catalogue once (`resolvePublishedCatalogueStore`, already called in `GameLeaderboardsService`) and matches rows by `g."gameCatalogueId" = <catalogue id>` instead of `objectId`. Other shops keep the `objectId` filter. |
| One value per user | A user can have more than one Epic row for the same catalogue (one added from the catalogue, one imported). The board takes each user's largest value (`MAX ... GROUP BY userId`), so time is never counted twice. The cost is that Hydra-tracked time on a separate row is not added to imported time. Summing would risk counting the same hours twice, which is worse on a ranking. |
| Weekly boards | `GamePlaytimeWeek` and the other weekly tables are keyed by `shop` and `objectId`. They join `Game` and filter on the catalogue the same way. Imported time has no weekly deltas, so weekly boards only gain users whose tracked rows were stored under the item id. |
| Cache keys | Epic boards are cached under the catalogue's offer id, so the page and the "my games" list share one cache entry. Invalidation also clears that key when a row under the item id changes. |
| Heatmap range | 53 weeks, the current one included. The backend cap `LEADERBOARD_PLAY_ACTIVITY_MAX_WEEKS` goes from 26 to 53. Days come from `UserGamePlaytimeDay`, which has no retention job. The launcher asks for 53 weeks and, if the API answers 400 (old backend), asks again with 26, so the launcher can ship before the backend. When the fallback answers, the grid, month labels and total use the 26 weeks that came back, not 53. Only a 400 triggers the fallback, and it is remembered for the session; it is marked to be removed once the backend change is deployed. |
| Heatmap layout | Its own full-width card ("Activity"), the first block of "Your stats", with the current and best streak in its header (flame after the numbers). The separate "Daily streak" card is removed; "Your week" and the friends streak sit side by side below. Square cells of 12 px with 3 px gaps, scrolled horizontally inside the card when the window is narrower than 53 columns, starting scrolled to today. Weekday labels on Mon, Wed and Fri only. Month label on the first week that starts in that month. |
| Heatmap summary | Under the grid, left: "128 h in 74 days" for the shown range. Right: "Less ▢▢▢▢ More". Zero days is always plural ("0 dias"). Month and weekday labels start with a capital letter. |
| Heatmap tooltip | The tooltip added in #2989 stays (today and yesterday labels, games of the day). It follows the new orientation: tips in the first and last two columns open inward, tips in the top two rows open below. |
| Leader cards | `grid-template-columns: repeat(auto-fill, minmax(200px, 1fr))` instead of one row of equal columns. Label on one line, then avatar, name and value. At 1280 px that is 4 per row, 2 rows for the 8 metrics (180 px gave 5 per row, checked on screen). |
| Highlighted review | Reviews keeps the ranking from main: podium, rows, row selection on the podium, the hover tooltip on rows and "Show more". Only the highlighted panel above the board changes. Above it, as plain text with the trophy and no box, the title from main: "Most upvoted review of the week", "... of all time" or "... by {name}". Below, the review drawn like one on the game page (`review-item.tsx`): the author's profile banner when there is one, 56 px avatar, name, filled star with the score and the rating text, relative date at the top right, a "{game} →" link line to the game page, the text clamped to 5 lines and the vote count in the game page votes row. No cover footer. It follows the selected entry as before. |
| Review styles | Reuse `review-item.scss` classes from the game page (`game-details__review-*`) for the banner, header, stars, text and votes row, so both screens change together. The 5-line clamp lives on `leaderboards__review-text` only, so the game page still shows reviews in full. The card has no vote buttons, replies or translation, because the leaderboard payload has no review id. |
| Strings | New keys in `en`, `pt-BR`, `es` and `ru` (`activity_title`, `activity_total`, `legend_less`, `legend_more`, `badge_amount_sessions`, `badge_remaining_to_top_rank`, `badge_remaining_to_top_percent`), with `_few`/`_many` in `ru` and `_zero` forms in `pt-BR`, where `Intl.PluralRules("pt")` puts 0 in `one`. Unused keys (`last_weeks`, `legend_min`, `legend_max`, `review_open_game`) are removed from every locale. The `review_top_*` titles stay. |
## Out of scope
- A year selector on the heatmap, as in the reference. One rolling year only.
- Counting the imported Epic total into the weekly boards. Imports have no dates.
- Changing the Epic import to store rows under the offer id.
## Changes by file
Launcher:
| File | Change |
| ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------- |
| `src/renderer/src/pages/leaderboards/stats-tab.tsx` | Activity card, month labels, total, fallback to 26 weeks; streak numbers moved into the Activity header. |
| `src/renderer/src/pages/leaderboards/global-leaderboard.ts` | `PLAY_ACTIVITY_WEEKS = 53`, `buildHeatmapMonths`, `sumHeatmap`. |
| `src/renderer/src/pages/leaderboards/ranking-tab.tsx` | Highlighted review rendered by `ReviewHighlight`; 8 leader card placeholders. |
| `src/renderer/src/pages/leaderboards/review-highlight.tsx` | New: the highlighted review card in the game page style. |
| `src/renderer/src/pages/leaderboards/leaderboards.scss` | Activity card, heatmap orientation, leader card grid, highlighted review card. |
| `src/locales/*/translation.json` | New and removed keys. |
| `src/renderer/src/pages/leaderboards/global-leaderboard.test.ts` | Tests for month labels and totals. |
Backend:
| File | Change |
| --------------------------------------------------------------------------------------- | ----------------------------------------------------- |
| `src/constants/leaderboard.ts` | `LEADERBOARD_PLAY_ACTIVITY_MAX_WEEKS = 53`. |
| `src/games/game-leaderboards.service.ts` | Pass the resolved catalogue id for Epic to the board. |
| `src/games/game-playtime.board.ts`, `game-sessions.board.ts`, `game-souvenirs.board.ts` | Epic filter by catalogue, `MAX` per user. |
| `src/leaderboards/leaderboard-eligibility.ts` | `gameAggregateBranchSql` takes the same game filter. |
| `src/leaderboards/game-leaderboard-registry.ts` | Cache key and invalidation for Epic item ids. |
## Tests
Automated:
- Launcher (`global-leaderboard.test.ts`): month labels land on the first week of each month; the total sums seconds and counts played days; 53 weeks end on the current week.
- Backend: an Epic user with an imported row under the item id ranks on the offer id board; a user with both rows appears once with the larger value; Steam boards are unchanged.
Local, before any commit (launcher dev against the local backend):
1. Your stats: the Activity card shows a year, months and weekdays line up, hover tooltips stay inside the card, narrow window scrolls and starts at today.
2. Ranking: the 8 leader cards show full labels and names.
3. Reviews metric: the ranking is unchanged, selecting a podium entry swaps the highlighted review, the row tooltip shows on hover and the game link opens the game page.
4. Fortnite page and "By game" tab: the imported Epic playtime ranks.
5. Screens shared with Eduardo for review. No commit, push or PR until approved.
## Addendum: game badges (October 28, 2026)
Requested by Eduardo during the local review. Today `GET /users/me/leaderboard-badges` always answers an empty list ("Badge rules are not defined yet"), so "Suas insígnias" never shows anything. The launcher section (`badges-section.tsx`) and its contract already exist and follow the prototype (`LeaderboardsF.dc.html`, "Suas insígnias").
### Rules
| Topic | Decision |
| ------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Which badges | Game badges only. Kinds `top_rank` (Top 1, Top 5, Top 10 by position) and `top_percent` (Top 1%, Top 5%, Top 10%). The other kinds in the contract (`weekly_leader`, `friends_podium`, `streak_record`) stay unused. |
| Boards | The per-game all-time global boards: playtime, sessions and souvenirs. Epic games use the catalogue identity from this change. |
| Minimum | A game only gives badges when its board has at least 100 ranked players (`LEADERBOARD_BADGE_MIN_PLAYERS = 100`). |
| One per game | Each threshold is a rank: 1, 5, 10, and `ceil(players × 1%)`, `ceil(players × 5%)`, `ceil(players × 10%)`. For each game and metric the user earns the highest tier their rank qualifies for, then, within that tier, the badge with the smaller rank (with 5,000 players, Top 1% is rank 50, so both #2 and #11 earn "Top 1%", gold, instead of #2 getting "Top 5", silver); at the same rank, position wins ("Top 5" over "Top 5%"). |
| Tiers | Gold for Top 1 and Top 1%, silver for Top 5 and Top 5%, bronze for Top 10 and Top 10%. These map to the existing `gold`, `silver` and `bronze` styles. |
| Locked badges | For games the user plays that pass the minimum but where they hold no badge yet, or could reach a stronger one, the next badge is shown locked, with `progress` = the user's value and the value at the target rank. Up to 8 locked badges, the closest ones first. |
| Locked text | The meta line names the target: "Faltam 4 h para o Top 10", "Faltam 3 sessões para o Top 5%". Keys `badge_remaining_to_top_rank` and `badge_remaining_to_top_percent` (singular "Falta"), plus `badge_amount_sessions` used only in this line, in `en` and `pt-BR`. Boards and cards keep showing sessions as a bare number. |
| Earned date | `earnedAt` is when the badge was first seen. It is stored per user, game, metric and kind so it survives, and a badge that is lost later disappears. |
| Where | Only in "Suas estatísticas", as today. |
| Cost | Computed per viewer from the cached boards, cached for 10 minutes. Only the viewer's 30 most played games are checked. |
### Changes by file
| Repository | File | Change |
| ---------- | -------------------------------------------------------- | ------------------------------------------------------------------------------------------------- |
| Backend | `src/leaderboards/leaderboard-viewer.service.ts` | `getLeaderboardBadges(viewerId)` with the rules above. |
| Backend | `src/leaderboards/leaderboard-viewer.controller.ts` | Real schema for the response instead of the empty list. |
| Backend | `src/constants/leaderboard.ts` | Minimum players, thresholds, locked badge limit and cache time. |
| Backend | `prisma/schema.prisma` + migration | `UserLeaderboardBadge` (userId, shop, objectId, metric, kind, value, firstSeenAt) for `earnedAt`. |
| Launcher | `src/renderer/src/pages/leaderboards/badges-section.tsx` | Locked text with the target, chip labels for Top N and Top N%. |
| Launcher | `src/locales/en/translation.json`, `pt-BR` | `badge_remaining_to`. |
### Tests
- Backend, in `sdd-local`: a game with 99 players gives no badge; with 100, rank 1 gives Top 1 gold; with 1,000 players rank 8 gives Top 1% gold, rank 40 gives Top 5% silver and rank 90 gives Top 10% bronze; with 5,000 players #2 and #11 give Top 1% gold and the next target for #11 is Top 1; a locked badge carries the value at the target rank; `earnedAt` stays the same across calls.
- Local, against the staging data: Eduardo's "Suas estatísticas" shows his real badges.

View file

@ -2751,7 +2751,6 @@
"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",
"clear_search": "Clear search",
@ -2772,9 +2771,11 @@
"streak_best": "Record",
"days_unit_one": "day",
"days_unit_other": "days",
"last_weeks": "Last {{count}} weeks",
"legend_min": "0 h",
"legend_max": "3 h+",
"activity_title": "Activity",
"activity_total_one": "{{hours}} h in {{count}} day",
"activity_total_other": "{{hours}} h in {{count}} days",
"legend_less": "Less",
"legend_more": "More",
"not_played": "Didn't play",
"today": "Today",
"yesterday": "Yesterday",
@ -2803,6 +2804,10 @@
"chip_days_other": "{{count}} DAYS",
"badge_earned_on": "Earned on {{date}}",
"badge_since": "Since {{date}}",
"badge_remaining": "{{amount}} to go"
"badge_remaining": "{{amount}} to go",
"badge_amount_sessions_one": "{{amount}} session",
"badge_amount_sessions_other": "{{amount}} sessions",
"badge_remaining_to_top_rank": "{{amount}} to go for Top {{value}}",
"badge_remaining_to_top_percent": "{{amount}} to go for Top {{value}}%"
}
}

View file

@ -2479,7 +2479,6 @@
"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",
"clear_search": "Borrar búsqueda",
@ -2500,9 +2499,11 @@
"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+",
"activity_title": "Actividad",
"activity_total_one": "{{hours}} h en {{count}} día",
"activity_total_other": "{{hours}} h en {{count}} días",
"legend_less": "Menos",
"legend_more": "Más",
"not_played": "No jugó",
"today": "Hoy",
"yesterday": "Ayer",
@ -2531,6 +2532,12 @@
"chip_days_other": "{{count}} DÍAS",
"badge_earned_on": "Conseguida el {{date}}",
"badge_since": "Desde {{date}}",
"badge_remaining": "Faltan {{amount}}"
"badge_remaining": "Faltan {{amount}}",
"badge_amount_sessions_one": "{{amount}} sesión",
"badge_amount_sessions_other": "{{amount}} sesiones",
"badge_remaining_to_top_rank_one": "Falta {{amount}} para el Top {{value}}",
"badge_remaining_to_top_rank_other": "Faltan {{amount}} para el Top {{value}}",
"badge_remaining_to_top_percent_one": "Falta {{amount}} para el Top {{value}}%",
"badge_remaining_to_top_percent_other": "Faltan {{amount}} para el Top {{value}}%"
}
}

View file

@ -2755,7 +2755,6 @@
"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",
"clear_search": "Limpar busca",
@ -2775,10 +2774,14 @@
"streak_current": "Atual",
"streak_best": "Recorde",
"days_unit_one": "dia",
"days_unit_zero": "dias",
"days_unit_other": "dias",
"last_weeks": "Últimas {{count}} semanas",
"legend_min": "0 h",
"legend_max": "3 h+",
"activity_title": "Atividade",
"activity_total_one": "{{hours}} h em {{count}} dia",
"activity_total_zero": "{{hours}} h em {{count}} dias",
"activity_total_other": "{{hours}} h em {{count}} dias",
"legend_less": "Menos",
"legend_more": "Mais",
"not_played": "Não jogou",
"today": "Hoje",
"yesterday": "Ontem",
@ -2787,6 +2790,7 @@
"online": "Online",
"offline": "Offline",
"days_count_one": "{{count}} dia",
"days_count_zero": "{{count}} dias",
"days_count_other": "{{count}} dias",
"see_all_friends": "Ver todos os amigos",
"friends_streak": "Sequência entre amigos",
@ -2807,6 +2811,12 @@
"chip_days_other": "{{count}} DIAS",
"badge_earned_on": "Conquistada em {{date}}",
"badge_since": "Desde {{date}}",
"badge_remaining": "faltam {{amount}}"
"badge_remaining": "faltam {{amount}}",
"badge_amount_sessions_one": "{{amount}} sessão",
"badge_amount_sessions_other": "{{amount}} sessões",
"badge_remaining_to_top_rank_one": "Falta {{amount}} para o Top {{value}}",
"badge_remaining_to_top_rank_other": "Faltam {{amount}} para o Top {{value}}",
"badge_remaining_to_top_percent_one": "Falta {{amount}} para o Top {{value}}%",
"badge_remaining_to_top_percent_other": "Faltam {{amount}} para o Top {{value}}%"
}
}

View file

@ -2530,7 +2530,6 @@
"review_top_week": "Самый популярный обзор недели",
"review_top_all": "Самый популярный обзор за всё время",
"review_top_of": "Самый популярный обзор {{name}}",
"review_open_game": "Открыть страницу {{game}}",
"my_games_title": "Рейтинги ваших игр",
"search_game": "Поиск игры",
"clear_search": "Очистить поиск",
@ -2553,9 +2552,13 @@
"days_unit_few": "дня",
"days_unit_many": "дней",
"days_unit_other": "дня",
"last_weeks": "Последние {{count}} недель",
"legend_min": "0 ч",
"legend_max": "3 ч+",
"activity_title": "Активность",
"activity_total_one": "{{hours}} ч за {{count}} день",
"activity_total_few": "{{hours}} ч за {{count}} дня",
"activity_total_many": "{{hours}} ч за {{count}} дней",
"activity_total_other": "{{hours}} ч за {{count}} дня",
"legend_less": "Меньше",
"legend_more": "Больше",
"not_played": "Не играл",
"today": "Сегодня",
"yesterday": "Вчера",
@ -2590,6 +2593,12 @@
"chip_days_other": "{{count}} ДНЯ",
"badge_earned_on": "Получен {{date}}",
"badge_since": "С {{date}}",
"badge_remaining": "Осталось {{amount}}"
"badge_remaining": "Осталось {{amount}}",
"badge_amount_sessions_one": "{{amount}} сессия",
"badge_amount_sessions_few": "{{amount}} сессии",
"badge_amount_sessions_many": "{{amount}} сессий",
"badge_amount_sessions_other": "{{amount}} сессии",
"badge_remaining_to_top_rank": "Осталось {{amount}} до Топ {{value}}",
"badge_remaining_to_top_percent": "Осталось {{amount}} до Топ {{value}}%"
}
}

View file

@ -2,7 +2,14 @@ import { registerEvent } from "../register-event";
import { HydraApi } from "@main/services";
interface HydraApiCallPayload {
method: "get" | "post" | "postResponse" | "put" | "patch" | "delete";
method:
| "get"
| "getResponse"
| "post"
| "postResponse"
| "put"
| "patch"
| "delete";
url: string;
data?: unknown;
params?: unknown;
@ -55,6 +62,11 @@ const hydraApiCall = async (
case "get":
request = HydraApi.get(url, params, hydraApiOptions);
break;
case "getResponse":
request = HydraApi.getResponse(url, params, hydraApiOptions).then(
({ status, data }) => ({ status, data })
);
break;
case "post":
request = HydraApi.post(url, data, hydraApiOptions);
break;

View file

@ -1592,6 +1592,23 @@ contextBridge.exposeInMainWorld("electron", {
timeout: options?.timeout,
},
}),
getResponse: <T = unknown>(
url: string,
options?: {
params?: unknown;
needsAuth?: boolean;
acceptedStatuses?: number[];
}
) =>
ipcRenderer.invoke("hydraApiCall", {
method: "getResponse",
url,
params: options?.params,
options: {
needsAuth: options?.needsAuth,
acceptedStatuses: options?.acceptedStatuses,
},
}) as Promise<{ status: number; data: T }>,
post: (
url: string,
options?: {

View file

@ -1163,6 +1163,14 @@ declare global {
timeout?: number;
}
) => Promise<T>;
getResponse: <T = unknown>(
url: string,
options?: {
params?: unknown;
needsAuth?: boolean;
acceptedStatuses?: number[];
}
) => Promise<{ status: number; data: T }>;
post: <T = unknown>(
url: string,
options?: {

View file

@ -4,6 +4,7 @@ import Skeleton from "react-loading-skeleton";
import { useTranslation } from "react-i18next";
import { Button, RankFrameRing } from "@renderer/components";
import { MAX_MINUTES_TO_SHOW_IN_PLAYTIME } from "@renderer/constants";
import { useDate, useFormat } from "@renderer/hooks";
import {
BADGES_PAGE_SIZE,
@ -14,6 +15,7 @@ import {
type LeaderboardBadge,
type MetricDefinition,
} from "./global-leaderboard";
import { getDurationParts } from "../game-details/leaderboard/game-leaderboard";
import { useApiResource } from "./leaderboards-api";
import { BlockError, StreakFlame } from "./leaderboard-bits";
import { useMetricText } from "./use-global-leaderboard-value";
@ -134,7 +136,27 @@ export function BadgesSection() {
const metric = findDefinition(badge.metric);
if (!metric) return "";
const amount = metricText.format(metric, Math.ceil(remaining));
const count = Math.ceil(remaining);
// Boards show bare session counts; only the badge text names the unit.
const amount = t(`badge_amount_${metric.id}`, {
count,
amount: numberFormatter.format(count),
defaultValue: metricText.format(metric, count),
});
// "4 h to go for Top 10", "3 sessions to go for Top 5%". The count is the
// number shown, so "1 h" and "1 session" read as singular.
if (badge.target) {
return t("badge_remaining_to", {
context: badge.target.kind,
count:
metric.valueType === "duration"
? getDurationParts(count, MAX_MINUTES_TO_SHOW_IN_PLAYTIME).amount
: count,
amount,
value: badge.target.value,
});
}
return t("badge_remaining", { amount });
};

View file

@ -4,6 +4,7 @@ import { describe, it } from "node:test";
import {
buildGlobalLeaderboardRequest,
buildHeatmap,
buildHeatmapMonths,
buildMetricsRequest,
hasReviewData,
hasStreakData,
@ -22,6 +23,7 @@ import {
normalizeMyGames,
normalizePlayActivity,
resolveWeekRange,
sumHeatmap,
type GlobalLeaderboardQuery,
} from "./global-leaderboard.js";
@ -339,15 +341,16 @@ describe("play activity", () => {
assert.equal(getPlayLevel(4 * 3600), 3);
});
it("builds 15 weeks of cells ending in the current week", () => {
it("builds 53 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");
assert.equal(cells.length, 371);
assert.equal(cells[0].date.toISOString(), "2025-10-20T00:00:00.000Z");
assert.equal(cells.at(-7)?.date.toISOString(), "2026-10-19T00:00:00.000Z");
const today = cells.find((cell) => cell.isToday);
assert.equal(today?.level, 2);
@ -355,6 +358,50 @@ describe("play activity", () => {
assert.equal(cells.filter((cell) => cell.isFuture).length, 4);
});
it("labels each month on the first week that starts in it", () => {
const labels = (now: number) =>
buildHeatmapMonths(buildHeatmap([], now)).map(
({ week, date }) => `${week}:${date.toISOString().slice(0, 10)}`
);
// The first column (Oct 20) is two weeks from November, so it is dropped.
assert.deepEqual(labels(Date.UTC(2026, 9, 21, 12)), [
"2:2025-11-03",
"6:2025-12-01",
"11:2026-01-05",
"15:2026-02-02",
"19:2026-03-02",
"24:2026-04-06",
"28:2026-05-04",
"32:2026-06-01",
"37:2026-07-06",
"41:2026-08-03",
"46:2026-09-07",
"50:2026-10-05",
]);
// The first column keeps its label when the next month is far enough,
// and a month starting in the last column has no room for its label.
const december = labels(Date.UTC(2026, 10, 30, 12));
assert.equal(december[0], "0:2025-12-01");
assert.equal(december.at(-1), "48:2026-11-02");
});
it("sums the seconds and played days of the heatmap", () => {
const cells = buildHeatmap(
[
{ date: "2026-10-19", seconds: 3600, games: [] },
{ date: "2026-10-21", seconds: 1800, games: [] },
{ date: "2026-01-02", seconds: 0, games: [] },
{ date: "2024-01-01", seconds: 9999, games: [] },
],
Date.UTC(2026, 9, 21, 12)
);
assert.deepEqual(sumHeatmap(cells), { seconds: 5400, days: 2 });
assert.deepEqual(sumHeatmap([]), { seconds: 0, days: 0 });
});
it("flags today and yesterday by UTC day", () => {
const flagged = (now: number) => {
const cells = buildHeatmap([], now);
@ -384,11 +431,30 @@ describe("play activity", () => {
});
assert.equal(activity.currentStreak, 5);
assert.equal(activity.weeks, 53);
assert.deepEqual(activity.days, [
{ date: "2026-10-21", seconds: 60, games: ["A"] },
]);
assert.throws(() => normalizePlayActivity({}), TypeError);
});
it("sizes the heatmap from the weeks the API answered for", () => {
const now = Date.UTC(2026, 9, 21, 12);
const activity = normalizePlayActivity(
{ days: [{ date: "2026-10-20", seconds: 7200, games: [] }] },
26
);
const cells = buildHeatmap(activity.days, now, activity.weeks);
assert.equal(activity.weeks, 26);
assert.equal(cells.length, 182);
assert.equal(cells[0].date.toISOString(), "2026-04-27T00:00:00.000Z");
assert.deepEqual(sumHeatmap(cells), { seconds: 7200, days: 1 });
assert.deepEqual(
buildHeatmapMonths(cells).map(({ week }) => week),
[1, 5, 10, 14, 19, 23]
);
});
});
describe("profile normalizers", () => {
@ -464,4 +530,37 @@ describe("profile normalizers", () => {
assert.equal(getBadgeProgress(badges.badges[0]), 0);
assert.throws(() => normalizeBadges({}), TypeError);
});
it("reads the target of locked game badges", () => {
const { badges } = normalizeBadges({
badges: [
{ id: "a", kind: "top_rank", tier: "bronze", value: 10 },
{
id: "b",
kind: "top_rank",
value: 10,
target: { kind: "top_percent", value: 5 },
},
{
id: "c",
kind: "top_rank",
value: 1,
earnedAt: "2026-09-03T00:00:00.000Z",
},
{ id: "d", kind: "weekly_leader", value: 1 },
{ id: "e", kind: "top_rank", target: { kind: "nope", value: 3 } },
],
});
assert.deepEqual(
badges.map((badge) => badge.target),
[
{ kind: "top_rank", value: 10 },
{ kind: "top_percent", value: 5 },
null,
null,
null,
]
);
});
});

View file

@ -10,7 +10,13 @@ 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 PLAY_ACTIVITY_WEEKS = 53;
// Older backends cap play activity at 26 weeks and answer 400 above it.
export const PLAY_ACTIVITY_FALLBACK_WEEKS = 26;
// A month label needs about two columns, so labels closer than this to the
// next one or to the end of the grid are dropped.
const HEATMAP_MONTH_MIN_WEEKS = 3;
const HEATMAP_MONTH_END_WEEKS = 2;
export const BADGES_PAGE_SIZE = 8;
export const SECONDS_PER_MINUTE = 60;
export const MINUTES_PER_HOUR = 60;
@ -103,6 +109,8 @@ export interface PlayActivityDay {
}
export interface PlayActivity {
// Weeks the API answered for, which can be fewer than asked for.
weeks: number;
currentStreak: number;
bestStreak: number;
playedToday: boolean;
@ -130,6 +138,12 @@ export const BADGE_TIERS = ["gold", "silver", "bronze", "streak"] as const;
export type BadgeKind = (typeof BADGE_KINDS)[number];
export type BadgeTier = (typeof BADGE_TIERS)[number];
// Locked game badges name the badge they lead to ("Top 10", "Top 5%").
export interface BadgeTarget {
kind: "top_rank" | "top_percent";
value: number;
}
export interface LeaderboardBadge {
id: string;
kind: BadgeKind;
@ -143,6 +157,7 @@ export interface LeaderboardBadge {
currentRank: number | null;
earnedAt: string | null;
progress: { current: number; target: number } | null;
target: BadgeTarget | null;
}
export interface LeaderboardBadges {
@ -457,6 +472,41 @@ export const buildHeatmap = (
});
};
export interface HeatmapMonth {
week: number;
date: Date;
}
// Labels each month on the first week that starts in it, like a contribution
// graph. Cells are week-major, so every seventh cell is a Monday.
export const buildHeatmapMonths = (cells: HeatmapCell[]): HeatmapMonth[] => {
const weeks = Math.floor(cells.length / 7);
const months: HeatmapMonth[] = [];
for (let week = 0; week < weeks; week++) {
const date = cells[week * 7].date;
const previous = week > 0 ? cells[(week - 1) * 7].date : null;
if (previous?.getUTCMonth() === date.getUTCMonth()) continue;
months.push({ week, date });
}
return months.filter(
(month, index) =>
weeks - month.week >= HEATMAP_MONTH_END_WEEKS &&
(month.week > 0 ||
(months[index + 1]?.week ?? weeks) >= HEATMAP_MONTH_MIN_WEEKS)
);
};
export const sumHeatmap = (cells: HeatmapCell[]) =>
cells.reduce(
(total, cell) => ({
seconds: total.seconds + cell.seconds,
days: total.days + (cell.seconds > 0 ? 1 : 0),
}),
{ seconds: 0, days: 0 }
);
export const normalizeMyGames = (response: unknown): MyGameRanking[] => {
const items = isRecord(response) ? response.games : response;
if (!Array.isArray(items)) throw new TypeError("Invalid games response");
@ -488,12 +538,16 @@ export const normalizeMyGames = (response: unknown): MyGameRanking[] => {
});
};
export const normalizePlayActivity = (response: unknown): PlayActivity => {
export const normalizePlayActivity = (
response: unknown,
weeks = PLAY_ACTIVITY_WEEKS
): PlayActivity => {
if (!isRecord(response) || !Array.isArray(response.days)) {
throw new TypeError("Invalid play activity response");
}
return {
weeks,
currentStreak: toNonNegativeNumber(response.currentStreak),
bestStreak: toNonNegativeNumber(response.bestStreak),
playedToday: response.playedToday === true,
@ -538,6 +592,18 @@ export const normalizeFriendStreaks = (response: unknown): FriendStreak[] => {
.sort((a, b) => Number(b.active) - Number(a.active) || b.days - a.days);
};
// Reads `target` when the API sends it and otherwise falls back to the locked
// badge itself, whose kind and value already describe the next badge.
const normalizeBadgeTarget = (
item: Record<string, unknown>
): BadgeTarget | null => {
if (toStringOrNull(item.earnedAt)) return null;
const source = isRecord(item.target) ? item.target : item;
const value = toNonNegativeNumber(source.value);
if (source.kind !== "top_rank" && source.kind !== "top_percent") return null;
return value ? { kind: source.kind, value } : null;
};
export const normalizeBadges = (response: unknown): LeaderboardBadges => {
if (!isRecord(response) || !Array.isArray(response.badges)) {
throw new TypeError("Invalid badges response");
@ -547,6 +613,7 @@ export const normalizeBadges = (response: unknown): LeaderboardBadges => {
if (!isRecord(item) || typeof item.id !== "string") return [];
if (!(BADGE_KINDS as readonly unknown[]).includes(item.kind)) return [];
const target = normalizeBadgeTarget(item);
const progress = isRecord(item.progress)
? {
current: toNonNegativeNumber(item.progress.current),
@ -570,6 +637,7 @@ export const normalizeBadges = (response: unknown): LeaderboardBadges => {
currentRank: toPositiveInteger(item.currentRank),
earnedAt: toStringOrNull(item.earnedAt),
progress,
target,
},
];
});

View file

@ -3,7 +3,10 @@ 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 {
leaderboardsCache,
LeaderboardRequestError,
} from "@renderer/services/leaderboards-cache";
import type {
LeaderboardRequestOptions,
LeaderboardUser,
@ -53,16 +56,56 @@ const readCached = <T>(
}
};
export interface ApiFallback<T> {
options: LeaderboardRequestOptions;
normalize: (response: unknown) => T;
}
// Main requests the API rejected with a 400, so later visits in this session
// go straight to the fallback instead of failing first.
const rejectedRequests = new Set<string>();
const isRejected = (
path: string | null,
options: LeaderboardRequestOptions,
accountId: string | null
) =>
path !== null &&
rejectedRequests.has(getRequestKey(path, options, accountId));
const readCachedWithFallback = <T>(
path: string | null,
options: LeaderboardRequestOptions,
fallback: ApiFallback<T> | undefined,
accountId: string | null,
normalize: (response: unknown) => T
): T | null =>
(isRejected(path, options, accountId)
? null
: readCached(path, options, accountId, normalize)) ??
(fallback
? readCached(path, fallback.options, accountId, fallback.normalize)
: null);
// With `fallback`, a 400 from the main request is retried once with the
// fallback options, and only a 400: other errors are reported as usual.
export function useApiResource<T>(
path: string | null,
options: LeaderboardRequestOptions,
normalize: (response: unknown) => T
normalize: (response: unknown) => T,
fallback?: ApiFallback<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);
const data = readCachedWithFallback(
path,
options,
fallback,
accountId,
normalize
);
return {
key: requestKey,
data,
@ -72,14 +115,15 @@ export function useApiResource<T>(
});
const [attempt, setAttempt] = useState(0);
const requestIdRef = useRef(0);
const latest = useRef({ path, options, accountId, normalize });
latest.current = { path, options, accountId, normalize };
const latest = useRef({ path, options, fallback, accountId, normalize });
latest.current = { path, options, fallback, accountId, normalize };
useEffect(() => {
const requestId = ++requestIdRef.current;
const {
path: currentPath,
options: currentOptions,
fallback: currentFallback,
accountId: currentAccountId,
} = latest.current;
@ -88,9 +132,10 @@ export function useApiResource<T>(
return;
}
const cached = readCached(
const cached = readCachedWithFallback(
currentPath,
currentOptions,
currentFallback,
currentAccountId,
latest.current.normalize
);
@ -111,16 +156,37 @@ export function useApiResource<T>(
hasError: false,
}));
leaderboardsCache
.get(currentPath, currentOptions, currentAccountId)
.then((response) => {
const fetchFallback = () => {
if (!currentFallback) throw new Error("No fallback request");
return leaderboardsCache
.get(currentPath, currentFallback.options, currentAccountId)
.then(currentFallback.normalize);
};
const request =
currentFallback &&
isRejected(currentPath, currentOptions, currentAccountId)
? fetchFallback()
: leaderboardsCache
.get(currentPath, currentOptions, currentAccountId)
.then((response) => latest.current.normalize(response))
.catch((error: unknown) => {
if (
!currentFallback ||
!(error instanceof LeaderboardRequestError) ||
error.status !== 400
) {
throw error;
}
rejectedRequests.add(
getRequestKey(currentPath, currentOptions, currentAccountId)
);
return fetchFallback();
});
request
.then((data) => {
if (requestId !== requestIdRef.current) return;
setState({
key: requestKey,
data: latest.current.normalize(response),
isLoading: false,
hasError: false,
});
setState({ key: requestKey, data, isLoading: false, hasError: false });
})
.catch((error) => {
if (requestId !== requestIdRef.current) return;
@ -139,7 +205,13 @@ export function useApiResource<T>(
const isCurrent = state.key === requestKey;
if (!isCurrent) {
const pending = readCached(path, options, accountId, normalize);
const pending = readCachedWithFallback(
path,
options,
fallback,
accountId,
normalize
);
return {
data: pending,
isLoading: Boolean(path) && pending === null,

View file

@ -7,6 +7,8 @@ $leaderboards-down: #ff6b81;
$leaderboards-ended: #838383;
$leaderboards-streak-from: #22c7ee;
$leaderboards-streak-to: #b05cff;
$leaderboards-heat-size: 12px;
$leaderboards-heat-gap: 3px;
.leaderboards {
display: flex;
@ -54,10 +56,11 @@ $leaderboards-streak-to: #b05cff;
}
}
// The heading grows to fill the row, which keeps the toggles on the right;
// when they wrap they start under the title instead.
&__toggles {
min-width: min(100%, 500px);
max-width: 100%;
margin-inline-start: auto;
display: flex;
gap: globals.$spacing-unit;
@ -147,8 +150,7 @@ $leaderboards-streak-to: #b05cff;
&__cards {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
gap: globals.$spacing-unit;
}
@ -269,12 +271,6 @@ $leaderboards-streak-to: #b05cff;
padding: calc(globals.$spacing-unit * 2);
}
&__panel-title {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__board-header {
display: flex;
align-items: center;
@ -401,17 +397,30 @@ $leaderboards-streak-to: #b05cff;
clip: rect(0 0 0 0);
}
// The selected review reuses the game page review (review-item.scss); these
// only add the game link and the static vote count.
&__review {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
padding: calc(globals.$spacing-unit * 2);
border: solid 1px rgba(243, 198, 17, 0.35);
border-radius: 8px;
background-color: globals.$dark-background-color;
gap: globals.$spacing-unit;
}
&__review-header {
&__review-rating {
margin-left: 2px;
color: rgba(255, 255, 255, 0.7);
}
&__review-game {
font-size: globals.$small-font-size;
}
&__review-highlight {
display: flex;
flex-direction: column;
gap: calc(globals.$spacing-unit * 1.5);
}
&__review-title {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
@ -423,45 +432,21 @@ $leaderboards-streak-to: #b05cff;
}
}
&__review-content {
display: flex;
align-items: flex-start;
gap: calc(globals.$spacing-unit * 2);
}
&__review-cover {
width: 92px;
height: 138px;
flex-shrink: 0;
object-fit: cover;
border: solid 1px globals.$border-color;
border-radius: 6px;
background-color: $leaderboards-surface;
}
&__review-body {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
}
&__review-game {
// Only here: a long review must not push the ranking down. The game page
// shows reviews in full.
&__review-text {
margin: 0;
font-size: globals.$body-font-size;
line-height: 20px;
font-weight: 700;
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 5;
line-clamp: 5;
overflow: hidden;
}
&__review-author {
display: flex;
align-items: center;
gap: globals.$spacing-unit;
}
&__review-name {
font-weight: 700;
// A vote count, not a button: keep the game page look without its hover.
&__review-votes {
cursor: default;
pointer-events: none;
}
&__review-score {
@ -476,34 +461,6 @@ $leaderboards-streak-to: #b05cff;
}
}
&__review-text {
display: -webkit-box;
-webkit-box-orient: vertical;
-webkit-line-clamp: 2;
line-clamp: 2;
overflow: hidden;
font-size: globals.$hint-font-size;
line-height: 20px;
}
&__review-footer {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: globals.$spacing-unit;
}
&__review-votes {
display: inline-flex;
align-items: center;
gap: 6px;
font-weight: 600;
svg {
color: $leaderboards-streak-from;
}
}
&__review-tip {
width: 360px;
display: flex;
@ -821,6 +778,12 @@ $leaderboards-streak-to: #b05cff;
.leaderboards__panel {
justify-content: space-between;
}
// Empty states fill the rest of the card and sit in its middle.
.leaderboards__panel > .leaderboards__message {
flex: 1;
justify-content: center;
}
}
&__mine {
@ -937,96 +900,129 @@ $leaderboards-streak-to: #b05cff;
padding: 0 globals.$spacing-unit globals.$spacing-unit;
}
&__streak-numbers {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
padding: 4px 0;
}
&__streak-number {
&__activity-header {
display: flex;
flex-direction: column;
flex-wrap: wrap;
align-items: center;
gap: 2px;
& + & {
border-left: solid 1px globals.$border-color;
}
justify-content: space-between;
gap: globals.$spacing-unit calc(globals.$spacing-unit * 2);
}
&__streak-label {
// Inset by the card padding so the numbers do not touch the right edge.
&__activity-streaks {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: globals.$spacing-unit calc(globals.$spacing-unit * 2);
padding-inline-end: calc(globals.$spacing-unit * 2);
font-size: globals.$small-font-size;
color: $leaderboards-muted-text;
}
&__streak-value {
display: flex;
&__activity-streak {
display: inline-flex;
align-items: baseline;
gap: 6px;
font-size: globals.$body-font-size;
color: globals.$body-color;
strong {
font-size: 24px;
line-height: 30px;
font-size: 18px;
line-height: 22px;
font-weight: 800;
color: globals.$muted-color;
}
}
&__heatmap-block {
&__activity-body {
position: relative;
width: max-content;
max-width: 100%;
margin-inline: auto;
display: flex;
flex-direction: column;
gap: globals.$spacing-unit;
gap: calc(globals.$spacing-unit * 1.5);
}
&__heatmap-header {
display: flex;
align-items: center;
justify-content: space-between;
&__activity-scroll {
overflow-x: auto;
padding-bottom: 4px;
}
&__activity-graph {
width: max-content;
display: grid;
grid-template-columns: auto auto;
grid-template-areas:
". months"
"days cells";
gap: 4px 6px;
}
&__activity-months {
grid-area: months;
display: grid;
// One column per week, like the cells, however many weeks there are.
grid-auto-columns: $leaderboards-heat-size;
column-gap: $leaderboards-heat-gap;
height: 14px;
}
&__activity-month {
grid-row: 1;
font-size: 11px;
line-height: 14px;
white-space: nowrap;
color: $leaderboards-muted-text;
}
&__heatmap-title {
font-weight: 700;
letter-spacing: 0.08em;
text-transform: uppercase;
&__activity-days {
grid-area: days;
display: grid;
grid-template-rows: repeat(7, $leaderboards-heat-size);
row-gap: $leaderboards-heat-gap;
}
&__activity-footer {
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: space-between;
gap: globals.$spacing-unit calc(globals.$spacing-unit * 2);
font-size: globals.$small-font-size;
color: $leaderboards-muted-text;
}
&__heatmap-legend {
display: flex;
align-items: center;
gap: 4px;
gap: 3px;
.leaderboards__heat {
width: 10px;
height: 10px;
border-radius: 2px;
width: $leaderboards-heat-size;
height: $leaderboards-heat-size;
}
}
&__heatmap {
grid-area: cells;
display: grid;
grid-template-columns: 12px repeat(15, minmax(0, 1fr));
grid-template-rows: repeat(7, auto);
grid-template-rows: repeat(7, $leaderboards-heat-size);
grid-auto-columns: $leaderboards-heat-size;
grid-auto-flow: column;
gap: 3px;
gap: $leaderboards-heat-gap;
}
&__heatmap-day {
display: flex;
align-items: center;
font-size: 9px;
font-size: 10px;
line-height: 1;
color: $leaderboards-muted-text;
}
&__heat {
position: relative;
aspect-ratio: 1;
box-sizing: border-box;
border: solid 1px transparent;
border-radius: 3px;
border-radius: 2px;
background-color: rgba(255, 255, 255, 0.06);
&--1 {
@ -1051,20 +1047,21 @@ $leaderboards-streak-to: #b05cff;
border-color: globals.$muted-color;
}
&:hover .leaderboards__heat-tip {
display: block;
&[data-heat-index]:hover {
border-color: rgba(255, 255, 255, 0.6);
}
}
// Positioned from the hovered cell in stats-tab.tsx; the modifiers only
// choose which side of that point the tip opens to.
&__heat-tip {
--heat-tip-x: -50%;
--heat-tip-y: -100%;
position: absolute;
z-index: 5;
bottom: calc(100% + 6px);
left: 50%;
transform: translateX(-50%);
transform: translate(var(--heat-tip-x), var(--heat-tip-y));
width: max-content;
max-width: 280px;
display: none;
padding: 6px globals.$spacing-unit;
border: solid 1px rgba(255, 255, 255, 0.15);
border-radius: 6px;
@ -1077,19 +1074,15 @@ $leaderboards-streak-to: #b05cff;
}
&__heat-tip--below {
top: calc(100% + 6px);
bottom: auto;
--heat-tip-y: 0;
}
&__heat-tip--start {
left: 0;
transform: none;
--heat-tip-x: -12px;
}
&__heat-tip--end {
right: 0;
left: auto;
transform: none;
--heat-tip-x: calc(-100% + 12px);
}
&__heat-tip-game {

View file

@ -1,21 +1,11 @@
import { useEffect, useState } from "react";
import cn from "classnames";
import {
ArrowRightIcon,
ClockIcon,
PeopleIcon,
StarIcon,
ThumbsupIcon,
TrophyIcon,
} from "@primer/octicons-react";
import { ClockIcon, PeopleIcon, StarIcon } 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,
@ -33,6 +23,7 @@ import {
import { BoardView } from "./board-view";
import { BlockError, BlockMessage, BoardSkeleton } from "./leaderboard-bits";
import { MetricIcon } from "./metric-icon";
import { ReviewHighlight } from "./review-highlight";
import {
EMPTY_BOARD_RESULT,
type GlobalBoardResults,
@ -40,7 +31,7 @@ import {
import { useMetricText } from "./use-global-leaderboard-value";
const METRIC_CARD_PLACEHOLDERS = Array.from(
{ length: 7 },
{ length: 8 },
(_, position) => `metric-placeholder-${position}`
);
@ -88,7 +79,6 @@ export function RankingTab({
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);
@ -215,65 +205,11 @@ export function RankingTab({
: 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>
<ReviewHighlight
title={title}
entry={selectedEntry}
review={selectedReview}
/>
);
};

View file

@ -0,0 +1,162 @@
import { ArrowRightIcon, TrophyIcon } from "@primer/octicons-react";
import { Star, ThumbsUp } from "lucide-react";
import cn from "classnames";
import { useTranslation } from "react-i18next";
import { useNavigate } from "react-router-dom";
import { Avatar } from "@renderer/components";
import { REVIEW_BANNER_IMAGE_SIZE } from "@renderer/constants";
import { useDate, useFormat, useProcessedImage } from "@renderer/hooks";
import { buildGameDetailsPath } from "@renderer/helpers";
import type { GameShop } from "@types";
import type { LeaderboardEntry } from "../game-details/leaderboard/game-leaderboard";
import type { LeaderboardReview } from "./global-leaderboard";
import "../game-details/review-item.scss";
const RATING_KEYS: Record<number, string> = {
1: "rating_very_negative",
2: "rating_negative",
3: "rating_neutral",
4: "rating_positive",
5: "rating_very_positive",
};
export interface ReviewHighlightProps {
title: string;
entry: LeaderboardEntry;
review: LeaderboardReview;
}
// The selected review, drawn like a review on the game page (review-item.tsx)
// without the vote buttons, replies and translation: the leaderboard payload
// has no review id. The game is a link line above the text.
export function ReviewHighlight({
title,
entry,
review,
}: Readonly<ReviewHighlightProps>) {
const { t } = useTranslation("leaderboards");
const { t: tGame } = useTranslation("game_details");
const { formatDistance, formatDateTime } = useDate();
const { numberFormatter } = useFormat();
const navigate = useNavigate();
const bannerImageUrl = useProcessedImage(
entry.user.backgroundImageUrl,
REVIEW_BANNER_IMAGE_SIZE
);
const createdAt = review.createdAt ? new Date(review.createdAt) : null;
const ratingKey = RATING_KEYS[Math.round(review.score)];
const openProfile = () => navigate(`/profile/${entry.user.id}`);
return (
<section className="leaderboards__review-highlight">
<div className="leaderboards__review-title">
<TrophyIcon size={16} />
<span>{title}</span>
</div>
<article className="game-details__review-item leaderboards__review">
<div
className={cn("game-details__review-header", {
"game-details__review-header--has-bg": bannerImageUrl,
})}
>
{bannerImageUrl && (
<img
src={bannerImageUrl}
alt=""
loading="lazy"
decoding="async"
className="game-details__review-banner"
/>
)}
<div className="game-details__review-header-top">
<div className="game-details__review-user">
<button
type="button"
onClick={openProfile}
title={entry.user.displayName}
>
<Avatar
src={entry.user.profileImageUrl}
alt={entry.user.displayName}
size={56}
/>
</button>
<div className="game-details__review-user-info">
<div className="game-details__review-name-row">
<button
type="button"
className="game-details__review-display-name game-details__review-display-name--clickable"
onClick={openProfile}
>
{entry.user.displayName}
</button>
</div>
<div className="game-details__review-meta-row">
<div className="game-details__review-meta-left">
<div className="game-details__review-score-stars">
<Star
size={12}
fill="currentColor"
className="game-details__review-star game-details__review-star--filled"
/>
<span className="game-details__review-score-text">
{t("review_score", { score: review.score })}
</span>
{ratingKey && (
<span className="leaderboards__review-rating">
{tGame(ratingKey)}
</span>
)}
</div>
</div>
</div>
</div>
</div>
{createdAt && (
<div
className="game-details__review-date"
title={formatDateTime(createdAt)}
>
{formatDistance(createdAt, new Date(), { addSuffix: true })}
</div>
)}
</div>
</div>
<button
type="button"
className="leaderboards__link leaderboards__review-game"
onClick={() =>
navigate(
buildGameDetailsPath({
shop: review.shop as GameShop,
objectId: review.objectId,
title: review.gameTitle,
})
)
}
>
{review.gameTitle}
<ArrowRightIcon size={14} />
</button>
<p className="game-details__review-content leaderboards__review-text">
{review.text}
</p>
<div className="game-details__review-actions">
<div className="game-details__review-actions-left">
<div className="game-details__review-votes">
<span className="game-details__vote-button leaderboards__review-votes">
<ThumbsUp size={16} />
<span>{numberFormatter.format(entry.value)}</span>
</span>
</div>
</div>
</div>
</article>
</section>
);
}

View file

@ -1,4 +1,11 @@
import { Fragment, useEffect, useMemo, useState } from "react";
import {
Fragment,
useEffect,
useLayoutEffect,
useMemo,
useRef,
useState,
} from "react";
import cn from "classnames";
import { PeopleIcon } from "@primer/octicons-react";
import Skeleton from "react-loading-skeleton";
@ -6,17 +13,21 @@ import { useTranslation } from "react-i18next";
import { Avatar, Button } from "@renderer/components";
import { LEADERBOARD_HEADER_BANNER_IMAGE_SIZE } from "@renderer/constants";
import { useProcessedImage } from "@renderer/hooks";
import { useFormat, useProcessedImage } from "@renderer/hooks";
import type { LeaderboardUser } from "../game-details/leaderboard/game-leaderboard";
import {
buildHeatmap,
buildHeatmapMonths,
DAY_MS,
getViewerRanking,
normalizeFriendStreaks,
normalizePlayActivity,
PLAY_ACTIVITY_FALLBACK_WEEKS,
PLAY_ACTIVITY_WEEKS,
MINUTES_PER_HOUR,
SECONDS_PER_HOUR,
SECONDS_PER_MINUTE,
sumHeatmap,
type MetricDefinition,
type GlobalLeaderboardPeriod,
type HeatmapCell,
@ -50,9 +61,38 @@ export interface StatsTabProps {
onRetry: () => void;
}
// Mondays to Sundays; only Monday, Wednesday and Friday get a label.
const DAY_LABEL_DATES = [5, 6, 7, 8, 9, 10, 11].map(
(day) => new Date(Date.UTC(2024, 1, day))
);
const LABELED_DAYS = new Set([0, 2, 4]);
const ACTIVITY_PARAMS = { weeks: PLAY_ACTIVITY_WEEKS };
// TODO: remove once hydra-api fix/epic-game-leaderboard is deployed.
// Older APIs answer 400 above 26 weeks; the grid then shows only those.
const ACTIVITY_FALLBACK = {
options: {
params: { weeks: PLAY_ACTIVITY_FALLBACK_WEEKS },
needsAuth: true,
},
normalize: (response: unknown) =>
normalizePlayActivity(response, PLAY_ACTIVITY_FALLBACK_WEEKS),
};
// Tips closer than this to an edge of the graph open inward.
const HEAT_TIP_EDGE_PX = 150;
const HEAT_TIP_GAP_PX = 6;
// Month and weekday names are lower case in some locales (pt-BR "nov.").
const capitalize = (text: string, locale: string) =>
text.charAt(0).toLocaleUpperCase(locale) + text.slice(1);
interface HeatTip {
index: number;
x: number;
y: number;
below: boolean;
start: boolean;
end: boolean;
}
function useUtcDay() {
const [day, setDay] = useState(
@ -79,6 +119,7 @@ export function StatsTab({
onRetry,
}: Readonly<StatsTabProps>) {
const { t, i18n } = useTranslation("leaderboards");
const { numberFormatter } = useFormat();
const metricText = useMetricText();
const viewerId = viewerUser?.id ?? null;
const isWeek = period === "week";
@ -89,8 +130,9 @@ export function StatsTab({
const activity = useApiResource(
"/users/me/play-activity",
{ params: { weeks: PLAY_ACTIVITY_WEEKS }, needsAuth: true },
normalizePlayActivity
{ params: ACTIVITY_PARAMS, needsAuth: true },
normalizePlayActivity,
ACTIVITY_FALLBACK
);
const friends = useApiResource(
"/users/me/friends/streaks",
@ -107,21 +149,52 @@ export function StatsTab({
});
const heatmap = useMemo(
() => buildHeatmap(activity.data?.days ?? [], utcDay),
() =>
buildHeatmap(
activity.data?.days ?? [],
utcDay,
activity.data?.weeks ?? PLAY_ACTIVITY_WEEKS
),
[activity.data, utcDay]
);
const dayLabels = useMemo(() => {
const heatmapMonths = useMemo(() => {
const formatter = new Intl.DateTimeFormat(i18n.language, {
weekday: "narrow",
month: "short",
timeZone: "UTC",
});
return DAY_LABEL_DATES.map((date) => ({
return buildHeatmapMonths(heatmap).map((month) => ({
...month,
label: capitalize(formatter.format(month.date), i18n.language),
}));
}, [heatmap, i18n.language]);
const heatmapTotal = useMemo(() => sumHeatmap(heatmap), [heatmap]);
const dayLabels = useMemo(() => {
const formatter = new Intl.DateTimeFormat(i18n.language, {
weekday: "short",
timeZone: "UTC",
});
return DAY_LABEL_DATES.map((date, index) => ({
key: date.toISOString(),
label: formatter.format(date),
label: LABELED_DAYS.has(index)
? capitalize(formatter.format(date), i18n.language)
: "",
}));
}, [i18n.language]);
const activityBodyRef = useRef<HTMLDivElement>(null);
const activityScrollRef = useRef<HTMLDivElement>(null);
const [heatTip, setHeatTip] = useState<HeatTip | null>(null);
const hasActivity = activity.data !== null;
// The newest weeks are on the right, so a narrow card starts at today.
useLayoutEffect(() => {
const scroll = activityScrollRef.current;
if (hasActivity && scroll) scroll.scrollLeft = scroll.scrollWidth;
}, [hasActivity]);
const cellDateFormatter = useMemo(
() =>
new Intl.DateTimeFormat(i18n.language, {
@ -159,114 +232,180 @@ export function StatsTab({
return [cellLabel(cell), time].join(" · ");
};
const heatmapWeeks = heatmap.length / 7;
// Tips in the first and last columns open inward and tips in the top rows
// The tip lives outside the scrolling grid so it is never clipped. Tips
// near the left and right edges open inward and tips in the top two rows
// open below, so they stay in the card.
const renderCellTip = (cell: HeatmapCell, index: number) => (
<span
className={cn("leaderboards__heat-tip", {
"leaderboards__heat-tip--start": Math.floor(index / 7) < 2,
"leaderboards__heat-tip--end":
Math.floor(index / 7) >= heatmapWeeks - 2,
"leaderboards__heat-tip--below": index % 7 < 2,
})}
aria-hidden="true"
>
{cellSummary(cell)}
{cell.games.map((game, index) => (
<Fragment key={`${game}-${index}`}>
{index === 0 ? " · " : ", "}
<span className="leaderboards__heat-tip-game">{game}</span>
</Fragment>
))}
</span>
);
const showHeatTip = (event: React.MouseEvent<HTMLDivElement>) => {
// The gaps between cells keep the current tip; future cells hide it.
const target = (event.target as HTMLElement).closest<HTMLElement>(
".leaderboards__heat"
);
const body = activityBodyRef.current;
if (!target || !body) return;
if (target.dataset.heatIndex === undefined) {
setHeatTip(null);
return;
}
const index = Number(target.dataset.heatIndex);
if (heatTip?.index === index) return;
const cell = target.getBoundingClientRect();
const bounds = body.getBoundingClientRect();
const x = cell.left - bounds.left + cell.width / 2;
const below = index % 7 < 2;
setHeatTip({
index,
x,
y: below
? cell.bottom - bounds.top + HEAT_TIP_GAP_PX
: cell.top - bounds.top - HEAT_TIP_GAP_PX,
below,
start: x < HEAT_TIP_EDGE_PX,
end: bounds.width - x < HEAT_TIP_EDGE_PX,
});
};
const renderHeatTip = () => {
const cell = heatTip ? heatmap[heatTip.index] : null;
if (!heatTip || !cell || cell.isFuture) return null;
return (
<span
className={cn("leaderboards__heat-tip", {
"leaderboards__heat-tip--below": heatTip.below,
"leaderboards__heat-tip--start": heatTip.start,
"leaderboards__heat-tip--end": heatTip.end,
})}
style={{ left: heatTip.x, top: heatTip.y }}
aria-hidden="true"
>
{cellSummary(cell)}
{cell.games.map((game, index) => (
<Fragment key={`${game}-${index}`}>
{index === 0 ? " · " : ", "}
<span className="leaderboards__heat-tip-game">{game}</span>
</Fragment>
))}
</span>
);
};
const atRecord =
activity.data !== null &&
activity.data.currentStreak > 0 &&
activity.data.currentStreak >= activity.data.bestStreak;
// Current and best streak, in the Activity header.
const renderStreak = () => {
if (!activity.data) return null;
return (
<div className="leaderboards__activity-streaks">
<span className="leaderboards__activity-streak">
{t("streak_current")}
<strong>{activity.data.currentStreak}</strong>
{t("days_unit", { count: activity.data.currentStreak })}
</span>
<span className="leaderboards__activity-streak">
{t("streak_best")}
<strong>{activity.data.bestStreak}</strong>
{t("days_unit", { count: activity.data.bestStreak })}
</span>
<StreakFlame size={16} flicker={atRecord} label={t("daily_streak")} />
</div>
);
};
const renderActivity = () => {
if (activity.isLoading && !activity.data) {
return <Skeleton height={220} className="leaderboards__block-skeleton" />;
return <Skeleton height={150} 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) => (
<div className="leaderboards__activity-body" ref={activityBodyRef}>
<div
className="leaderboards__activity-scroll"
ref={activityScrollRef}
onScroll={() => setHeatTip(null)}
>
<div className="leaderboards__activity-graph">
<div className="leaderboards__activity-months" aria-hidden="true">
{heatmapMonths.map((month) => (
<span
key={level}
className={`leaderboards__heat leaderboards__heat--${level}`}
key={month.date.toISOString()}
className="leaderboards__activity-month"
style={{ gridColumnStart: month.week + 1 }}
>
{month.label}
</span>
))}
</div>
<div className="leaderboards__activity-days" aria-hidden="true">
{dayLabels.map(({ key, label }) => (
<span key={key} className="leaderboards__heatmap-day">
{label}
</span>
))}
</div>
<div
className="leaderboards__heatmap"
onMouseMove={showHeatTip}
onMouseLeave={() => setHeatTip(null)}
>
{heatmap.map((cell, index) => (
<span
key={cell.date.toISOString()}
data-heat-index={cell.isFuture ? undefined : index}
role={cell.isFuture ? undefined : "img"}
aria-label={
cell.isFuture
? undefined
: [cellSummary(cell), cell.games.join(", ")]
.filter(Boolean)
.join(" · ")
}
className={cn(
"leaderboards__heat",
`leaderboards__heat--${cell.level}`,
{
"leaderboards__heat--future": cell.isFuture,
"leaderboards__heat--today": cell.isToday,
}
)}
/>
))}
{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, index) => (
<span
key={cell.date.toISOString()}
role={cell.isFuture ? undefined : "img"}
aria-label={
cell.isFuture
? undefined
: [cellSummary(cell), cell.games.join(", ")]
.filter(Boolean)
.join(" · ")
}
className={cn(
"leaderboards__heat",
`leaderboards__heat--${cell.level}`,
{
"leaderboards__heat--future": cell.isFuture,
"leaderboards__heat--today": cell.isToday,
}
)}
>
{!cell.isFuture && renderCellTip(cell, index)}
</span>
))}
</div>
</div>
</div>
</>
{renderHeatTip()}
<div className="leaderboards__activity-footer">
<span>
{/* pt-BR has `_zero` forms for this and the day counts:
Intl.PluralRules("pt") puts 0 in "one", which read "0 dia". */}
{t("activity_total", {
hours: numberFormatter.format(
Math.round(heatmapTotal.seconds / SECONDS_PER_HOUR)
),
count: heatmapTotal.days,
})}
</span>
<span className="leaderboards__heatmap-legend">
{t("legend_less")}
{[0, 1, 2, 3].map((level) => (
<span
key={level}
className={`leaderboards__heat leaderboards__heat--${level}`}
/>
))}
{t("legend_more")}
</span>
</div>
</div>
);
};
@ -362,6 +501,19 @@ export function StatsTab({
return (
<>
<section
className="leaderboards__panel leaderboards__activity"
aria-labelledby="activity-title"
>
<div className="leaderboards__activity-header">
<h2 id="activity-title" className="leaderboards__heading-3">
{t("activity_title")}
</h2>
{renderStreak()}
</div>
{renderActivity()}
</section>
<div className="leaderboards__stats-grid">
<section className="leaderboards__mine" aria-labelledby="mine-title">
<div
@ -446,21 +598,11 @@ export function StatsTab({
)}
</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 className="leaderboards__panel">
<h2 className="leaderboards__heading-3">{t("friends_streak")}</h2>
{renderFriends()}
</section>
</div>
<section className="leaderboards__panel">
<h2 className="leaderboards__heading-3">{t("friends_streak")}</h2>
{renderFriends()}
</section>
</>
);
}

View file

@ -45,12 +45,31 @@ const takeSource = (path: string, { params }: ApiRequestOptions) => {
return pending.source;
};
// Errors that cross IPC keep only their message, so a 400 is accepted here
// and rethrown with its status for callers that react to it.
export class LeaderboardRequestError extends Error {
constructor(readonly status: number) {
super(`Leaderboard request failed with status ${status}`);
}
}
const BAD_REQUEST_STATUS = 400;
const leaderboardsGet: ApiGet = (path, options) => {
const source = takeSource(path, options);
return globalThis.window.electron.hydraApi.get<unknown>(
path,
source ? { ...options, params: { ...options.params, source } } : options
);
return globalThis.window.electron.hydraApi
.getResponse<unknown>(path, {
...(source
? { ...options, params: { ...options.params, source } }
: options),
acceptedStatuses: [200, BAD_REQUEST_STATUS],
})
.then(({ status, data }) => {
if (status === BAD_REQUEST_STATUS) {
throw new LeaderboardRequestError(status);
}
return data;
});
};
export const leaderboardsCache = createResponseCache(leaderboardsGet);