История: изменение рейтинга на карточках партий профиля

GET /users/{id}/matches отдаёт у каждой партии rating_delta — изменение
общего рейтинга владельца истории за неё (один знак после запятой), в обоих
режимах, включая «лучшую партию»; история и рейтинг считаются одним
проигрыванием. В списке партий группы поле пустое: там непонятно, чья это
дельта.

Фронт: компактная карточка истории — второй строкой «Рейтинг: +12.3»,
подробная (MatchListView) — последней строкой, только когда дельта есть.
Рост — зелёным, падение — красным, минус типографский (formatRatingDelta).
schema.d.ts перегенерирован.

Тест: дуэль новичков ±32 в обеих историях, реванш с обратными знаками,
сумма изменений сходится с рейтингом, в списке группы дельты нет, режим
«лучшая» её отдаёт. #77

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LqSoRj99iwVEH5U5fnZgsd
This commit is contained in:
2026-09-14 23:51:37 +03:00
co-authored by Claude Opus 5
parent 171aebb6f8
commit 6f54e0f9df
9 changed files with 102 additions and 29 deletions
+2
View File
@@ -1888,6 +1888,8 @@ export interface components {
* @default []
*/
participants: components["schemas"]["MatchListParticipant"][];
/** Rating Delta */
rating_delta?: number | null;
};
/** MatchListParticipant */
MatchListParticipant: {
+23 -18
View File
@@ -3,6 +3,7 @@ import { useNavigate } from "react-router-dom";
import { formatDate, formatDuration } from "../domain/format";
import type { MatchListItem } from "../domain/types";
import { MatchListView } from "./MatchList";
import { RatingDelta } from "./RatingDelta";
/**
* История партий игрока в профиле. Подробный режим — тот же список, что у группы
@@ -53,29 +54,33 @@ export function MatchHistory({
return (
<button
key={m.id}
className="card row-between"
className="card"
style={{ margin: 0, width: "100%", textAlign: "left" }}
onClick={() => navigate(`/match/${m.id}`)}
>
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<span
className={
"place-badge" +
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
(mine?.eliminated ? " eliminated" : "")
}
>
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
</span>
<span
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{mine?.faction_name ?? "—"}
<div className="row-between">
<div className="row" style={{ gap: 10, minWidth: 0 }}>
<span
className={
"place-badge" +
(mine?.place === 1 && !mine?.eliminated ? " first" : "") +
(mine?.eliminated ? " eliminated" : "")
}
>
{mine?.eliminated ? "✕" : (mine?.place ?? "—")}
</span>
<span
style={{ overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}
>
{mine?.faction_name ?? "—"}
</span>
</div>
<span className="muted small" style={{ flexShrink: 0 }}>
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
</span>
</div>
<span className="muted small" style={{ flexShrink: 0 }}>
{formatDate(m.played_at)} · {formatDuration(m.duration_minutes)}
</span>
{/* Второй строкой — изменение рейтинга владельца профиля за партию. */}
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
</button>
);
})}
+4 -1
View File
@@ -3,10 +3,12 @@ import { useNavigate } from "react-router-dom";
import { formatDate, formatDuration } from "../domain/format";
import { winReasonLabel } from "../domain/winReasons";
import type { MatchListItem } from "../domain/types";
import { RatingDelta } from "./RatingDelta";
/** Список ЗАВЕРШЁННЫХ партий. Незавершённые сюда не попадают: группа отдаёт их
* отдельным блоком (InProgressMatches), а история профиля приходит с бэкенда уже
* отфильтрованной по status="finished". */
* отфильтрованной по status="finished". В истории профиля у партии есть rating_delta
* владельца — она идёт последней строкой; у списка группы её нет. */
export function MatchListView({ items }: { items: MatchListItem[] }) {
const navigate = useNavigate();
if (items.length === 0) return <div className="muted small">Партий пока нет.</div>;
@@ -48,6 +50,7 @@ export function MatchListView({ items }: { items: MatchListItem[] }) {
</div>
))}
</div>
{m.rating_delta != null && <RatingDelta delta={m.rating_delta} />}
</button>
);
})}
+13
View File
@@ -0,0 +1,13 @@
import { formatRatingDelta } from "../domain/format";
/** Строка карточки истории: сколько общего рейтинга владелец профиля получил или
* потерял за партию. Рост — зелёным, падение — красным, ноль — без цвета. */
export function RatingDelta({ delta }: { delta: number }) {
const text = formatRatingDelta(delta);
const tone = text.startsWith("+") ? "rating-up" : text.startsWith("−") ? "rating-down" : undefined;
return (
<div className="small rating-delta">
Рейтинг: <b className={tone}>{text}</b>
</div>
);
}
+8
View File
@@ -18,6 +18,14 @@ export function plural(n: number, one: string, few: string, many: string): strin
return many;
}
// Изменение рейтинга за партию: всегда со знаком и одним знаком после запятой
// («+12.3», «−4.1», «0.0»); минус — типографский, как в справке.
export function formatRatingDelta(delta: number): string {
const abs = Math.abs(delta).toFixed(1);
if (abs === "0.0") return "0.0";
return `${delta > 0 ? "+" : "−"}${abs}`;
}
export function formatDuration(minutes: number | null | undefined): string {
if (minutes == null) return "—";
const h = Math.floor(minutes / 60);
+5
View File
@@ -103,6 +103,11 @@ input:focus, select:focus, textarea:focus { border-color: var(--accent); }
.error-text { color: var(--danger); font-size: 13px; }
/* Изменение рейтинга за партию в истории профиля */
.rating-delta { color: var(--text-muted); margin-top: 6px; }
.rating-up { color: var(--success); }
.rating-down { color: var(--danger); }
/* Формула в справке */
.formula {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;