diff --git a/.deepsec/.gitignore b/.deepsec/.gitignore new file mode 100644 index 000000000..51b43f47e --- /dev/null +++ b/.deepsec/.gitignore @@ -0,0 +1,9 @@ +node_modules/ +.env*.local + +# Scan output — regenerated by `deepsec scan` / `process`. INFO.md +# and SETUP.md (manually edited) stay tracked. +data/*/files/ +data/*/runs/ +data/*/reports/ +data/*/project.json diff --git a/.deepsec/AGENTS.md b/.deepsec/AGENTS.md new file mode 100644 index 000000000..a9970395d --- /dev/null +++ b/.deepsec/AGENTS.md @@ -0,0 +1,23 @@ +# Agent setup + +This is a deepsec scanning workspace. Each registered project has its +own setup prompt at `data//SETUP.md` — open the relevant one when +asked to set a project up. + +## Common tasks + +- **Set up a project for scanning**: read `data//SETUP.md` and + follow it (read `node_modules/deepsec/SKILL.md`, then fill + `data//INFO.md` from the target codebase). +- **Add a new project**: run `deepsec init-project ` — it + scaffolds `data//` and prints/writes the setup prompt for the + new project. +- **Write a custom matcher** (only after a real true-positive shows you + a pattern worth keeping): read + `node_modules/deepsec/dist/docs/writing-matchers.md`. + +## Reference + +The deepsec skill is at `node_modules/deepsec/SKILL.md` (after +`pnpm install`). The full docs ship at +`node_modules/deepsec/dist/docs/`. diff --git a/.deepsec/README.md b/.deepsec/README.md new file mode 100644 index 000000000..997f0b4e4 --- /dev/null +++ b/.deepsec/README.md @@ -0,0 +1,74 @@ +# deepsec + +This directory holds the [deepsec](https://www.npmjs.com/package/deepsec) +config for the parent repo. Checked into git so teammates inherit +project context (auth shape, threat model, custom matchers); generated +scan output is gitignored. + +Currently configured project: `parsertime` (target: `..`). + +## Setup + +1. `pnpm install` — installs deepsec. +2. Add an AI Gateway / Anthropic / OpenAI token to `.env.local`. If + you already have `claude` or `codex` CLI logged in on this + machine, you can skip the token for non-sandbox runs (`process` / + `revalidate` / `triage`); deepsec auto-detects and reuses the + subscription. See + `node_modules/deepsec/dist/docs/vercel-setup.md` after install. +3. Open the parent repo in your coding agent (Claude Code, Cursor, …) + and have it follow `data/parsertime/SETUP.md` to fill in + `data/parsertime/INFO.md`. + +## Daily commands + +```bash +pnpm deepsec scan +pnpm deepsec process --concurrency 5 +pnpm deepsec revalidate --concurrency 5 # cuts FP rate +pnpm deepsec export --format md-dir --out ./findings +``` + +`--project-id` is auto-resolved while there's only one project in +`deepsec.config.ts`. Once you've added a second project, pass +`--project-id parsertime` (or whichever id you want) explicitly. + +`scan` is free (regex only). `process` is the AI stage (≈$0.30/file +on Opus by default). Run state goes to `data/parsertime/`. + +## Adding another project + +To scan another codebase from this same `.deepsec/`: + +```bash +pnpm deepsec init-project ../some-other-package # path relative to .deepsec/ +``` + +Appends an entry to `deepsec.config.ts` and writes +`data//{INFO.md,SETUP.md,project.json}`. Open the new SETUP.md +in your agent to fill in INFO.md. + +## Layout + +``` +deepsec.config.ts Project list (one entry per scanned repo) +data/parsertime/ + INFO.md Repo context — checked into git, hand-curated + SETUP.md Agent setup prompt — checked in, deletable + project.json Generated (gitignored) + files/ One JSON per scanned source file (gitignored) + runs/ Run metadata (gitignored) + reports/ Generated markdown reports (gitignored) +AGENTS.md Pointer for coding agents +.env.local Tokens (gitignored) +``` + +## Docs + +After `pnpm install`: + +- Skill: `node_modules/deepsec/SKILL.md` +- Full docs: `node_modules/deepsec/dist/docs/{getting-started,configuration,models,writing-matchers,plugins,architecture,data-layout,vercel-setup,faq}.md` + +Or browse on +[GitHub](https://github.com/vercel/deepsec/tree/main/docs). diff --git a/.deepsec/data/parsertime/INFO.md b/.deepsec/data/parsertime/INFO.md new file mode 100644 index 000000000..8668872f4 --- /dev/null +++ b/.deepsec/data/parsertime/INFO.md @@ -0,0 +1,74 @@ +# parsertime + +## What this codebase does + +Parsertime is a Next.js 16 App Router app for collegiate Overwatch teams to +track scrims, maps, player stats, tournaments, scouting, matchmaker requests, +availability, notifications, and AI chat insights. It is TypeScript/React with +Prisma on Postgres, NextAuth, Vercel KV/Blob/Edge Config, Stripe billing and AI +credits, Axiom telemetry, Discord bot integrations, FACEIT ingest, and server +route handlers under `src/app/api`. + +## Auth shape + +- NextAuth is configured in `src/lib/auth.ts`; route handlers and server pages + normally call `auth()` and then resolve the app user through `UserService` + with `AppRuntime.runPromise`. +- Role and plan checks use `$Enums.UserRole.ADMIN` / `UserRole.ADMIN` and the + `Permission` / `check` helpers for feature gates such as `create-team`, + `create-scrim`, and stats timeframe access. +- Team/scrim/map access is project-specific: use `isAuthedToViewTeam`, + `isTeamOwnerOrManager`, `isAuthedToViewScrim`, `isAuthedToViewMap`, or + explicit owner/manager/member checks against Prisma. +- Machine endpoints use non-session auth: `authenticateBotSecret`, + `resolveDiscordUser`, `verifyTeamAccess`, local cron secret checks, Stripe + webhook signatures, and FACEIT HMAC signatures. +- `guestMode` is an intentional sharing path for scrims and some `/team` + layouts; it should not imply general team membership or write access. + +## Threat model + +Highest impact is cross-team or public access to private scrim data, map stats, +player availability, reports, chat history, tournament admin flows, or team +management actions. Next are privilege escalation to admin/manager capabilities, +abuse of Stripe/AI-credit flows, unauthorized Blob/R2 uploads, bot/internal +endpoint abuse via shared secrets, and forged FACEIT/Stripe/cron callbacks. +Public scouting, leaderboard, health, metadata, and guest availability surfaces +exist, but should return only deliberately public or constrained data. + +## Project-specific patterns to flag + +- Any mutating `src/app/api/**/route.ts` that touches Prisma/services without + `auth()`, a named auth helper, `authenticateBotSecret`, a cron secret, or a + verified external webhook signature. +- Team, scrim, map, tournament, comparison, notification, or credit handlers + that trust a `teamId`, `scrimId`, `mapId`, `userId`, `conversationId`, or + `scheduleId` from params/body without checking membership, owner/manager + status, admin role, or the matching session user. +- Admin-only paths under `src/app/settings/admin` and `src/app/api/admin` that + do not require `UserRole.ADMIN` before search, audit-log, map-calibration, or + impersonation behavior. +- Upload/presign flows using `handleUpload`, Vercel Blob, R2, avatar/banner + keys, or map-calibration files that authenticate a user but do not authorize + ownership/manager/admin access for the target team/user/map. +- DEV_TOKEN/testing-email fallbacks, cron-like destructive routes, bot routes, + and webhook endpoints that are reachable in production without fail-closed + secret/signature validation. + +## Known false-positives + +- `src/app/api/auth/[...nextauth]/route.ts` is intentionally public because + NextAuth owns the callback/session flow; evaluate `src/lib/auth.ts` instead. +- `src/app/api/stripe/webhooks/route.ts` and + `src/app/api/faceit/webhook/route.ts` are sessionless by design; the intended + auth is Stripe signature verification or FACEIT HMAC verification. +- `src/app/api/bot/**` and `src/app/api/internal/availability/**` are machine + endpoints; `authenticateBotSecret` plus Discord user/team checks are the + expected auth shape. +- `src/app/api/availability/[scheduleId]/responses/**` allows unauthenticated + availability submissions for shared schedules, with optional password/name + ownership semantics. +- Public metadata and read surfaces include `robots.ts`, `sitemap.ts`, + `manifest.ts`, `.well-known/**`, `api/health`, `api/og`, `api/leaderboard/**`, + and public scouting/profile/stat pages; do not require `auth()` solely for + those routes. diff --git a/.deepsec/data/parsertime/SETUP.md b/.deepsec/data/parsertime/SETUP.md new file mode 100644 index 000000000..0b1417213 --- /dev/null +++ b/.deepsec/data/parsertime/SETUP.md @@ -0,0 +1,55 @@ +# Agent setup for `parsertime` + +This is a deepsec scanning workspace. Project `parsertime` was just registered +(target: `..`). Setup is incomplete — `data/parsertime/INFO.md` +still has placeholder sections. + +## What to do + +1. **Read the deepsec skill.** After `pnpm install`, the file is at + `node_modules/deepsec/SKILL.md`. It maps every doc topic to a file + under `node_modules/deepsec/dist/docs/`. Read `getting-started.md`, + `configuration.md`, and `writing-matchers.md` (skim the rest). + +2. **Fill in `data/parsertime/INFO.md`.** It's auto-injected into the AI + prompt for every batch — keep it short and selective. + + **Length budget: 50–100 lines total.** Verbose context dilutes + signal in the scanner's prompt window. The goal is "what would a + reviewer miss if they didn't read this?", not exhaustive enumeration. + + **Per-section rubric**: + - Pick 3–5 representative items per section. **Don't list every + file, helper, or callsite** — pick the patterns. + - Name primitives by their public name (e.g. `withAuthentication`, + `auth.can()`, `isTeamAdmin`). **No line numbers.** Don't enumerate + more than 5 paths in any list. + - Skip generic CWE categories — built-in matchers already cover + "SSRF", "SQL injection", "XSS". Cover what's _project-specific_: + internal auth helpers, custom middleware names, fork-specific + stubs, intended-public endpoints. + - One short paragraph or 3–5 short bullets per section. Not both. + + Source material (read in this order, stop when you have enough): + - `../README.md` + - any `AGENTS.md` / `CLAUDE.md` in `..` + - `../package.json` (or `go.mod`, `pyproject.toml`, etc.) + - 5–10 representative code files (entry points, auth helpers) — not + a full code tour. + +3. **(Optional) Add custom matchers** for repo-specific patterns the + built-in matchers won't catch. Read + `node_modules/deepsec/dist/docs/writing-matchers.md` first; the + workflow there starts from a confirmed finding and grows the matcher + from it. Don't add matchers speculatively — wait for a real TP. + +## When you're done + +The user will run: + +```bash +pnpm deepsec scan --project-id parsertime +pnpm deepsec process --project-id parsertime +``` + +You can delete this file once setup is complete. diff --git a/.deepsec/data/parsertime/tech.json b/.deepsec/data/parsertime/tech.json new file mode 100644 index 000000000..de12e79a5 --- /dev/null +++ b/.deepsec/data/parsertime/tech.json @@ -0,0 +1,6 @@ +{ + "tags": ["github-actions", "nextjs", "node", "prisma", "react"], + "sentinels": ["next.config.ts", "package.json"], + "detectedAt": "2026-05-27T15:25:18.654Z", + "rootPath": "/Users/lucasdoell/code/parsertime" +} diff --git a/.deepsec/deepsec.config.ts b/.deepsec/deepsec.config.ts new file mode 100644 index 000000000..b99d7d563 --- /dev/null +++ b/.deepsec/deepsec.config.ts @@ -0,0 +1,8 @@ +import { defineConfig } from "deepsec/config"; + +export default defineConfig({ + projects: [ + { id: "parsertime", root: ".." }, + // + ], +}); diff --git a/.deepsec/package.json b/.deepsec/package.json new file mode 100644 index 000000000..f9cacd6ef --- /dev/null +++ b/.deepsec/package.json @@ -0,0 +1,12 @@ +{ + "name": "deepsec-workspace", + "version": "0.1.0", + "private": true, + "description": "deepsec scanning workspace", + "type": "module", + "workspaces": [], + "packageManager": "pnpm@9.15.4", + "dependencies": { + "deepsec": "^2.0.10" + } +} diff --git a/.deepsec/pnpm-lock.yaml b/.deepsec/pnpm-lock.yaml new file mode 100644 index 000000000..e05bbfeb8 --- /dev/null +++ b/.deepsec/pnpm-lock.yaml @@ -0,0 +1,1246 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + deepsec: + specifier: ^2.0.10 + version: 2.0.10(@anthropic-ai/sdk@0.99.0(zod@3.24.4))(@modelcontextprotocol/sdk@1.29.0(zod@3.24.4))(zod@3.24.4) + +packages: + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.152': + resolution: {integrity: sha512-e4ZXNd/WRq4Ww0SSqttrubgR5NJ/7c8qkj+ePsTwOamKcPra2E1cIOqAcQqitJNJjPMW7e7NVkiFjsCkowng+A==} + cpu: [arm64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.152': + resolution: {integrity: sha512-7wyPOruUPwKcEIj7Moc6NeTSEZ6ltVwU9MQoWV7dzaFGq06YGoFoW9jJfPWSyA2H6v/Yx2sx7ta8vijP1VIBRQ==} + cpu: [x64] + os: [darwin] + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.152': + resolution: {integrity: sha512-CRlC+hVMVpdD3K3qan/bX420WPrieqfDnd/aPe2dGt2KT8ylTyDftsjSaWdlP8T5kwv+apkAkk6G0QHPhBJB4A==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.152': + resolution: {integrity: sha512-B1Vf1grD3o3bqnKx2TuUYJBdc5xDdMc1sTuikZTHkM2nejCAsmTJ3mZ3BuD3Ns2D7jyOQWeyXW05aEw4M0SEVw==} + cpu: [arm64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.152': + resolution: {integrity: sha512-c2wy9c9v3JWJT2g77B5QOgptkfQp+veDTo7TnexRA0he+kDlkLId2voD3iw1vk9VDVF5eSuQNCk6cMDbMu6LRg==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.152': + resolution: {integrity: sha512-S/YwxAInbtdLElx1H7gM5hDYeV9UiJtTh4eS8oQmjkPXu2Gddx6muek085FBFIlGypjvuXUHB7Nw5vtZtYHwGg==} + cpu: [x64] + os: [linux] + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.152': + resolution: {integrity: sha512-CNpXWfvyQt6fj6f7HaPtdaOKv+U7hevDnEEo816m5EbIDRgT0XkAN1p+EiuvEIiDzEwJ0/usgIzDlNcIUfTMrg==} + cpu: [arm64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.152': + resolution: {integrity: sha512-DsPwmrLf6caR164BRS5vH1cpBKYY9X5DkmjxI36Ou6FC3Tm2tLBYPh94ZBUIMyMzxARpQeRD6+QWEGNgwtR92w==} + cpu: [x64] + os: [win32] + + '@anthropic-ai/claude-agent-sdk@0.3.152': + resolution: {integrity: sha512-SiDI8shtKtiKuf6ogkYj7+DumSVk+q9vY6facyY4mKPUQz1XPxSUISSzIavg/U7DC0RiiZ1Y8WwU4LDf0lsPgA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@anthropic-ai/sdk': '>=0.93.0' + '@modelcontextprotocol/sdk': ^1.29.0 + zod: ^4.0.0 + + '@anthropic-ai/sdk@0.99.0': + resolution: {integrity: sha512-vdicFA9YjtvgpG8rxp39hqW4oxpkdRGPTq0QEts5TZhr2GkLozDduK/GiXrLQ7PzrKYBtIkQFdRW+QBjzlsABg==} + hasBin: true + peerDependencies: + zod: ^3.25.0 || ^4.0.0 + peerDependenciesMeta: + zod: + optional: true + + '@babel/runtime@7.29.7': + resolution: {integrity: sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==} + engines: {node: '>=6.9.0'} + + '@hono/node-server@1.19.14': + resolution: {integrity: sha512-GwtvgtXxnWsucXvbQXkRgqksiH2Qed37H9xHZocE5sA3N8O8O8/8FA3uclQXxXVzc9XBZuEOMK7+r02FmSpHtw==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + + '@isaacs/fs-minipass@4.0.1': + resolution: {integrity: sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==} + engines: {node: '>=18.0.0'} + + '@modelcontextprotocol/sdk@1.29.0': + resolution: {integrity: sha512-zo37mZA9hJWpULgkRpowewez1y6ML5GsXJPY8FI0tBBCd77HEvza4jDqRKOXgHNn867PVGCyTdzqpz0izu5ZjQ==} + engines: {node: '>=18'} + peerDependencies: + '@cfworker/json-schema': ^4.1.1 + zod: ^3.25 || ^4.0 + peerDependenciesMeta: + '@cfworker/json-schema': + optional: true + + '@openai/codex-sdk@0.125.0': + resolution: {integrity: sha512-1xCIHdSbQVF880nJ2aVWdPIsWZbSpKODwuP9y/gvtChDYhYfYEW0DKp2H8ZlctkzIjlzS/WzYmP6ZZPHIvs2Dg==} + engines: {node: '>=18'} + + '@openai/codex@0.125.0': + resolution: {integrity: sha512-GiE9wlgL95u/5BRirY5d3EaRLU1tu7Y1R09R8lCHHVmcQdSmhS809FdPDWH3gIYHS7ZriAPqXwJ3aLA0WKl40Q==} + engines: {node: '>=16'} + hasBin: true + + '@openai/codex@0.125.0-darwin-arm64': + resolution: {integrity: sha512-Gn2fHiSO0XgyHp1OSd5DWUTm66Bv9UEuipW5pVEj1E+hWZCOrdqnYttllKFWtRGj5yiKefNX3JIxONgh/ZwlOQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [darwin] + + '@openai/codex@0.125.0-darwin-x64': + resolution: {integrity: sha512-TZ5Lek2X/UXTI9LXFxzarvQaJeuTrqVh4POc7soO/8RclVnCxADnCf15sivxLd5eiFW4t0myGoeVoM4lciRiRg==} + engines: {node: '>=16'} + cpu: [x64] + os: [darwin] + + '@openai/codex@0.125.0-linux-arm64': + resolution: {integrity: sha512-pPnJoJD6rZ2Iin0zNt/up36bO2/EOp2B+1/rPHu/lSq3PJbT3Fmnfut2kJy5LylXb7bGA2XQbtqOogZzIbnlkA==} + engines: {node: '>=16'} + cpu: [arm64] + os: [linux] + + '@openai/codex@0.125.0-linux-x64': + resolution: {integrity: sha512-K2NTTEeBpz/G+N2x17UGWfauRt3So+ir4f+U/60l5PPnYEJB/w3YZrlXo2G9og8Dm9BqtoBAjoPV74sRv9tWWQ==} + engines: {node: '>=16'} + cpu: [x64] + os: [linux] + + '@openai/codex@0.125.0-win32-arm64': + resolution: {integrity: sha512-zxoUakw9oIHIFrAyk400XkkLBJFA6nOym0NDq6sQ/jhdcYraKqNSRCII2nsBwZHk+/4zgUvuk52iuutgysY/rQ==} + engines: {node: '>=16'} + cpu: [arm64] + os: [win32] + + '@openai/codex@0.125.0-win32-x64': + resolution: {integrity: sha512-ofpOK+OWH5QFuUZ9pTM0d/PcXUXiIP5z5DpRcE9MlucJoyOl4Zy4Nu3NcuHF4YzCkZMQb6x3j0tjDEPHKqNQzw==} + engines: {node: '>=16'} + cpu: [x64] + os: [win32] + + '@stablelib/base64@1.0.1': + resolution: {integrity: sha512-1bnPQqSxSuc3Ii6MhBysoWCg58j97aUjuCSZrGSmDxNqtytIi0k8utUenAwTZN4V5mXXYGsVUI9zeBqy+jBOSQ==} + + '@vercel/oidc@3.2.0': + resolution: {integrity: sha512-UycprH3T6n3jH0k44NHMa7pnFHGu/N05MjojYr+Mc6I7obkoLIJujSWwin1pCvdy/eOxrI/l3uDLQsmcrOb4ug==} + engines: {node: '>= 20'} + + '@vercel/oidc@3.4.1': + resolution: {integrity: sha512-H6B+/ig/GoahccL3WZjiHayHw1H5KhvTJNceqYulwfK9kkz5iul2hTmYzcJ7tTCQzyd0dutuL9xYFZCyLUqsog==} + engines: {node: '>= 20'} + + '@vercel/sandbox@1.10.2': + resolution: {integrity: sha512-rWhYfIyW0Va0gFxtz434LhVirV+eQs+AK0QQWtsOPw2oTvOSA4iogQqemRqvRPPbqI8nfZOz6kbCsytVa20gdw==} + + '@workflow/serde@4.1.0-beta.2': + resolution: {integrity: sha512-8kkeoQKLDaKXefjV5dbhBj2aErfKp1Mc4pb6tj8144cF+Em5SPbyMbyLCHp+BVrFfFVCBluCtMx+jjvaFVZGww==} + + accepts@2.0.0: + resolution: {integrity: sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==} + engines: {node: '>= 0.6'} + + ajv-formats@3.0.1: + resolution: {integrity: sha512-8iUql50EUR+uUcdRQ3HDqa6EVyo3docL8g5WJ3FNcWmu62IbkGUue/pEyLBW8VGKKucTPgqeks4fIU1DA4yowQ==} + peerDependencies: + ajv: ^8.0.0 + peerDependenciesMeta: + ajv: + optional: true + + ajv@8.20.0: + resolution: {integrity: sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==} + + async-retry@1.3.3: + resolution: {integrity: sha512-wfr/jstw9xNi/0teMHrRW7dsz3Lt5ARhYNZ2ewpadnhaIp5mbALhOAP+EAdsC7t4Z6wqsDVv9+W6gm1Dk9mEyw==} + + b4a@1.8.1: + resolution: {integrity: sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw==} + peerDependencies: + react-native-b4a: '*' + peerDependenciesMeta: + react-native-b4a: + optional: true + + balanced-match@4.0.4: + resolution: {integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==} + engines: {node: 18 || 20 || >=22} + + bare-events@2.8.3: + resolution: {integrity: sha512-HdUm8EMQBLaJvGUdidNNbqpA1kYkwNcb+MYxkxCLAPJGQzlv9J0C24h8V65Z4c5GLd/JEALDvpFCQgpLJqc0zw==} + peerDependencies: + bare-abort-controller: '*' + peerDependenciesMeta: + bare-abort-controller: + optional: true + + body-parser@2.2.2: + resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==} + engines: {node: '>=18'} + + brace-expansion@5.0.6: + resolution: {integrity: sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==} + engines: {node: 18 || 20 || >=22} + + bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + + call-bind-apply-helpers@1.0.2: + resolution: {integrity: sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==} + engines: {node: '>= 0.4'} + + call-bound@1.0.4: + resolution: {integrity: sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==} + engines: {node: '>= 0.4'} + + chownr@3.0.0: + resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} + engines: {node: '>=18'} + + content-disposition@1.1.0: + resolution: {integrity: sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==} + engines: {node: '>=18'} + + content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + + content-type@2.0.0: + resolution: {integrity: sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==} + engines: {node: '>=18'} + + cookie-signature@1.2.2: + resolution: {integrity: sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==} + engines: {node: '>=6.6.0'} + + cookie@0.7.2: + resolution: {integrity: sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==} + engines: {node: '>= 0.6'} + + cors@2.8.6: + resolution: {integrity: sha512-tJtZBBHA6vjIAaF6EnIaq6laBBP9aq/Y3ouVJjEfoHbRBcHBAHYcMh/w8LDrk2PvIMMq8gmopa5D4V8RmbrxGw==} + engines: {node: '>= 0.10'} + + cross-spawn@7.0.6: + resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} + engines: {node: '>= 8'} + + debug@4.4.3: + resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + + deepsec@2.0.10: + resolution: {integrity: sha512-Hg8xggMIbinQP629dp0hGKb/gGFqDttE4qUCxc/6Wy3vFz9yHlTr1zLDcRDU4mHpwHln8keKuPzO5ZvyOUjg2g==} + hasBin: true + + depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + + dunder-proto@1.0.1: + resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} + engines: {node: '>= 0.4'} + + ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + + encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + + es-define-property@1.0.1: + resolution: {integrity: sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==} + engines: {node: '>= 0.4'} + + es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + es-object-atoms@1.1.2: + resolution: {integrity: sha512-HWcBoN6NileqtSydK2FqHbS/LoDd2pqrnQHLyJzBj4kOp/ky2MWMN694xOfkK8/SnUsW2DH7EfyVlydKCsm1Zw==} + engines: {node: '>= 0.4'} + + escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + + etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + + events-universal@1.0.1: + resolution: {integrity: sha512-LUd5euvbMLpwOF8m6ivPCbhQeSiYVNb8Vs0fQ8QjXo0JTkEHpz8pxdQf0gStltaPpw0Cca8b39KxvK9cfKRiAw==} + + eventsource-parser@3.0.8: + resolution: {integrity: sha512-70QWGkr4snxr0OXLRWsFLeRBIRPuQOvt4s8QYjmUlmlkyTZkRqS7EDVRZtzU3TiyDbXSzaOeF0XUKy8PchzukQ==} + engines: {node: '>=18.0.0'} + + eventsource@3.0.7: + resolution: {integrity: sha512-CRT1WTyuQoD771GW56XEZFQ/ZoSfWid1alKGDYMmkt2yl8UXrVR4pspqWNEcqKvVIzg6PAltWjxcSSPrboA4iA==} + engines: {node: '>=18.0.0'} + + express-rate-limit@8.5.2: + resolution: {integrity: sha512-5Kb34ipNX694DH48vN9irak1Qx30nb0PLYHXfJgw4YEjiC3ZEmZJhwOp+VfiCYwFzvFTdB9QkArYS5kXa2cx2A==} + engines: {node: '>= 16'} + peerDependencies: + express: '>= 4.11' + + express@5.2.1: + resolution: {integrity: sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==} + engines: {node: '>= 18'} + + fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + fast-fifo@1.3.2: + resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==} + + fast-sha256@1.3.0: + resolution: {integrity: sha512-n11RGP/lrWEFI/bWdygLxhI+pVeo1ZYIVwvvPkW7azl/rOy+F3HYRZ2K5zeE9mmkhQppyv9sQFx0JM9UabnpPQ==} + + fast-uri@3.1.2: + resolution: {integrity: sha512-rVjf7ArG3LTk+FS6Yw81V1DLuZl1bRbNrev6Tmd/9RaroeeRRJhAt7jg/6YFxbvAQXUCavSoZhPPj6oOx+5KjQ==} + + finalhandler@2.1.1: + resolution: {integrity: sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==} + engines: {node: '>= 18.0.0'} + + forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + + fresh@2.0.0: + resolution: {integrity: sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==} + engines: {node: '>= 0.8'} + + function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + get-intrinsic@1.3.0: + resolution: {integrity: sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==} + engines: {node: '>= 0.4'} + + get-proto@1.0.1: + resolution: {integrity: sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==} + engines: {node: '>= 0.4'} + + gopd@1.2.0: + resolution: {integrity: sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==} + engines: {node: '>= 0.4'} + + has-symbols@1.1.0: + resolution: {integrity: sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==} + engines: {node: '>= 0.4'} + + hasown@2.0.3: + resolution: {integrity: sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==} + engines: {node: '>= 0.4'} + + hono@4.12.23: + resolution: {integrity: sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA==} + engines: {node: '>=16.9.0'} + + http-errors@2.0.1: + resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==} + engines: {node: '>= 0.8'} + + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + + inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + + ip-address@10.2.0: + resolution: {integrity: sha512-/+S6j4E9AHvW9SWMSEY9Xfy66O5PWvVEJ08O0y5JGyEKQpojb0K0GKpz/v5HJ/G0vi3D2sjGK78119oXZeE0qA==} + engines: {node: '>= 12'} + + ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + + is-promise@4.0.0: + resolution: {integrity: sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==} + + isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + + jose@6.2.3: + resolution: {integrity: sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw==} + + json-schema-to-ts@3.1.1: + resolution: {integrity: sha512-+DWg8jCJG2TEnpy7kOm/7/AxaYoaRbjVB4LFZLySZlWn8exGs3A4OLJR966cVvU26N7X9TWxl+Jsw7dzAqKT6g==} + engines: {node: '>=16'} + + json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + + json-schema-typed@8.0.2: + resolution: {integrity: sha512-fQhoXdcvc3V28x7C7BMs4P5+kNlgUURe2jmUT1T//oBRMDrqy1QPelJimwZGo7Hg9VPV3EQV5Bnq4hbFy2vetA==} + + jsonlines@0.1.1: + resolution: {integrity: sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==} + + math-intrinsics@1.1.0: + resolution: {integrity: sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==} + engines: {node: '>= 0.4'} + + media-typer@1.1.0: + resolution: {integrity: sha512-aisnrDP4GNe06UcKFnV5bfMNPBUw4jsLGaWwWfnH3v02GnBuXX2MCVn5RbrWo0j3pczUilYblq7fQ7Nw2t5XKw==} + engines: {node: '>= 0.8'} + + merge-descriptors@2.0.0: + resolution: {integrity: sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==} + engines: {node: '>=18'} + + mime-db@1.54.0: + resolution: {integrity: sha512-aU5EJuIN2WDemCcAp2vFBfp/m4EAhWJnUNSSw0ixs7/kXbd6Pg64EmwJkNdFhB8aWt1sH2CTXrLxo/iAGV3oPQ==} + engines: {node: '>= 0.6'} + + mime-types@3.0.2: + resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==} + engines: {node: '>=18'} + + minimatch@10.2.5: + resolution: {integrity: sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==} + engines: {node: 18 || 20 || >=22} + + minipass@7.1.3: + resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} + engines: {node: '>=16 || 14 >=14.17'} + + minizlib@3.1.0: + resolution: {integrity: sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==} + engines: {node: '>= 18'} + + ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + negotiator@1.0.0: + resolution: {integrity: sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==} + engines: {node: '>= 0.6'} + + object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + object-inspect@1.13.4: + resolution: {integrity: sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==} + engines: {node: '>= 0.4'} + + on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + + once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + + os-paths@4.4.0: + resolution: {integrity: sha512-wrAwOeXp1RRMFfQY8Sy7VaGVmPocaLwSFOYCGKSyo8qmJ+/yaafCl5BCA1IQZWqFSRBrKDYFeR9d/VyQzfH/jg==} + engines: {node: '>= 6.0'} + + parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + + path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + + path-to-regexp@8.4.2: + resolution: {integrity: sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + pkce-challenge@5.0.1: + resolution: {integrity: sha512-wQ0b/W4Fr01qtpHlqSqspcj3EhBvimsdh0KlHhH8HRZnMsEa0ea2fTULOXOS9ccQr3om+GcGRk4e+isrZWV8qQ==} + engines: {node: '>=16.20.0'} + + proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + + qs@6.15.2: + resolution: {integrity: sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw==} + engines: {node: '>=0.6'} + + range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + + raw-body@3.0.2: + resolution: {integrity: sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==} + engines: {node: '>= 0.10'} + + require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + + retry@0.13.1: + resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} + engines: {node: '>= 4'} + + router@2.2.0: + resolution: {integrity: sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==} + engines: {node: '>= 18'} + + safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + + send@1.2.1: + resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==} + engines: {node: '>= 18'} + + serve-static@2.2.1: + resolution: {integrity: sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==} + engines: {node: '>= 18'} + + setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + + shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + + shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + + side-channel-list@1.0.1: + resolution: {integrity: sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==} + engines: {node: '>= 0.4'} + + side-channel-map@1.0.1: + resolution: {integrity: sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==} + engines: {node: '>= 0.4'} + + side-channel-weakmap@1.0.2: + resolution: {integrity: sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==} + engines: {node: '>= 0.4'} + + side-channel@1.1.0: + resolution: {integrity: sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==} + engines: {node: '>= 0.4'} + + standardwebhooks@1.0.0: + resolution: {integrity: sha512-BbHGOQK9olHPMvQNHWul6MYlrRTAOKn03rOe4A8O3CLWhNf4YHBqq2HJKKC+sfqpxiBY52pNeesD6jIiLDz8jg==} + + statuses@2.0.2: + resolution: {integrity: sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==} + engines: {node: '>= 0.8'} + + streamx@2.26.0: + resolution: {integrity: sha512-VvNG1K72Po/xwJzxZFnZ++Tbrv4lwSptsbkFuzXCJAYZvCK5nnxsvXU6ajqkv7chyiI1Y0YXq2Jh8Iy8Y7NF/A==} + + tar-stream@3.1.7: + resolution: {integrity: sha512-qJj60CXt7IU1Ffyc3NJMjh6EkuCFej46zUqJ4J7pqYlThyd9bO0XBTmcOIhSzZJVWfsLks0+nle/j538YAW9RQ==} + + tar@7.5.15: + resolution: {integrity: sha512-dzGK0boVlC4W5QFuQN1EFSl3bIDYsk7Tj40U6eIBnK2k/8ml7TZ5agbI5j5+qnoVcAA+rNtBml8SEiLxZpNqRQ==} + engines: {node: '>=18'} + + text-decoder@1.2.7: + resolution: {integrity: sha512-vlLytXkeP4xvEq2otHeJfSQIRyWxo/oZGEbXrtEEF9Hnmrdly59sUbzZ/QgyWuLYHctCHxFF4tRQZNQ9k60ExQ==} + + toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + + ts-algebra@2.0.0: + resolution: {integrity: sha512-FPAhNPFMrkwz76P7cdjdmiShwMynZYN6SgOujD1urY4oNm80Ou9oMdmbR45LotcKOXoy7wSmHkRFE6Mxbrhefw==} + + type-is@2.1.0: + resolution: {integrity: sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==} + engines: {node: '>= 18'} + + undici@7.26.0: + resolution: {integrity: sha512-3O9Tf67pGhgOv9jM35AbhkXAKi13f3oy3aE4CSgr+TckGeY+/iu97ZXN+J7DpHPzLbVApFd1IFhcnBjREYXYcg==} + engines: {node: '>=20.18.1'} + + unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + + vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + + which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + + wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + + xdg-app-paths@5.1.0: + resolution: {integrity: sha512-RAQ3WkPf4KTU1A8RtFx3gWywzVKe00tfOPFfl2NDGqbIFENQO4kqAJp7mhQjNj/33W5x5hiWWUdyfPq/5SU3QA==} + engines: {node: '>=6'} + + xdg-portable@7.3.0: + resolution: {integrity: sha512-sqMMuL1rc0FmMBOzCpd0yuy9trqF2yTTVe+E9ogwCSWQCdDEtQUwrZPT6AxqtsFGRNxycgncbP/xmOOSPw5ZUw==} + engines: {node: '>= 6.0'} + + yallist@5.0.0: + resolution: {integrity: sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==} + engines: {node: '>=18'} + + zod-to-json-schema@3.25.2: + resolution: {integrity: sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==} + peerDependencies: + zod: ^3.25.28 || ^4 + + zod@3.24.4: + resolution: {integrity: sha512-OdqJE9UDRPwWsrHjLN2F8bPxvwJBK22EHLWtanu0LSYr5YqzsaaW3RMgmjwr8Rypg5k+meEJdSPXJZXE/yqOMg==} + +snapshots: + + '@anthropic-ai/claude-agent-sdk-darwin-arm64@0.3.152': + optional: true + + '@anthropic-ai/claude-agent-sdk-darwin-x64@0.3.152': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl@0.3.152': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-arm64@0.3.152': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64-musl@0.3.152': + optional: true + + '@anthropic-ai/claude-agent-sdk-linux-x64@0.3.152': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-arm64@0.3.152': + optional: true + + '@anthropic-ai/claude-agent-sdk-win32-x64@0.3.152': + optional: true + + '@anthropic-ai/claude-agent-sdk@0.3.152(@anthropic-ai/sdk@0.99.0(zod@3.24.4))(@modelcontextprotocol/sdk@1.29.0(zod@3.24.4))(zod@3.24.4)': + dependencies: + '@anthropic-ai/sdk': 0.99.0(zod@3.24.4) + '@modelcontextprotocol/sdk': 1.29.0(zod@3.24.4) + zod: 3.24.4 + optionalDependencies: + '@anthropic-ai/claude-agent-sdk-darwin-arm64': 0.3.152 + '@anthropic-ai/claude-agent-sdk-darwin-x64': 0.3.152 + '@anthropic-ai/claude-agent-sdk-linux-arm64': 0.3.152 + '@anthropic-ai/claude-agent-sdk-linux-arm64-musl': 0.3.152 + '@anthropic-ai/claude-agent-sdk-linux-x64': 0.3.152 + '@anthropic-ai/claude-agent-sdk-linux-x64-musl': 0.3.152 + '@anthropic-ai/claude-agent-sdk-win32-arm64': 0.3.152 + '@anthropic-ai/claude-agent-sdk-win32-x64': 0.3.152 + + '@anthropic-ai/sdk@0.99.0(zod@3.24.4)': + dependencies: + json-schema-to-ts: 3.1.1 + standardwebhooks: 1.0.0 + optionalDependencies: + zod: 3.24.4 + + '@babel/runtime@7.29.7': {} + + '@hono/node-server@1.19.14(hono@4.12.23)': + dependencies: + hono: 4.12.23 + + '@isaacs/fs-minipass@4.0.1': + dependencies: + minipass: 7.1.3 + + '@modelcontextprotocol/sdk@1.29.0(zod@3.24.4)': + dependencies: + '@hono/node-server': 1.19.14(hono@4.12.23) + ajv: 8.20.0 + ajv-formats: 3.0.1(ajv@8.20.0) + content-type: 1.0.5 + cors: 2.8.6 + cross-spawn: 7.0.6 + eventsource: 3.0.7 + eventsource-parser: 3.0.8 + express: 5.2.1 + express-rate-limit: 8.5.2(express@5.2.1) + hono: 4.12.23 + jose: 6.2.3 + json-schema-typed: 8.0.2 + pkce-challenge: 5.0.1 + raw-body: 3.0.2 + zod: 3.24.4 + zod-to-json-schema: 3.25.2(zod@3.24.4) + transitivePeerDependencies: + - supports-color + + '@openai/codex-sdk@0.125.0': + dependencies: + '@openai/codex': 0.125.0 + + '@openai/codex@0.125.0': + optionalDependencies: + '@openai/codex-darwin-arm64': '@openai/codex@0.125.0-darwin-arm64' + '@openai/codex-darwin-x64': '@openai/codex@0.125.0-darwin-x64' + '@openai/codex-linux-arm64': '@openai/codex@0.125.0-linux-arm64' + '@openai/codex-linux-x64': '@openai/codex@0.125.0-linux-x64' + '@openai/codex-win32-arm64': '@openai/codex@0.125.0-win32-arm64' + '@openai/codex-win32-x64': '@openai/codex@0.125.0-win32-x64' + + '@openai/codex@0.125.0-darwin-arm64': + optional: true + + '@openai/codex@0.125.0-darwin-x64': + optional: true + + '@openai/codex@0.125.0-linux-arm64': + optional: true + + '@openai/codex@0.125.0-linux-x64': + optional: true + + '@openai/codex@0.125.0-win32-arm64': + optional: true + + '@openai/codex@0.125.0-win32-x64': + optional: true + + '@stablelib/base64@1.0.1': {} + + '@vercel/oidc@3.2.0': {} + + '@vercel/oidc@3.4.1': {} + + '@vercel/sandbox@1.10.2': + dependencies: + '@vercel/oidc': 3.2.0 + '@workflow/serde': 4.1.0-beta.2 + async-retry: 1.3.3 + jsonlines: 0.1.1 + ms: 2.1.3 + picocolors: 1.1.1 + tar-stream: 3.1.7 + undici: 7.26.0 + xdg-app-paths: 5.1.0 + zod: 3.24.4 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + '@workflow/serde@4.1.0-beta.2': {} + + accepts@2.0.0: + dependencies: + mime-types: 3.0.2 + negotiator: 1.0.0 + + ajv-formats@3.0.1(ajv@8.20.0): + optionalDependencies: + ajv: 8.20.0 + + ajv@8.20.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-uri: 3.1.2 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + + async-retry@1.3.3: + dependencies: + retry: 0.13.1 + + b4a@1.8.1: {} + + balanced-match@4.0.4: {} + + bare-events@2.8.3: {} + + body-parser@2.2.2: + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 4.4.3 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + on-finished: 2.4.1 + qs: 6.15.2 + raw-body: 3.0.2 + type-is: 2.1.0 + transitivePeerDependencies: + - supports-color + + brace-expansion@5.0.6: + dependencies: + balanced-match: 4.0.4 + + bytes@3.1.2: {} + + call-bind-apply-helpers@1.0.2: + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + + call-bound@1.0.4: + dependencies: + call-bind-apply-helpers: 1.0.2 + get-intrinsic: 1.3.0 + + chownr@3.0.0: {} + + content-disposition@1.1.0: {} + + content-type@1.0.5: {} + + content-type@2.0.0: {} + + cookie-signature@1.2.2: {} + + cookie@0.7.2: {} + + cors@2.8.6: + dependencies: + object-assign: 4.1.1 + vary: 1.1.2 + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deepsec@2.0.10(@anthropic-ai/sdk@0.99.0(zod@3.24.4))(@modelcontextprotocol/sdk@1.29.0(zod@3.24.4))(zod@3.24.4): + dependencies: + '@anthropic-ai/claude-agent-sdk': 0.3.152(@anthropic-ai/sdk@0.99.0(zod@3.24.4))(@modelcontextprotocol/sdk@1.29.0(zod@3.24.4))(zod@3.24.4) + '@openai/codex': 0.125.0 + '@openai/codex-sdk': 0.125.0 + '@vercel/oidc': 3.4.1 + '@vercel/sandbox': 1.10.2 + jiti: 2.7.0 + minimatch: 10.2.5 + tar: 7.5.15 + transitivePeerDependencies: + - '@anthropic-ai/sdk' + - '@modelcontextprotocol/sdk' + - bare-abort-controller + - react-native-b4a + - zod + + depd@2.0.0: {} + + dunder-proto@1.0.1: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-errors: 1.3.0 + gopd: 1.2.0 + + ee-first@1.1.1: {} + + encodeurl@2.0.0: {} + + es-define-property@1.0.1: {} + + es-errors@1.3.0: {} + + es-object-atoms@1.1.2: + dependencies: + es-errors: 1.3.0 + + escape-html@1.0.3: {} + + etag@1.8.1: {} + + events-universal@1.0.1: + dependencies: + bare-events: 2.8.3 + transitivePeerDependencies: + - bare-abort-controller + + eventsource-parser@3.0.8: {} + + eventsource@3.0.7: + dependencies: + eventsource-parser: 3.0.8 + + express-rate-limit@8.5.2(express@5.2.1): + dependencies: + express: 5.2.1 + ip-address: 10.2.0 + + express@5.2.1: + dependencies: + accepts: 2.0.0 + body-parser: 2.2.2 + content-disposition: 1.1.0 + content-type: 1.0.5 + cookie: 0.7.2 + cookie-signature: 1.2.2 + debug: 4.4.3 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 2.1.1 + fresh: 2.0.0 + http-errors: 2.0.1 + merge-descriptors: 2.0.0 + mime-types: 3.0.2 + on-finished: 2.4.1 + once: 1.4.0 + parseurl: 1.3.3 + proxy-addr: 2.0.7 + qs: 6.15.2 + range-parser: 1.2.1 + router: 2.2.0 + send: 1.2.1 + serve-static: 2.2.1 + statuses: 2.0.2 + type-is: 2.1.0 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + + fast-deep-equal@3.1.3: {} + + fast-fifo@1.3.2: {} + + fast-sha256@1.3.0: {} + + fast-uri@3.1.2: {} + + finalhandler@2.1.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + forwarded@0.2.0: {} + + fresh@2.0.0: {} + + function-bind@1.1.2: {} + + get-intrinsic@1.3.0: + dependencies: + call-bind-apply-helpers: 1.0.2 + es-define-property: 1.0.1 + es-errors: 1.3.0 + es-object-atoms: 1.1.2 + function-bind: 1.1.2 + get-proto: 1.0.1 + gopd: 1.2.0 + has-symbols: 1.1.0 + hasown: 2.0.3 + math-intrinsics: 1.1.0 + + get-proto@1.0.1: + dependencies: + dunder-proto: 1.0.1 + es-object-atoms: 1.1.2 + + gopd@1.2.0: {} + + has-symbols@1.1.0: {} + + hasown@2.0.3: + dependencies: + function-bind: 1.1.2 + + hono@4.12.23: {} + + http-errors@2.0.1: + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.2 + toidentifier: 1.0.1 + + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + + inherits@2.0.4: {} + + ip-address@10.2.0: {} + + ipaddr.js@1.9.1: {} + + is-promise@4.0.0: {} + + isexe@2.0.0: {} + + jiti@2.7.0: {} + + jose@6.2.3: {} + + json-schema-to-ts@3.1.1: + dependencies: + '@babel/runtime': 7.29.7 + ts-algebra: 2.0.0 + + json-schema-traverse@1.0.0: {} + + json-schema-typed@8.0.2: {} + + jsonlines@0.1.1: {} + + math-intrinsics@1.1.0: {} + + media-typer@1.1.0: {} + + merge-descriptors@2.0.0: {} + + mime-db@1.54.0: {} + + mime-types@3.0.2: + dependencies: + mime-db: 1.54.0 + + minimatch@10.2.5: + dependencies: + brace-expansion: 5.0.6 + + minipass@7.1.3: {} + + minizlib@3.1.0: + dependencies: + minipass: 7.1.3 + + ms@2.1.3: {} + + negotiator@1.0.0: {} + + object-assign@4.1.1: {} + + object-inspect@1.13.4: {} + + on-finished@2.4.1: + dependencies: + ee-first: 1.1.1 + + once@1.4.0: + dependencies: + wrappy: 1.0.2 + + os-paths@4.4.0: {} + + parseurl@1.3.3: {} + + path-key@3.1.1: {} + + path-to-regexp@8.4.2: {} + + picocolors@1.1.1: {} + + pkce-challenge@5.0.1: {} + + proxy-addr@2.0.7: + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + + qs@6.15.2: + dependencies: + side-channel: 1.1.0 + + range-parser@1.2.1: {} + + raw-body@3.0.2: + dependencies: + bytes: 3.1.2 + http-errors: 2.0.1 + iconv-lite: 0.7.2 + unpipe: 1.0.0 + + require-from-string@2.0.2: {} + + retry@0.13.1: {} + + router@2.2.0: + dependencies: + debug: 4.4.3 + depd: 2.0.0 + is-promise: 4.0.0 + parseurl: 1.3.3 + path-to-regexp: 8.4.2 + transitivePeerDependencies: + - supports-color + + safer-buffer@2.1.2: {} + + send@1.2.1: + dependencies: + debug: 4.4.3 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 2.0.0 + http-errors: 2.0.1 + mime-types: 3.0.2 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.2 + transitivePeerDependencies: + - supports-color + + serve-static@2.2.1: + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 1.2.1 + transitivePeerDependencies: + - supports-color + + setprototypeof@1.2.0: {} + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + side-channel-list@1.0.1: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + + side-channel-map@1.0.1: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + + side-channel-weakmap@1.0.2: + dependencies: + call-bound: 1.0.4 + es-errors: 1.3.0 + get-intrinsic: 1.3.0 + object-inspect: 1.13.4 + side-channel-map: 1.0.1 + + side-channel@1.1.0: + dependencies: + es-errors: 1.3.0 + object-inspect: 1.13.4 + side-channel-list: 1.0.1 + side-channel-map: 1.0.1 + side-channel-weakmap: 1.0.2 + + standardwebhooks@1.0.0: + dependencies: + '@stablelib/base64': 1.0.1 + fast-sha256: 1.3.0 + + statuses@2.0.2: {} + + streamx@2.26.0: + dependencies: + events-universal: 1.0.1 + fast-fifo: 1.3.2 + text-decoder: 1.2.7 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar-stream@3.1.7: + dependencies: + b4a: 1.8.1 + fast-fifo: 1.3.2 + streamx: 2.26.0 + transitivePeerDependencies: + - bare-abort-controller + - react-native-b4a + + tar@7.5.15: + dependencies: + '@isaacs/fs-minipass': 4.0.1 + chownr: 3.0.0 + minipass: 7.1.3 + minizlib: 3.1.0 + yallist: 5.0.0 + + text-decoder@1.2.7: + dependencies: + b4a: 1.8.1 + transitivePeerDependencies: + - react-native-b4a + + toidentifier@1.0.1: {} + + ts-algebra@2.0.0: {} + + type-is@2.1.0: + dependencies: + content-type: 2.0.0 + media-typer: 1.1.0 + mime-types: 3.0.2 + + undici@7.26.0: {} + + unpipe@1.0.0: {} + + vary@1.1.2: {} + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + wrappy@1.0.2: {} + + xdg-app-paths@5.1.0: + dependencies: + xdg-portable: 7.3.0 + + xdg-portable@7.3.0: + dependencies: + os-paths: 4.4.0 + + yallist@5.0.0: {} + + zod-to-json-schema@3.25.2(zod@3.24.4): + dependencies: + zod: 3.24.4 + + zod@3.24.4: {} diff --git a/.deepsec/pnpm-workspace.yaml b/.deepsec/pnpm-workspace.yaml new file mode 100644 index 000000000..3334c0e43 --- /dev/null +++ b/.deepsec/pnpm-workspace.yaml @@ -0,0 +1 @@ +packages: [] diff --git a/.env.example b/.env.example index c3cd4ce4d..9e0b42a3c 100644 --- a/.env.example +++ b/.env.example @@ -59,3 +59,8 @@ CLOUDFLARE_R2_ACCOUNT_ID= CLOUDFLARE_R2_ACCESS_KEY_ID= CLOUDFLARE_R2_SECRET_ACCESS_KEY= CLOUDFLARE_R2_BUCKET_NAME= + +# Set to "true" to render the predictive-prefetch debug overlay (visualizes the +# cursor reach, heading cone, and per-link detection state). Never shown in +# production regardless of this value. +NEXT_PUBLIC_PREFETCH_DEBUG= diff --git a/.github/workflows/format.yml b/.github/workflows/format.yml index ce82d981d..fea086feb 100644 --- a/.github/workflows/format.yml +++ b/.github/workflows/format.yml @@ -5,21 +5,24 @@ on: [push] jobs: format: runs-on: ubuntu-latest - permissions: write-all + permissions: + contents: read strategy: matrix: node-version: [22.x] steps: - - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - version: 8 + persist-credentials: false + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + with: + version: 9.15.9 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node-version }} cache: "pnpm" - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm format:check diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml index 0afe5c3ff..392fab41a 100644 --- a/.github/workflows/lint.yml +++ b/.github/workflows/lint.yml @@ -5,21 +5,24 @@ on: [push] jobs: lint: runs-on: ubuntu-latest - permissions: write-all + permissions: + contents: read strategy: matrix: node-version: [22.x] steps: - - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - version: 8 + persist-credentials: false + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + with: + version: 9.15.9 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node-version }} cache: "pnpm" - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm lint diff --git a/.github/workflows/migrate.yml b/.github/workflows/migrate.yml index 682bf86e4..35873dd75 100644 --- a/.github/workflows/migrate.yml +++ b/.github/workflows/migrate.yml @@ -19,19 +19,19 @@ jobs: node-version: [22.x] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 - - uses: pnpm/action-setup@v3 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 with: - version: 8 + version: 9.15.9 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node-version }} cache: pnpm - - run: pnpm install --ignore-scripts + - run: pnpm install --frozen-lockfile --ignore-scripts - name: Deploy Prisma migrations env: diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml index e88037a16..47fb2b5fd 100644 --- a/.github/workflows/typecheck.yml +++ b/.github/workflows/typecheck.yml @@ -5,20 +5,24 @@ on: [push] jobs: typecheck: runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: node-version: [22.x] steps: - - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - version: 8 + persist-credentials: false + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + with: + version: 9.15.9 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node-version }} cache: "pnpm" - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm typecheck diff --git a/.github/workflows/vitest.yml b/.github/workflows/vitest.yml index f4b22b245..7f96c9890 100644 --- a/.github/workflows/vitest.yml +++ b/.github/workflows/vitest.yml @@ -8,6 +8,8 @@ on: [push] jobs: test: runs-on: ubuntu-latest + permissions: + contents: read strategy: matrix: @@ -15,14 +17,16 @@ jobs: # See supported Node.js release schedule at https://nodejs.org/en/about/releases/ steps: - - uses: actions/checkout@v3 - - uses: pnpm/action-setup@v3 + - uses: actions/checkout@34e114876b0b11c390a56381ad16ebd13914f8d5 # v4 with: - version: 8 + persist-credentials: false + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 + with: + version: 9.15.9 - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: ${{ matrix.node-version }} cache: "pnpm" - - run: pnpm install + - run: pnpm install --frozen-lockfile - run: pnpm test diff --git a/.gitignore b/.gitignore index bac496f8b..7f99e09b2 100644 --- a/.gitignore +++ b/.gitignore @@ -50,3 +50,17 @@ next-env.d.ts .superpowers/ # next-agents-md .next-docs/ +# agent-browser auth state +my-auth.json +*.auth.json + +docs/superpowers/ + +# git worktrees +.worktrees/ + +# WP training output (derived from user data — never commit) +artifacts/ + +# Prisma generated client +src/generated/ diff --git a/.oxfmtrc.json b/.oxfmtrc.json index aa6d63489..bba29dff7 100644 --- a/.oxfmtrc.json +++ b/.oxfmtrc.json @@ -15,6 +15,7 @@ "**/__tmp__/**", "lerna.json", ".github", - "pnpm-lock.yaml" + "pnpm-lock.yaml", + "src/generated/**" ] } diff --git a/.oxlintrc.json b/.oxlintrc.json index 3c88229b7..0fd0a2d88 100644 --- a/.oxlintrc.json +++ b/.oxlintrc.json @@ -17,7 +17,8 @@ "next-env.d.ts", "**/components/ui/**", "**/components/ai-elements/**", - "scripts/**" + "scripts/**", + "src/generated/**" ], "rules": { "getter-return": "error", diff --git a/.prettierignore b/.prettierignore index e69a6c31f..a344a6655 100644 --- a/.prettierignore +++ b/.prettierignore @@ -5,4 +5,5 @@ node_modules **/__tmp__/** lerna.json .github -pnpm-lock.yaml \ No newline at end of file +pnpm-lock.yaml +src/generated/ diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..25ebfc156 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,15 @@ +[Next.js Docs Index]|root: ./.next-docs|STOP. What you remember about Next.js is WRONG for this project. Always search docs and read before any task.|If docs missing, run this command first: npx @next/codemod agents-md --output CLAUDE.md|01-app:{04-glossary.mdx}|01-app/01-getting-started:{01-installation.mdx,02-project-structure.mdx,03-layouts-and-pages.mdx,04-linking-and-navigating.mdx,05-server-and-client-components.mdx,06-fetching-data.mdx,07-mutating-data.mdx,08-caching.mdx,09-revalidating.mdx,10-error-handling.mdx,11-css.mdx,12-images.mdx,13-fonts.mdx,14-metadata-and-og-images.mdx,15-route-handlers.mdx,16-proxy.mdx,17-deploying.mdx,18-upgrading.mdx}|01-app/02-guides:{ai-agents.mdx,analytics.mdx,authentication.mdx,backend-for-frontend.mdx,caching-without-cache-components.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,data-security.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instant-navigation.mdx,instrumentation.mdx,internationalization.mdx,json-ld.mdx,lazy-loading.mdx,local-development.mdx,mcp.mdx,mdx.mdx,memory-usage.mdx,migrating-to-cache-components.mdx,multi-tenant.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,prefetching.mdx,preserving-ui-state.mdx,production-checklist.mdx,progressive-web-apps.mdx,public-static-pages.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,single-page-applications.mdx,static-exports.mdx,streaming.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx,videos.mdx}|01-app/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|01-app/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|01-app/02-guides/upgrading:{codemods.mdx,version-14.mdx,version-15.mdx,version-16.mdx}|01-app/03-api-reference:{07-edge.mdx,08-turbopack.mdx}|01-app/03-api-reference/01-directives:{use-cache-private.mdx,use-cache-remote.mdx,use-cache.mdx,use-client.mdx,use-server.mdx}|01-app/03-api-reference/02-components:{font.mdx,form.mdx,image.mdx,link.mdx,script.mdx}|01-app/03-api-reference/03-file-conventions/01-metadata:{app-icons.mdx,manifest.mdx,opengraph-image.mdx,robots.mdx,sitemap.mdx}|01-app/03-api-reference/03-file-conventions/02-route-segment-config:{dynamicParams.mdx,instant.mdx,maxDuration.mdx,preferredRegion.mdx,runtime.mdx}|01-app/03-api-reference/03-file-conventions:{default.mdx,dynamic-routes.mdx,error.mdx,forbidden.mdx,instrumentation-client.mdx,instrumentation.mdx,intercepting-routes.mdx,layout.mdx,loading.mdx,mdx-components.mdx,not-found.mdx,page.mdx,parallel-routes.mdx,proxy.mdx,public-folder.mdx,route-groups.mdx,route.mdx,src-folder.mdx,template.mdx,unauthorized.mdx}|01-app/03-api-reference/04-functions:{after.mdx,cacheLife.mdx,cacheTag.mdx,catchError.mdx,connection.mdx,cookies.mdx,draft-mode.mdx,fetch.mdx,forbidden.mdx,generate-image-metadata.mdx,generate-metadata.mdx,generate-sitemaps.mdx,generate-static-params.mdx,generate-viewport.mdx,headers.mdx,image-response.mdx,next-request.mdx,next-response.mdx,not-found.mdx,permanentRedirect.mdx,redirect.mdx,refresh.mdx,revalidatePath.mdx,revalidateTag.mdx,unauthorized.mdx,unstable_cache.mdx,unstable_noStore.mdx,unstable_rethrow.mdx,updateTag.mdx,use-link-status.mdx,use-params.mdx,use-pathname.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,use-selected-layout-segment.mdx,use-selected-layout-segments.mdx,userAgent.mdx}|01-app/03-api-reference/05-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,appDir.mdx,assetPrefix.mdx,authInterrupts.mdx,basePath.mdx,cacheComponents.mdx,cacheHandlers.mdx,cacheLife.mdx,compress.mdx,crossOrigin.mdx,cssChunking.mdx,deploymentId.mdx,devIndicators.mdx,distDir.mdx,env.mdx,expireTime.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,htmlLimitedBots.mdx,httpAgentOptions.mdx,images.mdx,incrementalCacheHandlerPath.mdx,inlineCss.mdx,logging.mdx,mdxRs.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactCompiler.mdx,reactMaxHeadersLength.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,sassOptions.mdx,serverActions.mdx,serverComponentsHmrCache.mdx,serverExternalPackages.mdx,staleTimes.mdx,staticGeneration.mdx,taint.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,turbopackFileSystemCache.mdx,turbopackIgnoreIssue.mdx,typedRoutes.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,viewTransition.mdx,webVitalsAttribution.mdx,webpack.mdx}|01-app/03-api-reference/05-config:{02-typescript.mdx,03-eslint.mdx}|01-app/03-api-reference/06-cli:{create-next-app.mdx,next.mdx}|02-pages/01-getting-started:{01-installation.mdx,02-project-structure.mdx,04-images.mdx,05-fonts.mdx,06-css.mdx,11-deploying.mdx}|02-pages/02-guides:{analytics.mdx,authentication.mdx,babel.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instrumentation.mdx,internationalization.mdx,lazy-loading.mdx,mdx.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,post-css.mdx,preview-mode.mdx,production-checklist.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,static-exports.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx}|02-pages/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|02-pages/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|02-pages/02-guides/upgrading:{codemods.mdx,version-10.mdx,version-11.mdx,version-12.mdx,version-13.mdx,version-14.mdx,version-9.mdx}|02-pages/03-building-your-application/01-routing:{01-pages-and-layouts.mdx,02-dynamic-routes.mdx,03-linking-and-navigating.mdx,05-custom-app.mdx,06-custom-document.mdx,07-api-routes.mdx,08-custom-error.mdx}|02-pages/03-building-your-application/02-rendering:{01-server-side-rendering.mdx,02-static-site-generation.mdx,04-automatic-static-optimization.mdx,05-client-side-rendering.mdx}|02-pages/03-building-your-application/03-data-fetching:{01-get-static-props.mdx,02-get-static-paths.mdx,03-forms-and-mutations.mdx,03-get-server-side-props.mdx,05-client-side.mdx}|02-pages/03-building-your-application/06-configuring:{12-error-handling.mdx}|02-pages/04-api-reference:{06-edge.mdx,08-turbopack.mdx}|02-pages/04-api-reference/01-components:{font.mdx,form.mdx,head.mdx,image-legacy.mdx,image.mdx,link.mdx,script.mdx}|02-pages/04-api-reference/02-file-conventions:{instrumentation.mdx,proxy.mdx,public-folder.mdx,src-folder.mdx}|02-pages/04-api-reference/03-functions:{get-initial-props.mdx,get-server-side-props.mdx,get-static-paths.mdx,get-static-props.mdx,next-request.mdx,next-response.mdx,use-params.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,userAgent.mdx}|02-pages/04-api-reference/04-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,assetPrefix.mdx,basePath.mdx,bundlePagesRouterDependencies.mdx,compress.mdx,crossOrigin.mdx,deploymentId.mdx,devIndicators.mdx,distDir.mdx,env.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,httpAgentOptions.mdx,images.mdx,logging.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,serverExternalPackages.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,webVitalsAttribution.mdx,webpack.mdx}|02-pages/04-api-reference/04-config:{01-typescript.mdx,02-eslint.mdx}|02-pages/04-api-reference/05-cli:{create-next-app.mdx,next.mdx}|03-architecture:{accessibility.mdx,fast-refresh.mdx,nextjs-compiler.mdx,supported-browsers.mdx}|04-community:{01-contribution-guide.mdx,02-rspack.mdx} + +## Agent skills + +### Issue tracker + +GitHub issues at `luxdotdev/parsertime` via the `gh` CLI. See `docs/agents/issue-tracker.md`. + +### Triage labels + +Canonical defaults (`needs-triage`, `needs-info`, `ready-for-agent`, `ready-for-human`, `wontfix`); only `wontfix` exists today, the rest are created on first use. See `docs/agents/triage-labels.md`. + +### Domain docs + +Single-context: one `CONTEXT.md` + `docs/adr/` at the repo root (created lazily by `/grill-with-docs`). See `docs/agents/domain.md`. diff --git a/CLAUDE.md b/CLAUDE.md index 54187bb79..aa04911c7 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1 +1,3 @@ -[Next.js Docs Index]|root: ./.next-docs|STOP. What you remember about Next.js is WRONG for this project. Always search docs and read before any task.|If docs missing, run this command first: npx @next/codemod agents-md --output CLAUDE.md|01-app:{04-glossary.mdx}|01-app/01-getting-started:{01-installation.mdx,02-project-structure.mdx,03-layouts-and-pages.mdx,04-linking-and-navigating.mdx,05-server-and-client-components.mdx,06-fetching-data.mdx,07-mutating-data.mdx,08-caching.mdx,09-revalidating.mdx,10-error-handling.mdx,11-css.mdx,12-images.mdx,13-fonts.mdx,14-metadata-and-og-images.mdx,15-route-handlers.mdx,16-proxy.mdx,17-deploying.mdx,18-upgrading.mdx}|01-app/02-guides:{ai-agents.mdx,analytics.mdx,authentication.mdx,backend-for-frontend.mdx,caching-without-cache-components.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,data-security.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instant-navigation.mdx,instrumentation.mdx,internationalization.mdx,json-ld.mdx,lazy-loading.mdx,local-development.mdx,mcp.mdx,mdx.mdx,memory-usage.mdx,migrating-to-cache-components.mdx,multi-tenant.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,prefetching.mdx,preserving-ui-state.mdx,production-checklist.mdx,progressive-web-apps.mdx,public-static-pages.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,single-page-applications.mdx,static-exports.mdx,streaming.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx,videos.mdx}|01-app/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|01-app/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|01-app/02-guides/upgrading:{codemods.mdx,version-14.mdx,version-15.mdx,version-16.mdx}|01-app/03-api-reference:{07-edge.mdx,08-turbopack.mdx}|01-app/03-api-reference/01-directives:{use-cache-private.mdx,use-cache-remote.mdx,use-cache.mdx,use-client.mdx,use-server.mdx}|01-app/03-api-reference/02-components:{font.mdx,form.mdx,image.mdx,link.mdx,script.mdx}|01-app/03-api-reference/03-file-conventions/01-metadata:{app-icons.mdx,manifest.mdx,opengraph-image.mdx,robots.mdx,sitemap.mdx}|01-app/03-api-reference/03-file-conventions/02-route-segment-config:{dynamicParams.mdx,instant.mdx,maxDuration.mdx,preferredRegion.mdx,runtime.mdx}|01-app/03-api-reference/03-file-conventions:{default.mdx,dynamic-routes.mdx,error.mdx,forbidden.mdx,instrumentation-client.mdx,instrumentation.mdx,intercepting-routes.mdx,layout.mdx,loading.mdx,mdx-components.mdx,not-found.mdx,page.mdx,parallel-routes.mdx,proxy.mdx,public-folder.mdx,route-groups.mdx,route.mdx,src-folder.mdx,template.mdx,unauthorized.mdx}|01-app/03-api-reference/04-functions:{after.mdx,cacheLife.mdx,cacheTag.mdx,catchError.mdx,connection.mdx,cookies.mdx,draft-mode.mdx,fetch.mdx,forbidden.mdx,generate-image-metadata.mdx,generate-metadata.mdx,generate-sitemaps.mdx,generate-static-params.mdx,generate-viewport.mdx,headers.mdx,image-response.mdx,next-request.mdx,next-response.mdx,not-found.mdx,permanentRedirect.mdx,redirect.mdx,refresh.mdx,revalidatePath.mdx,revalidateTag.mdx,unauthorized.mdx,unstable_cache.mdx,unstable_noStore.mdx,unstable_rethrow.mdx,updateTag.mdx,use-link-status.mdx,use-params.mdx,use-pathname.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,use-selected-layout-segment.mdx,use-selected-layout-segments.mdx,userAgent.mdx}|01-app/03-api-reference/05-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,appDir.mdx,assetPrefix.mdx,authInterrupts.mdx,basePath.mdx,cacheComponents.mdx,cacheHandlers.mdx,cacheLife.mdx,compress.mdx,crossOrigin.mdx,cssChunking.mdx,deploymentId.mdx,devIndicators.mdx,distDir.mdx,env.mdx,expireTime.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,htmlLimitedBots.mdx,httpAgentOptions.mdx,images.mdx,incrementalCacheHandlerPath.mdx,inlineCss.mdx,logging.mdx,mdxRs.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactCompiler.mdx,reactMaxHeadersLength.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,sassOptions.mdx,serverActions.mdx,serverComponentsHmrCache.mdx,serverExternalPackages.mdx,staleTimes.mdx,staticGeneration.mdx,taint.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,turbopackFileSystemCache.mdx,turbopackIgnoreIssue.mdx,typedRoutes.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,viewTransition.mdx,webVitalsAttribution.mdx,webpack.mdx}|01-app/03-api-reference/05-config:{02-typescript.mdx,03-eslint.mdx}|01-app/03-api-reference/06-cli:{create-next-app.mdx,next.mdx}|02-pages/01-getting-started:{01-installation.mdx,02-project-structure.mdx,04-images.mdx,05-fonts.mdx,06-css.mdx,11-deploying.mdx}|02-pages/02-guides:{analytics.mdx,authentication.mdx,babel.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instrumentation.mdx,internationalization.mdx,lazy-loading.mdx,mdx.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,post-css.mdx,preview-mode.mdx,production-checklist.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,static-exports.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx}|02-pages/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|02-pages/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|02-pages/02-guides/upgrading:{codemods.mdx,version-10.mdx,version-11.mdx,version-12.mdx,version-13.mdx,version-14.mdx,version-9.mdx}|02-pages/03-building-your-application/01-routing:{01-pages-and-layouts.mdx,02-dynamic-routes.mdx,03-linking-and-navigating.mdx,05-custom-app.mdx,06-custom-document.mdx,07-api-routes.mdx,08-custom-error.mdx}|02-pages/03-building-your-application/02-rendering:{01-server-side-rendering.mdx,02-static-site-generation.mdx,04-automatic-static-optimization.mdx,05-client-side-rendering.mdx}|02-pages/03-building-your-application/03-data-fetching:{01-get-static-props.mdx,02-get-static-paths.mdx,03-forms-and-mutations.mdx,03-get-server-side-props.mdx,05-client-side.mdx}|02-pages/03-building-your-application/06-configuring:{12-error-handling.mdx}|02-pages/04-api-reference:{06-edge.mdx,08-turbopack.mdx}|02-pages/04-api-reference/01-components:{font.mdx,form.mdx,head.mdx,image-legacy.mdx,image.mdx,link.mdx,script.mdx}|02-pages/04-api-reference/02-file-conventions:{instrumentation.mdx,proxy.mdx,public-folder.mdx,src-folder.mdx}|02-pages/04-api-reference/03-functions:{get-initial-props.mdx,get-server-side-props.mdx,get-static-paths.mdx,get-static-props.mdx,next-request.mdx,next-response.mdx,use-params.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,userAgent.mdx}|02-pages/04-api-reference/04-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,assetPrefix.mdx,basePath.mdx,bundlePagesRouterDependencies.mdx,compress.mdx,crossOrigin.mdx,deploymentId.mdx,devIndicators.mdx,distDir.mdx,env.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,httpAgentOptions.mdx,images.mdx,logging.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,serverExternalPackages.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,webVitalsAttribution.mdx,webpack.mdx}|02-pages/04-api-reference/04-config:{01-typescript.mdx,02-eslint.mdx}|02-pages/04-api-reference/05-cli:{create-next-app.mdx,next.mdx}|03-architecture:{accessibility.mdx,fast-refresh.mdx,nextjs-compiler.mdx,supported-browsers.mdx}|04-community:{01-contribution-guide.mdx,02-rspack.mdx} +@AGENTS.md + +[Next.js Docs Index]|root: ./.next-docs|STOP. What you remember about Next.js is WRONG for this project. Always search docs and read before any task.|If docs missing, run this command first: npx @next/codemod agents-md --output CLAUDE.md|01-app:{04-glossary.mdx}|01-app/01-getting-started:{01-installation.mdx,02-project-structure.mdx,03-layouts-and-pages.mdx,04-linking-and-navigating.mdx,05-server-and-client-components.mdx,06-fetching-data.mdx,07-mutating-data.mdx,08-caching.mdx,09-revalidating.mdx,10-error-handling.mdx,11-css.mdx,12-images.mdx,13-fonts.mdx,14-metadata-and-og-images.mdx,15-route-handlers.mdx,16-proxy.mdx,17-deploying.mdx,18-upgrading.mdx}|01-app/02-guides:{ai-agents.mdx,analytics.mdx,authentication.mdx,backend-for-frontend.mdx,caching-without-cache-components.mdx,cdn-caching.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,data-security.mdx,debugging.mdx,deploying-to-platforms.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,how-revalidation-works.mdx,incremental-static-regeneration.mdx,instant-navigation.mdx,instrumentation.mdx,internationalization.mdx,json-ld.mdx,lazy-loading.mdx,local-development.mdx,mcp.mdx,mdx.mdx,memory-usage.mdx,migrating-to-cache-components.mdx,multi-tenant.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,ppr-platform-guide.mdx,prefetching.mdx,preserving-ui-state.mdx,production-checklist.mdx,progressive-web-apps.mdx,public-static-pages.mdx,redirecting.mdx,rendering-philosophy.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,single-page-applications.mdx,static-exports.mdx,streaming.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx,videos.mdx,view-transitions.mdx}|01-app/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|01-app/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|01-app/02-guides/upgrading:{codemods.mdx,version-14.mdx,version-15.mdx,version-16.mdx}|01-app/03-api-reference:{07-edge.mdx,08-turbopack.mdx}|01-app/03-api-reference/01-directives:{use-cache-private.mdx,use-cache-remote.mdx,use-cache.mdx,use-client.mdx,use-server.mdx}|01-app/03-api-reference/02-components:{font.mdx,form.mdx,image.mdx,link.mdx,script.mdx}|01-app/03-api-reference/03-file-conventions/01-metadata:{app-icons.mdx,manifest.mdx,opengraph-image.mdx,robots.mdx,sitemap.mdx}|01-app/03-api-reference/03-file-conventions/02-route-segment-config:{dynamicParams.mdx,instant.mdx,maxDuration.mdx,preferredRegion.mdx,runtime.mdx}|01-app/03-api-reference/03-file-conventions:{default.mdx,dynamic-routes.mdx,error.mdx,forbidden.mdx,instrumentation-client.mdx,instrumentation.mdx,intercepting-routes.mdx,layout.mdx,loading.mdx,mdx-components.mdx,not-found.mdx,page.mdx,parallel-routes.mdx,proxy.mdx,public-folder.mdx,route-groups.mdx,route.mdx,src-folder.mdx,template.mdx,unauthorized.mdx}|01-app/03-api-reference/04-functions:{after.mdx,cacheLife.mdx,cacheTag.mdx,catchError.mdx,connection.mdx,cookies.mdx,draft-mode.mdx,fetch.mdx,forbidden.mdx,generate-image-metadata.mdx,generate-metadata.mdx,generate-sitemaps.mdx,generate-static-params.mdx,generate-viewport.mdx,headers.mdx,image-response.mdx,next-request.mdx,next-response.mdx,not-found.mdx,permanentRedirect.mdx,redirect.mdx,refresh.mdx,revalidatePath.mdx,revalidateTag.mdx,unauthorized.mdx,unstable_cache.mdx,unstable_noStore.mdx,unstable_rethrow.mdx,updateTag.mdx,use-link-status.mdx,use-params.mdx,use-pathname.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,use-selected-layout-segment.mdx,use-selected-layout-segments.mdx,userAgent.mdx}|01-app/03-api-reference/05-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,appDir.mdx,assetPrefix.mdx,authInterrupts.mdx,basePath.mdx,cacheComponents.mdx,cacheHandlers.mdx,cacheLife.mdx,compress.mdx,crossOrigin.mdx,cssChunking.mdx,deploymentId.mdx,devIndicators.mdx,distDir.mdx,env.mdx,expireTime.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,htmlLimitedBots.mdx,httpAgentOptions.mdx,images.mdx,incrementalCacheHandlerPath.mdx,inlineCss.mdx,logging.mdx,mdxRs.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactCompiler.mdx,reactMaxHeadersLength.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,sassOptions.mdx,serverActions.mdx,serverComponentsHmrCache.mdx,serverExternalPackages.mdx,staleTimes.mdx,staticGeneration.mdx,taint.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,turbopackFileSystemCache.mdx,turbopackIgnoreIssue.mdx,typedRoutes.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,viewTransition.mdx,webVitalsAttribution.mdx,webpack.mdx}|01-app/03-api-reference/05-config:{02-typescript.mdx,03-eslint.mdx}|01-app/03-api-reference/06-cli:{create-next-app.mdx,next.mdx}|01-app/03-api-reference/07-adapters:{01-configuration.mdx,02-creating-an-adapter.mdx,03-api-reference.mdx,04-testing-adapters.mdx,05-routing-with-next-routing.mdx,06-implementing-ppr-in-an-adapter.mdx,07-runtime-integration.mdx,08-invoking-entrypoints.mdx,09-output-types.mdx,10-routing-information.mdx,11-use-cases.mdx}|02-pages/01-getting-started:{01-installation.mdx,02-project-structure.mdx,04-images.mdx,05-fonts.mdx,06-css.mdx,11-deploying.mdx}|02-pages/02-guides:{analytics.mdx,authentication.mdx,babel.mdx,ci-build-caching.mdx,content-security-policy.mdx,css-in-js.mdx,custom-server.mdx,debugging.mdx,draft-mode.mdx,environment-variables.mdx,forms.mdx,incremental-static-regeneration.mdx,instrumentation.mdx,internationalization.mdx,lazy-loading.mdx,mdx.mdx,multi-zones.mdx,open-telemetry.mdx,package-bundling.mdx,post-css.mdx,preview-mode.mdx,production-checklist.mdx,redirecting.mdx,sass.mdx,scripts.mdx,self-hosting.mdx,static-exports.mdx,tailwind-v3-css.mdx,third-party-libraries.mdx}|02-pages/02-guides/migrating:{app-router-migration.mdx,from-create-react-app.mdx,from-vite.mdx}|02-pages/02-guides/testing:{cypress.mdx,jest.mdx,playwright.mdx,vitest.mdx}|02-pages/02-guides/upgrading:{codemods.mdx,version-10.mdx,version-11.mdx,version-12.mdx,version-13.mdx,version-14.mdx,version-9.mdx}|02-pages/03-building-your-application/01-routing:{01-pages-and-layouts.mdx,02-dynamic-routes.mdx,03-linking-and-navigating.mdx,05-custom-app.mdx,06-custom-document.mdx,07-api-routes.mdx,08-custom-error.mdx}|02-pages/03-building-your-application/02-rendering:{01-server-side-rendering.mdx,02-static-site-generation.mdx,04-automatic-static-optimization.mdx,05-client-side-rendering.mdx}|02-pages/03-building-your-application/03-data-fetching:{01-get-static-props.mdx,02-get-static-paths.mdx,03-forms-and-mutations.mdx,03-get-server-side-props.mdx,05-client-side.mdx}|02-pages/03-building-your-application/06-configuring:{12-error-handling.mdx}|02-pages/04-api-reference:{06-edge.mdx,08-turbopack.mdx}|02-pages/04-api-reference/01-components:{font.mdx,form.mdx,head.mdx,image-legacy.mdx,image.mdx,link.mdx,script.mdx}|02-pages/04-api-reference/02-file-conventions:{instrumentation.mdx,proxy.mdx,public-folder.mdx,src-folder.mdx}|02-pages/04-api-reference/03-functions:{get-initial-props.mdx,get-server-side-props.mdx,get-static-paths.mdx,get-static-props.mdx,next-request.mdx,next-response.mdx,use-params.mdx,use-report-web-vitals.mdx,use-router.mdx,use-search-params.mdx,userAgent.mdx}|02-pages/04-api-reference/04-config/01-next-config-js:{adapterPath.mdx,allowedDevOrigins.mdx,assetPrefix.mdx,basePath.mdx,bundlePagesRouterDependencies.mdx,compress.mdx,crossOrigin.mdx,deploymentId.mdx,devIndicators.mdx,distDir.mdx,env.mdx,exportPathMap.mdx,generateBuildId.mdx,generateEtags.mdx,headers.mdx,httpAgentOptions.mdx,images.mdx,logging.mdx,onDemandEntries.mdx,optimizePackageImports.mdx,output.mdx,pageExtensions.mdx,poweredByHeader.mdx,productionBrowserSourceMaps.mdx,proxyClientMaxBodySize.mdx,reactStrictMode.mdx,redirects.mdx,rewrites.mdx,serverExternalPackages.mdx,trailingSlash.mdx,transpilePackages.mdx,turbopack.mdx,typescript.mdx,urlImports.mdx,useLightningcss.mdx,webVitalsAttribution.mdx,webpack.mdx}|02-pages/04-api-reference/04-config:{01-typescript.mdx,02-eslint.mdx}|02-pages/04-api-reference/05-cli:{create-next-app.mdx,next.mdx}|02-pages/04-api-reference/06-adapters:{01-configuration.mdx,02-creating-an-adapter.mdx,03-api-reference.mdx,04-testing-adapters.mdx,05-routing-with-next-routing.mdx,06-implementing-ppr-in-an-adapter.mdx,07-runtime-integration.mdx,08-invoking-entrypoints.mdx,09-output-types.mdx,10-routing-information.mdx,11-use-cases.mdx}|03-architecture:{accessibility.mdx,fast-refresh.mdx,nextjs-compiler.mdx,supported-browsers.mdx}|04-community:{01-contribution-guide.mdx,02-rspack.mdx} diff --git a/DESIGN.json b/DESIGN.json new file mode 100644 index 000000000..3439eb23b --- /dev/null +++ b/DESIGN.json @@ -0,0 +1,354 @@ +{ + "schemaVersion": 2, + "generatedAt": "2026-04-24T00:00:00Z", + "title": "Design System: Parsertime", + "extensions": { + "colorMeta": { + "primary-light": { + "role": "primary", + "displayName": "Signal Amber (light mode)", + "canonical": "oklch(0.55 0.17 68)", + "tonalRamp": [ + "oklch(0.18 0.04 68)", + "oklch(0.3 0.08 68)", + "oklch(0.42 0.12 68)", + "oklch(0.55 0.17 68)", + "oklch(0.66 0.17 72)", + "oklch(0.78 0.17 76)", + "oklch(0.88 0.13 80)", + "oklch(0.96 0.06 84)" + ] + }, + "primary-dark": { + "role": "primary", + "displayName": "Signal Amber (dark mode)", + "canonical": "oklch(0.82 0.17 78)", + "tonalRamp": [ + "oklch(0.18 0.04 78)", + "oklch(0.32 0.08 78)", + "oklch(0.46 0.12 78)", + "oklch(0.6 0.15 78)", + "oklch(0.72 0.17 78)", + "oklch(0.82 0.17 78)", + "oklch(0.9 0.13 80)", + "oklch(0.96 0.06 84)" + ] + }, + "background-dark": { + "role": "neutral", + "displayName": "Late-Night Page", + "canonical": "oklch(0.14 0.003 250)", + "tonalRamp": [ + "oklch(0.1 0.003 250)", + "oklch(0.14 0.003 250)", + "oklch(0.18 0.004 250)", + "oklch(0.27 0.005 250)", + "oklch(0.42 0.005 250)", + "oklch(0.6 0.005 250)", + "oklch(0.78 0.004 250)", + "oklch(0.92 0.003 250)" + ] + }, + "background-light": { + "role": "neutral", + "displayName": "Daylight Page", + "canonical": "oklch(0.985 0.003 250)", + "tonalRamp": [ + "oklch(0.18 0.005 250)", + "oklch(0.32 0.005 250)", + "oklch(0.5 0.005 250)", + "oklch(0.68 0.005 250)", + "oklch(0.82 0.004 250)", + "oklch(0.91 0.004 250)", + "oklch(0.97 0.003 250)", + "oklch(0.99 0.002 250)" + ] + }, + "destructive-light": { + "role": "destructive", + "displayName": "Stop Red", + "canonical": "oklch(0.58 0.22 27)", + "tonalRamp": [ + "oklch(0.18 0.06 27)", + "oklch(0.3 0.12 27)", + "oklch(0.42 0.18 27)", + "oklch(0.5 0.22 27)", + "oklch(0.58 0.22 27)", + "oklch(0.7 0.18 27)", + "oklch(0.84 0.1 27)", + "oklch(0.94 0.05 27)" + ] + }, + "team-1-off": { + "role": "team", + "displayName": "Team 1 Cyan", + "canonical": "oklch(0.753 0.154 232.18)", + "tonalRamp": [ + "oklch(0.2 0.05 232)", + "oklch(0.34 0.08 232)", + "oklch(0.48 0.11 232)", + "oklch(0.62 0.14 232)", + "oklch(0.753 0.154 232.18)", + "oklch(0.84 0.13 232)", + "oklch(0.92 0.08 232)", + "oklch(0.97 0.04 232)" + ] + }, + "team-2-off": { + "role": "team", + "displayName": "Team 2 Red-Orange", + "canonical": "oklch(0.6218 0.224 17.51)", + "tonalRamp": [ + "oklch(0.2 0.06 17)", + "oklch(0.32 0.12 17)", + "oklch(0.44 0.18 17)", + "oklch(0.55 0.22 17)", + "oklch(0.6218 0.224 17.51)", + "oklch(0.74 0.18 17)", + "oklch(0.86 0.1 17)", + "oklch(0.95 0.04 17)" + ] + } + }, + "typographyMeta": { + "display": { + "displayName": "Display", + "purpose": "Marketing hero titles and major page headlines on long-form surfaces." + }, + "headline": { + "displayName": "Headline", + "purpose": "Page titles inside the product. Default h1 for app pages." + }, + "title": { + "displayName": "Title", + "purpose": "Card titles, section labels, dialog headings." + }, + "body": { + "displayName": "Body", + "purpose": "All running content. 65–75ch on prose; product UI ignores measure." + }, + "label": { + "displayName": "Label (caps + tracked)", + "purpose": "Metadata layer: column headers, eyebrows, taxonomy tags, affordance labels." + }, + "numeral": { + "displayName": "Tabular Numeral", + "purpose": "Comparable numbers in stat columns, leaderboards, deltas. Aligns on the digit." + } + }, + "shadows": [ + { + "name": "xs", + "value": "0 1px 2px 0 oklch(0 0 0 / 0.05)", + "purpose": "Default card lift, plus inputs and outline buttons. Almost imperceptible — its job is to nudge the eye that a surface is liftable, not to create depth theater." + }, + { + "name": "ring-card-dark", + "value": "inset 0 0 0 1px oklch(1 0 0 / 0.10)", + "purpose": "Card ring in dark mode where shadow-xs is invisible against the deep page." + }, + { + "name": "ring-card-light", + "value": "inset 0 0 0 1px oklch(0.185 0.005 250 / 0.10)", + "purpose": "Card ring in light mode, paired with shadow-xs." + } + ], + "motion": [ + { + "name": "duration-exit", + "value": "150ms", + "purpose": "View-transition exit animations." + }, + { + "name": "duration-enter", + "value": "210ms", + "purpose": "View-transition enter animations." + }, + { + "name": "duration-move", + "value": "400ms", + "purpose": "View-transition spatial movement (slide, scale)." + }, + { + "name": "ease-collapsible", + "value": "cubic-bezier(0.215, 0.61, 0.355, 1)", + "purpose": "Collapsible open/close (Radix collapsible-down / collapsible-up keyframes)." + }, + { + "name": "ease-out", + "value": "cubic-bezier(0, 0, 0.2, 1)", + "purpose": "Default easing for state transitions and entrances. Exponential ease-out, no bounce." + } + ], + "breakpoints": [ + { "name": "sm", "value": "640px" }, + { "name": "md", "value": "768px" }, + { "name": "lg", "value": "1024px" }, + { "name": "xl", "value": "1280px" }, + { "name": "2xl", "value": "1536px" } + ] + }, + "components": [ + { + "name": "Primary Button", + "kind": "button", + "refersTo": "button-primary", + "description": "Default primary action. Amber on near-black, identical in light and dark.", + "html": "", + "css": ".ds-btn-primary { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 36px; padding: 0 10px; border: 1px solid transparent; border-radius: 8px; background: oklch(0.82 0.17 78); color: oklch(0.185 0.02 80); font-family: Switzer, ui-sans-serif, system-ui, sans-serif; font-size: 14px; font-weight: 500; line-height: 1; white-space: nowrap; cursor: pointer; transition: background 120ms cubic-bezier(0,0,0.2,1), box-shadow 120ms cubic-bezier(0,0,0.2,1); } .ds-btn-primary:hover { background: oklch(0.82 0.17 78 / 0.8); } .ds-btn-primary:focus-visible { outline: none; border-color: oklch(0.82 0.17 78); box-shadow: 0 0 0 3px oklch(0.82 0.17 78 / 0.5); } .ds-btn-primary:active { background: oklch(0.82 0.17 78 / 0.9); }" + }, + { + "name": "Outline Button", + "kind": "button", + "refersTo": "button-outline", + "description": "Non-primary action and dropdown trigger. Transparent ground with a low-contrast border.", + "html": "", + "css": ".ds-btn-outline { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 36px; padding: 0 10px; border: 1px solid oklch(1 0 0 / 0.12); border-radius: 8px; background: oklch(0.175 0.004 250 / 0.3); color: oklch(0.98 0.002 250); font-family: Switzer, ui-sans-serif, system-ui, sans-serif; font-size: 14px; font-weight: 500; box-shadow: 0 1px 2px 0 oklch(0 0 0 / 0.05); cursor: pointer; transition: background 120ms cubic-bezier(0,0,0.2,1); } .ds-btn-outline:hover { background: oklch(0.22 0.004 250 / 0.5); } .ds-btn-outline:focus-visible { outline: none; border-color: oklch(0.82 0.17 78); box-shadow: 0 0 0 3px oklch(0.82 0.17 78 / 0.5); } .ds-btn-outline[aria-expanded=\"true\"] { background: oklch(0.22 0.004 250); }" + }, + { + "name": "Ghost Button", + "kind": "button", + "refersTo": "button-ghost", + "description": "Icon-only and toolbar button. No chrome at rest.", + "html": "", + "css": ".ds-btn-ghost { display: inline-flex; align-items: center; justify-content: center; width: 36px; height: 36px; padding: 0; border: 1px solid transparent; border-radius: 8px; background: transparent; color: oklch(0.98 0.002 250); cursor: pointer; transition: background 120ms cubic-bezier(0,0,0.2,1); } .ds-btn-ghost:hover { background: oklch(0.22 0.004 250 / 0.5); } .ds-btn-ghost:focus-visible { outline: none; border-color: oklch(0.82 0.17 78); box-shadow: 0 0 0 3px oklch(0.82 0.17 78 / 0.5); }" + }, + { + "name": "Destructive Button", + "kind": "button", + "refersTo": "button-destructive", + "description": "Soft-fill destructive action. Red-tinted background, never used as the page-primary CTA.", + "html": "", + "css": ".ds-btn-destructive { display: inline-flex; align-items: center; justify-content: center; gap: 6px; height: 36px; padding: 0 10px; border: 1px solid transparent; border-radius: 8px; background: oklch(0.65 0.22 27 / 0.2); color: oklch(0.65 0.22 27); font-family: Switzer, ui-sans-serif, system-ui, sans-serif; font-size: 14px; font-weight: 500; cursor: pointer; transition: background 120ms cubic-bezier(0,0,0.2,1); } .ds-btn-destructive:hover { background: oklch(0.65 0.22 27 / 0.3); } .ds-btn-destructive:focus-visible { outline: none; border-color: oklch(0.65 0.22 27 / 0.4); box-shadow: 0 0 0 3px oklch(0.65 0.22 27 / 0.4); }" + }, + { + "name": "Card", + "kind": "card", + "refersTo": "card", + "description": "Default card surface. Tonal lift over the page plus a 1px ring; no decorative shadow.", + "html": "
Map performance
Win rate by map across the last 14 days.
Body content goes here.
", + "css": ".ds-card { display: flex; flex-direction: column; gap: 24px; padding: 24px 0; border-radius: 14px; background: oklch(0.175 0.004 250); color: oklch(0.98 0.002 250); box-shadow: 0 1px 2px 0 oklch(0 0 0 / 0.05), inset 0 0 0 1px oklch(1 0 0 / 0.10); overflow: hidden; font-family: Switzer, ui-sans-serif, system-ui, sans-serif; font-size: 14px; line-height: 1.5; } .ds-card__header { display: grid; gap: 4px; padding: 0 24px; } .ds-card__title { font-size: 16px; font-weight: 500; line-height: 1.3; } .ds-card__description { color: oklch(0.68 0.005 250); font-size: 14px; } .ds-card__content { padding: 0 24px; }" + }, + { + "name": "Input", + "kind": "input", + "refersTo": "input", + "description": "Text input. Focus is an instant 3px amber ring — no glow, no animation.", + "html": "", + "css": ".ds-input { display: block; width: 100%; height: 36px; padding: 4px 10px; border: 1px solid oklch(1 0 0 / 0.12); border-radius: 8px; background: oklch(0.175 0.004 250 / 0.3); color: oklch(0.98 0.002 250); font-family: Switzer, ui-sans-serif, system-ui, sans-serif; font-size: 14px; line-height: 1.4; box-shadow: 0 1px 2px 0 oklch(0 0 0 / 0.05); transition: border-color 120ms cubic-bezier(0,0,0.2,1), box-shadow 120ms cubic-bezier(0,0,0.2,1); outline: none; } .ds-input::placeholder { color: oklch(0.68 0.005 250); } .ds-input:focus-visible { border-color: oklch(0.82 0.17 78); box-shadow: 0 0 0 3px oklch(0.82 0.17 78 / 0.5); } .ds-input[aria-invalid=\"true\"] { border-color: oklch(0.65 0.22 27); box-shadow: 0 0 0 3px oklch(0.65 0.22 27 / 0.4); }" + }, + { + "name": "Badge", + "kind": "chip", + "refersTo": "badge-default", + "description": "Filled badge / pill. Used for taxonomy tags and inline status.", + "html": "MAIN SUPPORT", + "css": ".ds-badge { display: inline-flex; align-items: center; gap: 4px; height: 20px; padding: 0 8px; border-radius: 6px; background: oklch(0.225 0.005 250); color: oklch(0.98 0.002 250); font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; font-size: 11px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; line-height: 1; }" + }, + { + "name": "Stat Cell", + "kind": "custom", + "refersTo": "stat-cell", + "description": "Right-aligned tabular numeral cell with a caps label above. The base unit of any stat column or leaderboard row.", + "html": "
FINAL BLOWS
28
+4 vs last scrim
", + "css": ".ds-stat { display: flex; flex-direction: column; align-items: flex-end; gap: 2px; font-family: Switzer, ui-sans-serif, system-ui, sans-serif; } .ds-stat__label { font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; font-size: 11px; font-weight: 500; letter-spacing: 0.06em; text-transform: uppercase; color: oklch(0.68 0.005 250); } .ds-stat__value { font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; font-feature-settings: 'tnum'; font-size: 20px; font-weight: 600; color: oklch(0.98 0.002 250); line-height: 1.1; } .ds-stat__delta { font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; font-feature-settings: 'tnum'; font-size: 12px; color: oklch(0.82 0.17 78); }" + }, + { + "name": "Chart Tooltip", + "kind": "custom", + "refersTo": "chart-tooltip", + "description": "Recharts tooltip restyled to honor the Tooltip Surface Rule — popover ground, never amber.", + "html": "
Hanamura
Team 162%
Team 238%
", + "css": ".ds-chart-tooltip { display: flex; flex-direction: column; gap: 6px; padding: 8px 10px; border: 1px solid oklch(1 0 0 / 0.10); border-radius: 8px; background: oklch(0.175 0.004 250); color: oklch(0.98 0.002 250); font-family: Switzer, ui-sans-serif, system-ui, sans-serif; font-size: 12px; box-shadow: 0 4px 16px 0 oklch(0 0 0 / 0.4); min-width: 160px; } .ds-chart-tooltip__label { font-size: 12px; color: oklch(0.68 0.005 250); } .ds-chart-tooltip__row { display: grid; grid-template-columns: 10px 1fr auto; align-items: center; gap: 8px; } .ds-chart-tooltip__swatch { width: 10px; height: 10px; border-radius: 2px; } .ds-chart-tooltip__name { font-size: 13px; } .ds-chart-tooltip__value { font-family: 'Geist Mono', ui-monospace, 'SF Mono', Menlo, monospace; font-feature-settings: 'tnum'; font-size: 13px; font-weight: 500; }" + } + ], + "narrative": { + "northStar": "The Coach's Terminal", + "overview": "Parsertime sits next to a coach at 10pm ET, after the scrim block ends, on a 27\" monitor. It is a working surface, not a marketing surface — the data is the product, and every interface decision serves a coach who is reading numbers fast, comparing players across weeks, and deciding what next practice should focus on. The closest reference is a Bloomberg terminal in temperament: cool neutrals carry the UI, warm signal lands on the things that matter, and the system never gets visually tiring across hours of use. Linear's tuning, Vercel's restraint, PlanetScale's data calm.\n\nThe system is achromatic-first with a single amber accent. Neutrals are very slightly cool-tinted (hue ~250°, chroma 0.003–0.005); the coolness is imperceptible as 'blue' but it makes amber read as deliberately gold, not mustard. Amber is the brand identity, but the specific lightness shifts per mode for contrast: deep amber oklch(0.55 0.17 68) + near-white foreground in light mode, bright amber oklch(0.82 0.17 78) + near-black foreground in dark mode. Both still read as amber; both clear WCAG AA when used as text on the page background. Amber is reserved for selected, active, and primary CTA states. Hover gets a subtler treatment so amber stays meaningful for 'this is the chosen one.' Team identity, win/loss deltas, and chart series carry the rest of the chromatic weight.\n\nThis system explicitly rejects the gamer-aesthetic AI palette (cyan-on-near-black, purple-to-blue gradients, neon accents on dark). It rejects identical icon-heading-blurb dashboard bento grids, sparklines as decoration, glow/gradient hero cards, gradient text, and side-stripe accent borders. Caps + mono + tracked-out is the metadata layer for column headers and taxonomy tags — never the default voice.", + "keyCharacteristics": [ + "Achromatic-first with one signal hue (amber oklch(0.82 0.17 78))", + "Cool-tinted neutrals (hue 250°, chroma 0.003–0.005)", + "Dark by default, light fully first-class", + "Switzer for content, Geist Mono for labels and tabular numerals", + "Density tuned for desktop; mobile is for quick lookups, not the design target", + "Charts are first-class UI, with color-blind-safe team encoding as a hard requirement", + "Motion is functional only — view transitions, state changes, reduced-motion honored" + ], + "rules": [ + { + "name": "The Amber-as-Signal Rule", + "body": "Amber is for primary CTAs, focus rings, and selected/active state — and nothing else. Hover state must use a subtler treatment (slightly brighter border, slight surface lift). If hover and selected both turn amber, 'selected' loses its signal value and every card reads as winning on mouseover.", + "section": "colors" + }, + { + "name": "The Mode-Aware Amber Rule", + "body": "Light mode uses deep amber (oklch(0.55 0.17 68) + near-white foreground); dark mode uses bright amber (oklch(0.82 0.17 78) + near-black foreground). Both clear WCAG AA when used as text on the page background. Don't reach for the dark-mode amber in light mode — it fails contrast on text-primary against bg-background and the active nav item disappears.", + "section": "colors" + }, + { + "name": "The Cool-Neutral, Warm-Signal Rule", + "body": "Neutrals carry hue ~250°, chroma 0.003–0.005. Amber lands at hue ~68–78°, chroma 0.17. The contrast between cool ground and warm figure is the Bloomberg-terminal move — it is what makes the accent read as gold.", + "section": "colors" + }, + { + "name": "The Tooltip Surface Rule", + "body": "Chart tooltips render on --popover neutral surfaces with a border, never on --primary amber. Amber backgrounds wreck contrast for team-1 cyan and team-2 red-orange data text inside them.", + "section": "colors" + }, + { + "name": "The Team-Token Rule", + "body": "Any chart comparing entities (teams, heroes, players) uses --team-* tokens with the active colorblind variant honored. The chart-1..5 ramp is for categorical metrics without inherent entity identity.", + "section": "colors" + }, + { + "name": "The Describing-vs.-Doing Rule", + "body": "Caps + Geist Mono + tracked-out is for text that labels, describes, or taxonomizes. Sentence case in Switzer is for text that is content or asks the user to act. Caps everywhere is shouty and AI-templated; caps nowhere collapses metadata into content.", + "section": "typography" + }, + { + "name": "The Tabular-Numeral Rule", + "body": "Any number that compares to another number renders in Geist Mono with font-feature-settings: 'tnum'. Mixed proportional digits in stat columns is the kind of small unprofessionalism that disqualifies a tool from a coaching workflow.", + "section": "typography" + }, + { + "name": "The No-Mono-As-Vibe Rule", + "body": "Geist Mono is functional — labels, numerals, IDs, code. It is not used to make body prose look 'technical,' and it is not used on CTAs.", + "section": "typography" + }, + { + "name": "The Flat-By-Default Rule", + "body": "Surfaces are flat at rest. Depth is conveyed by tonal layering and a 1px ring at low contrast. A shadow only appears on shadow-xs cards and on floating menus/popovers/dialogs at the framework level — never as a decorative lift on landing-page hero cards.", + "section": "elevation" + }, + { + "name": "The Ring-Over-Shadow Rule", + "body": "In dark mode, cards use ring-1 ring-foreground/10 instead of a shadow because shadows over a oklch(0.14 ...) background are invisible. The ring uses a translucent white so it picks up the surface tint correctly when stacked.", + "section": "elevation" + } + ], + "dos": [ + "Do keep amber for primary CTAs, focus rings, and selected/active state. Light mode: deep amber oklch(0.55 0.17 68) + near-white foreground oklch(0.985 0.003 250). Dark mode: bright amber oklch(0.82 0.17 78) + near-black foreground oklch(0.185 0.02 80). Both clear WCAG AA when used as text on the page background.", + "Do render comparable numbers in Geist Mono with font-feature-settings: 'tnum'. Stat columns must align on the digit.", + "Do use caps + Geist Mono + tracked-out (0.06em) for the metadata layer (column headers, eyebrows, taxonomy tags) — and only that layer. Buttons, pills, CTAs, page titles, hero names stay in sentence-case Switzer.", + "Do use --team-* tokens for entity-identity charts (teams, heroes, players). Honor the user's active colorblind variant (off / deuteranopia / tritanopia / protanopia).", + "Do convey card depth through tonal layering plus ring-1 ring-foreground/10. Use shadow-xs for the soft lift; reserve real shadows for popovers and dialogs.", + "Do gate every keyframe and view transition behind prefers-reduced-motion: reduce. Reduced motion gets instant state changes.", + "Do render chart tooltips on --popover surfaces with a border. Body Switzer for category labels, Geist Mono tnum for values." + ], + "donts": [ + "Don't use cyan-on-near-black, neon-on-dark, or purple-to-blue gradients. That is the AI-tool palette; this is a coach's terminal.", + "Don't put amber on hover state. Hover uses a subtler treatment (slightly brighter border, surface lift). Amber must stay reserved for 'selected' so it carries signal.", + "Don't use the dark-mode amber oklch(0.82 0.17 78) as a text color in light mode. It fails WCAG AA against the near-white page background and the active nav item disappears. Use the deep light-mode amber oklch(0.55 0.17 68) (which is what --primary resolves to in light mode) instead.", + "Don't use side-stripe accent borders (border-left greater than 1px as a colored stripe) on cards, list rows, callouts, or alerts as a generic chrome treatment. Exception: an amber border-l-4 border-primary/50 stripe on testimonial blockquotes and emphasis blocks is welcome — it reads as an intentional quote indicator.", + "Don't use gradient text (background-clip: text plus a gradient background). Emphasis comes from weight or size, not from a decorative gradient.", + "Don't reach for glass / backdrop-blur as a default decorative trick. Rare and purposeful, or nothing.", + "Don't ship sparklines as decoration. A chart in this product must carry real signal at the size it is drawn — if it doesn't, cut it.", + "Don't use the identical-icon-heading-blurb dashboard bento grid pattern. It's the AI dashboard cliché and reads as templated.", + "Don't put caps on buttons, pills, CTAs, hero names, or page titles. Caps belongs on the metadata layer; everywhere else, sentence case.", + "Don't put chart tooltips on amber backgrounds. Team-1 cyan and team-2 red-orange data text wrecks against --primary.", + "Don't use Geist Mono on prose to make text look 'technical.' It is for labels, numerals, IDs, and code.", + "Don't add purple-era violet accents. The marketing aurora hero and per-team gradient orbs are the last remnants — they get retuned in the polish pass before launch.", + "Don't use em dashes or -- in copy. Use commas, colons, semicolons, periods, or parentheses." + ] + } +} diff --git a/DESIGN.md b/DESIGN.md new file mode 100644 index 000000000..234471e5a --- /dev/null +++ b/DESIGN.md @@ -0,0 +1,340 @@ +--- +name: Parsertime +description: Coach's terminal for Overwatch 2 scrim analytics — achromatic-first, amber-as-signal, dense and tuned for late-night desktop sessions. +colors: + background-light: "oklch(0.985 0.003 250)" + background-dark: "oklch(0.14 0.003 250)" + foreground-light: "oklch(0.185 0.005 250)" + foreground-dark: "oklch(0.98 0.002 250)" + card-light: "oklch(0.998 0.002 250)" + card-dark: "oklch(0.175 0.004 250)" + popover-light: "oklch(0.998 0.002 250)" + popover-dark: "oklch(0.175 0.004 250)" + muted-light: "oklch(0.965 0.003 250)" + muted-dark: "oklch(0.22 0.004 250)" + muted-foreground-light: "oklch(0.5 0.005 250)" + muted-foreground-dark: "oklch(0.68 0.005 250)" + border-light: "oklch(0.915 0.004 250)" + border-dark: "oklch(1 0 0 / 10%)" + primary-light: "oklch(0.55 0.17 68)" + primary-dark: "oklch(0.82 0.17 78)" + primary-foreground-light: "oklch(0.985 0.003 250)" + primary-foreground-dark: "oklch(0.185 0.02 80)" + destructive-light: "oklch(0.58 0.22 27)" + destructive-dark: "oklch(0.65 0.22 27)" + chart-1-light: "oklch(0.55 0.17 68)" + chart-1-dark: "oklch(0.82 0.17 78)" + chart-2-light: "oklch(0.32 0.09 58)" + chart-2-dark: "oklch(0.48 0.11 68)" + chart-3-light: "oklch(0.72 0.16 78)" + chart-3-dark: "oklch(0.92 0.1 88)" + chart-4-light: "oklch(0.42 0.12 62)" + chart-4-dark: "oklch(0.65 0.17 72)" + chart-5-light: "oklch(0.22 0.04 50)" + chart-5-dark: "oklch(0.35 0.06 55)" + team-1-off: "oklch(0.753 0.153965 232.1809)" + team-2-off: "oklch(0.6218 0.224 17.51)" + team-1-deuteranopia: "oklch(0.6074 0.1504 244.22)" + team-2-deuteranopia: "oklch(0.8268 0.0775 352.56)" + team-1-tritanopia: "oklch(0.7243 0.2464 142.5)" + team-2-tritanopia: "oklch(0.6781 0.3168 324.84)" + team-1-protanopia: "oklch(0.6921 0.1347 227.9)" + team-2-protanopia: "oklch(0.7307 0.1638 346.28)" +typography: + display: + fontFamily: "Switzer, ui-sans-serif, system-ui, sans-serif" + fontSize: "1.875rem" + fontWeight: 700 + lineHeight: 1.15 + letterSpacing: "-0.01em" + headline: + fontFamily: "Switzer, ui-sans-serif, system-ui, sans-serif" + fontSize: "1.5rem" + fontWeight: 700 + lineHeight: 1.2 + letterSpacing: "-0.005em" + title: + fontFamily: "Switzer, ui-sans-serif, system-ui, sans-serif" + fontSize: "1.125rem" + fontWeight: 600 + lineHeight: 1.3 + letterSpacing: "normal" + body: + fontFamily: "Switzer, ui-sans-serif, system-ui, sans-serif" + fontSize: "0.875rem" + fontWeight: 400 + lineHeight: 1.5 + letterSpacing: "normal" + label: + fontFamily: "Geist Mono, ui-monospace, SF Mono, Menlo, monospace" + fontSize: "0.75rem" + fontWeight: 500 + lineHeight: 1.2 + letterSpacing: "0.06em" + numeral: + fontFamily: "Geist Mono, ui-monospace, SF Mono, Menlo, monospace" + fontSize: "0.875rem" + fontWeight: 500 + lineHeight: 1.4 + letterSpacing: "normal" + fontFeature: "tnum" +rounded: + sm: "6px" + md: "8px" + lg: "10px" + xl: "14px" +spacing: + xs: "4px" + sm: "8px" + md: "12px" + lg: "16px" + xl: "24px" + 2xl: "32px" +components: + button-primary: + backgroundColor: "{colors.primary-dark}" + textColor: "{colors.primary-foreground-dark}" + rounded: "{rounded.md}" + padding: "0 10px" + height: "36px" + button-primary-hover: + backgroundColor: "{colors.primary-dark}" + textColor: "{colors.primary-foreground-dark}" + rounded: "{rounded.md}" + padding: "0 10px" + height: "36px" + button-outline: + backgroundColor: "{colors.background-dark}" + textColor: "{colors.foreground-dark}" + rounded: "{rounded.md}" + padding: "0 10px" + height: "36px" + button-secondary: + backgroundColor: "{colors.muted-dark}" + textColor: "{colors.foreground-dark}" + rounded: "{rounded.md}" + padding: "0 10px" + height: "36px" + button-ghost: + backgroundColor: "transparent" + textColor: "{colors.foreground-dark}" + rounded: "{rounded.md}" + padding: "0 10px" + height: "36px" + button-destructive: + backgroundColor: "oklch(0.65 0.22 27 / 20%)" + textColor: "{colors.destructive-dark}" + rounded: "{rounded.md}" + padding: "0 10px" + height: "36px" + card: + backgroundColor: "{colors.card-dark}" + textColor: "{colors.foreground-dark}" + rounded: "{rounded.xl}" + padding: "24px" + input: + backgroundColor: "transparent" + textColor: "{colors.foreground-dark}" + rounded: "{rounded.md}" + padding: "4px 10px" + height: "36px" + badge-default: + backgroundColor: "{colors.muted-dark}" + textColor: "{colors.foreground-dark}" + rounded: "{rounded.sm}" + padding: "2px 8px" + height: "20px" +--- + +# Design System: Parsertime + +## 1. Overview + +**Creative North Star: "The Coach's Terminal"** + +Parsertime sits next to a coach at 10pm ET, after the scrim block ends, on a 27" monitor. It is a working surface, not a marketing surface — the data is the product, and every interface decision serves a coach who is reading numbers fast, comparing players across weeks, and deciding what next practice should focus on. The closest reference is a Bloomberg terminal in temperament: cool neutrals carry the UI, warm signal lands on the things that matter, and the system never gets visually tiring across hours of use. Linear's tuning, Vercel's restraint, PlanetScale's data calm. + +The system is **achromatic-first with a single amber accent**. Neutrals are very slightly cool-tinted (hue ~250°, chroma 0.003–0.005); the coolness is imperceptible as "blue" but it makes amber read as deliberately _gold_, not _mustard_. Amber is the brand identity, but the **specific lightness shifts per mode for contrast**: a deep amber `oklch(0.55 0.17 68)` paired with near-white foreground in light mode, a bright amber `oklch(0.82 0.17 78)` paired with near-black foreground in dark mode. Both still read as amber; both clear WCAG AA when the token is used as text on the page background — which is the active-nav case the original mode-independent doctrine missed. Amber is reserved for **selected, active, and primary CTA** states. Hover gets a subtler treatment so amber stays meaningful for "this is the chosen one." Team identity, win/loss deltas, and chart series carry the rest of the chromatic weight. + +This system explicitly rejects the gamer-aesthetic AI palette (cyan-on-near-black, purple-to-blue gradients, neon accents on dark). It rejects identical icon-heading-blurb dashboard bento grids, sparklines as decoration, glow/gradient hero cards, gradient text, and side-stripe accent borders. Caps + mono + tracked-out is the metadata layer for column headers and taxonomy tags — never the default voice. + +**Key Characteristics:** + +- Achromatic-first with one signal hue (amber `oklch(0.82 0.17 78)`) +- Cool-tinted neutrals (hue 250°, chroma 0.003–0.005) +- Dark by default, light fully first-class +- Switzer for content, Geist Mono for labels and tabular numerals +- Density tuned for desktop; mobile is for quick lookups, not the design target +- Charts are first-class UI, with color-blind-safe team encoding as a hard requirement +- Motion is functional only — view transitions, state changes, reduced-motion honored + +## 2. Colors + +A cool-tinted achromatic spine with one warm amber signal hue. Team-identity and chart colors carry the rest of the chromatic weight; the brand hue is reserved for user intent and brand moments. + +### Primary + +- **Signal Amber, light mode** (`oklch(0.55 0.17 68)`): Primary CTAs, focus rings, selected/active state (open tab, highlighted card, active nav item), and brand moments on marketing. Paired with near-white foreground `oklch(0.985 0.003 250)`. Deep enough to clear WCAG AA when used as text on the near-white page background — that's the active-nav case (`text-primary` on a `bg-background` surface) the dark-mode doctrine alone could not cover. +- **Signal Amber, dark mode** (`oklch(0.82 0.17 78)`): Same role, brighter pairing. Paired with near-black foreground `oklch(0.185 0.02 80)`. The brightness keeps amber readable as text on the deep `oklch(0.14 ...)` page and gives buttons a confident gold lift. + +The amber identity is shared across modes — same hue family, same role, same place in the system. The lightness shifts (0.55 vs 0.82) are the price of clearing contrast in both themes; the token is no longer mode-independent. + +### Tertiary (chart ramp) + +The amber-family chart ramp lives entirely in the brand hue but uses **wide lightness intervals** so adjacent slices in a binary pie are clearly distinguishable. A tight monochromatic ramp failed; a non-amber gray looked dead; multi-hue palettes felt candy-ish. The ordering below maximizes contrast for the common 2-series case (binary pies, active/inactive splits). + +- **Hero Amber** (`chart-1`, `oklch(0.55 0.17 68)` light / `oklch(0.82 0.17 78)` dark): Matches `--primary`. The hero shade. +- **Deep Bronze** (`chart-2`, `oklch(0.32 0.09 58)` light / `oklch(0.48 0.11 68)` dark): A large lightness jump from chart-1 — same family, opposite end of the scale — so binary pies read instantly. +- **Pale Cream Amber** (`chart-3`, `oklch(0.72 0.16 78)` light / `oklch(0.92 0.1 88)` dark): The "third direction" shade. +- **Honey Pumpkin** (`chart-4`, `oklch(0.42 0.12 62)` light / `oklch(0.65 0.17 72)` dark): Fills the gap between chart-1 and chart-2. +- **Dark Umber** (`chart-5`, `oklch(0.22 0.04 50)` light / `oklch(0.35 0.06 55)` dark): Near the contrast floor, for deep backgrounds or rare 5th categories. + +### Neutral + +The cool tint (~250° hue, 0.003–0.005 chroma) is imperceptible as blue but flips amber from mustard to gold by contrast. Pure grays would flatten the system; warm-tinted neutrals would tip it into sepia. + +- **Page Background** (light `oklch(0.985 0.003 250)` / dark `oklch(0.14 0.003 250)`): Body surface. Dark mode runs deep so cards lift cleanly off it. +- **Card Surface** (light `oklch(0.998 0.002 250)` / dark `oklch(0.175 0.004 250)`): Card / popover background. In dark mode, sits one step lighter than the page so layering works without shadows. +- **Foreground** (light `oklch(0.185 0.005 250)` / dark `oklch(0.98 0.002 250)`): Body text and primary content. +- **Muted Foreground** (light `oklch(0.5 0.005 250)` / dark `oklch(0.68 0.005 250)`): Secondary text, descriptions, axis labels. +- **Border** (light `oklch(0.915 0.004 250)` / dark `oklch(1 0 0 / 10%)`): Card rings, dividers, input strokes. Dark mode uses a translucent white so borders pick up the surface tint correctly when stacked. + +### Team Identity (semantic) + +Load-bearing — team identity is a data primitive in this product. Each team token has off / deuteranopia / tritanopia / protanopia variants, and the active variant is a user-level preference. Charts that compare entities (teams, heroes, players) must use these tokens or an explicit per-entity palette, never the chart-1..5 ramp. + +- **Team 1** (default `oklch(0.753 0.154 232)` — cyan-leaning): Home team identity. +- **Team 2** (default `oklch(0.622 0.224 17.5)` — red-orange): Opposing team identity. + +### Named Rules + +**The Amber-as-Signal Rule.** Amber is for primary CTAs, focus rings, and selected/active state — and nothing else. Hover state must use a subtler treatment (slightly brighter border, slight surface lift). If hover and selected both turn amber, "selected" loses its signal value and every card reads as winning on mouseover. + +**The Mode-Aware Amber Rule.** Light mode uses deep amber (`oklch(0.55 0.17 68)` + near-white foreground); dark mode uses bright amber (`oklch(0.82 0.17 78)` + near-black foreground). Both clear WCAG AA when used as text on the page background. **Don't reach for the dark-mode amber in light mode just because it looks more "branded" — it fails contrast on `text-primary` against `bg-background` and the active nav item disappears.** + +**The Cool-Neutral, Warm-Signal Rule.** Neutrals carry hue ~250°, chroma 0.003–0.005. Amber lands at hue ~68–78°, chroma 0.17. The contrast between cool ground and warm figure is the Bloomberg-terminal move — it is what makes the accent read as gold. + +**The Tooltip Surface Rule.** Chart tooltips render on `--popover` neutral surfaces with a border, never on `--primary` amber. Amber backgrounds wreck contrast for team-1 cyan and team-2 red-orange data text inside them. + +**The Team-Token Rule.** Any chart comparing entities (teams, heroes, players) uses `--team-*` tokens with the active colorblind variant honored. The chart-1..5 ramp is for categorical metrics without inherent entity identity. + +## 3. Typography + +**Display Font:** Switzer (Indian Type Foundry / Pangram Pangram, OFL). Loaded via `next/font/local` as a single variable woff2, weight axis `100 900`, with a matching italic. Fallback: `ui-sans-serif, system-ui, -apple-system, sans-serif`. +**Body Font:** Switzer, same family. +**Label / Mono Font:** Geist Mono (Vercel, loaded via `next/font/google`). Fallback: `ui-monospace, "SF Mono", Menlo, monospace`. + +**Character.** Switzer is a modern grotesk with geometric precision and slightly sharper terminals than Geist — reads neutral at body sizes, picks up a confident edge at headlines. The variable axis lets us reach unusual weights (350, 550, 650) without loading extra files, which gives the hierarchy more room to breathe than the prior 400/700-only pairing. Geist Mono is retained as the metadata-and-numeral face; it is the half of the system that already does the trader-terminal lift on tabular figures, IDs, and caps-and-tracked labels, and it is never used as "technical vibes" decoration on prose. + +### Hierarchy + +- **Display** (700, `1.875rem` / 30px, line-height 1.15, tracking -0.01em): Marketing hero titles and major page headlines on long-form surfaces. +- **Headline** (700, `1.5rem` / 24px, line-height 1.2): Page titles inside the product. The default `h1` for app pages. +- **Title** (600, `1.125rem` / 18px, line-height 1.3): Card titles, section labels, dialog headings. The default `h2` / `h3` weight in product UI. +- **Body** (400, `0.875rem` / 14px, line-height 1.5): All running content. Cap measure at 65–75ch on long-form prose; product surfaces ignore measure since rows are typically wider than copy. +- **Label** (Geist Mono, 500, `0.75rem` / 12px, line-height 1.2, tracking 0.06em, **UPPERCASE**): Column headers, page eyebrows, stat ribbon labels, taxonomy tags ("MAIN SUPPORT", "TANK"), affordance labels ("SORT", "FILTER"). The metadata layer that recedes so content sits on top. +- **Numeral** (Geist Mono, 500, `0.875rem` / 14px, `font-feature-settings: "tnum"`): All comparable numbers — leaderboards, stat columns, deltas, win rates, ratings. Tabular figures align on the digit so analysts can scan columns at a glance. + +### Named Rules + +**The Describing-vs.-Doing Rule.** Caps + Geist Mono + tracked-out is for text that _labels, describes, or taxonomizes_: page eyebrows, column headers, role tags, "Sort"/"Filter" affordance labels. Sentence case in Switzer is for text that _is content_ or _asks the user to act_: hero names, page titles, prose, button labels, dropdown items, CTAs. Caps everywhere is shouty and AI-templated; caps nowhere collapses metadata into content. + +**The Tabular-Numeral Rule.** Any number that compares to another number renders in Geist Mono with `font-feature-settings: "tnum"`. Mixed proportional digits in stat columns is the kind of small unprofessionalism that disqualifies a tool from a coaching workflow. + +**The No-Mono-As-Vibe Rule.** Geist Mono is functional — labels, numerals, IDs, code. It is not used to make body prose look "technical," and it is not used on CTAs. + +## 4. Elevation + +The system is **flat-by-default with surface-tonal layering**. Cards lift off the page through a one-step lighter surface tone (and a 1-pixel ring at 10% white in dark mode), not a shadow. The single shadow vocabulary in use is `shadow-xs` — a barely-there shadow that signals "this is a card, not a panel" without introducing visual noise. + +Depth in this product is conveyed by: tonal layer (page → card → popover), border/ring (1px, very low contrast), and the active-state amber accent. Drop shadows are a polish tool for popovers and floating menus, never a generalized "depth" trick. + +### Shadow Vocabulary + +- **xs** (`box-shadow: 0 1px 2px 0 oklch(0 0 0 / 0.05)`): The default card shadow, plus inputs and outline buttons. Almost imperceptible — its job is to nudge the eye that a surface is liftable, not to create depth theater. + +### Named Rules + +**The Flat-By-Default Rule.** Surfaces are flat at rest. Depth is conveyed by tonal layering and a 1px ring at low contrast. A shadow only appears on `shadow-xs` cards and on floating menus/popovers/dialogs at the framework level — never as a decorative lift on landing-page hero cards. + +**The Ring-Over-Shadow Rule.** In dark mode, cards use `ring-1 ring-foreground/10` instead of a shadow because shadows over a `oklch(0.14 ...)` background are invisible. The ring uses a translucent white so it picks up the surface tint correctly when stacked. + +## 5. Components + +### Buttons + +- **Shape:** `rounded-md` corners (8px). Default size 36px tall, x-padding 10px, x-small 24px, small 32px, large 40px. The radius narrows by 2px at smaller sizes (`rounded-[min(var(--radius-md),10px)]` for `sm`, `8px` for `xs`) to keep optical roundness consistent. +- **Primary:** `bg-primary text-primary-foreground` — bright amber on near-black in dark mode, deep amber on near-white in light mode. Hover dims to 80% opacity (`hover:bg-primary/80`); selected/expanded state stays at full amber. +- **Outline:** Transparent background with `border-border`; hover swaps to `bg-muted text-foreground`. In dark mode, the outline button picks up `bg-input/30` for a subtle filled feel. `aria-expanded:true` matches the hover surface so dropdown triggers stay anchored when open. +- **Secondary:** `bg-secondary text-secondary-foreground` — a cool-tinted neutral surface, hover dims to 80%. Used for non-primary actions inside cards. +- **Ghost:** Fully transparent, picks up `bg-muted` on hover. The default for icon-only buttons inside dense toolbars. +- **Destructive:** `bg-destructive/10 text-destructive` — red-tinted soft fill, hover lifts to `/20`. The destructive-foreground constant is reserved for the destructive-on-destructive case (full color background); the soft fill is the dominant variant. +- **Link:** `text-primary` underline-on-hover. Inline only; never used for primary actions. +- **Focus ring:** `ring-[3px] ring-ring/50 border-ring` on `focus-visible`. Amber ring with 50% opacity, 3px wide, tied to the `--ring` token (which equals `--primary`). +- **Icon sizing:** Auto-sized SVG (`size-4` default, `size-3` at xs). Icon-only variants use the matching square size. + +### Cards + +- **Corner Style:** `rounded-xl` (14px). +- **Background:** `bg-card` — one step lighter than the page in dark mode, near-white in light. The card never shares the page background. +- **Shadow Strategy:** `shadow-xs` plus `ring-1 ring-foreground/10`. The ring carries depth in dark mode where `shadow-xs` is invisible; the shadow carries depth in light mode where the ring is faint. +- **Border:** No outer border distinct from the ring. +- **Internal Padding:** `py-6` default, `py-4` at `data-size=sm`. Header/content/footer all get `px-6` (`px-4` sm). Internal stack uses `gap-6` (`gap-4` sm) — generous in product UI by exception, since the card title is the main hierarchy mark inside a dense surface. +- **Image edge:** `*:[img:first-child]:rounded-t-xl *:[img:last-child]:rounded-b-xl` — full-bleed images at the top or bottom of a card stay flush with the card's corner radius. + +### Inputs / Fields + +- **Style:** `border-input` 1px stroke, transparent background in light, `bg-input/30` in dark. `rounded-md` (8px), 36px tall, `px-2.5 py-1`. `text-base` mobile / `text-sm` desktop so iOS doesn't auto-zoom on focus. +- **Focus:** `border-ring` swap and `ring-[3px] ring-ring/50` — the same amber 3px ring buttons use. No glow, no animation; the ring snaps in instantly. +- **Error / Invalid:** `aria-invalid:border-destructive` plus `aria-invalid:ring-destructive/20` (40% in dark). The error state is the same pattern as focus, just in destructive red. +- **Disabled:** `disabled:opacity-50 disabled:pointer-events-none`. No background change — the opacity drop is sufficient signal. + +### Badges / Chips + +- **Style:** `rounded-sm` (6px), 20px tall, `px-2 py-0.5`, `text-xs` Switzer. Filled variant uses `bg-secondary` (or `bg-muted`) with `text-secondary-foreground`. Outlined variant uses `border` with transparent background. +- **State:** Active/selected badges shift to `bg-primary text-primary-foreground` — the same amber-as-signal rule as buttons. + +### Navigation + +- **Style:** Sidebar uses `bg-sidebar` (slightly lighter than `bg-background` in dark mode, slightly lighter than `bg-card` in light). Active nav item picks up `bg-sidebar-accent` and `text-sidebar-accent-foreground`; the active-active state (current route) gets the amber `bg-sidebar-primary` token. +- **Typography:** Body Switzer for nav labels — caps treatment is reserved for sidebar group headers (the metadata layer). +- **Mobile:** Sidebar collapses behind a sheet trigger; on phones we surface a bottom-anchored navigation strip with the same amber-as-active rule. + +### Charts + +- **Library:** Recharts, restyled. Defaults are a starting point, not a destination. +- **Tooltip:** Always `bg-popover` with `border-border`, `rounded-md`, body Switzer for category labels, Geist Mono with `tnum` for values. Never amber background — see the Tooltip Surface Rule. +- **Color encoding:** Categorical metrics use the `chart-1..5` ramp. Entity comparisons (teams, heroes, players) use `--team-*` tokens with the active colorblind variant honored. Wins/losses use destructive red and primary amber respectively. +- **Axes / labels:** Geist Mono caps for axis labels, body Switzer for legend entries. Gridlines pick up `border-border` at 30–40% opacity. + +### View Transitions (signature) + +The product runs `view-transition-name` on persistent surfaces (site header, the active map card transitioning to a map detail page). Three transition styles in use: `fade-in`/`fade-out` (default), `slide-up`/`slide-down` (vertical navigation), `nav-forward`/`nav-back` (horizontal directional navigation), and `expand-map`/`contract-map` (the map card → map page flourish). Timing tokens: `--duration-exit: 150ms`, `--duration-enter: 210ms`, `--duration-move: 400ms`. Reduced motion zeroes all three. + +## 6. Do's and Don'ts + +### Do: + +- **Do** keep amber for primary CTAs, focus rings, and selected/active state. Light mode: deep amber `oklch(0.55 0.17 68)` + near-white foreground `oklch(0.985 0.003 250)`. Dark mode: bright amber `oklch(0.82 0.17 78)` + near-black foreground `oklch(0.185 0.02 80)`. Both clear WCAG AA when the token is used as text on the page background. +- **Do** render comparable numbers in Geist Mono with `font-feature-settings: "tnum"`. Stat columns must align on the digit. +- **Do** use caps + Geist Mono + tracked-out (0.06em) for the metadata layer (column headers, eyebrows, taxonomy tags) — and only that layer. Buttons, pills, CTAs, page titles, hero names stay in sentence-case Switzer. +- **Do** use `--team-*` tokens for entity-identity charts (teams, heroes, players). Honor the user's active colorblind variant (off / deuteranopia / tritanopia / protanopia). +- **Do** convey card depth through tonal layering plus `ring-1 ring-foreground/10`. Use `shadow-xs` for the soft lift; reserve real shadows for popovers and dialogs. +- **Do** gate every keyframe and view transition behind `prefers-reduced-motion: reduce`. Reduced motion gets instant state changes. +- **Do** render chart tooltips on `--popover` surfaces with a border. Body Switzer for category labels, Geist Mono `tnum` for values. + +### Don't: + +- **Don't** use cyan-on-near-black, neon-on-dark, or purple-to-blue gradients. That is the AI-tool palette; this is a coach's terminal. +- **Don't** put amber on hover state. Hover uses a subtler treatment (slightly brighter border, surface lift). Amber must stay reserved for "selected" so it carries signal. +- **Don't** use the dark-mode amber `oklch(0.82 0.17 78)` as a text color in light mode. It fails WCAG AA against the near-white page background and the active nav item disappears. Use the deep light-mode amber `oklch(0.55 0.17 68)` (which is what `--primary` resolves to in light mode) instead. +- **Don't** use side-stripe accent borders (`border-left` greater than 1px as a colored stripe) on cards, list rows, callouts, or alerts as a generic chrome treatment. Use full borders, background tints, or leading numerals instead. **Exception:** an amber `border-l-4 border-primary/50` stripe on testimonial blockquotes and similar emphasis blocks is welcome — it reads as an intentional quote indicator and reinforces brand color, and it overrides the cross-system side-stripe ban for this project. +- **Don't** use gradient text (`background-clip: text` plus a gradient background). Emphasis comes from weight or size, not from a decorative gradient. +- **Don't** reach for glass / backdrop-blur as a default decorative trick. Rare and purposeful, or nothing. +- **Don't** ship sparklines as decoration. A chart in this product must carry real signal at the size it is drawn — if it doesn't, cut it. +- **Don't** use the identical-icon-heading-blurb dashboard bento grid pattern. It's the AI dashboard cliché and reads as templated. +- **Don't** put caps on buttons, pills, CTAs, hero names, or page titles. Caps belongs on the metadata layer; everywhere else, sentence case. +- **Don't** put chart tooltips on amber backgrounds. Team-1 cyan and team-2 red-orange data text wrecks against `--primary`. +- **Don't** use Geist Mono on prose to make text look "technical." It is for labels, numerals, IDs, and code. +- **Don't** add purple-era violet accents (the marketing aurora hero and per-team gradient orbs are the last remnants — they get retuned in the polish pass before launch). +- **Don't** use em dashes or `--` in copy. Use commas, colons, semicolons, periods, or parentheses. diff --git a/PRODUCT.md b/PRODUCT.md new file mode 100644 index 000000000..4c248676b --- /dev/null +++ b/PRODUCT.md @@ -0,0 +1,76 @@ +# Product + +## Register + +product + +## Users + +**Primary audience: coaches and analysts** on collegiate and pro Overwatch 2 teams who use Parsertime to dig into post-scrim data, surface outliers, and build insights they take back to their roster. They are technically literate, comfortable with dense tables, and want signal over hand-holding. + +**Volume audience: players** checking their own scrim stats. They want fast answers ("how did I do?", "where did the team fall apart?") rather than deep exploration. The interface must serve them well without dragging the analyst experience down to a casual level. + +**Secondary buyers: team managers and program directors.** Managers care about organizing data and access; program directors care about season-long ROI and proof of progress. Both interact with the same surfaces as coaches but with broader scope. + +**Use context.** Most sessions happen **late at night on desktop**, immediately after a scrim block ends (~10pm ET). 78% desktop / 22% mobile. Sessions are focused, often on a large monitor, sometimes alongside VOD review on a second screen. Mobile is for quick lookups between things — not the surface to optimize density for. + +**Job to be done.** Turn raw scrim logs into a fast, trustworthy read of team and individual performance, with enough depth that an analyst can find non-obvious patterns and bring them to the next practice. Replace hours of spreadsheet copy-paste with automated dashboards that arrive within minutes of upload. + +## Product Purpose + +Parsertime turns raw Overwatch 2 scrim data into skill ratings, trend lines, and coaching insights. Coaches and players upload Workshop Log data from scrims and get dashboards with per-player stats, hero skill ratings (CSR, a 1–5000 Z-score scale), trend analysis, and team performance breakdowns across eight dimensions (Overview, Performance, Heroes, Trends, Maps, Swaps, Teamfights, Ultimates). + +It exists because the in-game scoreboard is incomplete, replay codes expire, and existing alternatives (Google Sheets, generic esports analytics tools) cost coaches hours of manual data entry per week and still can't show trends or hero-specific skill ratings. Parsertime makes data permanent, instant, and analyzable across any timeframe. + +Success looks like: coaches make practice decisions from data instead of gut feel; players see week-over-week improvement they can act on; programs justify investment with season-long analytics — and all of it happens in the gap between when a scrim ends and when the team starts VOD review. + +## Brand Personality + +Three words: **competitive, analytical, technical.** + +This is a tool for serious competitive teams — including pro orgs — and the interface should feel that way. Closer to a trader's terminal than a community fan site. Information-dense, fast, restrained, confident. Performance-tool energy, not gamer-aesthetic energy. + +It should feel like the kind of software a coach would leave open on a second monitor for hours without it getting visually tiring. + +**Voice.** Conversational, confident, practical — technical enough to be credible but never dense or intimidating. Direct and action-oriented. Lead with what the product does, not abstract concepts. Use gaming-native language ("scrim", "map", "swap"). Short sentences. Built-by-a-player authenticity, no-BS, community-driven. + +**Reference points the product should feel adjacent to:** + +- **Vercel** — confident neutrals, Geist typography, restrained accent use, sharp deltas +- **Linear** — dense without feeling cramped, every interaction tuned, dark surfaces that respect long sessions +- **PlanetScale dashboards** — comfortable data-heavy surfaces, calm color use, charts that read instantly +- **Bloomberg Terminal** — cool neutrals, warm signal, tabular figures, the "describing vs. doing" caps treatment + +## Anti-references + +- **Sparklines as decoration.** Tiny charts that look sophisticated but convey nothing have crept in and we want them out. Charts must carry real signal at the size they're drawn. +- **Generic dashboard bento grids** of identical icon-heading-blurb cards. +- **Glow / gradient hero cards**, gradient text, side-stripe accent borders on cards or list rows. +- **Gamer-aesthetic AI tells**: cyan-on-near-black, neon accents on dark, monospace-as-vibe, dark-with-purple-to-blue-gradients. +- **Caps on everything** (buttons, pill filters, CTAs) — feels shouty and templated. Caps belong only on the metadata/chrome layer. +- **Casual community-fan-site polish.** This is for pro and collegiate teams; the surface should not soften toward a hobbyist UI. +- **AI-powered marketing language**, enterprise jargon, "big data" copy. Not what users say. + +## Design Principles + +1. **Data first, chrome second.** Every pixel must earn its place by carrying information or directly serving a task. Decorative elements (stripes, gradients, glows, ornamental icons, vanity sparklines) need a real reason to exist or they get cut. + +2. **Density over breathing room — for the desktop product.** Analysts want to see more on screen, not less. Tight, intentional spacing; numbers right-aligned with tabular figures; no unnecessary card-wrapping; flatten hierarchy where possible. Marketing surfaces can breathe; the product cannot afford to. + +3. **Restraint with color, brutality with contrast.** Neutrals carry the UI; brand and team colors are reserved for actual signal — wins/losses, deltas, team identity, state changes. When color _does_ appear, it should be confident, not muted-to-the-point-of-invisible. + +4. **Earn every motion.** View transitions, state changes, and feedback are fair game. Decorative animation, parallax, and scroll-driven reveals are not. The user is here to read data fast; motion that delays a read is a regression. + +5. **Charts are first-class.** A chart in this product is a primary UI surface, not an illustration. It needs proper axes, legible labels, color-blind-safe team encoding, accessible tooltips, and density that survives a 27" monitor. Recharts defaults are a starting point, not a destination. + +6. **Desktop-first, mobile honest.** Design and review on desktop at the resolutions analysts actually use. Make mobile genuinely useful for quick lookups (clear hierarchy, key numbers, no horizontal scrolling on stat tables) — but do not amputate features or compress the desktop experience to make a phone happy. + +7. **Describing vs. doing.** Caps + mono + tracked-out is the metadata layer (column headers, eyebrows, taxonomy tags). Sentence case is content and interaction (names, titles, prose, buttons, pills, CTAs). Caps everywhere is shouty and AI-templated; caps nowhere collapses the metadata/content distinction. + +## Accessibility & Inclusion + +- **Team color tokens are non-negotiable.** `--team-1-*` / `--team-2-*` exist in deuteranopia, tritanopia, and protanopia variants. Team identity is a data primitive in this product; color-blind users must be able to read every chart. Preserve and respect those tokens — never swap them for arbitrary colors. +- **Reduced motion is honored.** All view transitions and keyframe animations are gated behind `prefers-reduced-motion`; reduced-motion users get instant state changes with no animation duration. +- **Contrast over decoration.** Neutrals + amber accent are picked so foreground/background pairs clear WCAG AA at the very least; primary buttons use a near-black foreground on amber so the button reads identically in light and dark mode without relying on theme-specific contrast tricks. +- **Keyboard and screen-reader parity.** Every chart tooltip, dropdown, and command surface must be reachable from the keyboard. Charts must carry textual labels alongside the visual encoding so the data is readable without color. +- **Tabular figures everywhere numbers compare.** Stat columns, leaderboards, and timeline tables use Geist Mono with tabular numerals so digits align — readable at a glance for analysts who scan columns. diff --git a/api/wp-train/.gitignore b/api/wp-train/.gitignore new file mode 100644 index 000000000..00f2d38d8 --- /dev/null +++ b/api/wp-train/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +.venv/ diff --git a/api/wp-train/harness.py b/api/wp-train/harness.py new file mode 100644 index 000000000..20412ff2a --- /dev/null +++ b/api/wp-train/harness.py @@ -0,0 +1,186 @@ +# Source of truth: scripts/wp/gbm-prototype/harness.py (deployed copy — keep in sync). +"""Offline harness for the WP GBM bake-off. Ports the TypeScript grouped-CV +fold, metrics, and isotonic calibration so LR and GBM are judged identically +to the shipped pipeline. Not serving code.""" + +import math + +_EPS = 1e-12 +_FIT_BINS = 20 + + +def group_fold(match_id: int, k: int) -> int: + """FNV-1a over the decimal matchId string, mod k — matches cv.ts groupFold. + 32-bit unsigned arithmetic via & 0xFFFFFFFF (mirrors Math.imul + >>> 0).""" + s = str(match_id) + h = 0x811C9DC5 + for ch in s: + h ^= ord(ch) + h = (h * 0x01000193) & 0xFFFFFFFF + return h % k + + +def log_loss(preds, labels): + s = 0.0 + for p, y in zip(preds, labels): + p = min(1 - _EPS, max(_EPS, p)) + s += -math.log(p) if y == 1 else -math.log(1 - p) + return s / len(preds) + + +def brier(preds, labels): + return sum((p - y) ** 2 for p, y in zip(preds, labels)) / len(preds) + + +def fit_calibration(preds, labels): + """20-bin PAVA isotonic; knots = (mean pred, pooled observed rate). + Mirrors calibration.ts fitCalibration exactly.""" + bins = [{"pred": 0.0, "label": 0.0, "n": 0} for _ in range(_FIT_BINS)] + for p, y in zip(preds, labels): + idx = min(_FIT_BINS - 1, int(p * _FIT_BINS)) + bins[idx]["pred"] += p + bins[idx]["label"] += y + bins[idx]["n"] += 1 + blocks = [] + for b in bins: + if b["n"] == 0: + continue + blocks.append(dict(b)) + while len(blocks) >= 2: + last, prev = blocks[-1], blocks[-2] + if prev["label"] / prev["n"] <= last["label"] / last["n"]: + break + prev["pred"] += last["pred"] + prev["label"] += last["label"] + prev["n"] += last["n"] + blocks.pop() + return { + "x": [b["pred"] / b["n"] for b in blocks], + "y": [b["label"] / b["n"] for b in blocks], + } + + +def apply_calibration(cal, p): + x, y = cal["x"], cal["y"] + if not x: + return p + if p <= x[0]: + return y[0] + if p >= x[-1]: + return y[-1] + i = 1 + while x[i] < p: + i += 1 + t = (p - x[i - 1]) / (x[i] - x[i - 1]) + return y[i - 1] + t * (y[i] - y[i - 1]) + + +def calibration_max_deviation(preds, labels, k=10, min_n=200): + """Max |meanPred - meanLabel| over bins with n>=min_n — the gate metric + (metrics.ts checkGates uses k=10 bins, min 200 samples, threshold 0.1).""" + agg = [{"pred": 0.0, "label": 0.0, "n": 0} for _ in range(k)] + for p, y in zip(preds, labels): + idx = min(k - 1, int(p * k)) + agg[idx]["pred"] += p + agg[idx]["label"] += y + agg[idx]["n"] += 1 + worst = 0.0 + for b in agg: + if b["n"] < min_n: + continue + dev = abs(b["pred"] / b["n"] - b["label"] / b["n"]) + worst = max(worst, dev) + return worst + + +import numpy as np +import pandas as pd + +META_COLS = 3 # matchId, roundId, label +BASE_FEATURES = 21 # shipped LR feature count (first 21 columns) + + +def load_matrix(path, n_features=None): + """Returns (match_ids, X, y, feature_names). n_features slices the leading + feature columns (21 -> shipped set; None -> all 123).""" + df = pd.read_csv(path) + feat_cols = list(df.columns[META_COLS:]) + if n_features is not None: + feat_cols = feat_cols[:n_features] + return ( + df["matchId"].to_numpy(), + df[feat_cols].to_numpy(dtype=float), + df["label"].to_numpy(dtype=int), + feat_cols, + ) + + +def grouped_cv_predict(match_ids, X, y, fit_predict, k=5): + """k grouped folds (folds by group_fold(matchId)); fit_predict(X_tr, y_tr, + X_val) -> val probabilities. Returns pooled (preds, labels).""" + folds = np.array([group_fold(int(m), k) for m in match_ids]) + pooled_preds, pooled_labels = [], [] + for f in range(k): + tr, val = folds != f, folds == f + if val.sum() == 0 or tr.sum() == 0: + continue + preds = fit_predict(X[tr], y[tr], X[val]) + pooled_preds.extend(np.asarray(preds).tolist()) + pooled_labels.extend(y[val].tolist()) + return np.array(pooled_preds), np.array(pooled_labels) + + +def calibrated_metrics(preds, labels): + """Calibrate on pooled holdout (as the TS pipeline does) and report.""" + cal = fit_calibration(preds.tolist(), labels.tolist()) + cp = np.array([apply_calibration(cal, float(p)) for p in preds]) + return { + "log_loss": log_loss(cp.tolist(), labels.tolist()), + "brier": brier(cp.tolist(), labels.tolist()), + "cal_max_dev": calibration_max_deviation(cp.tolist(), labels.tolist()), + "n": int(len(labels)), + "calibrated_preds": cp, + } + + +def lr_fit_predict(X_tr, y_tr, X_val): + """Standardized logistic regression — the LR control.""" + from sklearn.preprocessing import StandardScaler + from sklearn.linear_model import LogisticRegression + sc = StandardScaler().fit(X_tr) + clf = LogisticRegression(max_iter=1000, C=1.0).fit(sc.transform(X_tr), y_tr) + return clf.predict_proba(sc.transform(X_val))[:, 1] + + +SHIPPED_LR = { # corrected LR (model-v2) calibrated CV log loss, for trust check + "control": 0.6180, + "escort_hybrid": 0.6635, + "flashpoint": 0.5891, +} +TOLERANCE = 0.006 + + +def self_validate(data_dir="data"): + import os + ok = True + for fam, expected in SHIPPED_LR.items(): + match_ids, X, y, _ = load_matrix( + os.path.join(data_dir, f"dataset-{fam}.csv"), BASE_FEATURES + ) + preds, labels = grouped_cv_predict(match_ids, X, y, lr_fit_predict) + m = calibrated_metrics(preds, labels) + delta = m["log_loss"] - expected + status = "OK" if abs(delta) <= TOLERANCE else "MISMATCH" + if status == "MISMATCH": + ok = False + print(f"[{status}] {fam}: harness LR {m['log_loss']:.4f} " + f"vs shipped {expected:.4f} (delta {delta:+.4f})") + return ok + + +if __name__ == "__main__": + import sys + if not self_validate(): + print("\nHarness LR does not reproduce the shipped LR — comparison untrustworthy.") + sys.exit(1) + print("\nHarness validated.") diff --git a/api/wp-train/index.py b/api/wp-train/index.py new file mode 100644 index 000000000..b71ca0879 --- /dev/null +++ b/api/wp-train/index.py @@ -0,0 +1,186 @@ +"""Vercel Python serverless function: the weekly WP GBM trainer. + +Invoked programmatically by /api/cron/wp-retrain (never by cron directly). The +TS retrain route exports each mode's feature matrix to Vercel Blob and POSTs +{ runId, urls } here. This function: + + 1. Authenticates the Bearer CRON_SECRET (constant-time). + 2. Downloads each mode's CSV from its public blob URL to a temp file. + 3. Trains + gates a per-mode GBM via train_candidate(path) — NO champion/ + challenger here; the candidate family and its gate flag are collected raw. + 4. Assembles the gzipped candidate payload and POSTs it to PUBLISH_URL + (/api/cron/wp-publish), which loads the live R2 incumbent, runs the + per-mode champion/challenger decision, and single-sources the R2 publish. + +This function NEVER writes R2 directly, and never sees the incumbent — the +publish callback owns both the incumbent load and the publish. + +Modes: control, escort_hybrid, flashpoint are trained; push is data-blocked and +always null (matches the shipped per-mode model). +""" +# Required Vercel env vars: +# CRON_SECRET - bearer token; must match the cron/publish routes +# WP_FEATURE_HASH - must equal the TS featureHash() (currently 27b4a8ec1f49) +# PUBLISH_URL - /api/cron/wp-publish +import gzip +import hmac +import json +import os +import tempfile +import traceback +import urllib.request +from http.server import BaseHTTPRequestHandler + +from train_gbm import train_candidate + + +def _bearer(headers): + """Extract the bearer token from an Authorization header, or None.""" + raw = headers.get("Authorization") or headers.get("authorization") + if not raw or not raw.startswith("Bearer "): + return None + return raw[len("Bearer ") :] + + +def _authorized(headers): + """Constant-time compare against CRON_SECRET. Fails closed if unset.""" + expected = os.environ.get("CRON_SECRET") + if not expected: + return False + provided = _bearer(headers) + if provided is None: + return False + return hmac.compare_digest(provided, expected) + + +def _download_csv(url): + """Fetch a public blob URL into a temp .csv file; return its path.""" + fd, path = tempfile.mkstemp(suffix=".csv") + os.close(fd) + with urllib.request.urlopen(url, timeout=120) as resp: # noqa: S310 (trusted blob URL) + data = resp.read() + with open(path, "wb") as f: + f.write(data) + return path + + +def _publish(candidate): + """gzip + POST the candidate payload to the TS publish callback. The + artifact is ~4.4MB raw (near Vercel's 4.5MB body limit), so it must be + compressed. Returns (status_code, body_text).""" + publish_url = os.environ["PUBLISH_URL"] + secret = os.environ["CRON_SECRET"] + body = gzip.compress(json.dumps(candidate).encode("utf-8")) + request = urllib.request.Request( + publish_url, + data=body, + method="POST", + headers={ + "Authorization": f"Bearer {secret}", + "Content-Type": "application/json", + "Content-Encoding": "gzip", + }, + ) + with urllib.request.urlopen(request, timeout=120) as resp: # noqa: S310 + return resp.status, resp.read().decode("utf-8") + + +def _run(payload): + """Train + gate every supplied mode, assemble the candidate payload, publish + it. Champion/challenger runs in the TS publish route, not here. Returns a + JSON-serializable result dict.""" + urls = payload.get("urls") or {} + run_id = payload.get("runId") + + mode_families = { + "control": None, + "escort_hybrid": None, + "push": None, + "flashpoint": None, + } + gates = {} + trained = [] + errors = {} + + for mode, url in urls.items(): + if mode not in mode_families: + print(f"[wp-train] unknown mode {mode!r}; skipping") + continue + if mode == "push": + # push is data-blocked; never trained even if a URL slips through. + continue + try: + path = _download_csv(url) + try: + family, gate = train_candidate(path) + finally: + try: + os.remove(path) + except OSError: + pass + mode_families[mode] = family + gates[mode] = gate + trained.append(mode) + except Exception as exc: # noqa: BLE001 — isolate per-mode failures + errors[mode] = repr(exc) + print(f"[wp-train] mode {mode!r} failed: {exc!r}") + print(traceback.format_exc()) + + if not trained: + # Zero modes trained — nothing safe to publish. + return { + "published": False, + "runId": run_id, + "trained": trained, + "errors": errors, + "reason": "no_modes_trained", + } + + candidate = { + "schemaVersion": 1, + "featureHash": os.environ["WP_FEATURE_HASH"], + "modeFamilies": mode_families, + "gates": gates, + } + + status, text = _publish(candidate) + return { + "published": status == 200, + "publishStatus": status, + "publishBody": text, + "runId": run_id, + "trained": trained, + "errors": errors, + } + + +class handler(BaseHTTPRequestHandler): # noqa: N801 — Vercel requires this name + def _send(self, status, payload): + body = json.dumps(payload).encode("utf-8") + self.send_response(status) + self.send_header("Content-Type", "application/json") + self.send_header("Content-Length", str(len(body))) + self.end_headers() + self.wfile.write(body) + + def do_POST(self): # noqa: N802 — BaseHTTPRequestHandler interface + if not _authorized(self.headers): + self._send(401, {"error": "Unauthorized"}) + return + try: + length = int(self.headers.get("Content-Length") or 0) + raw = self.rfile.read(length) if length else b"{}" + payload = json.loads(raw.decode("utf-8") or "{}") + except (ValueError, json.JSONDecodeError): + self._send(400, {"error": "Invalid JSON body"}) + return + + try: + result = _run(payload) + except Exception as exc: # noqa: BLE001 — never leak a stack to the caller + print(f"[wp-train] run failed: {exc!r}") + print(traceback.format_exc()) + self._send(500, {"error": "Training run failed"}) + return + + self._send(200, result) diff --git a/api/wp-train/pyproject.toml b/api/wp-train/pyproject.toml new file mode 100644 index 000000000..390096a3c --- /dev/null +++ b/api/wp-train/pyproject.toml @@ -0,0 +1,14 @@ +[project] +name = "wp-train" +version = "0.1.0" +description = "Vercel Python serverless function: weekly WP GBM trainer" +requires-python = ">=3.9" +dependencies = [ + "lightgbm>=4.0,<5", + "scikit-learn>=1.3,<2", + # Pin numpy<2: lightgbm/pandas wheels compiled against numpy 1.x crash under + # numpy 2.x ("module compiled using NumPy 1.x cannot be run in NumPy 2.4"). + "numpy>=1.24,<2", + "pandas>=2.0,<3", + "requests>=2.31,<3", +] diff --git a/api/wp-train/serialize.py b/api/wp-train/serialize.py new file mode 100644 index 000000000..6b41afc91 --- /dev/null +++ b/api/wp-train/serialize.py @@ -0,0 +1,57 @@ +# Source of truth: scripts/wp/gbm-prototype/serialize.py (deployed copy — keep in sync). +"""Serialize a trained LightGBM booster into the artifact tree schema the TS +serving path consumes. Raw score = baseScore + sum of reached leaf values; +sigmoid + isotonic are applied downstream. baseScore captures LightGBM's +boost-from-average init offset (a constant), derived empirically and asserted.""" +import numpy as np + + +def _flatten(node, out): + if "leaf_value" in node: + out.append({"leaf": float(node["leaf_value"])}) + return len(out) - 1 + assert node.get("decision_type", "<=") == "<=", "only numeric <= splits supported" + idx = len(out) + out.append(None) # reserve slot before recursing + left = _flatten(node["left_child"], out) + right = _flatten(node["right_child"], out) + out[idx] = { + "feature": int(node["split_feature"]), + "threshold": float(node["threshold"]), + "left": left, + "right": right, + "defaultLeft": bool(node["default_left"]), + } + return idx + + +def _tree_leaf(tree, x): + i = 0 + while True: + n = tree[i] + if "leaf" in n: + return n["leaf"] + v = x[n["feature"]] + if v != v: + i = n["left"] if n["defaultLeft"] else n["right"] + else: + i = n["left"] if v <= n["threshold"] else n["right"] + + +def serialize_booster(booster): + dumped = booster.dump_model() + trees = [] + for ti in dumped["tree_info"]: + nodes = [] + _flatten(ti["tree_structure"], nodes) + trees.append(nodes) + # Derive the constant init offset: raw_score - sum(reached leaves). + n_feat = booster.num_feature() + probe = np.zeros((8, n_feat)) + for j in range(8): + probe[j, j % n_feat] = 1.0 # vary inputs so the offset is exercised + raw = booster.predict(probe, raw_score=True) + offsets = [raw[r] - sum(_tree_leaf(t, probe[r]) for t in trees) for r in range(8)] + base = float(np.mean(offsets)) + assert float(np.std(offsets)) < 1e-9, f"init offset not constant: {offsets}" + return {"trees": trees, "baseScore": base} diff --git a/api/wp-train/train_gbm.py b/api/wp-train/train_gbm.py new file mode 100644 index 000000000..948a80810 --- /dev/null +++ b/api/wp-train/train_gbm.py @@ -0,0 +1,71 @@ +# Source of truth: scripts/wp/gbm-prototype/train_gbm.py (deployed copy — keep in sync). +"""Per-mode GBM trainer: grouped CV (reuses harness), LightGBM fit, isotonic +calibration, gate, champion/challenger vs the live artifact's incumbent. +Produces the chosen family dict. Runs locally and inside the Vercel function.""" +import numpy as np +from lightgbm import LGBMClassifier + +from harness import ( + BASE_FEATURES, load_matrix, grouped_cv_predict, calibrated_metrics, + fit_calibration, +) +from serialize import serialize_booster + +GBM_PARAMS = dict( + n_estimators=400, learning_rate=0.05, num_leaves=31, min_child_samples=200, + subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0, + random_state=42, n_jobs=-1, verbose=-1, +) +CAL_MAX_DEV = 0.10 # gate (matches metrics.ts CALIBRATION_MAX_DEVIATION) + + +def _gbm_fit_predict(X_tr, y_tr, X_val): + return LGBMClassifier(**GBM_PARAMS).fit(X_tr, y_tr).predict_proba(X_val)[:, 1] + + +def gbm_gate_passes(metrics): + """Beat base-rate log loss AND calibration within tolerance (matches checkGates).""" + p = min(1 - 1e-12, max(1e-12, metrics["base_rate"])) + baseline = -(p * np.log(p) + (1 - p) * np.log(1 - p)) + return metrics["log_loss"] < baseline and metrics["cal_max_dev"] <= CAL_MAX_DEV + + +def choose_family(gbm_family, gbm_gate_pass, incumbent): + """Champion/challenger: ship GBM only if gated AND strictly better than the + incumbent's CV log loss; else carry the incumbent forward verbatim.""" + if not gbm_gate_pass: + return incumbent if incumbent is not None else gbm_family + if incumbent is None: + return gbm_family + if gbm_family["metrics"]["logLoss"] < incumbent["metrics"]["logLoss"]: + return gbm_family + return incumbent + + +def train_candidate(path): + """Train one mode's GBM + gate it. Returns (gbm_family_dict, gate_pass_bool). + No champion/challenger here — the TS publish route decides vs the incumbent.""" + match_ids, X, y, _ = load_matrix(path, None) + X = X[:, :BASE_FEATURES] # the 21 shipped features (handles 24- and 126-col CSVs) + preds, labels = grouped_cv_predict(match_ids, X, y, _gbm_fit_predict) + m = calibrated_metrics(preds, labels) + base_rate = float(np.mean(labels)) + gate = gbm_gate_passes({"log_loss": m["log_loss"], "cal_max_dev": m["cal_max_dev"], "base_rate": base_rate}) + booster = LGBMClassifier(**GBM_PARAMS).fit(X, y).booster_ + ser = serialize_booster(booster) + cal = fit_calibration(preds.tolist(), labels.tolist()) + family = { + "kind": "gbm", "trees": ser["trees"], "baseScore": ser["baseScore"], + "sampleCount": int(len(y)), + "calibration": {"x": cal["x"], "y": cal["y"]}, + "metrics": {"logLoss": m["log_loss"], "brier": m["brier"], "baseRate": base_rate}, + } + return family, gate + + +def train_family(path, incumbent): + """Train one mode; return the chosen family dict (gbm or carried incumbent). + Local build path — champion/challenger still runs here against the passed + incumbent (the Vercel function uses train_candidate + the TS publish route).""" + family, gate = train_candidate(path) + return choose_family(family, gate, incumbent) diff --git a/docs/agents/domain.md b/docs/agents/domain.md new file mode 100644 index 000000000..fd720a33b --- /dev/null +++ b/docs/agents/domain.md @@ -0,0 +1,35 @@ +# Domain Docs + +How the engineering skills should consume this repo's domain documentation when exploring the codebase. + +## Before exploring, read these + +- **`CONTEXT.md`** at the repo root. +- **`docs/adr/`** — read ADRs that touch the area you're about to work in. + +If any of these files don't exist, **proceed silently**. Don't flag their absence; don't suggest creating them upfront. The producer skill (`/grill-with-docs`) creates them lazily when terms or decisions actually get resolved. + +## File structure + +This is a single-context repo: + +``` +/ +├── CONTEXT.md +├── docs/adr/ +│ ├── 0001-some-decision.md +│ └── 0002-another-decision.md +└── src/ +``` + +## Use the glossary's vocabulary + +When your output names a domain concept (in an issue title, a refactor proposal, a hypothesis, a test name), use the term as defined in `CONTEXT.md`. Don't drift to synonyms the glossary explicitly avoids. + +If the concept you need isn't in the glossary yet, that's a signal — either you're inventing language the project doesn't use (reconsider) or there's a real gap (note it for `/grill-with-docs`). + +## Flag ADR conflicts + +If your output contradicts an existing ADR, surface it explicitly rather than silently overriding: + +> _Contradicts ADR-0007 (some decision) — but worth reopening because…_ diff --git a/docs/agents/issue-tracker.md b/docs/agents/issue-tracker.md new file mode 100644 index 000000000..f16602dd4 --- /dev/null +++ b/docs/agents/issue-tracker.md @@ -0,0 +1,22 @@ +# Issue tracker: GitHub + +Issues and PRDs for this repo live as GitHub issues at `luxdotdev/parsertime`. Use the `gh` CLI for all operations. + +## Conventions + +- **Create an issue**: `gh issue create --title "..." --body "..."`. Use a heredoc for multi-line bodies. +- **Read an issue**: `gh issue view --comments`, filtering comments by `jq` and also fetching labels. +- **List issues**: `gh issue list --state open --json number,title,body,labels,comments --jq '[.[] | {number, title, body, labels: [.labels[].name], comments: [.comments[].body]}]'` with appropriate `--label` and `--state` filters. +- **Comment on an issue**: `gh issue comment --body "..."` +- **Apply / remove labels**: `gh issue edit --add-label "..."` / `--remove-label "..."` +- **Close**: `gh issue close --comment "..."` + +Infer the repo from `git remote -v` — `gh` does this automatically when run inside a clone. + +## When a skill says "publish to the issue tracker" + +Create a GitHub issue. + +## When a skill says "fetch the relevant ticket" + +Run `gh issue view --comments`. diff --git a/docs/agents/triage-labels.md b/docs/agents/triage-labels.md new file mode 100644 index 000000000..19bbae3f8 --- /dev/null +++ b/docs/agents/triage-labels.md @@ -0,0 +1,15 @@ +# Triage Labels + +The skills speak in terms of five canonical triage roles. This file maps those roles to the actual label strings used in this repo's issue tracker. + +| Label in mattpocock/skills | Label in our tracker | Meaning | +| -------------------------- | -------------------- | ---------------------------------------- | +| `needs-triage` | `needs-triage` | Maintainer needs to evaluate this issue | +| `needs-info` | `needs-info` | Waiting on reporter for more information | +| `ready-for-agent` | `ready-for-agent` | Fully specified, ready for an AFK agent | +| `ready-for-human` | `ready-for-human` | Requires human implementation | +| `wontfix` | `wontfix` | Will not be actioned | + +When a skill mentions a role (e.g. "apply the AFK-ready triage label"), use the corresponding label string from this table. + +`wontfix` already exists on the GitHub repo. The other four don't — create them on first use with `gh label create `. diff --git a/docs/audits/deepsec-remediation-decisions.md b/docs/audits/deepsec-remediation-decisions.md new file mode 100644 index 000000000..a8f69f306 --- /dev/null +++ b/docs/audits/deepsec-remediation-decisions.md @@ -0,0 +1,19 @@ +# DeepSec Remediation Decisions + +Date: 2026-05-28 + +## Decisions + +- Removed the legacy `DEV_TOKEN` bypass instead of preserving a compatibility path. The affected mutation routes now require a real authenticated user and resource-scoped authorization. +- Moved Axiom browser logging to a no-op client transport and kept token-backed Axiom transport server-only. This prevents `AXIOM_TOKEN` exposure; browser web-vitals forwarding can be reintroduced later through a server route that validates and scrubs payloads. +- Changed tournament broadcast data from public cacheable output to authenticated, private, no-store output. Tournament creators, admins, and members/managers/owners of linked tournament teams can view it. +- Rejected completed tournament map winner/deletion changes rather than attempting automatic bracket rollback. Correct rollback semantics need an explicit product design before re-enabling historical edits. +- Enforced one `AppSettings` row per user with a deduping migration and unique index, then switched first-read and update paths to upsert by `userId`. +- Disabled raw Discord role mentions in availability reminders until roles can be bound to a verified guild. Reminder guild/channel overrides are only accepted when they match an existing verified bot notification configuration for that team. +- Accepted self-entered BattleTag matching for TSR attribution as a product tradeoff. Verified BattleTag linking remains difficult with the current data sources, so BattleTags continue to be user-editable identity hints for TSR rather than a security boundary. +- Bound team invite tokens to the intended invitee email instead of preserving bearer-only invite redemption. Existing unredeemed invite links created under the old inviter-email storage may need to be resent. + +## Operational Notes + +- Deployments need `AXIOM_TOKEN` and `AXIOM_DATASET` server environment variables. The old `NEXT_PUBLIC_AXIOM_TOKEN` and `NEXT_PUBLIC_AXIOM_DATASET` names should not be used for secrets. +- The app-settings migration keeps the oldest duplicate row for each user and deletes newer duplicates before creating the unique index. diff --git a/docs/audits/deepsec-remediation-summary.md b/docs/audits/deepsec-remediation-summary.md new file mode 100644 index 000000000..f38571b7f --- /dev/null +++ b/docs/audits/deepsec-remediation-summary.md @@ -0,0 +1,76 @@ +# DeepSec Remediation Summary + +Date: 2026-05-28 + +This document summarizes the security and bug remediation work completed from the DeepSec findings review. Detailed product and operational tradeoffs are captured separately in `docs/audits/deepsec-remediation-decisions.md`. + +## Scope + +- Reviewed the DeepSec findings across `HIGH`, `HIGH_BUG`, `MEDIUM`, and `BUG`. +- Fixed true-positive authorization, cross-tenant access, secret exposure, race condition, and correctness issues. +- Treated stale findings as resolved when the current code already contained the intended mitigation. +- Accepted BattleTag self-entry for TSR attribution as a product tradeoff, not a security boundary. + +## Security Changes + +- Removed legacy development-token bypass behavior from protected mutation paths and made affected routes require authenticated users plus resource-scoped authorization. +- Hardened cron and automation endpoints so scheduled jobs fail closed unless the expected secret is configured and provided. +- Restricted team, scrim, map, stats, tournament, and metadata routes to viewers with explicit access to the underlying resource. +- Bound team invite redemption to the intended invitee email rather than treating invite links as bearer-only capabilities. +- Constrained avatar uploads and map-data lookups to IDs owned by the target team, scrim, or map. +- Prevented pricing-page render from eagerly creating Stripe checkout sessions; checkout and customer portal sessions are now created only through the dedicated route. +- Kept Axiom token-backed logging server-only and removed the browser-exposed secret path. +- Reduced public aggregate leakage by adding thresholds to public hero trend data and bounding chat request/output sizes. +- Added least-privilege `contents: read` permissions to CI workflows. + +## Data Integrity And Race Fixes + +- Serialized tournament map finalization so concurrent map submissions cannot double-advance or corrupt match state. +- Made grand-final reset creation idempotent with a tournament-scoped advisory transaction lock and an in-transaction reset check. +- Serialized team creation quota checks and writes under an advisory transaction lock. +- Hardened admin map-calibration updates with structured validation. +- Fixed Map ID versus MapData ID confusion in map detail and edit flows. +- Allowed empty hero-assignment submissions to clear stale hero assignments instead of silently preserving old values. +- Made TSR recomputation and TSR breakdown logic agree on roster overrides, and made ingestion skip tracked FACEIT championship matches that omit a competition ID instead of throwing. + +## Application Bug Fixes + +- Guarded sparse analytics data so missing optional event rows return stable empty or zero values instead of crashing or persisting invalid calculations. +- Made parser handling tolerant of logs without optional kill sections while preserving player-stat rows. +- Sorted tournament-team match results before computing recent form and streaks. +- Grouped scrim-level ability timing and fight timelines per `MapDataId` so fights and ability windows do not merge across maps. +- Mirrored tournament creation constraints in client and server validation so impossible brackets return validation errors rather than generic failures. +- Fixed stale or incorrect calculations in team stats, hero bans, fight grouping, map charts, record-quality, AJAX leaderboard, overview percentages, and simulator context. +- Removed stale simulator role-trio signals that were no longer backed by current data. +- Fixed account deletion and upload/delete callback flows that could fail or perform work in the wrong order. + +## Decisions + +- BattleTag linking remains user-entered for TSR because verified BattleTag linking is difficult with current data sources and TSR attribution does not rely on BattleTag as an authorization boundary. +- Completed tournament map winner/deletion edits are rejected rather than automatically rolling back bracket state. +- Discord role mentions remain disabled unless tied to a verified bot notification configuration. +- Invite tokens are now email-bound; old unredeemed invite links created under the prior behavior may need to be resent. + +## Verification + +The final remediation batch passed: + +- `./node_modules/.bin/tsc --noEmit` +- `./node_modules/.bin/oxlint` + +The last remediation commits include: + +- `a33cb2ea` Harden analytics and tournament reset edges +- `2774400d` Allow clearing hero assignments +- `e631ade8` Bound public trend and chat usage exposure +- `1107985f` Harden admin calibration and team quota writes +- `8a7955a5` Defer pricing checkout creation +- `52cc528f` Protect team stats metadata +- `36b21527` Enforce team target entitlement +- `15fc7ca3` Require cron secret for invite cleanup +- `8af85aaf` Constrain map data route resolution +- `7d31e0de` Serialize tournament map finalization +- `5fbb986d` Bind team invites to invitee email +- `9cd34d0c` Gate data labeling page to admins +- `c0a34ae3` Fail closed on cron and team auth +- `a100c2a6` Harden read-only CI checkout credentials diff --git a/docs/audits/team-stats-redesign.md b/docs/audits/team-stats-redesign.md new file mode 100644 index 000000000..724c20a95 --- /dev/null +++ b/docs/audits/team-stats-redesign.md @@ -0,0 +1,251 @@ +# Team stats redesign audit + +`/stats/team/[teamId]` reviewed against the new patterns shipping on `/stats/map`, `/stats/hero`, `/leaderboard`, and the recent `/stats/hero/[heroName]` redesign. Captured DSG (team 345, all-time) at 1440×900. Screenshots at `/tmp/team-stats-audit/01–10`. + +## North star + +The page violates the design system's two strongest commitments: + +1. **Restraint with color, brutality with contrast.** Tank/Damage/Support cards, Hybrid/Escort/Control quadrants, Winning/Losing fight panels, and Win-Probability cards all carry **full-bleed colored backgrounds and saturated borders that signal nothing the heading and number don't already say**. Color is chrome, not signal. +2. **Data first, chrome second.** Section headings are sentence-case `` only — no Geist Mono metadata layer. The whole page reads as one continuous wall of cards, none of which are visually privileged. Compare to `/stats/map`, where the eyebrow + 4xl headline + ribbon + filter row carries the entire navigational frame in 64px of vertical space. + +The map-list redesign is the right reference. `MapHeroTrends` (`src/components/stats/map/map-hero-trends.tsx:164–181`) is the exact pattern to mirror. + +## Reference patterns to mirror + +From `/stats/map`: + +- **Header**: `border-border flex flex-wrap items-end justify-between gap-x-10 gap-y-4 border-b pb-6` with eyebrow + 4xl headline left, `
` Geist Mono stats ribbon right. +- **Eyebrow**: `text-muted-foreground font-mono text-xs tracking-[0.18em] uppercase`. Carries category metadata (`MAP META · LAST 60 DAYS · 53 UNIQUE MAPS`). +- **Filter row**: `mt-6 flex flex-wrap items-center gap-3` — outline buttons, popover map picker with image+name, sort select, role + sub-role pills. +- **Body**: dense table on a flat background. No card-on-card. Tabular nums with `font-mono` labels. + +From `/leaderboard` hub: + +- **Section grid**: `lg:grid-cols-[minmax(0,5fr)_minmax(0,7fr)] gap-x-10` with eyebrow + h2 + status + CTA on left, ribbon + prose + bullets on right. +- **Sections divided** by `divide-y divide-[var(--border)]`, no per-section card containers. + +## Tab-by-tab observations + +### Overview (01) + +- Page header is generic: 100px circular avatar + `text-3xl` team name + small "Overall Record" line. No eyebrow, no border, no ribbon. +- `QuickStatsCard` uses `text-green/blue/yellow/red-600` for the Last-10 winrate (`src/components/stats/team/quick-stats-card.tsx:19–24`). Date "Wednesday" is a 2xl Switzer figure with no mono treatment despite being the kind of categorical metadata mono is for. +- "Recent Activity" calendar is good — neutral, dense, matches the system. Keep. +- "Top Maps by Playtime" amber bars at full saturation and full row width are the right idea but feel decorative — bar length already encodes value, and the right-aligned percentage gets lost. +- "Strengths & Weaknesses" uses `border-green-500` and `border-red-500` as full card outlines — same chrome-as-color anti-pattern as Win Probability Insights. +- `RoleBalanceRadar` legend lives below the chart with three filled dots; the radar itself uses raw `#3b82f6 / #ef4444 / #eab308` tooltip text (`src/components/stats/team/role-balance-radar.tsx:32–40`). Should use `--team-1/2-*` or `--chart-*` tokens. + +### Performance (02) — worst color violation + +- `RolePerformanceCard` (`src/components/stats/team/role-performance-card.tsx:25–29`): + ```ts + Tank: "bg-blue-100 dark:bg-blue-950"; + Damage: "bg-red-100 dark:bg-red-950"; + Support: "bg-yellow-100 dark:bg-yellow-950"; + ``` + These are full-bleed colored card backgrounds. Color is chrome, the heading already says "Tank". **Strip the backgrounds; replace with a small role glyph + Geist Mono caps eyebrow.** +- "Best Role Trios" #1 row has a saturated amber pill at the start of the row — the kind of side-stripe-ish accent the system bans. Whole component should be a dense table with rank in `font-mono tabular-nums`. + +### Heroes (03) + +- "Hero Pool Overview" stat row is a strong pattern — keep the 4-column dense numerals but mono-label them. +- "Most Played by Role" splits into Tank/Damage/Support sub-cards, each with the same blue/red/yellow heavy tint as the Performance tab. **Same fix.** +- "Top Hero Winrates" uses a heavy green-on-green full-row highlight for #1. Drop the tint, keep the rank numeral and a tiny amber dot for the leader (`bg-primary`, 6px). +- "Hero Pickrate Heatmap" green gradient is the only chart on the page using a token-driven scale — keep it, but match the role/hero icon rendering already established in `ult-usage-overview-card.tsx` (24px hero image with rounded clip). +- "Most Banned Heroes" / "Bar Weak Points" / "Our Ban Strategy" use orange-amber-red bars at full saturation. Single-series bars should pick one accent — amber `--primary` — and let the bar length be the signal. + +### Trends (04) + +- "Winrate Over Time" line chart is acceptable. Series legend dots are tiny and in two unrelated colors; use one neutral and one amber. +- "Recent Form" uses a green-tint "Strong recent performance" badge at top. Move "Strong / Average / Needs work" to a Geist Mono caps tag with `bg-primary/15 text-primary`. +- "Win/Loss Streaks" is the second-worst color violation: full green card for "Current Streak", full bright green for "Longest Win Streak", full red for "Longest Loss Streak". **Three plain stat tiles with mono caps labels, single accent (amber for current streak only). Win = neutral foreground, loss = neutral foreground. The labels already disambiguate.** + +### Maps (05) — third-worst color violation + +- `MapModePerformanceCard` (`src/components/stats/team/map-mode-performance-card.tsx:29–36`) uses raw hex Tailwind palette values: + ```ts + Control: "#3b82f6"; // blue + Hybrid: "#8b5cf6"; // violet — banned by the design system + Escort: "#ec4899"; // pink + Push: "#f59e0b"; // amber + Clash: "#10b981"; // emerald + Flashpoint: "#ef4444"; // red + ``` + Two of these (violet, pink) are explicitly called out as the "AI tool palette" the system rejects. Move to `--chart-1..5` ramp or, better, drop colored stacks — game modes don't have inherent identity color. Use a flat horizontal bar with mono labels. +- The Hybrid / Flashpoint / Escort / Control quadrants beneath the chart use heavy green/red/amber/red full-card backgrounds. Same fix as Win Probability. +- "Map Winrate Gallery" — every map card has a saturated background tinted by win rate (red/amber/green). At ~16 cards on screen it's overwhelming. **Replace with a uniform card + a small mono-labelled win rate pill in the corner; let the heatmap below carry the comparative signal.** +- "Player Map Performance Matrix" is the right format (heatmap), but the cell colors are bright red/yellow/orange. Switch to a single amber→muted scale or a token-driven diverging scale. + +### Swaps (06) + +- "Hero Swap Overview" has four big colored numbers (cyan, red, amber, blue) — same hex palette as map modes. Mono-tabular black/foreground numbers with mono caps labels would read 10× more like a Bloomberg ribbon. +- "Swap Timing Distribution" placeholder pill / "By Swap Count / By Swap Timing" quadrants are good (neutral) — keep. This is the cleanest tab; use as a template for the redesign. + +### Teamfights (07) — second-worst color violation + +- `WinProbabilityInsights` (`src/components/stats/team/win-probability-insights.tsx:125–131`): + ```ts + high-positive: "border-green-500 bg-green-50 dark:bg-green-950/30" + negative: "border-red-500 bg-red-50 dark:bg-red-950/30" + moderate: "border-yellow-500 bg-yellow-50 dark:bg-yellow-950/30" + ``` + Plus `bg-green-600 / bg-red-600 / bg-yellow-600 text-white` solid badges in the corner. **This is a textbook bento-of-colored-cards anti-pattern.** Replace with a 4-column stat grid: tabular-nums value, mono caps label, single-line description, mono caps state tag (`STRONG / AVG / WEAK`) using `text-primary` or `text-destructive` only — no card tints, no border tints. +- "Overall Fight Performance" inner panel uses `bg-muted` correctly; values then go back to the green/yellow/red text scale. Pick one — neutral foreground with a single amber for the headline number. + +### Ultimates (08) + +- Top stat row is four big colored numerals (`text-blue/green/amber-600`). Strip color, mono caps the labels, tabular nums on the values. +- "Winning Fights" full-green card and "Losing Fights" full-red card next to each other are the most aggressive use of color on the page. **Two neutral stat tiles. The labels and the deltas in the numbers do all the work.** Optionally a tiny amber/destructive dot in the corner of the winning/losing tile. +- "Ultimate Timing Distribution" stacked-bar timeline is genuinely useful but its yellow/orange/red palette reads like a heat alert, not data. Move to a token-driven amber → muted ramp. +- "Player Ultimate Rankings" is a clean dense table — keep almost as-is, just add Geist Mono caps column headers. + +### Winrates (09) + +- "Hero Matchup Winrates" picker — Our Heroes column has a blue tint, Enemy Heroes column has a red tint. These are correct uses of the team-color tokens, but they should be `--team-1-off / --team-2-off`, not raw blue/red. (Verify in source.) +- "Best Compositions" shows 5 rank rows; in the screenshot 4 of them are empty placeholders with greyed names like "BPDM" and "1 game" stubs. Either show only what exists or render an honest empty state ("Need more matchup data — 1 game"). +- "Match Results" table is fine — keep, mono-up the column headers. + +### Simulator (10) + +- Two-column layout (Scenario Setup left, Predicted Win Rate right) is right. The "Enemy bans against us" section has a heavy red-tinted vertical band; "Our bans" has a heavy blue-tinted band. Drop both tints. The labels disambiguate. +- "Predicted Win Rate" big amber number is correct. Keep that, drop the column tints. + +## Cross-cutting issues + +### 1. Color used as chrome + +Inventoried below; every line is decorative color the system bans: + +| File | Lines | Treatment | +| ------------------------------- | ---------- | -------------------------------------------------------------- | +| `role-performance-card.tsx` | 25–29 | Tank/Damage/Support card backgrounds | +| `win-probability-insights.tsx` | 125–131 | Green/red/yellow card chrome + solid color badges | +| `map-mode-performance-card.tsx` | 29–36 | Hex Tailwind palette for game modes (incl. banned violet/pink) | +| `quick-stats-card.tsx` | 19–24 | Winrate text in green/blue/yellow/red | +| `ult-usage-overview-card.tsx` | 26–31 | Initiation rate text in green/blue/yellow/red | +| `ultimate-economy-card.tsx` | 33–52 | Efficiency text in green/blue/yellow/red | +| `role-balance-radar.tsx` | 32–40 | Tooltip uses raw `#3b82f6 / #ef4444 / #eab308` | +| `team-fight-stats-card.tsx` | (inferred) | Winning/Losing fight backgrounds | +| `recent-form-card.tsx` | (inferred) | Strong-performance badge tint | +| `win-loss-streaks-card.tsx` | (inferred) | Streak tile chrome | +| `top-maps-card.tsx` | (inferred) | Bar fill saturation | + +**Action**: introduce `getStateColor(state: 'positive' | 'negative' | 'neutral')` returning `text-primary / text-destructive / text-foreground` only. Drop background and border state colors entirely except in the rare case where the tile _is_ the chart (heatmap cells). + +### 2. No metadata layer + +None of the section headings use the `font-mono uppercase tracking-[0.16em]` eyebrow. Every card uses `CardTitle` (Switzer 18px semibold), and there is no descriptive line under the title. The map redesign sits on the eyebrow as a wayfinding rail. **Action**: add a `` primitive used by every card. + +### 3. Page header is undertuned + +`/stats/team/[teamId]` opens with a 100px circular avatar + 3xl team name + 14px "Overall Record" line. The map page opens with eyebrow + 4xl headline + right-side mono ribbon. **Action**: rebuild the header as: + +```tsx +
+
+

+ Team · {timeframeLabel} +

+

+ {team.name} +

+
+
+ + + +
+
+``` + +The 100px circular avatar should drop to 32px inline with the headline, or move to a small round inline avatar to the left of the team name (matching the dashboard team chips). + +### 4. Tabs are not part of the metadata layer + +The `` row is a flat strip of tabs that fights with the section headings beneath it for visual rank. **Action**: render tab labels in `font-mono uppercase tracking-[0.16em] text-xs`, anchor the tablist to the bottom of the header (border-b), and make the active tab carry a 1.5px amber underline rather than a filled pill. Compare to `/stats/map`'s sort + role pill row. + +### 5. Card-on-card-on-card + +- Performance tab nests Tank/Damage/Support panels inside `RolePerformanceCard`. Each inner panel has its own border + colored background, sitting inside an outer card with its own border. Three rings of containment. +- Heroes tab "Most Played by Role" does the same. +- Winrates tab "Hero Matchup Winrates" wraps two inner team panels in another card. + +**Action**: flatten. The outer card carries the border + ring; inner sections separate with `divide-y` or a 24px gap, no inner border. Where the data is genuinely tabular (ult player rankings, swap player breakdown, match results), drop the card entirely and place the table on the page background. + +### 6. Density inversion + +Tabs sit ~32px above content with `space-y-4` between cards (16px). Cards then internally use `space-y-6`/`gap-6` (24px) and `py-6 px-6` padding. The breathing space is _inside_ cards (where dense data should be) and the section separation is _tight_. **Action**: invert. Use `divide-y` between sections with 40–48px vertical gaps; tighten card internals to `space-y-4` and `py-4 px-5`. + +### 7. Stat ribbons are inconsistent + +The page mixes `text-3xl font-bold` (QuickStatsCard) with `text-2xl font-bold` (Best Day inside same card) with `text-xl font-bold` (Avg Fight Duration) — three sizes for what should be one stat-row scale. Map redesign uses one: `text-lg font-medium tabular-nums` with mono caps `text-[10px] tracking-[0.18em]` labels (`MapHeroTrends → Stat`). + +### 8. Empty / sparse states + +- Winrates tab "Best Compositions" renders 5 empty rows (4 placeholder, one real). Render only what exists; below the fold, an honest empty footer ("More matchup data needed — 1 game on record"). +- Performance tab "Best Role Trios" shows trio #2 with 50% (1 game) — one game is below the threshold for rank inference. **Action**: surface the minimum-sample threshold and either gray the row or hide it. + +### 9. Iconography is inconsistent + +- Tabs have no icons. Cards have icons sometimes (`Trophy`, `CalendarCheck`, `Clock`, `Heart`, `Shield`, `Swords`) and not other times. +- Icons inside `QuickStatsCard` use `text-muted-foreground` correctly, but icons inside `RolePerformanceCard` use `text-blue/red/yellow-400` — colored icons, not neutral. + +**Action**: pick one. Either ship icons everywhere (small mono-color, 14px, `text-muted-foreground`) or nowhere. Map redesign uses no card icons — sentences and numerals are doing the work. I'd vote for nowhere. + +### 10. Chart tokens + +Multiple charts bypass the design system's chart tokens: + +- `map-mode-performance-card.tsx` raw hex. +- `role-balance-radar.tsx` raw hex in tooltip text. +- `win-probability-insights.tsx` raw `bg-green-600 / bg-red-600` badge backgrounds. + +**Action**: introduce a `getChartColor(role | mode | series)` helper that maps semantic roles to `--chart-1..5` (categorical) or `--team-1-* / --team-2-*` (entity-comparison), honoring colorblind variants per the design system. + +## Prioritized recommendations + +### P0 — strip decorative color (1 day, mostly mechanical) + +- `role-performance-card.tsx`: drop `roleBgColors`, drop colored icon classes, lift the Tank/Damage/Support label into a Geist Mono caps eyebrow. +- `win-probability-insights.tsx`: drop `getImpactColor` background tinting; render a plain stat grid; replace solid colored badges with mono caps tags. +- `map-mode-performance-card.tsx`: replace hex palette with `--chart-1..5`; replace the colored quadrants with a flat table. +- `win-loss-streaks-card.tsx`, `recent-form-card.tsx`, `team-fight-stats-card.tsx`, `ult-usage-overview-card.tsx`: drop full-card color tints; keep optional small amber/destructive dot for the headline number. +- `quick-stats-card.tsx`, `ultimate-economy-card.tsx`: drop the four-tier `text-green/blue/yellow/red-600` scales; use `text-foreground` and a single amber for the highlight metric. +- `role-balance-radar.tsx`: token-ize tooltip color text (`text-team-1-off` etc., or `var(--chart-1)` etc.). +- `top-maps-card.tsx`: drop saturated bar fills, use `bg-primary/60`. +- `map-winrate-gallery`: drop per-card winrate tints. + +### P1 — rebuild the page chrome (1 day) + +- New header: eyebrow + 4xl headline + right-side `
` mono ribbon, bottom border. Drop the 100px circular avatar in favor of a 32px inline avatar. +- Tablist: mono caps labels with amber underline for active. Anchor to the bottom of the header. The selected-tab amber rule (`DESIGN.md` §2 Amber-as-Signal) is already satisfied — match the pattern. +- Page padding: `px-6 sm:px-10 pt-8 pb-16` to match `/stats/map` and `/leaderboard`. +- Section spacing: `divide-y divide-[var(--border)] gap-y-12` between major content blocks. + +### P2 — flatten card containment (1–2 days) + +- Add `` primitive with eyebrow / title / description. +- Convert Performance, Heroes ("Most Played by Role"), Teamfights ("Win Probability"), Ultimates ("Winning/Losing Fights") to flat sections — single outer surface, no nested cards. +- Convert "Player Map Performance Matrix", "Player Ultimate Rankings", "Player Swap Breakdown", "Match Results" to page-bg dense tables (no card wrap). +- Standardize stat ribbons: one scale (`text-lg font-medium tabular-nums` value, `text-[10px] mono caps tracking-[0.18em]` label). + +### P3 — content polish (0.5 day) + +- Honest empty states for "Best Compositions", "Best Role Trios" sub-threshold rows, "Ability Impact Analysis" (the placeholder dropdown). +- Consistent timeframe label format in the eyebrow ("Last 7 days" not "Last Week" — match the dropdown's case). +- Mono caps the column headers across every dense table. +- Ban the icon-on-icon-on-color treatment — one icon system, neutral. + +## What to keep + +- Hero Pickrate Heatmap visual structure (just retoken). +- Recent Activity Calendar. +- Tabular tables: Player Ultimate Rankings, Match Results, Player Swap Breakdown, Player Map Performance Matrix (palette aside). +- Swap tab quadrants (By Swap Count / By Swap Timing) — these are already quiet. +- Winrate Over Time line chart structure. +- The 8-tab content taxonomy itself — the surfaces are right; only the chrome is loud. + +## Implementation hint + +Most of the visual work resolves to ~7 component edits and one new ``. The data and layout topology are sound. The redesign is a **palette + chrome pass**, not a re-architecture. Estimate 2–3 focused days end-to-end. diff --git a/docs/metrics.md b/docs/metrics.md new file mode 100644 index 000000000..aee5106b9 --- /dev/null +++ b/docs/metrics.md @@ -0,0 +1,1718 @@ +# Parsertime Metrics + +All metrics are emitted to the Axiom dataset `ptime-metrics`. Paste each MPL query into Axiom's metrics chart builder (Explore → new chart → paste into the editor). + +Chart-builder tips: + +- Counters use `align to 5m using sum` — switch to `align using rate` for per-second rate charts. +- Duration histograms (`*_ms`) use `align to 5m using avg`. Use `p95` / `p99` via the builder's aggregator picker if you want tail latency. +- Replace `5m` with `$__interval` to let the dashboard auto-bucket by time range. + +## Contents + +Data-layer metrics (`src/data/`): + +- [Admin](#admin) +- [Comparison](#comparison) +- [Hero](#hero) +- [Intelligence](#intelligence) +- [Map](#map) +- [Player](#player) +- [Scouting](#scouting) +- [Scrim](#scrim) +- [Team](#team) +- [Tournament](#tournament) +- [Tournament Team](#tournament-team) +- [User](#user) + +Application metrics (`src/lib/axiom/metrics.ts`): + +- [Application](#application) + +## Admin + +Source: `src/data/admin/metrics.ts` + +### Unlabeled matches + +Successes (counter): + +``` +`ptime-metrics`:`admin.unlabeled_matches.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`admin.unlabeled_matches.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`admin.unlabeled_matches.query.duration_ms` | align to 5m using avg +``` + +### Match for labeling + +Successes (counter): + +``` +`ptime-metrics`:`admin.match_for_labeling.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`admin.match_for_labeling.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`admin.match_for_labeling.query.duration_ms` | align to 5m using avg +``` + +### Admin cache + +Requests (counter): + +``` +`ptime-metrics`:`admin.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`admin.cache.miss` | align to 5m using sum +``` + +## Comparison + +Source: `src/data/comparison/metrics.ts` + +### Comparison stats + +Successes (counter): + +``` +`ptime-metrics`:`comparison.stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`comparison.stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`comparison.stats.query.duration_ms` | align to 5m using avg +``` + +### Available maps + +Successes (counter): + +``` +`ptime-metrics`:`comparison.available_maps.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`comparison.available_maps.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`comparison.available_maps.query.duration_ms` | align to 5m using avg +``` + +### Team players + +Successes (counter): + +``` +`ptime-metrics`:`comparison.team_players.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`comparison.team_players.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`comparison.team_players.query.duration_ms` | align to 5m using avg +``` + +### Trends + +Successes (counter): + +``` +`ptime-metrics`:`comparison.trends.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`comparison.trends.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`comparison.trends.duration_ms` | align to 5m using avg +``` + +### Comparison cache + +Requests (counter): + +``` +`ptime-metrics`:`comparison.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`comparison.cache.miss` | align to 5m using sum +``` + +## Hero + +Source: `src/data/hero/metrics.ts` + +### Hero stats + +Successes (counter): + +``` +`ptime-metrics`:`hero.stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`hero.stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`hero.stats.query.duration_ms` | align to 5m using avg +``` + +### Hero kills + +Successes (counter): + +``` +`ptime-metrics`:`hero.kills.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`hero.kills.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`hero.kills.query.duration_ms` | align to 5m using avg +``` + +### Hero deaths + +Successes (counter): + +``` +`ptime-metrics`:`hero.deaths.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`hero.deaths.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`hero.deaths.query.duration_ms` | align to 5m using avg +``` + +### Hero cache + +Requests (counter): + +``` +`ptime-metrics`:`hero.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`hero.cache.miss` | align to 5m using sum +``` + +## Intelligence + +Source: `src/data/intelligence/metrics.ts` + +### Hero ban intelligence + +Successes (counter): + +``` +`ptime-metrics`:`intelligence.hero_ban.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`intelligence.hero_ban.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`intelligence.hero_ban.query.duration_ms` | align to 5m using avg +``` + +### Map intelligence + +Successes (counter): + +``` +`ptime-metrics`:`intelligence.map.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`intelligence.map.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`intelligence.map.query.duration_ms` | align to 5m using avg +``` + +### Intelligence cache + +Requests (counter): + +``` +`ptime-metrics`:`intelligence.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`intelligence.cache.miss` | align to 5m using sum +``` + +## Map + +Source: `src/data/map/metrics.ts` + +### Heatmap + +Successes (counter): + +``` +`ptime-metrics`:`map.heatmap.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.heatmap.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.heatmap.query.duration_ms` | align to 5m using avg +``` + +### Killfeed ult spans + +Successes (counter): + +``` +`ptime-metrics`:`map.killfeed.ult_spans.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.killfeed.ult_spans.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.killfeed.ult_spans.query.duration_ms` | align to 5m using avg +``` + +### Killfeed calibration + +Successes (counter): + +``` +`ptime-metrics`:`map.killfeed.calibration.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.killfeed.calibration.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.killfeed.calibration.query.duration_ms` | align to 5m using avg +``` + +### Replay data + +Successes (counter): + +``` +`ptime-metrics`:`map.replay.data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.replay.data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.replay.data.query.duration_ms` | align to 5m using avg +``` + +### Tempo + +Successes (counter): + +``` +`ptime-metrics`:`map.tempo.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.tempo.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.tempo.query.duration_ms` | align to 5m using avg +``` + +### Rotation death + +Successes (counter): + +``` +`ptime-metrics`:`map.rotation_death.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.rotation_death.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.rotation_death.query.duration_ms` | align to 5m using avg +``` + +### Map group (query) + +Successes (counter): + +``` +`ptime-metrics`:`map.group.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.group.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.group.query.duration_ms` | align to 5m using avg +``` + +### Map group (mutation) + +Successes (counter): + +``` +`ptime-metrics`:`map.group.mutation.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.group.mutation.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.group.mutation.duration_ms` | align to 5m using avg +``` + +### Map hero trends + +Successes (counter): + +``` +`ptime-metrics`:`map.hero_trends.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`map.hero_trends.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`map.hero_trends.query.duration_ms` | align to 5m using avg +``` + +### Map cache + +Requests (counter): + +``` +`ptime-metrics`:`map.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`map.cache.miss` | align to 5m using sum +``` + +## Player + +Source: `src/data/player/metrics.ts` + +### Most played heroes + +Successes (counter): + +``` +`ptime-metrics`:`player.most_played.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.most_played.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.most_played.query.duration_ms` | align to 5m using avg +``` + +### Player intelligence + +Successes (counter): + +``` +`ptime-metrics`:`player.intelligence.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.intelligence.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.intelligence.query.duration_ms` | align to 5m using avg +``` + +### Scouting players + +Successes (counter): + +``` +`ptime-metrics`:`player.scouting_players.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.scouting_players.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.scouting_players.query.duration_ms` | align to 5m using avg +``` + +### Player profile + +Successes (counter): + +``` +`ptime-metrics`:`player.profile.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.profile.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.profile.query.duration_ms` | align to 5m using avg +``` + +### Scouting analytics + +Successes (counter): + +``` +`ptime-metrics`:`player.scouting_analytics.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.scouting_analytics.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.scouting_analytics.query.duration_ms` | align to 5m using avg +``` + +### Player targets + +Successes (counter): + +``` +`ptime-metrics`:`player.targets.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.targets.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.targets.query.duration_ms` | align to 5m using avg +``` + +### Team targets + +Successes (counter): + +``` +`ptime-metrics`:`player.team_targets.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.team_targets.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.team_targets.query.duration_ms` | align to 5m using avg +``` + +### Recent scrim stats + +Successes (counter): + +``` +`ptime-metrics`:`player.recent_scrim_stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`player.recent_scrim_stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`player.recent_scrim_stats.query.duration_ms` | align to 5m using avg +``` + +### Player cache + +Requests (counter): + +``` +`ptime-metrics`:`player.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`player.cache.miss` | align to 5m using sum +``` + +## Scouting + +Source: `src/data/scouting/metrics.ts` + +### Scouting teams + +Successes (counter): + +``` +`ptime-metrics`:`scouting.teams.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.teams.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.teams.query.duration_ms` | align to 5m using avg +``` + +### Opponent match data + +Successes (counter): + +``` +`ptime-metrics`:`scouting.opponent_match_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.opponent_match_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.opponent_match_data.query.duration_ms` | align to 5m using avg +``` + +### Team profile + +Successes (counter): + +``` +`ptime-metrics`:`scouting.team_profile.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.team_profile.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.team_profile.query.duration_ms` | align to 5m using avg +``` + +### Strength ratings + +Successes (counter): + +``` +`ptime-metrics`:`scouting.strength_ratings.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.strength_ratings.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.strength_ratings.query.duration_ms` | align to 5m using avg +``` + +### Strength rating + +Successes (counter): + +``` +`ptime-metrics`:`scouting.strength_rating.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.strength_rating.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.strength_rating.query.duration_ms` | align to 5m using avg +``` + +### Strength percentile + +Successes (counter): + +``` +`ptime-metrics`:`scouting.strength_percentile.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scouting.strength_percentile.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scouting.strength_percentile.query.duration_ms` | align to 5m using avg +``` + +### Scouting cache + +Requests (counter): + +``` +`ptime-metrics`:`scouting.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`scouting.cache.miss` | align to 5m using sum +``` + +## Scrim + +Source: `src/data/scrim/metrics.ts` + +### Get scrim + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_scrim.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_scrim.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_scrim.query.duration_ms` | align to 5m using avg +``` + +### User-viewable scrims + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_user_viewable_scrims.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_user_viewable_scrims.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_user_viewable_scrims.query.duration_ms` | align to 5m using avg +``` + +### Final round stats + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_final_round_stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_final_round_stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_final_round_stats.query.duration_ms` | align to 5m using avg +``` + +### Final round stats (player) + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_final_round_stats_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_final_round_stats_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_final_round_stats_for_player.query.duration_ms` | align to 5m using avg +``` + +### All stats for player + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_all_stats_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_all_stats_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_all_stats_for_player.query.duration_ms` | align to 5m using avg +``` + +### All kills for player + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_all_kills_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_all_kills_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_all_kills_for_player.query.duration_ms` | align to 5m using avg +``` + +### All deaths for player + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_all_deaths_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_all_deaths_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_all_deaths_for_player.query.duration_ms` | align to 5m using avg +``` + +### All map winrates for player + +Successes (counter): + +``` +`ptime-metrics`:`scrim.get_all_map_winrates_for_player.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.get_all_map_winrates_for_player.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.get_all_map_winrates_for_player.query.duration_ms` | align to 5m using avg +``` + +### Scrim overview + +Successes (counter): + +``` +`ptime-metrics`:`scrim.overview.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.overview.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.overview.query.duration_ms` | align to 5m using avg +``` + +### Opponent map results + +Successes (counter): + +``` +`ptime-metrics`:`scrim.opponent.map_results.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.opponent.map_results.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.opponent.map_results.query.duration_ms` | align to 5m using avg +``` + +### Opponent hero bans + +Successes (counter): + +``` +`ptime-metrics`:`scrim.opponent.hero_bans.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.opponent.hero_bans.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.opponent.hero_bans.query.duration_ms` | align to 5m using avg +``` + +### Opponent player stats + +Successes (counter): + +``` +`ptime-metrics`:`scrim.opponent.player_stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.opponent.player_stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.opponent.player_stats.query.duration_ms` | align to 5m using avg +``` + +### Ability timing + +Successes (counter): + +``` +`ptime-metrics`:`scrim.ability_timing.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.ability_timing.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.ability_timing.query.duration_ms` | align to 5m using avg +``` + +### Fight timelines + +Successes (counter): + +``` +`ptime-metrics`:`scrim.fight_timelines.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.fight_timelines.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.fight_timelines.query.duration_ms` | align to 5m using avg +``` + +### Map ability timing + +Successes (counter): + +``` +`ptime-metrics`:`scrim.map_ability_timing.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`scrim.map_ability_timing.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`scrim.map_ability_timing.query.duration_ms` | align to 5m using avg +``` + +### Scrim cache + +Requests (counter): + +``` +`ptime-metrics`:`scrim.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`scrim.cache.miss` | align to 5m using sum +``` + +## Team + +Source: `src/data/team/metrics.ts` + +### Team roster + +Successes (counter): + +``` +`ptime-metrics`:`team.roster.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`team.roster.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`team.roster.query.duration_ms` | align to 5m using avg +``` + +### Team base data + +Successes (counter): + +``` +`ptime-metrics`:`team.base_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`team.base_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`team.base_data.query.duration_ms` | align to 5m using avg +``` + +### Team extended data + +Successes (counter): + +``` +`ptime-metrics`:`team.extended_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`team.extended_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`team.extended_data.query.duration_ms` | align to 5m using avg +``` + +### Team cache + +Requests (counter): + +``` +`ptime-metrics`:`team.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`team.cache.miss` | align to 5m using sum +``` + +## Tournament + +Source: `src/data/tournament/metrics.ts` + +### Get tournament + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_tournament.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_tournament.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_tournament.query.duration_ms` | align to 5m using avg +``` + +### User tournaments + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_user_tournaments.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_user_tournaments.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_user_tournaments.query.duration_ms` | align to 5m using avg +``` + +### Tournament match + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_tournament_match.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_tournament_match.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_tournament_match.query.duration_ms` | align to 5m using avg +``` + +### Tournament bracket + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_tournament_bracket.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_tournament_bracket.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_tournament_bracket.query.duration_ms` | align to 5m using avg +``` + +### RR standings + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_rr_standings.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_rr_standings.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_rr_standings.query.duration_ms` | align to 5m using avg +``` + +### Broadcast data + +Successes (counter): + +``` +`ptime-metrics`:`tournament.get_broadcast_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament.get_broadcast_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament.get_broadcast_data.query.duration_ms` | align to 5m using avg +``` + +### Tournament cache + +Requests (counter): + +``` +`ptime-metrics`:`tournament.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`tournament.cache.miss` | align to 5m using sum +``` + +### Broadcast cache + +Requests (counter): + +``` +`ptime-metrics`:`broadcast.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`broadcast.cache.miss` | align to 5m using sum +``` + +## Tournament Team + +Source: `src/data/tournament-team/metrics.ts` + +### Base data + +Successes (counter): + +``` +`ptime-metrics`:`tournament_team.base_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament_team.base_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament_team.base_data.query.duration_ms` | align to 5m using avg +``` + +### Roster + +Successes (counter): + +``` +`ptime-metrics`:`tournament_team.roster.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament_team.roster.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament_team.roster.query.duration_ms` | align to 5m using avg +``` + +### Extended data + +Successes (counter): + +``` +`ptime-metrics`:`tournament_team.extended_data.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament_team.extended_data.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament_team.extended_data.query.duration_ms` | align to 5m using avg +``` + +### Stats + +Successes (counter): + +``` +`ptime-metrics`:`tournament_team.stats.query.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`tournament_team.stats.query.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`tournament_team.stats.query.duration_ms` | align to 5m using avg +``` + +### Tournament team cache + +Requests (counter): + +``` +`ptime-metrics`:`tournament_team.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`tournament_team.cache.miss` | align to 5m using sum +``` + +## User + +Source: `src/data/user/metrics.ts` + +### getUser + +Successes (counter): + +``` +`ptime-metrics`:`user.getUser.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`user.getUser.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`user.getUser.duration_ms` | align to 5m using avg +``` + +### getTeamsWithPerms + +Successes (counter): + +``` +`ptime-metrics`:`user.getTeamsWithPerms.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`user.getTeamsWithPerms.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`user.getTeamsWithPerms.duration_ms` | align to 5m using avg +``` + +### getAppSettings + +Successes (counter): + +``` +`ptime-metrics`:`user.getAppSettings.success` | align to 5m using sum +``` + +Errors (counter): + +``` +`ptime-metrics`:`user.getAppSettings.error` | align to 5m using sum +``` + +Avg duration, ms (histogram): + +``` +`ptime-metrics`:`user.getAppSettings.duration_ms` | align to 5m using avg +``` + +### User cache + +Requests (counter): + +``` +`ptime-metrics`:`user.cache.request` | align to 5m using sum +``` + +Misses (counter): + +``` +`ptime-metrics`:`user.cache.miss` | align to 5m using sum +``` + +## Application + +Source: `src/lib/axiom/metrics.ts` + +Application-wide counters and latency histograms covering the core funnel, HTTP, database, AI chat, reliability, scrim pipeline, cron, billing, and bot notifications. + +### Core funnel + +**Sign-ins** — Counter: + +``` +`ptime-metrics`:`auth.signins` | align to 5m using sum +``` + +**New user registrations** — Counter: + +``` +`ptime-metrics`:`auth.new_users` | align to 5m using sum +``` + +**Teams created** — Counter: + +``` +`ptime-metrics`:`teams.created` | align to 5m using sum +``` + +**Team quota hits** — Counter: + +``` +`ptime-metrics`:`teams.quota_hits` | align to 5m using sum +``` + +**Scrims created** — Counter: + +``` +`ptime-metrics`:`scrims.created` | align to 5m using sum +``` + +**Maps added** — Counter: + +``` +`ptime-metrics`:`scrims.maps_added` | align to 5m using sum +``` + +**Maps removed** — Counter: + +``` +`ptime-metrics`:`scrims.maps_removed` | align to 5m using sum +``` + +### HTTP + +**HTTP requests** — Counter: + +``` +`ptime-metrics`:`http.requests` | align to 5m using sum +``` + +**HTTP errors (4xx/5xx)** — Counter: + +``` +`ptime-metrics`:`http.errors` | align to 5m using sum +``` + +**HTTP request duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`http.request_duration_ms` | align to 5m using avg +``` + +### AI chat + +**Chat requests** — Counter: + +``` +`ptime-metrics`:`ai.chat.requests` | align to 5m using sum +``` + +**Chat tokens consumed** — Counter: + +``` +`ptime-metrics`:`ai.chat.tokens` | align to 5m using sum +``` + +**Chat tool calls** — Counter: + +``` +`ptime-metrics`:`ai.chat.tool_calls` | align to 5m using sum +``` + +**Chat response duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`ai.chat.duration_ms` | align to 5m using avg +``` + +**Chat tool-call duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`ai.chat.tool_call_duration_ms` | align to 5m using avg +``` + +### Reliability + +**Rate-limit hits** — Counter: + +``` +`ptime-metrics`:`ratelimit.hits` | align to 5m using sum +``` + +### Scrim pipeline + +**Scrim parse duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`scrims.parse_duration_ms` | align to 5m using avg +``` + +**Map deletion duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`scrims.map_deletion_duration_ms` | align to 5m using avg +``` + +### Cron + +**Cron runs** — Counter: + +``` +`ptime-metrics`:`cron.runs` | align to 5m using sum +``` + +**Items deleted by cron** — Counter: + +``` +`ptime-metrics`:`cron.deleted_items` | align to 5m using sum +``` + +**Cron duration (avg ms)** — Avg latency, ms (histogram): + +``` +`ptime-metrics`:`cron.duration_ms` | align to 5m using avg +``` + +### Billing & bot + +**Stripe webhooks** — Counter: + +``` +`ptime-metrics`:`stripe.webhooks` | align to 5m using sum +``` + +**Bot notifications** — Counter: + +``` +`ptime-metrics`:`bot.notifications` | align to 5m using sum +``` diff --git a/docs/tsr-spec.md b/docs/tsr-spec.md new file mode 100644 index 000000000..bd598d4c6 --- /dev/null +++ b/docs/tsr-spec.md @@ -0,0 +1,601 @@ +# Parsertime Skill Rating System + +This is the working spec, updated based on the dry-run findings against real +FACEIT data. The original v1 spec is preserved in conversation; this document +captures the **current intended algorithm** that the implementation should +follow. + +Two distinct ratings live side-by-side: + +- **Raw CSR** — the existing per-hero Z-score in `src/lib/hero-rating.ts`. + Renamed in UI/labels from "CSR" to "Raw CSR". No algorithmic change. +- **TSR** (Tournament Skill Rating) — per-player rating grounded in + FACEIT-hosted Overwatch 2 tournament results. Aggregates to **Team TSR** for + scrim matchmaking. +- **Adjusted CSR** — an additive, display-only per-hero rating in + `src/lib/adjusted-csr.ts`. It keeps Raw CSR's stat model but compares each + player to peers in their current TSR tier before anchoring the result to that + tier's TSR prior. + +Raw CSR and TSR are deliberately not coupled. Raw CSR's bias against players who +face elite opponents is exactly why it cannot feed into TSR even as a prior. The +only point of contact for TSR computation is the **predicted-TSR fallback** for +unrostered players (see Unrostered Player Handling). Adjusted CSR may read TSR +as context, but it never feeds back into TSR, Team TSR, or predicted TSR. + +--- + +## Raw CSR + +Unchanged. The Z-score over `PlayerStat`, scaled to 1–5000 via +`2500 + (z * 1250 / (1 + |z|/3))`, stays intact. Renamed in UI from "CSR" to +"Raw CSR". + +A new lightweight derived value: + +- **Player composite CSR** = mean of the player's two most-played heroes' + Raw CSRs over the active window. Used only by the unrostered-player TSR + prediction. + +## Adjusted CSR + +Adjusted CSR is an additive leaderboard metric for answering: + +> How does this player's hero performance compare against peers in the same +> current TSR tier? + +For each hero, players are bucketed by current TSR rating using the same tier +anchors as TSR priors: + +| Current TSR rating | Adjusted CSR anchor | +| ------------------ | ------------------- | +| < 2800 | 2500 (Open) | +| 2800–3099 | 2800 (Advanced) | +| 3100–3449 | 3100 (Expert) | +| 3450–3849 | 3450 (Masters) | +| ≥ 3850 | 3850 (OWCS) | + +Within each hero and TSR tier, Parsertime computes the same role-weighted +per-10 stat Z-score as Raw CSR, but the baseline is the player's TSR-tier peer +pool instead of the global hero pool. + +``` +peer_delta = peer_z * 450 / (1 + |peer_z| / 3) +adjusted_CSR = clamp(tier_anchor + peer_delta, 1, 5000) +``` + +Small peer buckets are blended toward the global hero baseline: + +``` +confidence = peer_count / (peer_count + 25) +``` + +This keeps sparse OWCS or hero-specific cohorts from producing unstable +ratings while still rewarding or penalizing performance against harder lobbies. + +--- + +## TSR — Core Algorithm + +### Scale + +- Range: **1 – 5000**, integer +- Population mean: **2500** +- Target σ: **≈ 750** (4000 ≈ top 2.5%, 4250 ≈ top 1%) +- Hard ceiling: 5000, hard floor: 1 +- Soft cap (gain dampening) above 4000 + +### Tier priors + +Each player is anchored at the prior of the **highest tier they have ever +played** in tracked tournaments — _not_ the tier of their first observed +match. (See "Why max-tier-prior" below for why this diverges from the original +spec.) + +| Highest tier reached | Prior | +| -------------------- | ----- | +| No tracked history | 2500 | +| Open | 2500 | +| CAH | 2500 | +| Advanced | 2800 | +| Expert | 3100 | +| Masters | 3450 | +| OWCS | 3850 | + +CAH (Calling All Heroes) shares the open prior — its skill distribution is +too wide to anchor higher. + +### Match update — Elo with three multipliers + +For each completed (non-forfeit) match in chronological order: + +``` +expected = 1 / (1 + 10^((opp_rating - own_rating) / 400)) +delta = K_base * mov_multiplier * recency_weight * (actual - expected) +delta = delta * gain_dampener(rating, sign(delta)) +new_rating = clamp(own_rating + delta, 1, 5000) +``` + +`actual` = 1.0 win, 0.0 loss. The opposing-team rating is the **mean of the +opposing roster's current TSRs**. + +#### `K_base` — by per-player match count + +| Matches played | K | +| -------------- | --- | +| < 5 | 48 | +| 5–14 | 32 | +| 15–29 | 24 | +| 30+ | 16 | + +#### `mov_multiplier` — closeness of the bo3/bo5/bo7 result + +``` +maxDiff = ceil(bestOf / 2) +closeness = (maxDiff - actualDiff) / maxDiff +mov_multiplier = 1.5 - closeness +``` + +| Format | Score | mov_multiplier | +| ------ | ----- | -------------- | +| bo3 | 2-0 | 1.50× | +| bo3 | 2-1 | 1.00× | +| bo5 | 3-0 | 1.50× | +| bo5 | 3-1 | 1.17× | +| bo5 | 3-2 | 0.83× | +| bo7 | 4-0 | 1.50× | +| bo7 | 4-1 | 1.25× | +| bo7 | 4-2 | 1.00× | +| bo7 | 4-3 | 0.875× | + +#### `gain_dampener` — soft cap near 5000, **positive deltas only** + +``` +gain_dampener = (delta > 0) + ? 1 - max(0, (rating - 4000) / 1000)^2 + : 1 +``` + +| Rating | Dampener | +| ------ | -------- | +| ≤ 4000 | 1.00 | +| 4250 | 0.94 | +| 4500 | 0.75 | +| 4750 | 0.44 | +| 4900 | 0.19 | +| 5000 | 0.00 | + +Losses are never dampened — a top-rated player upset by a much weaker opponent +drops at full force. + +#### `recency_weight` — exponential decay with **365-day half-life** + +``` +recency_weight(age_days) = 0.5 ^ (age_days / 365) +``` + +| Match age | Weight | +| --------- | ------ | +| Today | 1.00 | +| 90 days | 0.84 | +| 180 days | 0.71 | +| 365 days | 0.50 | +| 730 days | 0.25 | +| 1095 days | 0.125 | + +Half-life is configurable (`RECENCY_HALF_LIFE_DAYS`, default 365). Dry-run +showed the original 180-day default crushed historical OWCS anchor data to ~6% +weight, which combined with the first-observed-tier prior caused inactive +players to cling to inflated ratings while active players couldn't escape low +priors. 365 days keeps 2-year-old OWCS data at ~25% weight — historical +results still pull active players upward meaningfully. + +### Forfeits + +Matches with FACEIT status `cancelled`, `aborted`, or where one team forfeited +are **skipped entirely** — neither side's rating is affected. Status field +casing varies (`FINISHED` vs `finished`); both must be accepted. + +### Roster credit + +- Default: **all players on the registered FACEIT roster** receive credit + (win or loss) for each non-forfeit match. +- A **manual sub override table** (`TsrRosterOverride`) lets admins exclude a + registered player who didn't compete, or include a sub who did. Per-match + granularity. + +--- + +## Why max-tier-prior (deviation from v1 spec) + +The v1 spec said: _"A player's prior is set by the tier of their first +observed FACEIT tournament."_ The dry-run revealed two failure modes: + +1. **Inactive players cling to inflated ratings.** A player whose first match + was an OWCS 2024 Stage 1 Main Event in April 2024 gets a 3850 prior. Their + 2-year-old matches now decay to ~25% weight (or 6% under the original 180d + half-life). They never play again, but their rating sits at ~3845 forever. + Dovestopher had 3 matches in April 2024 and was ranked #4 globally. +2. **Active players can't escape low priors.** A player whose first observed + match was an OWCS Open Qualifier (open to anyone) is anchored at 2500. + They subsequently reach OWCS Main Event and OWCS Central Regular Season, + but their ~50 historical wins are recency-decayed and their opponents are + anchored similarly low. pge ended up at 2557 with 57 matches across two + years of OWCS-tier play. + +**Max-tier-prior** fixes both: a player who has ever appeared in OWCS Main +Event / Regular Season / Playoffs is anchored at 3850 from the start of their +chronological replay. Subsequent matches move them organically. This requires +a one-pass pre-scan of the entire match pool to compute each player's +`max_tier_reached` before initialization. + +The activity floor (below) handles the inactive-player surfacing problem +separately. + +--- + +## Activity floor for display + +Computed for every player; surfaced selectively. + +``` +ranked_publicly = (recent_match_count >= DISPLAY_MIN_RECENT_MATCHES) +where recent_match_count = matches in last DISPLAY_ACTIVITY_WINDOW_DAYS +``` + +Defaults: **3 matches in last 365 days**. Inactive players still have a +computed TSR, exposed in admin views and via the API but not on the public +leaderboard. + +--- + +## Computation Strategy + +TSR is **not** a streaming rating. It is **fully recomputed via chronological +replay** because the recency weight depends on each match's age relative to +_today_, not the time of the original update. + +### Replay procedure (per region) + +1. Collect all non-forfeit, finished, tracked-organizer, classifiable-tier + matches involving any player in scope, sorted chronologically. +2. **Pre-scan**: for each player, find `max_tier_reached`. Initialize their + rating to `TIER_PRIORS[max_tier_reached]` and their match counter to 0. +3. For each match, in order: + - `K_eff = K_base(match_count) * mov_multiplier * gain_dampener(current_rating, sign) * recency_weight(today - match_date)` + - Apply the Elo update against the opposing roster's average rating. + - Increment the player's match counter. +4. Final rating after the last match = today's TSR. + +### Recompute cadence + +- **Daily** scheduled job for the full population. +- **On-demand** triggered by FACEIT `match_status_finished` webhooks scoped to + the tracked organizer GUIDs. Recomputes only the affected players. + +### Cost + +Trivial. A few thousand players × a few hundred matches each replays in +seconds. + +--- + +## Tier classification + +FACEIT exposes neither a tier enum nor a stable `championship_id → tier` +mapping. The `/championships/{id}` endpoint **404s for many active and +archived events** — this was confirmed for the current S8 NA OWCS Central. We +therefore skip that endpoint entirely and classify from the +`competition_name` field on the `/matches/{match_id}` payload, which is +populated reliably. + +The classifier is a regex chain. Order matters — earlier rules win: + +```ts +function classifyTier(name: string): Tier { + const n = name.toLowerCase(); + + // OW2 is 5v5; explicitly reject mini formats. These run under the literal + // "faceit" organizer (1v1/2v2/3v3 trials, brawl learnings) and would skew + // Elo wildly if mixed with tournament play. + if ( + /\b1v1\b|\b2v2\b|\b3v3\b|brawl vs brawl|\belimination\b|mini-poke|knight & squire|trial event/.test( + n + ) + ) + return "unclassified"; + + // OWCS umbrella. Open Qualifiers / OQ Phase / generic "Qualifier" within + // an OWCS-named event reduce to the open prior — most OQ participants are + // open-tier teams attempting to break in. Anything else under the OWCS + // umbrella (Main Event, Group X, Playoffs, Regular Season, Stage X) takes + // the OWCS prior. OWWC (Overwatch World Cup) is also OWCS-tier prestige. + if (/owcs|champions series|\bowwc\b/.test(n)) { + if (/open\s*qualif|\boq\b|\bqualifier\b/.test(n)) return "open"; + return "owcs"; + } + + if (/master/.test(n)) return "masters"; + if (/expert/.test(n)) return "expert"; + if (/advanced/.test(n)) return "advanced"; + if (/calling all heroes|\bcah\b/.test(n)) return "cah"; + + // FACEIT-run cups (Overdrive, WASB) and generic open / qualifier events. + if (/open|qualif|cup|showdown/.test(n)) return "open"; + + return "unclassified"; +} +``` + +**Admin override remains required.** Edge cases like `Stage 4 Swiss Skip`, +`Tiebreaker Citrus Nation vs Critical Roll`, or one-off invitationals fall +through to `unclassified` and need to be hand-tagged before contributing to +TSR. + +--- + +## Tracked organizer registry + +The v1 spec listed two organizers and acknowledged the CAH GUID as TBD. The +dry-run discovered four practical organizers covering the bulk of NA/EMEA OW2 +competitive play: + +| Organizer GUID | What it runs | +| -------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| `faceit_ow2` | FACEIT-run cups: Overdrive Cup, WASB Cup | +| `abd401de-e6ec-4ef1-8d4b-3d820f8f62ce` | OWCS 2024 (NA + EMEA Stage 1–4), Dreamhack Dallas, OWWC Conference Cups | +| `f0e8a591-08fd-4619-9d59-d97f0571842e` | FACEIT League S1–S8 (Master / Expert / Advanced / Open Central) + OWCS Central S4–S8 + OWCS 2025/2026 OQs + EWC Master qualifiers + LCQ | +| `37d7c27f-ddb7-4c2c-91d5-771cfe3376cd` | Calling All Heroes (CAH) | + +**Drift caveat.** OWCS 2024 ran under `abd401de-...`. Mid-2024 the FACEIT +Master league transitioned to OWCS Central under the new organizer +`f0e8a591-...`, which now hosts essentially all current high-tier NA play. +The list above must be revisited every season — a per-season organizer +discovery script (sweep new championships, classify, propose tracking) is the +right ongoing maintenance posture, not a hardcoded constant. + +The literal `faceit` organizer (no GUID, just the string `"faceit"`) is the +mini-format / trial-events organizer and is **explicitly excluded**. + +--- + +## Regional Handling + +Two separate ladders: **NA** and **EMEA**. Each player has at most one +regional TSR (assigned by their first observed FACEIT region). + +**Cross-region matches** (LAN events, OWWC inter-conference): update each +side's regional rating using the opponent's regional rating directly, as if +they were in the same league. Over time, this keeps the two ladders calibrated. + +**APAC and China** (WDG / NetEase OWCS) are out of scope for v1 — those +tournaments are not on FACEIT. + +**Drift caveat.** Cross-region play is rare. The NA / EMEA ladders may drift +relative to each other between LAN events. Acceptable for v1. + +--- + +## Team TSR + +``` +team_tsr = mean(player_tsr for player in starting_5) +``` + +Plain mean across the 5 starters. Role-weighting and "weakest link" floors +are deferred until we have real data to A/B against. + +Output structure: + +```ts +{ + value: number, // 1–5000 + confidence: "high" | "medium" | "low", + source: "tsr" | "predicted" | "csr_fallback" +} +``` + +--- + +## Unrostered Player Handling + +| Real TSRs on team | Behavior | +| ----------------- | -------------------------------------------------------------------------------------- | +| 5 of 5 | All real; `source = "tsr"`, `confidence = "high"` | +| 4 of 5 | Predict TSR for the 1 unrostered; `source = "predicted"`, `confidence = "high"` | +| 3 of 5 | Predict TSR for the 2 unrostered; `source = "predicted"`, `confidence = "medium"` | +| 0–2 of 5 | Team rating uses Raw CSR for everyone; `source = "csr_fallback"`, `confidence = "low"` | + +### Predicted TSR mechanic + +``` +team_offset = mean(player.TSR - player.composite_CSR for player in rostered_with_tsr) +predicted_TSR = clamp(unrostered.composite_CSR + team_offset, 1, 5000) +``` + +If `stdev(teammate_offsets) > THRESHOLD`, downgrade `confidence` to `"low"` +but still produce the prediction. Threshold to be tuned post-launch. + +### CSR fallback team rating + +When `source = "csr_fallback"`: + +``` +team_rating = mean(player.composite_CSR for player in starting_5) +``` + +This rating is **not comparable** to TSRs on the same scale — the UI must +label it distinctly. + +--- + +## Data Sources + +### FACEIT Data API + +- Endpoint: `https://open.faceit.com/data/v4/` +- Auth: server-side API key, `Authorization: Bearer ` +- Rate limit: 10,000 req/hr per key +- Game ID: `ow2` + +**Cloudflare caveat.** Some FACEIT endpoints return Cloudflare interstitials +when called via raw `curl`. Use Bun/Node `fetch` (which works consistently), +or set browser-like headers. The dry-run uses Bun and hits zero CF challenges. + +### Webhooks + +Subscribe to `match_status_finished` (and `match_status_cancelled`, +`match_status_aborted` to flag forfeits) scoped to the tracked organizer GUIDs. + +### Player identity mapping + +FACEIT exposes `game_player_id` for `ow2` players, which is the BattleTag they +linked at registration. Reverse-lookup endpoint: + +``` +GET /players?game=ow2&game_player_id= +``` + +Returns the FACEIT `player_id` (GUID), the canonical key. + +For nicknames that don't map cleanly to FACEIT handles (pros frequently use a +suffix like `pge4` on FACEIT), fall back to: + +``` +GET /search/players?nickname=&game=ow2&offset=0&limit=20 +``` + +Rank candidates by `verified` flag first, then by OW2 `skill_level` desc. + +A `BattletagAlias` override table maps Parsertime BattleTags to FACEIT +`player_id` for renamed accounts (Battle.net linking on FACEIT is one-shot). + +### Endpoints to avoid + +`/championships/{id}` returns 404 for many active and archived events. The +match payload (`/matches/{match_id}`) carries `competition_name`, +`competition_type`, and `organizer_id` directly — use those for tier +classification and organizer verification. The dry-run script never calls +`/championships/{id}` and works correctly. + +--- + +## Schema (sketch — not final) + +``` +FaceitPlayer + faceit_player_id pk GUID + battletag indexed + faceit_nickname + region NA | EMEA | OTHER + verified bool + ow2_skill_level int + +Tournament + faceit_championship_id pk + organizer_id + name + tier open | advanced | expert | masters | owcs | cah | unclassified + region + start_date + classified_by userId | "auto" + classified_at + +TournamentMatch + faceit_match_id pk + championship_id fk + team1_id, team2_id + format bo3 | bo5 | bo7 + team1_score, team2_score + status finished | cancelled | aborted + finished_at + organizer_id // denormalized from match payload for fast filtering + +TournamentRoster + match_id fk + team_side team1 | team2 + faceit_player_id fk + +TsrRosterOverride + match_id fk + faceit_player_id + action include | exclude + +BattletagAlias + battletag + faceit_player_id + +PlayerTsr + faceit_player_id pk + region + rating 1–5000 + match_count + recent_match_count_365d + max_tier_reached + computed_at +``` + +--- + +## Calibration Reference + +These are sanity-check targets, not contracts. The dry-run validates the +algorithm's _shape_ matches reality; absolute numbers depend on each player's +current form. + +| Scenario | Expected TSR | +| ----------------------------------------------- | ------------ | +| Top OWCS NA, sustained recent results | 4000–4350 | +| OWCS bottom-of-table, mixed record | 3500–3800 | +| OWCS player on a rough current run (e.g. 1W-3L) | 3700–3900 | +| Top-2 Masters, recent close OWCS qualifier loss | 3300–3550 | +| Mid-Masters | 3200–3450 | +| Top-4 Expert finisher | 2900–3150 | +| Mid-Advanced | 2700–2900 | +| Open champion, never advanced | 2600–2800 | +| Open team going 0-3 in groups, repeatedly | 1800–2300 | +| Unranked scrim-only player | No TSR | + +--- + +## Out of Scope for v1 + +- APAC / China OWCS (not on FACEIT) +- FACEIT public matchmaking Elo as a signal (different distribution) +- FPL hub matches (top-NA invite-only daily ladder; tempting but not a + bracketed tournament) +- Per-map updates (FACEIT's OW2 stats payload doesn't reliably attribute map + wins to players) +- Role-weighted team aggregation +- Drift toward prior during inactivity beyond what recency decay provides +- Numeric rating uncertainty (Glicko-style RD); the source/confidence enum is + the v1 surrogate + +--- + +## Open Items + +1. **Per-season organizer discovery script.** Sweep new championships under + known and adjacent organizers, propose tier classifications, queue for + admin review. Replaces the "hardcoded GUID list" with a self-maintaining + registry. +2. **Variance threshold** for downgrading prediction confidence — pick after + the first dry-run produces real teammate-offset distributions. +3. **Admin UI** for tier classification + sub overrides — wireframe before + building. +4. **OWWC seeding policy.** OWWC matches go through Conference Cups + (regional → inter-region). Confirm whether OWWC-only players (no FACEIT + League history) should be tracked at all, given the limited match volume. +5. **EMEA seed validation.** The current dry-run is NA-anchored. Run with an + EMEA Master/OWCS player as seed to confirm regional separation works as + intended and the EMEA ladder calibrates similarly. + +--- + +## Dry-run script + +`scripts/tsr-dry-run.ts` — run with `FACEIT_API_KEY= bun +scripts/tsr-dry-run.ts [seed-nickname]`. Pulls the seed player + opponents +(BFS one hop), replays the algorithm in-memory, and prints both an active +leaderboard and a small inactive tail. Cache lives in +`/tmp/parsertime-tsr-cache` — delete it to force a fresh fetch. diff --git a/docs/wp-gbm-bakeoff-findings.md b/docs/wp-gbm-bakeoff-findings.md new file mode 100644 index 000000000..7e977d533 --- /dev/null +++ b/docs/wp-gbm-bakeoff-findings.md @@ -0,0 +1,106 @@ +# WP GBM bake-off — findings (Phase 2a) + +**Date:** 2026-06-14 · **Verdict: GO on Phase 2b** (productionize a gradient-boosted-tree +model on the existing 21-feature set; do **not** add hero composition). + +Offline comparison of LightGBM vs the shipped logistic regression (model-v2), per mode, +on identical grouped-CV folds. Prototype: `scripts/wp/gbm-prototype/`. Design: +`docs/superpowers/specs/2026-06-14-wp-gbm-bakeoff-design.md`. + +## Greenlight rule (set before the run) + +Productionize GBM only if it beats LR by **≥ ~3% relative log loss on the decisive subset** +(snapshots where |calibrated WP − 0.5| > 0.3) for **control and escort**, while holding +aggregate calibration. The decisive subset is the lens because pooled log loss is dominated +by near-coin-flip snapshots and undersells a sharper model — and decisive moments are what a +coaching tool is read for. + +## Harness validation (trust anchor) + +The harness's own LR reproduces the shipped model's calibrated CV log loss within ±0.0005, +proving the fold port (FNV-1a, matched to `cv.ts`), calibration (20-bin PAVA), and metrics +are faithful — so the GBM comparison is apples-to-apples: + +| Mode | harness LR | shipped LR | Δ | +| ------------- | ---------- | ---------- | ------- | +| control | 0.6180 | 0.6180 | +0.0000 | +| escort_hybrid | 0.6633 | 0.6635 | −0.0002 | +| flashpoint | 0.5886 | 0.5891 | −0.0005 | + +GBM library: LightGBM 4.6.0 (no fallback). Conservative, untuned params +(`n_estimators=400, lr=0.05, num_leaves=31, min_child_samples=200`), so these numbers are a +**lower bound** on GBM's potential. + +## Results + +Calibrated CV log loss (lower = better); decisive = subset with |WP−0.5| > 0.3. + +| Mode | config | aggregate | cal max-dev | decisive | decisive n | +| ----------------- | ------- | --------- | ----------- | ---------- | ---------- | +| **control** | LR-21 | 0.6180 | 0.003 | 0.4318 | 88,992 | +| | GBM-21 | 0.5970 | 0.003 | **0.3738** | 139,613 | +| | GBM-123 | 0.5946 | 0.002 | 0.3785 | 148,471 | +| **escort_hybrid** | LR-21 | 0.6633 | 0.024 | 0.5110 | 364 | +| | GBM-21 | 0.6379 | 0.059 | **0.4419** | 66,076 | +| | GBM-123 | 0.6433 | 0.022 | 0.4744 | 22,259 | +| **flashpoint** | LR-21 | 0.5886 | 0.006 | 0.3461 | 136,312 | +| | GBM-21 | 0.5841 | 0.004 | **0.3340** | 141,206 | +| | GBM-123 | 0.5936 | 0.006 | 0.3589 | 125,734 | + +**Decisive relative improvement, best GBM vs LR:** control **+13.4%**, escort **+13.5%**, +flashpoint **+3.5%**. All three clear the 3% bar; control and escort clear it ~4×. + +## What it means + +1. **The model class was the bottleneck, decisively.** GBM-21 — the _same 21 features_, just + trees instead of logistic regression — delivers almost the entire gain (+13% decisive on + control and escort). This cleanly isolates model capacity, not feature engineering, as the + lever. It confirms the hypothesis that motivated Phase 2. + +2. **The LR was pathologically timid at decisive moments — GBM fixes it.** On escort the LR + produces only **364** confident predictions across 1.25M snapshots; GBM-21 confidently + (and accurately) handles **66,076**. This is the model card's "conservative by + construction, under-sharpens decisive moments" limitation made concrete — exactly the + states a coaching tool exists to explain. The qualitative win here exceeds the log-loss + number. + +3. **Hero identity still does not pay off — even under trees.** GBM-123 (+102 hero columns) + is marginally better than GBM-21 on control _aggregate_ but **worse on the decisive + subset** there, and worse on escort and flashpoint. Hero features do earn nonzero + importance (notably flashpoint, where Tracer/Sojourn/Genji rank 3rd–6th), so there is + _some_ signal — but not enough to generalize at this data scale; it adds CV-time variance. + Shelve hero composition again; the lift is in the model, not these features. + +4. **One calibration flag for Phase 2b.** GBM-21 on escort has aggregate calibration + max-deviation **0.059** — below the 0.1 production gate, but notably worse than LR (0.024) + and than GBM-123 (0.022). GBM trades some calibration tameness for decisiveness; Phase 2b + should budget recalibration iterations (isotonic on holdout, as today) and re-check the + gate per mode before shipping. + +### Top GBM-123 importances (gain %, abridged) + +- **control:** scoreDiff 6.0, controlProgressOwn/Enemy ~4, objProgressOwn/Enemy ~3.7, + roundNumberNorm 2.7; first hero feature (`enemy_hero_tracer`) at rank 10 (~1.8%). +- **escort_hybrid:** objProgress ~2.9, scoreDiff 2.8, timeRemainingNorm 2.5; heroes from + rank 9, dominated by supports (Ana, Baptiste, Kiriko, Lúcio). +- **flashpoint:** scoreDiff 5.0, isAttacker 2.5, then **heroes at ranks 3–6** + (Tracer/Sojourn/Genji) — the one mode where composition is meaningfully predictive, yet + GBM-123 still loses to GBM-21 on the decisive subset (overfit on the smaller sample). + +## Recommendation for Phase 2b + +Build the production GBM engine on the **21-feature set** (GBM-21). Open design forks to +resolve there (sketched in the earlier Vercel-Python research): + +- **Training runtime:** Vercel Python cron vs GitHub Actions (LightGBM is Python; can't run + in the current in-process TS cron). +- **Serving:** hand-rolled TS tree-traversal inference to keep the match-story hot path + dependency-free and Python-free (only training is Python). +- **Artifact:** a tree schema alongside the current linear `FamilyModel`, behind the same + featureHash guard and R2 versioning. +- **Per-mode rollout** under the existing grouped-CV calibration gates; re-tune calibration + for escort (the 0.059 flag), and consider light hyperparameter tuning (this prototype was + untuned, so production GBM should do at least as well). + +Hero composition stays shelved. The next _feature_ bet, if pursued, is positional/coordinate +data — not more hero columns. diff --git a/docs/wp-model-card.md b/docs/wp-model-card.md new file mode 100644 index 000000000..76a51a9e9 --- /dev/null +++ b/docs/wp-model-card.md @@ -0,0 +1,197 @@ +# Model card — Parsertime win probability (Match Story) + +**Model:** `wp-models/model-v3.json` · feature hash `27b4a8ec1f49` · trained 2026-06-14 +**Architecture:** per-mode **gradient-boosted trees (LightGBM)** + isotonic recalibration, +selected per mode by champion/challenger over the prior logistic-regression model +**Serving:** pure-TypeScript tree inference; Cloudflare R2 artifact, hourly-TTL cached loader +**Training:** weekly Vercel Python function (LightGBM) fed by a TS feature-matrix export + +## What it predicts + +For any moment in a parsed scrim map, the probability that a given team wins +the **current round** (Control) or the **map** (Escort/Hybrid, Flashpoint), +from the game state at that moment. Match Story uses it to draw the WP +timeline, price each fight's swing (WP after − WP before), compute +counterfactual carryovers (re-scoring a state with ult banks or alive counts +neutralized), attribute per-player WPA, and decompose swings into drivers. + +The target differs by mode on purpose: a lone Escort round has no +self-contained winner — the map does — so escort numbers answer "will we win +the map," which dilutes single-fight effects relative to Control. + +## Training data + +All teams' parsed scrims, globally pooled and team-anonymous — the model +learns _what game states win_, not anything about specific teams. From ~7,200 +usable maps the export produces ~2.7M labeled snapshots (one every 5 seconds +plus every fight boundary, two mirrored rows per snapshot so the model is +exactly antisymmetric between teams). + +Labels: Control rounds by per-round score delta; other modes by the canonical +`calculateWinner` (captures → farthest distance → score). Score-tied +flashpoint logs are excluded rather than force-labeled. Clash is excluded +(retired mode). **Push is excluded**: the logs carry no robot distance signal, +so Push winners are underivable, and it has only ~17 distinct maps — far below +the map-count gate. + +## Features (21) + +Game state is reconstructed from log events in one pass +(`src/lib/win-probability/game-state.ts`); the same code feeds training and +inference, and the artifact embeds a feature-list hash so a mismatched model +refuses to load. The feature set is unchanged from the prior LR model — the GBM +trains on the same 21 features (the bake-off rejected adding hero composition, +see below). + +| Group | Features | +| --------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Kills | `tankAliveDiff`, `dpsAliveDiff`, `supportAliveDiff` (respawn-adjusted, 10s constant, split by the victim's hero role), `aliveDiff × objMax`, `aliveDiff × controlMax` | +| Ults | `tankUltDiff`, `dpsUltDiff`, `supportUltDiff` (charged-unspent banks, by role; Echo duplicates count as Damage), `ultBankDiff × timeRemaining` | +| Objective | `scoreDiff`, `objProgressOwn/Enemy` (contest %), `controlProgressOwn/Enemy` (control win %), `holdsObjective` (±1), `objectiveIndexNorm`, `scoreDiff × roundNumber` | +| Context | `timeRemainingNorm`, `isAttacker`, `roundNumberNorm`, `isOvertime` | + +The hand-built interaction features (`aliveDiff × objMax`, etc.) are retained but +are now largely redundant — a tree model discovers interactions itself, and they +rank low in gain importance. The team-key parsing correctness fix (a prior +revision) — the per-player sweep key uses a tab delimiter because team names +contain spaces — remains essential; without it `aliveDiff` was noise on ~92% of +maps. + +## Model: per-mode GBM via champion/challenger + +Each weekly retrain trains a LightGBM model per mode and, for that mode, ships it +only if it **passes the calibration gate AND beats the incumbent's CV log loss**; +otherwise the incumbent family (which may be the prior LR) is carried forward +verbatim. The artifact is therefore a per-mode mix of model kinds — a tree family +`{ kind: "gbm", trees, baseScore, calibration, metrics }` or the legacy linear +family `{ kind: "lr", weights, … }` — behind one feature hash. A `kind`-less +family loads as LR (backward compatibility). model-v3 ships GBM for all three +shipped modes. + +LightGBM params (fixed, untuned): 400 trees, learning_rate 0.05, num_leaves 31, +min_child_samples 200, subsample/colsample 0.8, reg_lambda 1.0. Light +hyperparameter tuning is a future improvement; the gate + champion/challenger +prevent a bad model shipping. + +## Per-family results (5-fold CV, grouped by map; calibrated outputs) + +| Family | Model | Maps | Log loss (base 0.693) | Status | vs prior LR | +| ------------- | ----- | ------ | --------------------- | -------------------- | -------------- | +| Control | GBM | ~2,300 | 0.5966 | shipped | 0.6180 (−3.5%) | +| Escort/Hybrid | GBM | ~3,300 | 0.6378 | shipped | 0.6635 (−3.9%) | +| Flashpoint | GBM | ~1,560 | 0.5841 | shipped | 0.5891 (−0.8%) | +| Push | — | 17 | — | disabled (no labels) | — | + +Pooled log loss undersells the gain: the win is concentrated at **decisive +moments**. On snapshots where |WP−0.5| > 0.3, the bake-off measured GBM beating +LR by **~13% relative log loss on control and escort** and ~3.5% on flashpoint — +and the LR was pathologically timid (on escort it made only 364 confident +predictions across 1.25M snapshots vs GBM's 66,076). Full bake-off: +`docs/wp-gbm-bakeoff-findings.md`. + +Cross-validation folds are **grouped by map** — snapshots from the same round are +heavily autocorrelated, and a random split would leak. A `MIN_FAMILY_MAPS = 100` +gate disables any family with too few distinct maps for grouped CV to be +meaningful (this is what keeps Push out). + +**Calibration is the headline property, not accuracy.** Every shipped bin +(predicted vs. observed win rate, 10 bins) is within a few points of the diagonal; +the deploy gate fails any retrain whose calibrated bins drift more than 10 points +(n ≥ 200) or that loses to the base rate. An isotonic recalibration layer (PAVA +over held-out CV predictions, stored as knots in the artifact) sits on top of the +tree ensemble's sigmoid output. (Escort GBM calibration is the tightest case — +max-deviation ~0.06, within gate; watched per retrain.) + +## Feature importance (GBM gain) + +Trees have no signed coefficients; gain importance shows what they split on. +Objective state dominates every mode — `scoreDiff`, `controlProgressOwn/Enemy`, +`objProgressOwn/Enemy`, `holdsObjective` — with the role-split alive/ult features +secondary and the hand-built interaction columns ranking low (the model +rediscovers those interactions itself). `isOvertime`/`objectiveIndexNorm` remain +near-unused. + +## Driver decomposition (the "why" in Match Story) + +The fight-swing breakdown into objective/kills/ults is now **model-agnostic +ablation** (it must be — trees aren't linearly decomposable). For each driver +group, the swing is re-scored with that group's GameState fields reset to their +before-fight values; the WP the group "loses" is its contribution, normalized so +the parts sum to the swing. This works identically for GBM and LR and replaced +the old LR-only linear identity. It measures actual WP impact rather than +standardized-logit shares; it inherits the model's blind spots, and the "other" +context features (time, attacker role) are unattributed by design. + +## Serving & training pipeline + +- **Inference (TS, hot path):** `predictWinProbability` branches on family kind. + GBM = `isotonic(sigmoid(baseScore + Σ reached-leaf values))` via a hand-rolled + tree traversal — no Python on the request path. A committed parity fixture + (`test/win-probability/fixtures/gbm-parity.json`) asserts the TS traversal + reproduces LightGBM's own probabilities to <1e-6; it is the regression guard on + the inference port. +- **Training (weekly):** the Vercel cron route exports per-mode feature matrices + (CSV) to Vercel Blob and fires a Vercel **Python** function (`api/wp-train/`) + that trains LightGBM, calibrates, and gates each mode, then gzip-POSTs the + candidate families + gate flags to a TS publish callback (`/api/cron/wp-publish`). + The publish route loads the live incumbent from R2, runs the per-mode + champion/challenger decision (`chooseFamily`), and publishes — so both the + incumbent load and the R2 publish stay single-sourced in TypeScript, the + incumbent never travels the wire, and Python never holds R2 credentials. (The + payload is gzipped because the raw artifact is ~4.4MB, near Vercel's 4.5MB body + limit.) + +## Deployment + +Required Vercel env vars for the weekly retrain (`api/wp-train/`): + +| Var | Required | Description | +| ----------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------- | +| `CRON_SECRET` | yes | Bearer token; must match the `/api/cron/wp-retrain` and `/api/cron/wp-publish` routes | +| `WP_FEATURE_HASH` | yes | Must equal the TS `featureHash()` (currently `27b4a8ec1f49`); the publish route 400-rejects an artifact whose hash mismatches | +| `PUBLISH_URL` | yes | Full URL of the publish callback, e.g. `https:///api/cron/wp-publish` | + +## Limitations — read before trusting an edge case + +- **Respawns are a 10-second constant**; real respawn timing varies (overtime, + assemble phases). Stagger carryovers are approximate. +- **Hero identity does not help — confirmed under both model classes.** A team + hero-composition multi-hot (own+enemy, 102 columns) was tested with LR (made + every mode worse) and again in the GBM bake-off (GBM-123 vs GBM-21: flat-to- + worse). Trees find some hero signal (flashpoint especially) but not enough to + generalize. Shelved; the next feature bet is positional/coordinate data. +- **No positions.** Coordinate data is unused; spatial features remain untapped. +- **WPA is attribution by convention**, not causality: final blows, assists, + deaths (first weighted heaviest), and ult usage split a fight's swing. The UI + exposes the per-fight breakdown precisely so it can be audited. +- The model scores **states, not decisions** — it cannot know an ult was + "wasted," only that the bank changed and the fight was lost. +- **GBM is untuned** (fixed params); the gate and champion/challenger are the + guards against a bad retrain, and any mode that regresses falls back to its + incumbent rather than shipping. + +## Privacy & governance + +The artifact contains only model parameters (tree structures / linear +coefficients, calibration knots, CV metrics) — no player names, no team +identifiers, no match data. It is never committed to the repository; it lives in +R2 and is versioned (`model-vN.json` + `latest.json` pointer). Weekly retrains +publish per-mode only when the gate passes and the model beats its incumbent; a +failed or worse mode keeps the previous model serving. Rollback is re-pointing +`latest.json`. + +## Reproduction + +Local (the bake-off / first-artifact tooling): + +``` +bun scripts/wp/export-dataset.ts # dev DB → artifacts/wp/dataset-.csv +cd scripts/wp/gbm-prototype && uv run python build_artifact.py # champion/challenger → model-gbm.json +bun scripts/wp/upload-gbm.ts # validate hash + publish to R2 +``` + +Production retrains run weekly via `/api/cron/wp-retrain` → `api/wp-train` → +`/api/cron/wp-publish`. + +Spec and plans: `docs/superpowers/specs/2026-06-14-wp-gbm-engine-design.md`, +`docs/wp-gbm-bakeoff-findings.md`. diff --git a/messages/en.json b/messages/en.json index 3490897b2..17b0762e0 100644 --- a/messages/en.json +++ b/messages/en.json @@ -1,4 +1,288 @@ { + "bulkUpload": { + "dropTitleEmpty": "Drop Workshop logs here", + "dropTitleMore": "Drop more logs to add", + "dropSubtitle": "or click to browse", + "dropHint": ".TXT, UP TO 10MB EACH. {max} PER UPLOAD.", + "versus": "vs", + "noTimestamp": "No timestamp in filename, placed last", + "parseError": "Couldn't read this log", + "reorderMap": "Reorder map", + "removeMap": "Remove map", + "statusUploading": "Uploading", + "statusDone": "Done", + "statusFailed": "Failed", + "heroBansHeader": "Hero bans", + "addHeroBan": "Add hero ban", + "hero": "Hero", + "team": "Team", + "order": "Order", + "heroBansEmpty": "Add the heroes banned by each team. Ban order is preserved.", + "uploading": "Uploading", + "progress": "Uploading map {current} of {total}", + "uploadMaps": "{count, plural, =0 {Upload maps} one {Upload # map} other {Upload # maps}}", + "createScrimWithMaps": "{count, plural, =0 {Create scrim} one {Create scrim with # map} other {Create scrim with # maps}}", + "retryFailed": "{count, plural, one {Retry # failed map} other {Retry # failed maps}}", + "uploadedTitle": "Maps uploaded", + "uploadedCount": "{count, plural, one {# map added to the scrim} other {# maps added to the scrim}}", + "partialFailureTitle": "Some maps didn't upload", + "partialFailureDescription": "The maps that succeeded are saved. Retry the failed ones.", + "createFirstMapFailed": "The first map couldn't be uploaded, so the scrim wasn't created. Fix it and try again.", + "noMapsTitle": "No maps to upload", + "noMapsDescription": "Add at least one Workshop log first.", + "winnerLabel": "Winner:", + "winnerDetectedBadge": "Detected", + "winnerDetectedHint": "We detected this winner from player positions — confirm it or pick the other team.", + "missingPushWinnerTitle": "Pick a Push map winner", + "missingPushWinnerDescription": "Choose the winning team for every Push map before uploading.", + "maxMapsTitle": "Too many maps", + "maxMaps": "You can upload up to {max} maps at a time.", + "fileTypeTitle": "Unsupported file", + "fileType": "Only .txt Workshop logs are supported.", + "fileSizeTitle": "File too large", + "fileSize": "Each log must be under 10MB.", + "addDescription": "Drop one or more Workshop logs to add maps.", + "addTitle": "Add maps", + "addDialogDescription": "Drop your Workshop logs, set any hero bans, and upload. Maps keep the order shown." + }, + "queryBuilderPage": { + "title": "Query", + "subtitle": "Build a question one piece at a time. It reads like a sentence and runs as a real, team-scoped query. Hover any part to see the tables and columns underneath.", + "plannerLabel": "Question planner", + "plannerPlaceholder": "I need to know the number of final blows PGE has on Widowmaker versus the time he has played it in scrims", + "planQuestion": "Plan", + "plannedQuery": "Query planned", + "planFailed": "I could not plan that question yet.", + "plannerInterpreted": "Interpreted as", + "plannerDataset": "Data: {dataset}", + "plannerFilterCount": "{count, plural, =0 {no filters} one {# filter} other {# filters}}", + "sentenceLabel": "Query sentence", + "yourTeam": "your team", + "clauseFrom": "from", + "clauseShow": "show", + "clausePer": "per", + "clauseFor": "for", + "clauseWhere": "where", + "clauseAcross": "across", + "clauseSortedBy": "sorted by", + "clauseTop": "top", + "overall": "everyone", + "noFilters": "no filters", + "selectTeam": "select a team", + "filterAny": "any", + "sortDefault": "default order", + "limitAll": "all rows", + "topValue": "top {n}", + "addMetric": "Add a metric", + "addDimension": "Add a grouping", + "addFilter": "Add a filter", + "editDataset": "Change the data source", + "editMetric": "Edit this metric", + "editDimension": "Edit this grouping", + "editTeam": "Choose a team", + "editFilter": "Edit this filter", + "editScope": "Edit the scrim scope", + "editSort": "Edit the sort order", + "editLimit": "Edit the row limit", + "removeToken": "Remove", + "techTable": "{count, plural, one {Table} other {Tables}}", + "techColumn": "{count, plural, one {Column} other {Columns}}", + "searchPlaceholder": "Search", + "searchFields": "Search fields...", + "noResults": "No matches.", + "pickMetric": "Search metrics", + "pickDataset": "Search data sources", + "pickDimension": "Search groupings", + "pickFilter": "Search filters", + "opEq": "is", + "opNeq": "is not", + "opIn": "is any of", + "opGt": "greater than", + "opGte": "at least", + "opLt": "less than", + "opLte": "at most", + "loadingOptions": "Loading options", + "noOptions": "No values in this team's data.", + "scopeAll": "all scrims", + "scopeLastN": "last N scrims", + "scopeDateRange": "date range", + "scopeAllValue": "all scrims", + "scopeLastNValue": "the last {n} scrims", + "scopeRangeValue": "{from} to {to}", + "lastNLabel": "Number of recent scrims", + "from": "From", + "to": "To", + "scopeNote": "Scrims are ordered by date; the window decides which ones feed the query.", + "ascending": "Low to high", + "descending": "High to low", + "noSortable": "Add a metric or grouping first.", + "sortNone": "Clear sort", + "limitNone": "All", + "limitCustom": "Custom", + "teamScopeNote": "Every query is locked to scrims owned by this team. You can only pick teams you have access to.", + "compiledTitle": "Compiled query", + "compiledTables": "Tables", + "compiledEmpty": "Finish the sentence to see the compiled query.", + "copy": "Copy", + "copied": "Copied", + "emptyTitle": "Nothing run yet", + "emptyBody": "Shape the sentence above, then run it to see a table or chart here.", + "errorTitle": "That query did not run", + "errorBody": "Something went wrong. Try adjusting the sentence and run again.", + "zeroTitle": "No rows matched", + "zeroBody": "This team has no data that fits the query. Try loosening a filter or widening the scrim scope.", + "viewTable": "Table", + "viewChart": "Chart", + "viewSql": "SQL", + "askEyebrow": "Ask", + "sentenceEyebrow": "Build", + "outputEyebrow": "Output", + "outputTitle": "Results", + "chartType": "Chart type", + "chartTypeBar": "Bar", + "chartTypeLine": "Line", + "chartTypeArea": "Area", + "chartTypeDonut": "Donut", + "metaRows": "{count, plural, one {# row} other {# rows}}", + "metaScrims": "{count, plural, one {# scrim} other {# scrims}}", + "metaDuration": "{ms}ms", + "metaTruncated": "limit reached", + "chartCapped": "Charting the first {shown} of {total} rows.", + "needTeam": "Pick a team to run.", + "needMetric": "Add at least one metric.", + "run": "Run", + "running": "Running", + "save": "Save", + "saveTitle": "Save this query", + "saveNamePlaceholder": "Name this query", + "saveConfirm": "Save query", + "export": "Export CSV", + "savedQueries": "Saved", + "savedEmpty": "No saved queries yet.", + "deleteQuery": "Delete saved query", + "loadedQuery": "Loaded \"{name}\"", + "savedQuery": "Query saved", + "saveFailed": "Could not save the query.", + "noAccessTitle": "No teams to query", + "noAccessBody": "You do not have access to any teams yet. Join or create a team to start querying its scrim data.", + "metadata": { + "title": "Query Builder | Parsertime", + "description": "Build custom queries across your Overwatch scrim data." + } + }, + "analyst": { + "metadata": { + "title": "Analyst | Parsertime", + "description": "Your AI analyst for Overwatch scrim data, player performance, and team trends." + }, + "eyebrow": "Analyst", + "empty": { + "title": "Read your scrim data, fast.", + "description": "Ask about team performance, fight breakdowns, map win rates, and player trends. I pull straight from your uploaded scrims.", + "tryAsking": "Try asking", + "suggestions": [ + "How did my team perform in our last scrim?", + "Which maps do we have the best win rate on?", + "What are our performance trends this month?", + "Who had the most outlier stats recently?" + ], + "blocked": { + "description": "Add credits to start a conversation. Analyst is pay-as-you-go, $5 minimum.", + "addCredits": "Add credits" + } + }, + "composer": { + "placeholder": "Ask about your team's performance…", + "placeholderBlocked": "Add credits to continue chatting…", + "send": "Send message", + "stop": "Stop generating" + }, + "blockedBanner": { + "message": "Your balance is too low to send a new message. Add credits to keep chatting; past conversations stay visible in read-only mode.", + "addCredits": "Add credits" + }, + "edit": { + "cancel": "Cancel", + "resend": "Resend" + }, + "actions": { + "copy": "Copy", + "regenerate": "Regenerate", + "edit": "Edit" + }, + "newConversation": "New conversation", + "sidebar": { + "heading": "Chats", + "newChat": "New chat", + "deleteConversation": "Delete conversation: {title}", + "empty": "No conversations yet" + }, + "balanceChip": { + "balanceLabel": "AI chat balance", + "addCreditsLabel": "Add credits to continue" + }, + "cards": { + "record": { + "wins": "{count}W", + "losses": "{count}L", + "draws": "{count}D", + "winsLosses": "{wins}W · {losses}L" + }, + "loading": { + "writingReport": "Writing report" + }, + "scrimAnalysis": { + "eyebrow": "Scrim analysis", + "vs": "vs", + "acrossMaps": "across {count} maps", + "fightWr": "Fight WR", + "fightsWon": "Fights won", + "totalFights": "Total fights", + "players": "Players", + "insights": "Insights" + }, + "mapPerformance": { + "eyebrow": "Map performance", + "overall": "{winrate}% overall" + }, + "teamTrends": { + "eyebrow": "Performance trends", + "winStreak": "{count}-win streak", + "lossStreak": "{count}-loss streak", + "noStreak": "No active streak", + "weeklyWinRate": "Weekly win rate", + "last5": "Last 5", + "last10": "Last 10", + "last20": "Last 20" + }, + "player": { + "eyebrow": "Player", + "summary": "{mapCount} maps on {heroes}", + "kd": "K/D", + "elims": "Elims/10", + "deaths": "Deaths/10", + "damage": "Dmg/10", + "healing": "Heal/10" + }, + "report": { + "eyebrow": "Report created", + "viewReport": "View report" + }, + "teamOverview": { + "scrims": "{count} scrims" + }, + "scrimList": { + "eyebrow": "Recent scrims", + "found": "{count} found", + "maps": "{count} maps" + }, + "trend": { + "up": "Up", + "down": "Down", + "stable": "Stable" + } + } + }, "maps": { "aatlis": "Aatlis", "antarctic-peninsula": "Antarctic Peninsula", @@ -37,6 +321,37 @@ "throne-of-anubis": "Throne of Anubis", "watchpoint-gibraltar": "Watchpoint: Gibraltar" }, + "ui": { + "pagination": { + "ariaLabel": "pagination", + "previousPage": "Go to previous page", + "nextPage": "Go to next page", + "previous": "Previous", + "next": "Next", + "morePages": "More pages" + }, + "carousel": { + "previousSlide": "Previous slide", + "nextSlide": "Next slide" + }, + "dialog": { + "close": "Close" + }, + "sheet": { + "close": "Close" + }, + "sidebar": { + "title": "Sidebar", + "mobileDescription": "Displays the mobile sidebar.", + "toggle": "Toggle Sidebar" + }, + "opponentSearch": { + "placeholder": "Search OWCS teams…", + "clearSelection": "Clear opponent selection", + "teams": "OWCS teams", + "noTeamsFound": "No teams found." + } + }, "heroes": { "ana": "Ana", "anran": "Anran", @@ -73,6 +388,7 @@ "reaper": "Reaper", "reinhardt": "Reinhardt", "roadhog": "Roadhog", + "sierra": "Sierra", "sigma": "Sigma", "sojourn": "Sojourn", "soldier76": "Soldier: 76", @@ -90,7 +406,33 @@ "zenyatta": "Zenyatta" }, "landingPage": { + "pipeline": { + "eyebrow": "How it works", + "title": "From raw logs to coaching calls", + "description": "Drop your Workshop logs in after the block. Parsertime parses every event, computes ratings and positioning, and turns the night's scrims into dashboards before VOD review starts.", + "sourcesLabel": "Scrim logs", + "processingLabel": "Parsertime", + "outputsLabel": "Your dashboards", + "outputDashboards": "Team dashboards", + "outputRatings": "Hero skill ratings", + "outputReplays": "Replays & heatmaps", + "outputTrends": "Trend lines" + }, + "positional": { + "badge": "New in v3", + "title": "See the fight from above", + "description": "First-of-its-kind positional analytics for Overwatch 2. Every scrim becomes a top-down replay: watch rotations unfold, find the angles you keep dying to, and measure positioning with numbers no other tool has.", + "replayName": "Map replay viewer", + "replayDescription": "Scrub through any teamfight in a top-down view. Player paths, ult timings, and kill events plotted on calibrated map images.", + "heatmapsName": "Heatmaps", + "heatmapsDescription": "Kill, death, and presence density rendered per map and side. See exactly where fights are won and lost.", + "averagesName": "Positional averages", + "averagesDescription": "Engagement distance, high ground kill rate, isolation deaths: positioning metrics averaged across scrims and trended over time." + }, "hero": { + "liveData": "Live platform data", + "eyebrow": "Scrim analytics · Overwatch 2", + "trustedBy": "Trusted by {count}+ teams", "latestUpdates": "Latest updates", "title": "Every stat the scoreboard doesn't show you", "description": "Parsertime turns raw scrim data into skill ratings, trend lines, and the insights that change how you coach.", @@ -240,6 +582,10 @@ "verification": "The token has expired or has already been used. Please try again.", "adapterError": "There was an error with the database adapter. Please try clearing your cookies and try again. If the problem persists, please contact support.", "default": "There was an unknown error signing in. Please contact support." + }, + "metadata": { + "title": "Authentication Error | Parsertime", + "description": "Something went wrong while signing you in." } }, "noAuth": { @@ -250,7 +596,11 @@ "verifyRequest": { "title": "Check your email for a sign-in link", "description": "We've sent an email to you with a link to sign in. Click the link in the email to complete the sign-in process.", - "back": "Back to Home" + "back": "Back to Home", + "metadata": { + "title": "Check Your Email | Parsertime", + "description": "We sent you a sign-in link to continue." + } }, "signInPage": { "metadataSignIn": { @@ -294,19 +644,21 @@ "dashboard": { "metadata": { "title": "Dashboard | Parsertime", - "description": "Parsertime is a tool for analyzing Overwatch scrims.", + "description": "Your scrims, maps, and team performance at a glance.", "ogTitle": "Dashboard | Parsertime", "ogDescription": "Parsertime is a tool for analyzing Overwatch scrims." }, "title": "Dashboard", "overview": "Your Scrims", "admin": "Admin View", + "pendingFeedback": "{count, plural, one {# scrim awaiting feedback} other {# scrims awaiting feedback}}", "search": "Search...", "themeSwitcher": { "toggle": "Toggle theme", "light": "Light", "dark": "Dark", - "system": "System" + "system": "System", + "disguised": "Disguised" }, "userNav": { "dashboard": "Dashboard", @@ -318,6 +670,10 @@ "signOut": "Sign Out", "profile": "Profile" }, + "guestNav": { + "guestUser": "Guest User", + "signIn": "Sign In" + }, "commandMenu": { "searchPlaceholder": "Type a command or search...", "searchResult": "No results found.", @@ -414,6 +770,7 @@ }, "mainNav": { "dashboard": "Dashboard", + "toggleMenu": "Toggle Menu", "stats": "Stats", "teams": "Teams", "settings": "Settings", @@ -422,11 +779,14 @@ "leaderboard": "Leaderboard", "playerStats": "Player Stats", "heroStats": "Hero Stats", + "mapStats": "Map Stats", "compareStats": "Compare Players", "teamStats": "Team Stats", "scouting": "Scouting", "scoutPlayer": "Scout a player", "scoutTeam": "Scout a team", + "scoutFaceitTeam": "Scout a FACEIT team", + "scoutFaceitPlayer": "Scout a FACEIT player", "dataLabeling": "Data Labeling", "dataTools": "Data Tools", "mapCalibration": "Map Calibration", @@ -434,16 +794,21 @@ "chatNew": "New Conversation", "chatReports": "Reports", "tournaments": "Tournaments", + "query": "Query", "coaching": "Coaching", "coachingCanvas": "Canvas", "yourTeams": "Your Teams", - "availability": "Availability" + "availability": "Availability", + "matchmaker": "Matchmaker", + "ranked": "Ranked Tracker" }, "teamSwitcher": { "searchTeamPlaceholder": "Search team...", "noTeamFound": "No team found.", "individual": "Individual", "teams": "Teams", + "selectTeam": "Select a team", + "loading": "Loading...", "createTeam": "Create Team" }, "createTeam": { @@ -543,12 +908,23 @@ }, "creatingScrim": { "title": "Creating scrim", - "description": "Please wait..." + "description": "Uploading and parsing data, hold tight." }, "createdScrim": { "title": "Scrim created", - "description": "Your scrim has been created successfully.", - "errorTitle": "Error", + "description": "It's on your dashboard now.", + "primary": "Done", + "secondary": "Create another", + "errorTitle": "Couldn't create your scrim", + "errorReassurance": "Your form is still here, nothing was lost.", + "errorFallback": "Invalid log format", + "errorEscape": "If retrying doesn't fix it, the resources below can get you unstuck.", + "errorResourceLabel": "Need help?", + "errorResourceSchema": "Schema", + "errorResourceDebug": "Debug tips", + "errorResourceDiscord": "Discord", + "errorRetry": "Try again", + "errorBack": "Back to form", "errorDescription": "An error occurred: {res}. Please read the docs here to see if the error can be resolved." }, "dataCorruption": { @@ -563,30 +939,36 @@ "description": "Corrupted data was automatically fixed and your scrim has been created successfully." } }, - "scrimName": "Scrim Name", - "scrimPlaceholder": "New Scrim", - "scrimDescription": "This is the name of the scrim. It will be displayed on the dashboard.", + "scrimName": "Scrim name", + "scrimPlaceholder": "e.g. 04/24 mid expert", + "scrimDescription": "Shown on the dashboard.", "scrimRequiredError": "A scrim name is required.", "scrimMinMessage": "Name must be at least 2 characters.", "scrimMaxMessage": "Name must not be longer than 30 characters.", "replayCodeName": "Scrim Replay Code", "replayCodePlaceholder": "Replay Code", - "replayCodeDescription": "This is the replay code of the first map. You can edit the replay codes after the scrim is created.", + "replayCodeDescription": "Replay code for the first map; you can edit replay codes after the scrim is created.", "replayCodeMessage": "Name must not be longer than 6 characters.", - "teamName": "Assign a Team", + "teamName": "Team", "teamPlaceholder": "Select a team", "teamIndividual": "Individual", - "teamDescription": "You can manage teams from your dashboard.", + "teamDescription": "Manage teams from your teams.", "teamRequiredError": "A team name is required.", "teamLoading": "Loading...", - "dateName": "Scrim Date", + "dateName": "Date", "datePlaceholder": "Pick a date", - "dateDescription": "The scrim date is the date when the scrim took place.", + "dateDescription": "When the scrim took place.", "dateRequiredError": "A scrim date is required.", - "mapName": "First Map", - "mapDescription": "Upload the first map of the scrim. Only .xlsx and .txt files are accepted, and max file size is 1MB. You can add more maps after the scrim is created.", - "submit": "Submit", - "submitting": "Submitting...", + "mapName": "First map", + "mapDescription": ".xlsx or .txt, up to 10MB. Add more maps after the scrim is created.", + "mapDropTitle": "Drop a Workshop log here", + "mapDropSubtitle": "or click to browse", + "mapDropParsing": "Parsing scrim data…", + "mapDropParsed": "Parsed", + "mapDropReplace": "Replace", + "mapDropTeamSeparator": "vs", + "submit": "Create scrim", + "submitting": "Creating…", "heroBansName": "Hero Bans", "heroBansDescription": "Add the heroes banned by each team. Ban order will be preserved.", "addHeroBan": "Add Hero Ban", @@ -600,7 +982,42 @@ "cancel": "Cancel", "autoAssignTeamNames": "Auto-assign team names", "autoAssignTeamNamesDescription": "Automatically rename teams and normalize team positions so your team is always Team 1.", - "autoAssignDisabledDescription": "Select a team to enable auto-assign." + "autoAssignDisabledDescription": "Select a team to enable auto-assign.", + "opponentName": "OWCS opponent", + "opponentDescription": "Optional. Links to OWCS scouting analytics.", + "linkedRequestLabel": "Linked matchmaker request", + "linkedRequestNone": "Not from a matchmaker request", + "linkedRequestDescription": "If this scrim came from a matchmaker request, link it to enable post-scrim feedback." + }, + "activity": { + "regionLabel": "Recent activity overview", + "winRate": "Win rate", + "mapsLogged": "Maps logged", + "teamTsr": "Team TSR", + "bestMap": "Best map", + "scrimsLogged": "Scrims logged", + "activeTeams": "Active teams", + "latestScrim": "Latest scrim", + "record": "{wins}–{losses}", + "tsrRated": "{rated}/{total} rated · {confidence}", + "tsrNoData": "Not enough roster data", + "bestMapSub": "{winrate} win rate", + "bestMapNone": "No maps yet", + "never": "No scrims yet", + "trendEyebrowTeam": "Win rate · weekly", + "trendEyebrowAll": "Scrims logged · weekly", + "avgWinrate": "{winrate} avg", + "totalScrims": "{count} over {weeks}w", + "noHistory": "Not enough history to chart yet", + "winsLosses": "{wins} W · {losses} L", + "scrimsCount": "{count, plural, one {# scrim} other {# scrims}}", + "confidence": { + "high": "high confidence", + "medium": "medium confidence", + "low": "low confidence" + }, + "expand": "Expand activity overview", + "collapse": "Collapse activity overview" } }, "updateModal": { @@ -610,11 +1027,130 @@ "monthYear": "{date, date, ::MMMM yyyy}", "weekday": "{weekday, date, ::E}" }, + "availability": { + "timezoneSelect": { + "searchPlaceholder": "Search timezone...", + "empty": "No timezone found.", + "detected": "Detected", + "teamDefault": "Team default", + "regional": "Regional", + "allTimezones": "All timezones" + }, + "indexPage": { + "title": "{teamName} — Availability", + "settings": "Settings", + "notConfiguredManager": "Availability is not configured for this team yet. Set it up", + "notConfiguredViewer": "Availability is not configured for this team yet. Ask an owner or manager to configure it.", + "currentWeek": "Current week: {start} – {end}", + "responsesSoFar": "{count, plural, one {# response} other {# responses}} so far.", + "openShareLink": "Open share link", + "pastWeeks": "Past weeks", + "pastWeekRange": "{start} – {end}", + "pastWeekResponses": "({count, plural, one {# response} other {# responses}})", + "view": "View", + "startThisWeek": "Start this week" + }, + "settingsPage": { + "title": "Availability settings", + "description": "Configure how the shared availability calendar works for your team.", + "metadata": { + "title": "Availability Settings | Parsertime", + "description": "Configure your team's availability schedule and time zone." + } + }, + "settingsForm": { + "slotGranularity": "Slot granularity", + "slotMinutes": "{count} minutes", + "defaultTimezone": "Default timezone (for viewing)", + "startHour": "Start hour (0-23)", + "endHour": "End hour (1-24)", + "midnightHint": "24 = midnight", + "weeklyReminder": "Weekly Discord reminder", + "weeklyReminderDescription": "Bot pings a role with the fill link at the start of each week.", + "day": "Day", + "hour": "Hour", + "minute": "Minute", + "roleId": "Discord role ID to ping", + "guildId": "Guild ID (override)", + "channelId": "Channel ID (override)", + "botConfigPlaceholder": "leave blank to use bot config", + "saving": "Saving...", + "save": "Save settings", + "days": { + "sunday": "Sunday", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday" + }, + "toast": { + "saveFailed": "Failed to save settings", + "saved": "Settings saved" + }, + "validation": { + "endAfterStart": "End hour must be after start hour", + "evenSlots": "Window must divide evenly into slot minutes" + } + }, + "fillView": { + "title": "{teamName} availability", + "weekIntro": "Week of {date} - pick the times you're free.", + "name": "Name", + "namePlaceholder": "Your name", + "password": "Password ({required, select, true {required} other {optional}})", + "passwordRequiredPlaceholder": "Enter your password", + "passwordOptionalPlaceholder": "Set a password", + "viewingTimezone": "Viewing timezone", + "yourAvailability": "Your availability", + "saving": "Saving...", + "save": "Save my availability", + "clear": "Clear", + "timezoneMismatch": " the team's calendar is set to {teamTz}, but you're in {localTz}. Pick slots in your local time - we'll translate to the team's timezone automatically.", + "localTimezone": "Selecting in your local time: {timezone}", + "groupAvailability": "Group availability", + "responseCount": " ({count, plural, one {# response} other {# responses}})", + "hoverPrompt": "Hover a cell to see who's available", + "availabilityRatio": "{available} / {total}", + "namesSuffix": " - {names}", + "unavailable": "Unavailable: {names}", + "toast": { + "nameRequired": "Please enter your name", + "passwordRequired": "Password required to edit this name", + "incorrectPassword": "Incorrect password", + "saveFailed": "Failed to save availability", + "saved": "Availability saved" + }, + "promo": { + "title": "Scrim analytics and team tools for Overwatch", + "description": "Parsertime turns raw Workshop Log data into skill ratings, trend lines, and coaching insights - plus team management tools like the availability calendar you're using right now.", + "bullets": { + "instantReview": "Instant review. Upload a scrim, see per-player stats, maps, and teamfights in minutes - no spreadsheets.", + "csrRatings": "CSR ratings. Objective 1-5000 hero skill ratings across role-specific metrics.", + "trends": "Trends. Watch players improve across weeks and seasons, not just single matches.", + "coachingCanvas": "Coaching canvas. Draw up strats on a shared whiteboard and keep them alongside the scrims they came from.", + "teamAvailability": "Team availability. This calendar, plus Discord reminders that ping your role at the start of each week.", + "freeToStart": "Free to start. Two teams and five members on the free tier, no credit card." + }, + "signIn": "Sign in to see your team's scrims", + "learnMore": "Learn more about Parsertime" + }, + "metadata": { + "title": "Set Your Availability | Parsertime", + "description": "Mark the times you're available for scrims." + } + }, + "metadata": { + "title": "Availability | Parsertime", + "description": "See when your team is free and plan scrims around everyone's schedule." + } + }, "scrimPage": { "metadata": { "scrim": "Scrim", "title": "{scrimName} Overview | Parsertime", - "description": "Overview for {scrimName} on Parsertime. Parsertime is a tool for analyzing Overwatch scrims.", + "description": "Overview for {scrimName} — map results, player stats, and fight breakdowns from this Overwatch scrim.", "ogTitle": "{scrimName} Overview | Parsertime", "ogDescription": "Overview for {scrimName} on Parsertime. Parsertime is a tool for analyzing Overwatch scrims.", "ogImage": "{scrimName} Overview" @@ -622,6 +1158,10 @@ "back": "Back to dashboard", "newScrim": "New Scrim", "edit": "Edit scrim", + "meta": { + "mapCount": "{count, plural, one {# map} other {# maps}}", + "opp": "Opp" + }, "maps": { "title": "Maps", "altText": "The loading screen art for {map}." @@ -662,9 +1202,14 @@ }, "submitting": "Submitting", "submit": "Submit", + "cancel": "Cancel", "addHeroBan": "Add Hero Ban", "heroBansDescription": "Configure hero bans for this map. You can skip this step by clicking Submit without adding any bans.", - "heroBansTitle": "Add Hero Bans (Optional)" + "heroBansTitle": "Add Hero Bans (Optional)", + "heroBans": { + "reorder": "Reorder", + "removeBan": "Remove ban" + } }, "editScrim": { "back": "Back to scrim", @@ -698,10 +1243,15 @@ "title": "Enable Guest Mode", "description": "If enabled, the scrim will be accessible to any logged in user. If disabled, only your team members will be able to access the scrim." }, + "opponent": { + "title": "Opponent (OWCS)", + "description": "Link this scrim to an OWCS opponent to enable cross-referenced scouting analytics." + }, "maps": { "title": "Maps", "replayCodeLabel": "Replay Code for {map}", "replayCode": "Replay Code", + "heroBans": "Hero Bans", "delete": "Delete Map", "deleteDialog": { "title": "Are you absolutely sure?", @@ -752,27 +1302,429 @@ }, "code": "Replay code: {replayCode}" }, + "overviewTabs": { + "team": { + "yourTeam": "Your Team", + "opponent": "Opponent" + }, + "tabs": { + "visualizations": "Visualizations", + "rawStats": "Raw Stats" + }, + "actions": { + "collapseAll": "Collapse all", + "expandAll": "Expand all", + "collapseOverview": "Collapse overview", + "expandOverview": "Expand overview" + }, + "sections": { + "players": "Player Performance", + "fights": "Fights", + "abilities": "Ability Timing", + "ultimates": "Ultimates", + "ultAdvantage": "Ultimate Advantage", + "swaps": "Hero Swaps", + "positional": "Positioning", + "initiation": "Fight Initiations" + } + }, + "overviewSections": { + "fights": { + "noData": "No fight data available for this scrim.", + "summary": "Won {winrate}% of {total} {total, plural, one {fight} other {fights}} ({won}W - {lost}L)", + "firstUlt": "Used ults first in {count} {count, plural, one {fight} other {fights}} ({winrate}% WR).", + "opponentFirstUlt": "Opponent used ults first in {count} {count, plural, one {fight} other {fights}} ({winrate}% WR).", + "labels": { + "fightsWon": "Fights Won", + "firstPickRate": "First Pick Rate", + "firstDeathRate": "First Death Rate" + }, + "headings": { + "conditionalWinRates": "Conditional Win Rates" + }, + "units": { + "fights": "fights", + "firstDeaths": "first deaths" + }, + "chart": { + "overall": "Overall", + "firstPick": "First Pick", + "diedFirst": "Died First", + "usedUltFirst": "Used Ult First", + "opponentUltFirst": "Opp Ult First", + "winRate": "Win Rate", + "tooltipWinRate": "{winrate} win rate" + }, + "abilityTiming": { + "noData": "No high-impact abilities detected in this scrim.", + "phase": { + "preFight": "Pre-fight", + "early": "Early", + "mid": "Mid", + "late": "Late", + "cleanup": "Cleanup" + }, + "negativeOutlier": "{abilityName} used {phase} has {phaseWinrate} winrate vs {bestPhaseWinrate} when used {bestPhase}", + "positiveOutlier": "{abilityName} in {phase} correlates with {phaseWinrate} winrate — strong {pattern} pattern", + "patternInitiation": "initiation", + "patternTiming": "timing", + "cellTitle": "{winrate} win rate ({wins, number}W {losses, number}L from {fights, plural, =1 {# fight} other {# fights}})", + "fewerThanFights": "Fewer than {count, plural, =1 {# fight} other {# fights}}", + "legendWinRate": "Win rate:", + "legendBadTiming": "Bad timing", + "legendGoodTiming": "Good timing", + "hoverDetail": "{abilityName} used {phase}: {winrate} win rate across {fights, plural, =1 {# fight} other {# fights}} ({wins, number}W {losses, number}L)", + "info": "Cells show win rate when ability is used in that fight phase. “—” = fewer than {count, plural, =1 {# fight} other {# fights}}. Hover for details." + } + }, + "ultimates": { + "noData": "No ultimate data available for this scrim.", + "efficiency": "Ultimate efficiency: {value} fights won per ult ({rating})", + "ratings": { + "excellent": "Excellent", + "good": "Good", + "average": "Average", + "poor": "Poor" + }, + "labels": { + "totalUltimatesUsed": "Total Ultimates Used", + "roleUltimates": "{role} Ultimates" + }, + "headings": { + "usageBySubrole": "Ultimate Usage by Subrole", + "timingBreakdown": "Ultimate Timing Breakdown" + }, + "units": { + "ults": "ults" + }, + "chart": { + "noPlayer": "No player", + "ultCount": "{count, plural, one {# ult} other {# ults}}" + } + }, + "swaps": { + "noData": "No hero swap data available for this scrim.", + "summary": "{swaps} {swaps, plural, one {swap} other {swaps}} across all maps ({perMap}/map) vs opponent's {opponentSwaps}", + "topSwap": "Top swap: {fromHero} -> {toHero} ({count}x)", + "topSwapper": "Most active swapper: {playerName} ({swaps} {swaps, plural, one {swap} other {swaps}}, {maps} {maps, plural, one {map} other {maps}})", + "labels": { + "totalHeroSwaps": "Total Hero Swaps", + "swapVsNoSwapWinRate": "Swap vs No-Swap Win Rate", + "withSwaps": "With Swaps", + "withoutSwaps": "Without Swaps" + }, + "headings": { + "winRateBySwapCountTiming": "Win Rate by Swap Count & Timing" + }, + "units": { + "swaps": "swaps" + }, + "chart": { + "tooltipWinRate": "{winrate} win rate", + "detail": "{wins, number}W–{losses, number}L ({maps, plural, =1 {# map} other {# maps}})", + "winRate": "Win Rate", + "buckets": { + "zeroSwaps": "0 swaps", + "oneSwap": "1 swap", + "twoSwaps": "2 swaps", + "threePlusSwaps": "3+ swaps", + "early": "Early (0-33%)", + "mid": "Mid (33-66%)", + "late": "Late (66-100%)" + } + } + } + }, + "rawStatsSections": { + "fights": { + "label": "Fight analysis", + "title": "Fight Analysis", + "overallWinRate": "Overall fight win rate: {winrate} ({won, number}W / {lost, number}L across {total, plural, one {# fight} other {# fights}})", + "teamFirstDeath": "Your team died first in {rateValue} of fights.", + "teamFirstDeathComparison": "When you died first, you still won {firstDeathWinrate} of those fights vs {firstPickWinrate} when you got first pick.", + "firstPick": "Your team got first pick in {firstPickRate} of fights, winning {firstPickWinrate} of them.", + "firstUlt": "When your team used ultimates first, you won {winrate} of those fights.", + "opponentFirstUlt": "When the opponent used ultimates first, your win rate {hasFirstUlt, select, yes {dropped to} other {was}} {winrate}." + }, + "ultimates": { + "label": "Ultimate analysis", + "title": "Ultimate Analysis", + "ourTopFightInitiator": "Your most common fight-opening ultimate: {hero} ({count} {count, plural, one {fight} other {fights}}).", + "opponentTopFightInitiator": "Opponent's most common fight-opening ultimate: {hero} ({count} {count, plural, one {fight} other {fights}}).", + "goodDiscipline": "Good ultimate discipline — more ultimates used in wins than losses.", + "roomForImprovement": "Room for improvement — more ultimates used in losses than wins.", + "ultUsage": "Your team used {ourCount} {ourCount, plural, one {ultimate} other {ultimates}} vs opponent's {opponentCount}.", + "roleUltUsage": "{role} ultimates: your team used {ourCount} vs opponent's {opponentCount}.", + "roleFirstUlt": "You used {role} ultimates first in {rateValue} of fights.", + "subroleTiming": "{subrole}: {count} {count, plural, one {ultimate} other {ultimates}} ({pct}%) — {initiation} initiation, {midfight} midfight, {late} late", + "fightInitiations": "Your team initiated fights with ultimates in {rateValue} of ultimate-involved fights ({initiations} of {total}).", + "topUltUser": "Top ultimate user: {playerName} ({hero}) with {count} {count, plural, one {ultimate} other {ultimates}} used.", + "avgChargeAndHold": "Your team charged ultimates in an average of {chargeTime}s and held them for an average of {holdTime}s before using.", + "avgCharge": "Your team charged ultimates in an average of {chargeTime}s.", + "avgHold": "Your team held ultimates for an average of {holdTime}s before using.", + "ultimateEfficiency": "Ultimate Efficiency: {value} fights won per ultimate used ({rating}).", + "averageUltsPerFight": "Used an average of {won} ultimates per won fight vs {lost} per lost fight. {discipline}", + "wastedUltimates": "{count} wasted {count, plural, one {ultimate} other {ultimates}} ({percent}% of total) used in lost situations (3+ player disadvantage).", + "dryNonDryFights": "{dryCount} dry {dryCount, plural, one {fight} other {fights}} (no ultimates used) with a {dryRate}% win rate, plus {nonDryCount} {nonDryCount, plural, one {fight} other {fights}} where ultimates were used ({nonDryRate}% WR).", + "fightReversal": "Fight reversal rate (won after being down 2+ kills): {dryRate}% in dry fights vs {nonDryRate}% when ultimates were used.{insight, select, comeback { Ultimates are a key comeback tool for this team.} mechanics { This team can reverse fights through raw mechanics.} other {}}" + }, + "swaps": { + "label": "Hero swap analysis", + "title": "Hero Swap Analysis", + "ourTopSwap": "Your most common swap: {fromHero}{toHero} ({count} {count, plural, one {time} other {times}}).", + "opponentTopSwap": "Opponent's most common swap: {fromHero}{toHero} ({count} {count, plural, one {time} other {times}}).", + "summary": "Your team made {ourSwaps} hero {ourSwaps, plural, one {swap} other {swaps}} across all maps ({ourPerMap} per map) vs opponent's {opponentSwaps} ({opponentPerMap} per map).", + "winrateComparison": "Win rate on maps with swaps: {swapWinrate} ({swapWins, number}W / {swapLosses, number}L) vs {noSwapWinrate} without swaps ({noSwapWins, number}W / {noSwapLosses, number}L).", + "winrateDelta": "That's a {delta}% difference when swapping.", + "avgHeroTimeBeforeSwap": "Average time on a hero before swapping: {seconds}s.", + "topSwapper": "Most active swapper: {playerName} with {swaps} {swaps, plural, one {swap} other {swaps}} across {maps} {maps, plural, one {map} other {maps}}.", + "winrateBySwapCount": "Win rate by swap count:", + "swapCountBucket": "{label}: {winrate} ({wins, number}W-{losses, number}L)", + "winrateBySwapTiming": "Win rate by swap timing:", + "swapTimingBucket": "{label}: {winrate} ({maps, plural, one {# map} other {# maps}})" + } + }, + "overviewSummary": { + "ariaLabel": "Scrim overview", + "recordAria": "Record: {wins, plural, one {# win} other {# wins}}, {losses, plural, one {# loss} other {# losses}}, {draws, plural, one {# draw} other {# draws}}", + "winRate": "{winRate}% win rate", + "keyInsights": "Key Insights", + "record": { + "wins": "{count}W", + "losses": "{count}L", + "draws": "{count}D" + }, + "stats": { + "maps": "Maps", + "teamKd": "Team K/D", + "totalDamage": "Total Damage", + "totalHealing": "Total Healing", + "record": "{wins}W · {losses}L", + "elims": "{count, plural, one {# elim} other {# elims}}", + "heroDamage": "hero damage", + "healingDealt": "healing dealt" + } + }, + "playerPerformance": { + "columns": { + "player": "Player", + "maps": "Maps", + "kd": "K/D", + "elimsPer10": "Elims/10", + "damagePer10": "Dmg/10", + "firstDeath": "1st Death", + "teamFirstDeath": "Team 1st Death", + "trend": "Trend", + "outliers": "Outliers" + }, + "hover": { + "showTrendChart": "Show {playerName} performance trend chart", + "description": "Performance across maps (% of avg) · Click a stat to focus", + "unavailable": "Performance chart unavailable for this player.", + "metrics": { + "kd": "K/D", + "elimsPer10": "Elims/10", + "damagePer10": "Dmg/10", + "healingPer10": "Healing/10", + "firstDeathRate": "1st Death %", + "teamFirstDeathRate": "Team 1st Death %" + } + }, + "trend": { + "improving": "Improving", + "declining": "Declining", + "stable": "Stable", + "improvingAria": "Improving: {count, plural, one {# metric} other {# metrics}} trending up", + "decliningAria": "Declining: {count, plural, one {# metric} other {# metrics}} trending down", + "stableAria": "Stable performance" + }, + "outlier": { + "elite": "elite", + "belowAverage": "below avg", + "aria": "{label} at {percentile}th percentile vs database ({status})", + "tooltip": "{label}: {percentile}th %ile vs database ({status})" + }, + "fallback": { + "unknownPlayer": "Unknown Player", + "unknownHero": "Unknown Hero" + } + }, + "addToMapGroupDialog": { + "title": { + "add": "Add to Map Group", + "create": "Create New Map Group" + }, + "description": { + "add": "Select an existing map group to add {mapName} to, or create a new one.", + "create": "Create a new map group and add {mapName} to it." + }, + "selectGroup": "Select a map group", + "mapCount": "{count, plural, one {# map} other {# maps}}", + "empty": { + "title": "No map groups found.", + "description": "Create your first map group below." + }, + "createNew": "Create New Map Group", + "backToSelection": "Back to Selection", + "cancel": "Cancel", + "addToGroup": "Add to Group", + "createAndAdd": "Create and Add", + "form": { + "name": "Name", + "namePlaceholder": "e.g., Control Maps", + "description": "Description", + "descriptionPlaceholder": "Optional description for this group", + "category": "Category", + "categoryPlaceholder": "e.g., Map Type, Playstyle" + }, + "toast": { + "unknownError": "Unknown error", + "fetchFailed": "Failed to fetch map groups", + "nameRequired": "Please enter a name for the map group", + "added": { + "title": "Added to map group", + "description": "{mapName} added to {groupName}" + }, + "addFailed": { + "title": "Failed to add to map group" + }, + "created": { + "title": "Map group created", + "description": "Created \"{groupName}\" and added {mapName}" + }, + "createFailed": { + "title": "Failed to create map group" + } + } + }, "mapCard": { + "ariaLabel": "{map} map card", + "ariaLabelSelected": "{map} map card, selected for comparison", "altText": "The loading screen art for {map}.", "selected": "Selected", "copiedCode": "Copied replay code!", + "won": "Won", + "lost": "Lost", + "auto": "auto", + "autoTooltip": "Auto-detected from positions", + "editWinner": "Edit winner for {map}", "contextMenu": { "title": "Map Actions", "selectForComparison": "Select for comparison", "addToMapGroup": "Add to map group", + "setWinner": "Set winner", "viewDetails": "View Map Details", "copyCode": "Copy Replay Code" } }, + "mapWinnerDialog": { + "title": "Set Map Winner", + "description": "Choose the winning team for {mapName}. This overrides any automatically detected result.", + "cancel": "Cancel", + "save": "Save Winner", + "success": "Winner updated", + "error": "Failed to set winner" + }, "compareButton": { "selected": "{count, plural, =1 {1 map selected} other {# maps selected}}", + "selectedMapName": "{count, plural, =1 {1 selected map} other {# selected maps}}", "fromScrims": "{count, plural, =1 {from 1 scrim} other {from # scrims}}", "compare": "Compare Selected", "compareNow": "Compare Now", "addToMapGroup": "Add to Map Group", "clear": "Clear" }, - "viewStats": "View team stats" + "viewStats": "View team stats", + "overviewUnavailable": { + "title": "Overview not available yet", + "description": "We can't identify which roster is yours from a single scrim. Upload another scrim, then return to this page to view win/loss and team insights." + }, + "positional": { + "title": "Positional stats", + "description": "Averaged across this scrim's maps with positional data.", + "matrix": { + "below": "Below median", + "above": "Above median", + "legend": "Tint shows each value's deviation from the team median for that stat." + }, + "byZoneTitle": "Won / lost / even by zone", + "playerColumn": "Player", + "stats": { + "AVERAGE_ENGAGEMENT_DISTANCE": { + "short": "Eng Dist", + "full": "Engagement Distance" + }, + "HIGH_GROUND_KILL_PERCENTAGE": { + "short": "HG Kill %", + "full": "High Ground Kill %" + }, + "ISOLATION_DEATH_PERCENTAGE": { + "short": "Iso Death %", + "full": "Isolation Death %" + }, + "AVERAGE_FIGHT_START_SPREAD": { + "short": "Start Spread", + "full": "Fight Start Spread" + }, + "AVERAGE_ULT_CONVERSION_KILLS": { + "short": "Ult Conv", + "full": "Ult Conversion Kills" + }, + "ULT_DEATH_PERCENTAGE": { + "short": "Ult Death %", + "full": "Ult Death %" + }, + "AVERAGE_ULT_DISPLACEMENT": { + "short": "Ult Disp", + "full": "Ult Displacement" + }, + "ULTS_ON_OBJECTIVE_PERCENTAGE": { + "short": "Ult Obj %", + "full": "Ults On Objective %" + } + }, + "engagementsTitle": "Engagements", + "winrate": "Winrate", + "fights": "{total, plural, one {# fight} other {# fights}}", + "recordSummary": "{won}W / {lost}L / {even} even", + "wonLabel": "Won", + "lostLabel": "Lost", + "evenLabel": "Even", + "zonesTitle": "Zone control by map", + "zonesYourTeam": "Your team", + "zonesOpponents": "Opponents", + "zonesCount": "{count, plural, one {# zone} other {# zones}}", + "zonesControlPct": "{pct}% control", + "routesTitle": "Routes by map", + "routesSummary": "{total} routes · {won} won / {lost} lost", + "routesUndecided": "Undecided", + "zoneColumn": "Zone", + "teamColumn": "Team", + "killsColumn": "Kills", + "deathsColumn": "Deaths", + "ultsColumn": "Ults" + }, + "editMetadata": { + "title": "Edit {scrimName} | Parsertime", + "description": "Edit details and settings for {scrimName}." + }, + "wpa": { + "title": "Win Probability Added", + "description": "Per-player WPA summed across this scrim's maps.", + "columns": { + "player": "Player", + "team": "Team", + "maps": "Maps", + "wpa": "WPA" + } + }, + "initiationSection": { + "coverage": "Detected on {covered} of {total} maps with detailed logs.", + "noData": "No initiation data — these maps predate detailed logs.", + "wentFirst": "{team} — win rate going first", + "frequency": "{first} fights initiated", + "record": "{wins}W - {losses}L" + } }, "mapGroupsPage": { "metadata": { @@ -780,6 +1732,49 @@ "description": "Create and manage custom map groups for analysis" } }, + "tendenciesPage": { + "fightMap": { + "description": "Where your team wins and loses fights, pooled across recent scrims. Green areas are fights you usually win; red areas are where you struggle.", + "fights": "{count, plural, =1 {1 decisive fight} other {# decisive fights}}", + "overall": "Fight winrate", + "unnamedArea": "Unmarked area", + "notEnoughFights": "Not enough fight data yet — this view needs at least {count} decisive fights with positional data.", + "noCalibration": "No calibrated map image for this map. Calibrate it in the map calibration tool to unlock the fight map.", + "zone": "Zone", + "record": "Record", + "winrate": "Win rate", + "sectionEyebrow": "Fight map", + "sectionTitle": "Where you win and struggle", + "summaryFights": "Decisive fights", + "summaryFightsSub": "won or lost", + "summaryMaps": "Maps", + "summaryMapsSub": "with fight data", + "summaryStrongest": "Strongest map", + "summaryWeakest": "Weakest map", + "summaryNoRanking": "Not enough fights" + }, + "metadata": { + "title": "Tendencies | Parsertime", + "description": "Where your team wins and loses fights on each map, pooled across recent scrims" + }, + "title": "Tendencies", + "description": "Each map shows fight winrate by location — green where you win, red where you struggle.", + "empty": "No positional data yet.", + "totalRoutes": "Total routes", + "route": "Route {n}", + "record": "Record", + "winrate": "Win rate", + "wonShort": "W", + "lostShort": "L", + "clusterRoutes": "{count, plural, =1 {1 route} other {# routes}}", + "oneOffs": "{count, plural, =1 {1 one-off route not shown} other {# one-off routes not shown}}", + "share": "Share", + "routeColumn": "Route", + "outcomes": "Won / Lost / Unknown", + "won": "Won", + "lost": "Lost", + "unknown": "Unknown" + }, "comparePage": { "metadata": { "title": "Compare Maps | Parsertime", @@ -815,7 +1810,7 @@ "subtitle": "All metrics normalized per 10 minutes or as percentages for fair comparison", "exportCsv": "Export CSV", "statName": "Stat", - "maps": "maps", + "mapCount": "{count, plural, one {# map} other {# maps}}", "priority": "Priority", "note": "Note: All statistics are normalized (per 10 minutes, percentages, or averages) to enable fair comparison across different playtimes.", "categories": { @@ -1016,25 +2011,33 @@ }, "consistency": { "title": "Performance Consistency", - "subtitle": "Measures how reliable performance is across different maps and contexts", + "subtitle": "Measures how reliable performance is across different maps and contexts. Lower variance indicates consistent, dependable play.", "consistencyScore": "Consistency Score", "highlyConsistent": "Highly Consistent", + "highlyConsistentDescription": "Rock solid performance across all maps", "consistent": "Consistent", + "consistentDescription": "Reliable performance with minor variance", "moderatelyConsistent": "Moderately Consistent", + "moderatelyConsistentDescription": "Performance varies based on context", "variablePerformance": "Variable Performance", + "variablePerformanceDescription": "Significant variation across different maps", "meanStdDev": "Mean ± Standard Deviation", "performanceAcrossMaps": "Performance Across Maps", + "mapName": "Map {number}", + "metricPer10": "{metric} per 10", "understanding": { "title": "Understanding Consistency Metrics", - "stdDev": "Measures how spread out performance is. Lower values = more consistent.", - "cv": "Normalized measure of dispersion. Allows comparison across different scales.", - "score": "Overall reliability metric (0-100). Higher scores indicate dependable, steady performance." + "stdDev": "Standard Deviation: Measures how spread out performance is. Lower values = more consistent.", + "cv": "Coefficient of Variation: Normalized measure of dispersion. Allows comparison across different scales.", + "score": "Consistency Score: Overall reliability metric (0-100). Higher scores indicate dependable, steady performance." }, "metrics": { "eliminations": "Eliminations", "deaths": "Deaths", "damage": "Damage", + "damageK": "Damage (k)", "healing": "Healing", + "healingK": "Healing (k)", "mean": "Mean", "stdDev": "± Std Dev", "range": "Range", @@ -1075,6 +2078,67 @@ }, "compareButton": "Compare" }, + "mapGroupManager": { + "title": "Map Groups", + "description": "Create custom map groups to organize and compare performance", + "newGroup": "New Group", + "createDialog": { + "title": "Create Map Group", + "description": "Group maps together to analyze performance across different contexts" + }, + "editDialog": { + "title": "Edit Map Group", + "description": "Update the map group details and map selection" + }, + "deleteDialog": { + "title": "Delete Map Group", + "description": "Are you sure you want to delete \"{name}\"? This action cannot be undone.", + "cancel": "Cancel", + "delete": "Delete" + }, + "empty": { + "title": "No map groups yet", + "description": "Create your first map group to organize maps and compare performance across different contexts", + "create": "Create Map Group" + }, + "actions": { + "label": "Group actions", + "edit": "Edit", + "delete": "Delete" + }, + "card": { + "maps": "Maps", + "createdBy": "Created by {name}" + }, + "form": { + "name": "Group Name", + "namePlaceholder": "e.g., Brawl Maps, Open Maps", + "description": "Description", + "descriptionPlaceholder": "Optional description for this group", + "category": "Category", + "categoryPlaceholder": "e.g., Playstyle, Map Type", + "selectMaps": "Select Maps", + "noMapsAvailable": "No maps available", + "selectedMaps": "{count, plural, one {# map selected} other {# maps selected}}", + "create": "Create Group", + "update": "Update Group" + }, + "toast": { + "created": "Map group created", + "createdDescription": "Your map group has been created successfully.", + "updated": "Map group updated", + "updatedDescription": "Your map group has been updated successfully.", + "deleted": "Map group deleted", + "deletedDescription": "The map group has been deleted successfully.", + "error": "Error" + }, + "errors": { + "fetchFailed": "Failed to fetch map groups", + "createFailed": "Failed to create map group", + "updateFailed": "Failed to update map group", + "deleteFailed": "Failed to delete map group" + } + }, "teamComparison": { "myTeam": "My Team", "enemyTeam": "Enemy Team", @@ -1119,6 +2183,11 @@ }, "back": "Back to scrim overview", "dashboard": "Dashboard", + "tabsLabel": "Map sections", + "mobileBanner": { + "message": "For the best experience, we recommend using a larger screen.", + "dismiss": "Dismiss" + }, "playerSwitcher": { "select": "Select a player", "search": "Search player...", @@ -1132,7 +2201,96 @@ "dashboard": "Dashboard", "overview": "Overview", "analytics": "Analytics", - "charts": "Charts" + "charts": "Charts", + "telemetry": "Telemetry" + }, + "telemetry": { + "noData": { + "title": "No telemetry for this map", + "description": "This map was logged before per-event timing data was captured. The Charts tab still has round-by-round stats." + }, + "stats": { + "damageDealt": "Damage dealt", + "damageTaken": "Damage taken", + "healingDealt": "Healing dealt", + "eliminations": "Eliminations", + "deaths": "Deaths" + }, + "trace": { + "title": "Activity telemetry", + "description": "Damage, healing, and events across the map. Hover any point to read that moment." + }, + "ariaChart": "Player activity telemetry over {seconds} seconds", + "lanes": { + "output": "Damage out", + "support": "Healing / damage out", + "survival": "Taken / healing", + "events": "Events", + "target": "Damage by target" + }, + "roles": { + "tank": "Tank", + "damage": "DPS", + "support": "Support" + }, + "channels": { + "damageDealt": "Damage dealt", + "damageTaken": "Damage taken", + "healingDealt": "Healing dealt", + "healingReceived": "Healing received" + }, + "markers": { + "ult": "Ultimate", + "kill": "Kill", + "death": "Death", + "ability": "Ability", + "ability1": "Ability 1", + "ability2": "Ability 2", + "swap": "Hero swap" + }, + "matchups": { + "title": "Targeting & matchups", + "description": "Who this player damaged and traded with across the map.", + "byTarget": { + "title": "Damage by target role", + "empty": "No damage dealt on this map." + }, + "takenByRole": { + "title": "Damage taken by source role", + "empty": "No damage taken on this map." + }, + "killContribution": { + "title": "Kill contribution", + "focus": "Focus contribution", + "participation": "Participation", + "killsLabel": "team kills", + "empty": "No team kills with tracked damage." + }, + "radar": { + "title": "Damage traded per opponent", + "dealt": "Dealt", + "received": "Received", + "empty": "No opponent damage recorded for this map." + } + }, + "heatmap": { + "title": "Positional heatmap", + "description": "Where this player fought, died, and used abilities. Drag to pan, scroll to zoom.", + "heatGroup": "Heat", + "loading": "Loading map image...", + "noCalibration": "This map isn't calibrated yet, so positions can't be drawn over the map image.", + "noCoordinates": "No positional data was captured for this player on this map.", + "subMapsLabel": "Sub-maps", + "abilityFilter": "Filter abilities", + "layers": { + "damageDealt": "Damage dealt", + "damageTaken": "Damage taken", + "healingDealt": "Healing done", + "kills": "Kills", + "deaths": "Deaths", + "abilities": "Abilities" + } + } }, "overview": { "matchTime": "Total Match Time", @@ -1185,6 +2343,8 @@ "noResult": "No results." }, "analytics": { + "rhythm": "Rhythm", + "roundTrends": "Round trends", "avgUltChargeTime": { "title": "Average Ultimate Charge Time", "footer": "The average time it takes to build an ultimate. A lower time is better, while a longer time might indicate that you are not doing enough damage or healing." @@ -1205,7 +2365,9 @@ "title": "Versus Other Players", "description": "{playerName}'s score and winrates against players on the enemy team.", "score": "Score:", - "winrate": "Winrate: {winrate}%" + "winrate": "Winrate: {winrate}%", + "winrateLabel": "Winrate", + "empty": "No matchup data recorded for this player." }, "playerKillfeed": { "title": "Player Killfeed", @@ -1231,9 +2393,13 @@ "title": "MVP Score", "footer": "The MVP score is a calculation based on a player's statistics compared to the averages of all other players who have played the same hero in scrims. Hover over this card to see how the MVP score is calculated.", "totalScore": "Total Score: {score}", + "pointsUnit": "pts", + "heroIconAlt": "{hero} icon", "per10Value": "{per10Value}/10min vs avg {heroAverage}", + "zScore": "{score}σ", "percentile": "{percentile, selectordinal, one {#st} two {#nd} few {#rd} other {#th}} percentile", - "explanation": "The MVP score can be used to understand how a player is performing compared to similar players on their hero. The MVP stat does not take into account a player's performance compared to their teammates—it is a way to see who is having a stand-out performance compared to the global averages on their hero. MVP scores typically range between -100 and +100, with a normal distribution centered around 0." + "noData": "No data available", + "explanation": "The MVP score can be used to understand how a player is performing compared to similar players on their hero. The MVP stat does not take into account a player's performance compared to their teammates; it is a way to see who is having a stand-out performance compared to the global averages on their hero. MVP scores typically range between -100 and +100, with a normal distribution centered around 0." } }, "charts": { @@ -1276,11 +2442,91 @@ "notes": "Notes", "vod": "VOD", "heatmap": "Heatmap", - "replay": "Replay" + "replay": "Replay", + "routes": "Routes", + "story": "Match Story", + "initiation": "Initiation" + }, + "routes": { + "calibrationWarning": "This map's calibration is invalid — most recorded positions land outside the map image, so routes can't be drawn meaningfully. Recalibrate this map in the calibration tool (use at least 4 anchors).", + "empty": "No positional data on this map.", + "noCalibration": "No map calibration available. Routes require a calibrated orthographic map image.", + "showAll": "Show all routes", + "routeFallback": "Route {n}", + "loadingImage": "Loading map image...", + "canvasLabel": "Player routes drawn over the map image.", + "zoomHint": "{zoom, number}% · Scroll to zoom · Drag to pan", + "filters": { + "team": "Team", + "player": "Player", + "round": "Round", + "outcome": "Outcome", + "kind": "Kind", + "cluster": "Cluster", + "all": "All" + }, + "outcomes": { + "won": "Won", + "lost": "Lost", + "unknown": "Unknown" + }, + "kinds": { + "initial": "Initial", + "respawn": "Respawn" + }, + "clusters": { + "header": "Route clusters", + "routes": "{count} routes", + "outcomes": "{won} won / {lost} lost / {unknown} unknown" + } }, "replay": { + "ghost": { + "title": "Ghost", + "source": "Ghost source", + "alignMode": "Alignment", + "sourceRound": "Ghost round", + "otherScrim": "From another scrim", + "primaryRound": "Align onto round", + "alignRoundStart": "Round start", + "alignFirstContact": "First contact", + "nudge": "Offset (s)", + "playerFilter": "Ghost player", + "allPlayers": "All players", + "clear": "Clear ghost", + "noData": "No positional data in the selected source.", + "loadError": "Couldn't load ghost data.", + "arenaMismatch": "Ghost is on a different sub-map this round.", + "loading": "Loading ghost…" + }, "noCalibration": "No map calibration available. The replay viewer requires a calibrated orthographic map image.", - "noCoordinates": "No coordinate data available for this map." + "noCoordinates": "No coordinate data available for this map.", + "noCalibrationForRange": "No map calibration available for this time range.", + "viewerLabel": "Replay viewer", + "timeline": { + "roundShort": "R{number}", + "scrubber": "Replay scrubber", + "skipBack": "Skip back {seconds, plural, one {# second} other {# seconds}}", + "skipForward": "Skip forward {seconds, plural, one {# second} other {# seconds}}", + "play": "Play", + "pause": "Pause", + "playbackSpeed": "Playback speed", + "keyboardHint": "Space: play/pause · Arrow keys: seek 5s (Shift: 1s) · [ / ]: speed" + }, + "eventFeed": { + "events": "Events", + "noEventsNearby": "No events nearby", + "teamLabel": "Team {team}:", + "ultActivated": "ult activated", + "ultEnded": "ult ended", + "roundStart": "Round {round, number} Start", + "roundEnd": "Round {round, number} End" + }, + "map": { + "mapAlt": "Map", + "loadingImage": "Loading map image...", + "zoomHint": "{zoom, number}% · Scroll to zoom · Drag to pan" + } }, "heatmap": { "noCalibration": "No map calibration available. The heatmap requires a calibrated orthographic map image.", @@ -1289,28 +2535,88 @@ "damage": "Damage", "healing": "Healing", "kills": "Kills" + }, + "canvas": { + "canvasLabel": "Fight heatmap overlay on map image. Drag to pan, scroll to zoom. Use plus and minus to zoom, arrow keys to pan, zero to reset.", + "total": "{count, number} total", + "killEvents": "{count, plural, one {# kill event} other {# kill events}}", + "killEvent": "{attackerName} ({attackerHero}) eliminated {victimName} ({victimHero}) at {time}", + "loadingImage": "Loading map image...", + "zoomHint": "{zoom, number}% · Scroll to zoom · Drag to pan", + "primaryFire": "Primary Fire" + } + }, + "fightUltQuality": { + "noData": "No positional data on this map.", + "ults": { + "heading": "Ultimates", + "time": "Time", + "player": "Player", + "hero": "Hero", + "zone": "Zone", + "displacement": "Displacement", + "conversionKills": "Conversion kills", + "diedDuringUlt": "Died", + "died": "Died", + "meters": "{value}m", + "unpaired": "End event missing" + }, + "engagements": { + "heading": "Engagements", + "even": "Even" + }, + "zones": { + "title": "Zones", + "empty": "No published zones for this map yet.", + "zone": "Zone", + "team": "Team", + "kills": "Kills", + "deaths": "Deaths", + "ults": "Ults" } }, "vod": { + "eyebrow": "Film Review", "title": "VOD", + "description": "Link a YouTube or Twitch recording to review this map alongside the data.", "addVod": "Add VOD", "changeVod": "Change VOD", "uploadVod": "Upload VOD", "cancel": "Cancel", "inputPlaceholder": "Enter VOD URL here...", - "uploadVOD": "Upload Your VOD", - "noVod": "No VOD linked yet. Click here to add one.", + "uploadVOD": "Upload your VOD", + "noVod": "No VOD linked yet. Add one to start review.", "vodSuccess": "VOD linked successfully.", "vodFail": "Failed to link VOD", "invalidUrl": "URL must be from YouTube or Twitch", - "vodLabel": "Please enter a VOD URL (YouTube or Twitch)." + "vodLabel": "Please enter a VOD URL (YouTube or Twitch).", + "vodRequired": "A VOD URL is required." }, "tiptap": { - "placeholder": "Write, format, and highlight your text below.", + "eyebrow": "Map Notebook", + "placeholder": "Capture observations and follow-ups for this map. Notes save when you click save.", "notes": "Notes", "save": "Save", "saving": "Saving...", - "unsavedChanges": "Unsaved changes" + "unsavedChanges": "Unsaved changes", + "toolbar": { + "label": "Notes formatting", + "undo": "Undo", + "redo": "Redo", + "h1": "Heading 1", + "h2": "Heading 2", + "h3": "Heading 3", + "bold": "Bold", + "italic": "Italic", + "strike": "Strikethrough", + "codeBlock": "Code block", + "alignLeft": "Align left", + "alignCenter": "Align center", + "alignRight": "Align right", + "list": "Bulleted list", + "orderedList": "Numbered list", + "highlight": "Highlight" + } }, "overview": { "matchTime": "Total Match Time", @@ -1324,6 +2630,9 @@ "healedMore": "{teamName} healed more this map.", "title": "Overview", "round": "Round {number}", + "team1": "Team 1", + "team2": "Team 2", + "notAvailable": "N/A", "analysis": { "title": "Analysis", "tabFirstDeaths": "First Deaths", @@ -1336,7 +2645,7 @@ "footerTiming": "Ult timing categorizes each ultimate as initiation (first third of fight), midfight (middle third), or late (final third). Players are assigned to subroles based on their most-played heroes.", "footerEfficiency": "Efficiency rating = fights won per ultimate used. Wasted ultimates are those used when the team was already down 3+ players. Dry fights are fights where no ultimates were used. Reversals are fights won after being down 2+ kills.", "tabRotationDeaths": "Rotation Deaths", - "footerRotationDeaths": "Rotation deaths are kills that occur before teams engage — typically when a player is picked off while the team is moving between positions. Detected by checking if the first kill in a fight happens while team centroids are more than 50 meters apart.", + "footerRotationDeaths": "Rotation deaths are kills that occur before teams engage, typically when a player is picked off while the team is moving between positions. Detected by checking if the first kill in a fight happens while team centroids are more than 50 meters apart.", "footerSwaps": "Hero swaps are tracked from in-game hero change events. Round-start utility swaps (e.g. swapping to Lúcio for speed boost) are filtered out to show only mid-round strategic swaps.", "deathDescriptionTeam1": "{team1Name} had a first death in {team1FirstDeaths} fights, which is a percentage of {percentage}%.", "deathDescriptionTeam2": "{team2Name} had a first death in {team2FirstDeaths} fights, which is a percentage of {percentage}%.", @@ -1353,21 +2662,129 @@ "ultFightInitiator": "{teamName} initiated fights with ultimates in {percentage}% of ult-involved fights ({count} of {total}).", "ultTopFightOpener": "The most common fight-opening ultimate was {hero}, used to start {count} {count, plural, one {fight} other {fights}}.", "ultTopFightOpenerTeam": "{teamName}'s most common fight-opening ultimate: {hero} ({count} {count, plural, one {fight} other {fights}}).", - "ultSubroleTiming": "{subrole}: {count} {count, plural, one {ultimate} other {ultimates}} ({percentage}%) — {initiation} initiation, {midfight} midfight, {late} late", + "ultSubroleTiming": "{subrole}: {count} {count, plural, one {ultimate} other {ultimates}} ({percentage}%): {initiation} initiation, {midfight} midfight, {late} late", "ultEfficiency": "Ultimate Efficiency: {value} fights won per ultimate used ({rating}).", "ultEfficiencyExcellent": "Excellent", "ultEfficiencyGood": "Good", "ultEfficiencyAverage": "Average", "ultEfficiencyPoor": "Poor", "ultWonVsLost": "Used an average of {wonAvg} ultimates per won fight vs {lostAvg} per lost fight.", - "ultGoodDiscipline": "Good ultimate discipline — more ultimates used in wins than losses.", - "ultNeedsDiscipline": "Room for improvement — more ultimates used in losses than wins.", + "ultGoodDiscipline": "Good ultimate discipline: more ultimates used in wins than losses.", + "ultNeedsDiscipline": "Room for improvement: more ultimates used in losses than wins.", "ultWasted": "{count} wasted {count, plural, one {ultimate} other {ultimates}} ({percentage}% of total) used in lost situations (3+ player disadvantage).", "ultDryFights": "{dryCount} dry {dryCount, plural, one {fight} other {fights}} (no ultimates used) with a {winrate}% win rate, plus {nonDryCount} {nonDryCount, plural, one {fight} other {fights}} where ultimates were used ({nonDryWinrate}% WR).", "ultReversalRate": "Fight reversal rate (won after being down 2+ kills): {dryRate}% in dry fights vs {nonDryRate}% when ultimates were used.", "swapOverview": "{team1Name} made {team1Count} hero {team1Count, plural, one {swap} other {swaps}} vs {team2Name}'s {team2Count} hero {team2Count, plural, one {swap} other {swaps}}.", "swapTopPairTeam": "{teamName}'s most common swap: {fromHero}{toHero} ({count} {count, plural, one {time} other {times}}).", - "swapTopSwapperTeam": "{teamName}'s most active swapper: {playerName} with {count} {count, plural, one {swap} other {swaps}}." + "swapTopSwapperTeam": "{teamName}'s most active swapper: {playerName} with {count} {count, plural, one {swap} other {swaps}}.", + "tabAbilityTiming": "Ability Timing", + "footerAbilityTiming": "Win rates are computed per fight phase (pre-fight, early, mid, late, cleanup). Cells require at least 3 fights of data; outliers are highlighted when phase win rate diverges meaningfully from the ability's best phase.", + "collapseAll": "Collapse all", + "expandAll": "Expand all", + "mobileLabel": { + "deaths": "Deaths", + "ults": "Ults", + "timing": "Timing", + "efficiency": "Eff.", + "swaps": "Swaps", + "rotation": "Rotation", + "abilities": "Abilities" + }, + "deaths": { + "headToHeadLabel": "First Death Rate", + "headToHeadUnit": "first deaths", + "topPlayerCallout": "Player with most first deaths: {playerName} ({count} of {total} fights)", + "ajaxCallout": "{playerName} played Lúcio and Ajaxed {count} {count, plural, one {time} other {times}}", + "fightStripHeading": "Fight-by-fight first deaths", + "fightCount": "{count, plural, one {# fight} other {# fights}}", + "team1DiedFirst": "{teamName} died first", + "team2DiedFirst": "{teamName} died first", + "momentumHeading": "First death momentum", + "team1DyingMore": "{teamName} dying more", + "team2DyingMore": "{teamName} dying more", + "streakCallout": "Longest first death streak: {teamName} died first in {count} consecutive fights", + "fightAriaLabel": "Fight {fight}: {playerName} ({hero}) died first for {teamName}", + "fightTooltipFight": "Fight {fight}", + "fightTooltipFirstDeath": "{teamName} first death: {playerName} ({hero})" + }, + "ultimates": { + "headToHeadLabel": "Total Ultimates Used", + "headToHeadUnit": "ults", + "roleHeadToHeadLabel": "{role}: Used Ult First", + "roleHeadToHeadUnit": "fights", + "topUltUserLabel": "Top ult user:", + "topFightOpenerLabel": "Top fight opener:", + "fightCount": "{count, plural, one {# fight} other {# fights}}", + "subroleChartHeading": "Ultimate usage by subrole" + }, + "timing": { + "empty": "No timing data available for this map.", + "comparisonCallout": "{team1Name} uses {team1Pct}% of ults in initiation vs {team2Name}'s {team2Pct}%", + "breakdownHeading": "Ultimate timing breakdown" + }, + "efficiency": { + "empty": "No efficiency data available for this map.", + "avgChargeTimeLabel": "Avg Ult Charge Time", + "avgHoldTimeLabel": "Avg Ult Hold Time", + "scorecard": { + "wonFights": "Won Fights", + "lostFights": "Lost Fights", + "wasted": "Wasted", + "dryFights": "Dry Fights", + "winRate": "Win Rate", + "reversal": "Reversal", + "ultsPerFight": "{count} ults/fight", + "excellent": "Excellent", + "good": "Good", + "average": "Average", + "poor": "Poor" + } + }, + "swaps": { + "empty": "No hero swaps recorded for this map.", + "headToHeadLabel": "Total Hero Swaps", + "headToHeadUnit": "swaps", + "tableTopSwap": "Top Swap", + "tableMostActive": "Most Active", + "tableEmpty": "-", + "topPair": "{from} → {to} ({count}x)", + "topSwapper": "{name} ({count, plural, one {# swap} other {# swaps}})" + }, + "rotationDeaths": { + "empty": "No coordinate data available for rotation death analysis on this map.", + "noEvents": "No rotation deaths detected on this map.", + "headToHeadLabel": "Rotation Deaths", + "headToHeadUnit": "deaths", + "topPlayerCallout": "Most picked on rotation: {playerName} ({rotationCount} of {totalDeaths} deaths, {rate}%)", + "topKillerCallout": "Most rotation picks with: {hero} ({count} {count, plural, one {kill} other {kills}})", + "tablePlayer": "Player", + "tableRotationDeaths": "Rotation Deaths", + "tableTotalDeaths": "Total Deaths", + "tableRate": "Rate", + "eventsHeading": "Rotation death events ({count})" + }, + "abilityTiming": { + "phase": { + "preFight": "Pre-fight", + "early": "Early", + "mid": "Mid", + "late": "Late", + "cleanup": "Cleanup" + }, + "noTeamData": "No high-impact ability data for {teamName}.", + "negativeOutlier": "{abilityName} used {phase} has {phaseWinrate} win rate vs {bestPhaseWinrate} when used {bestPhase}.", + "positiveOutlier": "{abilityName} in {phase} correlates with {phaseWinrate} win rate: strong {pattern} pattern.", + "patternInitiation": "initiation", + "patternTiming": "timing", + "cellTitle": "{winrate} win rate ({wins}W {losses}L from {fights, plural, one {# fight} other {# fights}})", + "fewerThanFights": "Fewer than {count, plural, one {# fight} other {# fights}}", + "empty": "No high-impact abilities detected in this map.", + "legendWinRate": "Win rate:", + "legendBadTiming": "Bad timing", + "legendGoodTiming": "Good timing", + "hoverDetail": "{abilityName} used {phase}: {winrate} win rate across {fights, plural, one {# fight} other {# fights}} ({wins}W {losses}L)", + "info": "Win rates are from each team's perspective. Dash means fewer than {count} fights. Hover for details." + } } }, "overviewTable": { @@ -1418,6 +2835,31 @@ "dmgToHealsRatio": "The player's damage dealt to healing received ratio.", "ultsCharged": "The number of ultimates the player charged.", "ultsUsed": "The number of ultimates the player used." + }, + "mvpBreakdown": { + "mvpTitle": "{teamName} MVP", + "totalScore": "Total Score: {score}", + "topContributions": "Top Contributions:", + "points": "{sign}{points} pts", + "per10VsAverage": "{value}/10min vs avg {average}", + "percentile": "{percentile}th percentile", + "moreStats": "+ {count, plural, =1 {# more stat} other {# more stats}} calculated", + "stats": { + "eliminations": "Eliminations", + "final_blows": "Final Blows", + "deaths": "Deaths", + "hero_damage_dealt": "Hero Damage Dealt", + "healing_dealt": "Healing Dealt", + "healing_received": "Healing Received", + "damage_blocked": "Damage Blocked", + "damage_taken": "Damage Taken", + "solo_kills": "Solo Kills", + "ultimates_earned": "Ultimates Earned", + "ultimates_used": "Ultimates Used", + "objective_kills": "Objective Kills", + "offensive_assists": "Offensive Assists", + "defensive_assists": "Defensive Assists" + } } }, "killfeed": { @@ -1426,7 +2868,9 @@ "kills": "Kills", "deaths": "Deaths", "fightWins": "Fight Wins", - "title": "Killfeed" + "title": "Killfeed", + "team1": "Team 1", + "team2": "Team 2" }, "killfeedTable": { "limitTest": "Limit Testing", @@ -1438,6 +2882,7 @@ "method": "Method", "start": "Start", "end": "End", + "viewInReplay": "View in Replay", "fightWinner": "Fight Winner", "abilities": { "primary-fire": "Primary Fire", @@ -1450,7 +2895,7 @@ }, "exportCSV": "Export as CSV", "teamWins": "{team} Wins", - "roundEnded": "Round {num} Ended — {team}" + "roundEnded": "Round {num} Ended: {team}" }, "killfeedControls": { "title": "Display Options", @@ -1470,7 +2915,7 @@ "ultEnded": "{player}'s ultimate ended ({duration}s)", "ultEndedDeath": "{player} died, ending ultimate ({duration}s)", "ultDuration": "Duration: {duration}s", - "ultTime": "{start} — {end}", + "ultTime": "{start} to {end}", "ultKills": "{count, plural, one {# kill} other {# kills}} during ult", "ultDeaths": "{count, plural, one {# death} other {# deaths}} during ult", "diedDuringUlt": "{player} died during ultimate", @@ -1493,6 +2938,7 @@ "tooltipDiedDuringUlt": "Died during ultimate" }, "charts": { + "title": "Charts", "tooltip": "Looking for more information on charts? Check out the documentation to learn more.", "team1": "Team 1", "team2": "Team 2", @@ -1502,16 +2948,39 @@ "support": "Support", "round": "Round {round}", "killsByFight": { + "eyebrow": "Fight Flow", "title": "Kills By Fight", "description": "Kills are grouped by 15 second intervals. This chart shows the cumulative kills for each team at each interval. The x-axis represents the time in seconds, and the y-axis represents the cumulative kills. Team 1 is represented with positive numbers, while Team 2 is represented with negative numbers. The chart resets to 0 after each fight." }, "finalBlowsByRole": { + "eyebrow": "Role Breakdown", "title": "Final Blows By Role", "description": "This chart shows the number of final blows by role for each team. The roles are split into Tank, Damage, and Support. The x-axis represents the role, and the y-axis represents the number of final blows." }, "dmgByRound": { + "eyebrow": "Round Damage", "title": "Cumulative Hero Damage By Round", "description": "This chart shows the hero damage done by round for each team. The x-axis represents the round, and the y-axis represents the damage done. Note that the damage is cumulative, so the damage done in round 2 includes the damage done in round 1." + }, + "tempo": { + "eyebrow": "Match Flow", + "title": "Match Tempo", + "description": "Kills and ultimates traced as overlapping curves. Drag the scrubber below the chart to focus on a fight or phase." + }, + "ultAdvantage": { + "eyebrow": "Ultimates", + "title": "Ultimate advantage", + "description": "Which team held more ultimates entering each fight. Bars above zero mean {team} had the advantage.", + "noData": "No ultimate charge data recorded for this map.", + "teamAhead": "{team} ahead", + "teamAheadBy": "{team} +{n}", + "teamAheadByMore": "{team} +{n} or more", + "even": "Even", + "breakdownCaption": "Fights by ultimate advantage", + "fight": "Fight {n}", + "fightResult": "{team} won the fight", + "fightsCount": "{count, plural, one {# fight} other {# fights}}", + "noFights": "No fights" } }, "events": { @@ -1519,21 +2988,56 @@ "roundEnd": "Round {event} ended", "matchEnd": "Match ended", "matchStart": "Match started", + "noData": "No events recorded for this map.", + "round": "Round {number}", "mapEvents": { - "title": "Events", - "description": "Events that occurred during the match. This includes objective captures, rounds starting and ending, multikills, and more.", - "captureString1": "{team} took control of the point.", - "captureString2": "{team} captured the objective.", - "objectiveUpdate": "Point captured", - "swap": " {player} swapped to {hero}.", - "ultKills": " {player} killed {players, plural, =1 {one player} other {# players}} with/during their ultimate.", - "multikill": "During fight {event}, {player} got a multikill, killing {kills} players.", - "ajax": "{player} Ajaxed during fight {fight}." - }, - "ultsUsed": { - "title": "Ultimates Used", - "description": "A list of all ultimates used during the match and the times when they occurred.", - "ultStart": "{player} used their ultimate during fight {fight}." + "title": "Events" + }, + "totals": { + "rounds": "Rounds", + "fights": "Fights", + "multikills": "Multikills", + "ultimates": "Ultimates used", + "swaps": "Hero swaps" + }, + "filters": { + "label": "Filter events", + "all": "All", + "highlights": "Highlights", + "ultimates": "Ultimates", + "fights": "Fights", + "swaps": "Swaps", + "objectives": "Objectives", + "empty": "No events match this filter." + }, + "type": { + "matchStart": "Match", + "matchEnd": "Match", + "roundStart": "Round", + "roundEnd": "Round end", + "objective": "Objective", + "engagement": "Fight", + "swap": "Swap", + "ult": "Ult", + "ultKill": "Ult kill", + "multikill": "Multikill", + "ajax": "Ajax" + }, + "detail": { + "matchStart": "Match started.", + "matchEnd": "Match ended.", + "roundStart": "Round {number} started.", + "roundEnd": "Round {number} ended.", + "capture": "{team} captured the objective.", + "pointTake": "{team} took control of the point.", + "fightTag": "Fight {number}", + "ultKill": "{player} got {count, plural, =1 {one kill} other {# kills}} during their ultimate.", + "multikill": "{player} got {count, plural, =3 {a triple} =4 {a quad} =5 {a quint} other {a # multi}} kill in fight {fight}.", + "ajax": "{player} ajaxed in fight {fight}.", + "engagementWon": "won", + "engagementEven": "Even", + "ultConversion": "{count} conv", + "ultDied": "died" }, "tempo": { "title": "Match Tempo", @@ -1545,6 +3049,11 @@ "snapToFights": "Snap to fights", "fightLabel": "Fight {number}", "allFights": "All fights", + "selectionStart": "Selection start", + "selectionEnd": "Selection end", + "previousFight": "Previous fight", + "nextFight": "Next fight", + "even": "Even", "noData": "Not enough data to display tempo." } }, @@ -1553,6 +3062,19 @@ "team2": "Team 2", "playerCard": { "title": "Player Statistics", + "unplaced": "Unplaced", + "sr": "SR", + "maxScore": "Max score!", + "ranks": { + "bronze": "Bronze", + "silver": "Silver", + "gold": "Gold", + "platinum": "Platinum", + "diamond": "Diamond", + "master": "Master", + "grandmaster": "Grandmaster", + "champion": "Champion" + }, "allHeroes": { "title": "All Heroes", "timePlayed": "Time Played", @@ -1635,6 +3157,125 @@ }, "heroBans": { "bannedBy": "{hero} banned by {team}" + }, + "matchStory": { + "title": "Match story", + "description": "Win probability over the map, fight by fight, from {team1}'s perspective.", + "limited": "Ultimate events are missing from this log. Win probability is shown without ult economy and cascade insights are disabled.", + "noModel": "Not enough league-wide data for this game mode yet.", + "chart": { + "winProbability": "Win probability", + "round": "Round {round}", + "time": "Time", + "score": "Score", + "objective": "Obj", + "capture": "{team} took the objective", + "captureProgress": "{team} took the point. {t1}: {p1}%, {t2}: {p2}%" + }, + "ledger": { + "title": "Fight ledger", + "fight": "Fight", + "time": "Time", + "zone": "Zone", + "result": "Result", + "swing": "Swing", + "ults": "Ults", + "carryover": "Carryover", + "won": "Won", + "lost": "Lost", + "even": "Even", + "ultEconomy": "ult economy", + "stagger": "stagger", + "context": "Context", + "driverObjective": "objective", + "driverKills": "kills", + "driverUlts": "ults" + }, + "wpa": { + "title": "Win probability added", + "subtitle": "Attribution by convention — final blows, assists, deaths, and ult usage split each fight's swing. Expand a row to audit it.", + "player": "Player", + "team": "Team", + "total": "WPA", + "fightShare": "Fight {fight}: {share}%" + }, + "insights": { + "title": "Key moments", + "biggestSwing": "Fight {fight} swung the map by {swing}% toward {team}.", + "ultCarryover": "Ult economy from fight {prevFight} carried an estimated {cost}% win-probability penalty for {team} into fight {fight}.", + "staggerCarryover": "Staggered deaths before fight {fight} cost {team} an estimated {cost}% win probability.", + "earlyControl": "{team} took early control, winning {wins} of the first {of} fights.", + "longStretch": "{team} held the upper hand for {duration}, peaking at {pct}% win probability.", + "winStreak": "{team} ran off {count} straight fight wins from {from} to {to}.", + "closing": "{team} closed out the map, taking {wins} of the final {of} fights.", + "topWpa": "{player} ({team}) swung the most win probability of anyone: {wpa}% across the map.", + "biggestSwingObjective": "Fight {fight} swung the map by {swing}% toward {team} — mostly from the objective progress and score it produced.", + "biggestSwingKills": "Fight {fight} swung the map by {swing}% toward {team} — mostly from the player advantage it created.", + "biggestSwingUlts": "Fight {fight} swung the map by {swing}% toward {team} — mostly from the ult economy it created." + }, + "story": { + "title": "How the map unfolded" + }, + "takeaways": { + "title": "Where {team} can improve", + "lossProfileObjective": "{pct}% of your lost win probability across {count} losing fights came through the objective — lost fights converting into captures and score. Plan the post-fight reset to deny the cap, not just the refight.", + "lossProfileKills": "{pct}% of your lost win probability across {count} losing fights came from player-count deficits. The fights themselves are the problem — review positioning and target priority first.", + "lossProfileUlts": "{pct}% of your lost win probability across {count} losing fights came from ult economy. Track enemy banks and stop taking fights into charged ults.", + "ultDiscipline": "You spent {extra} more ults than the enemy across {count} losing fights ({fights}). Review what triggered those commits — late ults in a lost fight fund the enemy's next push.", + "staggers": "Staggered deaths bled into {count} fights ({fights}), costing roughly {cost}% win probability. Reset together instead of trickling back in.", + "ultDeficit": "You took {count} fights ({fights}) while down on ult economy, costing about {cost}%. Delay the re-engage until the banks even out.", + "firstDeaths": "{player} died first in {count} of your {of} losing fights ({fights}). Review those openings — the first death sets the fight's trajectory." + }, + "missed": { + "title": "Missed opportunities", + "empty": "None — clean execution from ahead", + "showing": "Showing {shown} of {total}", + "fight": "Fight {fight}", + "wp": "{before}% → {after}%", + "reason": { + "objective": "Lost on the objective", + "kills": "Lost the kill exchange", + "ults": "Ult disadvantage", + "earlyFirstDeath": "Early death: {player}", + "stagger": "Entered staggered (−{cost}%)", + "ultDeficit": "Ult deficit (−{cost}%)" + }, + "ult": { + "value": "{kills, plural, one {# kill} other {# kills}}", + "none": "no value", + "died": "died mid-ult", + "unknown": "used", + "heroFallback": "{hero} ult", + "summary": "{converted}/{total} converted" + }, + "wpLost": "WP lost" + } + }, + "fightInitiation": { + "eyebrow": "GOING FIRST", + "title": "Fight Initiation", + "unavailable": "This map predates the detailed log format, so fight initiation can't be detected.", + "summaryWinrate": "{team} win rate going first", + "summaryFrequency": "{team} fights initiated", + "coverage": "Based on {labeled} of {total} fights ({contested} contested).", + "fightLabel": "Fight {number}", + "initiatedBy": "{team} went first", + "contested": "Contested start", + "undetermined": "Undetermined", + "won": "Won", + "lost": "Lost", + "lostOpener": "Went first, lost the opener", + "evidencePlayers": "{count} players committed", + "evidenceUlt": "ult commit", + "evidenceAbility": "ability commit", + "responseGap": "{seconds}s before response", + "confidenceHigh": "High confidence", + "confidenceMedium": "Medium confidence", + "confidenceLow": "Low confidence", + "roundStarted": "Round {round} started", + "roundEnded": "Round {round} ended", + "roundChange": "Round {previous} → {round}", + "toggleRounds": "Rounds" } }, "scoutingPage": { @@ -1653,7 +3294,7 @@ "helperText": "Search for an OWCS team by name or abbreviation.", "resultsLabel": "Team search results", "matchCount": "{count, plural, one {# match} other {# matches}}", - "winRate": "WR", + "winRate": "{winRate} WR", "noResults": "No teams found." }, "team": { @@ -1672,11 +3313,26 @@ "report": "Report", "recommendations": "Recommendations" }, + "picker": { + "label": "Scouting for:", + "placeholder": "Select your team", + "noTeam": "No team selected" + }, "overview": { "record": "Record", "winRate": "Win Rate", "weightedWinRate": "Weighted WR", + "weightedPercent": "{value} weighted", "weightedWinRateTooltip": "Win rate weighted by recency — recent matches count more than older ones.", + "recordQuality": "Record Quality", + "recordQualityValue": "{wins} of {total}", + "recordQualityDescription": "wins came against teams rated above 1500", + "strengthRating": "Strength Rating", + "provisionalRating": "Provisional — fewer than 5 matches", + "topPercentile": "Top {value}", + "matchesRated": "{count, plural, one {# match rated} other {# matches rated}}", + "noCompetitiveData": "No competitive match data available. Tag scrims with an opponent to enable cross-referenced analytics.", + "winRateTrend": "Win rate trend: {value} over last {count, plural, one {# match} other {# matches}}", "recentForm": "Recent Form", "recentFormDescription": "Last 10 match results", "matchHistory": "Match History", @@ -1688,7 +3344,7 @@ "tournament": "Tournament", "win": "W", "loss": "L", - "matchCount": "{count, plural, one {match} other {matches}}", + "matchCount": "{count, plural, one {# match} other {# matches}}", "allTime": "All-time win rate", "noMatches": "No matches found for this team.", "previousPage": "Previous", @@ -1704,7 +3360,9 @@ "4": "Record Quality counts how many wins came against teams rated above 1500 (the starting average), showing whether a strong record was earned against strong competition.", "5": "Match data is sourced from Liquipedia and covers the last year of OWCS tournaments. When you select your team with the picker, your scrim data is also factored in." } - } + }, + "eyebrow": "Overview", + "title": "Form & record" }, "heroBans": { "bansAgainstTeam": "Bans Against Them", @@ -1715,6 +3373,27 @@ "count": "Count", "weighted": "Weighted", "noBans": "No hero ban data available.", + "selectTeamTitle": "Select your team to see their ban targets", + "selectTeamDescription": "Use the “Scouting for” picker above to select your team and see which of your heroes the opponent targets.", + "disruptionTargets": "Disruption Targets", + "disruptionTargetsEmptyDescription": "Heroes to ban against them, ranked by expected disruption", + "disruptionTargetsDescription": "Heroes to ban against them — higher disruption score means a bigger impact on their win rate when banned", + "notEnoughBanData": "Not enough ban data to calculate disruption scores.", + "percentagePoints": "{value}pp", + "deltaWhenBanned": "{delta} when banned", + "availabilitySummary": "{available, plural, one {# available} other {# available}} / {banned, plural, one {# banned} other {# banned}}", + "showFewer": "Show fewer", + "showMore": "{count, plural, one {Show # more} other {Show # more}}", + "theirBanTargets": "Their Ban Targets", + "theirBanTargetsDescription": "How their ban patterns overlap with your most-played heroes", + "theirBanTargetsActiveDescription": "Cross-references their ban patterns with your most-played heroes", + "noBanTargetOverlap": "No overlap found between their bans and your hero pool.", + "riskCritical": "Critical", + "riskWatch": "Watch", + "riskSafe": "Safe", + "exposureSummary": "— they ban it {opponentBanRate} of maps; your team plays it {userPlayRate} of the time", + "protectedHeroes": "Protected Heroes", + "protectedHeroesDescription": "Heroes this team rarely bans — expect them to appear in compositions", "methodology": { "title": "How we analyze hero bans", "points": { @@ -1725,7 +3404,10 @@ "4": "Their Ban Targets cross-references the opponent's ban history with your most-played heroes. Critical: they ban it 30%+ of maps and your team plays it 10%+ of the time. Watch: ban rate 15%+ and play rate 5%+. Safe: low overlap.", "5": "Protected Heroes are heroes this team bans in fewer than 5% of their maps — these are composition staples they want available." } - } + }, + "eyebrow": "Hero bans", + "title": "Ban environment", + "delta": "Delta" }, "maps": { "byMapType": "Win Rate by Map Type", @@ -1737,8 +3419,26 @@ "played": "Played", "won": "Won", "winRate": "Win Rate", + "rawWinRate": "Raw WR", "weightedWinRate": "Weighted WR", + "trend": "Trend", "noMaps": "No map data available.", + "advisorTitle": "Map Veto Advisor", + "advisorDescription": "Maps sorted by net advantage. Pick from the top, ban from the bottom.", + "crossReferenceAvailable": "Cross-reference available", + "matchupMatrixLabel": "Map matchup matrix", + "vetoRecommendation": "Veto Recommendation", + "pick": "Pick", + "ban": "Ban", + "opponentPerformanceDescription": "Opponent map performance with strength-weighted win rates and trend indicators", + "percentagePoints": "{value}pp", + "netAdvantageLabel": "{map}: {advantage} net advantage", + "versus": "vs", + "stableTrend": "Stable trend ({delta})", + "improvingTrend": "Improving ({delta} recent) — caution", + "decliningTrend": "Declining ({delta} recent) — opportunity", + "selectTeamTitle": "Select your team to unlock map matchups", + "selectTeamDescription": "Use the “Scouting for” picker above to select your team and enable cross-referenced map veto analysis.", "methodology": { "title": "How we measure map performance and matchups", "points": { @@ -1749,7 +3449,12 @@ "4": "Confidence dots indicate sample size: filled green = 20+ maps (high), amber = 10–19 (medium), faded = 5–9 (low). Matchups where either side has fewer than 5 maps are hidden from the veto recommendation.", "5": "When you select your team via the picker, your scrim map data is combined with the opponent's OWCS data. Both are decay-weighted identically; scrim data uses stricter confidence thresholds (30/15/7 maps instead of 20/10/5)." } - } + }, + "eyebrow": "Maps", + "title": "Map performance", + "matchupTitle": "Map matchups", + "netAdvantageHeader": "Net adv.", + "lowSample": "Low sample" }, "recommendations": { "title": "Strategic Recommendations", @@ -1781,6 +3486,39 @@ } }, "players": { + "selectTeamTitle": "Select your team to unlock roster readiness", + "selectTeamDescription": "Use the “Scouting for” picker above to select your team. This tab cross-references your players’ hero depth with this opponent’s ban patterns to identify who is most at risk.", + "matchupSummary": "Showing your roster’s hero depth and vulnerabilities when facing {opponentName}. Players with narrow hero pools whose primary heroes are frequently banned by this opponent are flagged as at-risk.", + "thisOpponent": "this opponent", + "atRiskPlayers": "At-Risk Players", + "atRiskPlayersDescription": "Your players most vulnerable to this opponent’s ban strategy, ranked by hero pool depth and ban exposure", + "vulnerabilitySummary": "{role} · {hero} · {banRate} ban rate", + "rolePlayers": "Your {role} Players", + "roleDescription": "Hero depth and performance relative to your other {role} players", + "unknownHero": "Unknown", + "noSecondary": "No secondary", + "delta": "Delta: {value}", + "sigmaValue": "{sign}{value}σ", + "significantDropOff": "Significant drop-off", + "banExposure": "Ban exposure: {riskLevel}", + "banExposureDescription": "This opponent bans {hero} in {banRate} of their maps — if forced off their main, z-score drops by {delta}", + "unknownAmount": "an unknown amount", + "primaryBadge": "1st", + "roles": { + "tank": "Tank", + "damage": "Damage", + "support": "Support" + }, + "riskLevels": { + "critical": "critical", + "high": "high", + "moderate": "moderate", + "low": "low" + }, + "riskAriaCritical": "Critical vulnerability", + "riskAriaHigh": "High vulnerability", + "riskAriaModerate": "Moderate vulnerability", + "riskAriaLow": "Low vulnerability", "methodology": { "title": "How roster readiness is assessed", "points": { @@ -1791,9 +3529,26 @@ "4": "Vulnerability Index = hero depth delta × opponent ban rate. Scale: above 0.5 = critical (large drop-off + frequently banned), 0.25–0.5 = high, 0.1–0.25 = moderate, below 0.1 = low.", "5": "Heroes need at least 3 maps with 2+ minutes of play time to be included. Players need at least 5 total maps for a meaningful confidence rating." } - } + }, + "eyebrow": "Roster prep", + "title": "Roster readiness", + "bestPlayer": "Standout performer", + "mapsPlayed": "{count, plural, one {# map played} other {# maps played}}", + "standoutBanTarget": "Targeted by this opponent — primary hero banned in {banRate} of their maps" }, "report": { + "title": "What the data says", + "scrimOnly": "Scrim data only — no competitive history available", + "selectTeamTitle": "Select your team for the full report", + "selectTeamDescription": "Cross-referenced insights (map matchups, player vulnerabilities) require selecting your team with the “Scouting for” picker above. Opponent-only insights are shown below.", + "topInsights": "Top Insights", + "additionalFindings": "{count, plural, one {Additional Finding (#)} other {Additional Findings (#)}}", + "noDataTitle": "Not enough data yet", + "noDataDescription": "Check back after more matches are played. Insights require a minimum sample size to be reliable.", + "competitiveMaps": "{count, plural, one {{formattedCount} competitive map} other {{formattedCount} competitive maps}}", + "scrimMaps": "{count, plural, one {{formattedCount} scrim map} other {{formattedCount} scrim maps}}", + "basedOn": "Based on {sources}", + "sourceJoin": "{first} and {second}", "methodology": { "title": "How the report is generated", "points": { @@ -1805,9 +3560,62 @@ "5": "Confidence tiers — High: 20+ maps. Medium: 10–19. Low: 5–9. Insufficient (hidden): under 5. Scrim-only data uses stricter thresholds (30/15/7) since practice matches carry lower signal strength.", "6": "Data source is shown on each insight card: 'Competitive' = OWCS matches only, 'Scrim' = your uploaded scrim data only, 'Competitive + Scrim' = both sources combined." } + }, + "eyebrow": "Scouting report", + "subtitle": "The highest-priority reads first — your edges and their threats.", + "confidenceLabel": "{level} confidence", + "confidence": { + "high": "High", + "medium": "Medium", + "low": "Low", + "insufficient": "Insufficient" + }, + "emptyLinked": "Not enough data yet to surface confident reads for this matchup.", + "emptyUnlinked": "Select your team above to cross-reference matchups, bans, and player exposure.", + "showFewer": "Show fewer", + "showMore": "Show {count} more", + "sampleMaps": "{count, plural, one {# map} other {# maps}}", + "source": { + "owcs": "OWCS", + "scrim": "Scrim", + "owcs+scrim": "OWCS + Scrim" + } + }, + "empty": "No scouting data available for this team.", + "header": { + "eyebrow": "OWCS scouting" + }, + "strengthExplainer": { + "trigger": "What is strength rating?", + "title": "How strength rating works", + "intro": "An Elo-style rating of competitive results, centered at 1500.", + "elo": { + "label": "Elo-based.", + "body": "Beating higher-rated teams raises it more than beating weaker ones." + }, + "quality": { + "label": "Record quality.", + "body": "Wins against teams above 1500 count for more than padding against weak fields." + }, + "weighted": { + "label": "Recency-weighted.", + "body": "Recent matches carry more weight than older ones (90-day half-life)." + }, + "provisional": { + "label": "Provisional below 5 matches.", + "body": "Ratings from a thin sample are flagged and shouldn't be over-read." } }, - "empty": "No scouting data available for this team." + "faceitLink": { + "eyebrow": "FACEIT signal", + "title": "Also competes on FACEIT as {name}", + "subtitle": "This roster matches a FACEIT team — adding an individual skill read OWCS results can't provide.", + "aggregateFsr": "Roster FSR", + "coverage": "{covered} of {shared} matched players rated", + "viewProfile": "View FACEIT profile", + "matchedPlayers": "Matched players", + "disclaimer": "Inferred from handle overlap and shared FACEIT matches — not a verified identity." + } }, "player": { "title": "Scout Player", @@ -1817,7 +3625,9 @@ "description": "Search for professional Overwatch players and view their competitive history, hero pool, and tournament performance.", "ogTitle": "Scout Player | Parsertime", "ogDescription": "Search for professional Overwatch players and view their competitive history, hero pool, and tournament performance.", - "ogImage": "Scout Player" + "ogImage": "Scout Player", + "profileTitle": "{player} Scouting | Parsertime", + "profileDescription": "Scout {player} — hero pool, competitive history, and scrim performance." }, "search": { "label": "Search players", @@ -1859,7 +3669,12 @@ "result": "Result", "heroes": "Heroes", "win": "W", - "loss": "L" + "loss": "L", + "eyebrow": "OWCS player", + "tournamentsLabel": "Tournaments", + "date": "Date", + "historyEyebrow": "Tournament history", + "recordValue": "{wins}–{losses}" }, "analytics": { "scrimOverview": { @@ -1885,7 +3700,9 @@ "killsPerUltimate": "Average number of eliminations achieved per ultimate ability used. Higher values suggest efficient ultimate usage.", "consistencyScore": "Scored 0–100 based on how stable per-10 stats are across maps. Calculated as the inverse of the average coefficient of variation for eliminations, deaths, damage, and healing. Above 75 is highly consistent.", "mapsPlayed": "Total number of scrim maps recorded in Parsertime for this player. Serves as the sample size for all scrim-based metrics." - } + }, + "eyebrow": "Scrim profile", + "sectionTitle": "Scrim performance" }, "performanceRadar": { "title": "Performance Profile", @@ -1916,7 +3733,13 @@ "won": "Won", "noData": "No competitive map data available.", "methodologyMapType": "Win rate by map type is calculated from all competitive match results where this player's team participated. Percentages are wins ÷ total maps played for each game mode (Control, Escort, Hybrid, Push, Flashpoint).", - "methodologyByMap": "Individual map win rates are calculated from competitive match results. Borders are color-coded: green for ≥60% win rate, red for ≤40%, and neutral otherwise." + "methodologyByMap": "Individual map win rates are calculated from competitive match results. Borders are color-coded: green for ≥60% win rate, red for ≤40%, and neutral otherwise.", + "empty": "No competitive map data.", + "eyebrow": "Maps", + "lowSample": "Low sample", + "map": "Map", + "mode": "Mode", + "winRate": "Win Rate" }, "killAnalysis": { "title": "Combat Patterns", @@ -1925,6 +3748,7 @@ "topThreats": "Top Threats", "killMethods": "Kill Methods", "roleDistribution": "Role Distribution", + "count": "Count", "kills": "{count, plural, one {# kill} other {# kills}}", "deaths": "{count, plural, one {# death} other {# deaths}}", "methodology": "Kill and death data is sourced from Parsertime scrim logs. Top targets and threats show the 5 most-eliminated and most-died-to heroes by total count. Kill methods are grouped by ability type. Role distribution reflects the percentage of playtime on Tank, Damage, and Support based on hero time across all scrims." @@ -1936,7 +3760,9 @@ "weaknesses": "Weaknesses", "noStrengths": "Not enough data to identify strengths.", "noWeaknesses": "No significant weaknesses identified.", - "methodology": "Insights are auto-generated by analyzing z-scores (hero performance vs global averages), competitive map win rates, first pick/death rates, consistency scores, and hero pool depth. Strengths trigger when metrics exceed positive thresholds; weaknesses trigger on negative thresholds. All data sources are combined to provide a holistic view." + "methodology": "Insights are auto-generated by analyzing z-scores (hero performance vs global averages), competitive map win rates, first pick/death rates, consistency scores, and hero pool depth. Strengths trigger when metrics exceed positive thresholds; weaknesses trigger on negative thresholds. All data sources are combined to provide a holistic view.", + "eyebrow": "Scouting read", + "readTitle": "What to know" }, "noScrimData": "This player doesn't have scrim data in Parsertime yet. Competitive analytics and tournament history are still available.", "statLabels": { @@ -1946,12 +3772,261 @@ "healing_dealt": "Healing", "damage_blocked": "Damage Blocked" } - } - } - }, + }, + "searchEyebrow": "OWCS player" + }, + "searchEyebrow": "OWCS scouting" + }, + "faceitScoutingPage": { + "title": "FACEIT Team Scouting", + "subtitle": "Search a team to see how they play and what to play into them.", + "search": { + "placeholder": "Search teams…", + "matches": "{count} matches", + "noResults": "No teams found" + }, + "strengthFsr": "FSR", + "strengthTsr": "TSR", + "coverage": "{covered}/{size} rated", + "tabs": { + "overview": "Overview", + "maps": "Maps", + "bans": "Hero Bans", + "roster": "Roster", + "recommendations": "Recommendations" + }, + "overview": { + "record": "Record", + "winRate": "Win rate", + "weightedWinRate": "Weighted win rate", + "form": "Recent form", + "tierDistribution": "Tiers played", + "strength": "Team strength", + "eyebrow": "Overview", + "title": "How they play", + "matchesPlayed": "{count} matches", + "weightedSub": "{winRate}% weighted", + "win": "W", + "loss": "L", + "noForm": "No matches recorded." + }, + "maps": { + "byMap": "By map", + "byType": "By mode", + "map": "Map", + "mode": "Mode", + "played": "Played", + "won": "Won", + "winRate": "Win rate", + "weighted": "Weighted", + "lowSample": "Low sample", + "attackDefense": "Attack vs Defense", + "attacking": "Attacking first", + "defending": "Defending first", + "eyebrow": "Maps", + "title": "Map performance", + "empty": "No map data.", + "mapsWon": "{won}/{played} maps" + }, + "bans": { + "title": "Hero ban environment", + "caveat": "Map-level signal: win rate when a hero is in the ban pool. FACEIT does not attribute bans to a side.", + "hero": "Hero", + "withBan": "With ban", + "withoutBan": "Without ban", + "delta": "Delta", + "sample": "Sample", + "showUnrated": "Show low-sample heroes", + "eyebrow": "Hero bans", + "empty": "No ban data.", + "banTarget": "Ban target", + "hideUnrated": "Hide low-sample heroes" + }, + "roster": { + "player": "Player", + "role": "Role", + "share": "Played", + "starter": "Starter", + "sub": "Sub", + "fsr": "FSR", + "tsr": "TSR", + "eyebrow": "Roster", + "title": "Roster" + }, + "recommendations": { + "pickMaps": "Pick these maps", + "avoidMaps": "Avoid or ban these maps", + "banHeroes": "Consider banning", + "dontBan": "Don't ban", + "none": "Not enough data for recommendations yet.", + "mapReason": "{winRate}% win rate over {played} maps", + "heroReason": "{withoutBan}% without ban vs {withBan}% with ban (n={bannedSample}/{notBannedSample})" + }, + "related": { + "title": "Also competed as", + "combine": "Combine history", + "separate": "Separate history", + "shared": "{n} shared players", + "matches": "{n} matches" + }, + "header": { + "eyebrow": "FACEIT team scout" + }, + "gamePlan": { + "eyebrow": "Game plan", + "title": "What to play into them", + "subtitle": "Synthesized from their map and ban tendencies. Sample sizes shown.", + "forceMaps": "Force these maps", + "banHeroes": "Ban these heroes", + "avoidMaps": "Avoid or ban these maps", + "dontBan": "Don't waste a ban", + "none": "Not enough data for a game plan yet." + }, + "searchEyebrow": "FACEIT scouting", + "metadata": { + "profileTitle": "{team} · FACEIT scouting | Parsertime", + "profileDescription": "Scouting report for {team}: FSR, map win rates, hero ban tendencies, roster, and a game plan for what to play into them.", + "searchTitle": "FACEIT Team Scouting | Parsertime" + }, + "patches": { + "eyebrow": "Patch timeline", + "title": "Form by patch", + "description": "How results move across balance patches. Recent eras weigh most — the meta shifts fast, so older form matters less.", + "patch": "Patch", + "winRate": "Win rate", + "matches": "Matches", + "mostBanned": "Most banned", + "preTracking": "Before patch tracking", + "through2025": "Through 2025", + "midSeason": "Mid-season · {date}", + "now": "now", + "lowSample": "low", + "noRecent": "No matches since patch tracking began (Jan 2026)." + } + }, + "faceitPlayerPage": { + "title": "FACEIT Player Scouting", + "subtitle": "Search a player to see their FSR, stat profile, and history.", + "search": { + "placeholder": "Search players…", + "matches": "{count} matches", + "noResults": "No players found", + "unrated": "Unrated" + }, + "header": { + "eyebrow": "Player", + "yes": "Yes", + "no": "No", + "region": "Region", + "level": "FACEIT level", + "verified": "Verified", + "team": "Current team" + }, + "fsr": { + "eyebrow": "FSR", + "title": "FSR", + "headline": "Headline FSR", + "byTier": "By tier", + "tier": "Tier", + "rating": "FSR", + "maps": "Maps", + "percentile": "Percentile", + "primary": "Primary", + "unrated": "Not enough rated maps for an FSR yet.", + "recent": "Last 365d", + "ratingScale": "Scale" + }, + "radar": { + "title": "Stat profile", + "tierLabel": "Tier", + "vsPeers": "Per-stat z-score vs tier peers" + }, + "insights": { + "strengths": "Strengths", + "weaknesses": "Weaknesses", + "none": "No standout stats" + }, + "roles": { + "eyebrow": "Roles", + "title": "Role usage", + "role": "Role", + "maps": "Maps", + "share": "Share" + }, + "maps": { + "eyebrow": "Maps", + "title": "Map winrates", + "byMap": "By map", + "byType": "By mode", + "map": "Map", + "mode": "Mode", + "played": "Played", + "won": "Won", + "winRate": "Win rate", + "lowSample": "Low sample", + "empty": "No map data." + }, + "history": { + "eyebrow": "History", + "title": "Match history", + "date": "Date", + "team": "Team", + "opponent": "Opponent", + "tier": "Tier", + "score": "Score", + "result": "Result", + "role": "Role", + "win": "W", + "loss": "L", + "empty": "No matches found", + "count": "{count} matches tracked" + }, + "teams": { + "eyebrow": "Teams", + "title": "Teams played for", + "appearances": "{count} matches" + }, + "stat": { + "eliminations": "Eliminations", + "finalBlows": "Final blows", + "deaths": "Deaths (inv.)", + "damageDealt": "Damage", + "healingDone": "Healing", + "damageMitigated": "Mitigated", + "soloKills": "Solo kills", + "assists": "Assists", + "objectiveTime": "Objective time" + }, + "threat": { + "eyebrow": "Scouting brief", + "title": "Threat assessment", + "headlineRating": "{role} rating", + "percentile": "Better than {pct}% of {tier} {role} players", + "threatensWith": "Threatens with", + "exploit": "Exploit", + "strongestMap": "Strongest map", + "weakestMap": "Weakest map", + "trackedRecord": "Tracked record", + "matchesTracked": "{count} matches tracked", + "zAbove": "+{z} vs peers", + "zBelow": "{z} vs peers", + "mapDetail": "{winRate}% over {played} maps", + "unratedEyebrow": "Rating", + "unratedTitle": "Unrated", + "unratedBody": "Not enough rated maps for an FSR yet. Map and match history below still apply.", + "noInsights": "Not enough data to summarize yet." + }, + "searchEyebrow": "FACEIT scouting", + "metadata": { + "profileTitle": "{player} · FACEIT scouting | Parsertime", + "profileDescription": "Scouting report for {player}: FSR by role and tier, stat profile, map win rates, and match history.", + "searchTitle": "FACEIT Player Scouting | Parsertime" + } + }, "coaching": { "title": "Coaching Canvas", "subtitle": "Annotate maps with hero placements, drawings, and strategy notes.", + "eyebrow": "Tactical board", "metadata": { "title": "Coaching Canvas | Parsertime", "description": "Annotate maps with hero placements and strategy drawings." @@ -1974,7 +4049,12 @@ "reset": "Reset", "strokeWidth": "Stroke width", "team1Color": "Team 1 color", - "team2Color": "Team 2 color" + "team2Color": "Team 2 color", + "neutralColors": { + "white": "White", + "black": "Black", + "yellow": "Yellow" + } }, "mapSelector": { "placeholder": "Select a map", @@ -2007,6 +4087,10 @@ "previousPage": "Previous", "nextPage": "Next", "pageInfo": "Page {current} of {total}", + "matchCount": "{count, plural, one {# match} other {# matches}}", + "fetchError": "Failed to fetch matches", + "errorLoading": "Error loading matches: {message}", + "unknownError": "Unknown error", "noMatches": "No unlabeled matches with VODs found.", "vs": "vs" }, @@ -2028,6 +4112,7 @@ "saveAll": "Save All Maps", "saving": "Saving...", "saved": "Saved!", + "saveFailed": "Save failed", "saveSuccess": "Team compositions saved successfully.", "saveError": "Failed to save team compositions.", "roleConstraint": "Select 1 Tank, 2 Damage, 2 Support", @@ -2035,6 +4120,8 @@ "playerAssignments": "{team} Player Assignments", "assignPlayer": "Assign player for {hero}", "selectPlayer": "Select player...", + "twitchVodTitle": "Twitch VOD", + "noVodAvailable": "No VOD available", "instructions": { "title": "Reviewer Instructions", "body": "Watch the VOD and use the panel on the right to record the hero composition for each map. For each hero, assign the player who was playing that hero at the start of the map.", @@ -2071,6 +4158,67 @@ "ogDescription": "Stats for {hero} on Parsertime. Parsertime is a tool for analyzing Overwatch scrims.", "ogImage": "Stats for {hero}" }, + "mapHeroTrends": { + "eyebrow": "Map Meta · Last 60 days", + "eyebrowWithCount": "Map Meta · Last 60 days · {count, plural, one {# unique map} other {# unique maps}}", + "empty": { + "title": "No recent map data", + "description": "Upload a scrim in the last 60 days to populate this view." + }, + "stats": { + "scrims": "Scrims", + "maps": "Maps", + "heroes": "Heroes" + }, + "allMaps": "All maps", + "searchMaps": "Search maps...", + "noMapsFound": "No maps found.", + "sortLabel": "Sort", + "roleFilter": "Role filter", + "noHeroesMatch": "No heroes match the selected filter.", + "showPickRateTrend": "Show pick rate trend for {hero}", + "record": "{wins, number}W · {losses, number}L", + "playtimeHoursMinutes": "{hours}h {minutes}m", + "playtimeMinutes": "{minutes}m", + "roles": { + "all": "All", + "tank": "Tank", + "damage": "Damage", + "support": "Support" + }, + "subroles": { + "hitscanDamage": "Hitscan DPS", + "flexDamage": "Flex DPS", + "groundTank": "Ground Tank", + "diveTank": "Dive Tank", + "flexSupport": "Flex Support", + "mainSupport": "Main Support" + }, + "mapTypes": { + "control": "Control", + "escort": "Escort", + "hybrid": "Hybrid", + "push": "Push", + "flashpoint": "Flashpoint", + "clash": "Clash", + "other": "Other" + }, + "sort": { + "pickRate": "Pick rate", + "playtime": "Playtime", + "winrate": "Winrate", + "trend": "Trend" + }, + "table": { + "hero": "Hero", + "role": "Role", + "playtime": "Playtime", + "pick": "Pick", + "winrate": "Winrate", + "sample": "Sample", + "trend": "Δ 30d" + } + }, "title": "Stats", "searchbar": { "placeholder": "Enter a player name...", @@ -2320,13 +4468,58 @@ "environmental_kills": "Environmental Kills", "deaths": "Deaths", "hero_damage_dealt": "Hero Damage Dealt" + }, + "identity": { + "scrims": "Scrims", + "games": "Maps", + "hours": "Hours", + "winrate": "Win rate", + "winrateSub": "{wins} of {total} maps won", + "scopeTimeframe": "Across {scrims} scrims · last {timeframe}", + "scopeAllTime": "Across {scrims} scrims · all time", + "scopeCustom": "Across {scrims} scrims · {from} → {to}", + "scopeCustomEmpty": "Pick a date range to see stats" + }, + "heroPortfolio": { + "title": "Hero portfolio", + "description": "Most played heroes in this window. Click any to filter the rest of the page.", + "empty": "No heroes played in this window", + "games": "Maps", + "finalBlows": "FBs", + "deaths": "Deaths", + "role": { + "tank": "Tank", + "damage": "Damage", + "support": "Support" + } + }, + "sections": { + "overview": "Overview", + "breakdown": "Performance breakdown", + "form": "Form", + "maps": "Map record", + "habits": "Habits", + "combat": "Combat" + }, + "performanceBreakdown": { + "description": "How this player ranks on the CSR leaderboard for the chosen hero, plus a Z-score breakdown vs the same peer pool.", + "selectOne": "Pick a single hero in the portfolio above to see the SR distribution and Z-score breakdown.", + "loading": "Loading breakdown…", + "error": "Could not load the breakdown for this hero.", + "notRanked": "{hero} doesn't have enough peer data yet (needs 10+ maps with 60s+ playtime per player).", + "distributionLabel": "SR distribution · {hero}", + "vsLabel": "Z-score vs other {hero} players", + "explainer": "Each axis shows how many standard deviations above (positive) or below (negative) the average this player sits on the selected hero. Pulls from the same data as the CSR leaderboard." } }, "compareStats": { + "eyebrow": "Stats · Player comparison", "title": "Compare Player Statistics", "enterPlayerNames": "Enter Player Names", "player1": "Player 1", "player2": "Player 2", + "playerA": "Player A", + "playerB": "Player B", "player1Placeholder": "Enter first player name", "player2Placeholder": "Enter second player name", "compare": "Compare", @@ -2341,7 +4534,8 @@ }, "heroStats": { "title": "Hero Stats", - "description": "Select a hero to view their stats. Stats are aggregated from all scrims played.", + "pickerTitle": "Hero stats", + "description": "Pick a hero to dive into their performance across all scrims.", "timeframe": { "one-week": "one week", "two-weeks": "two weeks", @@ -2441,13 +4635,39 @@ "environmental_kills": "Environmental Kills", "deaths": "Deaths", "hero_damage_dealt": "Hero Damage Dealt" + }, + "identity": { + "games": "Games", + "totalKills": "Kills", + "totalDeaths": "Deaths", + "kd": "K / D", + "scopeTimeframe": "Across {scrims} scrims · last {timeframe}", + "scopeAllTime": "Across {scrims} scrims · all time", + "scopeCustom": "Across {scrims} scrims · {from} → {to}", + "scopeCustomEmpty": "Pick a date range to see stats" + }, + "sections": { + "overview": "Overview", + "talent": "Talent pool", + "form": "Form", + "combat": "Combat" + }, + "talent": { + "description": "Where the CSR-eligible player pool sits on this hero, plus the top performers by CSR.", + "distribution": "SR distribution", + "topPlayers": "Top players", + "topCount": "top {count}", + "notRanked": "Not enough peer data on this hero yet (needs 10+ maps with 60s+ playtime per player).", + "explainer": "CSR is computed from the selected timeframe above. Click a player to see their full profile." } }, "playerMetrics": { "mvpScore": "Avg MVP Score", "fletaDeadliftPercentage": "Fleta Deadlift %", "firstPickPercentage": "First Pick %", + "totalFirstPicks": "{count, plural, =0 {No first picks} =1 {1 total first pick} other {# total first picks}}", "firstDeathPercentage": "First Death %", + "totalFirstDeaths": "{count, plural, =0 {No first deaths} =1 {1 total first death} other {# total first deaths}}", "fightReversalPercentage": "Fight Reversal %", "killsPerUltimate": "Kills per Ultimate", "averageUltChargeTime": "Avg Ult Charge Time", @@ -2458,7 +4678,7 @@ "teamPage": { "metadata": { "title": "Teams | Parsertime", - "description": "Parsertime is a tool for analyzing Overwatch scrims.", + "description": "Create and manage your Overwatch teams, rosters, and scrim history.", "ogTitle": "Teams | Parsertime", "ogDescription": "Parsertime is a tool for analyzing Overwatch scrims.", "ogImage": "Teams" @@ -2509,6 +4729,12 @@ "owner": "(Owner)", "you": "(You)", "addMember": "Add a member...", + "empty": { + "title": "No Teams", + "joinInvite": "Click here to join a team from an invite code.", + "createPrompt": "Or, create a new team by clicking the button below.", + "createTeam": "Create Team" + }, "join": { "enterToken": "Enter Your Invite Token", "joinTeam": "Join Team", @@ -2684,7 +4910,11 @@ "usage": "{current} of {allowed} used" }, "viewStats": "View stats", - "viewAvailability": "Availability" + "viewAvailability": "Availability", + "joinMetadata": { + "title": "Join a Team | Parsertime", + "description": "Accept your invite and join your Overwatch team on Parsertime." + } }, "settingsPage": { "metadata": { @@ -2738,6 +4968,15 @@ "profileForm": { "minMessage": "Name must be at least 2 characters.", "maxMessage": "Name must not be longer than 30 characters.", + "specialCharsMessage": "Name must not contain special characters.", + "unknownError": "Unknown error", + "errors": { + "updateName": "Failed to update name: {res}", + "updateBattletag": "Failed to update battletag: {res}", + "updateTitle": "Failed to update title: {res}", + "updateOnboarding": "Failed to update onboarding: {res}", + "updateSettings": "Failed to update settings: {res}" + }, "onSubmit": { "title": "Profile updated", "description": "Your profile has been successfully updated.", @@ -2796,6 +5035,28 @@ "title": "Accessibility Settings", "description": "Configure colorblind accessibility options to improve your experience", "label": "Colorblind Mode", + "options": { + "off": { + "label": "Off", + "description": "Standard colors" + }, + "deuteranopia": { + "label": "Deuteranopia", + "description": "Red-green colorblind (green-weak)" + }, + "protanopia": { + "label": "Protanopia", + "description": "Red-green colorblind (red-weak)" + }, + "tritanopia": { + "label": "Tritanopia", + "description": "Blue-yellow colorblind" + }, + "custom": { + "label": "Custom", + "description": "Choose your own team colors" + } + }, "team1": "Team 1", "team2": "Team 2", "customTeamColors": "Custom Team Colors", @@ -2810,6 +5071,7 @@ }, "title": { "title": "Title", + "placeholder": "Select a title", "description": "Select a title to use on your profile." } }, @@ -2847,13 +5109,21 @@ "selectChannel": "Select channel", "selectTeams": "Select teams", "submit": "Save", - "saving": "Saving..." + "saving": "Saving...", + "discordNotLinked": "Link your Discord account in the settings above to choose a server.", + "noSharedServers": "No servers found. You must share at least one server with the Parsertime bot." }, "toast": { "created": "Notification config created successfully.", "deleted": "Notification config deleted successfully.", - "error": "Something went wrong. Please try again." + "error": "Something went wrong. Please try again.", + "discordNotLinked": "Link your Discord account to configure notifications.", + "notGuildMember": "You must be a member of that Discord server." } + }, + "ranked": { + "title": "Ranked Stats", + "description": "Control who can see your ranked stats on your public profile." } }, "admin": { @@ -2875,6 +5145,40 @@ "target": "Target: {target}", "search-user-email": "Search User Email", "search-target": "Search Target", + "date-range": { + "select": "Select date range", + "from": "From {date}", + "until": "Until {date}", + "range": "{from} - {to}" + }, + "actions": { + "USER_BAN": "User Ban", + "USER_UNBAN": "User Unban", + "USER_AVATAR_UPDATED": "User Avatar Updated", + "USER_ACCOUNT_DELETED": "User Account Deleted", + "USER_NAME_UPDATED": "User Name Updated", + "TRUST_SCORE_ADJUST": "Trust Score Adjust", + "IMPERSONATE_USER": "Impersonate User", + "SUSPICIOUS_ACTIVITY_DETECTED": "Suspicious Activity Detected", + "TEAM_CREATED": "Team Created", + "TEAM_UPDATED": "Team Updated", + "TEAM_DELETED": "Team Deleted", + "TEAM_AVATAR_UPDATED": "Team Avatar Updated", + "TEAM_INVITE_SENT": "Team Invite Sent", + "TEAM_JOINED": "Team Joined", + "TEAM_LEFT": "Team Left", + "TEAM_MEMBER_PROMOTED": "Team Member Promoted", + "TEAM_MEMBER_DEMOTED": "Team Member Demoted", + "TEAM_MEMBER_REMOVED": "Team Member Removed", + "TEAM_OWNERSHIP_TRANSFERRED": "Team Ownership Transferred", + "SCRIM_CREATED": "Scrim Created", + "SCRIM_UPDATED": "Scrim Updated", + "SCRIM_DELETED": "Scrim Deleted", + "MAP_CREATED": "Map Created", + "MAP_UPDATED": "Map Updated", + "MAP_DELETED": "Map Deleted", + "BUG_REPORT_SUBMITTED": "Bug Report Submitted" + }, "download": { "button": "Download CSV", "downloading": "Downloading...", @@ -2952,7 +5256,7 @@ }, "user-search": { "metadata": { - "title": "User Search — Admin | Enter Queue", + "title": "User Search — Admin | Parsertime", "description": "Search for users with advanced filtering options" }, "title": "User Search", @@ -2973,8 +5277,18 @@ "select-billing-plan": "Select billing plan", "all-plans": "All Plans", "join-date-range": "Join Date Range", + "select-join-date-range": "Select join date range", + "date-range-from": "From {date}", + "date-range-until": "Until {date}", + "date-range": "{from} - {to}", "reset-filters": "Reset Filters" }, + "plans": { + "free": "Free", + "basic": "Basic", + "premium": "Premium", + "unknown": "Unknown" + }, "table": { "username": "Username", "email": "Email", @@ -3005,6 +5319,22 @@ "analytics": { "title": "Analytics", "description": "View detailed analytics and charts for user activity and growth.", + "activeUsers": { + "title": "Monthly Active Users", + "description": "Users on a team that uploaded a scrim this month, versus the rest of the user base" + }, + "monthlyActiveUsers": { + "title": "Active Users Over Time", + "description": "Unique users each month whose team uploaded a scrim, over the last 12 months" + }, + "activeTeams": { + "title": "Monthly Active Teams", + "description": "Teams that uploaded a scrim this month, versus all teams" + }, + "monthlyActiveTeams": { + "title": "Active Teams Over Time", + "description": "Unique teams that uploaded a scrim each month, over the last 12 months" + }, "userGrowth": { "title": "User Growth Over Time", "description": "Monthly user registration trends over the last 12 months" @@ -3028,6 +5358,51 @@ "billingPlans": { "title": "Billing Plan Distribution", "description": "Breakdown of users by billing plan (Free, Basic, Premium)" + }, + "charts": { + "last12Months": "Last 12 months", + "allTime": "All time", + "users": "Users", + "activeUsers": "Active Users", + "activeTeams": "Active Teams", + "teamsCreated": "Teams Created", + "projected": "Projected (rest of month)", + "currentMonthInProgress": "Current month in progress" + }, + "tabs": { + "growth": "Growth", + "adoption": "Feature Adoption", + "activity": "Activity", + "funnels": "Funnels" + }, + "usage": { + "adoptionTitle": "Feature Adoption", + "adoptionDescription": "Unique users per feature over the last 30 days", + "activeUsersTitle": "Active Users", + "activeUsersDescription": "Daily active users over the last 30 days", + "hotColdTitle": "Page Usage", + "hotColdDescription": "Most- and least-visited pages over the last 30 days", + "uniqueUsers": "Unique users", + "totalEvents": "Total events", + "dau": "DAU", + "page": "Page", + "views": "Views", + "underused": "Underused", + "scorecard": { + "dau": "Daily Active", + "wau": "Weekly Active", + "mau": "Monthly Active", + "stickiness": "Stickiness", + "events30d": "Events (30d)", + "activeFeatures": "Active Features" + }, + "funnels": { + "title": "Conversion Funnels", + "description": "Step-by-step conversion over the selected window", + "step": "Step", + "users": "Users", + "conversion": "Conversion" + } } }, "title": "Admin Settings", @@ -3112,6 +5487,12 @@ "errorTitle2": "An error occurred", "errorDescription2": "An error occurred while sending your message. Please try again later." } + }, + "metadata": { + "title": "Contact | Parsertime", + "description": "Get in touch with the Parsertime team.", + "ogTitle": "Contact | Parsertime", + "ogDescription": "Get in touch with the Parsertime team." } }, "aboutPage": { @@ -3353,7 +5734,7 @@ "privacyPage": { "metadata": { "title": "Privacy Policy | Parsertime", - "description": "Privacy Policy for Parsertime - A tool for analyzing Overwatch scrims.", + "description": "How Parsertime collects, uses, and protects your data.", "ogTitle": "Privacy Policy | Parsertime", "ogDescription": "Privacy Policy for Parsertime - A tool for analyzing Overwatch scrims." }, @@ -3405,7 +5786,7 @@ "termsPage": { "metadata": { "title": "Terms of Service | Parsertime", - "description": "Terms of Service for Parsertime - A tool for analyzing Overwatch scrims.", + "description": "The terms governing your use of Parsertime.", "ogTitle": "Terms of Service | Parsertime", "ogDescription": "Terms of Service for Parsertime - A tool for analyzing Overwatch scrims." }, @@ -3521,6 +5902,7 @@ "dataToolsTitle": "Data Tools", "playerStats": "Player Stats", "heroStats": "Hero Stats", + "mapStats": "Map Stats", "teamStats": "Team Stats", "comparePlayers": "Compare Players", "leaderboard": "Leaderboard", @@ -3542,12 +5924,165 @@ "coachingCanvas": "Canvas", "tournamentsTitle": "Tournaments", "viewTournaments": "View Tournaments", - "createTournament": "Create Tournament" + "createTournament": "Create Tournament", + "matchmaker": "Matchmaker" + }, + "mapCalibrationPage": { + "title": "Map Calibration", + "description": "Calibrate coordinate transforms for top-down map images. Each map needs anchor points mapping world coordinates to image pixels.", + "mapTypes": { + "control": "Control", + "escort": "Escort", + "flashpoint": "Flashpoint", + "hybrid": "Hybrid", + "push": "Push" + }, + "list": { + "searchPlaceholder": "Search maps…", + "noMatches": "No maps match your filters.", + "status": { + "noImage": "No image", + "calibrated": "Calibrated", + "anchors": "{count, plural, one {# anchor} other {# anchors}}", + "imageUploaded": "Image uploaded" + }, + "anchorSummary": "{count, plural, one {# anchor} other {# anchors}}", + "transformSavedSuffix": " · transform saved" + }, + "anchorDialog": { + "title": "Add Anchor Point", + "description": "Image position: ({imageU}, {imageV}). Enter the corresponding in-game world coordinates. Overwatch uses (X, Y, Z) where Y is vertical — enter only X and Z.", + "worldX": "World X", + "worldZ": "World Z", + "worldXPlaceholder": "e.g. 42.5", + "worldZPlaceholder": "e.g. -18.3", + "label": "Label (optional)", + "labelPlaceholder": "e.g. Point A, Spawn door", + "cancel": "Cancel", + "addAnchor": "Add Anchor" + }, + "anchorList": { + "empty": "No anchor points yet. Click on the map image to place one.", + "label": "Label", + "worldCoordinates": "World (X, Z)", + "imageCoordinates": "Image (U, V)", + "deleteAnchor": "Delete anchor {label}" + }, + "upload": { + "button": "Upload Image", + "title": "Upload Map Image", + "description": "Upload a top-down orthographic image for {mapName}. Supports PNG and JPEG up to 150MB.", + "selectFile": "Select map image file", + "requestingUploadUrl": "Requesting upload URL…", + "uploadingToStorage": "Uploading image to storage…", + "processingImage": "Processing image…", + "uploading": "Uploading…", + "largeImageNote": "This may take a moment for large images.", + "uploadUrlError": "Failed to get upload URL", + "storageUploadError": "Failed to upload image to storage", + "processImageError": "Failed to process image", + "uploadError": "Failed to upload image." + }, + "editor": { + "back": "Back", + "noImageUploaded": "No image uploaded for this map yet.", + "anchorPoints": "{formattedCount} {count, plural, one {Anchor Point} other {Anchor Points}}", + "computeTransform": "Compute Transform", + "computing": "Computing…", + "save": "Save", + "saving": "Saving…", + "minimumAnchorsHint": "Place at least 3 anchor points to compute a transform. More points improve accuracy.", + "createRecordError": "Failed to create calibration record.", + "addAnchorError": "Failed to add anchor point.", + "deleteAnchorError": "Failed to delete anchor point.", + "computeTransformError": "Failed to compute transform.", + "transformSaved": "Transform saved.", + "saveTransformError": "Failed to save transform.", + "imageReplaced": "Image replaced. Anchors cleared.", + "replaceImageError": "Failed to replace image." + }, + "transform": { + "title": "Computed Transform", + "saved": "Saved", + "unsaved": "Unsaved", + "scaleX": "Scale X:", + "scaleY": "Scale Y:", + "determinant": "Determinant:", + "avgError": "Avg Error:", + "pixelsPerUnit": "{value} px/unit", + "errorValue": "{percent} ({pixels}px)", + "withinTolerance": "Within expected tolerance for high-res images." + }, + "preview": { + "title": "Preview Test Points", + "description": "Enter world coordinates to verify they project to the expected map position. From Overwatch's (X, Y, Z), use only X and Z.", + "worldX": "World X", + "worldZ": "World Z", + "label": "Label", + "addTestPoint": "Add Test Point", + "clear": "Clear ({count})" + }, + "canvas": { + "ariaLabel": "Map calibration canvas. Click to place anchor points, drag to pan, scroll to zoom.", + "loading": "Loading map image…", + "gridOn": "Grid ON", + "gridOff": "Grid OFF", + "instructions": "{zoom} · Scroll to zoom · Drag to pan · Click to place" + }, + "replaceRender": { + "button": "Replace render", + "title": "Replace render for {mapName}", + "description": "Upload the new render, then paste the transform from the local alignment script. Your calibration is moved onto it — nothing changes until you confirm.", + "selectFile": "Select new render", + "uploading": "Uploading…", + "alignSummary": "{inliers} inliers · residual {residual}px", + "lowConfidence": "Low confidence — verify the anchor dots carefully before confirming.", + "staged": "Render uploaded. Run the alignment script locally and paste its output below.", + "scriptHint": "scripts/map-align ▸ uv run cli.py [old-image] [new-render]", + "transformLabel": "Alignment transform (JSON)", + "transformPlaceholder": "Paste the script's JSON output here", + "parseError": "Couldn't read that — paste the full JSON output of the alignment script.", + "showingOld": "Showing old image", + "showingNew": "Showing new render", + "compareTitle": "Compare old ↔ new:", + "compareBlink": "Blink", + "compareSwipe": "Swipe", + "confirm": "Confirm & apply", + "cancel": "Cancel", + "applying": "Applying…", + "applied": "Render replaced and calibration preserved.", + "applyError": "Failed to apply the new render." + } + }, + "credits": { + "title": "AI chat credits", + "description": "Pay-as-you-go balance for the AI analyst. Top up any time — {minimum} minimum.", + "settingsDescription": "Pay-as-you-go balance for the AI analyst.", + "currentBalance": "Current balance", + "balance": "Balance", + "addCredits": "Add credits", + "custom": "Custom", + "topUp": "Top up", + "manage": "Manage", + "autoRefill": "Auto-refill", + "autoRefillDescription": "Charge your saved card when the balance drops below the threshold.", + "savePaymentMethodHint": "Add credits once to save a payment method for auto-refill.", + "refillWhenBelow": "Refill when below", + "refillAmount": "Refill amount", + "pricing": "Pricing", + "pricingDescription": "{input} per million input tokens, {output} per million output tokens. Includes a {fee} platform fee over the underlying model rate.", + "minimumTopup": "Minimum top-up is {amount}.", + "checkoutError": "Failed to start checkout.", + "autoRefillUpdateError": "Failed to update auto-refill.", + "invalidDollarAmount": "Enter a valid dollar amount.", + "autoRefillSummary": " · Auto-refill {amount} at {threshold}", + "recentActivity": "Recent activity", + "noTransactions": "No transactions yet. Top up from the AI chat page or click Manage." }, "demoPage": { "metadata": { "title": "{mapName} Demo | Parsertime", - "description": "Demo overview for {mapName} on Parsertime. Parsertime is a tool for analyzing Overwatch scrims.", + "description": "Demo overview for {mapName} — explore Parsertime's Overwatch scrim analytics with sample data.", "ogTitle": "{mapName} Demo | Parsertime", "ogDescription": "Demo overview for {mapName} on Parsertime. Parsertime is a tool for analyzing Overwatch scrims.", "ogImage": "{mapName} Demo" @@ -3574,7 +6109,219 @@ "notification-created": "Notification created successfully", "create-notification-error": "Failed to create notification", "error-loading-description": "Please try refreshing the page.", - "no-notifications-description": "You'll see notifications here when they arrive." + "no-notifications-description": "You'll see notifications here when they arrive.", + "metadata": { + "title": "Notifications | Parsertime", + "description": "Your latest Parsertime alerts and team activity." + } + }, + "matchmaker": { + "title": "Scrim matchmaker", + "subtitle": "Find a team near your skill level", + "metadata": { + "title": "Matchmaker | Parsertime", + "description": "Find a scrim partner whose roster sits at a comparable TSR — built on the same skill rating you see on the team page." + }, + "hub": { + "eyebrow": "Matchmaker", + "title": "Find a scrim partner", + "description": "Match your roster against teams at a comparable skill level, see where they sit on the bracket ladder, and send a single canned introduction. Built on Team TSR — same scale you see on the team page.", + "mechanicsEyebrow": "Mechanics · How it works", + "mechanicsTitle": "Skill-aligned, lightweight, abuse-resistant", + "mechanicsDescription": "The matchmaker is a routed introduction, not a booking system. You browse teams; you fire one canned request; the other team picks it up in-app and on Discord.", + "ranking": { + "label": "How matches are ranked", + "sameRegion": "Same TSR region preferred (NA / EMEA)", + "closestDistance": "Closest absolute TSR distance, lowest first", + "sameBracket": "Same scrim bracket, then adjacent bracket bands", + "availabilityBonus": "Bonus when this week's availability overlaps", + "cooldownPenalty": "Recently messaged teams sink to the bottom" + }, + "sent": { + "label": "What gets sent", + "message": "A non-customizable message with your team's bracket and TSR", + "roster": "Top five rostered players' BattleTags and TSRs", + "delivery": "Delivered in-app to owners and managers, plus Discord if configured" + }, + "rateLimits": { + "label": "Rate limits", + "perPair": "One request per pair, per direction, per 24 hours", + "daily": "Up to ten outgoing requests per team per 24 hours" + }, + "teamPickerEyebrow": "Pick a team · Start", + "teamPickerTitle": "Which roster are you searching from?", + "teamPickerDescription": "Each team you belong to has its own Team TSR. Choose the one you want to scrim with — only teams with a Team TSR can be searched from.", + "teamTsr": "TSR {rating, number}", + "noTeamTsr": "No Team TSR yet", + "searchAs": "Search as {teamName}", + "ineligibleTitle": "Team needs a Team TSR before matchmaking", + "notEligible": "Not yet eligible", + "emptyTitle": "You're not on any teams yet", + "emptyDescription": "Join or create a team first, then return here to find scrim partners.", + "manageTeams": "Manage teams →", + "manageBlacklist": "Manage blacklist", + "bracketWithBand": "{band} {tier}", + "bands": { + "low": "Low", + "mid": "Mid", + "high": "High" + }, + "tiers": { + "unclassified": "Unclassified", + "open": "Open", + "cah": "CAH", + "advanced": "Advanced", + "expert": "Expert", + "masters": "Masters", + "owcs": "OWCS" + } + }, + "no-snapshot-title": "No Team TSR yet", + "no-snapshot-description": "Your team needs a Team TSR before you can search for scrim partners. Play in tracked tournaments or wait for the next daily refresh.", + "back-to-team": "Back to team", + "back-to-candidates": "← Back to candidates", + "no-candidates-title": "No nearby teams right now", + "no-candidates-description": "Try again later — the candidate pool refreshes daily.", + "requests-remaining": "{count} of 10 daily requests remaining", + "searchingAs": "Searching as {teamName}", + "send-button": "Send scrim request", + "send-button-cooldown": "Available in {duration}", + "send-button-limit": "Daily limit reached, resets in {duration}", + "send-button-limit-short": "Daily limit reached, resets in 24h", + "send-button-not-manager": "Only managers can send scrim requests", + "send-button-recent": "Already messaged in last 24h", + "delta-positive": "+{value}", + "delta-negative": "−{value}", + "delta-zero": "±0", + "skill-deviation": "Skill deviation", + "bracket-comparison": "{from} vs {to}", + "your-team": "Your team", + "their-team": "Their team", + "detail-eyebrow": "Matchmaker · Scrim", + "availability-overlap-title": "Availability overlap", + "availability-overlap": "{hours}h overlap this week", + "availability-overlap-short": "{hours, plural, one {#h overlap} other {#h overlap}}", + "availability-overlap-detail": "Both teams have {hours, plural, one {# shared hour} other {# shared hours}} this week.", + "no-availability": "No shared availability data", + "sent-relative": "Sent {when}", + "relative": { + "justNow": "just now", + "minutesAgo": "{count, plural, one {#m ago} other {#m ago}}", + "hoursAgo": "{count, plural, one {#h ago} other {#h ago}}", + "daysAgo": "{count, plural, one {#d ago} other {#d ago}}" + }, + "message-preview": "Message preview", + "request-message": "{fromTeamName} ({fromBracketLabel} · TSR {fromTsr, number}) is looking for a scrim. They're {delta} TSR from your roster.", + "roster": "Roster", + "send-success": "Scrim request sent", + "send-error-409": "You already messaged this team recently. Try again in 24 hours.", + "send-error-422": "One of the teams no longer has a Team TSR.", + "send-error-429": "Daily limit reached. Resets in 24 hours.", + "send-error-generic": "Couldn't send scrim request. Try again.", + "bracket-with-band": "{band} {tier}", + "bands": { + "low": "Low", + "mid": "Mid", + "high": "High" + }, + "tiers": { + "unclassified": "Unclassified", + "open": "Open", + "cah": "CAH", + "advanced": "Advanced", + "expert": "Expert", + "masters": "Masters", + "owcs": "OWCS" + } + }, + "teamTsr": { + "teamTsr": "Team TSR", + "teamCsr": "Team CSR", + "tsrShort": "TSR", + "ratingShort": "Rating", + "title": "Roster skill rating", + "meta": "{ratingLabel} · {rated, number} of {rosterSize, number} rated · {playtimeShare, number, percent} playtime backed", + "sources": { + "tsr": "Real TSR", + "predicted": "Predicted", + "csrFallback": "CSR fallback" + }, + "sourceCopy": { + "tsr": "Playtime-weighted mean of every active starter's tournament rating.", + "predicted": "Real TSR for rated players; the rest predicted from team CSR offset. Weighted by scrim playtime.", + "csrFallback": "Not enough rated playtime for TSR. Showing playtime-weighted per-hero CSR — not on the same scale as TSR." + }, + "confidence": { + "high": "High confidence", + "medium": "Medium confidence", + "low": "Low confidence" + }, + "contribution": { + "tsr": "TSR", + "predicted": "Predicted", + "predictedShort": "PRED", + "csr": "CSR", + "none": "—" + }, + "offsetSpread": "Offset spread σ {value, number}.", + "offsetSigma": "Offset σ", + "rated": "Rated", + "backed": "Backed", + "empty": "Add team members with linked BattleTags to compute a roster rating.", + "scrimBracket": "Scrim bracket", + "findScrims": "Find scrims →", + "playerTsr": "TSR {rating, number}", + "playerCsr": " · CSR {rating, number}", + "noTsr": "No TSR", + "playtimeSummary": "{percent, number, percent} · {playtime}", + "playtimeHours": "{hours}h", + "playtimeMinutes": "{minutes, number}m", + "bracketWithBand": "{band} {tier}", + "bands": { + "low": "Low", + "mid": "Mid", + "high": "High" + }, + "tiers": { + "unclassified": "Unclassified", + "open": "Open", + "cah": "CAH", + "advanced": "Advanced", + "expert": "Expert", + "masters": "Masters", + "owcs": "OWCS" + } + }, + "teamOps": { + "title": "Team ops", + "blacklist": { + "subtitle": "Teams you don't want to scrim. When a blacklisted team is also on Parsertime, you're hidden from each other in the matchmaker and neither can send a scrim request.", + "heading": "Blacklist", + "addPlaceholder": "Add a team to your blacklist", + "searchPlaceholder": "Search teams, or type a name", + "noMatches": "No matching teams", + "blockOffPlatform": "Block \"{name}\"", + "offPlatform": "Off-platform", + "onPlatform": "On Parsertime", + "blocking": "Blocking {name}", + "reasonPlaceholder": "Reason (optional)", + "confirmAdd": "Add to blacklist", + "cancel": "Cancel", + "empty": "No teams blacklisted yet. Add one above and you'll stop matching with them.", + "removeNamed": "Remove {name} from blacklist", + "addError": "Couldn't add that team. Try again.", + "removeError": "Couldn't remove that team. Try again." + }, + "feedback": { + "prompt": "You scrimmed {name}. How was it?", + "good": "Good scrim", + "okay": "Okay", + "poor": "Poor — blacklist them", + "skip": "Skip", + "reasonPlaceholder": "Reason (optional)", + "confirmBlacklist": "Blacklist team", + "error": "Couldn't save your feedback. Try again." + } }, "dataCorruption": { "warning": { @@ -3591,7 +6338,31 @@ "ogTitle": "{playerName}'s Profile | Parsertime", "ogDescription": "View {playerName}'s profile and stats on Parsertime.", "ogImage": "{playerName}'s Profile" - } + }, + "hoverCard": { + "bannerAlt": "{playerName} banner", + "topHeroes": "Top Heroes", + "timePlayed": "Time Played", + "loading": "Loading...", + "error": "Error loading data", + "empty": "No data available", + "viewProfile": "View Profile →" + }, + "rankedTab": "Ranked" + }, + "positioningCard": { + "title": "Positioning", + "description": "Spatial stats computed from positional data. Only maps with coordinate tracking are included.", + "mapsWithData": "{count, plural, one {# map with data} other {# maps with data}}", + "noData": "No positional data yet. Stats appear once scrims with coordinate tracking are uploaded.", + "engagementDistance": "Avg Engagement Distance", + "engagementDistanceHint": "Average distance to your target on final blows.", + "highGroundKills": "High Ground Kill %", + "highGroundKillsHint": "Share of your kills taken with a height advantage.", + "isolationDeaths": "Isolation Death %", + "isolationDeathsHint": "Deaths with no teammate within 15 meters.", + "fightStartSpread": "Fight Start Spread", + "fightStartSpreadHint": "Average distance to teammates when fights begin." }, "titles": { "DEVELOPER": "Parsertime Developer", @@ -3634,25 +6405,348 @@ "ogTitle": "Leaderboard | Parsertime", "ogDescription": "View the highest ranked players for each hero.", "ogImage": "Leaderboard" + }, + "hub": { + "metadata": { + "title": "Leaderboards | Parsertime", + "description": "Two ways to read player skill in Overwatch 2: per-hero composite rating and tournament-grounded Elo." + }, + "eyebrow": "Leaderboard", + "title": "Skill ratings", + "description": "Player skill is measured more than one way. Pick the question you're answering and read the board that fits.", + "answersEyebrow": "{metric} · Answers", + "browse": "Browse {metric}", + "computed": "How it's computed", + "reachesFor": "Reaches for", + "doesNotCapture": "Doesn't capture", + "stats": { + "perHero": "Per hero", + "topCount": "Top {count, number}", + "minSample": "Min sample", + "mapCount": "{count, plural, one {# map} other {# maps}}", + "scale": "Scale", + "scaleValue": "1-{max, number}", + "csrStatus": "Updates as scrims upload.", + "active": "Active", + "trackedPlayers": "Tracked players", + "trackedMatches": "Tracked matches", + "topRating": "Top rating", + "lastRecompute": "Last full recompute {when}.", + "awaitingSeed": "Awaiting initial seed." + }, + "relative": { + "justNow": "just now", + "minutesAgo": "{count, plural, one {#m ago} other {#m ago}}", + "hoursAgo": "{count, plural, one {#h ago} other {#h ago}}", + "daysAgo": "{count, plural, one {#d ago} other {#d ago}}" + }, + "metrics": { + "csr": { + "fullName": "Composite Skill Rating", + "question": "How does this player perform statistically vs. peers on the same hero?", + "derivation": "Z-score across role-weighted per-10-minute stats from logged scrims. Scaled to 1 to 5000, mean 2500. Per hero, top 50.", + "strengths": { + "heroSpecific": "Hero-specific, role-aware", + "scrimExecution": "Reflects raw scrim execution", + "fastUpdates": "Updates as soon as scrims upload" + }, + "caveats": { + "opponentStrength": "Doesn't account for opponent strength", + "minimumSample": "10 maps and 60 seconds per map minimum" + } + }, + "tsr": { + "fullName": "Tournament Skill Rating", + "question": "What level of competition can this player handle?", + "derivation": "Elo-style replay over FACEIT-hosted OW2 tournament results. Recency-weighted with a 365-day half-life, anchored at peak tier reached.", + "strengths": { + "headToHead": "Grounded in head-to-head outcomes", + "comparable": "Comparable across regions and tiers", + "currentForm": "Reflects current form" + }, + "caveats": { + "faceitOnly": "Only counts FACEIT-tracked tournaments", + "battleTag": "Requires linked BattleTag" + } + } + } + }, + "csrPage": { + "metadata": { + "title": "Composite Skill Rating | Parsertime", + "description": "Per-hero skill rating derived from Z-scored statistical performance against peers on the same hero." + }, + "eyebrow": "Leaderboard", + "eyebrowWithHero": "Leaderboard · {hero}", + "title": "Composite Skill Rating", + "description": "Per-hero rating derived from Z-scored statistical performance against peers on the same hero. Top 50 per hero, requires 10 maps and 60 seconds per map.", + "empty": { + "eyebrow": "Pick a hero", + "title": "The leaderboard is per hero", + "description": "Choose a hero above to view the top 50 performers. CSR is computed independently per hero so comparisons stay grounded in role-relevant stats." + }, + "accordion": { + "calculated": { + "title": "How is CSR calculated?", + "intro": "CSR is a skill rating derived from your statistical performance compared to the average player on a specific hero.", + "formulaTitle": "The formula", + "formula": "We calculate a Z-score for each key statistic, which measures how many standard deviations you are above or below the average.", + "normalized": "Stats are normalized to per 10 minutes.", + "positiveStats": "Positive stats (Eliminations) reward higher values.", + "negativeStats": "Negative stats (Deaths, Damage Taken) reward lower values.", + "roleWeightingTitle": "Role weighting", + "roleWeighting": "Each role prioritizes different stats. Tank: low Deaths (30%), Eliminations (20%), Solo Kills (15%). Damage: Eliminations (30%), Final Blows (20%), Damage Dealt (20%). Support: Healing (35%), low Deaths (25%).", + "uniqueWeightings": "Specific heroes like Mercy use unique weightings.", + "finalScalingTitle": "Final scaling", + "finalScaling": "Weighted Z-scores are summed and converted to an SR scale centered at 2500." + }, + "goodSr": { + "title": "What is a good SR?", + "body": "Scores follow a bell curve. Average is 2500; one standard deviation above (about 300 to 400 SR) puts you in roughly the top 16%. The further above 2500, the rarer the rating." + }, + "rank": { + "title": "How do I get on the board?", + "body": "Play at least 10 maps on the hero with at least 60 seconds of playtime per map. Brief mid-map swaps don't count. The board displays the top 50; ranks below that still show on the player profile." + }, + "interactive": { + "title": "Reading the table", + "body": "Click any row to open a detailed stat panel: SR distribution, role-radar, and per-10 breakdowns. Keyboard-navigable via Tab and Enter." + } + } + }, + "csr": { + "table": { + "player": "Player", + "maps": "Maps", + "time": "Time", + "elimsPer10": "Elims/10", + "damagePer10": "Dmg/10", + "healPer10": "Heal/10", + "blockPer10": "Block/10", + "noPlayers": "No players found." + }, + "stats": { + "selectedMeta": "Selected · Rank {rank, number} · {role}", + "sheetMeta": "Rank #{rank, number} • {hero} • {role}", + "roles": { + "tank": "Tank", + "damage": "Damage", + "support": "Support" + }, + "snapshot": "Snapshot", + "compositeSr": "Composite SR", + "percentileLabel": "Percentile", + "maps": "Maps", + "time": "Time", + "minutes": "{count, number}m", + "hero": "Hero", + "srDistribution": "SR distribution", + "distributionDescription": "Where {playerName} sits in the bell curve for this hero.", + "performanceBreakdown": "Performance breakdown", + "performanceDescription": "Z-scores against the leaderboard average. 0 is the average; positive is above, negative is below. Most players fall between -2 and +2.", + "per10Minutes": "Per 10 minutes", + "detailedStats": "Detailed stats (per 10 min)", + "statLabel": "{label}:", + "emptyTitle": "Detail panel", + "emptyDescription": "Pick a player to see their SR distribution, performance breakdown, and per-10 stats against the leaderboard average.", + "zScore": "Z-score", + "percentile": { + "top1": "Top 1% · Elite", + "top5": "Top 5% · Exceptional", + "top10": "Top 10% · Excellent", + "top25": "Top 25% · Very good", + "aboveAverage": "Above average", + "average": "Average", + "belowAverage": "Below average" + }, + "per10": { + "eliminations": "Eliminations", + "finalBlows": "Final blows", + "soloKills": "Solo kills", + "deaths": "Deaths", + "damage": "Damage", + "healing": "Healing", + "blocked": "Blocked", + "ultimates": "Ultimates" + } + }, + "distributionChart": { + "sr": "SR", + "rank": "Rank", + "percentile": "Percentile", + "notEnoughData": "Not enough data to generate a meaningful distribution. At least 3 players are needed.", + "skillRatingAxis": "Skill Rating (SR)", + "frequencyAxis": "Frequency", + "achievedPotential": "Achieved · Potential", + "distribution": "Distribution", + "meanLabel": "Mean ({value, number})", + "otherPlayers": "Other players", + "selectedPlayer": "Selected player", + "meanSr": "Mean SR", + "standardDeviation": "Standard deviation", + "plusMinus": "±{value, number}", + "selectedPlayerSr": "Selected player SR" + } + }, + "subnav": { + "ariaLabel": "Leaderboard variant" + }, + "heroSelector": { + "selectHero": "Select hero...", + "searchHero": "Search hero...", + "noHeroFound": "No hero found.", + "roles": { + "tank": "Tank", + "damage": "Damage", + "support": "Support" + } + }, + "tsr": { + "eyebrow": "Leaderboard", + "eyebrowComputed": "Leaderboard · Computed {computedAt}", + "title": "Tournament Skill Rating", + "description": "Elo-style rating from FACEIT-hosted Overwatch 2 tournament results. Recency weighted, anchored at peak tier reached.", + "stats": { + "active": "Active", + "trackedPlayers": "Tracked players", + "trackedMatches": "Tracked matches", + "topRating": "Top rating" + }, + "regions": { + "allRegions": "All regions", + "na": "NA", + "emea": "EMEA", + "other": "Other" + }, + "tiers": { + "allTiers": "All tiers", + "open": "Open", + "cah": "CAH", + "advanced": "Advanced", + "expert": "Expert", + "masters": "Masters", + "owcs": "OWCS" + }, + "sortOptions": { + "rating": "Rating", + "totalMatches": "Total matches", + "recentActivity": "Recent activity" + }, + "columns": { + "player": "Player", + "region": "Region", + "peakTier": "Peak tier", + "rating": "Rating", + "matches": "Matches", + "lastSeen": "Last seen" + }, + "relative": { + "today": "today", + "days": "{count, plural, one {#d} other {#d}}", + "weeks": "{count, plural, one {#w} other {#w}}", + "months": "{count, plural, one {#mo} other {#mo}}", + "years": "{count, plural, one {#y} other {#y}}", + "justNow": "just now", + "minutesAgo": "{count, plural, one {#m ago} other {#m ago}}", + "hoursAgo": "{count, plural, one {#h ago} other {#h ago}}", + "daysAgo": "{count, plural, one {#d ago} other {#d ago}}" + }, + "regionFilter": "Region filter", + "searchPlaceholder": "Search BattleTag or nickname", + "searchAria": "Search players", + "sort": "Sort", + "loading": "Loading...", + "noPlayersForQuery": "No players match \"{query}\".", + "noActivePlayers": "No active players match the selected filters.", + "showing": "Showing {visible, number} of {total, number}", + "loadMore": "Load more", + "activeCount": "{visible, number} of {total, number} active", + "inactive": "Inactive", + "recentMatchesWindow": "{count, number} · {days, number}d", + "detail": { + "selectedMeta": "Selected · {region} · Peak {tier}", + "rating": "Rating", + "matchRecord": "Match record", + "wins": "Wins", + "losses": "Losses", + "winRate": "Win rate", + "lastNDays": "Last {days, number}d", + "recordSummary": "{wins, number}W · {losses, number}L", + "tierBreakdown": "Tier breakdown", + "contributingFactors": "Contributing factors", + "factorRadarName": "Factors", + "factorDescription": "Normalized 0 to 1 within reasonable population bounds. Recency weighted with a {days, number}-day half-life.", + "topSwings": "Top swings", + "recentMatches": "Recent matches", + "won": "Won", + "lost": "Lost", + "score": "{first, number}-{second, number}", + "emptyTitle": "Detail panel", + "emptyDescription": "Pick a player to see what shaped their rating: tier ladder, match record, contributing factors, and the matches that moved the needle.", + "regions": { + "na": "NA", + "emea": "EMEA" + }, + "tiers": { + "open": "Open", + "cah": "CAH", + "advanced": "Advanced", + "expert": "Expert", + "masters": "Masters", + "owcs": "OWCS" + }, + "factors": { + "winRate": "Win rate", + "recentActivity": "Recent activity", + "tierStrength": "Tier strength", + "marginOfVictory": "Win margin", + "matchVolume": "Match volume" + }, + "factorRaw": { + "recentActivity": "{count, number} in last {days, number}d", + "averageTier": "Avg tier {tier}", + "averageMultiplier": "{value}x avg", + "matches": "{count, plural, one {# match} other {# matches}}" + }, + "tierLadder": { + "title": "Tier ladder", + "scale": "1-{max, number}", + "description": "Visualization zooms to {min, number}-{max, number}, the empirical band where active tournament players actually sit. The full scale runs 1 to {fullMax, number}.", + "ratingAria": "Rating {rating, number}" + } + } } }, "teamStats": { "fightStats": { + "eyebrow": "Teamfights · Fight stats", "title": "Team Fight Statistics", - "description": "Analyzed across {count} total fights", + "description": "Analyzed across {count, plural, =0 {no fights} =1 {1 total fight} other {# total fights}}", "noData": "No fight data available yet. Upload scrims to see team fight statistics.", "overallWinrate": "Overall Fight Winrate", "record": "{wins}W - {losses}L", "firstPickWinrate": "First Pick Winrate", - "firstPickCount": "In {count} fights with first pick", + "firstPickCount": "In {count, plural, =0 {no fights} =1 {1 fight} other {# fights}} with first pick", "firstDeathWinrate": "First Death Winrate", - "firstDeathCount": "In {count} fights losing first pick", + "firstDeathCount": "In {count, plural, =0 {no fights} =1 {1 fight} other {# fights}} losing first pick", "firstUltWinrate": "First Ultimate Winrate", - "firstUltCount": "In {count} fights using first ult", + "firstUltCount": "In {count, plural, =0 {no fights} =1 {1 fight} other {# fights}} using first ult", "dryFights": "Dry Fight Rate", - "dryFightDetails": "{count} dry fights ({winrate}% winrate)", + "dryFightDetails": "{count, plural, =0 {no dry fights} =1 {1 dry fight} other {# dry fights}} ({winrate} winrate)", "avgUltsPerFight": "Avg Ults Per Fight", - "avgUltsDescription": "In {count} non-dry fights" + "avgUltsDescription": "In {count, plural, =0 {no non-dry fights} =1 {1 non-dry fight} other {# non-dry fights}}" + }, + "initiationStats": { + "eyebrow": "Teamfights · Going first", + "title": "Fight Initiation", + "description": "Who throws the first punch, and whether it pays off. Detected on {covered} of {total} maps with detailed logs.", + "noData": "No initiation data yet. Upload scrims with detailed logs to see who goes first.", + "initiationWinrate": "Win rate going first", + "initiationFrequency": "Fights initiated", + "goingSecondWinrate": "Win rate going second", + "firstRecord": "{wins}W - {losses}L when initiating", + "frequencySub": "{first} of {decided} decided fights", + "secondRecord": "{wins}W - {losses}L on the back foot" } }, "teamStatsPage": { @@ -3664,6 +6758,12 @@ "ogDescription": "Stats for {teamName} on Parsertime. Parsertime is a tool for analyzing Overwatch scrims.", "ogImage": "{teamName} Stats" }, + "tempoRead": { + "faster": "Faster than average", + "about": "About average", + "slower": "Slower than average", + "delta": "({delta}s vs avg)" + }, "rangePicker": { "selectTimeframe": "Select timeframe", "lastWeek": "Last Week", @@ -3675,11 +6775,136 @@ "allTime": "All Time", "customRange": "Custom Range", "upgrade": "Upgrade to view more timeframes", - "pickDateRange": "Pick a date range" + "pickDateRange": "Pick a date range", + "loading": "Loading new timeframe" + }, + "insufficientScrimsPlaceholder": { + "title": "At least two scrims required", + "description": "Team stats need at least two scrims to reliably identify which side your team played on each map. You currently have {count, plural, =0 {no scrims} =1 {# scrim} other {# scrims}} uploaded. Upload another to unlock the full stats dashboard.", + "uploadScrim": "Upload a scrim" + }, + "simulatorTab": { + "header": { + "eyebrow": "Simulator · Win probability", + "title": "Win probability simulator" + }, + "noData": "No game data available for the selected time period. Play some scrims to unlock the simulator.", + "stats": { + "mapsTracked": "Maps tracked", + "historicalSample": "historical sample", + "baseWinrate": "Base winrate", + "beforeScenario": "before scenario", + "heroesScored": "Heroes scored", + "mapsScored": "Maps scored", + "availablePicks": "available picks" + }, + "scenario": { + "eyebrow": "Simulator · Scenario", + "title": "Scenario setup", + "description": "Configure bans, map, and composition to see how your estimated win rate changes.", + "reset": "Reset", + "resetAria": "Reset all scenario inputs", + "enemyBans": { + "label": "Enemy bans against us", + "description": "Heroes the opponent is banning from your pool" + }, + "ourBans": { + "label": "Our bans", + "description": "Heroes you are banning from the opponent" + }, + "ourComposition": { + "label": "Our composition", + "description": "Select up to 5 heroes for your team" + }, + "enemyComposition": { + "label": "Enemy composition", + "description": "Select up to 5 heroes for the enemy team" + } + }, + "heroPicker": { + "addHero": "Add hero", + "label": "Hero picker", + "selectHero": "Select {hero}", + "removeHero": "Remove {hero}", + "removeTooltip": "{hero} (click to remove)", + "roles": { + "Tank": "Tank", + "Damage": "Damage", + "Support": "Support" + } + }, + "mapPicker": { + "label": "Map", + "description": "Select the map being played", + "selectAria": "Select a map", + "placeholder": "Select a map...", + "searchPlaceholder": "Search maps...", + "noMapsFound": "No maps found.", + "noneSelected": "No map selected", + "mapTypes": { + "Clash": "Clash", + "Control": "Control", + "Escort": "Escort", + "Flashpoint": "Flashpoint", + "Hybrid": "Hybrid", + "Push": "Push" + } + }, + "prediction": { + "eyebrow": "Simulator · Prediction", + "title": "Predicted win rate", + "description": "Based on your team's historical data across {count, plural, =0 {no maps} =1 {# map} other {# maps}}", + "estimatedWinRateAria": "Estimated win rate: {value}", + "confidence": { + "high": "High confidence", + "medium": "Medium confidence", + "low": "Low confidence" + }, + "deltaVsBase": "{direction, select, positive {+{delta}pp} negative {-{delta}pp} other {{delta}pp}} vs base ({base})", + "warningsLabel": "Warnings", + "emptyScenario": "Configure a scenario on the left to see how parameters affect your win probability.", + "warnings": { + "enemyBanLowSample": "{hero} has only been banned {samples, plural, =0 {0 times} =1 {# time} other {# times}} — impact estimate may be unreliable", + "ourBanLowSample": "You've only banned {hero} {samples, plural, =0 {0 times} =1 {# time} other {# times}} — impact estimate may be unreliable", + "mapLowSample": "Only {samples, plural, =0 {0 games} =1 {# game} other {# games}} on {map} — map impact estimate may be unreliable", + "mapModeFallback": "No data for {map} — using {mapType} mode average instead", + "rosterLowSample": "This roster combination has only {games, plural, =0 {0 recorded games} =1 {# recorded game} other {# recorded games}}", + "enemyHeroLowSample": "Only {samples, plural, =0 {0 games} =1 {# game} other {# games}} against enemy {hero} — impact estimate may be unreliable" + }, + "insights": { + "enemyBanHurts": "{hero} being banned against you hurts by {delta}%", + "ourBanHelps": "Banning {hero} adds +{delta}% — your strongest ban", + "mapStrength": "{map} is a {direction, select, strong {strong map} weak {weak map} other {map}} for your team ({delta}%)", + "compositionImpact": "Your composition {direction, select, boosts {boosts} reduces {reduces} other {changes}} your odds by {delta}%", + "enemyCompositionImpact": "The enemy composition {direction, select, favors {favors} challenges {challenges} other {changes}} you by {delta}%" + }, + "mapTypes": { + "Clash": "Clash", + "Control": "Control", + "Escort": "Escort", + "Flashpoint": "Flashpoint", + "Hybrid": "Hybrid", + "Push": "Push" + } + }, + "breakdown": { + "heading": "Breakdown", + "listAria": "Win rate breakdown", + "baseWinrate": "Base win rate", + "enemyBanImpact": "Enemy ban impact", + "ourBans": "Our bans", + "map": "Map", + "composition": "Composition", + "enemyComp": "Enemy comp", + "impactPercent": "{direction, select, positive {+{value}%} negative {-{value}%} other {{value}%}}", + "barScale": "Bar scale: ±{scale}pp max. Bars are capped at ±{max}pp." + } }, "roleBalanceRadar": { + "eyebrow": "Overview · Role balance", "title": "Role Balance", "noData": "Not enough data to analyze role balance yet.", + "tooltipTitle": "Role Balance", "eliminations": "Eliminations", "survivability": "Survivability", "ultUsage": "Ult Usage", @@ -3695,29 +6920,181 @@ "insights": "Insights", "excellentBalance": "Your team shows excellent balance across all roles.", "fairlyBalanced": "Your team is fairly balanced with minor role disparities.", - "considerStrengthening": "Consider strengthening your {weakestRole} performance to improve team balance.", + "considerStrengthening": "Consider strengthening your {role} performance to improve team balance.", "negativeKD": "{role} players have a negative K/D ratio.", "dyingFrequently": "{role} players are dying frequently - focus on positioning.", "strongest": "Strongest:", "needsWork": "Needs Work:", - "balanceScore": "Balance Score:" + "balanceScore": "Balance Score:", + "roles": { + "Tank": "Tank", + "Damage": "Damage", + "Support": "Support" + } + }, + "overviewInsightsBand": { + "eyebrow": "Overview · Headline reads", + "title": "What's moving the needle", + "noInsights": "Not enough data for headline insights.", + "strongestRole": "Strongest role", + "needsWork": "Needs work", + "strongestMap": "Strongest map", + "bleedSpot": "Bleed spot", + "bestDay": "Best day", + "firstPickRate": "First pick rate", + "roleDetail": "{kd} K/D · {deaths} deaths/10m", + "mapWinrate": "{winrate} winrate", + "bestDayDetail": "{winrate} over {count, plural, =0 {no games} =1 {# game} other {# games}}", + "firstPickDetail": "{successful} of {total} closed", + "days": { + "sunday": "Sunday", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday" + } + }, + "matchupWinrateTab": { + "header": { + "eyebrow": "Winrates · Matchups", + "title": "Hero matchup winrates" + }, + "noData": "No game data available for the selected time period. Play some scrims to unlock matchup analysis.", + "stats": { + "scrimsTracked": "Scrims tracked", + "maps": "{count, plural, =0 {no maps} =1 {# map} other {# maps}}", + "bestMatchup": "Best matchup", + "worstMatchup": "Worst matchup", + "matchupLabel": "vs {hero} ({count, plural, =1 {# map} other {# maps}})", + "needSharedMaps": "Need 3+ shared maps", + "compositions": "Compositions", + "uniqueFiveStacks": "unique five-stacks" + }, + "explorer": { + "eyebrow": "Winrates · Matchups", + "title": "Hero matchup explorer", + "description": "Select heroes on each side to scope the analysis. All selected heroes must be present in a map for it to count.", + "clear": "Clear", + "clearAria": "Clear all hero selections", + "ourHeroes": "Our heroes", + "ourHeroesDescription": "Heroes your team played", + "enemyHeroes": "Enemy heroes", + "enemyHeroesDescription": "Heroes the opponent played", + "versus": "vs" + }, + "heroPicker": { + "addHero": "Add hero", + "label": "Hero picker", + "selectHero": "Select {hero}", + "removeHero": "Remove {hero}", + "removeTooltip": "{hero} (click to remove)", + "full": "(full)", + "slotFullAria": "{hero} ({role} slot full)", + "slotFullTooltip": "{hero} ({role} slot full)", + "roles": { + "Tank": "Tank", + "Damage": "Damage", + "Support": "Support" + } + }, + "summary": { + "eyebrow": "Winrates · Matchup summary", + "filteredTitle": "Filtered matchup", + "overallTitle": "Overall record", + "filteredDescription": "Based on {count, plural, =0 {no matching maps} =1 {# matching map} other {# matching maps}}", + "overallDescription": "Across all {count, plural, =0 {tracked maps} =1 {# tracked map} other {# tracked maps}}", + "noFilteredMaps": "No maps found for this matchup combination.", + "selectPrompt": "Select heroes above to explore matchup data.", + "winRateAria": "Win rate: {value}", + "confidence": { + "high": "High confidence", + "medium": "Medium confidence", + "low": "Low confidence" + }, + "record": "{wins}W / {losses}L ({count, plural, =0 {no maps} =1 {# map} other {# maps}})", + "roughlyEqual": "Roughly equal to your base winrate", + "deltaVsBase": "{direction, select, above {+{delta}pp above} below {-{delta}pp below} other {{delta}pp vs}} base ({base})" + }, + "trend": { + "heading": "Cumulative winrate trend", + "now": "Now {winrate}", + "trendDelta": "{direction, select, positive {(+{delta}pp trending)} negative {(-{delta}pp trending)} other {({delta}pp trending)}}", + "cumulative": "Cumulative", + "recordShort": "({wins}W / {losses}L)", + "scrimRecord": "{scrimName}: {wins}W / {losses}L ({winrate})", + "winPercentAxis": "Win %", + "trendName": "Trend", + "cumulativeWinrateName": "Cumulative Winrate", + "notEnoughData": "Not enough data to show a trend. Need at least 2 scrims." + }, + "compositions": { + "eyebrow": "Winrates · Compositions", + "title": "Best compositions", + "description": "Top performing five-stacks for this matchup (min. 2 games).", + "tableAria": "Best compositions", + "rank": "Rank", + "composition": "Composition", + "winrate": "Winrate", + "record": "Record", + "winsLosses": "{wins}W / {losses}L", + "noData": "Not enough data to identify top compositions." + }, + "results": { + "eyebrow": "Winrates · Match results", + "title": "Match results", + "description": "Individual map outcomes for this matchup.", + "date": "Date", + "scrim": "Scrim", + "map": "Map", + "result": "Result", + "ourHeroes": "Our heroes", + "enemyHeroes": "Enemy heroes", + "winShort": "W", + "lossShort": "L", + "noMaps": "No matching maps found." + } }, "bestRoleTriosCard": { + "eyebrow": "Performance · Best trios", "title": "Best Role Trios", + "description": "Most successful five-player rosters by winrate.", "noData": "Not enough data to determine best player combinations yet. Need at least 3 games with the same roster.", + "rank": "Rank", + "roster": "Roster", + "games": "Games", + "recordHeader": "W, L", + "winrateHeader": "Winrate", + "toggleDetails": "Toggle details", + "collapseTrio": "Collapse trio #{rank}", + "expandTrio": "Expand trio #{rank}", "gamesPlayed": "{count, plural, =0 {no games} =1 {one game} other {# games}}", "wins": "{count}W", "losses": "{count}L", + "record": "{wins}W, {losses}L", "tank": "Tank", "damage": "Damage", "support": "Support", "winRate": "Win Rate", + "winRateValue": "Win Rate {winrate}", "playMoreGames": "Play more games to see your best roster combinations" }, "heroPickrateHeatmap": { + "eyebrow": "Heroes · Pickrate", "title": "Hero Pickrate Heatmap", "noData": "No hero pickrate data available yet.", "description": "Top 15 most played heroes across the team", + "total": "Total", + "saturationNote": "Single-hue saturation ramp; colorblind safe.", + "noPlaytime": "No playtime", + "durationHoursShort": "{hours}h", + "durationMinutesShort": "{minutes}m", + "durationLong": "{hours}h {minutes}m {seconds}s", + "cellTitle": "{playerName} - {heroName}: {playtime}", + "playerTotalTitle": "{playerName} total: {playtime}", + "heroTotalTitle": "{heroName}: {playtime}", + "allHeroesTotalTitle": "All heroes total: {playtime}", "hoverText": "{playerName} on {heroName}: {playtime}", "hoverTextDescription": "Hover over a cell to see the player's pickrate for that hero." }, @@ -3735,6 +7112,7 @@ "pickDateRange": "Pick a date range" }, "heroPoolOverviewCard": { + "eyebrow": "Heroes · Pool overview", "title": "Hero Pool Overview", "noData": "No hero pool data available yet.", "excellent": "Excellent", @@ -3747,20 +7125,121 @@ "minGames": "3+ games", "specialists": "Specialists", "minOwnership": "30%+ ownership", + "mostPlayedByRoleEyebrow": "Heroes · Most played by role", "mostPlayedByRole": "Most Played by Role", "gamesPlayed": "{count, plural, =0 {no games} =1 {1 game} other {# games}}", "players": "{count, plural, =0 {no players} =1 {1 player} other {# players}}", "roleDistribution": "Role Distribution", "tankHeroes": "Tank Heroes", "dpsHeroes": "Damage Heroes", - "supportHeroes": "Support Heroes" + "supportHeroes": "Support Heroes", + "roles": { + "Tank": "Tank", + "Damage": "Damage", + "Support": "Support" + } }, "heroWinratesCard": { + "eyebrow": "Heroes · Top winrates", "title": "Top Hero Winrates", "noData": "Not enough games played to calculate hero winrates (need 3+ games per hero).", - "winsAndLosses": "{wins}W - {losses}L • {games} games" + "winsAndLosses": "{wins}W - {losses}L • {games, plural, =0 {no games} =1 {1 game} other {# games}}" + }, + "heroBanCards": { + "received": { + "eyebrow": "Heroes · Ban impact", + "emptyTitle": "Hero Ban Impact", + "title": "Most Banned Heroes", + "noData": "No hero ban data available for the selected time period.", + "description": "Heroes most frequently banned against your team across {maps, plural, =1 {# map} other {# maps}}. Weak point tags mark heroes whose absence drops your win rate." + }, + "outgoing": { + "eyebrow": "Heroes · Our bans", + "title": "Our Ban Strategy", + "noData": "No outgoing ban data available for the selected time period.", + "description": "Heroes your team bans most frequently across {maps, plural, =1 {# map} other {# maps}}. High value tags mark bans that lift your win rate the most." + }, + "table": { + "hero": "Hero", + "bans": "Bans", + "distribution": "Distribution", + "banRate": "Ban Rate", + "wrWith": "WR With", + "wrWithout": "WR Without", + "wrBanned": "WR Banned", + "wrNotBanned": "WR Not Banned", + "wrDelta": "WR Delta", + "tag": "Tag" + }, + "tags": { + "weakPoint": "Weak point", + "highValue": "High value" + } + }, + "abilityImpactAnalysisCard": { + "eyebrow": "Teamfights · Ability impact", + "title": "Ability impact", + "description": "How specific hero abilities shift fight outcomes across your scrims.", + "selectHero": "Select a hero", + "searchHeroes": "Search heroes", + "noHeroesFound": "No heroes found.", + "percentagePoints": "{value}pp", + "sampleCount": "({count, number})", + "roles": { + "Tank": "Tank", + "Damage": "Damage", + "Support": "Support" + }, + "table": { + "ability": "Ability", + "fights": "Fights", + "withWr": "With WR", + "withoutWr": "Without WR", + "delta": "Delta", + "tag": "Tag" + }, + "tags": { + "key": "Key", + "risk": "Risk" + }, + "smallSample": "Small sample for {hero}, results may not be statistically significant.", + "selectHeroPrompt": "Select a hero to see how their abilities shift fight outcomes." + }, + "ultImpactAnalysisCard": { + "eyebrow": "Ultimates · Impact analysis", + "title": "Ultimate impact", + "description": "How specific hero ultimates affect fight outcomes across scenarios.", + "selectHero": "Select a hero", + "searchHeroes": "Search heroes", + "noHeroesFound": "No heroes found.", + "roles": { + "Tank": "Tank", + "Damage": "Damage", + "Support": "Support" + }, + "scenarios": { + "uncontestedOurs": "Uncontested (ours)", + "uncontestedTheirs": "Uncontested (enemy)", + "mirrorOursFirst": "Mirror, ours first", + "mirrorTheirsFirst": "Mirror, theirs first" + }, + "table": { + "hero": "Hero", + "scenario": "Scenario", + "fights": "Fights", + "wins": "Wins", + "losses": "Losses", + "winrate": "Winrate", + "tag": "Tag" + }, + "tags": { + "keyUlt": "Key ult" + }, + "smallSample": "Only {count, plural, =1 {# fight} other {# fights}} analyzed for {hero}, results may not be statistically significant.", + "selectHeroPrompt": "Select a hero to see how their ultimate impacts fight outcomes." }, "mapModePerformanceCard": { + "eyebrow": "Maps · Mode performance", "winrate": "Winrate: {winrate}", "winsAndLosses": "{wins}W - {losses}L ({games, plural, =0 {no games} =1 {1 game} other {# games}})", "title": "Map Mode Performance", @@ -3775,18 +7254,37 @@ "worstMap": "Worst Map", "insights": "Insights", "excelsAt": "Your team excels at {mode} maps ({winrate}% winrate)", - "considerPracticing": "Consider practicing {mode} maps to improve balance ({winrate}% winrate)" + "considerPracticing": "Consider practicing {mode} maps to improve balance ({winrate}% winrate)", + "mapTypes": { + "Clash": "Clash", + "Control": "Control", + "Escort": "Escort", + "Flashpoint": "Flashpoint", + "Hybrid": "Hybrid", + "Push": "Push" + } }, "mapRosterDetailsSheet": { - "overall": "Overall: {wins}W - {losses}L ({winrate}% winrate) • {games, plural, =0 {no games} =1 {1 game} other {# games}} played", + "eyebrow": "Maps · Roster breakdown", + "overall": "Overall: {wins}W - {losses}L ({winrate} winrate) • {games, plural, =0 {no games} =1 {1 game} other {# games}} played", "noData": "No roster data available for this map yet.", "rosterPerformance": "Roster Performance ({count, plural, =0 {no lineups} =1 {1 lineup} other {# lineups}})", "bestLineup": "Best Lineup", "winsLossesRecord": "{wins}W - {losses}L", "gamesLabel": "{count, plural, =0 {no games} =1 {1 game} other {# games}} played", - "smallSample": "Small sample" + "smallSample": "Small sample", + "recordLabel": "Record", + "winrateLabel": "Winrate", + "overallLabel": "overall", + "lineupsLabel": "Lineups", + "distinctLabel": "distinct", + "lineupsEyebrow": "Maps · Lineups", + "lineupHeader": "Lineup", + "gamesHeader": "Games", + "tagHeader": "Tag" }, "mapWinrateGallery": { + "eyebrow": "Maps · Winrate gallery", "title": "Map Performance", "noData": "No map data available yet. Play some matches to see your team's map performance!", "mapFilters": "Map Filters", @@ -3803,53 +7301,90 @@ "showingMaps": "Showing {count} of {total} maps", "noMapsMatchFilters": "No maps match your current filters. Try adjusting your search or filters.", "gamesLabel": "{count, plural, =0 {no games} =1 {1 game} other {# games}} played", - "clickToSeeRosterPerformance": "Click on any map to see roster performance details" + "clickToSeeRosterPerformance": "Click on any map to see roster performance details", + "winsShort": "{count}W", + "lossesShort": "{count}L", + "mapTypes": { + "Clash": "Clash", + "Control": "Control", + "Escort": "Escort", + "Flashpoint": "Flashpoint", + "Hybrid": "Hybrid", + "Push": "Push" + } }, "playerMapPerformanceCard": { + "eyebrow": "Maps · Player map matrix", "title": "Player Map Performance Matrix", "noData": "No map performance data available yet.", "description": "Each player's best and worst maps by winrate", - "playerOnMap": "{player} on {map}: {winrate}% ({wins}W-{losses}L)", + "playerOnMap": "{player} on {map}: {winrate} ({wins}W-{losses}L)", "noDataTitle": "{player} - {map}: No data", "winsLossesRecord": "{wins}W - {losses}L", "hasntPlayedMap": "{player} hasn't played {map} yet.", - "playerMapPerformance": "{player} on {map}: {winrate}% ({wins}W-{losses}L) in {games, plural, =0 {0 games} =1 {1 game} other {# games}} played", + "playerMapPerformance": "{player} on {map}: {winrate} ({wins}W-{losses}L) in {games, plural, =0 {0 games} =1 {1 game} other {# games}} played", "hoverToSeePerformance": "Hover over a cell to see the player's performance on that map.", + "percentOrHigher": "{winrate}+", "bestMap": "Best map", "worstMap": "Worst map" }, "quickStatsCard": { + "eyebrow": "Overview · Signals", "title": "Quick Stats", "last10Games": "Last 10 Games", "winsLossesRecord": "{wins}W - {losses}L", + "recordShort": "{wins}-{losses}", "bestDay": "Best Day", "notEnoughData": "Not enough data (need 3+ games per day)", "gamesLabel": "{count, plural, =0 {no games} =1 {1 game} other {# games}} played", + "winrateShort": "{winrate}% WR", "avgFightDuration": "Avg Fight Duration", - "quickFights": "Quick fights", - "standard": "Standard", - "longFights": "Long fights", + "notAvailable": "N/A", + "durationSeconds": "{seconds}s", "firstPickSuccess": "First Pick Success", "firstPickSuccessRate": "{successfulFirstPicks} / {totalFirstPicks} picks won", + "heroPool": "Hero Pool", + "mapPool": "Map Pool", + "uniqueHeroes": "{count, plural, =0 {no unique heroes} =1 {1 unique hero} other {# unique heroes}}", + "uniqueMaps": "{count, plural, =0 {no unique maps} =1 {1 unique map} other {# unique maps}}", + "days": { + "sunday": "Sunday", + "monday": "Monday", + "tuesday": "Tuesday", + "wednesday": "Wednesday", + "thursday": "Thursday", + "friday": "Friday", + "saturday": "Saturday" + }, "noData": "No data" }, "recentFormCard": { + "eyebrow": "Trends · Recent form", "title": "Recent Form", "noData": "No recent matches to display.", - "winsLosses": "{wins}W - {losses}L • {winrate}% winrate", + "winsLosses": "{wins}W - {losses}L • {winrate} winrate", "last5": "Last 5", "last10": "Last 10", "last20": "Last 20", "strongRecentPerformance": "Strong recent performance", "strugglingRecently": "Struggling recently", "averagePerformance": "Average performance", + "result": "Result", + "scrim": "Scrim", + "map": "Map", + "date": "Date", + "indicatorTitle": "{scrimName} - {result}", "win": "W", "loss": "L" }, "rolePerformanceCard": { + "eyebrow": "Performance · Roles", "title": "Role Performance", "noDataAvailable": "No role performance data available yet.", "noData": "No data", + "stat": "Stat", + "durationHoursMinutes": "{hours}h {minutes}m", + "durationMinutes": "{minutes}m", "playtime": "Playtime", "maps": "Maps", "kd": "K/D", @@ -3859,36 +7394,70 @@ "ultEfficiency": "Ult Efficiency", "elims": "Elims", "deaths": "Deaths", - "assists": "Assists" + "assists": "Assists", + "roles": { + "Tank": "Tank", + "Damage": "Damage", + "Support": "Support" + } }, "selector": { "select": "Select a team..." }, "strengthsWeaknessesCard": { + "eyebrow": "Overview · Best & worst", "title": "Strengths & Weaknesses", "noData": "Not enough data to determine strengths and weaknesses yet.", "strongestMap": "Strongest Map", "bestPerformance": "Best performance", "playtime": "{time} played", + "durationHoursMinutesSeconds": "{hours}h {minutes}m {seconds}s", "blindSpot": "Blind Spot", "needsImprovement": "Needs improvement" }, "teamRosterGrid": { + "eyebrow": "Overview · Roster", "title": "Team Roster", - "noData": "No roster data available. Players will appear here once they've played in matches." + "playerTargets": "Player Targets", + "noData": "No roster data available. Players will appear here once they've played in matches.", + "subBadge": "Sub", + "manageSubstitute": "Manage substitute", + "markSubstitute": "Mark as substitute", + "unmarkSubstitute": "Remove substitute", + "markSuccess": "{player} marked as a substitute", + "unmarkSuccess": "{player} is no longer a substitute", + "errorTitle": "Could not update substitute", + "errorDescription": "{res}" }, "topMapsCard": { + "eyebrow": "Overview · Maps played", "title": "Top Maps by Playtime", - "noData": "No map data available yet." + "noData": "No map data available yet.", + "performanceEyebrow": "Overview · Map performance", + "performanceTitle": "Map performance", + "performanceDescription": "Most-played maps with winrate alongside playtime.", + "columns": { + "map": "Map", + "games": "Games", + "playtime": "Playtime", + "winrate": "Winrate", + "distribution": "Distribution", + "tag": "Tag" + }, + "tags": { + "strongest": "Strongest", + "bleed": "Bleed" + } }, "ultimateEconomyCard": { + "eyebrow": "Ultimates · Economy", "title": "Ultimate Economy", "noData": "No ultimate usage data available yet.", "description": "How efficiently your team uses ultimates", "ultimateEfficiency": "Ultimate Efficiency", "fightsWonPerUltUsed": "Fights won per ult used", "wastedUltimates": "Wasted Ultimates", - "wastePercentage": "{percentage}% of total ults", + "wastePercentage": "{percentage} of total ults", "usedInLostSituations": "Used in lost situations", "totalUltimates": "Total Ultimates", "acrossFights": "Across {fights} fights", @@ -3898,6 +7467,10 @@ "average": "Average", "poor": "Poor", "ultimateUsage": "Ultimate Usage: Won vs Lost Fights", + "outcome": "Outcome", + "fightsHeader": "Fights", + "avgUltsUsedHeader": "Avg ults used", + "detail": "Detail", "winningFights": "Winning Fights", "fights": "{count} fights", "averageUltsUsed": "Average ultimates used", @@ -3905,6 +7478,12 @@ "goodUltDiscipline": "Good ult discipline: You use more ults when winning ({ultsInWonFights}) than when losing ({ultsInLostFights}), showing you avoid wasting resources in lost fights.", "roomForImprovement": "Room for improvement: You use more ults when losing ({ultsInLostFights}) than when winning ({ultsInWonFights}). Consider holding ults in disadvantaged situations.", "context": "Context", + "bucket": "Bucket", + "winrate": "Winrate", + "reversals": "Reversals", + "dryFightsRow": "Dry fights", + "ultFightsRow": "Ult fights", + "avgAbbreviation": "avg", "dryFights": "Dry Fights:", "dryFightWinrate": "{count} dry fights ({winrate}% WR)", "nonDryFights": "Non-Dry Fights:", @@ -3912,13 +7491,15 @@ "fightReversals": "Fight Reversals", "dryReversalRate": "Dry: {rate}% ({count} of {total})", "nonDryReversalRate": "With ults: {rate}% ({count} of {total})", - "reversalInsightUltReliant": "Your team reverses fights more often when using ultimates ({nonDryRate}%) than without ({dryRate}%), suggesting ult-dependent comebacks.", - "reversalInsightMechanical": "Your team reverses fights more often without ultimates ({dryRate}%) than with them ({nonDryRate}%), showing strong mechanical comeback ability." + "reversalInsightUltReliant": "Your team reverses fights more often when using ultimates ({nonDryRate}) than without ({dryRate}), suggesting ult-dependent comebacks.", + "reversalInsightMechanical": "Your team reverses fights more often without ultimates ({dryRate}) than with them ({nonDryRate}), showing strong mechanical comeback ability." }, "winLossStreaksCard": { + "eyebrow": "Trends · Streaks", "title": "Win/Loss Streaks", "noData": "No streak data available yet.", "na": "N/A", + "dateRange": "{start} - {end}", "currentStreak": "Current Streak", "winStreakCount": "{count} {count, plural, =0 {wins} =1 {win} other {wins}}", "lossStreakCount": "{count} {count, plural, =0 {losses} =1 {loss} other {losses}}", @@ -3932,21 +7513,35 @@ "timeToBreakStreak": "Time to break the streak - review recent VODs" }, "winProbabilityInsights": { + "eyebrow": "Teamfights · Win probability", "title": "Win Probability Insights", "noData": "No teamfight data available yet.", "firstPickImpact": "First Pick Impact", + "firstPickHeadline": "{winrate} with first pick", + "firstPickDetail": "{wins}W of {fights} ({delta} vs overall)", "firstPickImpactDescription": "When getting first pick, your team wins {winrate}% of fights", "firstPickCount": "{wins}W / {fights} fights", "firstDeathComeback": "First Death Comeback", + "firstDeathHeadline": "{winrate} after first death", + "firstDeathDetail": "{wins}W of {fights} ({delta} vs overall)", "firstDeathComebackDescription": "When suffering first death, your team still wins {winrate}% of fights", "firstDeathCount": "{wins}W / {fights} fights", "ultimateAdvantage": "Ultimate Advantage", + "firstUltHeadline": "{winrate} on first ult", + "firstUltDetail": "{wins}W of {fights} ({delta} vs overall)", "ultimateAdvantageDescription": "Using first ultimate leads to {winrate}% fight winrate", "ultimateCount": "{wins}W / {fights} fights", "dryFightSuccess": "Dry Fight Success", + "dryFightHeadline": "{winrate} in dry fights", + "dryFightDetail": "{wins}W of {fights} dry fights ({delta} vs overall)", "dryFightSuccessDescription": "Winning {winrate}% of fights without using ultimates", "dryFightCount": "{wins}W / {fights} dry fights", + "ultEconomy": "Ult economy", + "ultEconomyHeadline": "{count} ults per won fight", + "ultEconomyDetail": "{count} per lost fight ({delta} delta)", "fightReversalComparison": "Fight Reversal: Dry vs Ult", + "fightReversalHeadline": "{dryRate} dry, {ultRate} with ults reverse", + "fightReversalDetail": "{dryReversals} dry, {ultReversals} ult reversals", "fightReversalComparisonDescription": "Reversal rate is {dryRate}% in dry fights vs {nonDryRate}% when ultimates are used", "fightReversalComparisonDetail": "{dryReversals} dry / {nonDryReversals} ult reversals", "strongAdvantage": "Strong Advantage", @@ -3956,48 +7551,83 @@ "overallFightPerformance": "Overall Fight Performance", "totalFights": "Total Fights", "fightsWon": "Fights Won", - "overallWinrate": "Overall Winrate" + "overallWinrate": "Overall Winrate", + "chartTitle": "Winrate by fight type", + "tooltipSummary": "{winrate} over {fights, plural, =0 {no fights} =1 {1 fight} other {# fights}}", + "rows": { + "overall": "Overall", + "firstPick": "First pick", + "firstDeath": "First death", + "firstUlt": "First ult", + "dry": "Dry", + "withUlts": "With ults" + } }, "winrateOverTimeChart": { + "eyebrow": "Trends · Winrate over time", "title": "Winrate Over Time", "noData": "Not enough data to show winrate trends yet.", - "average": "Average: {avgWinrate}% • {trend} {trendValue}%", + "average": "Average: {avgWinrate} • {trend} {trendValue}", "weekly": "Weekly", "monthly": "Monthly", "winrateLabel": "Winrate (%)", "50PercentLine": "50% Line", - "winrate": "Winrate: {winrate}%", + "winrate": "Winrate: {winrate}", "winsAndLosses": "{wins}W - {losses}L" }, "ultimatesTab": { "overview": { + "eyebrow": "Ultimates · Usage overview", "title": "Ultimate Usage Overview", "noData": "No ultimate usage data available yet.", "description": "Aggregated across {maps} maps", "totalUltsUsed": "Total Ultimates Used", "ultsPerMap": "{count} per map average", "avgChargeTime": "Avg Charge Time", - "chargeTimeFast": "Fast charge speed", - "chargeTimeAverage": "Average charge speed", - "chargeTimeSlow": "Slow — consider building ult faster", "avgHoldTime": "Avg Hold Time", - "holdTimeGood": "Good discipline — using ults quickly", - "holdTimeAverage": "Average hold time", - "holdTimeSlow": "Holding too long — use ults sooner", + "vsOpponents": { + "title": "vs opponents faced ({maps} maps)", + "you": "You", + "opponents": "Opponents", + "readFaster": "{delta}s faster than opponents", + "readSlower": "{delta}s slower than opponents", + "readEven": "about even with opponents", + "youFaster": "you {delta}s faster", + "youSlower": "you {delta}s slower", + "youEven": "about even", + "better": "Better", + "worse": "Worse", + "unnamed": "Unnamed opponents", + "colOpponent": "Opponent", + "colAvg": "Avg", + "colDelta": "vs you", + "colMaps": "Maps" + }, "fightInitiation": "Fight Initiation Rate", "fightInitiationDetail": "{count} of {total} ult fights", - "topOpenersLabel": "Top fight opener: {top} ({count} fights) — click to expand" + "metric": "Metric", + "value": "Value", + "read": "Read", + "fightOpenings": "Fight openings", + "topFightOpeners": "Top fight openers", + "topOpenersLabel": "Top fight opener: {top} ({count} fights)" }, "roleBreakdown": { + "eyebrow": "Ultimates · Role breakdown", "title": "Ultimate Usage by Role", "noData": "No ultimate usage data available yet.", "description": "How ultimates are distributed across roles and when they are used in fights", "percentage": "{value}% of total", + "role": "Role", + "ultsUsed": "Ults used", + "share": "Share", + "subroles": "Subroles", "timingTitle": "Ultimate Timing Distribution", "timingDescription": "When ultimates are used within fights: initiation (first third), midfight (middle third), or late (final third)", "yourTeam": "Your Team" }, "playerRankings": { + "eyebrow": "Ultimates · Player rankings", "title": "Player Ultimate Rankings", "noData": "No player data available yet.", "description": "Individual player ultimate usage across all scrims", @@ -4008,34 +7638,103 @@ "mapsPlayed": "Maps", "ultsPerMap": "Ults/Map", "fightOpener": "Fight Opener" + }, + "combos": { + "eyebrow": "Ultimates · Combos", + "title": "Ultimate combos", + "description": "Two-ultimate combos used within {window}s of each other, across {maps} maps.", + "noData": "No two-ultimate combos yet. Pair two ultimates within {window}s in a fight and they'll show up here.", + "metricLabel": "Combo metric", + "mostUsed": "Most used", + "winRate": "Win rate", + "captionMostUsed": "Most-used combos", + "captionWinRate": "Best win rate (min {min} uses)", + "winrateNeedsSamples": "No combo has been used at least {min} times yet, so there's no win rate to rank.", + "rowSummary": "{heroA} and {heroB}: used in {count} fights, {winrate}% win rate.", + "sampleSize": "n={count}", + "lowSampleNote": "Combos used fewer than {min} times are hidden from the win-rate ranking." + }, + "responses": { + "eyebrow": "Ultimates · Enemy responses", + "title": "Answering enemy ultimates", + "description": "Which ultimates your team uses within {window}s after an enemy ultimate, across {maps} maps.", + "noData": "No counter-ultimates yet. When your team answers an enemy ultimate within {window}s, it'll show up here.", + "viewLabel": "Response view", + "matrix": "Matrix", + "flow": "Flow", + "enemyAxis": "Enemy ultimate", + "ourAxis": "Our response", + "matrixHint": "Cell shade shows how often you answered; hover for win rate.", + "cellSummary": "Answered {enemyHero} with {ourHero} {count} times, {winrate}% win rate.", + "selectEnemyUlt": "Enemy ultimate", + "allEnemyUlts": "All enemy ultimates", + "searchEnemyUlts": "Search enemy ultimates", + "noEnemyUlts": "No enemy ultimates found.", + "flowSummary": "{ourHero} answered {enemyHero} {count} times ({winrate}% win rate).", + "noResponsesForHero": "No tracked responses to this enemy ultimate." + }, + "ultAdvantage": { + "eyebrow": "Ultimates · Advantage", + "title": "Ultimate advantage", + "description": "How your ultimate bank stacks up against the enemy entering fights, across {maps} maps.", + "noData": "Not enough ultimate charge data yet to track ultimate advantage.", + "avgAdvantage": "Avg ultimate advantage", + "avgAdvantageSub": "ultimates vs the enemy entering a fight", + "winWhenAhead": "Win when ahead", + "winWhenBehind": "Win when behind", + "ofFights": "{pct}% of fights", + "behind": "Behind", + "even": "Even", + "ahead": "Ahead", + "distributionCaption": "How often you enter fights...", + "winrateCaption": "Win rate by ultimate advantage", + "bucketBehind2": "Behind 2+", + "bucketBehind1": "Behind 1", + "bucketEven": "Even", + "bucketAhead1": "Ahead 1", + "bucketAhead2": "Ahead 2+", + "noFights": "No fights", + "fightsCount": "{count, plural, one {# fight} other {# fights}}", + "tempoCaption": "Average ultimate advantage by fight", + "fightTick": "Fight {n}", + "tempoHint": "Above zero means you entered the fight holding more ultimates than the enemy.", + "shapeCaption": "Fights by advantage", + "fightsAxis": "Fights" } }, "swapsTab": { "overview": { + "eyebrow": "Swaps · Overview", "title": "Hero Swap Overview", "noData": "No hero swap data available yet.", "description": "Aggregated across {maps} maps", "totalSwaps": "Total Swaps", "swapsPerMap": "{count} per map average", "winrateDelta": "Win Rate Impact", - "winrateDeltaPositive": "+{delta}% when swapping", - "winrateDeltaNegative": "{delta}% when swapping", + "winrateDeltaPositive": "+{delta} when swapping", + "winrateDeltaNegative": "{delta} when swapping", "winrateDeltaNeutral": "No difference", "noSwapWinrate": "No-Swap Win Rate", "noSwapDetail": "{wins}W - {losses}L without swaps", "swapWinrate": "Swap Win Rate", "swapDetail": "{wins}W - {losses}L with swaps", "avgTimeBeforeSwap": "Avg Time on Hero", - "avgTimeDetail": "Average time on hero before swapping" + "avgTimeDetail": "Average time on hero before swapping", + "durationSeconds": "{seconds}s", + "durationMinutes": "{minutes}m {seconds}s" }, "timing": { + "eyebrow": "Swaps · Timing", "title": "Swap Timing Distribution", "description": "When hero swaps occur during a match, as percentage of total match time", "noData": "No swap timing data available.", - "swapCount": "{count} swaps", + "swapCount": "{count, plural, one {# swap} other {# swaps}}", + "swapsLabel": "Swaps", + "matchTime": "Match time: {label}", "ofTotal": "{pct}% of all swaps" }, "winrate": { + "eyebrow": "Swaps · Winrate impact", "title": "Win Rate by Swap Behavior", "descriptionCount": "How win rate changes based on the number of swaps per map", "descriptionTiming": "Win rate for maps containing early, mid, or late swaps", @@ -4043,15 +7742,20 @@ "byCount": "By Swap Count", "byTiming": "By Swap Timing", "maps": "{count} maps", - "winrate": "{pct}%" + "winrate": "{pct}", + "winRateLabel": "Win Rate" }, "pairs": { + "eyebrow": "Swaps · Common pairs", "title": "Most Common Hero Swaps", "description": "Top hero-to-hero swap transitions across all maps", "noData": "No swap pair data available.", - "count": "{count} times" + "count": "{count, plural, one {# time} other {# times}}", + "times": "Times", + "timingLabel": "When in match" }, "player": { + "eyebrow": "Swaps · Player breakdown", "title": "Player Swap Breakdown", "description": "Individual player swap statistics and win rate impact", "noData": "No player swap data available.", @@ -4063,15 +7767,129 @@ "winrateWithout": "WR w/o Swaps", "topPair": "Top Swap" } + }, + "positional": { + "eyebrow": "Positional", + "title": "Positional stats", + "description": "Averages over the last {count} scrims with positional data.", + "trendsTitle": "Trends", + "playersTitle": "Per-player breakdown", + "playerColumn": "Player", + "matrix": { + "below": "Below median", + "above": "Above median", + "legend": "Tint shows each value's deviation from the roster median for that stat." + }, + "byZoneTitle": "Won / lost / even by zone", + "empty": "No positional data yet. Stats appear once scrims with coordinate tracking are uploaded.", + "stats": { + "AVERAGE_ENGAGEMENT_DISTANCE": { + "short": "Eng Dist", + "full": "Engagement Distance" + }, + "HIGH_GROUND_KILL_PERCENTAGE": { + "short": "HG Kill %", + "full": "High Ground Kill %" + }, + "ISOLATION_DEATH_PERCENTAGE": { + "short": "Iso Death %", + "full": "Isolation Death %" + }, + "AVERAGE_FIGHT_START_SPREAD": { + "short": "Start Spread", + "full": "Fight Start Spread" + }, + "AVERAGE_ULT_CONVERSION_KILLS": { + "short": "Ult Conv", + "full": "Ult Conversion Kills" + }, + "ULT_DEATH_PERCENTAGE": { + "short": "Ult Death %", + "full": "Ult Death %" + }, + "AVERAGE_ULT_DISPLACEMENT": { + "short": "Ult Disp", + "full": "Ult Displacement" + }, + "ULTS_ON_OBJECTIVE_PERCENTAGE": { + "short": "Ult Obj %", + "full": "Ults On Objective %" + } + }, + "engagementsTitle": "Engagements", + "winrate": "Winrate", + "fights": "{total, plural, one {# fight} other {# fights}}", + "recordSummary": "{won}W / {lost}L / {even} even", + "wonLabel": "Won", + "lostLabel": "Lost", + "evenLabel": "Even", + "zonesTitle": "Zone control by map", + "zonesYourTeam": "Your team", + "zonesOpponents": "Opponents", + "zonesCount": "{count, plural, one {# zone} other {# zones}}", + "zonesControlPct": "{pct}% control", + "routesTitle": "Routes by map", + "routesSummary": "{total} routes · {won} won / {lost} lost", + "routesUndecided": "Undecided", + "zoneColumn": "Zone", + "teamColumn": "Team", + "killsColumn": "Kills", + "deathsColumn": "Deaths", + "ultsColumn": "Ults" + }, + "charts": { + "eyebrow": "Correlation", + "title": "Player charts", + "description": "Each dot is a player, scored per 10 minutes. Use the hero filter to scope every chart.", + "regressionToggle": "Trend line + r", + "correlation": "r = {value}", + "trendUnavailable": "Need 2+ players for a trend", + "chartAlt": "Scatter chart of {x} versus {y} across {count} players", + "expand": "Expand chart", + "zoom": "Zoom", + "zoomLabel": "Zoom {axis} axis", + "resetZoom": "Reset zoom", + "legendTrend": "Linear trend", + "playersShown": "{shown} of {total} players shown", + "noData": "No players match the current filter.", + "customEyebrow": "Build your own", + "customTitle": "Custom chart", + "customDescription": "Pick any two stats to compare across the roster.", + "customChartTitle": "{x} vs {y}", + "xAxis": "X axis", + "yAxis": "Y axis", + "presetDamageDeaths": "Hero damage /10 vs Deaths /10", + "presetFinalBlowsDeaths": "Final blows /10 vs Deaths /10", + "presetDamageHealingReceived": "Damage received /10 vs Healing received /10", + "presetBlockedTaken": "Damage blocked /10 vs Damage taken /10", + "stats": { + "eliminations": "Eliminations", + "finalBlows": "Final blows", + "deaths": "Deaths", + "heroDamage": "Hero damage", + "healingDealt": "Healing dealt", + "healingReceived": "Healing received", + "selfHealing": "Self healing", + "damageTaken": "Damage taken", + "damageBlocked": "Damage blocked", + "ultimatesEarned": "Ultimates earned", + "ultimatesUsed": "Ultimates used", + "soloKills": "Solo kills", + "environmentalKills": "Environmental kills" + } } }, "targets": { "title": "Player Targets", + "signInRequired": "Please sign in to view targets.", + "teamNotFound": "Team not found.", + "premiumFeature": "Premium Feature", "premiumRequired": "Player targets require a Premium plan.", "upgradePrompt": "Upgrade to set measurable improvement goals for your players.", "viewPricing": "View Pricing", "noTargets": "No targets have been set by your coach yet.", "noTeamMembers": "No team members found.", + "addPlayerToSetTargets": "Add this player to your team to set targets", "setTarget": "Set Target", "editTarget": "Edit Target", "createTarget": "Create Target", @@ -4086,8 +7904,19 @@ "progress": "Progress", "baseline": "Baseline", "target": "Target", + "trend": "Trend", + "baselineValue": "Baseline: {value}", + "targetValue": "Target: {value}", + "clickToIsolate": "Click to isolate", + "baselineTargetSummary": "Baseline: {baseline} | Target: {target}", + "chartSummary": "Average: {average} | Max: {max} | Min: {min}", "targetAchieved": "Target achieved!", "coachNote": "Coach's note", + "progressSummary": { + "onTrack": "{count} on track", + "inProgress": "{count} in progress", + "behind": "{count} behind" + }, "form": { "title": "Set Target for {playerName}", "editTitle": "Edit Target for {playerName}", @@ -4117,9 +7946,696 @@ }, "narrative": { "changed": "Your {stat} has {direction} by {percent}% over the last {window} scrims.", + "changedRich": "Your {stat} has {direction} by {percent} over the last {window} scrims.", "progressToward": "You're {percent}% of the way to your target of {targetDirection}{targetPercent}%.", + "progressTowardRich": "You're {percent} of the way to your target of {targetDirection}{targetPercent}.", "increased": "increased", "decreased": "decreased" + }, + "metadata": { + "title": "Player Targets | Parsertime", + "description": "Set and track player development targets for your team." + } + }, + "chartComponents": { + "heroPickRateHover": { + "weekOf": "Week of {date}", + "pickRate": "Pick rate", + "playtime": "Playtime", + "eyebrow": "Pick rate · Last 60 days", + "average": "Avg", + "windowDelta": "Δ Window", + "percentagePoints": "{value}pp", + "playtimeHoursMinutes": "{hours}h {minutes}m", + "playtimeMinutes": "{minutes}m", + "patches": { + "season": "Season", + "midCycle": "Mid-cycle", + "hotfix": "Hotfix" + } + } + }, + "reportsPage": { + "metadata": { + "title": "Reports | Parsertime", + "description": "View shared AI-generated scrim analysis reports." + }, + "list": { + "title": "Reports", + "description": "AI-generated analysis reports from your chat conversations.", + "searchPlaceholder": "Search reports...", + "reportCount": "{count, plural, =0 {No reports} =1 {# report} other {# reports}}", + "emptyTitle": "No reports yet", + "emptyDescription": "Reports are created from Analyst conversations. Ask the Analyst to generate a report and it will appear here.", + "startChat": "Start a chat", + "noMatches": "No reports match \"{query}\"", + "pageStatus": "Page {currentPage} of {totalPages}", + "previousPage": "Previous page", + "previous": "Previous", + "nextPage": "Next page", + "next": "Next" + } + }, + "ranked": { + "title": "Ranked Tracker", + "emptyTitle": "No matches tracked yet", + "emptyDescription": "Start logging your games to see winrate trends across maps, heroes, and group sizes.", + "trackFirst": "Track your first match", + "addMatch": "Add match", + "import": { + "title": "Import from Winrate Tracker", + "description": "Upload the JSON export from the old site to sync your matches.", + "button": "Choose export file", + "importing": "Importing…", + "done": "Imported {imported} matches", + "failed": "Import failed" + }, + "metadata": { + "title": "Ranked Tracker | Parsertime", + "description": "Track your ranked Overwatch 2 matches and analyze your performance." + }, + "dashboard": { + "trackMatch": "Track match", + "allTime": "All time", + "roleAll": "All Roles", + "roleTank": "Tank", + "roleDamage": "Damage", + "roleSupport": "Support", + "filterByRole": "Filter by role", + "filterBySeason": "Filter by season or patch" + }, + "tabs": { + "overview": "Overview", + "heroes": "Heroes", + "maps": "Maps", + "time": "Time", + "patches": "Patches", + "groups": "Groups", + "roles": "Roles", + "heroDeepDives": "Hero Deep Dives", + "heroMapAnalysis": "Hero × Map Analysis", + "mapMastery": "Map Mastery", + "patterns": "Patterns" + }, + "summary": { + "matches": "Matches", + "winrate": "Winrate", + "bestMap": "Best map", + "currentStreak": "Current streak", + "acrossMaps": "across {count} maps", + "record": "{wins}W – {losses}L", + "recordWithDraws": "{wins}W – {losses}L – {draws}D", + "mapWinrate": "{winrate}% winrate", + "wonLast": "Won last {count}", + "lostLast": "Lost last {count}", + "noStreak": "No active streak" + }, + "matchList": { + "eyebrow": "History", + "recentMatches": "Recent matches", + "lastSevenDays": "(last 7 days)", + "counter": "{start}–{end} of {total}", + "pageOf": "Page {page} of {total}", + "previous": "Previous", + "next": "Next", + "previousAria": "Previous page", + "nextAria": "Next page", + "groupSolo": "Solo", + "groupDuo": "Duo", + "group3Stack": "3-Stack", + "group4Stack": "4-Stack", + "group5Stack": "5-Stack", + "groupNStack": "{count}-Stack", + "resultWin": "win", + "resultLoss": "loss", + "resultDraw": "draw", + "deleteMatchAria": "Delete match", + "deleteTitle": "Delete match?", + "deleteBody": "This will permanently remove this match from your history.", + "cancel": "Cancel", + "delete": "Delete", + "deleting": "Deleting..." + }, + "form": { + "title": "Track Matches", + "description": "Log your recent games. You can add multiple matches at once.", + "matchHeading": "Match {n}", + "removeMatch": "Remove match {n}", + "map": "Map", + "selectMap": "Select map...", + "searchMaps": "Search maps...", + "noMaps": "No maps found.", + "dateTime": "Date & Time", + "result": "Result", + "win": "Win", + "loss": "Loss", + "draw": "Draw", + "groupSize": "Group Size", + "groupSizeSolo": "Solo", + "groupSizeDuo": "Duo", + "groupSizeStack3": "3-Stack", + "groupSizeStack4": "4-Stack", + "groupSizeStack5": "5-Stack", + "heroesPlayed": "Heroes Played", + "percentTotal": "{total}%", + "percentSum": "Percentages must sum to 100", + "percentageFor": "Percentage for {hero}", + "removeHero": "Remove {hero}", + "addHero": "Add hero...", + "searchHeroes": "Search heroes...", + "noHeroes": "No heroes found.", + "addAnother": "Add another match", + "submit": "Submit {count, plural, one {match} other {# matches}}", + "submitting": "Submitting...", + "errorSelectMap": "Match {n}: Select a map", + "errorSelectResult": "Match {n}: Select a result", + "errorAddHero": "Match {n}: Add at least one hero", + "errorHeroSum": "Match {n}: Hero percentages must sum to 100 (currently {total})", + "errorGeneric": "Something went wrong" + }, + "charts": { + "mapWinLoss": { + "eyebrow": "Map performance", + "title": "Where do you win most?", + "description": "{bestMap} is your best map at {bestWinrate}% winrate", + "descriptionWithWorst": "{bestMap} is your best map at {bestWinrate}% winrate — {worstMap} is your toughest at {worstWinrate}%", + "wins": "Wins", + "losses": "Losses", + "winrateLabel": "Winrate:", + "footer": "Based on {count, plural, one {# match} other {# matches}} across {maps, plural, one {# map} other {# maps}}" + }, + "gameModeDistribution": { + "eyebrow": "Game modes", + "title": "What modes do you play?", + "description": "{mode} makes up {pct}% of your games", + "matchesValue": "{count, plural, one {# match} other {# matches}}", + "matchesUnit": "matches" + }, + "gameModeWinrate": { + "eyebrow": "Game modes", + "title": "Which modes suit you?", + "description": "{bestMode} is your strongest mode at {bestWinrate}%", + "descriptionBest": "{bestMode} is your strongest mode at {bestWinrate}%", + "descriptionBestWorst": "You dominate {bestMode} at {bestWinrate}% but struggle on {worstMode} at {worstWinrate}%", + "descriptionEmpty": "Play more matches to see mode winrates", + "winrate": "Winrate", + "winsOverGames": "{wins}W / {total, plural, one {# game} other {# games}}", + "footer": "Dashed line marks 50% winrate" + }, + "groupSizeBreakdown": { + "eyebrow": "Group size", + "title": "Where do you play most?", + "descriptionEmpty": "Track more matches to see your group habits", + "descriptionAllGrouped": "All {total} games played grouped", + "descriptionAllSolo": "All {total} games played solo", + "descriptionMixed": "{soloPct}% of your {total} games are solo — {groupedPct}% grouped", + "noData": "No data yet", + "wins": "Wins", + "losses": "Losses", + "draws": "{count, plural, one {# draw} other {# draws}}", + "winrateOverGames": "Winrate: {winrate}% over {total, plural, one {# game} other {# games}}", + "footer": "Based on {count, plural, one {# match} other {# matches}} across {sizes, plural, one {# group size} other {# group sizes}}" + }, + "groupSizeWinrate": { + "eyebrow": "Group size", + "title": "Better together?", + "descriptionEmpty": "Play more matches across different group sizes to see your trends", + "description": "You win {winrate}% as {label} — your best group configuration", + "descriptionWithCount": "You win {winrate}% as {label} — your best group configuration with {games} games", + "descriptionWithDiff": "You win {winrate}% as {label} — your best group configuration (+{diff}% vs. solo)", + "descriptionWithCountAndDiff": "You win {winrate}% as {label} — your best group configuration with {games} games (+{diff}% vs. solo)", + "noData": "No data yet", + "winrate": "Winrate", + "winLossGames": "{wins}W / {losses}L · {total, plural, one {# game} other {# games}}", + "footer": "Minimum {min} games required per group size · showing {showing, plural, one {# size} other {# sizes}}" + }, + "roleDistribution": { + "eyebrow": "Roles", + "title": "Where do you spend your time?", + "description": "You spend {pct}% of your time on {role}", + "descriptionEmpty": "Log some matches to see how you spread your time across roles", + "noData": "No data yet" + }, + "roleWinrate": { + "eyebrow": "Roles", + "title": "Which role wins you games?", + "description": "You win most as {role} — {winrate}% winrate", + "descriptionEmpty": "Play at least {min} matches per role to see winrates", + "noData": "No data yet", + "winrate": "Winrate", + "winLossGames": "{wins}W / {losses}L · {total, plural, one {# game} other {# games}}", + "footer": "Minimum {min} games required per role · showing {showing, plural, one {# role} other {# roles}}" + }, + "mostPlayedHeroes": { + "eyebrow": "Hero performance", + "title": "Who do you play most?", + "descriptionAllModes": "{hero} is your go-to across all modes with {count, plural, one {# game} other {# games}}", + "descriptionMode": "{hero} is your go-to in {mode} with {count, plural, one {# game} other {# games}}", + "descriptionEmpty": "Play more matches to see hero stats", + "filterAriaLabel": "Filter by game mode", + "allModes": "All modes", + "matches": "Matches", + "roleLabel": "Role: {role}", + "roles": { + "Tank": "Tank", + "Damage": "Damage", + "Support": "Support" + } + }, + "heroWinrate": { + "eyebrow": "Hero performance", + "title": "Who do you win with?", + "descriptionBestWorst": "{bestHero} leads at {bestWinrate}% — {worstHero} could use more practice", + "descriptionBest": "{bestHero} is your top performer at {bestWinrate}%", + "descriptionEmpty": "Play more matches to see hero winrates", + "winrate": "Winrate", + "winsTotal": "{wins}W / {total, plural, one {# game} other {# games}}", + "footer": "Minimum {min} matches required · showing {count, plural, one {# hero} other {# heroes}}" + }, + "oneTrick": { + "eyebrow": "Hero pool", + "title": "Are you a one-trick?", + "descriptionOneTrick": "You've spent {pct}% of your time on {hero} — a dedicated one-trick", + "descriptionSpecialist": "You lean toward {hero} but still have some variety", + "descriptionDiverse": "Your playtime is spread across many heroes", + "descriptionEmpty": "No matches tracked yet", + "labels": { + "One-Trick": "One-Trick", + "Specialist": "Specialist", + "Diverse": "Diverse" + }, + "breakdownAriaLabel": "Hero playtime breakdown", + "percentAriaLabel": "{pct} percent", + "noData": "No data yet", + "footer": "Based on weighted playtime · One-Trick ≥ {oneTrick}% · Specialist ≥ {specialist}%" + }, + "heroPoolDiversity": { + "eyebrow": "Hero pool", + "title": "How wide is your hero pool?", + "descriptionNoMatches": "No matches tracked yet", + "descriptionNoHeroData": "No hero data available", + "descriptionAllRoles": "You've played {count, plural, one {# unique hero} other {# unique heroes}} across all 3 roles", + "descriptionConcentrated": "Your pool is concentrated in {role} — {count, plural, one {# unique hero} other {# unique heroes}} total", + "uniqueHeroesAriaLabel": "{count, plural, one {# unique hero} other {# unique heroes}}", + "heroUnit": "{count, plural, one {hero} other {heroes}}", + "heroesLabel": "{count, plural, one {# hero} other {# heroes}}", + "heroesByRoleAriaLabel": "Heroes by role", + "roleHeroesAriaLabel": "{role} heroes", + "noRoleHeroes": "No {role} heroes played yet", + "noData": "No data yet" + }, + "heroSwap": { + "eyebrow": "Hero swaps", + "title": "Does switching heroes win games?", + "descriptionTrackMore": "Track more matches to see if swapping heroes helps you win", + "descriptionNoImpact": "Swapping heroes has no meaningful impact on your winrate", + "descriptionSwapBoost": "Swapping heroes gives you a +{delta}% winrate boost", + "descriptionStayAdvantage": "Staying on your hero gives you a +{delta}% winrate advantage", + "deltaNoDifference": "No meaningful difference", + "deltaSwapping": "+{delta}% when swapping", + "deltaStaying": "+{delta}% when staying", + "winrate": "Winrate", + "winsTotal": "{wins}W · {total, plural, one {# game} other {# games}}", + "emptyState": "Need at least 3 swap matches and 3 non-swap matches", + "footerRule": "Swap = 2+ heroes each played ≥ {pct}% of match", + "footerCounts": "{swaps, plural, one {# swap} other {# swaps}}, {single} single-hero" + }, + "roleFlexibility": { + "eyebrow": "Roles", + "title": "Role flexibility", + "descriptionAdaptive": "You play all three roles nearly equally — a true flex player", + "descriptionFlexible": "You lean toward {role} but still play others", + "descriptionSpecialist": "You mainly play {role} — a dedicated specialist", + "labels": { + "Adaptive": "Adaptive", + "Flexible": "Flexible", + "Specialist": "Specialist" + }, + "noData": "No data yet" + }, + "mapWinrateRanking": { + "eyebrow": "Map performance", + "title": "Map Winrate Rankings", + "insight": "{bestMap} is your strongest at {bestWinrate}% — {worstMap} is your toughest at {worstWinrate}%", + "insightEmpty": "No map data yet", + "avgLabel": "Avg {overallWinrate}%", + "footer": "{count} games across {maps} maps", + "fadedNote": "Faded bars have fewer than {min} games", + "confidenceStarsLabel": "Confidence: {count} out of 5 stars", + "deviationAbove": "+{deviation}% above average", + "deviationBelow": "{deviation}% below average", + "deviationAt": "At average", + "winrate": "Winrate", + "wld": "W / L / D", + "vsAvg": "vs. your avg ({overallWinrate}%)", + "confidence": "Confidence", + "lowSample": "Low sample — {count, plural, one {# game} other {# games}}" + }, + "mapTierList": { + "eyebrow": "Map performance", + "title": "Map Tier List", + "description": "Your {count} played maps ranked by winrate and confidence", + "descriptionEmpty": "Play more maps to generate a tier list", + "listLabel": "Map tier list", + "tierLabel": "Tier {tier}: {description}", + "tierDescription": { + "S": "Dominant", + "A": "Strong", + "B": "Solid", + "C": "Neutral", + "D": "Struggling" + }, + "lowSampleLabel": "Low sample size", + "winrateLine": "{winrate}% winrate ({wins}W / {losses}L)", + "winrateLineWithDraws": "{winrate}% winrate ({wins}W / {losses}L / {draws}D)", + "lowConfidence": "Low confidence — only {count, plural, one {# game} other {# games}}" + }, + "mapVolatility": { + "eyebrow": "Map performance", + "title": "Map Volatility", + "insight": "{mostVolatile} is your most unpredictable map — results vary widely", + "insightEmpty": "How consistent your results are on each map", + "footer": "High volatility means your results swing dramatically — these are your “coin-flip” maps", + "confidenceStarsLabel": "{count} out of 5 stars", + "volatility": "Volatility", + "assessment": "Assessment", + "winrate": "Winrate", + "confidence": "Confidence", + "gamesPlayed": "{count, plural, one {# game} other {# games}} played", + "assessmentExtreme": "Extremely unpredictable", + "assessmentHigh": "Highly volatile", + "assessmentModerate": "Moderately volatile", + "assessmentFairly": "Fairly consistent", + "assessmentVery": "Very consistent" + }, + "heroMapSynergy": { + "eyebrow": "Hero × map", + "title": "Hero × Map Synergy", + "description": "Which heroes win on which maps — cells with fewer than {min} games are shown as —", + "descriptionEmptyHeader": "Track enough matches to see which heroes work best on each map", + "noData": "No data available yet", + "filterGroupLabel": "Filter by map type", + "filterAll": "All", + "noMapsOfType": "No {type} maps played yet", + "matrixLabel": "Hero-map winrate matrix", + "heroMapHeader": "Hero / Map", + "cellLabel": "{hero} on {map}: {winrate}% winrate from {games} games", + "cellLabelInsufficient": "{hero} on {map}: insufficient data", + "cellTitle": "{hero} on {map}", + "cellWinrate": "{winrate}% winrate", + "cellRecord": "{wins}W / {losses}L ({games} games)", + "cellInsufficient": "Only {count, plural, one {# game} other {# games}} — need {min}+", + "cellNoGames": "No games played", + "legendWinrate": "Winrate:", + "legendLowGames": "<{min} games", + "footer": "Showing top {heroes} heroes by play frequency across {maps} maps", + "footerFiltered": "Showing top {heroes} heroes by play frequency across {maps} maps ({type} only)" + }, + "bestHeroPerMap": { + "eyebrow": "Hero × map", + "title": "Best Hero per Map", + "description": "Your highest-winrate hero on each map (min {min} games). The bar shows the 95% confidence interval; the tick marks the winrate.", + "descriptionEmptyHeader": "Play at least {min} games with a hero on a map to see your best picks", + "noData": "No qualifying data yet", + "infoAriaLabel": "About confidence intervals", + "infoTooltip": "Wilson score intervals account for sample size. A wide bar (highlighted) means the estimate is unreliable — play more games to narrow it.", + "ciAriaLabel": "Winrate {winrate}%, 95% confidence interval {low}% to {high}%", + "ciRange": "{low}%–{high}% CI", + "games": "{count, plural, one {# game} other {# games}}" + }, + "mapLearningCurve": { + "eyebrow": "Map mastery", + "title": "Map Learning Curve", + "emptyDescription": "Play at least {minGames} games on a map to see your improvement over time", + "emptyState": "Not enough data yet — need {minGames}+ games per map", + "legendEarly": "Early games", + "legendRecent": "Recent games", + "descriptionImproved": "You've improved most on {map} (+{delta}%) — first half vs second half of games", + "descriptionDefault": "First half vs second half of your games on each map", + "insightImproved": "You've improved most on {map} (+{delta}%)", + "insightDeclined": "{map} is your toughest to master ({delta}%)", + "insightNeutral": "Comparing your early vs recent performance per map", + "tooltipEarly": "Early ({games}g)", + "tooltipRecent": "Recent ({games}g)", + "tooltipImprovement": "+{delta}% improvement", + "tooltipDecline": "{delta}% decline", + "tooltipNoChange": "No change", + "footer": "Requires {minGames}+ games per map. Showing {count, plural, one {# qualifying map} other {# qualifying maps}}" + }, + "mapTimeline": { + "eyebrow": "Map mastery", + "title": "Win/Loss Timeline", + "emptyDescription": "Track your recent performance on each map", + "emptyState": "No map data yet", + "legendCumulative": "Cumulative winrate", + "selectMap": "Select map", + "result": { + "win": "win", + "loss": "loss", + "draw": "draw" + }, + "resultShort": { + "win": "W", + "loss": "L", + "draw": "D" + }, + "badgeTitle": "Game {index}: {result}", + "description": "Last {count, plural, one {# game} other {# games}} on {map}", + "lastPlayedToday": "played today", + "lastPlayedYesterday": "played yesterday", + "lastPlayedDaysAgo": "last played {days} days ago", + "recentResults": "Recent results (oldest → newest)", + "recentResultsAria": "Recent results on {map}", + "cumulativeWinrate": "Cumulative winrate over time", + "tooltipWinrate": "{value}% winrate", + "statOverall": "Overall", + "statGamesTracked": "Games tracked", + "statAvgGap": "Avg gap" + }, + "mapFamiliarity": { + "eyebrow": "Map mastery", + "title": "Map Familiarity", + "legendGamesPlayed": "Games played", + "description": "{played} of {available} maps played", + "descriptionWithAvoided": "{played} of {available} maps played — {avoided} never encountered", + "aboutVarietyScore": "About variety score", + "varietyScoreExplanation": "Variety score (0–100) measures how evenly your games are distributed across all available maps. 100 means perfectly equal time on every map.", + "mapVarietyScore": "Map variety score", + "varietyScoreAria": "Map variety score: {score}/100 — {label}", + "varietyDiverse": "Diverse", + "varietyModerate": "Moderate", + "varietyFocused": "Focused", + "varietyConcentrated": "Concentrated", + "resultShort": { + "win": "W", + "loss": "L", + "draw": "D" + }, + "tooltipGames": "{count, plural, one {# game} other {# games}} ({pct}% of total)", + "tooltipLast": "Last {count}: {results}", + "neverPlayed": "Maps you've never played ({count})", + "footer": "{total} total games — showing top {top} most-played maps" + }, + "repeatMap": { + "eyebrow": "Map mastery", + "title": "Repeat Map Performance", + "legendWinrate": "Winrate", + "labelFirstTime": "First time", + "labelRepeat": "Repeat", + "descriptionBetter": "You play better on repeat maps — +{delta}% when the map reappears in your session", + "descriptionWorse": "You underperform on repeat maps — {delta}% when the map reappears", + "descriptionNeutral": "Repeat maps have little impact on your performance", + "descriptionEmpty": "Does seeing the same map twice in a session affect your winrate?", + "insightBetter": "You perform {delta}% better when you see the same map twice in a session", + "insightWorse": "You perform {delta}% worse on repeat maps — fatigue may be a factor", + "insightNeutral": "Repeat maps have no meaningful effect on your winrate", + "emptyInsight": "Not enough repeat map data yet — play more sessions to see patterns", + "emptySubtext": "{count, plural, one {# repeat instance} other {# repeat instances}} recorded — need 5+", + "tooltipWinrate": "{value}% winrate", + "tooltipGames": "{count, plural, one {# game} other {# games}}", + "statFirstTime": "First time", + "statRepeat": "Repeat", + "statGames": "{count} games", + "footer": "Sessions grouped by calendar day — a repeat is when the same map appears more than once" + }, + "winrateTrend": { + "eyebrow": "Trend", + "title": "Are you improving?", + "emptyDescription": "Track more matches to see your trend", + "noData": "No data yet", + "trendImproving": "On the rise — your last {window}-game average is {current}%", + "trendDeclining": "Trending down — peaked at {peak}%, currently {current}%", + "trendStable": "Holding steady around {current}% over recent games", + "tooltipLabel": "Rolling winrate", + "tooltipMeta": "Game #{game} · {date}", + "footer": "{window}-game rolling average · {total} games total · peak {peak}%" + }, + "activityHeatmap": { + "eyebrow": "Activity", + "title": "When do you play?", + "description": "Most active on {day}s — averaging {avg} games on days you play", + "emptyDescription": "Log matches to see your activity pattern", + "gridLabel": "Activity heatmap", + "cellNoGames": "{date} — no games", + "cellGames": "{date} — {count, plural, one {# game} other {# games}}", + "less": "Less", + "more": "More", + "footer": "{days, plural, one {# active day} other {# active days}} in the last {weeks} weeks" + }, + "streak": { + "eyebrow": "Streaks", + "title": "Streak", + "resultWin": "Win", + "resultLoss": "Loss", + "resultDraw": "Draw", + "chipLabel": "Game {index}: {result}", + "winValue": "{count}W", + "lossValue": "{count}L", + "descriptionWin": "{count}-game win streak — keep it going", + "descriptionLoss": "{count}-game loss streak — time to turn it around", + "descriptionNone": "No active streak", + "currentStreak": "Current streak", + "longestWinStreak": "Longest win streak", + "longestLossStreak": "Longest loss streak", + "lastGames": "Last {count} games", + "lastGamesResults": "Last {count} game results", + "footer": "Most recent results shown left to right" + }, + "recentForm": { + "eyebrow": "Recent form", + "title": "Recent form", + "trendImproving": "Up {delta}% vs. your all-time average over your last {window} games", + "trendDeclining": "Down {delta}% vs. your all-time average over your last {window} games", + "trendStable": "Performing in line with your all-time average", + "lastGames": "Last {window} games", + "allTime": "All time", + "record": "{wins}W – {losses}L", + "recordWithDraws": "{wins}W – {losses}L – {draws}D", + "gamesCount": "({count} games)", + "deltaLabel": "{sign}{delta}% compared to overall", + "footer": "Comparing last {recent} games vs. {total} total" + }, + "sessionAnalysis": { + "eyebrow": "Sessions", + "title": "Session Performance", + "emptyDescription": "How do you perform within a single session?", + "emptyTitle": "Not enough sessions yet", + "emptyHint": "Sessions are groups of games played within 3 hours of each other. Play more matches to see trends.", + "insightSingle": "1 session tracked — {winrate}% winrate with {games, plural, one {# game} other {# games}}.", + "insightMany": "Across {sessions} sessions you average {winrate}% winrate and {games, plural, one {# game} other {# games}} per session.", + "tooltipWinrate": "{winrate}% winrate", + "tooltipRecord": "{wins}W – {losses}L · {games, plural, one {# game} other {# games}}", + "tooltipDuration": "~{minutes} min session", + "avgWinrate": "Avg winrate", + "gamesPerSession": "Games / session", + "totalSessions": "Total sessions", + "bestSession": "Best session", + "worstSession": "Worst session", + "footer": "Sessions are groups of games played within 3 hours of each other. Showing the last {count, plural, one {# session} other {# sessions}}." + }, + "dayOfWeek": { + "eyebrow": "Weekly rhythm", + "title": "Best Day to Play", + "emptyDescription": "Are you better on weekdays or weekends?", + "emptyTitle": "No matches yet", + "emptyHint": "Track matches across different days to see when you perform best.", + "insightStable": "You perform equally well on weekdays and weekends ({winrate}%). Your best day is {day}.", + "insightDelta": "You perform {delta}% better on {when, select, weekend {weekends} weekday {weekdays} other {weekends}}. Your strongest day is {day}.", + "tooltipNoGames": "No games on {day}", + "tooltipWinrate": "{winrate}% winrate", + "tooltipRecord": "{wins}W – {losses}L · {total, plural, one {# game} other {# games}}", + "tooltipRecordWithDraws": "{wins}W – {losses}L – {draws}D · {total, plural, one {# game} other {# games}}", + "weekdays": "Weekdays", + "weekdayRange": "Mon – Thu", + "weekends": "Weekends", + "weekendRange": "Fri – Sun", + "deltaLabel": "{delta}% {direction, select, better {better} worse {worse} other {better}} than weekdays", + "bestDay": "Best day", + "toughestDay": "Toughest day", + "footer": "Matches are attributed to the day the session started." + }, + "patchImpact": { + "seasonsEyebrow": "Seasons", + "seasonsTitle": "Winrate by season", + "seasonsDescription": "Your record within each ranked season.", + "seasonRecord": "{wins}W – {losses}L · {games} games", + "eyebrow": "Patch impact", + "title": "How patches move your winrate", + "description": "Rolling winrate over time. Vertical lines mark Overwatch patches, so you can see how the meta shifts line up with your form.", + "empty": "Not enough games to chart a timeline yet", + "tooltipWinrate": "{winrate}% rolling winrate", + "legendSeason": "Season", + "legendMidSeason": "Mid-season", + "legendHotfix": "Hotfix" + } + } + }, + "settings": { + "ranked": { + "toggleLabel": "Show ranked stats on my profile", + "toggleDescription": "Let others see a summary of your ranked performance on your public profile.", + "toggleError": "Couldn't update your privacy setting" + } + }, + "tournamentsPage": { + "metadata": { + "title": "Tournaments | Parsertime", + "description": "Run and track your Overwatch tournaments — brackets, matches, and team stats." + }, + "create": { + "metadata": { + "title": "Create Tournament | Parsertime", + "description": "Set up a new Overwatch tournament bracket." + } + }, + "detail": { + "metadata": { + "title": "{name} | Parsertime", + "description": "Bracket, matches, and standings for {name}." + } + }, + "match": { + "metadata": { + "title": "{team1} vs {team2} | Parsertime", + "description": "Match details and map results for {team1} vs {team2}." + } + }, + "teamStats": { + "metadata": { + "title": "{team} — Tournament Stats | Parsertime", + "description": "Tournament performance breakdown for {team}." + } + } + }, + "debugPage": { + "metadata": { + "title": "Debug | Parsertime", + "description": "Internal debugging tools." + } + }, + "fsrExplainer": { + "trigger": "How FSR works", + "title": "How FSR is calculated", + "intro": "FSR rates individual skill from in-game stats — not wins and losses — on a 1–5000 scale.", + "statBased": { + "label": "Stat-based.", + "body": "Built from per-role output (eliminations, damage, healing, deaths and more), weighted by what matters for each role." + }, + "peers": { + "label": "Measured against peers.", + "body": "Each stat becomes a z-score versus players in the same tier and role." + }, + "recency": { + "label": "Recency- and tier-weighted.", + "body": "Recent maps count more, and higher tiers (Expert through OWCS) carry more weight." + }, + "sample": { + "label": "Sample-aware.", + "body": "Small sample sizes are pulled toward the tier average until there is enough data." + }, + "scale": { + "label": "1–5000 scale.", + "body": "Anchored to the tier played in; percentile shows rank within that tier and role." } } } diff --git a/messages/ko.json b/messages/ko.json index 88a929a4a..c70bafc7a 100644 --- a/messages/ko.json +++ b/messages/ko.json @@ -1,4 +1,288 @@ { + "bulkUpload": { + "dropTitleEmpty": "워크샵 로그를 여기에 놓으세요", + "dropTitleMore": "맵을 추가하려면 로그를 더 놓으세요", + "dropSubtitle": "또는 클릭하여 찾아보기", + "dropHint": ".TXT, 각 최대 10MB. 한 번에 {max}개.", + "versus": "vs", + "noTimestamp": "파일 이름에 시간 정보가 없어 마지막에 배치됨", + "parseError": "이 로그를 읽을 수 없습니다", + "reorderMap": "맵 순서 변경", + "removeMap": "맵 제거", + "statusUploading": "업로드 중", + "statusDone": "완료", + "statusFailed": "실패", + "heroBansHeader": "영웅 밴", + "addHeroBan": "영웅 밴 추가", + "hero": "영웅", + "team": "팀", + "order": "순서", + "heroBansEmpty": "각 팀이 밴한 영웅을 추가하세요. 밴 순서는 유지됩니다.", + "uploading": "업로드 중", + "progress": "맵 {current}/{total} 업로드 중", + "uploadMaps": "맵 {count}개 업로드", + "createScrimWithMaps": "맵 {count}개로 스크림 생성", + "retryFailed": "실패한 맵 {count}개 다시 시도", + "uploadedTitle": "맵 업로드됨", + "uploadedCount": "스크림에 맵 {count}개가 추가되었습니다", + "partialFailureTitle": "일부 맵을 업로드하지 못했습니다", + "partialFailureDescription": "성공한 맵은 저장되었습니다. 실패한 맵을 다시 시도하세요.", + "createFirstMapFailed": "첫 번째 맵을 업로드하지 못해 스크림이 생성되지 않았습니다. 수정 후 다시 시도하세요.", + "noMapsTitle": "업로드할 맵이 없습니다", + "noMapsDescription": "먼저 워크샵 로그를 하나 이상 추가하세요.", + "winnerLabel": "승리 팀:", + "winnerDetectedBadge": "감지됨", + "winnerDetectedHint": "선수 위치를 기반으로 승리 팀을 감지했습니다 — 확인하거나 다른 팀을 선택하세요.", + "missingPushWinnerTitle": "Push 맵 승리 팀을 선택하세요", + "missingPushWinnerDescription": "업로드하기 전에 모든 Push 맵의 승리 팀을 선택하세요.", + "maxMapsTitle": "맵이 너무 많습니다", + "maxMaps": "한 번에 최대 {max}개의 맵을 업로드할 수 있습니다.", + "fileTypeTitle": "지원되지 않는 파일", + "fileType": ".txt 워크샵 로그만 지원됩니다.", + "fileSizeTitle": "파일이 너무 큽니다", + "fileSize": "각 로그는 10MB 미만이어야 합니다.", + "addDescription": "워크샵 로그를 놓아 맵을 추가하세요.", + "addTitle": "맵 추가", + "addDialogDescription": "워크샵 로그를 놓고 영웅 밴을 설정한 후 업로드하세요. 맵은 표시된 순서를 유지합니다." + }, + "queryBuilderPage": { + "title": "쿼리", + "subtitle": "질문을 한 조각씩 구성하세요. 문장처럼 읽히며 팀 범위로 제한된 실제 쿼리로 실행됩니다. 각 요소에 마우스를 올리면 사용되는 테이블과 컬럼을 확인할 수 있습니다.", + "plannerLabel": "질문 플래너", + "plannerPlaceholder": "PGE가 Widowmaker로 기록한 결정타 수와 스크림에서 플레이한 시간을 알고 싶어요", + "planQuestion": "계획", + "plannedQuery": "쿼리 계획됨", + "planFailed": "아직 이 질문을 계획할 수 없습니다.", + "plannerInterpreted": "해석 결과", + "plannerDataset": "데이터: {dataset}", + "plannerFilterCount": "{count, plural, =0 {필터 없음} other {필터 #개}}", + "sentenceLabel": "쿼리 문장", + "yourTeam": "내 팀", + "clauseFrom": "출처", + "clauseShow": "표시", + "clausePer": "그룹", + "clauseFor": "대상", + "clauseWhere": "조건", + "clauseAcross": "범위", + "clauseSortedBy": "정렬", + "clauseTop": "상위", + "overall": "전체", + "noFilters": "필터 없음", + "selectTeam": "팀 선택", + "filterAny": "모두", + "sortDefault": "기본 순서", + "limitAll": "모든 행", + "topValue": "상위 {n}", + "addMetric": "지표 추가", + "addDimension": "그룹 추가", + "addFilter": "필터 추가", + "editDataset": "데이터 소스 변경", + "editMetric": "지표 편집", + "editDimension": "그룹 편집", + "editTeam": "팀 선택", + "editFilter": "필터 편집", + "editScope": "스크림 범위 편집", + "editSort": "정렬 순서 편집", + "editLimit": "행 제한 편집", + "removeToken": "제거", + "techTable": "{count, plural, other {테이블}}", + "techColumn": "{count, plural, other {컬럼}}", + "searchPlaceholder": "검색", + "searchFields": "필드 검색...", + "noResults": "일치하는 항목이 없습니다.", + "pickMetric": "지표 검색", + "pickDataset": "데이터 소스 검색", + "pickDimension": "그룹 검색", + "pickFilter": "필터 검색", + "opEq": "같음", + "opNeq": "같지 않음", + "opIn": "다음 중 하나", + "opGt": "초과", + "opGte": "이상", + "opLt": "미만", + "opLte": "이하", + "loadingOptions": "옵션 불러오는 중", + "noOptions": "이 팀 데이터에 값이 없습니다.", + "scopeAll": "모든 스크림", + "scopeLastN": "최근 N개 스크림", + "scopeDateRange": "기간", + "scopeAllValue": "모든 스크림", + "scopeLastNValue": "최근 {n}개 스크림", + "scopeRangeValue": "{from} ~ {to}", + "lastNLabel": "최근 스크림 수", + "from": "시작", + "to": "종료", + "scopeNote": "스크림은 날짜순으로 정렬되며, 범위에 따라 쿼리에 포함될 스크림이 결정됩니다.", + "ascending": "낮은 순", + "descending": "높은 순", + "noSortable": "먼저 지표나 그룹을 추가하세요.", + "sortNone": "정렬 해제", + "limitNone": "전체", + "limitCustom": "직접 입력", + "teamScopeNote": "모든 쿼리는 이 팀이 소유한 스크림으로 제한됩니다. 접근 권한이 있는 팀만 선택할 수 있습니다.", + "compiledTitle": "컴파일된 쿼리", + "compiledTables": "테이블", + "compiledEmpty": "문장을 완성하면 컴파일된 쿼리가 표시됩니다.", + "copy": "복사", + "copied": "복사됨", + "emptyTitle": "아직 실행하지 않음", + "emptyBody": "위 문장을 구성한 뒤 실행하면 표나 차트가 여기에 표시됩니다.", + "errorTitle": "쿼리를 실행하지 못했습니다", + "errorBody": "문제가 발생했습니다. 문장을 조정한 뒤 다시 실행해 보세요.", + "zeroTitle": "일치하는 행 없음", + "zeroBody": "이 팀에는 쿼리에 맞는 데이터가 없습니다. 필터를 완화하거나 스크림 범위를 넓혀 보세요.", + "viewTable": "표", + "viewChart": "차트", + "viewSql": "SQL", + "askEyebrow": "질문", + "sentenceEyebrow": "구성", + "outputEyebrow": "출력", + "outputTitle": "결과", + "chartType": "차트 유형", + "chartTypeBar": "막대", + "chartTypeLine": "선", + "chartTypeArea": "영역", + "chartTypeDonut": "도넛", + "metaRows": "{count, plural, other {#행}}", + "metaScrims": "{count, plural, other {#개 스크림}}", + "metaDuration": "{ms}ms", + "metaTruncated": "제한 도달", + "chartCapped": "{total}개 행 중 처음 {shown}개를 차트로 표시합니다.", + "needTeam": "실행하려면 팀을 선택하세요.", + "needMetric": "지표를 하나 이상 추가하세요.", + "run": "실행", + "running": "실행 중", + "save": "저장", + "saveTitle": "이 쿼리 저장", + "saveNamePlaceholder": "쿼리 이름", + "saveConfirm": "쿼리 저장", + "export": "CSV 내보내기", + "savedQueries": "저장됨", + "savedEmpty": "저장된 쿼리가 없습니다.", + "deleteQuery": "저장된 쿼리 삭제", + "loadedQuery": "\"{name}\" 불러옴", + "savedQuery": "쿼리 저장됨", + "saveFailed": "쿼리를 저장할 수 없습니다.", + "noAccessTitle": "쿼리할 팀이 없습니다", + "noAccessBody": "아직 접근 가능한 팀이 없습니다. 팀에 참여하거나 생성하여 스크림 데이터를 쿼리하세요.", + "metadata": { + "title": "쿼리 빌더 | Parsertime", + "description": "오버워치 스크림 데이터로 맞춤 쿼리를 만드세요." + } + }, + "analyst": { + "metadata": { + "title": "분석가 | Parsertime", + "description": "오버워치 스크림 데이터, 플레이어 성과, 팀 추세를 위한 AI 분석가입니다." + }, + "eyebrow": "분석가", + "empty": { + "title": "스크림 데이터를 빠르게 확인하세요.", + "description": "팀 성과, 교전 분석, 맵 승률, 플레이어 추세에 대해 물어보세요. 업로드한 스크림에서 바로 가져옵니다.", + "tryAsking": "이렇게 물어보세요", + "suggestions": [ + "지난 스크림에서 우리 팀은 어떻게 했나요?", + "어떤 맵에서 승률이 가장 좋나요?", + "이번 달 성과 추세는 어떤가요?", + "최근 가장 이례적인 스탯을 기록한 선수는 누구인가요?" + ], + "blocked": { + "description": "대화를 시작하려면 크레딧을 추가하세요. 분석가는 종량제이며 최소 금액은 $5입니다.", + "addCredits": "크레딧 추가" + } + }, + "composer": { + "placeholder": "팀의 성과에 대해 물어보세요…", + "placeholderBlocked": "계속 대화하려면 크레딧을 추가하세요…", + "send": "메시지 보내기", + "stop": "생성 중지" + }, + "blockedBanner": { + "message": "잔액이 부족해 새 메시지를 보낼 수 없습니다. 계속 대화하려면 크레딧을 추가하세요. 이전 대화는 읽기 전용으로 계속 표시됩니다.", + "addCredits": "크레딧 추가" + }, + "edit": { + "cancel": "취소", + "resend": "다시 보내기" + }, + "actions": { + "copy": "복사", + "regenerate": "다시 생성", + "edit": "편집" + }, + "newConversation": "새 대화", + "sidebar": { + "heading": "대화", + "newChat": "새 대화", + "deleteConversation": "대화 삭제: {title}", + "empty": "아직 대화가 없습니다" + }, + "balanceChip": { + "balanceLabel": "AI 채팅 잔액", + "addCreditsLabel": "계속하려면 크레딧 추가" + }, + "cards": { + "record": { + "wins": "{count}승", + "losses": "{count}패", + "draws": "{count}무", + "winsLosses": "{wins}승 · {losses}패" + }, + "loading": { + "writingReport": "리포트 작성 중" + }, + "scrimAnalysis": { + "eyebrow": "스크림 분석", + "vs": "vs", + "acrossMaps": "맵 {count}개", + "fightWr": "교전 승률", + "fightsWon": "교전 승리", + "totalFights": "총 교전", + "players": "플레이어", + "insights": "인사이트" + }, + "mapPerformance": { + "eyebrow": "맵 성과", + "overall": "전체 {winrate}%" + }, + "teamTrends": { + "eyebrow": "성과 추세", + "winStreak": "{count}연승", + "lossStreak": "{count}연패", + "noStreak": "진행 중인 연승/연패 없음", + "weeklyWinRate": "주별 승률", + "last5": "최근 5경기", + "last10": "최근 10경기", + "last20": "최근 20경기" + }, + "player": { + "eyebrow": "플레이어", + "summary": "{heroes} · 맵 {mapCount}개", + "kd": "K/D", + "elims": "처치/10", + "deaths": "사망/10", + "damage": "피해/10", + "healing": "치유/10" + }, + "report": { + "eyebrow": "리포트 생성됨", + "viewReport": "리포트 보기" + }, + "teamOverview": { + "scrims": "스크림 {count}개" + }, + "scrimList": { + "eyebrow": "최근 스크림", + "found": "{count}개 발견", + "maps": "맵 {count}개" + }, + "trend": { + "up": "상승", + "down": "하락", + "stable": "유지" + } + } + }, "maps": { "aatlis": "아틸리스", "antarctic-peninsula": "남극 반도", @@ -37,6 +321,37 @@ "throne-of-anubis": "아누비스의 왕좌", "watchpoint-gibraltar": "감시 기지: 지브롤터" }, + "ui": { + "pagination": { + "ariaLabel": "페이지네이션", + "previousPage": "이전 페이지로 이동", + "nextPage": "다음 페이지로 이동", + "previous": "이전", + "next": "다음", + "morePages": "더 많은 페이지" + }, + "carousel": { + "previousSlide": "이전 슬라이드", + "nextSlide": "다음 슬라이드" + }, + "dialog": { + "close": "닫기" + }, + "sheet": { + "close": "닫기" + }, + "sidebar": { + "title": "사이드바", + "mobileDescription": "모바일 사이드바를 표시합니다.", + "toggle": "사이드바 전환" + }, + "opponentSearch": { + "placeholder": "OWCS 팀 검색…", + "clearSelection": "상대 선택 지우기", + "teams": "OWCS 팀", + "noTeamsFound": "팀을 찾을 수 없습니다." + } + }, "heroes": { "ana": "아나", "anran": "안란", @@ -73,6 +388,7 @@ "reaper": "리퍼", "reinhardt": "라인하르트", "roadhog": "로드호그", + "sierra": "시에라", "sigma": "시그마", "sojourn": "소전", "soldier76": "솔저: 76", @@ -80,15 +396,43 @@ "symmetra": "시메트라", "torbjorn": "토르비욘", "tracer": "트레이서", + "vendetta": "벤데타", "venture": "벤처", "widowmaker": "위도우메이커", "winston": "윈스턴", "wreckingball": "레킹볼", + "wuyang": "우양", "zarya": "자리야", "zenyatta": "젠야타" }, "landingPage": { + "pipeline": { + "eyebrow": "작동 방식", + "title": "원시 로그에서 코칭 판단까지", + "description": "스크림 블록이 끝나면 워크샵 로그를 업로드하세요. Parsertime이 모든 이벤트를 파싱하고 레이팅과 포지셔닝을 계산해, VOD 리뷰가 시작되기 전에 그날의 스크림을 대시보드로 만들어 드립니다.", + "sourcesLabel": "스크림 로그", + "processingLabel": "Parsertime", + "outputsLabel": "대시보드", + "outputDashboards": "팀 대시보드", + "outputRatings": "영웅 스킬 레이팅", + "outputReplays": "리플레이 & 히트맵", + "outputTrends": "추세선" + }, + "positional": { + "badge": "v3 신기능", + "title": "전장을 위에서 내려다보세요", + "description": "오버워치 2 최초의 위치 데이터 분석입니다. 모든 스크림이 탑다운 리플레이가 됩니다. 로테이션을 지켜보고, 반복해서 죽는 각도를 찾고, 다른 어떤 툴에도 없는 수치로 포지셔닝을 측정하세요.", + "replayName": "맵 리플레이 뷰어", + "replayDescription": "모든 한타를 탑다운 시점으로 돌려볼 수 있습니다. 플레이어 이동 경로, 궁극기 타이밍, 킬 이벤트가 보정된 맵 이미지 위에 표시됩니다.", + "heatmapsName": "히트맵", + "heatmapsDescription": "맵과 진영별 킬, 데스, 체류 밀도를 렌더링합니다. 전투의 승패가 갈리는 지점을 정확히 확인하세요.", + "averagesName": "위치 평균", + "averagesDescription": "교전 거리, 고지대 킬 비율, 고립 데스 등 포지셔닝 지표를 스크림 전반에 걸쳐 평균 내고 추세로 보여줍니다." + }, "hero": { + "liveData": "실시간 플랫폼 데이터", + "eyebrow": "스크림 분석 · 오버워치 2", + "trustedBy": "{count}개 이상의 팀이 신뢰합니다", "latestUpdates": "최신 업데이트", "title": "스크림 경험을 혁신하세요", "description": "이 플랫폼을 통해 스크림 데이터를 시각화하고, 개별 플레이어 성과를 추적하는 등 다양한 기능을 이용할 수 있습니다. 팀에 플레이어를 초대해 자신의 통계를 확인할 수 있도록 해보세요.", @@ -238,6 +582,10 @@ "verification": "토큰이 만료되었거나 이미 사용되었습니다. 다시 시도해 주세요.", "adapterError": "데이터베이스 어댑터 오류가 발생했습니다. 쿠키를 삭제한 후 다시 시도해 보세요. 문제가 계속되면 지원 팀에 문의해 주세요.", "default": "로그인 중 알 수 없는 오류가 발생했습니다. 지원 팀에 문의해 주세요." + }, + "metadata": { + "title": "인증 오류 | Parsertime", + "description": "로그인 중 문제가 발생했습니다." } }, "noAuth": { @@ -248,7 +596,11 @@ "verifyRequest": { "title": "로그인 링크를 이메일에서 확인하세요", "description": "로그인 링크가 포함된 이메일을 보냈습니다. 이메일의 링크를 클릭하여 로그인 절차를 완료하세요.", - "back": "홈으로 돌아가기" + "back": "홈으로 돌아가기", + "metadata": { + "title": "이메일을 확인하세요 | Parsertime", + "description": "계속하려면 로그인 링크를 이메일로 보냈습니다." + } }, "signInPage": { "metadataSignIn": { @@ -292,19 +644,21 @@ "dashboard": { "metadata": { "title": "대시보드 | Parsertime", - "description": "Parsertime은 오버워치 스크림 분석 도구입니다.", + "description": "스크림, 맵, 팀 성과를 한눈에 확인하세요.", "ogTitle": "대시보드 | Parsertime", "ogDescription": "Parsertime은 오버워치 스크림 분석 도구입니다." }, "title": "대시보드", "overview": "내 스크림", "admin": "관리자 보기", + "pendingFeedback": "{count, plural, other {{count}개의 스크림이 피드백을 기다리고 있습니다}}", "search": "검색...", "themeSwitcher": { "toggle": "테마 전환", "light": "라이트", "dark": "다크", - "system": "시스템" + "system": "시스템", + "disguised": "디스가이즈드" }, "userNav": { "dashboard": "대시보드", @@ -313,7 +667,12 @@ "contact": "문의하기", "docs": "문서", "admin": "관리자", - "signOut": "로그아웃" + "signOut": "로그아웃", + "profile": "프로필" + }, + "guestNav": { + "guestUser": "게스트 사용자", + "signIn": "로그인" }, "commandMenu": { "searchPlaceholder": "명령어 입력 또는 검색...", @@ -399,6 +758,9 @@ "oldToNew": "오래된 순", "newToOld": "최신 순", "search": "검색...", + "noScrimsFound": "필터와 일치하는 스크림을 찾을 수 없습니다.", + "tryAdjustingFilters": "검색어나 필터를 조정해 보세요.", + "searchDescription": "팁: team:팀이름, creator:사용자명 문법을 사용하면 검색 결과를 개선할 수 있습니다.", "searchInfoLabel": "검색 도움말" }, "pagination": { @@ -406,20 +768,82 @@ "next": "다음", "morePages": "더 많은 페이지" }, + "tour": { + "title": "대시보드 투어", + "step1": { + "title": "Parsertime에 오신 것을 환영합니다!", + "description": "이 투어는 대시보드의 주요 기능을 안내합니다. 시작해 볼까요!" + }, + "step2": { + "title": "첫 단계", + "description": "모든 것은 여기서 시작됩니다! 스크림 생성 버튼을 눌러 첫 세션을 설정하세요. 걱정하지 마세요. 다음 단계에서 업로드 과정을 안내합니다!" + }, + "step3": { + "title": "스크림 이름 지정", + "description": "스크림 이름을 입력하세요. 'Team A vs Team B'처럼 정리하기 쉬운 이름을 사용하면 좋습니다." + }, + "step4": { + "title": "팀 지정", + "description": "스크림을 공유할 팀을 선택하세요. 아직 팀을 만들지 않았다면 팀 페이지에서 만들 수 있습니다." + }, + "step5": { + "title": "날짜 설정", + "description": "스크림이 진행된 날짜를 선택하세요. 스크림을 시간순으로 정리하는 데 도움이 됩니다." + }, + "step6": { + "title": "첫 맵 업로드", + "description": "Workshop Log(.xlsx 또는 .txt)를 여기에 업로드하세요. 데이터를 처리해 스크림을 생성합니다." + }, + "step7": { + "title": "영웅 밴(선택 사항)", + "description": "스크림에 영웅 밴이 있었다면 여기에 추가할 수 있습니다. 스크림을 검토할 때 맥락을 제공하는 데 도움이 됩니다." + }, + "step8": { + "title": "준비 완료!", + "description": "이제 스크림 데이터 분석을 시작할 준비가 되었습니다!" + } + }, "mainNav": { "dashboard": "대시보드", + "toggleMenu": "메뉴 전환", "stats": "통계", "teams": "팀", "settings": "설정", "contact": "문의하기", "docs": "문서", - "tournaments": "토너먼트" + "leaderboard": "리더보드", + "playerStats": "플레이어 통계", + "heroStats": "영웅 통계", + "mapStats": "맵 통계", + "compareStats": "플레이어 비교", + "teamStats": "팀 통계", + "scouting": "스카우팅", + "scoutPlayer": "플레이어 스카우트", + "scoutTeam": "팀 스카우트", + "scoutFaceitTeam": "FACEIT 팀 스카우트", + "scoutFaceitPlayer": "FACEIT 플레이어 스카우트", + "dataLabeling": "데이터 라벨링", + "dataTools": "데이터 도구", + "mapCalibration": "맵 보정", + "chat": "애널리스트", + "chatNew": "새 대화", + "chatReports": "보고서", + "tournaments": "토너먼트", + "query": "쿼리", + "coaching": "코칭", + "coachingCanvas": "캔버스", + "yourTeams": "내 팀", + "availability": "가능 시간", + "matchmaker": "매치메이커", + "ranked": "랭크 트래커" }, "teamSwitcher": { "searchTeamPlaceholder": "팀 검색...", "noTeamFound": "팀을 찾을 수 없습니다.", "individual": "개인", "teams": "팀", + "selectTeam": "팀 선택", + "loading": "불러오는 중...", "createTeam": "팀 생성" }, "createTeam": { @@ -489,9 +913,32 @@ "createdScrim": { "title": "스크림 생성 완료", "description": "스크림이 성공적으로 생성되었습니다.", - "errorTitle": "오류", + "primary": "완료", + "secondary": "하나 더 만들기", + "errorTitle": "스크림을 만들 수 없습니다", + "errorReassurance": "입력한 내용은 그대로 남아 있습니다.", + "errorFallback": "잘못된 로그 형식", + "errorEscape": "다시 시도해도 해결되지 않으면 아래 리소스가 도움이 될 수 있습니다.", + "errorResourceLabel": "도움이 필요하신가요?", + "errorResourceSchema": "스키마", + "errorResourceDebug": "디버그 팁", + "errorResourceDiscord": "Discord", + "errorRetry": "다시 시도", + "errorBack": "폼으로 돌아가기", "errorDescription": "오류가 발생했습니다: {res}. 여기에서 문서를 읽고 오류를 해결할 수 있는지 확인하세요." }, + "dataCorruption": { + "warning": { + "title": "데이터 손상 감지됨", + "baseDescription": "손상된 데이터가 감지되었습니다. 자동 수정을 시도합니다:", + "invalidMercyRez": "잘못된 mercy_rez 줄은 제거됩니다", + "asteriskValues": "별표 값은 0으로 대체됩니다" + }, + "success": { + "title": "스크림 생성 완료", + "description": "손상된 데이터가 자동으로 수정되었고 스크림이 성공적으로 생성되었습니다." + } + }, "scrimName": "스크림 이름", "scrimPlaceholder": "새 스크림", "scrimDescription": "스크림의 이름입니다. 대시보드에 표시됩니다.", @@ -514,21 +961,196 @@ "dateRequiredError": "스크림 날짜가 필요합니다.", "mapName": "첫 번째 맵", "mapDescription": "스크림의 첫 번째 맵을 업로드하세요. .xlsx 및 .txt 파일만 허용되며, 최대 파일 크기는 1MB입니다. 스크림 생성 후 더 많은 맵을 추가할 수 있습니다.", + "mapDropTitle": "Workshop 로그를 여기에 놓으세요", + "mapDropSubtitle": "또는 클릭하여 찾아보기", + "mapDropParsing": "스크림 데이터 파싱 중…", + "mapDropParsed": "파싱 완료", + "mapDropReplace": "교체", + "mapDropTeamSeparator": "vs", "submit": "제출", "submitting": "제출 중...", - "autoAssignTeamNames": "Auto-assign team names", - "autoAssignTeamNamesDescription": "Automatically rename teams and normalize team positions so your team is always Team 1.", - "autoAssignDisabledDescription": "Select a team to enable auto-assign." + "heroBansName": "영웅 밴", + "heroBansDescription": "각 팀이 밴한 영웅을 추가하세요. 밴 순서는 유지됩니다.", + "addHeroBan": "영웅 밴 추가", + "orderName": "순서", + "selectTeam": "팀 선택", + "team": "팀", + "hero": "영웅", + "selectHero": "영웅 선택", + "heroBanRequiredError": "밴할 영웅이 필요합니다.", + "banPositionRequiredError": "밴 순서가 필요합니다.", + "cancel": "취소", + "autoAssignTeamNames": "팀 이름 자동 할당", + "autoAssignTeamNamesDescription": "팀 이름을 자동으로 바꾸고 팀 위치를 정규화하여 내 팀이 항상 Team 1이 되도록 합니다.", + "autoAssignDisabledDescription": "자동 할당을 활성화하려면 팀을 선택하세요.", + "opponentName": "OWCS 상대", + "opponentDescription": "선택 사항입니다. OWCS 스카우팅 분석에 연결합니다.", + "linkedRequestLabel": "연결된 매치메이커 요청", + "linkedRequestNone": "매치메이커 요청에서 온 스크림이 아닙니다", + "linkedRequestDescription": "이 스크림이 매치메이커 요청에서 온 경우, 연결하면 스크림 후 피드백을 사용할 수 있습니다." + }, + "activity": { + "regionLabel": "최근 활동 개요", + "winRate": "승률", + "mapsLogged": "기록된 맵", + "teamTsr": "팀 TSR", + "bestMap": "최고 맵", + "scrimsLogged": "기록된 스크림", + "activeTeams": "활동 팀", + "latestScrim": "최근 스크림", + "record": "{wins}–{losses}", + "tsrRated": "{rated}/{total} 평가됨 · {confidence}", + "tsrNoData": "로스터 데이터 부족", + "bestMapSub": "승률 {winrate}", + "bestMapNone": "아직 맵 없음", + "never": "아직 스크림 없음", + "trendEyebrowTeam": "승률 · 주별", + "trendEyebrowAll": "기록된 스크림 · 주별", + "avgWinrate": "평균 {winrate}", + "totalScrims": "{weeks}주간 {count}개", + "noHistory": "차트를 그릴 기록이 부족합니다", + "winsLosses": "{wins}승 · {losses}패", + "scrimsCount": "{count, plural, other {스크림 #개}}", + "confidence": { + "high": "높은 신뢰도", + "medium": "중간 신뢰도", + "low": "낮은 신뢰도" + }, + "expand": "활동 개요 펼치기", + "collapse": "활동 개요 접기" } }, "updateModal": { "dismiss": "Dismiss" }, + "calendar": { + "monthYear": "{date, date, ::yyyy년 MMMM}", + "weekday": "{weekday, date, ::E}" + }, + "availability": { + "timezoneSelect": { + "searchPlaceholder": "시간대 검색...", + "empty": "시간대를 찾을 수 없습니다.", + "detected": "감지됨", + "teamDefault": "팀 기본값", + "regional": "지역", + "allTimezones": "모든 시간대" + }, + "indexPage": { + "title": "{teamName} — 가능 시간", + "settings": "설정", + "notConfiguredManager": "아직 이 팀의 가능 시간이 설정되지 않았습니다. 설정하기", + "notConfiguredViewer": "아직 이 팀의 가능 시간이 설정되지 않았습니다. 소유자나 매니저에게 설정을 요청하세요.", + "currentWeek": "이번 주: {start} – {end}", + "responsesSoFar": "현재 {count}개의 응답이 있습니다.", + "openShareLink": "공유 링크 열기", + "pastWeeks": "지난 주", + "pastWeekRange": "{start} – {end}", + "pastWeekResponses": "({count}개 응답)", + "view": "보기", + "startThisWeek": "이번 주 시작" + }, + "settingsPage": { + "title": "가능 시간 설정", + "description": "팀의 공유 가능 시간 캘린더가 작동하는 방식을 설정하세요.", + "metadata": { + "title": "가능 시간 설정 | Parsertime", + "description": "팀의 가능 시간 일정과 시간대를 설정하세요." + } + }, + "settingsForm": { + "slotGranularity": "슬롯 간격", + "slotMinutes": "{count}분", + "defaultTimezone": "기본 시간대(보기용)", + "startHour": "시작 시간(0-23)", + "endHour": "종료 시간(1-24)", + "midnightHint": "24 = 자정", + "weeklyReminder": "주간 Discord 리마인더", + "weeklyReminderDescription": "봇이 매주 시작될 때 작성 링크와 함께 역할을 멘션합니다.", + "day": "요일", + "hour": "시", + "minute": "분", + "roleId": "멘션할 Discord 역할 ID", + "guildId": "서버 ID(재정의)", + "channelId": "채널 ID(재정의)", + "botConfigPlaceholder": "비워두면 봇 설정 사용", + "saving": "저장 중...", + "save": "설정 저장", + "days": { + "sunday": "일요일", + "monday": "월요일", + "tuesday": "화요일", + "wednesday": "수요일", + "thursday": "목요일", + "friday": "금요일", + "saturday": "토요일" + }, + "toast": { + "saveFailed": "설정을 저장하지 못했습니다", + "saved": "설정이 저장되었습니다" + }, + "validation": { + "endAfterStart": "종료 시간은 시작 시간보다 뒤여야 합니다", + "evenSlots": "시간 범위는 슬롯 분 단위로 나누어떨어져야 합니다" + } + }, + "fillView": { + "title": "{teamName} 가용 시간", + "weekIntro": "{date} 주 - 가능한 시간을 선택하세요.", + "name": "이름", + "namePlaceholder": "이름", + "password": "비밀번호 ({required, select, true {필수} other {선택}})", + "passwordRequiredPlaceholder": "비밀번호 입력", + "passwordOptionalPlaceholder": "비밀번호 설정", + "viewingTimezone": "보기 시간대", + "yourAvailability": "내 가용 시간", + "saving": "저장 중...", + "save": "내 가용 시간 저장", + "clear": "지우기", + "timezoneMismatch": " 팀 캘린더는 {teamTz} 시간대로 설정되어 있지만, 현재 위치는 {localTz}입니다. 현지 시간으로 슬롯을 선택하면 팀 시간대로 자동 변환됩니다.", + "localTimezone": "현지 시간으로 선택 중: {timezone}", + "groupAvailability": "그룹 가용 시간", + "responseCount": " ({count, plural, one {응답 #개} other {응답 #개}})", + "hoverPrompt": "셀 위에 마우스를 올리면 가능한 사람을 볼 수 있습니다", + "availabilityRatio": "{available} / {total}", + "namesSuffix": " - {names}", + "unavailable": "불가능: {names}", + "toast": { + "nameRequired": "이름을 입력하세요", + "passwordRequired": "이 이름을 편집하려면 비밀번호가 필요합니다", + "incorrectPassword": "비밀번호가 올바르지 않습니다", + "saveFailed": "가용 시간을 저장하지 못했습니다", + "saved": "가용 시간이 저장되었습니다" + }, + "promo": { + "title": "오버워치 스크림 분석과 팀 도구", + "description": "Parsertime은 Workshop Log 원본 데이터를 스킬 레이팅, 추세선, 코칭 인사이트로 바꾸고 지금 사용하는 가용 시간 캘린더 같은 팀 관리 도구도 제공합니다.", + "bullets": { + "instantReview": "즉시 리뷰. 스크림을 업로드하면 선수별 통계, 맵, 한타를 몇 분 안에 확인할 수 있습니다. 스프레드시트는 필요 없습니다.", + "csrRatings": "CSR 레이팅. 역할별 지표를 바탕으로 한 객관적인 1-5000 영웅 스킬 레이팅입니다.", + "trends": "추세. 단일 경기만이 아니라 여러 주와 시즌에 걸친 선수 성장을 확인하세요.", + "coachingCanvas": "코칭 캔버스. 공유 화이트보드에 전략을 그리고 해당 스크림과 함께 보관하세요.", + "teamAvailability": "팀 가용 시간. 이 캘린더와 매주 시작할 때 역할을 멘션하는 Discord 리마인더를 제공합니다.", + "freeToStart": "무료 시작. 무료 티어에서 팀 2개와 멤버 5명까지 사용할 수 있으며 신용카드는 필요 없습니다." + }, + "signIn": "로그인해서 팀 스크림 보기", + "learnMore": "Parsertime 더 알아보기" + }, + "metadata": { + "title": "가용 시간 입력 | Parsertime", + "description": "스크림이 가능한 시간을 표시하세요." + } + }, + "metadata": { + "title": "가능 시간 | Parsertime", + "description": "팀원들이 언제 가능한지 확인하고 모두의 일정에 맞춰 스크림을 계획하세요." + } + }, "scrimPage": { "metadata": { "scrim": "스크림", "title": "{scrimName} 개요 | Parsertime", - "description": "Parsertime에서 {scrimName}의 개요입니다. Parsertime은 오버워치 스크림을 분석하는 도구입니다.", + "description": "{scrimName} 개요 — 이 오버워치 스크림의 맵 결과, 플레이어 통계, 교전 분석입니다.", "ogTitle": "{scrimName} 개요 | Parsertime", "ogDescription": "Parsertime에서 {scrimName}의 개요입니다. Parsertime은 오버워치 스크림을 분석하는 도구입니다.", "ogImage": "{scrimName} 개요" @@ -536,6 +1158,10 @@ "back": "대시보드로 돌아가기", "newScrim": "새 스크림", "edit": "스크림 편집", + "meta": { + "mapCount": "{count, plural, one {맵 #개} other {맵 #개}}", + "opp": "상대" + }, "maps": { "title": "맵", "altText": "{map}의 로딩 화면 아트." @@ -561,6 +1187,28 @@ "createDescription": "맵이 성공적으로 생성되었습니다.", "errorTitle": "오류", "errorDescription": "오류가 발생했습니다: {res}. 여기에서 문서를 읽고 오류를 해결할 수 있는지 확인하세요." + }, + "dataCorruption": { + "warning": { + "title": "데이터 손상 감지됨", + "baseDescription": "손상된 데이터가 감지되었습니다. 자동 수정을 시도합니다:", + "invalidMercyRez": "잘못된 mercy_rez 줄은 제거됩니다", + "asteriskValues": "별표 값은 0으로 대체됩니다" + }, + "success": { + "title": "맵 추가 완료", + "description": "손상된 데이터가 자동으로 수정되었고 맵이 성공적으로 추가되었습니다." + } + }, + "submitting": "제출 중", + "submit": "제출", + "cancel": "취소", + "addHeroBan": "영웅 밴 추가", + "heroBansDescription": "이 맵의 영웅 밴을 설정하세요. 밴을 추가하지 않고 제출을 클릭하면 이 단계를 건너뛸 수 있습니다.", + "heroBansTitle": "영웅 밴 추가 (선택 사항)", + "heroBans": { + "reorder": "순서 변경", + "removeBan": "밴 제거" } }, "editScrim": { @@ -595,10 +1243,15 @@ "title": "게스트 모드 활성화", "description": "활성화하면 스크림에 로그인한 모든 사용자가 접근할 수 있습니다. 비활성화하면 팀 멤버만 스크림에 접근할 수 있습니다." }, + "opponent": { + "title": "상대 (OWCS)", + "description": "이 스크림을 OWCS 상대와 연결하여 교차 참조 스카우팅 분석을 활성화합니다." + }, "maps": { "title": "맵", "replayCodeLabel": "{map}의 리플레이 코드", "replayCode": "리플레이 코드", + "heroBans": "영웅 밴", "delete": "맵 삭제", "deleteDialog": { "title": "정말 확실합니까?", @@ -646,6 +1299,870 @@ "onClick": { "title": "클립보드에 복사되었습니다!", "description": "리플레이 코드가 클립보드에 복사되었습니다." + }, + "code": "리플레이 코드: {replayCode}" + }, + "overviewTabs": { + "team": { + "yourTeam": "내 팀", + "opponent": "상대" + }, + "tabs": { + "visualizations": "시각화", + "rawStats": "원시 통계" + }, + "actions": { + "collapseAll": "모두 접기", + "expandAll": "모두 펼치기", + "collapseOverview": "개요 접기", + "expandOverview": "개요 펼치기" + }, + "sections": { + "players": "플레이어 성과", + "fights": "교전", + "abilities": "능력 타이밍", + "ultimates": "궁극기", + "ultAdvantage": "궁극기 우위", + "swaps": "영웅 교체", + "positional": "포지셔닝", + "initiation": "한타 개시" + } + }, + "overviewSections": { + "fights": { + "noData": "이 스크림에는 교전 데이터가 없습니다.", + "summary": "교전 {total}회 중 {winrate}% 승리 ({won}승 - {lost}패)", + "firstUlt": "궁극기를 먼저 사용한 교전 {count}회 ({winrate}% 승률).", + "opponentFirstUlt": "상대가 궁극기를 먼저 사용한 교전 {count}회 ({winrate}% 승률).", + "labels": { + "fightsWon": "승리한 교전", + "firstPickRate": "첫 처치 비율", + "firstDeathRate": "첫 사망 비율" + }, + "headings": { + "conditionalWinRates": "조건별 승률" + }, + "units": { + "fights": "교전", + "firstDeaths": "첫 사망" + }, + "chart": { + "overall": "전체", + "firstPick": "첫 처치", + "diedFirst": "먼저 사망", + "usedUltFirst": "궁극기 선사용", + "opponentUltFirst": "상대 궁극기 선사용", + "winRate": "승률", + "tooltipWinRate": "승률 {winrate}" + }, + "abilityTiming": { + "noData": "이 스크림에서 영향도가 큰 능력이 감지되지 않았습니다.", + "phase": { + "preFight": "교전 전", + "early": "초반", + "mid": "중반", + "late": "후반", + "cleanup": "정리" + }, + "negativeOutlier": "{abilityName}을(를) {phase}에 사용하면 승률이 {phaseWinrate}이며, {bestPhase}에 사용할 때의 {bestPhaseWinrate}보다 낮습니다.", + "positiveOutlier": "{abilityName}을(를) {phase}에 사용하면 {phaseWinrate} 승률과 연관됩니다. 강한 {pattern} 패턴입니다.", + "patternInitiation": "개시", + "patternTiming": "타이밍", + "cellTitle": "승률 {winrate} ({fights, plural, other {교전 #회}} 중 {wins, number}승 {losses, number}패)", + "fewerThanFights": "교전 {count, plural, other {#회}} 미만", + "legendWinRate": "승률:", + "legendBadTiming": "나쁜 타이밍", + "legendGoodTiming": "좋은 타이밍", + "hoverDetail": "{abilityName} {phase} 사용: 승률 {winrate}, {fights, plural, other {교전 #회}}에서 {wins, number}승 {losses, number}패", + "info": "셀은 해당 교전 단계에서 능력을 사용했을 때의 승률을 보여줍니다. “—”는 교전 {count, plural, other {#회}} 미만을 의미합니다. 자세히 보려면 마우스를 올리세요." + } + }, + "ultimates": { + "noData": "이 스크림에는 궁극기 데이터가 없습니다.", + "efficiency": "궁극기 효율: {value} 궁극기당 승리 교전 수 ({rating})", + "ratings": { + "excellent": "매우 좋음", + "good": "좋음", + "average": "보통", + "poor": "낮음" + }, + "labels": { + "totalUltimatesUsed": "총 궁극기 사용", + "roleUltimates": "{role} 궁극기" + }, + "headings": { + "usageBySubrole": "하위 역할별 궁극기 사용", + "timingBreakdown": "궁극기 타이밍 분석" + }, + "units": { + "ults": "궁" + }, + "chart": { + "noPlayer": "선수 없음", + "ultCount": "{count, number}회" + } + }, + "swaps": { + "noData": "이 스크림에는 영웅 교체 데이터가 없습니다.", + "summary": "전체 맵에서 {swaps}회 교체 ({perMap}/맵), 상대는 {opponentSwaps}회", + "topSwap": "최다 교체: {fromHero} -> {toHero} ({count}회)", + "topSwapper": "가장 활발한 교체자: {playerName} ({swaps}회 교체, {maps}맵)", + "labels": { + "totalHeroSwaps": "총 영웅 교체", + "swapVsNoSwapWinRate": "교체 vs 비교체 승률", + "withSwaps": "교체 있음", + "withoutSwaps": "교체 없음" + }, + "headings": { + "winRateBySwapCountTiming": "교체 횟수 및 타이밍별 승률" + }, + "units": { + "swaps": "교체" + }, + "chart": { + "tooltipWinRate": "승률 {winrate}", + "detail": "{wins, number}승–{losses, number}패 ({maps, plural, other {#맵}})", + "winRate": "승률", + "buckets": { + "zeroSwaps": "교체 0회", + "oneSwap": "교체 1회", + "twoSwaps": "교체 2회", + "threePlusSwaps": "교체 3회 이상", + "early": "초반 (0-33%)", + "mid": "중반 (33-66%)", + "late": "후반 (66-100%)" + } + } + } + }, + "rawStatsSections": { + "fights": { + "label": "전투 분석", + "title": "전투 분석", + "overallWinRate": "전체 전투 승률: {winrate} ({total, plural, other {전투 #회}} 중 {won, number}승 / {lost, number}패)", + "teamFirstDeath": "우리 팀이 전투에서 먼저 사망한 비율은 {rateValue}입니다.", + "teamFirstDeathComparison": "먼저 사망했을 때도 해당 전투의 {firstDeathWinrate}를 이겼고, 첫 처치를 냈을 때는 {firstPickWinrate}를 이겼습니다.", + "firstPick": "우리 팀은 전투의 {firstPickRate}에서 첫 처치를 냈고, 그중 {firstPickWinrate}를 이겼습니다.", + "firstUlt": "우리 팀이 궁극기를 먼저 사용한 전투에서 {winrate}를 이겼습니다.", + "opponentFirstUlt": "상대가 궁극기를 먼저 사용했을 때 우리 팀 승률은 {hasFirstUlt, select, yes {하락해 } other {}}{winrate}였습니다." + }, + "ultimates": { + "label": "궁극기 분석", + "title": "궁극기 분석", + "ourTopFightInitiator": "가장 자주 전투를 연 궁극기: {hero} ({count}{count, plural, other {회 전투}}).", + "opponentTopFightInitiator": "상대가 가장 자주 전투를 연 궁극기: {hero} ({count}{count, plural, other {회 전투}}).", + "goodDiscipline": "좋은 궁극기 관리 — 패배한 전투보다 승리한 전투에서 궁극기를 더 많이 사용했습니다.", + "roomForImprovement": "개선 여지 — 승리한 전투보다 패배한 전투에서 궁극기를 더 많이 사용했습니다.", + "ultUsage": "우리 팀은 궁극기를 {ourCount}{ourCount, plural, other {회}} 사용했고, 상대는 {opponentCount}회 사용했습니다.", + "roleUltUsage": "{role} 궁극기: 우리 팀 {ourCount}회, 상대 {opponentCount}회.", + "roleFirstUlt": "{role} 궁극기를 전투의 {rateValue}에서 먼저 사용했습니다.", + "subroleTiming": "{subrole}: {count}{count, plural, other {회 궁극기}} ({pct}%) — 개시 {initiation}회, 중반 {midfight}회, 후반 {late}회", + "fightInitiations": "궁극기로 전투를 연 비율은 {rateValue}입니다 ({initiations} / {total}).", + "topUltUser": "최다 궁극기 사용자: {playerName} ({hero}), {count}{count, plural, other {회}} 사용.", + "avgChargeAndHold": "우리 팀은 평균 {chargeTime}초에 궁극기를 충전했고, 사용 전 평균 {holdTime}초 동안 보유했습니다.", + "avgCharge": "우리 팀은 평균 {chargeTime}초에 궁극기를 충전했습니다.", + "avgHold": "우리 팀은 궁극기를 사용하기 전 평균 {holdTime}초 동안 보유했습니다.", + "ultimateEfficiency": "궁극기 효율: 궁극기 1회 사용당 승리 전투 {value}회 ({rating}).", + "averageUltsPerFight": "승리한 전투에서는 평균 {won}회, 패배한 전투에서는 평균 {lost}회 궁극기를 사용했습니다. {discipline}", + "wastedUltimates": "{count}{count, plural, other {회}}의 궁극기를 낭비했습니다 (전체의 {percent}%). 이는 불리한 상황(3명 이상 열세)에서 사용되었습니다.", + "dryNonDryFights": "궁극기를 쓰지 않은 드라이 전투 {dryCount}{dryCount, plural, other {회}}의 승률은 {dryRate}%였고, 궁극기를 사용한 전투 {nonDryCount}{nonDryCount, plural, other {회}}의 승률은 {nonDryRate}%였습니다.", + "fightReversal": "역전 승률(2킬 이상 뒤진 뒤 승리): 드라이 전투 {dryRate}%, 궁극기 사용 전투 {nonDryRate}%.{insight, select, comeback { 궁극기는 이 팀의 핵심 역전 도구입니다.} mechanics { 이 팀은 순수 교전력으로도 전투를 뒤집을 수 있습니다.} other {}}" + }, + "swaps": { + "label": "영웅 교체 분석", + "title": "영웅 교체 분석", + "ourTopSwap": "가장 흔한 교체: {fromHero}{toHero} ({count}{count, plural, other {회}}).", + "opponentTopSwap": "상대의 가장 흔한 교체: {fromHero}{toHero} ({count}{count, plural, other {회}}).", + "summary": "우리 팀은 전체 맵에서 영웅 교체를 {ourSwaps}{ourSwaps, plural, other {회}} 했고 (맵당 {ourPerMap}회), 상대는 {opponentSwaps}회 (맵당 {opponentPerMap}회) 했습니다.", + "winrateComparison": "교체한 맵 승률: {swapWinrate} ({swapWins, number}승 / {swapLosses, number}패), 교체하지 않은 맵: {noSwapWinrate} ({noSwapWins, number}승 / {noSwapLosses, number}패).", + "winrateDelta": "교체 시 {delta}% 차이입니다.", + "avgHeroTimeBeforeSwap": "교체 전 한 영웅을 플레이한 평균 시간: {seconds}초.", + "topSwapper": "가장 활발한 교체자: {playerName}, {maps}{maps, plural, other {개 맵}}에서 {swaps}{swaps, plural, other {회 교체}}.", + "winrateBySwapCount": "교체 횟수별 승률:", + "swapCountBucket": "{label}: {winrate} ({wins, number}승-{losses, number}패)", + "winrateBySwapTiming": "교체 타이밍별 승률:", + "swapTimingBucket": "{label}: {winrate} ({maps, plural, other {#개 맵}})" + } + }, + "overviewSummary": { + "ariaLabel": "스크림 개요", + "recordAria": "전적: {wins}승, {losses}패, {draws}무", + "winRate": "{winRate}% 승률", + "keyInsights": "주요 인사이트", + "record": { + "wins": "{count}승", + "losses": "{count}패", + "draws": "{count}무" + }, + "stats": { + "maps": "맵", + "teamKd": "팀 K/D", + "totalDamage": "총 피해", + "totalHealing": "총 치유", + "record": "{wins}승 · {losses}패", + "elims": "{count} 처치", + "heroDamage": "영웅 피해", + "healingDealt": "치유량" + } + }, + "playerPerformance": { + "columns": { + "player": "플레이어", + "maps": "맵", + "kd": "K/D", + "elimsPer10": "처치/10", + "damagePer10": "피해/10", + "firstDeath": "첫 사망", + "teamFirstDeath": "팀 첫 사망", + "trend": "추세", + "outliers": "이상치" + }, + "hover": { + "showTrendChart": "{playerName} 성과 추세 차트 보기", + "description": "맵별 성과(평균 대비 %) · 지표를 클릭해 집중 보기", + "unavailable": "이 선수의 성과 차트를 표시할 수 없습니다.", + "metrics": { + "kd": "K/D", + "elimsPer10": "처치/10", + "damagePer10": "피해/10", + "healingPer10": "치유/10", + "firstDeathRate": "첫 사망 %", + "teamFirstDeathRate": "팀 첫 사망 %" + } + }, + "trend": { + "improving": "개선 중", + "declining": "하락 중", + "stable": "안정적", + "improvingAria": "개선 중: {count}개 지표 상승", + "decliningAria": "하락 중: {count}개 지표 하락", + "stableAria": "안정적인 성과" + }, + "outlier": { + "elite": "상위권", + "belowAverage": "평균 이하", + "aria": "{label}: 데이터베이스 {percentile}번째 백분위 ({status})", + "tooltip": "{label}: 데이터베이스 {percentile}번째 백분위 ({status})" + }, + "fallback": { + "unknownPlayer": "알 수 없는 플레이어", + "unknownHero": "알 수 없는 영웅" + } + }, + "addToMapGroupDialog": { + "title": { + "add": "맵 그룹에 추가", + "create": "새 맵 그룹 만들기" + }, + "description": { + "add": "{mapName}을 추가할 기존 맵 그룹을 선택하거나 새로 만드세요.", + "create": "새 맵 그룹을 만들고 {mapName}을 추가하세요." + }, + "selectGroup": "맵 그룹 선택", + "mapCount": "{count}개 맵", + "empty": { + "title": "맵 그룹을 찾을 수 없습니다.", + "description": "아래에서 첫 맵 그룹을 만드세요." + }, + "createNew": "새 맵 그룹 만들기", + "backToSelection": "선택으로 돌아가기", + "cancel": "취소", + "addToGroup": "그룹에 추가", + "createAndAdd": "만들고 추가", + "form": { + "name": "이름", + "namePlaceholder": "예: 컨트롤 맵", + "description": "설명", + "descriptionPlaceholder": "이 그룹에 대한 선택 설명", + "category": "카테고리", + "categoryPlaceholder": "예: 맵 유형, 플레이스타일" + }, + "toast": { + "unknownError": "알 수 없는 오류", + "fetchFailed": "맵 그룹을 가져오지 못했습니다", + "nameRequired": "맵 그룹 이름을 입력하세요", + "added": { + "title": "맵 그룹에 추가됨", + "description": "{mapName}이(가) {groupName}에 추가되었습니다" + }, + "addFailed": { + "title": "맵 그룹에 추가하지 못했습니다" + }, + "created": { + "title": "맵 그룹 생성됨", + "description": "\"{groupName}\"을(를) 만들고 {mapName}을(를) 추가했습니다" + }, + "createFailed": { + "title": "맵 그룹을 만들지 못했습니다" + } + } + }, + "mapCard": { + "ariaLabel": "{map} 맵 카드", + "ariaLabelSelected": "{map} 맵 카드, 비교 대상으로 선택됨", + "altText": "{map} 로딩 화면 아트.", + "selected": "선택됨", + "copiedCode": "리플레이 코드를 복사했습니다!", + "won": "승리", + "lost": "패배", + "auto": "자동", + "autoTooltip": "위치 기반 자동 감지", + "editWinner": "{map} 승자 편집", + "contextMenu": { + "title": "맵 작업", + "selectForComparison": "비교 대상으로 선택", + "addToMapGroup": "맵 그룹에 추가", + "setWinner": "승자 설정", + "viewDetails": "맵 상세 보기", + "copyCode": "리플레이 코드 복사" + } + }, + "mapWinnerDialog": { + "title": "맵 승자 설정", + "description": "{mapName}의 승리 팀을 선택하세요. 자동으로 감지된 결과를 덮어씁니다.", + "cancel": "취소", + "save": "승자 저장", + "success": "승자가 업데이트되었습니다", + "error": "승자 설정에 실패했습니다" + }, + "compareButton": { + "selected": "{count, plural, =1 {맵 1개 선택됨} other {맵 #개 선택됨}}", + "selectedMapName": "{count, plural, =1 {선택한 맵 1개} other {선택한 맵 #개}}", + "fromScrims": "{count, plural, =1 {스크림 1개에서} other {스크림 #개에서}}", + "compare": "선택한 맵 비교", + "compareNow": "지금 비교", + "addToMapGroup": "맵 그룹에 추가", + "clear": "초기화" + }, + "viewStats": "팀 통계 보기", + "overviewUnavailable": { + "title": "개요를 아직 사용할 수 없습니다", + "description": "스크림 하나만으로는 어느 로스터가 우리 팀인지 식별할 수 없습니다. 스크림을 하나 더 업로드한 뒤 이 페이지로 돌아오면 승패와 팀 인사이트를 확인할 수 있습니다." + }, + "positional": { + "title": "위치 통계", + "description": "위치 데이터가 있는 이 스크림의 맵 전체 평균입니다.", + "matrix": { + "below": "중앙값 미만", + "above": "중앙값 초과", + "legend": "색조는 각 값이 해당 스탯의 팀 중앙값에서 벗어난 정도를 나타냅니다." + }, + "byZoneTitle": "구역별 승 / 패 / 무", + "playerColumn": "선수", + "stats": { + "AVERAGE_ENGAGEMENT_DISTANCE": { + "short": "교전 거리", + "full": "평균 교전 거리" + }, + "HIGH_GROUND_KILL_PERCENTAGE": { + "short": "고지대 킬 %", + "full": "고지대 킬 %" + }, + "ISOLATION_DEATH_PERCENTAGE": { + "short": "고립 데스 %", + "full": "고립 데스 %" + }, + "AVERAGE_FIGHT_START_SPREAD": { + "short": "시작 간격", + "full": "한타 시작 간격" + }, + "AVERAGE_ULT_CONVERSION_KILLS": { + "short": "전환 킬", + "full": "궁극기 전환 킬" + }, + "ULT_DEATH_PERCENTAGE": { + "short": "궁중 데스 %", + "full": "궁극기 사용 중 데스 %" + }, + "AVERAGE_ULT_DISPLACEMENT": { + "short": "이동 거리", + "full": "평균 궁극기 이동 거리" + }, + "ULTS_ON_OBJECTIVE_PERCENTAGE": { + "short": "거점 궁 %", + "full": "거점 위 궁극기 %" + } + }, + "engagementsTitle": "교전", + "winrate": "승률", + "fights": "{total, plural, other {교전 #회}}", + "recordSummary": "{won}승 / {lost}패 / 무승부 {even}", + "wonLabel": "승리", + "lostLabel": "패배", + "evenLabel": "무승부", + "zonesTitle": "맵별 구역 장악", + "zonesYourTeam": "우리 팀", + "zonesOpponents": "상대 팀", + "zonesCount": "구역 {count}개", + "zonesControlPct": "{pct}% 장악", + "routesTitle": "맵별 이동 경로", + "routesSummary": "경로 {total}개 · {won}승 / {lost}패", + "routesUndecided": "미정", + "zoneColumn": "구역", + "teamColumn": "팀", + "killsColumn": "킬", + "deathsColumn": "데스", + "ultsColumn": "궁극기" + }, + "editMetadata": { + "title": "{scrimName} 편집 | Parsertime", + "description": "{scrimName}의 세부 정보와 설정을 편집하세요." + }, + "wpa": { + "title": "승리 확률 기여도", + "description": "이 스크림의 전체 맵에 대해 합산한 플레이어별 WPA입니다.", + "columns": { + "player": "플레이어", + "team": "팀", + "maps": "맵", + "wpa": "WPA" + } + }, + "initiationSection": { + "coverage": "상세 로그가 있는 {total}개 맵 중 {covered}개에서 감지됨.", + "noData": "개시 데이터 없음 — 상세 로그 이전의 맵입니다.", + "wentFirst": "{team} — 선공 승률", + "frequency": "{first}회 개시", + "record": "{wins}승 - {losses}패" + } + }, + "mapGroupsPage": { + "metadata": { + "title": "맵 그룹 | Parsertime", + "description": "분석을 위한 사용자 지정 맵 그룹을 만들고 관리하세요" + } + }, + "tendenciesPage": { + "fightMap": { + "description": "최근 스크림 전체에서 팀이 한타를 이기고 지는 위치입니다. 초록색은 주로 이기는 지역, 빨간색은 고전하는 지역입니다.", + "fights": "결정적 한타 {count}회", + "overall": "한타 승률", + "unnamedArea": "이름 없는 지역", + "notEnoughFights": "아직 한타 데이터가 부족합니다 — 위치 데이터가 있는 결정적 한타가 {count}회 이상 필요합니다.", + "noCalibration": "이 맵의 보정된 이미지가 없습니다. 맵 보정 도구에서 보정하면 한타 맵이 활성화됩니다.", + "zone": "구역", + "record": "전적", + "winrate": "승률", + "sectionEyebrow": "한타 맵", + "sectionTitle": "이기는 곳과 고전하는 곳", + "summaryFights": "결정적 한타", + "summaryFightsSub": "승패 기준", + "summaryMaps": "맵", + "summaryMapsSub": "한타 데이터 보유", + "summaryStrongest": "강한 맵", + "summaryWeakest": "약한 맵", + "summaryNoRanking": "한타 부족" + }, + "metadata": { + "title": "경향 | Parsertime", + "description": "최근 스크림을 종합해 맵별로 팀이 한타를 이기고 지는 위치를 보여줍니다" + }, + "title": "경향", + "description": "맵마다 위치별 한타 승률을 보여줍니다 — 초록색은 이기는 곳, 빨간색은 고전하는 곳입니다.", + "empty": "아직 위치 데이터가 없습니다.", + "totalRoutes": "전체 경로", + "route": "경로 {n}", + "record": "전적", + "winrate": "승률", + "wonShort": "승", + "lostShort": "패", + "clusterRoutes": "{count}개 경로", + "oneOffs": "한 번만 나타난 경로 {count}개는 표시하지 않음", + "share": "비율", + "routeColumn": "경로", + "outcomes": "승 / 패 / 미상", + "won": "승", + "lost": "패", + "unknown": "미상" + }, + "comparePage": { + "metadata": { + "title": "맵 비교 | Parsertime", + "description": "여러 맵에 걸친 플레이어 성능 비교" + }, + "title": "맵 비교", + "subtitle": "선택한 맵에서 플레이어 성능을 분석하세요", + "comparingMaps": "{count, plural, =1 {맵 1개 비교 중} other {맵 #개 비교 중}}", + "loading": "비교 데이터 로드 중...", + "emptyStates": { + "noMaps": { + "title": "선택된 맵 없음", + "description": "스크림 페이지에서 맵을 선택해 플레이어 성능 비교를 시작하세요." + }, + "noMapGroups": { + "title": "선택된 맵 그룹 없음", + "description": "아래에서 하나 이상의 맵 그룹을 선택해 플레이어 성능 비교를 시작하세요." + }, + "noPlayer": { + "title": "선택된 플레이어 없음", + "description": "맵 간 성능 비교를 시작하려면 플레이어를 선택하세요." + }, + "noData": { + "title": "사용 가능한 데이터 없음", + "description": "{player}은(는) 선택한 맵에 참여하지 않았습니다." + }, + "noTeamData": { + "description": "선택한 맵에 대한 팀 데이터가 없습니다." + } + }, + "detailedStats": { + "title": "상세 통계", + "subtitle": "공정한 비교를 위해 모든 지표를 10분당 값 또는 비율로 정규화했습니다", + "exportCsv": "CSV 내보내기", + "statName": "통계", + "mapCount": "{count}개 맵", + "priority": "우선 지표", + "note": "참고: 모든 통계는 서로 다른 플레이 시간을 공정하게 비교할 수 있도록 정규화되었습니다(10분당, 비율 또는 평균).", + "categories": { + "all": "전체 통계", + "combat": "전투", + "damage": "피해", + "support": "지원", + "defense": "방어", + "assists": "어시스트", + "ultimate": "궁극기", + "impact": "영향", + "consistency": "일관성" + }, + "stats": { + "eliminationsPer10": "10분당 처치", + "finalBlowsPer10": "10분당 결정타", + "soloKillsPer10": "10분당 솔로 킬", + "deathsPer10": "10분당 사망", + "kdRatio": "K/D 비율", + "allDamagePer10": "10분당 총 피해", + "heroDamagePer10": "10분당 영웅 피해", + "barrierDamagePer10": "10분당 방벽 피해", + "healingDealtPer10": "10분당 치유", + "healingReceivedPer10": "10분당 받은 치유", + "selfHealingPer10": "10분당 자가 치유", + "damageTakenPer10": "10분당 받은 피해", + "damageBlockedPer10": "10분당 막은 피해", + "offensiveAssistsPer10": "10분당 공격 어시스트", + "defensiveAssistsPer10": "10분당 방어 어시스트", + "ultimatesEarnedPer10": "10분당 획득 궁극기", + "ultimatesUsedPer10": "10분당 사용 궁극기", + "averageUltChargeTime": "평균 궁극기 충전 시간", + "averageTimeToUseUlt": "평균 궁극기 사용까지 시간", + "killsPerUltimate": "궁극기당 처치", + "averageDroughtTime": "평균 처치 공백 시간", + "firstPickPercentage": "첫 처치 %", + "firstPicksPer10": "10분당 첫 처치", + "firstDeathPercentage": "첫 사망 %", + "firstDeathsPer10": "10분당 첫 사망", + "fletaDeadliftPercentage": "플레타 데드리프트 %", + "mvpScore": "MVP 점수", + "mapMvpRate": "맵 MVP 비율", + "duelWinratePercentage": "대결 승률 %", + "fightReversalPercentage": "교전 역전률 %", + "eliminationsPer10StdDev": "처치 표준편차", + "deathsPer10StdDev": "사망 표준편차", + "allDamagePer10StdDev": "피해 표준편차", + "consistencyScore": "일관성 점수" + } + }, + "impactMetrics": { + "subtitle": "{count, plural, =1 {맵 1개에서 분석됨} other {맵 #개에서 분석됨}}", + "consistency": "일관성", + "keyStrengths": "주요 강점", + "developmentAreas": "고려할 영역", + "detailedMetrics": "상세 영향 지표", + "stats": { + "mvpScore": "MVP 점수", + "firstPickRate": "첫 처치 비율", + "killsPerUlt": "궁극기당 처치", + "firstPicks": "첫 처치", + "firstDeaths": "첫 사망", + "duelWinrate": "대결 승률", + "fightReversal": "교전 역전", + "fletaDeadlift": "팀 영향", + "mapMvpRate": "맵 MVP 비율" + } + }, + "views": { + "sideBySide": "나란히 보기", + "delta": "변화량", + "trends": "추세", + "charts": "차트", + "consistency": "일관성", + "detailedStats": "상세 통계", + "impactMetrics": "영향 지표" + }, + "filters": { + "title": "비교 필터", + "resetAll": "모두 초기화", + "playerLabel": "플레이어", + "heroesLabel": "영웅", + "player": "플레이어", + "heroesCount": "{count, plural, =1 {영웅 1명} other {영웅 #명}}" + }, + "playerSelector": { + "placeholder": "플레이어 선택...", + "search": "플레이어 검색...", + "noPlayerFound": "플레이어를 찾을 수 없습니다.", + "loading": "로딩 중...", + "mapCount": "{count, plural, =1 {맵 1개} other {맵 #개}}" + }, + "sideBySide": { + "requiresTwoMaps": "나란히 비교하려면 정확히 2개의 맵이 필요합니다.", + "statComparison": "통계 비교", + "stats": { + "eliminations": "처치", + "deaths": "사망", + "damage": "피해", + "healing": "치유", + "mitigated": "막은 피해", + "eliminationsPer10": "10분당 처치", + "deathsPer10": "10분당 사망", + "damagePer10": "10분당 피해", + "healingPer10": "10분당 치유", + "mitigatedPer10": "10분당 경감" + } + }, + "delta": { + "requiresTwoMaps": "변화량 보기는 정확히 2개의 맵이 필요합니다.", + "from": "이전", + "to": "이후", + "previous": "이전", + "significant": "유의미", + "summary": "유의미한 변화 요약", + "noSignificantChanges": "유의미한 변화가 감지되지 않았습니다(임계값: ±10%)", + "stats": { + "eliminations": "처치", + "deaths": "사망", + "damage": "피해", + "healing": "치유", + "mitigated": "막은 피해", + "eliminationsPer10": "10분당 처치", + "deathsPer10": "10분당 사망", + "damagePer10": "10분당 피해", + "healingPer10": "10분당 치유", + "mitigatedPer10": "10분당 경감" + } + }, + "consistency": { + "title": "성능 일관성", + "subtitle": "서로 다른 맵과 상황에서 성능이 얼마나 안정적인지 측정합니다. 변동이 낮을수록 더 일관적이고 신뢰할 수 있는 플레이를 의미합니다.", + "consistencyScore": "일관성 점수", + "highlyConsistent": "매우 일관적", + "highlyConsistentDescription": "모든 맵에서 매우 안정적인 성능입니다", + "consistent": "일관적", + "consistentDescription": "작은 변동만 있는 신뢰할 수 있는 성능입니다", + "moderatelyConsistent": "보통 수준으로 일관적", + "moderatelyConsistentDescription": "상황에 따라 성능이 달라집니다", + "variablePerformance": "변동이 큰 성능", + "variablePerformanceDescription": "맵마다 성능 차이가 크게 나타납니다", + "meanStdDev": "평균 ± 표준편차", + "performanceAcrossMaps": "맵별 성능", + "mapName": "맵 {number}", + "metricPer10": "10분당 {metric}", + "understanding": { + "title": "일관성 지표 이해하기", + "stdDev": "표준편차: 성능이 얼마나 퍼져 있는지 측정합니다. 값이 낮을수록 더 일관적입니다.", + "cv": "변동계수: 분산을 정규화한 지표입니다. 서로 다른 단위의 지표를 비교할 수 있습니다.", + "score": "일관성 점수: 전체 신뢰도 지표(0-100)입니다. 점수가 높을수록 안정적이고 꾸준한 성능을 의미합니다." + }, + "metrics": { + "eliminations": "처치", + "deaths": "사망", + "damage": "피해", + "damageK": "피해 (천)", + "healing": "치유", + "healingK": "치유 (천)", + "mean": "평균", + "stdDev": "± 표준편차", + "range": "범위", + "variation": "변동" + } + }, + "trends": { + "requiresThreePlus": "추세 보기는 3개 이상의 맵이 필요합니다.", + "aggregateSummary": "집계 요약", + "totalMaps": "전체 맵", + "avgElimsPer10": "평균 처치/10분", + "avgDeathsPer10": "평균 사망/10분", + "avgDamagePer10": "평균 피해/10분", + "performanceProgression": "성능 추이", + "firstHalf": "전반", + "secondHalf": "후반", + "maps": "맵", + "improvement": "개선", + "improvementDesc": "맵 후반부에서 성능이 향상되었습니다.", + "decline": "하락", + "declineDesc": "맵 후반부에서 성능이 하락했습니다.", + "stable": "안정", + "stableDesc": "모든 맵에서 성능이 일관적으로 유지되었습니다.", + "bestPerformance": "최고 성능", + "needsImprovement": "개선 필요", + "elimsPer10": "처치/10분", + "deathsPer10": "사망/10분", + "damagePer10": "피해/10분", + "perMapBreakdown": "맵별 분석", + "elims": "처치/10분", + "deaths": "사망/10분", + "damage": "피해/10분" + }, + "charts": { + "performanceProgression": "성능 추이", + "statComparison": "통계 비교", + "performanceProfile": "성능 프로필", + "firstPickPercentage": "첫 처치 %", + "firstDeathPercentage": "첫 사망 %", + "killsPerUltimate": "궁극기당 처치", + "duelWinrate": "대결 승률", + "soloKillsPer10": "10분당 솔로 킬", + "damageTakenPer10": "10분당 받은 피해", + "healingReceivedPer10": "10분당 받은 치유", + "fightReversalPercentage": "교전 역전률 %", + "per10": "10분당", + "avgUltImpact": "평균 궁극기 영향", + "oneVsOneSuccess": "1v1 성공률", + "individualKills": "개인 처치", + "damageReceived": "받은 피해", + "supportReceived": "받은 지원", + "clutchPlays": "클러치 성능", + "totalEliminations": "총 처치", + "totalDeaths": "총 사망", + "totalDamage": "총 피해", + "avgPer10": "10분당 평균", + "eliminations": "처치", + "deaths": "사망", + "damage": "피해 (천)", + "healing": "치유 (천)", + "mitigated": "경감 (천)", + "healingPer10K": "치유/10분 (천)", + "blockedPer10K": "방어/10분 (천)", + "elimsPer10": "처치/10분", + "deathsPer10": "사망/10분", + "damagePer10K": "피해/10분 (천)", + "elimsPer10Short": "처치", + "deathsPer10Short": "사망", + "damagePer10Short": "피해", + "healingPer10Short": "치유", + "mitigatedPer10Short": "경감", + "averagePerformance": "평균 성능", + "errorRange": "범위", + "stdDev": "표준편차" + }, + "comparisonMode": { + "label": "비교 모드", + "player": "플레이어", + "team": "팀", + "playerDescription": "맵별 개별 플레이어 성능 비교", + "teamDescription": "우리 팀과 상대 팀의 맵별 성능 비교" + }, + "mapSelectionMode": { + "label": "맵 선택", + "individual": "개별 맵", + "groups": "맵 그룹", + "individualDescription": "개별로 선택한 맵으로 비교", + "groupsDescription": "미리 정의된 맵 그룹으로 비교(예: 러시 맵, 오픈 맵)", + "selectGroups": "맵 그룹 선택", + "selectGroupsPlaceholder": "비교할 맵 그룹 선택...", + "manageGroups": "그룹 관리", + "groupsHint": "맵 그룹 페이지에서 맵 그룹을 만들고 관리할 수 있습니다", + "individualMapsSelected": "{count, plural, =1 {스크림 페이지에서 선택한 맵 1개} other {스크림 페이지에서 선택한 맵 #개}}" + }, + "mapGroupSelector": { + "title": "비교할 맵 그룹 선택", + "description": "성능을 비교할 맵 그룹을 최대 2개까지 선택하세요. 서로 다른 맵 유형, 플레이스타일 또는 기간을 비교할 수 있습니다.", + "noGroups": { + "title": "사용 가능한 맵 그룹 없음", + "description": "맵 그룹을 만들어 팀 성능을 정리하고 비교하세요", + "createButton": "맵 그룹 만들기" + }, + "hint": { + "selectGroups": "계속하려면 맵 그룹을 하나 이상 선택하세요", + "selectOneMore": "비교를 위해 그룹을 하나 더 선택하세요(선택 사항)", + "readyToCompare": "선택한 그룹을 비교할 준비가 되었습니다" + }, + "compareButton": "비교" + }, + "mapGroupManager": { + "title": "맵 그룹", + "description": "성능을 정리하고 비교할 사용자 지정 맵 그룹을 만드세요", + "newGroup": "새 그룹", + "createDialog": { + "title": "맵 그룹 만들기", + "description": "서로 다른 상황의 성능을 분석할 수 있도록 맵을 그룹으로 묶으세요" + }, + "editDialog": { + "title": "맵 그룹 편집", + "description": "맵 그룹 세부정보와 맵 선택을 업데이트하세요" + }, + "deleteDialog": { + "title": "맵 그룹 삭제", + "description": "\"{name}\"을(를) 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.", + "cancel": "취소", + "delete": "삭제" + }, + "empty": { + "title": "아직 맵 그룹이 없습니다", + "description": "첫 맵 그룹을 만들어 맵을 정리하고 서로 다른 상황의 성능을 비교하세요", + "create": "맵 그룹 만들기" + }, + "actions": { + "label": "그룹 작업", + "edit": "편집", + "delete": "삭제" + }, + "card": { + "maps": "맵", + "createdBy": "{name}이(가) 생성" + }, + "form": { + "name": "그룹 이름", + "namePlaceholder": "예: 러시 맵, 오픈 맵", + "description": "설명", + "descriptionPlaceholder": "이 그룹에 대한 선택 설명", + "category": "카테고리", + "categoryPlaceholder": "예: 플레이스타일, 맵 유형", + "selectMaps": "맵 선택", + "noMapsAvailable": "사용 가능한 맵 없음", + "selectedMaps": "{count, plural, one {맵 #개 선택됨} other {맵 #개 선택됨}}", + "create": "그룹 만들기", + "update": "그룹 업데이트" + }, + "toast": { + "created": "맵 그룹 생성됨", + "createdDescription": "맵 그룹이 성공적으로 생성되었습니다.", + "updated": "맵 그룹 업데이트됨", + "updatedDescription": "맵 그룹이 성공적으로 업데이트되었습니다.", + "deleted": "맵 그룹 삭제됨", + "deletedDescription": "맵 그룹이 성공적으로 삭제되었습니다.", + "error": "오류" + }, + "errors": { + "fetchFailed": "맵 그룹을 가져오지 못했습니다", + "createFailed": "맵 그룹을 생성하지 못했습니다", + "updateFailed": "맵 그룹을 업데이트하지 못했습니다", + "deleteFailed": "맵 그룹을 삭제하지 못했습니다" + } + }, + "teamComparison": { + "myTeam": "우리 팀", + "enemyTeam": "상대 팀", + "players": "{count, plural, =1 {플레이어 1명} other {플레이어 #명}}", + "comparingMaps": "{count, plural, =1 {맵 1개 비교 중} other {맵 #개 비교 중}}", + "categories": { + "combat": "전투 성능", + "damage": "피해 및 생존력", + "support": "지원 및 방어", + "ultimate": "궁극기 경제" + }, + "stats": { + "eliminationsPer10": "10분당 처치", + "finalBlowsPer10": "10분당 결정타", + "deathsPer10": "10분당 사망", + "firstPickPercentage": "첫 처치 %", + "firstDeathPercentage": "첫 사망 %", + "heroDamagePer10": "10분당 영웅 피해", + "damageTakenPer10": "10분당 받은 피해", + "healingDealtPer10": "10분당 치유", + "damageBlockedPer10": "10분당 막은 피해", + "ultimatesEarnedPer10": "10분당 획득 궁극기", + "averageUltChargeTime": "평균 궁극기 충전 시간", + "killsPerUltimate": "궁극기당 처치" } } }, @@ -666,6 +2183,11 @@ }, "back": "스크림 개요로 돌아가기", "dashboard": "대시보드", + "tabsLabel": "맵 섹션", + "mobileBanner": { + "message": "최상의 경험을 위해 더 큰 화면 사용을 권장합니다.", + "dismiss": "닫기" + }, "playerSwitcher": { "select": "플레이어 선택", "search": "플레이어 검색...", @@ -679,7 +2201,96 @@ "dashboard": "대시보드", "overview": "개요", "analytics": "분석", - "charts": "차트" + "charts": "차트", + "telemetry": "텔레메트리" + }, + "telemetry": { + "noData": { + "title": "이 맵에는 텔레메트리가 없습니다", + "description": "이 맵은 이벤트별 타이밍 데이터가 기록되기 전에 업로드되었습니다. 차트 탭에서 라운드별 통계를 확인할 수 있습니다." + }, + "stats": { + "damageDealt": "가한 피해", + "damageTaken": "받은 피해", + "healingDealt": "힐량", + "eliminations": "처치", + "deaths": "죽음" + }, + "trace": { + "title": "활동 텔레메트리", + "description": "맵 전반의 피해, 힐, 이벤트입니다. 임의의 지점에 마우스를 올려 해당 순간을 확인하세요." + }, + "ariaChart": "{seconds}초 동안의 플레이어 활동 텔레메트리", + "lanes": { + "output": "가한 피해", + "support": "힐 / 피해", + "survival": "받은 피해 / 힐", + "events": "이벤트", + "target": "대상별 피해" + }, + "roles": { + "tank": "탱커", + "damage": "딜러", + "support": "힐러" + }, + "channels": { + "damageDealt": "가한 피해", + "damageTaken": "받은 피해", + "healingDealt": "힐량", + "healingReceived": "받은 힐" + }, + "markers": { + "ult": "궁극기", + "kill": "처치", + "death": "죽음", + "ability": "스킬", + "ability1": "스킬 1", + "ability2": "스킬 2", + "swap": "영웅 교체" + }, + "matchups": { + "title": "타겟 및 매치업", + "description": "이 선수가 맵 전반에서 누구에게 피해를 주고 교전했는지 보여줍니다.", + "byTarget": { + "title": "역할별 가한 피해", + "empty": "이 맵에서 가한 피해가 없습니다." + }, + "takenByRole": { + "title": "역할별 받은 피해", + "empty": "이 맵에서 받은 피해가 없습니다." + }, + "killContribution": { + "title": "킬 기여도", + "focus": "포커스 기여", + "participation": "참여율", + "killsLabel": "팀 킬", + "empty": "추적된 피해가 있는 팀 킬이 없습니다." + }, + "radar": { + "title": "상대별 피해 교환", + "dealt": "가함", + "received": "받음", + "empty": "이 맵에 대한 상대 피해 기록이 없습니다." + } + }, + "heatmap": { + "title": "위치 히트맵", + "description": "이 선수가 교전하고, 죽고, 스킬을 사용한 위치입니다. 드래그하여 이동, 스크롤하여 확대하세요.", + "heatGroup": "히트맵", + "loading": "맵 이미지 로드 중...", + "noCalibration": "이 맵은 아직 보정되지 않아 맵 이미지 위에 위치를 표시할 수 없습니다.", + "noCoordinates": "이 맵에서 이 선수의 위치 데이터가 기록되지 않았습니다.", + "subMapsLabel": "서브맵", + "abilityFilter": "스킬 필터", + "layers": { + "damageDealt": "가한 피해", + "damageTaken": "받은 피해", + "healingDealt": "힐", + "kills": "처치", + "deaths": "죽음", + "abilities": "스킬" + } + } }, "overview": { "matchTime": "총 경기 시간", @@ -732,6 +2343,8 @@ "noResult": "결과 없음." }, "analytics": { + "rhythm": "리듬", + "roundTrends": "라운드별 추이", "avgUltChargeTime": { "title": "평균 궁극기 충전 시간", "footer": "궁극기를 충전하는 데 걸리는 평균 시간입니다. 시간이 짧을수록 좋으며, 시간이 길 경우 충분한 피해나 치유를 하지 않고 있다는 것을 나타낼 수 있습니다." @@ -752,7 +2365,9 @@ "title": "다른 플레이어와의 비교", "description": "{playerName}의 점수와 적 팀 플레이어에 대한 승률.", "score": "점수:", - "winrate": "승률: {winrate}%" + "winrate": "승률: {winrate}%", + "winrateLabel": "승률", + "empty": "이 플레이어의 매치업 데이터가 없습니다." }, "playerKillfeed": { "title": "플레이어 킬 피드", @@ -773,6 +2388,18 @@ "round": "라운드 {round}", "dmgDone": "가한 피해", "dmgTaken": "받은 피해" + }, + "mvpScore": { + "title": "MVP 점수", + "footer": "MVP 점수는 같은 영웅을 스크림에서 플레이한 다른 모든 플레이어의 평균과 플레이어 통계를 비교해 계산합니다. 이 카드 위에 마우스를 올리면 계산 방식을 볼 수 있습니다.", + "totalScore": "총점: {score}", + "pointsUnit": "점", + "heroIconAlt": "{hero} 아이콘", + "per10Value": "10분당 {per10Value} · 평균 {heroAverage}", + "zScore": "{score}σ", + "percentile": "{percentile}번째 백분위", + "noData": "사용 가능한 데이터 없음", + "explanation": "MVP 점수는 같은 영웅을 플레이한 비슷한 플레이어들과 비교해 이 플레이어의 성과를 이해하는 데 사용할 수 있습니다. 팀원 대비 성과는 반영하지 않으며, 해당 영웅의 전체 평균 대비 두드러진 성과를 보여주는 지표입니다. MVP 점수는 보통 -100에서 +100 사이이며 0을 중심으로 분포합니다." } }, "charts": { @@ -812,12 +2439,94 @@ "charts": "차트", "events": "이벤트", "compare": "비교", + "notes": "노트", + "vod": "VOD", "heatmap": "히트맵", - "replay": "리플레이" + "replay": "리플레이", + "routes": "이동 경로", + "story": "경기 스토리", + "initiation": "한타 개시" + }, + "routes": { + "calibrationWarning": "이 맵의 좌표 보정이 잘못되어 대부분의 위치가 맵 이미지 밖에 표시됩니다. 보정 도구에서 앵커를 4개 이상 사용해 다시 보정해 주세요.", + "empty": "이 맵에 위치 데이터가 없습니다.", + "noCalibration": "맵 캘리브레이션이 없습니다. 이동 경로는 캘리브레이션된 정사영 맵 이미지가 필요합니다.", + "showAll": "모든 경로 표시", + "routeFallback": "경로 {n}", + "loadingImage": "맵 이미지를 불러오는 중...", + "canvasLabel": "맵 이미지 위에 그려진 플레이어 이동 경로입니다.", + "zoomHint": "{zoom, number}% · 스크롤로 확대/축소 · 드래그로 이동", + "filters": { + "team": "팀", + "player": "플레이어", + "round": "라운드", + "outcome": "결과", + "kind": "유형", + "cluster": "클러스터", + "all": "전체" + }, + "outcomes": { + "won": "승리", + "lost": "패배", + "unknown": "알 수 없음" + }, + "kinds": { + "initial": "시작", + "respawn": "리스폰" + }, + "clusters": { + "header": "경로 클러스터", + "routes": "경로 {count}개", + "outcomes": "{won}승 / {lost}패 / {unknown} 알 수 없음" + } }, "replay": { + "ghost": { + "title": "고스트", + "source": "고스트 소스", + "alignMode": "정렬 기준", + "sourceRound": "고스트 라운드", + "otherScrim": "다른 스크림에서", + "primaryRound": "정렬할 라운드", + "alignRoundStart": "라운드 시작", + "alignFirstContact": "첫 교전", + "nudge": "오프셋 (초)", + "playerFilter": "고스트 플레이어", + "allPlayers": "모든 플레이어", + "clear": "고스트 지우기", + "noData": "선택한 소스에 위치 데이터가 없습니다.", + "loadError": "고스트 데이터를 불러오지 못했습니다.", + "arenaMismatch": "이 라운드에서는 고스트가 다른 맵 구역에 있습니다.", + "loading": "고스트 불러오는 중…" + }, "noCalibration": "맵 캘리브레이션이 없습니다. 리플레이 뷰어는 캘리브레이션된 정사영 맵 이미지가 필요합니다.", - "noCoordinates": "이 맵에 좌표 데이터가 없습니다." + "noCoordinates": "이 맵에 좌표 데이터가 없습니다.", + "noCalibrationForRange": "이 시간 범위에 사용할 맵 캘리브레이션이 없습니다.", + "viewerLabel": "리플레이 뷰어", + "timeline": { + "roundShort": "R{number}", + "scrubber": "리플레이 탐색 막대", + "skipBack": "{seconds, number}초 뒤로 이동", + "skipForward": "{seconds, number}초 앞으로 이동", + "play": "재생", + "pause": "일시정지", + "playbackSpeed": "재생 속도", + "keyboardHint": "Space: 재생/일시정지 · 화살표 키: 5초 이동 (Shift: 1초) · [ / ]: 속도" + }, + "eventFeed": { + "events": "이벤트", + "noEventsNearby": "근처 이벤트가 없습니다", + "teamLabel": "{team}팀:", + "ultActivated": "궁극기 활성화", + "ultEnded": "궁극기 종료", + "roundStart": "{round, number}라운드 시작", + "roundEnd": "{round, number}라운드 종료" + }, + "map": { + "mapAlt": "맵", + "loadingImage": "맵 이미지를 불러오는 중...", + "zoomHint": "{zoom, number}% · 스크롤로 확대/축소 · 드래그로 이동" + } }, "heatmap": { "noCalibration": "맵 캘리브레이션이 없습니다. 히트맵은 캘리브레이션된 정사영 맵 이미지가 필요합니다.", @@ -826,10 +2535,91 @@ "damage": "피해", "healing": "치유", "kills": "킬" + }, + "canvas": { + "canvasLabel": "맵 이미지 위 전투 히트맵 오버레이입니다. 드래그로 이동하고 스크롤로 확대/축소하세요. 더하기/빼기 키로 확대/축소하고, 화살표 키로 이동하며, 0 키로 초기화합니다.", + "total": "총 {count, number}개", + "killEvents": "킬 이벤트 {count, number}개", + "killEvent": "{attackerName}({attackerHero})이 {time}에 {victimName}({victimHero})을 처치", + "loadingImage": "맵 이미지를 불러오는 중...", + "zoomHint": "{zoom, number}% · 스크롤로 확대/축소 · 드래그로 이동", + "primaryFire": "기본 공격" } }, - "overview": { - "matchTime": "총 경기 시간", + "fightUltQuality": { + "noData": "이 맵에 위치 데이터가 없습니다.", + "ults": { + "heading": "궁극기", + "time": "시간", + "player": "선수", + "hero": "영웅", + "zone": "구역", + "displacement": "이동 거리", + "conversionKills": "전환 킬", + "diedDuringUlt": "사망", + "died": "사망", + "meters": "{value}m", + "unpaired": "종료 이벤트 누락" + }, + "engagements": { + "heading": "교전", + "even": "무승부" + }, + "zones": { + "title": "구역", + "empty": "이 맵에는 아직 게시된 구역이 없습니다.", + "zone": "구역", + "team": "팀", + "kills": "킬", + "deaths": "데스", + "ults": "궁극기" + } + }, + "vod": { + "eyebrow": "영상 리뷰", + "title": "VOD", + "description": "이 맵을 데이터와 함께 검토할 수 있도록 YouTube 또는 Twitch 녹화를 연결하세요.", + "addVod": "VOD 추가", + "changeVod": "VOD 변경", + "uploadVod": "VOD 업로드", + "cancel": "취소", + "inputPlaceholder": "VOD URL을 입력하세요...", + "uploadVOD": "VOD 업로드", + "noVod": "아직 연결된 VOD가 없습니다. 리뷰를 시작하려면 하나를 추가하세요.", + "vodSuccess": "VOD가 성공적으로 연결되었습니다.", + "vodFail": "VOD 연결에 실패했습니다", + "invalidUrl": "URL은 YouTube 또는 Twitch 주소여야 합니다", + "vodLabel": "VOD URL을 입력하세요(YouTube 또는 Twitch).", + "vodRequired": "VOD URL이 필요합니다." + }, + "tiptap": { + "eyebrow": "맵 노트북", + "placeholder": "이 맵에 대한 관찰과 후속 작업을 기록하세요. 저장을 클릭하면 노트가 저장됩니다.", + "notes": "노트", + "save": "저장", + "saving": "저장 중...", + "unsavedChanges": "저장되지 않은 변경 사항", + "toolbar": { + "label": "노트 서식", + "undo": "실행 취소", + "redo": "다시 실행", + "h1": "제목 1", + "h2": "제목 2", + "h3": "제목 3", + "bold": "굵게", + "italic": "기울임", + "strike": "취소선", + "codeBlock": "코드 블록", + "alignLeft": "왼쪽 정렬", + "alignCenter": "가운데 정렬", + "alignRight": "오른쪽 정렬", + "list": "글머리 기호 목록", + "orderedList": "번호 매기기 목록", + "highlight": "강조" + } + }, + "overview": { + "matchTime": "총 경기 시간", "minutes": "{time}분", "score": "점수", "winner": "승자:", @@ -840,6 +2630,9 @@ "healedMore": "{teamName}이(가) 이 맵에서 더 많은 치유를 제공했습니다.", "title": "개요", "round": "라운드 {number}", + "team1": "팀 1", + "team2": "팀 2", + "notAvailable": "해당 없음", "analysis": { "title": "분석", "tabFirstDeaths": "첫 번째 사망", @@ -880,7 +2673,118 @@ "ultNeedsDiscipline": "개선 필요 — 승리보다 패배 시 더 많은 궁극기 사용.", "ultWasted": "{count}회 낭비된 궁극기 (전체의 {percentage}%) — 불리한 상황(3명 이상 열세)에서 사용.", "ultDryFights": "{dryCount}회 드라이 전투 (궁극기 미사용), 승률 {winrate}%. {nonDryCount}회 전투에서 궁극기 사용 (승률 {nonDryWinrate}%).", - "ultReversalRate": "전투 역전 비율 (2명 이상 열세 후 승리): 드라이 전투 {dryRate}% vs 궁극기 사용 시 {nonDryRate}%." + "ultReversalRate": "전투 역전 비율 (2명 이상 열세 후 승리): 드라이 전투 {dryRate}% vs 궁극기 사용 시 {nonDryRate}%.", + "swapOverview": "{team1Name}은(는) 영웅 교체를 {team1Count}회 했고, {team2Name}은(는) {team2Count}회 했습니다.", + "swapTopPairTeam": "{teamName}의 가장 흔한 교체: {fromHero}{toHero} ({count}회).", + "swapTopSwapperTeam": "{teamName}의 가장 활발한 교체 선수: {playerName}, {count}회 교체.", + "tabAbilityTiming": "스킬 타이밍", + "footerAbilityTiming": "승률은 전투 단계(교전 전, 초반, 중반, 후반, 정리)별로 계산됩니다. 셀은 최소 3전투 이상의 데이터가 필요하며, 해당 스킬의 최적 단계와 단계 승률이 의미 있게 차이 나는 경우 이상치로 강조됩니다.", + "collapseAll": "모두 접기", + "expandAll": "모두 펼치기", + "mobileLabel": { + "deaths": "사망", + "ults": "궁", + "timing": "타이밍", + "efficiency": "효율", + "swaps": "교체", + "rotation": "로테이션", + "abilities": "스킬" + }, + "deaths": { + "headToHeadLabel": "첫 사망 비율", + "headToHeadUnit": "첫 사망", + "topPlayerCallout": "첫 사망이 가장 많은 선수: {playerName} ({total}전투 중 {count}회)", + "ajaxCallout": "{playerName}은(는) 루시우를 플레이하며 {count}회 Ajax를 기록했습니다.", + "fightStripHeading": "전투별 첫 사망", + "fightCount": "{count}전투", + "team1DiedFirst": "{teamName} 첫 사망", + "team2DiedFirst": "{teamName} 첫 사망", + "momentumHeading": "첫 사망 흐름", + "team1DyingMore": "{teamName} 사망 증가", + "team2DyingMore": "{teamName} 사망 증가", + "streakCallout": "최장 첫 사망 연속 기록: {teamName}이(가) {count}전투 연속으로 먼저 사망", + "fightAriaLabel": "전투 {fight}: {playerName} ({hero})이(가) {teamName}에서 먼저 사망", + "fightTooltipFight": "전투 {fight}", + "fightTooltipFirstDeath": "{teamName} 첫 사망: {playerName} ({hero})" + }, + "ultimates": { + "headToHeadLabel": "총 사용 궁극기", + "headToHeadUnit": "궁극기", + "roleHeadToHeadLabel": "{role}: 선궁 사용", + "roleHeadToHeadUnit": "전투", + "topUltUserLabel": "최다 궁극기 사용자:", + "topFightOpenerLabel": "최다 전투 개시:", + "fightCount": "{count}전투", + "subroleChartHeading": "서브역할별 궁극기 사용" + }, + "timing": { + "empty": "이 맵에는 타이밍 데이터가 없습니다.", + "comparisonCallout": "{team1Name}은(는) 궁극기의 {team1Pct}%를 개시 타이밍에 사용했고, {team2Name}은(는) {team2Pct}%입니다.", + "breakdownHeading": "궁극기 타이밍 분석" + }, + "efficiency": { + "empty": "이 맵에는 효율성 데이터가 없습니다.", + "avgChargeTimeLabel": "평균 궁극기 충전 시간", + "avgHoldTimeLabel": "평균 궁극기 보유 시간", + "scorecard": { + "wonFights": "승리 전투", + "lostFights": "패배 전투", + "wasted": "낭비", + "dryFights": "드라이 전투", + "winRate": "승률", + "reversal": "역전", + "ultsPerFight": "전투당 궁극기 {count}회", + "excellent": "우수", + "good": "양호", + "average": "보통", + "poor": "미흡" + } + }, + "swaps": { + "empty": "이 맵에는 기록된 영웅 교체가 없습니다.", + "headToHeadLabel": "총 영웅 교체", + "headToHeadUnit": "교체", + "tableTopSwap": "최다 교체", + "tableMostActive": "최다 활동", + "tableEmpty": "-", + "topPair": "{from} → {to} ({count}회)", + "topSwapper": "{name} ({count}회 교체)" + }, + "rotationDeaths": { + "empty": "이 맵에는 로테이션 사망 분석을 위한 좌표 데이터가 없습니다.", + "noEvents": "이 맵에서는 로테이션 사망이 감지되지 않았습니다.", + "headToHeadLabel": "로테이션 사망", + "headToHeadUnit": "사망", + "topPlayerCallout": "로테이션 중 가장 자주 끊긴 선수: {playerName} ({totalDeaths}번 사망 중 {rotationCount}회, {rate}%)", + "topKillerCallout": "로테이션 픽을 가장 많이 만든 영웅: {hero} ({count}킬)", + "tablePlayer": "선수", + "tableRotationDeaths": "로테이션 사망", + "tableTotalDeaths": "총 사망", + "tableRate": "비율", + "eventsHeading": "로테이션 사망 이벤트 ({count})" + }, + "abilityTiming": { + "phase": { + "preFight": "교전 전", + "early": "초반", + "mid": "중반", + "late": "후반", + "cleanup": "정리" + }, + "noTeamData": "{teamName}의 고영향 스킬 데이터가 없습니다.", + "negativeOutlier": "{abilityName}을(를) {phase}에 사용하면 승률이 {phaseWinrate}이며, {bestPhase}에 사용했을 때의 {bestPhaseWinrate}보다 낮습니다.", + "positiveOutlier": "{abilityName}을(를) {phase}에 사용했을 때 {phaseWinrate} 승률과 연관됩니다: 강한 {pattern} 패턴.", + "patternInitiation": "개시", + "patternTiming": "타이밍", + "cellTitle": "승률 {winrate} ({fights}전투 중 {wins}승 {losses}패)", + "fewerThanFights": "{count}전투 미만", + "empty": "이 맵에서는 고영향 스킬이 감지되지 않았습니다.", + "legendWinRate": "승률:", + "legendBadTiming": "나쁜 타이밍", + "legendGoodTiming": "좋은 타이밍", + "hoverDetail": "{abilityName} {phase} 사용: {winrate} 승률, {fights}전투 ({wins}승 {losses}패)", + "info": "승률은 각 팀의 관점에서 계산됩니다. 대시는 {count}전투 미만을 의미합니다. 자세한 내용은 마우스를 올려 확인하세요." + } } }, "overviewTable": { @@ -931,6 +2835,31 @@ "dmgToHealsRatio": "플레이어의 가한 피해와 받은 치유 비율입니다.", "ultsCharged": "플레이어가 충전한 궁극기 수입니다.", "ultsUsed": "플레이어가 사용한 궁극기 수입니다." + }, + "mvpBreakdown": { + "mvpTitle": "{teamName} MVP", + "totalScore": "총점: {score}", + "topContributions": "주요 기여:", + "points": "{sign}{points}점", + "per10VsAverage": "10분당 {value} / 평균 {average}", + "percentile": "상위 {percentile} 백분위", + "moreStats": "+ 추가 계산된 스탯 {count, plural, other {#개}}", + "stats": { + "eliminations": "처치", + "final_blows": "결정타", + "deaths": "사망", + "hero_damage_dealt": "영웅 피해량", + "healing_dealt": "치유량", + "healing_received": "받은 치유량", + "damage_blocked": "막은 피해량", + "damage_taken": "받은 피해량", + "solo_kills": "단독 처치", + "ultimates_earned": "궁극기 획득", + "ultimates_used": "궁극기 사용", + "objective_kills": "목표 처치", + "offensive_assists": "공격 지원", + "defensive_assists": "방어 지원" + } } }, "killfeed": { @@ -939,7 +2868,9 @@ "kills": "처치", "deaths": "죽음", "fightWins": "전투 승리", - "title": "킬 피드" + "title": "킬 피드", + "team1": "팀 1", + "team2": "팀 2" }, "killfeedTable": { "limitTest": "제한 테스트", @@ -951,6 +2882,7 @@ "method": "방법", "start": "시작", "end": "종료", + "viewInReplay": "리플레이에서 보기", "fightWinner": "전투 승리자", "abilities": { "primary-fire": "기본 발사", @@ -968,6 +2900,10 @@ "killfeedControls": { "title": "표시 옵션", "ariaLabel": "킬 피드 표시 설정", + "viewMode": "보기 모드", + "timelineView": "이벤트 타임라인", + "timelineViewDescription": "모든 맵 이벤트를 시간 비율에 맞춰 표시합니다", + "ultSection": "궁극기 추적", "ultBrackets": "궁극기 지속 괄호", "ultLabels": "궁극기 라벨", "ultStartEvents": "궁극기 시작 이벤트", @@ -980,15 +2916,29 @@ "ultEndedDeath": "{player}이(가) 사망하여 궁극기가 종료되었습니다 ({duration}초)", "ultDuration": "지속 시간: {duration}초", "ultTime": "{start} — {end}", - "ultKills": "궁극기 중 {count}처치", - "ultDeaths": "궁극기 중 {count}사망", + "ultKills": "{count, plural, other {궁극기 중 #처치}}", + "ultDeaths": "{count, plural, other {궁극기 중 #사망}}", "diedDuringUlt": "{player}이(가) 궁극기 중 사망했습니다", - "ultLabel": "{player} ({hero})이(가) {duration}초 동안 궁극기를 사용, {kills}처치", + "ultLabel": "{player} ({hero})이(가) {duration}초 동안 궁극기를 사용, {kills, plural, other {#처치}}", "instantUlt": "{player} ({hero})이(가) 즉시 궁극기를 사용했습니다", "instantUltAt": "{time}에 즉시 궁극기 사용", - "ultInstant": "{player}의 즉시 궁극기 ({duration}초)" + "ultInstant": "{player}의 즉시 궁극기 ({duration}초)", + "tooltipFightStart": "전투 {num} 시작", + "tooltipFightEnd": "전투 {num} 종료", + "tooltipTimestamp": "시간: {time}", + "tooltipDuration": "지속 시간: {duration}초", + "tooltipWinner": "승자: {team}", + "tooltipTotalKills": "{count, plural, other {총 #처치}}", + "tooltipKill": "{attacker} → {victim}", + "tooltipMethod": "방식: {method}", + "tooltipTeam": "팀: {team}", + "tooltipUltActivated": "{player}이(가) {hero} 궁극기를 사용했습니다", + "tooltipUltEnded": "{player}의 {hero} 궁극기가 종료되었습니다", + "tooltipUltInstant": "{player}이(가) 즉시 {hero} 궁극기를 사용했습니다", + "tooltipDiedDuringUlt": "궁극기 중 사망" }, "charts": { + "title": "차트", "tooltip": "차트에 대한 추가 정보를 찾고 계신가요? 문서를 확인하세요에서 더 알아보세요.", "team1": "팀 1", "team2": "팀 2", @@ -998,16 +2948,39 @@ "support": "지원", "round": "라운드 {round}", "killsByFight": { + "eyebrow": "전투 흐름", "title": "전투별 처치", "description": "처치는 15초 간격으로 그룹화됩니다. 이 차트는 각 간격에서 각 팀의 누적 처치를 보여줍니다. x축은 초 단위의 시간을 나타내고, y축은 누적 처치를 나타냅니다. 팀 1은 양수로, 팀 2는 음수로 표시됩니다. 차트는 각 전투 후 0으로 초기화됩니다." }, "finalBlowsByRole": { + "eyebrow": "역할 분석", "title": "역할별 최종 타격", "description": "이 차트는 각 팀의 역할별 최종 타격 수를 보여줍니다. 역할은 탱크, 딜러, 지원으로 나뉘어 표시됩니다. x축은 역할을 나타내고, y축은 최종 타격 수를 나타냅니다." }, "dmgByRound": { + "eyebrow": "라운드 피해", "title": "라운드별 누적 영웅 피해", "description": "이 차트는 각 팀의 라운드별 가한 영웅 피해를 보여줍니다. x축은 라운드를 나타내고, y축은 가한 피해를 나타냅니다. 피해는 누적되므로, 2라운드의 피해는 1라운드의 피해를 포함합니다." + }, + "tempo": { + "eyebrow": "경기 흐름", + "title": "경기 템포", + "description": "처치와 궁극기를 겹치는 곡선으로 표현합니다. 차트 아래의 스크러버를 드래그해 특정 전투나 단계에 집중하세요." + }, + "ultAdvantage": { + "eyebrow": "궁극기", + "title": "궁극기 우위", + "description": "각 교전 진입 시 어느 팀이 더 많은 궁극기를 보유했는지 보여줍니다. 0보다 높은 막대는 {team}의 우위를 의미합니다.", + "noData": "이 맵에 기록된 궁극기 충전 데이터가 없습니다.", + "teamAhead": "{team} 우위", + "teamAheadBy": "{team} +{n}", + "teamAheadByMore": "{team} +{n} 이상", + "even": "동등", + "breakdownCaption": "궁극기 우위별 교전", + "fight": "{n}번째 교전", + "fightResult": "{team} 교전 승리", + "fightsCount": "{count, plural, other {교전 #회}}", + "noFights": "교전 없음" } }, "events": { @@ -1015,21 +2988,56 @@ "roundEnd": "라운드 {event} 종료됨", "matchEnd": "경기 종료됨", "matchStart": "경기 시작됨", + "noData": "이 맵에 기록된 이벤트가 없습니다.", + "round": "라운드 {number}", "mapEvents": { - "title": "이벤트", - "description": "경기 중 발생한 이벤트입니다. 여기에는 목표 점령, 라운드 시작 및 종료, 멀티킬 등이 포함됩니다.", - "captureString1": "{team}이(가) 점령 지점을 차지했습니다.", - "captureString2": "{team}이(가) 목표를 점령했습니다.", - "objectiveUpdate": "점령 완료", - "swap": " {player}이(가) {hero}(으)로 변경했습니다.", - "ultKills": " {player}이(가) 궁극기를 사용해 {players, plural, =1 {한 명의 플레이어} other {#명의 플레이어}}를 처치했습니다.", - "multikill": "전투 {event} 중에 {player}이(가) 멀티킬을 기록하며 {kills}명을 처치했습니다.", - "ajax": "{player}이(가) 전투 {fight} 중 Ajax를 기록했습니다." - }, - "ultsUsed": { - "title": "사용된 궁극기", - "description": "경기 중 사용된 모든 궁극기와 발생 시간을 보여줍니다.", - "ultStart": "{player}이(가) 전투 {fight} 중에 궁극기를 사용했습니다." + "title": "이벤트" + }, + "totals": { + "rounds": "라운드", + "fights": "전투", + "multikills": "멀티킬", + "ultimates": "사용한 궁극기", + "swaps": "영웅 변경" + }, + "filters": { + "label": "이벤트 필터", + "all": "전체", + "highlights": "하이라이트", + "ultimates": "궁극기", + "fights": "교전", + "swaps": "변경", + "objectives": "목표", + "empty": "이 필터와 일치하는 이벤트가 없습니다." + }, + "type": { + "matchStart": "경기", + "matchEnd": "경기", + "roundStart": "라운드", + "roundEnd": "라운드 종료", + "objective": "목표", + "engagement": "교전", + "swap": "변경", + "ult": "궁극기", + "ultKill": "궁극기 처치", + "multikill": "멀티킬", + "ajax": "에이잭스" + }, + "detail": { + "matchStart": "경기가 시작되었습니다.", + "matchEnd": "경기가 종료되었습니다.", + "roundStart": "라운드 {number}이(가) 시작되었습니다.", + "roundEnd": "라운드 {number}이(가) 종료되었습니다.", + "capture": "{team}이(가) 목표를 점령했습니다.", + "pointTake": "{team}이(가) 점령 지점을 차지했습니다.", + "fightTag": "전투 {number}", + "ultKill": "{player}이(가) 궁극기를 사용해 {count, plural, =1 {한 명을} other {#명을}} 처치했습니다.", + "multikill": "{player}이(가) 전투 {fight}에서 {count, plural, =3 {트리플} =4 {쿼드} =5 {퀸텀플} other {#-멀티}} 킬을 기록했습니다.", + "ajax": "{player}이(가) 전투 {fight}에서 에이잭스를 기록했습니다.", + "engagementWon": "승리", + "engagementEven": "무승부", + "ultConversion": "{count} 전환", + "ultDied": "사망" }, "tempo": { "title": "경기 템포", @@ -1041,6 +3049,11 @@ "snapToFights": "전투에 맞추기", "fightLabel": "전투 {number}", "allFights": "모든 전투", + "selectionStart": "선택 시작", + "selectionEnd": "선택 끝", + "previousFight": "이전 전투", + "nextFight": "다음 전투", + "even": "동등", "noData": "템포를 표시할 데이터가 충분하지 않습니다." } }, @@ -1049,6 +3062,19 @@ "team2": "팀 2", "playerCard": { "title": "플레이어 통계", + "unplaced": "미배치", + "sr": "SR", + "maxScore": "최고 점수!", + "ranks": { + "bronze": "브론즈", + "silver": "실버", + "gold": "골드", + "platinum": "플래티넘", + "diamond": "다이아몬드", + "master": "마스터", + "grandmaster": "그랜드마스터", + "champion": "챔피언" + }, "allHeroes": { "title": "모든 영웅", "timePlayed": "플레이 시간", @@ -1119,9 +3145,882 @@ "healingDealtPer10Min": "10분당 {num} 치유 제공", "healingReceived": "받은 치유", "healingReceivedNum": "{num} 받은 치유", - "healingReceivedPer10Min": "10분당 {num} 받은 치유" + "healingReceivedPer10Min": "10분당 {num} 받은 치유", + "ratingTooltip": "영웅 SR은 맵 성과를 기준으로 계산됩니다. 전체 영웅 SR은 맵 개요 페이지의 통계 표에서 플레이어 이름 위에 마우스를 올리면 볼 수 있습니다.", + "unratedTooltip": "순위에 오르려면 최소 60초 이상 플레이해야 합니다", + "unplaced": "미배치" + }, + "percentile": "{percentile}번째 백분위", + "percentileExplanation": "{hero} 플레이어 중 {stat} 성과 상위 {percent}%", + "vsAverage": "평균 대비 {average}/10분" + } + }, + "heroBans": { + "bannedBy": "{hero} 영웅이 {team}에 의해 밴됨" + }, + "matchStory": { + "title": "경기 스토리", + "description": "{team1} 관점에서 본 한타별 승리 확률 변화입니다.", + "limited": "이 로그에는 궁극기 이벤트가 없습니다. 승리 확률은 궁극기 경제 없이 표시되며 연쇄 인사이트는 비활성화됩니다.", + "noModel": "이 게임 모드에 대한 리그 전체 데이터가 아직 충분하지 않습니다.", + "chart": { + "winProbability": "승리 확률", + "round": "{round}라운드", + "time": "시간", + "score": "점수", + "objective": "목표", + "capture": "{team}이(가) 목표를 차지했습니다", + "captureProgress": "{team} 거점 점령. {t1}: {p1}%, {t2}: {p2}%" + }, + "ledger": { + "title": "한타 기록", + "fight": "한타", + "time": "시간", + "zone": "구역", + "result": "결과", + "swing": "변동", + "ults": "궁극기", + "carryover": "이월", + "won": "승리", + "lost": "패배", + "even": "무승부", + "ultEconomy": "궁극기 경제", + "stagger": "스태거", + "context": "맥락", + "driverObjective": "목표", + "driverKills": "킬", + "driverUlts": "궁극기" + }, + "wpa": { + "title": "승리 확률 기여도", + "subtitle": "규약 기반 배분 — 막타, 어시스트, 데스, 궁극기 사용으로 각 한타의 변동을 나눕니다. 행을 펼쳐 자세히 확인하세요.", + "player": "플레이어", + "team": "팀", + "total": "WPA", + "fightShare": "{fight}번째 한타: {share}%" + }, + "insights": { + "title": "주요 순간", + "biggestSwing": "{fight}번째 한타로 승리 확률이 {team} 쪽으로 {swing}% 이동했습니다.", + "ultCarryover": "{prevFight}번째 한타의 궁극기 경제로 인해 {team}은(는) {fight}번째 한타에 약 {cost}%의 승리 확률 손해를 안고 진입했습니다.", + "staggerCarryover": "{fight}번째 한타 전 스태거 데스로 {team}이(가) 약 {cost}%의 승리 확률을 잃었습니다.", + "earlyControl": "{team}이(가) 초반 주도권을 잡아 첫 {of}번의 한타 중 {wins}번을 승리했습니다.", + "longStretch": "{team}이(가) {duration} 동안 우위를 유지했으며 승리 확률은 최고 {pct}%였습니다.", + "winStreak": "{team}이(가) {from}부터 {to}까지 한타 {count}연승을 거뒀습니다.", + "closing": "{team}이(가) 마지막 {of}번의 한타 중 {wins}번을 승리하며 맵을 마무리했습니다.", + "topWpa": "{player}({team})이(가) 맵 전체에서 가장 큰 승리 확률 변화를 만들었습니다: {wpa}%.", + "biggestSwingObjective": "{fight}번째 한타로 승리 확률이 {team} 쪽으로 {swing}% 이동했습니다 — 주로 그 결과로 얻은 목표 진행도와 점수 때문입니다.", + "biggestSwingKills": "{fight}번째 한타로 승리 확률이 {team} 쪽으로 {swing}% 이동했습니다 — 주로 그 결과로 생긴 인원 우위 때문입니다.", + "biggestSwingUlts": "{fight}번째 한타로 승리 확률이 {team} 쪽으로 {swing}% 이동했습니다 — 주로 그 결과로 생긴 궁극기 경제 우위 때문입니다." + }, + "story": { + "title": "맵 전개 과정" + }, + "takeaways": { + "title": "{team}의 개선 포인트", + "lossProfileObjective": "패배한 한타 {count}번에서 잃은 승리 확률의 {pct}%가 목표를 통해 발생했습니다 — 한타 패배가 점령과 점수로 이어졌습니다. 재교전만이 아니라 점령 저지를 위한 한타 후 재정비를 계획하세요.", + "lossProfileKills": "패배한 한타 {count}번에서 잃은 승리 확률의 {pct}%가 인원 열세에서 비롯됐습니다. 한타 자체가 문제입니다 — 포지셔닝과 타깃 우선순위부터 점검하세요.", + "lossProfileUlts": "패배한 한타 {count}번에서 잃은 승리 확률의 {pct}%가 궁극기 경제에서 비롯됐습니다. 상대 궁극기 보유량을 추적하고 충전된 궁극기를 향해 한타를 열지 마세요.", + "ultDiscipline": "패배한 한타 {count}번({fights})에서 상대보다 궁극기를 {extra}개 더 사용했습니다. 그 사용 시점을 복기하세요 — 진 한타에서의 늦은 궁극기는 상대의 다음 공세에 힘을 실어줍니다.", + "staggers": "스태거 데스가 한타 {count}번({fights})에 영향을 미쳐 약 {cost}%의 승리 확률을 잃었습니다. 한 명씩 복귀하지 말고 함께 재정비하세요.", + "ultDeficit": "궁극기 경제 열세 상태로 한타 {count}번({fights})을 받아 약 {cost}%를 잃었습니다. 궁극기 격차가 줄어들 때까지 재교전을 늦추세요.", + "firstDeaths": "{player}이(가) 패배한 한타 {of}번 중 {count}번({fights})에서 가장 먼저 전사했습니다. 그 시작 장면을 복기하세요 — 첫 데스가 한타의 흐름을 결정합니다." + }, + "missed": { + "title": "놓친 기회", + "empty": "없음 — 유리한 상황을 깔끔하게 마무리", + "showing": "{total}개 중 {shown}개 표시", + "fight": "교전 {fight}", + "wp": "{before}% → {after}%", + "reason": { + "objective": "거점에서 손해", + "kills": "교전 손해", + "ults": "궁극기 열세", + "earlyFirstDeath": "이른 죽음: {player}", + "stagger": "전열이 무너진 채 시작 (−{cost}%)", + "ultDeficit": "궁극기 열세로 시작 (−{cost}%)" + }, + "ult": { + "value": "{kills, plural, other {#킬}}", + "none": "효과 없음", + "died": "사용 중 사망", + "unknown": "사용", + "heroFallback": "{hero} 궁극기", + "summary": "{converted}/{total} 효과" + }, + "wpLost": "승률 손실" + } + }, + "fightInitiation": { + "eyebrow": "선공", + "title": "한타 개시", + "unavailable": "이 맵은 상세 로그 형식 이전이라 한타 개시를 감지할 수 없습니다.", + "summaryWinrate": "{team} 선공 시 승률", + "summaryFrequency": "{team} 개시한 한타", + "coverage": "전체 {total} 한타 중 {labeled} 한타 기준 ({contested} 한타 판정 불가).", + "fightLabel": "한타 {number}", + "initiatedBy": "{team} 선공", + "contested": "동시 개시", + "undetermined": "판정 불가", + "won": "승리", + "lost": "패배", + "lostOpener": "선공했으나 초반 손해", + "evidencePlayers": "{count}명 합류", + "evidenceUlt": "궁극기 개시", + "evidenceAbility": "스킬 개시", + "responseGap": "{seconds}초 후 대응", + "confidenceHigh": "높은 신뢰도", + "confidenceMedium": "보통 신뢰도", + "confidenceLow": "낮은 신뢰도", + "roundStarted": "{round}라운드 시작", + "roundEnded": "{round}라운드 종료", + "roundChange": "{previous} → {round}라운드", + "toggleRounds": "라운드" + } + }, + "scoutingPage": { + "title": "스카우팅", + "subtitle": "OWCS 팀을 검색하고 토너먼트 성과를 분석하세요.", + "metadata": { + "title": "스카우팅 | Parsertime", + "description": "OWCS 팀을 스카우팅하세요. 이름으로 검색하고 경기 기록, 승률, 토너먼트 성과를 확인할 수 있습니다.", + "ogTitle": "스카우팅 | Parsertime", + "ogDescription": "OWCS 팀을 스카우팅하세요. 이름으로 검색하고 경기 기록, 승률, 토너먼트 성과를 확인할 수 있습니다.", + "ogImage": "스카우팅" + }, + "search": { + "label": "팀 검색", + "placeholder": "팀 검색...", + "helperText": "이름이나 약어로 OWCS 팀을 검색하세요.", + "resultsLabel": "팀 검색 결과", + "matchCount": "{count, plural, =1 {경기 1개} other {경기 #개}}", + "winRate": "{winRate} 승률", + "noResults": "팀을 찾을 수 없습니다." + }, + "player": { + "title": "선수 스카우트", + "subtitle": "프로 선수를 검색하고 경쟁 이력을 분석하세요.", + "metadata": { + "title": "선수 스카우트 | Parsertime", + "description": "프로 Overwatch 선수를 검색하고 경쟁 이력, 영웅 풀, 토너먼트 성과를 확인하세요.", + "ogTitle": "선수 스카우트 | Parsertime", + "ogDescription": "프로 Overwatch 선수를 검색하고 경쟁 이력, 영웅 풀, 토너먼트 성과를 확인하세요.", + "ogImage": "선수 스카우트", + "profileTitle": "{player} 스카우팅 | Parsertime", + "profileDescription": "{player} 스카우팅 — 영웅 풀, 대회 기록, 스크림 성과." + }, + "search": { + "label": "선수 검색", + "placeholder": "선수 검색...", + "helperText": "이름, 팀, 역할로 프로 선수를 검색하세요.", + "resultsLabel": "선수 검색 결과", + "noResults": "선수를 찾을 수 없습니다." + }, + "profile": { + "metadata": { + "title": "{player} | 선수 스카우트 | Parsertime", + "description": "{player} 스카우트 — 영웅 풀, 토너먼트 이력, 경쟁 성과를 확인하세요.", + "ogTitle": "{player} | 선수 스카우트 | Parsertime", + "ogDescription": "{player} 스카우트 — 영웅 풀, 토너먼트 이력, 경쟁 성과를 확인하세요." + }, + "backToSearch": "검색으로 돌아가기", + "liquipediaProfile": "Liquipedia에서 보기", + "careerWinnings": "커리어 상금", + "signatureHeroes": "대표 영웅", + "tournamentAppearances": "토너먼트 출전", + "competitiveMaps": "경쟁전 맵", + "tournaments": "{count}개 토너먼트", + "maps": "{count}개 맵", + "heroPool": "영웅 풀", + "knownFor": "주요 특징", + "knownForSource": "Liquipedia 출처", + "observedInCompetition": "대회에서 관찰됨", + "heroMaps": "{count}개 맵", + "noHeroData": "아직 경쟁 영웅 데이터가 없습니다.", + "tournamentHistory": "토너먼트 이력", + "team": "팀", + "role": "역할", + "record": "전적", + "winRate": "승률", + "noTournamentData": "아직 토너먼트 데이터가 없습니다.", + "matchDetails": "경기 세부 정보", + "opponent": "상대", + "score": "점수", + "result": "결과", + "heroes": "영웅", + "win": "승", + "loss": "패", + "eyebrow": "OWCS 선수", + "tournamentsLabel": "토너먼트", + "date": "날짜", + "historyEyebrow": "토너먼트 기록", + "recordValue": "{wins}–{losses}" + }, + "analytics": { + "scrimOverview": { + "title": "스크림 성과", + "description": "Parsertime 스크림 데이터의 핵심 지표입니다.", + "mvpScore": "MVP 점수", + "kdRatio": "K/D 비율", + "firstPickRate": "첫 픽 비율", + "firstDeathRate": "첫 사망 비율", + "fightReversalRate": "전투 역전율", + "killsPerUltimate": "궁극기당 처치", + "consistencyScore": "일관성", + "mapsPlayed": "플레이한 맵", + "firstPicks": "첫 픽 {count}회", + "firstDeaths": "첫 사망 {count}회", + "outOf100": "/ 100", + "methodology": { + "mvpScore": "글로벌 영웅 평균과 비교한 10분당 지표 기반 점수입니다. 각 지표는 중요도에 따라 최대 ±10점까지 기여합니다(표준편차당 4점). 일반적인 범위는 −100에서 +100입니다.", + "kdRatio": "기록된 모든 스크림에서 총 처치를 총 사망으로 나눈 값입니다. 처치에는 최종 타격과 지원이 모두 포함됩니다.", + "firstPickRate": "이 선수가 개막 처치를 기록한 팀 전투의 비율입니다. 맵별로 측정한 뒤 전체 맵 평균을 사용합니다.", + "firstDeathRate": "이 선수가 전투에서 가장 먼저 사망한 팀 전투의 비율입니다. 높은 값은 공격적인 포지셔닝 또는 집중 견제를 의미할 수 있습니다.", + "fightReversalRate": "이 선수의 팀이 먼저 2명 이상 잃은 뒤 승리한 전투의 비율입니다. 클러치 잠재력을 보여줍니다.", + "killsPerUltimate": "사용한 궁극기당 평균 처치 수입니다. 값이 높을수록 궁극기 활용 효율이 좋다는 뜻입니다.", + "consistencyScore": "맵별 10분당 지표가 얼마나 안정적인지 0-100으로 점수화합니다. 처치, 사망, 피해, 치유의 평균 변동계수 역수로 계산하며 75 이상은 매우 일관적입니다.", + "mapsPlayed": "Parsertime에 기록된 이 선수의 스크림 맵 총수입니다. 모든 스크림 기반 지표의 표본 크기입니다." + }, + "eyebrow": "스크림 프로필", + "sectionTitle": "스크림 성과" + }, + "performanceRadar": { + "title": "성과 프로필", + "description": "전체 Parsertime 선수의 글로벌 영웅 평균 대비 z-점수입니다.", + "aboveAverage": "평균 이상", + "belowAverage": "평균 이하", + "noData": "레이더 차트에 사용할 스크림 데이터가 없습니다.", + "methodology": "각 축은 이 선수의 10분당 지표가 동일 영웅 글로벌 평균에서 표준편차(σ) 기준으로 얼마나 떨어져 있는지 보여줍니다. 같은 영웅을 플레이한 모든 Parsertime 선수의 최소 3맵 이상 데이터를 기준선으로 사용합니다." + }, + "heroZScores": { + "title": "영웅 성과", + "description": "글로벌 기준선과 비교한 영웅별 종합 z-점수입니다.", + "primary": "주 영웅", + "maps": "{count}개 맵", + "delta": "차이", + "significantDropOff": "큰 하락", + "noData": "영웅 성과 데이터가 없습니다.", + "methodology": "종합 z-점수는 각 영웅의 지표별 z-점수(처치, 사망, 피해, 치유, 방어)를 가중 평균한 값입니다. 개별 z-점수는 이 선수의 10분당 지표를 해당 영웅의 글로벌 평균 및 표준편차와 비교합니다. 차이는 주 영웅과 보조 영웅 숙련도 간 격차를 보여줍니다." + }, + "mapWinrates": { + "title": "맵 성과", + "description": "대회 경기의 승률입니다.", + "byMapType": "맵 유형별", + "byMap": "맵별", + "competitive": "대회", + "scrims": "스크림", + "played": "플레이", + "won": "승리", + "noData": "경쟁 맵 데이터가 없습니다.", + "methodologyMapType": "맵 유형별 승률은 이 선수의 팀이 참가한 모든 대회 경기 결과에서 계산됩니다. 비율은 각 게임 모드(Control, Escort, Hybrid, Push, Flashpoint)에서 승리 ÷ 총 플레이 맵입니다.", + "methodologyByMap": "개별 맵 승률은 대회 경기 결과에서 계산됩니다. 테두리는 색으로 구분됩니다: 승률 60% 이상은 초록, 40% 이하는 빨강, 그 외는 중립입니다.", + "empty": "대회 맵 데이터가 없습니다.", + "eyebrow": "맵", + "lowSample": "표본 부족", + "map": "맵", + "mode": "모드", + "winRate": "승률" + }, + "killAnalysis": { + "title": "전투 패턴", + "description": "스크림 데이터의 처치 및 사망 분석입니다.", + "topTargets": "주요 대상", + "topThreats": "주요 위협", + "killMethods": "처치 방식", + "roleDistribution": "역할 분포", + "count": "횟수", + "kills": "{count}킬", + "deaths": "{count}사망", + "methodology": "처치 및 사망 데이터는 Parsertime 스크림 로그에서 가져옵니다. 주요 대상과 위협은 총량 기준으로 가장 많이 처치한 영웅과 가장 많이 사망한 상대 영웅 상위 5개를 보여줍니다. 처치 방식은 스킬 유형별로 그룹화됩니다. 역할 분포는 모든 스크림의 영웅 플레이 시간을 기준으로 Tank, Damage, Support 비율을 반영합니다." + }, + "strengthsWeaknesses": { + "title": "스카우팅 인사이트", + "description": "대회 및 스크림 데이터에서 자동 생성된 강점과 약점입니다.", + "strengths": "강점", + "weaknesses": "약점", + "noStrengths": "강점을 식별하기에 데이터가 부족합니다.", + "noWeaknesses": "뚜렷한 약점이 식별되지 않았습니다.", + "methodology": "인사이트는 z-점수(글로벌 평균 대비 영웅 성과), 대회 맵 승률, 첫 픽/첫 사망 비율, 일관성 점수, 영웅 풀 깊이를 분석해 자동 생성됩니다. 강점은 긍정 임계값을 넘을 때, 약점은 부정 임계값에 도달할 때 표시됩니다. 모든 데이터 소스를 결합해 종합적인 관점을 제공합니다.", + "eyebrow": "스카우팅 분석", + "readTitle": "핵심 정리" + }, + "noScrimData": "이 선수는 아직 Parsertime 스크림 데이터가 없습니다. 대회 분석과 토너먼트 이력은 계속 사용할 수 있습니다.", + "statLabels": { + "eliminations": "처치", + "deaths": "사망", + "hero_damage_dealt": "영웅 피해", + "healing_dealt": "치유", + "damage_blocked": "방어한 피해" + } + }, + "searchEyebrow": "OWCS 선수" + }, + "team": { + "metadata": { + "title": "{team} 스카우팅 | Parsertime", + "description": "{team} 스카우트 — 경기 이력, 영웅 밴, 맵 성과, 전략 추천을 확인하세요.", + "ogTitle": "{team} 스카우팅 | Parsertime", + "ogDescription": "{team} 스카우트 — 경기 이력, 영웅 밴, 맵 성과, 전략 추천을 확인하세요." + }, + "backToSearch": "검색으로 돌아가기", + "tabs": { + "overview": "개요", + "heroBans": "영웅 밴", + "maps": "맵", + "players": "로스터 준비", + "report": "리포트", + "recommendations": "추천" + }, + "picker": { + "label": "스카우팅 기준:", + "placeholder": "팀 선택", + "noTeam": "선택된 팀 없음" + }, + "overview": { + "record": "전적", + "winRate": "승률", + "weightedWinRate": "가중 승률", + "weightedPercent": "{value} 가중", + "weightedWinRateTooltip": "최근 경기에 더 높은 비중을 두는 최신성 가중 승률입니다.", + "recordQuality": "전적 품질", + "recordQualityValue": "{total}승 중 {wins}승", + "recordQualityDescription": "1500점 이상 팀을 상대로 거둔 승리입니다", + "strengthRating": "전력 등급", + "provisionalRating": "임시 등급 — 5경기 미만", + "topPercentile": "상위 {value}", + "matchesRated": "{count}경기 평가됨", + "noCompetitiveData": "사용 가능한 경쟁전 경기 데이터가 없습니다. 상대를 지정해 스크림을 태그하면 교차 참조 분석을 사용할 수 있습니다.", + "winRateTrend": "승률 추세: 최근 {count}경기 기준 {value}", + "recentForm": "최근 폼", + "recentFormDescription": "최근 10경기 결과", + "matchHistory": "경기 이력", + "matchHistoryDescription": "기록된 모든 경기, 최신순", + "date": "날짜", + "opponent": "상대", + "score": "점수", + "result": "결과", + "tournament": "토너먼트", + "win": "승", + "loss": "패", + "matchCount": "{count}경기", + "allTime": "전체 승률", + "noMatches": "이 팀의 경기를 찾을 수 없습니다.", + "previousPage": "이전", + "nextPage": "다음", + "pageInfo": "{current}/{total} 페이지", + "methodology": { + "title": "이 통계를 계산하는 방법", + "points": { + "0": "승률은 이 팀의 기록된 모든 OWCS 경기에서 계산됩니다.", + "1": "가중 승률은 90일 반감기의 지수 감쇠를 사용합니다. 90일 전 경기는 오늘 경기의 절반만큼 반영됩니다.", + "2": "최근 폼은 최근 10경기 결과를 최신순으로 보여줍니다. 스파크라인은 해당 경기들의 승패 흐름을 보여줍니다.", + "3": "Strength Rating은 1500을 중심으로 한 Elo 방식 점수입니다. 강한 상대를 이기면 약한 상대를 이길 때보다 더 많이 상승합니다. 5경기 미만이면 임시 등급으로 표시됩니다.", + "4": "Record Quality는 1500점 이상 팀을 상대로 얻은 승리 수를 집계해 좋은 전적이 강한 경쟁에서 나온 것인지 보여줍니다.", + "5": "경기 데이터는 Liquipedia에서 가져오며 최근 1년 OWCS 토너먼트를 다룹니다. 팀 선택기를 사용하면 업로드한 스크림 데이터도 반영됩니다." + } + }, + "eyebrow": "개요", + "title": "폼 & 전적" + }, + "heroBans": { + "bansAgainstTeam": "상대가 이 팀에게 한 밴", + "bansAgainstDescription": "상대가 이 팀을 상대할 때 밴하는 영웅입니다. 인식된 강점을 보여줍니다.", + "bansByTeam": "이 팀의 밴", + "bansByDescription": "이 팀이 밴하는 영웅입니다. 상대에게 허용하고 싶지 않은 것을 보여줍니다.", + "hero": "영웅", + "count": "횟수", + "weighted": "가중", + "noBans": "사용 가능한 영웅 밴 데이터가 없습니다.", + "selectTeamTitle": "상대 밴 대상을 보려면 팀을 선택하세요", + "selectTeamDescription": "위의 “스카우팅 기준” 선택기에서 팀을 선택하면 상대가 우리 팀의 어떤 영웅을 노리는지 확인할 수 있습니다.", + "disruptionTargets": "방해 대상", + "disruptionTargetsEmptyDescription": "예상 방해 효과 기준으로 정렬한 상대 밴 후보입니다", + "disruptionTargetsDescription": "상대를 상대로 밴할 영웅입니다. 방해 점수가 높을수록 밴했을 때 상대 승률에 더 큰 영향을 줍니다.", + "notEnoughBanData": "방해 점수를 계산하기에 밴 데이터가 부족합니다.", + "percentagePoints": "{value}pp", + "deltaWhenBanned": "밴 시 {delta}", + "availabilitySummary": "사용 가능 {available}맵 / 밴 {banned}맵", + "showFewer": "간단히 보기", + "showMore": "{count}개 더 보기", + "theirBanTargets": "상대의 밴 대상", + "theirBanTargetsDescription": "상대 밴 패턴과 우리 팀의 주력 영웅이 얼마나 겹치는지 보여줍니다", + "theirBanTargetsActiveDescription": "상대 밴 패턴과 우리 팀의 주력 영웅을 교차 참조합니다", + "noBanTargetOverlap": "상대 밴과 우리 영웅 풀 사이에 겹치는 항목이 없습니다.", + "riskCritical": "치명", + "riskWatch": "주의", + "riskSafe": "안전", + "exposureSummary": "— 상대는 이 영웅을 맵의 {opponentBanRate}에서 밴하고, 우리 팀은 {userPlayRate}의 시간 동안 플레이합니다", + "protectedHeroes": "보호된 영웅", + "protectedHeroesDescription": "이 팀이 거의 밴하지 않는 영웅입니다. 조합에 등장할 가능성이 높습니다.", + "methodology": { + "title": "영웅 밴을 분석하는 방법", + "points": { + "0": "각 OWCS 맵에서는 팀마다 영웅을 하나씩 밴할 수 있습니다. 이 팀이 밴한 영웅과 이 팀을 상대로 밴된 영웅을 모든 맵에서 추적합니다.", + "1": "가중 횟수는 90일 반감기의 지수 감쇠를 적용해 오래된 패턴보다 최근 밴 패턴을 우선합니다.", + "2": "Disruption Score는 특정 영웅 밴이 상대에게 얼마나 큰 피해를 주는지 측정합니다. 승률 차이와 표본 신뢰도를 결합하며 10 초과는 의미 있고 20 초과는 고영향 밴 대상입니다.", + "3": "Win-Rate Delta(pp)는 퍼센트포인트 단위입니다. 예를 들어 '밴 시 −22pp'는 해당 영웅이 없을 때 상대 승률이 22퍼센트포인트 감소한다는 뜻입니다.", + "4": "Their Ban Targets는 상대의 밴 이력과 우리 팀이 많이 플레이한 영웅을 교차 참조합니다. Critical은 상대가 맵의 30% 이상에서 밴하고 우리 팀이 10% 이상 플레이한 경우입니다.", + "5": "Protected Heroes는 이 팀이 맵의 5% 미만에서만 밴하는 영웅입니다. 이들은 조합의 핵심이라 열어두고 싶어 하는 영웅일 수 있습니다." + } + }, + "eyebrow": "영웅 밴", + "title": "밴 환경", + "delta": "변화" + }, + "maps": { + "byMapType": "맵 유형별 승률", + "byMapTypeDescription": "게임 모드별 성과 분석", + "byMap": "맵별 성과", + "byMapDescription": "개별 맵 승률", + "mapType": "모드", + "map": "맵", + "played": "플레이", + "won": "승리", + "winRate": "승률", + "rawWinRate": "원 승률", + "weightedWinRate": "가중 승률", + "trend": "추세", + "noMaps": "사용 가능한 맵 데이터가 없습니다.", + "advisorTitle": "맵 밴픽 어드바이저", + "advisorDescription": "순이점 기준으로 맵을 정렬합니다. 위쪽 맵은 선택하고 아래쪽 맵은 밴하세요.", + "crossReferenceAvailable": "교차 참조 가능", + "matchupMatrixLabel": "맵 매치업 매트릭스", + "vetoRecommendation": "밴픽 추천", + "pick": "선택", + "ban": "밴", + "opponentPerformanceDescription": "상대 맵 성과, 전력 가중 승률, 추세 지표입니다", + "percentagePoints": "{value}pp", + "netAdvantageLabel": "{map}: 순이점 {advantage}", + "versus": "대", + "stableTrend": "안정 추세({delta})", + "improvingTrend": "최근 개선({delta}) — 주의", + "decliningTrend": "최근 하락({delta}) — 기회", + "selectTeamTitle": "맵 매치업을 열려면 팀을 선택하세요", + "selectTeamDescription": "위의 “스카우팅 기준” 선택기에서 팀을 선택하면 교차 참조 맵 밴픽 분석을 사용할 수 있습니다.", + "methodology": { + "title": "맵 성과와 매치업을 측정하는 방법", + "points": { + "0": "맵별 승률은 시리즈 승패가 아니라 각 시리즈 안의 개별 맵 결과로 계산됩니다.", + "1": "전력 가중 승률은 Elo 등급을 사용해 상대 수준을 보정합니다. 상위권 팀 상대로 60% 승률은 하위권 팀 상대로 80%보다 더 가치 있을 수 있습니다.", + "2": "순이점(pp)은 우리 맵 승률과 상대의 전력 가중 승률 사이의 퍼센트포인트 차이입니다.", + "3": "추세 화살표는 최근 10맵과 전체 전적을 비교합니다. +10pp 초과는 개선, −10pp 미만은 하락, 그 외는 안정입니다.", + "4": "신뢰도 점은 표본 크기를 나타냅니다. 초록 채움은 20맵 이상, 주황은 10-19맵, 흐림은 5-9맵입니다.", + "5": "팀 선택기를 사용하면 우리 팀의 스크림 맵 데이터가 상대의 OWCS 데이터와 결합됩니다. 둘 다 같은 방식으로 감쇠 가중됩니다." + } + }, + "eyebrow": "맵", + "title": "맵 성적", + "matchupTitle": "맵 매치업", + "netAdvantageHeader": "순이점", + "lowSample": "표본 부족" + }, + "recommendations": { + "title": "전략 추천", + "description": "이 팀의 최신성 가중 성과 분석을 기반으로 한 실행 가능한 인사이트입니다.", + "heroesToBan": "밴할 영웅", + "heroesToBanDescription": "상대가 이 팀을 상대로 가장 자주 밴하는 영웅입니다. 이 팀의 인식된 강점입니다.", + "mapsToPick": "선택할 맵", + "mapsToPickDescription": "이 팀의 가중 승률이 가장 낮은 맵입니다. 약점을 공략하세요.", + "mapsToAvoid": "피할 맵", + "mapsToAvoidDescription": "이 팀의 가중 승률이 가장 높은 맵입니다. 강점을 피하세요.", + "weightedWinRate": "가중 승률", + "sampleSize": "{count}경기", + "noRecommendations": "추천을 생성하기에 데이터가 부족합니다.", + "confidence": { + "high": "높은 신뢰도", + "medium": "중간 신뢰도", + "low": "낮은 신뢰도", + "insufficient": "데이터 부족" + }, + "methodology": { + "title": "추천을 생성하는 방법", + "points": { + "0": "밴할 영웅은 상대들이 이 팀을 상대로 가장 자주 밴한 영웅입니다. 이를 밴하면 이 팀의 인식된 강점을 차단합니다.", + "1": "선택할 맵은 이 팀의 최신성 가중 승률이 가장 낮은 맵입니다(최소 3경기).", + "2": "피할 맵은 이 팀의 최신성 가중 승률이 가장 높은 맵입니다.", + "3": "신뢰도는 표본 크기를 기준으로 합니다. 높음은 10경기 이상, 중간은 5-9경기, 낮음은 5경기 미만입니다.", + "4": "모든 지표는 90일 반감기의 지수 감쇠를 사용해 오래된 결과보다 현재 메타를 반영합니다." + } + } + }, + "players": { + "selectTeamTitle": "로스터 준비도를 열려면 팀을 선택하세요", + "selectTeamDescription": "위의 “스카우팅 기준” 선택기에서 팀을 선택하세요. 이 탭은 우리 선수들의 영웅 깊이와 상대 밴 패턴을 교차 참조해 누가 가장 위험한지 식별합니다.", + "matchupSummary": "우리 로스터의 영웅 깊이와 취약성을 {opponentName} 상대 기준으로 보여줍니다. 주 영웅이 자주 밴되고 영웅 풀이 좁은 선수는 위험 대상으로 표시됩니다.", + "thisOpponent": "이 상대", + "atRiskPlayers": "위험 선수", + "atRiskPlayersDescription": "영웅 풀 깊이와 밴 노출도를 기준으로, 이 상대의 밴 전략에 가장 취약한 우리 선수들입니다", + "vulnerabilitySummary": "{role} · {hero} · 밴율 {banRate}", + "rolePlayers": "우리 {role} 선수", + "roleDescription": "다른 {role} 선수와 비교한 영웅 깊이 및 성과입니다", + "unknownHero": "알 수 없음", + "noSecondary": "보조 영웅 없음", + "delta": "차이: {value}", + "sigmaValue": "{sign}{value}σ", + "significantDropOff": "큰 하락", + "banExposure": "밴 노출: {riskLevel}", + "banExposureDescription": "이 상대는 {hero}를 맵의 {banRate}에서 밴합니다. 주 영웅에서 밀려나면 z-점수가 {delta}만큼 떨어집니다", + "unknownAmount": "알 수 없는 수치", + "primaryBadge": "1순위", + "roles": { + "tank": "탱커", + "damage": "딜러", + "support": "서포터" + }, + "riskLevels": { + "critical": "치명", + "high": "높음", + "moderate": "보통", + "low": "낮음" + }, + "riskAriaCritical": "치명적 취약성", + "riskAriaHigh": "높은 취약성", + "riskAriaModerate": "보통 취약성", + "riskAriaLow": "낮은 취약성", + "methodology": { + "title": "로스터 준비도를 평가하는 방법", + "points": { + "0": "이 탭은 특정 상대를 기준으로 우리 로스터의 준비도를 보여줍니다. 영웅 깊이와 상대 밴 패턴을 바탕으로 위험한 선수를 식별합니다.", + "1": "Z-점수는 각 영웅의 10분당 지표가 로스터 내 같은 역할의 모든 영웅 성과와 어떻게 비교되는지 측정합니다.", + "2": "지표는 역할별로 가중됩니다. Tank: 사망(30% 역산), 처치(20%), 피해(20%). Damage: 처치(30%), 피해(30%), 사망(20% 역산). Support: 치유(35%), 사망(25% 역산), 피해(14%), 처치(10%).", + "3": "주/보조 영웅 차이는 선수의 최고 영웅과 두 번째 영웅 간 z-점수 격차입니다. 1.5σ를 넘으면 주 영웅에서 밀려났을 때 큰 하락이 발생합니다.", + "4": "Vulnerability Index = 영웅 깊이 차이 × 상대 밴율입니다. 0.5 초과는 치명, 0.25-0.5는 높음, 0.1-0.25는 보통, 0.1 미만은 낮음입니다.", + "5": "포함되려면 영웅은 최소 3맵과 2분 이상의 플레이 시간이 필요합니다. 의미 있는 신뢰도에는 선수당 최소 5맵이 필요합니다." + } + }, + "eyebrow": "로스터 준비", + "title": "로스터 대비 상태", + "bestPlayer": "핵심 활약 선수", + "mapsPlayed": "{count, plural, other {#개 맵 플레이}}", + "standoutBanTarget": "이 상대의 표적 — 주 영웅이 상대 맵의 {banRate}에서 밴됨" + }, + "report": { + "title": "데이터가 말하는 것", + "scrimOnly": "스크림 데이터만 있음 — 경쟁전 이력이 없습니다", + "selectTeamTitle": "전체 리포트를 보려면 팀을 선택하세요", + "selectTeamDescription": "맵 매치업과 선수 취약성 같은 교차 참조 인사이트는 위의 “스카우팅 기준” 선택기에서 팀을 선택해야 표시됩니다. 아래에는 상대 전용 인사이트만 표시됩니다.", + "topInsights": "주요 인사이트", + "additionalFindings": "{count}개 추가 발견", + "noDataTitle": "아직 데이터가 부족합니다", + "noDataDescription": "더 많은 경기가 기록된 뒤 다시 확인하세요. 신뢰할 수 있는 인사이트에는 최소 표본 크기가 필요합니다.", + "competitiveMaps": "{formattedCount}개 경쟁전 맵", + "scrimMaps": "{formattedCount}개 스크림 맵", + "basedOn": "{sources} 기준", + "sourceJoin": "{first} 및 {second}", + "methodology": { + "title": "리포트를 생성하는 방법", + "points": { + "0": "인사이트는 우리 팀이 업로드한 스크림 데이터와 상대의 OWCS 대회 이력을 교차 참조해 생성됩니다.", + "1": "맵 매치업 인사이트는 우리 승률과 상대 전력 가중 승률의 순이점이 ±15pp를 넘을 때 표시됩니다.", + "2": "밴 방해 인사이트는 특정 영웅 밴이 상대 승률을 크게 떨어뜨릴 때 표시됩니다.", + "3": "선수 취약성 인사이트는 주 영웅이 보조 영웅보다 크게 좋고, 그 영웅이 상대에게 자주 밴될 때 표시됩니다.", + "4": "각 인사이트는 이점의 크기, 신뢰도, 실행 가능성을 결합한 1-100 우선순위 점수를 가집니다.", + "5": "신뢰도 단계 — 높음: 20맵 이상. 중간: 10-19. 낮음: 5-9. 부족: 5 미만. 스크림 전용 데이터는 더 엄격한 기준을 사용합니다.", + "6": "각 인사이트에는 데이터 출처가 표시됩니다. 'Competitive'는 OWCS 경기만, 'Scrim'은 업로드한 스크림 데이터만, 'Competitive + Scrim'은 둘 다 사용한 것입니다." + } + }, + "eyebrow": "스카우팅 리포트", + "subtitle": "우선순위가 높은 항목부터 — 우리의 강점과 상대의 위협.", + "confidenceLabel": "{level} 신뢰도", + "confidence": { + "high": "높음", + "medium": "보통", + "low": "낮음", + "insufficient": "부족" + }, + "emptyLinked": "이 매치업에 대해 신뢰할 만한 분석을 내놓기에는 아직 데이터가 부족합니다.", + "emptyUnlinked": "위에서 우리 팀을 선택하면 매치업·밴·선수 노출을 교차 분석합니다.", + "showFewer": "간략히 보기", + "showMore": "{count}개 더 보기", + "sampleMaps": "{count, plural, other {#개 맵}}", + "source": { + "owcs": "OWCS", + "scrim": "스크림", + "owcs+scrim": "OWCS + 스크림" } + }, + "empty": "이 팀에 사용할 수 있는 스카우팅 데이터가 없습니다.", + "header": { + "eyebrow": "OWCS 스카우팅" + }, + "strengthExplainer": { + "trigger": "전력 등급이란?", + "title": "전력 등급 계산 방식", + "intro": "1500을 기준으로 한 Elo 방식의 경기 성과 등급입니다.", + "elo": { + "label": "Elo 기반.", + "body": "강한 팀을 이기면 약한 팀을 이길 때보다 등급이 더 오릅니다." + }, + "quality": { + "label": "전적의 질.", + "body": "1500 이상 팀을 상대로 한 승리가 약체 상대 승리보다 가치가 큽니다." + }, + "weighted": { + "label": "최신 가중치.", + "body": "최근 경기가 오래된 경기보다 더 큰 비중을 가집니다 (반감기 90일)." + }, + "provisional": { + "label": "5경기 미만은 잠정치.", + "body": "표본이 적은 등급은 표시되며 과대 해석하지 않는 것이 좋습니다." + } + }, + "faceitLink": { + "eyebrow": "FACEIT 신호", + "title": "FACEIT에서는 {name}(으)로 활동", + "subtitle": "이 로스터는 FACEIT 팀과 일치합니다 — OWCS 전적만으로는 알 수 없는 개인 기량 지표를 더해 줍니다.", + "aggregateFsr": "로스터 FSR", + "coverage": "일치 선수 {shared}명 중 {covered}명 평가됨", + "viewProfile": "FACEIT 프로필 보기", + "matchedPlayers": "일치한 선수", + "disclaimer": "닉네임 일치와 공유된 FACEIT 경기로 추론된 결과이며, 확인된 동일인은 아닙니다." } + }, + "searchEyebrow": "OWCS 스카우팅" + }, + "faceitScoutingPage": { + "title": "FACEIT 팀 스카우팅", + "subtitle": "팀을 검색하여 그들의 플레이 방식과 대응 전략을 확인하세요.", + "search": { + "placeholder": "팀 검색…", + "matches": "{count}경기", + "noResults": "팀을 찾을 수 없습니다" + }, + "strengthFsr": "FSR", + "strengthTsr": "TSR", + "coverage": "{covered}/{size} 평가됨", + "tabs": { + "overview": "개요", + "maps": "맵", + "bans": "영웅 밴", + "roster": "로스터", + "recommendations": "추천" + }, + "overview": { + "record": "전적", + "winRate": "승률", + "weightedWinRate": "가중 승률", + "form": "최근 폼", + "tierDistribution": "플레이한 티어", + "strength": "팀 전력", + "eyebrow": "개요", + "title": "플레이 방식", + "matchesPlayed": "{count}경기", + "weightedSub": "가중 {winRate}%", + "win": "승", + "loss": "패", + "noForm": "기록된 경기가 없습니다." + }, + "maps": { + "byMap": "맵별", + "byType": "모드별", + "map": "맵", + "mode": "모드", + "played": "플레이", + "won": "승리", + "winRate": "승률", + "weighted": "가중", + "lowSample": "표본 부족", + "attackDefense": "공격 vs 수비", + "attacking": "공격 선공", + "defending": "수비 선공", + "eyebrow": "맵", + "title": "맵 성과", + "empty": "맵 데이터가 없습니다.", + "mapsWon": "{won}/{played}맵" + }, + "bans": { + "title": "영웅 밴 환경", + "caveat": "맵 단위 신호: 영웅이 밴 풀에 있을 때의 승률입니다. FACEIT은 밴을 특정 진영에 귀속하지 않습니다.", + "hero": "영웅", + "withBan": "밴 있음", + "withoutBan": "밴 없음", + "delta": "차이", + "sample": "표본", + "showUnrated": "표본 부족 영웅 표시", + "eyebrow": "영웅 밴", + "empty": "밴 데이터가 없습니다.", + "banTarget": "밴 추천", + "hideUnrated": "표본 부족 영웅 숨기기" + }, + "roster": { + "player": "선수", + "role": "역할", + "share": "출전", + "starter": "주전", + "sub": "후보", + "fsr": "FSR", + "tsr": "TSR", + "eyebrow": "로스터", + "title": "로스터" + }, + "recommendations": { + "pickMaps": "선택할 맵", + "avoidMaps": "피하거나 밴할 맵", + "banHeroes": "밴 고려 영웅", + "dontBan": "밴하지 말 것", + "none": "아직 추천을 생성하기에 데이터가 부족합니다.", + "mapReason": "{played}개 맵에서 {winRate}% 승률", + "heroReason": "밴 없이 {withoutBan}% vs 밴 시 {withBan}% (n={bannedSample}/{notBannedSample})" + }, + "related": { + "title": "다른 이름으로 참가", + "combine": "이력 합산", + "separate": "이력 분리", + "shared": "공유 선수 {n}명", + "matches": "{n}경기" + }, + "header": { + "eyebrow": "FACEIT 팀 스카우트" + }, + "gamePlan": { + "eyebrow": "게임 플랜", + "title": "대응 전략", + "subtitle": "상대의 맵·밴 경향을 종합했습니다. 표본 크기 포함.", + "forceMaps": "유도할 맵", + "banHeroes": "밴할 영웅", + "avoidMaps": "피하거나 밴할 맵", + "dontBan": "밴 낭비 금지", + "none": "아직 게임 플랜을 세우기에 데이터가 부족합니다." + }, + "searchEyebrow": "FACEIT 스카우팅", + "metadata": { + "profileTitle": "{team} · FACEIT 스카우팅 | Parsertime", + "profileDescription": "{team} 스카우팅 리포트: FSR, 맵 승률, 영웅 밴 경향, 로스터, 그리고 대응 전략.", + "searchTitle": "FACEIT 팀 스카우팅 | Parsertime" + }, + "patches": { + "eyebrow": "패치 타임라인", + "title": "패치별 폼", + "description": "밸런스 패치에 따라 성적이 어떻게 변하는지 보여줍니다. 메타가 빠르게 바뀌므로 최근 패치일수록 더 중요합니다.", + "patch": "패치", + "winRate": "승률", + "matches": "경기", + "mostBanned": "최다 밴", + "preTracking": "패치 추적 이전", + "through2025": "2025년까지", + "midSeason": "시즌 중 · {date}", + "now": "현재", + "lowSample": "부족", + "noRecent": "패치 추적이 시작된 이후(2026년 1월) 경기가 없습니다." + } + }, + "faceitPlayerPage": { + "title": "FACEIT 선수 스카우팅", + "subtitle": "선수를 검색하여 FSR, 스탯 프로필 및 전적을 확인하세요.", + "search": { + "placeholder": "선수 검색…", + "matches": "{count}명", + "noResults": "선수를 찾을 수 없습니다", + "unrated": "미평가" + }, + "header": { + "eyebrow": "선수", + "yes": "예", + "no": "아니오", + "region": "지역", + "level": "FACEIT 레벨", + "verified": "인증됨", + "team": "현재 팀" + }, + "fsr": { + "eyebrow": "FSR", + "title": "FSR", + "headline": "대표 FSR", + "byTier": "티어별", + "tier": "티어", + "rating": "FSR", + "maps": "맵", + "percentile": "백분위", + "primary": "주요", + "unrated": "FSR을 산출하기에 평가된 맵이 부족합니다.", + "recent": "최근 365일", + "ratingScale": "척도" + }, + "radar": { + "title": "스탯 프로필", + "tierLabel": "티어", + "vsPeers": "티어 동급 대비 스탯별 z-점수" + }, + "insights": { + "strengths": "강점", + "weaknesses": "약점", + "none": "두드러진 스탯 없음" + }, + "roles": { + "eyebrow": "역할", + "title": "역할 사용 현황", + "role": "역할", + "maps": "맵", + "share": "비율" + }, + "maps": { + "eyebrow": "맵", + "title": "맵 승률", + "byMap": "맵별", + "byType": "모드별", + "map": "맵", + "mode": "모드", + "played": "플레이", + "won": "승리", + "winRate": "승률", + "lowSample": "표본 부족", + "empty": "맵 데이터가 없습니다." + }, + "history": { + "eyebrow": "이력", + "title": "매치 이력", + "date": "날짜", + "team": "팀", + "opponent": "상대", + "tier": "티어", + "score": "스코어", + "result": "결과", + "role": "역할", + "win": "승", + "loss": "패", + "empty": "매치를 찾을 수 없습니다", + "count": "{count}경기 집계" + }, + "teams": { + "eyebrow": "팀", + "title": "소속 팀", + "appearances": "{count}경기" + }, + "stat": { + "eliminations": "처치", + "finalBlows": "최후 일격", + "deaths": "사망 (역산)", + "damageDealt": "피해량", + "healingDone": "치유량", + "damageMitigated": "경감량", + "soloKills": "단독 처치", + "assists": "도움", + "objectiveTime": "목표 점령 시간" + }, + "threat": { + "eyebrow": "스카우팅 요약", + "title": "위협 평가", + "headlineRating": "{role} 레이팅", + "percentile": "{tier} {role} 선수 중 {pct}%보다 우수", + "threatensWith": "주요 위협", + "exploit": "공략 포인트", + "strongestMap": "강한 맵", + "weakestMap": "약한 맵", + "trackedRecord": "집계 전적", + "matchesTracked": "{count}경기 집계", + "zAbove": "동급 대비 +{z}", + "zBelow": "동급 대비 {z}", + "mapDetail": "{played}개 맵에서 {winRate}%", + "unratedEyebrow": "레이팅", + "unratedTitle": "미평가", + "unratedBody": "FSR을 산출하기에 평가된 맵이 부족합니다. 아래 맵·매치 이력은 참고할 수 있습니다.", + "noInsights": "아직 요약하기에 데이터가 부족합니다." + }, + "searchEyebrow": "FACEIT 스카우팅", + "metadata": { + "profileTitle": "{player} · FACEIT 스카우팅 | Parsertime", + "profileDescription": "{player} 스카우팅 리포트: 역할·티어별 FSR, 스탯 프로필, 맵 승률, 매치 이력.", + "searchTitle": "FACEIT 선수 스카우팅 | Parsertime" } }, "statsPage": { @@ -1153,6 +4052,87 @@ "ogDescription": "Parsertime에서 {hero}의 통계입니다. Parsertime은 오버워치 스크림을 분석하는 도구입니다.", "ogImage": "{hero}의 통계" }, + "mapHeroTrends": { + "eyebrow": "맵 메타 · 최근 60일", + "eyebrowWithCount": "맵 메타 · 최근 60일 · 고유 맵 {count, number}개", + "empty": { + "title": "최근 맵 데이터가 없습니다", + "description": "최근 60일 안에 스크림을 업로드하면 이 보기가 채워집니다." + }, + "stats": { + "scrims": "스크림", + "maps": "맵", + "heroes": "영웅" + }, + "allMaps": "모든 맵", + "searchMaps": "맵 검색...", + "noMapsFound": "맵을 찾을 수 없습니다.", + "sortLabel": "정렬", + "roleFilter": "역할 필터", + "noHeroesMatch": "선택한 필터와 일치하는 영웅이 없습니다.", + "showPickRateTrend": "{hero} 선택률 추세 보기", + "record": "{wins, number}승 · {losses, number}패", + "playtimeHoursMinutes": "{hours}시간 {minutes}분", + "playtimeMinutes": "{minutes}분", + "roles": { + "all": "전체", + "tank": "탱커", + "damage": "딜러", + "support": "지원" + }, + "subroles": { + "hitscanDamage": "히트스캔 딜러", + "flexDamage": "플렉스 딜러", + "groundTank": "그라운드 탱커", + "diveTank": "다이브 탱커", + "flexSupport": "플렉스 지원가", + "mainSupport": "메인 지원가" + }, + "mapTypes": { + "control": "쟁탈", + "escort": "호위", + "hybrid": "혼합", + "push": "밀기", + "flashpoint": "플래시포인트", + "clash": "격돌", + "other": "기타" + }, + "sort": { + "pickRate": "선택률", + "playtime": "플레이 시간", + "winrate": "승률", + "trend": "추세" + }, + "table": { + "hero": "영웅", + "role": "역할", + "playtime": "플레이 시간", + "pick": "선택", + "winrate": "승률", + "sample": "표본", + "trend": "30일 변화" + } + }, + "compareStats": { + "eyebrow": "통계 · 플레이어 비교", + "title": "플레이어 통계 비교", + "enterPlayerNames": "플레이어 이름 입력", + "player1": "플레이어 1", + "player2": "플레이어 2", + "playerA": "플레이어 A", + "playerB": "플레이어 B", + "player1Placeholder": "첫 번째 플레이어 이름 입력", + "player2Placeholder": "두 번째 플레이어 이름 입력", + "compare": "비교", + "reset": "초기화", + "loading": "플레이어 통계 로드 중...", + "errorLoading": "플레이어 데이터를 불러오는 중 오류가 발생했습니다", + "errorPlayer1": "플레이어 1 데이터를 불러오지 못했습니다", + "errorPlayer2": "플레이어 2 데이터를 불러오지 못했습니다", + "errorBoth": " 및 ", + "enterPlayersPrompt": "통계를 비교하려면 위에 두 플레이어 이름을 입력하세요", + "filters": "필터" + }, "title": "통계", "searchbar": { "placeholder": "플레이어 이름을 입력하세요...", @@ -1248,6 +4228,9 @@ "all-time-data": "전체 기간 데이터에서", "custom": "{from} - {to} 사이" }, + "clientDate": { + "formatDate": "{date, date, ::EEE MMM dd yyyy}" + }, "rangePicker": { "selectTime": "기간 선택", "lastWeek": "지난주", @@ -1268,6 +4251,27 @@ "damage": "딜러", "support": "지원" }, + "heroFilter": { + "selectHeroes": "영웅 선택", + "allHeroes": "모든 영웅", + "heroesSelected": "{count}명의 영웅 선택됨", + "searchHeroes": "영웅 검색...", + "noHeroesFound": "영웅을 찾을 수 없습니다", + "quickSelect": "빠른 선택", + "reset": "초기화", + "allTanks": "모든 탱커", + "allDamage": "모든 딜러", + "allSupport": "모든 지원가", + "hitscanDPS": "히트스캔 딜러", + "flexDPS": "플렉스 딜러", + "groundTanks": "그라운드 탱커", + "diveTanks": "다이브 탱커", + "flexSupports": "플렉스 지원가", + "mainSupports": "메인 지원가", + "tank": "탱커", + "damage": "딜러", + "support": "지원" + }, "mostPlayed": { "title": "가장 많이 플레이한 영웅", "rank": "순위", @@ -1378,11 +4382,54 @@ "environmental_kills": "환경 처치", "deaths": "죽음", "hero_damage_dealt": "가한 영웅 피해" + }, + "identity": { + "scrims": "스크림", + "games": "맵", + "hours": "시간", + "winrate": "승률", + "winrateSub": "{total}개 중 {wins}개 승리", + "scopeTimeframe": "스크림 {scrims}회 · 지난 {timeframe}", + "scopeAllTime": "스크림 {scrims}회 · 전체 기간", + "scopeCustom": "스크림 {scrims}회 · {from} → {to}", + "scopeCustomEmpty": "통계를 보려면 기간을 선택하세요" + }, + "heroPortfolio": { + "title": "영웅 포트폴리오", + "description": "이 기간 동안 가장 많이 플레이한 영웅입니다. 클릭하여 페이지 전체에 필터를 적용하세요.", + "empty": "이 기간 동안 플레이한 영웅이 없습니다", + "games": "맵", + "finalBlows": "마지막 일격", + "deaths": "죽음", + "role": { + "tank": "탱커", + "damage": "딜러", + "support": "지원" + } + }, + "sections": { + "overview": "개요", + "breakdown": "성과 분석", + "form": "폼", + "maps": "맵 기록", + "habits": "습관", + "combat": "전투" + }, + "performanceBreakdown": { + "description": "선택한 영웅의 CSR 리더보드 내 순위와 동일한 동료 풀과의 Z-점수 비교입니다.", + "selectOne": "위의 영웅 포트폴리오에서 영웅 하나를 선택하면 SR 분포와 Z-점수 분석을 볼 수 있습니다.", + "loading": "분석 데이터를 불러오는 중…", + "error": "이 영웅의 분석 데이터를 불러올 수 없습니다.", + "notRanked": "{hero}에 대한 비교 데이터가 부족합니다 (플레이어당 60초 이상 플레이된 맵이 10개 이상 필요).", + "distributionLabel": "SR 분포 · {hero}", + "vsLabel": "다른 {hero} 플레이어와의 Z-점수 비교", + "explainer": "각 축은 선택한 영웅에서 이 플레이어가 평균보다 얼마나 표준편차만큼 위(양수) 또는 아래(음수)에 있는지를 보여줍니다. CSR 리더보드와 동일한 데이터를 사용합니다." } }, "heroStats": { "title": "영웅 통계", - "description": "영웅을 선택하여 통계를 확인하세요. 통계는 플레이된 모든 스크림에서 집계됩니다.", + "pickerTitle": "영웅 통계", + "description": "영웅을 선택하여 모든 스크림에서의 성과를 확인하세요.", "timeframe": { "one-week": "1주", "two-weeks": "2주", @@ -1482,13 +4529,50 @@ "environmental_kills": "환경 처치", "deaths": "죽음", "hero_damage_dealt": "가한 영웅 피해" + }, + "identity": { + "games": "경기", + "totalKills": "킬", + "totalDeaths": "데스", + "kd": "K / D", + "scopeTimeframe": "스크림 {scrims}회 · 지난 {timeframe}", + "scopeAllTime": "스크림 {scrims}회 · 전체 기간", + "scopeCustom": "스크림 {scrims}회 · {from} → {to}", + "scopeCustomEmpty": "통계를 보려면 기간을 선택하세요" + }, + "sections": { + "overview": "개요", + "talent": "플레이어 풀", + "form": "폼", + "combat": "전투" + }, + "talent": { + "description": "이 영웅에서 CSR 자격이 있는 플레이어 풀의 분포와 CSR 상위 플레이어입니다.", + "distribution": "SR 분포", + "topPlayers": "상위 플레이어", + "topCount": "상위 {count}명", + "notRanked": "이 영웅에 대한 비교 데이터가 부족합니다 (플레이어당 60초 이상 플레이된 맵이 10개 이상 필요).", + "explainer": "CSR은 위에서 선택한 기간의 데이터로 계산됩니다. 플레이어를 클릭하면 전체 프로필을 볼 수 있습니다." } + }, + "playerMetrics": { + "mvpScore": "평균 MVP 점수", + "fletaDeadliftPercentage": "플레타 데드리프트 %", + "firstPickPercentage": "첫 처치 %", + "totalFirstPicks": "{count, plural, =0 {첫 처치 없음} other {총 첫 처치 #회}}", + "firstDeathPercentage": "첫 사망 %", + "totalFirstDeaths": "{count, plural, =0 {첫 사망 없음} other {총 첫 사망 #회}}", + "fightReversalPercentage": "교전 역전 %", + "killsPerUltimate": "궁극기당 처치", + "averageUltChargeTime": "평균 궁극기 충전 시간", + "droughtTime": "평균 무처치 시간", + "mapMVPCount": "총 맵 MVP" } }, "teamPage": { "metadata": { "title": "팀 | Parsertime", - "description": "Parsertime은 오버워치 스크림을 분석하는 도구입니다.", + "description": "오버워치 팀, 로스터, 스크림 기록을 만들고 관리하세요.", "ogTitle": "팀 | Parsertime", "ogDescription": "Parsertime은 오버워치 스크림을 분석하는 도구입니다.", "ogImage": "팀" @@ -1505,6 +4589,8 @@ "viewTeams": "내 팀 보기", "teams": "팀", "admin": "관리자 보기", + "viewStats": "통계 보기", + "viewAvailability": "가능 시간", "search": { "placeholder": "팀 검색...", "filters": "필터", @@ -1539,6 +4625,25 @@ "owner": "(소유자)", "you": "(나)", "addMember": "멤버 추가...", + "scoutingLink": { + "title": "OWCS 스카우팅 연결", + "description": "교차 참조 스카우팅 보고서를 활성화하려면 이 팀을 OWCS 팀 약어와 연결하세요. 업로드한 스크림 데이터가 상대 경기 기록과 연결됩니다.", + "placeholder": "예: CORNELL, NYU, COLU", + "noTeam": "연결된 팀 없음", + "linked": "{abbr}에 연결됨" + }, + "teamMemberUsage": { + "title": "사용량 세부정보", + "description": "팀 멤버 사용량은 아래에 표시됩니다. 사용량은 청구 플랜과 생성한 팀 수를 기준으로 합니다.", + "teamMemberCount": "팀 멤버", + "usage": "{allowed}명 중 {current}명 사용됨" + }, + "empty": { + "title": "팀 없음", + "joinInvite": "초대 코드로 팀에 가입하려면 여기를 클릭하세요.", + "createPrompt": "또는 아래 버튼을 클릭해 새 팀을 만드세요.", + "createTeam": "팀 만들기" + }, "join": { "enterToken": "초대 토큰 입력", "joinTeam": "팀 가입", @@ -1699,6 +4804,10 @@ "errorTitle": "오류", "errorDescription": "오류가 발생했습니다: {res}" } + }, + "joinMetadata": { + "title": "팀 참가 | Parsertime", + "description": "초대를 수락하고 Parsertime에서 오버워치 팀에 참가하세요." } }, "settingsPage": { @@ -1713,8 +4822,13 @@ "description": "계정 설정 및 환경설정을 관리하세요.", "sideNav": { "profile": "프로필", - "linkedAccounts": "Integrations", - "admin": "관리자" + "linkedAccounts": "연동", + "admin": "관리자", + "dashboard": "대시보드", + "analytics": "분석", + "impersonateUser": "사용자 가장", + "auditLogs": "감사 로그", + "billing": "청구" }, "profile": { "title": "프로필", @@ -1728,9 +4842,35 @@ "planUpgrade": "요금제 업그레이드", "manageSubscription": "구독 관리" }, + "billing": { + "title": "청구", + "description": "청구 설정과 환경설정을 관리하세요.", + "planDescription": "현재 사용 중인 요금제는 {billingPlan} 요금제입니다.", + "billingPlan": { + "FREE": "무료", + "BASIC": "기본", + "PREMIUM": "프리미엄" + }, + "planUpgrade": "요금제 업그레이드", + "manageSubscription": "구독 관리", + "teamCount": "팀", + "teamMemberCount": "팀 멤버", + "scrimCount": "스크림", + "viewOtherPlans": "다른 요금제 보기", + "usage": "{allowed}개 중 {current}개 사용됨" + }, "profileForm": { "minMessage": "이름은 최소 2자 이상이어야 합니다.", "maxMessage": "이름은 30자를 초과할 수 없습니다.", + "specialCharsMessage": "이름에는 특수 문자를 포함할 수 없습니다.", + "unknownError": "알 수 없는 오류", + "errors": { + "updateName": "이름 업데이트 실패: {res}", + "updateBattletag": "배틀태그 업데이트 실패: {res}", + "updateTitle": "칭호 업데이트 실패: {res}", + "updateOnboarding": "온보딩 설정 업데이트 실패: {res}", + "updateSettings": "설정 업데이트 실패: {res}" + }, "onSubmit": { "title": "프로필 업데이트 완료", "description": "프로필이 성공적으로 업데이트되었습니다.", @@ -1741,6 +4881,10 @@ "title": "사용자 이름", "description": "공개 표시 이름입니다. 실제 이름이나 별명을 사용할 수 있습니다." }, + "seenOnboarding": { + "title": "온보딩 확인 완료", + "description": "활성화하면 다음에 대시보드를 방문할 때 소개를 보지 않습니다." + }, "avatar": { "title": "아바타", "altText": "사용자 아바타", @@ -1780,7 +4924,50 @@ } } }, - "update": "프로필 업데이트" + "update": "프로필 업데이트", + "colorblindMode": { + "title": "접근성 설정", + "description": "경험을 개선하기 위한 색각 보정 옵션을 설정하세요", + "label": "색각 보정 모드", + "options": { + "off": { + "label": "끄기", + "description": "기본 색상" + }, + "deuteranopia": { + "label": "제2색약", + "description": "적록 색각 이상(녹색 약화)" + }, + "protanopia": { + "label": "제1색약", + "description": "적록 색각 이상(적색 약화)" + }, + "tritanopia": { + "label": "제3색약", + "description": "청황 색각 이상" + }, + "custom": { + "label": "사용자 지정", + "description": "팀 색상을 직접 선택" + } + }, + "team1": "Team 1", + "team2": "Team 2", + "customTeamColors": "사용자 지정 팀 색상", + "customTeamColorsDescription": "각 팀의 사용자 지정 색상을 선택하세요", + "team1Color": "Team 1 색상", + "team2Color": "Team 2 색상" + }, + "updating": "업데이트 중...", + "battletag": { + "title": "배틀태그", + "description": "계정과 연결할 게임 내 사용자 이름입니다. 해시태그 뒤 숫자는 포함하지 마세요." + }, + "title": { + "title": "칭호", + "placeholder": "칭호 선택", + "description": "프로필에 사용할 칭호를 선택하세요." + } }, "linkedAccounts": { "title": "Integrations", @@ -1816,13 +5003,21 @@ "selectChannel": "채널 선택", "selectTeams": "팀 선택", "submit": "저장", - "saving": "저장 중..." + "saving": "저장 중...", + "discordNotLinked": "서버를 선택하려면 위 설정에서 Discord 계정을 연결하세요.", + "noSharedServers": "서버를 찾을 수 없습니다. Parsertime 봇과 공유하는 서버가 하나 이상 있어야 합니다." }, "toast": { "created": "알림 설정이 생성되었습니다.", "deleted": "알림 설정이 삭제되었습니다.", - "error": "문제가 발생했습니다. 다시 시도해주세요." + "error": "문제가 발생했습니다. 다시 시도해주세요.", + "discordNotLinked": "알림을 설정하려면 Discord 계정을 연결하세요.", + "notGuildMember": "해당 Discord 서버의 멤버여야 합니다." } + }, + "ranked": { + "title": "랭크 통계", + "description": "공개 프로필에서 랭크 통계를 볼 수 있는 사람을 설정합니다." } }, "admin": { @@ -1843,6 +5038,284 @@ "description": "가장 URL이 클립보드에 복사되었습니다.", "errorTitle": "오류", "errorDescription": "사용자를 가장하는 중 오류가 발생했습니다." + }, + "impersonateUser": { + "title": "사용자 가장하기", + "description": "해당 사용자의 관점에서 사이트를 확인하기 위해 사용자를 가장합니다.", + "email": { + "title": "이메일", + "description": "가장하려는 사용자의 이메일입니다.", + "message": "유효한 이메일 주소를 입력하세요." + } + }, + "audit-log": { + "title": "최근 사용자 작업", + "action-types": "작업 유형", + "filter-label": "작업 유형으로 필터링", + "clear-filters": "모두 지우기", + "user-email": "사용자 이메일: {userEmail}", + "target": "대상: {target}", + "search-user-email": "사용자 이메일 검색", + "search-target": "대상 검색", + "date-range": { + "select": "날짜 범위 선택", + "from": "{date}부터", + "until": "{date}까지", + "range": "{from} - {to}" + }, + "actions": { + "USER_BAN": "사용자 차단", + "USER_UNBAN": "사용자 차단 해제", + "USER_AVATAR_UPDATED": "사용자 아바타 업데이트", + "USER_ACCOUNT_DELETED": "사용자 계정 삭제", + "USER_NAME_UPDATED": "사용자 이름 업데이트", + "TRUST_SCORE_ADJUST": "신뢰 점수 조정", + "IMPERSONATE_USER": "사용자 가장", + "SUSPICIOUS_ACTIVITY_DETECTED": "의심스러운 활동 감지", + "TEAM_CREATED": "팀 생성", + "TEAM_UPDATED": "팀 업데이트", + "TEAM_DELETED": "팀 삭제", + "TEAM_AVATAR_UPDATED": "팀 아바타 업데이트", + "TEAM_INVITE_SENT": "팀 초대 전송", + "TEAM_JOINED": "팀 가입", + "TEAM_LEFT": "팀 탈퇴", + "TEAM_MEMBER_PROMOTED": "팀원 승격", + "TEAM_MEMBER_DEMOTED": "팀원 강등", + "TEAM_MEMBER_REMOVED": "팀원 제거", + "TEAM_OWNERSHIP_TRANSFERRED": "팀 소유권 이전", + "SCRIM_CREATED": "스크림 생성", + "SCRIM_UPDATED": "스크림 업데이트", + "SCRIM_DELETED": "스크림 삭제", + "MAP_CREATED": "맵 생성", + "MAP_UPDATED": "맵 업데이트", + "MAP_DELETED": "맵 삭제", + "BUG_REPORT_SUBMITTED": "버그 신고 제출" + }, + "download": { + "button": "CSV 다운로드", + "downloading": "다운로드 중...", + "filename": "audit-logs-{date}.csv", + "tooltip": "현재 필터링된 감사 로그를 CSV 파일로 다운로드합니다.", + "error": "CSV 다운로드 오류: {error}", + "unknown-error": "알 수 없는 오류" + }, + "table": { + "user-email": "사용자 이메일", + "action": "작업", + "target": "대상", + "details": "세부 정보", + "date-time": "날짜 및 시간", + "date-time-format": "{date} {time}", + "no-logs-found": "필터와 일치하는 감사 로그가 없습니다.", + "error-loading-logs": "감사 로그를 불러오는 중 오류가 발생했습니다." + }, + "showing-logs": "{count}개의 로그 표시 중.", + "hover": { + "user-email": "사용자 이메일", + "action": "작업", + "target": "대상", + "details": "세부 정보", + "id": "ID: {id}", + "timestamp": "타임스탬프", + "auto-generated": "이 작업은 시스템에서 자동으로 수행되었습니다." + } + }, + "dashboard": { + "title": "관리자 대시보드", + "description": "사용자와 콘텐츠를 관리하고 플랫폼 활동을 모니터링합니다.", + "user-search": { + "title": "사용자 검색", + "description": "고급 필터 옵션으로 사용자를 검색합니다." + }, + "audit-log": { + "title": "감사 로그", + "description": "스태프 구성원이 수행한 최근 작업" + }, + "stats-cards": { + "total-users": { + "title": "전체 사용자", + "delta": "이번 달 신규 사용자 {delta}명" + }, + "user-growth": { + "title": "사용자 증가", + "delta": "지난달 {lastMonth}명 대비 {delta}" + }, + "scrim-activity": { + "title": "스크림 활동", + "delta": "지난달 {lastMonth}개 대비 {delta}" + }, + "conversion-rate": { + "title": "전환율", + "delta": "유료 사용자 {paidUsers}명" + }, + "monthly-chart": { + "title": "월별 사용자 가입", + "description": "최근 12개월 신규 사용자 가입 추이" + }, + "low-trust-users": { + "title": "낮은 신뢰도 사용자", + "delta": "지난주 대비 {delta}" + }, + "pending-reports": { + "title": "대기 중인 신고", + "delta": "지난주 대비 {delta}" + }, + "images-pending-review": { + "title": "검토 대기 이미지", + "delta": "지난주 대비 {delta}" + } + } + }, + "user-search": { + "metadata": { + "title": "사용자 검색 — 관리자 | Parsertime", + "description": "고급 필터 옵션으로 사용자를 검색합니다." + }, + "title": "사용자 검색", + "description": "고급 필터 옵션으로 사용자를 검색합니다.", + "search-placeholder": "사용자 이름 또는 이메일로 검색...", + "filters": { + "title": "필터", + "trust-score": "신뢰 점수 범위", + "status": "사용자 상태", + "select-status": "상태 선택", + "all-statuses": "모든 상태", + "verification-status": "인증 상태", + "select-verification-status": "인증 상태 선택", + "all-users": "모든 사용자", + "verified-only": "인증된 사용자만", + "unverified-only": "미인증 사용자만", + "billing-plan": "결제 플랜", + "select-billing-plan": "결제 플랜 선택", + "all-plans": "모든 플랜", + "join-date-range": "가입일 범위", + "select-join-date-range": "가입일 범위 선택", + "date-range-from": "{date}부터", + "date-range-until": "{date}까지", + "date-range": "{from} - {to}", + "reset-filters": "필터 초기화" + }, + "plans": { + "free": "무료", + "basic": "Basic", + "premium": "Premium", + "unknown": "알 수 없음" + }, + "table": { + "username": "사용자 이름", + "email": "이메일", + "trust-score": "신뢰 점수", + "verification": "인증 상태", + "join-date": "가입일", + "actions": "작업", + "view-profile": "프로필 보기", + "edit-user": "사용자 편집", + "adjust-trust-score": "신뢰 점수 조정", + "ban-user": "사용자 차단", + "unban-user": "사용자 차단 해제", + "no-users-found": "필터와 일치하는 사용자가 없습니다.", + "open-menu": "메뉴 열기", + "error-loading-users": "사용자를 불러오는 중 오류가 발생했습니다.", + "status": "상태", + "banned": "차단됨", + "active": "활성", + "name": "이름", + "billing-plan": "결제 플랜", + "role": "역할", + "email-verified": "이메일 인증 여부", + "unverified": "미인증", + "verified": "인증됨" + }, + "showing-users": "{count}명의 사용자 표시 중." + }, + "analytics": { + "title": "분석", + "description": "사용자 활동과 성장에 대한 자세한 분석 및 차트를 확인합니다.", + "activeUsers": { + "title": "월간 활성 사용자", + "description": "이번 달 스크림을 업로드한 팀에 속한 사용자와 나머지 사용자 비교" + }, + "monthlyActiveUsers": { + "title": "시간별 활성 사용자", + "description": "최근 12개월 동안 팀이 스크림을 업로드한 월별 고유 사용자" + }, + "activeTeams": { + "title": "월간 활성 팀", + "description": "이번 달 스크림을 업로드한 팀과 전체 팀 비교" + }, + "monthlyActiveTeams": { + "title": "시간별 활성 팀", + "description": "최근 12개월 동안 스크림을 업로드한 월별 고유 팀" + }, + "userGrowth": { + "title": "시간별 사용자 증가", + "description": "최근 12개월 월별 사용자 가입 추이" + }, + "scrimActivity": { + "title": "스크림 활동", + "description": "최근 30일간 일별 스크림 생성 활동" + }, + "teamCreations": { + "title": "팀 생성", + "description": "최근 12개월 월별 팀 생성 추이" + }, + "teamManagerDistribution": { + "title": "사용자 역할 분포", + "description": "일반 사용자와 팀 관리자 비율 분석" + }, + "signupMethods": { + "title": "사용자 가입 방식", + "description": "가입 방식 분석(OAuth 제공자와 이메일)" + }, + "billingPlans": { + "title": "결제 플랜 분포", + "description": "플랜별 사용자 분포(무료, Basic, Premium)" + }, + "charts": { + "last12Months": "최근 12개월", + "allTime": "전체 기간", + "users": "사용자", + "activeUsers": "활성 사용자", + "activeTeams": "활성 팀", + "teamsCreated": "생성된 팀", + "projected": "이번 달 예상 (남은 기간)", + "currentMonthInProgress": "이번 달은 진행 중입니다" + }, + "tabs": { + "growth": "성장", + "adoption": "기능 도입", + "activity": "활동", + "funnels": "퍼널" + }, + "usage": { + "adoptionTitle": "기능 도입", + "adoptionDescription": "최근 30일간 기능별 고유 사용자 수", + "activeUsersTitle": "활성 사용자", + "activeUsersDescription": "최근 30일간 일별 활성 사용자 수", + "hotColdTitle": "페이지 사용 현황", + "hotColdDescription": "최근 30일간 가장 많이 및 적게 방문한 페이지", + "uniqueUsers": "고유 사용자", + "totalEvents": "총 이벤트", + "dau": "DAU", + "page": "페이지", + "views": "조회수", + "underused": "저활용", + "scorecard": { + "dau": "일간 활성 사용자", + "wau": "주간 활성 사용자", + "mau": "월간 활성 사용자", + "stickiness": "고착도", + "events30d": "이벤트 (30일)", + "activeFeatures": "활성 기능" + }, + "funnels": { + "title": "전환 퍼널", + "description": "선택한 기간 동안의 단계별 전환율", + "step": "단계", + "users": "사용자", + "conversion": "전환율" + } + } } }, "dangerZone": { @@ -1908,6 +5381,12 @@ "errorTitle2": "오류가 발생했습니다", "errorDescription2": "메시지를 보내는 중 오류가 발생했습니다. 나중에 다시 시도하세요." } + }, + "metadata": { + "title": "문의하기 | Parsertime", + "description": "Parsertime 팀에 문의하세요.", + "ogTitle": "문의하기 | Parsertime", + "ogDescription": "Parsertime 팀에 문의하세요." } }, "aboutPage": { @@ -2147,6 +5626,12 @@ } }, "privacyPage": { + "metadata": { + "title": "개인정보처리방침 | Parsertime", + "description": "Parsertime이 데이터를 수집, 사용, 보호하는 방식을 안내합니다.", + "ogTitle": "개인정보처리방침 | Parsertime", + "ogDescription": "오버워치 스크림 분석 도구 Parsertime의 개인정보처리방침입니다." + }, "privacyPolicy": { "title": "개인정보처리방침", "description": "Parsertime은 웹사이트 방문자의 신뢰와 자신감을 유지하기 위해 최선을 다하고 있습니다. 특히, Parsertime은 이메일 목록을 마케팅 목적으로 다른 회사 및 비즈니스에 판매, 임대 또는 거래하지 않는다는 점을 알려드리고자 합니다. 이 개인정보처리방침에서는 언제, 왜 개인 정보를 수집하는지, 이를 어떻게 사용하는지, 그리고 이를 안전하게 보호하는 방법에 대해 자세히 설명합니다." @@ -2193,6 +5678,12 @@ } }, "termsPage": { + "metadata": { + "title": "서비스 이용약관 | Parsertime", + "description": "Parsertime 사용에 적용되는 약관입니다.", + "ogTitle": "서비스 이용약관 | Parsertime", + "ogDescription": "오버워치 스크림 분석 도구 Parsertime의 서비스 이용약관입니다." + }, "termsOfService": { "title": "서비스 이용약관", "description": "이 서비스 이용약관은 오버워치 스크림 분석을 위한 오픈소스 도구인 Parsertime의 사용을 규정합니다. 당사의 서비스에 접근하거나 사용함으로써 이 약관에 구속되는 것에 동의합니다." @@ -2265,31 +5756,377 @@ "description": "이 서비스 이용약관에 대한 질문이 있으시면 legal@lux.dev로 연락해 주십시오." } }, - "notFound": { - "404": "404", - "header": "페이지를 찾을 수 없습니다", - "description": "죄송합니다. 찾고 계신 페이지를 찾을 수 없습니다.", - "backHome": "홈으로 돌아가기", - "contact": "지원팀에 문의하기" + "dataCorruption": { + "warning": { + "title": "데이터 손상 감지됨", + "baseDescription": "손상된 데이터가 감지되었습니다. 자동 수정을 시도합니다:", + "invalidMercyRez": "잘못된 mercy_rez 줄은 제거됩니다", + "asteriskValues": "별표 값은 0으로 대체됩니다" + } }, - "footer": { - "changelog": "변경 로그 보기", - "healthOk": "모든 시스템 정상", - "healthDegraded": "성능 저하", - "healthUnknown": "상태 확인 불가", - "healthChecking": "상태 확인 중…", - "healthCardTitle": "서비스 상태", - "healthStatusOperational": "정상", - "healthStatusDown": "중단", - "healthServiceDatabase": "데이터베이스", - "healthServiceDiscordBot": "Discord 봇", - "workshopCode": "워크숍 코드", - "clickToCopy": "클릭하여 복사", - "copied": "복사됨!", - "copiedDescription": "워크숍 코드가 클립보드에 복사되었습니다", - "navAriaLabel": "푸터 내비게이션", - "productTitle": "제품", - "statsTitle": "통계", + "leaderboardPage": { + "metadata": { + "title": "리더보드 | Parsertime", + "description": "각 영웅의 최고 랭킹 플레이어를 확인하세요.", + "ogTitle": "리더보드 | Parsertime", + "ogDescription": "각 영웅의 최고 랭킹 플레이어를 확인하세요.", + "ogImage": "리더보드" + }, + "hub": { + "metadata": { + "title": "리더보드 | Parsertime", + "description": "Overwatch 2 플레이어 실력을 읽는 두 가지 방식: 영웅별 종합 평점과 토너먼트 기반 Elo입니다." + }, + "eyebrow": "리더보드", + "title": "실력 평점", + "description": "플레이어 실력은 한 가지 방식만으로 측정되지 않습니다. 답하고 싶은 질문에 맞는 보드를 선택하세요.", + "answersEyebrow": "{metric} · 답하는 질문", + "browse": "{metric} 보기", + "computed": "계산 방식", + "reachesFor": "잘 포착하는 것", + "doesNotCapture": "포착하지 못하는 것", + "stats": { + "perHero": "영웅별", + "topCount": "상위 {count, number}", + "minSample": "최소 표본", + "mapCount": "{count, number}맵", + "scale": "척도", + "scaleValue": "1-{max, number}", + "csrStatus": "스크림이 업로드되면 갱신됩니다.", + "active": "활성", + "trackedPlayers": "추적 플레이어", + "trackedMatches": "추적 경기", + "topRating": "최고 평점", + "lastRecompute": "마지막 전체 재계산: {when}.", + "awaitingSeed": "초기 시드를 기다리는 중입니다." + }, + "relative": { + "justNow": "방금 전", + "minutesAgo": "{count}분 전", + "hoursAgo": "{count}시간 전", + "daysAgo": "{count}일 전" + }, + "metrics": { + "csr": { + "fullName": "종합 실력 평점", + "question": "이 플레이어는 같은 영웅의 다른 플레이어와 비교해 통계적으로 얼마나 잘 수행하나요?", + "derivation": "기록된 스크림의 역할 가중 10분당 통계를 Z-점수화합니다. 평균 2500, 1부터 5000까지의 척도로 변환하며 영웅별 상위 50명을 표시합니다.", + "strengths": { + "heroSpecific": "영웅별, 역할 인지", + "scrimExecution": "실제 스크림 수행을 반영", + "fastUpdates": "스크림 업로드 즉시 갱신" + }, + "caveats": { + "opponentStrength": "상대 실력은 반영하지 않음", + "minimumSample": "최소 10맵 및 맵당 60초 필요" + } + }, + "tsr": { + "fullName": "토너먼트 실력 평점", + "question": "이 플레이어는 어느 수준의 대회를 감당할 수 있나요?", + "derivation": "FACEIT에서 주최한 OW2 토너먼트 결과를 Elo 방식으로 재생합니다. 365일 반감기의 최근성 가중치를 적용하고 도달한 최고 티어를 기준으로 보정합니다.", + "strengths": { + "headToHead": "맞대결 결과에 기반", + "comparable": "지역과 티어를 넘어 비교 가능", + "currentForm": "현재 폼을 반영" + }, + "caveats": { + "faceitOnly": "FACEIT에서 추적한 토너먼트만 포함", + "battleTag": "연결된 BattleTag 필요" + } + } + } + }, + "csrPage": { + "metadata": { + "title": "종합 실력 평점 | Parsertime", + "description": "같은 영웅의 다른 플레이어와 비교한 Z-점수 기반 통계 성과로 계산되는 영웅별 실력 평점입니다." + }, + "eyebrow": "리더보드", + "eyebrowWithHero": "리더보드 · {hero}", + "title": "종합 실력 평점", + "description": "같은 영웅의 다른 플레이어와 비교한 Z-점수 기반 통계 성과로 계산되는 영웅별 평점입니다. 영웅별 상위 50명이며, 10맵과 맵당 60초가 필요합니다.", + "empty": { + "eyebrow": "영웅 선택", + "title": "리더보드는 영웅별입니다", + "description": "위에서 영웅을 선택해 상위 50명을 확인하세요. CSR은 영웅마다 독립적으로 계산되어 역할에 맞는 통계로 비교합니다." + }, + "accordion": { + "calculated": { + "title": "CSR은 어떻게 계산되나요?", + "intro": "CSR은 특정 영웅의 평균 플레이어와 비교한 통계 성과에서 도출한 실력 평점입니다.", + "formulaTitle": "공식", + "formula": "핵심 통계마다 Z-점수를 계산해 평균보다 몇 표준편차 위 또는 아래인지 측정합니다.", + "normalized": "통계는 10분당 값으로 정규화됩니다.", + "positiveStats": "긍정 통계(처치)는 값이 높을수록 보상합니다.", + "negativeStats": "부정 통계(사망, 받은 피해)는 값이 낮을수록 보상합니다.", + "roleWeightingTitle": "역할 가중치", + "roleWeighting": "역할마다 우선하는 통계가 다릅니다. 탱커: 낮은 사망(30%), 처치(20%), 단독 처치(15%). 공격: 처치(30%), 결정타(20%), 피해량(20%). 지원: 치유(35%), 낮은 사망(25%).", + "uniqueWeightings": "Mercy 같은 특정 영웅은 고유 가중치를 사용합니다.", + "finalScalingTitle": "최종 스케일링", + "finalScaling": "가중 Z-점수를 합산해 2500을 중심으로 하는 SR 척도로 변환합니다." + }, + "goodSr": { + "title": "좋은 SR은 어느 정도인가요?", + "body": "점수는 종형 곡선을 따릅니다. 평균은 2500이며, 평균보다 표준편차 1개(약 300~400 SR) 높으면 대략 상위 16%입니다. 2500보다 높을수록 더 드문 평점입니다." + }, + "rank": { + "title": "리더보드에 오르려면?", + "body": "해당 영웅으로 최소 10맵을 플레이하고 맵마다 최소 60초의 플레이 시간이 필요합니다. 짧은 맵 중간 교체는 계산하지 않습니다. 보드는 상위 50명을 표시하며, 그 아래 순위도 플레이어 프로필에는 표시됩니다." + }, + "interactive": { + "title": "표 읽기", + "body": "행을 클릭하면 SR 분포, 역할 레이더, 10분당 분석이 포함된 세부 통계 패널이 열립니다. Tab과 Enter로 키보드 탐색도 가능합니다." + } + } + }, + "csr": { + "table": { + "player": "플레이어", + "maps": "맵", + "time": "시간", + "elimsPer10": "처치/10분", + "damagePer10": "피해/10분", + "healPer10": "치유/10분", + "blockPer10": "방어/10분", + "noPlayers": "플레이어를 찾을 수 없습니다." + }, + "stats": { + "selectedMeta": "선택됨 · {rank, number}위 · {role}", + "sheetMeta": "{rank, number}위 • {hero} • {role}", + "roles": { + "tank": "탱커", + "damage": "공격", + "support": "지원" + }, + "snapshot": "스냅샷", + "compositeSr": "종합 SR", + "percentileLabel": "백분위", + "maps": "맵", + "time": "시간", + "minutes": "{count, number}분", + "hero": "영웅", + "srDistribution": "SR 분포", + "distributionDescription": "{playerName}이 이 영웅의 종형 곡선에서 어디에 위치하는지 보여줍니다.", + "performanceBreakdown": "성과 분석", + "performanceDescription": "리더보드 평균 대비 Z-점수입니다. 0은 평균, 양수는 평균 이상, 음수는 평균 이하입니다. 대부분의 플레이어는 -2에서 +2 사이에 분포합니다.", + "per10Minutes": "10분당", + "detailedStats": "세부 통계(10분당)", + "statLabel": "{label}:", + "emptyTitle": "세부 패널", + "emptyDescription": "플레이어를 선택하면 SR 분포, 성과 분석, 리더보드 평균 대비 10분당 통계를 확인할 수 있습니다.", + "zScore": "Z-점수", + "percentile": { + "top1": "상위 1% · 엘리트", + "top5": "상위 5% · 탁월함", + "top10": "상위 10% · 우수함", + "top25": "상위 25% · 매우 좋음", + "aboveAverage": "평균 이상", + "average": "평균", + "belowAverage": "평균 이하" + }, + "per10": { + "eliminations": "처치", + "finalBlows": "결정타", + "soloKills": "단독 처치", + "deaths": "사망", + "damage": "피해", + "healing": "치유", + "blocked": "방어", + "ultimates": "궁극기" + } + }, + "distributionChart": { + "sr": "SR", + "rank": "순위", + "percentile": "백분위", + "notEnoughData": "의미 있는 분포를 생성하기에 데이터가 부족합니다. 최소 3명의 플레이어가 필요합니다.", + "skillRatingAxis": "실력 평점(SR)", + "frequencyAxis": "빈도", + "achievedPotential": "달성 · 잠재", + "distribution": "분포", + "meanLabel": "평균({value, number})", + "otherPlayers": "다른 플레이어", + "selectedPlayer": "선택한 플레이어", + "meanSr": "평균 SR", + "standardDeviation": "표준편차", + "plusMinus": "±{value, number}", + "selectedPlayerSr": "선택한 플레이어 SR" + } + }, + "subnav": { + "ariaLabel": "리더보드 유형" + }, + "heroSelector": { + "selectHero": "영웅 선택...", + "searchHero": "영웅 검색...", + "noHeroFound": "영웅을 찾을 수 없습니다.", + "roles": { + "tank": "탱커", + "damage": "공격", + "support": "지원" + } + }, + "tsr": { + "eyebrow": "리더보드", + "eyebrowComputed": "리더보드 · {computedAt} 계산됨", + "title": "토너먼트 실력 평점", + "description": "FACEIT에서 주최한 Overwatch 2 토너먼트 결과로 계산한 Elo 방식 평점입니다. 최근 경기 가중치를 적용하고 도달한 최고 티어를 기준으로 보정합니다.", + "stats": { + "active": "활성", + "trackedPlayers": "추적 플레이어", + "trackedMatches": "추적 경기", + "topRating": "최고 평점" + }, + "regions": { + "allRegions": "모든 지역", + "na": "NA", + "emea": "EMEA", + "other": "기타" + }, + "tiers": { + "allTiers": "모든 티어", + "open": "오픈", + "cah": "CAH", + "advanced": "어드밴스드", + "expert": "엑스퍼트", + "masters": "마스터즈", + "owcs": "OWCS" + }, + "sortOptions": { + "rating": "평점", + "totalMatches": "전체 경기", + "recentActivity": "최근 활동" + }, + "columns": { + "player": "플레이어", + "region": "지역", + "peakTier": "최고 티어", + "rating": "평점", + "matches": "경기", + "lastSeen": "최근 경기" + }, + "relative": { + "today": "오늘", + "days": "{count}일", + "weeks": "{count}주", + "months": "{count}개월", + "years": "{count}년", + "justNow": "방금 전", + "minutesAgo": "{count}분 전", + "hoursAgo": "{count}시간 전", + "daysAgo": "{count}일 전" + }, + "regionFilter": "지역 필터", + "searchPlaceholder": "BattleTag 또는 닉네임 검색", + "searchAria": "플레이어 검색", + "sort": "정렬", + "loading": "불러오는 중...", + "noPlayersForQuery": "\"{query}\"와 일치하는 플레이어가 없습니다.", + "noActivePlayers": "선택한 필터와 일치하는 활성 플레이어가 없습니다.", + "showing": "{total, number}명 중 {visible, number}명 표시", + "loadMore": "더 불러오기", + "activeCount": "활성 {total, number}명 중 {visible, number}명", + "inactive": "비활성", + "recentMatchesWindow": "{count, number} · 최근 {days, number}일", + "detail": { + "selectedMeta": "선택됨 · {region} · 최고 {tier}", + "rating": "평점", + "matchRecord": "경기 전적", + "wins": "승", + "losses": "패", + "winRate": "승률", + "lastNDays": "최근 {days, number}일", + "recordSummary": "{wins, number}승 · {losses, number}패", + "tierBreakdown": "티어 분포", + "contributingFactors": "기여 요인", + "factorRadarName": "요인", + "factorDescription": "합리적인 모집단 범위 안에서 0부터 1까지 정규화했습니다. 최근성에는 {days, number}일 반감기를 적용했습니다.", + "topSwings": "가장 큰 변동", + "recentMatches": "최근 경기", + "won": "승리", + "lost": "패배", + "score": "{first, number}-{second, number}", + "emptyTitle": "세부 패널", + "emptyDescription": "플레이어를 선택하면 티어 래더, 경기 전적, 기여 요인, 평점에 영향을 준 경기까지 확인할 수 있습니다.", + "regions": { + "na": "NA", + "emea": "EMEA" + }, + "tiers": { + "open": "오픈", + "cah": "CAH", + "advanced": "어드밴스드", + "expert": "엑스퍼트", + "masters": "마스터즈", + "owcs": "OWCS" + }, + "factors": { + "winRate": "승률", + "recentActivity": "최근 활동", + "tierStrength": "티어 강도", + "marginOfVictory": "승리 격차", + "matchVolume": "경기 수" + }, + "factorRaw": { + "recentActivity": "최근 {days, number}일 {count, number}회", + "averageTier": "평균 티어 {tier}", + "averageMultiplier": "평균 {value}배", + "matches": "{count, number}경기" + }, + "tierLadder": { + "title": "티어 래더", + "scale": "1-{max, number}", + "description": "시각화는 활성 토너먼트 플레이어가 실제로 분포하는 경험적 구간인 {min, number}-{max, number}으로 확대됩니다. 전체 척도는 1부터 {fullMax, number}까지입니다.", + "ratingAria": "평점 {rating, number}" + } + } + } + }, + "profilePage": { + "layoutMetadata": { + "title": "{playerName}의 프로필 | Parsertime", + "description": "Parsertime에서 {playerName}의 프로필과 통계를 확인하세요.", + "ogTitle": "{playerName}의 프로필 | Parsertime", + "ogDescription": "Parsertime에서 {playerName}의 프로필과 통계를 확인하세요.", + "ogImage": "{playerName}의 프로필" + }, + "hoverCard": { + "bannerAlt": "{playerName} 배너", + "topHeroes": "상위 영웅", + "timePlayed": "플레이 시간", + "loading": "불러오는 중...", + "error": "데이터를 불러오는 중 오류가 발생했습니다", + "empty": "사용 가능한 데이터가 없습니다", + "viewProfile": "프로필 보기 →" + }, + "rankedTab": "랭크" + }, + "notFound": { + "404": "404", + "header": "페이지를 찾을 수 없습니다", + "description": "죄송합니다. 찾고 계신 페이지를 찾을 수 없습니다.", + "backHome": "홈으로 돌아가기", + "contact": "지원팀에 문의하기" + }, + "footer": { + "changelog": "변경 로그 보기", + "healthOk": "모든 시스템 정상", + "healthDegraded": "성능 저하", + "healthUnknown": "상태 확인 불가", + "healthChecking": "상태 확인 중…", + "healthCardTitle": "서비스 상태", + "healthStatusOperational": "정상", + "healthStatusDown": "중단", + "healthServiceDatabase": "데이터베이스", + "healthServiceDiscordBot": "Discord 봇", + "workshopCode": "워크숍 코드", + "clickToCopy": "클릭하여 복사", + "copied": "복사됨!", + "copiedDescription": "워크숍 코드가 클립보드에 복사되었습니다", + "navAriaLabel": "푸터 내비게이션", + "productTitle": "제품", + "statsTitle": "통계", "teamsTitle": "팀", "supportTitle": "지원", "feedback": "피드백", @@ -2305,10 +6142,12 @@ "dataToolsTitle": "데이터 도구", "playerStats": "플레이어 통계", "heroStats": "영웅 통계", + "mapStats": "맵 통계", "teamStats": "팀 통계", "comparePlayers": "플레이어 비교", "leaderboard": "리더보드", "yourTeams": "내 팀", + "availability": "가능 시간", "joinTeam": "팀 참가", "dashboard": "대시보드", "settings": "설정", @@ -2322,14 +6161,275 @@ "reports": "보고서", "dataLabeling": "데이터 라벨링", "mapCalibration": "맵 캘리브레이션", + "coachingTitle": "코칭", + "coachingCanvas": "캔버스", "tournamentsTitle": "토너먼트", "viewTournaments": "토너먼트 보기", - "createTournament": "토너먼트 만들기" + "createTournament": "토너먼트 만들기", + "matchmaker": "Matchmaker" + }, + "mapCalibrationPage": { + "title": "맵 캘리브레이션", + "description": "탑다운 맵 이미지의 좌표 변환을 보정합니다. 각 맵에는 월드 좌표를 이미지 픽셀에 매핑하는 앵커 포인트가 필요합니다.", + "mapTypes": { + "control": "컨트롤", + "escort": "호위", + "flashpoint": "플래시포인트", + "hybrid": "하이브리드", + "push": "푸시" + }, + "list": { + "searchPlaceholder": "맵 검색…", + "noMatches": "필터와 일치하는 맵이 없습니다.", + "status": { + "noImage": "이미지 없음", + "calibrated": "보정됨", + "anchors": "앵커 {count}개", + "imageUploaded": "이미지 업로드됨" + }, + "anchorSummary": "앵커 {count}개", + "transformSavedSuffix": " · 변환 저장됨" + }, + "anchorDialog": { + "title": "앵커 포인트 추가", + "description": "이미지 위치: ({imageU}, {imageV}). 대응하는 게임 내 월드 좌표를 입력하세요. Overwatch는 (X, Y, Z)를 사용하며 Y는 수직축입니다. XZ만 입력하세요.", + "worldX": "월드 X", + "worldZ": "월드 Z", + "worldXPlaceholder": "예: 42.5", + "worldZPlaceholder": "예: -18.3", + "label": "라벨(선택)", + "labelPlaceholder": "예: A 지점, 스폰 문", + "cancel": "취소", + "addAnchor": "앵커 추가" + }, + "anchorList": { + "empty": "아직 앵커 포인트가 없습니다. 맵 이미지를 클릭해 배치하세요.", + "label": "라벨", + "worldCoordinates": "월드 (X, Z)", + "imageCoordinates": "이미지 (U, V)", + "deleteAnchor": "{label} 앵커 삭제" + }, + "upload": { + "button": "이미지 업로드", + "title": "맵 이미지 업로드", + "description": "{mapName}의 탑다운 직교 이미지를 업로드하세요. PNG 및 JPEG를 최대 150MB까지 지원합니다.", + "selectFile": "맵 이미지 파일 선택", + "requestingUploadUrl": "업로드 URL 요청 중…", + "uploadingToStorage": "스토리지에 이미지 업로드 중…", + "processingImage": "이미지 처리 중…", + "uploading": "업로드 중…", + "largeImageNote": "큰 이미지는 시간이 걸릴 수 있습니다.", + "uploadUrlError": "업로드 URL을 가져오지 못했습니다", + "storageUploadError": "스토리지에 이미지를 업로드하지 못했습니다", + "processImageError": "이미지를 처리하지 못했습니다", + "uploadError": "이미지 업로드에 실패했습니다." + }, + "editor": { + "back": "뒤로", + "noImageUploaded": "아직 이 맵에 업로드된 이미지가 없습니다.", + "anchorPoints": "앵커 포인트 {formattedCount}개", + "computeTransform": "변환 계산", + "computing": "계산 중…", + "save": "저장", + "saving": "저장 중…", + "minimumAnchorsHint": "변환을 계산하려면 최소 3개의 앵커 포인트를 배치하세요. 포인트가 많을수록 정확도가 향상됩니다.", + "createRecordError": "캘리브레이션 기록을 만들지 못했습니다.", + "addAnchorError": "앵커 포인트를 추가하지 못했습니다.", + "deleteAnchorError": "앵커 포인트를 삭제하지 못했습니다.", + "computeTransformError": "변환을 계산하지 못했습니다.", + "transformSaved": "변환이 저장되었습니다.", + "saveTransformError": "변환을 저장하지 못했습니다.", + "imageReplaced": "이미지가 교체되었습니다. 앵커가 초기화되었습니다.", + "replaceImageError": "이미지를 교체하지 못했습니다." + }, + "transform": { + "title": "계산된 변환", + "saved": "저장됨", + "unsaved": "저장 안 됨", + "scaleX": "X 스케일:", + "scaleY": "Y 스케일:", + "determinant": "행렬식:", + "avgError": "평균 오차:", + "pixelsPerUnit": "{value} px/단위", + "errorValue": "{percent} ({pixels}px)", + "withinTolerance": "고해상도 이미지의 예상 허용 오차 범위 안입니다." + }, + "preview": { + "title": "테스트 포인트 미리보기", + "description": "월드 좌표가 예상 맵 위치에 투영되는지 확인하세요. Overwatch의 (X, Y, Z) 중 XZ만 사용합니다.", + "worldX": "월드 X", + "worldZ": "월드 Z", + "label": "라벨", + "addTestPoint": "테스트 포인트 추가", + "clear": "지우기({count})" + }, + "canvas": { + "ariaLabel": "맵 캘리브레이션 캔버스입니다. 클릭해 앵커 포인트를 배치하고, 드래그해 이동하며, 스크롤해 확대/축소합니다.", + "loading": "맵 이미지 로드 중…", + "gridOn": "그리드 켜짐", + "gridOff": "그리드 꺼짐", + "instructions": "{zoom} · 스크롤로 확대/축소 · 드래그로 이동 · 클릭으로 배치" + }, + "replaceRender": { + "button": "Replace render", + "title": "Replace render for {mapName}", + "description": "Upload the new render, then paste the transform from the local alignment script. Your calibration is moved onto it — nothing changes until you confirm.", + "selectFile": "Select new render", + "uploading": "Uploading…", + "alignSummary": "{inliers} inliers · residual {residual}px", + "lowConfidence": "Low confidence — verify the anchor dots carefully before confirming.", + "staged": "Render uploaded. Run the alignment script locally and paste its output below.", + "scriptHint": "scripts/map-align ▸ uv run cli.py [old-image] [new-render]", + "transformLabel": "Alignment transform (JSON)", + "transformPlaceholder": "Paste the script's JSON output here", + "parseError": "Couldn't read that — paste the full JSON output of the alignment script.", + "showingOld": "Showing old image", + "showingNew": "Showing new render", + "compareTitle": "Compare old ↔ new:", + "compareBlink": "Blink", + "compareSwipe": "Swipe", + "confirm": "Confirm & apply", + "cancel": "Cancel", + "applying": "Applying…", + "applied": "Render replaced and calibration preserved.", + "applyError": "Failed to apply the new render." + } + }, + "credits": { + "title": "AI 채팅 크레딧", + "description": "AI 분석가용 종량제 잔액입니다. 언제든 충전할 수 있으며 최소 금액은 {minimum}입니다.", + "settingsDescription": "AI 분석가용 종량제 잔액입니다.", + "currentBalance": "현재 잔액", + "balance": "잔액", + "addCredits": "크레딧 추가", + "custom": "직접 입력", + "topUp": "충전", + "manage": "관리", + "autoRefill": "자동 충전", + "autoRefillDescription": "잔액이 기준 아래로 떨어지면 저장된 카드로 결제합니다.", + "savePaymentMethodHint": "크레딧을 한 번 추가하면 자동 충전에 사용할 결제 수단이 저장됩니다.", + "refillWhenBelow": "이 금액 미만이면 충전", + "refillAmount": "충전 금액", + "pricing": "요금", + "pricingDescription": "입력 토큰 100만 개당 {input}, 출력 토큰 100만 개당 {output}입니다. 기본 모델 요금에 {fee} 플랫폼 수수료가 포함됩니다.", + "minimumTopup": "최소 충전 금액은 {amount}입니다.", + "checkoutError": "결제를 시작하지 못했습니다.", + "autoRefillUpdateError": "자동 충전을 업데이트하지 못했습니다.", + "invalidDollarAmount": "유효한 달러 금액을 입력하세요.", + "autoRefillSummary": " · {threshold}에서 {amount} 자동 충전", + "recentActivity": "최근 활동", + "noTransactions": "아직 거래가 없습니다. AI 채팅 페이지에서 충전하거나 관리를 클릭하세요." + }, + "coaching": { + "title": "코칭 캔버스", + "subtitle": "맵에 영웅 배치, 그림, 전략 노트를 표시하세요.", + "eyebrow": "전술 보드", + "metadata": { + "title": "코칭 캔버스 | Parsertime", + "description": "영웅 배치와 전략 그림을 맵에 표시하세요." + }, + "sidebar": { + "team1": "팀 1", + "team2": "팀 2", + "tank": "탱커", + "damage": "딜러", + "support": "지원" + }, + "toolbar": { + "select": "선택", + "pen": "펜", + "arrow": "화살표", + "circle": "원", + "eraser": "지우개", + "undo": "실행 취소", + "redo": "다시 실행", + "reset": "초기화", + "strokeWidth": "선 굵기", + "team1Color": "팀 1 색상", + "team2Color": "팀 2 색상", + "neutralColors": { + "white": "흰색", + "black": "검정", + "yellow": "노랑" + } + }, + "mapSelector": { + "placeholder": "맵 선택", + "search": "맵 검색...", + "noResults": "맵을 찾을 수 없습니다.", + "subMapPlaceholder": "지점 선택", + "label": "맵" + }, + "reset": { + "title": "캔버스를 초기화할까요?", + "description": "모든 영웅 배치와 그림이 지워집니다. 이 작업은 되돌릴 수 없습니다.", + "confirm": "초기화", + "cancel": "취소" + } + }, + "dataLabeling": { + "title": "데이터 라벨링", + "subtitle": "OWCS 토너먼트 경기의 팀 조합을 라벨링하세요.", + "metadata": { + "title": "데이터 라벨링 | Parsertime", + "description": "OWCS 토너먼트 경기의 팀 조합을 라벨링하세요." + }, + "matchList": { + "title": "라벨링되지 않은 경기", + "date": "날짜", + "teams": "팀", + "score": "스코어", + "tournament": "토너먼트", + "progress": "진행 상황", + "previousPage": "이전", + "nextPage": "다음", + "pageInfo": "{total}페이지 중 {current}페이지", + "matchCount": "{count}경기", + "fetchError": "경기를 가져오지 못했습니다", + "errorLoading": "경기 로드 오류: {message}", + "unknownError": "알 수 없는 오류", + "noMatches": "VOD가 있는 라벨링되지 않은 경기를 찾을 수 없습니다.", + "vs": "대" + }, + "labeling": { + "backToList": "경기 목록으로 돌아가기", + "map": "맵 {number}", + "mapTabs": "맵", + "labeled": "라벨링됨", + "unlabeled": "미라벨링", + "heroBans": "영웅 밴", + "noBans": "이 맵에는 밴이 없습니다.", + "team1Comp": "{team} 조합", + "team2Comp": "{team} 조합", + "tank": "탱커", + "damage": "딜러", + "support": "지원", + "save": "저장", + "reset": "초기화", + "saveAll": "모든 맵 저장", + "saving": "저장 중...", + "saved": "저장됨!", + "saveFailed": "저장 실패", + "saveSuccess": "팀 조합을 저장했습니다.", + "saveError": "팀 조합 저장에 실패했습니다.", + "roleConstraint": "탱커 1명, 딜러 2명, 지원 2명을 선택하세요", + "heroBanned": "밴됨", + "playerAssignments": "{team} 플레이어 배정", + "assignPlayer": "{hero} 플레이어 배정", + "selectPlayer": "플레이어 선택...", + "twitchVodTitle": "Twitch VOD", + "noVodAvailable": "사용 가능한 VOD가 없습니다", + "instructions": { + "title": "리뷰어 안내", + "body": "VOD를 보면서 오른쪽 패널을 사용해 각 맵의 영웅 조합을 기록하세요. 각 영웅에는 맵 시작 시 해당 영웅을 플레이한 선수를 배정하세요.", + "swapNote": "초기 픽만 기록하세요. 맵 중간 영웅 교체는 기록하지 않아도 됩니다. 이 작업은 매치업에 따른 교체가 아니라 각 팀의 시작 조합을 파악하기 위한 것입니다." + } + } }, "demoPage": { "metadata": { "title": "{mapName} 데모 | Parsertime", - "description": "Parsertime에서 {mapName}에 대한 데모 개요입니다. Parsertime은 오버워치 스크림을 분석하는 도구입니다.", + "description": "{mapName} 데모 개요 — 샘플 데이터로 Parsertime의 오버워치 스크림 분석을 살펴보세요.", "ogTitle": "{mapName} 데모 | Parsertime", "ogDescription": "Parsertime에서 {mapName}에 대한 데모 개요입니다. Parsertime은 오버워치 스크림을 분석하는 도구입니다.", "ogImage": "{mapName} 데모" @@ -2349,15 +6449,923 @@ "mark-all-as-read-error": "모두 읽음으로 표시 실패", "mark-as-read-error": "읽음으로 표시 실패", "error-loading": "알림 로딩 오류", + "error-loading-description": "페이지를 새로고침해 보세요.", + "no-notifications-description": "알림이 도착하면 여기에 표시됩니다.", "marking-all-as-read": "업데이트 중...", "mark-as-read": "읽음으로 표시", "delete": "삭제", "delete-error": "알림 삭제 실패", "notification-created": "알림 생성 성공", - "create-notification-error": "알림 생성 실패" + "create-notification-error": "알림 생성 실패", + "metadata": { + "title": "알림 | Parsertime", + "description": "최신 Parsertime 알림과 팀 활동입니다." + } + }, + "positioningCard": { + "title": "포지셔닝", + "description": "좌표 데이터를 기반으로 계산된 위치 통계입니다. 좌표 추적이 포함된 맵만 집계됩니다.", + "mapsWithData": "{count}개 맵 데이터", + "noData": "아직 위치 데이터가 없습니다. 좌표 추적이 포함된 스크림을 업로드하면 통계가 표시됩니다.", + "engagementDistance": "평균 교전 거리", + "engagementDistanceHint": "막타 기준 대상과의 평균 거리입니다.", + "highGroundKills": "고지대 킬 %", + "highGroundKillsHint": "높이 우위를 가지고 올린 킬의 비율입니다.", + "isolationDeaths": "고립 데스 %", + "isolationDeathsHint": "15미터 이내에 아군이 없는 상태에서의 데스 비율입니다.", + "fightStartSpread": "한타 시작 간격", + "fightStartSpreadHint": "한타 시작 시 아군과의 평균 거리입니다." + }, + "titles": { + "DEVELOPER": "Parsertime 개발자", + "DEVELOPER-description": "Parsertime 개발에 기여한 사용자에게 수여됩니다.", + "EMPLOYEE": "lux.dev 직원", + "EMPLOYEE-description": "lux.dev LLC의 직원인 사용자에게 수여됩니다.", + "BETA_TESTER": "베타 테스터", + "BETA_TESTER-description": "베타 테스터 목록에 추가된 사용자에게 수여됩니다.", + "DAY_ONE_USER": "처음부터 함께한 사용자", + "DAY_ONE_USER-description": "처음부터 함께한 사용자에게 수여됩니다. 출시일(2024년 4월 14일) 이전에 가입한 사용자에게 주어집니다.", + "BASIC_PLAN_SUBSCRIBER": "서포터", + "BASIC_PLAN_SUBSCRIBER-description": "베이직 플랜을 구독한 사용자에게 수여됩니다. 구독자는 이름 옆에 분홍색 하트 아이콘도 받습니다.", + "PREMIUM_PLAN_SUBSCRIBER": "프리미엄 서포터", + "PREMIUM_PLAN_SUBSCRIBER-description": "프리미엄 플랜을 구독한 사용자에게 수여됩니다. 구독자는 이름 옆에 금색 하트 아이콘도 받습니다.", + "VIP": "VIP", + "VIP-description": "Parsertime 커뮤니티에 크게 기여한 사용자에게 수여됩니다.", + "HIGHEST_RANK_ON_A_HERO": "위대함 달성", + "HIGHEST_RANK_ON_A_HERO-description": "한 영웅에서 최고 랭크를 달성한 사용자에게 수여됩니다.", + "HIGHEST_AJAX_COUNT": "에이잭스 왕", + "HIGHEST_AJAX_COUNT-description": "가장 많은 에이잭스를 기록한 사용자에게 수여됩니다.", + "HIGHEST_FLETA_DEADLIFT_PERCENTAGE": "플레타의 제자", + "HIGHEST_FLETA_DEADLIFT_PERCENTAGE-description": "가장 높은 플레타 데드리프트 비율을 달성한 사용자에게 수여됩니다.", + "TOP_3_KILLS": "연쇄 처치자", + "TOP_3_KILLS-description": "처치 리더보드 상위 3위에 오른 사용자에게 수여됩니다.", + "TOP_3_DAMAGE_DEALT": "대미지 딜러", + "TOP_3_DAMAGE_DEALT-description": "가한 피해 리더보드 상위 3위에 오른 사용자에게 수여됩니다.", + "TOP_3_HEALING": "궁극의 메딕", + "TOP_3_HEALING-description": "치유 리더보드 상위 3위에 오른 사용자에게 수여됩니다.", + "TOP_3_DAMAGE_BLOCKED": "피해 흡수자", + "TOP_3_DAMAGE_BLOCKED-description": "막은 피해 리더보드 상위 3위에 오른 사용자에게 수여됩니다.", + "TOP_3_DEATHS": "피더", + "TOP_3_DEATHS-description": "사망 리더보드 상위 3위에 오른 사용자에게 수여됩니다.", + "TOP_3_TIME_PLAYED": "만성 온라인", + "TOP_3_TIME_PLAYED-description": "플레이 시간 리더보드 상위 3위에 오른 사용자에게 수여됩니다. 밖에도 나가세요!" + }, + "matchmaker": { + "title": "스크림 매치메이커", + "subtitle": "비슷한 실력대의 팀 찾기", + "metadata": { + "title": "매치메이커 | Parsertime", + "description": "팀 페이지와 같은 실력 평점인 TSR을 기반으로 비슷한 수준의 스크림 상대를 찾으세요." + }, + "hub": { + "eyebrow": "매치메이커", + "title": "스크림 상대 찾기", + "description": "내 로스터와 비슷한 실력대의 팀을 비교하고, 상대가 브래킷 사다리 어디에 있는지 확인한 뒤 정해진 소개 메시지 하나를 보낼 수 있습니다. 팀 페이지와 같은 Team TSR 척도를 사용합니다.", + "mechanicsEyebrow": "작동 방식 · 개요", + "mechanicsTitle": "실력 기준, 가볍고 남용 방지", + "mechanicsDescription": "매치메이커는 예약 시스템이 아니라 소개 라우팅입니다. 팀을 둘러보고, 정해진 요청 하나를 보내면 상대 팀이 앱과 Discord에서 확인합니다.", + "ranking": { + "label": "매칭 순위 기준", + "sameRegion": "같은 TSR 지역 우선(NA / EMEA)", + "closestDistance": "절대 TSR 차이가 가까운 팀 우선", + "sameBracket": "같은 스크림 브래킷, 그다음 인접 브래킷 밴드", + "availabilityBonus": "이번 주 가능 시간이 겹치면 보너스", + "cooldownPenalty": "최근 메시지를 보낸 팀은 아래로 이동" + }, + "sent": { + "label": "전송되는 내용", + "message": "팀의 브래킷과 TSR이 포함된 수정 불가 메시지", + "roster": "로스터 상위 5명의 BattleTag와 TSR", + "delivery": "팀 소유자와 매니저에게 앱 내 전달, 설정된 경우 Discord에도 전달" + }, + "rateLimits": { + "label": "요청 제한", + "perPair": "팀 쌍과 방향마다 24시간에 요청 1회", + "daily": "팀당 24시간 동안 최대 10개의 발신 요청" + }, + "teamPickerEyebrow": "팀 선택 · 시작", + "teamPickerTitle": "어떤 로스터로 검색할까요?", + "teamPickerDescription": "소속된 각 팀은 자체 Team TSR을 가집니다. 스크림에 사용할 팀을 선택하세요. Team TSR이 있는 팀만 검색을 시작할 수 있습니다.", + "teamTsr": "TSR {rating, number}", + "noTeamTsr": "아직 Team TSR 없음", + "searchAs": "{teamName}(으)로 검색", + "ineligibleTitle": "매치메이킹 전에 Team TSR이 필요합니다", + "notEligible": "아직 사용 불가", + "emptyTitle": "아직 소속된 팀이 없습니다", + "emptyDescription": "먼저 팀에 가입하거나 팀을 만든 뒤 이곳으로 돌아와 스크림 상대를 찾으세요.", + "manageTeams": "팀 관리 →", + "manageBlacklist": "블랙리스트 관리", + "bracketWithBand": "{band} {tier}", + "bands": { + "low": "하위", + "mid": "중위", + "high": "상위" + }, + "tiers": { + "unclassified": "미분류", + "open": "오픈", + "cah": "CAH", + "advanced": "어드밴스드", + "expert": "엑스퍼트", + "masters": "마스터즈", + "owcs": "OWCS" + } + }, + "no-snapshot-title": "아직 Team TSR 없음", + "no-snapshot-description": "스크림 상대를 검색하려면 팀에 Team TSR이 필요합니다. 추적되는 토너먼트에 참가하거나 다음 일일 갱신을 기다려 주세요.", + "back-to-team": "팀으로 돌아가기", + "back-to-candidates": "← 후보로 돌아가기", + "no-candidates-title": "지금은 가까운 팀이 없습니다", + "no-candidates-description": "나중에 다시 시도하세요. 후보 풀은 매일 갱신됩니다.", + "requests-remaining": "일일 요청 {count} / 10개 남음", + "searchingAs": "{teamName}(으)로 검색 중", + "send-button": "스크림 요청 보내기", + "send-button-cooldown": "{duration} 후 사용 가능", + "send-button-limit": "일일 한도에 도달했습니다. {duration} 후 초기화됩니다", + "send-button-limit-short": "일일 한도 도달, 24시간 후 초기화", + "send-button-not-manager": "매니저만 스크림 요청을 보낼 수 있습니다", + "send-button-recent": "최근 24시간 안에 이미 메시지를 보냈습니다", + "delta-positive": "+{value}", + "delta-negative": "−{value}", + "delta-zero": "±0", + "skill-deviation": "실력 차이", + "bracket-comparison": "{from} 대 {to}", + "your-team": "내 팀", + "their-team": "상대 팀", + "detail-eyebrow": "매치메이커 · 스크림", + "availability-overlap-title": "가능 시간 겹침", + "availability-overlap": "이번 주 {hours}시간 겹침", + "availability-overlap-short": "{hours}시간 겹침", + "availability-overlap-detail": "두 팀은 이번 주에 {hours}시간의 공통 가능 시간이 있습니다.", + "no-availability": "공통 가능 시간 데이터 없음", + "sent-relative": "{when} 전송", + "relative": { + "justNow": "방금 전", + "minutesAgo": "{count}분 전", + "hoursAgo": "{count}시간 전", + "daysAgo": "{count}일 전" + }, + "message-preview": "메시지 미리보기", + "request-message": "{fromTeamName} ({fromBracketLabel} · TSR {fromTsr, number}) 팀이 스크림을 찾고 있습니다. 내 로스터와의 TSR 차이는 {delta}입니다.", + "roster": "로스터", + "send-success": "스크림 요청을 보냈습니다", + "send-error-409": "최근 이 팀에 이미 메시지를 보냈습니다. 24시간 후 다시 시도하세요.", + "send-error-422": "두 팀 중 하나에 더 이상 팀 TSR이 없습니다.", + "send-error-429": "일일 한도에 도달했습니다. 24시간 후 초기화됩니다.", + "send-error-generic": "스크림 요청을 보낼 수 없습니다. 다시 시도하세요.", + "bracket-with-band": "{band} {tier}", + "bands": { + "low": "하위", + "mid": "중위", + "high": "상위" + }, + "tiers": { + "unclassified": "미분류", + "open": "오픈", + "cah": "CAH", + "advanced": "어드밴스드", + "expert": "엑스퍼트", + "masters": "마스터즈", + "owcs": "OWCS" + } + }, + "teamTsr": { + "teamTsr": "팀 TSR", + "teamCsr": "팀 CSR", + "tsrShort": "TSR", + "ratingShort": "평점", + "title": "로스터 실력 평점", + "meta": "{ratingLabel} · {rosterSize, number}명 중 {rated, number}명 평점 있음 · 플레이 시간 {playtimeShare, number, percent} 반영", + "sources": { + "tsr": "실제 TSR", + "predicted": "예측", + "csrFallback": "CSR 대체값" + }, + "sourceCopy": { + "tsr": "활성 선발 전원의 토너먼트 평점을 플레이 시간으로 가중 평균했습니다.", + "predicted": "평점이 있는 플레이어는 실제 TSR을, 나머지는 팀 CSR 오프셋으로 예측했습니다. 스크림 플레이 시간으로 가중합니다.", + "csrFallback": "TSR을 계산할 평점 기반 플레이 시간이 부족합니다. 플레이 시간 가중 영웅별 CSR을 표시하며 TSR과 같은 척도는 아닙니다." + }, + "confidence": { + "high": "높은 신뢰도", + "medium": "중간 신뢰도", + "low": "낮은 신뢰도" + }, + "contribution": { + "tsr": "TSR", + "predicted": "예측", + "predictedShort": "예측", + "csr": "CSR", + "none": "—" + }, + "offsetSpread": "오프셋 분산 σ {value, number}.", + "offsetSigma": "오프셋 σ", + "rated": "평점 있음", + "backed": "반영", + "empty": "로스터 평점을 계산하려면 BattleTag가 연결된 팀원을 추가하세요.", + "scrimBracket": "스크림 브래킷", + "findScrims": "스크림 찾기 →", + "playerTsr": "TSR {rating, number}", + "playerCsr": " · CSR {rating, number}", + "noTsr": "TSR 없음", + "playtimeSummary": "{percent, number, percent} · {playtime}", + "playtimeHours": "{hours}시간", + "playtimeMinutes": "{minutes, number}분", + "bracketWithBand": "{band} {tier}", + "bands": { + "low": "하위", + "mid": "중위", + "high": "상위" + }, + "tiers": { + "unclassified": "미분류", + "open": "오픈", + "cah": "CAH", + "advanced": "어드밴스드", + "expert": "엑스퍼트", + "masters": "마스터즈", + "owcs": "OWCS" + } + }, + "teamOps": { + "title": "팀 운영", + "blacklist": { + "subtitle": "스크림을 원하지 않는 팀입니다. 블랙리스트에 등록된 팀이 Parsertime에도 있는 경우, 매치메이커에서 서로에게 숨겨지며 어느 쪽도 스크림 요청을 보낼 수 없습니다.", + "heading": "블랙리스트", + "addPlaceholder": "블랙리스트에 팀 추가", + "searchPlaceholder": "팀 검색 또는 이름 입력", + "noMatches": "일치하는 팀이 없습니다", + "blockOffPlatform": "\"{name}\" 차단", + "offPlatform": "플랫폼 외부", + "onPlatform": "Parsertime 등록", + "blocking": "{name} 차단 중", + "reasonPlaceholder": "사유 (선택)", + "confirmAdd": "블랙리스트에 추가", + "cancel": "취소", + "empty": "아직 블랙리스트에 등록된 팀이 없습니다. 위에서 추가하면 해당 팀과 매칭되지 않습니다.", + "removeNamed": "{name}을(를) 블랙리스트에서 제거", + "addError": "팀을 추가하지 못했습니다. 다시 시도해 주세요.", + "removeError": "팀을 제거하지 못했습니다. 다시 시도해 주세요." + }, + "feedback": { + "prompt": "{name}과(와) 스크림을 했습니다. 어떠셨나요?", + "good": "좋은 스크림이었습니다", + "okay": "그럭저럭", + "poor": "별로 — 블랙리스트에 추가", + "skip": "건너뛰기", + "reasonPlaceholder": "사유 (선택)", + "confirmBlacklist": "팀 블랙리스트 추가", + "error": "피드백을 저장하지 못했습니다. 다시 시도해 주세요." + } }, "teamStatsPage": { + "layoutMetadata": { + "defaultTeam": "팀", + "title": "{teamName} 통계 | Parsertime", + "description": "Parsertime에서 {teamName}의 통계입니다. Parsertime은 오버워치 스크림을 분석하는 도구입니다.", + "ogTitle": "{teamName} 통계 | Parsertime", + "ogDescription": "Parsertime에서 {teamName}의 통계입니다. Parsertime은 오버워치 스크림을 분석하는 도구입니다.", + "ogImage": "{teamName} 통계" + }, + "selector": { + "select": "팀 선택..." + }, + "tempoRead": { + "faster": "평균보다 빠름", + "about": "평균 수준", + "slower": "평균보다 느림", + "delta": "(평균 대비 {delta}초)" + }, "rangePicker": { + "selectTimeframe": "기간 선택", + "lastWeek": "지난주", + "last2Weeks": "지난 2주", + "lastMonth": "지난 달", + "last3Months": "지난 3개월", + "last6Months": "지난 6개월", + "lastYear": "지난 1년", + "allTime": "전체 기간", + "customRange": "사용자 지정", + "upgrade": "더 많은 기간을 보려면 업그레이드하세요", + "pickDateRange": "날짜 범위 선택", + "loading": "새 기간 불러오는 중" + }, + "insufficientScrimsPlaceholder": { + "title": "스크림이 최소 2개 필요합니다", + "description": "팀 통계는 각 맵에서 우리 팀이 어느 진영으로 플레이했는지 안정적으로 식별하려면 스크림이 최소 2개 필요합니다. 현재 업로드된 스크림은 {count, plural, =0 {없습니다} other {#개입니다}}. 전체 통계 대시보드를 열려면 스크림을 하나 더 업로드하세요.", + "uploadScrim": "스크림 업로드" + }, + "simulatorTab": { + "header": { + "eyebrow": "시뮬레이터 · 승리 확률", + "title": "승리 확률 시뮬레이터" + }, + "noData": "선택한 기간에 사용할 게임 데이터가 없습니다. 스크림을 플레이하면 시뮬레이터를 사용할 수 있습니다.", + "stats": { + "mapsTracked": "추적한 맵", + "historicalSample": "기록 샘플", + "baseWinrate": "기준 승률", + "beforeScenario": "시나리오 적용 전", + "heroesScored": "평가된 영웅", + "mapsScored": "평가된 맵", + "availablePicks": "선택 가능" + }, + "scenario": { + "eyebrow": "시뮬레이터 · 시나리오", + "title": "시나리오 설정", + "description": "밴, 맵, 조합을 설정해 예상 승률이 어떻게 바뀌는지 확인하세요.", + "reset": "초기화", + "resetAria": "모든 시나리오 입력 초기화", + "enemyBans": { + "label": "상대가 우리에게 거는 밴", + "description": "상대가 우리 영웅 풀에서 막는 영웅" + }, + "ourBans": { + "label": "우리 밴", + "description": "우리가 상대에게 금지하는 영웅" + }, + "ourComposition": { + "label": "우리 조합", + "description": "우리 팀 영웅을 최대 5명까지 선택" + }, + "enemyComposition": { + "label": "상대 조합", + "description": "상대 팀 영웅을 최대 5명까지 선택" + } + }, + "heroPicker": { + "addHero": "영웅 추가", + "label": "영웅 선택기", + "selectHero": "{hero} 선택", + "removeHero": "{hero} 제거", + "removeTooltip": "{hero} (클릭하여 제거)", + "roles": { + "Tank": "탱커", + "Damage": "딜러", + "Support": "지원" + } + }, + "mapPicker": { + "label": "맵", + "description": "플레이할 맵을 선택하세요", + "selectAria": "맵 선택", + "placeholder": "맵 선택...", + "searchPlaceholder": "맵 검색...", + "noMapsFound": "맵을 찾을 수 없습니다.", + "noneSelected": "선택한 맵 없음", + "mapTypes": { + "Clash": "클래시", + "Control": "쟁탈", + "Escort": "호위", + "Flashpoint": "플래시포인트", + "Hybrid": "혼합", + "Push": "밀기" + } + }, + "prediction": { + "eyebrow": "시뮬레이터 · 예측", + "title": "예상 승률", + "description": "팀의 과거 데이터 {count, plural, =0 {맵 없음} other {#개 맵}} 기준", + "estimatedWinRateAria": "예상 승률: {value}", + "confidence": { + "high": "높은 신뢰도", + "medium": "중간 신뢰도", + "low": "낮은 신뢰도" + }, + "deltaVsBase": "기준({base}) 대비 {direction, select, positive {+{delta}pp} negative {-{delta}pp} other {{delta}pp}}", + "warningsLabel": "주의", + "emptyScenario": "왼쪽에서 시나리오를 설정하면 변수들이 승리 확률에 어떤 영향을 주는지 볼 수 있습니다.", + "warnings": { + "enemyBanLowSample": "{hero}은(는) {samples, plural, =0 {밴된 적이 없습니다} other {#회만 밴되었습니다}}. 영향 추정치가 불안정할 수 있습니다", + "ourBanLowSample": "{hero}을(를) {samples, plural, =0 {밴한 적이 없습니다} other {#회만 밴했습니다}}. 영향 추정치가 불안정할 수 있습니다", + "mapLowSample": "{map}에서 플레이한 게임이 {samples, plural, =0 {없습니다} other {#개뿐입니다}}. 맵 영향 추정치가 불안정할 수 있습니다", + "mapModeFallback": "{map} 데이터가 없어 {mapType} 모드 평균을 대신 사용합니다", + "rosterLowSample": "이 로스터 조합은 기록된 게임이 {games, plural, =0 {없습니다} other {#개뿐입니다}}", + "enemyHeroLowSample": "상대 {hero}를 상대한 게임이 {samples, plural, =0 {없습니다} other {#개뿐입니다}}. 영향 추정치가 불안정할 수 있습니다" + }, + "insights": { + "enemyBanHurts": "{hero} 밴은 우리 팀 승률을 {delta}% 낮춥니다", + "ourBanHelps": "{hero} 밴은 +{delta}%를 더해 주는 가장 강한 밴입니다", + "mapStrength": "{map}은(는) 우리 팀에게 {direction, select, strong {강한 맵} weak {약한 맵} other {보통 맵}}입니다 ({delta}%)", + "compositionImpact": "우리 조합은 승리 가능성을 {direction, select, boosts {높여} reduces {낮춰} other {바꿔}} {delta}% 만듭니다", + "enemyCompositionImpact": "상대 조합은 우리에게 {direction, select, favors {유리하게} challenges {불리하게} other {영향을 주게}} {delta}% 작용합니다" + }, + "mapTypes": { + "Clash": "클래시", + "Control": "쟁탈", + "Escort": "호위", + "Flashpoint": "플래시포인트", + "Hybrid": "혼합", + "Push": "밀기" + } + }, + "breakdown": { + "heading": "분석", + "listAria": "승률 분석", + "baseWinrate": "기준 승률", + "enemyBanImpact": "상대 밴 영향", + "ourBans": "우리 밴", + "map": "맵", + "composition": "조합", + "enemyComp": "상대 조합", + "impactPercent": "{direction, select, positive {+{value}%} negative {-{value}%} other {{value}%}}", + "barScale": "막대 기준: 최대 ±{scale}pp. 막대는 ±{max}pp에서 제한됩니다." + } + }, + "ultimateEconomyCard": { + "eyebrow": "궁극기 · 경제", + "title": "궁극기 경제", + "noData": "아직 궁극기 사용 데이터가 없습니다.", + "description": "팀이 궁극기를 얼마나 효율적으로 사용하는지 보여줍니다", + "ultimateEfficiency": "궁극기 효율", + "fightsWonPerUltUsed": "사용한 궁극기당 승리 교전", + "wastedUltimates": "낭비된 궁극기", + "wastePercentage": "총 궁극기 중 {percentage}", + "usedInLostSituations": "패배 상황에서 사용됨", + "totalUltimates": "총 궁극기", + "acrossFights": "{fights}회 교전 전체", + "ultimatesPerFight": "교전당 {ultimates}", + "excellent": "훌륭함", + "good": "좋음", + "average": "보통", + "poor": "낮음", + "ultimateUsage": "궁극기 사용: 승리 교전 vs 패배 교전", + "outcome": "결과", + "fightsHeader": "교전", + "avgUltsUsedHeader": "평균 궁극기 사용", + "detail": "세부정보", + "winningFights": "승리 교전", + "fights": "{count, plural, other {교전 #회}}", + "averageUltsUsed": "평균 궁극기 사용", + "losingFights": "패배 교전", + "goodUltDiscipline": "좋은 궁극기 규율: 패배 중일 때({ultsInLostFights})보다 승리 중일 때({ultsInWonFights}) 궁극기를 더 많이 사용하므로, 불리한 교전에서 자원을 낭비하지 않고 있습니다.", + "roomForImprovement": "개선 여지: 승리 중일 때({ultsInWonFights})보다 패배 중일 때({ultsInLostFights}) 궁극기를 더 많이 사용합니다. 불리한 상황에서는 궁극기를 아끼는 것을 고려하세요.", + "context": "맥락", + "bucket": "구간", + "winrate": "승률", + "reversals": "역전", + "dryFightsRow": "노궁 교전", + "ultFightsRow": "궁극기 교전", + "avgAbbreviation": "평균", + "dryFights": "노궁 교전:", + "dryFightWinrate": "노궁 교전 {count}회 (승률 {winrate}%)", + "nonDryFights": "궁극기 사용 교전:", + "nonDryFightsCount": "궁극기 사용 교전 {count}회 (평균 궁극기 {ults}개)", + "fightReversals": "교전 역전", + "dryReversalRate": "노궁: {rate}% ({total}회 중 {count}회)", + "nonDryReversalRate": "궁극기 사용: {rate}% ({total}회 중 {count}회)", + "reversalInsightUltReliant": "팀은 궁극기를 사용할 때({nonDryRate}) 사용하지 않을 때({dryRate})보다 교전을 더 자주 뒤집습니다. 궁극기에 의존한 역전 경향이 있습니다.", + "reversalInsightMechanical": "팀은 궁극기를 사용하지 않을 때({dryRate}) 사용할 때({nonDryRate})보다 교전을 더 자주 뒤집습니다. 강한 기본 교전 역전 능력을 보여줍니다." + }, + "ultimatesTab": { + "overview": { + "eyebrow": "궁극기 · 사용 개요", + "title": "궁극기 사용 개요", + "noData": "아직 궁극기 사용 데이터가 없습니다.", + "description": "{maps}개 맵에서 집계됨", + "totalUltsUsed": "총 사용한 궁극기", + "ultsPerMap": "맵당 평균 {count}회", + "avgChargeTime": "평균 충전 시간", + "avgHoldTime": "평균 보유 시간", + "vsOpponents": { + "title": "상대한 팀 대비 ({maps}개 맵)", + "you": "우리", + "opponents": "상대", + "readFaster": "상대보다 {delta}초 빠름", + "readSlower": "상대보다 {delta}초 느림", + "readEven": "상대와 비슷함", + "youFaster": "우리가 {delta}초 빠름", + "youSlower": "우리가 {delta}초 느림", + "youEven": "비슷함", + "better": "우수", + "worse": "저조", + "unnamed": "이름 없는 상대", + "colOpponent": "상대", + "colAvg": "평균", + "colDelta": "우리 대비", + "colMaps": "맵" + }, + "fightInitiation": "교전 개시율", + "fightInitiationDetail": "궁극기 교전 {total}회 중 {count}회", + "metric": "지표", + "value": "값", + "read": "해석", + "fightOpenings": "교전 개시", + "topFightOpeners": "주요 교전 개시 영웅", + "topOpenersLabel": "가장 많이 교전을 연 영웅: {top} ({count}회)" + }, + "roleBreakdown": { + "eyebrow": "궁극기 · 역할 분석", + "title": "역할별 궁극기 사용", + "noData": "아직 궁극기 사용 데이터가 없습니다.", + "description": "역할별 궁극기 분포와 교전 내 사용 타이밍입니다", + "percentage": "전체의 {value}%", + "role": "역할", + "ultsUsed": "사용한 궁극기", + "share": "비중", + "subroles": "서브역할", + "timingTitle": "궁극기 타이밍 분포", + "timingDescription": "교전 내 궁극기 사용 시점입니다: 개시(첫 1/3), 중반(가운데 1/3), 후반(마지막 1/3).", + "yourTeam": "내 팀" + }, + "playerRankings": { + "eyebrow": "궁극기 · 플레이어 순위", + "title": "플레이어 궁극기 순위", + "noData": "아직 플레이어 데이터가 없습니다.", + "description": "모든 스크림에서의 개인별 궁극기 사용입니다", + "tableLabel": "플레이어 궁극기 순위", + "player": "플레이어", + "hero": "주 영웅", + "totalUlts": "총 궁극기", + "mapsPlayed": "맵", + "ultsPerMap": "맵당 궁극기", + "fightOpener": "교전 개시" + }, + "combos": { + "eyebrow": "궁극기 · 콤보", + "title": "궁극기 콤보", + "description": "{maps}개 맵에서 {window}초 이내에 함께 사용된 2인 궁극기 콤보입니다.", + "noData": "아직 2인 궁극기 콤보가 없습니다. 교전에서 {window}초 이내에 궁극기 두 개를 함께 사용하면 여기에 표시됩니다.", + "metricLabel": "콤보 지표", + "mostUsed": "가장 많이 사용", + "winRate": "승률", + "captionMostUsed": "가장 많이 사용된 콤보", + "captionWinRate": "최고 승률 (최소 {min}회 사용)", + "winrateNeedsSamples": "아직 {min}회 이상 사용된 콤보가 없어 승률 순위를 매길 수 없습니다.", + "rowSummary": "{heroA} 및 {heroB}: {count}회 교전에서 사용, 승률 {winrate}%.", + "sampleSize": "n={count}", + "lowSampleNote": "{min}회 미만 사용된 콤보는 승률 순위에서 숨겨집니다." + }, + "responses": { + "eyebrow": "궁극기 · 적 궁극기 대응", + "title": "적 궁극기 대응", + "description": "{maps}개 맵에서 적 궁극기 후 {window}초 이내에 우리 팀이 사용한 궁극기입니다.", + "noData": "아직 카운터 궁극기가 없습니다. 적 궁극기에 {window}초 이내에 대응하면 여기에 표시됩니다.", + "viewLabel": "대응 보기", + "matrix": "매트릭스", + "flow": "흐름", + "enemyAxis": "적 궁극기", + "ourAxis": "우리 대응", + "matrixHint": "셀 색상은 대응 빈도를 나타냅니다. 마우스를 올리면 승률을 볼 수 있습니다.", + "cellSummary": "{enemyHero}에 {ourHero}(으)로 {count}회 대응, 승률 {winrate}%.", + "selectEnemyUlt": "적 궁극기", + "allEnemyUlts": "모든 적 궁극기", + "searchEnemyUlts": "적 궁극기 검색", + "noEnemyUlts": "적 궁극기를 찾을 수 없습니다.", + "flowSummary": "{ourHero}이(가) {enemyHero}에 {count}회 대응 (승률 {winrate}%).", + "noResponsesForHero": "이 적 궁극기에 대한 추적된 대응이 없습니다." + }, + "ultAdvantage": { + "eyebrow": "궁극기 · 우위", + "title": "궁극기 우위", + "description": "{maps}개 맵에서 교전 진입 시 보유 궁극기가 적과 비교해 어떤지 보여줍니다.", + "noData": "궁극기 우위를 추적할 만큼 궁극기 충전 데이터가 아직 충분하지 않습니다.", + "avgAdvantage": "평균 궁극기 우위", + "avgAdvantageSub": "교전 진입 시 적 대비 궁극기 수", + "winWhenAhead": "우위일 때 승률", + "winWhenBehind": "열세일 때 승률", + "ofFights": "교전의 {pct}%", + "behind": "열세", + "even": "동등", + "ahead": "우위", + "distributionCaption": "교전에 진입하는 빈도...", + "winrateCaption": "궁극기 우위별 승률", + "bucketBehind2": "2개 이상 열세", + "bucketBehind1": "1개 열세", + "bucketEven": "동등", + "bucketAhead1": "1개 우위", + "bucketAhead2": "2개 이상 우위", + "noFights": "교전 없음", + "fightsCount": "{count, plural, other {교전 #회}}", + "tempoCaption": "교전별 평균 궁극기 우위", + "fightTick": "{n}번째 교전", + "tempoHint": "0보다 높으면 적보다 많은 궁극기를 보유한 채 교전에 진입했다는 의미입니다.", + "shapeCaption": "우위별 교전 수", + "fightsAxis": "교전" + } + }, + "swapsTab": { + "overview": { + "eyebrow": "교체 · 개요", + "title": "영웅 교체 개요", + "noData": "아직 영웅 교체 데이터가 없습니다.", + "description": "{maps}개 맵에서 집계됨", + "totalSwaps": "총 교체", + "swapsPerMap": "맵당 평균 {count}회", + "winrateDelta": "승률 영향", + "winrateDeltaPositive": "교체 시 +{delta}", + "winrateDeltaNegative": "교체 시 {delta}", + "winrateDeltaNeutral": "차이 없음", + "noSwapWinrate": "교체 없음 승률", + "noSwapDetail": "교체 없이 {wins}승 - {losses}패", + "swapWinrate": "교체 승률", + "swapDetail": "교체 포함 {wins}승 - {losses}패", + "avgTimeBeforeSwap": "영웅 평균 플레이 시간", + "avgTimeDetail": "교체 전 영웅을 플레이한 평균 시간", + "durationSeconds": "{seconds}초", + "durationMinutes": "{minutes}분 {seconds}초" + }, + "timing": { + "eyebrow": "교체 · 타이밍", + "title": "교체 타이밍 분포", + "description": "경기 전체 시간 대비 영웅 교체가 발생한 시점의 비율입니다", + "noData": "사용 가능한 교체 타이밍 데이터가 없습니다.", + "swapCount": "{count, plural, other {교체 #회}}", + "swapsLabel": "교체", + "matchTime": "경기 시간: {label}", + "ofTotal": "전체 교체의 {pct}%" + }, + "winrate": { + "eyebrow": "교체 · 승률 영향", + "title": "교체 행동별 승률", + "descriptionCount": "맵당 교체 횟수에 따라 승률이 어떻게 달라지는지 보여줍니다", + "descriptionTiming": "초반, 중반, 후반 교체가 포함된 맵의 승률입니다", + "noData": "사용 가능한 교체 승률 데이터가 없습니다.", + "byCount": "교체 횟수별", + "byTiming": "교체 타이밍별", + "maps": "{count}개 맵", + "winrate": "{pct}", + "winRateLabel": "승률" + }, + "pairs": { + "eyebrow": "교체 · 자주 쓰는 조합", + "title": "가장 흔한 영웅 교체", + "description": "모든 맵에서 가장 많이 발생한 영웅 간 교체 전환입니다", + "noData": "사용 가능한 교체 조합 데이터가 없습니다.", + "count": "{count, plural, other {#회}}", + "times": "횟수", + "timingLabel": "경기 내 시점" + }, + "player": { + "eyebrow": "교체 · 플레이어 분석", + "title": "플레이어 교체 분석", + "description": "개별 플레이어의 교체 통계와 승률 영향입니다", + "noData": "사용 가능한 플레이어 교체 데이터가 없습니다.", + "tableLabel": "플레이어 교체 통계", + "player": "플레이어", + "swaps": "교체", + "mapsWithSwaps": "교체한 맵", + "winrateWith": "교체 시 승률", + "winrateWithout": "교체 없을 때 승률", + "topPair": "주요 교체" + } + }, + "positional": { + "eyebrow": "위치", + "title": "위치 통계", + "description": "위치 데이터가 있는 최근 {count}개 스크림의 평균입니다.", + "trendsTitle": "추세", + "playersTitle": "선수별 분석", + "playerColumn": "선수", + "matrix": { + "below": "중앙값 미만", + "above": "중앙값 초과", + "legend": "색조는 각 값이 해당 스탯의 로스터 중앙값에서 벗어난 정도를 나타냅니다." + }, + "byZoneTitle": "구역별 승 / 패 / 무", + "empty": "아직 위치 데이터가 없습니다. 좌표 추적이 포함된 스크림을 업로드하면 통계가 표시됩니다.", + "stats": { + "AVERAGE_ENGAGEMENT_DISTANCE": { + "short": "교전 거리", + "full": "평균 교전 거리" + }, + "HIGH_GROUND_KILL_PERCENTAGE": { + "short": "고지대 킬 %", + "full": "고지대 킬 %" + }, + "ISOLATION_DEATH_PERCENTAGE": { + "short": "고립 데스 %", + "full": "고립 데스 %" + }, + "AVERAGE_FIGHT_START_SPREAD": { + "short": "시작 간격", + "full": "한타 시작 간격" + }, + "AVERAGE_ULT_CONVERSION_KILLS": { + "short": "전환 킬", + "full": "궁극기 전환 킬" + }, + "ULT_DEATH_PERCENTAGE": { + "short": "궁중 데스 %", + "full": "궁극기 사용 중 데스 %" + }, + "AVERAGE_ULT_DISPLACEMENT": { + "short": "이동 거리", + "full": "평균 궁극기 이동 거리" + }, + "ULTS_ON_OBJECTIVE_PERCENTAGE": { + "short": "거점 궁 %", + "full": "거점 위 궁극기 %" + } + }, + "engagementsTitle": "교전", + "winrate": "승률", + "fights": "{total, plural, other {교전 #회}}", + "recordSummary": "{won}승 / {lost}패 / 무승부 {even}", + "wonLabel": "승리", + "lostLabel": "패배", + "evenLabel": "무승부", + "zonesTitle": "맵별 구역 장악", + "zonesYourTeam": "우리 팀", + "zonesOpponents": "상대 팀", + "zonesCount": "구역 {count}개", + "zonesControlPct": "{pct}% 장악", + "routesTitle": "맵별 이동 경로", + "routesSummary": "경로 {total}개 · {won}승 / {lost}패", + "routesUndecided": "미정", + "zoneColumn": "구역", + "teamColumn": "팀", + "killsColumn": "킬", + "deathsColumn": "데스", + "ultsColumn": "궁극기" + }, + "roleBalanceRadar": { + "eyebrow": "개요 · 역할 균형", + "title": "역할 균형", + "noData": "역할 균형을 분석하기에는 아직 데이터가 부족합니다.", + "tooltipTitle": "역할 균형", + "eliminations": "처치", + "survivability": "생존력", + "ultUsage": "궁극기 사용", + "activity": "활동량", + "balanced": "균형적", + "tankHeavy": "탱커 편중", + "damageHeavy": "딜러 편중", + "supportHeavy": "지원 편중", + "insufficientData": "데이터 부족", + "tank": "탱커", + "damage": "딜러", + "support": "지원", + "insights": "인사이트", + "excellentBalance": "팀이 모든 역할에서 매우 균형 잡힌 모습을 보입니다.", + "fairlyBalanced": "팀은 약간의 역할 편차가 있지만 대체로 균형적입니다.", + "considerStrengthening": "팀 균형을 개선하려면 {role} 성능을 강화해 보세요.", + "negativeKD": "{role} 플레이어의 K/D가 음수입니다.", + "dyingFrequently": "{role} 플레이어가 자주 사망하고 있습니다 - 포지셔닝에 집중하세요.", + "strongest": "가장 강한 역할:", + "needsWork": "개선 필요:", + "balanceScore": "균형 점수:", + "roles": { + "Tank": "탱커", + "Damage": "딜러", + "Support": "지원" + } + }, + "overviewInsightsBand": { + "eyebrow": "개요 · 핵심 신호", + "title": "무엇이 성과를 움직이는가", + "noInsights": "핵심 인사이트를 만들 만큼 데이터가 아직 충분하지 않습니다.", + "strongestRole": "가장 강한 역할", + "needsWork": "개선 필요", + "strongestMap": "가장 강한 맵", + "bleedSpot": "취약 지점", + "bestDay": "최고 요일", + "firstPickRate": "첫 처치 전환율", + "roleDetail": "{kd} K/D · 10분당 사망 {deaths}", + "mapWinrate": "승률 {winrate}", + "bestDayDetail": "{count, plural, =0 {게임 없음} other {#게임}} 기준 {winrate}", + "firstPickDetail": "{total}회 중 {successful}회 마무리", + "days": { + "sunday": "일요일", + "monday": "월요일", + "tuesday": "화요일", + "wednesday": "수요일", + "thursday": "목요일", + "friday": "금요일", + "saturday": "토요일" + } + }, + "matchupWinrateTab": { + "header": { + "eyebrow": "승률 · 매치업", + "title": "영웅 매치업 승률" + }, + "noData": "선택한 기간에 사용할 게임 데이터가 없습니다. 스크림을 플레이하면 매치업 분석을 볼 수 있습니다.", + "stats": { + "scrimsTracked": "추적한 스크림", + "maps": "{count, plural, =0 {맵 없음} other {#개 맵}}", + "bestMatchup": "최고 매치업", + "worstMatchup": "최악 매치업", + "matchupLabel": "{hero} 상대 ({count, plural, other {#개 맵}})", + "needSharedMaps": "공통 맵 3개 이상 필요", + "compositions": "조합", + "uniqueFiveStacks": "고유 5인 조합" + }, + "explorer": { + "eyebrow": "승률 · 매치업", + "title": "영웅 매치업 탐색", + "description": "양쪽 영웅을 선택해 분석 범위를 좁히세요. 선택한 모든 영웅이 한 맵에 있어야 집계됩니다.", + "clear": "지우기", + "clearAria": "모든 영웅 선택 지우기", + "ourHeroes": "우리 영웅", + "ourHeroesDescription": "우리 팀이 플레이한 영웅", + "enemyHeroes": "상대 영웅", + "enemyHeroesDescription": "상대가 플레이한 영웅", + "versus": "vs" + }, + "heroPicker": { + "addHero": "영웅 추가", + "label": "영웅 선택기", + "selectHero": "{hero} 선택", + "removeHero": "{hero} 제거", + "removeTooltip": "{hero} (클릭하여 제거)", + "full": "(가득 참)", + "slotFullAria": "{hero} ({role} 슬롯 가득 참)", + "slotFullTooltip": "{hero} ({role} 슬롯 가득 참)", + "roles": { + "Tank": "탱커", + "Damage": "딜러", + "Support": "지원" + } + }, + "summary": { + "eyebrow": "승률 · 매치업 요약", + "filteredTitle": "필터된 매치업", + "overallTitle": "전체 기록", + "filteredDescription": "{count, plural, =0 {일치하는 맵 없음} other {일치하는 맵 #개}} 기준", + "overallDescription": "추적한 {count, plural, =0 {맵 없음} other {#개 맵}} 전체 기준", + "noFilteredMaps": "이 매치업 조합과 일치하는 맵이 없습니다.", + "selectPrompt": "위에서 영웅을 선택해 매치업 데이터를 탐색하세요.", + "winRateAria": "승률: {value}", + "confidence": { + "high": "높은 신뢰도", + "medium": "중간 신뢰도", + "low": "낮은 신뢰도" + }, + "record": "{wins}승 / {losses}패 ({count, plural, =0 {맵 없음} other {#개 맵}})", + "roughlyEqual": "기준 승률과 거의 같습니다", + "deltaVsBase": "기준({base}) 대비 {direction, select, above {+{delta}pp 높음} below {-{delta}pp 낮음} other {{delta}pp}}" + }, + "trend": { + "heading": "누적 승률 추세", + "now": "현재 {winrate}", + "trendDelta": "{direction, select, positive {(+{delta}pp 추세)} negative {(-{delta}pp 추세)} other {({delta}pp 추세)}}", + "cumulative": "누적", + "recordShort": "({wins}승 / {losses}패)", + "scrimRecord": "{scrimName}: {wins}승 / {losses}패 ({winrate})", + "winPercentAxis": "승률 %", + "trendName": "추세", + "cumulativeWinrateName": "누적 승률", + "notEnoughData": "추세를 표시할 데이터가 부족합니다. 스크림이 최소 2개 필요합니다." + }, + "compositions": { + "eyebrow": "승률 · 조합", + "title": "최고 조합", + "description": "이 매치업에서 가장 좋은 5인 조합입니다(최소 2게임).", + "tableAria": "최고 조합", + "rank": "순위", + "composition": "조합", + "winrate": "승률", + "record": "기록", + "winsLosses": "{wins}승 / {losses}패", + "noData": "상위 조합을 식별할 만큼 데이터가 충분하지 않습니다." + }, + "results": { + "eyebrow": "승률 · 매치 결과", + "title": "매치 결과", + "description": "이 매치업의 개별 맵 결과입니다.", + "date": "날짜", + "scrim": "스크림", + "map": "맵", + "result": "결과", + "ourHeroes": "우리 영웅", + "enemyHeroes": "상대 영웅", + "winShort": "승", + "lossShort": "패", + "noMaps": "일치하는 맵이 없습니다." + } + }, + "mapWinrateGallery": { + "eyebrow": "맵 · 승률 갤러리", + "title": "맵 성능", + "noData": "아직 맵 데이터가 없습니다. 경기를 플레이하면 팀의 맵 성능을 확인할 수 있습니다!", + "mapFilters": "맵 필터", + "clearFilters": "필터 지우기", + "searchMaps": "맵 검색", + "searchMapNames": "맵 이름 검색...", + "mapType": "맵 유형", + "allTypes": "모든 유형", + "sortBy": "정렬 기준", + "sortByPlaceholder": "정렬 기준...", + "highestWinrate": "최고 승률", + "mostPlayed": "가장 많이 플레이", + "alphabetical": "가나다순", + "showingMaps": "총 {total}개 중 {count}개 맵 표시", + "noMapsMatchFilters": "현재 필터와 일치하는 맵이 없습니다. 검색어나 필터를 조정해 보세요.", + "gamesLabel": "{count, plural, =0 {플레이한 경기 없음} other {#경기 플레이}}", + "clickToSeeRosterPerformance": "맵을 클릭하면 로스터 성능 세부정보를 볼 수 있습니다", + "winsShort": "{count}승", + "lossesShort": "{count}패", + "mapTypes": { + "Clash": "클래시", + "Control": "쟁탈", + "Escort": "호위", + "Flashpoint": "플래시포인트", + "Hybrid": "혼합", + "Push": "밀기" + } + }, + "heroPoolContainer": { "selectTimeframe": "기간 선택", "lastWeek": "지난주", "last2Weeks": "지난 2주", @@ -2369,15 +7377,520 @@ "customRange": "사용자 지정", "upgrade": "더 많은 기간을 보려면 업그레이드하세요", "pickDateRange": "날짜 범위 선택" + }, + "heroPoolOverviewCard": { + "eyebrow": "영웅 · 풀 개요", + "title": "영웅 풀 개요", + "noData": "아직 영웅 풀 데이터가 없습니다.", + "excellent": "훌륭함", + "good": "좋음", + "average": "보통", + "limited": "제한적", + "totalHeroes": "전체 영웅", + "effectivePool": "유효 영웅 풀", + "diversityScore": "다양성 점수", + "minGames": "3경기 이상", + "specialists": "전문가", + "minOwnership": "점유율 30% 이상", + "mostPlayedByRoleEyebrow": "영웅 · 역할별 최다 플레이", + "mostPlayedByRole": "역할별 최다 플레이", + "gamesPlayed": "{count, plural, =0 {경기 없음} other {#경기}}", + "players": "{count, plural, =0 {플레이어 없음} other {#명 플레이}}", + "roleDistribution": "역할 분포", + "tankHeroes": "탱커 영웅", + "dpsHeroes": "딜러 영웅", + "supportHeroes": "지원 영웅", + "roles": { + "Tank": "탱커", + "Damage": "딜러", + "Support": "지원" + } + }, + "heroWinratesCard": { + "eyebrow": "영웅 · 최고 승률", + "title": "영웅별 최고 승률", + "noData": "영웅 승률을 계산할 만큼 플레이한 게임이 아직 부족합니다(영웅당 3게임 이상 필요).", + "winsAndLosses": "{wins}승 - {losses}패 • {games, plural, =0 {게임 없음} other {#게임}}" + }, + "heroBanCards": { + "received": { + "eyebrow": "영웅 · 밴 영향", + "emptyTitle": "영웅 밴 영향", + "title": "가장 많이 밴당한 영웅", + "noData": "선택한 기간에 사용할 영웅 밴 데이터가 없습니다.", + "description": "{maps, number}개 맵에서 상대가 우리 팀을 상대로 가장 자주 밴한 영웅입니다. 약점 태그는 해당 영웅이 없을 때 승률이 떨어지는 경우를 표시합니다." + }, + "outgoing": { + "eyebrow": "영웅 · 우리 밴", + "title": "우리 팀 밴 전략", + "noData": "선택한 기간에 사용할 우리 팀 밴 데이터가 없습니다.", + "description": "{maps, number}개 맵에서 우리 팀이 가장 자주 밴한 영웅입니다. 고가치 태그는 승률을 가장 많이 끌어올린 밴을 표시합니다." + }, + "table": { + "hero": "영웅", + "bans": "밴", + "distribution": "분포", + "banRate": "밴 비율", + "wrWith": "있을 때 승률", + "wrWithout": "없을 때 승률", + "wrBanned": "밴 시 승률", + "wrNotBanned": "밴 안 함 승률", + "wrDelta": "승률 차이", + "tag": "태그" + }, + "tags": { + "weakPoint": "약점", + "highValue": "고가치" + } + }, + "abilityImpactAnalysisCard": { + "eyebrow": "팀 전투 · 능력 영향", + "title": "능력 영향", + "description": "특정 영웅 능력이 스크림 전투 결과를 어떻게 바꾸는지 보여줍니다.", + "selectHero": "영웅 선택", + "searchHeroes": "영웅 검색", + "noHeroesFound": "영웅을 찾을 수 없습니다.", + "percentagePoints": "{value}pp", + "sampleCount": "({count, number})", + "roles": { + "Tank": "탱커", + "Damage": "딜러", + "Support": "지원" + }, + "table": { + "ability": "능력", + "fights": "전투", + "withWr": "사용 시 승률", + "withoutWr": "미사용 시 승률", + "delta": "차이", + "tag": "태그" + }, + "tags": { + "key": "핵심", + "risk": "위험" + }, + "smallSample": "{hero}의 표본이 작아 결과가 통계적으로 유의하지 않을 수 있습니다.", + "selectHeroPrompt": "영웅을 선택하면 해당 능력이 전투 결과를 어떻게 바꾸는지 볼 수 있습니다." + }, + "ultImpactAnalysisCard": { + "eyebrow": "궁극기 · 영향 분석", + "title": "궁극기 영향", + "description": "특정 영웅 궁극기가 여러 상황에서 전투 결과에 어떤 영향을 주는지 보여줍니다.", + "selectHero": "영웅 선택", + "searchHeroes": "영웅 검색", + "noHeroesFound": "영웅을 찾을 수 없습니다.", + "roles": { + "Tank": "탱커", + "Damage": "딜러", + "Support": "지원" + }, + "scenarios": { + "uncontestedOurs": "상대 대응 없음 (우리)", + "uncontestedTheirs": "우리 대응 없음 (상대)", + "mirrorOursFirst": "미러전, 우리 선사용", + "mirrorTheirsFirst": "미러전, 상대 선사용" + }, + "table": { + "hero": "영웅", + "scenario": "상황", + "fights": "전투", + "wins": "승", + "losses": "패", + "winrate": "승률", + "tag": "태그" + }, + "tags": { + "keyUlt": "핵심 궁극기" + }, + "smallSample": "{hero}에 대해 분석된 전투가 {count, plural, other {#회}}뿐이라 결과가 통계적으로 유의하지 않을 수 있습니다.", + "selectHeroPrompt": "영웅을 선택하면 해당 궁극기가 전투 결과에 어떤 영향을 주는지 볼 수 있습니다." + }, + "mapModePerformanceCard": { + "eyebrow": "맵 · 모드 성능", + "winrate": "승률: {winrate}", + "winsAndLosses": "{wins}승 - {losses}패 ({games, plural, =0 {경기 없음} other {#경기}})", + "title": "맵 모드 성능", + "noData": "아직 맵 모드 데이터가 없습니다.", + "best": "최고: {mode}", + "winrateLabel": "승률 %", + "record": "전적", + "winsLossesRecord": "{wins}승 - {losses}패", + "gamesLabel": "경기", + "avgTimeLabel": "평균 시간", + "bestMap": "최고 맵", + "worstMap": "최저 맵", + "insights": "인사이트", + "excelsAt": "팀은 {mode} 맵에서 강합니다 (승률 {winrate}%)", + "considerPracticing": "균형을 개선하려면 {mode} 맵 연습을 고려하세요 (승률 {winrate}%)", + "mapTypes": { + "Clash": "클래시", + "Control": "쟁탈", + "Escort": "호위", + "Flashpoint": "플래시포인트", + "Hybrid": "혼합", + "Push": "밀기" + } + }, + "teamRosterGrid": { + "eyebrow": "개요 · 로스터", + "title": "팀 로스터", + "playerTargets": "플레이어 목표", + "noData": "아직 로스터 데이터가 없습니다. 플레이어는 경기에 참여하면 여기에 표시됩니다.", + "subBadge": "교체", + "manageSubstitute": "교체 선수 관리", + "markSubstitute": "교체 선수로 지정", + "unmarkSubstitute": "교체 선수 지정 해제", + "markSuccess": "{player} 님을 교체 선수로 지정했습니다", + "unmarkSuccess": "{player} 님의 교체 선수 지정을 해제했습니다", + "errorTitle": "교체 선수를 업데이트할 수 없습니다", + "errorDescription": "{res}" + }, + "topMapsCard": { + "eyebrow": "개요 · 플레이한 맵", + "title": "플레이 시간 상위 맵", + "noData": "아직 맵 데이터가 없습니다.", + "performanceEyebrow": "개요 · 맵 성과", + "performanceTitle": "맵 성과", + "performanceDescription": "가장 많이 플레이한 맵과 승률, 플레이 시간을 함께 보여줍니다.", + "columns": { + "map": "맵", + "games": "경기", + "playtime": "플레이 시간", + "winrate": "승률", + "distribution": "분포", + "tag": "태그" + }, + "tags": { + "strongest": "강점", + "bleed": "취약" + } + }, + "quickStatsCard": { + "eyebrow": "개요 · 신호", + "title": "빠른 통계", + "last10Games": "최근 10경기", + "winsLossesRecord": "{wins}승 - {losses}패", + "recordShort": "{wins}-{losses}", + "bestDay": "최고 요일", + "notEnoughData": "데이터가 부족합니다 (요일별 3경기 이상 필요)", + "gamesLabel": "{count, plural, =0 {플레이한 경기 없음} other {#경기 플레이}}", + "winrateShort": "승률 {winrate}%", + "avgFightDuration": "평균 교전 시간", + "notAvailable": "해당 없음", + "durationSeconds": "{seconds}초", + "firstPickSuccess": "첫 처치 성공", + "firstPickSuccessRate": "{totalFirstPicks}번 중 {successfulFirstPicks}번 승리", + "heroPool": "영웅 풀", + "mapPool": "맵 풀", + "uniqueHeroes": "{count, plural, =0 {고유 영웅 없음} other {고유 영웅 #명}}", + "uniqueMaps": "{count, plural, =0 {고유 맵 없음} other {고유 맵 #개}}", + "days": { + "sunday": "일요일", + "monday": "월요일", + "tuesday": "화요일", + "wednesday": "수요일", + "thursday": "목요일", + "friday": "금요일", + "saturday": "토요일" + }, + "noData": "데이터 없음" + }, + "winLossStreaksCard": { + "eyebrow": "추세 · 연승/연패", + "title": "연승/연패", + "noData": "아직 연승/연패 데이터가 없습니다.", + "na": "해당 없음", + "dateRange": "{start} - {end}", + "currentStreak": "현재 흐름", + "winStreakCount": "{count}연승", + "lossStreakCount": "{count}연패", + "hot": "🔥 상승세", + "cold": "❄️ 하락세", + "longestWinStreak": "최장 연승", + "noWinsYet": "아직 승리가 없습니다", + "longestLossStreak": "최장 연패", + "noLossesYet": "아직 패배가 없습니다", + "keepMomentumGoing": "좋은 흐름을 계속 이어가세요!", + "timeToBreakStreak": "흐름을 끊을 때입니다 - 최근 VOD를 다시 확인해 보세요" + }, + "rolePerformanceCard": { + "eyebrow": "성능 · 역할", + "title": "역할 성능", + "noDataAvailable": "아직 역할 성능 데이터가 없습니다.", + "noData": "데이터 없음", + "stat": "스탯", + "durationHoursMinutes": "{hours}시간 {minutes}분", + "durationMinutes": "{minutes}분", + "playtime": "플레이 시간", + "maps": "맵", + "kd": "K/D", + "damagePerMin": "피해량/10분", + "healingPerMin": "치유량/10분", + "deathsPerMin": "사망/10분", + "ultEfficiency": "궁극기 효율", + "elims": "처치", + "deaths": "사망", + "assists": "지원", + "roles": { + "Tank": "탱커", + "Damage": "딜러", + "Support": "지원" + } + }, + "winProbabilityInsights": { + "eyebrow": "팀 교전 · 승리 확률", + "title": "승리 확률 인사이트", + "noData": "아직 팀 교전 데이터가 없습니다.", + "firstPickImpact": "첫 처치 영향", + "firstPickHeadline": "첫 처치 시 {winrate}", + "firstPickDetail": "{fights}회 중 {wins}승 (전체 대비 {delta})", + "firstPickImpactDescription": "첫 처치를 가져가면 팀은 교전의 {winrate}%를 승리합니다", + "firstPickCount": "{wins}승 / 교전 {fights}회", + "firstDeathComeback": "첫 사망 후 역전", + "firstDeathHeadline": "첫 사망 후 {winrate}", + "firstDeathDetail": "{fights}회 중 {wins}승 (전체 대비 {delta})", + "firstDeathComebackDescription": "첫 사망을 당해도 팀은 교전의 {winrate}%를 승리합니다", + "firstDeathCount": "{wins}승 / 교전 {fights}회", + "ultimateAdvantage": "궁극기 우위", + "firstUltHeadline": "첫 궁극기 사용 시 {winrate}", + "firstUltDetail": "{fights}회 중 {wins}승 (전체 대비 {delta})", + "ultimateAdvantageDescription": "첫 궁극기 사용은 {winrate}% 교전 승률로 이어집니다", + "ultimateCount": "{wins}승 / 교전 {fights}회", + "dryFightSuccess": "노궁 교전 성공", + "dryFightHeadline": "노궁 교전에서 {winrate}", + "dryFightDetail": "노궁 교전 {fights}회 중 {wins}승 (전체 대비 {delta})", + "dryFightSuccessDescription": "궁극기 없이 교전의 {winrate}%를 승리합니다", + "dryFightCount": "{wins}승 / 노궁 교전 {fights}회", + "ultEconomy": "궁극기 경제", + "ultEconomyHeadline": "승리 교전당 궁극기 {count}개", + "ultEconomyDetail": "패배 교전당 {count}개 ({delta} 차이)", + "fightReversalComparison": "교전 역전: 노궁 vs 궁극기", + "fightReversalHeadline": "노궁 {dryRate}, 궁극기 사용 시 {ultRate} 역전", + "fightReversalDetail": "노궁 역전 {dryReversals}회, 궁극기 역전 {ultReversals}회", + "fightReversalComparisonDescription": "역전율은 노궁 교전 {dryRate}%, 궁극기 사용 교전 {nonDryRate}%입니다", + "fightReversalComparisonDetail": "노궁 역전 {dryReversals}회 / 궁극기 역전 {nonDryReversals}회", + "strongAdvantage": "강한 우위", + "needsImprovement": "개선 필요", + "average": "보통", + "description": "여러 교전 상황이 승리 확률에 미치는 영향", + "overallFightPerformance": "전체 교전 성능", + "totalFights": "총 교전", + "fightsWon": "승리한 교전", + "overallWinrate": "전체 승률", + "chartTitle": "교전 유형별 승률", + "tooltipSummary": "{fights, plural, =0 {교전 없음} other {교전 #회}} 기준 {winrate}", + "rows": { + "overall": "전체", + "firstPick": "첫 처치", + "firstDeath": "첫 사망", + "firstUlt": "첫 궁극기", + "dry": "노궁", + "withUlts": "궁극기 사용" + } + }, + "recentFormCard": { + "eyebrow": "추세 · 최근 흐름", + "title": "최근 흐름", + "noData": "표시할 최근 경기가 없습니다.", + "winsLosses": "{wins}승 - {losses}패 • 승률 {winrate}", + "last5": "최근 5경기", + "last10": "최근 10경기", + "last20": "최근 20경기", + "strongRecentPerformance": "최근 성과 좋음", + "strugglingRecently": "최근 부진", + "averagePerformance": "평균 성과", + "result": "결과", + "scrim": "스크림", + "map": "맵", + "date": "날짜", + "indicatorTitle": "{scrimName} - {result}", + "win": "승", + "loss": "패" + }, + "playerMapPerformanceCard": { + "eyebrow": "맵 · 플레이어 맵 매트릭스", + "title": "플레이어 맵 성능 매트릭스", + "noData": "아직 맵 성능 데이터가 없습니다.", + "description": "플레이어별 최고/최저 승률 맵", + "playerOnMap": "{map}에서 {player}: {winrate} ({wins}승-{losses}패)", + "noDataTitle": "{player} - {map}: 데이터 없음", + "winsLossesRecord": "{wins}승 - {losses}패", + "hasntPlayedMap": "{player}님은 아직 {map}을(를) 플레이하지 않았습니다.", + "playerMapPerformance": "{player}님의 {map} 성능: {winrate} ({wins}승-{losses}패), {games, plural, =0 {플레이한 경기 없음} other {#경기 플레이}}", + "hoverToSeePerformance": "셀에 마우스를 올리면 해당 맵에서의 플레이어 성능을 볼 수 있습니다.", + "percentOrHigher": "{winrate}+", + "bestMap": "최고 맵", + "worstMap": "최저 맵" + }, + "bestRoleTriosCard": { + "eyebrow": "성능 · 최고 트리오", + "title": "최고 역할 트리오", + "description": "승률 기준 가장 성공적인 5인 로스터입니다.", + "noData": "아직 최고의 플레이어 조합을 판단할 데이터가 부족합니다. 같은 로스터로 최소 3경기가 필요합니다.", + "rank": "순위", + "roster": "로스터", + "games": "경기", + "recordHeader": "승, 패", + "winrateHeader": "승률", + "toggleDetails": "세부정보 토글", + "collapseTrio": "{rank}위 트리오 접기", + "expandTrio": "{rank}위 트리오 펼치기", + "gamesPlayed": "{count, plural, =0 {경기 없음} other {#경기}}", + "wins": "{count}승", + "losses": "{count}패", + "record": "{wins}승, {losses}패", + "tank": "탱커", + "damage": "딜러", + "support": "지원", + "winRate": "승률", + "winRateValue": "승률 {winrate}", + "playMoreGames": "더 많은 경기를 플레이하면 최고의 로스터 조합을 볼 수 있습니다" + }, + "strengthsWeaknessesCard": { + "eyebrow": "개요 · 최고/최저", + "title": "강점 및 약점", + "noData": "아직 강점과 약점을 판단할 데이터가 부족합니다.", + "strongestMap": "가장 강한 맵", + "bestPerformance": "최고 성능", + "playtime": "{time} 플레이", + "durationHoursMinutesSeconds": "{hours}시간 {minutes}분 {seconds}초", + "blindSpot": "취약 맵", + "needsImprovement": "개선 필요" + }, + "winrateOverTimeChart": { + "eyebrow": "추세 · 시간별 승률", + "title": "시간별 승률", + "noData": "아직 승률 추세를 표시할 데이터가 부족합니다.", + "average": "평균: {avgWinrate} • {trend} {trendValue}", + "weekly": "주별", + "monthly": "월별", + "winrateLabel": "승률 (%)", + "50PercentLine": "50% 기준선", + "winrate": "승률: {winrate}", + "winsAndLosses": "{wins}승 - {losses}패" + }, + "mapRosterDetailsSheet": { + "eyebrow": "맵 · 로스터 분석", + "overall": "전체: {wins}승 - {losses}패 (승률 {winrate}) • {games, plural, =0 {플레이한 경기 없음} other {#경기 플레이}}", + "noData": "아직 이 맵의 로스터 데이터가 없습니다.", + "rosterPerformance": "로스터 성능 ({count, plural, =0 {라인업 없음} other {#개 라인업}})", + "bestLineup": "최고 라인업", + "winsLossesRecord": "{wins}승 - {losses}패", + "gamesLabel": "{count, plural, =0 {플레이한 경기 없음} other {#경기 플레이}}", + "smallSample": "표본 적음", + "recordLabel": "전적", + "winrateLabel": "승률", + "overallLabel": "전체", + "lineupsLabel": "라인업", + "distinctLabel": "고유", + "lineupsEyebrow": "맵 · 라인업", + "lineupHeader": "라인업", + "gamesHeader": "경기", + "tagHeader": "태그" + }, + "heroPickrateHeatmap": { + "eyebrow": "영웅 · 픽률", + "title": "영웅 픽률 히트맵", + "noData": "아직 영웅 픽률 데이터가 없습니다.", + "description": "팀 전체에서 가장 많이 플레이한 상위 15개 영웅", + "total": "합계", + "saturationNote": "단일 색상 채도 범위이며 색각 이상 사용자에게도 안전합니다.", + "noPlaytime": "플레이 시간 없음", + "durationHoursShort": "{hours}시간", + "durationMinutesShort": "{minutes}분", + "durationLong": "{hours}시간 {minutes}분 {seconds}초", + "cellTitle": "{playerName} - {heroName}: {playtime}", + "playerTotalTitle": "{playerName} 합계: {playtime}", + "heroTotalTitle": "{heroName}: {playtime}", + "allHeroesTotalTitle": "전체 영웅 합계: {playtime}", + "hoverText": "{playerName}님의 {heroName}: {playtime}", + "hoverTextDescription": "셀에 마우스를 올리면 해당 영웅의 플레이어 픽률을 볼 수 있습니다." + }, + "charts": { + "eyebrow": "상관관계", + "title": "선수 차트", + "description": "각 점은 10분당으로 환산된 선수입니다. 영웅 필터로 모든 차트의 범위를 조정하세요.", + "regressionToggle": "추세선 + r", + "correlation": "r = {value}", + "trendUnavailable": "추세를 보려면 선수가 2명 이상 필요합니다", + "chartAlt": "선수 {count}명의 {x} 대 {y} 산점도", + "expand": "차트 확대", + "zoom": "확대", + "zoomLabel": "{axis} 축 확대", + "resetZoom": "확대 초기화", + "legendTrend": "선형 추세", + "playersShown": "선수 {total}명 중 {shown}명 표시", + "noData": "현재 필터에 해당하는 선수가 없습니다.", + "customEyebrow": "직접 만들기", + "customTitle": "사용자 지정 차트", + "customDescription": "두 가지 스탯을 선택해 로스터 전체에서 비교하세요.", + "customChartTitle": "{x} 대 {y}", + "xAxis": "X축", + "yAxis": "Y축", + "presetDamageDeaths": "10분당 영웅 피해 vs 10분당 죽음", + "presetFinalBlowsDeaths": "10분당 마무리 일격 vs 10분당 죽음", + "presetDamageHealingReceived": "10분당 받은 피해 vs 10분당 받은 치유", + "presetBlockedTaken": "10분당 막은 피해 vs 10분당 받은 피해", + "stats": { + "eliminations": "처치", + "finalBlows": "마무리 일격", + "deaths": "죽음", + "heroDamage": "영웅 피해", + "healingDealt": "치유량", + "healingReceived": "받은 치유", + "selfHealing": "자가 치유", + "damageTaken": "받은 피해", + "damageBlocked": "막은 피해", + "ultimatesEarned": "획득한 궁극기", + "ultimatesUsed": "사용한 궁극기", + "soloKills": "단독 처치", + "environmentalKills": "환경 처치" + } + } + }, + "teamStats": { + "fightStats": { + "eyebrow": "팀 교전 · 교전 통계", + "title": "팀 교전 통계", + "description": "{count, plural, =0 {교전 없음} other {총 #회 교전}} 분석", + "noData": "아직 교전 데이터가 없습니다. 스크림을 업로드하면 팀 교전 통계를 볼 수 있습니다.", + "overallWinrate": "전체 교전 승률", + "record": "{wins}승 - {losses}패", + "firstPickWinrate": "첫 처치 승률", + "firstPickCount": "첫 처치 교전 {count, plural, =0 {없음} other {#회}}", + "firstDeathWinrate": "첫 사망 후 승률", + "firstDeathCount": "첫 처치를 내준 교전 {count, plural, =0 {없음} other {#회}}", + "firstUltWinrate": "첫 궁극기 승률", + "firstUltCount": "첫 궁극기 사용 교전 {count, plural, =0 {없음} other {#회}}", + "dryFights": "노궁 교전 비율", + "dryFightDetails": "노궁 교전 {count, plural, =0 {없음} other {#회}} (승률 {winrate})", + "avgUltsPerFight": "교전당 평균 궁극기", + "avgUltsDescription": "궁극기 사용 교전 {count, plural, =0 {없음} other {#회}} 기준" + }, + "initiationStats": { + "eyebrow": "한타 · 선공", + "title": "한타 개시", + "description": "누가 먼저 들어가고 그 성과는 어떤지. 상세 로그가 있는 {total}개 맵 중 {covered}개에서 감지됨.", + "noData": "개시 데이터가 아직 없습니다. 상세 로그가 있는 스크림을 업로드하세요.", + "initiationWinrate": "선공 시 승률", + "initiationFrequency": "개시한 한타", + "goingSecondWinrate": "후공 시 승률", + "firstRecord": "선공 {wins}승 - {losses}패", + "frequencySub": "유효 한타 {decided}개 중 {first}개", + "secondRecord": "후공 {wins}승 - {losses}패" } }, "targets": { "title": "선수 목표", + "signInRequired": "목표를 보려면 로그인하세요.", + "teamNotFound": "팀을 찾을 수 없습니다.", + "premiumFeature": "프리미엄 기능", "premiumRequired": "선수 목표는 프리미엄 플랜이 필요합니다.", "upgradePrompt": "업그레이드하여 선수들의 측정 가능한 개선 목표를 설정하세요.", "viewPricing": "가격 보기", "noTargets": "아직 코치가 설정한 목표가 없습니다.", "noTeamMembers": "팀원을 찾을 수 없습니다.", + "addPlayerToSetTargets": "목표를 설정하려면 이 선수를 팀에 추가하세요", "setTarget": "목표 설정", "editTarget": "목표 수정", "createTarget": "목표 생성", @@ -2392,8 +7905,19 @@ "progress": "진행도", "baseline": "기준값", "target": "목표", + "trend": "추세", + "baselineValue": "기준값: {value}", + "targetValue": "목표: {value}", + "clickToIsolate": "클릭하여 분리", + "baselineTargetSummary": "기준값: {baseline} | 목표: {target}", + "chartSummary": "평균: {average} | 최대: {max} | 최소: {min}", "targetAchieved": "목표 달성!", "coachNote": "코치 메모", + "progressSummary": { + "onTrack": "{count}개 순조로움", + "inProgress": "{count}개 진행 중", + "behind": "{count}개 뒤처짐" + }, "form": { "title": "{playerName}의 목표 설정", "editTitle": "{playerName}의 목표 수정", @@ -2423,9 +7947,696 @@ }, "narrative": { "changed": "{stat}이(가) 최근 {window} 스크림에서 {percent}% {direction}했습니다.", + "changedRich": "{stat}이(가) 최근 {window} 스크림에서 {percent} {direction}했습니다.", "progressToward": "목표 {targetDirection}{targetPercent}%까지 {percent}% 달성했습니다.", + "progressTowardRich": "목표 {targetDirection}{targetPercent}까지 {percent} 달성했습니다.", "increased": "증가", "decreased": "감소" + }, + "metadata": { + "title": "선수 목표 | Parsertime", + "description": "팀원의 성장 목표를 설정하고 추적하세요." + } + }, + "chartComponents": { + "heroPickRateHover": { + "weekOf": "{date} 주", + "pickRate": "선택률", + "playtime": "플레이 시간", + "eyebrow": "선택률 · 최근 60일", + "average": "평균", + "windowDelta": "기간 변화", + "percentagePoints": "{value}pp", + "playtimeHoursMinutes": "{hours}시간 {minutes}분", + "playtimeMinutes": "{minutes}분", + "patches": { + "season": "시즌", + "midCycle": "중간 패치", + "hotfix": "핫픽스" + } + } + }, + "reportsPage": { + "metadata": { + "title": "보고서 | Parsertime", + "description": "공유된 AI 스크림 분석 보고서를 확인하세요." + }, + "list": { + "title": "보고서", + "description": "채팅 대화에서 생성된 AI 분석 보고서입니다.", + "searchPlaceholder": "보고서 검색...", + "reportCount": "{count, plural, =0 {보고서 없음} other {보고서 #개}}", + "emptyTitle": "아직 보고서가 없습니다", + "emptyDescription": "보고서는 Analyst 대화에서 생성됩니다. Analyst에게 보고서 생성을 요청하면 여기에 표시됩니다.", + "startChat": "채팅 시작", + "noMatches": "\"{query}\"와 일치하는 보고서가 없습니다", + "pageStatus": "{currentPage}/{totalPages}페이지", + "previousPage": "이전 페이지", + "previous": "이전", + "nextPage": "다음 페이지", + "next": "다음" + } + }, + "ranked": { + "title": "랭크 트래커", + "emptyTitle": "아직 기록된 매치가 없습니다", + "emptyDescription": "게임을 기록하여 맵, 영웅, 파티 규모별 승률 추이를 확인하세요.", + "trackFirst": "첫 번째 매치 기록하기", + "addMatch": "매치 추가", + "import": { + "title": "Winrate Tracker에서 가져오기", + "description": "이전 사이트의 JSON 내보내기 파일을 업로드하여 매치를 동기화하세요.", + "button": "내보내기 파일 선택", + "importing": "가져오는 중…", + "done": "{imported}개의 매치를 가져왔습니다", + "failed": "가져오기 실패" + }, + "metadata": { + "title": "랭크 트래커 | Parsertime", + "description": "오버워치 2 랭크 매치를 기록하고 실력을 분석하세요." + }, + "dashboard": { + "trackMatch": "매치 기록", + "allTime": "전체 기간", + "roleAll": "모든 역할", + "roleTank": "탱커", + "roleDamage": "딜러", + "roleSupport": "지원가", + "filterByRole": "역할별 필터", + "filterBySeason": "시즌 또는 패치별 필터" + }, + "tabs": { + "overview": "개요", + "heroes": "영웅", + "maps": "맵", + "time": "시간", + "patches": "패치", + "groups": "파티", + "roles": "역할", + "heroDeepDives": "영웅 심층 분석", + "heroMapAnalysis": "영웅 × 맵 분석", + "mapMastery": "맵 숙련도", + "patterns": "패턴" + }, + "summary": { + "matches": "매치", + "winrate": "승률", + "bestMap": "최고 맵", + "currentStreak": "현재 연승", + "acrossMaps": "{count}개 맵에서", + "record": "{wins}승 – {losses}패", + "recordWithDraws": "{wins}승 – {losses}패 – {draws}무", + "mapWinrate": "승률 {winrate}%", + "wonLast": "최근 {count}연승", + "lostLast": "최근 {count}연패", + "noStreak": "진행 중인 연승 없음" + }, + "matchList": { + "eyebrow": "기록", + "recentMatches": "최근 매치", + "lastSevenDays": "(최근 7일)", + "counter": "{total}개 중 {start}–{end}", + "pageOf": "{total} 중 {page} 페이지", + "previous": "이전", + "next": "다음", + "previousAria": "이전 페이지", + "nextAria": "다음 페이지", + "groupSolo": "솔로", + "groupDuo": "듀오", + "group3Stack": "3인 파티", + "group4Stack": "4인 파티", + "group5Stack": "5인 파티", + "groupNStack": "{count}인 파티", + "resultWin": "승리", + "resultLoss": "패배", + "resultDraw": "무승부", + "deleteMatchAria": "매치 삭제", + "deleteTitle": "매치를 삭제하시겠습니까?", + "deleteBody": "이 매치가 기록에서 영구적으로 삭제됩니다.", + "cancel": "취소", + "delete": "삭제", + "deleting": "삭제 중..." + }, + "form": { + "title": "매치 기록", + "description": "최근 게임을 기록하세요. 여러 매치를 한 번에 추가할 수 있습니다.", + "matchHeading": "매치 {n}", + "removeMatch": "매치 {n} 제거", + "map": "맵", + "selectMap": "맵 선택...", + "searchMaps": "맵 검색...", + "noMaps": "맵을 찾을 수 없습니다.", + "dateTime": "날짜 및 시간", + "result": "결과", + "win": "승리", + "loss": "패배", + "draw": "무승부", + "groupSize": "파티 규모", + "groupSizeSolo": "솔로", + "groupSizeDuo": "듀오", + "groupSizeStack3": "3인 파티", + "groupSizeStack4": "4인 파티", + "groupSizeStack5": "5인 파티", + "heroesPlayed": "플레이한 영웅", + "percentTotal": "{total}%", + "percentSum": "백분율의 합이 100이 되어야 합니다", + "percentageFor": "{hero}의 백분율", + "removeHero": "{hero} 제거", + "addHero": "영웅 추가...", + "searchHeroes": "영웅 검색...", + "noHeroes": "영웅을 찾을 수 없습니다.", + "addAnother": "다른 매치 추가", + "submit": "{count}개 매치 기록", + "submitting": "기록 중...", + "errorSelectMap": "매치 {n}: 맵을 선택하세요", + "errorSelectResult": "매치 {n}: 결과를 선택하세요", + "errorAddHero": "매치 {n}: 영웅을 하나 이상 추가하세요", + "errorHeroSum": "매치 {n}: 영웅 백분율의 합이 100이 되어야 합니다 (현재 {total})", + "errorGeneric": "문제가 발생했습니다" + }, + "charts": { + "mapWinLoss": { + "eyebrow": "맵 성적", + "title": "어디에서 가장 많이 이기나요?", + "description": "{bestMap}이(가) 승률 {bestWinrate}%로 최고의 맵입니다", + "descriptionWithWorst": "{bestMap}이(가) 승률 {bestWinrate}%로 최고의 맵입니다 — {worstMap}은(는) 승률 {worstWinrate}%로 가장 어렵습니다", + "wins": "승리", + "losses": "패배", + "winrateLabel": "승률:", + "footer": "{maps}개 맵에서 {count}개 매치 기준" + }, + "gameModeDistribution": { + "eyebrow": "게임 모드", + "title": "어떤 모드를 플레이하나요?", + "description": "{mode}이(가) 게임의 {pct}%를 차지합니다", + "matchesValue": "{count}개 매치", + "matchesUnit": "매치" + }, + "gameModeWinrate": { + "eyebrow": "게임 모드", + "title": "어떤 모드가 잘 맞나요?", + "description": "{bestMode}이(가) 승률 {bestWinrate}%로 가장 강한 모드입니다", + "descriptionBest": "{bestMode}이(가) 승률 {bestWinrate}%로 가장 강한 모드입니다", + "descriptionBestWorst": "{bestMode}에서는 승률 {bestWinrate}%로 압도하지만 {worstMode}에서는 승률 {worstWinrate}%로 고전합니다", + "descriptionEmpty": "모드별 승률을 보려면 매치를 더 플레이하세요", + "winrate": "승률", + "winsOverGames": "{wins}승 / {total}게임", + "footer": "점선은 승률 50%를 나타냅니다" + }, + "groupSizeBreakdown": { + "eyebrow": "파티 규모", + "title": "주로 어떻게 플레이하나요?", + "descriptionEmpty": "파티 습관을 보려면 매치를 더 기록하세요", + "descriptionAllGrouped": "{total}개 게임 전부 파티로 플레이", + "descriptionAllSolo": "{total}개 게임 전부 솔로로 플레이", + "descriptionMixed": "{total}개 게임 중 {soloPct}%가 솔로 — {groupedPct}%가 파티", + "noData": "아직 데이터가 없습니다", + "wins": "승리", + "losses": "패배", + "draws": "{count}무", + "winrateOverGames": "승률: {winrate}%, {total}게임 기준", + "footer": "{sizes}개 파티 규모에서 {count}개 매치 기준" + }, + "groupSizeWinrate": { + "eyebrow": "파티 규모", + "title": "함께하면 더 강한가요?", + "descriptionEmpty": "추이를 보려면 다양한 파티 규모로 매치를 더 플레이하세요", + "description": "{label}일 때 승률 {winrate}% — 최고의 파티 구성입니다", + "descriptionWithCount": "{label}일 때 승률 {winrate}% — {games}게임 기준 최고의 파티 구성입니다", + "descriptionWithDiff": "{label}일 때 승률 {winrate}% — 최고의 파티 구성입니다 (솔로 대비 +{diff}%)", + "descriptionWithCountAndDiff": "{label}일 때 승률 {winrate}% — {games}게임 기준 최고의 파티 구성입니다 (솔로 대비 +{diff}%)", + "noData": "아직 데이터가 없습니다", + "winrate": "승률", + "winLossGames": "{wins}승 / {losses}패 · {total}게임", + "footer": "파티 규모당 최소 {min}게임 필요 · {showing}개 규모 표시" + }, + "roleDistribution": { + "eyebrow": "역할", + "title": "시간을 어디에 쓰나요?", + "description": "시간의 {pct}%를 {role}에 사용합니다", + "descriptionEmpty": "역할별 시간 분배를 보려면 매치를 기록하세요", + "noData": "아직 데이터가 없습니다" + }, + "roleWinrate": { + "eyebrow": "역할", + "title": "어떤 역할로 가장 잘 이기나요?", + "description": "{role}일 때 가장 많이 이깁니다 — 승률 {winrate}%", + "descriptionEmpty": "승률을 보려면 역할당 최소 {min}매치를 플레이하세요", + "noData": "아직 데이터가 없습니다", + "winrate": "승률", + "winLossGames": "{wins}승 / {losses}패 · {total}게임", + "footer": "역할당 최소 {min}게임 필요 · {showing}개 역할 표시" + }, + "mostPlayedHeroes": { + "eyebrow": "영웅 성과", + "title": "가장 많이 하는 영웅은?", + "descriptionAllModes": "모든 모드에서 {hero}을(를) {count}매치로 가장 많이 플레이했습니다", + "descriptionMode": "{mode}에서 {hero}을(를) {count}매치로 가장 많이 플레이했습니다", + "descriptionEmpty": "영웅 통계를 보려면 매치를 더 플레이하세요", + "filterAriaLabel": "게임 모드로 필터링", + "allModes": "모든 모드", + "matches": "매치", + "roleLabel": "역할: {role}", + "roles": { + "Tank": "탱커", + "Damage": "딜러", + "Support": "서포터" + } + }, + "heroWinrate": { + "eyebrow": "영웅 성과", + "title": "어떤 영웅으로 이기나요?", + "descriptionBestWorst": "{bestHero}이(가) {bestWinrate}%로 선두 — {worstHero}은(는) 연습이 더 필요합니다", + "descriptionBest": "{bestHero}이(가) {bestWinrate}%로 최고 성과 영웅입니다", + "descriptionEmpty": "영웅 승률을 보려면 매치를 더 플레이하세요", + "winrate": "승률", + "winsTotal": "{wins}승 / {total}매치", + "footer": "최소 {min}매치 필요 · 영웅 {count}개 표시 중" + }, + "oneTrick": { + "eyebrow": "영웅 풀", + "title": "원트릭인가요?", + "descriptionOneTrick": "플레이 시간의 {pct}%를 {hero}에 사용했습니다 — 확실한 원트릭", + "descriptionSpecialist": "{hero} 위주이지만 어느 정도 다양성도 있습니다", + "descriptionDiverse": "플레이 시간이 여러 영웅에 고르게 분포되어 있습니다", + "descriptionEmpty": "아직 추적된 매치가 없습니다", + "labels": { + "One-Trick": "원트릭", + "Specialist": "스페셜리스트", + "Diverse": "다재다능" + }, + "breakdownAriaLabel": "영웅 플레이 시간 분포", + "percentAriaLabel": "{pct} 퍼센트", + "noData": "아직 데이터가 없습니다", + "footer": "가중 플레이 시간 기준 · 원트릭 ≥ {oneTrick}% · 스페셜리스트 ≥ {specialist}%" + }, + "heroPoolDiversity": { + "eyebrow": "영웅 풀", + "title": "영웅 풀이 얼마나 넓나요?", + "descriptionNoMatches": "아직 추적된 매치가 없습니다", + "descriptionNoHeroData": "영웅 데이터가 없습니다", + "descriptionAllRoles": "3개 역할에 걸쳐 고유 영웅 {count}명을 플레이했습니다", + "descriptionConcentrated": "영웅 풀이 {role}에 집중되어 있습니다 — 총 고유 영웅 {count}명", + "uniqueHeroesAriaLabel": "고유 영웅 {count}명", + "heroUnit": "영웅", + "heroesLabel": "영웅 {count}명", + "heroesByRoleAriaLabel": "역할별 영웅", + "roleHeroesAriaLabel": "{role} 영웅", + "noRoleHeroes": "아직 플레이한 {role} 영웅이 없습니다", + "noData": "아직 데이터가 없습니다" + }, + "heroSwap": { + "eyebrow": "영웅 교체", + "title": "영웅 교체가 승리로 이어지나요?", + "descriptionTrackMore": "영웅 교체가 승리에 도움이 되는지 보려면 매치를 더 추적하세요", + "descriptionNoImpact": "영웅 교체가 승률에 유의미한 영향을 주지 않습니다", + "descriptionSwapBoost": "영웅을 교체하면 승률이 +{delta}% 상승합니다", + "descriptionStayAdvantage": "영웅을 유지하면 승률이 +{delta}% 더 높습니다", + "deltaNoDifference": "유의미한 차이 없음", + "deltaSwapping": "교체 시 +{delta}%", + "deltaStaying": "유지 시 +{delta}%", + "winrate": "승률", + "winsTotal": "{wins}승 · {total}매치", + "emptyState": "교체 매치 3회와 비교체 매치 3회 이상이 필요합니다", + "footerRule": "교체 = 각각 매치의 {pct}% 이상 플레이한 영웅 2명 이상", + "footerCounts": "교체 {swaps}회, 단일 영웅 {single}회" + }, + "roleFlexibility": { + "eyebrow": "역할", + "title": "역할 유연성", + "descriptionAdaptive": "세 가지 역할을 거의 동등하게 플레이합니다 — 진정한 플렉스 플레이어", + "descriptionFlexible": "{role} 위주이지만 다른 역할도 플레이합니다", + "descriptionSpecialist": "주로 {role}을(를) 플레이합니다 — 확실한 스페셜리스트", + "labels": { + "Adaptive": "적응형", + "Flexible": "유연형", + "Specialist": "스페셜리스트" + }, + "noData": "아직 데이터가 없습니다" + }, + "mapWinrateRanking": { + "eyebrow": "맵 성적", + "title": "맵 승률 순위", + "insight": "{bestMap}이(가) {bestWinrate}%로 가장 강하고, {worstMap}이(가) {worstWinrate}%로 가장 어렵습니다", + "insightEmpty": "아직 맵 데이터가 없습니다", + "avgLabel": "평균 {overallWinrate}%", + "footer": "{maps}개 맵에서 {count}경기", + "fadedNote": "흐릿한 막대는 {min}경기 미만입니다", + "confidenceStarsLabel": "신뢰도: 별 5개 중 {count}개", + "deviationAbove": "평균보다 +{deviation}%", + "deviationBelow": "평균보다 {deviation}%", + "deviationAt": "평균 수준", + "winrate": "승률", + "wld": "승 / 패 / 무", + "vsAvg": "내 평균 대비 ({overallWinrate}%)", + "confidence": "신뢰도", + "lowSample": "표본 부족 — {count}경기" + }, + "mapTierList": { + "eyebrow": "맵 성적", + "title": "맵 티어 리스트", + "description": "플레이한 {count}개 맵을 승률과 신뢰도 순으로 정렬했습니다", + "descriptionEmpty": "티어 리스트를 만들려면 맵을 더 플레이하세요", + "listLabel": "맵 티어 리스트", + "tierLabel": "{tier} 티어: {description}", + "tierDescription": { + "S": "압도적", + "A": "강력함", + "B": "안정적", + "C": "보통", + "D": "고전 중" + }, + "lowSampleLabel": "표본 크기 부족", + "winrateLine": "승률 {winrate}% ({wins}승 / {losses}패)", + "winrateLineWithDraws": "승률 {winrate}% ({wins}승 / {losses}패 / {draws}무)", + "lowConfidence": "신뢰도 낮음 — {count}경기뿐" + }, + "mapVolatility": { + "eyebrow": "맵 성적", + "title": "맵 변동성", + "insight": "{mostVolatile}이(가) 가장 예측하기 어려운 맵으로, 결과 편차가 큽니다", + "insightEmpty": "각 맵에서 결과가 얼마나 일관적인지를 보여줍니다", + "footer": "변동성이 높다는 것은 결과가 크게 출렁인다는 뜻으로, “동전 던지기” 맵입니다", + "confidenceStarsLabel": "별 5개 중 {count}개", + "volatility": "변동성", + "assessment": "평가", + "winrate": "승률", + "confidence": "신뢰도", + "gamesPlayed": "{count}경기 플레이", + "assessmentExtreme": "극도로 예측 불가", + "assessmentHigh": "변동성 매우 높음", + "assessmentModerate": "변동성 보통", + "assessmentFairly": "꽤 일관적", + "assessmentVery": "매우 일관적" + }, + "heroMapSynergy": { + "eyebrow": "영웅 × 맵", + "title": "영웅 × 맵 시너지", + "description": "어떤 영웅이 어떤 맵에서 이기는지 — {min}경기 미만인 칸은 —로 표시됩니다", + "descriptionEmptyHeader": "각 맵에서 어떤 영웅이 가장 잘 맞는지 보려면 매치를 충분히 기록하세요", + "noData": "아직 데이터가 없습니다", + "filterGroupLabel": "맵 유형으로 필터링", + "filterAll": "전체", + "noMapsOfType": "아직 {type} 맵을 플레이하지 않았습니다", + "matrixLabel": "영웅-맵 승률 매트릭스", + "heroMapHeader": "영웅 / 맵", + "cellLabel": "{map}에서 {hero}: {games}경기 기준 승률 {winrate}%", + "cellLabelInsufficient": "{map}에서 {hero}: 데이터 부족", + "cellTitle": "{map}에서 {hero}", + "cellWinrate": "승률 {winrate}%", + "cellRecord": "{wins}승 / {losses}패 ({games}경기)", + "cellInsufficient": "{count}경기뿐 — {min}경기 이상 필요", + "cellNoGames": "플레이한 경기 없음", + "legendWinrate": "승률:", + "legendLowGames": "{min}경기 미만", + "footer": "플레이 빈도 기준 상위 {heroes}명의 영웅을 {maps}개 맵에 걸쳐 표시", + "footerFiltered": "플레이 빈도 기준 상위 {heroes}명의 영웅을 {maps}개 맵에 걸쳐 표시 ({type} 전용)" + }, + "bestHeroPerMap": { + "eyebrow": "영웅 × 맵", + "title": "맵별 최고 영웅", + "description": "각 맵에서 승률이 가장 높은 영웅입니다 (최소 {min}경기). 막대는 95% 신뢰 구간을, 눈금은 승률을 나타냅니다.", + "descriptionEmptyHeader": "최고의 픽을 보려면 한 맵에서 한 영웅으로 최소 {min}경기를 플레이하세요", + "noData": "아직 조건을 만족하는 데이터가 없습니다", + "infoAriaLabel": "신뢰 구간 안내", + "infoTooltip": "Wilson 점수 구간은 표본 크기를 반영합니다. 막대가 넓다면(강조 표시) 추정값이 불안정하다는 뜻이니 더 많이 플레이해 구간을 좁히세요.", + "ciAriaLabel": "승률 {winrate}%, 95% 신뢰 구간 {low}%에서 {high}%", + "ciRange": "{low}%–{high}% CI", + "games": "{count}경기" + }, + "mapLearningCurve": { + "eyebrow": "맵 숙련도", + "title": "맵 학습 곡선", + "emptyDescription": "시간에 따른 향상을 보려면 한 맵에서 최소 {minGames}경기를 플레이하세요", + "emptyState": "아직 데이터가 부족합니다 — 맵당 {minGames}경기 이상 필요", + "legendEarly": "초반 경기", + "legendRecent": "최근 경기", + "descriptionImproved": "{map}에서 가장 많이 향상되었습니다 (+{delta}%) — 경기 전반부 대 후반부", + "descriptionDefault": "각 맵에서 경기 전반부 대 후반부 비교", + "insightImproved": "{map}에서 가장 많이 향상되었습니다 (+{delta}%)", + "insightDeclined": "{map}이(가) 가장 마스터하기 어렵습니다 ({delta}%)", + "insightNeutral": "맵별 초반 대 최근 성과 비교", + "tooltipEarly": "초반 ({games}경기)", + "tooltipRecent": "최근 ({games}경기)", + "tooltipImprovement": "+{delta}% 향상", + "tooltipDecline": "{delta}% 하락", + "tooltipNoChange": "변화 없음", + "footer": "맵당 {minGames}경기 이상 필요. 조건을 충족하는 {count}개 맵 표시 중" + }, + "mapTimeline": { + "eyebrow": "맵 숙련도", + "title": "승패 타임라인", + "emptyDescription": "각 맵에서의 최근 성과를 추적하세요", + "emptyState": "아직 맵 데이터가 없습니다", + "legendCumulative": "누적 승률", + "selectMap": "맵 선택", + "result": { + "win": "승리", + "loss": "패배", + "draw": "무승부" + }, + "resultShort": { + "win": "승", + "loss": "패", + "draw": "무" + }, + "badgeTitle": "{index}번째 경기: {result}", + "description": "{map}에서의 최근 {count}경기", + "lastPlayedToday": "오늘 플레이함", + "lastPlayedYesterday": "어제 플레이함", + "lastPlayedDaysAgo": "{days}일 전 마지막 플레이", + "recentResults": "최근 결과 (오래된 순 → 최신 순)", + "recentResultsAria": "{map}에서의 최근 결과", + "cumulativeWinrate": "시간에 따른 누적 승률", + "tooltipWinrate": "승률 {value}%", + "statOverall": "전체", + "statGamesTracked": "추적된 경기", + "statAvgGap": "평균 간격" + }, + "mapFamiliarity": { + "eyebrow": "맵 숙련도", + "title": "맵 친숙도", + "legendGamesPlayed": "플레이한 경기", + "description": "{available}개 맵 중 {played}개 플레이", + "descriptionWithAvoided": "{available}개 맵 중 {played}개 플레이 — {avoided}개는 한 번도 만나지 못함", + "aboutVarietyScore": "다양성 점수에 대하여", + "varietyScoreExplanation": "다양성 점수(0–100)는 플레이한 경기가 모든 이용 가능한 맵에 얼마나 고르게 분포되어 있는지를 측정합니다. 100은 모든 맵에서 완벽하게 동일한 시간을 의미합니다.", + "mapVarietyScore": "맵 다양성 점수", + "varietyScoreAria": "맵 다양성 점수: {score}/100 — {label}", + "varietyDiverse": "다양함", + "varietyModerate": "보통", + "varietyFocused": "집중됨", + "varietyConcentrated": "편중됨", + "resultShort": { + "win": "승", + "loss": "패", + "draw": "무" + }, + "tooltipGames": "{count}경기 (전체의 {pct}%)", + "tooltipLast": "최근 {count}: {results}", + "neverPlayed": "한 번도 플레이하지 않은 맵 ({count})", + "footer": "총 {total}경기 — 가장 많이 플레이한 상위 {top}개 맵 표시" + }, + "repeatMap": { + "eyebrow": "맵 숙련도", + "title": "반복 맵 성과", + "legendWinrate": "승률", + "labelFirstTime": "첫 번째", + "labelRepeat": "반복", + "descriptionBetter": "반복 맵에서 더 잘 플레이합니다 — 같은 세션에서 맵이 다시 나올 때 +{delta}%", + "descriptionWorse": "반복 맵에서 저조한 성과를 보입니다 — 맵이 다시 나올 때 {delta}%", + "descriptionNeutral": "반복 맵은 성과에 거의 영향을 주지 않습니다", + "descriptionEmpty": "한 세션에서 같은 맵을 두 번 보는 것이 승률에 영향을 미칠까요?", + "insightBetter": "한 세션에서 같은 맵을 두 번 볼 때 {delta}% 더 잘합니다", + "insightWorse": "반복 맵에서 {delta}% 더 못합니다 — 피로가 원인일 수 있습니다", + "insightNeutral": "반복 맵은 승률에 의미 있는 영향을 주지 않습니다", + "emptyInsight": "아직 반복 맵 데이터가 부족합니다 — 패턴을 보려면 더 많은 세션을 플레이하세요", + "emptySubtext": "{count}개의 반복 사례 기록됨 — 5개 이상 필요", + "tooltipWinrate": "승률 {value}%", + "tooltipGames": "{count}경기", + "statFirstTime": "첫 번째", + "statRepeat": "반복", + "statGames": "{count}경기", + "footer": "세션은 날짜별로 그룹화됩니다 — 반복은 같은 맵이 두 번 이상 나오는 경우입니다" + }, + "winrateTrend": { + "eyebrow": "추이", + "title": "실력이 늘고 있나요?", + "emptyDescription": "추이를 보려면 매치를 더 기록하세요", + "noData": "아직 데이터가 없습니다", + "trendImproving": "상승세 — 최근 {window}경기 평균 승률은 {current}%입니다", + "trendDeclining": "하락세 — 최고 {peak}%였으나 현재는 {current}%입니다", + "trendStable": "최근 경기 동안 약 {current}%로 안정적입니다", + "tooltipLabel": "이동 승률", + "tooltipMeta": "{game}번째 경기 · {date}", + "footer": "{window}경기 이동 평균 · 총 {total}경기 · 최고 {peak}%" + }, + "activityHeatmap": { + "eyebrow": "활동", + "title": "언제 플레이하나요?", + "description": "{day}에 가장 활발 — 플레이한 날 평균 {avg}경기", + "emptyDescription": "매치를 기록하여 활동 패턴을 확인하세요", + "gridLabel": "활동 히트맵", + "cellNoGames": "{date} — 경기 없음", + "cellGames": "{date} — {count}경기", + "less": "적음", + "more": "많음", + "footer": "최근 {weeks}주 동안 {days}일 활동" + }, + "streak": { + "eyebrow": "연속 기록", + "title": "연속 기록", + "resultWin": "승리", + "resultLoss": "패배", + "resultDraw": "무승부", + "chipLabel": "{index}번째 경기: {result}", + "winValue": "{count}연승", + "lossValue": "{count}연패", + "descriptionWin": "{count}연승 중 — 계속 이어가세요", + "descriptionLoss": "{count}연패 중 — 분위기를 바꿀 때입니다", + "descriptionNone": "진행 중인 연속 기록 없음", + "currentStreak": "현재 연속 기록", + "longestWinStreak": "최장 연승", + "longestLossStreak": "최장 연패", + "lastGames": "최근 {count}경기", + "lastGamesResults": "최근 {count}경기 결과", + "footer": "최근 결과가 왼쪽에서 오른쪽 순으로 표시됩니다" + }, + "recentForm": { + "eyebrow": "최근 폼", + "title": "최근 폼", + "trendImproving": "최근 {window}경기 동안 통산 평균 대비 {delta}% 상승", + "trendDeclining": "최근 {window}경기 동안 통산 평균 대비 {delta}% 하락", + "trendStable": "통산 평균과 비슷한 수준을 유지 중입니다", + "lastGames": "최근 {window}경기", + "allTime": "통산", + "record": "{wins}승 – {losses}패", + "recordWithDraws": "{wins}승 – {losses}패 – {draws}무", + "gamesCount": "({count}경기)", + "deltaLabel": "통산 대비 {sign}{delta}%", + "footer": "최근 {recent}경기 vs. 통산 {total}경기 비교" + }, + "sessionAnalysis": { + "eyebrow": "세션", + "title": "세션 성적", + "emptyDescription": "한 세션 안에서 얼마나 잘하나요?", + "emptyTitle": "아직 세션이 충분하지 않습니다", + "emptyHint": "세션은 서로 3시간 이내에 플레이한 경기 묶음입니다. 매치를 더 플레이하면 추이를 확인할 수 있습니다.", + "insightSingle": "세션 1개 기록 — 승률 {winrate}%, {games}경기.", + "insightMany": "{sessions}개 세션에서 평균 승률 {winrate}%, 세션당 {games}경기를 기록했습니다.", + "tooltipWinrate": "승률 {winrate}%", + "tooltipRecord": "{wins}승 – {losses}패 · {games}경기", + "tooltipDuration": "약 {minutes}분 세션", + "avgWinrate": "평균 승률", + "gamesPerSession": "세션당 경기", + "totalSessions": "총 세션", + "bestSession": "최고 세션", + "worstSession": "최저 세션", + "footer": "세션은 서로 3시간 이내에 플레이한 경기 묶음입니다. 최근 {count}개 세션을 표시합니다." + }, + "dayOfWeek": { + "eyebrow": "주간 리듬", + "title": "플레이하기 좋은 요일", + "emptyDescription": "평일과 주말 중 언제 더 잘하나요?", + "emptyTitle": "아직 매치가 없습니다", + "emptyHint": "여러 요일에 걸쳐 매치를 기록하여 언제 가장 잘하는지 확인하세요.", + "insightStable": "평일과 주말에 비슷하게 잘합니다 ({winrate}%). 가장 좋은 요일은 {day}입니다.", + "insightDelta": "{when, select, weekend {주말} weekday {평일} other {주말}}에 {delta}% 더 잘합니다. 가장 강한 요일은 {day}입니다.", + "tooltipNoGames": "{day}에 경기 없음", + "tooltipWinrate": "승률 {winrate}%", + "tooltipRecord": "{wins}승 – {losses}패 · {total}경기", + "tooltipRecordWithDraws": "{wins}승 – {losses}패 – {draws}무 · {total}경기", + "weekdays": "평일", + "weekdayRange": "월 – 목", + "weekends": "주말", + "weekendRange": "금 – 일", + "deltaLabel": "평일 대비 {delta}% {direction, select, better {더 좋음} worse {더 나쁨} other {더 좋음}}", + "bestDay": "최고의 날", + "toughestDay": "가장 힘든 날", + "footer": "매치는 세션이 시작된 요일에 귀속됩니다." + }, + "patchImpact": { + "seasonsEyebrow": "시즌", + "seasonsTitle": "시즌별 승률", + "seasonsDescription": "각 랭크 시즌별 전적입니다.", + "seasonRecord": "{wins}승 – {losses}패 · {games}경기", + "eyebrow": "패치 영향", + "title": "패치가 승률에 미치는 영향", + "description": "시간에 따른 이동 승률입니다. 세로선은 오버워치 패치를 나타내므로 메타 변화가 폼과 어떻게 맞물리는지 확인할 수 있습니다.", + "empty": "타임라인을 그릴 만큼 경기가 충분하지 않습니다", + "tooltipWinrate": "이동 승률 {winrate}%", + "legendSeason": "시즌", + "legendMidSeason": "미드 시즌", + "legendHotfix": "핫픽스" + } + } + }, + "settings": { + "ranked": { + "toggleLabel": "내 프로필에 랭크 통계 표시", + "toggleDescription": "다른 사람들이 내 공개 프로필에서 랭크 성적 요약을 볼 수 있도록 합니다.", + "toggleError": "개인정보 설정을 업데이트할 수 없습니다" + } + }, + "tournamentsPage": { + "metadata": { + "title": "토너먼트 | Parsertime", + "description": "오버워치 토너먼트를 운영하고 추적하세요 — 대진표, 경기, 팀 통계." + }, + "create": { + "metadata": { + "title": "토너먼트 만들기 | Parsertime", + "description": "새 오버워치 토너먼트 대진표를 설정하세요." + } + }, + "detail": { + "metadata": { + "title": "{name} | Parsertime", + "description": "{name}의 대진표, 경기, 순위입니다." + } + }, + "match": { + "metadata": { + "title": "{team1} vs {team2} | Parsertime", + "description": "{team1} vs {team2}의 경기 세부 정보와 맵 결과입니다." + } + }, + "teamStats": { + "metadata": { + "title": "{team} — 토너먼트 통계 | Parsertime", + "description": "{team}의 토너먼트 성과 분석입니다." + } + } + }, + "debugPage": { + "metadata": { + "title": "디버그 | Parsertime", + "description": "내부 디버깅 도구입니다." + } + }, + "fsrExplainer": { + "trigger": "FSR 산출 방식", + "title": "FSR 산출 방법", + "intro": "FSR은 승패가 아니라 인게임 스탯으로 개인 기량을 1–5000 척도로 평가합니다.", + "statBased": { + "label": "스탯 기반.", + "body": "역할별 성과(처치, 피해량, 치유량, 사망 등)를 각 역할에 중요한 정도로 가중해 산출합니다." + }, + "peers": { + "label": "동급 선수와 비교.", + "body": "각 스탯을 같은 티어·역할 선수들과 비교한 z-점수로 변환합니다." + }, + "recency": { + "label": "최근성·티어 가중.", + "body": "최근 맵일수록 비중이 크고, 높은 티어(엑스퍼트~OWCS)일수록 가중치가 큽니다." + }, + "sample": { + "label": "표본 보정.", + "body": "데이터가 충분해질 때까지 표본이 적으면 티어 평균 쪽으로 보정됩니다." + }, + "scale": { + "label": "1–5000 척도.", + "body": "플레이한 티어를 기준으로 고정되며, 백분위는 해당 티어·역할 내 순위를 나타냅니다." } } } diff --git a/messages/zh.json b/messages/zh.json index 9c9a95d8d..28ab8b5de 100644 --- a/messages/zh.json +++ b/messages/zh.json @@ -1,4 +1,288 @@ { + "bulkUpload": { + "dropTitleEmpty": "将工作坊日志拖到此处", + "dropTitleMore": "拖入更多日志以添加地图", + "dropSubtitle": "或点击浏览", + "dropHint": ".TXT,每个最大 10MB。每次最多 {max} 张。", + "versus": "vs", + "noTimestamp": "文件名中没有时间戳,已置于末尾", + "parseError": "无法读取此日志", + "reorderMap": "重新排序地图", + "removeMap": "移除地图", + "statusUploading": "上传中", + "statusDone": "完成", + "statusFailed": "失败", + "heroBansHeader": "英雄禁用", + "addHeroBan": "添加英雄禁用", + "hero": "英雄", + "team": "队伍", + "order": "顺序", + "heroBansEmpty": "添加各队禁用的英雄。禁用顺序将被保留。", + "uploading": "上传中", + "progress": "正在上传第 {current}/{total} 张地图", + "uploadMaps": "上传 {count} 张地图", + "createScrimWithMaps": "使用 {count} 张地图创建训练赛", + "retryFailed": "重试 {count} 张失败的地图", + "uploadedTitle": "地图已上传", + "uploadedCount": "已向训练赛添加 {count} 张地图", + "partialFailureTitle": "部分地图未能上传", + "partialFailureDescription": "已成功的地图已保存。请重试失败的地图。", + "createFirstMapFailed": "第一张地图无法上传,因此未创建训练赛。请修复后重试。", + "noMapsTitle": "没有可上传的地图", + "noMapsDescription": "请先添加至少一个工作坊日志。", + "winnerLabel": "获胜方:", + "winnerDetectedBadge": "已检测", + "winnerDetectedHint": "我们根据选手位置检测出此获胜方 — 请确认或选择另一支队伍。", + "missingPushWinnerTitle": "请选择推进地图的获胜方", + "missingPushWinnerDescription": "上传前请为每张推进地图选择获胜队伍。", + "maxMapsTitle": "地图过多", + "maxMaps": "每次最多可上传 {max} 张地图。", + "fileTypeTitle": "不支持的文件", + "fileType": "仅支持 .txt 工作坊日志。", + "fileSizeTitle": "文件过大", + "fileSize": "每个日志必须小于 10MB。", + "addDescription": "拖入工作坊日志以添加地图。", + "addTitle": "添加地图", + "addDialogDescription": "拖入工作坊日志,设置英雄禁用,然后上传。地图将保持所示顺序。" + }, + "queryBuilderPage": { + "title": "查询", + "subtitle": "逐步构建一个问题。它读起来像一句话,并作为限定于团队范围的真实查询运行。将鼠标悬停在任意部分上即可查看底层的表和列。", + "plannerLabel": "问题规划器", + "plannerPlaceholder": "我想知道 PGE 在 Widowmaker 上的最终击杀数,以及他在训练赛中使用她的时间", + "planQuestion": "规划", + "plannedQuery": "查询已规划", + "planFailed": "暂时无法规划这个问题。", + "plannerInterpreted": "解析为", + "plannerDataset": "数据:{dataset}", + "plannerFilterCount": "{count, plural, =0 {无筛选} other {# 个筛选}}", + "sentenceLabel": "查询语句", + "yourTeam": "你的团队", + "clauseFrom": "来自", + "clauseShow": "显示", + "clausePer": "按", + "clauseFor": "团队", + "clauseWhere": "条件", + "clauseAcross": "范围", + "clauseSortedBy": "排序", + "clauseTop": "前", + "overall": "全部", + "noFilters": "无筛选", + "selectTeam": "选择团队", + "filterAny": "任意", + "sortDefault": "默认顺序", + "limitAll": "所有行", + "topValue": "前 {n}", + "addMetric": "添加指标", + "addDimension": "添加分组", + "addFilter": "添加筛选", + "editDataset": "更改数据源", + "editMetric": "编辑此指标", + "editDimension": "编辑此分组", + "editTeam": "选择团队", + "editFilter": "编辑此筛选", + "editScope": "编辑训练赛范围", + "editSort": "编辑排序", + "editLimit": "编辑行数限制", + "removeToken": "移除", + "techTable": "{count, plural, other {表}}", + "techColumn": "{count, plural, other {列}}", + "searchPlaceholder": "搜索", + "searchFields": "搜索字段...", + "noResults": "没有匹配项。", + "pickMetric": "搜索指标", + "pickDataset": "搜索数据源", + "pickDimension": "搜索分组", + "pickFilter": "搜索筛选", + "opEq": "等于", + "opNeq": "不等于", + "opIn": "是其中之一", + "opGt": "大于", + "opGte": "至少", + "opLt": "小于", + "opLte": "至多", + "loadingOptions": "正在加载选项", + "noOptions": "此团队数据中没有值。", + "scopeAll": "所有训练赛", + "scopeLastN": "最近 N 场训练赛", + "scopeDateRange": "日期范围", + "scopeAllValue": "所有训练赛", + "scopeLastNValue": "最近 {n} 场训练赛", + "scopeRangeValue": "{from} 至 {to}", + "lastNLabel": "最近训练赛数量", + "from": "起始", + "to": "结束", + "scopeNote": "训练赛按日期排序;范围决定哪些训练赛进入查询。", + "ascending": "从低到高", + "descending": "从高到低", + "noSortable": "请先添加指标或分组。", + "sortNone": "清除排序", + "limitNone": "全部", + "limitCustom": "自定义", + "teamScopeNote": "每个查询都锁定为该团队拥有的训练赛。你只能选择有权限访问的团队。", + "compiledTitle": "已编译查询", + "compiledTables": "表", + "compiledEmpty": "完成语句后即可查看已编译的查询。", + "copy": "复制", + "copied": "已复制", + "emptyTitle": "尚未运行", + "emptyBody": "构建上方语句,然后运行即可在此查看表格或图表。", + "errorTitle": "查询未能运行", + "errorBody": "出现问题。请调整语句后重试。", + "zeroTitle": "没有匹配的行", + "zeroBody": "该团队没有符合查询的数据。请尝试放宽筛选或扩大训练赛范围。", + "viewTable": "表格", + "viewChart": "图表", + "viewSql": "SQL", + "askEyebrow": "提问", + "sentenceEyebrow": "构建", + "outputEyebrow": "输出", + "outputTitle": "结果", + "chartType": "图表类型", + "chartTypeBar": "柱状", + "chartTypeLine": "折线", + "chartTypeArea": "面积", + "chartTypeDonut": "环形", + "metaRows": "{count, plural, other {# 行}}", + "metaScrims": "{count, plural, other {# 场训练赛}}", + "metaDuration": "{ms}ms", + "metaTruncated": "已达上限", + "chartCapped": "在 {total} 行中显示前 {shown} 行。", + "needTeam": "请选择团队以运行。", + "needMetric": "请至少添加一个指标。", + "run": "运行", + "running": "运行中", + "save": "保存", + "saveTitle": "保存此查询", + "saveNamePlaceholder": "为查询命名", + "saveConfirm": "保存查询", + "export": "导出 CSV", + "savedQueries": "已保存", + "savedEmpty": "暂无已保存的查询。", + "deleteQuery": "删除已保存的查询", + "loadedQuery": "已加载“{name}”", + "savedQuery": "查询已保存", + "saveFailed": "无法保存查询。", + "noAccessTitle": "没有可查询的团队", + "noAccessBody": "你还无权访问任何团队。加入或创建团队以查询其训练赛数据。", + "metadata": { + "title": "查询构建器 | Parsertime", + "description": "在您的《守望先锋》训练赛数据上构建自定义查询。" + } + }, + "analyst": { + "metadata": { + "title": "分析师 | Parsertime", + "description": "为《守望先锋》训练赛数据、球员表现和团队趋势打造的 AI 分析师。" + }, + "eyebrow": "分析师", + "empty": { + "title": "快速读懂你的训练赛数据。", + "description": "询问团队表现、团战拆解、地图胜率和球员趋势。我会直接从你上传的训练赛中提取数据。", + "tryAsking": "试着问问", + "suggestions": [ + "我们队在上一场训练赛中表现如何?", + "我们在哪些地图上的胜率最高?", + "本月我们的表现趋势怎么样?", + "最近谁的数据最为突出?" + ], + "blocked": { + "description": "添加额度以开始对话。分析师采用即用即付,最低 $5。", + "addCredits": "添加额度" + } + }, + "composer": { + "placeholder": "询问你团队的表现……", + "placeholderBlocked": "添加额度以继续对话……", + "send": "发送消息", + "stop": "停止生成" + }, + "blockedBanner": { + "message": "余额过低,无法发送新消息。添加额度即可继续对话;过往对话仍以只读方式显示。", + "addCredits": "添加额度" + }, + "edit": { + "cancel": "取消", + "resend": "重新发送" + }, + "actions": { + "copy": "复制", + "regenerate": "重新生成", + "edit": "编辑" + }, + "newConversation": "新对话", + "sidebar": { + "heading": "对话", + "newChat": "新对话", + "deleteConversation": "删除对话:{title}", + "empty": "暂无对话" + }, + "balanceChip": { + "balanceLabel": "AI 聊天余额", + "addCreditsLabel": "添加额度以继续" + }, + "cards": { + "record": { + "wins": "{count}胜", + "losses": "{count}负", + "draws": "{count}平", + "winsLosses": "{wins}胜 · {losses}负" + }, + "loading": { + "writingReport": "正在生成报告" + }, + "scrimAnalysis": { + "eyebrow": "训练赛分析", + "vs": "vs", + "acrossMaps": "共 {count} 张地图", + "fightWr": "团战胜率", + "fightsWon": "团战胜场", + "totalFights": "团战总数", + "players": "球员", + "insights": "要点" + }, + "mapPerformance": { + "eyebrow": "地图表现", + "overall": "总体 {winrate}%" + }, + "teamTrends": { + "eyebrow": "表现趋势", + "winStreak": "{count} 连胜", + "lossStreak": "{count} 连败", + "noStreak": "暂无连胜或连败", + "weeklyWinRate": "每周胜率", + "last5": "最近 5 场", + "last10": "最近 10 场", + "last20": "最近 20 场" + }, + "player": { + "eyebrow": "球员", + "summary": "{heroes} · {mapCount} 张地图", + "kd": "K/D", + "elims": "击杀/10", + "deaths": "死亡/10", + "damage": "伤害/10", + "healing": "治疗/10" + }, + "report": { + "eyebrow": "报告已生成", + "viewReport": "查看报告" + }, + "teamOverview": { + "scrims": "{count} 场训练赛" + }, + "scrimList": { + "eyebrow": "近期训练赛", + "found": "找到 {count} 个", + "maps": "{count} 张地图" + }, + "trend": { + "up": "上升", + "down": "下降", + "stable": "稳定" + } + } + }, "maps": { "aatlis": "阿特拉斯", "antarctic-peninsula": "南极半岛", @@ -37,6 +321,37 @@ "throne-of-anubis": "阿努比斯王座", "watchpoint-gibraltar": "监测站:直布罗陀" }, + "ui": { + "pagination": { + "ariaLabel": "分页", + "previousPage": "前往上一页", + "nextPage": "前往下一页", + "previous": "上一页", + "next": "下一页", + "morePages": "更多页面" + }, + "carousel": { + "previousSlide": "上一张幻灯片", + "nextSlide": "下一张幻灯片" + }, + "dialog": { + "close": "关闭" + }, + "sheet": { + "close": "关闭" + }, + "sidebar": { + "title": "侧边栏", + "mobileDescription": "显示移动端侧边栏。", + "toggle": "切换侧边栏" + }, + "opponentSearch": { + "placeholder": "搜索 OWCS 队伍…", + "clearSelection": "清除对手选择", + "teams": "OWCS 队伍", + "noTeamsFound": "未找到队伍。" + } + }, "heroes": { "ana": "安娜", "anran": "安燃", @@ -73,6 +388,7 @@ "reaper": "死神", "reinhardt": "莱因哈特", "roadhog": "路霸", + "sierra": "希拉", "sigma": "西格玛", "sojourn": "索杰恩", "soldier76": "士兵:76", @@ -80,15 +396,43 @@ "symmetra": "秩序之光", "torbjorn": "托比昂", "tracer": "猎空", + "vendetta": "文德塔", "venture": "探奇", "widowmaker": "黑百合", "winston": "温斯顿", "wreckingball": "破坏球", + "wuyang": "无漾", "zarya": "查莉娅", "zenyatta": "禅雅塔" }, "landingPage": { + "pipeline": { + "eyebrow": "工作原理", + "title": "从原始日志到教练决策", + "description": "训练赛结束后上传工坊日志。Parsertime 解析每个事件,计算评分与站位数据,在 VOD 复盘开始前就把当晚的训练赛变成仪表盘。", + "sourcesLabel": "训练赛日志", + "processingLabel": "Parsertime", + "outputsLabel": "你的仪表盘", + "outputDashboards": "队伍仪表盘", + "outputRatings": "英雄技能评分", + "outputReplays": "回放与热力图", + "outputTrends": "趋势线" + }, + "positional": { + "badge": "v3 新功能", + "title": "俯瞰整场战斗", + "description": "首创的守望先锋 2 位置数据分析。每场训练赛都变成俯视回放:观察转点过程,找出你反复阵亡的角度,用其他工具都没有的数据衡量站位。", + "replayName": "地图回放查看器", + "replayDescription": "以俯视视角回放任意团战。玩家移动路径、大招时机和击杀事件都绘制在校准后的地图图像上。", + "heatmapsName": "热力图", + "heatmapsDescription": "按地图和攻防方渲染击杀、阵亡和占位密度。精确看到团战胜负发生的位置。", + "averagesName": "位置平均值", + "averagesDescription": "交战距离、高台击杀率、孤立阵亡率等站位指标,跨训练赛取平均并呈现趋势。" + }, "hero": { + "liveData": "实时平台数据", + "eyebrow": "训练赛数据分析 · 守望先锋 2", + "trustedBy": "深受 {count}+ 支队伍信赖", "latestUpdates": "最新更新", "title": "革新您的训练赛体验", "description": "使用我们的平台,直观查看您的比赛数据,跟踪球员的个人表现等。邀请球员加入您的团队,并让他们查看自己的统计数据。", @@ -238,6 +582,10 @@ "verification": "令牌已过期或已被使用。请重试。", "adapterError": "数据库适配器出现错误。请尝试清除您的 Cookie 后重试。如果问题仍然存在,请联系支持团队。", "default": "登录时发生未知错误。请联系支持。" + }, + "metadata": { + "title": "身份验证错误 | Parsertime", + "description": "登录时出现问题。" } }, "noAuth": { @@ -248,7 +596,11 @@ "verifyRequest": { "title": "检查您的电子邮件以获取登录链接", "description": "我们已向您发送了一封带有登录链接的电子邮件。点击电子邮件中的链接以完成登录过程。", - "back": "返回首页" + "back": "返回首页", + "metadata": { + "title": "请查收邮件 | Parsertime", + "description": "我们已向您发送登录链接以继续。" + } }, "signInPage": { "metadataSignIn": { @@ -292,19 +644,21 @@ "dashboard": { "metadata": { "title": "仪表盘 | Parsertime", - "description": "Parsertime 是一款用于分析《守望先锋》训练赛的工具。", + "description": "一目了然地查看您的训练赛、地图和团队表现。", "ogTitle": "仪表盘 | Parsertime", "ogDescription": "Parsertime 是一款用于分析《守望先锋》训练赛的工具。" }, "title": "仪表盘", "overview": "您的比赛", "admin": "管理员视图", + "pendingFeedback": "{count, plural, other {{count} 场训练赛等待反馈}}", "search": "搜索...", "themeSwitcher": { "toggle": "切换主题", "light": "浅色", "dark": "深色", - "system": "系统默认" + "system": "系统默认", + "disguised": "Disguised" }, "userNav": { "dashboard": "仪表盘", @@ -313,7 +667,12 @@ "contact": "联系我们", "docs": "文档", "admin": "管理员", - "signOut": "退出登录" + "signOut": "退出登录", + "profile": "个人资料" + }, + "guestNav": { + "guestUser": "访客用户", + "signIn": "登录" }, "commandMenu": { "searchPlaceholder": "输入命令或搜索...", @@ -399,6 +758,9 @@ "oldToNew": "从旧到新", "newToOld": "从新到旧", "search": "搜索...", + "noScrimsFound": "没有找到符合筛选条件的训练赛。", + "tryAdjustingFilters": "请尝试调整搜索词或筛选条件。", + "searchDescription": "提示: 使用 team:队伍名creator:用户名 语法可以改善搜索结果。", "searchInfoLabel": "搜索帮助" }, "pagination": { @@ -406,20 +768,82 @@ "next": "下一页", "morePages": "更多页面" }, + "tour": { + "title": "仪表盘导览", + "step1": { + "title": "欢迎使用 Parsertime!", + "description": "此导览将带你了解仪表盘的主要功能。我们开始吧!" + }, + "step2": { + "title": "第一步", + "description": "一切都从这里开始!点击创建训练赛按钮来设置你的第一个场次。别担心,下一步会引导你完成上传流程!" + }, + "step3": { + "title": "命名训练赛", + "description": "给训练赛起个名字!可以使用类似“Team A vs Team B”的名称,方便整理。" + }, + "step4": { + "title": "分配队伍", + "description": "选择要共享此训练赛的队伍。如果还没有创建队伍,可以前往队伍页面创建。" + }, + "step5": { + "title": "设置日期", + "description": "选择训练赛进行的日期。这有助于按时间顺序整理训练赛。" + }, + "step6": { + "title": "上传第一张地图", + "description": "在这里上传 Workshop Log(.xlsx 或 .txt)。我们会处理数据并创建你的训练赛。" + }, + "step7": { + "title": "英雄禁用(可选)", + "description": "如果训练赛中有英雄禁用,可以在这里添加。这有助于在复盘训练赛时提供背景信息。" + }, + "step8": { + "title": "全部就绪!", + "description": "你已经可以开始分析训练赛数据了!" + } + }, "mainNav": { "dashboard": "仪表盘", + "toggleMenu": "切换菜单", "stats": "统计数据", "teams": "团队", "settings": "设置", "contact": "联系我们", "docs": "文档", - "tournaments": "锦标赛" + "leaderboard": "排行榜", + "playerStats": "玩家统计", + "heroStats": "英雄统计", + "mapStats": "地图统计", + "compareStats": "玩家对比", + "teamStats": "团队统计", + "scouting": "侦察", + "scoutPlayer": "侦察玩家", + "scoutTeam": "侦察队伍", + "scoutFaceitTeam": "侦察 FACEIT 队伍", + "scoutFaceitPlayer": "侦察 FACEIT 玩家", + "dataLabeling": "数据标注", + "dataTools": "数据工具", + "mapCalibration": "地图校准", + "chat": "分析师", + "chatNew": "新对话", + "chatReports": "报告", + "tournaments": "锦标赛", + "query": "查询", + "coaching": "教练", + "coachingCanvas": "画布", + "yourTeams": "你的队伍", + "availability": "可用时间", + "matchmaker": "匹配工具", + "ranked": "排位追踪器" }, "teamSwitcher": { "searchTeamPlaceholder": "搜索团队...", "noTeamFound": "未找到团队。", "individual": "个人", "teams": "团队", + "selectTeam": "选择团队", + "loading": "正在加载...", "createTeam": "创建团队" }, "createTeam": { @@ -489,8 +913,31 @@ "createdScrim": { "title": "比赛创建成功", "description": "您的比赛已成功创建。", - "errorTitle": "错误", - "errorDescription": "发生错误:{res}。请查看文档 () 以解决可能的问题。" + "primary": "完成", + "secondary": "再创建一个", + "errorTitle": "无法创建训练赛", + "errorReassurance": "你的表单内容仍在,没有丢失。", + "errorFallback": "日志格式无效", + "errorEscape": "如果重试后仍无法解决,下面的资源可以帮助你排查。", + "errorResourceLabel": "需要帮助?", + "errorResourceSchema": "架构", + "errorResourceDebug": "调试提示", + "errorResourceDiscord": "Discord", + "errorRetry": "重试", + "errorBack": "返回表单", + "errorDescription": "发生错误:{res}。请查看文档以确认是否可以解决该错误。" + }, + "dataCorruption": { + "warning": { + "title": "检测到数据损坏", + "baseDescription": "检测到损坏数据,正在尝试自动修复:", + "invalidMercyRez": "无效的 mercy_rez 行将被移除", + "asteriskValues": "星号值将替换为 0" + }, + "success": { + "title": "训练赛创建成功", + "description": "损坏数据已自动修复,训练赛已成功创建。" + } }, "scrimName": "比赛名称", "scrimPlaceholder": "新训练赛", @@ -514,21 +961,196 @@ "dateRequiredError": "比赛日期不能为空。", "mapName": "第一张地图", "mapDescription": "请上传第一张比赛地图文件。支持 .xlsx 和 .txt 文件,文件大小不得超过 1MB。比赛创建后可以添加更多地图。", + "mapDropTitle": "将 Workshop 日志拖放到这里", + "mapDropSubtitle": "或点击浏览", + "mapDropParsing": "正在解析训练赛数据…", + "mapDropParsed": "已解析", + "mapDropReplace": "替换", + "mapDropTeamSeparator": "vs", "submit": "提交", "submitting": "提交中...", - "autoAssignTeamNames": "Auto-assign team names", - "autoAssignTeamNamesDescription": "Automatically rename teams and normalize team positions so your team is always Team 1.", - "autoAssignDisabledDescription": "Select a team to enable auto-assign." + "heroBansName": "英雄禁用", + "heroBansDescription": "添加每支队伍禁用的英雄。禁用顺序会被保留。", + "addHeroBan": "添加英雄禁用", + "orderName": "顺序", + "selectTeam": "选择队伍", + "team": "队伍", + "hero": "英雄", + "selectHero": "选择英雄", + "heroBanRequiredError": "需要选择要禁用的英雄。", + "banPositionRequiredError": "需要填写禁用顺序。", + "cancel": "取消", + "autoAssignTeamNames": "自动分配队伍名称", + "autoAssignTeamNamesDescription": "自动重命名队伍并规范队伍位置,使你的队伍始终为 Team 1。", + "autoAssignDisabledDescription": "选择队伍以启用自动分配。", + "opponentName": "OWCS 对手", + "opponentDescription": "可选。关联到 OWCS 侦察分析。", + "linkedRequestLabel": "关联的配对请求", + "linkedRequestNone": "非配对请求产生的训练赛", + "linkedRequestDescription": "如果此训练赛来自配对请求,请关联以启用赛后反馈功能。" + }, + "activity": { + "regionLabel": "近期活动概览", + "winRate": "胜率", + "mapsLogged": "已记录地图", + "teamTsr": "团队 TSR", + "bestMap": "最佳地图", + "scrimsLogged": "已记录比赛", + "activeTeams": "活跃战队", + "latestScrim": "最近比赛", + "record": "{wins}–{losses}", + "tsrRated": "{rated}/{total} 已评级 · {confidence}", + "tsrNoData": "阵容数据不足", + "bestMapSub": "胜率 {winrate}", + "bestMapNone": "暂无地图", + "never": "暂无比赛", + "trendEyebrowTeam": "胜率 · 每周", + "trendEyebrowAll": "已记录比赛 · 每周", + "avgWinrate": "平均 {winrate}", + "totalScrims": "{weeks} 周内 {count} 场", + "noHistory": "暂无足够历史数据绘制图表", + "winsLosses": "{wins} 胜 · {losses} 负", + "scrimsCount": "{count, plural, other {# 场比赛}}", + "confidence": { + "high": "高置信度", + "medium": "中置信度", + "low": "低置信度" + }, + "expand": "展开活动概览", + "collapse": "收起活动概览" } }, "updateModal": { "dismiss": "关闭" }, + "calendar": { + "monthYear": "{date, date, ::yyyy年MMMM}", + "weekday": "{weekday, date, ::E}" + }, + "availability": { + "timezoneSelect": { + "searchPlaceholder": "搜索时区...", + "empty": "未找到时区。", + "detected": "已检测", + "teamDefault": "队伍默认", + "regional": "区域", + "allTimezones": "所有时区" + }, + "indexPage": { + "title": "{teamName} — 可用时间", + "settings": "设置", + "notConfiguredManager": "这支队伍尚未配置可用时间。立即设置", + "notConfiguredViewer": "这支队伍尚未配置可用时间。请让所有者或管理员进行设置。", + "currentWeek": "本周:{start} – {end}", + "responsesSoFar": "目前已有 {count} 份回复。", + "openShareLink": "打开分享链接", + "pastWeeks": "过往周次", + "pastWeekRange": "{start} – {end}", + "pastWeekResponses": "({count} 份回复)", + "view": "查看", + "startThisWeek": "开始本周" + }, + "settingsPage": { + "title": "可用时间设置", + "description": "配置队伍共享可用时间日历的工作方式。", + "metadata": { + "title": "可用时间设置 | Parsertime", + "description": "配置团队的可用时间安排和时区。" + } + }, + "settingsForm": { + "slotGranularity": "时间段粒度", + "slotMinutes": "{count} 分钟", + "defaultTimezone": "默认时区(用于查看)", + "startHour": "开始小时(0-23)", + "endHour": "结束小时(1-24)", + "midnightHint": "24 = 午夜", + "weeklyReminder": "每周 Discord 提醒", + "weeklyReminderDescription": "机器人会在每周开始时用填写链接提醒指定身份组。", + "day": "星期", + "hour": "小时", + "minute": "分钟", + "roleId": "要提醒的 Discord 身份组 ID", + "guildId": "服务器 ID(覆盖)", + "channelId": "频道 ID(覆盖)", + "botConfigPlaceholder": "留空以使用机器人配置", + "saving": "正在保存...", + "save": "保存设置", + "days": { + "sunday": "星期日", + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六" + }, + "toast": { + "saveFailed": "保存设置失败", + "saved": "设置已保存" + }, + "validation": { + "endAfterStart": "结束小时必须晚于开始小时", + "evenSlots": "可用时间窗口必须能被时间段分钟数整除" + } + }, + "fillView": { + "title": "{teamName} 可用时间", + "weekIntro": "{date} 当周 - 请选择你有空的时间。", + "name": "姓名", + "namePlaceholder": "你的姓名", + "password": "密码({required, select, true {必填} other {可选}})", + "passwordRequiredPlaceholder": "输入你的密码", + "passwordOptionalPlaceholder": "设置密码", + "viewingTimezone": "查看时区", + "yourAvailability": "你的可用时间", + "saving": "正在保存...", + "save": "保存我的可用时间", + "clear": "清除", + "timezoneMismatch": "队伍日历设置为 {teamTz},但你当前位于 {localTz}。请按本地时间选择时间段,我们会自动转换为队伍时区。", + "localTimezone": "正在按你的本地时间选择:{timezone}", + "groupAvailability": "小组可用时间", + "responseCount": "({count, plural, one {# 条回复} other {# 条回复}})", + "hoverPrompt": "将鼠标悬停在单元格上查看谁有空", + "availabilityRatio": "{available} / {total}", + "namesSuffix": " - {names}", + "unavailable": "不可用:{names}", + "toast": { + "nameRequired": "请输入你的姓名", + "passwordRequired": "编辑此姓名需要密码", + "incorrectPassword": "密码不正确", + "saveFailed": "保存可用时间失败", + "saved": "可用时间已保存" + }, + "promo": { + "title": "面向《守望先锋》的训练赛分析和队伍工具", + "description": "Parsertime 会将原始 Workshop Log 数据转换为技能评分、趋势线和执教洞察,并提供你正在使用的可用时间日历等队伍管理工具。", + "bullets": { + "instantReview": "即时复盘。 上传训练赛后,几分钟内即可查看每位选手的统计、地图和团战,无需电子表格。", + "csrRatings": "CSR 评分。 基于角色专属指标的客观 1-5000 英雄技能评分。", + "trends": "趋势。 观察选手跨周和跨赛季的进步,而不只看单场比赛。", + "coachingCanvas": "执教画布。 在共享白板上绘制战术,并与对应训练赛保存在一起。", + "teamAvailability": "队伍可用时间。 此日历加上 Discord 提醒,可在每周开始时提醒你的身份组。", + "freeToStart": "免费开始。 免费层级支持两个队伍和五名成员,无需信用卡。" + }, + "signIn": "登录以查看队伍训练赛", + "learnMore": "进一步了解 Parsertime" + }, + "metadata": { + "title": "填写可用时间 | Parsertime", + "description": "标记您可以参加训练赛的时间。" + } + }, + "metadata": { + "title": "可用时间 | Parsertime", + "description": "查看团队成员的空闲时间,围绕每个人的日程安排训练赛。" + } + }, "scrimPage": { "metadata": { "scrim": "训练赛", "title": "{scrimName} 概述 | Parsertime", - "description": "在 Parsertime 上查看 {scrimName} 的概述。Parsertime 是一款用于分析《守望先锋》训练赛的工具。", + "description": "{scrimName} 概览 — 这场《守望先锋》训练赛的地图结果、玩家数据和团战分析。", "ogTitle": "{scrimName} 概述 | Parsertime", "ogDescription": "在 Parsertime 上查看 {scrimName} 的概述。Parsertime 是一款用于分析《守望先锋》训练赛的工具。", "ogImage": "{scrimName} 概述" @@ -536,6 +1158,10 @@ "back": "返回仪表板", "newScrim": "新训练赛", "edit": "编辑训练赛", + "meta": { + "mapCount": "{count, plural, one {# 张地图} other {# 张地图}}", + "opp": "对手" + }, "maps": { "title": "地图", "altText": "{map} 的加载画面艺术" @@ -561,6 +1187,28 @@ "createDescription": "地图已成功创建。", "errorTitle": "错误", "errorDescription": "发生错误: {res}。请查看 文档 以解决该问题。" + }, + "dataCorruption": { + "warning": { + "title": "检测到数据损坏", + "baseDescription": "检测到损坏的数据。正在尝试自动修复:", + "invalidMercyRez": "无效的 mercy_rez 行将被移除", + "asteriskValues": "星号值将被替换为 0" + }, + "success": { + "title": "地图添加成功", + "description": "损坏的数据已自动修复,地图已成功添加。" + } + }, + "submitting": "正在提交", + "submit": "提交", + "cancel": "取消", + "addHeroBan": "添加英雄禁用", + "heroBansDescription": "为此地图配置英雄禁用。如果不添加任何禁用并点击提交,可以跳过此步骤。", + "heroBansTitle": "添加英雄禁用(可选)", + "heroBans": { + "reorder": "重新排序", + "removeBan": "移除禁用" } }, "editScrim": { @@ -595,10 +1243,15 @@ "title": "启用访客模式", "description": "启用后,任何登录用户都可以访问此训练赛。禁用后,只有您的队伍成员可以访问此训练赛。" }, + "opponent": { + "title": "对手 (OWCS)", + "description": "将此训练赛关联到 OWCS 对手,以启用交叉引用的侦察分析。" + }, "maps": { "title": "地图", "replayCodeLabel": "{map} 的重播代码", "replayCode": "重播代码", + "heroBans": "英雄禁用", "delete": "删除地图", "deleteDialog": { "title": "您确定吗?", @@ -646,6 +1299,870 @@ "onClick": { "title": "已复制到剪贴板!", "description": "重播代码已复制到剪贴板。" + }, + "code": "重播代码:{replayCode}" + }, + "overviewTabs": { + "team": { + "yourTeam": "你的队伍", + "opponent": "对手" + }, + "tabs": { + "visualizations": "可视化", + "rawStats": "原始数据" + }, + "actions": { + "collapseAll": "全部收起", + "expandAll": "全部展开", + "collapseOverview": "收起概览", + "expandOverview": "展开概览" + }, + "sections": { + "players": "玩家表现", + "fights": "团战", + "abilities": "技能时机", + "ultimates": "终极技能", + "ultAdvantage": "终极技能优势", + "swaps": "英雄更换", + "positional": "站位", + "initiation": "团战发起" + } + }, + "overviewSections": { + "fights": { + "noData": "本次训练赛暂无团战数据。", + "summary": "赢下 {winrate}%,共 {total} 场团战({won}胜 - {lost}负)", + "firstUlt": "率先使用终极技能 {count} 次({winrate}% 胜率)。", + "opponentFirstUlt": "对手率先使用终极技能 {count} 次({winrate}% 胜率)。", + "labels": { + "fightsWon": "赢下团战", + "firstPickRate": "首杀率", + "firstDeathRate": "首死率" + }, + "headings": { + "conditionalWinRates": "条件胜率" + }, + "units": { + "fights": "场团战", + "firstDeaths": "次首死" + }, + "chart": { + "overall": "整体", + "firstPick": "率先击杀", + "diedFirst": "率先阵亡", + "usedUltFirst": "率先开大", + "opponentUltFirst": "对手率先开大", + "winRate": "胜率", + "tooltipWinRate": "{winrate} 胜率" + }, + "abilityTiming": { + "noData": "本次训练赛未检测到高影响技能。", + "phase": { + "preFight": "战斗前", + "early": "前期", + "mid": "中期", + "late": "后期", + "cleanup": "收尾" + }, + "negativeOutlier": "{abilityName} 在{phase}使用时胜率为 {phaseWinrate},低于在{bestPhase}使用时的 {bestPhaseWinrate}。", + "positiveOutlier": "{abilityName} 在{phase}使用时对应 {phaseWinrate} 胜率,呈现强{pattern}模式。", + "patternInitiation": "开战", + "patternTiming": "时机", + "cellTitle": "{winrate} 胜率({fights, plural, other {# 场战斗}}中 {wins, number}胜 {losses, number}负)", + "fewerThanFights": "少于 {count, plural, other {# 场战斗}}", + "legendWinRate": "胜率:", + "legendBadTiming": "不佳时机", + "legendGoodTiming": "良好时机", + "hoverDetail": "{abilityName} 在{phase}使用:{winrate} 胜率,{fights, plural, other {# 场战斗}}中 {wins, number}胜 {losses, number}负", + "info": "单元格显示技能在对应战斗阶段使用时的胜率。“—”表示少于 {count, plural, other {# 场战斗}}。悬停可查看详情。" + } + }, + "ultimates": { + "noData": "本次训练赛暂无终极技能数据。", + "efficiency": "终极技能效率:{value} 每个终极技能赢下的团战数({rating})", + "ratings": { + "excellent": "优秀", + "good": "良好", + "average": "一般", + "poor": "较差" + }, + "labels": { + "totalUltimatesUsed": "终极技能总使用数", + "roleUltimates": "{role} 终极技能" + }, + "headings": { + "usageBySubrole": "按副职责统计终极技能使用", + "timingBreakdown": "终极技能时机细分" + }, + "units": { + "ults": "个大招" + }, + "chart": { + "noPlayer": "无选手", + "ultCount": "{count, number} 个终极技能" + } + }, + "swaps": { + "noData": "本次训练赛暂无英雄更换数据。", + "summary": "所有地图共 {swaps} 次英雄更换({perMap}/图),对手为 {opponentSwaps} 次", + "topSwap": "最常见更换:{fromHero} -> {toHero}{count}次)", + "topSwapper": "最活跃更换者:{playerName}{swaps} 次更换,{maps} 张地图)", + "labels": { + "totalHeroSwaps": "英雄更换总数", + "swapVsNoSwapWinRate": "更换与不更换胜率", + "withSwaps": "发生更换", + "withoutSwaps": "未发生更换" + }, + "headings": { + "winRateBySwapCountTiming": "按更换次数与时机统计胜率" + }, + "units": { + "swaps": "次更换" + }, + "chart": { + "tooltipWinRate": "{winrate} 胜率", + "detail": "{wins, number}胜–{losses, number}负({maps, plural, other {# 张地图}})", + "winRate": "胜率", + "buckets": { + "zeroSwaps": "0 次更换", + "oneSwap": "1 次更换", + "twoSwaps": "2 次更换", + "threePlusSwaps": "3 次以上更换", + "early": "前期(0-33%)", + "mid": "中期(33-66%)", + "late": "后期(66-100%)" + } + } + } + }, + "rawStatsSections": { + "fights": { + "label": "团战分析", + "title": "团战分析", + "overallWinRate": "整体团战胜率:{winrate} (共 {total, plural, other {# 场团战}},{won, number} 胜 / {lost, number} 负)", + "teamFirstDeath": "你方在 {rateValue} 的团战中率先阵亡。", + "teamFirstDeathComparison": "率先阵亡时仍赢下 {firstDeathWinrate} 的团战;拿到首杀时赢下 {firstPickWinrate}。", + "firstPick": "你方在 {firstPickRate} 的团战中拿到首杀,并赢下其中 {firstPickWinrate}。", + "firstUlt": "你方率先使用终极技能时,赢下 {winrate} 的团战。", + "opponentFirstUlt": "对手率先使用终极技能时,你方胜率{hasFirstUlt, select, yes {降至} other {为}} {winrate}。" + }, + "ultimates": { + "label": "终极技能分析", + "title": "终极技能分析", + "ourTopFightInitiator": "你方最常用的开战终极技能:{hero}{count}{count, plural, other { 场战斗}})。", + "opponentTopFightInitiator": "对手最常用的开战终极技能:{hero}{count}{count, plural, other { 场战斗}})。", + "goodDiscipline": "终极技能纪律良好 — 胜利团战中使用的终极技能多于失败团战。", + "roomForImprovement": "仍有改进空间 — 失败团战中使用的终极技能多于胜利团战。", + "ultUsage": "你方使用了 {ourCount}{ourCount, plural, other { 个终极技能}},对手使用了 {opponentCount} 个。", + "roleUltUsage": "{role} 终极技能:你方使用 {ourCount} 个,对手使用 {opponentCount} 个。", + "roleFirstUlt": "你方在 {rateValue} 的团战中率先使用 {role} 终极技能。", + "subroleTiming": "{subrole}:{count}{count, plural, other { 个终极技能}}({pct}%)— 开战 {initiation}中期 {midfight}后期 {late}", + "fightInitiations": "你方在 {rateValue} 的终极技能团战中用终极技能开战({initiations} / {total})。", + "topUltUser": "使用终极技能最多:{playerName}({hero}),使用 {count}{count, plural, other { 个}}。", + "avgChargeAndHold": "你方平均 {chargeTime} 秒充满终极技能,并在使用前平均持有 {holdTime} 秒。", + "avgCharge": "你方平均 {chargeTime} 秒充满终极技能。", + "avgHold": "你方在使用终极技能前平均持有 {holdTime} 秒。", + "ultimateEfficiency": "终极技能效率:每个终极技能赢下 {value} 场团战({rating})。", + "averageUltsPerFight": "胜利团战平均使用 {won} 个终极技能,失败团战平均使用 {lost} 个。{discipline}", + "wastedUltimates": "浪费了 {count}{count, plural, other { 个终极技能}}(占总数 {percent}%),这些终极技能用于已失利局面(落后 3 人以上)。", + "dryNonDryFights": "{dryCount}{dryCount, plural, other { 场干团}}(未使用终极技能)的胜率为 {dryRate}%,另有 {nonDryCount}{nonDryCount, plural, other { 场}}使用终极技能的团战({nonDryRate}% 胜率)。", + "fightReversal": "逆转率(落后 2 个以上击杀后获胜):干团 {dryRate}%,使用终极技能时 {nonDryRate}%。{insight, select, comeback {终极技能是这支队伍的关键翻盘工具。} mechanics {这支队伍也能凭借硬实力逆转团战。} other {}}" + }, + "swaps": { + "label": "英雄更换分析", + "title": "英雄更换分析", + "ourTopSwap": "你方最常见更换:{fromHero}{toHero}{count}{count, plural, other { 次}})。", + "opponentTopSwap": "对手最常见更换:{fromHero}{toHero}{count}{count, plural, other { 次}})。", + "summary": "你方在所有地图中进行了 {ourSwaps}{ourSwaps, plural, other { 次英雄更换}}(每图 {ourPerMap} 次),对手为 {opponentSwaps} 次(每图 {opponentPerMap} 次)。", + "winrateComparison": "发生更换的地图胜率:{swapWinrate} ({swapWins, number}胜 / {swapLosses, number}负),未更换时为 {noSwapWinrate} ({noSwapWins, number}胜 / {noSwapLosses, number}负)。", + "winrateDelta": "更换时相差 {delta}%。", + "avgHeroTimeBeforeSwap": "更换前单个英雄平均使用时间:{seconds} 秒。", + "topSwapper": "最活跃更换者:{playerName},在 {maps}{maps, plural, other { 张地图}}中更换 {swaps}{swaps, plural, other { 次}}。", + "winrateBySwapCount": "按更换次数统计胜率:", + "swapCountBucket": "{label}:{winrate} ({wins, number}胜-{losses, number}负)", + "winrateBySwapTiming": "按更换时机统计胜率:", + "swapTimingBucket": "{label}:{winrate} ({maps, plural, other {# 张地图}})" + } + }, + "overviewSummary": { + "ariaLabel": "训练赛概览", + "recordAria": "战绩:{wins} 胜,{losses} 负,{draws} 平", + "winRate": "{winRate}% 胜率", + "keyInsights": "关键洞察", + "record": { + "wins": "{count}胜", + "losses": "{count}负", + "draws": "{count}平" + }, + "stats": { + "maps": "地图", + "teamKd": "团队 K/D", + "totalDamage": "总伤害", + "totalHealing": "总治疗", + "record": "{wins}胜 · {losses}负", + "elims": "{count} 次击杀", + "heroDamage": "英雄伤害", + "healingDealt": "治疗量" + } + }, + "playerPerformance": { + "columns": { + "player": "玩家", + "maps": "地图", + "kd": "K/D", + "elimsPer10": "击杀/10", + "damagePer10": "伤害/10", + "firstDeath": "首死", + "teamFirstDeath": "团队首死", + "trend": "趋势", + "outliers": "异常项" + }, + "hover": { + "showTrendChart": "显示 {playerName} 的表现趋势图", + "description": "各地图表现(相对平均值 %)· 点击指标聚焦", + "unavailable": "此玩家的表现图表暂不可用。", + "metrics": { + "kd": "K/D", + "elimsPer10": "击杀/10", + "damagePer10": "伤害/10", + "healingPer10": "治疗/10", + "firstDeathRate": "首死 %", + "teamFirstDeathRate": "团队首死 %" + } + }, + "trend": { + "improving": "提升中", + "declining": "下降中", + "stable": "稳定", + "improvingAria": "提升中:{count} 项指标上升", + "decliningAria": "下降中:{count} 项指标下降", + "stableAria": "表现稳定" + }, + "outlier": { + "elite": "顶尖", + "belowAverage": "低于平均", + "aria": "{label} 位于数据库第 {percentile} 百分位({status})", + "tooltip": "{label}:数据库第 {percentile} 百分位({status})" + }, + "fallback": { + "unknownPlayer": "未知玩家", + "unknownHero": "未知英雄" + } + }, + "addToMapGroupDialog": { + "title": { + "add": "添加到地图组", + "create": "创建新地图组" + }, + "description": { + "add": "选择一个现有地图组来添加 {mapName},或创建一个新地图组。", + "create": "创建一个新地图组并添加 {mapName}。" + }, + "selectGroup": "选择地图组", + "mapCount": "{count} 张地图", + "empty": { + "title": "未找到地图组。", + "description": "在下方创建你的第一个地图组。" + }, + "createNew": "创建新地图组", + "backToSelection": "返回选择", + "cancel": "取消", + "addToGroup": "添加到组", + "createAndAdd": "创建并添加", + "form": { + "name": "名称", + "namePlaceholder": "例如:控制图", + "description": "描述", + "descriptionPlaceholder": "该组的可选描述", + "category": "分类", + "categoryPlaceholder": "例如:地图类型、打法" + }, + "toast": { + "unknownError": "未知错误", + "fetchFailed": "获取地图组失败", + "nameRequired": "请输入地图组名称", + "added": { + "title": "已添加到地图组", + "description": "{mapName} 已添加到 {groupName}" + }, + "addFailed": { + "title": "添加到地图组失败" + }, + "created": { + "title": "地图组已创建", + "description": "已创建“{groupName}”并添加 {mapName}" + }, + "createFailed": { + "title": "创建地图组失败" + } + } + }, + "mapCard": { + "ariaLabel": "{map} 地图卡片", + "ariaLabelSelected": "{map} 地图卡片,已选择用于比较", + "altText": "{map} 的加载界面美术图。", + "selected": "已选择", + "copiedCode": "已复制回放代码!", + "won": "胜", + "lost": "负", + "auto": "自动", + "autoTooltip": "根据位置自动检测", + "editWinner": "编辑 {map} 的胜方", + "contextMenu": { + "title": "地图操作", + "selectForComparison": "选择用于比较", + "addToMapGroup": "添加到地图组", + "setWinner": "设置胜方", + "viewDetails": "查看地图详情", + "copyCode": "复制回放代码" + } + }, + "mapWinnerDialog": { + "title": "设置地图胜方", + "description": "为 {mapName} 选择获胜队伍。这将覆盖自动检测的结果。", + "cancel": "取消", + "save": "保存胜方", + "success": "胜方已更新", + "error": "设置胜方失败" + }, + "compareButton": { + "selected": "{count, plural, =1 {已选择 1 张地图} other {已选择 # 张地图}}", + "selectedMapName": "{count, plural, =1 {1 张已选地图} other {# 张已选地图}}", + "fromScrims": "{count, plural, =1 {来自 1 场训练赛} other {来自 # 场训练赛}}", + "compare": "比较已选地图", + "compareNow": "立即比较", + "addToMapGroup": "添加到地图组", + "clear": "清除" + }, + "viewStats": "查看团队统计", + "overviewUnavailable": { + "title": "概览暂不可用", + "description": "仅凭一场训练赛无法识别哪个阵容是您的队伍。请再上传一场训练赛,然后返回此页面即可查看胜负和团队分析。" + }, + "positional": { + "title": "站位数据", + "description": "基于本场训练赛中包含站位数据的地图取平均。", + "matrix": { + "below": "低于中位数", + "above": "高于中位数", + "legend": "色调表示每个数值相对于该项数据团队中位数的偏差。" + }, + "byZoneTitle": "按区域划分的胜 / 负 / 平", + "playerColumn": "选手", + "stats": { + "AVERAGE_ENGAGEMENT_DISTANCE": { + "short": "交战距离", + "full": "平均交战距离" + }, + "HIGH_GROUND_KILL_PERCENTAGE": { + "short": "高地击杀 %", + "full": "高地击杀 %" + }, + "ISOLATION_DEATH_PERCENTAGE": { + "short": "孤立死亡 %", + "full": "孤立死亡 %" + }, + "AVERAGE_FIGHT_START_SPREAD": { + "short": "开战间距", + "full": "团战开始间距" + }, + "AVERAGE_ULT_CONVERSION_KILLS": { + "short": "转化击杀", + "full": "终极技能转化击杀" + }, + "ULT_DEATH_PERCENTAGE": { + "short": "开大阵亡 %", + "full": "使用终极技能时阵亡 %" + }, + "AVERAGE_ULT_DISPLACEMENT": { + "short": "位移", + "full": "平均终极技能位移" + }, + "ULTS_ON_OBJECTIVE_PERCENTAGE": { + "short": "点上大招 %", + "full": "争夺点上的终极技能 %" + } + }, + "engagementsTitle": "交战", + "winrate": "胜率", + "fights": "{total, plural, other {# 次交战}}", + "recordSummary": "{won}胜 / {lost}负 / {even}平", + "wonLabel": "胜", + "lostLabel": "负", + "evenLabel": "平", + "zonesTitle": "各地图区域控制", + "zonesYourTeam": "我方", + "zonesOpponents": "对手", + "zonesCount": "{count} 个区域", + "zonesControlPct": "{pct}% 控制", + "routesTitle": "各地图路线", + "routesSummary": "{total} 条路线 · {won} 胜 / {lost} 负", + "routesUndecided": "未定", + "zoneColumn": "区域", + "teamColumn": "队伍", + "killsColumn": "击杀", + "deathsColumn": "死亡", + "ultsColumn": "终极技能" + }, + "editMetadata": { + "title": "编辑 {scrimName} | Parsertime", + "description": "编辑 {scrimName} 的详情和设置。" + }, + "wpa": { + "title": "胜率贡献值", + "description": "按选手汇总本次训练赛全部地图的 WPA。", + "columns": { + "player": "选手", + "team": "队伍", + "maps": "地图", + "wpa": "WPA" + } + }, + "initiationSection": { + "coverage": "在 {total} 张拥有详细日志的地图中检测到 {covered} 张。", + "noData": "暂无发起数据 — 这些地图早于详细日志。", + "wentFirst": "{team} — 先手胜率", + "frequency": "发起 {first} 次团战", + "record": "{wins}胜 - {losses}负" + } + }, + "mapGroupsPage": { + "metadata": { + "title": "地图组 | Parsertime", + "description": "创建和管理用于分析的自定义地图组" + } + }, + "tendenciesPage": { + "fightMap": { + "description": "汇总最近训练赛,展示队伍在地图上哪些位置赢下团战、哪些位置陷入苦战。绿色为优势区域,红色为劣势区域。", + "fights": "{count} 次决定性团战", + "overall": "团战胜率", + "unnamedArea": "未命名区域", + "notEnoughFights": "团战数据不足——此视图需要至少 {count} 次带坐标数据的决定性团战。", + "noCalibration": "该地图没有校准图像。请在校准工具中完成校准以启用团战地图。", + "zone": "区域", + "record": "战绩", + "winrate": "胜率", + "sectionEyebrow": "团战地图", + "sectionTitle": "优势区与劣势区", + "summaryFights": "决定性团战", + "summaryFightsSub": "已分胜负", + "summaryMaps": "地图", + "summaryMapsSub": "含团战数据", + "summaryStrongest": "最强地图", + "summaryWeakest": "最弱地图", + "summaryNoRanking": "团战不足" + }, + "metadata": { + "title": "倾向 | Parsertime", + "description": "汇总最近训练赛,展示队伍在各地图上团战的优势与劣势区域" + }, + "title": "倾向", + "description": "每张地图按位置展示团战胜率——绿色为优势区域,红色为劣势区域。", + "empty": "暂无位置数据。", + "totalRoutes": "路线总数", + "route": "路线 {n}", + "record": "战绩", + "winrate": "胜率", + "wonShort": "胜", + "lostShort": "负", + "clusterRoutes": "{count} 条路线", + "oneOffs": "{count} 条仅出现一次的路线未显示", + "share": "占比", + "routeColumn": "路线", + "outcomes": "胜 / 负 / 未知", + "won": "胜", + "lost": "负", + "unknown": "未知" + }, + "comparePage": { + "metadata": { + "title": "地图比较 | Parsertime", + "description": "比较多张地图中的玩家表现" + }, + "title": "地图比较", + "subtitle": "分析所选地图中的玩家表现", + "comparingMaps": "{count, plural, =1 {正在比较 1 张地图} other {正在比较 # 张地图}}", + "loading": "正在加载比较数据...", + "emptyStates": { + "noMaps": { + "title": "未选择地图", + "description": "请从训练赛页面选择地图,开始比较玩家表现。" + }, + "noMapGroups": { + "title": "未选择地图组", + "description": "请在下方选择一个或多个地图组,开始比较玩家表现。" + }, + "noPlayer": { + "title": "未选择玩家", + "description": "请选择一名玩家,开始比较其跨地图表现。" + }, + "noData": { + "title": "无可用数据", + "description": "{player} 未参与所选地图。" + }, + "noTeamData": { + "description": "所选地图没有团队数据。" + } + }, + "detailedStats": { + "title": "详细统计", + "subtitle": "所有指标按每 10 分钟或百分比标准化,便于公平比较", + "exportCsv": "导出 CSV", + "statName": "统计", + "mapCount": "{count} 张地图", + "priority": "重点", + "note": "注意:所有统计都已标准化(每 10 分钟、百分比或平均值),以便公平比较不同游戏时长。", + "categories": { + "all": "全部统计", + "combat": "战斗", + "damage": "伤害", + "support": "辅助", + "defense": "防御", + "assists": "助攻", + "ultimate": "终极技能", + "impact": "影响", + "consistency": "稳定性" + }, + "stats": { + "eliminationsPer10": "每 10 分钟击杀", + "finalBlowsPer10": "每 10 分钟最后击杀", + "soloKillsPer10": "每 10 分钟单杀", + "deathsPer10": "每 10 分钟死亡", + "kdRatio": "K/D 比", + "allDamagePer10": "每 10 分钟总伤害", + "heroDamagePer10": "每 10 分钟英雄伤害", + "barrierDamagePer10": "每 10 分钟屏障伤害", + "healingDealtPer10": "每 10 分钟治疗量", + "healingReceivedPer10": "每 10 分钟受到治疗", + "selfHealingPer10": "每 10 分钟自我治疗", + "damageTakenPer10": "每 10 分钟承受伤害", + "damageBlockedPer10": "每 10 分钟阻挡伤害", + "offensiveAssistsPer10": "每 10 分钟进攻助攻", + "defensiveAssistsPer10": "每 10 分钟防守助攻", + "ultimatesEarnedPer10": "每 10 分钟获得终极技能", + "ultimatesUsedPer10": "每 10 分钟使用终极技能", + "averageUltChargeTime": "平均终极技能充能时间", + "averageTimeToUseUlt": "平均终极技能持有时间", + "killsPerUltimate": "每个终极技能击杀", + "averageDroughtTime": "平均击杀间隔", + "firstPickPercentage": "首杀 %", + "firstPicksPer10": "每 10 分钟首杀", + "firstDeathPercentage": "首死 %", + "firstDeathsPer10": "每 10 分钟首死", + "fletaDeadliftPercentage": "Fleta 扛队率 %", + "mvpScore": "MVP 分数", + "mapMvpRate": "地图 MVP 率", + "duelWinratePercentage": "对位胜率 %", + "fightReversalPercentage": "团战逆转率 %", + "eliminationsPer10StdDev": "击杀标准差", + "deathsPer10StdDev": "死亡标准差", + "allDamagePer10StdDev": "伤害标准差", + "consistencyScore": "稳定性分数" + } + }, + "impactMetrics": { + "subtitle": "{count, plural, =1 {已分析 1 张地图} other {已分析 # 张地图}}", + "consistency": "稳定性", + "keyStrengths": "主要优势", + "developmentAreas": "可改进方向", + "detailedMetrics": "详细影响指标", + "stats": { + "mvpScore": "MVP 分数", + "firstPickRate": "首杀率", + "killsPerUlt": "每个终极技能击杀", + "firstPicks": "首杀", + "firstDeaths": "首死", + "duelWinrate": "对位胜率", + "fightReversal": "团战逆转", + "fletaDeadlift": "团队影响", + "mapMvpRate": "地图 MVP 率" + } + }, + "views": { + "sideBySide": "并排比较", + "delta": "差异", + "trends": "趋势", + "charts": "图表", + "consistency": "稳定性", + "detailedStats": "详细统计", + "impactMetrics": "影响指标" + }, + "filters": { + "title": "比较筛选", + "resetAll": "全部重置", + "playerLabel": "玩家", + "heroesLabel": "英雄", + "player": "玩家", + "heroesCount": "{count, plural, =1 {1 个英雄} other {# 个英雄}}" + }, + "playerSelector": { + "placeholder": "选择玩家...", + "search": "搜索玩家...", + "noPlayerFound": "未找到玩家。", + "loading": "正在加载...", + "mapCount": "{count, plural, =1 {1 张地图} other {# 张地图}}" + }, + "sideBySide": { + "requiresTwoMaps": "并排比较需要恰好 2 张地图。", + "statComparison": "统计比较", + "stats": { + "eliminations": "击杀", + "deaths": "死亡", + "damage": "伤害", + "healing": "治疗", + "mitigated": "减伤", + "eliminationsPer10": "每 10 分钟击杀", + "deathsPer10": "每 10 分钟死亡", + "damagePer10": "每 10 分钟伤害", + "healingPer10": "每 10 分钟治疗", + "mitigatedPer10": "每 10 分钟减伤" + } + }, + "delta": { + "requiresTwoMaps": "差异视图需要恰好 2 张地图。", + "from": "从", + "to": "到", + "previous": "之前", + "significant": "显著", + "summary": "显著变化摘要", + "noSignificantChanges": "未检测到显著变化(阈值:±10%)", + "stats": { + "eliminations": "击杀", + "deaths": "死亡", + "damage": "伤害", + "healing": "治疗", + "mitigated": "减伤", + "eliminationsPer10": "每 10 分钟击杀", + "deathsPer10": "每 10 分钟死亡", + "damagePer10": "每 10 分钟伤害", + "healingPer10": "每 10 分钟治疗", + "mitigatedPer10": "每 10 分钟减伤" + } + }, + "consistency": { + "title": "表现稳定性", + "subtitle": "衡量不同地图和情境下表现是否可靠。方差越低,说明发挥越稳定、越可靠。", + "consistencyScore": "稳定性分数", + "highlyConsistent": "高度稳定", + "highlyConsistentDescription": "所有地图上的表现都非常稳定", + "consistent": "稳定", + "consistentDescription": "表现可靠,仅有轻微波动", + "moderatelyConsistent": "中等稳定", + "moderatelyConsistentDescription": "表现会随情境有所变化", + "variablePerformance": "表现波动", + "variablePerformanceDescription": "不同地图之间存在明显表现差异", + "meanStdDev": "平均值 ± 标准差", + "performanceAcrossMaps": "各地图表现", + "mapName": "地图 {number}", + "metricPer10": "每 10 分钟{metric}", + "understanding": { + "title": "理解稳定性指标", + "stdDev": "标准差:衡量表现分布的离散程度。数值越低,表现越稳定。", + "cv": "变异系数:标准化后的离散程度指标,可用于比较不同量级的指标。", + "score": "稳定性分数:整体可靠性指标(0-100)。分数越高,说明表现越稳定、越可靠。" + }, + "metrics": { + "eliminations": "击杀", + "deaths": "死亡", + "damage": "伤害", + "damageK": "伤害(千)", + "healing": "治疗", + "healingK": "治疗(千)", + "mean": "平均值", + "stdDev": "± 标准差", + "range": "范围", + "variation": "波动" + } + }, + "trends": { + "requiresThreePlus": "趋势视图需要 3 张或更多地图。", + "aggregateSummary": "汇总概览", + "totalMaps": "地图总数", + "avgElimsPer10": "平均击杀/10 分钟", + "avgDeathsPer10": "平均死亡/10 分钟", + "avgDamagePer10": "平均伤害/10 分钟", + "performanceProgression": "表现趋势", + "firstHalf": "前半段", + "secondHalf": "后半段", + "maps": "地图", + "improvement": "提升", + "improvementDesc": "后半段地图中的表现有所提升。", + "decline": "下降", + "declineDesc": "后半段地图中的表现有所下降。", + "stable": "稳定", + "stableDesc": "所有地图中的表现保持稳定。", + "bestPerformance": "最佳表现", + "needsImprovement": "需要提升", + "elimsPer10": "击杀/10 分钟", + "deathsPer10": "死亡/10 分钟", + "damagePer10": "伤害/10 分钟", + "perMapBreakdown": "逐地图拆解", + "elims": "击杀/10 分钟", + "deaths": "死亡/10 分钟", + "damage": "伤害/10 分钟" + }, + "charts": { + "performanceProgression": "表现趋势", + "statComparison": "统计比较", + "performanceProfile": "表现画像", + "firstPickPercentage": "首杀 %", + "firstDeathPercentage": "首死 %", + "killsPerUltimate": "每个终极技能击杀", + "duelWinrate": "对位胜率", + "soloKillsPer10": "每 10 分钟单杀", + "damageTakenPer10": "每 10 分钟承受伤害", + "healingReceivedPer10": "每 10 分钟受到治疗", + "fightReversalPercentage": "团战逆转率 %", + "per10": "每 10 分钟", + "avgUltImpact": "平均终极技能影响", + "oneVsOneSuccess": "1v1 成功率", + "individualKills": "个人击杀", + "damageReceived": "受到伤害", + "supportReceived": "受到支援", + "clutchPlays": "关键表现", + "totalEliminations": "总击杀", + "totalDeaths": "总死亡", + "totalDamage": "总伤害", + "avgPer10": "平均/10 分钟", + "eliminations": "击杀", + "deaths": "死亡", + "damage": "伤害(千)", + "healing": "治疗(千)", + "mitigated": "减伤(千)", + "healingPer10K": "治疗/10 分钟(千)", + "blockedPer10K": "阻挡/10 分钟(千)", + "elimsPer10": "击杀/10 分钟", + "deathsPer10": "死亡/10 分钟", + "damagePer10K": "伤害/10 分钟(千)", + "elimsPer10Short": "击杀", + "deathsPer10Short": "死亡", + "damagePer10Short": "伤害", + "healingPer10Short": "治疗", + "mitigatedPer10Short": "减伤", + "averagePerformance": "平均表现", + "errorRange": "范围", + "stdDev": "标准差" + }, + "comparisonMode": { + "label": "比较模式", + "player": "玩家", + "team": "队伍", + "playerDescription": "比较单个玩家在多张地图中的表现", + "teamDescription": "比较我方队伍与敌方队伍在多张地图中的表现" + }, + "mapSelectionMode": { + "label": "地图选择", + "individual": "单张地图", + "groups": "地图组", + "individualDescription": "使用单独选择的地图进行比较", + "groupsDescription": "使用预设地图组进行比较(例如:团战地图、开放地图)", + "selectGroups": "选择地图组", + "selectGroupsPlaceholder": "选择要比较的地图组...", + "manageGroups": "管理地图组", + "groupsHint": "你可以在地图组页面创建和管理地图组", + "individualMapsSelected": "{count, plural, =1 {从训练赛页面选择了 1 张地图} other {从训练赛页面选择了 # 张地图}}" + }, + "mapGroupSelector": { + "title": "选择要比较的地图组", + "description": "最多选择 2 个地图组来比较表现。你可以比较不同地图类型、打法或时间段。", + "noGroups": { + "title": "没有可用地图组", + "description": "创建地图组来整理并比较队伍表现", + "createButton": "创建地图组" + }, + "hint": { + "selectGroups": "至少选择一个地图组以继续", + "selectOneMore": "可再选择一个地图组进行比较(可选)", + "readyToCompare": "已准备好比较所选地图组" + }, + "compareButton": "比较" + }, + "mapGroupManager": { + "title": "地图组", + "description": "创建自定义地图组来整理并比较表现", + "newGroup": "新建组", + "createDialog": { + "title": "创建地图组", + "description": "将地图组合在一起,以分析不同情境下的表现" + }, + "editDialog": { + "title": "编辑地图组", + "description": "更新地图组详情和地图选择" + }, + "deleteDialog": { + "title": "删除地图组", + "description": "确定要删除“{name}”吗?此操作无法撤销。", + "cancel": "取消", + "delete": "删除" + }, + "empty": { + "title": "还没有地图组", + "description": "创建第一个地图组,以整理地图并比较不同情境下的表现", + "create": "创建地图组" + }, + "actions": { + "label": "组操作", + "edit": "编辑", + "delete": "删除" + }, + "card": { + "maps": "地图", + "createdBy": "由 {name} 创建" + }, + "form": { + "name": "组名称", + "namePlaceholder": "例如:团战地图、开放地图", + "description": "描述", + "descriptionPlaceholder": "此组的可选描述", + "category": "类别", + "categoryPlaceholder": "例如:打法、地图类型", + "selectMaps": "选择地图", + "noMapsAvailable": "没有可用地图", + "selectedMaps": "{count, plural, one {已选择 # 张地图} other {已选择 # 张地图}}", + "create": "创建组", + "update": "更新组" + }, + "toast": { + "created": "地图组已创建", + "createdDescription": "地图组已成功创建。", + "updated": "地图组已更新", + "updatedDescription": "地图组已成功更新。", + "deleted": "地图组已删除", + "deletedDescription": "地图组已成功删除。", + "error": "错误" + }, + "errors": { + "fetchFailed": "获取地图组失败", + "createFailed": "创建地图组失败", + "updateFailed": "更新地图组失败", + "deleteFailed": "删除地图组失败" + } + }, + "teamComparison": { + "myTeam": "我方队伍", + "enemyTeam": "敌方队伍", + "players": "{count, plural, =1 {1 名玩家} other {# 名玩家}}", + "comparingMaps": "{count, plural, =1 {正在比较 1 张地图} other {正在比较 # 张地图}}", + "categories": { + "combat": "战斗表现", + "damage": "伤害与生存", + "support": "支援与防御", + "ultimate": "终极技能经济" + }, + "stats": { + "eliminationsPer10": "每 10 分钟击杀", + "finalBlowsPer10": "每 10 分钟最后击杀", + "deathsPer10": "每 10 分钟死亡", + "firstPickPercentage": "首杀 %", + "firstDeathPercentage": "首死 %", + "heroDamagePer10": "每 10 分钟英雄伤害", + "damageTakenPer10": "每 10 分钟承受伤害", + "healingDealtPer10": "每 10 分钟治疗量", + "damageBlockedPer10": "每 10 分钟阻挡伤害", + "ultimatesEarnedPer10": "每 10 分钟获得终极技能", + "averageUltChargeTime": "平均终极技能充能时间", + "killsPerUltimate": "每个终极技能击杀" } } }, @@ -666,6 +2183,11 @@ }, "back": "返回比赛概览", "dashboard": "仪表板", + "tabsLabel": "地图分区", + "mobileBanner": { + "message": "为了获得最佳体验,建议使用更大的屏幕。", + "dismiss": "关闭" + }, "playerSwitcher": { "select": "选择玩家", "search": "搜索玩家...", @@ -679,7 +2201,96 @@ "dashboard": "仪表板", "overview": "概览", "analytics": "分析", - "charts": "图表" + "charts": "图表", + "telemetry": "遥测" + }, + "telemetry": { + "noData": { + "title": "此地图没有遥测数据", + "description": "此地图在记录逐事件计时数据之前上传。图表标签页仍提供逐回合统计。" + }, + "stats": { + "damageDealt": "造成伤害", + "damageTaken": "承受伤害", + "healingDealt": "治疗量", + "eliminations": "击杀", + "deaths": "死亡" + }, + "trace": { + "title": "活动遥测", + "description": "整张地图的伤害、治疗与事件。悬停任意位置查看该时刻。" + }, + "ariaChart": "{seconds} 秒内的玩家活动遥测", + "lanes": { + "output": "输出伤害", + "support": "治疗 / 伤害", + "survival": "承受 / 治疗", + "events": "事件", + "target": "按目标分类的伤害" + }, + "roles": { + "tank": "坦克", + "damage": "输出", + "support": "辅助" + }, + "channels": { + "damageDealt": "造成伤害", + "damageTaken": "承受伤害", + "healingDealt": "治疗量", + "healingReceived": "受到治疗" + }, + "markers": { + "ult": "终极技能", + "kill": "击杀", + "death": "死亡", + "ability": "技能", + "ability1": "技能 1", + "ability2": "技能 2", + "swap": "英雄切换" + }, + "matchups": { + "title": "目标与对位", + "description": "该玩家在整张地图中对谁造成伤害并交火。", + "byTarget": { + "title": "按角色分类的伤害", + "empty": "此地图未造成伤害。" + }, + "takenByRole": { + "title": "按来源角色分类的承受伤害", + "empty": "此地图未承受伤害。" + }, + "killContribution": { + "title": "击杀贡献", + "focus": "集火贡献", + "participation": "参与率", + "killsLabel": "团队击杀", + "empty": "没有可追踪伤害的团队击杀。" + }, + "radar": { + "title": "每个对手的伤害交换", + "dealt": "造成", + "received": "受到", + "empty": "此地图没有对手伤害记录。" + } + }, + "heatmap": { + "title": "位置热力图", + "description": "该玩家交战、死亡和使用技能的位置。拖动平移,滚动缩放。", + "heatGroup": "热力", + "loading": "正在加载地图图像……", + "noCalibration": "此地图尚未校准,无法在地图图像上绘制位置。", + "noCoordinates": "未记录该玩家在此地图上的位置数据。", + "subMapsLabel": "子地图", + "abilityFilter": "筛选技能", + "layers": { + "damageDealt": "造成伤害", + "damageTaken": "承受伤害", + "healingDealt": "治疗", + "kills": "击杀", + "deaths": "死亡", + "abilities": "技能" + } + } }, "overview": { "matchTime": "总比赛时间", @@ -732,6 +2343,8 @@ "noResult": "无结果。" }, "analytics": { + "rhythm": "节奏", + "roundTrends": "回合趋势", "avgUltChargeTime": { "title": "平均终极充能时间", "footer": "完成一个终极技能充能所需的平均时间。时间越短越好,时间过长可能表明伤害或治疗量不足。" @@ -752,7 +2365,9 @@ "title": "与其他玩家的比较", "description": "{playerName} 对敌方队伍玩家的得分和胜率。", "score": "得分:", - "winrate": "胜率: {winrate}%" + "winrate": "胜率: {winrate}%", + "winrateLabel": "胜率", + "empty": "该玩家暂无对位数据。" }, "playerKillfeed": { "title": "玩家击杀记录", @@ -773,6 +2388,18 @@ "round": "回合 {round}", "dmgDone": "造成伤害", "dmgTaken": "承受伤害" + }, + "mvpScore": { + "title": "MVP 分数", + "footer": "MVP 分数会将玩家统计与其他在训练赛中使用同一英雄的玩家平均值进行比较。悬停此卡片可查看计算方式。", + "totalScore": "总分:{score}", + "pointsUnit": "分", + "heroIconAlt": "{hero} 图标", + "per10Value": "每 10 分钟 {per10Value} · 平均 {heroAverage}", + "zScore": "{score}σ", + "percentile": "第 {percentile} 百分位", + "noData": "暂无可用数据", + "explanation": "MVP 分数可用于了解玩家相对同英雄相似玩家的表现。该指标不会比较玩家与队友之间的表现,而是显示其相对该英雄全局平均值是否突出。MVP 分数通常在 -100 到 +100 之间,并以 0 为中心分布。" } }, "charts": { @@ -812,12 +2439,94 @@ "charts": "图表", "events": "事件", "compare": "对比", + "notes": "笔记", + "vod": "VOD", "heatmap": "热力图", - "replay": "回放" + "replay": "回放", + "routes": "路线", + "story": "比赛故事", + "initiation": "团战发起" + }, + "routes": { + "calibrationWarning": "该地图的坐标校准无效——大多数记录位置落在地图图像之外,无法正确绘制路线。请在校准工具中使用至少 4 个锚点重新校准。", + "empty": "此地图没有位置数据。", + "noCalibration": "没有可用的地图校准数据。路线需要已校准的正射地图图片。", + "showAll": "显示所有路线", + "routeFallback": "路线 {n}", + "loadingImage": "正在加载地图图片...", + "canvasLabel": "绘制在地图图片上的玩家路线。", + "zoomHint": "{zoom, number}% · 滚动缩放 · 拖动平移", + "filters": { + "team": "队伍", + "player": "玩家", + "round": "回合", + "outcome": "结果", + "kind": "类型", + "cluster": "聚类", + "all": "全部" + }, + "outcomes": { + "won": "获胜", + "lost": "失败", + "unknown": "未知" + }, + "kinds": { + "initial": "初始", + "respawn": "重生" + }, + "clusters": { + "header": "路线聚类", + "routes": "{count} 条路线", + "outcomes": "{won} 胜 / {lost} 负 / {unknown} 未知" + } }, "replay": { + "ghost": { + "title": "幽灵回放", + "source": "幽灵来源", + "alignMode": "对齐方式", + "sourceRound": "幽灵回合", + "otherScrim": "来自其他训练赛", + "primaryRound": "对齐到回合", + "alignRoundStart": "回合开始", + "alignFirstContact": "首次交战", + "nudge": "偏移(秒)", + "playerFilter": "幽灵玩家", + "allPlayers": "所有玩家", + "clear": "清除幽灵", + "noData": "所选来源没有站位数据。", + "loadError": "幽灵数据加载失败。", + "arenaMismatch": "本回合幽灵位于不同的子地图。", + "loading": "正在加载幽灵…" + }, "noCalibration": "没有可用的地图校准数据。回放查看器需要已校准的正射地图图片。", - "noCoordinates": "此地图没有坐标数据。" + "noCoordinates": "此地图没有坐标数据。", + "noCalibrationForRange": "此时间范围没有可用的地图校准数据。", + "viewerLabel": "回放查看器", + "timeline": { + "roundShort": "R{number}", + "scrubber": "回放拖动条", + "skipBack": "后退 {seconds, number} 秒", + "skipForward": "前进 {seconds, number} 秒", + "play": "播放", + "pause": "暂停", + "playbackSpeed": "播放速度", + "keyboardHint": "Space:播放/暂停 · 方向键:跳转 5 秒(Shift:1 秒)· [ / ]:速度" + }, + "eventFeed": { + "events": "事件", + "noEventsNearby": "附近没有事件", + "teamLabel": "{team}队:", + "ultActivated": "终极技能已启动", + "ultEnded": "终极技能已结束", + "roundStart": "第 {round, number} 回合开始", + "roundEnd": "第 {round, number} 回合结束" + }, + "map": { + "mapAlt": "地图", + "loadingImage": "正在加载地图图片...", + "zoomHint": "{zoom, number}% · 滚动缩放 · 拖动平移" + } }, "heatmap": { "noCalibration": "没有可用的地图校准数据。热力图需要已校准的正射地图图片。", @@ -826,20 +2535,104 @@ "damage": "伤害", "healing": "治疗", "kills": "击杀" + }, + "canvas": { + "canvasLabel": "地图图片上的团战热力图叠层。拖动平移,滚动缩放。使用加号和减号缩放,方向键平移,0 键重置。", + "total": "共 {count, number} 个", + "killEvents": "{count, number} 个击杀事件", + "killEvent": "{attackerName}({attackerHero})在 {time} 击杀了 {victimName}({victimHero})", + "loadingImage": "正在加载地图图片...", + "zoomHint": "{zoom, number}% · 滚动缩放 · 拖动平移", + "primaryFire": "主要攻击" } }, - "overview": { - "matchTime": "总比赛时间", - "minutes": "{time} 分钟", - "score": "比分", - "winner": "获胜方:", - "pushLimitations": "由于训练赛代码的限制,暂不支持推车模式。:(", - "heroDamageDealt": "英雄造成的伤害", + "fightUltQuality": { + "noData": "此地图没有位置数据。", + "ults": { + "heading": "终极技能", + "time": "时间", + "player": "选手", + "hero": "英雄", + "zone": "区域", + "displacement": "位移", + "conversionKills": "转化击杀", + "diedDuringUlt": "阵亡", + "died": "阵亡", + "meters": "{value}米", + "unpaired": "缺少结束事件" + }, + "engagements": { + "heading": "团战", + "even": "平局" + }, + "zones": { + "title": "区域", + "empty": "此地图尚未发布任何区域。", + "zone": "区域", + "team": "队伍", + "kills": "击杀", + "deaths": "阵亡", + "ults": "终极技能" + } + }, + "vod": { + "eyebrow": "录像复盘", + "title": "VOD", + "description": "关联 YouTube 或 Twitch 录像,以便结合数据复盘此地图。", + "addVod": "添加 VOD", + "changeVod": "更改 VOD", + "uploadVod": "上传 VOD", + "cancel": "取消", + "inputPlaceholder": "在此输入 VOD URL...", + "uploadVOD": "上传您的 VOD", + "noVod": "尚未关联 VOD。添加一个即可开始复盘。", + "vodSuccess": "VOD 已成功关联。", + "vodFail": "关联 VOD 失败", + "invalidUrl": "URL 必须来自 YouTube 或 Twitch", + "vodLabel": "请输入 VOD URL(YouTube 或 Twitch)。", + "vodRequired": "需要 VOD URL。" + }, + "tiptap": { + "eyebrow": "地图笔记本", + "placeholder": "记录此地图的观察和后续事项。点击保存后笔记会被保存。", + "notes": "笔记", + "save": "保存", + "saving": "正在保存...", + "unsavedChanges": "未保存的更改", + "toolbar": { + "label": "笔记格式", + "undo": "撤销", + "redo": "重做", + "h1": "标题 1", + "h2": "标题 2", + "h3": "标题 3", + "bold": "加粗", + "italic": "斜体", + "strike": "删除线", + "codeBlock": "代码块", + "alignLeft": "左对齐", + "alignCenter": "居中对齐", + "alignRight": "右对齐", + "list": "项目符号列表", + "orderedList": "编号列表", + "highlight": "高亮" + } + }, + "overview": { + "matchTime": "总比赛时间", + "minutes": "{time} 分钟", + "score": "比分", + "winner": "获胜方:", + "pushLimitations": "由于训练赛代码的限制,暂不支持推车模式。:(", + "heroDamageDealt": "英雄造成的伤害", "dealtMore": "{teamName} 在这张地图上造成了更多的英雄伤害。", "teamHealingDealt": "团队治疗量", "healedMore": "{teamName} 在这张地图上提供了更多的治疗。", "title": "概述", "round": "回合 {number}", + "team1": "队伍 1", + "team2": "队伍 2", + "notAvailable": "不可用", "analysis": { "title": "分析", "tabFirstDeaths": "首杀", @@ -880,7 +2673,118 @@ "ultNeedsDiscipline": "有待改进——失败时使用更多终极技能。", "ultWasted": "{count} 个浪费的终极技能(占总数 {percentage}%)——在劣势情况下(3名以上劣势)使用。", "ultDryFights": "{dryCount} 场无终极技能战斗,胜率 {winrate}%。另有 {nonDryCount} 场战斗使用了终极技能(胜率 {nonDryWinrate}%)。", - "ultReversalRate": "战斗逆转率(落后2人以上后获胜):无终极技能时 {dryRate}% vs 使用终极技能时 {nonDryRate}%。" + "ultReversalRate": "战斗逆转率(落后2人以上后获胜):无终极技能时 {dryRate}% vs 使用终极技能时 {nonDryRate}%。", + "swapOverview": "{team1Name} 进行了 {team1Count} 次英雄更换,{team2Name} 进行了 {team2Count} 次。", + "swapTopPairTeam": "{teamName} 最常见的更换:{fromHero}{toHero}{count} 次)。", + "swapTopSwapperTeam": "{teamName} 最活跃的更换者:{playerName},共 {count} 次更换。", + "tabAbilityTiming": "技能时机", + "footerAbilityTiming": "胜率按战斗阶段(战前、前期、中期、后期、收尾)计算。每个单元格至少需要 3 场战斗数据;当某阶段胜率与该技能最佳阶段存在明显差异时,会高亮异常项。", + "collapseAll": "全部折叠", + "expandAll": "全部展开", + "mobileLabel": { + "deaths": "阵亡", + "ults": "终极", + "timing": "时机", + "efficiency": "效率", + "swaps": "更换", + "rotation": "轮转", + "abilities": "技能" + }, + "deaths": { + "headToHeadLabel": "首次阵亡率", + "headToHeadUnit": "首次阵亡", + "topPlayerCallout": "首次阵亡最多的玩家:{playerName} ({total} 场战斗中 {count} 次)", + "ajaxCallout": "{playerName} 使用卢西奥并出现了 {count} 次 Ajax。", + "fightStripHeading": "逐战首次阵亡", + "fightCount": "{count} 场战斗", + "team1DiedFirst": "{teamName} 率先阵亡", + "team2DiedFirst": "{teamName} 率先阵亡", + "momentumHeading": "首次阵亡走势", + "team1DyingMore": "{teamName} 阵亡更多", + "team2DyingMore": "{teamName} 阵亡更多", + "streakCallout": "最长首次阵亡连续纪录:{teamName} 连续 {count} 场战斗率先阵亡", + "fightAriaLabel": "战斗 {fight}:{playerName}({hero})为 {teamName} 率先阵亡", + "fightTooltipFight": "战斗 {fight}", + "fightTooltipFirstDeath": "{teamName} 首次阵亡:{playerName}({hero})" + }, + "ultimates": { + "headToHeadLabel": "终极技能使用总数", + "headToHeadUnit": "终极技能", + "roleHeadToHeadLabel": "{role}:率先使用终极技能", + "roleHeadToHeadUnit": "战斗", + "topUltUserLabel": "最多终极技能使用者:", + "topFightOpenerLabel": "最多战斗开场:", + "fightCount": "{count} 场战斗", + "subroleChartHeading": "按副职责统计终极技能使用" + }, + "timing": { + "empty": "这张地图没有时机数据。", + "comparisonCallout": "{team1Name}{team1Pct}% 的终极技能用于开战,{team2Name}{team2Pct}%。", + "breakdownHeading": "终极技能时机细分" + }, + "efficiency": { + "empty": "这张地图没有效率数据。", + "avgChargeTimeLabel": "平均终极技能充能时间", + "avgHoldTimeLabel": "平均终极技能持有时间", + "scorecard": { + "wonFights": "获胜战斗", + "lostFights": "失败战斗", + "wasted": "浪费", + "dryFights": "无终极战斗", + "winRate": "胜率", + "reversal": "逆转", + "ultsPerFight": "每场 {count} 个终极技能", + "excellent": "优秀", + "good": "良好", + "average": "一般", + "poor": "较差" + } + }, + "swaps": { + "empty": "这张地图没有记录到英雄更换。", + "headToHeadLabel": "英雄更换总数", + "headToHeadUnit": "更换", + "tableTopSwap": "最多更换", + "tableMostActive": "最活跃", + "tableEmpty": "-", + "topPair": "{from} → {to}({count}次)", + "topSwapper": "{name}({count}次更换)" + }, + "rotationDeaths": { + "empty": "这张地图没有可用于轮转阵亡分析的坐标数据。", + "noEvents": "这张地图未检测到轮转阵亡。", + "headToHeadLabel": "轮转阵亡", + "headToHeadUnit": "阵亡", + "topPlayerCallout": "轮转中最常被抓的玩家:{playerName} ({totalDeaths} 次阵亡中 {rotationCount} 次,{rate}%)", + "topKillerCallout": "轮转抓人最多的英雄:{hero} ({count} 次击杀)", + "tablePlayer": "玩家", + "tableRotationDeaths": "轮转阵亡", + "tableTotalDeaths": "总阵亡", + "tableRate": "比例", + "eventsHeading": "轮转阵亡事件({count})" + }, + "abilityTiming": { + "phase": { + "preFight": "战前", + "early": "前期", + "mid": "中期", + "late": "后期", + "cleanup": "收尾" + }, + "noTeamData": "{teamName} 没有高影响技能数据。", + "negativeOutlier": "{abilityName} 在{phase}使用时胜率为 {phaseWinrate},低于在{bestPhase}使用时的 {bestPhaseWinrate}。", + "positiveOutlier": "{abilityName} 在{phase}使用与 {phaseWinrate} 胜率相关:呈现明显的{pattern}模式。", + "patternInitiation": "开战", + "patternTiming": "时机", + "cellTitle": "{winrate} 胜率({fights} 场战斗,{wins}胜 {losses}负)", + "fewerThanFights": "少于 {count} 场战斗", + "empty": "这张地图未检测到高影响技能。", + "legendWinRate": "胜率:", + "legendBadTiming": "不佳时机", + "legendGoodTiming": "良好时机", + "hoverDetail": "{abilityName} 在{phase}使用:{winrate} 胜率,覆盖 {fights} 场战斗({wins}胜 {losses}负)", + "info": "胜率按各队视角计算。短横线表示少于 {count} 场战斗。悬停可查看详情。" + } } }, "overviewTable": { @@ -931,6 +2835,31 @@ "dmgToHealsRatio": "玩家的伤害与接受治疗的比例。", "ultsCharged": "玩家累计充能的终极技能次数。", "ultsUsed": "玩家使用的终极技能次数。" + }, + "mvpBreakdown": { + "mvpTitle": "{teamName} MVP", + "totalScore": "总分:{score}", + "topContributions": "主要贡献:", + "points": "{sign}{points} 分", + "per10VsAverage": "每 10 分钟 {value} / 平均 {average}", + "percentile": "第 {percentile} 百分位", + "moreStats": "+ 另有 {count, plural, other {# 项}}统计已计算", + "stats": { + "eliminations": "消灭", + "final_blows": "最后一击", + "deaths": "死亡", + "hero_damage_dealt": "英雄伤害", + "healing_dealt": "治疗量", + "healing_received": "接受治疗", + "damage_blocked": "格挡伤害", + "damage_taken": "承受伤害", + "solo_kills": "单杀", + "ultimates_earned": "终极技能获取", + "ultimates_used": "终极技能使用", + "objective_kills": "目标击杀", + "offensive_assists": "进攻助攻", + "defensive_assists": "防守助攻" + } } }, "killfeed": { @@ -939,7 +2868,9 @@ "kills": "击杀", "deaths": "死亡人数", "fightWins": "战斗胜利", - "title": "击杀记录" + "title": "击杀记录", + "team1": "队伍 1", + "team2": "队伍 2" }, "killfeedTable": { "limitTest": "极限测试", @@ -951,6 +2882,7 @@ "method": "方式", "start": "开始", "end": "结束", + "viewInReplay": "在回放中查看", "fightWinner": "战斗胜者", "abilities": { "primary-fire": "主武器", @@ -968,6 +2900,10 @@ "killfeedControls": { "title": "显示选项", "ariaLabel": "击杀记录显示设置", + "viewMode": "查看模式", + "timelineView": "事件时间线", + "timelineViewDescription": "按时间比例显示所有地图事件", + "ultSection": "终极技能追踪", "ultBrackets": "终极技能持续括号", "ultLabels": "终极技能标签", "ultStartEvents": "终极技能开始事件", @@ -980,15 +2916,29 @@ "ultEndedDeath": "{player} 死亡,终极技能结束 ({duration}秒)", "ultDuration": "持续时间: {duration}秒", "ultTime": "{start} — {end}", - "ultKills": "终极技能期间 {count} 次击杀", - "ultDeaths": "终极技能期间 {count} 次死亡", + "ultKills": "{count, plural, other {终极技能期间 # 次击杀}}", + "ultDeaths": "{count, plural, other {终极技能期间 # 次死亡}}", "diedDuringUlt": "{player} 在终极技能期间死亡", - "ultLabel": "{player} ({hero}) 使用了终极技能 {duration}秒,{kills} 次击杀", + "ultLabel": "{player} ({hero}) 使用了终极技能 {duration}秒,{kills, plural, other {# 次击杀}}", "instantUlt": "{player} ({hero}) 使用了瞬间终极技能", "instantUltAt": "在 {time} 使用了瞬间终极技能", - "ultInstant": "{player} 的瞬间终极技能 ({duration}秒)" + "ultInstant": "{player} 的瞬间终极技能 ({duration}秒)", + "tooltipFightStart": "战斗 {num} 开始", + "tooltipFightEnd": "战斗 {num} 结束", + "tooltipTimestamp": "时间:{time}", + "tooltipDuration": "持续时间:{duration}秒", + "tooltipWinner": "获胜方:{team}", + "tooltipTotalKills": "{count, plural, other {共 # 次击杀}}", + "tooltipKill": "{attacker} → {victim}", + "tooltipMethod": "方式:{method}", + "tooltipTeam": "队伍:{team}", + "tooltipUltActivated": "{player} 使用了 {hero} 终极技能", + "tooltipUltEnded": "{player} 的 {hero} 终极技能已结束", + "tooltipUltInstant": "{player} 使用了瞬间 {hero} 终极技能", + "tooltipDiedDuringUlt": "在终极技能期间死亡" }, "charts": { + "title": "图表", "tooltip": "想了解更多关于图表的信息吗?查看文档 了解更多内容。", "team1": "队伍 1", "team2": "队伍 2", @@ -998,16 +2948,39 @@ "support": "支援", "round": "回合 {round}", "killsByFight": { + "eyebrow": "战斗节奏", "title": "按战斗的击杀数", "description": "击杀数按 15 秒间隔分组。此图表显示了每支队伍在每个时间间隔内的累计击杀数。x 轴代表以秒为单位的时间,y 轴代表累计击杀数。队伍 1 用正数表示,队伍 2 用负数表示。每次战斗结束后,图表都会重置为 0。" }, "finalBlowsByRole": { + "eyebrow": "角色分布", "title": "按角色的最后击杀", "description": "此图表显示了每个队伍按角色的最后击杀次数。角色分为坦克、输出和支援。x 轴代表角色,y 轴代表最后击杀的次数。" }, "dmgByRound": { + "eyebrow": "回合伤害", "title": "每回合累计英雄伤害", "description": "此图表显示了每支队伍在各回合累计的英雄伤害。x 轴代表回合,y 轴代表造成的伤害。请注意,伤害是累计的,因此第二回合的伤害包括第一回合的伤害。" + }, + "tempo": { + "eyebrow": "比赛流向", + "title": "比赛节奏", + "description": "以叠加曲线呈现击杀和终极技能。拖动图表下方的滑块以聚焦某场战斗或阶段。" + }, + "ultAdvantage": { + "eyebrow": "终极技能", + "title": "终极技能优势", + "description": "进入每场团战时哪支队伍持有更多终极技能。零线以上的柱形表示 {team} 占优。", + "noData": "本地图未记录终极技能充能数据。", + "teamAhead": "{team} 占优", + "teamAheadBy": "{team} +{n}", + "teamAheadByMore": "{team} +{n} 及以上", + "even": "持平", + "breakdownCaption": "按终极技能优势划分的团战", + "fight": "第 {n} 场", + "fightResult": "{team} 赢得团战", + "fightsCount": "{count, plural, other {# 场团战}}", + "noFights": "无团战" } }, "events": { @@ -1015,21 +2988,56 @@ "roundEnd": "第 {event} 回合结束", "matchEnd": "比赛结束", "matchStart": "比赛开始", + "noData": "此地图未记录任何事件。", + "round": "第 {number} 回合", "mapEvents": { - "title": "赛事事件", - "description": "比赛期间发生的事件,包括目标点占领、回合开始和结束、多重击杀等。", - "captureString1": "{team} 占领了目标点。", - "captureString2": "{team} 占领了目标。", - "objectiveUpdate": "目标点更新", - "swap": " {player} 切换为 {hero}。", - "ultKills": " {player} 使用终极技能击杀了 {players, plural, =1 {一名玩家} other {# 名玩家}}。", - "multikill": "在第 {event} 场战斗中,{player} 完成了一次多杀,击杀了 {kills} 名玩家。", - "ajax": "{player} 在第 {fight} 场战斗中触发了亚克斯现象。" - }, - "ultsUsed": { - "title": "使用的终极技能", - "description": "列出了比赛中使用的所有终极技能及其使用时间。", - "ultStart": "{player} 在第 {fight} 场战斗中使用了他们的终极技能。" + "title": "赛事事件" + }, + "totals": { + "rounds": "回合", + "fights": "战斗", + "multikills": "多杀", + "ultimates": "使用的终极技能", + "swaps": "英雄切换" + }, + "filters": { + "label": "筛选事件", + "all": "全部", + "highlights": "亮点", + "ultimates": "终极技能", + "fights": "团战", + "swaps": "切换", + "objectives": "目标", + "empty": "没有事件匹配该筛选条件。" + }, + "type": { + "matchStart": "比赛", + "matchEnd": "比赛", + "roundStart": "回合", + "roundEnd": "回合结束", + "objective": "目标", + "engagement": "团战", + "swap": "切换", + "ult": "终极", + "ultKill": "终极击杀", + "multikill": "多杀", + "ajax": "亚克斯" + }, + "detail": { + "matchStart": "比赛已开始。", + "matchEnd": "比赛已结束。", + "roundStart": "第 {number} 回合已开始。", + "roundEnd": "第 {number} 回合已结束。", + "capture": "{team} 占领了目标。", + "pointTake": "{team} 占领了目标点。", + "fightTag": "战斗 {number}", + "ultKill": "{player} 在终极技能期间击杀了 {count, plural, =1 {一名玩家} other {# 名玩家}}。", + "multikill": "{player} 在第 {fight} 场战斗中完成 {count, plural, =3 {三} =4 {四} =5 {五} other {# 连}} 杀。", + "ajax": "{player} 在第 {fight} 场战斗中触发了亚克斯现象。", + "engagementWon": "获胜", + "engagementEven": "平局", + "ultConversion": "{count} 转化", + "ultDied": "阵亡" }, "tempo": { "title": "比赛节奏", @@ -1041,6 +3049,11 @@ "snapToFights": "对齐战斗", "fightLabel": "战斗 {number}", "allFights": "所有战斗", + "selectionStart": "选择起点", + "selectionEnd": "选择终点", + "previousFight": "上一场战斗", + "nextFight": "下一场战斗", + "even": "持平", "noData": "数据不足,无法显示节奏。" } }, @@ -1049,6 +3062,19 @@ "team2": "队伍 2", "playerCard": { "title": "玩家统计", + "unplaced": "未定级", + "sr": "SR", + "maxScore": "最高分!", + "ranks": { + "bronze": "青铜", + "silver": "白银", + "gold": "黄金", + "platinum": "铂金", + "diamond": "钻石", + "master": "大师", + "grandmaster": "宗师", + "champion": "冠军" + }, "allHeroes": { "title": "所有英雄", "timePlayed": "游戏时间", @@ -1119,9 +3145,882 @@ "healingDealtPer10Min": "每 10 分钟提供 {num} 点治疗", "healingReceived": "受到的治疗", "healingReceivedNum": "受到了 {num} 点治疗", - "healingReceivedPer10Min": "每 10 分钟受到 {num} 点治疗" + "healingReceivedPer10Min": "每 10 分钟受到 {num} 点治疗", + "ratingTooltip": "英雄 SR 根据地图表现计算。若要查看整体英雄 SR,请在地图概览页的统计表中悬停玩家名称。", + "unratedTooltip": "至少游玩 60 秒才会进入排名", + "unplaced": "未定级" + }, + "percentile": "第 {percentile} 百分位", + "percentileExplanation": "{stat} 表现位于 {hero} 玩家前 {percent}%", + "vsAverage": "对比平均 {average}/10分钟" + } + }, + "heroBans": { + "bannedBy": "{hero} 被 {team} 禁用" + }, + "matchStory": { + "title": "比赛故事", + "description": "从 {team1} 视角呈现的逐团战胜率变化。", + "limited": "该日志缺少终极技能事件。胜率曲线不含大招经济,连锁洞察已禁用。", + "noModel": "该游戏模式的全局数据还不足。", + "chart": { + "winProbability": "胜率", + "round": "第 {round} 回合", + "time": "时间", + "score": "比分", + "objective": "目标", + "capture": "{team} 占领了目标", + "captureProgress": "{team} 占领点位。{t1}:{p1}%,{t2}:{p2}%" + }, + "ledger": { + "title": "团战记录", + "fight": "团战", + "time": "时间", + "zone": "区域", + "result": "结果", + "swing": "波动", + "ults": "大招", + "carryover": "延续", + "won": "胜", + "lost": "负", + "even": "平", + "ultEconomy": "大招经济", + "stagger": "梯次阵亡", + "context": "背景", + "driverObjective": "目标", + "driverKills": "击杀", + "driverUlts": "大招" + }, + "wpa": { + "title": "胜率贡献值", + "subtitle": "按约定分配——由最后一击、助攻、阵亡和大招使用来拆分每场团战的波动。展开行可查看明细。", + "player": "选手", + "team": "队伍", + "total": "WPA", + "fightShare": "第 {fight} 场团战:{share}%" + }, + "insights": { + "title": "关键时刻", + "biggestSwing": "第 {fight} 场团战使胜率向 {team} 方向移动了 {swing}%。", + "ultCarryover": "第 {prevFight} 场团战的大招经济使 {team} 进入第 {fight} 场团战时损失约 {cost}% 胜率。", + "staggerCarryover": "第 {fight} 场团战前的梯次阵亡使 {team} 损失约 {cost}% 胜率。", + "earlyControl": "{team} 掌控开局,赢下前 {of} 场团战中的 {wins} 场。", + "longStretch": "{team} 保持优势达 {duration},胜率最高达 {pct}%。", + "winStreak": "{team} 从 {from} 到 {to} 取得 {count} 场团战连胜。", + "closing": "{team} 拿下最后 {of} 场团战中的 {wins} 场,终结了比赛。", + "topWpa": "{player}({team})带来全场最大的胜率变化:{wpa}%。", + "biggestSwingObjective": "第 {fight} 场团战使胜率向 {team} 方向移动了 {swing}%——主要来自由此带来的目标进度和比分。", + "biggestSwingKills": "第 {fight} 场团战使胜率向 {team} 方向移动了 {swing}%——主要来自由此形成的人数优势。", + "biggestSwingUlts": "第 {fight} 场团战使胜率向 {team} 方向移动了 {swing}%——主要来自由此形成的大招经济优势。" + }, + "story": { + "title": "比赛走势" + }, + "takeaways": { + "title": "{team} 的改进方向", + "lossProfileObjective": "在 {count} 场失利团战中,你方损失的胜率有 {pct}% 来自目标——团战失利转化为占领和比分。规划战后重整以阻止占领,而不只是再打一波。", + "lossProfileKills": "在 {count} 场失利团战中,你方损失的胜率有 {pct}% 来自人数劣势。问题出在团战本身——优先复盘站位和集火目标。", + "lossProfileUlts": "在 {count} 场失利团战中,你方损失的胜率有 {pct}% 来自大招经济。关注敌方大招储备,不要在对方大招充能完毕时开团。", + "ultDiscipline": "在 {count} 场失利团战({fights})中,你方比敌方多用了 {extra} 个大招。复盘这些大招的使用时机——败局中的大招会为敌方下一波进攻买单。", + "staggers": "梯次阵亡影响了 {count} 场团战({fights}),损失约 {cost}% 胜率。整队重整,不要逐个回场。", + "ultDeficit": "在大招经济落后的情况下接了 {count} 场团战({fights}),损失约 {cost}%。等大招差距拉平后再接战。", + "firstDeaths": "{player} 在 {of} 场失利团战中有 {count} 场({fights})率先阵亡。复盘这些开局——首死决定团战走向。" + }, + "missed": { + "title": "错失的机会", + "empty": "无 —— 领先时收得干净利落", + "showing": "显示 {total} 个中的 {shown} 个", + "fight": "团战 {fight}", + "wp": "{before}% → {after}%", + "reason": { + "objective": "目标点失利", + "kills": "击杀交换失利", + "ults": "终极技能劣势", + "earlyFirstDeath": "过早阵亡:{player}", + "stagger": "减员状态下开战 (−{cost}%)", + "ultDeficit": "终极技能劣势开战 (−{cost}%)" + }, + "ult": { + "value": "{kills, plural, other {# 击杀}}", + "none": "无效果", + "died": "释放中阵亡", + "unknown": "已使用", + "heroFallback": "{hero} 终极技能", + "summary": "{converted}/{total} 有效" + }, + "wpLost": "胜率损失" + } + }, + "fightInitiation": { + "eyebrow": "先手开团", + "title": "团战发起", + "unavailable": "该地图早于详细日志格式,无法检测团战发起。", + "summaryWinrate": "{team} 先手开团胜率", + "summaryFrequency": "{team} 发起的团战", + "coverage": "基于 {total} 场团战中的 {labeled} 场({contested} 场存在争议)。", + "fightLabel": "第 {number} 团", + "initiatedBy": "{team} 先手", + "contested": "争议开团", + "undetermined": "无法判定", + "won": "胜", + "lost": "负", + "lostOpener": "先手开团但输掉开局", + "evidencePlayers": "{count} 名队员参与", + "evidenceUlt": "大招开团", + "evidenceAbility": "技能开团", + "responseGap": "{seconds} 秒后对方响应", + "confidenceHigh": "高可信度", + "confidenceMedium": "中等可信度", + "confidenceLow": "低可信度", + "roundStarted": "第 {round} 回合开始", + "roundEnded": "第 {round} 回合结束", + "roundChange": "第 {previous} → {round} 回合", + "toggleRounds": "回合" + } + }, + "scoutingPage": { + "title": "球探", + "subtitle": "搜索 OWCS 团队并分析他们的赛事表现。", + "metadata": { + "title": "球探 | Parsertime", + "description": "球探 OWCS 团队:按名称搜索,查看比赛历史、胜率和赛事表现。", + "ogTitle": "球探 | Parsertime", + "ogDescription": "球探 OWCS 团队:按名称搜索,查看比赛历史、胜率和赛事表现。", + "ogImage": "球探" + }, + "search": { + "label": "搜索团队", + "placeholder": "搜索团队...", + "helperText": "按名称或缩写搜索 OWCS 团队。", + "resultsLabel": "团队搜索结果", + "matchCount": "{count, plural, =1 {1 场比赛} other {# 场比赛}}", + "winRate": "{winRate} 胜率", + "noResults": "未找到团队。" + }, + "player": { + "title": "球探选手", + "subtitle": "搜索职业选手并分析他们的赛事历史。", + "metadata": { + "title": "球探选手 | Parsertime", + "description": "搜索职业《守望先锋》选手,查看他们的赛事历史、英雄池和锦标赛表现。", + "ogTitle": "球探选手 | Parsertime", + "ogDescription": "搜索职业《守望先锋》选手,查看他们的赛事历史、英雄池和锦标赛表现。", + "ogImage": "球探选手", + "profileTitle": "{player} 球探 | Parsertime", + "profileDescription": "球探 {player}——英雄池、赛事历史与训练赛表现。" + }, + "search": { + "label": "搜索选手", + "placeholder": "搜索选手...", + "helperText": "按姓名、队伍或职责搜索职业选手。", + "resultsLabel": "选手搜索结果", + "noResults": "未找到选手。" + }, + "profile": { + "metadata": { + "title": "{player} | 球探选手 | Parsertime", + "description": "球探 {player} — 查看英雄池、锦标赛历史和竞技表现。", + "ogTitle": "{player} | 球探选手 | Parsertime", + "ogDescription": "球探 {player} — 查看英雄池、锦标赛历史和竞技表现。" + }, + "backToSearch": "返回搜索", + "liquipediaProfile": "在 Liquipedia 查看", + "careerWinnings": "职业奖金", + "signatureHeroes": "代表英雄", + "tournamentAppearances": "锦标赛出场", + "competitiveMaps": "竞技地图", + "tournaments": "{count} 项锦标赛", + "maps": "{count} 张地图", + "heroPool": "英雄池", + "knownFor": "知名特点", + "knownForSource": "来自 Liquipedia", + "observedInCompetition": "比赛中观察到", + "heroMaps": "{count} 张地图", + "noHeroData": "暂无竞技英雄数据。", + "tournamentHistory": "锦标赛历史", + "team": "队伍", + "role": "职责", + "record": "战绩", + "winRate": "胜率", + "noTournamentData": "暂无锦标赛数据。", + "matchDetails": "比赛详情", + "opponent": "对手", + "score": "比分", + "result": "结果", + "heroes": "英雄", + "win": "胜", + "loss": "负", + "eyebrow": "OWCS 选手", + "tournamentsLabel": "锦标赛", + "date": "日期", + "historyEyebrow": "锦标赛历史", + "recordValue": "{wins}–{losses}" + }, + "analytics": { + "scrimOverview": { + "title": "训练赛表现", + "description": "来自 Parsertime 训练赛数据的关键指标。", + "mvpScore": "MVP 分数", + "kdRatio": "K/D 比", + "firstPickRate": "首杀率", + "firstDeathRate": "首死率", + "fightReversalRate": "团战逆转率", + "killsPerUltimate": "每大招击杀", + "consistencyScore": "稳定性", + "mapsPlayed": "已打地图", + "firstPicks": "{count} 次首杀", + "firstDeaths": "{count} 次首死", + "outOf100": "/ 100", + "methodology": { + "mvpScore": "基于积分的分数,将每 10 分钟数据与全局英雄平均值进行比较。每项数据按重要性最多贡献 ±10 分(每个标准差 4 分)。通常范围为 −100 到 +100。", + "kdRatio": "所有已记录训练赛中的总消灭数除以总死亡数。消灭包括最后一击和助攻。", + "firstPickRate": "该选手取得开场击杀的团战比例。按地图测量,并在所有地图上取平均。", + "firstDeathRate": "该选手在团战中率先阵亡的比例。较高数值可能表示站位激进或被重点针对。", + "fightReversalRate": "该选手所在队伍在先损失 2 名或更多队员后仍赢下团战的比例,体现关键局能力。", + "killsPerUltimate": "每次使用终极技能取得的平均消灭数。数值越高说明终极技能使用效率越好。", + "consistencyScore": "根据各地图每 10 分钟数据的稳定程度给出 0-100 分。由消灭、死亡、伤害和治疗的平均变异系数倒数计算;75 以上表示非常稳定。", + "mapsPlayed": "Parsertime 中记录的该选手训练赛地图总数,是所有训练赛指标的样本量。" + }, + "eyebrow": "训练赛档案", + "sectionTitle": "训练赛表现" + }, + "performanceRadar": { + "title": "表现画像", + "description": "与所有 Parsertime 选手的全局英雄平均值相比的 z 分数。", + "aboveAverage": "高于平均", + "belowAverage": "低于平均", + "noData": "暂无可用于雷达图的训练赛数据。", + "methodology": "每个轴表示该选手每 10 分钟数据距离同英雄全局平均值多少个标准差(σ)。基线来自所有 Parsertime 选手在同一英雄上的数据,且至少需要 3 张地图。" + }, + "heroZScores": { + "title": "英雄表现", + "description": "与全局基线相比的各英雄综合 z 分数。", + "primary": "主力", + "maps": "{count} 张地图", + "delta": "差值", + "significantDropOff": "明显下滑", + "noData": "暂无英雄表现数据。", + "methodology": "综合 z 分数是每个英雄各项数据 z 分数(消灭、死亡、伤害、治疗、承伤)的加权平均。单项 z 分数将该选手每 10 分钟数据与该英雄的全局平均值和标准差比较。差值显示主力英雄与第二英雄熟练度之间的差距。" + }, + "mapWinrates": { + "title": "地图表现", + "description": "来自正式比赛的胜率。", + "byMapType": "按地图类型", + "byMap": "按地图", + "competitive": "正式比赛", + "scrims": "训练赛", + "played": "已打", + "won": "获胜", + "noData": "暂无竞技地图数据。", + "methodologyMapType": "地图类型胜率根据该选手所在队伍参与的所有正式比赛结果计算。百分比为每种模式(Control、Escort、Hybrid、Push、Flashpoint)的胜场数 ÷ 总地图数。", + "methodologyByMap": "单张地图胜率根据正式比赛结果计算。边框按颜色区分:胜率 ≥60% 为绿色,≤40% 为红色,其余为中性。", + "empty": "暂无赛事地图数据。", + "eyebrow": "地图", + "lowSample": "样本不足", + "map": "地图", + "mode": "模式", + "winRate": "胜率" + }, + "killAnalysis": { + "title": "战斗模式", + "description": "来自训练赛数据的击杀和死亡分析。", + "topTargets": "主要目标", + "topThreats": "主要威胁", + "killMethods": "击杀方式", + "roleDistribution": "职责分布", + "count": "数量", + "kills": "{count} 次击杀", + "deaths": "{count} 次死亡", + "methodology": "击杀和死亡数据来自 Parsertime 训练赛日志。主要目标和威胁展示总数最高的 5 个被击杀英雄和导致该选手死亡的英雄。击杀方式按技能类型分组。职责分布根据所有训练赛中的英雄使用时间,反映 Tank、Damage 和 Support 的占比。" + }, + "strengthsWeaknesses": { + "title": "球探洞察", + "description": "根据正式比赛和训练赛数据自动生成的优势与弱点。", + "strengths": "优势", + "weaknesses": "弱点", + "noStrengths": "数据不足,无法识别优势。", + "noWeaknesses": "未发现明显弱点。", + "methodology": "洞察通过分析 z 分数(相对全局平均的英雄表现)、正式比赛地图胜率、首杀/首死率、稳定性分数和英雄池深度自动生成。指标超过正向阈值时触发优势,达到负向阈值时触发弱点。系统会结合所有数据来源提供整体视角。", + "eyebrow": "球探解读", + "readTitle": "关键信息" + }, + "noScrimData": "该选手尚无 Parsertime 训练赛数据。正式比赛分析和锦标赛历史仍可查看。", + "statLabels": { + "eliminations": "消灭", + "deaths": "死亡", + "hero_damage_dealt": "英雄伤害", + "healing_dealt": "治疗", + "damage_blocked": "承受伤害" + } + }, + "searchEyebrow": "OWCS 选手" + }, + "team": { + "metadata": { + "title": "{team} 球探 | Parsertime", + "description": "球探 {team} — 查看比赛历史、英雄禁用、地图表现和策略建议。", + "ogTitle": "{team} 球探 | Parsertime", + "ogDescription": "球探 {team} — 查看比赛历史、英雄禁用、地图表现和策略建议。" + }, + "backToSearch": "返回搜索", + "tabs": { + "overview": "概述", + "heroBans": "英雄禁用", + "maps": "地图", + "players": "阵容准备", + "report": "报告", + "recommendations": "建议" + }, + "picker": { + "label": "球探对象:", + "placeholder": "选择你的队伍", + "noTeam": "未选择队伍" + }, + "overview": { + "record": "战绩", + "winRate": "胜率", + "weightedWinRate": "加权胜率", + "weightedPercent": "{value} 加权", + "weightedWinRateTooltip": "按时间新近程度加权的胜率;近期比赛权重高于较早比赛。", + "recordQuality": "战绩质量", + "recordQualityValue": "{total} 胜中的 {wins} 胜", + "recordQualityDescription": "来自击败 1500 分以上队伍的胜场", + "strengthRating": "实力评分", + "provisionalRating": "临时评分 — 少于 5 场比赛", + "topPercentile": "前 {value}", + "matchesRated": "已评定 {count} 场比赛", + "noCompetitiveData": "暂无正式比赛数据。为训练赛标记对手后即可启用交叉引用分析。", + "winRateTrend": "胜率趋势:最近 {count} 场为 {value}", + "recentForm": "近期状态", + "recentFormDescription": "最近 10 场比赛结果", + "matchHistory": "比赛历史", + "matchHistoryDescription": "所有已记录比赛,按最近时间排序", + "date": "日期", + "opponent": "对手", + "score": "比分", + "result": "结果", + "tournament": "锦标赛", + "win": "胜", + "loss": "负", + "matchCount": "{count} 场比赛", + "allTime": "总胜率", + "noMatches": "未找到该队伍的比赛。", + "previousPage": "上一页", + "nextPage": "下一页", + "pageInfo": "第 {current} 页,共 {total} 页", + "methodology": { + "title": "这些统计如何计算", + "points": { + "0": "胜率根据该队伍所有已记录 OWCS 比赛计算。", + "1": "加权胜率使用 90 天半衰期的指数衰减;90 天前的比赛权重约为今天比赛的一半。", + "2": "近期状态展示最近 10 场比赛结果,按最近优先排列。迷你趋势线展示这些比赛的胜负走势。", + "3": "Strength Rating 是以 1500 为中心的 Elo 风格评分。击败高评分对手会比击败低评分对手提升更多。少于 5 场比赛时标记为临时评分。", + "4": "Record Quality 统计有多少胜利来自击败 1500 分以上队伍,用于判断好战绩是否来自强竞争。", + "5": "比赛数据来自 Liquipedia,覆盖过去一年 OWCS 锦标赛。使用队伍选择器选择你的队伍后,也会纳入你的训练赛数据。" + } + }, + "eyebrow": "概览", + "title": "状态与战绩" + }, + "heroBans": { + "bansAgainstTeam": "针对他们的禁用", + "bansAgainstDescription": "对手面对该队伍时禁用的英雄,反映外界认为的强点。", + "bansByTeam": "他们的禁用", + "bansByDescription": "该队伍禁用的英雄,反映他们想限制对手的内容。", + "hero": "英雄", + "count": "次数", + "weighted": "加权", + "noBans": "暂无英雄禁用数据。", + "selectTeamTitle": "选择你的队伍以查看他们的禁用目标", + "selectTeamDescription": "使用上方“球探对象”选择器选择你的队伍,即可查看对手会针对你队哪些英雄。", + "disruptionTargets": "干扰目标", + "disruptionTargetsEmptyDescription": "按预期干扰效果排序的对手禁用目标", + "disruptionTargetsDescription": "建议针对他们禁用的英雄;干扰分越高,禁用后对其胜率影响越大。", + "notEnoughBanData": "禁用数据不足,无法计算干扰分。", + "percentagePoints": "{value}pp", + "deltaWhenBanned": "禁用时 {delta}", + "availabilitySummary": "{available} 张可用 / {banned} 张被禁", + "showFewer": "收起", + "showMore": "再显示 {count} 个", + "theirBanTargets": "他们的禁用目标", + "theirBanTargetsDescription": "展示他们的禁用模式与你队最常使用英雄的重叠情况", + "theirBanTargetsActiveDescription": "交叉引用他们的禁用模式与你队最常使用的英雄", + "noBanTargetOverlap": "他们的禁用与英雄池之间没有发现重叠。", + "riskCritical": "关键", + "riskWatch": "关注", + "riskSafe": "安全", + "exposureSummary": "— 他们在 {opponentBanRate} 的地图中禁用该英雄;你的队伍有 {userPlayRate} 的时间使用它", + "protectedHeroes": "受保护英雄", + "protectedHeroesDescription": "该队伍很少禁用的英雄;预计会出现在阵容中", + "methodology": { + "title": "我们如何分析英雄禁用", + "points": { + "0": "每张 OWCS 地图每队可禁用一个英雄。我们追踪该队伍禁用和被针对禁用的英雄。", + "1": "加权次数使用 90 天半衰期的指数衰减,因此近期禁用模式优先于旧模式。", + "2": "Disruption Score 衡量禁用某英雄对对手造成的影响,结合胜率差和样本可靠性。10 以上有意义,20 以上是高影响禁用目标。", + "3": "Win-Rate Delta(pp) 使用百分点。例如“禁用时 −22pp”表示该英雄不可用时对手胜率下降 22 个百分点。", + "4": "Their Ban Targets 会把对手禁用历史与你队最常使用的英雄交叉比对。Critical 表示他们在 30% 以上地图禁用,且你的队伍使用率 10% 以上。", + "5": "Protected Heroes 是该队伍在少于 5% 地图中禁用的英雄,可能是他们希望保留的阵容核心。" + } + }, + "eyebrow": "英雄禁用", + "title": "禁用环境", + "delta": "差值" + }, + "maps": { + "byMapType": "按地图类型统计胜率", + "byMapTypeDescription": "按游戏模式拆分表现", + "byMap": "单地图表现", + "byMapDescription": "各地图胜率", + "mapType": "模式", + "map": "地图", + "played": "已打", + "won": "获胜", + "winRate": "胜率", + "rawWinRate": "原始胜率", + "weightedWinRate": "加权胜率", + "trend": "趋势", + "noMaps": "暂无地图数据。", + "advisorTitle": "地图禁选顾问", + "advisorDescription": "按净优势排序地图。从顶部选择,从底部禁用。", + "crossReferenceAvailable": "可进行交叉引用", + "matchupMatrixLabel": "地图对局矩阵", + "vetoRecommendation": "禁选建议", + "pick": "选择", + "ban": "禁用", + "opponentPerformanceDescription": "对手地图表现,包含强度加权胜率和趋势指标", + "percentagePoints": "{value}pp", + "netAdvantageLabel": "{map}:净优势 {advantage}", + "versus": "对", + "stableTrend": "趋势稳定({delta})", + "improvingTrend": "近期提升({delta})— 谨慎", + "decliningTrend": "近期下滑({delta})— 机会", + "selectTeamTitle": "选择你的队伍以解锁地图对局", + "selectTeamDescription": "使用上方“球探对象”选择器选择你的队伍,即可启用交叉引用地图禁选分析。", + "methodology": { + "title": "我们如何衡量地图表现和对局关系", + "points": { + "0": "每张地图胜率根据系列赛中的单地图结果计算,而不仅是系列赛胜负。", + "1": "强度加权胜率使用 Elo 评分校正对手强度。面对顶级队伍的 60% 胜率可能比面对弱队的 80% 更有价值。", + "2": "净优势(pp) 是你的地图胜率与对手强度加权胜率之间的百分点差。", + "3": "趋势箭头将最近 10 张地图与整体记录比较。高于 +10pp 为改善,低于 −10pp 为下滑,其余为稳定。", + "4": "置信度点表示样本量:绿色实心为 20+ 地图,琥珀色为 10-19,淡色为 5-9。", + "5": "通过队伍选择器选择你的队伍后,你的训练赛地图数据会与对手 OWCS 数据合并,并使用相同的衰减加权。" + } + }, + "eyebrow": "地图", + "title": "地图表现", + "matchupTitle": "地图对阵", + "netAdvantageHeader": "净优势", + "lowSample": "样本不足" + }, + "recommendations": { + "title": "策略建议", + "description": "基于该队伍近期加权表现分析的可执行洞察。", + "heroesToBan": "建议禁用英雄", + "heroesToBanDescription": "对手面对该队伍时最常禁用的英雄,代表他们被认为的强点。", + "mapsToPick": "建议选择地图", + "mapsToPickDescription": "该队伍加权胜率最低的地图,可利用这些弱点。", + "mapsToAvoid": "建议避开地图", + "mapsToAvoidDescription": "该队伍加权胜率最高的地图,避免让他们发挥强项。", + "weightedWinRate": "加权胜率", + "sampleSize": "{count} 场比赛", + "noRecommendations": "数据不足,无法生成建议。", + "confidence": { + "high": "高置信度", + "medium": "中等置信度", + "low": "低置信度", + "insufficient": "数据不足" + }, + "methodology": { + "title": "我们如何生成建议", + "points": { + "0": "建议禁用英雄是对手最常针对该队伍禁用的英雄,禁用它们可以限制其被认为的强点。", + "1": "建议选择地图是该队伍近期加权胜率最低的地图(至少打过 3 场)。", + "2": "建议避开地图是该队伍近期加权胜率最高的地图。", + "3": "置信度基于样本量:高为 10+ 场,中为 5-9 场,低为少于 5 场。", + "4": "所有指标都使用 90 天半衰期指数衰减,使建议反映当前版本环境而非过时结果。" + } } + }, + "players": { + "selectTeamTitle": "选择你的队伍以解锁阵容准备度", + "selectTeamDescription": "使用上方“球探对象”选择器选择你的队伍。此标签会交叉引用你方选手的英雄深度和该对手的禁用模式,找出最容易被针对的人。", + "matchupSummary": "显示面对 {opponentName}你方阵容 的英雄深度和弱点。英雄池较窄且主力英雄经常被该对手禁用的选手会被标记为高风险。", + "thisOpponent": "该对手", + "atRiskPlayers": "高风险选手", + "atRiskPlayersDescription": "按英雄池深度和禁用暴露度排序,列出最容易受到该对手禁用策略影响的选手", + "vulnerabilitySummary": "{role} · {hero} · 禁用率 {banRate}", + "rolePlayers": "你的 {role} 选手", + "roleDescription": "与其他 {role} 选手相比的英雄深度和表现", + "unknownHero": "未知", + "noSecondary": "无副选英雄", + "delta": "差值:{value}", + "sigmaValue": "{sign}{value}σ", + "significantDropOff": "明显下滑", + "banExposure": "禁用暴露:{riskLevel}", + "banExposureDescription": "该对手在 {banRate} 的地图中禁用 {hero};如果被迫离开主英雄,z 分数会下降 {delta}", + "unknownAmount": "未知幅度", + "primaryBadge": "主力", + "roles": { + "tank": "坦克", + "damage": "输出", + "support": "支援" + }, + "riskLevels": { + "critical": "关键", + "high": "高", + "moderate": "中等", + "low": "低" + }, + "riskAriaCritical": "关键弱点", + "riskAriaHigh": "高风险弱点", + "riskAriaModerate": "中等弱点", + "riskAriaLow": "低风险弱点", + "methodology": { + "title": "如何评估阵容准备度", + "points": { + "0": "此标签展示你的阵容面对特定对手时的准备度,并基于英雄深度和对手禁用模式识别高风险选手。", + "1": "Z 分数衡量选手每个英雄的每 10 分钟数据,与阵容内同职责所有英雄表现相比如何。", + "2": "数据按职责加权 — Tank:死亡(30% 反向)、消灭(20%)、伤害(20%)。Damage:消灭(30%)、伤害(30%)、死亡(20% 反向)。Support:治疗(35%)、死亡(25% 反向)、伤害(14%)、消灭(10%)。", + "3": "主/副英雄差值是选手最佳英雄与第二英雄之间的 z 分数差。超过 1.5σ 表示被迫离开主英雄时会明显下滑。", + "4": "Vulnerability Index = 英雄深度差值 × 对手禁用率。0.5 以上为关键,0.25-0.5 为高,0.1-0.25 为中等,0.1 以下为低。", + "5": "英雄至少需要 3 张地图且使用时间超过 2 分钟才会纳入。选手至少需要 5 张总地图才有有意义的置信度。" + } + }, + "eyebrow": "阵容准备", + "title": "阵容备战", + "bestPlayer": "突出表现者", + "mapsPlayed": "{count, plural, other {出战 # 张地图}}", + "standoutBanTarget": "被该对手针对——其主力英雄在对方 {banRate} 的地图中被禁" + }, + "report": { + "title": "数据告诉我们的", + "scrimOnly": "仅有训练赛数据 — 暂无正式比赛历史", + "selectTeamTitle": "选择你的队伍以查看完整报告", + "selectTeamDescription": "地图对局和选手弱点等交叉引用洞察,需要先在上方“球探对象”选择器中选择你的队伍。下方仅显示对手单方洞察。", + "topInsights": "重点洞察", + "additionalFindings": "{count} 条其他发现", + "noDataTitle": "数据仍然不足", + "noDataDescription": "记录更多比赛后再回来查看。可靠洞察需要满足最低样本量。", + "competitiveMaps": "{formattedCount} 张正式比赛地图", + "scrimMaps": "{formattedCount} 张训练赛地图", + "basedOn": "基于 {sources}", + "sourceJoin": "{first} 和 {second}", + "methodology": { + "title": "报告如何生成", + "points": { + "0": "洞察通过交叉引用你队上传的训练赛数据与对手 OWCS 正式比赛历史生成。", + "1": "地图对局洞察会在你的胜率与对手强度加权胜率之间的净优势超过 ±15pp 时显示。", + "2": "禁用干扰洞察会标记禁用后让对手胜率显著下降的英雄。", + "3": "选手弱点洞察会标记主英雄明显强于副英雄且经常被对手禁用的选手。", + "4": "每条洞察都有 1-100 的优先级分数,结合优势幅度、置信度和可执行性。", + "5": "置信度层级 — 高:20+ 地图;中:10-19;低:5-9;不足(隐藏):少于 5。纯训练赛数据使用更严格阈值。", + "6": "每条洞察都会显示数据来源:“Competitive”代表仅 OWCS 比赛,“Scrim”代表仅上传的训练赛数据,“Competitive + Scrim”代表两者结合。" + } + }, + "eyebrow": "球探报告", + "subtitle": "优先级最高的解读在前——你的优势与对手的威胁。", + "confidenceLabel": "{level} 置信度", + "confidence": { + "high": "高", + "medium": "中", + "low": "低", + "insufficient": "不足" + }, + "emptyLinked": "目前数据不足,无法对该对阵给出可靠的解读。", + "emptyUnlinked": "在上方选择你的队伍,以交叉分析对阵、禁用与选手风险。", + "showFewer": "收起", + "showMore": "再显示 {count} 条", + "sampleMaps": "{count, plural, other {# 张地图}}", + "source": { + "owcs": "OWCS", + "scrim": "训练赛", + "owcs+scrim": "OWCS + 训练赛" + } + }, + "empty": "该队伍暂无可用球探数据。", + "header": { + "eyebrow": "OWCS 球探" + }, + "strengthExplainer": { + "trigger": "什么是实力评分?", + "title": "实力评分的计算方式", + "intro": "以 1500 为基准的 Elo 式赛事成绩评分。", + "elo": { + "label": "基于 Elo。", + "body": "击败评分更高的队伍比击败较弱队伍提升更多。" + }, + "quality": { + "label": "战绩质量。", + "body": "战胜 1500 以上的队伍比击败弱旅更有分量。" + }, + "weighted": { + "label": "近期加权。", + "body": "近期比赛比早期比赛权重更高(半衰期 90 天)。" + }, + "provisional": { + "label": "不足 5 场为临时评分。", + "body": "样本不足的评分会被标注,不应过度解读。" + } + }, + "faceitLink": { + "eyebrow": "FACEIT 信号", + "title": "在 FACEIT 上以 {name} 参赛", + "subtitle": "该阵容与一支 FACEIT 队伍吻合——补充了 OWCS 战绩无法提供的个人水平读数。", + "aggregateFsr": "阵容 FSR", + "coverage": "{shared} 名吻合选手中 {covered} 名已评分", + "viewProfile": "查看 FACEIT 资料", + "matchedPlayers": "吻合的选手", + "disclaimer": "根据昵称重合与共同的 FACEIT 比赛推断得出,并非已核实的身份。" } + }, + "searchEyebrow": "OWCS 球探" + }, + "faceitScoutingPage": { + "title": "FACEIT 团队球探", + "subtitle": "搜索团队,了解他们的打法及应对策略。", + "search": { + "placeholder": "搜索团队…", + "matches": "{count} 场比赛", + "noResults": "未找到团队" + }, + "strengthFsr": "FSR", + "strengthTsr": "TSR", + "coverage": "{covered}/{size} 已评级", + "tabs": { + "overview": "概述", + "maps": "地图", + "bans": "英雄禁用", + "roster": "阵容", + "recommendations": "建议" + }, + "overview": { + "record": "战绩", + "winRate": "胜率", + "weightedWinRate": "加权胜率", + "form": "近期表现", + "tierDistribution": "参与段位", + "strength": "团队实力", + "eyebrow": "概述", + "title": "他们的打法", + "matchesPlayed": "{count} 场比赛", + "weightedSub": "加权 {winRate}%", + "win": "胜", + "loss": "负", + "noForm": "暂无比赛记录。" + }, + "maps": { + "byMap": "按地图", + "byType": "按模式", + "map": "地图", + "mode": "模式", + "played": "场次", + "won": "胜利", + "winRate": "胜率", + "weighted": "加权", + "lowSample": "样本不足", + "attackDefense": "进攻 vs 防守", + "attacking": "先手进攻", + "defending": "先手防守", + "eyebrow": "地图", + "title": "地图表现", + "empty": "暂无地图数据。", + "mapsWon": "{won}/{played} 张地图" + }, + "bans": { + "title": "英雄禁用环境", + "caveat": "地图级信号:英雄处于禁用池时的胜率。FACEIT 不将禁用归属于具体阵营。", + "hero": "英雄", + "withBan": "有禁用", + "withoutBan": "无禁用", + "delta": "差值", + "sample": "样本", + "showUnrated": "显示低样本英雄", + "eyebrow": "英雄禁用", + "empty": "暂无禁用数据。", + "banTarget": "禁用目标", + "hideUnrated": "隐藏低样本英雄" + }, + "roster": { + "player": "选手", + "role": "职责", + "share": "出场", + "starter": "首发", + "sub": "替补", + "fsr": "FSR", + "tsr": "TSR", + "eyebrow": "阵容", + "title": "阵容" + }, + "recommendations": { + "pickMaps": "优先选择的地图", + "avoidMaps": "避免或禁用的地图", + "banHeroes": "考虑禁用", + "dontBan": "不要禁用", + "none": "暂无足够数据生成建议。", + "mapReason": "{played}张地图 {winRate}% 胜率", + "heroReason": "未禁用 {withoutBan}% vs 禁用 {withBan}%(n={bannedSample}/{notBannedSample})" + }, + "related": { + "title": "也曾以此名参赛", + "combine": "合并历史", + "separate": "分开历史", + "shared": "{n} 名共同选手", + "matches": "{n} 场比赛" + }, + "header": { + "eyebrow": "FACEIT 团队球探" + }, + "gamePlan": { + "eyebrow": "战术方案", + "title": "应对策略", + "subtitle": "综合其地图与禁用倾向得出,含样本数量。", + "forceMaps": "尽量打这些地图", + "banHeroes": "禁用这些英雄", + "avoidMaps": "避免或禁用的地图", + "dontBan": "不要浪费禁用", + "none": "暂无足够数据制定战术方案。" + }, + "searchEyebrow": "FACEIT 球探", + "metadata": { + "profileTitle": "{team} · FACEIT 球探 | Parsertime", + "profileDescription": "{team} 球探报告:FSR、地图胜率、英雄禁用倾向、阵容,以及应对战术方案。", + "searchTitle": "FACEIT 团队球探 | Parsertime" + }, + "patches": { + "eyebrow": "补丁时间线", + "title": "各补丁表现", + "description": "展示成绩如何随平衡补丁变化。版本元变化很快,因此近期补丁权重更高,较早的表现意义较小。", + "patch": "补丁", + "winRate": "胜率", + "matches": "场次", + "mostBanned": "最常禁用", + "preTracking": "补丁追踪之前", + "through2025": "截至 2025 年", + "midSeason": "赛季中 · {date}", + "now": "至今", + "lowSample": "不足", + "noRecent": "自补丁追踪开始(2026 年 1 月)以来暂无比赛。" + } + }, + "faceitPlayerPage": { + "title": "FACEIT 选手球探", + "subtitle": "搜索选手,查看其 FSR、数据档案及历史战绩。", + "search": { + "placeholder": "搜索选手…", + "matches": "{count} 名选手", + "noResults": "未找到选手", + "unrated": "未评级" + }, + "header": { + "eyebrow": "选手", + "yes": "是", + "no": "否", + "region": "地区", + "level": "FACEIT 等级", + "verified": "已认证", + "team": "当前团队" + }, + "fsr": { + "eyebrow": "FSR", + "title": "FSR", + "headline": "代表 FSR", + "byTier": "按段位", + "tier": "段位", + "rating": "FSR", + "maps": "地图", + "percentile": "百分位", + "primary": "主要", + "unrated": "评级地图不足,暂无 FSR。", + "recent": "近 365 天", + "ratingScale": "区间" + }, + "radar": { + "title": "数据档案", + "tierLabel": "段位", + "vsPeers": "对比同段位的各项数据 z 分数" + }, + "insights": { + "strengths": "优势", + "weaknesses": "劣势", + "none": "暂无突出数据" + }, + "roles": { + "eyebrow": "职责", + "title": "职责使用情况", + "role": "职责", + "maps": "地图", + "share": "占比" + }, + "maps": { + "eyebrow": "地图", + "title": "地图胜率", + "byMap": "按地图", + "byType": "按模式", + "map": "地图", + "mode": "模式", + "played": "场次", + "won": "胜利", + "winRate": "胜率", + "lowSample": "样本不足", + "empty": "暂无地图数据。" + }, + "history": { + "eyebrow": "历史", + "title": "比赛记录", + "date": "日期", + "team": "团队", + "opponent": "对手", + "tier": "段位", + "score": "比分", + "result": "结果", + "role": "职责", + "win": "胜", + "loss": "负", + "empty": "未找到比赛记录", + "count": "已统计 {count} 场" + }, + "teams": { + "eyebrow": "团队", + "title": "效力过的团队", + "appearances": "{count} 场比赛" + }, + "stat": { + "eliminations": "击杀", + "finalBlows": "终结一击", + "deaths": "死亡(反向)", + "damageDealt": "造成伤害", + "healingDone": "治疗量", + "damageMitigated": "减免伤害", + "soloKills": "单杀", + "assists": "助攻", + "objectiveTime": "目标占领时间" + }, + "threat": { + "eyebrow": "球探简报", + "title": "威胁评估", + "headlineRating": "{role} 评级", + "percentile": "强于 {pct}% 的 {tier} {role} 选手", + "threatensWith": "主要威胁", + "exploit": "可利用弱点", + "strongestMap": "最强地图", + "weakestMap": "最弱地图", + "trackedRecord": "统计战绩", + "matchesTracked": "已统计 {count} 场", + "zAbove": "对比同级 +{z}", + "zBelow": "对比同级 {z}", + "mapDetail": "{played} 张地图 {winRate}%", + "unratedEyebrow": "评级", + "unratedTitle": "未评级", + "unratedBody": "评级地图不足,暂无 FSR。下方地图与比赛记录仍可参考。", + "noInsights": "数据不足,暂无法总结。" + }, + "searchEyebrow": "FACEIT 球探", + "metadata": { + "profileTitle": "{player} · FACEIT 球探 | Parsertime", + "profileDescription": "{player} 球探报告:按职责与段位的 FSR、数据档案、地图胜率与比赛记录。", + "searchTitle": "FACEIT 选手球探 | Parsertime" } }, "statsPage": { @@ -1153,6 +4052,87 @@ "ogDescription": "Parsertime 上 {hero} 的统计数据。Parsertime 是一款用于分析《守望先锋》训练赛的工具。", "ogImage": "{hero}的统计数据" }, + "mapHeroTrends": { + "eyebrow": "地图环境 · 过去 60 天", + "eyebrowWithCount": "地图环境 · 过去 60 天 · {count, number} 张唯一地图", + "empty": { + "title": "暂无近期地图数据", + "description": "上传过去 60 天内的训练赛后,此视图会显示数据。" + }, + "stats": { + "scrims": "训练赛", + "maps": "地图", + "heroes": "英雄" + }, + "allMaps": "所有地图", + "searchMaps": "搜索地图...", + "noMapsFound": "未找到地图。", + "sortLabel": "排序", + "roleFilter": "职责筛选", + "noHeroesMatch": "没有英雄符合所选筛选条件。", + "showPickRateTrend": "显示 {hero} 的选择率趋势", + "record": "{wins, number}胜 · {losses, number}负", + "playtimeHoursMinutes": "{hours}小时 {minutes}分钟", + "playtimeMinutes": "{minutes}分钟", + "roles": { + "all": "全部", + "tank": "坦克", + "damage": "输出", + "support": "支援" + }, + "subroles": { + "hitscanDamage": "长枪输出", + "flexDamage": "自由输出", + "groundTank": "地面坦克", + "diveTank": "机动坦克", + "flexSupport": "自由支援", + "mainSupport": "主支援" + }, + "mapTypes": { + "control": "控制", + "escort": "运载", + "hybrid": "混合", + "push": "机动推进", + "flashpoint": "闪点作战", + "clash": "冲突", + "other": "其他" + }, + "sort": { + "pickRate": "选择率", + "playtime": "游戏时间", + "winrate": "胜率", + "trend": "趋势" + }, + "table": { + "hero": "英雄", + "role": "职责", + "playtime": "游戏时间", + "pick": "选择", + "winrate": "胜率", + "sample": "样本", + "trend": "30 天变化" + } + }, + "compareStats": { + "eyebrow": "统计 · 玩家对比", + "title": "比较玩家统计数据", + "enterPlayerNames": "输入玩家名称", + "player1": "玩家 1", + "player2": "玩家 2", + "playerA": "玩家 A", + "playerB": "玩家 B", + "player1Placeholder": "输入第一位玩家名称", + "player2Placeholder": "输入第二位玩家名称", + "compare": "比较", + "reset": "重置", + "loading": "正在加载玩家统计数据...", + "errorLoading": "加载玩家数据时出错", + "errorPlayer1": "无法加载玩家 1 数据", + "errorPlayer2": "无法加载玩家 2 数据", + "errorBoth": " 和 ", + "enterPlayersPrompt": "在上方输入两位玩家名称以比较他们的统计数据", + "filters": "筛选" + }, "title": "统计数据", "searchbar": { "placeholder": "输入球员姓名...", @@ -1271,6 +4251,27 @@ "damage": "输出", "support": "支援" }, + "heroFilter": { + "selectHeroes": "选择英雄", + "allHeroes": "所有英雄", + "heroesSelected": "已选择 {count} 个英雄", + "searchHeroes": "搜索英雄...", + "noHeroesFound": "未找到英雄", + "quickSelect": "快速选择", + "reset": "重置", + "allTanks": "所有坦克", + "allDamage": "所有输出", + "allSupport": "所有支援", + "hitscanDPS": "长枪输出", + "flexDPS": "自由输出", + "groundTanks": "地面坦克", + "diveTanks": "机动坦克", + "flexSupports": "自由支援", + "mainSupports": "主支援", + "tank": "坦克", + "damage": "输出", + "support": "支援" + }, "mostPlayed": { "title": "最常使用的英雄", "rank": "排名", @@ -1381,11 +4382,54 @@ "environmental_kills": "环境击杀", "deaths": "死亡", "hero_damage_dealt": "英雄伤害" + }, + "identity": { + "scrims": "训练赛", + "games": "地图", + "hours": "小时", + "winrate": "胜率", + "winrateSub": "{wins}/{total} 张地图获胜", + "scopeTimeframe": "共 {scrims} 场训练赛 · 过去 {timeframe}", + "scopeAllTime": "共 {scrims} 场训练赛 · 全部时间", + "scopeCustom": "共 {scrims} 场训练赛 · {from} → {to}", + "scopeCustomEmpty": "选择日期范围以查看统计数据" + }, + "heroPortfolio": { + "title": "英雄表现", + "description": "在此时间范围内最常使用的英雄。点击任意一个以筛选页面其余内容。", + "empty": "在此时间范围内未使用任何英雄", + "games": "地图", + "finalBlows": "最后一击", + "deaths": "死亡", + "role": { + "tank": "坦克", + "damage": "输出", + "support": "支援" + } + }, + "sections": { + "overview": "概览", + "breakdown": "表现细分", + "form": "表现趋势", + "maps": "地图记录", + "habits": "习惯", + "combat": "对战" + }, + "performanceBreakdown": { + "description": "该玩家在所选英雄 CSR 排行榜上的位置,以及与同行对比的 Z-score 细分。", + "selectOne": "在上方英雄表现中选择一名英雄即可查看 SR 分布与 Z-score 细分。", + "loading": "正在加载细分数据…", + "error": "无法加载该英雄的细分数据。", + "notRanked": "{hero} 的同行数据还不够(每位玩家需要至少 10 张地图且每张地图至少 60 秒)。", + "distributionLabel": "SR 分布 · {hero}", + "vsLabel": "与其他 {hero} 玩家的 Z-score 对比", + "explainer": "每个轴显示该玩家在所选英雄上相比平均值高出(正值)或低出(负值)多少个标准差。数据与 CSR 排行榜一致。" } }, "heroStats": { "title": "英雄统计数据", - "description": "选择一个英雄查看他们的统计数据。统计数据来自所有训练赛的累积。", + "pickerTitle": "英雄统计", + "description": "选择一名英雄,深入了解其在所有训练赛中的表现。", "timeframe": { "one-week": "一周", "two-weeks": "两周", @@ -1485,13 +4529,50 @@ "environmental_kills": "环境击杀", "deaths": "死亡", "hero_damage_dealt": "英雄伤害" + }, + "identity": { + "games": "场次", + "totalKills": "击杀", + "totalDeaths": "死亡", + "kd": "K / D", + "scopeTimeframe": "共 {scrims} 场训练赛 · 过去 {timeframe}", + "scopeAllTime": "共 {scrims} 场训练赛 · 全部时间", + "scopeCustom": "共 {scrims} 场训练赛 · {from} → {to}", + "scopeCustomEmpty": "选择日期范围以查看统计数据" + }, + "sections": { + "overview": "概览", + "talent": "玩家池", + "form": "表现趋势", + "combat": "对战" + }, + "talent": { + "description": "符合 CSR 资格的玩家在此英雄上的分布,以及 CSR 排名前列的玩家。", + "distribution": "SR 分布", + "topPlayers": "顶尖玩家", + "topCount": "前 {count} 名", + "notRanked": "该英雄的同行数据还不够(每位玩家至少需要 10 张地图且每张至少 60 秒)。", + "explainer": "CSR 基于上方所选时间范围的数据计算。点击玩家可查看其完整资料。" } + }, + "playerMetrics": { + "mvpScore": "平均 MVP 分数", + "fletaDeadliftPercentage": "Fleta Deadlift %", + "firstPickPercentage": "首杀 %", + "totalFirstPicks": "{count, plural, =0 {无首杀} other {共 # 次首杀}}", + "firstDeathPercentage": "先阵亡 %", + "totalFirstDeaths": "{count, plural, =0 {无先阵亡} other {共 # 次先阵亡}}", + "fightReversalPercentage": "团战逆转 %", + "killsPerUltimate": "每次终极技能击杀", + "averageUltChargeTime": "平均终极技能充能时间", + "droughtTime": "平均击杀空窗时间", + "mapMVPCount": "地图 MVP 总数" } }, "teamPage": { "metadata": { "title": "团队 | Parsertime", - "description": "Parsertime 是一款用于分析《守望先锋》训练赛的工具。", + "description": "创建和管理您的《守望先锋》团队、阵容和训练赛记录。", "ogTitle": "团队 | Parsertime", "ogDescription": "Parsertime 是一款用于分析《守望先锋》训练赛的工具。", "ogImage": "团队" @@ -1508,6 +4589,8 @@ "viewTeams": "查看您的团队", "teams": "团队", "admin": "管理员视图", + "viewStats": "查看统计", + "viewAvailability": "可用时间", "search": { "placeholder": "搜索团队...", "filters": "筛选", @@ -1542,6 +4625,25 @@ "owner": "(所有者)", "you": "(您)", "addMember": "添加成员...", + "scoutingLink": { + "title": "OWCS 球探关联", + "description": "将此团队关联到 OWCS 团队缩写,以启用交叉引用的球探报告。这会把您上传的训练赛数据连接到对手比赛历史。", + "placeholder": "例如:CORNELL、NYU、COLU", + "noTeam": "未关联团队", + "linked": "已关联到 {abbr}" + }, + "teamMemberUsage": { + "title": "用量详情", + "description": "您的团队成员用量如下所示。用量基于您的计费方案和已创建的团队数量。", + "teamMemberCount": "团队成员", + "usage": "已使用 {current} / {allowed}" + }, + "empty": { + "title": "没有团队", + "joinInvite": "点击这里使用邀请码加入团队。", + "createPrompt": "或者点击下方按钮创建新团队。", + "createTeam": "创建团队" + }, "join": { "enterToken": "输入您的邀请令牌", "joinTeam": "加入团队", @@ -1702,6 +4804,10 @@ "errorTitle": "发生错误", "errorDescription": "发生错误:{res}" } + }, + "joinMetadata": { + "title": "加入团队 | Parsertime", + "description": "接受邀请,在 Parsertime 上加入您的《守望先锋》团队。" } }, "settingsPage": { @@ -1716,8 +4822,13 @@ "description": "管理账户设置和偏好。", "sideNav": { "profile": "简介", - "linkedAccounts": "Integrations", - "admin": "管理员" + "linkedAccounts": "集成", + "admin": "管理员", + "dashboard": "仪表板", + "analytics": "分析", + "impersonateUser": "模拟用户", + "auditLogs": "审计日志", + "billing": "账单" }, "profile": { "title": "简介", @@ -1731,9 +4842,35 @@ "planUpgrade": "升级您的计划", "manageSubscription": "管理您的订阅" }, + "billing": { + "title": "账单", + "description": "管理您的账单设置和偏好。", + "planDescription": "您当前的方案是 {billingPlan}。", + "billingPlan": { + "FREE": "免费", + "BASIC": "基本", + "PREMIUM": "高级" + }, + "planUpgrade": "升级您的方案", + "manageSubscription": "管理您的订阅", + "teamCount": "团队", + "teamMemberCount": "团队成员", + "scrimCount": "训练赛", + "viewOtherPlans": "查看其他方案", + "usage": "已使用 {current} / {allowed}" + }, "profileForm": { "minMessage": "姓名必须至少包含 2 个字符。", "maxMessage": "姓名长度不得超过 30 个字符。", + "specialCharsMessage": "姓名不能包含特殊字符。", + "unknownError": "未知错误", + "errors": { + "updateName": "更新姓名失败:{res}", + "updateBattletag": "更新 Battletag 失败:{res}", + "updateTitle": "更新称号失败:{res}", + "updateOnboarding": "更新引导设置失败:{res}", + "updateSettings": "更新设置失败:{res}" + }, "onSubmit": { "title": "简介已更新", "description": "您的个人资料已成功更新。", @@ -1744,6 +4881,10 @@ "title": "用户名", "description": "这是您的公开显示名称。可以是真名,也可以是化名。" }, + "seenOnboarding": { + "title": "已看过引导", + "description": "启用后,下次访问仪表板时将不再显示介绍。" + }, "avatar": { "title": "头像", "altText": "用户头像", @@ -1783,7 +4924,50 @@ } } }, - "update": "更新个人资料" + "update": "更新个人资料", + "colorblindMode": { + "title": "无障碍设置", + "description": "配置色盲无障碍选项以改善你的使用体验", + "label": "色盲模式", + "options": { + "off": { + "label": "关闭", + "description": "标准颜色" + }, + "deuteranopia": { + "label": "绿色弱", + "description": "红绿色盲(绿色弱)" + }, + "protanopia": { + "label": "红色弱", + "description": "红绿色盲(红色弱)" + }, + "tritanopia": { + "label": "蓝黄色盲", + "description": "蓝黄色觉异常" + }, + "custom": { + "label": "自定义", + "description": "选择你自己的队伍颜色" + } + }, + "team1": "Team 1", + "team2": "Team 2", + "customTeamColors": "自定义队伍颜色", + "customTeamColorsDescription": "为每支队伍选择自定义颜色", + "team1Color": "Team 1 颜色", + "team2Color": "Team 2 颜色" + }, + "updating": "正在更新...", + "battletag": { + "title": "Battletag", + "description": "这是要与你账户关联的游戏内用户名。请不要包含井号后的数字。" + }, + "title": { + "title": "称号", + "placeholder": "选择称号", + "description": "选择要在个人资料上使用的称号。" + } }, "linkedAccounts": { "title": "Integrations", @@ -1819,13 +5003,21 @@ "selectChannel": "选择频道", "selectTeams": "选择队伍", "submit": "保存", - "saving": "保存中..." + "saving": "保存中...", + "discordNotLinked": "请先在上方设置中关联您的 Discord 账号,然后选择服务器。", + "noSharedServers": "未找到服务器。您必须与 Parsertime 机器人共处至少一个服务器。" }, "toast": { "created": "通知配置创建成功。", "deleted": "通知配置删除成功。", - "error": "出了点问题,请重试。" + "error": "出了点问题,请重试。", + "discordNotLinked": "请关联您的 Discord 账号以配置通知。", + "notGuildMember": "您必须是该 Discord 服务器的成员。" } + }, + "ranked": { + "title": "排位统计", + "description": "控制谁可以在您的公开主页上查看您的排位统计。" } }, "admin": { @@ -1846,6 +5038,284 @@ "description": "模拟的 URL 已复制到您的剪贴板。", "errorTitle": "错误", "errorDescription": "模拟用户时发生错误。" + }, + "impersonateUser": { + "title": "模拟用户", + "description": "模拟某个用户,以该用户视角查看网站。", + "email": { + "title": "电子邮件", + "description": "这是您想要模拟的用户电子邮件地址。", + "message": "请输入有效的电子邮件地址。" + } + }, + "audit-log": { + "title": "最近用户操作", + "action-types": "操作类型", + "filter-label": "按操作类型筛选", + "clear-filters": "全部清除", + "user-email": "用户邮箱:{userEmail}", + "target": "目标:{target}", + "search-user-email": "搜索用户邮箱", + "search-target": "搜索目标", + "date-range": { + "select": "选择日期范围", + "from": "从 {date}", + "until": "直到 {date}", + "range": "{from} - {to}" + }, + "actions": { + "USER_BAN": "封禁用户", + "USER_UNBAN": "解除封禁", + "USER_AVATAR_UPDATED": "用户头像已更新", + "USER_ACCOUNT_DELETED": "用户账号已删除", + "USER_NAME_UPDATED": "用户名已更新", + "TRUST_SCORE_ADJUST": "调整信任分", + "IMPERSONATE_USER": "模拟用户", + "SUSPICIOUS_ACTIVITY_DETECTED": "检测到可疑活动", + "TEAM_CREATED": "团队已创建", + "TEAM_UPDATED": "团队已更新", + "TEAM_DELETED": "团队已删除", + "TEAM_AVATAR_UPDATED": "团队头像已更新", + "TEAM_INVITE_SENT": "团队邀请已发送", + "TEAM_JOINED": "加入团队", + "TEAM_LEFT": "离开团队", + "TEAM_MEMBER_PROMOTED": "团队成员已提升", + "TEAM_MEMBER_DEMOTED": "团队成员已降级", + "TEAM_MEMBER_REMOVED": "团队成员已移除", + "TEAM_OWNERSHIP_TRANSFERRED": "团队所有权已转移", + "SCRIM_CREATED": "训练赛已创建", + "SCRIM_UPDATED": "训练赛已更新", + "SCRIM_DELETED": "训练赛已删除", + "MAP_CREATED": "地图已创建", + "MAP_UPDATED": "地图已更新", + "MAP_DELETED": "地图已删除", + "BUG_REPORT_SUBMITTED": "错误报告已提交" + }, + "download": { + "button": "下载 CSV", + "downloading": "正在下载...", + "filename": "audit-logs-{date}.csv", + "tooltip": "将当前筛选的审计日志下载为 CSV 文件。", + "error": "下载 CSV 时出错:{error}", + "unknown-error": "未知错误" + }, + "table": { + "user-email": "用户邮箱", + "action": "操作", + "target": "目标", + "details": "详情", + "date-time": "日期和时间", + "date-time-format": "{date} {time}", + "no-logs-found": "没有找到符合筛选条件的审计日志。", + "error-loading-logs": "加载审计日志时出错" + }, + "showing-logs": "正在显示 {count} 条日志。", + "hover": { + "user-email": "用户邮箱", + "action": "操作", + "target": "目标", + "details": "详情", + "id": "ID:{id}", + "timestamp": "时间戳", + "auto-generated": "此操作由系统自动执行。" + } + }, + "dashboard": { + "title": "管理员仪表盘", + "description": "管理用户和内容,并监控平台活动", + "user-search": { + "title": "用户搜索", + "description": "使用高级筛选选项搜索用户" + }, + "audit-log": { + "title": "审计日志", + "description": "工作人员最近执行的操作" + }, + "stats-cards": { + "total-users": { + "title": "用户总数", + "delta": "本月新增 {delta} 名用户" + }, + "user-growth": { + "title": "用户增长", + "delta": "与上月 {lastMonth} 相比 {delta}" + }, + "scrim-activity": { + "title": "训练赛活动", + "delta": "与上月 {lastMonth} 相比 {delta}" + }, + "conversion-rate": { + "title": "转化率", + "delta": "{paidUsers} 名付费用户" + }, + "monthly-chart": { + "title": "每月用户注册", + "description": "过去 12 个月的新用户注册趋势" + }, + "low-trust-users": { + "title": "低信任用户", + "delta": "较上周 {delta}" + }, + "pending-reports": { + "title": "待处理举报", + "delta": "较上周 {delta}" + }, + "images-pending-review": { + "title": "待审核图片", + "delta": "较上周 {delta}" + } + } + }, + "user-search": { + "metadata": { + "title": "用户搜索 — 管理员 | Parsertime", + "description": "使用高级筛选选项搜索用户" + }, + "title": "用户搜索", + "description": "使用高级筛选选项搜索用户", + "search-placeholder": "按用户名或电子邮件搜索...", + "filters": { + "title": "筛选", + "trust-score": "信任分范围", + "status": "用户状态", + "select-status": "选择状态", + "all-statuses": "所有状态", + "verification-status": "验证状态", + "select-verification-status": "选择验证状态", + "all-users": "所有用户", + "verified-only": "仅已验证", + "unverified-only": "仅未验证", + "billing-plan": "计费方案", + "select-billing-plan": "选择计费方案", + "all-plans": "所有方案", + "join-date-range": "加入日期范围", + "select-join-date-range": "选择加入日期范围", + "date-range-from": "从 {date}", + "date-range-until": "直到 {date}", + "date-range": "{from} - {to}", + "reset-filters": "重置筛选" + }, + "plans": { + "free": "免费", + "basic": "Basic", + "premium": "Premium", + "unknown": "未知" + }, + "table": { + "username": "用户名", + "email": "电子邮件", + "trust-score": "信任分", + "verification": "验证状态", + "join-date": "加入日期", + "actions": "操作", + "view-profile": "查看资料", + "edit-user": "编辑用户", + "adjust-trust-score": "调整信任分", + "ban-user": "封禁用户", + "unban-user": "解除封禁", + "no-users-found": "没有找到符合筛选条件的用户。", + "open-menu": "打开菜单", + "error-loading-users": "加载用户时出错", + "status": "状态", + "banned": "已封禁", + "active": "活跃", + "name": "姓名", + "billing-plan": "计费方案", + "role": "角色", + "email-verified": "邮箱已验证?", + "unverified": "未验证", + "verified": "已验证" + }, + "showing-users": "正在显示 {count} 名用户。" + }, + "analytics": { + "title": "分析", + "description": "查看用户活动和增长的详细分析与图表。", + "activeUsers": { + "title": "月活跃用户", + "description": "本月所在团队上传过训练赛的用户与其他用户对比" + }, + "monthlyActiveUsers": { + "title": "活跃用户趋势", + "description": "过去 12 个月中,所在团队上传过训练赛的每月唯一用户" + }, + "activeTeams": { + "title": "月活跃团队", + "description": "本月上传过训练赛的团队与所有团队对比" + }, + "monthlyActiveTeams": { + "title": "活跃团队趋势", + "description": "过去 12 个月中每月上传过训练赛的唯一团队" + }, + "userGrowth": { + "title": "用户增长趋势", + "description": "过去 12 个月的每月用户注册趋势" + }, + "scrimActivity": { + "title": "训练赛活动", + "description": "过去 30 天的每日训练赛创建活动" + }, + "teamCreations": { + "title": "团队创建", + "description": "过去 12 个月的每月团队创建趋势" + }, + "teamManagerDistribution": { + "title": "用户角色分布", + "description": "普通用户与团队管理员的百分比分布" + }, + "signupMethods": { + "title": "用户注册方式", + "description": "用户注册方式细分(OAuth 提供商与电子邮件)" + }, + "billingPlans": { + "title": "计费方案分布", + "description": "按计费方案划分的用户分布(免费、Basic、Premium)" + }, + "charts": { + "last12Months": "过去 12 个月", + "allTime": "全部时间", + "users": "用户", + "activeUsers": "活跃用户", + "activeTeams": "活跃团队", + "teamsCreated": "已创建团队", + "projected": "本月预计 (剩余)", + "currentMonthInProgress": "本月仍在进行中" + }, + "tabs": { + "growth": "增长", + "adoption": "功能采用", + "activity": "活动", + "funnels": "转化漏斗" + }, + "usage": { + "adoptionTitle": "功能采用", + "adoptionDescription": "过去 30 天内每项功能的唯一用户数", + "activeUsersTitle": "活跃用户", + "activeUsersDescription": "过去 30 天的每日活跃用户数", + "hotColdTitle": "页面使用情况", + "hotColdDescription": "过去 30 天访问量最多和最少的页面", + "uniqueUsers": "唯一用户", + "totalEvents": "总事件数", + "dau": "DAU", + "page": "页面", + "views": "浏览量", + "underused": "低使用率", + "scorecard": { + "dau": "日活跃", + "wau": "周活跃", + "mau": "月活跃", + "stickiness": "粘性", + "events30d": "事件数 (30天)", + "activeFeatures": "活跃功能" + }, + "funnels": { + "title": "转化漏斗", + "description": "所选时间窗口内的逐步转化情况", + "step": "步骤", + "users": "用户", + "conversion": "转化率" + } + } } }, "dangerZone": { @@ -1911,6 +5381,12 @@ "errorTitle2": "发生错误", "errorDescription2": "发送消息时发生错误。请稍后再试。" } + }, + "metadata": { + "title": "联系我们 | Parsertime", + "description": "联系 Parsertime 团队。", + "ogTitle": "联系我们 | Parsertime", + "ogDescription": "联系 Parsertime 团队。" } }, "aboutPage": { @@ -2150,6 +5626,12 @@ } }, "privacyPage": { + "metadata": { + "title": "隐私政策 | Parsertime", + "description": "Parsertime 如何收集、使用和保护您的数据。", + "ogTitle": "隐私政策 | Parsertime", + "ogDescription": "Parsertime 的隐私政策,Parsertime 是一款用于分析《守望先锋》训练赛的工具。" + }, "privacyPolicy": { "title": "隐私政策", "description": "在 Parsertime,我们致力于维护访问我们网站的用户的信任和信心。特别是,我们希望您知道 Parsertime 并不会出于营销目的向其他公司和企业出售、出租或交易电子邮件列表。在本隐私政策中,我们提供了有关我们何时以及为何收集您的个人信息、如何使用这些信息以及如何确保其安全的详细信息。" @@ -2196,6 +5678,12 @@ } }, "termsPage": { + "metadata": { + "title": "服务条款 | Parsertime", + "description": "管辖您使用 Parsertime 的条款。", + "ogTitle": "服务条款 | Parsertime", + "ogDescription": "Parsertime 的服务条款,Parsertime 是一款用于分析《守望先锋》训练赛的工具。" + }, "termsOfService": { "title": "服务条款", "description": "本服务条款规范您对 Parsertime 的使用,这是一个用于分析《守望先锋》训练赛的开源工具。通过访问或使用我们的服务,您同意受这些条款的约束。" @@ -2268,30 +5756,376 @@ "description": "如果您对这些服务条款有任何疑问,请通过 legal@lux.dev 联系我们。" } }, - "notFound": { - "404": "404", - "header": "页面未找到", - "description": "抱歉,我们无法找到您要找的页面。", - "backHome": "返回主页", - "contact": "联系支持" + "dataCorruption": { + "warning": { + "title": "检测到数据损坏", + "baseDescription": "检测到损坏的数据。正在尝试自动修复:", + "invalidMercyRez": "无效的 mercy_rez 行将被移除", + "asteriskValues": "星号值将被替换为 0" + } }, - "footer": { - "changelog": "点击查看更新日志", - "healthOk": "所有系统正常", - "healthDegraded": "性能下降", - "healthUnknown": "状态不可用", - "healthChecking": "正在检查状态…", - "healthCardTitle": "服务状态", - "healthStatusOperational": "正常运行", - "healthStatusDown": "已中断", - "healthServiceDatabase": "数据库", - "healthServiceDiscordBot": "Discord 机器人", - "workshopCode": "工坊代码", - "clickToCopy": "点击复制", - "copied": "已复制!", - "copiedDescription": "工坊代码已复制到剪贴板", - "navAriaLabel": "页脚导航", - "productTitle": "产品", + "leaderboardPage": { + "metadata": { + "title": "排行榜 | Parsertime", + "description": "查看每位英雄排名最高的玩家。", + "ogTitle": "排行榜 | Parsertime", + "ogDescription": "查看每位英雄排名最高的玩家。", + "ogImage": "排行榜" + }, + "hub": { + "metadata": { + "title": "排行榜 | Parsertime", + "description": "用两种方式理解《守望先锋 2》玩家实力:按英雄统计的综合评分,以及基于锦标赛的 Elo。" + }, + "eyebrow": "排行榜", + "title": "技术评分", + "description": "玩家实力不止一种衡量方式。选择你要回答的问题,再查看对应的榜单。", + "answersEyebrow": "{metric} · 回答的问题", + "browse": "浏览 {metric}", + "computed": "计算方式", + "reachesFor": "适合衡量", + "doesNotCapture": "不适合衡量", + "stats": { + "perHero": "按英雄", + "topCount": "前 {count, number}", + "minSample": "最低样本", + "mapCount": "{count, number} 张地图", + "scale": "量表", + "scaleValue": "1-{max, number}", + "csrStatus": "训练赛上传后会更新。", + "active": "活跃", + "trackedPlayers": "已跟踪玩家", + "trackedMatches": "已跟踪比赛", + "topRating": "最高评分", + "lastRecompute": "上次完整重算:{when}。", + "awaitingSeed": "正在等待初始数据。" + }, + "relative": { + "justNow": "刚刚", + "minutesAgo": "{count}分钟前", + "hoursAgo": "{count}小时前", + "daysAgo": "{count}天前" + }, + "metrics": { + "csr": { + "fullName": "综合技术评分", + "question": "这名玩家与同英雄同侪相比,统计表现如何?", + "derivation": "基于已记录训练赛中的角色加权每 10 分钟数据计算 Z 分数。缩放到 1 到 5000,均值为 2500。按英雄展示前 50 名。", + "strengths": { + "heroSpecific": "按英雄、按角色理解", + "scrimExecution": "反映训练赛原始执行表现", + "fastUpdates": "训练赛上传后立即更新" + }, + "caveats": { + "opponentStrength": "不计入对手强度", + "minimumSample": "至少 10 张地图且每张地图 60 秒" + } + }, + "tsr": { + "fullName": "锦标赛技术评分", + "question": "这名玩家能应对什么级别的竞争?", + "derivation": "对 FACEIT 举办的 OW2 锦标赛结果进行 Elo 风格重放。按 365 天半衰期加权近期表现,并以达到过的最高级别校准。", + "strengths": { + "headToHead": "基于直接对战结果", + "comparable": "可跨地区和级别比较", + "currentForm": "反映当前状态" + }, + "caveats": { + "faceitOnly": "只统计 FACEIT 跟踪的锦标赛", + "battleTag": "需要已关联的 BattleTag" + } + } + } + }, + "csrPage": { + "metadata": { + "title": "综合技术评分 | Parsertime", + "description": "按英雄计算的技术评分,来自与同英雄同侪比较后的 Z 分数统计表现。" + }, + "eyebrow": "排行榜", + "eyebrowWithHero": "排行榜 · {hero}", + "title": "综合技术评分", + "description": "按英雄计算的评分,来自与同英雄同侪比较后的 Z 分数统计表现。每位英雄前 50 名,需要 10 张地图且每张地图至少 60 秒。", + "empty": { + "eyebrow": "选择英雄", + "title": "排行榜按英雄计算", + "description": "在上方选择英雄即可查看前 50 名玩家。CSR 会按英雄独立计算,因此比较会基于符合角色定位的数据。" + }, + "accordion": { + "calculated": { + "title": "CSR 如何计算?", + "intro": "CSR 是一种技术评分,来自你在特定英雄上的统计表现与平均玩家的比较。", + "formulaTitle": "公式", + "formula": "我们会为每项关键统计计算 Z 分数,衡量你高于或低于平均值多少个标准差。", + "normalized": "统计会归一化为每 10 分钟数据。", + "positiveStats": "正向统计(消灭)数值越高奖励越多。", + "negativeStats": "负向统计(死亡、承受伤害)数值越低奖励越多。", + "roleWeightingTitle": "角色权重", + "roleWeighting": "不同角色优先考虑不同统计。重装:低死亡(30%)、消灭(20%)、单杀(15%)。输出:消灭(30%)、最后一击(20%)、造成伤害(20%)。支援:治疗(35%)、低死亡(25%)。", + "uniqueWeightings": "像 Mercy 这样的特定英雄会使用专属权重。", + "finalScalingTitle": "最终缩放", + "finalScaling": "加权 Z 分数会汇总并转换为以 2500 为中心的 SR 量表。" + }, + "goodSr": { + "title": "什么算好的 SR?", + "body": "分数遵循钟形曲线。平均值为 2500;高出一个标准差(约 300 到 400 SR)大约位于前 16%。高于 2500 越多,评分越稀有。" + }, + "rank": { + "title": "怎样登上榜单?", + "body": "使用该英雄至少打 10 张地图,并且每张地图至少有 60 秒上场时间。短暂的地图中途换人不会计入。榜单显示前 50 名;低于此排名的结果仍会显示在玩家资料中。" + }, + "interactive": { + "title": "阅读表格", + "body": "点击任意行即可打开详细统计面板:SR 分布、角色雷达图和每 10 分钟分解数据。也可以通过 Tab 和 Enter 使用键盘操作。" + } + } + }, + "csr": { + "table": { + "player": "玩家", + "maps": "地图", + "time": "时间", + "elimsPer10": "消灭/10 分钟", + "damagePer10": "伤害/10 分钟", + "healPer10": "治疗/10 分钟", + "blockPer10": "格挡/10 分钟", + "noPlayers": "未找到玩家。" + }, + "stats": { + "selectedMeta": "已选择 · 第 {rank, number} 名 · {role}", + "sheetMeta": "第 {rank, number} 名 • {hero} • {role}", + "roles": { + "tank": "重装", + "damage": "输出", + "support": "支援" + }, + "snapshot": "快照", + "compositeSr": "综合 SR", + "percentileLabel": "百分位", + "maps": "地图", + "time": "时间", + "minutes": "{count, number} 分钟", + "hero": "英雄", + "srDistribution": "SR 分布", + "distributionDescription": "{playerName} 在该英雄钟形曲线中的位置。", + "performanceBreakdown": "表现分析", + "performanceDescription": "相对于排行榜平均值的 Z 分数。0 表示平均,正数高于平均,负数低于平均。大多数玩家位于 -2 到 +2 之间。", + "per10Minutes": "每 10 分钟", + "detailedStats": "详细数据(每 10 分钟)", + "statLabel": "{label}:", + "emptyTitle": "详情面板", + "emptyDescription": "选择一名玩家即可查看其 SR 分布、表现分析,以及相对于排行榜平均值的每 10 分钟数据。", + "zScore": "Z 分数", + "percentile": { + "top1": "前 1% · 精英", + "top5": "前 5% · 卓越", + "top10": "前 10% · 优秀", + "top25": "前 25% · 很好", + "aboveAverage": "高于平均", + "average": "平均", + "belowAverage": "低于平均" + }, + "per10": { + "eliminations": "消灭", + "finalBlows": "最后一击", + "soloKills": "单杀", + "deaths": "死亡", + "damage": "伤害", + "healing": "治疗", + "blocked": "格挡", + "ultimates": "终极技能" + } + }, + "distributionChart": { + "sr": "SR", + "rank": "排名", + "percentile": "百分位", + "notEnoughData": "数据不足,无法生成有意义的分布。至少需要 3 名玩家。", + "skillRatingAxis": "技术评分(SR)", + "frequencyAxis": "频率", + "achievedPotential": "已达成 · 潜力", + "distribution": "分布", + "meanLabel": "平均值({value, number})", + "otherPlayers": "其他玩家", + "selectedPlayer": "选中玩家", + "meanSr": "平均 SR", + "standardDeviation": "标准差", + "plusMinus": "±{value, number}", + "selectedPlayerSr": "选中玩家 SR" + } + }, + "subnav": { + "ariaLabel": "排行榜类型" + }, + "heroSelector": { + "selectHero": "选择英雄...", + "searchHero": "搜索英雄...", + "noHeroFound": "未找到英雄。", + "roles": { + "tank": "重装", + "damage": "输出", + "support": "支援" + } + }, + "tsr": { + "eyebrow": "排行榜", + "eyebrowComputed": "排行榜 · 计算于 {computedAt}", + "title": "锦标赛技术评分", + "description": "基于 FACEIT 举办的《守望先锋 2》锦标赛结果计算的 Elo 风格评分。按近期表现加权,并以达到过的最高级别校准。", + "stats": { + "active": "活跃", + "trackedPlayers": "已跟踪玩家", + "trackedMatches": "已跟踪比赛", + "topRating": "最高评分" + }, + "regions": { + "allRegions": "所有地区", + "na": "NA", + "emea": "EMEA", + "other": "其他" + }, + "tiers": { + "allTiers": "所有级别", + "open": "公开组", + "cah": "CAH", + "advanced": "高级组", + "expert": "专家组", + "masters": "大师组", + "owcs": "OWCS" + }, + "sortOptions": { + "rating": "评分", + "totalMatches": "总比赛数", + "recentActivity": "近期活跃度" + }, + "columns": { + "player": "玩家", + "region": "地区", + "peakTier": "最高级别", + "rating": "评分", + "matches": "比赛", + "lastSeen": "最近出现" + }, + "relative": { + "today": "今天", + "days": "{count}天", + "weeks": "{count}周", + "months": "{count}个月", + "years": "{count}年", + "justNow": "刚刚", + "minutesAgo": "{count}分钟前", + "hoursAgo": "{count}小时前", + "daysAgo": "{count}天前" + }, + "regionFilter": "地区筛选", + "searchPlaceholder": "搜索 BattleTag 或昵称", + "searchAria": "搜索玩家", + "sort": "排序", + "loading": "正在加载...", + "noPlayersForQuery": "没有玩家匹配“{query}”。", + "noActivePlayers": "没有活跃玩家匹配所选筛选条件。", + "showing": "显示 {visible, number} / {total, number}", + "loadMore": "加载更多", + "activeCount": "{visible, number} / {total, number} 名活跃玩家", + "inactive": "非活跃", + "recentMatchesWindow": "{count, number} · 近 {days, number} 天", + "detail": { + "selectedMeta": "已选择 · {region} · 最高 {tier}", + "rating": "评分", + "matchRecord": "比赛记录", + "wins": "胜场", + "losses": "负场", + "winRate": "胜率", + "lastNDays": "近 {days, number} 天", + "recordSummary": "{wins, number}胜 · {losses, number}负", + "tierBreakdown": "级别分布", + "contributingFactors": "贡献因素", + "factorRadarName": "因素", + "factorDescription": "在合理的人群范围内归一化为 0 到 1。近期权重采用 {days, number} 天半衰期。", + "topSwings": "最大波动", + "recentMatches": "近期比赛", + "won": "胜", + "lost": "负", + "score": "{first, number}-{second, number}", + "emptyTitle": "详情面板", + "emptyDescription": "选择一名玩家即可查看影响其评分的内容:级别阶梯、比赛记录、贡献因素,以及推动评分变化的比赛。", + "regions": { + "na": "NA", + "emea": "EMEA" + }, + "tiers": { + "open": "公开组", + "cah": "CAH", + "advanced": "高级组", + "expert": "专家组", + "masters": "大师组", + "owcs": "OWCS" + }, + "factors": { + "winRate": "胜率", + "recentActivity": "近期活跃度", + "tierStrength": "级别强度", + "marginOfVictory": "胜场优势", + "matchVolume": "比赛数量" + }, + "factorRaw": { + "recentActivity": "近 {days, number} 天 {count, number} 场", + "averageTier": "平均级别 {tier}", + "averageMultiplier": "平均 {value} 倍", + "matches": "{count, number} 场比赛" + }, + "tierLadder": { + "title": "级别阶梯", + "scale": "1-{max, number}", + "description": "可视化会放大到 {min, number}-{max, number},这是活跃锦标赛玩家实际所在的经验区间。完整量表为 1 到 {fullMax, number}。", + "ratingAria": "评分 {rating, number}" + } + } + } + }, + "profilePage": { + "layoutMetadata": { + "title": "{playerName} 的个人资料 | Parsertime", + "description": "在 Parsertime 查看 {playerName} 的个人资料和统计数据。", + "ogTitle": "{playerName} 的个人资料 | Parsertime", + "ogDescription": "在 Parsertime 查看 {playerName} 的个人资料和统计数据。", + "ogImage": "{playerName} 的个人资料" + }, + "hoverCard": { + "bannerAlt": "{playerName} 横幅", + "topHeroes": "常用英雄", + "timePlayed": "游戏时间", + "loading": "正在加载...", + "error": "加载数据时出错", + "empty": "暂无可用数据", + "viewProfile": "查看资料 →" + }, + "rankedTab": "排位" + }, + "notFound": { + "404": "404", + "header": "页面未找到", + "description": "抱歉,我们无法找到您要找的页面。", + "backHome": "返回主页", + "contact": "联系支持" + }, + "footer": { + "changelog": "点击查看更新日志", + "healthOk": "所有系统正常", + "healthDegraded": "性能下降", + "healthUnknown": "状态不可用", + "healthChecking": "正在检查状态…", + "healthCardTitle": "服务状态", + "healthStatusOperational": "正常运行", + "healthStatusDown": "已中断", + "healthServiceDatabase": "数据库", + "healthServiceDiscordBot": "Discord 机器人", + "workshopCode": "工坊代码", + "clickToCopy": "点击复制", + "copied": "已复制!", + "copiedDescription": "工坊代码已复制到剪贴板", + "navAriaLabel": "页脚导航", + "productTitle": "产品", "statsTitle": "统计数据", "teamsTitle": "团队", "supportTitle": "支持", @@ -2308,10 +6142,12 @@ "dataToolsTitle": "数据工具", "playerStats": "玩家统计", "heroStats": "英雄统计", + "mapStats": "地图统计", "teamStats": "团队统计", "comparePlayers": "玩家对比", "leaderboard": "排行榜", "yourTeams": "我的团队", + "availability": "可用时间", "joinTeam": "加入团队", "dashboard": "仪表板", "settings": "设置", @@ -2325,14 +6161,275 @@ "reports": "报告", "dataLabeling": "数据标注", "mapCalibration": "地图校准", + "coachingTitle": "教练", + "coachingCanvas": "画布", "tournamentsTitle": "锦标赛", "viewTournaments": "查看锦标赛", - "createTournament": "创建锦标赛" + "createTournament": "创建锦标赛", + "matchmaker": "Matchmaker" + }, + "mapCalibrationPage": { + "title": "地图校准", + "description": "为俯视地图图像校准坐标变换。每张地图都需要锚点,将世界坐标映射到图像像素。", + "mapTypes": { + "control": "控制", + "escort": "运载", + "flashpoint": "闪点", + "hybrid": "混合", + "push": "推进" + }, + "list": { + "searchPlaceholder": "搜索地图…", + "noMatches": "没有地图符合筛选条件。", + "status": { + "noImage": "无图像", + "calibrated": "已校准", + "anchors": "{count} 个锚点", + "imageUploaded": "已上传图像" + }, + "anchorSummary": "{count} 个锚点", + "transformSavedSuffix": " · 变换已保存" + }, + "anchorDialog": { + "title": "添加锚点", + "description": "图像位置:({imageU}, {imageV})。请输入对应的游戏内世界坐标。《守望先锋》使用 (X, Y, Z),其中 Y 是垂直轴;这里只输入 XZ。", + "worldX": "世界 X", + "worldZ": "世界 Z", + "worldXPlaceholder": "例如 42.5", + "worldZPlaceholder": "例如 -18.3", + "label": "标签(可选)", + "labelPlaceholder": "例如 A 点、出生点门口", + "cancel": "取消", + "addAnchor": "添加锚点" + }, + "anchorList": { + "empty": "还没有锚点。点击地图图像放置一个锚点。", + "label": "标签", + "worldCoordinates": "世界 (X, Z)", + "imageCoordinates": "图像 (U, V)", + "deleteAnchor": "删除锚点 {label}" + }, + "upload": { + "button": "上传图像", + "title": "上传地图图像", + "description": "为 {mapName} 上传俯视正交图像。支持 PNG 和 JPEG,最大 150MB。", + "selectFile": "选择地图图像文件", + "requestingUploadUrl": "正在请求上传 URL…", + "uploadingToStorage": "正在上传图像到存储…", + "processingImage": "正在处理图像…", + "uploading": "正在上传…", + "largeImageNote": "大图像可能需要一些时间。", + "uploadUrlError": "获取上传 URL 失败", + "storageUploadError": "上传图像到存储失败", + "processImageError": "处理图像失败", + "uploadError": "图像上传失败。" + }, + "editor": { + "back": "返回", + "noImageUploaded": "这张地图尚未上传图像。", + "anchorPoints": "{formattedCount} 个锚点", + "computeTransform": "计算变换", + "computing": "正在计算…", + "save": "保存", + "saving": "正在保存…", + "minimumAnchorsHint": "至少放置 3 个锚点才能计算变换。更多点可以提高精度。", + "createRecordError": "创建校准记录失败。", + "addAnchorError": "添加锚点失败。", + "deleteAnchorError": "删除锚点失败。", + "computeTransformError": "计算变换失败。", + "transformSaved": "变换已保存。", + "saveTransformError": "保存变换失败。", + "imageReplaced": "图像已替换,锚点已清除。", + "replaceImageError": "替换图像失败。" + }, + "transform": { + "title": "计算出的变换", + "saved": "已保存", + "unsaved": "未保存", + "scaleX": "X 缩放:", + "scaleY": "Y 缩放:", + "determinant": "行列式:", + "avgError": "平均误差:", + "pixelsPerUnit": "{value} px/单位", + "errorValue": "{percent}({pixels}px)", + "withinTolerance": "在高分辨率图像的预期容差范围内。" + }, + "preview": { + "title": "预览测试点", + "description": "输入世界坐标,确认它们是否投影到预期地图位置。从《守望先锋》的 (X, Y, Z) 中,只使用 XZ。", + "worldX": "世界 X", + "worldZ": "世界 Z", + "label": "标签", + "addTestPoint": "添加测试点", + "clear": "清除({count})" + }, + "canvas": { + "ariaLabel": "地图校准画布。点击放置锚点,拖动平移,滚动缩放。", + "loading": "正在加载地图图像…", + "gridOn": "网格开启", + "gridOff": "网格关闭", + "instructions": "{zoom} · 滚动缩放 · 拖动平移 · 点击放置" + }, + "replaceRender": { + "button": "Replace render", + "title": "Replace render for {mapName}", + "description": "Upload the new render, then paste the transform from the local alignment script. Your calibration is moved onto it — nothing changes until you confirm.", + "selectFile": "Select new render", + "uploading": "Uploading…", + "alignSummary": "{inliers} inliers · residual {residual}px", + "lowConfidence": "Low confidence — verify the anchor dots carefully before confirming.", + "staged": "Render uploaded. Run the alignment script locally and paste its output below.", + "scriptHint": "scripts/map-align ▸ uv run cli.py [old-image] [new-render]", + "transformLabel": "Alignment transform (JSON)", + "transformPlaceholder": "Paste the script's JSON output here", + "parseError": "Couldn't read that — paste the full JSON output of the alignment script.", + "showingOld": "Showing old image", + "showingNew": "Showing new render", + "compareTitle": "Compare old ↔ new:", + "compareBlink": "Blink", + "compareSwipe": "Swipe", + "confirm": "Confirm & apply", + "cancel": "Cancel", + "applying": "Applying…", + "applied": "Render replaced and calibration preserved.", + "applyError": "Failed to apply the new render." + } + }, + "credits": { + "title": "AI 聊天额度", + "description": "AI 分析师的即用即付余额。可随时充值,最低 {minimum}。", + "settingsDescription": "AI 分析师的即用即付余额。", + "currentBalance": "当前余额", + "balance": "余额", + "addCredits": "添加额度", + "custom": "自定义", + "topUp": "充值", + "manage": "管理", + "autoRefill": "自动充值", + "autoRefillDescription": "余额低于阈值时,使用已保存的银行卡扣款。", + "savePaymentMethodHint": "先添加一次额度,即可保存用于自动充值的付款方式。", + "refillWhenBelow": "低于此金额时充值", + "refillAmount": "充值金额", + "pricing": "价格", + "pricingDescription": "输入 token 每百万 {input},输出 token 每百万 {output}。包含基于底层模型费率的 {fee} 平台费用。", + "minimumTopup": "最低充值金额为 {amount}。", + "checkoutError": "无法开始结账。", + "autoRefillUpdateError": "更新自动充值失败。", + "invalidDollarAmount": "请输入有效的美元金额。", + "autoRefillSummary": " · 低于 {threshold} 时自动充值 {amount}", + "recentActivity": "近期活动", + "noTransactions": "暂无交易。可从 AI 聊天页面充值,或点击管理。" + }, + "coaching": { + "title": "教练画布", + "subtitle": "在地图上标注英雄站位、绘图和战术笔记。", + "eyebrow": "战术板", + "metadata": { + "title": "教练画布 | Parsertime", + "description": "在地图上标注英雄站位和战术绘图。" + }, + "sidebar": { + "team1": "队伍 1", + "team2": "队伍 2", + "tank": "坦克", + "damage": "输出", + "support": "支援" + }, + "toolbar": { + "select": "选择", + "pen": "画笔", + "arrow": "箭头", + "circle": "圆形", + "eraser": "橡皮擦", + "undo": "撤销", + "redo": "重做", + "reset": "重置", + "strokeWidth": "线条宽度", + "team1Color": "队伍 1 颜色", + "team2Color": "队伍 2 颜色", + "neutralColors": { + "white": "白色", + "black": "黑色", + "yellow": "黄色" + } + }, + "mapSelector": { + "placeholder": "选择地图", + "search": "搜索地图...", + "noResults": "未找到地图。", + "subMapPlaceholder": "选择点位", + "label": "地图" + }, + "reset": { + "title": "重置画布?", + "description": "这会清除所有英雄站位和绘图。此操作无法撤销。", + "confirm": "重置", + "cancel": "取消" + } + }, + "dataLabeling": { + "title": "数据标注", + "subtitle": "为 OWCS 锦标赛比赛标注团队阵容。", + "metadata": { + "title": "数据标注 | Parsertime", + "description": "为 OWCS 锦标赛比赛标注团队阵容。" + }, + "matchList": { + "title": "未标注比赛", + "date": "日期", + "teams": "队伍", + "score": "比分", + "tournament": "锦标赛", + "progress": "进度", + "previousPage": "上一页", + "nextPage": "下一页", + "pageInfo": "第 {current} 页,共 {total} 页", + "matchCount": "{count} 场比赛", + "fetchError": "获取比赛失败", + "errorLoading": "加载比赛出错:{message}", + "unknownError": "未知错误", + "noMatches": "未找到带 VOD 的未标注比赛。", + "vs": "对阵" + }, + "labeling": { + "backToList": "返回比赛列表", + "map": "地图 {number}", + "mapTabs": "地图", + "labeled": "已标注", + "unlabeled": "未标注", + "heroBans": "英雄禁用", + "noBans": "此地图没有禁用英雄。", + "team1Comp": "{team} 阵容", + "team2Comp": "{team} 阵容", + "tank": "坦克", + "damage": "输出", + "support": "支援", + "save": "保存", + "reset": "重置", + "saveAll": "保存所有地图", + "saving": "正在保存...", + "saved": "已保存!", + "saveFailed": "保存失败", + "saveSuccess": "团队阵容已成功保存。", + "saveError": "保存团队阵容失败。", + "roleConstraint": "选择 1 名坦克、2 名输出、2 名支援", + "heroBanned": "已禁用", + "playerAssignments": "{team} 玩家分配", + "assignPlayer": "为 {hero} 分配玩家", + "selectPlayer": "选择玩家...", + "twitchVodTitle": "Twitch VOD", + "noVodAvailable": "暂无可用 VOD", + "instructions": { + "title": "审核说明", + "body": "观看 VOD,并使用右侧面板记录每张地图的英雄阵容。对于每个英雄,请分配地图开始时使用该英雄的玩家。", + "swapNote": "只记录初始选择。地图中途的英雄更换无需记录,因为我们关注的是每支队伍的开局阵容,而不是根据对局做出的换人。" + } + } }, "demoPage": { "metadata": { "title": "{mapName} 演示 | Parsertime", - "description": "Parsertime 上 {mapName} 的演示概览。Parsertime 是一款用于分析《守望先锋》训练赛的工具。", + "description": "{mapName} 演示概览 — 通过示例数据探索 Parsertime 的《守望先锋》训练赛分析。", "ogTitle": "{mapName} 演示 | Parsertime", "ogDescription": "Parsertime 上 {mapName} 的演示概览。Parsertime 是一款用于分析《守望先锋》训练赛的工具。", "ogImage": "{mapName} 演示" @@ -2352,15 +6449,923 @@ "mark-all-as-read-error": "标记全部已读失败", "mark-as-read-error": "标记已读失败", "error-loading": "加载通知失败", + "error-loading-description": "请尝试刷新页面。", + "no-notifications-description": "通知到达时会显示在这里。", "marking-all-as-read": "更新中...", "mark-as-read": "标记已读", "delete": "删除", "delete-error": "删除通知失败", "notification-created": "通知创建成功", - "create-notification-error": "创建通知失败" + "create-notification-error": "创建通知失败", + "metadata": { + "title": "通知 | Parsertime", + "description": "您最新的 Parsertime 提醒和团队动态。" + } + }, + "positioningCard": { + "title": "站位", + "description": "基于坐标数据计算的站位统计。仅统计包含坐标追踪的地图。", + "mapsWithData": "{count} 张地图有数据", + "noData": "暂无站位数据。上传包含坐标追踪的训练赛后将显示统计。", + "engagementDistance": "平均交战距离", + "engagementDistanceHint": "击杀时与目标的平均距离。", + "highGroundKills": "高地击杀 %", + "highGroundKillsHint": "拥有高度优势时取得击杀的比例。", + "isolationDeaths": "孤立死亡 %", + "isolationDeathsHint": "死亡时 15 米内没有队友的比例。", + "fightStartSpread": "团战开始间距", + "fightStartSpreadHint": "团战开始时与队友的平均距离。" + }, + "titles": { + "DEVELOPER": "Parsertime 开发者", + "DEVELOPER-description": "授予为 Parsertime 开发做出贡献的用户。", + "EMPLOYEE": "lux.dev 员工", + "EMPLOYEE-description": "授予 lux.dev LLC 的员工。", + "BETA_TESTER": "Beta 测试员", + "BETA_TESTER-description": "授予已加入 Beta 测试名单的用户。", + "DAY_ONE_USER": "从一开始就在", + "DAY_ONE_USER-description": "授予从一开始就与我们同行的用户。此称号授予在发布日期(2024 年 4 月 14 日)之前注册的用户。", + "BASIC_PLAN_SUBSCRIBER": "支持者", + "BASIC_PLAN_SUBSCRIBER-description": "授予订阅基础方案的用户。订阅者还会在名称旁获得粉色爱心图标。", + "PREMIUM_PLAN_SUBSCRIBER": "高级支持者", + "PREMIUM_PLAN_SUBSCRIBER-description": "授予订阅高级方案的用户。订阅者还会在名称旁获得金色爱心图标。", + "VIP": "VIP", + "VIP-description": "授予对 Parsertime 社区做出重大贡献的用户。", + "HIGHEST_RANK_ON_A_HERO": "成就伟大", + "HIGHEST_RANK_ON_A_HERO-description": "授予在某位英雄上达到最高排名的用户。", + "HIGHEST_AJAX_COUNT": "Ajax 之王", + "HIGHEST_AJAX_COUNT-description": "授予达到最高 Ajax 次数的用户。", + "HIGHEST_FLETA_DEADLIFT_PERCENTAGE": "Fleta 的门徒", + "HIGHEST_FLETA_DEADLIFT_PERCENTAGE-description": "授予达到最高 Fleta deadlift 百分比的用户。", + "TOP_3_KILLS": "连续击杀者", + "TOP_3_KILLS-description": "授予进入击杀排行榜前三名的用户。", + "TOP_3_DAMAGE_DEALT": "伤害输出者", + "TOP_3_DAMAGE_DEALT-description": "授予进入造成伤害排行榜前三名的用户。", + "TOP_3_HEALING": "终极医护", + "TOP_3_HEALING-description": "授予进入治疗量排行榜前三名的用户。", + "TOP_3_DAMAGE_BLOCKED": "伤害海绵", + "TOP_3_DAMAGE_BLOCKED-description": "授予进入阻挡伤害排行榜前三名的用户。", + "TOP_3_DEATHS": "送头王", + "TOP_3_DEATHS-description": "授予进入死亡次数排行榜前三名的用户。", + "TOP_3_TIME_PLAYED": "长期在线", + "TOP_3_TIME_PLAYED-description": "授予进入游戏时长排行榜前三名的用户。出去走走吧!" + }, + "matchmaker": { + "title": "训练赛匹配工具", + "subtitle": "查找接近你实力水平的队伍", + "metadata": { + "title": "匹配工具 | Parsertime", + "description": "寻找阵容 TSR 接近的训练赛伙伴,使用与你在团队页面看到的同一套技术评分。" + }, + "hub": { + "eyebrow": "匹配工具", + "title": "寻找训练赛伙伴", + "description": "将你的阵容与实力相近的队伍匹配,查看他们在级别阶梯中的位置,并发送一条固定介绍消息。基于 Team TSR,与你在团队页面看到的量表一致。", + "mechanicsEyebrow": "机制 · 工作方式", + "mechanicsTitle": "按实力匹配、轻量、抗滥用", + "mechanicsDescription": "匹配工具是引荐通道,不是预约系统。你浏览队伍,发送一条固定请求,对方团队会在应用内和 Discord 上收到。", + "ranking": { + "label": "匹配排序依据", + "sameRegion": "优先相同 TSR 地区(NA / EMEA)", + "closestDistance": "TSR 绝对差距越小越靠前", + "sameBracket": "优先相同训练赛级别,其次相邻级别段", + "availabilityBonus": "本周可用时间重叠时加权", + "cooldownPenalty": "最近联系过的队伍会排到更后" + }, + "sent": { + "label": "发送内容", + "message": "一条不可自定义的消息,包含你的队伍级别和 TSR", + "roster": "阵容中前五名玩家的 BattleTag 和 TSR", + "delivery": "发送到所有者和管理员的应用内收件箱;如已配置,也会发送到 Discord" + }, + "rateLimits": { + "label": "频率限制", + "perPair": "每对队伍、每个方向每 24 小时 1 次请求", + "daily": "每支队伍每 24 小时最多 10 个发出请求" + }, + "teamPickerEyebrow": "选择队伍 · 开始", + "teamPickerTitle": "你要用哪套阵容搜索?", + "teamPickerDescription": "你所属的每支队伍都有自己的 Team TSR。选择要进行训练赛的队伍;只有拥有 Team TSR 的队伍才能开始搜索。", + "teamTsr": "TSR {rating, number}", + "noTeamTsr": "尚无 Team TSR", + "searchAs": "以 {teamName} 搜索", + "ineligibleTitle": "匹配前需要 Team TSR", + "notEligible": "暂不可用", + "emptyTitle": "你还不在任何队伍中", + "emptyDescription": "先加入或创建一支队伍,然后回到这里寻找训练赛伙伴。", + "manageTeams": "管理队伍 →", + "manageBlacklist": "管理黑名单", + "bracketWithBand": "{band}{tier}", + "bands": { + "low": "低", + "mid": "中", + "high": "高" + }, + "tiers": { + "unclassified": "未分级", + "open": "公开组", + "cah": "CAH", + "advanced": "高级组", + "expert": "专家组", + "masters": "大师组", + "owcs": "OWCS" + } + }, + "no-snapshot-title": "尚无 Team TSR", + "no-snapshot-description": "搜索训练赛伙伴前,你的队伍需要 Team TSR。请参加已跟踪的锦标赛,或等待下一次每日刷新。", + "back-to-team": "返回队伍", + "back-to-candidates": "← 返回候选队伍", + "no-candidates-title": "当前没有接近的队伍", + "no-candidates-description": "稍后再试,候选池每天刷新。", + "requests-remaining": "今日还剩 {count} / 10 个请求", + "searchingAs": "正在以 {teamName} 搜索", + "send-button": "发送训练赛请求", + "send-button-cooldown": "{duration} 后可用", + "send-button-limit": "已达到每日限制,将在 {duration} 后重置", + "send-button-limit-short": "已达到每日限制,24 小时后重置", + "send-button-not-manager": "只有管理员可以发送训练赛请求", + "send-button-recent": "过去 24 小时内已联系过", + "delta-positive": "+{value}", + "delta-negative": "−{value}", + "delta-zero": "±0", + "skill-deviation": "实力差距", + "bracket-comparison": "{from} 对 {to}", + "your-team": "你的队伍", + "their-team": "对方队伍", + "detail-eyebrow": "匹配工具 · 训练赛", + "availability-overlap-title": "可用时间重叠", + "availability-overlap": "本周重叠 {hours} 小时", + "availability-overlap-short": "重叠 {hours} 小时", + "availability-overlap-detail": "两支队伍本周有 {hours} 小时 的共同可用时间。", + "no-availability": "没有共同可用时间数据", + "sent-relative": "{when}已发送", + "relative": { + "justNow": "刚刚", + "minutesAgo": "{count}分钟前", + "hoursAgo": "{count}小时前", + "daysAgo": "{count}天前" + }, + "message-preview": "消息预览", + "request-message": "{fromTeamName}({fromBracketLabel} · TSR {fromTsr, number})正在寻找训练赛。与贵队阵容的 TSR 差距为 {delta}。", + "roster": "阵容", + "send-success": "训练赛请求已发送", + "send-error-409": "你最近已经联系过这支队伍。请 24 小时后再试。", + "send-error-422": "其中一支队伍不再拥有队伍 TSR。", + "send-error-429": "已达到每日限制。24 小时后重置。", + "send-error-generic": "无法发送训练赛请求。请重试。", + "bracket-with-band": "{band}{tier}", + "bands": { + "low": "低", + "mid": "中", + "high": "高" + }, + "tiers": { + "unclassified": "未分级", + "open": "公开组", + "cah": "CAH", + "advanced": "高级组", + "expert": "专家组", + "masters": "大师组", + "owcs": "OWCS" + } + }, + "teamTsr": { + "teamTsr": "队伍 TSR", + "teamCsr": "队伍 CSR", + "tsrShort": "TSR", + "ratingShort": "评分", + "title": "阵容技术评分", + "meta": "{ratingLabel} · {rosterSize, number} 人中 {rated, number} 人有评分 · {playtimeShare, number, percent} 上场时间有数据支撑", + "sources": { + "tsr": "真实 TSR", + "predicted": "预测", + "csrFallback": "CSR 兜底" + }, + "sourceCopy": { + "tsr": "按上场时间加权平均每位活跃首发的锦标赛评分。", + "predicted": "有评分的玩家使用真实 TSR,其余玩家根据团队 CSR 偏移预测。按训练赛上场时间加权。", + "csrFallback": "TSR 评分上场时间不足。当前显示按上场时间加权的每英雄 CSR,且与 TSR 不在同一量表。" + }, + "confidence": { + "high": "高置信度", + "medium": "中等置信度", + "low": "低置信度" + }, + "contribution": { + "tsr": "TSR", + "predicted": "预测", + "predictedShort": "预测", + "csr": "CSR", + "none": "—" + }, + "offsetSpread": "偏移离散度 σ {value, number}。", + "offsetSigma": "偏移 σ", + "rated": "已评分", + "backed": "有数据", + "empty": "添加已关联 BattleTag 的队员以计算阵容评分。", + "scrimBracket": "训练赛级别", + "findScrims": "寻找训练赛 →", + "playerTsr": "TSR {rating, number}", + "playerCsr": " · CSR {rating, number}", + "noTsr": "无 TSR", + "playtimeSummary": "{percent, number, percent} · {playtime}", + "playtimeHours": "{hours}小时", + "playtimeMinutes": "{minutes, number}分钟", + "bracketWithBand": "{band}{tier}", + "bands": { + "low": "低", + "mid": "中", + "high": "高" + }, + "tiers": { + "unclassified": "未分级", + "open": "公开组", + "cah": "CAH", + "advanced": "高级组", + "expert": "专家组", + "masters": "大师组", + "owcs": "OWCS" + } + }, + "teamOps": { + "title": "队伍管理", + "blacklist": { + "subtitle": "你不想约训练赛的队伍。当黑名单中的队伍也在 Parsertime 时,双方在匹配工具中互相隐藏,且均无法发送训练赛请求。", + "heading": "黑名单", + "addPlaceholder": "将队伍添加到黑名单", + "searchPlaceholder": "搜索队伍或输入名称", + "noMatches": "没有匹配的队伍", + "blockOffPlatform": "屏蔽\"{name}\"", + "offPlatform": "平台外", + "onPlatform": "已在 Parsertime", + "blocking": "正在屏蔽 {name}", + "reasonPlaceholder": "原因(可选)", + "confirmAdd": "加入黑名单", + "cancel": "取消", + "empty": "暂无黑名单队伍。在上方添加后,系统将不再为你匹配该队伍。", + "removeNamed": "将 {name} 从黑名单中移除", + "addError": "无法添加该队伍,请重试。", + "removeError": "无法移除该队伍,请重试。" + }, + "feedback": { + "prompt": "你与 {name} 打了一场训练赛,感觉怎么样?", + "good": "打得不错", + "okay": "还行", + "poor": "较差 — 加入黑名单", + "skip": "跳过", + "reasonPlaceholder": "原因(可选)", + "confirmBlacklist": "加入黑名单", + "error": "无法保存你的反馈,请重试。" + } }, "teamStatsPage": { + "layoutMetadata": { + "defaultTeam": "团队", + "title": "{teamName} 统计 | Parsertime", + "description": "Parsertime 上 {teamName} 的统计数据。Parsertime 是一款用于分析《守望先锋》训练赛的工具。", + "ogTitle": "{teamName} 统计 | Parsertime", + "ogDescription": "Parsertime 上 {teamName} 的统计数据。Parsertime 是一款用于分析《守望先锋》训练赛的工具。", + "ogImage": "{teamName} 统计" + }, + "selector": { + "select": "选择团队..." + }, + "tempoRead": { + "faster": "快于平均", + "about": "接近平均", + "slower": "慢于平均", + "delta": "(对比平均 {delta} 秒)" + }, "rangePicker": { + "selectTimeframe": "选择时间范围", + "lastWeek": "过去一周", + "last2Weeks": "过去两周", + "lastMonth": "过去一个月", + "last3Months": "过去三个月", + "last6Months": "过去六个月", + "lastYear": "过去一年", + "allTime": "所有时间", + "customRange": "自定义范围", + "upgrade": "升级以查看更多时间范围", + "pickDateRange": "选择日期范围", + "loading": "正在加载新时间范围" + }, + "insufficientScrimsPlaceholder": { + "title": "至少需要两场训练赛", + "description": "团队统计需要至少两场训练赛,才能可靠识别您的队伍在每张地图中所在的阵营。您当前已上传 {count, plural, =0 {0 场训练赛} other {# 场训练赛}}。再上传一场即可解锁完整统计仪表板。", + "uploadScrim": "上传训练赛" + }, + "simulatorTab": { + "header": { + "eyebrow": "模拟器 · 胜率", + "title": "胜率模拟器" + }, + "noData": "所选时间段内没有游戏数据。进行一些训练赛后即可解锁模拟器。", + "stats": { + "mapsTracked": "已追踪地图", + "historicalSample": "历史样本", + "baseWinrate": "基础胜率", + "beforeScenario": "场景应用前", + "heroesScored": "已评分英雄", + "mapsScored": "已评分地图", + "availablePicks": "可选项" + }, + "scenario": { + "eyebrow": "模拟器 · 场景", + "title": "场景设置", + "description": "配置禁用、地图和阵容,查看预计胜率如何变化。", + "reset": "重置", + "resetAria": "重置所有场景输入", + "enemyBans": { + "label": "敌方针对我方的禁用", + "description": "对手从你的英雄池中禁用的英雄" + }, + "ourBans": { + "label": "我方禁用", + "description": "你方针对对手禁用的英雄" + }, + "ourComposition": { + "label": "我方阵容", + "description": "最多选择 5 名我方英雄" + }, + "enemyComposition": { + "label": "敌方阵容", + "description": "最多选择 5 名敌方英雄" + } + }, + "heroPicker": { + "addHero": "添加英雄", + "label": "英雄选择器", + "selectHero": "选择 {hero}", + "removeHero": "移除 {hero}", + "removeTooltip": "{hero}(点击移除)", + "roles": { + "Tank": "坦克", + "Damage": "输出", + "Support": "支援" + } + }, + "mapPicker": { + "label": "地图", + "description": "选择正在进行的地图", + "selectAria": "选择地图", + "placeholder": "选择地图...", + "searchPlaceholder": "搜索地图...", + "noMapsFound": "未找到地图。", + "noneSelected": "未选择地图", + "mapTypes": { + "Clash": "攻防争锋", + "Control": "占领要点", + "Escort": "运载目标", + "Flashpoint": "闪点作战", + "Hybrid": "攻击/护送", + "Push": "机动推进" + } + }, + "prediction": { + "eyebrow": "模拟器 · 预测", + "title": "预计胜率", + "description": "基于你方 {count, plural, =0 {无地图} other {# 张地图}} 的历史数据", + "estimatedWinRateAria": "预计胜率:{value}", + "confidence": { + "high": "高置信度", + "medium": "中等置信度", + "low": "低置信度" + }, + "deltaVsBase": "相对基础值({base}){direction, select, positive {+{delta}pp} negative {-{delta}pp} other {{delta}pp}}", + "warningsLabel": "警告", + "emptyScenario": "在左侧配置场景,即可查看各参数如何影响你的获胜概率。", + "warnings": { + "enemyBanLowSample": "{hero} 只被禁用了 {samples, plural, =0 {0 次} other {# 次}},影响估计可能不稳定", + "ourBanLowSample": "你方只禁用过 {hero} {samples, plural, =0 {0 次} other {# 次}},影响估计可能不稳定", + "mapLowSample": "{map} 只有 {samples, plural, =0 {0 场比赛} other {# 场比赛}},地图影响估计可能不稳定", + "mapModeFallback": "没有 {map} 的数据,改用 {mapType} 模式平均值", + "rosterLowSample": "该阵容组合只有 {games, plural, =0 {0 场记录比赛} other {# 场记录比赛}}", + "enemyHeroLowSample": "对阵敌方 {hero} 只有 {samples, plural, =0 {0 场比赛} other {# 场比赛}},影响估计可能不稳定" + }, + "insights": { + "enemyBanHurts": "{hero} 被对手禁用会让你方胜率降低 {delta}%", + "ourBanHelps": "禁用 {hero} 可带来 +{delta}%,是你方最强禁用选择", + "mapStrength": "{map} 对你的队伍来说是{direction, select, strong {强势地图} weak {弱势地图} other {普通地图}}({delta}%)", + "compositionImpact": "我方阵容会让获胜机会{direction, select, boosts {提高} reduces {降低} other {变化}} {delta}%", + "enemyCompositionImpact": "敌方阵容会让局面对你方{direction, select, favors {更有利} challenges {更不利} other {产生影响}} {delta}%" + }, + "mapTypes": { + "Clash": "攻防争锋", + "Control": "占领要点", + "Escort": "运载目标", + "Flashpoint": "闪点作战", + "Hybrid": "攻击/护送", + "Push": "机动推进" + } + }, + "breakdown": { + "heading": "细分", + "listAria": "胜率细分", + "baseWinrate": "基础胜率", + "enemyBanImpact": "敌方禁用影响", + "ourBans": "我方禁用", + "map": "地图", + "composition": "阵容", + "enemyComp": "敌方阵容", + "impactPercent": "{direction, select, positive {+{value}%} negative {-{value}%} other {{value}%}}", + "barScale": "柱状比例:最大 ±{scale}pp。柱状条上限为 ±{max}pp。" + } + }, + "ultimateEconomyCard": { + "eyebrow": "终极技能 · 经济", + "title": "终极技能经济", + "noData": "暂无终极技能使用数据。", + "description": "衡量您的团队使用终极技能的效率", + "ultimateEfficiency": "终极技能效率", + "fightsWonPerUltUsed": "每次终极技能带来的获胜战斗", + "wastedUltimates": "浪费的终极技能", + "wastePercentage": "占总终极技能 {percentage}", + "usedInLostSituations": "在已失利局面中使用", + "totalUltimates": "终极技能总数", + "acrossFights": "覆盖 {fights} 场战斗", + "ultimatesPerFight": "每场战斗 {ultimates} 个", + "excellent": "优秀", + "good": "良好", + "average": "一般", + "poor": "较差", + "ultimateUsage": "终极技能使用:获胜战斗 vs 失利战斗", + "outcome": "结果", + "fightsHeader": "战斗", + "avgUltsUsedHeader": "平均终极技能使用", + "detail": "详情", + "winningFights": "获胜战斗", + "fights": "{count, plural, other {# 场战斗}}", + "averageUltsUsed": "平均终极技能使用", + "losingFights": "失利战斗", + "goodUltDiscipline": "良好的终极技能纪律:您在获胜时使用的终极技能({ultsInWonFights})多于失利时({ultsInLostFights}),说明您会避免在已失利的战斗中浪费资源。", + "roomForImprovement": "仍有改进空间:您在失利时使用的终极技能({ultsInLostFights})多于获胜时({ultsInWonFights})。可以考虑在劣势局面中保留终极技能。", + "context": "背景", + "bucket": "分组", + "winrate": "胜率", + "reversals": "逆转", + "dryFightsRow": "无终极技能战斗", + "ultFightsRow": "有终极技能战斗", + "avgAbbreviation": "平均", + "dryFights": "无终极技能战斗:", + "dryFightWinrate": "{count} 场无终极技能战斗(胜率 {winrate}%)", + "nonDryFights": "有终极技能战斗:", + "nonDryFightsCount": "{count} 场有终极技能战斗(平均 {ults} 个终极技能)", + "fightReversals": "战斗逆转", + "dryReversalRate": "无终极技能:{rate}%({count}/{total})", + "nonDryReversalRate": "有终极技能:{rate}%({count}/{total})", + "reversalInsightUltReliant": "您的团队在使用终极技能时({nonDryRate})比不使用时({dryRate})更常逆转战斗,说明翻盘更依赖终极技能。", + "reversalInsightMechanical": "您的团队在不使用终极技能时({dryRate})比使用时({nonDryRate})更常逆转战斗,展现出较强的基础翻盘能力。" + }, + "ultimatesTab": { + "overview": { + "eyebrow": "终极技能 · 使用概览", + "title": "终极技能使用概览", + "noData": "暂无终极技能使用数据。", + "description": "汇总自 {maps} 张地图", + "totalUltsUsed": "终极技能使用总数", + "ultsPerMap": "平均每张地图 {count} 次", + "avgChargeTime": "平均充能时间", + "avgHoldTime": "平均持有时间", + "vsOpponents": { + "title": "对比对手 ({maps} 张地图)", + "you": "我方", + "opponents": "对手", + "readFaster": "比对手快 {delta} 秒", + "readSlower": "比对手慢 {delta} 秒", + "readEven": "与对手相当", + "youFaster": "我方快 {delta} 秒", + "youSlower": "我方慢 {delta} 秒", + "youEven": "相当", + "better": "更好", + "worse": "更差", + "unnamed": "未命名对手", + "colOpponent": "对手", + "colAvg": "平均", + "colDelta": "对比我方", + "colMaps": "地图" + }, + "fightInitiation": "战斗发起率", + "fightInitiationDetail": "{total} 场终极技能战斗中的 {count} 场", + "metric": "指标", + "value": "值", + "read": "解读", + "fightOpenings": "战斗发起", + "topFightOpeners": "主要战斗发起英雄", + "topOpenersLabel": "最常发起战斗:{top}({count} 场)" + }, + "roleBreakdown": { + "eyebrow": "终极技能 · 角色分布", + "title": "按职责划分的终极技能使用", + "noData": "暂无终极技能使用数据。", + "description": "终极技能在各职责中的分布,以及它们在战斗中的使用时机", + "percentage": "占总数 {value}%", + "role": "职责", + "ultsUsed": "已用终极技能", + "share": "占比", + "subroles": "子职责", + "timingTitle": "终极技能时机分布", + "timingDescription": "终极技能在战斗中的使用时机:开战(前三分之一)、中段(中间三分之一)或后段(后三分之一)。", + "yourTeam": "您的团队" + }, + "playerRankings": { + "eyebrow": "终极技能 · 玩家排名", + "title": "玩家终极技能排名", + "noData": "暂无玩家数据。", + "description": "所有训练赛中的个人终极技能使用情况", + "tableLabel": "玩家终极技能排名", + "player": "玩家", + "hero": "主要英雄", + "totalUlts": "终极技能总数", + "mapsPlayed": "地图", + "ultsPerMap": "每图终极技能", + "fightOpener": "战斗发起" + }, + "combos": { + "eyebrow": "终极技能 · 连携", + "title": "终极技能连携", + "description": "在 {maps} 张地图中,彼此间隔 {window} 秒内使用的双终极技能连携。", + "noData": "暂无双终极技能连携。在一场战斗中于 {window} 秒内使用两个终极技能后,它们会显示在这里。", + "metricLabel": "连携指标", + "mostUsed": "使用最多", + "winRate": "胜率", + "captionMostUsed": "使用最多的连携", + "captionWinRate": "最佳胜率(至少使用 {min} 次)", + "winrateNeedsSamples": "暂无连携至少使用 {min} 次,因此无法按胜率排名。", + "rowSummary": "{heroA} 和 {heroB}:在 {count} 场战斗中使用,胜率 {winrate}%。", + "sampleSize": "n={count}", + "lowSampleNote": "使用次数少于 {min} 次的连携会从胜率排名中隐藏。" + }, + "responses": { + "eyebrow": "终极技能 · 敌方应对", + "title": "应对敌方终极技能", + "description": "在 {maps} 张地图中,敌方终极技能后 {window} 秒内我方使用的终极技能。", + "noData": "暂无反制终极技能。当我方在敌方终极技能后 {window} 秒内回应时,会显示在这里。", + "viewLabel": "应对视图", + "matrix": "矩阵", + "flow": "流向", + "enemyAxis": "敌方终极技能", + "ourAxis": "我方应对", + "matrixHint": "单元格颜色表示应对频率;悬停可查看胜率。", + "cellSummary": "用 {ourHero} 应对 {enemyHero} {count} 次,胜率 {winrate}%。", + "selectEnemyUlt": "敌方终极技能", + "allEnemyUlts": "所有敌方终极技能", + "searchEnemyUlts": "搜索敌方终极技能", + "noEnemyUlts": "未找到敌方终极技能。", + "flowSummary": "{ourHero} 应对 {enemyHero} {count} 次(胜率 {winrate}%)。", + "noResponsesForHero": "没有追踪到对此敌方终极技能的应对。" + }, + "ultAdvantage": { + "eyebrow": "终极技能 · 优势", + "title": "终极技能优势", + "description": "在 {maps} 张地图中,进入战斗时我方终极技能储备相对敌方的情况。", + "noData": "终极技能充能数据不足,暂无法追踪终极技能优势。", + "avgAdvantage": "平均终极技能优势", + "avgAdvantageSub": "进入战斗时相对敌方的终极技能数量", + "winWhenAhead": "领先时胜率", + "winWhenBehind": "落后时胜率", + "ofFights": "占战斗 {pct}%", + "behind": "落后", + "even": "持平", + "ahead": "领先", + "distributionCaption": "进入战斗时的状态分布...", + "winrateCaption": "按终极技能优势划分的胜率", + "bucketBehind2": "落后 2+", + "bucketBehind1": "落后 1", + "bucketEven": "持平", + "bucketAhead1": "领先 1", + "bucketAhead2": "领先 2+", + "noFights": "无战斗", + "fightsCount": "{count, plural, other {# 场战斗}}", + "tempoCaption": "每场战斗的平均终极技能优势", + "fightTick": "战斗 {n}", + "tempoHint": "高于 0 表示进入战斗时我方持有的终极技能多于敌方。", + "shapeCaption": "按优势划分的战斗数", + "fightsAxis": "战斗" + } + }, + "swapsTab": { + "overview": { + "eyebrow": "换人 · 概览", + "title": "英雄更换概览", + "noData": "暂无英雄更换数据。", + "description": "汇总自 {maps} 张地图", + "totalSwaps": "更换总数", + "swapsPerMap": "平均每张地图 {count} 次", + "winrateDelta": "胜率影响", + "winrateDeltaPositive": "更换时 +{delta}", + "winrateDeltaNegative": "更换时 {delta}", + "winrateDeltaNeutral": "无差异", + "noSwapWinrate": "未更换胜率", + "noSwapDetail": "未更换时 {wins}胜 - {losses}负", + "swapWinrate": "更换胜率", + "swapDetail": "有更换时 {wins}胜 - {losses}负", + "avgTimeBeforeSwap": "英雄平均使用时间", + "avgTimeDetail": "更换前使用该英雄的平均时间", + "durationSeconds": "{seconds} 秒", + "durationMinutes": "{minutes} 分 {seconds} 秒" + }, + "timing": { + "eyebrow": "换人 · 时机", + "title": "更换时机分布", + "description": "英雄更换在比赛时间中的发生位置,以总比赛时间百分比表示", + "noData": "暂无更换时机数据。", + "swapCount": "{count, plural, other {# 次更换}}", + "swapsLabel": "更换", + "matchTime": "比赛时间:{label}", + "ofTotal": "占全部更换 {pct}%" + }, + "winrate": { + "eyebrow": "换人 · 胜率影响", + "title": "按更换行为划分的胜率", + "descriptionCount": "根据每张地图的更换次数展示胜率变化", + "descriptionTiming": "包含早期、中期或后期更换的地图胜率", + "noData": "暂无更换胜率数据。", + "byCount": "按更换次数", + "byTiming": "按更换时机", + "maps": "{count} 张地图", + "winrate": "{pct}", + "winRateLabel": "胜率" + }, + "pairs": { + "eyebrow": "换人 · 常见组合", + "title": "最常见英雄更换", + "description": "所有地图中最常见的英雄到英雄更换路径", + "noData": "暂无更换组合数据。", + "count": "{count, plural, other {# 次}}", + "times": "次数", + "timingLabel": "比赛内时机" + }, + "player": { + "eyebrow": "换人 · 玩家分析", + "title": "玩家更换分析", + "description": "各玩家的英雄更换统计及其对胜率的影响", + "noData": "暂无玩家更换数据。", + "tableLabel": "玩家更换统计", + "player": "玩家", + "swaps": "更换", + "mapsWithSwaps": "有更换地图", + "winrateWith": "有更换胜率", + "winrateWithout": "无更换胜率", + "topPair": "主要更换" + } + }, + "positional": { + "eyebrow": "站位", + "title": "站位数据", + "description": "基于最近 {count} 场包含站位数据的训练赛取平均。", + "trendsTitle": "趋势", + "playersTitle": "选手分项", + "playerColumn": "选手", + "matrix": { + "below": "低于中位数", + "above": "高于中位数", + "legend": "色调表示每个数值相对于该项数据阵容中位数的偏差。" + }, + "byZoneTitle": "按区域划分的胜 / 负 / 平", + "empty": "暂无站位数据。上传包含坐标追踪的训练赛后将显示统计。", + "stats": { + "AVERAGE_ENGAGEMENT_DISTANCE": { + "short": "交战距离", + "full": "平均交战距离" + }, + "HIGH_GROUND_KILL_PERCENTAGE": { + "short": "高地击杀 %", + "full": "高地击杀 %" + }, + "ISOLATION_DEATH_PERCENTAGE": { + "short": "孤立死亡 %", + "full": "孤立死亡 %" + }, + "AVERAGE_FIGHT_START_SPREAD": { + "short": "开战间距", + "full": "团战开始间距" + }, + "AVERAGE_ULT_CONVERSION_KILLS": { + "short": "转化击杀", + "full": "终极技能转化击杀" + }, + "ULT_DEATH_PERCENTAGE": { + "short": "开大阵亡 %", + "full": "使用终极技能时阵亡 %" + }, + "AVERAGE_ULT_DISPLACEMENT": { + "short": "位移", + "full": "平均终极技能位移" + }, + "ULTS_ON_OBJECTIVE_PERCENTAGE": { + "short": "点上大招 %", + "full": "争夺点上的终极技能 %" + } + }, + "engagementsTitle": "交战", + "winrate": "胜率", + "fights": "{total, plural, other {# 次交战}}", + "recordSummary": "{won}胜 / {lost}负 / {even}平", + "wonLabel": "胜", + "lostLabel": "负", + "evenLabel": "平", + "zonesTitle": "各地图区域控制", + "zonesYourTeam": "我方", + "zonesOpponents": "对手", + "zonesCount": "{count} 个区域", + "zonesControlPct": "{pct}% 控制", + "routesTitle": "各地图路线", + "routesSummary": "{total} 条路线 · {won} 胜 / {lost} 负", + "routesUndecided": "未定", + "zoneColumn": "区域", + "teamColumn": "队伍", + "killsColumn": "击杀", + "deathsColumn": "死亡", + "ultsColumn": "终极技能" + }, + "roleBalanceRadar": { + "eyebrow": "概览 · 职责平衡", + "title": "职责平衡", + "noData": "还没有足够数据来分析职责平衡。", + "tooltipTitle": "职责平衡", + "eliminations": "击杀", + "survivability": "生存能力", + "ultUsage": "终极技能使用", + "activity": "活跃度", + "balanced": "平衡", + "tankHeavy": "偏重坦克", + "damageHeavy": "偏重输出", + "supportHeavy": "偏重支援", + "insufficientData": "数据不足", + "tank": "坦克", + "damage": "输出", + "support": "支援", + "insights": "洞察", + "excellentBalance": "你的队伍在所有职责上都非常平衡。", + "fairlyBalanced": "你的队伍整体较为平衡,但仍有轻微职责差异。", + "considerStrengthening": "建议加强 {role} 表现,以改善队伍平衡。", + "negativeKD": "{role} 玩家 K/D 为负。", + "dyingFrequently": "{role} 玩家死亡较频繁 - 请专注于站位。", + "strongest": "最强职责:", + "needsWork": "需要加强:", + "balanceScore": "平衡分数:", + "roles": { + "Tank": "坦克", + "Damage": "输出", + "Support": "支援" + } + }, + "overviewInsightsBand": { + "eyebrow": "概览 · 关键解读", + "title": "影响胜负的因素", + "noInsights": "数据还不足,无法生成关键洞察。", + "strongestRole": "最强职责", + "needsWork": "需要加强", + "strongestMap": "最强地图", + "bleedSpot": "薄弱地图", + "bestDay": "最佳星期", + "firstPickRate": "首杀转化率", + "roleDetail": "{kd} K/D · 每 10 分钟死亡 {deaths}", + "mapWinrate": "胜率 {winrate}", + "bestDayDetail": "在 {count, plural, =0 {0 场比赛} other {# 场比赛}} 中 {winrate}", + "firstPickDetail": "{total} 次中成功收下 {successful} 次", + "days": { + "sunday": "星期日", + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六" + } + }, + "matchupWinrateTab": { + "header": { + "eyebrow": "胜率 · 对局", + "title": "英雄对局胜率" + }, + "noData": "所选时间段内没有游戏数据。进行一些训练赛后即可解锁对局分析。", + "stats": { + "scrimsTracked": "已追踪训练赛", + "maps": "{count, plural, =0 {无地图} other {# 张地图}}", + "bestMatchup": "最佳对局", + "worstMatchup": "最差对局", + "matchupLabel": "对阵 {hero}({count, plural, other {# 张地图}})", + "needSharedMaps": "需要 3 张以上共同地图", + "compositions": "阵容", + "uniqueFiveStacks": "不同五人阵容" + }, + "explorer": { + "eyebrow": "胜率 · 对局", + "title": "英雄对局探索器", + "description": "选择双方英雄来限定分析范围。所选英雄必须同时出现在同一张地图中才会计入。", + "clear": "清除", + "clearAria": "清除所有英雄选择", + "ourHeroes": "我方英雄", + "ourHeroesDescription": "你的队伍使用过的英雄", + "enemyHeroes": "敌方英雄", + "enemyHeroesDescription": "对手使用过的英雄", + "versus": "vs" + }, + "heroPicker": { + "addHero": "添加英雄", + "label": "英雄选择器", + "selectHero": "选择 {hero}", + "removeHero": "移除 {hero}", + "removeTooltip": "{hero}(点击移除)", + "full": "(已满)", + "slotFullAria": "{hero}({role}位置已满)", + "slotFullTooltip": "{hero}({role}位置已满)", + "roles": { + "Tank": "坦克", + "Damage": "输出", + "Support": "支援" + } + }, + "summary": { + "eyebrow": "胜率 · 对局摘要", + "filteredTitle": "筛选后对局", + "overallTitle": "整体战绩", + "filteredDescription": "基于 {count, plural, =0 {无匹配地图} other {# 张匹配地图}}", + "overallDescription": "基于全部 {count, plural, =0 {无已追踪地图} other {# 张已追踪地图}}", + "noFilteredMaps": "没有找到符合此对局组合的地图。", + "selectPrompt": "在上方选择英雄以探索对局数据。", + "winRateAria": "胜率:{value}", + "confidence": { + "high": "高置信度", + "medium": "中等置信度", + "low": "低置信度" + }, + "record": "{wins}胜 / {losses}负({count, plural, =0 {无地图} other {# 张地图}})", + "roughlyEqual": "与基础胜率大致相同", + "deltaVsBase": "相对基础值({base}){direction, select, above {高 +{delta}pp} below {低 -{delta}pp} other {{delta}pp}}" + }, + "trend": { + "heading": "累计胜率趋势", + "now": "当前 {winrate}", + "trendDelta": "{direction, select, positive {(+{delta}pp 趋势)} negative {(-{delta}pp 趋势)} other {({delta}pp 趋势)}}", + "cumulative": "累计", + "recordShort": "({wins}胜 / {losses}负)", + "scrimRecord": "{scrimName}:{wins}胜 / {losses}负({winrate})", + "winPercentAxis": "胜率 %", + "trendName": "趋势", + "cumulativeWinrateName": "累计胜率", + "notEnoughData": "数据不足,无法显示趋势。至少需要 2 场训练赛。" + }, + "compositions": { + "eyebrow": "胜率 · 阵容", + "title": "最佳阵容", + "description": "此对局中表现最好的五人阵容(至少 2 场)。", + "tableAria": "最佳阵容", + "rank": "排名", + "composition": "阵容", + "winrate": "胜率", + "record": "战绩", + "winsLosses": "{wins}胜 / {losses}负", + "noData": "数据不足,无法识别最佳阵容。" + }, + "results": { + "eyebrow": "胜率 · 比赛结果", + "title": "比赛结果", + "description": "此对局的单张地图结果。", + "date": "日期", + "scrim": "训练赛", + "map": "地图", + "result": "结果", + "ourHeroes": "我方英雄", + "enemyHeroes": "敌方英雄", + "winShort": "胜", + "lossShort": "负", + "noMaps": "未找到匹配地图。" + } + }, + "mapWinrateGallery": { + "eyebrow": "地图 · 胜率图库", + "title": "地图表现", + "noData": "还没有地图数据。进行一些比赛后即可查看队伍的地图表现!", + "mapFilters": "地图筛选", + "clearFilters": "清除筛选", + "searchMaps": "搜索地图", + "searchMapNames": "搜索地图名称...", + "mapType": "地图类型", + "allTypes": "所有类型", + "sortBy": "排序方式", + "sortByPlaceholder": "选择排序方式...", + "highestWinrate": "最高胜率", + "mostPlayed": "最多游玩", + "alphabetical": "按字母顺序", + "showingMaps": "显示 {count} / {total} 张地图", + "noMapsMatchFilters": "没有地图符合当前筛选条件。请尝试调整搜索或筛选。", + "gamesLabel": "{count, plural, =0 {未进行比赛} other {已进行 # 场比赛}}", + "clickToSeeRosterPerformance": "点击任意地图查看阵容表现详情", + "winsShort": "{count}胜", + "lossesShort": "{count}负", + "mapTypes": { + "Clash": "攻防争锋", + "Control": "占领要点", + "Escort": "运载目标", + "Flashpoint": "闪点作战", + "Hybrid": "攻击/护送", + "Push": "机动推进" + } + }, + "heroPoolContainer": { "selectTimeframe": "选择时间范围", "lastWeek": "过去一周", "last2Weeks": "过去两周", @@ -2372,15 +7377,520 @@ "customRange": "自定义范围", "upgrade": "升级以查看更多时间范围", "pickDateRange": "选择日期范围" + }, + "heroPoolOverviewCard": { + "eyebrow": "英雄 · 英雄池概览", + "title": "英雄池概览", + "noData": "还没有英雄池数据。", + "excellent": "优秀", + "good": "良好", + "average": "一般", + "limited": "有限", + "totalHeroes": "英雄总数", + "effectivePool": "有效英雄池", + "diversityScore": "多样性分数", + "minGames": "3 场以上", + "specialists": "专精英雄", + "minOwnership": "30% 以上使用占比", + "mostPlayedByRoleEyebrow": "英雄 · 按职责最常使用", + "mostPlayedByRole": "按职责最常使用", + "gamesPlayed": "{count, plural, =0 {无比赛} other {# 场比赛}}", + "players": "{count, plural, =0 {无玩家} other {# 名玩家}}", + "roleDistribution": "职责分布", + "tankHeroes": "坦克英雄", + "dpsHeroes": "输出英雄", + "supportHeroes": "支援英雄", + "roles": { + "Tank": "坦克", + "Damage": "输出", + "Support": "支援" + } + }, + "heroWinratesCard": { + "eyebrow": "英雄 · 最高胜率", + "title": "英雄最高胜率", + "noData": "游戏场次还不足,无法计算英雄胜率(每个英雄至少需要 3 场)。", + "winsAndLosses": "{wins}胜 - {losses}负 • {games, plural, =0 {无比赛} other {# 场比赛}}" + }, + "heroBanCards": { + "received": { + "eyebrow": "英雄 · 禁用影响", + "emptyTitle": "英雄禁用影响", + "title": "最常被禁英雄", + "noData": "所选时间段内暂无英雄禁用数据。", + "description": "在 {maps, number} 张地图中,对手最常针对您队伍禁用的英雄。弱点标签表示该英雄缺席时您的胜率会下降。" + }, + "outgoing": { + "eyebrow": "英雄 · 我方禁用", + "title": "我方禁用策略", + "noData": "所选时间段内暂无我方禁用数据。", + "description": "在 {maps, number} 张地图中,您的队伍最常禁用的英雄。高价值标签表示最能提升胜率的禁用选择。" + }, + "table": { + "hero": "英雄", + "bans": "禁用", + "distribution": "分布", + "banRate": "禁用率", + "wrWith": "有该英雄胜率", + "wrWithout": "无该英雄胜率", + "wrBanned": "禁用时胜率", + "wrNotBanned": "未禁用时胜率", + "wrDelta": "胜率差", + "tag": "标签" + }, + "tags": { + "weakPoint": "弱点", + "highValue": "高价值" + } + }, + "abilityImpactAnalysisCard": { + "eyebrow": "团战 · 技能影响", + "title": "技能影响", + "description": "查看特定英雄技能如何影响训练赛中的团战结果。", + "selectHero": "选择英雄", + "searchHeroes": "搜索英雄", + "noHeroesFound": "未找到英雄。", + "percentagePoints": "{value}pp", + "sampleCount": "({count, number})", + "roles": { + "Tank": "坦克", + "Damage": "输出", + "Support": "支援" + }, + "table": { + "ability": "技能", + "fights": "团战", + "withWr": "使用时胜率", + "withoutWr": "未使用时胜率", + "delta": "差值", + "tag": "标签" + }, + "tags": { + "key": "关键", + "risk": "风险" + }, + "smallSample": "{hero} 的样本较小,结果可能不具备统计显著性。", + "selectHeroPrompt": "选择一名英雄,查看其技能如何改变团战结果。" + }, + "ultImpactAnalysisCard": { + "eyebrow": "终极技能 · 影响分析", + "title": "终极技能影响", + "description": "查看特定英雄终极技能在不同场景中如何影响团战结果。", + "selectHero": "选择英雄", + "searchHeroes": "搜索英雄", + "noHeroesFound": "未找到英雄。", + "roles": { + "Tank": "坦克", + "Damage": "输出", + "Support": "支援" + }, + "scenarios": { + "uncontestedOurs": "未被反制(我方)", + "uncontestedTheirs": "未被反制(敌方)", + "mirrorOursFirst": "镜像,我方先手", + "mirrorTheirsFirst": "镜像,敌方先手" + }, + "table": { + "hero": "英雄", + "scenario": "场景", + "fights": "团战", + "wins": "胜", + "losses": "负", + "winrate": "胜率", + "tag": "标签" + }, + "tags": { + "keyUlt": "关键大招" + }, + "smallSample": "{hero} 仅分析了 {count, plural, other {# 场战斗}},结果可能不具备统计显著性。", + "selectHeroPrompt": "选择一名英雄,查看其终极技能如何影响团战结果。" + }, + "mapModePerformanceCard": { + "eyebrow": "地图 · 模式表现", + "winrate": "胜率:{winrate}", + "winsAndLosses": "{wins}胜 - {losses}负({games, plural, =0 {无比赛} other {# 场比赛}})", + "title": "地图模式表现", + "noData": "还没有地图模式数据。", + "best": "最佳:{mode}", + "winrateLabel": "胜率 %", + "record": "战绩", + "winsLossesRecord": "{wins}胜 - {losses}负", + "gamesLabel": "比赛", + "avgTimeLabel": "平均时间", + "bestMap": "最佳地图", + "worstMap": "最低地图", + "insights": "洞察", + "excelsAt": "你的队伍擅长 {mode} 地图(胜率 {winrate}%)", + "considerPracticing": "建议练习 {mode} 地图以改善平衡性(胜率 {winrate}%)", + "mapTypes": { + "Clash": "攻防争锋", + "Control": "占领要点", + "Escort": "运载目标", + "Flashpoint": "闪点作战", + "Hybrid": "攻击/护送", + "Push": "机动推进" + } + }, + "teamRosterGrid": { + "eyebrow": "概览 · 阵容", + "title": "团队阵容", + "playerTargets": "玩家目标", + "noData": "还没有阵容数据。玩家参加比赛后会显示在这里。", + "subBadge": "替补", + "manageSubstitute": "管理替补", + "markSubstitute": "设为替补", + "unmarkSubstitute": "取消替补", + "markSuccess": "已将 {player} 设为替补", + "unmarkSuccess": "已取消 {player} 的替补身份", + "errorTitle": "无法更新替补", + "errorDescription": "{res}" + }, + "topMapsCard": { + "eyebrow": "概览 · 已游玩地图", + "title": "按游玩时间排名的热门地图", + "noData": "还没有地图数据。", + "performanceEyebrow": "概览 · 地图表现", + "performanceTitle": "地图表现", + "performanceDescription": "显示最常游玩的地图,并同时展示胜率和游玩时间。", + "columns": { + "map": "地图", + "games": "比赛", + "playtime": "游玩时间", + "winrate": "胜率", + "distribution": "分布", + "tag": "标签" + }, + "tags": { + "strongest": "强项", + "bleed": "短板" + } + }, + "quickStatsCard": { + "eyebrow": "概览 · 信号", + "title": "快速数据", + "last10Games": "最近 10 场", + "winsLossesRecord": "{wins}胜 - {losses}负", + "recordShort": "{wins}-{losses}", + "bestDay": "最佳日期", + "notEnoughData": "数据不足 (每天至少需要 3 场比赛)", + "gamesLabel": "{count, plural, =0 {未进行比赛} other {已进行 # 场比赛}}", + "winrateShort": "胜率 {winrate}%", + "avgFightDuration": "平均团战时长", + "notAvailable": "暂无", + "durationSeconds": "{seconds} 秒", + "firstPickSuccess": "首杀成功率", + "firstPickSuccessRate": "{totalFirstPicks} 次首杀中赢下 {successfulFirstPicks} 次", + "heroPool": "英雄池", + "mapPool": "地图池", + "uniqueHeroes": "{count, plural, =0 {无独特英雄} other {# 名独特英雄}}", + "uniqueMaps": "{count, plural, =0 {无独特地图} other {# 张独特地图}}", + "days": { + "sunday": "星期日", + "monday": "星期一", + "tuesday": "星期二", + "wednesday": "星期三", + "thursday": "星期四", + "friday": "星期五", + "saturday": "星期六" + }, + "noData": "无数据" + }, + "winLossStreaksCard": { + "eyebrow": "趋势 · 连胜/连败", + "title": "连胜/连败", + "noData": "还没有连胜/连败数据。", + "na": "暂无", + "dateRange": "{start} - {end}", + "currentStreak": "当前走势", + "winStreakCount": "{count} 连胜", + "lossStreakCount": "{count} 连败", + "hot": "🔥 状态正热", + "cold": "❄️ 状态低迷", + "longestWinStreak": "最长连胜", + "noWinsYet": "还没有胜场", + "longestLossStreak": "最长连败", + "noLossesYet": "还没有负场", + "keepMomentumGoing": "继续保持势头!", + "timeToBreakStreak": "该终结连败了 - 复盘最近的 VOD" + }, + "rolePerformanceCard": { + "eyebrow": "表现 · 职责", + "title": "职责表现", + "noDataAvailable": "还没有职责表现数据。", + "noData": "无数据", + "stat": "数据", + "durationHoursMinutes": "{hours} 小时 {minutes} 分钟", + "durationMinutes": "{minutes} 分钟", + "playtime": "游戏时间", + "maps": "地图", + "kd": "K/D", + "damagePerMin": "伤害/10 分钟", + "healingPerMin": "治疗/10 分钟", + "deathsPerMin": "死亡/10 分钟", + "ultEfficiency": "终极技能效率", + "elims": "击杀", + "deaths": "死亡", + "assists": "助攻", + "roles": { + "Tank": "坦克", + "Damage": "输出", + "Support": "支援" + } + }, + "winProbabilityInsights": { + "eyebrow": "团战 · 胜利概率", + "title": "胜利概率洞察", + "noData": "还没有团战数据。", + "firstPickImpact": "首杀影响", + "firstPickHeadline": "取得首杀时 {winrate}", + "firstPickDetail": "{fights} 场中赢下 {wins} 场(相比整体 {delta})", + "firstPickImpactDescription": "取得首杀时,你的队伍赢下 {winrate}% 的团战", + "firstPickCount": "{wins}胜 / {fights} 场团战", + "firstDeathComeback": "先阵亡后的反打", + "firstDeathHeadline": "先阵亡后 {winrate}", + "firstDeathDetail": "{fights} 场中赢下 {wins} 场(相比整体 {delta})", + "firstDeathComebackDescription": "先阵亡时,你的队伍仍赢下 {winrate}% 的团战", + "firstDeathCount": "{wins}胜 / {fights} 场团战", + "ultimateAdvantage": "终极技能优势", + "firstUltHeadline": "先用终极技能时 {winrate}", + "firstUltDetail": "{fights} 场中赢下 {wins} 场(相比整体 {delta})", + "ultimateAdvantageDescription": "先使用终极技能带来 {winrate}% 的团战胜率", + "ultimateCount": "{wins}胜 / {fights} 场团战", + "dryFightSuccess": "无终极技能团战成功率", + "dryFightHeadline": "无终极技能团战 {winrate}", + "dryFightDetail": "{fights} 场无终极技能团战中赢下 {wins} 场(相比整体 {delta})", + "dryFightSuccessDescription": "不使用终极技能时赢下 {winrate}% 的团战", + "dryFightCount": "{wins}胜 / {fights} 场无终极技能团战", + "ultEconomy": "终极技能经济", + "ultEconomyHeadline": "每场获胜团战使用 {count} 个终极技能", + "ultEconomyDetail": "每场失利团战使用 {count} 个(差值 {delta})", + "fightReversalComparison": "团战逆转:无终极技能 vs 使用终极技能", + "fightReversalHeadline": "无终极技能 {dryRate},使用终极技能 {ultRate} 逆转", + "fightReversalDetail": "{dryReversals} 次无终极技能逆转,{ultReversals} 次终极技能逆转", + "fightReversalComparisonDescription": "无终极技能团战逆转率为 {dryRate}%,使用终极技能时为 {nonDryRate}%", + "fightReversalComparisonDetail": "{dryReversals} 次无终极技能 / {nonDryReversals} 次终极技能逆转", + "strongAdvantage": "明显优势", + "needsImprovement": "需要改进", + "average": "一般", + "description": "不同团战情境如何影响你的胜利概率", + "overallFightPerformance": "整体团战表现", + "totalFights": "团战总数", + "fightsWon": "获胜团战", + "overallWinrate": "整体胜率", + "chartTitle": "按团战类型划分的胜率", + "tooltipSummary": "{winrate},基于 {fights, plural, =0 {无团战} other {# 场团战}}", + "rows": { + "overall": "整体", + "firstPick": "首杀", + "firstDeath": "先阵亡", + "firstUlt": "先用终极技能", + "dry": "无终极技能", + "withUlts": "使用终极技能" + } + }, + "recentFormCard": { + "eyebrow": "趋势 · 近期状态", + "title": "近期状态", + "noData": "没有可显示的近期比赛。", + "winsLosses": "{wins}胜 - {losses}负 • 胜率 {winrate}", + "last5": "最近 5 场", + "last10": "最近 10 场", + "last20": "最近 20 场", + "strongRecentPerformance": "近期表现强势", + "strugglingRecently": "近期表现低迷", + "averagePerformance": "表现一般", + "result": "结果", + "scrim": "训练赛", + "map": "地图", + "date": "日期", + "indicatorTitle": "{scrimName} - {result}", + "win": "胜", + "loss": "负" + }, + "playerMapPerformanceCard": { + "eyebrow": "地图 · 玩家地图矩阵", + "title": "玩家地图表现矩阵", + "noData": "还没有地图表现数据。", + "description": "每名玩家按胜率显示最佳和最差地图", + "playerOnMap": "{player} 在 {map}:{winrate}({wins}胜-{losses}负)", + "noDataTitle": "{player} - {map}:无数据", + "winsLossesRecord": "{wins}胜 - {losses}负", + "hasntPlayedMap": "{player} 还没有玩过 {map}。", + "playerMapPerformance": "{player}{map}:{winrate}({wins}胜-{losses}负),{games, plural, =0 {未进行比赛} other {已进行 # 场比赛}}", + "hoverToSeePerformance": "将鼠标悬停在单元格上,查看该玩家在此地图上的表现。", + "percentOrHigher": "{winrate}+", + "bestMap": "最佳地图", + "worstMap": "最差地图" + }, + "bestRoleTriosCard": { + "eyebrow": "表现 · 最佳三职责组合", + "title": "最佳职责组合", + "description": "按胜率排序最成功的五人阵容。", + "noData": "数据不足,暂时无法确定最佳玩家组合。需要同一阵容至少 3 场比赛。", + "rank": "排名", + "roster": "阵容", + "games": "比赛", + "recordHeader": "胜, 负", + "winrateHeader": "胜率", + "toggleDetails": "切换详情", + "collapseTrio": "收起第 {rank} 名组合", + "expandTrio": "展开第 {rank} 名组合", + "gamesPlayed": "{count, plural, =0 {无比赛} other {# 场比赛}}", + "wins": "{count}胜", + "losses": "{count}负", + "record": "{wins}胜, {losses}负", + "tank": "坦克", + "damage": "输出", + "support": "支援", + "winRate": "胜率", + "winRateValue": "胜率 {winrate}", + "playMoreGames": "进行更多比赛后即可查看你的最佳阵容组合" + }, + "strengthsWeaknessesCard": { + "eyebrow": "概览 · 最佳与最差", + "title": "优势与弱点", + "noData": "数据不足,暂时无法判断优势与弱点。", + "strongestMap": "最强地图", + "bestPerformance": "最佳表现", + "playtime": "已游玩 {time}", + "durationHoursMinutesSeconds": "{hours} 小时 {minutes} 分钟 {seconds} 秒", + "blindSpot": "短板地图", + "needsImprovement": "需要改进" + }, + "winrateOverTimeChart": { + "eyebrow": "趋势 · 胜率变化", + "title": "胜率变化", + "noData": "数据不足,暂时无法显示胜率趋势。", + "average": "平均:{avgWinrate} • {trend} {trendValue}", + "weekly": "每周", + "monthly": "每月", + "winrateLabel": "胜率 (%)", + "50PercentLine": "50% 基准线", + "winrate": "胜率:{winrate}", + "winsAndLosses": "{wins}胜 - {losses}负" + }, + "mapRosterDetailsSheet": { + "eyebrow": "地图 · 阵容拆解", + "overall": "整体:{wins}胜 - {losses}负(胜率 {winrate})• {games, plural, =0 {未进行比赛} other {已进行 # 场比赛}}", + "noData": "此地图还没有阵容数据。", + "rosterPerformance": "阵容表现({count, plural, =0 {无阵容} other {# 套阵容}})", + "bestLineup": "最佳阵容", + "winsLossesRecord": "{wins}胜 - {losses}负", + "gamesLabel": "{count, plural, =0 {未进行比赛} other {已进行 # 场比赛}}", + "smallSample": "样本较小", + "recordLabel": "战绩", + "winrateLabel": "胜率", + "overallLabel": "整体", + "lineupsLabel": "阵容", + "distinctLabel": "不同阵容", + "lineupsEyebrow": "地图 · 阵容", + "lineupHeader": "阵容", + "gamesHeader": "比赛", + "tagHeader": "标签" + }, + "heroPickrateHeatmap": { + "eyebrow": "英雄 · 选用率", + "title": "英雄选用率热力图", + "noData": "还没有英雄选用率数据。", + "description": "全队最常使用的前 15 名英雄", + "total": "总计", + "saturationNote": "单色饱和度渐变;对色觉障碍用户友好。", + "noPlaytime": "无游戏时间", + "durationHoursShort": "{hours} 小时", + "durationMinutesShort": "{minutes} 分钟", + "durationLong": "{hours} 小时 {minutes} 分钟 {seconds} 秒", + "cellTitle": "{playerName} - {heroName}:{playtime}", + "playerTotalTitle": "{playerName} 总计:{playtime}", + "heroTotalTitle": "{heroName}:{playtime}", + "allHeroesTotalTitle": "所有英雄总计:{playtime}", + "hoverText": "{playerName} 使用 {heroName}:{playtime}", + "hoverTextDescription": "将鼠标悬停在单元格上,查看该玩家对此英雄的选用率。" + }, + "charts": { + "eyebrow": "相关性", + "title": "选手图表", + "description": "每个点代表一名选手,按每 10 分钟换算。使用英雄筛选器调整所有图表的范围。", + "regressionToggle": "趋势线 + r", + "correlation": "r = {value}", + "trendUnavailable": "趋势线需要至少 2 名选手", + "chartAlt": "{count} 名选手的 {x} 对比 {y} 散点图", + "expand": "放大图表", + "zoom": "缩放", + "zoomLabel": "缩放 {axis} 轴", + "resetZoom": "重置缩放", + "legendTrend": "线性趋势", + "playersShown": "显示 {total} 名中的 {shown} 名选手", + "noData": "没有符合当前筛选条件的选手。", + "customEyebrow": "自定义", + "customTitle": "自定义图表", + "customDescription": "选择任意两项数据在阵容中进行比较。", + "customChartTitle": "{x} 对比 {y}", + "xAxis": "X 轴", + "yAxis": "Y 轴", + "presetDamageDeaths": "每 10 分钟英雄伤害 vs 每 10 分钟死亡", + "presetFinalBlowsDeaths": "每 10 分钟最后一击 vs 每 10 分钟死亡", + "presetDamageHealingReceived": "每 10 分钟承受伤害 vs 每 10 分钟受到治疗", + "presetBlockedTaken": "每 10 分钟格挡伤害 vs 每 10 分钟承受伤害", + "stats": { + "eliminations": "击杀参与", + "finalBlows": "最后一击", + "deaths": "死亡", + "heroDamage": "英雄伤害", + "healingDealt": "治疗量", + "healingReceived": "受到治疗", + "selfHealing": "自我治疗", + "damageTaken": "承受伤害", + "damageBlocked": "格挡伤害", + "ultimatesEarned": "获得的终极技能", + "ultimatesUsed": "使用的终极技能", + "soloKills": "单独击杀", + "environmentalKills": "环境击杀" + } + } + }, + "teamStats": { + "fightStats": { + "eyebrow": "团战 · 团战统计", + "title": "团队团战统计", + "description": "已分析 {count, plural, =0 {无团战} other {# 场团战}}", + "noData": "还没有团战数据。上传训练赛后即可查看团队团战统计。", + "overallWinrate": "整体团战胜率", + "record": "{wins}胜 - {losses}负", + "firstPickWinrate": "首杀胜率", + "firstPickCount": "取得首杀的团战 {count, plural, =0 {无} other {# 场}}", + "firstDeathWinrate": "先阵亡后胜率", + "firstDeathCount": "先失首杀的团战 {count, plural, =0 {无} other {# 场}}", + "firstUltWinrate": "先用终极技能胜率", + "firstUltCount": "先使用终极技能的团战 {count, plural, =0 {无} other {# 场}}", + "dryFights": "无终极技能团战比例", + "dryFightDetails": "无终极技能团战 {count, plural, =0 {无} other {# 场}}(胜率 {winrate})", + "avgUltsPerFight": "每场团战平均终极技能", + "avgUltsDescription": "基于 {count, plural, =0 {无使用终极技能的团战} other {# 场使用终极技能的团战}}" + }, + "initiationStats": { + "eyebrow": "团战 · 先手", + "title": "团战发起", + "description": "谁先出手,以及是否奏效。在 {total} 张拥有详细日志的地图中检测到 {covered} 张。", + "noData": "暂无发起数据。上传带详细日志的训练赛即可查看谁先手。", + "initiationWinrate": "先手胜率", + "initiationFrequency": "发起的团战", + "goingSecondWinrate": "后手胜率", + "firstRecord": "先手 {wins}胜 - {losses}负", + "frequencySub": "{decided} 场有效团战中的 {first} 场", + "secondRecord": "后手 {wins}胜 - {losses}负" } }, "targets": { "title": "选手目标", + "signInRequired": "请登录以查看目标。", + "teamNotFound": "未找到队伍。", + "premiumFeature": "高级功能", "premiumRequired": "选手目标需要高级计划。", "upgradePrompt": "升级以为您的选手设定可衡量的改进目标。", "viewPricing": "查看价格", "noTargets": "教练尚未设定任何目标。", "noTeamMembers": "未找到队员。", + "addPlayerToSetTargets": "将此选手加入你的队伍以设置目标", "setTarget": "设定目标", "editTarget": "编辑目标", "createTarget": "创建目标", @@ -2395,8 +7905,19 @@ "progress": "进度", "baseline": "基准值", "target": "目标", + "trend": "趋势", + "baselineValue": "基准值:{value}", + "targetValue": "目标:{value}", + "clickToIsolate": "点击以单独查看", + "baselineTargetSummary": "基准值:{baseline} | 目标:{target}", + "chartSummary": "平均值:{average} | 最大值:{max} | 最小值:{min}", "targetAchieved": "目标达成!", "coachNote": "教练备注", + "progressSummary": { + "onTrack": "{count} 个正常", + "inProgress": "{count} 个进行中", + "behind": "{count} 个落后" + }, "form": { "title": "为 {playerName} 设定目标", "editTitle": "编辑 {playerName} 的目标", @@ -2426,9 +7947,696 @@ }, "narrative": { "changed": "您的{stat}在最近{window}场训练赛中{direction}了{percent}%。", + "changedRich": "您的{stat}在最近 {window} 场训练赛中{direction}了 {percent}。", "progressToward": "您已达成目标{targetDirection}{targetPercent}%的{percent}%。", + "progressTowardRich": "您已完成目标 {targetDirection}{targetPercent} 的 {percent}。", "increased": "提高", "decreased": "降低" + }, + "metadata": { + "title": "选手目标 | Parsertime", + "description": "为您的团队设定并追踪选手成长目标。" + } + }, + "chartComponents": { + "heroPickRateHover": { + "weekOf": "{date} 当周", + "pickRate": "选择率", + "playtime": "游戏时间", + "eyebrow": "选择率 · 过去 60 天", + "average": "平均", + "windowDelta": "窗口变化", + "percentagePoints": "{value}pp", + "playtimeHoursMinutes": "{hours}小时 {minutes}分钟", + "playtimeMinutes": "{minutes}分钟", + "patches": { + "season": "赛季", + "midCycle": "季中", + "hotfix": "热修复" + } + } + }, + "reportsPage": { + "metadata": { + "title": "报告 | Parsertime", + "description": "查看共享的 AI 训练赛分析报告。" + }, + "list": { + "title": "报告", + "description": "来自聊天对话的 AI 分析报告。", + "searchPlaceholder": "搜索报告...", + "reportCount": "{count, plural, =0 {没有报告} other {# 份报告}}", + "emptyTitle": "暂无报告", + "emptyDescription": "报告由 Analyst 对话创建。让 Analyst 生成报告后,它会显示在这里。", + "startChat": "开始聊天", + "noMatches": "没有与 \"{query}\" 匹配的报告", + "pageStatus": "第 {currentPage} 页,共 {totalPages} 页", + "previousPage": "上一页", + "previous": "上一页", + "nextPage": "下一页", + "next": "下一页" + } + }, + "ranked": { + "title": "排位追踪器", + "emptyTitle": "暂无记录的对局", + "emptyDescription": "开始记录您的游戏,查看跨地图、英雄和组队规模的胜率趋势。", + "trackFirst": "记录首场对局", + "addMatch": "添加对局", + "import": { + "title": "从 Winrate Tracker 导入", + "description": "上传旧网站的 JSON 导出文件以同步您的对局记录。", + "button": "选择导出文件", + "importing": "导入中…", + "done": "已导入 {imported} 场对局", + "failed": "导入失败" + }, + "metadata": { + "title": "排位追踪器 | Parsertime", + "description": "追踪您的《守望先锋2》排位对局并分析您的表现。" + }, + "dashboard": { + "trackMatch": "记录对局", + "allTime": "全部时间", + "roleAll": "所有定位", + "roleTank": "重装", + "roleDamage": "输出", + "roleSupport": "支援", + "filterByRole": "按定位筛选", + "filterBySeason": "按赛季或补丁筛选" + }, + "tabs": { + "overview": "概览", + "heroes": "英雄", + "maps": "地图", + "time": "时间", + "patches": "补丁", + "groups": "组队", + "roles": "定位", + "heroDeepDives": "英雄深度分析", + "heroMapAnalysis": "英雄 × 地图分析", + "mapMastery": "地图熟练度", + "patterns": "规律" + }, + "summary": { + "matches": "对局", + "winrate": "胜率", + "bestMap": "最佳地图", + "currentStreak": "当前连胜", + "acrossMaps": "跨 {count} 张地图", + "record": "{wins}胜 – {losses}负", + "recordWithDraws": "{wins}胜 – {losses}负 – {draws}平", + "mapWinrate": "胜率 {winrate}%", + "wonLast": "最近 {count} 连胜", + "lostLast": "最近 {count} 连败", + "noStreak": "暂无连胜" + }, + "matchList": { + "eyebrow": "历史", + "recentMatches": "最近对局", + "lastSevenDays": "(最近 7 天)", + "counter": "{total} 中的 {start}–{end}", + "pageOf": "第 {page} 页,共 {total} 页", + "previous": "上一页", + "next": "下一页", + "previousAria": "上一页", + "nextAria": "下一页", + "groupSolo": "单人", + "groupDuo": "双人", + "group3Stack": "3人组队", + "group4Stack": "4人组队", + "group5Stack": "5人组队", + "groupNStack": "{count}人组队", + "resultWin": "胜", + "resultLoss": "负", + "resultDraw": "平", + "deleteMatchAria": "删除对局", + "deleteTitle": "删除对局?", + "deleteBody": "此操作将从您的历史记录中永久删除该对局。", + "cancel": "取消", + "delete": "删除", + "deleting": "删除中..." + }, + "form": { + "title": "记录对局", + "description": "记录您最近的游戏。您可以一次添加多场对局。", + "matchHeading": "对局 {n}", + "removeMatch": "移除对局 {n}", + "map": "地图", + "selectMap": "选择地图...", + "searchMaps": "搜索地图...", + "noMaps": "未找到地图。", + "dateTime": "日期和时间", + "result": "结果", + "win": "胜利", + "loss": "失败", + "draw": "平局", + "groupSize": "组队规模", + "groupSizeSolo": "单人", + "groupSizeDuo": "双人", + "groupSizeStack3": "三人组", + "groupSizeStack4": "四人组", + "groupSizeStack5": "五人组", + "heroesPlayed": "使用的英雄", + "percentTotal": "{total}%", + "percentSum": "百分比之和必须为 100", + "percentageFor": "{hero} 的百分比", + "removeHero": "移除 {hero}", + "addHero": "添加英雄...", + "searchHeroes": "搜索英雄...", + "noHeroes": "未找到英雄。", + "addAnother": "添加另一场对局", + "submit": "记录 {count} 场对局", + "submitting": "记录中...", + "errorSelectMap": "对局 {n}:请选择地图", + "errorSelectResult": "对局 {n}:请选择结果", + "errorAddHero": "对局 {n}:请至少添加一名英雄", + "errorHeroSum": "对局 {n}:英雄百分比之和必须为 100(当前为 {total})", + "errorGeneric": "出了点问题" + }, + "charts": { + "mapWinLoss": { + "eyebrow": "地图战绩", + "title": "你在哪里赢得最多?", + "description": "{bestMap}是你的最佳地图,胜率 {bestWinrate}%", + "descriptionWithWorst": "{bestMap}是你的最佳地图,胜率 {bestWinrate}% — {worstMap}最为艰难,胜率仅 {worstWinrate}%", + "wins": "胜场", + "losses": "负场", + "winrateLabel": "胜率:", + "footer": "基于 {maps} 张地图的 {count} 场对局" + }, + "gameModeDistribution": { + "eyebrow": "游戏模式", + "title": "你常玩哪些模式?", + "description": "{mode}占你对局的 {pct}%", + "matchesValue": "{count} 场对局", + "matchesUnit": "对局" + }, + "gameModeWinrate": { + "eyebrow": "游戏模式", + "title": "哪些模式适合你?", + "description": "{bestMode}是你最强的模式,胜率 {bestWinrate}%", + "descriptionBest": "{bestMode}是你最强的模式,胜率 {bestWinrate}%", + "descriptionBestWorst": "你在 {bestMode} 以 {bestWinrate}% 的胜率称霸,但在 {worstMode} 以 {worstWinrate}% 的胜率苦战", + "descriptionEmpty": "多打几场对局以查看各模式胜率", + "winrate": "胜率", + "winsOverGames": "{wins}胜 / {total} 场对局", + "footer": "虚线标示 50% 胜率" + }, + "groupSizeBreakdown": { + "eyebrow": "组队规模", + "title": "你主要怎么玩?", + "descriptionEmpty": "记录更多对局以查看你的组队习惯", + "descriptionAllGrouped": "全部 {total} 场对局均为组队", + "descriptionAllSolo": "全部 {total} 场对局均为单排", + "descriptionMixed": "{total} 场对局中 {soloPct}% 为单排 — {groupedPct}% 为组队", + "noData": "暂无数据", + "wins": "胜场", + "losses": "负场", + "draws": "{count} 平", + "winrateOverGames": "胜率:{winrate}%,基于 {total} 场对局", + "footer": "基于 {sizes} 种组队规模的 {count} 场对局" + }, + "groupSizeWinrate": { + "eyebrow": "组队规模", + "title": "并肩作战更强吗?", + "descriptionEmpty": "在不同组队规模下多打几场对局以查看你的趋势", + "description": "{label}时你的胜率为 {winrate}% — 这是你最佳的组队配置", + "descriptionWithCount": "{label}时你的胜率为 {winrate}% — 基于 {games} 场对局,这是你最佳的组队配置", + "descriptionWithDiff": "{label}时你的胜率为 {winrate}% — 这是你最佳的组队配置(比单排高 +{diff}%)", + "descriptionWithCountAndDiff": "{label}时你的胜率为 {winrate}% — 基于 {games} 场对局,这是你最佳的组队配置(比单排高 +{diff}%)", + "noData": "暂无数据", + "winrate": "胜率", + "winLossGames": "{wins}胜 / {losses}负 · {total} 场对局", + "footer": "每种组队规模至少需 {min} 场对局 · 显示 {showing} 种规模" + }, + "roleDistribution": { + "eyebrow": "定位", + "title": "你的时间花在哪里?", + "description": "你 {pct}% 的时间花在{role}上", + "descriptionEmpty": "记录一些对局以查看你在各定位间的时间分配", + "noData": "暂无数据" + }, + "roleWinrate": { + "eyebrow": "定位", + "title": "哪个定位为你赢得对局?", + "description": "你在{role}时赢得最多 — 胜率 {winrate}%", + "descriptionEmpty": "每个定位至少打 {min} 场对局以查看胜率", + "noData": "暂无数据", + "winrate": "胜率", + "winLossGames": "{wins}胜 / {losses}负 · {total} 场对局", + "footer": "每个定位至少需 {min} 场对局 · 显示 {showing} 个定位" + }, + "mostPlayedHeroes": { + "eyebrow": "英雄表现", + "title": "你最常玩哪个英雄?", + "descriptionAllModes": "在所有模式中,{hero}是你的首选,共 {count} 场对局", + "descriptionMode": "在{mode}中,{hero}是你的首选,共 {count} 场对局", + "descriptionEmpty": "多打几场对局以查看英雄数据", + "filterAriaLabel": "按游戏模式筛选", + "allModes": "所有模式", + "matches": "对局", + "roleLabel": "定位:{role}", + "roles": { + "Tank": "坦克", + "Damage": "输出", + "Support": "辅助" + } + }, + "heroWinrate": { + "eyebrow": "英雄表现", + "title": "你用哪个英雄赢得多?", + "descriptionBestWorst": "{bestHero}以 {bestWinrate}% 领先 — {worstHero}还需多加练习", + "descriptionBest": "{bestHero}是你表现最佳的英雄,胜率 {bestWinrate}%", + "descriptionEmpty": "多打几场对局以查看英雄胜率", + "winrate": "胜率", + "winsTotal": "{wins}胜 / {total} 场对局", + "footer": "至少需要 {min} 场对局 · 显示 {count} 个英雄" + }, + "oneTrick": { + "eyebrow": "英雄池", + "title": "你是独狼玩家吗?", + "descriptionOneTrick": "你将 {pct}% 的时间花在了{hero}上 — 专注的独狼玩家", + "descriptionSpecialist": "你偏好{hero},但也有一定多样性", + "descriptionDiverse": "你的游戏时间分散在多个英雄上", + "descriptionEmpty": "尚未追踪任何对局", + "labels": { + "One-Trick": "独狼", + "Specialist": "专精", + "Diverse": "多元" + }, + "breakdownAriaLabel": "英雄游戏时间分布", + "percentAriaLabel": "百分之 {pct}", + "noData": "暂无数据", + "footer": "基于加权游戏时间 · 独狼 ≥ {oneTrick}% · 专精 ≥ {specialist}%" + }, + "heroPoolDiversity": { + "eyebrow": "英雄池", + "title": "你的英雄池有多广?", + "descriptionNoMatches": "尚未追踪任何对局", + "descriptionNoHeroData": "暂无英雄数据", + "descriptionAllRoles": "你在全部 3 个定位中玩过 {count} 个不同英雄", + "descriptionConcentrated": "你的英雄池集中在{role} — 共 {count} 个不同英雄", + "uniqueHeroesAriaLabel": "{count} 个不同英雄", + "heroUnit": "英雄", + "heroesLabel": "{count} 个英雄", + "heroesByRoleAriaLabel": "按定位划分的英雄", + "roleHeroesAriaLabel": "{role}英雄", + "noRoleHeroes": "尚未玩过{role}英雄", + "noData": "暂无数据" + }, + "heroSwap": { + "eyebrow": "英雄切换", + "title": "切换英雄能赢下对局吗?", + "descriptionTrackMore": "追踪更多对局以了解切换英雄是否有助于获胜", + "descriptionNoImpact": "切换英雄对你的胜率没有显著影响", + "descriptionSwapBoost": "切换英雄能让你的胜率提升 +{delta}%", + "descriptionStayAdvantage": "坚守同一英雄能让你的胜率高出 +{delta}%", + "deltaNoDifference": "无显著差异", + "deltaSwapping": "切换时 +{delta}%", + "deltaStaying": "坚守时 +{delta}%", + "winrate": "胜率", + "winsTotal": "{wins}胜 · {total} 场对局", + "emptyState": "至少需要 3 场切换对局和 3 场非切换对局", + "footerRule": "切换 = 各自占对局 {pct}% 以上的英雄达 2 个或以上", + "footerCounts": "{swaps} 次切换,{single} 次单英雄" + }, + "roleFlexibility": { + "eyebrow": "定位", + "title": "定位灵活性", + "descriptionAdaptive": "你几乎均衡地玩三个定位 — 真正的全能玩家", + "descriptionFlexible": "你偏好{role},但也会玩其他定位", + "descriptionSpecialist": "你主要玩{role} — 专注的专精玩家", + "labels": { + "Adaptive": "全能型", + "Flexible": "灵活型", + "Specialist": "专精型" + }, + "noData": "暂无数据" + }, + "mapWinrateRanking": { + "eyebrow": "地图表现", + "title": "地图胜率排名", + "insight": "{bestMap}是你最强的地图,胜率 {bestWinrate}% — {worstMap}是你最艰难的地图,胜率 {worstWinrate}%", + "insightEmpty": "暂无地图数据", + "avgLabel": "平均 {overallWinrate}%", + "footer": "{maps} 张地图共 {count} 场对局", + "fadedNote": "淡色柱条的对局少于 {min} 场", + "confidenceStarsLabel": "置信度:5 星中的 {count} 星", + "deviationAbove": "高于平均 +{deviation}%", + "deviationBelow": "低于平均 {deviation}%", + "deviationAt": "处于平均", + "winrate": "胜率", + "wld": "胜 / 负 / 平", + "vsAvg": "对比你的平均 ({overallWinrate}%)", + "confidence": "置信度", + "lowSample": "样本不足 — {count} 场对局" + }, + "mapTierList": { + "eyebrow": "地图表现", + "title": "地图分级列表", + "description": "按胜率和置信度对你玩过的 {count} 张地图排名", + "descriptionEmpty": "多玩几张地图以生成分级列表", + "listLabel": "地图分级列表", + "tierLabel": "{tier} 级:{description}", + "tierDescription": { + "S": "统治级", + "A": "强势", + "B": "稳健", + "C": "中规中矩", + "D": "挣扎" + }, + "lowSampleLabel": "样本量不足", + "winrateLine": "胜率 {winrate}% ({wins}胜 / {losses}负)", + "winrateLineWithDraws": "胜率 {winrate}% ({wins}胜 / {losses}负 / {draws}平)", + "lowConfidence": "置信度低 — 仅 {count} 场对局" + }, + "mapVolatility": { + "eyebrow": "地图表现", + "title": "地图波动性", + "insight": "{mostVolatile}是你最难预测的地图,结果起伏很大", + "insightEmpty": "展示你在每张地图上结果的稳定程度", + "footer": "高波动意味着你的结果大幅起伏 — 这些是你的“掷硬币”地图", + "confidenceStarsLabel": "5 星中的 {count} 星", + "volatility": "波动性", + "assessment": "评估", + "winrate": "胜率", + "confidence": "置信度", + "gamesPlayed": "已玩 {count} 场对局", + "assessmentExtreme": "极难预测", + "assessmentHigh": "波动很大", + "assessmentModerate": "波动适中", + "assessmentFairly": "较为稳定", + "assessmentVery": "非常稳定" + }, + "heroMapSynergy": { + "eyebrow": "英雄 × 地图", + "title": "英雄 × 地图协同", + "description": "哪些英雄在哪些地图上获胜 — 对局少于 {min} 场的格子显示为 —", + "descriptionEmptyHeader": "记录足够多的对局,即可看出哪些英雄在每张地图上表现最佳", + "noData": "暂无可用数据", + "filterGroupLabel": "按地图类型筛选", + "filterAll": "全部", + "noMapsOfType": "尚未在 {type} 地图上进行对局", + "matrixLabel": "英雄-地图胜率矩阵", + "heroMapHeader": "英雄 / 地图", + "cellLabel": "{hero} 在 {map}:{games} 场对局的胜率为 {winrate}%", + "cellLabelInsufficient": "{hero} 在 {map}:数据不足", + "cellTitle": "{hero} 在 {map}", + "cellWinrate": "胜率 {winrate}%", + "cellRecord": "{wins}胜 / {losses}负 ({games} 场对局)", + "cellInsufficient": "仅 {count} 场对局 — 需要 {min} 场以上", + "cellNoGames": "尚无对局", + "legendWinrate": "胜率:", + "legendLowGames": "少于 {min} 场对局", + "footer": "按出场频率展示前 {heroes} 名英雄,覆盖 {maps} 张地图", + "footerFiltered": "按出场频率展示前 {heroes} 名英雄,覆盖 {maps} 张地图 (仅 {type})" + }, + "bestHeroPerMap": { + "eyebrow": "英雄 × 地图", + "title": "每张地图的最佳英雄", + "description": "你在每张地图上胜率最高的英雄 (至少 {min} 场对局)。柱条表示 95% 置信区间,刻度标记胜率。", + "descriptionEmptyHeader": "在一张地图上用同一英雄至少进行 {min} 场对局,即可看到你的最佳选择", + "noData": "暂无符合条件的数据", + "infoAriaLabel": "关于置信区间", + "infoTooltip": "Wilson 评分区间会考虑样本量。柱条较宽 (高亮显示) 意味着估计值不可靠 — 多玩几局以缩小区间。", + "ciAriaLabel": "胜率 {winrate}%,95% 置信区间 {low}% 到 {high}%", + "ciRange": "{low}%–{high}% CI", + "games": "{count} 场对局" + }, + "mapLearningCurve": { + "eyebrow": "地图精通", + "title": "地图学习曲线", + "emptyDescription": "在一张地图上至少进行 {minGames} 场对局,即可查看您随时间的进步", + "emptyState": "数据尚不足 — 每张地图需要 {minGames} 场以上对局", + "legendEarly": "早期对局", + "legendRecent": "近期对局", + "descriptionImproved": "您在 {map} 上进步最大 (+{delta}%) — 对局前半段对比后半段", + "descriptionDefault": "每张地图上对局前半段对比后半段", + "insightImproved": "您在 {map} 上进步最大 (+{delta}%)", + "insightDeclined": "{map} 是您最难掌握的地图 ({delta}%)", + "insightNeutral": "对比您在各地图上的早期与近期表现", + "tooltipEarly": "早期 ({games}场)", + "tooltipRecent": "近期 ({games}场)", + "tooltipImprovement": "+{delta}% 提升", + "tooltipDecline": "{delta}% 下降", + "tooltipNoChange": "无变化", + "footer": "每张地图需要 {minGames} 场以上对局。显示 {count} 张符合条件的地图" + }, + "mapTimeline": { + "eyebrow": "地图精通", + "title": "胜负时间线", + "emptyDescription": "追踪您在每张地图上的近期表现", + "emptyState": "暂无地图数据", + "legendCumulative": "累计胜率", + "selectMap": "选择地图", + "result": { + "win": "胜", + "loss": "负", + "draw": "平" + }, + "resultShort": { + "win": "胜", + "loss": "负", + "draw": "平" + }, + "badgeTitle": "第 {index} 场对局:{result}", + "description": "{map} 上最近 {count} 场对局", + "lastPlayedToday": "今天玩过", + "lastPlayedYesterday": "昨天玩过", + "lastPlayedDaysAgo": "上次游玩于 {days} 天前", + "recentResults": "近期结果(从旧到新)", + "recentResultsAria": "{map} 上的近期结果", + "cumulativeWinrate": "随时间变化的累计胜率", + "tooltipWinrate": "胜率 {value}%", + "statOverall": "总计", + "statGamesTracked": "已追踪对局", + "statAvgGap": "平均间隔" + }, + "mapFamiliarity": { + "eyebrow": "地图精通", + "title": "地图熟悉度", + "legendGamesPlayed": "已玩对局", + "description": "已游玩 {available} 张地图中的 {played} 张", + "descriptionWithAvoided": "已游玩 {available} 张地图中的 {played} 张 — {avoided} 张从未遇到", + "aboutVarietyScore": "关于多样性评分", + "varietyScoreExplanation": "多样性评分(0–100)衡量您的对局在所有可用地图上的分布均匀程度。100 表示在每张地图上的时间完全均等。", + "mapVarietyScore": "地图多样性评分", + "varietyScoreAria": "地图多样性评分:{score}/100 — {label}", + "varietyDiverse": "多样", + "varietyModerate": "适中", + "varietyFocused": "集中", + "varietyConcentrated": "高度集中", + "resultShort": { + "win": "胜", + "loss": "负", + "draw": "平" + }, + "tooltipGames": "{count} 场对局(占总数的 {pct}%)", + "tooltipLast": "最近 {count} 场:{results}", + "neverPlayed": "您从未玩过的地图 ({count})", + "footer": "共 {total} 场对局 — 显示游玩最多的前 {top} 张地图" + }, + "repeatMap": { + "eyebrow": "地图精通", + "title": "重复地图表现", + "legendWinrate": "胜率", + "labelFirstTime": "首次", + "labelRepeat": "重复", + "descriptionBetter": "您在重复地图上表现更好 — 同一场次中地图再次出现时 +{delta}%", + "descriptionWorse": "您在重复地图上表现欠佳 — 地图再次出现时 {delta}%", + "descriptionNeutral": "重复地图对您的表现影响不大", + "descriptionEmpty": "在同一场次中两次看到同一张地图会影响您的胜率吗?", + "insightBetter": "在同一场次中两次看到同一张地图时,您的表现提升 {delta}%", + "insightWorse": "您在重复地图上的表现下降 {delta}% — 疲劳可能是一个因素", + "insightNeutral": "重复地图对您的胜率没有显著影响", + "emptyInsight": "重复地图数据尚不足 — 多进行几个场次以查看规律", + "emptySubtext": "已记录 {count} 次重复 — 需要 5 次以上", + "tooltipWinrate": "胜率 {value}%", + "tooltipGames": "{count} 场对局", + "statFirstTime": "首次", + "statRepeat": "重复", + "statGames": "{count} 场对局", + "footer": "场次按日历日分组 — 重复是指同一张地图出现一次以上" + }, + "winrateTrend": { + "eyebrow": "趋势", + "title": "你在进步吗?", + "emptyDescription": "记录更多对局以查看你的趋势", + "noData": "暂无数据", + "trendImproving": "正在上升 — 你最近 {window} 场的平均胜率为 {current}%", + "trendDeclining": "正在下滑 — 峰值为 {peak}%,当前为 {current}%", + "trendStable": "最近对局保持在 {current}% 左右", + "tooltipLabel": "滚动胜率", + "tooltipMeta": "第 {game} 场 · {date}", + "footer": "{window} 场滚动平均 · 共 {total} 场 · 峰值 {peak}%" + }, + "activityHeatmap": { + "eyebrow": "活跃度", + "title": "你什么时候玩?", + "description": "在{day}最活跃 — 游玩日平均 {avg} 场", + "emptyDescription": "记录对局以查看你的活跃模式", + "gridLabel": "活跃度热力图", + "cellNoGames": "{date} — 无对局", + "cellGames": "{date} — {count} 场对局", + "less": "较少", + "more": "较多", + "footer": "最近 {weeks} 周内有 {days} 个活跃日" + }, + "streak": { + "eyebrow": "连胜连败", + "title": "连续记录", + "resultWin": "胜", + "resultLoss": "负", + "resultDraw": "平", + "chipLabel": "第 {index} 场:{result}", + "winValue": "{count}连胜", + "lossValue": "{count}连败", + "descriptionWin": "{count} 连胜 — 保持下去", + "descriptionLoss": "{count} 连败 — 是时候扭转局面了", + "descriptionNone": "暂无进行中的连续记录", + "currentStreak": "当前连续记录", + "longestWinStreak": "最长连胜", + "longestLossStreak": "最长连败", + "lastGames": "最近 {count} 场", + "lastGamesResults": "最近 {count} 场结果", + "footer": "最新结果从左到右显示" + }, + "recentForm": { + "eyebrow": "近期状态", + "title": "近期状态", + "trendImproving": "最近 {window} 场较生涯平均高 {delta}%", + "trendDeclining": "最近 {window} 场较生涯平均低 {delta}%", + "trendStable": "与生涯平均水平持平", + "lastGames": "最近 {window} 场", + "allTime": "生涯", + "record": "{wins}胜 – {losses}负", + "recordWithDraws": "{wins}胜 – {losses}负 – {draws}平", + "gamesCount": "({count} 场)", + "deltaLabel": "较整体 {sign}{delta}%", + "footer": "对比最近 {recent} 场与生涯共 {total} 场" + }, + "sessionAnalysis": { + "eyebrow": "会话", + "title": "会话表现", + "emptyDescription": "你在单次会话中的表现如何?", + "emptyTitle": "会话还不够多", + "emptyHint": "会话是指彼此相隔 3 小时以内的对局组。多玩一些对局即可查看趋势。", + "insightSingle": "已记录 1 次会话 — 胜率 {winrate}%,{games} 场。", + "insightMany": "在 {sessions} 次会话中,你的平均胜率为 {winrate}%,每次会话 {games} 场。", + "tooltipWinrate": "胜率 {winrate}%", + "tooltipRecord": "{wins}胜 – {losses}负 · {games} 场", + "tooltipDuration": "约 {minutes} 分钟会话", + "avgWinrate": "平均胜率", + "gamesPerSession": "每会话场数", + "totalSessions": "会话总数", + "bestSession": "最佳会话", + "worstSession": "最差会话", + "footer": "会话是指彼此相隔 3 小时以内的对局组。显示最近 {count} 次会话。" + }, + "dayOfWeek": { + "eyebrow": "每周节奏", + "title": "最适合游玩的日子", + "emptyDescription": "你在工作日还是周末表现更好?", + "emptyTitle": "暂无对局", + "emptyHint": "在不同日期记录对局,了解你何时表现最佳。", + "insightStable": "你在工作日和周末表现相当({winrate}%)。你最好的日子是{day}。", + "insightDelta": "你在{when, select, weekend {周末} weekday {工作日} other {周末}}表现高 {delta}%。你最强的日子是{day}。", + "tooltipNoGames": "{day}无对局", + "tooltipWinrate": "胜率 {winrate}%", + "tooltipRecord": "{wins}胜 – {losses}负 · {total} 场", + "tooltipRecordWithDraws": "{wins}胜 – {losses}负 – {draws}平 · {total} 场", + "weekdays": "工作日", + "weekdayRange": "周一 – 周四", + "weekends": "周末", + "weekendRange": "周五 – 周日", + "deltaLabel": "较工作日{direction, select, better {高} worse {低} other {高}} {delta}%", + "bestDay": "最佳日", + "toughestDay": "最艰难日", + "footer": "对局归属于会话开始的那一天。" + }, + "patchImpact": { + "seasonsEyebrow": "赛季", + "seasonsTitle": "各赛季胜率", + "seasonsDescription": "你在每个排位赛季的战绩。", + "seasonRecord": "{wins}胜 – {losses}负 · {games} 场", + "eyebrow": "补丁影响", + "title": "补丁如何影响你的胜率", + "description": "随时间变化的滚动胜率。竖线标记《守望先锋》补丁,让你看到版本变化如何与你的状态对应。", + "empty": "对局还不足以绘制时间线", + "tooltipWinrate": "滚动胜率 {winrate}%", + "legendSeason": "赛季", + "legendMidSeason": "赛季中期", + "legendHotfix": "热修复" + } + } + }, + "settings": { + "ranked": { + "toggleLabel": "在我的主页显示排位统计", + "toggleDescription": "让他人在您的公开主页上查看您的排位表现摘要。", + "toggleError": "无法更新您的隐私设置" + } + }, + "tournamentsPage": { + "metadata": { + "title": "锦标赛 | Parsertime", + "description": "举办并追踪您的《守望先锋》锦标赛 — 对阵图、比赛和团队数据。" + }, + "create": { + "metadata": { + "title": "创建锦标赛 | Parsertime", + "description": "设置新的《守望先锋》锦标赛对阵图。" + } + }, + "detail": { + "metadata": { + "title": "{name} | Parsertime", + "description": "{name} 的对阵图、比赛和排名。" + } + }, + "match": { + "metadata": { + "title": "{team1} vs {team2} | Parsertime", + "description": "{team1} vs {team2} 的比赛详情和地图结果。" + } + }, + "teamStats": { + "metadata": { + "title": "{team} — 锦标赛数据 | Parsertime", + "description": "{team} 的锦标赛表现分析。" + } + } + }, + "debugPage": { + "metadata": { + "title": "调试 | Parsertime", + "description": "内部调试工具。" + } + }, + "fsrExplainer": { + "trigger": "FSR 如何计算", + "title": "FSR 如何计算", + "intro": "FSR 依据局内数据(而非胜负)以 1–5000 的区间衡量个人实力。", + "statBased": { + "label": "基于数据。", + "body": "由各职责的产出(击杀、伤害、治疗、死亡等)构成,并按各职责的重要程度加权。" + }, + "peers": { + "label": "对比同级选手。", + "body": "每项数据都会换算为与同段位、同职责选手对比的 z 分数。" + }, + "recency": { + "label": "近期与段位加权。", + "body": "近期地图权重更高,越高的段位(专家至 OWCS)权重越大。" + }, + "sample": { + "label": "样本敏感。", + "body": "样本不足时会向段位平均值收敛,直到数据充足。" + }, + "scale": { + "label": "1–5000 区间。", + "body": "以所在段位为锚点;百分位显示在该段位与职责中的排名。" } } } diff --git a/package.json b/package.json index 8860c758e..6c25dcf33 100644 --- a/package.json +++ b/package.json @@ -2,6 +2,7 @@ "name": "parsertime", "version": "3.0.0", "private": true, + "packageManager": "pnpm@9.15.9", "scripts": { "dev": "next dev --turbopack", "build": "next build", @@ -14,10 +15,12 @@ "format": "oxfmt", "format:check": "oxfmt --check", "typecheck": "next typegen && tsc --noEmit", - "db:push": "prisma db push", + "db:push": "prisma db push && prisma generate", "db:generate": "prisma generate", - "db:migrate": "prisma migrate dev", - "db:deploy": "prisma migrate deploy" + "db:migrate": "prisma migrate dev && prisma generate", + "db:deploy": "prisma migrate deploy", + "wp:export": "bun scripts/wp/export-dataset.ts", + "wp:train": "bun scripts/wp/train.ts" }, "dependencies": { "@ai-sdk/react": "^3.0.136", @@ -49,7 +52,8 @@ "@opentelemetry/sdk-trace-base": "^2.2.0", "@opentelemetry/sdk-trace-node": "^2.2.0", "@opentelemetry/semantic-conventions": "^1.38.0", - "@prisma/client": "^6.16.2", + "@prisma/adapter-pg": "^7.8.0", + "@prisma/client": "^7.8.0", "@prisma/instrumentation": "^7.2.0", "@radix-ui/react-accordion": "^1.2.12", "@radix-ui/react-alert-dialog": "^1.1.15", @@ -115,6 +119,7 @@ "ansi-to-react": "^6.2.6", "babel-plugin-react-compiler": "^1.0.0", "botid": "^1.5.10", + "cheerio": "^1.2.0", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", @@ -132,7 +137,7 @@ "media-chrome": "^4.18.2", "motion": "^12.23.26", "nanoid": "^5.1.5", - "next": "16.2.3", + "next": "16.2.6", "next-auth": "5.0.0-beta.29", "next-axiom": "^1.9.2", "next-intl": "^4.3.9", @@ -140,6 +145,7 @@ "next-themes": "^0.4.6", "nextstepjs": "^2.1.2", "nuqs": "^2.8.9", + "pg": "^8.21.0", "radix-ui": "^1.4.3", "react": "19.2.3", "react-day-picker": "^9.12.0", @@ -163,7 +169,7 @@ "use-stick-to-bottom": "^1.1.3", "validator": "^13.15.15", "vaul": "^1.1.2", - "xlsx": "^0.18.5", + "xlsx": "https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz", "zod": "^4.1.8" }, "devDependencies": { @@ -171,10 +177,12 @@ "@total-typescript/ts-reset": "^0.6.1", "@types/color": "^4.2.0", "@types/node": "^20.19.15", + "@types/pg": "^8.20.0", "@types/react": "19.2.2", "@types/react-dom": "19.2.2", "@types/three": "^0.183.1", "@types/validator": "^13.15.3", + "dotenv": "^17.4.2", "globals": "^16.4.0", "oxfmt": "^0.40.0", "oxlint": "^1.58.0", @@ -182,7 +190,7 @@ "postcss": "^8.5.6", "prettier": "^3.7.4", "prettier-plugin-tailwindcss": "^0.6.14", - "prisma": "^6.16.2", + "prisma": "^7.8.0", "sass": "^1.93.2", "tailwindcss": "^4.1.13", "tw-animate-css": "^1.3.8", @@ -197,11 +205,16 @@ "@types/react-dom": "19.2.2" }, "onlyBuiltDependencies": [ - "@tailwindcss/oxide", - "@vercel/speed-insights", + "@parcel/watcher", "@prisma/client", "@prisma/engines", - "prisma" + "@tailwindcss/oxide", + "@vercel/speed-insights", + "esbuild", + "msgpackr-extract", + "prisma", + "protobufjs", + "sharp" ] } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3db91f472..891db2d48 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -18,7 +18,7 @@ importers: version: 3.0.136(react@19.2.3)(zod@4.1.9) '@auth/prisma-adapter': specifier: 1.0.5 - version: 1.0.5(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2)) + version: 1.0.5(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.2))(typescript@5.9.2)) '@aws-sdk/client-s3': specifier: ^3.1024.0 version: 3.1024.0 @@ -36,7 +36,7 @@ importers: version: 0.1.6(@axiomhq/js@1.3.1) '@axiomhq/nextjs': specifier: ^0.1.6 - version: 0.1.6(@axiomhq/logging@0.1.6(@axiomhq/js@1.3.1))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) + version: 0.1.6(@axiomhq/logging@0.1.6(@axiomhq/js@1.3.1))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) '@axiomhq/react': specifier: ^0.1.6 version: 0.1.6(@axiomhq/logging@0.1.6(@axiomhq/js@1.3.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -60,7 +60,7 @@ importers: version: 0.63.0(@effect/platform@0.96.0(effect@3.21.0))(@opentelemetry/api@1.9.0)(@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-logs@0.214.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-metrics@2.6.1(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-node@2.2.0(@opentelemetry/api@1.9.0))(@opentelemetry/sdk-trace-web@2.6.0(@opentelemetry/api@1.9.0))(@opentelemetry/semantic-conventions@1.38.0)(effect@3.21.0) '@flags-sdk/vercel': specifier: ^1.0.1 - version: 1.0.1(@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)))(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) + version: 1.0.1(@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)))(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) '@floating-ui/react': specifier: ^0.27.16 version: 0.27.16(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -75,7 +75,7 @@ importers: version: 5.2.2(react-hook-form@7.62.0(react@19.2.3)) '@next/third-parties': specifier: ^16.1.1 - version: 16.1.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + version: 16.1.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) '@opentelemetry/api': specifier: ^1.9.0 version: 1.9.0 @@ -100,9 +100,12 @@ importers: '@opentelemetry/semantic-conventions': specifier: ^1.38.0 version: 1.38.0 + '@prisma/adapter-pg': + specifier: ^7.8.0 + version: 7.8.0 '@prisma/client': - specifier: ^6.16.2 - version: 6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2) + specifier: ^7.8.0 + version: 7.8.0(prisma@7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.2))(typescript@5.9.2) '@prisma/instrumentation': specifier: ^7.2.0 version: 7.2.0(@opentelemetry/api@1.9.0) @@ -258,7 +261,7 @@ importers: version: 1.35.3 '@vercel/analytics': specifier: ^2.0.1 - version: 2.0.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + version: 2.0.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) '@vercel/blob': specifier: ^2.0.0 version: 2.0.0 @@ -267,7 +270,7 @@ importers: version: 1.4.0(@opentelemetry/api@1.9.0) '@vercel/flags-core': specifier: ^1.0.1 - version: 1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) + version: 1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) '@vercel/functions': specifier: ^3.1.0 version: 3.1.0(@aws-sdk/credential-provider-web-identity@3.972.28) @@ -276,10 +279,10 @@ importers: version: 3.0.0 '@vercel/speed-insights': specifier: ^1.2.0 - version: 1.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + version: 1.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) '@vercel/toolbar': specifier: ^0.1.41 - version: 0.1.41(@vercel/analytics@2.0.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(@vercel/speed-insights@1.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)) + version: 0.1.41(@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)) '@xstate/store': specifier: ^3.17.1 version: 3.17.1(react@19.2.3) @@ -297,7 +300,10 @@ importers: version: 1.0.0 botid: specifier: ^1.5.10 - version: 1.5.10(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + version: 1.5.10(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + cheerio: + specifier: ^1.2.0 + version: 1.2.0 class-variance-authority: specifier: ^0.7.1 version: 0.7.1 @@ -321,7 +327,7 @@ importers: version: 8.6.0(react@19.2.3) flags: specifier: ^4.0.3 - version: 4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) framer-motion: specifier: ^12.23.13 version: 12.23.13(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -330,7 +336,7 @@ importers: version: 7.1.0 geist: specifier: ^1.5.1 - version: 1.5.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) + version: 1.5.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) input-otp: specifier: ^1.4.2 version: 1.4.2(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -350,29 +356,32 @@ importers: specifier: ^5.1.5 version: 5.1.5 next: - specifier: 16.2.3 - version: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + specifier: 16.2.6 + version: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) next-auth: specifier: 5.0.0-beta.29 - version: 5.0.0-beta.29(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + version: 5.0.0-beta.29(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) next-axiom: specifier: ^1.9.2 - version: 1.9.2(@aws-sdk/credential-provider-web-identity@3.972.28)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + version: 1.9.2(@aws-sdk/credential-provider-web-identity@3.972.28)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) next-intl: specifier: ^4.3.9 - version: 4.3.9(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)(typescript@5.9.2) + version: 4.3.9(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)(typescript@5.9.2) next-seo: specifier: ^7.2.0 - version: 7.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + version: 7.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) next-themes: specifier: ^0.4.6 version: 0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nextstepjs: specifier: ^2.1.2 - version: 2.1.2(motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + version: 2.1.2(motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) nuqs: specifier: ^2.8.9 - version: 2.8.9(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + version: 2.8.9(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + pg: + specifier: ^8.21.0 + version: 8.21.0 radix-ui: specifier: ^1.4.3 version: 1.4.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -399,7 +408,7 @@ importers: version: 3.0.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react-scan: specifier: ^0.4.3 - version: 0.4.3(@types/react@19.2.2)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.50.2) + version: 0.4.3(@types/react@19.2.2)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.50.2) recharts: specifier: ^2.15.4 version: 2.15.4(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -461,6 +470,9 @@ importers: '@types/node': specifier: ^20.19.15 version: 20.19.16 + '@types/pg': + specifier: ^8.20.0 + version: 8.20.0 '@types/react': specifier: 19.2.2 version: 19.2.2 @@ -473,6 +485,9 @@ importers: '@types/validator': specifier: ^13.15.3 version: 13.15.3 + dotenv: + specifier: ^17.4.2 + version: 17.4.2 globals: specifier: ^16.4.0 version: 16.4.0 @@ -495,8 +510,8 @@ importers: specifier: ^0.6.14 version: 0.6.14(prettier@3.7.4) prisma: - specifier: ^6.16.2 - version: 6.16.2(typescript@5.9.2) + specifier: ^7.8.0 + version: 7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.2) sass: specifier: ^1.93.2 version: 1.93.2 @@ -511,10 +526,10 @@ importers: version: 5.9.2 vitest: specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) + version: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) vitest-mock-extended: specifier: ^3.1.0 - version: 3.1.0(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)) + version: 3.1.0(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)) packages: @@ -1025,6 +1040,20 @@ packages: peerDependencies: effect: ^3.21.0 + '@electric-sql/pglite-socket@0.1.1': + resolution: {integrity: sha512-p2hoXw3Z3LQHwTeikdZNsFBOvXGqKY2hk51BBw+8NKND8eoH+8LFOtW9Z8CQKmTJ2qqGYu82ipqiyFZOTTXNfw==} + hasBin: true + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1': + resolution: {integrity: sha512-C+T3oivmy9bpQvSxVqXA1UDY8cB9Eb9vZHL9zxWwEUfDixbXv4G3r2LjoTdR33LD8aomR3O9ZXEO3XEwr/cUCA==} + peerDependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': + resolution: {integrity: sha512-mZ9NzzUSYPOCnxHH1oAHPRzoMFJHY472raDKwXl/+6oPbpdJ7g8LsCN4FSaIIfkiCKHhb3iF/Zqo3NYxaIhU7Q==} + '@emnapi/runtime@1.7.0': resolution: {integrity: sha512-oAYoQnCYaQZKVS53Fq23ceWMRxq5EhQsE0x0RdQ55jT7wagMu5k+fS39v1fiSLrtrLQlXwVINenqhLMtTrV/1Q==} @@ -1251,6 +1280,12 @@ packages: peerDependencies: react: '>= 16 || ^19.0.0-rc' + '@hono/node-server@1.19.11': + resolution: {integrity: sha512-dr8/3zEaB+p0D2n/IUrlPF1HZm586qgJNXK1a9fhg/PzdtkK7Ksd5l312tJX2yBuALqDYBlG20QEbayqPyxn+g==} + engines: {node: '>=18.14.1'} + peerDependencies: + hono: ^4 + '@hookform/resolvers@5.2.2': resolution: {integrity: sha512-A/IxlMLShx3KjV/HeTcTfaMxdwy690+L/ZADoeaTltLx+CVuzkeVIPuybK3jrRfw7YZnmdKsVVHAlEPIAEUNlA==} peerDependencies: @@ -1419,6 +1454,9 @@ packages: '@jridgewell/trace-mapping@0.3.31': resolution: {integrity: sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==} + '@kurkle/color@0.3.4': + resolution: {integrity: sha512-M5UknZPHRu3DEDWoipU6sE8PdkZ6Z/S+v4dD+Ke8IaNlpdSQah50lz1KtcFBa2vsdOnwbbnxJwVM4wty6udA5w==} + '@mermaid-js/parser@1.0.1': resolution: {integrity: sha512-opmV19kN1JsK0T6HhhokHpcVkqKpF+x2pPDKKM2ThHtZAB5F4PROopk0amuVYK5qMrIA4erzpNm8gmPNJgMDxQ==} @@ -1455,53 +1493,53 @@ packages: '@next/env@15.1.6': resolution: {integrity: sha512-d9AFQVPEYNr+aqokIiPLNK/MTyt3DWa/dpKveiAaVccUadFbhFEvY6FXYX2LJO2Hv7PHnLBu2oWwB4uBuHjr/w==} - '@next/env@16.2.3': - resolution: {integrity: sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==} + '@next/env@16.2.6': + resolution: {integrity: sha512-gd8HoHN4ufj73WmR3JmVolrpJR47ILK6LouP5xElPglaVxir6e1a7VzvTvDWkOoPXT9rkkTzyCxBu4yeZfZwcw==} - '@next/swc-darwin-arm64@16.2.3': - resolution: {integrity: sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==} + '@next/swc-darwin-arm64@16.2.6': + resolution: {integrity: sha512-ZJGkkcNfYgrrMkqOdZ7zoLa1TOy0qpcMfk/z4Mh/FKUz40gVO+HNQWqmLxf67Z5WB64DRp0dhEbyHfel+6sJUg==} engines: {node: '>= 10'} cpu: [arm64] os: [darwin] - '@next/swc-darwin-x64@16.2.3': - resolution: {integrity: sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==} + '@next/swc-darwin-x64@16.2.6': + resolution: {integrity: sha512-v/YLBHIY132Ced3puBJ7YJKw1lqsCrgcNo2aRJlCEyQrrCeRJlvGlnmxhPxNQI3KE3N1DN5r9TPNPvka3nq5RQ==} engines: {node: '>= 10'} cpu: [x64] os: [darwin] - '@next/swc-linux-arm64-gnu@16.2.3': - resolution: {integrity: sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==} + '@next/swc-linux-arm64-gnu@16.2.6': + resolution: {integrity: sha512-RPOvqlYBbcQjkz9VQQDZ2T2bARIjXZV1KFlt+V2Mr6SW/e4I9fcKsaA0hdyf2FHoTlsV2xnBd5Y912rP/1Ce6w==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-arm64-musl@16.2.3': - resolution: {integrity: sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==} + '@next/swc-linux-arm64-musl@16.2.6': + resolution: {integrity: sha512-URUTu1+dMkxJsPFgm+OeEvq9wf5sujw0EvgYy80TDGHTSLTnIHeqb0Eu8A3sC95IRgjejQL+kC4mw+4yPxiAXA==} engines: {node: '>= 10'} cpu: [arm64] os: [linux] - '@next/swc-linux-x64-gnu@16.2.3': - resolution: {integrity: sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==} + '@next/swc-linux-x64-gnu@16.2.6': + resolution: {integrity: sha512-DOj182mPV8G3UkrayLoREM5YEYI+Dk5wv7Ox9xl1fFibAELEsFD0lDPfHIeILlutMMfdyhlzYPELG3peuKaurw==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-linux-x64-musl@16.2.3': - resolution: {integrity: sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==} + '@next/swc-linux-x64-musl@16.2.6': + resolution: {integrity: sha512-HKQ5SP/V/ub73UvF7n/zeJlxk2kLmtL7Wzrg4WfmkjmNos5onJ2tKu7yZOPdL18A6Svfn3max29ym+ry7NkK4g==} engines: {node: '>= 10'} cpu: [x64] os: [linux] - '@next/swc-win32-arm64-msvc@16.2.3': - resolution: {integrity: sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==} + '@next/swc-win32-arm64-msvc@16.2.6': + resolution: {integrity: sha512-LZXpTlPyS5v7HhSmnvsLGP3iIYgYOBnc8r8ArlT55sGHV89bR2HlDdBjWQ+PY6SJMmk8TuVGFuxalnP3k/0Dwg==} engines: {node: '>= 10'} cpu: [arm64] os: [win32] - '@next/swc-win32-x64-msvc@16.2.3': - resolution: {integrity: sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==} + '@next/swc-win32-x64-msvc@16.2.6': + resolution: {integrity: sha512-F0+4i0h9J6C4eE3EAPWsoCk7UW/dbzOjyzxY0qnDUOYFu6FFmdZ6l97/XdV3/Nz3VYyO7UWjyEJUXkGqcoXfMA==} engines: {node: '>= 10'} cpu: [x64] os: [win32] @@ -2045,41 +2083,74 @@ packages: peerDependencies: preact: 10.x - '@prisma/client@6.16.2': - resolution: {integrity: sha512-E00PxBcalMfYO/TWnXobBVUai6eW/g5OsifWQsQDzJYm7yaY+IRLo7ZLsaefi0QkTpxfuhFcQ/w180i6kX3iJw==} - engines: {node: '>=18.18'} + '@prisma/adapter-pg@7.8.0': + resolution: {integrity: sha512-ygb3UkerK3v8MDpXVgCISdRNDozpxh6+JVJgiIGbSr5KBgz10LLf5ejUskPGoXlsIjxsOu6nuy1JVQr2EKGSlg==} + + '@prisma/client-runtime-utils@7.8.0': + resolution: {integrity: sha512-5NQZztQ0oY/ADFkmd9gPuweH5A1/CCY8YQPorLLO0Mu6a87mY5gsnDkzmFmIHs9NFaLnZojzgddFVN4RpKYrdw==} + + '@prisma/client@7.8.0': + resolution: {integrity: sha512-HFp3Dawv/3sU3JtlPha90IB+48lS7zHiH4LKZPjmcE8YH5P9DOXGPvo8dqOtO7MqLDd1p2hOWMcFlRT1DMblHw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} peerDependencies: prisma: '*' - typescript: '>=5.1.0' + typescript: '>=5.4.0' peerDependenciesMeta: prisma: optional: true typescript: optional: true - '@prisma/config@6.16.2': - resolution: {integrity: sha512-mKXSUrcqXj0LXWPmJsK2s3p9PN+aoAbyMx7m5E1v1FufofR1ZpPoIArjjzOIm+bJRLLvYftoNYLx1tbHgF9/yg==} + '@prisma/config@7.8.0': + resolution: {integrity: sha512-HFESzd9rx2ZQxlK+TL7tu1HPvCqrHiL6LCxYykI2c34mvaUuIVVl3lYuicJD/MNnzgPnyeBEMlK4WTomJCV5jw==} + + '@prisma/debug@7.2.0': + resolution: {integrity: sha512-YSGTiSlBAVJPzX4ONZmMotL+ozJwQjRmZweQNIq/ER0tQJKJynNkRB3kyvt37eOfsbMCXk3gnLF6J9OJ4QWftw==} + + '@prisma/debug@7.8.0': + resolution: {integrity: sha512-p+QZReysDUqXC+mk17q9a+Y/qzh4c2KYliDK30buYUyfrGeTGSyfmc0AIrJRhZJrLHhRiJa9Au/J72h3C+szvA==} - '@prisma/debug@6.16.2': - resolution: {integrity: sha512-bo4/gA/HVV6u8YK2uY6glhNsJ7r+k/i5iQ9ny/3q5bt9ijCj7WMPUwfTKPvtEgLP+/r26Z686ly11hhcLiQ8zA==} + '@prisma/dev@0.24.3': + resolution: {integrity: sha512-ffHlQuKXZiaDt9Go0OnCTdJZrHxK0k7omJKNV86/VjpsXu5EIHZLK0T7JSWgvNlJwh56kW9JFu9v0qJciFzepg==} - '@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43': - resolution: {integrity: sha512-ThvlDaKIVrnrv97ujNFDYiQbeMQpLa0O86HFA2mNoip4mtFqM7U5GSz2ie1i2xByZtvPztJlNRgPsXGeM/kqAA==} + '@prisma/driver-adapter-utils@7.8.0': + resolution: {integrity: sha512-/Q13o0ZT0rjc1Xk0Q9KhZYwuq2EW/vSbWUBKfgEKkaCuB/Sg6bqnjmTZqC5cD4d6y1vfFAEwBRzfzoSMIVJ55A==} - '@prisma/engines@6.16.2': - resolution: {integrity: sha512-7yf3AjfPUgsg/l7JSu1iEhsmZZ/YE00yURPjTikqm2z4btM0bCl2coFtTGfeSOWbQMmq45Jab+53yGUIAT1sjA==} + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': + resolution: {integrity: sha512-fJPQxCkLgA5EayWaW8eArgCvjJ+N+Kz3VyeNKMEeYiQC4alNkxRKFVAGxv/ZUzuJISKqdw+zGeDbS6mn6RCPOA==} - '@prisma/fetch-engine@6.16.2': - resolution: {integrity: sha512-wPnZ8DMRqpgzye758ZvfAMiNJRuYpz+rhgEBZi60ZqDIgOU2694oJxiuu3GKFeYeR/hXxso4/2oBC243t/whxQ==} + '@prisma/engines@7.8.0': + resolution: {integrity: sha512-jx3rCnNNrt5uzbkKlegtQ2GZHxSlihMCzutgT/BP6UIDF1r9tDI39hV/0T/cHZgzJ3ELbuQPXlVZy+Y1n0pcgw==} - '@prisma/get-platform@6.16.2': - resolution: {integrity: sha512-U/P36Uke5wS7r1+omtAgJpEB94tlT4SdlgaeTc6HVTTT93pXj7zZ+B/cZnmnvjcNPfWddgoDx8RLjmQwqGDYyA==} + '@prisma/fetch-engine@7.8.0': + resolution: {integrity: sha512-gwB0Euiz/DDRyxFRpLXYlK3RfaZUj1c5dAYMuhZYfApg7arknJlcb9bIsOHDppJmbqYaVA+yBIiFMDBfprsNPQ==} + + '@prisma/get-platform@7.2.0': + resolution: {integrity: sha512-k1V0l0Td1732EHpAfi2eySTezyllok9dXb6UQanajkJQzPUGi3vO2z7jdkz67SypFTdmbnyGYxvEvYZdZsMAVA==} + + '@prisma/get-platform@7.8.0': + resolution: {integrity: sha512-WlxgRGnolL8VH2EmkH1R/DkKNr/mVdS3G2h42IZFFZ3eUrH9OT6t73kIOSlkkrv50wG123Iq8d96ufv5LlZktw==} '@prisma/instrumentation@7.2.0': resolution: {integrity: sha512-Rh9Z4x5kEj1OdARd7U18AtVrnL6rmLSI0qYShaB4W7Wx5BKbgzndWF+QnuzMb7GLfVdlT5aYCXoPQVYuYtVu0g==} peerDependencies: '@opentelemetry/api': ^1.8 + '@prisma/query-plan-executor@7.2.0': + resolution: {integrity: sha512-EOZmNzcV8uJ0mae3DhTsiHgoNCuu1J9mULQpGCh62zN3PxPTd+qI9tJvk5jOst8WHKQNwJWR3b39t0XvfBB0WQ==} + + '@prisma/streams-local@0.1.2': + resolution: {integrity: sha512-l49yTxKKF2odFxaAXTmwmkBKL3+bVQ1tFOooGifu4xkdb9NMNLxHj27XAhTylWZod8I+ISGM5erU1xcl/oBCtg==} + engines: {bun: '>=1.3.6', node: '>=22.0.0'} + + '@prisma/studio-core@0.27.3': + resolution: {integrity: sha512-AADjNFPdsrglxHQVTmHFqv6DuKQZ5WY4p5/gVFY017twvNrSwpLJ9lqUbYYxEu2W7nbvVxTZA8deJ8LseNALsw==} + engines: {node: ^20.19 || ^22.12 || >=24.0, pnpm: '8'} + peerDependencies: + '@types/react': 19.2.2 + react: ^18.0.0 || ^19.0.0 + react-dom: ^18.0.0 || ^19.0.0 + '@protobufjs/aspromise@1.1.2': resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} @@ -2831,95 +2902,111 @@ packages: '@react-email/body@0.1.0': resolution: {integrity: sha512-o1bcSAmDYNNHECbkeyceCVPGmVsYvT+O3sSO/Ct7apKUu3JphTi31hu+0Nwqr/pgV5QFqdoT5vdS3SW5DJFHgQ==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/button@0.2.0': resolution: {integrity: sha512-8i+v6cMxr2emz4ihCrRiYJPp2/sdYsNNsBzXStlcA+/B9Umpm5Jj3WJKYpgTPM+aeyiqlG/MMI1AucnBm4f1oQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-block@0.1.0': resolution: {integrity: sha512-jSpHFsgqnQXxDIssE4gvmdtFncaFQz5D6e22BnVjcCPk/udK+0A9jRwGFEG8JD2si9ZXBmU4WsuqQEczuZn4ww==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/code-inline@0.0.5': resolution: {integrity: sha512-MmAsOzdJpzsnY2cZoPHFPk6uDO/Ncpb4Kh1hAt9UZc1xOW3fIzpe1Pi9y9p6wwUmpaeeDalJxAxH6/fnTquinA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/column@0.0.13': resolution: {integrity: sha512-Lqq17l7ShzJG/d3b1w/+lVO+gp2FM05ZUo/nW0rjxB8xBICXOVv6PqjDnn3FXKssvhO5qAV20lHM6S+spRhEwQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/components@0.5.3': resolution: {integrity: sha512-8G5vsoMehuGOT4cDqaYLdpagtqCYPl4vThXNylClxO6SrN2w9Mh1+i2RNGj/rdqh/woamHORjlXMYCA/kzDMew==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/container@0.0.15': resolution: {integrity: sha512-Qo2IQo0ru2kZq47REmHW3iXjAQaKu4tpeq/M8m1zHIVwKduL2vYOBQWbC2oDnMtWPmkBjej6XxgtZByxM6cCFg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/font@0.0.9': resolution: {integrity: sha512-4zjq23oT9APXkerqeslPH3OZWuh5X4crHK6nx82mVHV2SrLba8+8dPEnWbaACWTNjOCbcLIzaC9unk7Wq2MIXw==} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/head@0.0.12': resolution: {integrity: sha512-X2Ii6dDFMF+D4niNwMAHbTkeCjlYYnMsd7edXOsi0JByxt9wNyZ9EnhFiBoQdqkE+SMDcu8TlNNttMrf5sJeMA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/heading@0.0.15': resolution: {integrity: sha512-xF2GqsvBrp/HbRHWEfOgSfRFX+Q8I5KBEIG5+Lv3Vb2R/NYr0s8A5JhHHGf2pWBMJdbP4B2WHgj/VUrhy8dkIg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/hr@0.0.11': resolution: {integrity: sha512-S1gZHVhwOsd1Iad5IFhpfICwNPMGPJidG/Uysy1AwmspyoAP5a4Iw3OWEpINFdgh9MHladbxcLKO2AJO+cA9Lw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/html@0.0.11': resolution: {integrity: sha512-qJhbOQy5VW5qzU74AimjAR9FRFQfrMa7dn4gkEXKMB/S9xZN8e1yC1uA9C15jkXI/PzmJ0muDIWmFwatm5/+VA==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/img@0.0.11': resolution: {integrity: sha512-aGc8Y6U5C3igoMaqAJKsCpkbm1XjguQ09Acd+YcTKwjnC2+0w3yGUJkjWB2vTx4tN8dCqQCXO8FmdJpMfOA9EQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/link@0.0.12': resolution: {integrity: sha512-vF+xxQk2fGS1CN7UPQDbzvcBGfffr+GjTPNiWM38fhBfsLv6A/YUfaqxWlmL7zLzVmo0K2cvvV9wxlSyNba1aQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/markdown@0.0.15': resolution: {integrity: sha512-UQA9pVm5sbflgtg3EX3FquUP4aMBzmLReLbGJ6DZQZnAskBF36aI56cRykDq1o+1jT+CKIK1CducPYziaXliag==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/preview@0.0.13': resolution: {integrity: sha512-F7j9FJ0JN/A4d7yr+aw28p4uX7VLWs7hTHtLo7WRyw4G+Lit6Zucq4UWKRxJC8lpsUdzVmG7aBJnKOT+urqs/w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -2933,24 +3020,28 @@ packages: '@react-email/row@0.0.12': resolution: {integrity: sha512-HkCdnEjvK3o+n0y0tZKXYhIXUNPDx+2vq1dJTmqappVHXS5tXS6W5JOPZr5j+eoZ8gY3PShI2LWj5rWF7ZEtIQ==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/section@0.0.16': resolution: {integrity: sha512-FjqF9xQ8FoeUZYKSdt8sMIKvoT9XF8BrzhT3xiFKdEMwYNbsDflcjfErJe3jb7Wj/es/lKTbV5QR1dnLzGpL3w==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/tailwind@1.2.2': resolution: {integrity: sha512-heO9Khaqxm6Ulm6p7HQ9h01oiiLRrZuuEQuYds/O7Iyp3c58sMVHZGIxiRXO/kSs857NZQycpjewEVKF3jhNTw==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc '@react-email/text@0.1.5': resolution: {integrity: sha512-o5PNHFSE085VMXayxH+SJ1LSOtGsTv+RpNKnTiJDrJUwoBu77G3PlKOsZZQHCNyD28WsQpl9v2WcJLbQudqwPg==} engines: {node: '>=18.0.0'} + deprecated: Package no longer supported. Contact Support at https://www.npmjs.com/support for more info. peerDependencies: react: ^18.0 || ^19.0 || ^19.0.0-rc @@ -4081,6 +4172,9 @@ packages: '@types/node@20.19.16': resolution: {integrity: sha512-VS6TTONVdgwJwtJr7U+ghEjpfmQdqehLLpg/iMYGOd1+ilaFjdBJwFuPggJ4EAYPDCzWfDUHoIxyVnu+tOWVuQ==} + '@types/pg@8.20.0': + resolution: {integrity: sha512-bEPFOaMAHTEP1EzpvHTbmwR8UsFyHSKsRisLIHVMXnpNefSbGA1bD6CVy+qKjGSqmZqNqBDV2azOBo8TgkcVow==} + '@types/react-dom@19.2.2': resolution: {integrity: sha512-9KQPoO6mZCi7jcIStSnlOWn2nEF3mNmyr3rIAsGnAbQKYbRLyqmeSc39EVgtxXVia+LMT8j3knZLAZAh+xLmrw==} peerDependencies: @@ -4123,6 +4217,7 @@ packages: '@ungap/structured-clone@1.3.0': resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + deprecated: Potential CWE-502 - Update to 1.3.1 or higher '@upsetjs/venn.js@2.0.0': resolution: {integrity: sha512-WbBhLrooyePuQ1VZxrJjtLvTc4NVfpOyKx0sKqioq9bX1C1m7Jgykkn8gLrtwumBioXIqam8DLxp88Adbue6Hw==} @@ -4226,6 +4321,7 @@ packages: '@vercel/kv@3.0.0': resolution: {integrity: sha512-pKT8fRnfyYk2MgvyB6fn6ipJPCdfZwiKDdw7vB+HL50rjboEBHDVBEcnwfkEpVSp2AjNtoaOUH7zG+bVC/rvSg==} engines: {node: '>=14.6'} + deprecated: 'Vercel KV is deprecated. If you had an existing KV store, it should have moved to Upstash Redis which you will see under Vercel Integrations. For new projects, install a Redis integration from Vercel Marketplace: https://vercel.com/marketplace?category=storage&search=redis' '@vercel/microfrontends@1.3.0': resolution: {integrity: sha512-7hvza7+osdfowUllyDthk3QerhoU/8kQgr9QNsIxOJQxpbpwGlN3mMNvYG7CaPB41kQutY6D7StCiOg0E0xGFg==} @@ -4425,6 +4521,10 @@ packages: asynckit@0.4.0: resolution: {integrity: sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==} + aws-ssl-profiles@1.1.2: + resolution: {integrity: sha512-NZKeq9AfyQvEeNlN0zSYAaWrmBffJh3IELMZfRpJVWgrpEbtEpnjvzqBPf+mxoI287JohRDoa+/nsfqqiZmF6g==} + engines: {node: '>= 6.0.0'} + axios@1.12.2: resolution: {integrity: sha512-vMJzPewAlRyOgxV2dU0Cuz2O8zzzx9VYtbJOaBgXFeLc4IV/Eg50n4LowmehOOR61S8ZMpc2K5Sa7g6A4jfkUw==} @@ -4439,9 +4539,8 @@ packages: engines: {node: '>=6.0.0'} hasBin: true - baseline-browser-mapping@2.8.7: - resolution: {integrity: sha512-bxxN2M3a4d1CRoQC//IqsR5XrLh0IJ8TCv2x6Y9N0nckNz/rTjZB3//GGscZziZOxmjP55rzxg/ze7usFI9FqQ==} - hasBin: true + better-result@2.9.2: + resolution: {integrity: sha512-WIFoBPCdnTOdk9inkE1ZRvCZ4P0CpSkAiLlchC65N7n9DcjZ3NhqkBOlafzpOVnO8ixyi37kicmSJ3ENhPZl7Q==} binary-extensions@2.3.0: resolution: {integrity: sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==} @@ -4452,6 +4551,9 @@ packages: peerDependencies: react: '>=17.0.1' + boolbase@1.0.0: + resolution: {integrity: sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==} + botid@1.5.10: resolution: {integrity: sha512-hhgty1u0CxozqTqLbTQMtYBwmWdzWZTAsBCvN7/qhkN3fM7MlXacmmcMoyc0f+vV+U6RRoLYdlo32td+PhJyew==} peerDependencies: @@ -4475,10 +4577,10 @@ packages: engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} hasBin: true - c12@3.1.0: - resolution: {integrity: sha512-uWoS8OU1MEIsOv8p/5a82c3H31LsWVR5qiyXVfBNOzfffjUWtPnhAb4BYI2uG2HfGmZmFjCtui5XNWaps+iFuw==} + c12@3.3.4: + resolution: {integrity: sha512-cM0ApFQSBXuourJejzwv/AuPRvAxordTyParRVcHjjtXirtkzM0uK2L9TTn9s0cXZbG7E55jCivRQzoxYmRAlA==} peerDependencies: - magicast: ^0.3.5 + magicast: '*' peerDependenciesMeta: magicast: optional: true @@ -4522,10 +4624,21 @@ packages: character-reference-invalid@2.0.1: resolution: {integrity: sha512-iBZ4F4wRbyORVsu0jPV7gXkOsGYjGHPmAyv+HiHG8gi5PtC9KI2j1+v8/tlibRvjoWX027ypmG/n0HtO5t7unw==} + chart.js@4.5.1: + resolution: {integrity: sha512-GIjfiT9dbmHRiYi6Nl2yFCq7kkwdkp1W/lp2J99rX0yo9tgJGn3lKQATztIjb5tVtevcBtIdICNWqlq5+E8/Pw==} + engines: {pnpm: '>=8'} + check-error@2.1.1: resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} engines: {node: '>= 16'} + cheerio-select@2.1.0: + resolution: {integrity: sha512-9v9kG0LvzrlcungtnJtpGNxY+fzECQKhK4EGJX2vByejiMX84MFNQw4UxPJl3bFbTMw+Dfs37XaIkCwTZfLh4g==} + + cheerio@1.2.0: + resolution: {integrity: sha512-WDrybc/gKFpTYQutKIK6UvfcuxijIZfMfXaYm8NMsPQxSYvf+13fXUJ4rztGGbJcBQ/GF55gvrZ0Bc0bj/mqvg==} + engines: {node: '>=20.18.1'} + chevrotain-allstar@0.3.1: resolution: {integrity: sha512-b7g+y9A0v4mxCW1qUhf3BSVPg+/NvGErk/dOkrDaHA0nQIQGAtrOjlX//9OQtRlSCy+x9rfB5N8yC71lH1nvMw==} peerDependencies: @@ -4542,13 +4655,14 @@ packages: resolution: {integrity: sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA==} engines: {node: '>= 14.16.0'} + chokidar@5.0.0: + resolution: {integrity: sha512-TQMmc3w+5AxjpL8iIiwebF73dRDF4fBIieAqGn9RGCWaEVwQ6Fb2cGe31Yns0RRIzii5goJ1Y7xbMwo1TxMplw==} + engines: {node: '>= 20.19.0'} + chownr@3.0.0: resolution: {integrity: sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==} engines: {node: '>=18'} - citty@0.1.6: - resolution: {integrity: sha512-tskPPKEs8D2KPafUypv2gxwJP8h/OaJmC82QQGGDQcHvXX43xF2VDACcJVmZ0EuSxkpO9Kc4MlrA3q0+FG58AQ==} - cjs-module-lexer@1.4.3: resolution: {integrity: sha512-9z8TZaGM1pfswYeXrUpzPrkx8UnWYdhJclsiYMm6x/w5+nN+8Tf/LnAgfLGQCm59qAOxU8WwHEq2vNwF6i4j+Q==} @@ -4609,12 +4723,8 @@ packages: confbox@0.1.8: resolution: {integrity: sha512-RMtmw0iFkeR4YV+fUOSucriAQNb9g8zFR52MWCtl+cCZOFRNL6zeB395vPzFhEjjn4fMxXudmELnl/KF/WrK6w==} - confbox@0.2.2: - resolution: {integrity: sha512-1NB+BKqhtNipMsov4xI/NnhCKp9XG9NamYp5PVm9klAT0fsrNPjaFICsCFhNhwZJKNh7zB/3q8qXz0E9oaMNtQ==} - - consola@3.4.2: - resolution: {integrity: sha512-5IKcdX0nnYavi6G7TtOhwkYzyjfJlatbjMjuLSfE2kYT5pMDOilZ4OvMhi637CcDICTmz3wARPoyhqyX1Y+XvA==} - engines: {node: ^14.18.0 || >=16.10.0} + confbox@0.2.4: + resolution: {integrity: sha512-ysOGlgTFbN2/Y6Cg3Iye8YKulHw+R2fNXHrgSmXISQdMnomY6eNDprVdW9R5xBguEqI954+S6709UyiO7B+6OQ==} convert-source-map@2.0.0: resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} @@ -4640,6 +4750,13 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + css-select@5.2.2: + resolution: {integrity: sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==} + + css-what@6.2.2: + resolution: {integrity: sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==} + engines: {node: '>= 6'} + csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} @@ -4838,8 +4955,8 @@ packages: resolution: {integrity: sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==} engines: {node: '>=0.10.0'} - defu@6.1.4: - resolution: {integrity: sha512-mEQCMmwJu317oSz8CwdIOdwf3xMif1ttiM8LTufzc3g6kR+9Pe236twL8j3IYT1F7GfRgGcW6MWxzZjLIkuHIg==} + defu@6.1.7: + resolution: {integrity: sha512-7z22QmUWiQ/2d0KkdYmANbRUVABpZ9SNYyH5vx6PZ+nE5bcC0l7uFvEfHlyld/HcGBFTL536ClDt3DEcSlEJAQ==} delaunator@5.0.1: resolution: {integrity: sha512-8nvh+XBe96aCESrGOqMp/84b13H9cdKbG5P2ejQCh4d4sK9RL4371qou9drQjMhvnPmhWl5hnmqbEE0fXr9Xnw==} @@ -4848,6 +4965,10 @@ packages: resolution: {integrity: sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==} engines: {node: '>=0.4.0'} + denque@2.1.0: + resolution: {integrity: sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==} + engines: {node: '>=0.10'} + dequal@2.0.3: resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} engines: {node: '>=6'} @@ -4893,16 +5014,16 @@ packages: domutils@3.2.2: resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==} - dotenv@16.6.1: - resolution: {integrity: sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow==} + dotenv@17.4.2: + resolution: {integrity: sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw==} engines: {node: '>=12'} dunder-proto@1.0.1: resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==} engines: {node: '>= 0.4'} - effect@3.16.12: - resolution: {integrity: sha512-N39iBk0K71F9nb442TLbTkjl24FLUzuvx2i1I2RsEAQsdAdUTuUoW0vlfUXgkMTUOnYqKnWcFfqw4hK4Pw27hg==} + effect@3.20.0: + resolution: {integrity: sha512-qMLfDJscrNG8p/aw+IkT9W7fgj50Z4wG5bLBy0Txsxz8iUHjDIkOgO3SV0WZfnQbNG2VJYb0b+rDLMrhM4+Krw==} effect@3.21.0: resolution: {integrity: sha512-PPN80qRokCd1f015IANNhrwOnLO7GrrMQfk4/lnZRE/8j7UPWrNNjPV0uBrZutI/nHzernbW+J0hdqQysHiSnQ==} @@ -4927,6 +5048,9 @@ packages: resolution: {integrity: sha512-i6UzDscO/XfAcNYD75CfICkmfLedpyPDdozrLMmQc5ORaQcdMoc21OnlEylMIqI7U8eniKrPMxxtj8k0vhmJhA==} engines: {node: '>=14'} + encoding-sniffer@0.2.1: + resolution: {integrity: sha512-5gvq20T6vfpekVtqrYQsSCFZ1wEg5+wW0/QaZMWkFr6BqD3NfKs0rLCx4rrVlSWJeZb5NBJgVLswK/w2MWU+Gw==} + enhanced-resolve@5.18.3: resolution: {integrity: sha512-d4lC8xfavMeBjzGr2vECC3fsGXziXZQyJxD868h2M/mBI3PwAuODxAkLkq5HYuvrPYcUtiLzsTo8U3PgX3Ocww==} engines: {node: '>=10.13.0'} @@ -4939,6 +5063,14 @@ packages: resolution: {integrity: sha512-aN97NXWF6AWBTahfVOIrB/NShkzi5H7F9r1s9mD3cDj4Ko5f2qhhVoYMibXF7GlLveb/D2ioWay8lxI97Ven3g==} engines: {node: '>=0.12'} + entities@7.0.1: + resolution: {integrity: sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA==} + engines: {node: '>=0.12'} + + env-paths@3.0.0: + resolution: {integrity: sha512-dtJUTepzMW3Lm/NPxRf3wP4642UWhjL2sQxc+ym2YMj1m/H2zDNQOlezafzkHwn6sMstjHTwG6iQQsctDW/b1A==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + es-content-type@0.0.10: resolution: {integrity: sha512-yCgcv1M2IuFUoGZ3zE4OR2INGmZOwEuyaE5WX4MOKGpJcO8JXgVOIcXVicwnTqlxvx6qs9IJGl/Rr1+YtCkRgg==} engines: {node: '>=12.x'} @@ -5021,8 +5153,8 @@ packages: resolution: {integrity: sha512-JhFGDVJ7tmDJItKhYgJCGLOWjuK9vPxiXoUFLwLDc99NlmklilbiQJwoctZtt13+xMw91MCk/REan6MWHqDjyA==} engines: {node: '>=12.0.0'} - exsolve@1.0.7: - resolution: {integrity: sha512-VO5fQUzZtI6C+vx4w/4BWJpg3s/5l+6pRQEHzFRM8WFi4XffSP1Z+4qi7GbjWbvRQEbdIco5mIMq+zX4rPuLrw==} + exsolve@1.0.8: + resolution: {integrity: sha512-LmDxfWXwcTArk8fUEnOfSZpHOJ6zOMUJKOtFLFqJLoKJetuQG874Uc7/Kki7zFLzYybmZhp1M7+98pfMqeX8yA==} extend@3.0.2: resolution: {integrity: sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==} @@ -5117,6 +5249,10 @@ packages: debug: optional: true + foreground-child@3.3.1: + resolution: {integrity: sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw==} + engines: {node: '>=14'} + form-data@4.0.4: resolution: {integrity: sha512-KrGhL9Q4zjj0kiUt5OO4Mr/A/jlI2jDYs5eHBpYHPcBEVSiipAvn2Ko2HnPe20rmcuuvMHNdZFp+4IlGTMF0Ow==} engines: {node: '>= 6'} @@ -5171,6 +5307,9 @@ packages: peerDependencies: next: '>=13.2.0' + generate-function@2.3.1: + resolution: {integrity: sha512-eeB5GfMNeevm/GRYq20ShmsaGcmI81kIX2K9XQx5miC8KdHaC6Jm0qQ8ZNeGOi7wYB8OsdxKs+Y2oVuTFuVwKQ==} + gensync@1.0.0-beta.2: resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} engines: {node: '>=6.9.0'} @@ -5187,6 +5326,9 @@ packages: resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} engines: {node: '>=6'} + get-port-please@3.2.0: + resolution: {integrity: sha512-I9QVvBw5U/hw3RmWpYKRumUeaDgxTPd401x364rLmWBJcOQ753eov1eTgzDqRG9bqFIfDc7gfzcQEWrUri3o1A==} + get-port@5.1.1: resolution: {integrity: sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==} engines: {node: '>=8'} @@ -5202,8 +5344,8 @@ packages: get-tsconfig@4.10.1: resolution: {integrity: sha512-auHyJ4AgMz7vgS8Hp3N6HXSmlMdUyhSUrfBF16w153rxtLIEOE+HGqaBppczZvnHLqQJfiHotCYpNhl0lUROFQ==} - giget@2.0.0: - resolution: {integrity: sha512-L5bGsVkxJbJgdnwyuheIunkGatUF/zssUoxxjACCseZYAVbaqdh9Tsmmlkl8vYan09H7sbvKt4pS8GqKLBrEzA==} + giget@3.3.0: + resolution: {integrity: sha512-gzi2D96p+AMfDcmJHGDj3KJ9NRiwvlFAU5yfa3ROwWZmFUjX4P43x3BcyRaOMMLto1vUo7C+86+MFhYTl6Ryiw==} hasBin: true glob-parent@5.1.2: @@ -5221,6 +5363,12 @@ packages: graceful-fs@4.2.11: resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + grammex@3.1.12: + resolution: {integrity: sha512-6ufJOsSA7LcQehIJNCO7HIBykfM7DXQual0Ny780/DEcJIpBlHRvcqEBWGPYd7hrXL2GJ3oJI1MIhaXjWmLQOQ==} + + graphmatch@1.1.1: + resolution: {integrity: sha512-5ykVn/EXM1hF0XCaWh05VbYvEiOL2lY1kBxZtaYsyvjp7cmWOU1XsAdfQBwClraEofXDT197lFbXOEVMHpvQOg==} + hachure-fill@0.5.2: resolution: {integrity: sha512-3GKBOn+m2LX9iq+JC1064cSFprJY4jL1jCXTcpnfER5HYE2l/4EfWSGzkPa/ZDBmYI0ZOEj5VHV/eKnPGkHuOg==} @@ -5278,6 +5426,10 @@ packages: hastscript@9.0.1: resolution: {integrity: sha512-g7df9rMFX/SPi34tyGCyUBREQoKkapwdY/T04Qn9TDWfHhAYt4/I0gMVirzK5wEzeUqIjEB+LXC/ypb7Aqno5w==} + hono@4.12.25: + resolution: {integrity: sha512-2NFaIyNVgJmBs/ecmtGzlmluTFs5cHEWGTdu0t1HBwYzoGXOL5nUQBRMXsXWla5i4KkG//QMzVP88m1+I3fdAQ==} + engines: {node: '>=16.9.0'} + html-to-text@9.0.5: resolution: {integrity: sha512-qY60FjREgVZL03vJU6IfMV4GDjGBIoOyvuFdpBDIX9yTlDw0TjxVBQp+P8NvpdIXNJvfWBTNul7fsAQJq2FNpg==} engines: {node: '>=14'} @@ -5288,6 +5440,9 @@ packages: html-void-elements@3.0.0: resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + htmlparser2@10.1.0: + resolution: {integrity: sha512-VTZkM9GWRAtEpveh7MSF6SjjrpNVNNVJfFup7xTY3UpFtm67foy9HDVXneLtFVt4pMz5kZtgNcvCniNFb1hlEQ==} + htmlparser2@8.0.2: resolution: {integrity: sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==} @@ -5295,6 +5450,9 @@ packages: resolution: {integrity: sha512-7mz/721AbnJwIVbnaSv1Cz3Am0ZLT/UBwkC92VlxhXv/k/BBQfM2fXElQNC27BVGr0uwUpplYPQM9LnaBMR5NQ==} engines: {node: '>=8.0.0'} + http-status-codes@2.3.0: + resolution: {integrity: sha512-RJ8XvFvpPM/Dmc5SV+dC4y5PCeOhT3x1Hq0NU3rjGeg5a/CqlhZ7uudknPwZFz4aeAXDcbAyaeP7GAo9lvngtA==} + human-signals@2.1.0: resolution: {integrity: sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw==} engines: {node: '>=10.17.0'} @@ -5303,6 +5461,10 @@ packages: resolution: {integrity: sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==} engines: {node: '>=0.10.0'} + iconv-lite@0.7.2: + resolution: {integrity: sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==} + engines: {node: '>=0.10.0'} + immutable@5.1.3: resolution: {integrity: sha512-+chQdDfvscSF1SJqv2gn4SRO2ZyS3xL3r7IW/wWEEzrzLisnOlKiQu5ytC/BVNcS15C39WT2Hg/bjKjDMcu+zg==} @@ -5371,6 +5533,9 @@ packages: resolution: {integrity: sha512-+Pgi+vMuUNkJyExiMBt5IlFoMyKnr5zhJ4Uspz58WOhBF5QoIZkFyNHIbBAtHwzVAgk5RtndVNsDRN61/mmDqg==} engines: {node: '>=12'} + is-property@1.0.2: + resolution: {integrity: sha512-Ks/IoX00TtClbGQr4TWXemAnktAQvYB7HzcCxDGqEZU6oCmb2INHuOoKxbtR+HFkmYWBKv/dOZtGRiAjDhj92g==} + is-stream@2.0.1: resolution: {integrity: sha512-hFoiJiTl63nn+kstHGBtewWSKnQLpyb155KHheA1l39uvtO9nWIop1p3udqPcUd/xbF1VLMO4n7OI6p7RbngDg==} engines: {node: '>=8'} @@ -5382,6 +5547,10 @@ packages: resolution: {integrity: sha512-twQoecYPiVA5K/h6SxtORw/Bs3ar+mLUtoPSc7iMXzQzK8d7eJ/R09wmTwAjiamETn1cXYPGfNnu7DMoHgu12w==} hasBin: true + jiti@2.7.0: + resolution: {integrity: sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==} + hasBin: true + jose@4.15.9: resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} @@ -5551,6 +5720,10 @@ packages: lru-cache@5.1.1: resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + lru.min@1.1.4: + resolution: {integrity: sha512-DqC6n3QQ77zdFpCMASA1a3Jlb64Hv2N2DciFGkO/4L9+q/IpIAuRlKOvCXabtRW6cQf8usbmM6BE/TOPysCdIA==} + engines: {bun: '>=1.0.0', deno: '>=1.30.0', node: '>=8.0.0'} + lucide-react@0.544.0: resolution: {integrity: sha512-t5tS44bqd825zAW45UQxpG2CvcC4urOwn2TrwSH8u+MjeE+1NnWl6QqeQ/6NdjMqdOygyiT9p3Ev0p1NJykxjw==} peerDependencies: @@ -5852,6 +6025,14 @@ packages: multipasta@0.2.7: resolution: {integrity: sha512-KPA58d68KgGil15oDqXjkUBEBYc00XvbPj5/X+dyzeo/lWm9Nc25pQRlf1D+gv4OpK7NM0J1odrbu9JNNGvynA==} + mysql2@3.15.3: + resolution: {integrity: sha512-FBrGau0IXmuqg4haEZRBfHNWB5mUARw6hNwPDXXGg0XzVJ50mr/9hb267lvpVMnhZ1FON3qNd4Xfcez1rbFwSg==} + engines: {node: '>= 8.0'} + + named-placeholders@1.1.6: + resolution: {integrity: sha512-Tz09sEL2EEuv5fFowm419c1+a/jSMiBjI9gHxVLrVdbUkkNUUfjsVYs9pVZu5oCon/kmRh9TfLEObFtkVxmY0w==} + engines: {node: '>=8.0.0'} + nanoid@3.3.11: resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} @@ -5915,8 +6096,8 @@ packages: react: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc react-dom: ^16.8 || ^17 || ^18 || ^19 || ^19.0.0-rc - next@16.2.3: - resolution: {integrity: sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==} + next@16.2.6: + resolution: {integrity: sha512-qOVgKJg1+At15NpeUP+eJgCHvTCgXsogweq87Ri/Ix7PkqQHg4sdaXmSFqKlgaIXE4kW0g25LE68W87UANlHtw==} engines: {node: '>=20.9.0'} hasBin: true peerDependencies: @@ -5956,9 +6137,6 @@ packages: node-addon-api@7.1.1: resolution: {integrity: sha512-5m3bsyrjFWE1xf7nz7YXdN4udnVtXK6/Yfgn5qnahL6bCkf2yKt4k3nuTKAtT4r3IG8JNR2ncsIMdZuAzJjHQQ==} - node-fetch-native@1.6.7: - resolution: {integrity: sha512-g9yhqoedzIUm0nTnTqAQvueMPVOuIY16bqgAJJC8XOOubYFNwz6IER9qs0Gq2Xd0+CecCKFjtdDTMA4u4xG06Q==} - node-gyp-build-optional-packages@5.2.2: resolution: {integrity: sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==} hasBin: true @@ -5977,6 +6155,9 @@ packages: resolution: {integrity: sha512-S48WzZW777zhNIrn7gxOlISNAqi9ZC/uQFnRdbeIHhZhCA6UqpkOT8T1G7BvfdgP4Er8gF4sUbaS0i7QvIfCWw==} engines: {node: '>=8'} + nth-check@2.1.1: + resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==} + nuqs@2.8.9: resolution: {integrity: sha512-8ou6AEwsxMWSYo2qkfZtYFVzngwbKmg4c00HVxC1fF6CEJv3Fwm6eoZmfVPALB+vw8Udo7KL5uy96PFcYe1BIQ==} peerDependencies: @@ -5998,11 +6179,6 @@ packages: react-router-dom: optional: true - nypm@0.6.2: - resolution: {integrity: sha512-7eM+hpOtrKrBDCh7Ypu2lJ9Z7PNZBdi/8AT3AX8xoCj43BBVHD0hPSTEvMtkMpfs8FCqBGhxB+uToIQimA111g==} - engines: {node: ^14.16.0 || >=16.10.0} - hasBin: true - oauth4webapi@2.17.0: resolution: {integrity: sha512-lbC0Z7uzAFNFyzEYRIC+pkSVvDHJTbEW+dYlSBAlCYDe6RxUkJ26bClhk8ocBZip1wfI9uKTe0fm4Ib4RHn6uQ==} @@ -6072,6 +6248,12 @@ packages: parse-entities@4.0.2: resolution: {integrity: sha512-GG2AQYWoLgL877gQIKeRPGO1xF9+eG1ujIb5soS5gPvLQ1y2o8FL90w2QWNdf9I361Mpp7726c+lj3U0qK1uGw==} + parse5-htmlparser2-tree-adapter@7.1.0: + resolution: {integrity: sha512-ruw5xyKs6lrpo9x9rCZqZZnIUntICjQAd0Wsmp396Ul9lN/h+ifgVV1x1gZHi8euej6wTfpqX8j+BFQxF0NS/g==} + + parse5-parser-stream@7.1.2: + resolution: {integrity: sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==} + parse5@7.3.0: resolution: {integrity: sha512-IInvU7fabl34qmi9gY8XOVxhYyMyuH2xUNpb2q8/Y+7552KlejkRvqvD19nMoUW/uQGGbqNpA6Tufu5FL5BZgw==} @@ -6106,8 +6288,42 @@ packages: peberminta@0.9.0: resolution: {integrity: sha512-XIxfHpEuSJbITd1H3EeQwpcZbTLHc+VVr8ANI9t5sit565tsI4/xK3KWTUFE2e6QiangUkh3B0jihzmGnNrRsQ==} - perfect-debounce@1.0.0: - resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + perfect-debounce@2.1.0: + resolution: {integrity: sha512-LjgdTytVFXeUgtHZr9WYViYSM/g8MkcTPYDlPa3cDqMirHjKiSZPYd6DoL7pK8AJQr+uWkQvCjHNdiMqsrJs+g==} + + pg-cloudflare@1.4.0: + resolution: {integrity: sha512-Vo7z/6rrQYxpNRylp4Tlob2elzbh+N/MOQbxFVWCxS7oEx6jF53GTJFxK2WWpKuBRkmiin4Mt+xofFDjx09R0A==} + + pg-connection-string@2.13.0: + resolution: {integrity: sha512-EMnU9E2fSULdsbErBbMaXJvFeD9B4+nPcM3f+4lsiCR0BHLPrLVjv3DbyM2hgQQviKJaTWIRRTjKjWlHg3p2ig==} + + pg-int8@1.0.1: + resolution: {integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==} + engines: {node: '>=4.0.0'} + + pg-pool@3.14.0: + resolution: {integrity: sha512-gKtPkFdQPU3DksooVLi9LsjZxrsBUZIpa+7aVx+LV5pNh0KzP4Zleud2po+ConrxbuXGBJ6Hfer6hdgpIBpBaw==} + peerDependencies: + pg: '>=8.0' + + pg-protocol@1.14.0: + resolution: {integrity: sha512-n5taZ1kO3s9ngDTVxsEznOqCyToTgz0FLuPq0B33COy5pPpuWJpY3/2oRBVETuOgzdqRXfWpM9HIhp2LBBT1BA==} + + pg-types@2.2.0: + resolution: {integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==} + engines: {node: '>=4'} + + pg@8.21.0: + resolution: {integrity: sha512-AUP1EYJuHraQGsVoCQVIcM7TEJVGtDzxWtGFZd8rds9d+CCXlU5Js1rYgfLNvxy9iJrpHjGrRjoi/3BT9fRyiA==} + engines: {node: '>= 16.0.0'} + peerDependencies: + pg-native: '>=3.0.1' + peerDependenciesMeta: + pg-native: + optional: true + + pgpass@1.0.5: + resolution: {integrity: sha512-FdW9r/jQZhSeohs1Z3sI1yxFQNFvMcnmfuj4WBMUTxOrAyLMaTcE1aAMBiTlbMNaXvBCQuVi0R7hd8udDSP7ug==} picocolors@1.1.1: resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} @@ -6150,6 +6366,30 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + postgres-array@2.0.0: + resolution: {integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==} + engines: {node: '>=4'} + + postgres-array@3.0.4: + resolution: {integrity: sha512-nAUSGfSDGOaOAEGwqsRY27GPOea7CNipJPOA7lPbdEpx5Kg3qzdP0AaWC5MlhTWV9s4hFX39nomVZ+C4tnGOJQ==} + engines: {node: '>=12'} + + postgres-bytea@1.0.1: + resolution: {integrity: sha512-5+5HqXnsZPE65IJZSMkZtURARZelel2oXUEO8rH83VS/hxH5vv1uHquPg5wZs8yMAfdv971IU+kcPUczi7NVBQ==} + engines: {node: '>=0.10.0'} + + postgres-date@1.0.7: + resolution: {integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==} + engines: {node: '>=0.10.0'} + + postgres-interval@1.2.0: + resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} + engines: {node: '>=0.10.0'} + + postgres@3.4.7: + resolution: {integrity: sha512-Jtc2612XINuBjIl/QTWsV5UvE8UHuNblcO3vVADSrKsrc6RqGX6lOW1cEo3CM2v0XG4Nat8nI+YM7/f26VxXLw==} + engines: {node: '>=12'} + preact-render-to-string@5.2.3: resolution: {integrity: sha512-aPDxUn5o3GhWdtJtW0svRC2SS/l8D9MAgo2+AWml+BhDImb27ALf04Q2d+AHqUUOc6RdSXFIBVa2gxzgMKgtZA==} peerDependencies: @@ -6238,13 +6478,16 @@ packages: pretty-format@3.8.0: resolution: {integrity: sha512-WuxUnVtlWL1OfZFQFuqvnvs6MiAGk9UNsBostyBOB0Is9wb5uRESevA6rnl/rkksXaGX3GzZhPup5d6Vp1nFew==} - prisma@6.16.2: - resolution: {integrity: sha512-aRvldGE5UUJTtVmFiH3WfNFNiqFlAtePUxcI0UEGlnXCX7DqhiMT5TRYwncHFeA/Reca5W6ToXXyCMTeFPdSXA==} - engines: {node: '>=18.18'} + prisma@7.8.0: + resolution: {integrity: sha512-yfN4yrw7HV9kEJhoy1+jgah0jafEIQsf7uWouSsM8MvJtlubsk+kM7AIBWZ8+GJl74Yj3c+nbYqBkMOxtsZ3Lw==} + engines: {node: ^20.19 || ^22.12 || >=24.0} hasBin: true peerDependencies: - typescript: '>=5.1.0' + better-sqlite3: '>=9.0.0' + typescript: '>=5.4.0' peerDependenciesMeta: + better-sqlite3: + optional: true typescript: optional: true @@ -6255,6 +6498,9 @@ packages: prop-types@15.8.1: resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + proper-lockfile@4.1.2: + resolution: {integrity: sha512-TjNPblN4BwAWMXU8s9AEz4JmQxnD1NNL7bNOY/AKUzyamc379FWASUhc/K1pL2noVb+XmZKLL68cjzLsiOAMaA==} + property-information@7.1.0: resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==} @@ -6354,8 +6600,8 @@ packages: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} - rc9@2.1.2: - resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==} + rc9@3.0.1: + resolution: {integrity: sha512-gMDyleLWVE+i6Sgtc0QbbY6pEKqYs97NGi6isHQPqYlLemPoO8dxQ3uGi0f4NiP98c+jMW6cG1Kx9dDwfvqARQ==} react-day-picker@9.12.0: resolution: {integrity: sha512-t8OvG/Zrciso5CQJu5b1A7yzEmebvST+S3pOVQJWxwjjVngyG/CA2htN/D15dLI4uTEuLLkbZyS4YYt480FAtA==} @@ -6476,12 +6722,17 @@ packages: resolution: {integrity: sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg==} engines: {node: '>= 14.18.0'} + readdirp@5.0.0: + resolution: {integrity: sha512-9u/XQ1pvrQtYyMpZe7DXKv2p5CNvyVwzUB6uhLAnQwHMSgKMBR62lc7AHljaeteeHXn11XTAaLLUVZYVZyuRBQ==} + engines: {node: '>= 20.19.0'} + recharts-scale@0.4.5: resolution: {integrity: sha512-kivNFO+0OcUNu7jQquLXAxz1FIwZj8nrj+YkOKc5694NbjCvcT6aSZiIzNzd2Kul4o4rTto8QVR9lMNtxD4G1w==} recharts@2.15.4: resolution: {integrity: sha512-UT/q6fwS3c1dHbXv2uFgYJ9BMFHu3fwnd7AYZaEQhXuYQ4hgsxLvsUXzGdKeZrW5xopzDCvuA2N41WJ88I7zIw==} engines: {node: '>=14'} + deprecated: 1.x and 2.x branches are no longer active. Bump to Recharts v3 to receive latest features and bugfixes. See https://github.com/recharts/recharts/wiki/3.0-migration-guide peerDependencies: react: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 react-dom: ^16.0.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 @@ -6546,6 +6797,9 @@ packages: remark-stringify@11.0.0: resolution: {integrity: sha512-1OSmLd3awB/t8qdoEOMazZkNsfVTeY4fTsgzcQFdXNq8ToTN4ZGwrMnlda4K6smTFKD+GRV6O48i6Z4iKgPPpw==} + remeda@2.33.4: + resolution: {integrity: sha512-ygHswjlc/opg2VrtiYvUOPLjxjtdKvjGz1/plDhkG66hjNjFr1xmfrs2ClNFo/E6TyUFiwYNh53bKV26oBoMGQ==} + remend@1.3.0: resolution: {integrity: sha512-iIhggPkhW3hFImKtB10w0dz4EZbs28mV/dmbcYVonWEJ6UGHHpP+bFZnTh6GNWJONg5m+U56JrL+8IxZRdgWjw==} @@ -6563,6 +6817,10 @@ packages: resolve-pkg-maps@1.0.0: resolution: {integrity: sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==} + retry@0.12.0: + resolution: {integrity: sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==} + engines: {node: '>= 4'} + retry@0.13.1: resolution: {integrity: sha512-XQBQ3I8W1Cge0Seh+6gjj03LbmRFWuoszgK9ooCpwYIrhhoO80pfq4cUkU5DkknwfOfFteRwlZ56PYOGYyFWdg==} engines: {node: '>= 4'} @@ -6614,6 +6872,9 @@ packages: engines: {node: '>=10'} hasBin: true + seq-queue@0.0.5: + resolution: {integrity: sha512-hr3Wtp/GZIc/6DAGPDcV4/9WoZhjrkXsi5B/07QgX8tsdc6ilr7BFM6PM6rbdAX1kFSDYeZGLipIZZKyQP0O5Q==} + sharp@0.34.5: resolution: {integrity: sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==} engines: {node: ^18.17.0 || ^20.3.0 || >=21.0.0} @@ -6655,6 +6916,10 @@ packages: signal-exit@3.0.7: resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + sisteransi@1.0.5: resolution: {integrity: sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==} @@ -6671,9 +6936,20 @@ packages: space-separated-tokens@2.0.2: resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + split2@4.2.0: + resolution: {integrity: sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==} + engines: {node: '>= 10.x'} + + sqlstring@2.3.3: + resolution: {integrity: sha512-qC9iz2FlN7DQl3+wjwn3802RTyjCx7sDvfQEXchwa6CWOx07/WVfh91gBmQ9fahw8snwGEWU3xGzOt4tFyHLxg==} + engines: {node: '>= 0.6'} + stackback@0.0.2: resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + std-env@3.10.0: + resolution: {integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==} + std-env@3.9.0: resolution: {integrity: sha512-UGvjygr6F6tpH7o2qyqR6QYpwraIjKSdtzyBdyytFOHmPZY917kwdwLG0RbOjWOnKmnm3PeHjaoLLMie7kPLQw==} @@ -6763,6 +7039,7 @@ packages: tar@7.4.3: resolution: {integrity: sha512-5S7Va8hKfV7W5U6g3aYxXmlPoZVAwUMy9AOKyF2fVuZa2UD3qZjg578OrLRt8PcNN1PleVaL/5/yYATNL0ICUw==} engines: {node: '>=18'} + deprecated: Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me third-party-capital@1.0.20: resolution: {integrity: sha512-oB7yIimd8SuGptespDAZnNkzIz+NWaJCu2RMsbs4Wmp9zSDUM8Nhi3s2OOcqYuv3mN4hitXc8DVx+LyUmbUDiA==} @@ -6866,6 +7143,10 @@ packages: resolution: {integrity: sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg==} engines: {node: '>=14.0'} + undici@7.27.2: + resolution: {integrity: sha512-uZsKNuzQxDMUY6M3pIMvy5tvlGmtq8XJ2oLAkfRKGNu+1VQAIvLy2xIVG5ATZl5wDXl/tddByAWCizRbOme+TA==} + engines: {node: '>=20.18.1'} + unified@11.0.5: resolution: {integrity: sha512-xKvGhPWw3k84Qjh8bI3ZeJjqnyadK+GEFtazSfZv/rKeTkTjOJho6mFqh2SM96iIcZokxiOpg78GazTSg8+KHA==} @@ -6957,8 +7238,17 @@ packages: uuid@9.0.1: resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + deprecated: uuid@10 and below is no longer supported. For ESM codebases, update to uuid@latest. For CommonJS codebases, use uuid@11 (but be aware this version will likely be deprecated in 2028). hasBin: true + valibot@1.2.0: + resolution: {integrity: sha512-mm1rxUsmOxzrwnX5arGS+U4T25RdvpPjPN4yR0u9pUBov9+zGVtO84tif1eY4r6zWxVxu3KzIyknJy3rxfRZZg==} + peerDependencies: + typescript: '>=5' + peerDependenciesMeta: + typescript: + optional: true + validator@13.15.15: resolution: {integrity: sha512-BgWVbCI72aIQy937xbawcs+hrVaN/CZ2UwutgaJ36hGqRrLNM+f5LUT/YPRbo8IV/ASeFzXszezV+y2+rq3l8A==} engines: {node: '>= 0.10'} @@ -7092,9 +7382,18 @@ packages: webpack-virtual-modules@0.6.2: resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + whatwg-encoding@3.1.1: + resolution: {integrity: sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==} + engines: {node: '>=18'} + deprecated: Use @exodus/bytes instead for a more spec-conformant and faster implementation + whatwg-fetch@3.6.20: resolution: {integrity: sha512-EqhiFU6daOA8kpjOWTL0olhVOF3i7OrFzSYiGsEMB8GcXS+RrzauAERX65xMeNWVqxA6HXH2m69Z9LaKKdisfg==} + whatwg-mimetype@4.0.0: + resolution: {integrity: sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==} + engines: {node: '>=18'} + which@2.0.2: resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} engines: {node: '>= 8'} @@ -7106,11 +7405,15 @@ packages: hasBin: true xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz: - resolution: {tarball: https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz} + resolution: {integrity: sha512-+nKZ39+nvK7Qq6i0PvWWRA4j/EkfWOtkP/YhMtupm+lJIiHxUrgTr1CcKv1nBk1rHtkRRQ3O2+Ih/q/sA+FXZA==, tarball: https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz} version: 0.20.2 engines: {node: '>=0.8'} hasBin: true + xtend@4.0.2: + resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==} + engines: {node: '>=0.4'} + yallist@3.1.1: resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} @@ -7122,6 +7425,9 @@ packages: resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} engines: {node: '>=10'} + zeptomatch@2.1.0: + resolution: {integrity: sha512-KiGErG2J0G82LSpniV0CtIzjlJ10E04j02VOudJsPyPwNZgGnRKQy7I1R7GMyg/QswnE4l7ohSGrQbQbjXPPDA==} + zod@4.1.9: resolution: {integrity: sha512-HI32jTq0AUAC125z30E8bQNz0RQ+9Uc+4J7V97gLYjZVKRjeydPgGt6dvQzFrav7MYOUGFqqOGiHpA/fdbd0cQ==} @@ -7197,10 +7503,10 @@ snapshots: preact: 10.24.3 preact-render-to-string: 6.5.11(preact@10.24.3) - '@auth/prisma-adapter@1.0.5(@prisma/client@6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2))': + '@auth/prisma-adapter@1.0.5(@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.2))(typescript@5.9.2))': dependencies: '@auth/core': 0.18.0 - '@prisma/client': 6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2) + '@prisma/client': 7.8.0(prisma@7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.2))(typescript@5.9.2) transitivePeerDependencies: - nodemailer @@ -8001,10 +8307,10 @@ snapshots: optionalDependencies: '@axiomhq/js': 1.3.1 - '@axiomhq/nextjs@0.1.6(@axiomhq/logging@0.1.6(@axiomhq/js@1.3.1))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))': + '@axiomhq/nextjs@0.1.6(@axiomhq/logging@0.1.6(@axiomhq/js@1.3.1))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))': dependencies: '@axiomhq/logging': 0.1.6(@axiomhq/js@1.3.1) - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) '@axiomhq/react@0.1.6(@axiomhq/logging@0.1.6(@axiomhq/js@1.3.1))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': dependencies: @@ -8205,6 +8511,16 @@ snapshots: msgpackr: 1.11.9 multipasta: 0.2.7 + '@electric-sql/pglite-socket@0.1.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite-tools@0.3.1(@electric-sql/pglite@0.4.1)': + dependencies: + '@electric-sql/pglite': 0.4.1 + + '@electric-sql/pglite@0.4.1': {} + '@emnapi/runtime@1.7.0': dependencies: tslib: 2.8.1 @@ -8290,10 +8606,10 @@ snapshots: '@fastify/busboy@2.1.1': {} - '@flags-sdk/vercel@1.0.1(@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)))(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': + '@flags-sdk/vercel@1.0.1(@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)))(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))': dependencies: - '@vercel/flags-core': 1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) - flags: 4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@vercel/flags-core': 1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)) + flags: 4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) '@floating-ui/core@1.7.3': dependencies: @@ -8372,6 +8688,10 @@ snapshots: dependencies: react: 19.2.3 + '@hono/node-server@1.19.11(hono@4.12.25)': + dependencies: + hono: 4.12.25 + '@hookform/resolvers@5.2.2(react-hook-form@7.62.0(react@19.2.3))': dependencies: '@standard-schema/utils': 0.3.0 @@ -8504,6 +8824,8 @@ snapshots: '@jridgewell/resolve-uri': 3.1.2 '@jridgewell/sourcemap-codec': 1.5.5 + '@kurkle/color@0.3.4': {} + '@mermaid-js/parser@1.0.1': dependencies: langium: 4.2.1 @@ -8528,35 +8850,35 @@ snapshots: '@next/env@15.1.6': {} - '@next/env@16.2.3': {} + '@next/env@16.2.6': {} - '@next/swc-darwin-arm64@16.2.3': + '@next/swc-darwin-arm64@16.2.6': optional: true - '@next/swc-darwin-x64@16.2.3': + '@next/swc-darwin-x64@16.2.6': optional: true - '@next/swc-linux-arm64-gnu@16.2.3': + '@next/swc-linux-arm64-gnu@16.2.6': optional: true - '@next/swc-linux-arm64-musl@16.2.3': + '@next/swc-linux-arm64-musl@16.2.6': optional: true - '@next/swc-linux-x64-gnu@16.2.3': + '@next/swc-linux-x64-gnu@16.2.6': optional: true - '@next/swc-linux-x64-musl@16.2.3': + '@next/swc-linux-x64-musl@16.2.6': optional: true - '@next/swc-win32-arm64-msvc@16.2.3': + '@next/swc-win32-arm64-msvc@16.2.6': optional: true - '@next/swc-win32-x64-msvc@16.2.3': + '@next/swc-win32-x64-msvc@16.2.6': optional: true - '@next/third-parties@16.1.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)': + '@next/third-parties@16.1.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)': dependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 third-party-capital: 1.0.20 @@ -8964,40 +9286,85 @@ snapshots: '@preact/signals-core': 1.12.1 preact: 10.27.2 - '@prisma/client@6.16.2(prisma@6.16.2(typescript@5.9.2))(typescript@5.9.2)': + '@prisma/adapter-pg@7.8.0': + dependencies: + '@prisma/driver-adapter-utils': 7.8.0 + '@types/pg': 8.20.0 + pg: 8.21.0 + postgres-array: 3.0.4 + transitivePeerDependencies: + - pg-native + + '@prisma/client-runtime-utils@7.8.0': {} + + '@prisma/client@7.8.0(prisma@7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.2))(typescript@5.9.2)': + dependencies: + '@prisma/client-runtime-utils': 7.8.0 optionalDependencies: - prisma: 6.16.2(typescript@5.9.2) + prisma: 7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.2) typescript: 5.9.2 - '@prisma/config@6.16.2': + '@prisma/config@7.8.0': dependencies: - c12: 3.1.0 + c12: 3.3.4 deepmerge-ts: 7.1.5 - effect: 3.16.12 + effect: 3.20.0 empathic: 2.0.0 transitivePeerDependencies: - magicast - '@prisma/debug@6.16.2': {} + '@prisma/debug@7.2.0': {} - '@prisma/engines-version@6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43': {} + '@prisma/debug@7.8.0': {} - '@prisma/engines@6.16.2': + '@prisma/dev@0.24.3(typescript@5.9.2)': dependencies: - '@prisma/debug': 6.16.2 - '@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43 - '@prisma/fetch-engine': 6.16.2 - '@prisma/get-platform': 6.16.2 + '@electric-sql/pglite': 0.4.1 + '@electric-sql/pglite-socket': 0.1.1(@electric-sql/pglite@0.4.1) + '@electric-sql/pglite-tools': 0.3.1(@electric-sql/pglite@0.4.1) + '@hono/node-server': 1.19.11(hono@4.12.25) + '@prisma/get-platform': 7.2.0 + '@prisma/query-plan-executor': 7.2.0 + '@prisma/streams-local': 0.1.2 + foreground-child: 3.3.1 + get-port-please: 3.2.0 + hono: 4.12.25 + http-status-codes: 2.3.0 + pathe: 2.0.3 + proper-lockfile: 4.1.2 + remeda: 2.33.4 + std-env: 3.10.0 + valibot: 1.2.0(typescript@5.9.2) + zeptomatch: 2.1.0 + transitivePeerDependencies: + - typescript - '@prisma/fetch-engine@6.16.2': + '@prisma/driver-adapter-utils@7.8.0': dependencies: - '@prisma/debug': 6.16.2 - '@prisma/engines-version': 6.16.0-7.1c57fdcd7e44b29b9313256c76699e91c3ac3c43 - '@prisma/get-platform': 6.16.2 + '@prisma/debug': 7.8.0 - '@prisma/get-platform@6.16.2': + '@prisma/engines-version@7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a': {} + + '@prisma/engines@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/fetch-engine': 7.8.0 + '@prisma/get-platform': 7.8.0 + + '@prisma/fetch-engine@7.8.0': + dependencies: + '@prisma/debug': 7.8.0 + '@prisma/engines-version': 7.8.0-6.3c6e192761c0362d496ed980de936e2f3cebcd3a + '@prisma/get-platform': 7.8.0 + + '@prisma/get-platform@7.2.0': + dependencies: + '@prisma/debug': 7.2.0 + + '@prisma/get-platform@7.8.0': dependencies: - '@prisma/debug': 6.16.2 + '@prisma/debug': 7.8.0 '@prisma/instrumentation@7.2.0(@opentelemetry/api@1.9.0)': dependencies: @@ -9006,6 +9373,25 @@ snapshots: transitivePeerDependencies: - supports-color + '@prisma/query-plan-executor@7.2.0': {} + + '@prisma/streams-local@0.1.2': + dependencies: + ajv: 8.17.1 + better-result: 2.9.2 + env-paths: 3.0.0 + proper-lockfile: 4.1.2 + + '@prisma/studio-core@0.27.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)': + dependencies: + '@radix-ui/react-toggle': 1.1.10(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + '@types/react': 19.2.2 + chart.js: 4.5.1 + react: 19.2.3 + react-dom: 19.2.3(react@19.2.3) + transitivePeerDependencies: + - '@types/react-dom' + '@protobufjs/aspromise@1.1.2': {} '@protobufjs/base64@1.1.2': {} @@ -11308,6 +11694,12 @@ snapshots: dependencies: undici-types: 6.21.0 + '@types/pg@8.20.0': + dependencies: + '@types/node': 20.19.16 + pg-protocol: 1.14.0 + pg-types: 2.2.0 + '@types/react-dom@19.2.2(@types/react@19.2.2)': dependencies: '@types/react': 19.2.2 @@ -11367,9 +11759,9 @@ snapshots: dependencies: uncrypto: 0.1.3 - '@vercel/analytics@2.0.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)': + '@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)': optionalDependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 '@vercel/blob@2.0.0': @@ -11388,14 +11780,14 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.0 - '@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))': + '@vercel/flags-core@1.0.1(@aws-sdk/credential-provider-web-identity@3.972.28)(flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))': dependencies: '@vercel/functions': 3.4.2(@aws-sdk/credential-provider-web-identity@3.972.28) - flags: 4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + flags: 4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3) jose: 5.2.1 js-xxhash: 4.0.0 optionalDependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' @@ -11421,7 +11813,7 @@ snapshots: dependencies: '@upstash/redis': 1.35.3 - '@vercel/microfrontends@1.3.0(@vercel/analytics@2.0.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(@vercel/speed-insights@1.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5))': + '@vercel/microfrontends@1.3.0(@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5))': dependencies: '@next/env': 15.1.6 ajv: 8.17.1 @@ -11433,12 +11825,12 @@ snapshots: nanoid: 3.3.11 path-to-regexp: 6.2.1 optionalDependencies: - '@vercel/analytics': 2.0.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) - '@vercel/speed-insights': 1.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + '@vercel/analytics': 2.0.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + '@vercel/speed-insights': 1.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - vite: 7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) + vite: 7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) transitivePeerDependencies: - debug @@ -11456,15 +11848,15 @@ snapshots: '@vercel/oidc@3.2.0': {} - '@vercel/speed-insights@1.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)': + '@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)': optionalDependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 - '@vercel/toolbar@0.1.41(@vercel/analytics@2.0.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(@vercel/speed-insights@1.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5))': + '@vercel/toolbar@0.1.41(@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5))': dependencies: '@tinyhttp/app': 1.3.0 - '@vercel/microfrontends': 1.3.0(@vercel/analytics@2.0.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(@vercel/speed-insights@1.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)) + '@vercel/microfrontends': 1.3.0(@vercel/analytics@2.0.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(@vercel/speed-insights@1.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(vite@7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)) chokidar: 3.6.0 execa: 5.1.1 fast-glob: 3.3.3 @@ -11473,9 +11865,9 @@ snapshots: jsonc-parser: 3.3.1 strip-ansi: 6.0.1 optionalDependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 - vite: 7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) + vite: 7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) transitivePeerDependencies: - '@sveltejs/kit' - '@vercel/analytics' @@ -11491,13 +11883,13 @@ snapshots: chai: 5.3.3 tinyrainbow: 2.0.0 - '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5))': + '@vitest/mocker@3.2.4(vite@7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5))': dependencies: '@vitest/spy': 3.2.4 estree-walker: 3.0.3 magic-string: 0.30.19 optionalDependencies: - vite: 7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) + vite: 7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) '@vitest/pretty-format@3.2.4': dependencies: @@ -11612,6 +12004,8 @@ snapshots: asynckit@0.4.0: {} + aws-ssl-profiles@1.1.2: {} + axios@1.12.2: dependencies: follow-redirects: 1.15.11 @@ -11628,7 +12022,7 @@ snapshots: baseline-browser-mapping@2.10.8: {} - baseline-browser-mapping@2.8.7: {} + better-result@2.9.2: {} binary-extensions@2.3.0: {} @@ -11639,9 +12033,11 @@ snapshots: transitivePeerDependencies: - '@types/react' - botid@1.5.10(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): + boolbase@1.0.0: {} + + botid@1.5.10(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): optionalDependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 bowser@2.12.1: {} @@ -11652,26 +12048,26 @@ snapshots: browserslist@4.26.2: dependencies: - baseline-browser-mapping: 2.8.7 + baseline-browser-mapping: 2.10.8 caniuse-lite: 1.0.30001743 electron-to-chromium: 1.5.224 node-releases: 2.0.21 update-browserslist-db: 1.1.3(browserslist@4.26.2) - c12@3.1.0: + c12@3.3.4: dependencies: - chokidar: 4.0.3 - confbox: 0.2.2 - defu: 6.1.4 - dotenv: 16.6.1 - exsolve: 1.0.7 - giget: 2.0.0 - jiti: 2.5.1 + chokidar: 5.0.0 + confbox: 0.2.4 + defu: 6.1.7 + dotenv: 17.4.2 + exsolve: 1.0.8 + giget: 3.3.0 + jiti: 2.7.0 ohash: 2.0.11 pathe: 2.0.3 - perfect-debounce: 1.0.0 + perfect-debounce: 2.1.0 pkg-types: 2.3.0 - rc9: 2.1.2 + rc9: 3.0.1 cac@6.7.14: {} @@ -11709,8 +12105,35 @@ snapshots: character-reference-invalid@2.0.1: {} + chart.js@4.5.1: + dependencies: + '@kurkle/color': 0.3.4 + check-error@2.1.1: {} + cheerio-select@2.1.0: + dependencies: + boolbase: 1.0.0 + css-select: 5.2.2 + css-what: 6.2.2 + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + + cheerio@1.2.0: + dependencies: + cheerio-select: 2.1.0 + dom-serializer: 2.0.0 + domhandler: 5.0.3 + domutils: 3.2.2 + encoding-sniffer: 0.2.1 + htmlparser2: 10.1.0 + parse5: 7.3.0 + parse5-htmlparser2-tree-adapter: 7.1.0 + parse5-parser-stream: 7.1.2 + undici: 7.27.2 + whatwg-mimetype: 4.0.0 + chevrotain-allstar@0.3.1(chevrotain@11.1.2): dependencies: chevrotain: 11.1.2 @@ -11741,11 +12164,11 @@ snapshots: dependencies: readdirp: 4.1.2 - chownr@3.0.0: {} - - citty@0.1.6: + chokidar@5.0.0: dependencies: - consola: 3.4.2 + readdirp: 5.0.0 + + chownr@3.0.0: {} cjs-module-lexer@1.4.3: {} @@ -11800,9 +12223,7 @@ snapshots: confbox@0.1.8: {} - confbox@0.2.2: {} - - consola@3.4.2: {} + confbox@0.2.4: {} convert-source-map@2.0.0: {} @@ -11826,6 +12247,16 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + css-select@5.2.2: + dependencies: + boolbase: 1.0.0 + css-what: 6.2.2 + domhandler: 5.0.3 + domutils: 3.2.2 + nth-check: 2.1.1 + + css-what@6.2.2: {} + csstype@3.1.3: {} cytoscape-cose-bilkent@4.1.0(cytoscape@3.33.1): @@ -12036,7 +12467,7 @@ snapshots: deepmerge@4.3.1: {} - defu@6.1.4: {} + defu@6.1.7: {} delaunator@5.0.1: dependencies: @@ -12044,6 +12475,8 @@ snapshots: delayed-stream@1.0.0: {} + denque@2.1.0: {} + dequal@2.0.3: {} destr@2.0.5: {} @@ -12088,7 +12521,7 @@ snapshots: domelementtype: 2.3.0 domhandler: 5.0.3 - dotenv@16.6.1: {} + dotenv@17.4.2: {} dunder-proto@1.0.1: dependencies: @@ -12096,7 +12529,7 @@ snapshots: es-errors: 1.3.0 gopd: 1.2.0 - effect@3.16.12: + effect@3.20.0: dependencies: '@standard-schema/spec': 1.1.0 fast-check: 3.23.2 @@ -12122,6 +12555,11 @@ snapshots: empathic@2.0.0: {} + encoding-sniffer@0.2.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-encoding: 3.1.1 + enhanced-resolve@5.18.3: dependencies: graceful-fs: 4.2.11 @@ -12131,6 +12569,10 @@ snapshots: entities@6.0.1: {} + entities@7.0.1: {} + + env-paths@3.0.0: {} + es-content-type@0.0.10: {} es-define-property@1.0.1: {} @@ -12223,7 +12665,7 @@ snapshots: expect-type@1.2.2: {} - exsolve@1.0.7: {} + exsolve@1.0.8: {} extend@3.0.2: {} @@ -12284,18 +12726,23 @@ snapshots: locate-path: 6.0.0 path-exists: 4.0.0 - flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + flags@4.0.3(@opentelemetry/api@1.9.0)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@edge-runtime/cookies': 5.0.2 jose: 5.10.0 optionalDependencies: '@opentelemetry/api': 1.9.0 - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) follow-redirects@1.15.11: {} + foreground-child@3.3.1: + dependencies: + cross-spawn: 7.0.6 + signal-exit: 4.1.0 + form-data@4.0.4: dependencies: asynckit: 0.4.0 @@ -12332,9 +12779,13 @@ snapshots: fuse.js@7.1.0: {} - geist@1.5.1(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)): + geist@1.5.1(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2)): + dependencies: + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + + generate-function@2.3.1: dependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + is-property: 1.0.2 gensync@1.0.0-beta.2: {} @@ -12355,6 +12806,8 @@ snapshots: get-nonce@1.0.1: {} + get-port-please@3.2.0: {} + get-port@5.1.1: {} get-proto@1.0.1: @@ -12368,14 +12821,7 @@ snapshots: dependencies: resolve-pkg-maps: 1.0.0 - giget@2.0.0: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - defu: 6.1.4 - node-fetch-native: 1.6.7 - nypm: 0.6.2 - pathe: 2.0.3 + giget@3.3.0: {} glob-parent@5.1.2: dependencies: @@ -12387,6 +12833,10 @@ snapshots: graceful-fs@4.2.11: {} + grammex@3.1.12: {} + + graphmatch@1.1.1: {} + hachure-fill@0.5.2: {} has-symbols@1.1.0: {} @@ -12525,6 +12975,8 @@ snapshots: property-information: 7.1.0 space-separated-tokens: 2.0.2 + hono@4.12.25: {} + html-to-text@9.0.5: dependencies: '@selderee/plugin-htmlparser2': 0.11.0 @@ -12537,6 +12989,13 @@ snapshots: html-void-elements@3.0.0: {} + htmlparser2@10.1.0: + dependencies: + domelementtype: 2.3.0 + domhandler: 5.0.3 + domutils: 3.2.2 + entities: 7.0.1 + htmlparser2@8.0.2: dependencies: domelementtype: 2.3.0 @@ -12552,12 +13011,18 @@ snapshots: transitivePeerDependencies: - debug + http-status-codes@2.3.0: {} + human-signals@2.1.0: {} iconv-lite@0.6.3: dependencies: safer-buffer: 2.1.2 + iconv-lite@0.7.2: + dependencies: + safer-buffer: 2.1.2 + immutable@5.1.3: {} import-in-the-middle@2.0.1: @@ -12616,12 +13081,16 @@ snapshots: is-plain-obj@4.1.0: {} + is-property@1.0.2: {} + is-stream@2.0.1: {} isexe@2.0.0: {} jiti@2.5.1: {} + jiti@2.7.0: {} + jose@4.15.9: {} jose@5.10.0: {} @@ -12751,6 +13220,8 @@ snapshots: dependencies: yallist: 3.1.1 + lru.min@1.1.4: {} + lucide-react@0.544.0(react@19.2.3): dependencies: react: 19.2.3 @@ -13291,6 +13762,22 @@ snapshots: multipasta@0.2.7: {} + mysql2@3.15.3: + dependencies: + aws-ssl-profiles: 1.1.2 + denque: 2.1.0 + generate-function: 2.3.1 + iconv-lite: 0.7.2 + long: 5.3.2 + lru.min: 1.1.4 + named-placeholders: 1.1.6 + seq-queue: 0.0.5 + sqlstring: 2.3.3 + + named-placeholders@1.1.6: + dependencies: + lru.min: 1.1.4 + nanoid@3.3.11: {} nanoid@5.1.5: {} @@ -13299,35 +13786,35 @@ snapshots: negotiator@1.0.0: {} - next-auth@5.0.0-beta.29(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): + next-auth@5.0.0-beta.29(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): dependencies: '@auth/core': 0.40.0 - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 - next-axiom@1.9.2(@aws-sdk/credential-provider-web-identity@3.972.28)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): + next-axiom@1.9.2(@aws-sdk/credential-provider-web-identity@3.972.28)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): dependencies: '@vercel/functions': 2.2.13(@aws-sdk/credential-provider-web-identity@3.972.28) - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 use-deep-compare: 1.3.0(react@19.2.3) whatwg-fetch: 3.6.20 transitivePeerDependencies: - '@aws-sdk/credential-provider-web-identity' - next-intl@4.3.9(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)(typescript@5.9.2): + next-intl@4.3.9(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3)(typescript@5.9.2): dependencies: '@formatjs/intl-localematcher': 0.5.10 negotiator: 1.0.0 - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 use-intl: 4.3.9(react@19.2.3) optionalDependencies: typescript: 5.9.2 - next-seo@7.2.0(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): + next-seo@7.2.0(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): dependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) react: 19.2.3 next-themes@0.4.6(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -13335,9 +13822,9 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2): + next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2): dependencies: - '@next/env': 16.2.3 + '@next/env': 16.2.6 '@swc/helpers': 0.5.15 baseline-browser-mapping: 2.10.8 caniuse-lite: 1.0.30001743 @@ -13346,14 +13833,14 @@ snapshots: react-dom: 19.2.3(react@19.2.3) styled-jsx: 5.1.6(@babel/core@7.28.4)(react@19.2.3) optionalDependencies: - '@next/swc-darwin-arm64': 16.2.3 - '@next/swc-darwin-x64': 16.2.3 - '@next/swc-linux-arm64-gnu': 16.2.3 - '@next/swc-linux-arm64-musl': 16.2.3 - '@next/swc-linux-x64-gnu': 16.2.3 - '@next/swc-linux-x64-musl': 16.2.3 - '@next/swc-win32-arm64-msvc': 16.2.3 - '@next/swc-win32-x64-msvc': 16.2.3 + '@next/swc-darwin-arm64': 16.2.6 + '@next/swc-darwin-x64': 16.2.6 + '@next/swc-linux-arm64-gnu': 16.2.6 + '@next/swc-linux-arm64-musl': 16.2.6 + '@next/swc-linux-x64-gnu': 16.2.6 + '@next/swc-linux-x64-musl': 16.2.6 + '@next/swc-win32-arm64-msvc': 16.2.6 + '@next/swc-win32-x64-msvc': 16.2.6 '@opentelemetry/api': 1.9.0 babel-plugin-react-compiler: 1.0.0 sass: 1.93.2 @@ -13362,19 +13849,17 @@ snapshots: - '@babel/core' - babel-plugin-macros - nextstepjs@2.1.2(motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): + nextstepjs@2.1.2(motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3))(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion: 12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3) react: 19.2.3 react-dom: 19.2.3(react@19.2.3) optionalDependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) node-addon-api@7.1.1: optional: true - node-fetch-native@1.6.7: {} - node-gyp-build-optional-packages@5.2.2: dependencies: detect-libc: 2.1.2 @@ -13390,20 +13875,16 @@ snapshots: dependencies: path-key: 3.1.1 - nuqs@2.8.9(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): + nth-check@2.1.1: + dependencies: + boolbase: 1.0.0 + + nuqs@2.8.9(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react@19.2.3): dependencies: '@standard-schema/spec': 1.0.0 react: 19.2.3 optionalDependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) - - nypm@0.6.2: - dependencies: - citty: 0.1.6 - consola: 3.4.2 - pathe: 2.0.3 - pkg-types: 2.3.0 - tinyexec: 1.0.1 + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) oauth4webapi@2.17.0: {} @@ -13509,6 +13990,15 @@ snapshots: is-decimal: 2.0.1 is-hexadecimal: 2.0.1 + parse5-htmlparser2-tree-adapter@7.1.0: + dependencies: + domhandler: 5.0.3 + parse5: 7.3.0 + + parse5-parser-stream@7.1.2: + dependencies: + parse5: 7.3.0 + parse5@7.3.0: dependencies: entities: 6.0.1 @@ -13534,7 +14024,42 @@ snapshots: peberminta@0.9.0: {} - perfect-debounce@1.0.0: {} + perfect-debounce@2.1.0: {} + + pg-cloudflare@1.4.0: + optional: true + + pg-connection-string@2.13.0: {} + + pg-int8@1.0.1: {} + + pg-pool@3.14.0(pg@8.21.0): + dependencies: + pg: 8.21.0 + + pg-protocol@1.14.0: {} + + pg-types@2.2.0: + dependencies: + pg-int8: 1.0.1 + postgres-array: 2.0.0 + postgres-bytea: 1.0.1 + postgres-date: 1.0.7 + postgres-interval: 1.2.0 + + pg@8.21.0: + dependencies: + pg-connection-string: 2.13.0 + pg-pool: 3.14.0(pg@8.21.0) + pg-protocol: 1.14.0 + pg-types: 2.2.0 + pgpass: 1.0.5 + optionalDependencies: + pg-cloudflare: 1.4.0 + + pgpass@1.0.5: + dependencies: + split2: 4.2.0 picocolors@1.1.1: {} @@ -13550,8 +14075,8 @@ snapshots: pkg-types@2.3.0: dependencies: - confbox: 0.2.2 - exsolve: 1.0.7 + confbox: 0.2.4 + exsolve: 1.0.8 pathe: 2.0.3 playwright-core@1.55.1: {} @@ -13581,6 +14106,20 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + postgres-array@2.0.0: {} + + postgres-array@3.0.4: {} + + postgres-bytea@1.0.1: {} + + postgres-date@1.0.7: {} + + postgres-interval@1.2.0: + dependencies: + xtend: 4.0.2 + + postgres@3.4.7: {} + preact-render-to-string@5.2.3(preact@10.11.3): dependencies: preact: 10.11.3 @@ -13604,14 +14143,22 @@ snapshots: pretty-format@3.8.0: {} - prisma@6.16.2(typescript@5.9.2): + prisma@7.8.0(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(typescript@5.9.2): dependencies: - '@prisma/config': 6.16.2 - '@prisma/engines': 6.16.2 + '@prisma/config': 7.8.0 + '@prisma/dev': 0.24.3(typescript@5.9.2) + '@prisma/engines': 7.8.0 + '@prisma/studio-core': 0.27.3(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) + mysql2: 3.15.3 + postgres: 3.4.7 optionalDependencies: typescript: 5.9.2 transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' - magicast + - react + - react-dom prismjs@1.30.0: {} @@ -13621,6 +14168,12 @@ snapshots: object-assign: 4.1.1 react-is: 16.13.1 + proper-lockfile@4.1.2: + dependencies: + graceful-fs: 4.2.11 + retry: 0.12.0 + signal-exit: 3.0.7 + property-information@7.1.0: {} prosemirror-changeset@2.3.1: @@ -13818,9 +14371,9 @@ snapshots: range-parser@1.2.1: {} - rc9@2.1.2: + rc9@3.0.1: dependencies: - defu: 6.1.4 + defu: 6.1.7 destr: 2.0.5 react-day-picker@9.12.0(react@19.2.3): @@ -13888,7 +14441,7 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - react-scan@0.4.3(@types/react@19.2.2)(next@16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.50.2): + react-scan@0.4.3(@types/react@19.2.2)(next@16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2))(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(rollup@4.50.2): dependencies: '@babel/core': 7.28.4 '@babel/generator': 7.28.3 @@ -13910,7 +14463,7 @@ snapshots: react-dom: 19.2.3(react@19.2.3) tsx: 4.20.5 optionalDependencies: - next: 16.2.3(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) + next: 16.2.6(@babel/core@7.28.4)(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)(sass@1.93.2) unplugin: 2.1.0 transitivePeerDependencies: - '@types/react' @@ -13950,6 +14503,8 @@ snapshots: readdirp@4.1.2: {} + readdirp@5.0.0: {} + recharts-scale@0.4.5: dependencies: decimal.js-light: 2.5.1 @@ -14067,6 +14622,8 @@ snapshots: mdast-util-to-markdown: 2.1.2 unified: 11.0.5 + remeda@2.33.4: {} + remend@1.3.0: {} require-from-string@2.0.2: {} @@ -14082,6 +14639,8 @@ snapshots: resolve-pkg-maps@1.0.0: {} + retry@0.12.0: {} + retry@0.13.1: {} reusify@1.1.0: {} @@ -14150,6 +14709,8 @@ snapshots: semver@7.7.3: {} + seq-queue@0.0.5: {} + sharp@0.34.5: dependencies: '@img/colour': 1.0.0 @@ -14241,6 +14802,8 @@ snapshots: signal-exit@3.0.7: {} + signal-exit@4.1.0: {} + sisteransi@1.0.5: {} sonner@2.0.7(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -14252,8 +14815,14 @@ snapshots: space-separated-tokens@2.0.2: {} + split2@4.2.0: {} + + sqlstring@2.3.3: {} + stackback@0.0.2: {} + std-env@3.10.0: {} + std-env@3.9.0: {} streamdown@2.5.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -14425,6 +14994,8 @@ snapshots: dependencies: '@fastify/busboy': 2.1.1 + undici@7.27.2: {} + unified@11.0.5: dependencies: '@types/unist': 3.0.3 @@ -14527,6 +15098,10 @@ snapshots: uuid@9.0.1: {} + valibot@1.2.0(typescript@5.9.2): + optionalDependencies: + typescript: 5.9.2 + validator@13.15.15: {} vaul@1.1.2(@types/react-dom@19.2.2(@types/react@19.2.2))(@types/react@19.2.2)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): @@ -14570,13 +15145,13 @@ snapshots: d3-time: 3.1.0 d3-timer: 3.0.1 - vite-node@3.2.4(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5): + vite-node@3.2.4(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5): dependencies: cac: 6.7.14 debug: 4.4.3 es-module-lexer: 1.7.0 pathe: 2.0.3 - vite: 7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) + vite: 7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) transitivePeerDependencies: - '@types/node' - jiti @@ -14591,7 +15166,7 @@ snapshots: - tsx - yaml - vite@7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5): + vite@7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5): dependencies: esbuild: 0.25.9 fdir: 6.5.0(picomatch@4.0.3) @@ -14602,22 +15177,22 @@ snapshots: optionalDependencies: '@types/node': 20.19.16 fsevents: 2.3.3 - jiti: 2.5.1 + jiti: 2.7.0 lightningcss: 1.30.1 sass: 1.93.2 tsx: 4.20.5 - vitest-mock-extended@3.1.0(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)): + vitest-mock-extended@3.1.0(typescript@5.9.2)(vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)): dependencies: ts-essentials: 10.1.1(typescript@5.9.2) typescript: 5.9.2 - vitest: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) + vitest: 3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) - vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5): + vitest@3.2.4(@types/debug@4.1.13)(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5): dependencies: '@types/chai': 5.2.2 '@vitest/expect': 3.2.4 - '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)) + '@vitest/mocker': 3.2.4(vite@7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5)) '@vitest/pretty-format': 3.2.4 '@vitest/runner': 3.2.4 '@vitest/snapshot': 3.2.4 @@ -14635,8 +15210,8 @@ snapshots: tinyglobby: 0.2.15 tinypool: 1.1.1 tinyrainbow: 2.0.0 - vite: 7.1.5(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) - vite-node: 3.2.4(@types/node@20.19.16)(jiti@2.5.1)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) + vite: 7.1.5(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) + vite-node: 3.2.4(@types/node@20.19.16)(jiti@2.7.0)(lightningcss@1.30.1)(sass@1.93.2)(tsx@4.20.5) why-is-node-running: 2.3.0 optionalDependencies: '@types/debug': 4.1.13 @@ -14681,8 +15256,14 @@ snapshots: webpack-virtual-modules@0.6.2: optional: true + whatwg-encoding@3.1.1: + dependencies: + iconv-lite: 0.6.3 + whatwg-fetch@3.6.20: {} + whatwg-mimetype@4.0.0: {} + which@2.0.2: dependencies: isexe: 2.0.0 @@ -14694,12 +15275,19 @@ snapshots: xlsx@https://cdn.sheetjs.com/xlsx-0.20.2/xlsx-0.20.2.tgz: {} + xtend@4.0.2: {} + yallist@3.1.1: {} yallist@5.0.0: {} yocto-queue@0.1.0: {} + zeptomatch@2.1.0: + dependencies: + grammex: 3.1.12 + graphmatch: 1.1.1 + zod@4.1.9: {} zustand@4.5.7(@types/react@19.2.2)(react@19.2.3): diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 000000000..7d9cfb46f --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,14 @@ +packages: + - . + +allowBuilds: + "@parcel/watcher": true + "@prisma/client": true + "@prisma/engines": true + "@tailwindcss/oxide": true + "@vercel/speed-insights": true + esbuild: true + msgpackr-extract: true + prisma: true + protobufjs: true + sharp: true diff --git a/prisma.config.ts b/prisma.config.ts new file mode 100644 index 000000000..08f1f1caa --- /dev/null +++ b/prisma.config.ts @@ -0,0 +1,20 @@ +import "dotenv/config"; +import { defineConfig } from "prisma/config"; + +/** CLI-only config (generate, migrate, db push). The app's runtime + * connection lives in src/lib/prisma.ts via the pg driver adapter. + * Migrations need the direct (non-pooled) connection, hence DIRECT_URL + * first. The placeholder keeps `prisma generate` working in environments + * with no DB env at all (CI lint/typecheck/vitest installs). */ +export default defineConfig({ + schema: "prisma/schema.prisma", + migrations: { + path: "prisma/migrations", + }, + datasource: { + url: + process.env.DIRECT_URL ?? + process.env.DATABASE_URL ?? + "postgresql://localhost:5432/parsertime", + }, +}); diff --git a/prisma/migrations/20260424222814_add_ai_credit_models/migration.sql b/prisma/migrations/20260424222814_add_ai_credit_models/migration.sql new file mode 100644 index 000000000..dbb2ace8b --- /dev/null +++ b/prisma/migrations/20260424222814_add_ai_credit_models/migration.sql @@ -0,0 +1,59 @@ +-- CreateEnum +CREATE TYPE "public"."CreditTransactionType" AS ENUM ('TOPUP', 'AUTO_REFILL', 'CHARGE', 'REFUND', 'ADJUSTMENT'); + +-- CreateTable +CREATE TABLE "public"."UserCredits" ( + "userId" TEXT NOT NULL, + "balanceCents" INTEGER NOT NULL DEFAULT 0, + "autoRefillEnabled" BOOLEAN NOT NULL DEFAULT false, + "autoRefillThresholdCents" INTEGER NOT NULL DEFAULT 200, + "autoRefillAmountCents" INTEGER NOT NULL DEFAULT 1000, + "stripePaymentMethodId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "UserCredits_pkey" PRIMARY KEY ("userId") +); + +-- CreateTable +CREATE TABLE "public"."CreditTransaction" ( + "id" SERIAL NOT NULL, + "userId" TEXT NOT NULL, + "type" "public"."CreditTransactionType" NOT NULL, + "amountCents" INTEGER NOT NULL, + "balanceAfterCents" INTEGER NOT NULL, + "description" TEXT NOT NULL, + "stripeEventId" TEXT, + "metadata" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "CreditTransaction_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."PendingAutoRefill" ( + "userId" TEXT NOT NULL, + "stripeIdempotencyKey" TEXT NOT NULL, + "paymentIntentId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "PendingAutoRefill_pkey" PRIMARY KEY ("userId") +); + +-- CreateIndex +CREATE UNIQUE INDEX "CreditTransaction_stripeEventId_key" ON "public"."CreditTransaction"("stripeEventId"); + +-- CreateIndex +CREATE INDEX "CreditTransaction_userId_createdAt_idx" ON "public"."CreditTransaction"("userId", "createdAt"); + +-- CreateIndex +CREATE UNIQUE INDEX "PendingAutoRefill_stripeIdempotencyKey_key" ON "public"."PendingAutoRefill"("stripeIdempotencyKey"); + +-- AddForeignKey +ALTER TABLE "public"."UserCredits" ADD CONSTRAINT "UserCredits_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."CreditTransaction" ADD CONSTRAINT "CreditTransaction_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PendingAutoRefill" ADD CONSTRAINT "PendingAutoRefill_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260426232603_add_tsr_models/migration.sql b/prisma/migrations/20260426232603_add_tsr_models/migration.sql new file mode 100644 index 000000000..53f21d390 --- /dev/null +++ b/prisma/migrations/20260426232603_add_tsr_models/migration.sql @@ -0,0 +1,167 @@ +-- CreateEnum +CREATE TYPE "public"."FaceitTier" AS ENUM ('UNCLASSIFIED', 'OPEN', 'CAH', 'ADVANCED', 'EXPERT', 'MASTERS', 'OWCS'); + +-- CreateEnum +CREATE TYPE "public"."TsrRegion" AS ENUM ('NA', 'EMEA', 'OTHER'); + +-- CreateEnum +CREATE TYPE "public"."FaceitMatchStatus" AS ENUM ('FINISHED', 'CANCELLED', 'ABORTED'); + +-- CreateEnum +CREATE TYPE "public"."TsrRosterOverrideAction" AS ENUM ('INCLUDE', 'EXCLUDE'); + +-- CreateTable +CREATE TABLE "public"."FaceitPlayer" ( + "faceitPlayerId" TEXT NOT NULL, + "battletag" TEXT, + "faceitNickname" TEXT NOT NULL, + "region" "public"."TsrRegion" NOT NULL DEFAULT 'OTHER', + "verified" BOOLEAN NOT NULL DEFAULT false, + "ow2SkillLevel" INTEGER, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FaceitPlayer_pkey" PRIMARY KEY ("faceitPlayerId") +); + +-- CreateTable +CREATE TABLE "public"."FaceitChampionship" ( + "championshipId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "organizerId" TEXT NOT NULL, + "tier" "public"."FaceitTier" NOT NULL DEFAULT 'UNCLASSIFIED', + "region" "public"."TsrRegion" NOT NULL DEFAULT 'OTHER', + "startDate" TIMESTAMP(3), + "classifiedBy" TEXT, + "classifiedAt" TIMESTAMP(3), + "ingestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FaceitChampionship_pkey" PRIMARY KEY ("championshipId") +); + +-- CreateTable +CREATE TABLE "public"."FaceitMatch" ( + "faceitMatchId" TEXT NOT NULL, + "championshipId" TEXT NOT NULL, + "organizerId" TEXT NOT NULL, + "bestOf" INTEGER NOT NULL, + "team1Score" INTEGER NOT NULL, + "team2Score" INTEGER NOT NULL, + "winnerFaction" INTEGER NOT NULL, + "status" "public"."FaceitMatchStatus" NOT NULL, + "finishedAt" TIMESTAMP(3) NOT NULL, + "rawRegion" TEXT NOT NULL, + "ingestedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FaceitMatch_pkey" PRIMARY KEY ("faceitMatchId") +); + +-- CreateTable +CREATE TABLE "public"."FaceitMatchRoster" ( + "id" SERIAL NOT NULL, + "matchId" TEXT NOT NULL, + "teamSide" INTEGER NOT NULL, + "faceitPlayerId" TEXT NOT NULL, + + CONSTRAINT "FaceitMatchRoster_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."TsrRosterOverride" ( + "id" SERIAL NOT NULL, + "matchId" TEXT NOT NULL, + "faceitPlayerId" TEXT NOT NULL, + "action" "public"."TsrRosterOverrideAction" NOT NULL, + "teamSide" INTEGER, + "createdBy" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TsrRosterOverride_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."BattletagAlias" ( + "battletag" TEXT NOT NULL, + "faceitPlayerId" TEXT NOT NULL, + + CONSTRAINT "BattletagAlias_pkey" PRIMARY KEY ("battletag") +); + +-- CreateTable +CREATE TABLE "public"."PlayerTsr" ( + "faceitPlayerId" TEXT NOT NULL, + "region" "public"."TsrRegion" NOT NULL, + "rating" INTEGER NOT NULL, + "matchCount" INTEGER NOT NULL, + "recentMatchCount365d" INTEGER NOT NULL, + "maxTierReached" "public"."FaceitTier" NOT NULL, + "computedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlayerTsr_pkey" PRIMARY KEY ("faceitPlayerId") +); + +-- CreateIndex +CREATE INDEX "FaceitPlayer_battletag_idx" ON "public"."FaceitPlayer"("battletag"); + +-- CreateIndex +CREATE INDEX "FaceitPlayer_region_idx" ON "public"."FaceitPlayer"("region"); + +-- CreateIndex +CREATE INDEX "FaceitChampionship_organizerId_idx" ON "public"."FaceitChampionship"("organizerId"); + +-- CreateIndex +CREATE INDEX "FaceitChampionship_tier_idx" ON "public"."FaceitChampionship"("tier"); + +-- CreateIndex +CREATE INDEX "FaceitChampionship_organizerId_tier_idx" ON "public"."FaceitChampionship"("organizerId", "tier"); + +-- CreateIndex +CREATE INDEX "FaceitMatch_championshipId_idx" ON "public"."FaceitMatch"("championshipId"); + +-- CreateIndex +CREATE INDEX "FaceitMatch_finishedAt_idx" ON "public"."FaceitMatch"("finishedAt"); + +-- CreateIndex +CREATE INDEX "FaceitMatch_organizerId_finishedAt_idx" ON "public"."FaceitMatch"("organizerId", "finishedAt"); + +-- CreateIndex +CREATE INDEX "FaceitMatchRoster_faceitPlayerId_idx" ON "public"."FaceitMatchRoster"("faceitPlayerId"); + +-- CreateIndex +CREATE INDEX "FaceitMatchRoster_matchId_idx" ON "public"."FaceitMatchRoster"("matchId"); + +-- CreateIndex +CREATE UNIQUE INDEX "FaceitMatchRoster_matchId_faceitPlayerId_key" ON "public"."FaceitMatchRoster"("matchId", "faceitPlayerId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TsrRosterOverride_matchId_faceitPlayerId_key" ON "public"."TsrRosterOverride"("matchId", "faceitPlayerId"); + +-- CreateIndex +CREATE INDEX "BattletagAlias_faceitPlayerId_idx" ON "public"."BattletagAlias"("faceitPlayerId"); + +-- CreateIndex +CREATE INDEX "PlayerTsr_region_rating_idx" ON "public"."PlayerTsr"("region", "rating" DESC); + +-- CreateIndex +CREATE INDEX "PlayerTsr_recentMatchCount365d_idx" ON "public"."PlayerTsr"("recentMatchCount365d"); + +-- AddForeignKey +ALTER TABLE "public"."FaceitMatch" ADD CONSTRAINT "FaceitMatch_championshipId_fkey" FOREIGN KEY ("championshipId") REFERENCES "public"."FaceitChampionship"("championshipId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitMatchRoster" ADD CONSTRAINT "FaceitMatchRoster_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "public"."FaceitMatch"("faceitMatchId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitMatchRoster" ADD CONSTRAINT "FaceitMatchRoster_faceitPlayerId_fkey" FOREIGN KEY ("faceitPlayerId") REFERENCES "public"."FaceitPlayer"("faceitPlayerId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."TsrRosterOverride" ADD CONSTRAINT "TsrRosterOverride_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "public"."FaceitMatch"("faceitMatchId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."TsrRosterOverride" ADD CONSTRAINT "TsrRosterOverride_faceitPlayerId_fkey" FOREIGN KEY ("faceitPlayerId") REFERENCES "public"."FaceitPlayer"("faceitPlayerId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."BattletagAlias" ADD CONSTRAINT "BattletagAlias_faceitPlayerId_fkey" FOREIGN KEY ("faceitPlayerId") REFERENCES "public"."FaceitPlayer"("faceitPlayerId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."PlayerTsr" ADD CONSTRAINT "PlayerTsr_faceitPlayerId_fkey" FOREIGN KEY ("faceitPlayerId") REFERENCES "public"."FaceitPlayer"("faceitPlayerId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260427223543_add_matchmaker_models/migration.sql b/prisma/migrations/20260427223543_add_matchmaker_models/migration.sql new file mode 100644 index 000000000..84535a7cc --- /dev/null +++ b/prisma/migrations/20260427223543_add_matchmaker_models/migration.sql @@ -0,0 +1,63 @@ +-- CreateEnum +CREATE TYPE "public"."TeamTsrSource" AS ENUM ('TSR', 'PREDICTED', 'CSR_FALLBACK'); + +-- CreateEnum +CREATE TYPE "public"."TeamTsrConfidence" AS ENUM ('HIGH', 'MEDIUM', 'LOW'); + +-- CreateTable +CREATE TABLE "public"."TeamTsrSnapshot" ( + "teamId" INTEGER NOT NULL, + "rating" INTEGER NOT NULL, + "source" "public"."TeamTsrSource" NOT NULL, + "confidence" "public"."TeamTsrConfidence" NOT NULL, + "bracketTier" "public"."FaceitTier" NOT NULL, + "bracketBand" TEXT, + "region" "public"."TsrRegion" NOT NULL, + "rosterSize" INTEGER NOT NULL, + "ratedCount" INTEGER NOT NULL, + "playtimeBackedShare" DOUBLE PRECISION NOT NULL, + "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamTsrSnapshot_pkey" PRIMARY KEY ("teamId") +); + +-- CreateTable +CREATE TABLE "public"."ScrimRequest" ( + "id" TEXT NOT NULL, + "fromTeamId" INTEGER NOT NULL, + "toTeamId" INTEGER NOT NULL, + "sentByUserId" TEXT NOT NULL, + "fromTsr" INTEGER NOT NULL, + "toTsr" INTEGER NOT NULL, + "message" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ScrimRequest_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TeamTsrSnapshot_region_rating_idx" ON "public"."TeamTsrSnapshot"("region", "rating"); + +-- CreateIndex +CREATE INDEX "TeamTsrSnapshot_bracketTier_bracketBand_idx" ON "public"."TeamTsrSnapshot"("bracketTier", "bracketBand"); + +-- CreateIndex +CREATE INDEX "ScrimRequest_fromTeamId_createdAt_idx" ON "public"."ScrimRequest"("fromTeamId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ScrimRequest_toTeamId_createdAt_idx" ON "public"."ScrimRequest"("toTeamId", "createdAt"); + +-- CreateIndex +CREATE INDEX "ScrimRequest_fromTeamId_toTeamId_createdAt_idx" ON "public"."ScrimRequest"("fromTeamId", "toTeamId", "createdAt"); + +-- AddForeignKey +ALTER TABLE "public"."TeamTsrSnapshot" ADD CONSTRAINT "TeamTsrSnapshot_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ScrimRequest" ADD CONSTRAINT "ScrimRequest_fromTeamId_fkey" FOREIGN KEY ("fromTeamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ScrimRequest" ADD CONSTRAINT "ScrimRequest_toTeamId_fkey" FOREIGN KEY ("toTeamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ScrimRequest" ADD CONSTRAINT "ScrimRequest_sentByUserId_fkey" FOREIGN KEY ("sentByUserId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260427230842_add_scrim_request_sent_audit_action/migration.sql b/prisma/migrations/20260427230842_add_scrim_request_sent_audit_action/migration.sql new file mode 100644 index 000000000..7428c9879 --- /dev/null +++ b/prisma/migrations/20260427230842_add_scrim_request_sent_audit_action/migration.sql @@ -0,0 +1,2 @@ +-- AlterEnum +ALTER TYPE "public"."AuditLogAction" ADD VALUE 'SCRIM_REQUEST_SENT'; diff --git a/prisma/migrations/20260528010000_app_settings_unique_user/migration.sql b/prisma/migrations/20260528010000_app_settings_unique_user/migration.sql new file mode 100644 index 000000000..211865007 --- /dev/null +++ b/prisma/migrations/20260528010000_app_settings_unique_user/migration.sql @@ -0,0 +1,9 @@ +-- Keep the oldest settings row per user before enforcing uniqueness. +DELETE FROM "public"."AppSettings" newer +USING "public"."AppSettings" older +WHERE newer."userId" = older."userId" + AND newer."id" > older."id"; + +-- Replace the non-unique lookup index with a uniqueness guarantee. +DROP INDEX IF EXISTS "public"."AppSettings_userId_idx"; +CREATE UNIQUE INDEX "AppSettings_userId_key" ON "public"."AppSettings"("userId"); diff --git a/prisma/migrations/20260529050523_add_saved_query_model/migration.sql b/prisma/migrations/20260529050523_add_saved_query_model/migration.sql new file mode 100644 index 000000000..c7e7faaad --- /dev/null +++ b/prisma/migrations/20260529050523_add_saved_query_model/migration.sql @@ -0,0 +1,18 @@ +-- CreateTable +CREATE TABLE "public"."SavedQuery" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "teamId" INTEGER NOT NULL, + "spec" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "SavedQuery_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "SavedQuery_userId_idx" ON "public"."SavedQuery"("userId"); + +-- CreateIndex +CREATE INDEX "SavedQuery_teamId_idx" ON "public"."SavedQuery"("teamId"); diff --git a/prisma/migrations/20260531000000_add_map_order/migration.sql b/prisma/migrations/20260531000000_add_map_order/migration.sql new file mode 100644 index 000000000..3f90fa2de --- /dev/null +++ b/prisma/migrations/20260531000000_add_map_order/migration.sql @@ -0,0 +1,20 @@ +-- AlterTable: add explicit display order for maps within a scrim. +ALTER TABLE "Map" ADD COLUMN "order" INTEGER NOT NULL DEFAULT 0; + +-- Backfill existing maps so each scrim's maps keep their current id-ascending +-- order. Without this, every map defaults to order 0 and the new sort is a no-op +-- against the old id-based ordering for historical scrims. +WITH ordered AS ( + SELECT + "id", + ROW_NUMBER() OVER (PARTITION BY "scrimId" ORDER BY "id" ASC) - 1 AS rn + FROM "Map" + WHERE "scrimId" IS NOT NULL +) +UPDATE "Map" m +SET "order" = ordered.rn +FROM ordered +WHERE m."id" = ordered."id"; + +-- CreateIndex +CREATE INDEX "Map_scrimId_order_idx" ON "Map"("scrimId", "order"); diff --git a/prisma/migrations/20260601003011_add_team_substitute/migration.sql b/prisma/migrations/20260601003011_add_team_substitute/migration.sql new file mode 100644 index 000000000..44aaf9b58 --- /dev/null +++ b/prisma/migrations/20260601003011_add_team_substitute/migration.sql @@ -0,0 +1,37 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "public"."AuditLogAction" ADD VALUE 'TEAM_SUBSTITUTE_MARKED'; +ALTER TYPE "public"."AuditLogAction" ADD VALUE 'TEAM_SUBSTITUTE_UNMARKED'; + +-- CreateTable +CREATE TABLE "public"."TeamSubstitute" ( + "id" SERIAL NOT NULL, + "teamId" INTEGER NOT NULL, + "playerName" TEXT NOT NULL, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "TeamSubstitute_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TeamSubstitute_teamId_idx" ON "public"."TeamSubstitute"("teamId"); + +-- CreateIndex +CREATE INDEX "TeamSubstitute_createdBy_idx" ON "public"."TeamSubstitute"("createdBy"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamSubstitute_teamId_playerName_key" ON "public"."TeamSubstitute"("teamId", "playerName"); + +-- AddForeignKey +ALTER TABLE "public"."TeamSubstitute" ADD CONSTRAINT "TeamSubstitute_teamId_fkey" FOREIGN KEY ("teamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."TeamSubstitute" ADD CONSTRAINT "TeamSubstitute_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260601020000_add_faceit_scouting_data/migration.sql b/prisma/migrations/20260601020000_add_faceit_scouting_data/migration.sql new file mode 100644 index 000000000..a51430c17 --- /dev/null +++ b/prisma/migrations/20260601020000_add_faceit_scouting_data/migration.sql @@ -0,0 +1,213 @@ +-- AlterTable +ALTER TABLE "public"."FaceitMatch" +ADD COLUMN "team1FaceitTeamId" TEXT, +ADD COLUMN "team2FaceitTeamId" TEXT, +ADD COLUMN "team1Name" TEXT, +ADD COLUMN "team2Name" TEXT, +ADD COLUMN "rawDetails" JSONB, +ADD COLUMN "rawVoting" JSONB; + +-- CreateTable +CREATE TABLE "public"."FaceitTeam" ( + "faceitTeamId" TEXT NOT NULL, + "name" TEXT NOT NULL, + "avatar" TEXT, + "type" TEXT, + "firstSeenAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "lastSyncedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "FaceitTeam_pkey" PRIMARY KEY ("faceitTeamId") +); + +-- CreateTable +CREATE TABLE "public"."FaceitMatchTeam" ( + "id" SERIAL NOT NULL, + "matchId" TEXT NOT NULL, + "teamSide" INTEGER NOT NULL, + "faceitTeamId" TEXT, + "teamName" TEXT NOT NULL, + "score" INTEGER NOT NULL, + "winner" BOOLEAN NOT NULL DEFAULT false, + "rawStats" JSONB, + + CONSTRAINT "FaceitMatchTeam_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."FaceitMatchMap" ( + "id" SERIAL NOT NULL, + "matchId" TEXT NOT NULL, + "gameNumber" INTEGER NOT NULL, + "mapGuid" TEXT, + "mapName" TEXT, + "mapType" "public"."MapType", + "attackingFirstFaction" TEXT, + "winnerFaction" INTEGER, + "winnerFaceitTeamId" TEXT, + "team1Score" INTEGER, + "team2Score" INTEGER, + "scoreSummary" TEXT, + "played" BOOLEAN, + "rawRoundStats" JSONB, + "rawDetailedResult" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FaceitMatchMap_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."FaceitHeroBan" ( + "id" SERIAL NOT NULL, + "faceitMapId" INTEGER NOT NULL, + "heroGuid" TEXT, + "heroName" TEXT NOT NULL, + "role" TEXT, + "banOrder" INTEGER NOT NULL, + "bannedByFaction" INTEGER, + "source" TEXT NOT NULL, + "rawEntity" JSONB, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FaceitHeroBan_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."FaceitMapTeamStats" ( + "id" SERIAL NOT NULL, + "faceitMapId" INTEGER NOT NULL, + "teamSide" INTEGER NOT NULL, + "faceitTeamId" TEXT, + "teamName" TEXT NOT NULL, + "score" INTEGER, + "won" BOOLEAN, + "eliminations" INTEGER, + "deaths" INTEGER, + "finalBlows" INTEGER, + "objectiveTime" DOUBLE PRECISION, + "timePlayed" DOUBLE PRECISION, + "rawStats" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FaceitMapTeamStats_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."FaceitMapPlayerStats" ( + "id" SERIAL NOT NULL, + "faceitMapId" INTEGER NOT NULL, + "teamSide" INTEGER NOT NULL, + "faceitPlayerId" TEXT NOT NULL, + "nickname" TEXT NOT NULL, + "role" TEXT, + "result" INTEGER, + "eliminations" INTEGER, + "assists" INTEGER, + "deaths" INTEGER, + "finalBlows" INTEGER, + "soloKills" INTEGER, + "multiKills" INTEGER, + "environmentalKills" INTEGER, + "damageDealt" DOUBLE PRECISION, + "healingDone" DOUBLE PRECISION, + "damageMitigated" DOUBLE PRECISION, + "objectiveTime" DOUBLE PRECISION, + "timePlayed" DOUBLE PRECISION, + "rawStats" JSONB NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FaceitMapPlayerStats_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "FaceitMatch_team1FaceitTeamId_idx" ON "public"."FaceitMatch"("team1FaceitTeamId"); + +-- CreateIndex +CREATE INDEX "FaceitMatch_team2FaceitTeamId_idx" ON "public"."FaceitMatch"("team2FaceitTeamId"); + +-- CreateIndex +CREATE INDEX "FaceitTeam_name_idx" ON "public"."FaceitTeam"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "FaceitMatchTeam_matchId_teamSide_key" ON "public"."FaceitMatchTeam"("matchId", "teamSide"); + +-- CreateIndex +CREATE INDEX "FaceitMatchTeam_faceitTeamId_idx" ON "public"."FaceitMatchTeam"("faceitTeamId"); + +-- CreateIndex +CREATE INDEX "FaceitMatchTeam_teamName_idx" ON "public"."FaceitMatchTeam"("teamName"); + +-- CreateIndex +CREATE UNIQUE INDEX "FaceitMatchMap_matchId_gameNumber_key" ON "public"."FaceitMatchMap"("matchId", "gameNumber"); + +-- CreateIndex +CREATE INDEX "FaceitMatchMap_matchId_idx" ON "public"."FaceitMatchMap"("matchId"); + +-- CreateIndex +CREATE INDEX "FaceitMatchMap_mapGuid_idx" ON "public"."FaceitMatchMap"("mapGuid"); + +-- CreateIndex +CREATE INDEX "FaceitMatchMap_mapName_idx" ON "public"."FaceitMatchMap"("mapName"); + +-- CreateIndex +CREATE INDEX "FaceitMatchMap_mapType_idx" ON "public"."FaceitMatchMap"("mapType"); + +-- CreateIndex +CREATE INDEX "FaceitHeroBan_faceitMapId_idx" ON "public"."FaceitHeroBan"("faceitMapId"); + +-- CreateIndex +CREATE INDEX "FaceitHeroBan_heroName_idx" ON "public"."FaceitHeroBan"("heroName"); + +-- CreateIndex +CREATE UNIQUE INDEX "FaceitMapTeamStats_faceitMapId_teamSide_key" ON "public"."FaceitMapTeamStats"("faceitMapId", "teamSide"); + +-- CreateIndex +CREATE INDEX "FaceitMapTeamStats_faceitMapId_idx" ON "public"."FaceitMapTeamStats"("faceitMapId"); + +-- CreateIndex +CREATE INDEX "FaceitMapTeamStats_faceitTeamId_idx" ON "public"."FaceitMapTeamStats"("faceitTeamId"); + +-- CreateIndex +CREATE INDEX "FaceitMapTeamStats_teamName_idx" ON "public"."FaceitMapTeamStats"("teamName"); + +-- CreateIndex +CREATE UNIQUE INDEX "FaceitMapPlayerStats_faceitMapId_faceitPlayerId_key" ON "public"."FaceitMapPlayerStats"("faceitMapId", "faceitPlayerId"); + +-- CreateIndex +CREATE INDEX "FaceitMapPlayerStats_faceitMapId_idx" ON "public"."FaceitMapPlayerStats"("faceitMapId"); + +-- CreateIndex +CREATE INDEX "FaceitMapPlayerStats_faceitPlayerId_idx" ON "public"."FaceitMapPlayerStats"("faceitPlayerId"); + +-- CreateIndex +CREATE INDEX "FaceitMapPlayerStats_nickname_idx" ON "public"."FaceitMapPlayerStats"("nickname"); + +-- CreateIndex +CREATE INDEX "FaceitMapPlayerStats_role_idx" ON "public"."FaceitMapPlayerStats"("role"); + +-- AddForeignKey +ALTER TABLE "public"."FaceitMatchTeam" ADD CONSTRAINT "FaceitMatchTeam_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "public"."FaceitMatch"("faceitMatchId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitMatchTeam" ADD CONSTRAINT "FaceitMatchTeam_faceitTeamId_fkey" FOREIGN KEY ("faceitTeamId") REFERENCES "public"."FaceitTeam"("faceitTeamId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitMatchMap" ADD CONSTRAINT "FaceitMatchMap_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "public"."FaceitMatch"("faceitMatchId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitHeroBan" ADD CONSTRAINT "FaceitHeroBan_faceitMapId_fkey" FOREIGN KEY ("faceitMapId") REFERENCES "public"."FaceitMatchMap"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitMapTeamStats" ADD CONSTRAINT "FaceitMapTeamStats_faceitMapId_fkey" FOREIGN KEY ("faceitMapId") REFERENCES "public"."FaceitMatchMap"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitMapTeamStats" ADD CONSTRAINT "FaceitMapTeamStats_faceitTeamId_fkey" FOREIGN KEY ("faceitTeamId") REFERENCES "public"."FaceitTeam"("faceitTeamId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitMapPlayerStats" ADD CONSTRAINT "FaceitMapPlayerStats_faceitMapId_fkey" FOREIGN KEY ("faceitMapId") REFERENCES "public"."FaceitMatchMap"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."FaceitMapPlayerStats" ADD CONSTRAINT "FaceitMapPlayerStats_faceitPlayerId_fkey" FOREIGN KEY ("faceitPlayerId") REFERENCES "public"."FaceitPlayer"("faceitPlayerId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260601070443_add_team_blacklist_and_scrim_feedback/migration.sql b/prisma/migrations/20260601070443_add_team_blacklist_and_scrim_feedback/migration.sql new file mode 100644 index 000000000..b5bf2d58f --- /dev/null +++ b/prisma/migrations/20260601070443_add_team_blacklist_and_scrim_feedback/migration.sql @@ -0,0 +1,71 @@ +-- CreateEnum +CREATE TYPE "public"."TeamBlacklistSource" AS ENUM ('MANUAL', 'POST_SCRIM'); + +-- CreateEnum +CREATE TYPE "public"."ScrimFeedbackVerdict" AS ENUM ('GOOD', 'NEUTRAL', 'BLACKLISTED', 'DISMISSED'); + +-- AlterTable +ALTER TABLE "public"."Scrim" ADD COLUMN "opponentTeamId" INTEGER, +ADD COLUMN "scrimRequestId" TEXT; + +-- CreateTable +CREATE TABLE "public"."TeamBlacklist" ( + "id" TEXT NOT NULL, + "ownerTeamId" INTEGER NOT NULL, + "blockedTeamId" INTEGER, + "blockedTeamName" TEXT NOT NULL, + "blockedKey" TEXT NOT NULL, + "reason" TEXT, + "source" "public"."TeamBlacklistSource" NOT NULL DEFAULT 'MANUAL', + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TeamBlacklist_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."ScrimFeedback" ( + "id" TEXT NOT NULL, + "scrimId" INTEGER NOT NULL, + "verdict" "public"."ScrimFeedbackVerdict" NOT NULL, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "ScrimFeedback_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "TeamBlacklist_blockedTeamId_idx" ON "public"."TeamBlacklist"("blockedTeamId"); + +-- CreateIndex +CREATE INDEX "TeamBlacklist_ownerTeamId_idx" ON "public"."TeamBlacklist"("ownerTeamId"); + +-- CreateIndex +CREATE UNIQUE INDEX "TeamBlacklist_ownerTeamId_blockedKey_key" ON "public"."TeamBlacklist"("ownerTeamId", "blockedKey"); + +-- CreateIndex +CREATE UNIQUE INDEX "ScrimFeedback_scrimId_key" ON "public"."ScrimFeedback"("scrimId"); + +-- CreateIndex +CREATE INDEX "Scrim_opponentTeamId_idx" ON "public"."Scrim"("opponentTeamId"); + +-- AddForeignKey +ALTER TABLE "public"."Scrim" ADD CONSTRAINT "Scrim_opponentTeamId_fkey" FOREIGN KEY ("opponentTeamId") REFERENCES "public"."Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."Scrim" ADD CONSTRAINT "Scrim_scrimRequestId_fkey" FOREIGN KEY ("scrimRequestId") REFERENCES "public"."ScrimRequest"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."TeamBlacklist" ADD CONSTRAINT "TeamBlacklist_ownerTeamId_fkey" FOREIGN KEY ("ownerTeamId") REFERENCES "public"."Team"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."TeamBlacklist" ADD CONSTRAINT "TeamBlacklist_blockedTeamId_fkey" FOREIGN KEY ("blockedTeamId") REFERENCES "public"."Team"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."TeamBlacklist" ADD CONSTRAINT "TeamBlacklist_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ScrimFeedback" ADD CONSTRAINT "ScrimFeedback_scrimId_fkey" FOREIGN KEY ("scrimId") REFERENCES "public"."Scrim"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."ScrimFeedback" ADD CONSTRAINT "ScrimFeedback_createdBy_fkey" FOREIGN KEY ("createdBy") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260601071238_add_team_ops_indexes/migration.sql b/prisma/migrations/20260601071238_add_team_ops_indexes/migration.sql new file mode 100644 index 000000000..a6d2250dd --- /dev/null +++ b/prisma/migrations/20260601071238_add_team_ops_indexes/migration.sql @@ -0,0 +1,8 @@ +-- CreateIndex +CREATE INDEX "Scrim_teamId_opponentTeamId_idx" ON "public"."Scrim"("teamId", "opponentTeamId"); + +-- CreateIndex +CREATE INDEX "ScrimFeedback_createdBy_idx" ON "public"."ScrimFeedback"("createdBy"); + +-- CreateIndex +CREATE INDEX "TeamBlacklist_createdBy_idx" ON "public"."TeamBlacklist"("createdBy"); diff --git a/prisma/migrations/20260610010544_add_overwatch_patch/migration.sql b/prisma/migrations/20260610010544_add_overwatch_patch/migration.sql new file mode 100644 index 000000000..d1954f35a --- /dev/null +++ b/prisma/migrations/20260610010544_add_overwatch_patch/migration.sql @@ -0,0 +1,25 @@ +-- CreateEnum +CREATE TYPE "public"."OverwatchPatchType" AS ENUM ('SEASON', 'MID_SEASON', 'HOTFIX'); + +-- CreateEnum +CREATE TYPE "public"."OverwatchPatchSource" AS ENUM ('MANUAL', 'SCRAPED'); + +-- CreateTable +CREATE TABLE "public"."OverwatchPatch" ( + "id" TEXT NOT NULL, + "date" DATE NOT NULL, + "type" "public"."OverwatchPatchType" NOT NULL, + "name" TEXT NOT NULL, + "rawTitle" TEXT, + "sourceUrl" TEXT, + "bodyExcerpt" TEXT, + "source" "public"."OverwatchPatchSource" NOT NULL DEFAULT 'SCRAPED', + "needsReview" BOOLEAN NOT NULL DEFAULT false, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "OverwatchPatch_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "OverwatchPatch_date_key" ON "public"."OverwatchPatch"("date"); diff --git a/prisma/migrations/20260610031339_add_spatial_calculated_stat_types/migration.sql b/prisma/migrations/20260610031339_add_spatial_calculated_stat_types/migration.sql new file mode 100644 index 000000000..95a9d830f --- /dev/null +++ b/prisma/migrations/20260610031339_add_spatial_calculated_stat_types/migration.sql @@ -0,0 +1,12 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "public"."CalculatedStatType" ADD VALUE 'AVERAGE_ENGAGEMENT_DISTANCE'; +ALTER TYPE "public"."CalculatedStatType" ADD VALUE 'HIGH_GROUND_KILL_PERCENTAGE'; +ALTER TYPE "public"."CalculatedStatType" ADD VALUE 'ISOLATION_DEATH_PERCENTAGE'; +ALTER TYPE "public"."CalculatedStatType" ADD VALUE 'AVERAGE_FIGHT_START_SPREAD'; diff --git a/prisma/migrations/20260610040706_add_calculated_stat_mapdata_stat_index/migration.sql b/prisma/migrations/20260610040706_add_calculated_stat_mapdata_stat_index/migration.sql new file mode 100644 index 000000000..4f8c162bb --- /dev/null +++ b/prisma/migrations/20260610040706_add_calculated_stat_mapdata_stat_index/migration.sql @@ -0,0 +1,2 @@ +-- CreateIndex +CREATE INDEX "CalculatedStat_MapDataId_stat_idx" ON "public"."CalculatedStat"("MapDataId", "stat"); diff --git a/prisma/migrations/20260610063726_add_map_zones/migration.sql b/prisma/migrations/20260610063726_add_map_zones/migration.sql new file mode 100644 index 000000000..dbdfed2fb --- /dev/null +++ b/prisma/migrations/20260610063726_add_map_zones/migration.sql @@ -0,0 +1,34 @@ +-- CreateEnum +CREATE TYPE "public"."MapZoneCategory" AS ENUM ('POINT', 'LANE'); + +-- CreateEnum +CREATE TYPE "public"."MapZoneStatus" AS ENUM ('DRAFT', 'PUBLISHED'); + +-- CreateEnum +CREATE TYPE "public"."MapZoneSource" AS ENUM ('AUTO', 'MANUAL'); + +-- CreateEnum +CREATE TYPE "public"."LaneRole" AS ENUM ('MAIN', 'FLANK'); + +-- CreateTable +CREATE TABLE "public"."MapZone" ( + "id" SERIAL NOT NULL, + "calibrationId" INTEGER NOT NULL, + "name" TEXT NOT NULL, + "category" "public"."MapZoneCategory" NOT NULL, + "status" "public"."MapZoneStatus" NOT NULL DEFAULT 'DRAFT', + "source" "public"."MapZoneSource" NOT NULL DEFAULT 'MANUAL', + "laneRole" "public"."LaneRole", + "vertices" JSONB NOT NULL, + "createdBy" TEXT NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "MapZone_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MapZone_calibrationId_status_idx" ON "public"."MapZone"("calibrationId", "status"); + +-- AddForeignKey +ALTER TABLE "public"."MapZone" ADD CONSTRAINT "MapZone_calibrationId_fkey" FOREIGN KEY ("calibrationId") REFERENCES "public"."MapCalibration"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260610073610_add_ult_quality_stat_types/migration.sql b/prisma/migrations/20260610073610_add_ult_quality_stat_types/migration.sql new file mode 100644 index 000000000..58bf48675 --- /dev/null +++ b/prisma/migrations/20260610073610_add_ult_quality_stat_types/migration.sql @@ -0,0 +1,12 @@ +-- AlterEnum +-- This migration adds more than one value to an enum. +-- With PostgreSQL versions 11 and earlier, this is not possible +-- in a single migration. This can be worked around by creating +-- multiple migrations, each migration adding only one value to +-- the enum. + + +ALTER TYPE "public"."CalculatedStatType" ADD VALUE 'AVERAGE_ULT_CONVERSION_KILLS'; +ALTER TYPE "public"."CalculatedStatType" ADD VALUE 'ULT_DEATH_PERCENTAGE'; +ALTER TYPE "public"."CalculatedStatType" ADD VALUE 'AVERAGE_ULT_DISPLACEMENT'; +ALTER TYPE "public"."CalculatedStatType" ADD VALUE 'ULTS_ON_OBJECTIVE_PERCENTAGE'; diff --git a/prisma/migrations/20260610120000_optimize_top_3_heroes_indexes/migration.sql b/prisma/migrations/20260610120000_optimize_top_3_heroes_indexes/migration.sql new file mode 100644 index 000000000..eddc34453 --- /dev/null +++ b/prisma/migrations/20260610120000_optimize_top_3_heroes_indexes/migration.sql @@ -0,0 +1,17 @@ +-- CreateIndex +CREATE INDEX "PlayerStat_player_name_player_hero_hero_time_played_idx" ON "public"."PlayerStat"("player_name", "player_hero", "hero_time_played"); + +-- DropIndex +DROP INDEX "public"."Map_scrimId_idx"; + +-- DropIndex +DROP INDEX "public"."TeamSubstitute_teamId_idx"; + +-- DropIndex +DROP INDEX "public"."FaceitMapPlayerStats_faceitMapId_idx"; + +-- DropIndex +DROP INDEX "public"."FaceitMapTeamStats_faceitMapId_idx"; + +-- DropIndex +DROP INDEX "public"."FaceitMatchMap_matchId_idx"; diff --git a/prisma/migrations/20260611000000_baseline_usage_analytics/migration.sql b/prisma/migrations/20260611000000_baseline_usage_analytics/migration.sql new file mode 100644 index 000000000..bf3edd9b3 --- /dev/null +++ b/prisma/migrations/20260611000000_baseline_usage_analytics/migration.sql @@ -0,0 +1,66 @@ +-- CreateEnum +CREATE TYPE "public"."UsageEnv" AS ENUM ('PRODUCTION', 'PREVIEW', 'DEVELOPMENT'); + +-- CreateEnum +CREATE TYPE "public"."UsageSource" AS ENUM ('SERVER', 'CLIENT'); + +-- CreateTable +CREATE TABLE "public"."DailyFeatureRollup" ( + "environment" "public"."UsageEnv" NOT NULL, + "day" TEXT NOT NULL, + "name" TEXT NOT NULL, + "uniqueUsers" INTEGER NOT NULL, + "uniqueTeams" INTEGER NOT NULL, + "totalEvents" INTEGER NOT NULL, + + CONSTRAINT "DailyFeatureRollup_pkey" PRIMARY KEY ("environment","day","name") +); + +-- CreateTable +CREATE TABLE "public"."DailyPageRollup" ( + "environment" "public"."UsageEnv" NOT NULL, + "day" TEXT NOT NULL, + "path" TEXT NOT NULL, + "views" INTEGER NOT NULL, + "uniqueUsers" INTEGER NOT NULL, + + CONSTRAINT "DailyPageRollup_pkey" PRIMARY KEY ("environment","day","path") +); + +-- CreateTable +CREATE TABLE "public"."UsageEvent" ( + "id" BIGSERIAL NOT NULL, + "ts" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "name" TEXT NOT NULL, + "source" "public"."UsageSource" NOT NULL, + "environment" "public"."UsageEnv" NOT NULL, + "userId" TEXT, + "teamId" INTEGER, + "path" TEXT, + "sessionId" TEXT, + "props" JSONB, + + CONSTRAINT "UsageEvent_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."UserActiveDay" ( + "environment" "public"."UsageEnv" NOT NULL, + "day" TEXT NOT NULL, + "userId" TEXT NOT NULL, + + CONSTRAINT "UserActiveDay_pkey" PRIMARY KEY ("environment","day","userId") +); + +-- CreateIndex +CREATE INDEX "UsageEvent_environment_name_ts_idx" ON "public"."UsageEvent"("environment" ASC, "name" ASC, "ts" ASC); + +-- CreateIndex +CREATE INDEX "UsageEvent_environment_path_ts_idx" ON "public"."UsageEvent"("environment" ASC, "path" ASC, "ts" ASC); + +-- CreateIndex +CREATE INDEX "UsageEvent_ts_idx" ON "public"."UsageEvent"("ts" ASC); + +-- CreateIndex +CREATE INDEX "UsageEvent_userId_ts_idx" ON "public"."UsageEvent"("userId" ASC, "ts" ASC); + diff --git a/prisma/migrations/20260612014427_add_ranked_tracker_models/migration.sql b/prisma/migrations/20260612014427_add_ranked_tracker_models/migration.sql new file mode 100644 index 000000000..bfd6a6cae --- /dev/null +++ b/prisma/migrations/20260612014427_add_ranked_tracker_models/migration.sql @@ -0,0 +1,59 @@ +-- AlterTable +ALTER TABLE "public"."User" ADD COLUMN "rankedStatsPublic" BOOLEAN NOT NULL DEFAULT false; + +-- CreateTable +CREATE TABLE "public"."RankedMatch" ( + "id" TEXT NOT NULL, + "userId" TEXT NOT NULL, + "map" TEXT NOT NULL, + "mapType" TEXT NOT NULL, + "result" TEXT NOT NULL, + "groupSize" INTEGER NOT NULL, + "playedAt" TIMESTAMP(3) NOT NULL, + "sourceId" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "RankedMatch_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."RankedMatchHero" ( + "id" TEXT NOT NULL, + "matchId" TEXT NOT NULL, + "hero" TEXT NOT NULL, + "role" TEXT NOT NULL, + "percentage" INTEGER NOT NULL, + + CONSTRAINT "RankedMatchHero_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "public"."RankedImportClaim" ( + "id" TEXT NOT NULL, + "email" TEXT NOT NULL, + "oauthKey" TEXT, + "payload" JSONB NOT NULL, + "claimedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "RankedImportClaim_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "RankedMatch_userId_idx" ON "public"."RankedMatch"("userId"); + +-- CreateIndex +CREATE UNIQUE INDEX "RankedMatch_userId_sourceId_key" ON "public"."RankedMatch"("userId", "sourceId"); + +-- CreateIndex +CREATE INDEX "RankedMatchHero_matchId_idx" ON "public"."RankedMatchHero"("matchId"); + +-- CreateIndex +CREATE INDEX "RankedImportClaim_email_idx" ON "public"."RankedImportClaim"("email"); + +-- AddForeignKey +ALTER TABLE "public"."RankedMatch" ADD CONSTRAINT "RankedMatch_userId_fkey" FOREIGN KEY ("userId") REFERENCES "public"."User"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "public"."RankedMatchHero" ADD CONSTRAINT "RankedMatchHero_matchId_fkey" FOREIGN KEY ("matchId") REFERENCES "public"."RankedMatch"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260612020000_drop_availability_week_starts_on/migration.sql b/prisma/migrations/20260612020000_drop_availability_week_starts_on/migration.sql new file mode 100644 index 000000000..45c087fdb --- /dev/null +++ b/prisma/migrations/20260612020000_drop_availability_week_starts_on/migration.sql @@ -0,0 +1,2 @@ +-- Drop the unused weekStartsOn column from TeamAvailabilitySettings. +ALTER TABLE "public"."TeamAvailabilitySettings" DROP COLUMN "weekStartsOn"; diff --git a/prisma/migrations/20260613073051_add_fsr_models/migration.sql b/prisma/migrations/20260613073051_add_fsr_models/migration.sql new file mode 100644 index 000000000..6c0316f2e --- /dev/null +++ b/prisma/migrations/20260613073051_add_fsr_models/migration.sql @@ -0,0 +1,56 @@ +-- CreateEnum +CREATE TYPE "FaceitRole" AS ENUM ('TANK', 'DAMAGE', 'SUPPORT'); + +-- CreateTable +CREATE TABLE "PlayerFsr" ( + "faceitPlayerId" TEXT NOT NULL, + "role" "FaceitRole" NOT NULL, + "fsr" INTEGER NOT NULL, + "compositeZ" DOUBLE PRECISION NOT NULL, + "effectiveAnchor" INTEGER NOT NULL, + "mapCount" INTEGER NOT NULL, + "recentMapCount365d" INTEGER NOT NULL, + "tiersPlayed" "FaceitTier"[], + "computedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlayerFsr_pkey" PRIMARY KEY ("faceitPlayerId","role") +); + +-- CreateTable +CREATE TABLE "PlayerFsrTier" ( + "faceitPlayerId" TEXT NOT NULL, + "role" "FaceitRole" NOT NULL, + "tier" "FaceitTier" NOT NULL, + "fsr" INTEGER NOT NULL, + "compositeZ" DOUBLE PRECISION NOT NULL, + "mapCount" INTEGER NOT NULL, + "minutesPlayed" DOUBLE PRECISION NOT NULL, + "peerCount" INTEGER NOT NULL, + "statZ" JSONB NOT NULL, + "computedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "PlayerFsrTier_pkey" PRIMARY KEY ("faceitPlayerId","role","tier") +); + +-- CreateTable +CREATE TABLE "FsrBaseline" ( + "tier" "FaceitTier" NOT NULL, + "role" "FaceitRole" NOT NULL, + "sampleN" INTEGER NOT NULL, + "stats" JSONB NOT NULL, + "computedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "FsrBaseline_pkey" PRIMARY KEY ("tier","role") +); + +-- CreateIndex +CREATE INDEX "PlayerFsr_role_fsr_idx" ON "PlayerFsr"("role", "fsr"); + +-- CreateIndex +CREATE INDEX "PlayerFsrTier_tier_role_fsr_idx" ON "PlayerFsrTier"("tier", "role", "fsr"); + +-- AddForeignKey +ALTER TABLE "PlayerFsr" ADD CONSTRAINT "PlayerFsr_faceitPlayerId_fkey" FOREIGN KEY ("faceitPlayerId") REFERENCES "FaceitPlayer"("faceitPlayerId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PlayerFsrTier" ADD CONSTRAINT "PlayerFsrTier_faceitPlayerId_fkey" FOREIGN KEY ("faceitPlayerId") REFERENCES "FaceitPlayer"("faceitPlayerId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/migrations/20260614055853_add_map_winner_fields/migration.sql b/prisma/migrations/20260614055853_add_map_winner_fields/migration.sql new file mode 100644 index 000000000..123ca25e3 --- /dev/null +++ b/prisma/migrations/20260614055853_add_map_winner_fields/migration.sql @@ -0,0 +1,6 @@ +-- CreateEnum +CREATE TYPE "WinnerSource" AS ENUM ('auto_score', 'auto_coords', 'manual'); + +-- AlterTable +ALTER TABLE "Map" ADD COLUMN "winner" TEXT, +ADD COLUMN "winnerSource" "WinnerSource"; diff --git a/prisma/migrations/20260615140000_add_playerstat_lower_name_index/migration.sql b/prisma/migrations/20260615140000_add_playerstat_lower_name_index/migration.sql new file mode 100644 index 000000000..5530f2ba3 --- /dev/null +++ b/prisma/migrations/20260615140000_add_playerstat_lower_name_index/migration.sql @@ -0,0 +1,12 @@ +-- Migration: Index case-insensitive player_name lookups +-- +-- Several hot queries match players with `player_name ILIKE $1` (exact name, +-- case-insensitive — no wildcards). ILIKE (~~*) cannot use a plain btree index, +-- so those queries fell back to a full sequential scan of "PlayerStat". The +-- profile "heroes played" query alone was ~47% of total database time. +-- +-- The call sites now use `lower(player_name) = lower($1)`, which this functional +-- index serves as a direct seek. Kept non-partial so it also covers lookups that +-- do not filter on hero_time_played (e.g. the scouting-analytics maxTime CTE). +CREATE INDEX IF NOT EXISTS "idx_playerstat_lower_name" + ON "PlayerStat" (lower(player_name)); diff --git a/prisma/migrations/20260615150000_drop_redundant_indexes/migration.sql b/prisma/migrations/20260615150000_drop_redundant_indexes/migration.sql new file mode 100644 index 000000000..b9e05825f --- /dev/null +++ b/prisma/migrations/20260615150000_drop_redundant_indexes/migration.sql @@ -0,0 +1,39 @@ +-- Migration: Drop redundant single-column indexes +-- +-- Each index below is a leading-prefix duplicate of a composite unique/index on +-- the same table (per PlanetScale schema recommendations), so it provides no read +-- benefit while adding write and storage overhead. Queries that filtered on the +-- single column continue to use the leading column of the covering index. +-- +-- Covered-by relationships: +-- RankedMatch_userId_idx -> RankedMatch_userId_sourceId_key +-- Scrim_teamId_idx -> Scrim_teamId_opponentTeamId_idx +-- TeamBlacklist_ownerTeamId_idx -> TeamBlacklist_ownerTeamId_blocked*_key +-- FaceitMatchRoster_matchId_idx -> FaceitMatchRoster_matchId_faceitPlayerId_key +-- FaceitChampionship_organizerId_idx -> FaceitChampionship_organizerId_tier_idx +-- AvailabilitySchedule_teamId_idx -> AvailabilitySchedule_teamId_weekStart_key +-- AvailabilityResponse_scheduleId_idx -> AvailabilityResponse_scheduleId_name*_key +-- TournamentTeam_tournamentId_idx -> TournamentTeam_tournamentId_seed_key +-- TournamentRound_tournamentId_idx -> TournamentRound_tournamentId_bracket_roundNumber_key +-- TournamentMap_matchId_idx -> TournamentMap_matchId_gameNumber_key +-- ScoutingRoster_tournamentId_idx -> ScoutingRoster_tournamentId_teamName_key +-- ScoutingHeroAssignment_mapResultId_idx -> ScoutingHeroAssignment_mapResultId_team_heroName_key +-- Note_scrimId_MapDataId_idx -> Note_scrimId_MapDataId_key +-- ComparisonGroup_teamId_idx -> ComparisonGroup_teamId_playerName_idx +-- AppliedTitle_userId_idx -> AppliedTitle_userId_title_key + +DROP INDEX IF EXISTS "public"."RankedMatch_userId_idx"; +DROP INDEX IF EXISTS "public"."Scrim_teamId_idx"; +DROP INDEX IF EXISTS "public"."TeamBlacklist_ownerTeamId_idx"; +DROP INDEX IF EXISTS "public"."FaceitMatchRoster_matchId_idx"; +DROP INDEX IF EXISTS "public"."FaceitChampionship_organizerId_idx"; +DROP INDEX IF EXISTS "public"."AvailabilitySchedule_teamId_idx"; +DROP INDEX IF EXISTS "public"."AvailabilityResponse_scheduleId_idx"; +DROP INDEX IF EXISTS "public"."TournamentTeam_tournamentId_idx"; +DROP INDEX IF EXISTS "public"."TournamentRound_tournamentId_idx"; +DROP INDEX IF EXISTS "public"."TournamentMap_matchId_idx"; +DROP INDEX IF EXISTS "public"."ScoutingRoster_tournamentId_idx"; +DROP INDEX IF EXISTS "public"."ScoutingHeroAssignment_mapResultId_idx"; +DROP INDEX IF EXISTS "public"."Note_scrimId_MapDataId_idx"; +DROP INDEX IF EXISTS "public"."ComparisonGroup_teamId_idx"; +DROP INDEX IF EXISTS "public"."AppliedTitle_userId_idx"; diff --git a/prisma/migrations/20260615183852_add_tempo_baseline/migration.sql b/prisma/migrations/20260615183852_add_tempo_baseline/migration.sql new file mode 100644 index 000000000..58c6b52b5 --- /dev/null +++ b/prisma/migrations/20260615183852_add_tempo_baseline/migration.sql @@ -0,0 +1,13 @@ +-- CreateEnum +CREATE TYPE "TempoMetric" AS ENUM ('FIGHT_DURATION', 'ULT_CHARGE_TIME', 'ULT_HOLD_TIME'); + +-- CreateTable +CREATE TABLE "TempoBaseline" ( + "metric" "TempoMetric" NOT NULL, + "mean" DOUBLE PRECISION NOT NULL, + "stdDev" DOUBLE PRECISION NOT NULL, + "sampleN" INTEGER NOT NULL, + "computedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "TempoBaseline_pkey" PRIMARY KEY ("metric") +); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index ca228dbe2..5bbd4363c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -2,13 +2,12 @@ // learn more about it in the docs: https://pris.ly/d/prisma-schema datasource db { - provider = "postgresql" - url = env("DATABASE_URL") - directUrl = env("DIRECT_URL") + provider = "postgresql" } generator client { - provider = "prisma-client-js" + provider = "prisma-client" + output = "../src/generated/prisma" } model Account { @@ -62,6 +61,7 @@ model User { createdAt DateTime @default(now()) updatedAt DateTime @default(now()) @updatedAt battletag String? + rankedStatsPublic Boolean @default(false) appliedTitles AppliedTitle[] seenOnboarding Boolean @default(false) comparisonGroups ComparisonGroup[] @@ -71,6 +71,14 @@ model User { chatConversations ChatConversation[] chatReports ChatReport[] availabilityResponses AvailabilityResponse[] + credits UserCredits? + creditTransactions CreditTransaction[] + pendingAutoRefill PendingAutoRefill? + scrimRequestsSent ScrimRequest[] + teamSubstitutes TeamSubstitute[] + teamBlacklists TeamBlacklist[] + scrimFeedback ScrimFeedback[] + rankedMatches RankedMatch[] @@index([id, email]) } @@ -107,15 +115,13 @@ enum ColorblindMode { model AppSettings { id Int @id @default(autoincrement()) - userId String + userId String @unique colorblindMode ColorblindMode @default(OFF) customTeam1Color String? customTeam2Color String? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) - - @@index([userId]) } enum BillingPlan { @@ -162,6 +168,8 @@ enum AuditLogAction { TEAM_MEMBER_PROMOTED TEAM_MEMBER_DEMOTED TEAM_MEMBER_REMOVED + TEAM_SUBSTITUTE_MARKED + TEAM_SUBSTITUTE_UNMARKED TEAM_OWNERSHIP_TRANSFERRED SCRIM_CREATED SCRIM_UPDATED @@ -179,6 +187,7 @@ enum AuditLogAction { TOURNAMENT_UPDATED TOURNAMENT_DELETED TOURNAMENT_MATCH_UPDATED + SCRIM_REQUEST_SENT } model AuditLog { @@ -211,6 +220,13 @@ model Team { tournamentTeams TournamentTeam[] availabilitySettings TeamAvailabilitySettings? availabilitySchedules AvailabilitySchedule[] + teamTsrSnapshot TeamTsrSnapshot? + scrimRequestsFrom ScrimRequest[] @relation("ScrimRequestFrom") + scrimRequestsTo ScrimRequest[] @relation("ScrimRequestTo") + substitutes TeamSubstitute[] + blacklistsOwned TeamBlacklist[] @relation("BlacklistOwner") + blacklistedBy TeamBlacklist[] @relation("BlacklistBlocked") + scrimsAgainst Scrim[] @relation("ScrimOpponent") } model BotNotificationConfig { @@ -239,12 +255,39 @@ model TeamManager { @@index([userId]) } +model TeamSubstitute { + id Int @id @default(autoincrement()) + teamId Int + playerName String + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade) + + @@unique([teamId, playerName]) + @@index([createdBy]) +} + enum UserRole { ADMIN MANAGER USER } +enum TeamBlacklistSource { + MANUAL + POST_SCRIM +} + +enum ScrimFeedbackVerdict { + GOOD + NEUTRAL + BLACKLISTED + DISMISSED +} + model TeamInviteToken { id Int @id @default(autoincrement()) teamId Int @@ -267,7 +310,6 @@ model ComparisonGroup { team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade) - @@index([teamId]) @@index([createdBy]) @@index([teamId, playerName]) } @@ -289,6 +331,19 @@ model MapGroup { @@index([createdBy]) } +model SavedQuery { + id String @id @default(cuid()) + userId String + name String + teamId Int + spec Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([userId]) + @@index([teamId]) +} + model Scrim { id Int @id @default(autoincrement()) name String @@ -305,23 +360,32 @@ model Scrim { team1Name String? team2Name String? tournamentMatch TournamentMatch? + opponentTeamId Int? + scrimRequestId String? + opponentTeam Team? @relation("ScrimOpponent", fields: [opponentTeamId], references: [id], onDelete: SetNull) + scrimRequest ScrimRequest? @relation(fields: [scrimRequestId], references: [id], onDelete: SetNull) + feedback ScrimFeedback? - @@index([teamId]) + @@index([opponentTeamId]) + @@index([teamId, opponentTeamId]) } model Map { - id Int @id @default(autoincrement()) - name String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - mapData MapData[] - Scrim Scrim? @relation(fields: [scrimId], references: [id]) - vod String? - scrimId Int? - replayCode String? - tournamentMaps TournamentMap[] + id Int @id @default(autoincrement()) + name String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + order Int @default(0) + mapData MapData[] + Scrim Scrim? @relation(fields: [scrimId], references: [id]) + vod String? + scrimId Int? + replayCode String? + winner String? + winnerSource WinnerSource? + tournamentMaps TournamentMap[] - @@index([scrimId]) + @@index([scrimId, order]) } model HeroBan { @@ -364,7 +428,6 @@ model Note { MapDataId Int @@unique([scrimId, MapDataId]) - @@index([scrimId, MapDataId]) } model AppliedTitle { @@ -376,7 +439,6 @@ model AppliedTitle { user User @relation(fields: [userId], references: [id]) @@unique([userId, title]) - @@index([userId]) } enum CalculatedStatType { @@ -394,6 +456,14 @@ enum CalculatedStatType { KILLS_PER_ULTIMATE DUEL_WINRATE_PERCENTAGE FIGHT_REVERSAL_PERCENTAGE + AVERAGE_ENGAGEMENT_DISTANCE + HIGH_GROUND_KILL_PERCENTAGE + ISOLATION_DEATH_PERCENTAGE + AVERAGE_FIGHT_START_SPREAD + AVERAGE_ULT_CONVERSION_KILLS + ULT_DEATH_PERCENTAGE + AVERAGE_ULT_DISPLACEMENT + ULTS_ON_OBJECTIVE_PERCENTAGE } enum Role { @@ -417,6 +487,7 @@ model CalculatedStat { @@index([scrimId, MapDataId]) @@index([playerName, hero, stat]) + @@index([MapDataId, stat]) } enum EventType { @@ -458,6 +529,12 @@ enum MapType { Push } +enum WinnerSource { + auto_score + auto_coords + manual +} + enum TournamentFormat { SINGLE_ELIMINATION DOUBLE_ELIMINATION @@ -830,6 +907,7 @@ model PlayerStat { @@index([scrimId]) @@index([MapDataId]) + @@index([player_name, player_hero, hero_time_played]) } model PointProgress { @@ -1184,7 +1262,6 @@ model ScoutingHeroAssignment { updatedAt DateTime @updatedAt @@unique([mapResultId, team, heroName]) - @@index([mapResultId]) @@index([playerName]) } @@ -1220,7 +1297,6 @@ model ScoutingRoster { updatedAt DateTime @updatedAt @@unique([tournamentId, teamName]) - @@index([tournamentId]) @@index([teamName]) } @@ -1284,22 +1360,23 @@ model ChatReport { } model MapCalibration { - id Int @id @default(autoincrement()) - mapName String @unique - imageUrl String - imageWidth Int + id Int @id @default(autoincrement()) + mapName String @unique + imageUrl String + imageWidth Int imageHeight Int displayImageKey String? affineA Float? - affineB Float? - affineC Float? - affineD Float? - affineTx Float? - affineTy Float? - createdBy String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - anchors MapCalibrationAnchor[] + affineB Float? + affineC Float? + affineD Float? + affineTx Float? + affineTy Float? + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + anchors MapCalibrationAnchor[] + zones MapZone[] } model MapCalibrationAnchor { @@ -1317,26 +1394,63 @@ model MapCalibrationAnchor { @@index([calibrationId]) } +enum MapZoneCategory { + POINT + LANE +} + +enum MapZoneStatus { + DRAFT + PUBLISHED +} + +enum MapZoneSource { + AUTO + MANUAL +} + +enum LaneRole { + MAIN + FLANK +} + +model MapZone { + id Int @id @default(autoincrement()) + calibrationId Int + name String + category MapZoneCategory + status MapZoneStatus @default(DRAFT) + source MapZoneSource @default(MANUAL) + laneRole LaneRole? + vertices Json + createdBy String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + calibration MapCalibration @relation(fields: [calibrationId], references: [id], onDelete: Cascade) + + @@index([calibrationId, status]) +} + // Tournament Models model Tournament { - id Int @id @default(autoincrement()) - name String - format TournamentFormat - bestOf Int @default(3) - playoffBestOf Int? - teamSlots Int @default(8) - status TournamentStatus @default(DRAFT) - startDate DateTime? - endDate DateTime? - grandFinalReset Boolean @default(true) - advancingTeams Int? - creatorId String - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt - teams TournamentTeam[] - rounds TournamentRound[] - matches TournamentMatch[] + id Int @id @default(autoincrement()) + name String + format TournamentFormat + bestOf Int @default(3) + playoffBestOf Int? + teamSlots Int @default(8) + status TournamentStatus @default(DRAFT) + startDate DateTime? + endDate DateTime? + grandFinalReset Boolean @default(true) + advancingTeams Int? + creatorId String + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + teams TournamentTeam[] + rounds TournamentRound[] + matches TournamentMatch[] @@index([creatorId]) } @@ -1357,7 +1471,6 @@ model TournamentTeam { matchesWon TournamentMatch[] @relation("MatchWinner") @@unique([tournamentId, seed]) - @@index([tournamentId]) @@index([teamId]) } @@ -1372,7 +1485,6 @@ model TournamentRound { matches TournamentMatch[] @@unique([tournamentId, bracket, roundNumber]) - @@index([tournamentId]) } model TournamentMatch { @@ -1416,7 +1528,6 @@ model TournamentMap { map Map? @relation(fields: [mapId], references: [id]) @@unique([matchId, gameNumber]) - @@index([matchId]) @@index([mapId]) } @@ -1432,7 +1543,6 @@ model TeamAvailabilitySettings { hoursStart Int @default(12) hoursEnd Int @default(24) timezone String @default("America/New_York") - weekStartsOn Int @default(0) reminderEnabled Boolean @default(true) reminderDayOfWeek Int @default(0) reminderHour Int @default(12) @@ -1458,7 +1568,6 @@ model AvailabilitySchedule { responses AvailabilityResponse[] @@unique([teamId, weekStart]) - @@index([teamId]) } model AvailabilityResponse { @@ -1477,6 +1586,619 @@ model AvailabilityResponse { user User? @relation(fields: [userId], references: [id], onDelete: SetNull) @@unique([scheduleId, nameKey]) - @@index([scheduleId]) @@index([userId]) } + +model UserCredits { + userId String @id + balanceCents Int @default(0) + autoRefillEnabled Boolean @default(false) + autoRefillThresholdCents Int @default(200) + autoRefillAmountCents Int @default(1000) + stripePaymentMethodId String? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum CreditTransactionType { + TOPUP + AUTO_REFILL + CHARGE + REFUND + ADJUSTMENT +} + +model CreditTransaction { + id Int @id @default(autoincrement()) + userId String + type CreditTransactionType + amountCents Int + balanceAfterCents Int + description String + stripeEventId String? @unique + metadata Json? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId, createdAt]) +} + +model PendingAutoRefill { + userId String @id + stripeIdempotencyKey String @unique + paymentIntentId String? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} + +enum FaceitTier { + UNCLASSIFIED + OPEN + CAH + ADVANCED + EXPERT + MASTERS + OWCS +} + +enum TeamTsrSource { + TSR + PREDICTED + CSR_FALLBACK +} + +enum TeamTsrConfidence { + HIGH + MEDIUM + LOW +} + +enum TsrRegion { + NA + EMEA + OTHER +} + +enum FaceitMatchStatus { + FINISHED + CANCELLED + ABORTED +} + +enum FaceitRole { + TANK + DAMAGE + SUPPORT +} + +enum TsrRosterOverrideAction { + INCLUDE + EXCLUDE +} + +model FaceitPlayer { + faceitPlayerId String @id + battletag String? + faceitNickname String + region TsrRegion @default(OTHER) + verified Boolean @default(false) + ow2SkillLevel Int? + firstSeenAt DateTime @default(now()) + lastSyncedAt DateTime @default(now()) + + rosterEntries FaceitMatchRoster[] + rosterOverrides TsrRosterOverride[] + aliases BattletagAlias[] + mapStats FaceitMapPlayerStats[] + tsr PlayerTsr? + fsr PlayerFsr[] + fsrTiers PlayerFsrTier[] + + @@index([battletag]) + @@index([region]) +} + +model FaceitTeam { + faceitTeamId String @id + name String + avatar String? + type String? + firstSeenAt DateTime @default(now()) + lastSyncedAt DateTime @default(now()) + + matchSides FaceitMatchTeam[] + mapStats FaceitMapTeamStats[] + + @@index([name]) +} + +model FaceitChampionship { + championshipId String @id + name String + organizerId String + tier FaceitTier @default(UNCLASSIFIED) + region TsrRegion @default(OTHER) + startDate DateTime? + classifiedBy String? + classifiedAt DateTime? + ingestedAt DateTime @default(now()) + + matches FaceitMatch[] + + @@index([tier]) + @@index([organizerId, tier]) +} + +model FaceitMatch { + faceitMatchId String @id + championshipId String + organizerId String + bestOf Int + team1FaceitTeamId String? + team2FaceitTeamId String? + team1Name String? + team2Name String? + team1Score Int + team2Score Int + winnerFaction Int + status FaceitMatchStatus + finishedAt DateTime + rawRegion String + rawDetails Json? + rawVoting Json? + ingestedAt DateTime @default(now()) + + championship FaceitChampionship @relation(fields: [championshipId], references: [championshipId], onDelete: Cascade) + matchTeams FaceitMatchTeam[] + maps FaceitMatchMap[] + rosters FaceitMatchRoster[] + rosterOverrides TsrRosterOverride[] + + @@index([championshipId]) + @@index([finishedAt]) + @@index([team1FaceitTeamId]) + @@index([team2FaceitTeamId]) + @@index([organizerId, finishedAt]) +} + +model FaceitMatchTeam { + id Int @id @default(autoincrement()) + matchId String + teamSide Int + faceitTeamId String? + teamName String + score Int + winner Boolean @default(false) + rawStats Json? + + match FaceitMatch @relation(fields: [matchId], references: [faceitMatchId], onDelete: Cascade) + team FaceitTeam? @relation(fields: [faceitTeamId], references: [faceitTeamId], onDelete: SetNull) + + @@unique([matchId, teamSide]) + @@index([faceitTeamId]) + @@index([teamName]) +} + +model FaceitMatchRoster { + id Int @id @default(autoincrement()) + matchId String + teamSide Int + faceitPlayerId String + + match FaceitMatch @relation(fields: [matchId], references: [faceitMatchId], onDelete: Cascade) + player FaceitPlayer @relation(fields: [faceitPlayerId], references: [faceitPlayerId], onDelete: Cascade) + + @@unique([matchId, faceitPlayerId]) + @@index([faceitPlayerId]) +} + +model FaceitMatchMap { + id Int @id @default(autoincrement()) + matchId String + gameNumber Int + mapGuid String? + mapName String? + mapType MapType? + attackingFirstFaction String? + winnerFaction Int? + winnerFaceitTeamId String? + team1Score Int? + team2Score Int? + scoreSummary String? + played Boolean? + rawRoundStats Json? + rawDetailedResult Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + match FaceitMatch @relation(fields: [matchId], references: [faceitMatchId], onDelete: Cascade) + heroBans FaceitHeroBan[] + playerStats FaceitMapPlayerStats[] + teamStats FaceitMapTeamStats[] + + @@unique([matchId, gameNumber]) + @@index([mapGuid]) + @@index([mapName]) + @@index([mapType]) +} + +model FaceitHeroBan { + id Int @id @default(autoincrement()) + faceitMapId Int + heroGuid String? + heroName String + role String? + banOrder Int + bannedByFaction Int? + source String + rawEntity Json? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + map FaceitMatchMap @relation(fields: [faceitMapId], references: [id], onDelete: Cascade) + + @@index([faceitMapId]) + @@index([heroName]) +} + +model FaceitMapTeamStats { + id Int @id @default(autoincrement()) + faceitMapId Int + teamSide Int + faceitTeamId String? + teamName String + score Int? + won Boolean? + eliminations Int? + deaths Int? + finalBlows Int? + objectiveTime Float? + timePlayed Float? + rawStats Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + map FaceitMatchMap @relation(fields: [faceitMapId], references: [id], onDelete: Cascade) + team FaceitTeam? @relation(fields: [faceitTeamId], references: [faceitTeamId], onDelete: SetNull) + + @@unique([faceitMapId, teamSide]) + @@index([faceitTeamId]) + @@index([teamName]) +} + +model FaceitMapPlayerStats { + id Int @id @default(autoincrement()) + faceitMapId Int + teamSide Int + faceitPlayerId String + nickname String + role String? + result Int? + eliminations Int? + assists Int? + deaths Int? + finalBlows Int? + soloKills Int? + multiKills Int? + environmentalKills Int? + damageDealt Float? + healingDone Float? + damageMitigated Float? + objectiveTime Float? + timePlayed Float? + rawStats Json + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + map FaceitMatchMap @relation(fields: [faceitMapId], references: [id], onDelete: Cascade) + player FaceitPlayer @relation(fields: [faceitPlayerId], references: [faceitPlayerId], onDelete: Cascade) + + @@unique([faceitMapId, faceitPlayerId]) + @@index([faceitPlayerId]) + @@index([nickname]) + @@index([role]) +} + +model TsrRosterOverride { + id Int @id @default(autoincrement()) + matchId String + faceitPlayerId String + action TsrRosterOverrideAction + // Only meaningful for INCLUDE (which team the sub played for, 1 or 2). + // Null for EXCLUDE. + teamSide Int? + createdBy String? + createdAt DateTime @default(now()) + + match FaceitMatch @relation(fields: [matchId], references: [faceitMatchId], onDelete: Cascade) + player FaceitPlayer @relation(fields: [faceitPlayerId], references: [faceitPlayerId], onDelete: Cascade) + + @@unique([matchId, faceitPlayerId]) +} + +model BattletagAlias { + battletag String @id + faceitPlayerId String + + player FaceitPlayer @relation(fields: [faceitPlayerId], references: [faceitPlayerId], onDelete: Cascade) + + @@index([faceitPlayerId]) +} + +model PlayerTsr { + faceitPlayerId String @id + region TsrRegion + rating Int + matchCount Int + recentMatchCount365d Int + maxTierReached FaceitTier + computedAt DateTime + + player FaceitPlayer @relation(fields: [faceitPlayerId], references: [faceitPlayerId], onDelete: Cascade) + + @@index([region, rating(sort: Desc)]) + @@index([recentMatchCount365d]) +} + +model PlayerFsr { + faceitPlayerId String + role FaceitRole + fsr Int + compositeZ Float + effectiveAnchor Int + mapCount Int + recentMapCount365d Int + tiersPlayed FaceitTier[] + computedAt DateTime + + player FaceitPlayer @relation(fields: [faceitPlayerId], references: [faceitPlayerId], onDelete: Cascade) + + @@id([faceitPlayerId, role]) + @@index([role, fsr]) +} + +model PlayerFsrTier { + faceitPlayerId String + role FaceitRole + tier FaceitTier + fsr Int + compositeZ Float + mapCount Int + minutesPlayed Float + peerCount Int + statZ Json + computedAt DateTime + + player FaceitPlayer @relation(fields: [faceitPlayerId], references: [faceitPlayerId], onDelete: Cascade) + + @@id([faceitPlayerId, role, tier]) + @@index([tier, role, fsr]) +} + +model FsrBaseline { + tier FaceitTier + role FaceitRole + sampleN Int + stats Json + computedAt DateTime + + @@id([tier, role]) +} + +enum TempoMetric { + FIGHT_DURATION + ULT_CHARGE_TIME + ULT_HOLD_TIME +} + +model TempoBaseline { + metric TempoMetric @id + mean Float + stdDev Float + sampleN Int + computedAt DateTime @default(now()) +} + +model TeamTsrSnapshot { + teamId Int @id + rating Int + source TeamTsrSource + confidence TeamTsrConfidence + bracketTier FaceitTier + bracketBand String? + region TsrRegion + rosterSize Int + ratedCount Int + playtimeBackedShare Float + computedAt DateTime @default(now()) + + team Team @relation(fields: [teamId], references: [id], onDelete: Cascade) + + @@index([region, rating]) + @@index([bracketTier, bracketBand]) +} + +model ScrimRequest { + id String @id @default(cuid()) + fromTeamId Int + toTeamId Int + sentByUserId String + fromTsr Int + toTsr Int + message String + createdAt DateTime @default(now()) + + fromTeam Team @relation("ScrimRequestFrom", fields: [fromTeamId], references: [id], onDelete: Cascade) + toTeam Team @relation("ScrimRequestTo", fields: [toTeamId], references: [id], onDelete: Cascade) + sentBy User @relation(fields: [sentByUserId], references: [id], onDelete: Cascade) + linkedScrims Scrim[] + + @@index([fromTeamId, createdAt]) + @@index([toTeamId, createdAt]) + @@index([fromTeamId, toTeamId, createdAt]) +} + +model TeamBlacklist { + id String @id @default(cuid()) + ownerTeamId Int + blockedTeamId Int? + blockedTeamName String + blockedKey String + reason String? + source TeamBlacklistSource @default(MANUAL) + createdBy String + createdAt DateTime @default(now()) + + ownerTeam Team @relation("BlacklistOwner", fields: [ownerTeamId], references: [id], onDelete: Cascade) + blockedTeam Team? @relation("BlacklistBlocked", fields: [blockedTeamId], references: [id], onDelete: SetNull) + creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade) + + @@unique([ownerTeamId, blockedKey]) + @@index([blockedTeamId]) + @@index([createdBy]) +} + +model ScrimFeedback { + id String @id @default(cuid()) + scrimId Int @unique + verdict ScrimFeedbackVerdict + createdBy String + createdAt DateTime @default(now()) + + scrim Scrim @relation(fields: [scrimId], references: [id], onDelete: Cascade) + creator User @relation(fields: [createdBy], references: [id], onDelete: Cascade) + + @@index([createdBy]) +} + +enum OverwatchPatchType { + SEASON + MID_SEASON + HOTFIX +} + +enum OverwatchPatchSource { + MANUAL + SCRAPED +} + +model OverwatchPatch { + id String @id @default(cuid()) + date DateTime @db.Date + type OverwatchPatchType + name String + rawTitle String? + sourceUrl String? + bodyExcerpt String? + source OverwatchPatchSource @default(SCRAPED) + needsReview Boolean @default(false) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@unique([date]) +} + +// --------------------------------------------------------------------------- +// Usage-analytics models — raw events + per-day rollups +// --------------------------------------------------------------------------- + +enum UsageSource { + SERVER + CLIENT +} + +enum UsageEnv { + PRODUCTION + PREVIEW + DEVELOPMENT +} + +model UsageEvent { + id BigInt @id @default(autoincrement()) + ts DateTime @default(now()) + name String + source UsageSource + environment UsageEnv + userId String? + teamId Int? + path String? + sessionId String? + props Json? + + @@index([ts]) + @@index([environment, name, ts]) + @@index([userId, ts]) + @@index([environment, path, ts]) +} + +model DailyFeatureRollup { + environment UsageEnv + day String + name String + uniqueUsers Int + uniqueTeams Int + totalEvents Int + + @@id([environment, day, name]) +} + +model DailyPageRollup { + environment UsageEnv + day String + path String + views Int + uniqueUsers Int + + @@id([environment, day, path]) +} + +model UserActiveDay { + environment UsageEnv + day String + userId String + + @@id([environment, day, userId]) +} + +model RankedMatch { + id String @id @default(cuid()) + userId String + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + map String + mapType String + result String // win | loss | draw + groupSize Int + playedAt DateTime + sourceId String? // original winrate-tracker Match.id, for idempotent import + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + heroes RankedMatchHero[] + + @@unique([userId, sourceId]) +} + +model RankedMatchHero { + id String @id @default(cuid()) + matchId String + match RankedMatch @relation(fields: [matchId], references: [id], onDelete: Cascade) + hero String + role String + percentage Int + + @@index([matchId]) +} + +model RankedImportClaim { + id String @id @default(cuid()) + email String + oauthKey String? // "provider:providerAccountId" fallback identity + payload Json // RankedExportBundle + claimedAt DateTime? + createdAt DateTime @default(now()) + + @@index([email]) +} diff --git a/public/falling.png b/public/falling.png new file mode 100644 index 000000000..99ad0c975 Binary files /dev/null and b/public/falling.png differ diff --git a/public/fonts/Switzer-Variable.woff2 b/public/fonts/Switzer-Variable.woff2 new file mode 100644 index 000000000..91f868af4 Binary files /dev/null and b/public/fonts/Switzer-Variable.woff2 differ diff --git a/public/fonts/Switzer-VariableItalic.woff2 b/public/fonts/Switzer-VariableItalic.woff2 new file mode 100644 index 000000000..8cc3f746c Binary files /dev/null and b/public/fonts/Switzer-VariableItalic.woff2 differ diff --git a/public/heroes/shion.png b/public/heroes/shion.png new file mode 100644 index 000000000..2b2f07a39 Binary files /dev/null and b/public/heroes/shion.png differ diff --git a/public/map-heatmap-light.png b/public/map-heatmap-light.png new file mode 100644 index 000000000..dec74edd5 Binary files /dev/null and b/public/map-heatmap-light.png differ diff --git a/public/map-heatmap.png b/public/map-heatmap.png new file mode 100644 index 000000000..69fcc6d49 Binary files /dev/null and b/public/map-heatmap.png differ diff --git a/public/map-replay-light.png b/public/map-replay-light.png new file mode 100644 index 000000000..5bb8f85c4 Binary files /dev/null and b/public/map-replay-light.png differ diff --git a/public/map-replay.png b/public/map-replay.png new file mode 100644 index 000000000..86038e828 Binary files /dev/null and b/public/map-replay.png differ diff --git a/scripts/backfill-push-winners.ts b/scripts/backfill-push-winners.ts new file mode 100644 index 000000000..2f07adeaf --- /dev/null +++ b/scripts/backfill-push-winners.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env bun + +import prisma from "@/lib/prisma"; +import { loadPositionalEventBundles } from "@/lib/positional-events"; +import { computePushWinner } from "@/lib/push-winner"; +import { pushInputFromBundle } from "@/lib/push-winner-adapters"; +import { mapNameToMapTypeMapping } from "@/types/map"; +import { $Enums } from "@/generated/prisma/client"; + +const commit = process.argv.includes("--commit"); + +async function main() { + // Push maps that don't already have a stored winner. + const maps = await prisma.map.findMany({ + where: { winner: null }, + select: { id: true, name: true, mapData: { select: { id: true } } }, + }); + + const pushMaps = maps.filter( + (m) => + mapNameToMapTypeMapping[ + m.name as keyof typeof mapNameToMapTypeMapping + ] === $Enums.MapType.Push + ); + console.log(`Found ${pushMaps.length} Push maps without a stored winner.`); + + let decided = 0; + let noData = 0; + + for (const map of pushMaps) { + const mapDataIds = map.mapData.map((md) => md.id); + if (mapDataIds.length === 0) { + noData++; + console.log(`map ${map.id} (${map.name}): no mapData — skip`); + continue; + } + + const bundles = await loadPositionalEventBundles(mapDataIds); + const bundle = bundles.get(mapDataIds[0]); + const input = bundle ? pushInputFromBundle(bundle) : null; + const result = input ? computePushWinner(input) : null; + + if (!result) { + noData++; + console.log( + `map ${map.id} (${map.name}): no coordinate data — leave N/A` + ); + continue; + } + + decided++; + console.log( + `map ${map.id} (${map.name}): winner=${result.winner} ` + + `margin=${result.margin.toFixed(1)} confidence=${result.confidence.toFixed(2)}` + ); + + if (commit) { + await prisma.map.update({ + where: { id: map.id }, + data: { winner: result.winner, winnerSource: "auto_coords" }, + }); + } + } + + console.log( + `\n${decided} decided, ${noData} left as N/A. ` + + (commit + ? "Changes committed." + : "Dry run — re-run with --commit to write.") + ); +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/backfill-team-tsr-snapshots.ts b/scripts/backfill-team-tsr-snapshots.ts new file mode 100644 index 000000000..cec76dac8 --- /dev/null +++ b/scripts/backfill-team-tsr-snapshots.ts @@ -0,0 +1,12 @@ +import { recomputeAllTeamTsrSnapshots } from "@/lib/matchmaker/snapshot"; + +async function main() { + console.log("Backfilling TeamTsrSnapshot for all teams…"); + const result = await recomputeAllTeamTsrSnapshots(); + console.log(`Done: ${result.written} written, ${result.cleared} cleared.`); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/calculate-all-stats.ts b/scripts/calculate-all-stats.ts index d0520ebe2..dbb3f8ac5 100755 --- a/scripts/calculate-all-stats.ts +++ b/scripts/calculate-all-stats.ts @@ -2,7 +2,7 @@ import { calculateStats } from "@/lib/calculate-stats"; import prisma from "@/lib/prisma"; -import { type CalculatedStatType, type Role } from "@prisma/client"; +import { type CalculatedStatType, type Role } from "@/generated/prisma/client"; async function getUniquePlayersForMap(mapDataId: number): Promise { const players = await prisma.playerStat.findMany({ diff --git a/scripts/calculate-spatial-stats.ts b/scripts/calculate-spatial-stats.ts new file mode 100644 index 000000000..c91b12610 --- /dev/null +++ b/scripts/calculate-spatial-stats.ts @@ -0,0 +1,128 @@ +#!/usr/bin/env bun + +import prisma from "@/lib/prisma"; +import { + getSpatialStatsForMapData, + SPATIAL_STAT_TYPES, +} from "@/lib/spatial-stats"; +import { heroRoleMapping, type HeroName } from "@/types/heroes"; +import type { CalculatedStatType, Role } from "@/generated/prisma/client"; + +async function processMap(mapDataId: number, scrimId: number) { + const players = await prisma.playerStat.findMany({ + where: { MapDataId: mapDataId }, + select: { player_name: true }, + distinct: ["player_name"], + }); + + const records: Array<{ + scrimId: number; + playerName: string; + hero: string; + role: Role; + stat: CalculatedStatType; + value: number; + MapDataId: number; + }> = []; + + for (const { player_name } of players) { + const stats = await getSpatialStatsForMapData(mapDataId, player_name); + + const heroRow = await prisma.playerStat.findFirst({ + where: { MapDataId: mapDataId, player_name }, + orderBy: [{ match_time: "desc" }, { hero_time_played: "desc" }], + select: { player_hero: true }, + }); + if (!heroRow) continue; + + const hero = heroRow.player_hero; + const roleName = heroRoleMapping[hero as HeroName]; + if (!roleName) { + console.warn(` Unknown hero "${hero}" for ${player_name}, skipping`); + continue; + } + const role = roleName.toUpperCase() as Role; + + const entries: Array<[CalculatedStatType, number | null]> = [ + ["AVERAGE_ENGAGEMENT_DISTANCE", stats.averageEngagementDistance], + ["HIGH_GROUND_KILL_PERCENTAGE", stats.highGroundKillPercentage], + ["ISOLATION_DEATH_PERCENTAGE", stats.isolationDeathPercentage], + ["AVERAGE_FIGHT_START_SPREAD", stats.averageFightStartSpread], + ]; + + for (const [stat, value] of entries) { + if (value !== null && !Number.isNaN(value)) { + records.push({ + scrimId, + playerName: player_name, + hero, + role, + stat, + value, + MapDataId: mapDataId, + }); + } + } + } + + if (records.length > 0) { + await prisma.calculatedStat.createMany({ + data: records, + skipDuplicates: true, + }); + } + + return records.length; +} + +async function main() { + // Only maps that actually have coordinate data are worth processing. + const coordMaps = await prisma.kill.findMany({ + where: { attacker_x: { not: null }, MapDataId: { not: null } }, + select: { MapDataId: true, scrimId: true }, + distinct: ["MapDataId"], + }); + + console.log(`Found ${coordMaps.length} maps with positional data`); + + let processed = 0; + let skipped = 0; + let failed = 0; + + for (const { MapDataId, scrimId } of coordMaps) { + const mapDataId = MapDataId!; + + // CalculatedStat has NO unique constraint, so skipDuplicates does not + // dedupe — this existence check is the idempotency guard. + const existing = await prisma.calculatedStat.count({ + where: { + MapDataId: mapDataId, + stat: { in: [...SPATIAL_STAT_TYPES] as CalculatedStatType[] }, + }, + }); + if (existing > 0) { + skipped++; + continue; + } + + try { + const count = await processMap(mapDataId, scrimId); + processed++; + console.log(`Processed map ${mapDataId}: ${count} stat rows`); + } catch (error) { + failed++; + console.error(`Failed map ${mapDataId}:`, error); + } + } + + console.log( + `\nDone. ${processed} processed, ${skipped} skipped (already done), ${failed} failed.` + ); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/calculate-ult-stats.ts b/scripts/calculate-ult-stats.ts new file mode 100644 index 000000000..245de419f --- /dev/null +++ b/scripts/calculate-ult-stats.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env bun + +import prisma from "@/lib/prisma"; +import { getUltQualityStatsForMapData } from "@/lib/ult-quality-db"; +import { ULT_STAT_TYPES } from "@/lib/ult-quality"; +import { heroRoleMapping, type HeroName } from "@/types/heroes"; +import type { CalculatedStatType, Role } from "@/generated/prisma/client"; + +async function processMap(mapDataId: number, scrimId: number) { + const players = await prisma.playerStat.findMany({ + where: { MapDataId: mapDataId }, + select: { player_name: true }, + distinct: ["player_name"], + }); + + const records: Array<{ + scrimId: number; + playerName: string; + hero: string; + role: Role; + stat: CalculatedStatType; + value: number; + MapDataId: number; + }> = []; + + for (const { player_name } of players) { + const stats = await getUltQualityStatsForMapData(mapDataId, player_name); + + const heroRow = await prisma.playerStat.findFirst({ + where: { MapDataId: mapDataId, player_name }, + orderBy: [{ match_time: "desc" }, { hero_time_played: "desc" }], + select: { player_hero: true }, + }); + if (!heroRow) continue; + + const hero = heroRow.player_hero; + const roleName = heroRoleMapping[hero as HeroName]; + if (!roleName) { + console.warn(` Unknown hero "${hero}" for ${player_name}, skipping`); + continue; + } + const role = roleName.toUpperCase() as Role; + + const entries: Array<[CalculatedStatType, number | null]> = [ + ["AVERAGE_ULT_CONVERSION_KILLS", stats.averageUltConversionKills], + ["ULT_DEATH_PERCENTAGE", stats.ultDeathPercentage], + ["AVERAGE_ULT_DISPLACEMENT", stats.averageUltDisplacement], + ["ULTS_ON_OBJECTIVE_PERCENTAGE", stats.ultsOnObjectivePercentage], + ]; + + for (const [stat, value] of entries) { + if (value !== null && !Number.isNaN(value)) { + records.push({ + scrimId, + playerName: player_name, + hero, + role, + stat, + value, + MapDataId: mapDataId, + }); + } + } + } + + if (records.length > 0) { + await prisma.calculatedStat.createMany({ + data: records, + skipDuplicates: true, + }); + } + + return records.length; +} + +async function main() { + // Only maps that actually have ultimateStart rows are worth processing. + const ultMaps = await prisma.ultimateStart.findMany({ + where: { MapDataId: { not: null } }, + select: { MapDataId: true, scrimId: true }, + distinct: ["MapDataId"], + }); + + console.log(`Found ${ultMaps.length} maps with ultimate data`); + + let processed = 0; + let skipped = 0; + let failed = 0; + + for (const { MapDataId, scrimId } of ultMaps) { + const mapDataId = MapDataId!; + + // CalculatedStat has NO unique constraint, so skipDuplicates does not + // dedupe — this existence check is the idempotency guard. + const existing = await prisma.calculatedStat.count({ + where: { + MapDataId: mapDataId, + stat: { in: [...ULT_STAT_TYPES] as CalculatedStatType[] }, + }, + }); + if (existing > 0) { + skipped++; + continue; + } + + try { + const count = await processMap(mapDataId, scrimId); + processed++; + console.log(`Processed map ${mapDataId}: ${count} stat rows`); + } catch (error) { + failed++; + console.error(`Failed map ${mapDataId}:`, error); + } + } + + console.log( + `\nDone. ${processed} processed, ${skipped} skipped (already done), ${failed} failed.` + ); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/faceit-link-dry-run.ts b/scripts/faceit-link-dry-run.ts new file mode 100644 index 000000000..f36d01802 --- /dev/null +++ b/scripts/faceit-link-dry-run.ts @@ -0,0 +1,56 @@ +#!/usr/bin/env bun +/** + * Read-only sanity check for the OWCS->FACEIT roster link service. Runs the + * link for a few known-overlapping teams. Writes nothing. + * + * Usage: bun scripts/faceit-link-dry-run.ts ["Team Full Name"] + */ +import { + ScoutingFaceitLinkService, + ScoutingFaceitLinkServiceLive, +} from "../src/data/scouting/faceit-link-service"; +import { Effect, ManagedRuntime } from "effect"; + +const ScriptRuntime = ManagedRuntime.make(ScoutingFaceitLinkServiceLive); + +const TEAMS = process.argv[2] + ? [process.argv[2]] + : [ + "Dallas Fuel", + "Twisted Minds", + "Amplify", + "Team Liquid", + "Nonexistent FC", + ]; + +async function main() { + for (const team of TEAMS) { + const link = await ScriptRuntime.runPromise( + ScoutingFaceitLinkService.pipe( + Effect.flatMap((s) => s.getFaceitTeamLink(team)) + ) + ); + if (!link) { + console.log(`\n${team}: no corroborated FACEIT link`); + continue; + } + console.log( + `\n${team} -> FACEIT "${link.faceitTeamName}" (${link.faceitTeamId})` + ); + console.log( + ` roster ${link.rosterSize}, aggregate FSR ${link.aggregateFsr ?? "—"} (${link.fsrCovered} rated)` + ); + for (const p of link.sharedPlayers) { + console.log( + ` ${p.owcsName} = ${p.faceitNickname} FSR ${p.fsr ?? "—"}` + ); + } + } +} + +main() + .then(() => process.exit(0)) + .catch((e) => { + console.error(e); + process.exit(1); + }); diff --git a/scripts/faceit-player-dry-run.ts b/scripts/faceit-player-dry-run.ts new file mode 100644 index 000000000..87c5c77ed --- /dev/null +++ b/scripts/faceit-player-dry-run.ts @@ -0,0 +1,75 @@ +#!/usr/bin/env bun +/** Read-only FACEIT player scouting sanity check. Prints a rated player's full + * profile and an unrated player's degraded profile. Writes nothing. + * Usage: bun scripts/faceit-player-dry-run.ts [faceitPlayerId] */ +import { ManagedRuntime, Effect } from "effect"; +import { + FaceitPlayerScoutingService, + FaceitPlayerScoutingServiceLive, +} from "../src/data/faceit/player-service"; + +async function main() { + const rt = ManagedRuntime.make(FaceitPlayerScoutingServiceLive); + const players = await rt.runPromise( + FaceitPlayerScoutingService.pipe( + Effect.flatMap((s) => s.getFaceitPlayers()) + ) + ); + console.log(`Total players: ${players.length}`); + const ratedId = + process.argv[2] ?? players.find((p) => p.topFsr != null)?.faceitPlayerId; + const unratedId = players.find((p) => p.topFsr == null)?.faceitPlayerId; + + for (const [label, id] of [ + ["RATED", ratedId], + ["UNRATED", unratedId], + ] as const) { + if (!id) continue; + const p = await rt.runPromise( + FaceitPlayerScoutingService.pipe( + Effect.flatMap((s) => s.getFaceitPlayerProfile(id)) + ) + ); + console.log( + `\n=== ${label}: ${p?.player.nickname} (${id}) rated=${p?.rated} ===` + ); + if (!p) { + console.log("null profile"); + continue; + } + console.log( + "FSR roles:", + JSON.stringify( + p.fsrRoles.map((r) => ({ + role: r.role, + fsr: r.fsr, + primary: r.primary, + headlineTier: r.headlineTier, + tiers: r.tiers.map((t) => ({ + tier: t.tier, + fsr: t.fsr, + pct: Math.round(t.percentile), + })), + strengths: r.strengths.map((s) => s.stat), + weaknesses: r.weaknesses.map((s) => s.stat), + })) + ) + ); + console.log("Role usage:", JSON.stringify(p.roleUsage)); + console.log("Top maps:", JSON.stringify(p.mapWinrates.byMap.slice(0, 5))); + console.log( + "Match history (first 3):", + JSON.stringify(p.matchHistory.slice(0, 3)) + ); + console.log( + "History length:", + p.matchHistory.length, + "Teams:", + JSON.stringify(p.teams.slice(0, 5)) + ); + } + await rt.dispose(); +} +main() + .catch((e) => console.error("ERR", e instanceof Error ? e.message : e)) + .finally(() => process.exit(0)); diff --git a/scripts/faceit-team-dry-run.ts b/scripts/faceit-team-dry-run.ts new file mode 100644 index 000000000..83f2e3e19 --- /dev/null +++ b/scripts/faceit-team-dry-run.ts @@ -0,0 +1,73 @@ +#!/usr/bin/env bun +/** + * Read-only FACEIT team scouting sanity check. Picks the team with the most + * matches (or a faceitTeamId passed as argv[2]), runs the profile, and prints + * it. Writes nothing. + * + * Usage: bun scripts/faceit-team-dry-run.ts [faceitTeamId] + */ +import { + FaceitTeamScoutingService, + FaceitTeamScoutingServiceLive, +} from "../src/data/faceit/team-service"; +import { Effect, ManagedRuntime } from "effect"; + +// Build a script-local runtime from just the FACEIT layer. +// AppRuntime (src/data/runtime) has `import "server-only"` which throws in bun, +// so we construct a minimal runtime here instead. +const ScriptRuntime = ManagedRuntime.make(FaceitTeamScoutingServiceLive); + +async function main() { + const teams = await ScriptRuntime.runPromise( + FaceitTeamScoutingService.pipe(Effect.flatMap((s) => s.getFaceitTeams())) + ); + console.log(`Total teams: ${teams.length}`); + const teamId = process.argv[2] ?? teams[0]?.faceitTeamId; + if (!teamId) throw new Error("no teams found"); + + const profile = await ScriptRuntime.runPromise( + FaceitTeamScoutingService.pipe( + Effect.flatMap((s) => s.getFaceitTeamProfile(teamId)) + ) + ); + if (!profile) { + console.log(`No profile for ${teamId}`); + return; + } + console.log(`\n=== ${profile.team.name} (${teamId}) ===`); + console.log("Overview:", JSON.stringify(profile.overview)); + console.log("Strength:", JSON.stringify(profile.strength)); + console.log( + "Top maps:", + JSON.stringify(profile.mapAnalysis.byMap.slice(0, 6)) + ); + console.log( + "Attack/Defense:", + JSON.stringify(profile.mapAnalysis.attackDefense) + ); + console.log( + "Hero-ban env (top 6 by |delta|):", + JSON.stringify(profile.heroBanEnvironment.slice(0, 6)) + ); + console.log( + "Roster:", + JSON.stringify( + profile.roster.map((p) => ({ + n: p.nickname, + role: p.role, + fsr: p.fsr, + tsr: p.tsr, + share: Number(p.appearanceShare.toFixed(2)), + })) + ) + ); + console.log("Related teams:", JSON.stringify(profile.relatedTeams)); + console.log( + "Recommendations:", + JSON.stringify(profile.recommendations, null, 2) + ); +} + +main() + .catch((e) => console.error("ERR", e instanceof Error ? e.message : e)) + .finally(() => process.exit(0)); diff --git a/scripts/fsr-bootstrap.ts b/scripts/fsr-bootstrap.ts new file mode 100644 index 000000000..cc911e58f --- /dev/null +++ b/scripts/fsr-bootstrap.ts @@ -0,0 +1,24 @@ +#!/usr/bin/env bun +/** + * One-shot FSR bootstrap: run a full recompute and report counts. Safe to run + * any time; the daily cron also calls recomputeAllFsr() after the TSR steps. + * + * Usage: bun scripts/fsr-bootstrap.ts + */ +import { recomputeAllFsr } from "../src/lib/fsr/compute"; +import prisma from "../src/lib/prisma"; + +async function main() { + const result = await recomputeAllFsr(); + console.log("FSR recompute complete:", JSON.stringify(result, null, 2)); +} + +main() + .catch((e) => { + console.error("ERR", e instanceof Error ? e.message : e); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + process.exit(process.exitCode ?? 0); + }); diff --git a/scripts/fsr-dry-run.ts b/scripts/fsr-dry-run.ts new file mode 100644 index 000000000..791dc86d9 --- /dev/null +++ b/scripts/fsr-dry-run.ts @@ -0,0 +1,93 @@ +#!/usr/bin/env bun +/** + * Read-only FSR dry run. Loads the aggregate groups from the live DB, computes + * baselines + per-tier cells + headlines in memory, and prints leaderboards. + * Writes nothing. Use this to validate and tune the stat weights / constants + * before the first real recompute. + * + * Usage: bun scripts/fsr-dry-run.ts [headlineLimit] + */ +import { loadFsrGroups } from "../src/lib/fsr/aggregate"; +import { + baselineKey, + computeBaselines, + groupPer10, +} from "../src/lib/fsr/baselines"; +import { ALL_FSR_STAT_COLUMNS, getFsrStatConfigs } from "../src/lib/fsr/config"; +import { + FSR_MIN_MAPS_PER_CELL, + FSR_SHRINKAGE_K, +} from "../src/lib/fsr/constants"; +import { blendHeadline, compositeZScore, zScore } from "../src/lib/fsr/formula"; +import type { AnchoredTier, FsrStatColumn } from "../src/lib/fsr/types"; + +type HeadlineCell = { + tier: AnchoredTier; + compositeZ: number; + sumRecency: number; +}; + +async function main() { + const limit = Number(process.argv[2] ?? 25); + const now = new Date(); + const recentCutoff = new Date(now.getTime() - 365 * 86400 * 1000); + + const groups = await loadFsrGroups(now, recentCutoff); + console.log(`Loaded ${groups.length} (player x role x tier) groups`); + + const baselines = computeBaselines(groups, FSR_MIN_MAPS_PER_CELL); + console.log("Baseline cells (tier x role -> sampleN):"); + for (const [key, cell] of baselines) console.log(` ${key}: ${cell.sampleN}`); + + const headlineByKey = new Map< + string, + { cells: HeadlineCell[]; maps: number } + >(); + for (const g of groups) { + if (g.mapCount < FSR_MIN_MAPS_PER_CELL) continue; + const cell = baselines.get(baselineKey(g.tier, g.role)); + if (!cell) continue; + const per10 = groupPer10(g); + const configs = getFsrStatConfigs(g.role); + const zByStat: Partial> = {}; + for (const col of ALL_FSR_STAT_COLUMNS) { + const base = cell.baseline[col]; + const cfg = configs.find((c) => c.column === col); + zByStat[col] = base + ? zScore(per10[col], base.mean, base.stddev, cfg?.invert ?? false) + : 0; + } + const composite = compositeZScore( + zByStat, + configs, + g.mapCount, + FSR_SHRINKAGE_K + ); + const key = `${g.faceitPlayerId}:${g.role}`; + const acc = headlineByKey.get(key) ?? { cells: [], maps: 0 }; + acc.cells.push({ + tier: g.tier, + compositeZ: composite, + sumRecency: g.sumRecency, + }); + acc.maps += g.mapCount; + headlineByKey.set(key, acc); + } + + const headlines = [...headlineByKey.entries()].map(([key, acc]) => { + const [pid, role] = key.split(":"); + return { pid, role, maps: acc.maps, ...blendHeadline(acc.cells) }; + }); + headlines.sort((a, b) => b.fsr - a.fsr); + + console.log(`\nTop ${limit} headline FSR:`); + for (const h of headlines.slice(0, limit)) { + console.log( + ` ${h.fsr} ${h.role.padEnd(7)} maps=${String(h.maps).padStart(4)} anchor=${h.anchor} z=${h.zBlend.toFixed(2)} ${h.pid}` + ); + } +} + +main() + .catch((e) => console.error("ERR", e instanceof Error ? e.message : e)) + .finally(() => process.exit(0)); diff --git a/scripts/generate-halftone.ts b/scripts/generate-halftone.ts new file mode 100644 index 000000000..be818e7e4 --- /dev/null +++ b/scripts/generate-halftone.ts @@ -0,0 +1,120 @@ +#!/usr/bin/env bun +import { writeFileSync } from "node:fs"; +import path from "node:path"; +import sharp from "sharp"; +import { luminance, renderHalftoneSvg, sampleDots } from "../src/lib/halftone"; + +// Tuning dials — adjust against the figure, then re-run. +const COLS = 58; // dot columns across the cropped figure +const CELL = 10; // SVG units between dot centers +const MAX_RADIUS = 0.95; // fraction of half-cell +const THRESHOLD = 0.7; // luminance >= this is background when sampling dots +const BBOX_THRESHOLD = 0.45; // stricter: only near-black figure pixels set the crop +const PAD = 0.06; // padding around the figure bbox, as a fraction of bbox size +const MARGIN = 0.02; // edge band (fraction of each dimension) excluded from the figure search +const GATE_FLOOR = 10; // min dark px in a row/col before it counts; guards near-blank images + +// Run from the repo root: `bun scripts/generate-halftone.ts`. +const root = process.cwd(); +const SRC = path.join(root, "public/falling.png"); +const OUT = path.join(root, "src/app/falling-halftone.ts"); + +// Composite any alpha over white so the background is uniform before sampling. +const base = sharp(SRC).flatten({ background: "#ffffff" }); +const { data, info } = await base + .clone() + .raw() + .toBuffer({ resolveWithObject: true }); +const { width, height, channels } = info; + +// Pass 1: find the figure's bounding box via row/column projection. The source +// has a lot of near-white margin AND sparse edge noise (e.g. a 1px-wide dark +// column at x=0 spanning the full height). A raw min/max bbox gets dragged to +// the whole frame by that noise, so instead we count near-black pixels per row +// and per column and keep only the dense band where the actual figure lives. +const colDark = new Array(width).fill(0); +const rowDark = new Array(height).fill(0); +for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + const i = (y * width + x) * channels; + if (luminance(data[i], data[i + 1], data[i + 2]) >= BBOX_THRESHOLD) + continue; + colDark[x]++; + rowDark[y]++; + } +} + +// Search only the interior, ignoring a thin border margin where edge artifacts +// (like the full-height 1px column at x=0) live. A row/column belongs to the +// figure only if its dark-pixel count clears a gate relative to the densest +// interior line — this rejects sparse noise. +const mx = Math.round(width * MARGIN); +const my = Math.round(height * MARGIN); +const interiorMax = (counts: number[], lo: number, hi: number) => { + let m = 0; + for (let k = lo; k <= hi; k++) if (counts[k] > m) m = counts[k]; + return m; +}; +const colGate = Math.max( + GATE_FLOOR, + interiorMax(colDark, mx, width - 1 - mx) * 0.1 +); +const rowGate = Math.max( + GATE_FLOOR, + interiorMax(rowDark, my, height - 1 - my) * 0.1 +); +const firstAbove = (counts: number[], gate: number, lo: number, hi: number) => { + for (let k = lo; k <= hi; k++) if (counts[k] >= gate) return k; + return -1; +}; +const lastAbove = (counts: number[], gate: number, lo: number, hi: number) => { + for (let k = hi; k >= lo; k--) if (counts[k] >= gate) return k; + return -1; +}; +const minX = firstAbove(colDark, colGate, mx, width - 1 - mx); +const maxX = lastAbove(colDark, colGate, mx, width - 1 - mx); +const minY = firstAbove(rowDark, rowGate, my, height - 1 - my); +const maxY = lastAbove(rowDark, rowGate, my, height - 1 - my); +// maxX/maxY cannot be -1 once minX/minY >= 0 (same range, same gate), so +// checking the mins is sufficient to detect "no figure found". +if (minX < 0 || minY < 0) { + throw new Error("No figure found — check BBOX_THRESHOLD against the source."); +} + +const padX = Math.round((maxX - minX) * PAD); +const padY = Math.round((maxY - minY) * PAD); +const left = Math.max(0, minX - padX); +const top = Math.max(0, minY - padY); +// If the figure sits within padX/padY of the margin, `left`/`top` clamp to 0 +// and the eaten padding spills to the opposite edge. Fine for a centered +// figure; revisit if a future source hugs an edge. +const cropW = Math.min(width - left, maxX - minX + 1 + padX * 2); +const cropH = Math.min(height - top, maxY - minY + 1 + padY * 2); + +// Pass 2: sample the cropped figure into halftone dots. +const cropped = await sharp(SRC) + .flatten({ background: "#ffffff" }) + .extract({ left, top, width: cropW, height: cropH }) + .raw() + .toBuffer({ resolveWithObject: true }); + +const dots = sampleDots( + cropped.data, + cropped.info.width, + cropped.info.height, + cropped.info.channels, + { cols: COLS, cell: CELL, maxRadius: MAX_RADIUS, threshold: THRESHOLD } +); + +const svg = renderHalftoneSvg(dots); + +const module = `// GENERATED by scripts/generate-halftone.ts — do not edit by hand. +// Source: public/falling.png. Re-run: bun scripts/generate-halftone.ts +export const fallingHalftoneSvg = ${JSON.stringify(svg)}; +`; + +writeFileSync(OUT, module); +console.log( + `Wrote ${OUT} — ${dots.length} dots, ${svg.length} bytes ` + + `(crop ${cropW}x${cropH} from ${width}x${height})` +); diff --git a/scripts/map-align/.gitignore b/scripts/map-align/.gitignore new file mode 100644 index 000000000..dbf583245 --- /dev/null +++ b/scripts/map-align/.gitignore @@ -0,0 +1,2 @@ +__pycache__/ +map-align-out/ diff --git a/scripts/map-align/README.md b/scripts/map-align/README.md new file mode 100644 index 000000000..b64458617 --- /dev/null +++ b/scripts/map-align/README.md @@ -0,0 +1,104 @@ +# Map render re-alignment + +Local CLI that aligns a **new** map render to the **currently-calibrated** image, +so an updated render (e.g. a new seasonal pass) can replace the old one without +losing calibration. It computes the old→new pixel transform via OpenCV feature +matching and prints it as JSON to paste into the admin **Replace render** dialog. + +This runs locally rather than as a Vercel function: OpenCV is a ~98MB native +dependency that exceeds the Vercel Python bundle size limit. Everything else +(staging, the anchor-overlay review, backup, remap, promote, revert) stays in the +app — the CLI only produces the transform. + +## Run + +```bash +cd scripts/map-align +uv run cli.py +``` + +- `old_image` — the exact image the map was calibrated on (its pixel space is + where the stored anchors live). +- `new_image` — the new render you're swapping in. + +Example: + +```bash +uv run cli.py ~/code/map-models/reference-images/aatlis.png \ + ~/code/map-models/final-renders/aatlis.png +``` + +Output (stdout): + +```json +{ + "pixelAffine": { "a": ..., "b": ..., "c": ..., "d": ..., "tx": ..., "ty": ... }, + "inliers": 51, + "residual": 0.232 +} +``` + +## Batch (many maps at once) + +`batch.py` aligns every map that has both a reference image and a new render, +so you don't run the CLI 40 times. It writes a manifest + review overlays and +prints a summary table. It does NOT touch the database or R2 — you still apply +each map in the dialog by pasting its transform. + +```bash +cd scripts/map-align +uv run batch.py [--out ./map-align-out] +``` + +Example: + +```bash +uv run batch.py ~/code/map-models/reference-images ~/code/map-models/final-renders +``` + +Outputs (under `--out`, default `./map-align-out`): + +- `transforms.json` — keyed by map name; each value is the + `{ pixelAffine, inliers, residual, lowConfidence }` object the dialog parses. + Maps that fail to align are omitted. To apply one map: open it in the dialog, + upload its render, and paste `transforms[""]`. +- `overlays/.png` — alignment-QA image: new-render edges in **green**, + warped-old edges in **magenta**, over a dim copy of the new render. Where the + alignment is good the strong structural edges coincide and read **white**; + real misalignment shows as parallel green/magenta fringes on those structures. + (Green/magenta in areas where the two render passes simply have different + content/detail is expected and not a misalignment.) +- A stdout summary table: `map | status | inliers | residual | flag`. + +**Read the flags.** `LOW CONFIDENCE` (residual > 5px or inliers < 25) and +`failed` maps are still surfaced but warrant a careful overlay check — or just +re-anchor those maps manually in the editor. Maps present only in one directory +are reported as skipped. + +## Then, in the app + +1. Open the map's calibration editor (`/map-calibration/`) and click + **Replace render**. +2. Upload the same new render. +3. Paste the JSON above. +4. Review the old anchors re-projected onto the new render (and the blink/swipe + compare). `inliers` (higher is better) and `residual` (lower is better, in + original pixels) are shown to inform — they never gate the decision. +5. **Confirm** to back up the prior state, remap the anchors, re-derive the + affine, and promote the new image. **Revert last swap** undoes it. + +## How it works + +Both images are decoded **memory-bounded** to a common 2048px working size +(OpenCV reduced-resolution decode), aligned with ORB feature matching on Canny +edge maps + RANSAC (robust to the heavy color/background differences between +render passes), and the resulting affine is rescaled back to the original pixel +space. This handles high-resolution renders (8K/4K) that would otherwise blow +memory and break feature matching across a scale gap. + +## Tests + +```bash +cd scripts/map-align +uv run --no-project --with opencv-python-headless --with numpy --with pytest python -m pytest -v +``` diff --git a/scripts/map-align/align.py b/scripts/map-align/align.py new file mode 100644 index 000000000..badc0b786 --- /dev/null +++ b/scripts/map-align/align.py @@ -0,0 +1,211 @@ +"""Pure image-registration helpers for the map-align local CLI. + +Computes a 2x3 affine P mapping OLD-image pixels -> NEW-image pixels via ORB +feature matching + RANSAC on Canny edge maps. + +Feature matching (not phase correlation / ECC) is required because successive +render passes differ heavily in color, lighting and background masking; building +edges/corners are the stable signal. Matching on Canny edge maps rather than +raw grayscale makes the descriptors photometrically invariant — edge positions +are identical regardless of color inversion or brightness shifts. +""" +import cv2 +import numpy as np + +ORB_N_FEATURES = 5000 # generous upper bound for feature-rich renders +ALIGN_TARGET_PX = 2048 # working resolution cap; keeps memory bounded across 4K/8K inputs +LOWE_RATIO = 0.75 # Lowe (2004) ratio test threshold +CANNY_LOW = 50 # Canny hysteresis thresholds +CANNY_HIGH = 150 +RANSAC_REPROJ_PX = 3.0 # px; tight because renders are pixel-exact +MIN_GOOD_MATCHES = 10 +MIN_INLIERS = 8 + + +class AlignError(Exception): + """Raised when the two images cannot be reliably aligned.""" + + +def _to_edges(bgr: np.ndarray) -> np.ndarray: + """Return a photometric-invariant Canny edge map for descriptor matching. + + Edges sit at the same positions regardless of color/brightness, so ORB + descriptors survive the render pass's palette/background change. + """ + gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) + return cv2.Canny(gray, CANNY_LOW, CANNY_HIGH) + + +def align_images(old_bgr: np.ndarray, new_bgr: np.ndarray): + """Return (P, inliers, residual). + + P - 2x3 list-of-lists affine, old pixels -> new pixels. + inliers - number of RANSAC inlier matches. + residual - mean reprojection error (px) over inliers. + + Raises AlignError if the images cannot be reliably aligned. + """ + old_edges = _to_edges(old_bgr) + new_edges = _to_edges(new_bgr) + + orb = cv2.ORB_create(nfeatures=ORB_N_FEATURES) + k1, d1 = orb.detectAndCompute(old_edges, None) + k2, d2 = orb.detectAndCompute(new_edges, None) + if d1 is None or d2 is None or len(k1) < 4 or len(k2) < 4: + raise AlignError("not enough features detected") + + matcher = cv2.BFMatcher(cv2.NORM_HAMMING) + knn = matcher.knnMatch(d1, d2, k=2) + good = [] + for pair in knn: + if len(pair) == 2: + m, n = pair + if m.distance < LOWE_RATIO * n.distance: + good.append(m) + if len(good) < MIN_GOOD_MATCHES: + raise AlignError("too few good matches (%d)" % len(good)) + + src = np.float32([k1[m.queryIdx].pt for m in good]).reshape(-1, 1, 2) + dst = np.float32([k2[m.trainIdx].pt for m in good]).reshape(-1, 1, 2) + + P, inlier_mask = cv2.estimateAffine2D( + src, dst, method=cv2.RANSAC, ransacReprojThreshold=RANSAC_REPROJ_PX, + maxIters=5000, confidence=0.999, + ) + if P is None or inlier_mask is None: + raise AlignError("affine estimation failed") + + mask = inlier_mask.ravel().astype(bool) + n_inliers = int(mask.sum()) + if n_inliers < MIN_INLIERS: + raise AlignError("too few inliers (%d)" % n_inliers) + + src_in = src.reshape(-1, 2)[mask] + dst_in = dst.reshape(-1, 2)[mask] + proj = (P[:, :2] @ src_in.T).T + P[:, 2] + residual = float(np.sqrt(((proj - dst_in) ** 2).sum(axis=1)).mean()) + + return P.tolist(), n_inliers, residual + + +def _png_dimensions(data): + """Return (width, height) from a PNG header, or None if not a PNG. + + PNG is an 8-byte signature followed by the IHDR chunk whose width/height are + big-endian uint32 at byte offsets 16 and 20. Reading dimensions up front lets + us pick a reduced-resolution decode instead of materialising a huge raster. + """ + if len(data) >= 24 and data[:8] == b"\x89PNG\r\n\x1a\n": + width = int.from_bytes(data[16:20], "big") + height = int.from_bytes(data[20:24], "big") + if width > 0 and height > 0: + return width, height + return None + + +def decode_bounded(data, target=ALIGN_TARGET_PX): + """Decode image bytes to a BGR array whose long edge is <= target. + + Large PNGs are decoded at a reduced resolution (cv2 IMREAD_REDUCED_*) so the + full raster is never allocated (an 8K render decodes straight to ~2048px, not + ~200MB), then resized to the exact target. This bounds memory and normalises + scale so feature matching works across mismatched resolutions. + + Returns (img, scale) where scale = decoded_long_edge / original_long_edge + (<= 1.0), used later to map the transform back to original pixels. + """ + dims = _png_dimensions(data) + arr = np.frombuffer(data, dtype=np.uint8) + + if dims is not None: + orig_long = max(dims) + reduce_factor = 1 + for factor in (8, 4, 2): + if orig_long // factor >= target: + reduce_factor = factor + break + flag = { + 1: cv2.IMREAD_COLOR, + 2: cv2.IMREAD_REDUCED_COLOR_2, + 4: cv2.IMREAD_REDUCED_COLOR_4, + 8: cv2.IMREAD_REDUCED_COLOR_8, + }[reduce_factor] + img = cv2.imdecode(arr, flag) + else: + # Non-PNG (no IHDR to read dimensions from): decode at full resolution, + # then resize below. The upload pipeline always re-encodes to PNG, so in + # production this branch is unreachable; a very large non-PNG input would + # allocate its full raster here before the downscale. + img = cv2.imdecode(arr, cv2.IMREAD_COLOR) + orig_long = None + + if img is None: + raise AlignError("could not decode image") + + if orig_long is None: + orig_long = max(img.shape[:2]) + + height, width = img.shape[:2] + long_edge = max(height, width) + if long_edge > target: + s = target / long_edge + img = cv2.resize( + img, + (max(1, round(width * s)), max(1, round(height * s))), + interpolation=cv2.INTER_AREA, + ) + + scale = max(img.shape[:2]) / orig_long + return img, scale + + +def rescale_transform(transform, scale_old, scale_new): + """Map a 2x3 affine computed on downscaled images back to original pixels. + + `transform` maps old_small -> new_small. The composition is: + old_full --(*scale_old)--> old_small --transform--> new_small --(/scale_new)--> new_full + """ + f = scale_old / scale_new + a, b, tx = transform[0] + c, d, ty = transform[1] + return [[a * f, b * f, tx / scale_new], [c * f, d * f, ty / scale_new]] + + +def align_bounded(old_bytes, new_bytes, target=ALIGN_TARGET_PX): + """Decode both images memory-bounded, align, and return the transform in + ORIGINAL pixel space (old original -> new original). + + Returns (P, inliers, residual): P is a 2x3 list old_original -> new_original, + residual is mean reprojection error in NEW-original pixels. + """ + old_img, scale_old = decode_bounded(old_bytes, target) + new_img, scale_new = decode_bounded(new_bytes, target) + transform, inliers, residual = align_images(old_img, new_img) + full = rescale_transform(transform, scale_old, scale_new) + return full, inliers, residual / scale_new + + +def edge_overlay(old_small, new_small, transform_small): + """Build an alignment-QA image: new-render edges in green, warped-old edges + in magenta, over a dim copy of the new render. Where the alignment is good + the two edge sets coincide and read white; misalignment shows as separated + green/magenta fringes. + + `transform_small` is the 2x3 affine in the SMALL (decoded) pixel space that + maps old_small -> new_small (i.e. `align_images`' raw output, before any + rescale). All three inputs are in that same downscaled space. + """ + height, width = new_small.shape[:2] + matrix = np.asarray(transform_small, dtype=np.float32) + warped_old = cv2.warpAffine(old_small, matrix, (width, height)) + + new_edges = _to_edges(new_small) + old_edges = _to_edges(warped_old) + + gray_new = cv2.cvtColor(new_small, cv2.COLOR_BGR2GRAY) + overlay = cv2.cvtColor((gray_new * 0.3).astype(np.uint8), cv2.COLOR_GRAY2BGR) + # BGR channels: green = new edges; blue+red = old edges -> magenta; both -> white. + overlay[:, :, 1] = np.maximum(overlay[:, :, 1], new_edges) + overlay[:, :, 0] = np.maximum(overlay[:, :, 0], old_edges) + overlay[:, :, 2] = np.maximum(overlay[:, :, 2], old_edges) + return overlay diff --git a/scripts/map-align/batch.py b/scripts/map-align/batch.py new file mode 100644 index 000000000..a14b4ee8f --- /dev/null +++ b/scripts/map-align/batch.py @@ -0,0 +1,179 @@ +"""Batch map render re-alignment. + +Loops every map that has BOTH a reference image (the currently-calibrated one) +and a new render, aligns each pair, and writes: + + /transforms.json - { "": { pixelAffine, inliers, residual, lowConfidence } } + /overlays/.png - edge-overlay QA image (new=green, warped-old=magenta) + +plus a summary table on stdout. Nothing is written to the database or R2 — apply +each map in the admin Replace-render dialog by pasting transforms[]. + +Usage: + cd scripts/map-align + uv run batch.py [--out ./map-align-out] + +Example: + uv run batch.py ~/code/map-models/reference-images ~/code/map-models/final-renders +""" +import argparse +import json +import os +import sys + +import cv2 + +from align import ( + ALIGN_TARGET_PX, + AlignError, + align_images, + decode_bounded, + edge_overlay, + rescale_transform, +) + +# Mirror the dialog's informational thresholds (original-pixel residual / inliers). +LOW_CONFIDENCE_RESIDUAL = 5.0 +LOW_CONFIDENCE_INLIERS = 25 + + +def _png_stems(directory): + """Map { filename-without-extension: full path } for PNGs in a directory.""" + out = {} + for name in os.listdir(directory): + if name.lower().endswith(".png"): + out[os.path.splitext(name)[0]] = os.path.join(directory, name) + return out + + +def _align_pair(old_path, new_path, overlay_path): + """Align one pair, write its overlay, and return its manifest entry + row.""" + with open(old_path, "rb") as f: + old_bytes = f.read() + with open(new_path, "rb") as f: + new_bytes = f.read() + + old_small, scale_old = decode_bounded(old_bytes, ALIGN_TARGET_PX) + new_small, scale_new = decode_bounded(new_bytes, ALIGN_TARGET_PX) + transform_small, inliers, residual_small = align_images(old_small, new_small) + + full = rescale_transform(transform_small, scale_old, scale_new) + residual = residual_small / scale_new + low_confidence = ( + residual > LOW_CONFIDENCE_RESIDUAL or inliers < LOW_CONFIDENCE_INLIERS + ) + + overlay = edge_overlay(old_small, new_small, transform_small) + cv2.imwrite(overlay_path, overlay) + + a, b, tx = full[0] + c, d, ty = full[1] + entry = { + "pixelAffine": {"a": a, "b": b, "c": c, "d": d, "tx": tx, "ty": ty}, + "inliers": inliers, + "residual": round(residual, 3), + "lowConfidence": low_confidence, + } + return entry, low_confidence, inliers, round(residual, 3) + + +def run_batch(reference_dir, final_dir, out_dir): + """Align every reference/final pair. Writes transforms.json + overlays under + out_dir. Returns a summary dict: { transforms, rows, out_dir }. + + rows is a list of { map, status, inliers, residual, flag } for the summary + table (and for tests) covering matched, skipped, and failed maps. + """ + refs = _png_stems(reference_dir) + finals = _png_stems(final_dir) + overlays_dir = os.path.join(out_dir, "overlays") + os.makedirs(overlays_dir, exist_ok=True) + + transforms = {} + rows = [] + + for name in sorted(set(refs) - set(finals)): + rows.append(_row(name, "skipped: no new render")) + for name in sorted(set(finals) - set(refs)): + rows.append(_row(name, "skipped: no reference image")) + + for name in sorted(set(refs) & set(finals)): + overlay_path = os.path.join(overlays_dir, name + ".png") + try: + entry, low, inliers, residual = _align_pair( + refs[name], finals[name], overlay_path + ) + transforms[name] = entry + rows.append( + _row( + name, + "ok", + inliers=inliers, + residual=residual, + flag="LOW CONFIDENCE" if low else "", + ) + ) + except AlignError as e: + rows.append(_row(name, f"failed: {e}", flag="FAILED")) + except Exception as e: # noqa: BLE001 - report and continue the batch + rows.append(_row(name, f"error: {e}", flag="ERROR")) + + with open(os.path.join(out_dir, "transforms.json"), "w") as f: + json.dump(transforms, f, indent=2) + + return {"transforms": transforms, "rows": rows, "out_dir": out_dir} + + +def _row(name, status, inliers="", residual="", flag=""): + return { + "map": name, + "status": status, + "inliers": inliers, + "residual": residual, + "flag": flag, + } + + +def _print_summary(summary): + rows = summary["rows"] + name_w = max([len("map")] + [len(r["map"]) for r in rows], default=3) + status_w = max([len("status")] + [len(str(r["status"])) for r in rows], default=6) + header = f"{'map':<{name_w}} {'status':<{status_w}} {'inliers':>7} {'residual':>8} flag" + print(header) + print("-" * len(header)) + for r in rows: + print( + f"{r['map']:<{name_w}} {str(r['status']):<{status_w}} " + f"{str(r['inliers']):>7} {str(r['residual']):>8} {r['flag']}" + ) + + ok = sum(1 for r in rows if r["status"] == "ok") + low = sum(1 for r in rows if r["flag"] == "LOW CONFIDENCE") + failed = sum(1 for r in rows if r["flag"] in ("FAILED", "ERROR")) + skipped = sum(1 for r in rows if str(r["status"]).startswith("skipped")) + print( + f"\n{ok} aligned ({low} low-confidence), {failed} failed, {skipped} skipped." + ) + print(f"transforms.json + overlays/ written to {summary['out_dir']}") + + +def main(): + parser = argparse.ArgumentParser( + description="Align every reference/final map render pair and write a " + "transforms manifest + review overlays." + ) + parser.add_argument("reference_dir", help="directory of currently-calibrated images") + parser.add_argument("final_dir", help="directory of new renders") + parser.add_argument( + "--out", + default="./map-align-out", + help="output directory (default: ./map-align-out)", + ) + args = parser.parse_args() + summary = run_batch(args.reference_dir, args.final_dir, args.out) + _print_summary(summary) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/map-align/cli.py b/scripts/map-align/cli.py new file mode 100644 index 000000000..c808d2545 --- /dev/null +++ b/scripts/map-align/cli.py @@ -0,0 +1,68 @@ +"""Local CLI for map render re-alignment. + +Aligns a NEW render to the currently-calibrated (OLD) image and prints the 2x3 +pixel transform as JSON, ready to paste into the admin "Replace render" dialog. +Runs locally (where OpenCV installs trivially) instead of on Vercel, where the +~98MB opencv bundle exceeds the Python function size limit. + +Usage: + cd scripts/map-align + uv run cli.py + +Example: + uv run cli.py ~/code/map-models/reference-images/aatlis.png \\ + ~/code/map-models/final-renders/aatlis.png + +The OLD image must be the exact image the map was calibrated on (its pixel space +is where the stored anchors live); the NEW image is the render you're swapping in. +Prints, on stdout, JSON of the form: + + { "pixelAffine": { "a", "b", "c", "d", "tx", "ty" }, + "inliers": , "residual": } + +Copy that JSON into the dialog to review the re-projected anchors and confirm. +""" +import argparse +import json +import sys + +from align import AlignError, align_bounded + + +def main() -> int: + parser = argparse.ArgumentParser( + description="Align a new map render to the calibrated image and print " + "the pixel transform as JSON for the admin Replace-render dialog." + ) + parser.add_argument("old_image", help="path to the currently-calibrated (old) image") + parser.add_argument("new_image", help="path to the new render") + args = parser.parse_args() + + try: + with open(args.old_image, "rb") as f: + old_bytes = f.read() + with open(args.new_image, "rb") as f: + new_bytes = f.read() + except OSError as e: + print(f"could not read image: {e}", file=sys.stderr) + return 1 + + try: + transform, inliers, residual = align_bounded(old_bytes, new_bytes) + except AlignError as e: + print(f"alignment failed: {e}", file=sys.stderr) + return 1 + + a, b, tx = transform[0] + c, d, ty = transform[1] + payload = { + "pixelAffine": {"a": a, "b": b, "c": c, "d": d, "tx": tx, "ty": ty}, + "inliers": inliers, + "residual": round(residual, 3), + } + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/map-align/pyproject.toml b/scripts/map-align/pyproject.toml new file mode 100644 index 000000000..9a0f9b90a --- /dev/null +++ b/scripts/map-align/pyproject.toml @@ -0,0 +1,13 @@ +[project] +name = "map-align" +version = "0.1.0" +description = "Local CLI: align a new map render to the calibrated image and print the pixel transform" +requires-python = ">=3.9" +dependencies = [ + # numpy<2: opencv wheels are compiled against numpy 1.x. + "numpy>=1.24,<2", + "opencv-python-headless>=4.8,<5", +] + +[dependency-groups] +dev = ["pytest>=8,<9"] diff --git a/scripts/map-align/test_align.py b/scripts/map-align/test_align.py new file mode 100644 index 000000000..1b7fab428 --- /dev/null +++ b/scripts/map-align/test_align.py @@ -0,0 +1,157 @@ +import numpy as np +import cv2 +import pytest + +from align import ( + align_images, + AlignError, + decode_bounded, + rescale_transform, + align_bounded, + edge_overlay, +) + + +def _synthetic_map(seed=0): + """Deterministic feature-rich image to register against.""" + rng = np.random.default_rng(seed) + img = np.full((400, 400, 3), 240, dtype=np.uint8) + for _ in range(60): + x, y = int(rng.integers(20, 360)), int(rng.integers(20, 360)) + w, h = int(rng.integers(10, 50)), int(rng.integers(10, 50)) + color = tuple(int(c) for c in rng.integers(0, 200, size=3)) + cv2.rectangle(img, (x, y), (x + w, y + h), color, -1) + for _ in range(40): + x, y = int(rng.integers(20, 380)), int(rng.integers(20, 380)) + r = int(rng.integers(5, 25)) + color = tuple(int(c) for c in rng.integers(0, 200, size=3)) + cv2.circle(img, (x, y), r, color, -1) + return img + + +def test_edge_overlay_shape_and_colors(): + base = _synthetic_map(seed=9) + M = cv2.getRotationMatrix2D((200, 200), 2.0, 1.0) + new = cv2.warpAffine(base, M, (400, 400), borderValue=(0, 0, 0)) + + # identity small-transform: warped-old == base; new is the rotated render. + overlay = edge_overlay(base, new, [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]]) + + assert overlay.shape == new.shape + assert overlay.dtype == np.uint8 + # green channel carries the new-render edges; some must be present. + green_only = ( + (overlay[:, :, 1] > 100) + & (overlay[:, :, 0] < 60) + & (overlay[:, :, 2] < 60) + ) + assert green_only.sum() > 0 + # blue+red (magenta) carries the warped-old edges; present and symmetric. + assert overlay[:, :, 0].max() > 100 + assert int((overlay[:, :, 0] != overlay[:, :, 2]).sum()) == 0 + + +def test_recovers_known_affine_despite_photometric_change(): + old = _synthetic_map(seed=1) + + # Known affine: small rotation + scale + translation. + M = cv2.getRotationMatrix2D((200, 200), 4.0, 1.06) + M[0, 2] += 9.0 + M[1, 2] += -6.0 + warped = cv2.warpAffine(old, M, (400, 400), borderValue=(0, 0, 0)) + + # Heavy photometric change: invert colors + black out a border (mimic the + # render pass differences — different palette, black out-of-bounds mask). + new = 255 - warped + new[:30, :] = 0 + new[-30:, :] = 0 + + P, inliers, residual = align_images(old, new) + + assert inliers >= 20 + assert residual < 2.0 + + # P must reproduce the known mapping on test points. + pts = np.array([[100, 100], [300, 120], [150, 320], [250, 250]], dtype=np.float64) + expected = (M[:, :2] @ pts.T).T + M[:, 2] + got = (np.array(P)[:, :2] @ pts.T).T + np.array(P)[:, 2] + err = np.sqrt(((got - expected) ** 2).sum(axis=1)).max() + assert err < 2.0 + + +def test_raises_on_unalignable_inputs(): + old = _synthetic_map(seed=2) + new = np.zeros((400, 400, 3), dtype=np.uint8) # featureless + with pytest.raises(AlignError): + align_images(old, new) + + +def test_decode_bounded_downscales_large_png(): + # 4096px synthetic PNG -> reduced-decode should bound it to <= 2048. + base = _synthetic_map(seed=5) + big = cv2.resize(base, (4096, 4096), interpolation=cv2.INTER_NEAREST) + ok, buf = cv2.imencode(".png", big) + assert ok + img, scale = decode_bounded(buf.tobytes(), target=2048) + assert max(img.shape[:2]) <= 2048 + # decoded long edge / original long edge + assert scale == pytest.approx(max(img.shape[:2]) / 4096, rel=0.02) + + +def test_decode_bounded_passthrough_small(): + # A 400px image is already under target -> returned unchanged, scale 1.0. + base = _synthetic_map(seed=6) + ok, buf = cv2.imencode(".png", base) + assert ok + img, scale = decode_bounded(buf.tobytes(), target=2048) + assert img.shape[:2] == (400, 400) + assert scale == 1.0 + + +def test_rescale_transform_math(): + # old downscaled by 0.25, new by 0.5; identity in small space -> + # full-space transform scales old_full -> new_full by 0.5. + P = [[1.0, 0.0, 0.0], [0.0, 1.0, 0.0]] + full = rescale_transform(P, 0.25, 0.5) + assert full[0][0] == pytest.approx(0.5) + assert full[1][1] == pytest.approx(0.5) + assert full[0][2] == pytest.approx(0.0) + # a translation in small-new space scales by 1/scale_new + P2 = [[1.0, 0.0, 10.0], [0.0, 1.0, -4.0]] + full2 = rescale_transform(P2, 0.5, 0.5) + assert full2[0][2] == pytest.approx(20.0) + assert full2[1][2] == pytest.approx(-8.0) + + +def test_align_bounded_recovers_scaled_affine_from_bytes(): + # old_full is 4096px; new_full is produced by a KNOWN affine that includes a + # 0.5 scale into a 2048 canvas, plus a photometric inversion. align_bounded + # must recover P (old_full -> new_full) in ORIGINAL pixel space. + base = _synthetic_map(seed=7) + old_full = cv2.resize(base, (4096, 4096), interpolation=cv2.INTER_NEAREST) + + # Known old_full -> new_full affine: rotate ~3 deg about (2048,2048), scale 0.5. + theta = np.deg2rad(3.0) + s = 0.5 + cos, sin = np.cos(theta) * s, np.sin(theta) * s + cx, cy = 2048.0, 2048.0 + M = np.array([ + [cos, -sin, cx - cos * cx + sin * cy], + [sin, cos, cy - sin * cx - cos * cy], + ], dtype=np.float64) + new_full = cv2.warpAffine(old_full, M, (2048, 2048), borderValue=(0, 0, 0)) + new_full = 255 - new_full # photometric change + + ok1, ob = cv2.imencode(".png", old_full) + ok2, nb = cv2.imencode(".png", new_full) + assert ok1 and ok2 + + P, inliers, residual = align_bounded(ob.tobytes(), nb.tobytes(), target=2048) + assert inliers >= 20 + + pts = np.array([[500, 500], [3000, 800], [1500, 3500], [2048, 2048]], dtype=np.float64) + expected = (M[:, :2] @ pts.T).T + M[:, 2] + Pn = np.array(P, dtype=np.float64) + got = (Pn[:, :2] @ pts.T).T + Pn[:, 2] + err = np.sqrt(((got - expected) ** 2).sum(axis=1)).max() + assert err < 5.0 # within 5 px in new-original space (downscaled-align tolerance) diff --git a/scripts/map-align/test_batch.py b/scripts/map-align/test_batch.py new file mode 100644 index 000000000..25a07fafe --- /dev/null +++ b/scripts/map-align/test_batch.py @@ -0,0 +1,76 @@ +import json +import os + +import cv2 +import numpy as np + +from batch import run_batch + + +def _feature_image(seed, size): + rng = np.random.default_rng(seed) + img = np.full((size, size, 3), 240, dtype=np.uint8) + for _ in range(90): + x, y = int(rng.integers(8, size - 48)), int(rng.integers(8, size - 48)) + w, h = int(rng.integers(8, 44)), int(rng.integers(8, 44)) + color = tuple(int(c) for c in rng.integers(0, 200, size=3)) + cv2.rectangle(img, (x, y), (x + w, y + h), color, -1) + for _ in range(60): + x, y = int(rng.integers(8, size - 8)), int(rng.integers(8, size - 8)) + r = int(rng.integers(4, 22)) + color = tuple(int(c) for c in rng.integers(0, 200, size=3)) + cv2.circle(img, (x, y), r, color, -1) + return img + + +def _write_png(path, img): + ok, buf = cv2.imencode(".png", img) + assert ok + with open(path, "wb") as f: + f.write(buf.tobytes()) + + +def test_run_batch_aligns_skips_and_flags(tmp_path): + ref_dir = tmp_path / "ref" + ref_dir.mkdir() + fin_dir = tmp_path / "fin" + fin_dir.mkdir() + out_dir = tmp_path / "out" + + # Aligned pair: the final render is a known scaled+rotated, photometrically + # inverted version of the reference (mimics a new render pass at half size). + base = _feature_image(1, 1024) + M = cv2.getRotationMatrix2D((512, 512), 3.0, 0.5) + new = 255 - cv2.warpAffine(base, M, (512, 512), borderValue=(0, 0, 0)) + _write_png(ref_dir / "alpha.png", base) + _write_png(fin_dir / "alpha.png", new) + + # Unalignable pair: a featureless (blank) final render. + _write_png(ref_dir / "beta.png", _feature_image(2, 512)) + _write_png(fin_dir / "beta.png", np.zeros((512, 512, 3), dtype=np.uint8)) + + # Reference-only and final-only maps (should be skipped, not failed). + _write_png(ref_dir / "gamma.png", _feature_image(3, 256)) + _write_png(fin_dir / "delta.png", _feature_image(4, 256)) + + summary = run_batch(str(ref_dir), str(fin_dir), str(out_dir)) + + # alpha aligned, with all six affine fields and an overlay on disk. + assert "alpha" in summary["transforms"] + entry = summary["transforms"]["alpha"] + assert set(entry["pixelAffine"]) == {"a", "b", "c", "d", "tx", "ty"} + assert os.path.exists(out_dir / "overlays" / "alpha.png") + + # beta could not align -> absent from the manifest. + assert "beta" not in summary["transforms"] + + # transforms.json on disk matches the returned manifest. + with open(out_dir / "transforms.json") as f: + on_disk = json.load(f) + assert "alpha" in on_disk and "beta" not in on_disk + + rows = {r["map"]: r for r in summary["rows"]} + assert rows["alpha"]["status"] == "ok" + assert rows["beta"]["flag"] in ("FAILED", "ERROR") + assert rows["gamma"]["status"].startswith("skipped") # reference-only + assert rows["delta"]["status"].startswith("skipped") # final-only diff --git a/scripts/map-align/uv.lock b/scripts/map-align/uv.lock new file mode 100644 index 000000000..0db32257d --- /dev/null +++ b/scripts/map-align/uv.lock @@ -0,0 +1,264 @@ +version = 1 +revision = 1 +requires-python = ">=3.9" +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.10.*' and sys_platform == 'darwin'", + "python_full_version == '3.10.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.10.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.10.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'", + "python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')", +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "exceptiongroup" +version = "1.3.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8a/0e/97c33bf5009bdbac74fd2beace167cab3f978feb69cc36f1ef79360d6c4e/exceptiongroup-1.3.1-py3-none-any.whl", hash = "sha256:a7a39a3bd276781e98394987d3a5701d0c4edffb633bb7a5144577f82c773598", size = 16740 }, +] + +[[package]] +name = "iniconfig" +version = "2.1.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version < '3.10' and platform_machine == 'arm64' and sys_platform == 'darwin'", + "python_full_version < '3.10' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version < '3.10' and platform_machine != 'arm64' and sys_platform == 'darwin') or (python_full_version < '3.10' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.10' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/f2/97/ebf4da567aa6827c909642694d71c9fcf53e5b504f2d96afea02718862f3/iniconfig-2.1.0.tar.gz", hash = "sha256:3abbd2e30b36733fee78f9c7f7308f2d0050e88f0087fd25c2645f63c773e1c7", size = 4793 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2c/e1/e6716421ea10d38022b952c159d5161ca1193197fb744506875fbb87ea7b/iniconfig-2.1.0-py3-none-any.whl", hash = "sha256:9deba5723312380e77435581c6bf4935c94cbfab9b1ed33ef8d238ea168eb760", size = 6050 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +resolution-markers = [ + "python_full_version >= '3.12' and sys_platform == 'darwin'", + "python_full_version >= '3.12' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version >= '3.12' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version >= '3.12' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.11.*' and sys_platform == 'darwin'", + "python_full_version == '3.11.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'linux')", + "python_full_version == '3.10.*' and sys_platform == 'darwin'", + "python_full_version == '3.10.*' and platform_machine == 'aarch64' and sys_platform == 'linux'", + "(python_full_version == '3.10.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.10.*' and sys_platform != 'darwin' and sys_platform != 'linux')", +] +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "map-align" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "numpy" }, + { name = "opencv-python-headless" }, +] + +[package.dev-dependencies] +dev = [ + { name = "pytest" }, +] + +[package.metadata] +requires-dist = [ + { name = "numpy", specifier = ">=1.24,<2" }, + { name = "opencv-python-headless", specifier = ">=4.8,<5" }, +] + +[package.metadata.requires-dev] +dev = [{ name = "pytest", specifier = ">=8,<9" }] + +[[package]] +name = "numpy" +version = "1.26.4" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/65/6e/09db70a523a96d25e115e71cc56a6f9031e7b8cd166c1ac8438307c14058/numpy-1.26.4.tar.gz", hash = "sha256:2a02aba9ed12e4ac4eb3ea9421c420301a0c6460d9830d74a9df87efa4912010", size = 15786129 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a7/94/ace0fdea5241a27d13543ee117cbc65868e82213fb31a8eb7fe9ff23f313/numpy-1.26.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:9ff0f4f29c51e2803569d7a51c2304de5554655a60c5d776e35b4a41413830d0", size = 20631468 }, + { url = "https://files.pythonhosted.org/packages/20/f7/b24208eba89f9d1b58c1668bc6c8c4fd472b20c45573cb767f59d49fb0f6/numpy-1.26.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:2e4ee3380d6de9c9ec04745830fd9e2eccb3e6cf790d39d7b98ffd19b0dd754a", size = 13966411 }, + { url = "https://files.pythonhosted.org/packages/fc/a5/4beee6488160798683eed5bdb7eead455892c3b4e1f78d79d8d3f3b084ac/numpy-1.26.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d209d8969599b27ad20994c8e41936ee0964e6da07478d6c35016bc386b66ad4", size = 14219016 }, + { url = "https://files.pythonhosted.org/packages/4b/d7/ecf66c1cd12dc28b4040b15ab4d17b773b87fa9d29ca16125de01adb36cd/numpy-1.26.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ffa75af20b44f8dba823498024771d5ac50620e6915abac414251bd971b4529f", size = 18240889 }, + { url = "https://files.pythonhosted.org/packages/24/03/6f229fe3187546435c4f6f89f6d26c129d4f5bed40552899fcf1f0bf9e50/numpy-1.26.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:62b8e4b1e28009ef2846b4c7852046736bab361f7aeadeb6a5b89ebec3c7055a", size = 13876746 }, + { url = "https://files.pythonhosted.org/packages/39/fe/39ada9b094f01f5a35486577c848fe274e374bbf8d8f472e1423a0bbd26d/numpy-1.26.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:a4abb4f9001ad2858e7ac189089c42178fcce737e4169dc61321660f1a96c7d2", size = 18078620 }, + { url = "https://files.pythonhosted.org/packages/d5/ef/6ad11d51197aad206a9ad2286dc1aac6a378059e06e8cf22cd08ed4f20dc/numpy-1.26.4-cp310-cp310-win32.whl", hash = "sha256:bfe25acf8b437eb2a8b2d49d443800a5f18508cd811fea3181723922a8a82b07", size = 5972659 }, + { url = "https://files.pythonhosted.org/packages/19/77/538f202862b9183f54108557bfda67e17603fc560c384559e769321c9d92/numpy-1.26.4-cp310-cp310-win_amd64.whl", hash = "sha256:b97fe8060236edf3662adfc2c633f56a08ae30560c56310562cb4f95500022d5", size = 15808905 }, + { url = "https://files.pythonhosted.org/packages/11/57/baae43d14fe163fa0e4c47f307b6b2511ab8d7d30177c491960504252053/numpy-1.26.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:4c66707fabe114439db9068ee468c26bbdf909cac0fb58686a42a24de1760c71", size = 20630554 }, + { url = "https://files.pythonhosted.org/packages/1a/2e/151484f49fd03944c4a3ad9c418ed193cfd02724e138ac8a9505d056c582/numpy-1.26.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:edd8b5fe47dab091176d21bb6de568acdd906d1887a4584a15a9a96a1dca06ef", size = 13997127 }, + { url = "https://files.pythonhosted.org/packages/79/ae/7e5b85136806f9dadf4878bf73cf223fe5c2636818ba3ab1c585d0403164/numpy-1.26.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7ab55401287bfec946ced39700c053796e7cc0e3acbef09993a9ad2adba6ca6e", size = 14222994 }, + { url = "https://files.pythonhosted.org/packages/3a/d0/edc009c27b406c4f9cbc79274d6e46d634d139075492ad055e3d68445925/numpy-1.26.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:666dbfb6ec68962c033a450943ded891bed2d54e6755e35e5835d63f4f6931d5", size = 18252005 }, + { url = "https://files.pythonhosted.org/packages/09/bf/2b1aaf8f525f2923ff6cfcf134ae5e750e279ac65ebf386c75a0cf6da06a/numpy-1.26.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:96ff0b2ad353d8f990b63294c8986f1ec3cb19d749234014f4e7eb0112ceba5a", size = 13885297 }, + { url = "https://files.pythonhosted.org/packages/df/a0/4e0f14d847cfc2a633a1c8621d00724f3206cfeddeb66d35698c4e2cf3d2/numpy-1.26.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:60dedbb91afcbfdc9bc0b1f3f402804070deed7392c23eb7a7f07fa857868e8a", size = 18093567 }, + { url = "https://files.pythonhosted.org/packages/d2/b7/a734c733286e10a7f1a8ad1ae8c90f2d33bf604a96548e0a4a3a6739b468/numpy-1.26.4-cp311-cp311-win32.whl", hash = "sha256:1af303d6b2210eb850fcf03064d364652b7120803a0b872f5211f5234b399f20", size = 5968812 }, + { url = "https://files.pythonhosted.org/packages/3f/6b/5610004206cf7f8e7ad91c5a85a8c71b2f2f8051a0c0c4d5916b76d6cbb2/numpy-1.26.4-cp311-cp311-win_amd64.whl", hash = "sha256:cd25bcecc4974d09257ffcd1f098ee778f7834c3ad767fe5db785be9a4aa9cb2", size = 15811913 }, + { url = "https://files.pythonhosted.org/packages/95/12/8f2020a8e8b8383ac0177dc9570aad031a3beb12e38847f7129bacd96228/numpy-1.26.4-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:b3ce300f3644fb06443ee2222c2201dd3a89ea6040541412b8fa189341847218", size = 20335901 }, + { url = "https://files.pythonhosted.org/packages/75/5b/ca6c8bd14007e5ca171c7c03102d17b4f4e0ceb53957e8c44343a9546dcc/numpy-1.26.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:03a8c78d01d9781b28a6989f6fa1bb2c4f2d51201cf99d3dd875df6fbd96b23b", size = 13685868 }, + { url = "https://files.pythonhosted.org/packages/79/f8/97f10e6755e2a7d027ca783f63044d5b1bc1ae7acb12afe6a9b4286eac17/numpy-1.26.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9fad7dcb1aac3c7f0584a5a8133e3a43eeb2fe127f47e3632d43d677c66c102b", size = 13925109 }, + { url = "https://files.pythonhosted.org/packages/0f/50/de23fde84e45f5c4fda2488c759b69990fd4512387a8632860f3ac9cd225/numpy-1.26.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:675d61ffbfa78604709862923189bad94014bef562cc35cf61d3a07bba02a7ed", size = 17950613 }, + { url = "https://files.pythonhosted.org/packages/4c/0c/9c603826b6465e82591e05ca230dfc13376da512b25ccd0894709b054ed0/numpy-1.26.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:ab47dbe5cc8210f55aa58e4805fe224dac469cde56b9f731a4c098b91917159a", size = 13572172 }, + { url = "https://files.pythonhosted.org/packages/76/8c/2ba3902e1a0fc1c74962ea9bb33a534bb05984ad7ff9515bf8d07527cadd/numpy-1.26.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:1dda2e7b4ec9dd512f84935c5f126c8bd8b9f2fc001e9f54af255e8c5f16b0e0", size = 17786643 }, + { url = "https://files.pythonhosted.org/packages/28/4a/46d9e65106879492374999e76eb85f87b15328e06bd1550668f79f7b18c6/numpy-1.26.4-cp312-cp312-win32.whl", hash = "sha256:50193e430acfc1346175fcbdaa28ffec49947a06918b7b92130744e81e640110", size = 5677803 }, + { url = "https://files.pythonhosted.org/packages/16/2e/86f24451c2d530c88daf997cb8d6ac622c1d40d19f5a031ed68a4b73a374/numpy-1.26.4-cp312-cp312-win_amd64.whl", hash = "sha256:08beddf13648eb95f8d867350f6a018a4be2e5ad54c8d8caed89ebca558b2818", size = 15517754 }, + { url = "https://files.pythonhosted.org/packages/7d/24/ce71dc08f06534269f66e73c04f5709ee024a1afe92a7b6e1d73f158e1f8/numpy-1.26.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:7349ab0fa0c429c82442a27a9673fc802ffdb7c7775fad780226cb234965e53c", size = 20636301 }, + { url = "https://files.pythonhosted.org/packages/ae/8c/ab03a7c25741f9ebc92684a20125fbc9fc1b8e1e700beb9197d750fdff88/numpy-1.26.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:52b8b60467cd7dd1e9ed082188b4e6bb35aa5cdd01777621a1658910745b90be", size = 13971216 }, + { url = "https://files.pythonhosted.org/packages/6d/64/c3bcdf822269421d85fe0d64ba972003f9bb4aa9a419da64b86856c9961f/numpy-1.26.4-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d5241e0a80d808d70546c697135da2c613f30e28251ff8307eb72ba696945764", size = 14226281 }, + { url = "https://files.pythonhosted.org/packages/54/30/c2a907b9443cf42b90c17ad10c1e8fa801975f01cb9764f3f8eb8aea638b/numpy-1.26.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f870204a840a60da0b12273ef34f7051e98c3b5961b61b0c2c1be6dfd64fbcd3", size = 18249516 }, + { url = "https://files.pythonhosted.org/packages/43/12/01a563fc44c07095996d0129b8899daf89e4742146f7044cdbdb3101c57f/numpy-1.26.4-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:679b0076f67ecc0138fd2ede3a8fd196dddc2ad3254069bcb9faf9a79b1cebcd", size = 13882132 }, + { url = "https://files.pythonhosted.org/packages/16/ee/9df80b06680aaa23fc6c31211387e0db349e0e36d6a63ba3bd78c5acdf11/numpy-1.26.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:47711010ad8555514b434df65f7d7b076bb8261df1ca9bb78f53d3b2db02e95c", size = 18084181 }, + { url = "https://files.pythonhosted.org/packages/28/7d/4b92e2fe20b214ffca36107f1a3e75ef4c488430e64de2d9af5db3a4637d/numpy-1.26.4-cp39-cp39-win32.whl", hash = "sha256:a354325ee03388678242a4d7ebcd08b5c727033fcff3b2f536aea978e15ee9e6", size = 5976360 }, + { url = "https://files.pythonhosted.org/packages/b5/42/054082bd8220bbf6f297f982f0a8f5479fcbc55c8b511d928df07b965869/numpy-1.26.4-cp39-cp39-win_amd64.whl", hash = "sha256:3373d5d70a5fe74a2c1bb6d2cfd9609ecf686d47a2d7b1d37a8f3b6bf6003aea", size = 15814633 }, + { url = "https://files.pythonhosted.org/packages/3f/72/3df6c1c06fc83d9cfe381cccb4be2532bbd38bf93fbc9fad087b6687f1c0/numpy-1.26.4-pp39-pypy39_pp73-macosx_10_9_x86_64.whl", hash = "sha256:afedb719a9dcfc7eaf2287b839d8198e06dcd4cb5d276a3df279231138e83d30", size = 20455961 }, + { url = "https://files.pythonhosted.org/packages/8e/02/570545bac308b58ffb21adda0f4e220ba716fb658a63c151daecc3293350/numpy-1.26.4-pp39-pypy39_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:95a7476c59002f2f6c590b9b7b998306fba6a5aa646b1e22ddfeaf8f78c3a29c", size = 18061071 }, + { url = "https://files.pythonhosted.org/packages/f4/5f/fafd8c51235f60d49f7a88e2275e13971e90555b67da52dd6416caec32fe/numpy-1.26.4-pp39-pypy39_pp73-win_amd64.whl", hash = "sha256:7e50d0a0cc3189f9cb0aeb3a6a6af18c16f59f004b866cd2be1c14b36134a4a0", size = 15709730 }, +] + +[[package]] +name = "opencv-python-headless" +version = "4.11.0.86" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/36/2f/5b2b3ba52c864848885ba988f24b7f105052f68da9ab0e693cc7c25b0b30/opencv-python-headless-4.11.0.86.tar.gz", hash = "sha256:996eb282ca4b43ec6a3972414de0e2331f5d9cda2b41091a49739c19fb843798", size = 95177929 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/dc/53/2c50afa0b1e05ecdb4603818e85f7d174e683d874ef63a6abe3ac92220c8/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:48128188ade4a7e517237c8e1e11a9cdf5c282761473383e77beb875bb1e61ca", size = 37326460 }, + { url = "https://files.pythonhosted.org/packages/3b/43/68555327df94bb9b59a1fd645f63fafb0762515344d2046698762fc19d58/opencv_python_headless-4.11.0.86-cp37-abi3-macosx_13_0_x86_64.whl", hash = "sha256:a66c1b286a9de872c343ee7c3553b084244299714ebb50fbdcd76f07ebbe6c81", size = 56723330 }, + { url = "https://files.pythonhosted.org/packages/45/be/1438ce43ebe65317344a87e4b150865c5585f4c0db880a34cdae5ac46881/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6efabcaa9df731f29e5ea9051776715b1bdd1845d7c9530065c7951d2a2899eb", size = 29487060 }, + { url = "https://files.pythonhosted.org/packages/dd/5c/c139a7876099916879609372bfa513b7f1257f7f1a908b0bdc1c2328241b/opencv_python_headless-4.11.0.86-cp37-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0e0a27c19dd1f40ddff94976cfe43066fbbe9dfbb2ec1907d66c19caef42a57b", size = 49969856 }, + { url = "https://files.pythonhosted.org/packages/95/dd/ed1191c9dc91abcc9f752b499b7928aacabf10567bb2c2535944d848af18/opencv_python_headless-4.11.0.86-cp37-abi3-win32.whl", hash = "sha256:f447d8acbb0b6f2808da71fddd29c1cdd448d2bc98f72d9bb78a7a898fc9621b", size = 29324425 }, + { url = "https://files.pythonhosted.org/packages/86/8a/69176a64335aed183529207ba8bc3d329c2999d852b4f3818027203f50e6/opencv_python_headless-4.11.0.86-cp37-abi3-win_amd64.whl", hash = "sha256:6c304df9caa7a6a5710b91709dd4786bf20a74d57672b3c31f7033cc638174ca", size = 39402386 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "8.4.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "exceptiongroup", marker = "python_full_version < '3.11'" }, + { name = "iniconfig", version = "2.1.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.10'" }, + { name = "iniconfig", version = "2.3.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.10'" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/5c/00a0e072241553e1a7496d638deababa67c5058571567b92a7eaa258397c/pytest-8.4.2.tar.gz", hash = "sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01", size = 1519618 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a8/a4/20da314d277121d6534b3a980b29035dcd51e6744bd79075a6ce8fa4eb8d/pytest-8.4.2-py3-none-any.whl", hash = "sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79", size = 365750 }, +] + +[[package]] +name = "tomli" +version = "2.4.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/22/de/48c59722572767841493b26183a0d1cc411d54fd759c5607c4590b6563a6/tomli-2.4.1.tar.gz", hash = "sha256:7c7e1a961a0b2f2472c1ac5b69affa0ae1132c39adcb67aba98568702b9cc23f", size = 17543 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/11/db3d5885d8528263d8adc260bb2d28ebf1270b96e98f0e0268d32b8d9900/tomli-2.4.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:f8f0fc26ec2cc2b965b7a3b87cd19c5c6b8c5e5f436b984e85f486d652285c30", size = 154704 }, + { url = "https://files.pythonhosted.org/packages/6d/f7/675db52c7e46064a9aa928885a9b20f4124ecb9bc2e1ce74c9106648d202/tomli-2.4.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4ab97e64ccda8756376892c53a72bd1f964e519c77236368527f758fbc36a53a", size = 149454 }, + { url = "https://files.pythonhosted.org/packages/61/71/81c50943cf953efa35bce7646caab3cf457a7d8c030b27cfb40d7235f9ee/tomli-2.4.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:96481a5786729fd470164b47cdb3e0e58062a496f455ee41b4403be77cb5a076", size = 237561 }, + { url = "https://files.pythonhosted.org/packages/48/c1/f41d9cb618acccca7df82aaf682f9b49013c9397212cb9f53219e3abac37/tomli-2.4.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5a881ab208c0baf688221f8cecc5401bd291d67e38a1ac884d6736cbcd8247e9", size = 243824 }, + { url = "https://files.pythonhosted.org/packages/22/e4/5a816ecdd1f8ca51fb756ef684b90f2780afc52fc67f987e3c61d800a46d/tomli-2.4.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:47149d5bd38761ac8be13a84864bf0b7b70bc051806bc3669ab1cbc56216b23c", size = 242227 }, + { url = "https://files.pythonhosted.org/packages/6b/49/2b2a0ef529aa6eec245d25f0c703e020a73955ad7edf73e7f54ddc608aa5/tomli-2.4.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:ec9bfaf3ad2df51ace80688143a6a4ebc09a248f6ff781a9945e51937008fcbc", size = 247859 }, + { url = "https://files.pythonhosted.org/packages/83/bd/6c1a630eaca337e1e78c5903104f831bda934c426f9231429396ce3c3467/tomli-2.4.1-cp311-cp311-win32.whl", hash = "sha256:ff2983983d34813c1aeb0fa89091e76c3a22889ee83ab27c5eeb45100560c049", size = 97204 }, + { url = "https://files.pythonhosted.org/packages/42/59/71461df1a885647e10b6bb7802d0b8e66480c61f3f43079e0dcd315b3954/tomli-2.4.1-cp311-cp311-win_amd64.whl", hash = "sha256:5ee18d9ebdb417e384b58fe414e8d6af9f4e7a0ae761519fb50f721de398dd4e", size = 108084 }, + { url = "https://files.pythonhosted.org/packages/b8/83/dceca96142499c069475b790e7913b1044c1a4337e700751f48ed723f883/tomli-2.4.1-cp311-cp311-win_arm64.whl", hash = "sha256:c2541745709bad0264b7d4705ad453b76ccd191e64aa6f0fc66b69a293a45ece", size = 95285 }, + { url = "https://files.pythonhosted.org/packages/c1/ba/42f134a3fe2b370f555f44b1d72feebb94debcab01676bf918d0cb70e9aa/tomli-2.4.1-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:c742f741d58a28940ce01d58f0ab2ea3ced8b12402f162f4d534dfe18ba1cd6a", size = 155924 }, + { url = "https://files.pythonhosted.org/packages/dc/c7/62d7a17c26487ade21c5422b646110f2162f1fcc95980ef7f63e73c68f14/tomli-2.4.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:7f86fd587c4ed9dd76f318225e7d9b29cfc5a9d43de44e5754db8d1128487085", size = 150018 }, + { url = "https://files.pythonhosted.org/packages/5c/05/79d13d7c15f13bdef410bdd49a6485b1c37d28968314eabee452c22a7fda/tomli-2.4.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ff18e6a727ee0ab0388507b89d1bc6a22b138d1e2fa56d1ad494586d61d2eae9", size = 244948 }, + { url = "https://files.pythonhosted.org/packages/10/90/d62ce007a1c80d0b2c93e02cab211224756240884751b94ca72df8a875ca/tomli-2.4.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:136443dbd7e1dee43c68ac2694fde36b2849865fa258d39bf822c10e8068eac5", size = 253341 }, + { url = "https://files.pythonhosted.org/packages/1a/7e/caf6496d60152ad4ed09282c1885cca4eea150bfd007da84aea07bcc0a3e/tomli-2.4.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:5e262d41726bc187e69af7825504c933b6794dc3fbd5945e41a79bb14c31f585", size = 248159 }, + { url = "https://files.pythonhosted.org/packages/99/e7/c6f69c3120de34bbd882c6fba7975f3d7a746e9218e56ab46a1bc4b42552/tomli-2.4.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:5cb41aa38891e073ee49d55fbc7839cfdb2bc0e600add13874d048c94aadddd1", size = 253290 }, + { url = "https://files.pythonhosted.org/packages/d6/2f/4a3c322f22c5c66c4b836ec58211641a4067364f5dcdd7b974b4c5da300c/tomli-2.4.1-cp312-cp312-win32.whl", hash = "sha256:da25dc3563bff5965356133435b757a795a17b17d01dbc0f42fb32447ddfd917", size = 98141 }, + { url = "https://files.pythonhosted.org/packages/24/22/4daacd05391b92c55759d55eaee21e1dfaea86ce5c571f10083360adf534/tomli-2.4.1-cp312-cp312-win_amd64.whl", hash = "sha256:52c8ef851d9a240f11a88c003eacb03c31fc1c9c4ec64a99a0f922b93874fda9", size = 108847 }, + { url = "https://files.pythonhosted.org/packages/68/fd/70e768887666ddd9e9f5d85129e84910f2db2796f9096aa02b721a53098d/tomli-2.4.1-cp312-cp312-win_arm64.whl", hash = "sha256:f758f1b9299d059cc3f6546ae2af89670cb1c4d48ea29c3cacc4fe7de3058257", size = 95088 }, + { url = "https://files.pythonhosted.org/packages/07/06/b823a7e818c756d9a7123ba2cda7d07bc2dd32835648d1a7b7b7a05d848d/tomli-2.4.1-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:36d2bd2ad5fb9eaddba5226aa02c8ec3fa4f192631e347b3ed28186d43be6b54", size = 155866 }, + { url = "https://files.pythonhosted.org/packages/14/6f/12645cf7f08e1a20c7eb8c297c6f11d31c1b50f316a7e7e1e1de6e2e7b7e/tomli-2.4.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:eb0dc4e38e6a1fd579e5d50369aa2e10acfc9cace504579b2faabb478e76941a", size = 149887 }, + { url = "https://files.pythonhosted.org/packages/5c/e0/90637574e5e7212c09099c67ad349b04ec4d6020324539297b634a0192b0/tomli-2.4.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:c7f2c7f2b9ca6bdeef8f0fa897f8e05085923eb091721675170254cbc5b02897", size = 243704 }, + { url = "https://files.pythonhosted.org/packages/10/8f/d3ddb16c5a4befdf31a23307f72828686ab2096f068eaf56631e136c1fdd/tomli-2.4.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f3c6818a1a86dd6dca7ddcaaf76947d5ba31aecc28cb1b67009a5877c9a64f3f", size = 251628 }, + { url = "https://files.pythonhosted.org/packages/e3/f1/dbeeb9116715abee2485bf0a12d07a8f31af94d71608c171c45f64c0469d/tomli-2.4.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d312ef37c91508b0ab2cee7da26ec0b3ed2f03ce12bd87a588d771ae15dcf82d", size = 247180 }, + { url = "https://files.pythonhosted.org/packages/d3/74/16336ffd19ed4da28a70959f92f506233bd7cfc2332b20bdb01591e8b1d1/tomli-2.4.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51529d40e3ca50046d7606fa99ce3956a617f9b36380da3b7f0dd3dd28e68cb5", size = 251674 }, + { url = "https://files.pythonhosted.org/packages/16/f9/229fa3434c590ddf6c0aa9af64d3af4b752540686cace29e6281e3458469/tomli-2.4.1-cp313-cp313-win32.whl", hash = "sha256:2190f2e9dd7508d2a90ded5ed369255980a1bcdd58e52f7fe24b8162bf9fedbd", size = 97976 }, + { url = "https://files.pythonhosted.org/packages/6a/1e/71dfd96bcc1c775420cb8befe7a9d35f2e5b1309798f009dca17b7708c1e/tomli-2.4.1-cp313-cp313-win_amd64.whl", hash = "sha256:8d65a2fbf9d2f8352685bc1364177ee3923d6baf5e7f43ea4959d7d8bc326a36", size = 108755 }, + { url = "https://files.pythonhosted.org/packages/83/7a/d34f422a021d62420b78f5c538e5b102f62bea616d1d75a13f0a88acb04a/tomli-2.4.1-cp313-cp313-win_arm64.whl", hash = "sha256:4b605484e43cdc43f0954ddae319fb75f04cc10dd80d830540060ee7cd0243cd", size = 95265 }, + { url = "https://files.pythonhosted.org/packages/3c/fb/9a5c8d27dbab540869f7c1f8eb0abb3244189ce780ba9cd73f3770662072/tomli-2.4.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fd0409a3653af6c147209d267a0e4243f0ae46b011aa978b1080359fddc9b6cf", size = 155726 }, + { url = "https://files.pythonhosted.org/packages/62/05/d2f816630cc771ad836af54f5001f47a6f611d2d39535364f148b6a92d6b/tomli-2.4.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:a120733b01c45e9a0c34aeef92bf0cf1d56cfe81ed9d47d562f9ed591a9828ac", size = 149859 }, + { url = "https://files.pythonhosted.org/packages/ce/48/66341bdb858ad9bd0ceab5a86f90eddab127cf8b046418009f2125630ecb/tomli-2.4.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:559db847dc486944896521f68d8190be1c9e719fced785720d2216fe7022b662", size = 244713 }, + { url = "https://files.pythonhosted.org/packages/df/6d/c5fad00d82b3c7a3ab6189bd4b10e60466f22cfe8a08a9394185c8a8111c/tomli-2.4.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:01f520d4f53ef97964a240a035ec2a869fe1a37dde002b57ebc4417a27ccd853", size = 252084 }, + { url = "https://files.pythonhosted.org/packages/00/71/3a69e86f3eafe8c7a59d008d245888051005bd657760e96d5fbfb0b740c2/tomli-2.4.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:7f94b27a62cfad8496c8d2513e1a222dd446f095fca8987fceef261225538a15", size = 247973 }, + { url = "https://files.pythonhosted.org/packages/67/50/361e986652847fec4bd5e4a0208752fbe64689c603c7ae5ea7cb16b1c0ca/tomli-2.4.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ede3e6487c5ef5d28634ba3f31f989030ad6af71edfb0055cbbd14189ff240ba", size = 256223 }, + { url = "https://files.pythonhosted.org/packages/8c/9a/b4173689a9203472e5467217e0154b00e260621caa227b6fa01feab16998/tomli-2.4.1-cp314-cp314-win32.whl", hash = "sha256:3d48a93ee1c9b79c04bb38772ee1b64dcf18ff43085896ea460ca8dec96f35f6", size = 98973 }, + { url = "https://files.pythonhosted.org/packages/14/58/640ac93bf230cd27d002462c9af0d837779f8773bc03dee06b5835208214/tomli-2.4.1-cp314-cp314-win_amd64.whl", hash = "sha256:88dceee75c2c63af144e456745e10101eb67361050196b0b6af5d717254dddf7", size = 109082 }, + { url = "https://files.pythonhosted.org/packages/d5/2f/702d5e05b227401c1068f0d386d79a589bb12bf64c3d2c72ce0631e3bc49/tomli-2.4.1-cp314-cp314-win_arm64.whl", hash = "sha256:b8c198f8c1805dc42708689ed6864951fd2494f924149d3e4bce7710f8eb5232", size = 96490 }, + { url = "https://files.pythonhosted.org/packages/45/4b/b877b05c8ba62927d9865dd980e34a755de541eb65fffba52b4cc495d4d2/tomli-2.4.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:d4d8fe59808a54658fcc0160ecfb1b30f9089906c50b23bcb4c69eddc19ec2b4", size = 164263 }, + { url = "https://files.pythonhosted.org/packages/24/79/6ab420d37a270b89f7195dec5448f79400d9e9c1826df982f3f8e97b24fd/tomli-2.4.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:7008df2e7655c495dd12d2a4ad038ff878d4ca4b81fccaf82b714e07eae4402c", size = 160736 }, + { url = "https://files.pythonhosted.org/packages/02/e0/3630057d8eb170310785723ed5adcdfb7d50cb7e6455f85ba8a3deed642b/tomli-2.4.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1d8591993e228b0c930c4bb0db464bdad97b3289fb981255d6c9a41aedc84b2d", size = 270717 }, + { url = "https://files.pythonhosted.org/packages/7a/b4/1613716072e544d1a7891f548d8f9ec6ce2faf42ca65acae01d76ea06bb0/tomli-2.4.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:734e20b57ba95624ecf1841e72b53f6e186355e216e5412de414e3c51e5e3c41", size = 278461 }, + { url = "https://files.pythonhosted.org/packages/05/38/30f541baf6a3f6df77b3df16b01ba319221389e2da59427e221ef417ac0c/tomli-2.4.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8a650c2dbafa08d42e51ba0b62740dae4ecb9338eefa093aa5c78ceb546fcd5c", size = 274855 }, + { url = "https://files.pythonhosted.org/packages/77/a3/ec9dd4fd2c38e98de34223b995a3b34813e6bdadf86c75314c928350ed14/tomli-2.4.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:504aa796fe0569bb43171066009ead363de03675276d2d121ac1a4572397870f", size = 283144 }, + { url = "https://files.pythonhosted.org/packages/ef/be/605a6261cac79fba2ec0c9827e986e00323a1945700969b8ee0b30d85453/tomli-2.4.1-cp314-cp314t-win32.whl", hash = "sha256:b1d22e6e9387bf4739fbe23bfa80e93f6b0373a7f1b96c6227c32bef95a4d7a8", size = 108683 }, + { url = "https://files.pythonhosted.org/packages/12/64/da524626d3b9cc40c168a13da8335fe1c51be12c0a63685cc6db7308daae/tomli-2.4.1-cp314-cp314t-win_amd64.whl", hash = "sha256:2c1c351919aca02858f740c6d33adea0c5deea37f9ecca1cc1ef9e884a619d26", size = 121196 }, + { url = "https://files.pythonhosted.org/packages/5a/cd/e80b62269fc78fc36c9af5a6b89c835baa8af28ff5ad28c7028d60860320/tomli-2.4.1-cp314-cp314t-win_arm64.whl", hash = "sha256:eab21f45c7f66c13f2a9e0e1535309cee140182a9cdae1e041d02e47291e8396", size = 100393 }, + { url = "https://files.pythonhosted.org/packages/7b/61/cceae43728b7de99d9b847560c262873a1f6c98202171fd5ed62640b494b/tomli-2.4.1-py3-none-any.whl", hash = "sha256:0d85819802132122da43cb86656f8d1f8c6587d54ae7dcaf30e90533028b49fe", size = 14583 }, +] + +[[package]] +name = "typing-extensions" +version = "4.15.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/94/1a15dd82efb362ac84269196e94cf00f187f7ed21c242792a923cdb1c61f/typing_extensions-4.15.0.tar.gz", hash = "sha256:0cea48d173cc12fa28ecabc3b837ea3cf6f38c6d1136f85cbaaf598984861466", size = 109391 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/67/36e9267722cc04a6b9f15c7f3441c2363321a3ea07da7ae0c0707beb2a9c/typing_extensions-4.15.0-py3-none-any.whl", hash = "sha256:f0fa19c6845758ab08074a0cfa8b7aecb71c999ca73d62883bc25cc018c4e548", size = 44614 }, +] diff --git a/scripts/migrate-to-r2.ts b/scripts/migrate-to-r2.ts index 019b46423..56f7fa975 100644 --- a/scripts/migrate-to-r2.ts +++ b/scripts/migrate-to-r2.ts @@ -1,5 +1,7 @@ import { PutObjectCommand, S3Client } from "@aws-sdk/client-s3"; -import { PrismaClient } from "@prisma/client"; +import { PrismaClient } from "@/generated/prisma/client"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { sanitizeDatabaseUrl } from "@/lib/db-url"; import sharp from "sharp"; const REQUIRED_ENV_VARS = [ @@ -8,6 +10,8 @@ const REQUIRED_ENV_VARS = [ "CLOUDFLARE_R2_SECRET_ACCESS_KEY", "CLOUDFLARE_R2_BUCKET_NAME", ] as const; +const VERCEL_BLOB_HOST_SUFFIX = ".public.blob.vercel-storage.com"; +const MAX_IMAGE_BYTES = 20 * 1024 * 1024; function checkEnvVars() { const missing = REQUIRED_ENV_VARS.filter((key) => !process.env[key]); @@ -19,21 +23,72 @@ function checkEnvVars() { } } -function isVercelBlobUrl(url: string): boolean { - return ( - url.startsWith("https://") && - url.includes(".public.blob.vercel-storage.com") - ); +function parseVercelBlobUrl(urlString: string): URL | null { + try { + const url = new URL(urlString); + if ( + url.protocol !== "https:" || + !url.hostname.endsWith(VERCEL_BLOB_HOST_SUFFIX) + ) { + return null; + } + return url; + } catch { + return null; + } } function toSlug(mapName: string): string { return mapName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); } +async function readCappedBody(response: Response): Promise { + const contentLength = response.headers.get("content-length"); + if (contentLength && Number(contentLength) > MAX_IMAGE_BYTES) { + throw new Error(`Image is larger than ${MAX_IMAGE_BYTES} bytes`); + } + + const reader = response.body?.getReader(); + if (!reader) throw new Error("Response body is empty"); + + const chunks: Uint8Array[] = []; + let totalBytes = 0; + + while (true) { + const { done, value } = await reader.read(); + if (done) break; + totalBytes += value.byteLength; + if (totalBytes > MAX_IMAGE_BYTES) { + throw new Error(`Image is larger than ${MAX_IMAGE_BYTES} bytes`); + } + chunks.push(value); + } + + return Buffer.concat(chunks, totalBytes); +} + +async function downloadImage(url: URL): Promise { + const response = await fetch(url, { redirect: "manual" }); + if (!response.ok) { + throw new Error(`HTTP ${response.status} ${response.statusText}`); + } + + const contentType = response.headers.get("content-type")?.toLowerCase(); + if (!contentType?.startsWith("image/")) { + throw new Error(`Unexpected content type: ${contentType ?? "unknown"}`); + } + + return readCappedBody(response); +} + async function main() { checkEnvVars(); - const prisma = new PrismaClient(); + const prisma = new PrismaClient({ + adapter: new PrismaPg({ + connectionString: sanitizeDatabaseUrl(process.env.DATABASE_URL), + }), + }); const s3Client = new S3Client({ region: "auto", @@ -72,7 +127,8 @@ async function main() { const record = records[i]; const index = i + 1; - if (!isVercelBlobUrl(record.imageUrl)) { + const sourceUrl = parseVercelBlobUrl(record.imageUrl); + if (!sourceUrl) { console.log( `[${index}/${total}] Skipping "${record.mapName}" — not a Vercel Blob URL` ); @@ -84,12 +140,7 @@ async function main() { let originalBuffer: Buffer | null = null; for (let attempt = 0; attempt < 3; attempt++) { try { - const response = await fetch(record.imageUrl); - if (!response.ok) { - throw new Error(`HTTP ${response.status} ${response.statusText}`); - } - const arrayBuffer = await response.arrayBuffer(); - originalBuffer = Buffer.from(arrayBuffer); + originalBuffer = await downloadImage(sourceUrl); break; } catch (fetchErr) { if (attempt < 2) { @@ -105,21 +156,26 @@ async function main() { } if (!originalBuffer) throw new Error("Download failed after 3 attempts"); - const slug = toSlug(record.mapName); + const slug = `${record.id}-${toSlug(record.mapName)}`; const originalKey = `map-images/${slug}/original.png`; const displayKey = `map-images/${slug}/display.png`; - await upload(originalKey, originalBuffer, "image/png"); - const metadata = await sharp(originalBuffer).metadata(); + if (!metadata.width || !metadata.height) { + throw new Error("Downloaded file is not a readable image"); + } + + const originalPngBuffer = await sharp(originalBuffer).png().toBuffer(); + await upload(originalKey, originalPngBuffer, "image/png"); + let displayBuffer: Buffer; - if (metadata.width && metadata.width > 2560) { + if (metadata.width > 2560) { displayBuffer = await sharp(originalBuffer) .resize(2560) .png() .toBuffer(); } else { - displayBuffer = originalBuffer; + displayBuffer = originalPngBuffer; } await upload(displayKey, displayBuffer, "image/png"); diff --git a/scripts/migrate-winrate-tracker.ts b/scripts/migrate-winrate-tracker.ts new file mode 100644 index 000000000..c2d70dfa3 --- /dev/null +++ b/scripts/migrate-winrate-tracker.ts @@ -0,0 +1,308 @@ +/** + * One-time migration script: winrate-tracker → Parsertime ranked matches. + * + * Reads the old winrate-tracker Postgres DB via `pg` (raw SQL, old schema), + * resolves each old user to a Parsertime user (email-first, OAuth-key fallback), + * then either upserts their ranked matches or parks a RankedImportClaim for + * manual claim later. + * + * Usage: + * WINRATE_DATABASE_URL=postgres://... pnpm exec tsx scripts/migrate-winrate-tracker.ts + * WINRATE_DATABASE_URL=postgres://... pnpm exec tsx scripts/migrate-winrate-tracker.ts --dry-run + */ + +import fs from "node:fs"; +import path from "node:path"; +import { Pool } from "pg"; +import { PrismaPg } from "@prisma/adapter-pg"; +import { sanitizeDatabaseUrl } from "@/lib/db-url"; +import { PrismaClient } from "@/generated/prisma/client"; +import type { RankedExportBundle } from "@/lib/ranked/export-schema"; + +// --------------------------------------------------------------------------- +// Types for the old winrate-tracker schema rows +// --------------------------------------------------------------------------- + +type OldUserRow = { + id: string; + email: string; + name: string; +}; + +type OldAccountRow = { + userId: string; + providerId: string; + accountId: string; +}; + +type OldMatchRow = { + id: string; + userId: string; + map: string; + mapType: string; + result: string; + groupSize: number; + playedAt: Date; +}; + +type OldMatchHeroRow = { + matchId: string; + hero: string; + role: string; + percentage: number; +}; + +// --------------------------------------------------------------------------- +// Pure resolver — exported for unit tests +// --------------------------------------------------------------------------- + +export type OldUserIdentity = { email: string; oauthKeys: string[] }; + +export function pickParsertimeMatch( + oldUser: OldUserIdentity, + byEmail: Map, + byOauthKey: Map +): string | null { + const emailHit = byEmail.get(oldUser.email); + if (emailHit) return emailHit; + for (const key of oldUser.oauthKeys) { + const oauthHit = byOauthKey.get(key); + if (oauthHit) return oauthHit; + } + return null; +} + +// --------------------------------------------------------------------------- +// main() +// --------------------------------------------------------------------------- + +async function main(): Promise { + const dryRun = process.argv.includes("--dry-run"); + if (dryRun) { + console.log("[migrate] DRY RUN — no writes will be made"); + } + + const winrateUrl = process.env.WINRATE_DATABASE_URL; + if (!winrateUrl) { + console.error( + "Missing required environment variable: WINRATE_DATABASE_URL" + ); + process.exit(1); + } + + const pool = new Pool({ connectionString: winrateUrl }); + const prisma = new PrismaClient({ + adapter: new PrismaPg({ + connectionString: sanitizeDatabaseUrl(process.env.DATABASE_URL), + }), + }); + + try { + // ----------------------------------------------------------------------- + // 1. Load all data from old DB + // ----------------------------------------------------------------------- + + const { rows: oldUsers } = await pool.query( + 'SELECT id, email, name FROM "user"' + ); + console.log(`[migrate] Old users: ${oldUsers.length}`); + + const { rows: oldAccounts } = await pool.query( + 'SELECT "userId", "providerId", "accountId" FROM "account"' + ); + + const { rows: oldMatches } = await pool.query( + 'SELECT id, "userId", map, "mapType", result, "groupSize", "playedAt" FROM "match"' + ); + console.log(`[migrate] Old matches: ${oldMatches.length}`); + + const { rows: oldMatchHeroes } = await pool.query( + 'SELECT "matchId", hero, role, percentage FROM "match_hero"' + ); + + // ----------------------------------------------------------------------- + // 2. Build lookup maps from the old data + // ----------------------------------------------------------------------- + + // userId → oauthKeys[] + const oauthKeysByUserId = new Map(); + for (const acc of oldAccounts) { + const key = `${acc.providerId}:${acc.accountId}`; + const existing = oauthKeysByUserId.get(acc.userId); + if (existing) { + existing.push(key); + } else { + oauthKeysByUserId.set(acc.userId, [key]); + } + } + + // matchId → heroes[] + const heroesByMatchId = new Map< + string, + Array<{ hero: string; role: string; percentage: number }> + >(); + for (const h of oldMatchHeroes) { + const existing = heroesByMatchId.get(h.matchId); + const entry = { hero: h.hero, role: h.role, percentage: h.percentage }; + if (existing) { + existing.push(entry); + } else { + heroesByMatchId.set(h.matchId, [entry]); + } + } + + // userId → matches[] + const matchesByUserId = new Map(); + for (const m of oldMatches) { + const existing = matchesByUserId.get(m.userId); + if (existing) { + existing.push(m); + } else { + matchesByUserId.set(m.userId, [m]); + } + } + + // ----------------------------------------------------------------------- + // 3. Build Parsertime identity indexes + // ----------------------------------------------------------------------- + + const ptUsers = await prisma.user.findMany({ + select: { id: true, email: true }, + }); + const ptByEmail = new Map(); + for (const u of ptUsers) { + if (u.email) ptByEmail.set(u.email, u.id); + } + + const ptAccounts = await prisma.account.findMany({ + select: { provider: true, providerAccountId: true, userId: true }, + }); + const ptByOauthKey = new Map(); + for (const a of ptAccounts) { + ptByOauthKey.set(`${a.provider}:${a.providerAccountId}`, a.userId); + } + + // ----------------------------------------------------------------------- + // 4. Process each old user + // ----------------------------------------------------------------------- + + let linked = 0; + let parked = 0; + + for (const oldUser of oldUsers) { + const oauthKeys = oauthKeysByUserId.get(oldUser.id) ?? []; + const identity: OldUserIdentity = { email: oldUser.email, oauthKeys }; + + const userMatches = matchesByUserId.get(oldUser.id) ?? []; + + // Build the export bundle (the canonical shape, also used as claim payload) + const bundle: RankedExportBundle = { + version: 1, + user: { + email: oldUser.email, + oauthAccounts: oauthKeys.map((key) => { + const colonIdx = key.indexOf(":"); + return { + provider: key.slice(0, colonIdx), + providerAccountId: key.slice(colonIdx + 1), + }; + }), + }, + matches: userMatches + .filter((m) => + (["win", "loss", "draw"] as string[]).includes(m.result) + ) + .map((m) => ({ + sourceId: m.id, + map: m.map, + mapType: m.mapType, + result: m.result as "win" | "loss" | "draw", + groupSize: m.groupSize, + playedAt: + m.playedAt instanceof Date + ? m.playedAt.toISOString() + : new Date(m.playedAt).toISOString(), + heroes: heroesByMatchId.get(m.id) ?? [], + })), + }; + + const ptUserId = pickParsertimeMatch(identity, ptByEmail, ptByOauthKey); + + if (ptUserId !== null) { + // Upsert each match (idempotent) + if (!dryRun) { + for (const m of bundle.matches) { + await prisma.rankedMatch.upsert({ + where: { + userId_sourceId: { userId: ptUserId, sourceId: m.sourceId }, + }, + create: { + userId: ptUserId, + map: m.map, + mapType: m.mapType, + result: m.result, + groupSize: m.groupSize, + playedAt: new Date(m.playedAt), + sourceId: m.sourceId, + heroes: { create: [...m.heroes] }, + }, + update: {}, + }); + } + } + console.log( + `[migrate] Linked ${oldUser.email} → ${ptUserId} (${bundle.matches.length} match(es))` + ); + linked++; + } else { + // Park a claim and write a per-user export file + if (!dryRun) { + await prisma.rankedImportClaim.create({ + data: { + email: oldUser.email, + oauthKey: oauthKeys[0] ?? null, + // Prisma Json field accepts any serialisable value + payload: bundle as object, + }, + }); + + const exportDir = path.resolve( + path.dirname(new URL(import.meta.url).pathname), + "..", + "migration-exports" + ); + fs.mkdirSync(exportDir, { recursive: true }); + const safeEmail = oldUser.email.replace(/[^a-zA-Z0-9@._-]/g, "_"); + fs.writeFileSync( + path.join(exportDir, `${safeEmail}.json`), + JSON.stringify(bundle, null, 2) + ); + } + console.log( + `[migrate] Parked claim for ${oldUser.email} (${bundle.matches.length} match(es))` + ); + parked++; + } + } + + console.log( + `\n[migrate] Summary — Linked: ${linked}, Parked: ${parked}, dryRun=${dryRun}` + ); + } finally { + await pool.end(); + await prisma.$disconnect(); + } +} + +// Entrypoint guard — compatible with both ESM (import.meta.url) and tsx runner +const isMain = + process.argv[1] !== undefined && + (process.argv[1].endsWith("migrate-winrate-tracker.ts") || + process.argv[1].endsWith("migrate-winrate-tracker.js")); + +if (isMain) { + main().catch((e: unknown) => { + console.error(e); + process.exit(1); + }); +} diff --git a/scripts/seed-patches.ts b/scripts/seed-patches.ts new file mode 100644 index 000000000..aa837e035 --- /dev/null +++ b/scripts/seed-patches.ts @@ -0,0 +1,42 @@ +#!/usr/bin/env bun +import { upsertScrapedPatches } from "@/data/overwatch/patches-service"; +import { scrapeMonth } from "@/lib/overwatch/patch-scraper"; + +const START_YEAR = 2026; +const START_MONTH = 1; // January 2026 onward + +async function main() { + const now = new Date(); + const endYear = now.getUTCFullYear(); + const endMonth = now.getUTCMonth() + 1; + + const totals = { inserted: 0, updated: 0, skipped: 0 }; + + for (let year = START_YEAR; year <= endYear; year++) { + const from = year === START_YEAR ? START_MONTH : 1; + const to = year === endYear ? endMonth : 12; + for (let month = from; month <= to; month++) { + try { + const patches = await scrapeMonth(year, month); + const result = await upsertScrapedPatches(patches); + totals.inserted += result.inserted; + totals.updated += result.updated; + totals.skipped += result.skipped; + console.log( + `${year}-${String(month).padStart(2, "0")}: ` + + `${patches.length} scraped, +${result.inserted} new, ` + + `~${result.updated} updated, ${result.skipped} skipped` + ); + } catch (err) { + console.error(`${year}-${month} failed:`, err); + } + } + } + + console.log("Done:", totals); +} + +main().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/scripts/seed-scouting-data.ts b/scripts/seed-scouting-data.ts index 4884402f2..6e09046d0 100644 --- a/scripts/seed-scouting-data.ts +++ b/scripts/seed-scouting-data.ts @@ -5,8 +5,8 @@ import { type MapType, type RosterCategory, type RosterRole, -} from "@prisma/client"; -import { JsonValue } from "@prisma/client/runtime/library"; +} from "@/generated/prisma/client"; +import type { Prisma } from "@/generated/prisma/client"; import { readFileSync } from "fs"; import { resolve } from "path"; @@ -209,7 +209,7 @@ async function seedMatch(tournamentId: number, match: MatchData) { winnerFullName: cleanTeamName(match.winnerFullName), matchDate, mvp: match.mvp, - vods: (match.vods as unknown as JsonValue[]) ?? [], + vods: (match.vods as unknown as Prisma.JsonValue[]) ?? [], matchRoomUrl: match.matchRoomUrl ?? null, }, }); diff --git a/scripts/seed-zone-proposals.ts b/scripts/seed-zone-proposals.ts new file mode 100644 index 000000000..2df34f815 --- /dev/null +++ b/scripts/seed-zone-proposals.ts @@ -0,0 +1,45 @@ +#!/usr/bin/env bun + +import prisma from "@/lib/prisma"; +import { classifyZonesForCalibration } from "@/lib/zones/classify"; + +async function main() { + const calibrations = await prisma.mapCalibration.findMany({ + select: { id: true, mapName: true }, + orderBy: { mapName: "asc" }, + }); + console.log(`Found ${calibrations.length} calibrations`); + + let proposed = 0; + let skipped = 0; + let failed = 0; + + for (const { id, mapName } of calibrations) { + try { + const result = await classifyZonesForCalibration(id, "zone-seed-script"); + if (result.ok) { + proposed++; + console.log( + `${mapName}: ${result.pointZones} point(s), ${result.laneZones} lane(s)` + ); + } else { + skipped++; + console.log(`${mapName}: skipped (${result.reason})`); + } + } catch (error) { + failed++; + console.error(`${mapName}: FAILED`, error); + } + } + + console.log( + `\nDone. ${proposed} proposed, ${skipped} skipped, ${failed} failed.` + ); +} + +main() + .catch((error) => { + console.error(error); + process.exitCode = 1; + }) + .finally(() => prisma.$disconnect()); diff --git a/scripts/tsr-bootstrap.ts b/scripts/tsr-bootstrap.ts new file mode 100644 index 000000000..227cc2f3e --- /dev/null +++ b/scripts/tsr-bootstrap.ts @@ -0,0 +1,148 @@ +#!/usr/bin/env bun +/** + * One-shot TSR bootstrap. Use this once after the schema migration to seed + * the database with a real player pool — the daily cron only re-ingests + * players we already track, so it can't populate an empty DB. + * + * Steps: + * 1. Sweep the tracked organizers and upsert all championships (with + * auto-classification). + * 2. For each seed player, fetch their history → upsert all + * championship-type matches → cascade to all roster co-players. + * 2.5. (--deep only) Pull championship history for EVERY known + * FaceitPlayer, not just seeds. Discovers Regular Season / + * Playoffs championships the org's /championships endpoint + * omits — those are only visible via player match history. + * 3. Backfill every tracked, classified championship in the DB by + * pulling its full match list (past + ongoing). Fills in seasons + * that no seed player has covered. + * 4. Enrich every FaceitPlayer row with their full profile (readable + * BattleTag, region, verified flag, skill level). + * 5. Run a full TSR recompute. + * + * Steps 2.5 and 3 are the slow ones (thousands of API calls each). + * Flags: + * --deep include step 2.5 (recommended for first full sync) + * --skip-backfill skip step 3 + * + * Usage: + * FACEIT_API_KEY= bun scripts/tsr-bootstrap.ts [seed-nicknames…] + * + * Defaults to "pge" and "baYek9" if no seed nicknames are passed. + */ + +import { + backfillTrackedChampionships, + discoverAllTrackedChampionships, + enrichKnownPlayers, + ingestKnownPlayersHistory, + ingestPlayerHistory, + upsertFullPlayer, +} from "@/lib/tsr/ingest"; +import { recomputeAllTsrs } from "@/lib/tsr/replay"; +import { + type FaceitPlayerLookupResult, + getPlayerById, + lookupPlayerByNickname, + searchPlayers, +} from "@/lib/tsr/faceit-client"; + +const DEFAULT_SEEDS = ["pge", "baYek9"]; + +async function resolveSeed( + nick: string +): Promise { + const direct = await lookupPlayerByNickname(nick); + if (direct) return direct; + const candidates = await searchPlayers(nick); + if (candidates.length === 0) return null; + const ranked = candidates.slice().sort((a, b) => { + if (!!b.verified !== !!a.verified) return b.verified ? 1 : -1; + const sa = Number(a.games?.find((g) => g.name === "ow2")?.skill_level ?? 0); + const sb = Number(b.games?.find((g) => g.name === "ow2")?.skill_level ?? 0); + return sb - sa; + }); + const picked = ranked[0]!; + return getPlayerById(picked.player_id); +} + +async function main() { + if (!process.env.FACEIT_API_KEY) { + console.error("FACEIT_API_KEY env var is required"); + process.exit(1); + } + + const args = process.argv.slice(2); + const skipBackfill = args.includes("--skip-backfill"); + const deep = args.includes("--deep"); + const seeds = args.filter((a) => !a.startsWith("--")); + const seedNicks = seeds.length > 0 ? seeds : DEFAULT_SEEDS; + const totalSteps = 3 + (deep ? 1 : 0) + (skipBackfill ? 0 : 1); + let step = 0; + const next = () => ++step; + + console.log( + `\n[${next()}/${totalSteps}] Discovering championships under tracked organizers…` + ); + const discovery = await discoverAllTrackedChampionships(); + console.log(` ${JSON.stringify(discovery, null, 2)}`); + + console.log( + `\n[${next()}/${totalSteps}] Ingesting seed players: ${seedNicks.join(", ")}` + ); + for (const nick of seedNicks) { + console.log(`\n → ${nick}`); + const player = await resolveSeed(nick); + if (!player) { + console.warn(` ! no FACEIT player found for "${nick}"`); + continue; + } + console.log(` found ${player.nickname} (${player.player_id})`); + await upsertFullPlayer(player); + const result = await ingestPlayerHistory(player.player_id, { + maxPages: 20, + }); + console.log( + ` scanned=${result.scanned} ingested=${result.ingested} skipped=${result.skipped} affected=${result.affectedPlayerIds.size}` + ); + } + + if (deep) { + console.log( + `\n[${next()}/${totalSteps}] Deep history scan over every known player (discovers hidden championships)…` + ); + const deepResult = await ingestKnownPlayersHistory({ maxPages: 20 }); + console.log( + ` scanned=${deepResult.scanned} ingested=${deepResult.ingested} skipped=${deepResult.skipped} failed=${deepResult.failed} new_championships=${deepResult.newChampionships}` + ); + } + + if (!skipBackfill) { + console.log( + `\n[${next()}/${totalSteps}] Backfilling every tracked championship (past + ongoing)…` + ); + const backfill = await backfillTrackedChampionships(); + console.log( + ` championships=${backfill.championships} ingested=${backfill.totalIngested} skipped=${backfill.totalSkipped}` + ); + } + + console.log( + `\n[${next()}/${totalSteps}] Enriching player profiles (readable BattleTags, regions)…` + ); + const enrich = await enrichKnownPlayers(); + console.log( + ` scanned=${enrich.scanned} enriched=${enrich.enriched} failed=${enrich.failed}` + ); + + console.log(`\n[${next()}/${totalSteps}] Running full TSR recompute…`); + const replay = await recomputeAllTsrs(); + console.log(` ${JSON.stringify(replay)}`); + + console.log(`\nDone.\n`); +} + +main().catch((err) => { + console.error("\n!! bootstrap failed:", err); + process.exit(1); +}); diff --git a/scripts/tsr-dry-run.ts b/scripts/tsr-dry-run.ts new file mode 100644 index 000000000..560dbe2ac --- /dev/null +++ b/scripts/tsr-dry-run.ts @@ -0,0 +1,687 @@ +#!/usr/bin/env bun +/** + * TSR (Tournament Skill Rating) dry-run. + * + * Pulls real FACEIT data for a seed player + opponents, replays the rating + * algorithm in-memory, and prints per-player TSRs alongside the matches that + * moved them most. No DB writes, no schema changes. + * + * Usage: + * FACEIT_API_KEY=xxx bun scripts/tsr-dry-run.ts [seed-nickname] + * + * Default seed nickname is "pge". Cached responses live in /tmp/parsertime-tsr-cache. + * Delete that directory to force fresh fetches. + */ + +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const FACEIT_BASE = "https://open.faceit.com/data/v4"; +const API_KEY = process.env.FACEIT_API_KEY; +if (!API_KEY) { + console.error("FACEIT_API_KEY env var is required"); + process.exit(1); +} + +const CACHE_DIR = "/tmp/parsertime-tsr-cache"; +mkdirSync(CACHE_DIR, { recursive: true }); + +const TRACKED_ORGANIZERS = new Set([ + "faceit_ow2", // Overdrive Cup, WASB Cup, FACEIT-run cups + "abd401de-e6ec-4ef1-8d4b-3d820f8f62ce", // OWCS 2024 (NA + EMEA stages) + "f0e8a591-08fd-4619-9d59-d97f0571842e", // FACEIT Master league + OWCS Central S1-S8 + 2026 OQ + "37d7c27f-ddb7-4c2c-91d5-771cfe3376cd", // Calling All Heroes +]); + +type Tier = + | "open" + | "advanced" + | "expert" + | "masters" + | "owcs" + | "cah" + | "unclassified"; + +const TIER_PRIORS: Record = { + unclassified: 2500, + open: 2500, + advanced: 2800, + expert: 3100, + masters: 3450, + owcs: 3850, + cah: 2500, // CAH explicitly does not have a tier prior; falls back to mean +}; + +const TIER_RANK: Record = { + unclassified: 0, + open: 1, + cah: 1, + advanced: 2, + expert: 3, + masters: 4, + owcs: 5, +}; + +// Bumped from spec default of 180. With 180d half-life, 2-year-old OWCS data +// weighs ~6%, which means historical anchor wins barely move the rating and +// inactive players cling to their priors. 365d half-life keeps 2024 matches +// at ~25% weight — historical results still pull active players upward. +const RECENCY_HALF_LIFE_DAYS = 365; +// Display floor: a player must have at least this many tracked matches in the +// last DISPLAY_ACTIVITY_WINDOW_DAYS to appear in the active leaderboard. +// Inactive players still have a computed rating; we just don't surface them. +const DISPLAY_MIN_RECENT_MATCHES = 3; +const DISPLAY_ACTIVITY_WINDOW_DAYS = 365; +const SOFT_CAP_START = 4000; +const HARD_CEILING = 5000; +const HARD_FLOOR = 1; + +const SEED_NICKNAME = process.argv[2] ?? "pge"; +const HISTORY_PAGE_LIMIT = 100; +const MAX_HISTORY_PAGES = 20; +const OPPONENT_FANOUT = 12; // additional players pulled from seed's match history +const FETCH_DELAY_MS = 60; + +function sleep(ms: number): Promise { + return new Promise((r) => setTimeout(r, ms)); +} + +function cachePath(key: string): string { + return join(CACHE_DIR, `${key.replace(/[^a-z0-9_-]/gi, "_")}.json`); +} + +async function faceit(path: string, cacheKey?: string): Promise { + const key = cacheKey ?? path; + const cp = cachePath(key); + if (existsSync(cp)) { + return JSON.parse(readFileSync(cp, "utf-8")) as T; + } + await sleep(FETCH_DELAY_MS); + const res = await fetch(`${FACEIT_BASE}${path}`, { + headers: { + Authorization: `Bearer ${API_KEY}`, + Accept: "application/json", + }, + }); + if (!res.ok) { + const body = await res.text(); + throw new Error(`FACEIT ${res.status} ${path}: ${body.slice(0, 300)}`); + } + const json = (await res.json()) as T; + writeFileSync(cp, JSON.stringify(json)); + return json; +} + +function classifyTier(name: string | undefined): Tier { + if (!name) return "unclassified"; + const n = name.toLowerCase(); + // OW2 is 5v5; reject mini formats (1v1/2v2/3v3 duels and brawl learnings) + // which run under the literal "faceit" organizer and would skew Elo wildly. + if ( + /\b1v1\b|\b2v2\b|\b3v3\b|brawl vs brawl|\belimination\b|mini-poke|knight & squire|trial event/.test( + n + ) + ) { + return "unclassified"; + } + // OWCS umbrella — distinguish open qualifier paths from real OWCS play. + // S4-S8 "OWCS Central - Regular Season/Playoffs" → owcs (top NA league). + // OWCS 2024 Stage X Open Qualifiers / OWCS 202X Open Qualifier / OQ Phase → open. + // OWWC (Overwatch World Cup) is also OWCS-tier prestige. + if (/owcs|champions series|\bowwc\b/.test(n)) { + if (/open\s*qualif|\boq\b|\bqualifier\b/.test(n)) return "open"; + return "owcs"; + } + if (/master/.test(n)) return "masters"; + if (/expert/.test(n)) return "expert"; + if (/advanced/.test(n)) return "advanced"; + if (/calling all heroes|\bcah\b/.test(n)) return "cah"; + // FACEIT cups + generic open / qualifier. + if (/open|qualif|cup|showdown/.test(n)) return "open"; + return "unclassified"; +} + +function inferRegion( + name: string | undefined, + faceitRegion: string | undefined +): "NA" | "EMEA" | "OTHER" { + const n = (name ?? "").toLowerCase(); + if (/\bna\b|north america|americas/.test(n)) return "NA"; + if (/\bemea\b|\beu\b|europe/.test(n)) return "EMEA"; + if (faceitRegion === "US") return "NA"; + if (faceitRegion === "EU") return "EMEA"; + return "OTHER"; +} + +function kBase(matchCount: number): number { + if (matchCount < 5) return 48; + if (matchCount < 15) return 32; + if (matchCount < 30) return 24; + return 16; +} + +function movMultiplier(bestOf: number, scoreA: number, scoreB: number): number { + const maxDiff = Math.ceil(bestOf / 2); + if (maxDiff <= 0) return 1; + const actualDiff = Math.abs(scoreA - scoreB); + const closeness = (maxDiff - actualDiff) / maxDiff; + return 1.5 - closeness; +} + +function gainDampener(rating: number, deltaSign: number): number { + if (deltaSign <= 0) return 1; + if (rating <= SOFT_CAP_START) return 1; + const x = (rating - SOFT_CAP_START) / 1000; + return Math.max(0, 1 - x * x); +} + +function recencyWeight(ageDays: number): number { + return Math.pow(0.5, ageDays / RECENCY_HALF_LIFE_DAYS); +} + +function clamp(x: number, lo: number, hi: number): number { + return Math.max(lo, Math.min(hi, x)); +} + +interface FaceitPlayerLookup { + player_id: string; + nickname: string; + country?: string; + games?: Record< + string, + { game_player_id?: string; game_player_name?: string; region?: string } + >; +} + +interface HistoryItem { + match_id: string; + game_id?: string; + competition_id?: string; + competition_type?: string; // "championship" | "matchmaking" | "hub" + competition_name?: string; + organizer_id?: string; + status?: string; + finished_at?: number; + started_at?: number; +} + +interface HistoryResponse { + items: HistoryItem[]; + start: number; + end: number; +} + +interface MatchDetail { + match_id: string; + competition_id?: string; + competition_type?: string; + competition_name?: string; + organizer_id?: string; + region?: string; + status: string; + best_of?: number; + finished_at?: number; + started_at?: number; + results?: { + winner?: string; + score?: { faction1?: number; faction2?: number }; + }; + teams?: { + faction1?: { + name?: string; + roster?: Array<{ player_id: string; nickname: string }>; + }; + faction2?: { + name?: string; + roster?: Array<{ player_id: string; nickname: string }>; + }; + }; +} + +interface SearchPlayerItem { + player_id: string; + nickname: string; + country?: string; + verified?: boolean; + games?: Array<{ name: string; skill_level?: string | number }>; +} + +interface SearchPlayersResponse { + items: SearchPlayerItem[]; +} + +async function lookupPlayer(query: string): Promise { + // If the query is exact and matches a real FACEIT nickname, this 200s. + // For pros whose FACEIT handle differs from their in-game name, this 404s + // and we fall through to fuzzy search. + try { + return await faceit( + `/players?nickname=${encodeURIComponent(query)}&game=ow2`, + `player_lookup_${query}` + ); + } catch (err) { + if (!String(err).includes("404")) throw err; + } + + const search = await faceit( + `/search/players?nickname=${encodeURIComponent(query)}&game=ow2&offset=0&limit=20`, + `player_search_${query}` + ); + const ow2Candidates = search.items.filter((p) => + p.games?.some((g) => g.name === "ow2") + ); + if (ow2Candidates.length === 0) { + throw new Error( + `No OW2-bound FACEIT player found for "${query}". Search returned ${search.items.length} total candidates.` + ); + } + + // Rank: verified first, then by OW2 skill level desc. + const ranked = ow2Candidates.slice().sort((a, b) => { + if (!!b.verified !== !!a.verified) return b.verified ? 1 : -1; + const sa = Number(a.games?.find((g) => g.name === "ow2")?.skill_level ?? 0); + const sb = Number(b.games?.find((g) => g.name === "ow2")?.skill_level ?? 0); + return sb - sa; + }); + + console.log( + ` (no exact match; search returned ${ow2Candidates.length} OW2 candidates):` + ); + for (const c of ranked.slice(0, 5)) { + const sl = c.games?.find((g) => g.name === "ow2")?.skill_level ?? "?"; + const v = c.verified ? "✓" : " "; + console.log( + ` ${v} ${c.nickname.padEnd(20)} ${c.player_id} country=${c.country ?? "?"} skill=${sl}` + ); + } + const picked = ranked[0]!; + console.log(` picking top: ${picked.nickname}`); + return faceit( + `/players/${picked.player_id}`, + `player_${picked.player_id}` + ); +} + +async function getPlayerHistory(playerId: string): Promise { + const all: HistoryItem[] = []; + for (let page = 0; page < MAX_HISTORY_PAGES; page++) { + const offset = page * HISTORY_PAGE_LIMIT; + const data = await faceit( + `/players/${playerId}/history?game=ow2&offset=${offset}&limit=${HISTORY_PAGE_LIMIT}`, + `history_${playerId}_${offset}` + ); + if (!data.items?.length) break; + all.push(...data.items); + if (data.items.length < HISTORY_PAGE_LIMIT) break; + } + return all; +} + +async function getMatch(matchId: string): Promise { + return faceit(`/matches/${matchId}`, `match_${matchId}`); +} + +interface RatedMatch { + match_id: string; + finished_at: number; // unix seconds + championship_id: string; + championship_name: string; + tier: Tier; + region: "NA" | "EMEA" | "OTHER"; + best_of: number; + score: [number, number]; // faction1, faction2 + winner_faction: 1 | 2; + faction1: string[]; // player_ids + faction2: string[]; // player_ids + status: "finished"; +} + +interface PlayerState { + player_id: string; + nickname: string; + region: "NA" | "EMEA" | "OTHER"; + rating: number; + match_count: number; + first_tier?: Tier; + contributions: Array<{ + match_id: string; + delta: number; + opp_rating: number; + won: boolean; + finished_at: number; + tier: Tier; + score: [number, number]; + }>; +} + +async function main() { + console.log(`\n=== TSR dry-run · seed "${SEED_NICKNAME}" ===\n`); + + // 1. Look up seed player. + console.log(`[1/5] Looking up seed player…`); + const seed = await lookupPlayer(SEED_NICKNAME); + console.log( + ` ${seed.nickname} (player_id=${seed.player_id}, country=${seed.country ?? "?"})` + ); + const ow2 = seed.games?.ow2; + if (ow2) { + console.log( + ` BattleTag: ${ow2.game_player_id ?? "?"} region: ${ow2.region ?? "?"}` + ); + } + + // 2. Pull seed's full match history → fetch full match details for every + // championship-type match. We classify tier and verify organizer from the + // match detail itself (which carries competition_name and organizer_id), + // bypassing /championships/{id} entirely — that endpoint 404s for many + // active and archived events. + console.log(`\n[2/5] Fetching seed match history & details…`); + const seedHistory = await getPlayerHistory(seed.player_id); + const seedChampMatches = seedHistory.filter( + (m) => m.competition_type === "championship" + ); + console.log( + ` ${seedHistory.length} total matches, ${seedChampMatches.length} championship-type` + ); + const matchDetails = new Map(); + for (const m of seedChampMatches) { + try { + const md = await getMatch(m.match_id); + matchDetails.set(m.match_id, md); + } catch (err) { + console.warn( + ` ! match ${m.match_id} fetch failed: ${(err as Error).message}` + ); + } + } + + // 3. Co-occurrence-based opponent fanout. + console.log(`\n[3/5] Expanding to opponents (1 hop)…`); + const opponentCounts = new Map(); + for (const md of matchDetails.values()) { + const roster = [ + ...(md.teams?.faction1?.roster ?? []), + ...(md.teams?.faction2?.roster ?? []), + ]; + for (const r of roster) { + if (r.player_id === seed.player_id) continue; + const cur = opponentCounts.get(r.player_id) ?? { + nickname: r.nickname, + count: 0, + }; + cur.count += 1; + cur.nickname = r.nickname; + opponentCounts.set(r.player_id, cur); + } + } + const opponents = [...opponentCounts.entries()] + .sort((a, b) => b[1].count - a[1].count) + .slice(0, OPPONENT_FANOUT); + for (const [pid, info] of opponents) { + console.log( + ` - ${info.nickname.padEnd(24)} ${pid} (${info.count} co-matches w/ seed)` + ); + } + for (const [pid, info] of opponents) { + const hist = await getPlayerHistory(pid); + const champMatches = hist.filter( + (m) => m.competition_type === "championship" + ); + let added = 0; + for (const m of champMatches) { + if (matchDetails.has(m.match_id)) continue; + try { + const md = await getMatch(m.match_id); + matchDetails.set(m.match_id, md); + added += 1; + } catch { + /* ignore */ + } + } + console.log( + ` + ${info.nickname.padEnd(24)} pulled ${added} new champ matches` + ); + } + console.log( + ` total championship-type matches in pool: ${matchDetails.size}` + ); + + // 4. Filter to tracked organizer + classifiable tier; build rated-match list. + console.log(`\n[4/5] Classifying & filtering matches…`); + const ratedMatches: RatedMatch[] = []; + let skippedForfeits = 0; + let skippedUntracked = 0; + let skippedUnclassified = 0; + let skippedMissingData = 0; + const tierBuckets = new Map(); + for (const md of matchDetails.values()) { + if (md.status !== "FINISHED" && md.status !== "finished") { + skippedForfeits += 1; + continue; + } + if (!md.organizer_id || !TRACKED_ORGANIZERS.has(md.organizer_id)) { + skippedUntracked += 1; + continue; + } + const tier = classifyTier(md.competition_name); + if (tier === "unclassified") { + skippedUnclassified += 1; + continue; + } + const f1 = md.teams?.faction1?.roster?.map((r) => r.player_id) ?? []; + const f2 = md.teams?.faction2?.roster?.map((r) => r.player_id) ?? []; + const s1 = md.results?.score?.faction1; + const s2 = md.results?.score?.faction2; + const winner = md.results?.winner; + if ( + f1.length === 0 || + f2.length === 0 || + s1 === undefined || + s2 === undefined || + !winner || + !md.finished_at + ) { + skippedMissingData += 1; + continue; + } + const winnerFaction: 1 | 2 = winner === "faction1" ? 1 : 2; + const bestOf = md.best_of ?? Math.max(s1 + s2, 3); + const region = inferRegion(md.competition_name, md.region); + ratedMatches.push({ + match_id: md.match_id, + finished_at: md.finished_at, + championship_id: md.competition_id ?? "", + championship_name: md.competition_name ?? "?", + tier, + region, + best_of: bestOf, + score: [s1, s2], + winner_faction: winnerFaction, + faction1: f1, + faction2: f2, + status: "finished", + }); + tierBuckets.set(tier, (tierBuckets.get(tier) ?? 0) + 1); + } + ratedMatches.sort((a, b) => a.finished_at - b.finished_at); + console.log( + ` → ${ratedMatches.length} usable matches; skipped ${skippedForfeits} forfeits, ${skippedUntracked} untracked-org, ${skippedUnclassified} unclassified, ${skippedMissingData} with missing data` + ); + console.log(` tier mix:`, Object.fromEntries(tierBuckets)); + + console.log( + `\n[5/5] Replaying ${ratedMatches.length} matches chronologically…` + ); + + // Pre-scan: per player, find the highest tier they've ever appeared in. + // We diverge from the spec's "first observed tier" rule because that rewards + // inactive players (Dovestopher cling to OWCS prior with 3 stale matches) + // and punishes players who started in OWCS Open Quals before reaching Main + // Event (pge anchored at open-2500). Highest-tier-reached gives every player + // credit for the level they've actually played at; the activity-window + // display floor handles surfacing. + const maxTierByPlayer = new Map(); + for (const m of ratedMatches) { + for (const pid of [...m.faction1, ...m.faction2]) { + const cur = maxTierByPlayer.get(pid); + if (!cur || TIER_RANK[m.tier] > TIER_RANK[cur]) { + maxTierByPlayer.set(pid, m.tier); + } + } + } + + const players = new Map(); + function ensurePlayer( + playerId: string, + region: "NA" | "EMEA" | "OTHER", + nickname: string + ) { + let p = players.get(playerId); + if (!p) { + const maxTier = maxTierByPlayer.get(playerId) ?? "unclassified"; + p = { + player_id: playerId, + nickname, + region, + rating: TIER_PRIORS[maxTier], + match_count: 0, + first_tier: maxTier, // field name kept; semantically now "max tier" + contributions: [], + }; + players.set(playerId, p); + } else { + if (!p.nickname && nickname) p.nickname = nickname; + if (p.region === "OTHER" && region !== "OTHER") p.region = region; + } + return p; + } + + // Build a nickname index from match details (for nicer reporting). + const nicknameByPid = new Map(); + for (const md of matchDetails.values()) { + for (const r of md.teams?.faction1?.roster ?? []) + nicknameByPid.set(r.player_id, r.nickname); + for (const r of md.teams?.faction2?.roster ?? []) + nicknameByPid.set(r.player_id, r.nickname); + } + nicknameByPid.set(seed.player_id, seed.nickname); + + const todayMs = Date.now(); + for (const m of ratedMatches) { + const ageDays = (todayMs - m.finished_at * 1000) / (1000 * 60 * 60 * 24); + const recency = recencyWeight(ageDays); + const mov = movMultiplier(m.best_of, m.score[0], m.score[1]); + + // Average team rating per faction (for the head-to-head Elo). + const f1Players = m.faction1.map((pid) => + ensurePlayer(pid, m.region, nicknameByPid.get(pid) ?? "") + ); + const f2Players = m.faction2.map((pid) => + ensurePlayer(pid, m.region, nicknameByPid.get(pid) ?? "") + ); + const f1Avg = + f1Players.reduce((s, p) => s + p.rating, 0) / f1Players.length; + const f2Avg = + f2Players.reduce((s, p) => s + p.rating, 0) / f2Players.length; + + // Apply per-player Elo update against the opposing team's average. + function update(side: PlayerState[], oppAvg: number, won: boolean) { + for (const p of side) { + const expected = 1 / (1 + Math.pow(10, (oppAvg - p.rating) / 400)); + const actual = won ? 1 : 0; + const k = kBase(p.match_count); + const baseDelta = k * mov * recency * (actual - expected); + const dampener = gainDampener(p.rating, baseDelta); + const delta = baseDelta * dampener; + p.rating = clamp(p.rating + delta, HARD_FLOOR, HARD_CEILING); + p.match_count += 1; + p.contributions.push({ + match_id: m.match_id, + delta, + opp_rating: oppAvg, + won, + finished_at: m.finished_at, + tier: m.tier, + score: m.score, + }); + } + } + const f1Won = m.winner_faction === 1; + update(f1Players, f2Avg, f1Won); + update(f2Players, f1Avg, !f1Won); + } + + // Report. Active = ≥DISPLAY_MIN_RECENT_MATCHES tracked matches in the + // last DISPLAY_ACTIVITY_WINDOW_DAYS. Inactive players still have a TSR; + // we just don't surface them in the ranked board. + const todayMsForReport = Date.now(); + const activityCutoffSec = + (todayMsForReport - DISPLAY_ACTIVITY_WINDOW_DAYS * 86400 * 1000) / 1000; + function recentMatchCount(p: PlayerState): number { + return p.contributions.filter((c) => c.finished_at >= activityCutoffSec) + .length; + } + function lastMatchDate(p: PlayerState): { sec: number; iso: string } { + const sec = p.contributions.reduce((m, c) => Math.max(m, c.finished_at), 0); + return { + sec, + iso: sec ? new Date(sec * 1000).toISOString().slice(0, 10) : "—", + }; + } + + const allPlayers = [...players.values()].filter((p) => p.match_count >= 3); + const active = allPlayers + .filter((p) => recentMatchCount(p) >= DISPLAY_MIN_RECENT_MATCHES) + .sort((a, b) => b.rating - a.rating); + const inactive = allPlayers + .filter((p) => recentMatchCount(p) < DISPLAY_MIN_RECENT_MATCHES) + .sort((a, b) => b.rating - a.rating); + + console.log( + `\n=== Active TSRs (${active.length} players · ≥${DISPLAY_MIN_RECENT_MATCHES} matches in last ${DISPLAY_ACTIVITY_WINDOW_DAYS}d) ===\n` + ); + console.log( + " rank rating nickname region total recent prior last-match" + ); + for (const [i, p] of active.entries()) { + const tier = p.first_tier ?? "unclassified"; + const last = lastMatchDate(p); + console.log( + ` ${String(i + 1).padStart(4)} ${String(Math.round(p.rating)).padStart(6)} ${p.nickname.padEnd(22)} ${p.region.padEnd(6)} ${String(p.match_count).padStart(5)} ${String(recentMatchCount(p)).padStart(6)} ${`${tier}(${TIER_PRIORS[tier]})`.padEnd(13)} ${last.iso}` + ); + } + + console.log( + `\n--- Inactive (top 10 of ${inactive.length}, hidden from active board) ---` + ); + for (const p of inactive.slice(0, 10)) { + const tier = p.first_tier ?? "unclassified"; + const last = lastMatchDate(p); + console.log( + ` ${String(Math.round(p.rating)).padStart(6)} ${p.nickname.padEnd(22)} ${p.region.padEnd(6)} ${String(p.match_count).padStart(5)} ${String(recentMatchCount(p)).padStart(6)} ${`${tier}(${TIER_PRIORS[tier]})`.padEnd(13)} ${last.iso}` + ); + } + + // Highlight seed's contributing matches. + const seedState = players.get(seed.player_id); + if (seedState) { + console.log(`\n=== ${seed.nickname} top swings ===\n`); + const top = [...seedState.contributions] + .sort((a, b) => Math.abs(b.delta) - Math.abs(a.delta)) + .slice(0, 8); + for (const c of top) { + const date = new Date(c.finished_at * 1000).toISOString().slice(0, 10); + const sign = c.delta >= 0 ? "+" : ""; + console.log( + ` ${date} ${c.tier.padEnd(8)} ${c.won ? "W" : "L"} ${c.score[0]}-${c.score[1]} vs avg ${Math.round(c.opp_rating)} Δ ${sign}${c.delta.toFixed(1)}` + ); + } + } + + console.log(`\nDone. Cache lives in ${CACHE_DIR} — delete to refetch.\n`); +} + +main().catch((err) => { + console.error("\n!! dry-run failed:", err); + process.exit(1); +}); diff --git a/scripts/tsr-recompute-only.mjs b/scripts/tsr-recompute-only.mjs new file mode 100644 index 000000000..829f8e17a --- /dev/null +++ b/scripts/tsr-recompute-only.mjs @@ -0,0 +1,339 @@ +#!/usr/bin/env bun + +import { PrismaPg } from "@prisma/adapter-pg"; +import { sanitizeDatabaseUrl } from "../src/lib/db-url.ts"; +import { PrismaClient } from "../src/generated/prisma/client.ts"; + +const prisma = new PrismaClient({ + adapter: new PrismaPg({ + connectionString: sanitizeDatabaseUrl(process.env.DATABASE_URL), + }), +}); + +const TIER_PRIORS = { + UNCLASSIFIED: 2500, + OPEN: 2500, + CAH: 2500, + ADVANCED: 2800, + EXPERT: 3100, + MASTERS: 3450, + OWCS: 3850, +}; + +const TIER_RANK = { + UNCLASSIFIED: 0, + OPEN: 1, + CAH: 1, + ADVANCED: 2, + EXPERT: 3, + MASTERS: 4, + OWCS: 5, +}; + +const DISPLAY_ACTIVITY_WINDOW_DAYS = 365; +const RECENCY_HALF_LIFE_DAYS = 365; +const TSR_HARD_FLOOR = 1; +const TSR_HARD_CEILING = 5000; +const TSR_SOFT_CAP_START = 4000; +const TSR_RECOMPUTE_LOCK_ID = 732401; +const MATCH_PAGE_SIZE = 1000; +const UPSERT_CHUNK_SIZE = 500; + +function kBase(matchCount) { + if (matchCount < 5) return 48; + if (matchCount < 15) return 32; + if (matchCount < 30) return 24; + return 16; +} + +function movMultiplier(bestOf, scoreA, scoreB) { + const maxDiff = Math.ceil(bestOf / 2); + if (maxDiff <= 0) return 1; + const actualDiff = Math.abs(scoreA - scoreB); + const closeness = (maxDiff - actualDiff) / maxDiff; + return 1.5 - closeness; +} + +function gainDampener(rating, deltaSign) { + if (deltaSign <= 0) return 1; + if (rating <= TSR_SOFT_CAP_START) return 1; + const x = (rating - TSR_SOFT_CAP_START) / 1000; + return Math.max(0, 1 - x * x); +} + +function recencyWeight(ageDays) { + return Math.pow(0.5, ageDays / RECENCY_HALF_LIFE_DAYS); +} + +function clampRating(x) { + return Math.max(TSR_HARD_FLOOR, Math.min(TSR_HARD_CEILING, x)); +} + +async function loadReplayMatches() { + const out = []; + let page = 0; + + for (;;) { + const rows = await prisma.faceitMatch.findMany({ + where: { + status: "FINISHED", + championship: { tier: { not: "UNCLASSIFIED" } }, + }, + select: { + faceitMatchId: true, + finishedAt: true, + bestOf: true, + team1Score: true, + team2Score: true, + winnerFaction: true, + championship: { select: { tier: true } }, + }, + orderBy: [{ finishedAt: "asc" }, { faceitMatchId: "asc" }], + skip: page * MATCH_PAGE_SIZE, + take: MATCH_PAGE_SIZE, + }); + + if (rows.length === 0) break; + + const matchIds = rows.map((m) => m.faceitMatchId); + const [rosters, overrides] = await Promise.all([ + prisma.faceitMatchRoster.findMany({ + where: { matchId: { in: matchIds } }, + select: { matchId: true, teamSide: true, faceitPlayerId: true }, + }), + prisma.tsrRosterOverride.findMany({ + where: { matchId: { in: matchIds } }, + select: { + matchId: true, + faceitPlayerId: true, + action: true, + teamSide: true, + }, + }), + ]); + + const rostersByMatch = new Map(); + for (const roster of rosters) { + const list = rostersByMatch.get(roster.matchId) ?? []; + list.push(roster); + rostersByMatch.set(roster.matchId, list); + } + + const overridesByMatch = new Map(); + for (const override of overrides) { + const list = overridesByMatch.get(override.matchId) ?? []; + list.push(override); + overridesByMatch.set(override.matchId, list); + } + + for (const match of rows) { + const matchRosters = rostersByMatch.get(match.faceitMatchId) ?? []; + const matchOverrides = overridesByMatch.get(match.faceitMatchId) ?? []; + const exclude = new Set( + matchOverrides + .filter((o) => o.action === "EXCLUDE") + .map((o) => o.faceitPlayerId) + ); + const includeBySide = { 1: [], 2: [] }; + for (const override of matchOverrides) { + if ( + override.action === "INCLUDE" && + (override.teamSide === 1 || override.teamSide === 2) + ) { + includeBySide[override.teamSide].push(override.faceitPlayerId); + } + } + + const faction1 = matchRosters + .filter((r) => r.teamSide === 1 && !exclude.has(r.faceitPlayerId)) + .map((r) => r.faceitPlayerId) + .concat(includeBySide[1]); + const faction2 = matchRosters + .filter((r) => r.teamSide === 2 && !exclude.has(r.faceitPlayerId)) + .map((r) => r.faceitPlayerId) + .concat(includeBySide[2]); + + if (faction1.length === 0 || faction2.length === 0) continue; + if (match.winnerFaction !== 1 && match.winnerFaction !== 2) continue; + + out.push({ + matchId: match.faceitMatchId, + finishedAt: match.finishedAt, + bestOf: match.bestOf, + team1Score: match.team1Score, + team2Score: match.team2Score, + winnerFaction: match.winnerFaction, + tier: match.championship.tier, + faction1, + faction2, + }); + } + + page += 1; + console.log(`loaded ${out.length} replayable matches...`); + } + + return out; +} + +async function recomputeAllTsrs() { + const lockStart = Date.now(); + const [lock] = await prisma.$queryRaw` + SELECT pg_try_advisory_lock(${TSR_RECOMPUTE_LOCK_ID}) AS locked + `; + if (!lock?.locked) { + return { + matchesReplayed: 0, + playersUpdated: 0, + staleRowsDropped: 0, + durationMs: Date.now() - lockStart, + skipped: true, + }; + } + + try { + return await recomputeAllTsrsUnlocked(); + } finally { + await prisma.$executeRaw` + SELECT pg_advisory_unlock(${TSR_RECOMPUTE_LOCK_ID}) + `; + } +} + +async function recomputeAllTsrsUnlocked() { + const start = Date.now(); + const matches = await loadReplayMatches(); + + const maxTierByPlayer = new Map(); + for (const match of matches) { + for (const pid of [...match.faction1, ...match.faction2]) { + const cur = maxTierByPlayer.get(pid); + if (!cur || TIER_RANK[match.tier] > TIER_RANK[cur]) { + maxTierByPlayer.set(pid, match.tier); + } + } + } + + const states = new Map(); + function ensure(pid) { + let state = states.get(pid); + if (!state) { + const tier = maxTierByPlayer.get(pid) ?? "UNCLASSIFIED"; + state = { + rating: TIER_PRIORS[tier], + matchCount: 0, + recentCount: 0, + maxTier: tier, + }; + states.set(pid, state); + } + return state; + } + + const todayMs = Date.now(); + const recentCutoffMs = todayMs - DISPLAY_ACTIVITY_WINDOW_DAYS * 86400 * 1000; + + let replayed = 0; + for (const match of matches) { + const ageDays = + (todayMs - match.finishedAt.getTime()) / (1000 * 60 * 60 * 24); + const recency = recencyWeight(ageDays); + const mov = movMultiplier(match.bestOf, match.team1Score, match.team2Score); + const isRecent = match.finishedAt.getTime() >= recentCutoffMs; + + const f1States = match.faction1.map(ensure); + const f2States = match.faction2.map(ensure); + const f1Avg = + f1States.reduce((sum, player) => sum + player.rating, 0) / + f1States.length; + const f2Avg = + f2States.reduce((sum, player) => sum + player.rating, 0) / + f2States.length; + + function updateSide(side, oppAvg, won) { + for (const player of side) { + const expected = 1 / (1 + Math.pow(10, (oppAvg - player.rating) / 400)); + const actual = won ? 1 : 0; + const k = kBase(player.matchCount); + const baseDelta = k * mov * recency * (actual - expected); + const dampener = gainDampener(player.rating, baseDelta); + const delta = baseDelta * dampener; + player.rating = clampRating(player.rating + delta); + player.matchCount += 1; + if (isRecent) player.recentCount += 1; + } + } + + const f1Won = match.winnerFaction === 1; + updateSide(f1States, f2Avg, f1Won); + updateSide(f2States, f1Avg, !f1Won); + + replayed += 1; + if (replayed % 5000 === 0) { + console.log(`replayed ${replayed}/${matches.length} matches...`); + } + } + + const ids = [...states.keys()]; + const computedAt = new Date(); + const playerRows = ids.length + ? await prisma.faceitPlayer.findMany({ + where: { faceitPlayerId: { in: ids } }, + select: { faceitPlayerId: true, region: true }, + }) + : []; + const regionByPlayer = new Map( + playerRows.map((row) => [row.faceitPlayerId, row.region]) + ); + + let updated = 0; + for (let i = 0; i < ids.length; i += UPSERT_CHUNK_SIZE) { + const slice = ids.slice(i, i + UPSERT_CHUNK_SIZE); + await prisma.$transaction( + slice.map((pid) => { + const state = states.get(pid); + const region = regionByPlayer.get(pid) ?? "OTHER"; + return prisma.playerTsr.upsert({ + where: { faceitPlayerId: pid }, + create: { + faceitPlayerId: pid, + region, + rating: Math.round(state.rating), + matchCount: state.matchCount, + recentMatchCount365d: state.recentCount, + maxTierReached: state.maxTier, + computedAt, + }, + update: { + region, + rating: Math.round(state.rating), + matchCount: state.matchCount, + recentMatchCount365d: state.recentCount, + maxTierReached: state.maxTier, + computedAt, + }, + }); + }) + ); + updated += slice.length; + console.log(`upserted ${updated}/${ids.length} player TSR rows...`); + } + + const stale = await prisma.playerTsr.deleteMany({ + where: { computedAt: { lt: computedAt } }, + }); + + return { + matchesReplayed: matches.length, + playersUpdated: updated, + staleRowsDropped: stale.count, + durationMs: Date.now() - start, + }; +} + +try { + const result = await recomputeAllTsrs(); + console.log(JSON.stringify(result, null, 2)); +} finally { + await prisma.$disconnect(); +} diff --git a/scripts/wp/export-dataset.ts b/scripts/wp/export-dataset.ts new file mode 100644 index 000000000..cce79a827 --- /dev/null +++ b/scripts/wp/export-dataset.ts @@ -0,0 +1,70 @@ +#!/usr/bin/env bun + +import prisma from "@/lib/prisma"; +import { datasetToCsv } from "@/lib/win-probability/training/csv"; +import { + buildRows, + fetchEventLog, +} from "@/lib/win-probability/training/extract"; +import { MODE_FAMILIES } from "@/lib/win-probability/types"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const OUT_DIR = "artifacts/wp"; +const BATCH_SIZE = 50; + +async function main() { + fs.mkdirSync(OUT_DIR, { recursive: true }); + // Header-only serialization gives the canonical first line; row bodies are + // appended below via the same helper so output stays byte-identical. + const header = datasetToCsv([]); + const streams = new Map( + MODE_FAMILIES.map((family) => { + const stream = fs.createWriteStream( + path.join(OUT_DIR, `dataset-${family}.csv`) + ); + stream.write(header); + return [family, stream] as const; + }) + ); + + const maps = await prisma.matchStart.findMany({ + where: { map_type: { not: "Clash" }, MapDataId: { not: null } }, + select: { MapDataId: true }, + distinct: ["MapDataId"], + }); + console.log(`Exporting ${maps.length} maps…`); + + let written = 0; + let skipped = 0; + for (let i = 0; i < maps.length; i += BATCH_SIZE) { + const batch = maps.slice(i, i + BATCH_SIZE); + const logs = await Promise.all( + batch.map((m) => fetchEventLog(m.MapDataId as number)) + ); + for (let j = 0; j < batch.length; j++) { + const log = logs[j]; + if (log === null) { + skipped++; + continue; + } + const rows = buildRows(log, batch[j].MapDataId as number); + if (rows.length === 0) { + skipped++; // tie-labeled or empty map — counted, never silent + continue; + } + const stream = streams.get(log.modeFamily); + if (stream === undefined) continue; + // Drop the helper's header line; only the row body is appended here. + stream.write(datasetToCsv(rows).slice(header.length)); + written++; + } + console.log(` ${Math.min(i + BATCH_SIZE, maps.length)}/${maps.length}`); + } + + for (const stream of streams.values()) stream.end(); + console.log(`Done. Maps exported: ${written}, skipped: ${skipped}.`); + await prisma.$disconnect(); +} + +await main(); diff --git a/scripts/wp/gbm-prototype/.gitignore b/scripts/wp/gbm-prototype/.gitignore new file mode 100644 index 000000000..e32787f22 --- /dev/null +++ b/scripts/wp/gbm-prototype/.gitignore @@ -0,0 +1,5 @@ +data/ +.venv/ +__pycache__/ +*.pyc +results.json diff --git a/scripts/wp/gbm-prototype/README.md b/scripts/wp/gbm-prototype/README.md new file mode 100644 index 000000000..eec17b5b1 --- /dev/null +++ b/scripts/wp/gbm-prototype/README.md @@ -0,0 +1,20 @@ +# WP GBM bake-off (Phase 2a, offline) + +Local-only prototype answering whether a gradient-boosted-tree model beats the +shipped logistic regression at decisive moments (see +`docs/superpowers/specs/2026-06-14-wp-gbm-bakeoff-design.md`). Not serving code. +Python is managed with **uv**. + +## Setup (uv) + + uv sync # creates .venv and installs from pyproject.toml + uv.lock + +## Data + +`data/dataset-.csv` are the corrected 126-column matrices (3 meta + 123 +features), gitignored. Regenerate per the plan's Task 1 if missing. + +## Run + + uv run pytest # port unit tests (folds, metrics, calibration) + uv run python bakeoff.py # the full LR-vs-GBM comparison diff --git a/scripts/wp/gbm-prototype/bakeoff.py b/scripts/wp/gbm-prototype/bakeoff.py new file mode 100644 index 000000000..0a7d163fa --- /dev/null +++ b/scripts/wp/gbm-prototype/bakeoff.py @@ -0,0 +1,117 @@ +"""Runs LR-21, GBM-21, GBM-123 per mode on identical grouped-CV folds; reports +aggregate + decisive-subset metrics and GBM feature importances. Writes +results.json for the findings note. Offline analysis — not serving code.""" +import json +import os +import numpy as np + +from harness import ( + BASE_FEATURES, load_matrix, grouped_cv_predict, calibrated_metrics, + lr_fit_predict, self_validate, log_loss, brier, +) + +FAMILIES = ["control", "escort_hybrid", "flashpoint"] +DECISIVE_MARGIN = 0.3 # |calibrated pred - 0.5| > 0.3 -> a "decided" state + +def make_gbm_fit_predict(): + """LightGBM (preferred) or sklearn HistGradientBoosting fallback. Trees are + scale-invariant -> no standardization. Conservative params guard the smaller + flashpoint sample; this is a prototype, not tuned.""" + try: + from lightgbm import LGBMClassifier + def fit_predict(X_tr, y_tr, X_val): + clf = LGBMClassifier( + n_estimators=400, learning_rate=0.05, num_leaves=31, + min_child_samples=200, subsample=0.8, subsample_freq=1, + colsample_bytree=0.8, reg_lambda=1.0, random_state=42, + n_jobs=-1, verbose=-1, + ).fit(X_tr, y_tr) + fit_predict.last_model = clf + return clf.predict_proba(X_val)[:, 1] + fit_predict.kind = "lightgbm" + return fit_predict + except Exception as e: + from sklearn.ensemble import HistGradientBoostingClassifier + print(f"(lightgbm unavailable: {e}; using sklearn HistGradientBoosting)") + def fit_predict(X_tr, y_tr, X_val): + clf = HistGradientBoostingClassifier( + max_iter=400, learning_rate=0.05, max_leaf_nodes=31, + min_samples_leaf=200, l2_regularization=1.0, random_state=42, + ).fit(X_tr, y_tr) + fit_predict.last_model = clf + return clf.predict_proba(X_val)[:, 1] + fit_predict.kind = "hist_gbm" + return fit_predict + +def decisive_metrics(metrics, labels): + cp = metrics["calibrated_preds"] + mask = np.abs(cp - 0.5) > DECISIVE_MARGIN + if mask.sum() == 0: + return {"log_loss": None, "brier": None, "n": 0} + return { + "log_loss": log_loss(cp[mask].tolist(), labels[mask].tolist()), + "brier": brier(cp[mask].tolist(), labels[mask].tolist()), + "n": int(mask.sum()), + } + +def importances_for_full_model(fit_predict, feat_names, X, y): + """Train one full-data GBM to read gain importances (top 15).""" + fit_predict(X, y, X[:1]) + model = fit_predict.last_model + imp = getattr(model, "feature_importances_", None) + if imp is None: + return [] + imp = np.asarray(imp, dtype=float) + order = np.argsort(imp)[::-1][:15] + total = float(imp.sum()) or 1.0 + return [{"feature": feat_names[i], "gain_pct": round(100 * imp[i] / total, 2)} + for i in order] + +def run_config(name, path, n_features, fit_predict): + match_ids, X, y, feat_names = load_matrix(path, n_features) + preds, labels = grouped_cv_predict(match_ids, X, y, fit_predict) + agg = calibrated_metrics(preds, labels) + dec = decisive_metrics(agg, labels) + out = { + "config": name, + "aggregate": {"log_loss": agg["log_loss"], "brier": agg["brier"], + "cal_max_dev": agg["cal_max_dev"], "n": agg["n"]}, + "decisive": dec, + } + return out, (feat_names, X, y) + +def main(): + if not self_validate(): + raise SystemExit("Harness LR does not reproduce shipped LR — aborting.") + print() + gbm = make_gbm_fit_predict() + results = {"gbm_kind": gbm.kind, "decisive_margin": DECISIVE_MARGIN, "families": {}} + for fam in FAMILIES: + path = os.path.join("data", f"dataset-{fam}.csv") + fam_out = {"configs": []} + lr_out, _ = run_config("LR-21", path, BASE_FEATURES, lr_fit_predict) + fam_out["configs"].append(lr_out) + gbm21_out, _ = run_config("GBM-21", path, BASE_FEATURES, gbm) + fam_out["configs"].append(gbm21_out) + gbm123_out, full = run_config("GBM-123", path, None, gbm) + fam_out["configs"].append(gbm123_out) + feat_names, X, y = full + fam_out["gbm_123_importances"] = importances_for_full_model(gbm, feat_names, X, y) + lr_dec = lr_out["decisive"]["log_loss"] + best_gbm_dec = min(gbm21_out["decisive"]["log_loss"], gbm123_out["decisive"]["log_loss"]) + fam_out["decisive_rel_improvement_pct"] = round(100 * (lr_dec - best_gbm_dec) / lr_dec, 2) + results["families"][fam] = fam_out + print(f"\n=== {fam} ===") + for c in fam_out["configs"]: + d = c["decisive"] + print(f" {c['config']:8s} agg {c['aggregate']['log_loss']:.4f} " + f"(dev {c['aggregate']['cal_max_dev']:.3f}) " + f"decisive {d['log_loss']:.4f} (n={d['n']})") + print(f" decisive rel. improvement best-GBM vs LR: {fam_out['decisive_rel_improvement_pct']}%") + print(f" top GBM-123 importances: {fam_out['gbm_123_importances'][:5]}") + with open("results.json", "w") as fh: + json.dump(results, fh, indent=2) + print("\nWrote results.json") + +if __name__ == "__main__": + main() diff --git a/scripts/wp/gbm-prototype/build_artifact.py b/scripts/wp/gbm-prototype/build_artifact.py new file mode 100644 index 000000000..118724fe8 --- /dev/null +++ b/scripts/wp/gbm-prototype/build_artifact.py @@ -0,0 +1,42 @@ +"""Builds the merged GBM artifact locally: per mode, champion/challenger vs the +live R2 artifact (if reachable); writes artifacts/wp/model-gbm.json. No upload.""" +import json +import os +import urllib.request +from train_gbm import train_family + +FAMILIES = ["control", "escort_hybrid", "flashpoint"] +LATEST_MODEL_URL = os.environ.get("WP_LATEST_MODEL_URL") # public URL of the live model JSON, or unset + +def load_incumbents(): + if not LATEST_MODEL_URL: + print("WP_LATEST_MODEL_URL unset — treating all modes as no-incumbent (first GBM)") + return {} + try: + with urllib.request.urlopen(LATEST_MODEL_URL) as r: + art = json.load(r) + return {f: art["modeFamilies"].get(f) for f in FAMILIES} + except Exception as e: + print(f"incumbent load failed ({e}) — treating as no-incumbent") + return {} + +def main(): + incumbents = load_incumbents() + families = {"control": None, "escort_hybrid": None, "push": None, "flashpoint": None} + for fam in FAMILIES: + chosen = train_family(f"data/dataset-{fam}.csv", incumbents.get(fam)) + families[fam] = chosen + print(f"{fam}: shipped {chosen['kind']} logLoss={chosen['metrics']['logLoss']:.4f}") + artifact = { + "schemaVersion": 1, "modelVersion": 0, + "createdAt": "PLACEHOLDER", + "featureHash": os.environ["WP_FEATURE_HASH"], + "modeFamilies": families, + } + out = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", "..", "artifacts", "wp", "model-gbm.json")) + with open(out, "w") as fh: + json.dump(artifact, fh) + print(f"wrote {out}") + +if __name__ == "__main__": + main() diff --git a/scripts/wp/gbm-prototype/harness.py b/scripts/wp/gbm-prototype/harness.py new file mode 100644 index 000000000..361c8af10 --- /dev/null +++ b/scripts/wp/gbm-prototype/harness.py @@ -0,0 +1,185 @@ +"""Offline harness for the WP GBM bake-off. Ports the TypeScript grouped-CV +fold, metrics, and isotonic calibration so LR and GBM are judged identically +to the shipped pipeline. Not serving code.""" + +import math + +_EPS = 1e-12 +_FIT_BINS = 20 + + +def group_fold(match_id: int, k: int) -> int: + """FNV-1a over the decimal matchId string, mod k — matches cv.ts groupFold. + 32-bit unsigned arithmetic via & 0xFFFFFFFF (mirrors Math.imul + >>> 0).""" + s = str(match_id) + h = 0x811C9DC5 + for ch in s: + h ^= ord(ch) + h = (h * 0x01000193) & 0xFFFFFFFF + return h % k + + +def log_loss(preds, labels): + s = 0.0 + for p, y in zip(preds, labels): + p = min(1 - _EPS, max(_EPS, p)) + s += -math.log(p) if y == 1 else -math.log(1 - p) + return s / len(preds) + + +def brier(preds, labels): + return sum((p - y) ** 2 for p, y in zip(preds, labels)) / len(preds) + + +def fit_calibration(preds, labels): + """20-bin PAVA isotonic; knots = (mean pred, pooled observed rate). + Mirrors calibration.ts fitCalibration exactly.""" + bins = [{"pred": 0.0, "label": 0.0, "n": 0} for _ in range(_FIT_BINS)] + for p, y in zip(preds, labels): + idx = min(_FIT_BINS - 1, int(p * _FIT_BINS)) + bins[idx]["pred"] += p + bins[idx]["label"] += y + bins[idx]["n"] += 1 + blocks = [] + for b in bins: + if b["n"] == 0: + continue + blocks.append(dict(b)) + while len(blocks) >= 2: + last, prev = blocks[-1], blocks[-2] + if prev["label"] / prev["n"] <= last["label"] / last["n"]: + break + prev["pred"] += last["pred"] + prev["label"] += last["label"] + prev["n"] += last["n"] + blocks.pop() + return { + "x": [b["pred"] / b["n"] for b in blocks], + "y": [b["label"] / b["n"] for b in blocks], + } + + +def apply_calibration(cal, p): + x, y = cal["x"], cal["y"] + if not x: + return p + if p <= x[0]: + return y[0] + if p >= x[-1]: + return y[-1] + i = 1 + while x[i] < p: + i += 1 + t = (p - x[i - 1]) / (x[i] - x[i - 1]) + return y[i - 1] + t * (y[i] - y[i - 1]) + + +def calibration_max_deviation(preds, labels, k=10, min_n=200): + """Max |meanPred - meanLabel| over bins with n>=min_n — the gate metric + (metrics.ts checkGates uses k=10 bins, min 200 samples, threshold 0.1).""" + agg = [{"pred": 0.0, "label": 0.0, "n": 0} for _ in range(k)] + for p, y in zip(preds, labels): + idx = min(k - 1, int(p * k)) + agg[idx]["pred"] += p + agg[idx]["label"] += y + agg[idx]["n"] += 1 + worst = 0.0 + for b in agg: + if b["n"] < min_n: + continue + dev = abs(b["pred"] / b["n"] - b["label"] / b["n"]) + worst = max(worst, dev) + return worst + + +import numpy as np +import pandas as pd + +META_COLS = 3 # matchId, roundId, label +BASE_FEATURES = 21 # shipped LR feature count (first 21 columns) + + +def load_matrix(path, n_features=None): + """Returns (match_ids, X, y, feature_names). n_features slices the leading + feature columns (21 -> shipped set; None -> all 123).""" + df = pd.read_csv(path) + feat_cols = list(df.columns[META_COLS:]) + if n_features is not None: + feat_cols = feat_cols[:n_features] + return ( + df["matchId"].to_numpy(), + df[feat_cols].to_numpy(dtype=float), + df["label"].to_numpy(dtype=int), + feat_cols, + ) + + +def grouped_cv_predict(match_ids, X, y, fit_predict, k=5): + """k grouped folds (folds by group_fold(matchId)); fit_predict(X_tr, y_tr, + X_val) -> val probabilities. Returns pooled (preds, labels).""" + folds = np.array([group_fold(int(m), k) for m in match_ids]) + pooled_preds, pooled_labels = [], [] + for f in range(k): + tr, val = folds != f, folds == f + if val.sum() == 0 or tr.sum() == 0: + continue + preds = fit_predict(X[tr], y[tr], X[val]) + pooled_preds.extend(np.asarray(preds).tolist()) + pooled_labels.extend(y[val].tolist()) + return np.array(pooled_preds), np.array(pooled_labels) + + +def calibrated_metrics(preds, labels): + """Calibrate on pooled holdout (as the TS pipeline does) and report.""" + cal = fit_calibration(preds.tolist(), labels.tolist()) + cp = np.array([apply_calibration(cal, float(p)) for p in preds]) + return { + "log_loss": log_loss(cp.tolist(), labels.tolist()), + "brier": brier(cp.tolist(), labels.tolist()), + "cal_max_dev": calibration_max_deviation(cp.tolist(), labels.tolist()), + "n": int(len(labels)), + "calibrated_preds": cp, + } + + +def lr_fit_predict(X_tr, y_tr, X_val): + """Standardized logistic regression — the LR control.""" + from sklearn.preprocessing import StandardScaler + from sklearn.linear_model import LogisticRegression + sc = StandardScaler().fit(X_tr) + clf = LogisticRegression(max_iter=1000, C=1.0).fit(sc.transform(X_tr), y_tr) + return clf.predict_proba(sc.transform(X_val))[:, 1] + + +SHIPPED_LR = { # corrected LR (model-v2) calibrated CV log loss, for trust check + "control": 0.6180, + "escort_hybrid": 0.6635, + "flashpoint": 0.5891, +} +TOLERANCE = 0.006 + + +def self_validate(data_dir="data"): + import os + ok = True + for fam, expected in SHIPPED_LR.items(): + match_ids, X, y, _ = load_matrix( + os.path.join(data_dir, f"dataset-{fam}.csv"), BASE_FEATURES + ) + preds, labels = grouped_cv_predict(match_ids, X, y, lr_fit_predict) + m = calibrated_metrics(preds, labels) + delta = m["log_loss"] - expected + status = "OK" if abs(delta) <= TOLERANCE else "MISMATCH" + if status == "MISMATCH": + ok = False + print(f"[{status}] {fam}: harness LR {m['log_loss']:.4f} " + f"vs shipped {expected:.4f} (delta {delta:+.4f})") + return ok + + +if __name__ == "__main__": + import sys + if not self_validate(): + print("\nHarness LR does not reproduce the shipped LR — comparison untrustworthy.") + sys.exit(1) + print("\nHarness validated.") diff --git a/scripts/wp/gbm-prototype/make_parity_fixture.py b/scripts/wp/gbm-prototype/make_parity_fixture.py new file mode 100644 index 000000000..e0d4bbf4e --- /dev/null +++ b/scripts/wp/gbm-prototype/make_parity_fixture.py @@ -0,0 +1,36 @@ +"""Generates test/win-probability/fixtures/gbm-parity.json: a small GBM family +(serialized trees) + sample 21-feature rows + the probabilities LightGBM itself +produces (sigmoid of raw score, no isotonic). The TS parity test asserts TS +inference reproduces these, guarding the tree-traversal port.""" +import json +import os +import numpy as np +from lightgbm import LGBMClassifier +from serialize import serialize_booster + +N_FEATURES = 21 + +def main(): + rng = np.random.default_rng(7) + X = rng.normal(size=(4000, N_FEATURES)) + y = (X[:, 0] - X[:, 6] + 0.5 * X[:, 13] + rng.normal(scale=0.4, size=4000) > 0).astype(int) + clf = LGBMClassifier(n_estimators=60, num_leaves=16, learning_rate=0.08, + min_child_samples=40, random_state=7, verbose=-1).fit(X, y) + ser = serialize_booster(clf.booster_) + sample = rng.normal(size=(50, N_FEATURES)) + probs = clf.predict_proba(sample)[:, 1] # sigmoid(raw); no isotonic + fixture = { + "family": {"kind": "gbm", "baseScore": ser["baseScore"], "trees": ser["trees"], "sampleCount": 4000}, + "rows": sample.tolist(), + "expectedProbs": probs.tolist(), + } + out = os.path.abspath(os.path.join( + os.path.dirname(__file__), "..", "..", "..", + "test", "win-probability", "fixtures", "gbm-parity.json")) + os.makedirs(os.path.dirname(out), exist_ok=True) + with open(out, "w") as fh: + json.dump(fixture, fh) + print(f"wrote {out} ({len(probs)} rows, {len(ser['trees'])} trees)") + +if __name__ == "__main__": + main() diff --git a/scripts/wp/gbm-prototype/predict_check.py b/scripts/wp/gbm-prototype/predict_check.py new file mode 100644 index 000000000..197df113e --- /dev/null +++ b/scripts/wp/gbm-prototype/predict_check.py @@ -0,0 +1,56 @@ +"""Scores ~200 real rows per mode through the Python serving path (the same +serialize._tree_leaf + sigmoid + isotonic the artifact ships) and writes +data/py_preds_.json = [{row: [...21 floats], p: prob}] for the TS parity +check to reproduce. Reads artifacts/wp/model-gbm.json. No DB, no network.""" +import json +import math +import os + +import pandas as pd + +from harness import apply_calibration +from serialize import _tree_leaf + +FAMILIES = ["control", "escort_hybrid", "flashpoint"] +META_COLS = 3 +BASE_FEATURES = 21 +N_ROWS = 200 + + +def sigmoid(z): + return 1.0 / (1.0 + math.exp(-z)) + + +def gbm_raw_score(family, row): + s = family["baseScore"] + for tree in family["trees"]: + s += _tree_leaf(tree, row) + return s + + +def main(): + here = os.path.dirname(__file__) + art_path = os.path.abspath( + os.path.join(here, "..", "..", "..", "artifacts", "wp", "model-gbm.json") + ) + artifact = json.load(open(art_path)) + for fam in FAMILIES: + family = artifact["modeFamilies"][fam] + assert family is not None and family["kind"] == "gbm", fam + df = pd.read_csv(os.path.join(here, "data", f"dataset-{fam}.csv")) + feat_cols = list(df.columns[META_COLS : META_COLS + BASE_FEATURES]) + sub = df[feat_cols].head(N_ROWS).to_numpy(dtype=float) + cal = family["calibration"] + out = [] + for r in sub: + raw = sigmoid(gbm_raw_score(family, r)) + p = apply_calibration(cal, raw) + out.append({"row": [float(v) for v in r], "p": float(p)}) + dst = os.path.join(here, "data", f"py_preds_{fam}.json") + with open(dst, "w") as fh: + json.dump(out, fh) + print(f"{fam}: wrote {len(out)} preds -> {dst}") + + +if __name__ == "__main__": + main() diff --git a/scripts/wp/gbm-prototype/pyproject.toml b/scripts/wp/gbm-prototype/pyproject.toml new file mode 100644 index 000000000..7b78b5c63 --- /dev/null +++ b/scripts/wp/gbm-prototype/pyproject.toml @@ -0,0 +1,11 @@ +[project] +name = "wp-gbm-bakeoff" +version = "0.1.0" +requires-python = ">=3.11" +dependencies = [ + "lightgbm>=4.6.0", + "numpy>=2.4.6", + "pandas>=3.0.3", + "pytest>=9.1.0", + "scikit-learn>=1.9.0", +] diff --git a/scripts/wp/gbm-prototype/serialize.py b/scripts/wp/gbm-prototype/serialize.py new file mode 100644 index 000000000..44cfc569b --- /dev/null +++ b/scripts/wp/gbm-prototype/serialize.py @@ -0,0 +1,56 @@ +"""Serialize a trained LightGBM booster into the artifact tree schema the TS +serving path consumes. Raw score = baseScore + sum of reached leaf values; +sigmoid + isotonic are applied downstream. baseScore captures LightGBM's +boost-from-average init offset (a constant), derived empirically and asserted.""" +import numpy as np + + +def _flatten(node, out): + if "leaf_value" in node: + out.append({"leaf": float(node["leaf_value"])}) + return len(out) - 1 + assert node.get("decision_type", "<=") == "<=", "only numeric <= splits supported" + idx = len(out) + out.append(None) # reserve slot before recursing + left = _flatten(node["left_child"], out) + right = _flatten(node["right_child"], out) + out[idx] = { + "feature": int(node["split_feature"]), + "threshold": float(node["threshold"]), + "left": left, + "right": right, + "defaultLeft": bool(node["default_left"]), + } + return idx + + +def _tree_leaf(tree, x): + i = 0 + while True: + n = tree[i] + if "leaf" in n: + return n["leaf"] + v = x[n["feature"]] + if v != v: + i = n["left"] if n["defaultLeft"] else n["right"] + else: + i = n["left"] if v <= n["threshold"] else n["right"] + + +def serialize_booster(booster): + dumped = booster.dump_model() + trees = [] + for ti in dumped["tree_info"]: + nodes = [] + _flatten(ti["tree_structure"], nodes) + trees.append(nodes) + # Derive the constant init offset: raw_score - sum(reached leaves). + n_feat = booster.num_feature() + probe = np.zeros((8, n_feat)) + for j in range(8): + probe[j, j % n_feat] = 1.0 # vary inputs so the offset is exercised + raw = booster.predict(probe, raw_score=True) + offsets = [raw[r] - sum(_tree_leaf(t, probe[r]) for t in trees) for r in range(8)] + base = float(np.mean(offsets)) + assert float(np.std(offsets)) < 1e-9, f"init offset not constant: {offsets}" + return {"trees": trees, "baseScore": base} diff --git a/scripts/wp/gbm-prototype/test_harness.py b/scripts/wp/gbm-prototype/test_harness.py new file mode 100644 index 000000000..46ca666d6 --- /dev/null +++ b/scripts/wp/gbm-prototype/test_harness.py @@ -0,0 +1,40 @@ +import math +from harness import group_fold, log_loss, brier, fit_calibration, apply_calibration + +# (matchId, fold) pairs printed by the TS groupFold (plan Task 2 Step 2). +EXPECTED = [ + (1, 4), + (2, 1), + (100, 2), + (12345, 4), + (999999, 4), + (7187, 3), + (826444, 1), +] + + +def test_group_fold_matches_typescript(): + for match_id, fold in EXPECTED: + assert group_fold(match_id, 5) == fold + + +def test_log_loss_and_brier_basics(): + preds = [0.9, 0.1, 0.8, 0.2] + labels = [1, 0, 1, 0] + assert log_loss(preds, labels) < 0.25 + assert abs(brier(preds, labels) - 0.025) < 1e-9 # mean of 0.01,0.01,0.04,0.04 + + +def test_calibration_is_monotone_and_maps_ends(): + preds = [0.1] * 100 + [0.9] * 100 + labels = [1] * 30 + [0] * 70 + [1] * 80 + [0] * 20 # obs 0.30 then 0.80 + cal = fit_calibration(preds, labels) + assert all(cal["y"][i] <= cal["y"][i + 1] + 1e-12 for i in range(len(cal["y"]) - 1)) + assert abs(apply_calibration(cal, 0.1) - 0.30) < 0.05 + assert abs(apply_calibration(cal, 0.9) - 0.80) < 0.05 + + +def test_apply_calibration_clamps_outside_knots(): + cal = {"x": [0.2, 0.8], "y": [0.25, 0.75]} + assert apply_calibration(cal, 0.0) == 0.25 + assert apply_calibration(cal, 1.0) == 0.75 diff --git a/scripts/wp/gbm-prototype/test_serialize.py b/scripts/wp/gbm-prototype/test_serialize.py new file mode 100644 index 000000000..419157ba0 --- /dev/null +++ b/scripts/wp/gbm-prototype/test_serialize.py @@ -0,0 +1,27 @@ +import numpy as np +from lightgbm import LGBMClassifier +from serialize import serialize_booster + +def _leaf(tree, x): + i = 0 + while True: + n = tree[i] + if "leaf" in n: + return n["leaf"] + v = x[n["feature"]] + if v != v: # NaN + i = n["left"] if n["defaultLeft"] else n["right"] + else: + i = n["left"] if v <= n["threshold"] else n["right"] + +def test_serialized_trees_reproduce_lightgbm_raw_score(): + rng = np.random.default_rng(0) + X = rng.normal(size=(2000, 6)) + y = (X[:, 0] + 0.5 * X[:, 1] - X[:, 3] + rng.normal(scale=0.3, size=2000) > 0).astype(int) + clf = LGBMClassifier(n_estimators=30, num_leaves=8, learning_rate=0.1, + min_child_samples=20, random_state=0, verbose=-1).fit(X, y) + art = serialize_booster(clf.booster_) + lgb_raw = clf.booster_.predict(X[:200], raw_score=True) + for r in range(200): + ours = art["baseScore"] + sum(_leaf(tree, X[r]) for tree in art["trees"]) + assert abs(ours - lgb_raw[r]) < 1e-9 diff --git a/scripts/wp/gbm-prototype/test_train_gbm.py b/scripts/wp/gbm-prototype/test_train_gbm.py new file mode 100644 index 000000000..f86c0e528 --- /dev/null +++ b/scripts/wp/gbm-prototype/test_train_gbm.py @@ -0,0 +1,22 @@ +from train_gbm import choose_family + + +def _fam(kind, ll): + return {"kind": kind, "metrics": {"logLoss": ll, "brier": 0.2, "baseRate": 0.5}} + + +def test_challenger_ships_when_better_and_gated(): + assert choose_family(_fam("gbm", 0.60), gbm_gate_pass=True, incumbent=_fam("lr", 0.62))["kind"] == "gbm" + + +def test_incumbent_kept_when_gbm_worse(): + out = choose_family(_fam("gbm", 0.63), gbm_gate_pass=True, incumbent=_fam("lr", 0.62)) + assert out["kind"] == "lr" and out["metrics"]["logLoss"] == 0.62 + + +def test_incumbent_kept_when_gbm_fails_gate(): + assert choose_family(_fam("gbm", 0.50), gbm_gate_pass=False, incumbent=_fam("lr", 0.62))["kind"] == "lr" + + +def test_gbm_ships_when_no_incumbent(): + assert choose_family(_fam("gbm", 0.60), gbm_gate_pass=True, incumbent=None)["kind"] == "gbm" diff --git a/scripts/wp/gbm-prototype/train_gbm.py b/scripts/wp/gbm-prototype/train_gbm.py new file mode 100644 index 000000000..3fc94af07 --- /dev/null +++ b/scripts/wp/gbm-prototype/train_gbm.py @@ -0,0 +1,70 @@ +"""Per-mode GBM trainer: grouped CV (reuses harness), LightGBM fit, isotonic +calibration, gate, champion/challenger vs the live artifact's incumbent. +Produces the chosen family dict. Runs locally and inside the Vercel function.""" +import numpy as np +from lightgbm import LGBMClassifier + +from harness import ( + BASE_FEATURES, load_matrix, grouped_cv_predict, calibrated_metrics, + fit_calibration, +) +from serialize import serialize_booster + +GBM_PARAMS = dict( + n_estimators=400, learning_rate=0.05, num_leaves=31, min_child_samples=200, + subsample=0.8, subsample_freq=1, colsample_bytree=0.8, reg_lambda=1.0, + random_state=42, n_jobs=-1, verbose=-1, +) +CAL_MAX_DEV = 0.10 # gate (matches metrics.ts CALIBRATION_MAX_DEVIATION) + + +def _gbm_fit_predict(X_tr, y_tr, X_val): + return LGBMClassifier(**GBM_PARAMS).fit(X_tr, y_tr).predict_proba(X_val)[:, 1] + + +def gbm_gate_passes(metrics): + """Beat base-rate log loss AND calibration within tolerance (matches checkGates).""" + p = min(1 - 1e-12, max(1e-12, metrics["base_rate"])) + baseline = -(p * np.log(p) + (1 - p) * np.log(1 - p)) + return metrics["log_loss"] < baseline and metrics["cal_max_dev"] <= CAL_MAX_DEV + + +def choose_family(gbm_family, gbm_gate_pass, incumbent): + """Champion/challenger: ship GBM only if gated AND strictly better than the + incumbent's CV log loss; else carry the incumbent forward verbatim.""" + if not gbm_gate_pass: + return incumbent if incumbent is not None else gbm_family + if incumbent is None: + return gbm_family + if gbm_family["metrics"]["logLoss"] < incumbent["metrics"]["logLoss"]: + return gbm_family + return incumbent + + +def train_candidate(path): + """Train one mode's GBM + gate it. Returns (gbm_family_dict, gate_pass_bool). + No champion/challenger here — the TS publish route decides vs the incumbent.""" + match_ids, X, y, _ = load_matrix(path, None) + X = X[:, :BASE_FEATURES] # the 21 shipped features (handles 24- and 126-col CSVs) + preds, labels = grouped_cv_predict(match_ids, X, y, _gbm_fit_predict) + m = calibrated_metrics(preds, labels) + base_rate = float(np.mean(labels)) + gate = gbm_gate_passes({"log_loss": m["log_loss"], "cal_max_dev": m["cal_max_dev"], "base_rate": base_rate}) + booster = LGBMClassifier(**GBM_PARAMS).fit(X, y).booster_ + ser = serialize_booster(booster) + cal = fit_calibration(preds.tolist(), labels.tolist()) + family = { + "kind": "gbm", "trees": ser["trees"], "baseScore": ser["baseScore"], + "sampleCount": int(len(y)), + "calibration": {"x": cal["x"], "y": cal["y"]}, + "metrics": {"logLoss": m["log_loss"], "brier": m["brier"], "baseRate": base_rate}, + } + return family, gate + + +def train_family(path, incumbent): + """Train one mode; return the chosen family dict (gbm or carried incumbent). + Local build path — champion/challenger still runs here against the passed + incumbent (the Vercel function uses train_candidate + the TS publish route).""" + family, gate = train_candidate(path) + return choose_family(family, gate, incumbent) diff --git a/scripts/wp/gbm-prototype/uv.lock b/scripts/wp/gbm-prototype/uv.lock new file mode 100644 index 000000000..408af56cd --- /dev/null +++ b/scripts/wp/gbm-prototype/uv.lock @@ -0,0 +1,422 @@ +version = 1 +revision = 1 +requires-python = ">=3.11" +resolution-markers = [ + "python_full_version >= '3.14' and sys_platform == 'win32'", + "python_full_version >= '3.14' and sys_platform == 'emscripten'", + "python_full_version >= '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", + "python_full_version < '3.14' and sys_platform == 'win32'", + "python_full_version < '3.14' and sys_platform == 'emscripten'", + "python_full_version < '3.14' and sys_platform != 'emscripten' and sys_platform != 'win32'", +] + +[[package]] +name = "colorama" +version = "0.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d8/53/6f443c9a4a8358a93a6792e2acffb9d9d5cb0a5cfd8802644b7b1c9a02e4/colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44", size = 27697 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335 }, +] + +[[package]] +name = "iniconfig" +version = "2.3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/72/34/14ca021ce8e5dfedc35312d08ba8bf51fdd999c576889fc2c24cb97f4f10/iniconfig-2.3.0.tar.gz", hash = "sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730", size = 20503 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/b1/3846dd7f199d53cb17f49cba7e651e9ce294d8497c8c150530ed11865bb8/iniconfig-2.3.0-py3-none-any.whl", hash = "sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12", size = 7484 }, +] + +[[package]] +name = "joblib" +version = "1.5.3" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/41/f2/d34e8b3a08a9cc79a50b2208a93dce981fe615b64d5a4d4abee421d898df/joblib-1.5.3.tar.gz", hash = "sha256:8561a3269e6801106863fd0d6d84bb737be9e7631e33aaed3fb9ce5953688da3", size = 331603 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7b/91/984aca2ec129e2757d1e4e3c81c3fcda9d0f85b74670a094cc443d9ee949/joblib-1.5.3-py3-none-any.whl", hash = "sha256:5fc3c5039fc5ca8c0276333a188bbd59d6b7ab37fe6632daa76bc7f9ec18e713", size = 309071 }, +] + +[[package]] +name = "lightgbm" +version = "4.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "scipy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/68/0b/a2e9f5c5da7ef047cc60cef37f86185088845e8433e54d2e7ed439cce8a3/lightgbm-4.6.0.tar.gz", hash = "sha256:cb1c59720eb569389c0ba74d14f52351b573af489f230032a1c9f314f8bab7fe", size = 1703705 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f2/75/cffc9962cca296bc5536896b7e65b4a7cdeb8db208e71b9c0133c08f8f7e/lightgbm-4.6.0-py3-none-macosx_10_15_x86_64.whl", hash = "sha256:b7a393de8a334d5c8e490df91270f0763f83f959574d504c7ccb9eee4aef70ed", size = 2010151 }, + { url = "https://files.pythonhosted.org/packages/21/1b/550ee378512b78847930f5d74228ca1fdba2a7fbdeaac9aeccc085b0e257/lightgbm-4.6.0-py3-none-macosx_12_0_arm64.whl", hash = "sha256:2dafd98d4e02b844ceb0b61450a660681076b1ea6c7adb8c566dfd66832aafad", size = 1592172 }, + { url = "https://files.pythonhosted.org/packages/64/41/4fbde2c3d29e25ee7c41d87df2f2e5eda65b431ee154d4d462c31041846c/lightgbm-4.6.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:4d68712bbd2b57a0b14390cbf9376c1d5ed773fa2e71e099cac588703b590336", size = 3454567 }, + { url = "https://files.pythonhosted.org/packages/42/86/dabda8fbcb1b00bcfb0003c3776e8ade1aa7b413dff0a2c08f457dace22f/lightgbm-4.6.0-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:cb19b5afea55b5b61cbb2131095f50538bd608a00655f23ad5d25ae3e3bf1c8d", size = 3569831 }, + { url = "https://files.pythonhosted.org/packages/5e/23/f8b28ca248bb629b9e08f877dd2965d1994e1674a03d67cd10c5246da248/lightgbm-4.6.0-py3-none-win_amd64.whl", hash = "sha256:37089ee95664b6550a7189d887dbf098e3eadab03537e411f52c63c121e3ba4b", size = 1451509 }, +] + +[[package]] +name = "narwhals" +version = "2.22.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/62/3c/c4ef2164a71c1a63d7f1ae411c4082c5fa872405106db60a4b7114989ad7/narwhals-2.22.1.tar.gz", hash = "sha256:d62920805a0a43b7ff8b54b0c0d3142d796f8a9301836ada37e573d6a33cbcd9", size = 647493 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/48/ca/36339329c4604adbcc99c899b7eb1ce1a555c499b6a6860757dc9bfed36d/narwhals-2.22.1-py3-none-any.whl", hash = "sha256:60567d774edf77db53906f89d9fbd164e66e56d66d388e1e6990f17ac33cfb53", size = 454815 }, +] + +[[package]] +name = "numpy" +version = "2.4.6" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d0/ad/fed0499ce6a338d2a03ebae59cd15093910c8875328855781952abf6c2fe/numpy-2.4.6.tar.gz", hash = "sha256:f3a3570c4a2a16746ac2c31a7c7c7b0c186b95ce902e33db6f28094ed7387dda", size = 20735807 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b3/49/ec46835a70be8fa6446c495126ac84fdb28cb2558e1620ffb87a10c8b64c/numpy-2.4.6-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:0280e0356c0829a18d9de1cb7eee50ec22ca639878d7240307ca0943d73cd2c4", size = 16969194 }, + { url = "https://files.pythonhosted.org/packages/0e/0d/f5957185c0ee2f3e12f78715aa9e3b353fd83633316c8532b38faa37e3f6/numpy-2.4.6-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:110f8b71aacb688ec69062bb7f6938a0f8acb01b7c1c4beb453c65b6d234584d", size = 14964111 }, + { url = "https://files.pythonhosted.org/packages/ad/40/40a40ee0ddf7ceb782c49af278894b686e586d65d8c1889c8b5da01a3d7d/numpy-2.4.6-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:4cfe66903cc32a9921a6733d96b19bb6abf310397581bbad89c228f5abaf0ee8", size = 5469159 }, + { url = "https://files.pythonhosted.org/packages/63/13/f9a8046535cb21deae82f8d03de9617e08882d274fad2539630761888228/numpy-2.4.6-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:8155154c7c691289fe18f510b5d4657c68c67989f293f0535a91360392ff6538", size = 6798936 }, + { url = "https://files.pythonhosted.org/packages/33/a8/6fa8c1a345a8c85dbb21932c447bee07c30a2c2a3f31e369c0a84b300147/numpy-2.4.6-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ab0a9c4ffb1a6d95ef519fe4247dba8eb6b18ad93999f76b7f657039acabd47", size = 15966692 }, + { url = "https://files.pythonhosted.org/packages/02/03/74fe2a4cb3817d94d86402f2506554130a2f01414e299b5a843e5a8a957f/numpy-2.4.6-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:89cd468399cfd2504718f0ba50e410dca55a170b61a02ad92bb18c8a65186e93", size = 16918164 }, + { url = "https://files.pythonhosted.org/packages/c5/80/3615be3313f7e7696609bc194b9f0101da809df79e859bdb84e0cd043f46/numpy-2.4.6-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:c2d37ab77531417474168eb79d6d80b14f821a966818505d03013d0833edb7a8", size = 17322877 }, + { url = "https://files.pythonhosted.org/packages/ca/ac/a691e0fe2675e370d0e08ff905adc49a1c8830e8cae03efe4477e92cd55d/numpy-2.4.6-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f407cb6b8e9d6d8c626bc73c945db1706035af8fd632295547bf1c9e46d092d6", size = 18651487 }, + { url = "https://files.pythonhosted.org/packages/15/a7/9bc1cd626d7bf6869bfedf27b91b6ab5dd607758bf8e959d6fa80c6a59cb/numpy-2.4.6-cp311-cp311-win32.whl", hash = "sha256:ddea102b48f9e339f3948bf22040944184627a30fdf7f858667673b9c5f033c8", size = 6233945 }, + { url = "https://files.pythonhosted.org/packages/c5/31/7fc6239c12bce7e931463251cca4426c465e1876ba3cc785402ef4dd8f4e/numpy-2.4.6-cp311-cp311-win_amd64.whl", hash = "sha256:1e254a00cdf42b1e4d5b3d68d33af63268d41340d8885df2ab6470f2e1500147", size = 12608406 }, + { url = "https://files.pythonhosted.org/packages/27/83/140f85a466595a16382996a1bf06b2b54bcd597488921b0c9daaeeda72af/numpy-2.4.6-cp311-cp311-win_arm64.whl", hash = "sha256:ed9749eef4cbd126da3dc1d6bcb3a57f5eb7ac6a6484146bdbf743f552dfc577", size = 10479528 }, + { url = "https://files.pythonhosted.org/packages/95/2a/3d7b5ac8aac24feaf9ad7ed58f45b0bbc06d37e4338ae84c9f2298b570f9/numpy-2.4.6-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:001fbb8e08d942dd57599e781f2472269ee7f2755fae407b4f67b2f0b17da3f1", size = 16689119 }, + { url = "https://files.pythonhosted.org/packages/ea/12/92c4c131527599e8288d6918e888d88726f84d805d784b771f32408aeaef/numpy-2.4.6-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:ebfb099f8dcf083deef3ac1ca4c1503f387cf76296fcb3816b66f5ecb5f54fdb", size = 14699246 }, + { url = "https://files.pythonhosted.org/packages/ad/fe/c0a6b7b2ca128a8fb228575147073b660656734b8ebe4d76c8fd748dcc79/numpy-2.4.6-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3213d622a0283a39a93d188f3cf72b26862df52fbb4ca3697f51705016523d41", size = 5204410 }, + { url = "https://files.pythonhosted.org/packages/f3/d4/9770d14ba719432bb90a421bfd443872ed0f70f7264b64bec12ea363d5fd/numpy-2.4.6-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:357cc07a6d7b0b182ff02249616a03742827ebb1277546b5c7cd7f7620a45698", size = 6551240 }, + { url = "https://files.pythonhosted.org/packages/c9/c6/50a46a6205feba2343f1d6d17438107c5dc491ed1c736e6ea68689fd906b/numpy-2.4.6-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f9fb9157b4ce2971008323afe46053787b526ef624fea915b261468a8421a0f", size = 15671012 }, + { url = "https://files.pythonhosted.org/packages/99/60/14115e6364fa676c5397c2ad3004e527e9aa487abf5d0706ec81bbd08529/numpy-2.4.6-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:90f9849678c75fe7afa2d348ac842c168b0a4d3d61919687216dfc547976d853", size = 16645538 }, + { url = "https://files.pythonhosted.org/packages/ae/c5/693cbe59e57db94d2231fa519ca3978dc9e19da5a8f088588f5c6e947ff2/numpy-2.4.6-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:c1a2af6c6ef86344a6b0db6b97834208bf598db514f2b155042439b62605601a", size = 17020706 }, + { url = "https://files.pythonhosted.org/packages/ef/fc/85b7c4eff9b4966ade25c2273cf7e7012e92366c032058653934b37de044/numpy-2.4.6-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:e5805d5a22fd19c8ccff10a9561f9df94436b0545619ea579db2d3c35294bce2", size = 18368541 }, + { url = "https://files.pythonhosted.org/packages/f6/81/e1b27545deedce7f4a0b348618c6b62d74e36a4dc9ccd42f3eb2f85eee32/numpy-2.4.6-cp312-cp312-win32.whl", hash = "sha256:e3eeb0aabd6bd5ce64faae67e9935203a6991b4bc2a485a767fbafb2c5125f45", size = 5962825 }, + { url = "https://files.pythonhosted.org/packages/ab/ca/feab00bd44aa5fe1ad2c18f08b4d3bb92e26484b0b1d1443897809ed528c/numpy-2.4.6-cp312-cp312-win_amd64.whl", hash = "sha256:d8e8286dd7cea7895157318d1b91cdacac64c479f3cbc8dce548331728484751", size = 12321687 }, + { url = "https://files.pythonhosted.org/packages/63/cf/5a6d34850a39d1093558564f77ee8e8e0bee5061151b8f05a55711001ec7/numpy-2.4.6-cp312-cp312-win_arm64.whl", hash = "sha256:4081eb135ac24158bd51cdfbef16f1c64df7063b1143f24731387137c092bec8", size = 10221482 }, + { url = "https://files.pythonhosted.org/packages/fb/82/bdab26d7438c6791ca31b7c024ca37c1eab8b726ba236129005cd4a06e45/numpy-2.4.6-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:511dbaf848decaaaf4b4ca48032619fb3138710c4bf7da7617765edad1ef96b0", size = 16684648 }, + { url = "https://files.pythonhosted.org/packages/1b/30/a80189bcc7f5e4258b3fbc3968d909d1756f54d023299ecc39ad6fdb9ef8/numpy-2.4.6-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:bf162abab1c1a736333192707cef898e735a5ca00f38f27eeedf44b39d9e85eb", size = 14693902 }, + { url = "https://files.pythonhosted.org/packages/97/12/70b5d0d7c15e1ebb8a6a84a8caa1d19e181d84fb58bb6d70aca29099dec1/numpy-2.4.6-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:043191bfa8eab18c776647b62723ac9dddece59743b13f49b2016094129c2b3f", size = 5198992 }, + { url = "https://files.pythonhosted.org/packages/ba/8c/ebd2a8f8a83541f8d38cc5667e8c2b69cecfd30da6e45693e8158857d44b/numpy-2.4.6-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:6180d8b35af935aed8ece3a85e0a43f87393ae0ac87c8d2c8bd2c993f7270ef3", size = 6546944 }, + { url = "https://files.pythonhosted.org/packages/bb/c5/7b863a97a91671a0338f4253bd3b5a3d3852f0692dae91711c9f4a10e787/numpy-2.4.6-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:72fbe16c6fac95aedf5937fa873445cec2110be35d8a4e9433d7501fd98dae6b", size = 15669392 }, + { url = "https://files.pythonhosted.org/packages/a5/9d/3584b9984ca4c047aea75214ce1a4c4c73d849bd71b604264b7f5653f8a8/numpy-2.4.6-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a7830bab239b79cda9c08c2da014761cafb48da6150e1da17ac06283f43b6089", size = 16633220 }, + { url = "https://files.pythonhosted.org/packages/05/ae/7c67fba23bd98caec7c99261f3a16072ade14813486b0282cb29846de832/numpy-2.4.6-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:ef4aea96ce4d3b074422cb4f2f64e216bf9e213004bb58ecfdf50ea02ea8eb9a", size = 17020800 }, + { url = "https://files.pythonhosted.org/packages/d9/5d/3b6725cb31d983c5e66916f5d36f6d7e5521129e4c4404d64f918292a5b6/numpy-2.4.6-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:dfa20cc6ca228e6b155b11da03825975ce66aea520985dbbddf0f2a5a495c605", size = 18357600 }, + { url = "https://files.pythonhosted.org/packages/f7/da/2ccc6c2fe8898dee01d90c75c5f5f914a23daf99e3e0f59516a08760c8b5/numpy-2.4.6-cp313-cp313-win32.whl", hash = "sha256:56b39e5e0622a09a25bf5baf62f4bcf0cb8a41ae6e2819cf49bbc5a74c083f91", size = 5961134 }, + { url = "https://files.pythonhosted.org/packages/b5/cd/9cc4dc876fb065d5c220aae4d5e14826b2715331bb7618ce1fb07a679d99/numpy-2.4.6-cp313-cp313-win_amd64.whl", hash = "sha256:c4fc99836233ea196540b17ab0983aff60ed07941751930f5f4d05bc3b3b7359", size = 12318598 }, + { url = "https://files.pythonhosted.org/packages/39/1e/c0bcba1f8694116485fe28fd1be698c278fcda4141c5b0e53a2aed8b12a8/numpy-2.4.6-cp313-cp313-win_arm64.whl", hash = "sha256:a7c711e21628b52034bb5ab8d1bce291f752fcc5e92accc615778acee1ff4778", size = 10222272 }, + { url = "https://files.pythonhosted.org/packages/63/6d/cc5619247c8f4204e507f5883528372e4ac4bb189e579fb859a12e480b1f/numpy-2.4.6-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:112b06a867b235ef466ed3508ddf0238050df9c727cafb5301ac385b899189a1", size = 14821197 }, + { url = "https://files.pythonhosted.org/packages/00/58/f1c39161c87d9e9bed660f1ed4bafc0e403d5ec9650b6dd77aead07d489b/numpy-2.4.6-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:eaf7fa2de5c0be8ae6ff8e9bea2ccd725e980541244521d8d4b5f3354a27babe", size = 5326287 }, + { url = "https://files.pythonhosted.org/packages/af/57/3917ab0fd97f271a8694513581b8a36c655f111c446852c302f04ccdb6fc/numpy-2.4.6-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:7265a2f3d436e54ef9f2b52b5c937e6be778781bd97a590319d7348f1c1ca997", size = 6646763 }, + { url = "https://files.pythonhosted.org/packages/eb/0f/037e64c494b67581ae18193d770adef354c41f3f2c8ebf865602d949bf8f/numpy-2.4.6-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f74a575920ab21fe304421a3fc28793d82e299cae9eccb37084e9fc7f3617c20", size = 15728070 }, + { url = "https://files.pythonhosted.org/packages/21/a6/5d2bae9c9542eb4df16dc9c46dc79c186e9bad53805dfa5399a6023c6db0/numpy-2.4.6-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:ede83e07a75dd06bc501566c1eca2afc0d61677c1472ac9ad93fdee6e638a48d", size = 16681752 }, + { url = "https://files.pythonhosted.org/packages/92/14/23d1dfb410ae362cd59ce53e936b1513d545eb40db3949ced632e19a459e/numpy-2.4.6-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:68bb27509ac1b9a3443094260f6326150663b06abe40b73a2f81160623da5b67", size = 17086024 }, + { url = "https://files.pythonhosted.org/packages/4b/6e/23595a2c642cdf3bc567877064bdd7f91c8b0038a4453cf2daf7248eafe9/numpy-2.4.6-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:a0df0043bdb289bde1f62da130d20df23d58b45429f752bc7a8fc5325a225ecd", size = 18403398 }, + { url = "https://files.pythonhosted.org/packages/8a/90/0ac3bc947217e66dec77e7cbc6a1979d1af70b6461b82f620d3bccd5e4c8/numpy-2.4.6-cp313-cp313t-win32.whl", hash = "sha256:29a287e0cf63ff528da061de6b9f64a4618da591ca1046aafc54062e40ca7eab", size = 6084971 }, + { url = "https://files.pythonhosted.org/packages/77/71/5673e351671a1d2bd6063b91b44f70c0affea7d1516fa7a6572941ba4aa1/numpy-2.4.6-cp313-cp313t-win_amd64.whl", hash = "sha256:25c692919ac5a01f170a3bfcd62d745b24fd095c353d50812637d6fcab442e75", size = 12458532 }, + { url = "https://files.pythonhosted.org/packages/3f/88/19d3503c5046e688f049274b27a3ef3d771152fa80d3ba3d01a3dff61abe/numpy-2.4.6-cp313-cp313t-win_arm64.whl", hash = "sha256:1e978ec1e8bd0e0e4de6bb75de9d30cbb74db6b6a2bb727618613703ca0167dd", size = 10291881 }, + { url = "https://files.pythonhosted.org/packages/f8/91/3ab2044d05fd16d343c5ac2e69b127f1b2854040dd20b193257c78028bd3/numpy-2.4.6-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:06ca2f61ec4385a07a6977c55ba998a4466c123642b4a32694d3128fce18c079", size = 16683458 }, + { url = "https://files.pythonhosted.org/packages/8e/62/764ce66fa4147ae6d73071a3abf804ffe606f174618697c571acdf26a7c9/numpy-2.4.6-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:38efbc8de75c7a0fc1ac190162d892787f3f47b57cc291231aafee36b80982b7", size = 14704559 }, + { url = "https://files.pythonhosted.org/packages/60/61/23f27c172f022e04025b7dc2367f4d63c1a398120607ec896228649a6f48/numpy-2.4.6-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:d581b735e177fdcdce6fed8e7e8880a3fb6ee4e3653a3ac6af01c6f4c03effc5", size = 5209716 }, + { url = "https://files.pythonhosted.org/packages/03/71/21cf70dc6ea3e3acb95fc53a265b2fc248b981f0194ceb5b475271b8809d/numpy-2.4.6-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:0a041d3d761dc3c35cc56ce0351506a02bcbc25f7b169f652435141a17db9096", size = 6543947 }, + { url = "https://files.pythonhosted.org/packages/d5/91/64288395ee1799bd2e0b04a305dce9666da90c961e1f3fe982a05ee1c036/numpy-2.4.6-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:40fdc1ae7125e518ea98e53e69a4ebc27e1fd50510c47b7ea130cf21e5e1d42b", size = 15685197 }, + { url = "https://files.pythonhosted.org/packages/f3/eb/ebffaa97dc55502df69584a8f0dcf07f69a3e0b3e2323670a2722db9aa39/numpy-2.4.6-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a2c306dea656c12c68f51f4cea133cbe78ca7435eb28c735eac1d3ebe73be6e8", size = 16638245 }, + { url = "https://files.pythonhosted.org/packages/b8/0b/54f9da33128d7e350fab89c7455902eeae70349ee52bddb448dc4a576f45/numpy-2.4.6-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:33111801a01c12a8a1e3721f0a9232f8cfc8ae2c6b7098167e6f623c6073f402", size = 17036587 }, + { url = "https://files.pythonhosted.org/packages/b6/f0/fdebc1052db1cc37c64beb22072d67cd6d1c71adca1299f53dec2b5e20d3/numpy-2.4.6-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:ae506e6902902557576a26ff33eda8695e7ecb3cb36c3b573a0765dee114ebdb", size = 18363226 }, + { url = "https://files.pythonhosted.org/packages/aa/b4/298628d98c72b57e57f7165ae6a481a1deaf6f3c28262a6e4c739c275930/numpy-2.4.6-cp314-cp314-win32.whl", hash = "sha256:aaf159caa35993cb1f56fb9b8e4610d35758e7ca005412eb1daa856a78c9c4b1", size = 6010196 }, + { url = "https://files.pythonhosted.org/packages/df/ac/46de6dda46478f7942f839e094970be2d4a861e005c4b3bf07c92e291a09/numpy-2.4.6-cp314-cp314-win_amd64.whl", hash = "sha256:b507f5c4c1d508876d1819b6bf9a49d365b96320b5d4993426b33a23ca4b8261", size = 12450334 }, + { url = "https://files.pythonhosted.org/packages/78/92/b8b798ac784102c0da830d2257d59358e3d3d90d1e2b3f2575dad976c5cf/numpy-2.4.6-cp314-cp314-win_arm64.whl", hash = "sha256:6f41ae150c4e32db4f3310cdaf64b1593a03dbabe29eec77fc9b50fe64061df6", size = 10495678 }, + { url = "https://files.pythonhosted.org/packages/30/34/ec28d1aa8115971537c01469ab2011ee96827930f0a124de1000cc2a7ed7/numpy-2.4.6-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ece3d2cfe132e7d51f44a832b303895e6f2d499c5e74dfbdb06ee246147a304a", size = 14823672 }, + { url = "https://files.pythonhosted.org/packages/16/bd/f6d1fede4e54e8042a7ff97bb495510f3c220f94bcd9e8b228e87c92cc0d/numpy-2.4.6-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:e3e5193ef5a3dc73bceee50f7fdc2c90dbb76c42df8d8fae3d1067a583df579e", size = 5328731 }, + { url = "https://files.pythonhosted.org/packages/f4/f0/e105b9e2fd728a9910103884decd6951d9dd73896b914a98d9a231de02ee/numpy-2.4.6-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:17f9ade344e7d9b464a084d69bcf18fc691cb1db67c62ed80820bf4926d78f0e", size = 6649805 }, + { url = "https://files.pythonhosted.org/packages/82/dd/1206a7ca6ab15e3f02069707ca96222e202af681bb73756da7527f3cb837/numpy-2.4.6-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:9cd5ffd25db4e7ba6a375693b3fc0fc1791ec636c17db3720da19bde7180ec43", size = 15730496 }, + { url = "https://files.pythonhosted.org/packages/51/e7/38d3ea825dcab85a591734decb2f6c67caa7c8367d374df1a1c3842f9b07/numpy-2.4.6-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:7d92c3819208a60205a12a245c91ad70cb0a85336659b19b834205573ac8456e", size = 16679616 }, + { url = "https://files.pythonhosted.org/packages/93/b7/caabfdf53edf663e0b4eb74d7d405d83baef09eb5e83bcd32d601d72b93e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:e85b752a1e912b70eaad4fafbd4d1238007ab221de2009b9a2f5ae7461239895", size = 17085145 }, + { url = "https://files.pythonhosted.org/packages/f9/45/68d7c33a6bcf3e5aa3bdbd57a367e6f615286dfd6482f97e8ffeb734306e/numpy-2.4.6-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:29cb7f67d10b479ff07c17d33e39f78c07f71c40ef30d63c153d340e96cd3fb4", size = 18403813 }, + { url = "https://files.pythonhosted.org/packages/9c/50/0753655aa844c99cd9e018aacf76f130f1bd81d881bb74bc0aef5d73a8ba/numpy-2.4.6-cp314-cp314t-win32.whl", hash = "sha256:260a5d70215b61ab4fadf5c7baacd64821842975eea312125ed3c39a6391b063", size = 6156982 }, + { url = "https://files.pythonhosted.org/packages/b2/d4/7c67becf668f973cb490cec3e98dfd799d866f9c989a54d355672cfa0db6/numpy-2.4.6-cp314-cp314t-win_amd64.whl", hash = "sha256:81a1cca95ed5bb92aa8b10dd2cdc9a0d3853a50fad926c28b5d7e8ea54389627", size = 12638908 }, + { url = "https://files.pythonhosted.org/packages/43/bb/e1c71a4295b1b1d1393d50dbb4f2a36283c6859d9d3892e84f00ec5a91d5/numpy-2.4.6-cp314-cp314t-win_arm64.whl", hash = "sha256:0c9136e14ed34a9e343a31c533d78a9813a69a3148332bce5e9821cb2f996e66", size = 10565867 }, + { url = "https://files.pythonhosted.org/packages/de/12/b422cc84439adc0d00de605bf4a308890ae5c26f2c71fbd73e5d08fbb0dd/numpy-2.4.6-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:55cced7c52e981362f708ad635198e97a752dfba412cc03c23bbf3bd8d5cd662", size = 16847511 }, + { url = "https://files.pythonhosted.org/packages/44/53/f481bef68011740f8849418d82db07230e825013f31f4eef5ba5b805316a/numpy-2.4.6-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d6da64deb6b8ed903e7560180a92f2d804ee1ba5eeb849ac2748b8c1aba1f6d7", size = 14889064 }, + { url = "https://files.pythonhosted.org/packages/7f/57/42ed575c10ced8af951d426bc4e1f8aff16fd851db33f067036215a7f860/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_arm64.whl", hash = "sha256:68a5124b13fa6cc2086764a20005d30bc0548146f7f5322f02fce212ca14317f", size = 5394157 }, + { url = "https://files.pythonhosted.org/packages/6a/ef/f66cc724fcc36c1e364c67f51ae9146090b8b584f27d58b97fdae3edd737/numpy-2.4.6-pp311-pypy311_pp73-macosx_14_0_x86_64.whl", hash = "sha256:948424b06129ce883307e8cff868c31396d8dc7630a59c61d70d98dbe70f222c", size = 6708728 }, + { url = "https://files.pythonhosted.org/packages/1a/9c/c531f2293b91265d8b48e9b329f54fdd7ffae73cb4134ea10cca4237e9cc/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5dbbdb29840ca3d91ee0fece42fc29278886d908280bfec0a5846c6f901a3eb0", size = 15798374 }, + { url = "https://files.pythonhosted.org/packages/1a/b0/413077f6b1153ed3cba361401c6783bbad6114804a000cc22eb71c13e190/numpy-2.4.6-pp311-pypy311_pp73-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8ad03c0965fb3c692200e74d458ca28c1dbb4ce96f9a479a8aa041ad5fabca02", size = 16747286 }, + { url = "https://files.pythonhosted.org/packages/15/ce/e5ec180bc41812edcd8daeb8639d205622c0e8c02259d8ab25a0201b3c2a/numpy-2.4.6-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:2803abfebfc990042cd494d8ce2d5f82e9d847af6d35ec486923aa19dbad5e73", size = 12504263 }, +] + +[[package]] +name = "packaging" +version = "26.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195 }, +] + +[[package]] +name = "pandas" +version = "3.0.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/f8/87/4341c6252d1c47b08768c3d25ac487362bf403f0313ddae4a2a26c9b1b4c/pandas-3.0.3.tar.gz", hash = "sha256:696a4a00a2a2a35d4e5deb3fc946641b96c944f02230e4f76137fe35d806c4fc", size = 4651414 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/42/16/b5c76b838fd9bf6ce84d3a53346b8874ec05c5f0040d75ef2c320100cd2a/pandas-3.0.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:455f6f8139d4282188f526868dbc3c828470e88a3d9d59a891bd46a455f21b98", size = 10338495 }, + { url = "https://files.pythonhosted.org/packages/5a/b0/a4ffc4ae74d2d822200dcc46898987d8eb6032d1e2b219cae39da6f5cbcc/pandas-3.0.3-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:4e15135e2ee5df1063313e2425ceef8ac0f4ae775893815b0923651b806a5639", size = 9938250 }, + { url = "https://files.pythonhosted.org/packages/2e/b2/3323601a52caee42c019e370090ca4544b241437240ca04f786cce82b0cf/pandas-3.0.3-cp311-cp311-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:05f1f1752b8533ea03f7f39a9c15b1a058d067bb48f4748948e7a8691e0510f2", size = 10770558 }, + { url = "https://files.pythonhosted.org/packages/32/f1/bbecd2f867b97abebe0f9b53d750f862251b40337e061b36676ded3d920f/pandas-3.0.3-cp311-cp311-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:8a1e45c80cceb3b4a21bc5939d52e8cbd8d9b7305309219d59e9754d9ce09e27", size = 11274611 }, + { url = "https://files.pythonhosted.org/packages/7f/4f/eafabf2d5fae5adf143b4d18d3706c5efdc368a7c4eb1ee8a3eddabbd0f6/pandas-3.0.3-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:14da8316da4d0c5a77618425996bfb1248ca87fc2c1486e6fde4652bd18b5824", size = 11784670 }, + { url = "https://files.pythonhosted.org/packages/49/44/1eb20389301b57b19cc099a1c2f662501f72f08a65f912d05822613c1532/pandas-3.0.3-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a55066a0505dae0ba2b50a46637db34b46f9094c65c5d4800794ef6335010938", size = 12353708 }, + { url = "https://files.pythonhosted.org/packages/eb/62/c321f13b5ba1819fc8dca456c7fce578da2dcfecff1abbf0eaddf8406c0f/pandas-3.0.3-cp311-cp311-win_amd64.whl", hash = "sha256:6674ab18ad8c57802867264b00e15e7bb904700cdd9046e3b2fa1fce237439ea", size = 9907609 }, + { url = "https://files.pythonhosted.org/packages/53/85/1b7f563ebc6357c27233a02a96b589bcce1fa9c6eb89fb4f0e56421d277e/pandas-3.0.3-cp311-cp311-win_arm64.whl", hash = "sha256:5cc09a68b3120e0f54870dede8287a7bb1fa463907e4fcec1ea77cab6179bf7a", size = 9165596 }, + { url = "https://files.pythonhosted.org/packages/24/f1/392f8c5bfc16f66a0d2d41561c01627c228fe7ed2a0d056ef11315042570/pandas-3.0.3-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:fed2ff7fd9779120e388e285fc029bd5cf9490cdd2e4166a9ee22c0e49a9ab09", size = 10357846 }, + { url = "https://files.pythonhosted.org/packages/cf/3d/b16412745651e855f357e5e66930248688378853a6e2698a214e331fba1f/pandas-3.0.3-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b168fc218fd80a6cbdbdbc1a97ddc7889ed057d7eb45f50d866ceab5f39904c4", size = 9899550 }, + { url = "https://files.pythonhosted.org/packages/31/a8/fa2535168fffcedf67f4f6de28d2dd903a747ca7c8ea6989451aaeb3a92f/pandas-3.0.3-cp312-cp312-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0383c72c75cdcca61a9e116e611143902dbfd08bff356829c2f6d1cf40a9ca8c", size = 10412965 }, + { url = "https://files.pythonhosted.org/packages/65/b6/09b01cdbc15224e2850365192d17b7bdebb8bdbd8780ed221fcdf0d9a515/pandas-3.0.3-cp312-cp312-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6dc0b3fd2169c9157deed50b4d519553a3655c8c6a96027136d654592be973a9", size = 10894600 }, + { url = "https://files.pythonhosted.org/packages/c9/a4/2eb28f2fccb4ced4a2c79ab2a5dee9ade1ebf44922ebad6fea158c9f95d4/pandas-3.0.3-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:7e65d5407dc0b394f509699650e4a2ec01c0514f21850f453fa60f3be79a5dbf", size = 11422824 }, + { url = "https://files.pythonhosted.org/packages/f8/45/830bb57f533a4604b355e07edcb8ea18cf88b5f94e5fca92f27052d7c597/pandas-3.0.3-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f8894dc474d648fe7b6ff0ca9b0bd73950d19952bc1a6534540762c5d79d305c", size = 11950889 }, + { url = "https://files.pythonhosted.org/packages/b9/c5/fc1b368f303087d20e8c9bf3d6ceb186263cfac0ade735cd938538bea839/pandas-3.0.3-cp312-cp312-win_amd64.whl", hash = "sha256:c7be265b62cef88e253a941e4698604973736dcfe242fdb5198f0f7bc473cdcc", size = 9755463 }, + { url = "https://files.pythonhosted.org/packages/86/bd/fda8f9705b1b09c6ebe14bfc0fa0e4ec8584d54ea673628f157ff55131af/pandas-3.0.3-cp312-cp312-win_arm64.whl", hash = "sha256:557409bc4178e70ee8d9ddb494798e51ebf6ea59330f6be22c51bab2a7db6c49", size = 9066158 }, + { url = "https://files.pythonhosted.org/packages/c5/90/62d8302883c44308c477e222c3daf7c813a34c8e96985882fbd53d964352/pandas-3.0.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:67b3b64c11910cfa29f4e94a14d3bff9ee693b6fc76055e7cad549cee0aec5fa", size = 10331071 }, + { url = "https://files.pythonhosted.org/packages/7f/ae/6a6493c783a101f165e4356953ba3c74d6f77f0042fa7d753da9dfbb640c/pandas-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:39436b377d56d2a2e52d0395bdbee171f01068e99af5250509aceeb929f765c7", size = 9875690 }, + { url = "https://files.pythonhosted.org/packages/62/7c/5df8e9f56c69a2769fbe9382a5ef8f2658c007e376434e1e2cbb57ad895f/pandas-3.0.3-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d4be06d68f9ddcfc645b87534911da79a8fbffc7573c80e0edcf42a5020624d8", size = 10381634 }, + { url = "https://files.pythonhosted.org/packages/99/68/1237369725aa617bb358263d535803e3053fdbc593513ec5ed9c9896b5b6/pandas-3.0.3-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a4eeb6830daf35a71cc09649bd823e2b542dac246cdee9614c6e4bd65028cd6a", size = 10891243 }, + { url = "https://files.pythonhosted.org/packages/25/93/77d108e8af7222b4a503ebde0e30215b1c2e4f8e53a526431890f22d5586/pandas-3.0.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:1928e07221f82db493cd4af1e23c1bfca524a19a4699887975bff68f49a72bfb", size = 11388659 }, + { url = "https://files.pythonhosted.org/packages/d0/bd/eff5b4399f332ac386c853f6cd2bd3fa2ca0061b9f36ecd9c4d7c4265649/pandas-3.0.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:51b1fe551acb77dac643c6fda86084d8d446c10fe64b06a9cc29c4cc8540e7f2", size = 11942880 }, + { url = "https://files.pythonhosted.org/packages/2c/20/559ace4200982c3887d0b86bfd0d856a2143ef8ddab63cc07934951a964c/pandas-3.0.3-cp313-cp313-win_amd64.whl", hash = "sha256:a82d532a3351d435432cd913edbccaf8b8e01d4dd0e5ced5a8d2e8ecd94c7e44", size = 9757091 }, + { url = "https://files.pythonhosted.org/packages/3a/66/69055a09fe200f29f922a3eeec4804611900b95f52d932ece3393c3c0c19/pandas-3.0.3-cp313-cp313-win_arm64.whl", hash = "sha256:275c14e0fce14a2ec20eee474aecd305478ea3c1e6f6a9d8fe219a165542717e", size = 9057282 }, + { url = "https://files.pythonhosted.org/packages/57/0e/efe801b0e6811e8e650cd21b7f2608e30f08a7067e2bf6e8752b0d56ee3c/pandas-3.0.3-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:46997386d528eb40376ecd6b033cf4a8a1e5282580f68f43de875b78cba2199d", size = 10767016 }, + { url = "https://files.pythonhosted.org/packages/ea/dc/eb55135a1d5f0f0519f28da1f609a206d2cad1f9c35c32d51e38dd7261ae/pandas-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:261e308dfb22448384b7580cf719d2f998fe2966c92893c3e77d14008af1f066", size = 10420210 }, + { url = "https://files.pythonhosted.org/packages/c6/3e/b1d5d955ce33ffecb407465a60bc32769d74fcf68224b7ae67ae11d4dea4/pandas-3.0.3-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:dd1a5d1def6a46002e964510bdc67c368aa0951df5d1d9f8365336f5a1f490cd", size = 10336126 }, + { url = "https://files.pythonhosted.org/packages/f5/76/a01261711ab60a22d71b862f0de20e4c504bf80457270ad8cb42110f6abc/pandas-3.0.3-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:d72828c20c6d6e83e1e22a6a3b47b326b71664112fa9705dcbccfd7a39b62085", size = 10728051 }, + { url = "https://files.pythonhosted.org/packages/e9/21/ea191195e587b18cf682e97f433f81b2d0fbe341380e80a3e0d6e4403c8e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:d26cbe1fcfc12e8fd900e2454163e466b2d3af84f7c75481df7683ffc073d870", size = 11350796 }, + { url = "https://files.pythonhosted.org/packages/64/69/f0eaaf54939f0e8c6768fd06be9af2cef9b36048b96dfb9e1b2c685a807e/pandas-3.0.3-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:3e91cec1879ada0624fc3dc9953c5cbd60208e59c0db28f540c5d6d47502422f", size = 11799741 }, + { url = "https://files.pythonhosted.org/packages/45/a4/865e0e510cae5fc2194de4db28be638952de942571ba9125934fd9c01d47/pandas-3.0.3-cp313-cp313t-win_amd64.whl", hash = "sha256:08d789b41f87e0905880e293cedf6197ce71fe67cc081358b1e148a491b9bd13", size = 10499958 }, + { url = "https://files.pythonhosted.org/packages/86/54/effdcc3c0ff7a08037889200e148ebe94c16c4f653be078c7b3675955df1/pandas-3.0.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:3650109c0f22879df8bd6179ab9ee3d7f1d1d4e7e0094a3f0032d9f51e2e64ac", size = 10336065 }, + { url = "https://files.pythonhosted.org/packages/68/10/bf2d6738d72748b961a3751ab89522d58c54efc36a8e1a12161216cd45cf/pandas-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:bab900348131a7db1f69a7309ef141fd5680f1487094193bcbbb61791573bf8f", size = 9926101 }, + { url = "https://files.pythonhosted.org/packages/ae/e9/e35cf11c8a136e757b956f5f0efdcaa50aecde85ea055f1898dfc68262f3/pandas-3.0.3-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ba7e08b9ac1d54569cd1e256e3668975ed624d6826f7b68df0342b012007bddb", size = 10457553 }, + { url = "https://files.pythonhosted.org/packages/58/3b/1cdec6772bdbaf7b25dab360c59f03cadf05492dd724c6540af905389b07/pandas-3.0.3-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:9d71c63ae4ebdbf70209742096f1fc46a83a0613c99d4b23766cced9ff8cd62a", size = 10914065 }, + { url = "https://files.pythonhosted.org/packages/c4/c2/1ef644445fcd72e3627bceec77e3560636f87ddce4ed841afe76b83b5bf9/pandas-3.0.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e3a2ec42c98ffa2565a67e08e218d06d72576d758d90facb7c00805194d8f360", size = 11459188 }, + { url = "https://files.pythonhosted.org/packages/7e/49/4d8d4f42cbc9c4adc7a1870f269c02cbd6cd40d059622c06fb298addcbad/pandas-3.0.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:335f62418ed562cfc3c49e9e196375c28b729dcef8543abf4f9438e381bf3c76", size = 11982966 }, + { url = "https://files.pythonhosted.org/packages/38/55/792619469bab9882d8bbd5865d45a72f6478762d04a9af4bf0d08c503e95/pandas-3.0.3-cp314-cp314-win_amd64.whl", hash = "sha256:3c20a521bbb85902f79f7270c80a59e1b5452d96d170c034f207181870f97ac5", size = 9876755 }, + { url = "https://files.pythonhosted.org/packages/2a/af/33c469653b0ba03b50c3a98192d4c07f0c75c66b263ceb097fce0ee97d31/pandas-3.0.3-cp314-cp314-win_arm64.whl", hash = "sha256:a2d2dff8a04f3917b55ab3910c32990f8ddf7eceba114947838cefa976a68977", size = 9198658 }, + { url = "https://files.pythonhosted.org/packages/a2/fa/b8c257bd76b8bd060c3a9151c1fca05e9b9c5e3af5d0f549c0356f6d143d/pandas-3.0.3-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:0d589105b3c14645af1738ff279b2995102d8f7a03b0a66dc8d95550eb513e04", size = 10787242 }, + { url = "https://files.pythonhosted.org/packages/54/eb/f19206ffb0bf1919002969aa448b4702c6594845156a6f8050674855aac3/pandas-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:13fc1e853d9e04743d11ba75a985ccbc2a317fe07d8af61e445a6fd24dacd6a6", size = 10436369 }, + { url = "https://files.pythonhosted.org/packages/fd/24/c7c39fb4fe22b71a0c2d78bf0c585c600092d85f94f086d2b3b2f6ca27e2/pandas-3.0.3-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:819959dab7bbd0049c15623fbac4e29a191b9528160a61fb1032242d8ced2d9c", size = 10358306 }, + { url = "https://files.pythonhosted.org/packages/16/ec/dd2a9eb7fa1204df88c0864164e35b228ac581062ac612ba0a67fd812e4c/pandas-3.0.3-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:60ae316d3fd75d1858d450d0db0103ea2be3e7d4a95ec2f064f7e2ae63f7b028", size = 10758394 }, + { url = "https://files.pythonhosted.org/packages/95/6e/00c61ea8e85b4f6d8d35e11852a1a4998fc7fafc91c6a602d1cc9c972d64/pandas-3.0.3-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bd3a518890b400d32f9023722dc9a9a5c969f00b415419a3c06c043f09bb5d7d", size = 11375717 }, + { url = "https://files.pythonhosted.org/packages/31/89/8fc1c268969fac43688d65fd92e67df24bd128d53cb4d2eee534cd307399/pandas-3.0.3-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9c39be2d709d01fa972a0cabc522389fceca4f3969332ba25a7d6c5802cf976a", size = 11828897 }, + { url = "https://files.pythonhosted.org/packages/56/3b/e7d20dea247a3e6dc0bd8a6953854afbedc03951def4e7371e05e7263e25/pandas-3.0.3-cp314-cp314t-win_amd64.whl", hash = "sha256:4db8c527972a821cf5286b40ccc57642a39bc62e62022b42f99f8a67fca8c3a1", size = 10900855 }, + { url = "https://files.pythonhosted.org/packages/0f/54/68a0978d1ef8502b8492099beaa6e7a0c1b32e3b5d4f677f5810cb08711c/pandas-3.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:b2c95f8bfc1ee412bf482605d7bfd30c12d1d26bd59fdd91efeef1d4718decb1", size = 9466464 }, +] + +[[package]] +name = "pluggy" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f9/e2/3e91f31a7d2b083fe6ef3fa267035b518369d9511ffab804f839851d2779/pluggy-1.6.0.tar.gz", hash = "sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3", size = 69412 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538 }, +] + +[[package]] +name = "pygments" +version = "2.20.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c3/b2/bc9c9196916376152d655522fdcebac55e66de6603a76a02bca1b6414f6c/pygments-2.20.0.tar.gz", hash = "sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f", size = 4955991 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f4/7e/a72dd26f3b0f4f2bf1dd8923c85f7ceb43172af56d63c7383eb62b332364/pygments-2.20.0-py3-none-any.whl", hash = "sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176", size = 1231151 }, +] + +[[package]] +name = "pytest" +version = "9.1.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "colorama", marker = "sys_platform == 'win32'" }, + { name = "iniconfig" }, + { name = "packaging" }, + { name = "pluggy" }, + { name = "pygments" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/84/0e/b5858858d74958632c49b72cb25a3976ff9f632397626715be71c89d3971/pytest-9.1.0.tar.gz", hash = "sha256:41dd9148c08072446394cefd3d79701701335a9f4cae69ba92e39f6c7f5c061c", size = 1634181 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/8b/5a/ba30a81239b909821b3153e303e7def45178bf353da4f72380e6c5e8793b/pytest-9.1.0-py3-none-any.whl", hash = "sha256:8ebb0e7888bdf2bdfc602ec51f8f62d50200af37356c74e503c79a94f5c81f32", size = 386453 }, +] + +[[package]] +name = "python-dateutil" +version = "2.9.0.post0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "six" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892 }, +] + +[[package]] +name = "scikit-learn" +version = "1.9.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "joblib" }, + { name = "narwhals" }, + { name = "numpy" }, + { name = "scipy" }, + { name = "threadpoolctl" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/fa/6f/37092bdb25f712817231799fc5674d8e704066a8a70c1d2d40517e18b4ab/scikit_learn-1.9.0.tar.gz", hash = "sha256:8833266989d3a5110178a9fae30783675460724d0e1efb13b14901d2c660c557", size = 7750767 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f5/be/e844fd9586e66540a15b71924d17a6cbc1bb749e81ddd0a796bcdba4c055/scikit_learn-1.9.0-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:9db6f4d34e68c8899e4cab27fdf8eafe6ed21f2ba52ceb25ea250cd237f8e47b", size = 8789686 }, + { url = "https://files.pythonhosted.org/packages/42/e2/ff880f62677a17d035817d543cb0fc8727d01eccbee81c5f7fc733a9d856/scikit_learn-1.9.0-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:f401448645a3e7bc115aa3c094097865155b34bff1cba8101857d9104e99074c", size = 8256782 }, + { url = "https://files.pythonhosted.org/packages/25/64/eb40435e1a508ab1b4e284ce43ae80f6a162e5be5e38ed5a6fab467a9ea4/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fd3a8ef0c758555a3b23c03adaa858af32f7736785ded50ad5991f59c4ed03fa", size = 8992419 }, + { url = "https://files.pythonhosted.org/packages/8d/da/4810a28e473185429e45a57eebcc91fc991b33d889cc0676063e671db03d/scikit_learn-1.9.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7e254636164090da847715a27f8e5478feb98c40a9e0ee90cbd277de9e5ceb8", size = 9281411 }, + { url = "https://files.pythonhosted.org/packages/3b/67/be3d369f40d8178ba3bd86635d132e08cb5329b023e4669d9426d84bc007/scikit_learn-1.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:5dc1818c77575d149e25fce9ef82dd7b7263ae372f03494158668ad632a69759", size = 8272736 }, + { url = "https://files.pythonhosted.org/packages/37/79/a733f02dc2118da7e77a134b34f39f40201a353311b011d20859d2db3556/scikit_learn-1.9.0-cp311-cp311-win_arm64.whl", hash = "sha256:366652351f092b219c248f1e72821e841960a63d8f358f1dcfd54dc1cbdbbc28", size = 7919564 }, + { url = "https://files.pythonhosted.org/packages/ac/20/75f915ff375d6249e6550ac740fdbbd66159a068fd3af1400ff62036b07a/scikit_learn-1.9.0-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:2bd41b0d201bc81575531b96b713d3eb5e5f50fb0b82101ff0f92294fdc236ac", size = 8741122 }, + { url = "https://files.pythonhosted.org/packages/cc/d5/2b5148f2279196775e1db2aeb85d14b70ac80e7e32b3b28e7ebeafb0901d/scikit_learn-1.9.0-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:5be45aa4a42a68a533913a6ed736cf309de2226411c79ef8d609a5456f1939b1", size = 8261512 }, + { url = "https://files.pythonhosted.org/packages/a0/ee/5adbc77656b71f9456a2f5a7a9fdb4bcf9207a6b962889f1c2f9323afa4e/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5e50ed4da51974e86e940690e9a3d82e729b62b5a49f7c9bac534d515d39d86f", size = 8837603 }, + { url = "https://files.pythonhosted.org/packages/6c/c2/63fdda36c56437eeb44aaf9493c8bcd62ce230ab1598924fc626ffbfa943/scikit_learn-1.9.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:056c92bb67ad4c28463c2f2653d9701449201e7e7a9e94e321be0f71c4fef2b8", size = 9132097 }, + { url = "https://files.pythonhosted.org/packages/83/a4/c8e67227c680e2259c8864ae72ff48b06e16a6f51253a22167aa02a8aa4e/scikit_learn-1.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:4306775fad04cc4b472a1b15af1ae9cede1540fbfcc17fbce3767cd8dc7ae283", size = 8211173 }, + { url = "https://files.pythonhosted.org/packages/cf/fd/3c0863792e98e67e9184aa4029288a175935eb65443afcd30d4f143450cf/scikit_learn-1.9.0-cp312-cp312-win_arm64.whl", hash = "sha256:26e22435f63bcdcf396b574273f29f13dd531f5ea035801f5be10ba1540a4e60", size = 7867451 }, + { url = "https://files.pythonhosted.org/packages/3c/01/cf3310626b6d48d3e9be69a1223f9180360b5e6edb045f50fade723ce494/scikit_learn-1.9.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:80746d63bd4b6eaca54d36fe5feaf4d28bb38dc6f9470f81c7cad7c40155f119", size = 8705188 }, + { url = "https://files.pythonhosted.org/packages/3e/04/5acd7ae280c5f93b6ac5ef6cdec14eef4c8d1cd91d85b3292989c94d96b1/scikit_learn-1.9.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5b934c45c252844a91d69fda3a34cff5e7307e1db10d77cb10a3980312c74713", size = 8228299 }, + { url = "https://files.pythonhosted.org/packages/0c/39/ffe829a5b8ecb40a518724a997794657fdc354ada5e8fe8e64d998c0bac9/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:38c3dcb9a1ffb85505ec53d54c7b4aea0cff70050425a7760c2af661ac85df05", size = 8789690 }, + { url = "https://files.pythonhosted.org/packages/1f/88/8dab5de10c638c083772a6be83a3d8106ced492f74a928c8693638e5bb50/scikit_learn-1.9.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:da76d09304a4706db7cc1e3ebaa3b6b98a67365cc11d2996c4f1e58ba47df714", size = 9087723 }, + { url = "https://files.pythonhosted.org/packages/20/3f/7917ca72464038f6240ec70c29f94862d08a34a74291ae4d4ec5eb8186a0/scikit_learn-1.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:5808d98f15c6bf6d9d96d2348c1997392a5888ce7097e664105f930c4bca1277", size = 8184330 }, + { url = "https://files.pythonhosted.org/packages/78/c7/15739eb2f61fda3c54639e9942414e5a19ad8a8d1f5a3266afad7cb7df80/scikit_learn-1.9.0-cp313-cp313-win_arm64.whl", hash = "sha256:d77f54c017633791bc0225a43e2f8d03745fdcfe4880268fcc4df15f505dec2e", size = 7840653 }, + { url = "https://files.pythonhosted.org/packages/f4/7d/c9a35cf59b20a86fec24d306f1547b78dec194b08d367ce2a3e4854169d9/scikit_learn-1.9.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:9656acd4e93f74e0b66c8a36c88830a99252dfa900044d36bc2212ae89a47162", size = 8713289 }, + { url = "https://files.pythonhosted.org/packages/3c/a7/552a7821597c632b907f7bfe8f36f9f572777af8ef8a48353041cf8e091a/scikit_learn-1.9.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:24360002ae845e7866522b0a5bbf690802e7bc388cac8663502e78aa98598aa2", size = 8245141 }, + { url = "https://files.pythonhosted.org/packages/7d/79/f4a0c4fe9711154cddabf913471153af79056382ddc612cfe5ee0ff4b72e/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5162ad10a418c8a282dde04c9aa06965de3e9a65f33c1440c0ae69bb1a09d913", size = 8847671 }, + { url = "https://files.pythonhosted.org/packages/f0/af/4d72d9e475ac83719160c662619e4bf7b95c19507cd582e7d0167a3c3dae/scikit_learn-1.9.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1fea2cc5677ab49d6f5bade978c866da44957b712d92e9635e8b4f723013c3cb", size = 9118104 }, + { url = "https://files.pythonhosted.org/packages/a2/d5/6a58eea2cb9abbb9b3f2bb8b2cfb3243d1152d69f442d256c7af71304769/scikit_learn-1.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:64fa347efc1c839c487433e40c5144d38c336e8a2b59c81aa8660373945c2673", size = 8290674 }, + { url = "https://files.pythonhosted.org/packages/65/5b/d4c879cf358f1187141cf90ced473f087183489090244f50c124a2ee478b/scikit_learn-1.9.0-cp314-cp314-win_arm64.whl", hash = "sha256:1b944b6db288f6b926e3650026ddafb988929de95d11fc2cc5fa117773c9ba42", size = 7978807 }, + { url = "https://files.pythonhosted.org/packages/8a/43/bfae3121ec67ae09150d453c442c7c1cc166e9aefe056e6ab3b7728a5cfc/scikit_learn-1.9.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:4ccacf04ca5f4b492158a5f28afe0ace43f81b2571e4b9a66d34848b46128949", size = 9031941 }, + { url = "https://files.pythonhosted.org/packages/75/b0/20a4546eb17f3b25d3c66df15810411c14ed5065bcfab50b53c96fb627b2/scikit_learn-1.9.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:ee1a8db2c18c08e34c7412d4b10be1cac214cd4ea7dc9715a6a327eb49a37c96", size = 8613528 }, + { url = "https://files.pythonhosted.org/packages/18/3c/e440e039bb82cd19004edaaad00acbde0fb9b461083c3ecf37941c557312/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:147e9329ef0e39f75d4cffa02b2aa48d827832684926cd5210d9a2cb5c57246b", size = 8855050 }, + { url = "https://files.pythonhosted.org/packages/43/26/b341b8dab5998da6270a3a42c2152c578501354d36f944b5856757035ef8/scikit_learn-1.9.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5bad8f8b9950321b54c965fdcbac6c6c55e79e16646b49977bcf3668d3870a1a", size = 9097190 }, + { url = "https://files.pythonhosted.org/packages/fb/de/b650b4d69b84468cfa2e28a3ff7b8103743029e6446ce1a97fe060ef688c/scikit_learn-1.9.0-cp314-cp314t-win_amd64.whl", hash = "sha256:78fc56eafd4edb9575d2d8950d1dd152061abb573341a1cb7e099fc40f6c6666", size = 8963204 }, + { url = "https://files.pythonhosted.org/packages/ee/f3/ff83d76d7418112e5a61326443cdda87be3545dd8d6599c95b2481a4419e/scikit_learn-1.9.0-cp314-cp314t-win_arm64.whl", hash = "sha256:051075bda8b7aab87b1906ab3d4740a1e1224a19d7b3781a576736edc94e76aa", size = 8222661 }, +] + +[[package]] +name = "scipy" +version = "1.17.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/7a/97/5a3609c4f8d58b039179648e62dd220f89864f56f7357f5d4f45c29eb2cc/scipy-1.17.1.tar.gz", hash = "sha256:95d8e012d8cb8816c226aef832200b1d45109ed4464303e997c5b13122b297c0", size = 30573822 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/df/75/b4ce781849931fef6fd529afa6b63711d5a733065722d0c3e2724af9e40a/scipy-1.17.1-cp311-cp311-macosx_10_14_x86_64.whl", hash = "sha256:1f95b894f13729334fb990162e911c9e5dc1ab390c58aa6cbecb389c5b5e28ec", size = 31613675 }, + { url = "https://files.pythonhosted.org/packages/f7/58/bccc2861b305abdd1b8663d6130c0b3d7cc22e8d86663edbc8401bfd40d4/scipy-1.17.1-cp311-cp311-macosx_12_0_arm64.whl", hash = "sha256:e18f12c6b0bc5a592ed23d3f7b891f68fd7f8241d69b7883769eb5d5dfb52696", size = 28162057 }, + { url = "https://files.pythonhosted.org/packages/6d/ee/18146b7757ed4976276b9c9819108adbc73c5aad636e5353e20746b73069/scipy-1.17.1-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:a3472cfbca0a54177d0faa68f697d8ba4c80bbdc19908c3465556d9f7efce9ee", size = 20334032 }, + { url = "https://files.pythonhosted.org/packages/ec/e6/cef1cf3557f0c54954198554a10016b6a03b2ec9e22a4e1df734936bd99c/scipy-1.17.1-cp311-cp311-macosx_14_0_x86_64.whl", hash = "sha256:766e0dc5a616d026a3a1cffa379af959671729083882f50307e18175797b3dfd", size = 22709533 }, + { url = "https://files.pythonhosted.org/packages/4d/60/8804678875fc59362b0fb759ab3ecce1f09c10a735680318ac30da8cd76b/scipy-1.17.1-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:744b2bf3640d907b79f3fd7874efe432d1cf171ee721243e350f55234b4cec4c", size = 33062057 }, + { url = "https://files.pythonhosted.org/packages/09/7d/af933f0f6e0767995b4e2d705a0665e454d1c19402aa7e895de3951ebb04/scipy-1.17.1-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:43af8d1f3bea642559019edfe64e9b11192a8978efbd1539d7bc2aaa23d92de4", size = 35349300 }, + { url = "https://files.pythonhosted.org/packages/b4/3d/7ccbbdcbb54c8fdc20d3b6930137c782a163fa626f0aef920349873421ba/scipy-1.17.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:cd96a1898c0a47be4520327e01f874acfd61fb48a9420f8aa9f6483412ffa444", size = 35127333 }, + { url = "https://files.pythonhosted.org/packages/e8/19/f926cb11c42b15ba08e3a71e376d816ac08614f769b4f47e06c3580c836a/scipy-1.17.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:4eb6c25dd62ee8d5edf68a8e1c171dd71c292fdae95d8aeb3dd7d7de4c364082", size = 37741314 }, + { url = "https://files.pythonhosted.org/packages/95/da/0d1df507cf574b3f224ccc3d45244c9a1d732c81dcb26b1e8a766ae271a8/scipy-1.17.1-cp311-cp311-win_amd64.whl", hash = "sha256:d30e57c72013c2a4fe441c2fcb8e77b14e152ad48b5464858e07e2ad9fbfceff", size = 36607512 }, + { url = "https://files.pythonhosted.org/packages/68/7f/bdd79ceaad24b671543ffe0ef61ed8e659440eb683b66f033454dcee90eb/scipy-1.17.1-cp311-cp311-win_arm64.whl", hash = "sha256:9ecb4efb1cd6e8c4afea0daa91a87fbddbce1b99d2895d151596716c0b2e859d", size = 24599248 }, + { url = "https://files.pythonhosted.org/packages/35/48/b992b488d6f299dbe3f11a20b24d3dda3d46f1a635ede1c46b5b17a7b163/scipy-1.17.1-cp312-cp312-macosx_10_14_x86_64.whl", hash = "sha256:35c3a56d2ef83efc372eaec584314bd0ef2e2f0d2adb21c55e6ad5b344c0dcb8", size = 31610954 }, + { url = "https://files.pythonhosted.org/packages/b2/02/cf107b01494c19dc100f1d0b7ac3cc08666e96ba2d64db7626066cee895e/scipy-1.17.1-cp312-cp312-macosx_12_0_arm64.whl", hash = "sha256:fcb310ddb270a06114bb64bbe53c94926b943f5b7f0842194d585c65eb4edd76", size = 28172662 }, + { url = "https://files.pythonhosted.org/packages/cf/a9/599c28631bad314d219cf9ffd40e985b24d603fc8a2f4ccc5ae8419a535b/scipy-1.17.1-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:cc90d2e9c7e5c7f1a482c9875007c095c3194b1cfedca3c2f3291cdc2bc7c086", size = 20344366 }, + { url = "https://files.pythonhosted.org/packages/35/f5/906eda513271c8deb5af284e5ef0206d17a96239af79f9fa0aebfe0e36b4/scipy-1.17.1-cp312-cp312-macosx_14_0_x86_64.whl", hash = "sha256:c80be5ede8f3f8eded4eff73cc99a25c388ce98e555b17d31da05287015ffa5b", size = 22704017 }, + { url = "https://files.pythonhosted.org/packages/da/34/16f10e3042d2f1d6b66e0428308ab52224b6a23049cb2f5c1756f713815f/scipy-1.17.1-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e19ebea31758fac5893a2ac360fedd00116cbb7628e650842a6691ba7ca28a21", size = 32927842 }, + { url = "https://files.pythonhosted.org/packages/01/8e/1e35281b8ab6d5d72ebe9911edcdffa3f36b04ed9d51dec6dd140396e220/scipy-1.17.1-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:02ae3b274fde71c5e92ac4d54bc06c42d80e399fec704383dcd99b301df37458", size = 35235890 }, + { url = "https://files.pythonhosted.org/packages/c5/5c/9d7f4c88bea6e0d5a4f1bc0506a53a00e9fcb198de372bfe4d3652cef482/scipy-1.17.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:8a604bae87c6195d8b1045eddece0514d041604b14f2727bbc2b3020172045eb", size = 35003557 }, + { url = "https://files.pythonhosted.org/packages/65/94/7698add8f276dbab7a9de9fb6b0e02fc13ee61d51c7c3f85ac28b65e1239/scipy-1.17.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:f590cd684941912d10becc07325a3eeb77886fe981415660d9265c4c418d0bea", size = 37625856 }, + { url = "https://files.pythonhosted.org/packages/a2/84/dc08d77fbf3d87d3ee27f6a0c6dcce1de5829a64f2eae85a0ecc1f0daa73/scipy-1.17.1-cp312-cp312-win_amd64.whl", hash = "sha256:41b71f4a3a4cab9d366cd9065b288efc4d4f3c0b37a91a8e0947fb5bd7f31d87", size = 36549682 }, + { url = "https://files.pythonhosted.org/packages/bc/98/fe9ae9ffb3b54b62559f52dedaebe204b408db8109a8c66fdd04869e6424/scipy-1.17.1-cp312-cp312-win_arm64.whl", hash = "sha256:f4115102802df98b2b0db3cce5cb9b92572633a1197c77b7553e5203f284a5b3", size = 24547340 }, + { url = "https://files.pythonhosted.org/packages/76/27/07ee1b57b65e92645f219b37148a7e7928b82e2b5dbeccecb4dff7c64f0b/scipy-1.17.1-cp313-cp313-macosx_10_14_x86_64.whl", hash = "sha256:5e3c5c011904115f88a39308379c17f91546f77c1667cea98739fe0fccea804c", size = 31590199 }, + { url = "https://files.pythonhosted.org/packages/ec/ae/db19f8ab842e9b724bf5dbb7db29302a91f1e55bc4d04b1025d6d605a2c5/scipy-1.17.1-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:6fac755ca3d2c3edcb22f479fceaa241704111414831ddd3bc6056e18516892f", size = 28154001 }, + { url = "https://files.pythonhosted.org/packages/5b/58/3ce96251560107b381cbd6e8413c483bbb1228a6b919fa8652b0d4090e7f/scipy-1.17.1-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:7ff200bf9d24f2e4d5dc6ee8c3ac64d739d3a89e2326ba68aaf6c4a2b838fd7d", size = 20325719 }, + { url = "https://files.pythonhosted.org/packages/b2/83/15087d945e0e4d48ce2377498abf5ad171ae013232ae31d06f336e64c999/scipy-1.17.1-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:4b400bdc6f79fa02a4d86640310dde87a21fba0c979efff5248908c6f15fad1b", size = 22683595 }, + { url = "https://files.pythonhosted.org/packages/b4/e0/e58fbde4a1a594c8be8114eb4aac1a55bcd6587047efc18a61eb1f5c0d30/scipy-1.17.1-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:2b64ca7d4aee0102a97f3ba22124052b4bd2152522355073580bf4845e2550b6", size = 32896429 }, + { url = "https://files.pythonhosted.org/packages/f5/5f/f17563f28ff03c7b6799c50d01d5d856a1d55f2676f537ca8d28c7f627cd/scipy-1.17.1-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:581b2264fc0aa555f3f435a5944da7504ea3a065d7029ad60e7c3d1ae09c5464", size = 35203952 }, + { url = "https://files.pythonhosted.org/packages/8d/a5/9afd17de24f657fdfe4df9a3f1ea049b39aef7c06000c13db1530d81ccca/scipy-1.17.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:beeda3d4ae615106d7094f7e7cef6218392e4465cc95d25f900bebabfded0950", size = 34979063 }, + { url = "https://files.pythonhosted.org/packages/8b/13/88b1d2384b424bf7c924f2038c1c409f8d88bb2a8d49d097861dd64a57b2/scipy-1.17.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6609bc224e9568f65064cfa72edc0f24ee6655b47575954ec6339534b2798369", size = 37598449 }, + { url = "https://files.pythonhosted.org/packages/35/e5/d6d0e51fc888f692a35134336866341c08655d92614f492c6860dc45bb2c/scipy-1.17.1-cp313-cp313-win_amd64.whl", hash = "sha256:37425bc9175607b0268f493d79a292c39f9d001a357bebb6b88fdfaff13f6448", size = 36510943 }, + { url = "https://files.pythonhosted.org/packages/2a/fd/3be73c564e2a01e690e19cc618811540ba5354c67c8680dce3281123fb79/scipy-1.17.1-cp313-cp313-win_arm64.whl", hash = "sha256:5cf36e801231b6a2059bf354720274b7558746f3b1a4efb43fcf557ccd484a87", size = 24545621 }, + { url = "https://files.pythonhosted.org/packages/6f/6b/17787db8b8114933a66f9dcc479a8272e4b4da75fe03b0c282f7b0ade8cd/scipy-1.17.1-cp313-cp313t-macosx_10_14_x86_64.whl", hash = "sha256:d59c30000a16d8edc7e64152e30220bfbd724c9bbb08368c054e24c651314f0a", size = 31936708 }, + { url = "https://files.pythonhosted.org/packages/38/2e/524405c2b6392765ab1e2b722a41d5da33dc5c7b7278184a8ad29b6cb206/scipy-1.17.1-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:010f4333c96c9bb1a4516269e33cb5917b08ef2166d5556ca2fd9f082a9e6ea0", size = 28570135 }, + { url = "https://files.pythonhosted.org/packages/fd/c3/5bd7199f4ea8556c0c8e39f04ccb014ac37d1468e6cfa6a95c6b3562b76e/scipy-1.17.1-cp313-cp313t-macosx_14_0_arm64.whl", hash = "sha256:2ceb2d3e01c5f1d83c4189737a42d9cb2fc38a6eeed225e7515eef71ad301dce", size = 20741977 }, + { url = "https://files.pythonhosted.org/packages/d9/b8/8ccd9b766ad14c78386599708eb745f6b44f08400a5fd0ade7cf89b6fc93/scipy-1.17.1-cp313-cp313t-macosx_14_0_x86_64.whl", hash = "sha256:844e165636711ef41f80b4103ed234181646b98a53c8f05da12ca5ca289134f6", size = 23029601 }, + { url = "https://files.pythonhosted.org/packages/6d/a0/3cb6f4d2fb3e17428ad2880333cac878909ad1a89f678527b5328b93c1d4/scipy-1.17.1-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:158dd96d2207e21c966063e1635b1063cd7787b627b6f07305315dd73d9c679e", size = 33019667 }, + { url = "https://files.pythonhosted.org/packages/f3/c3/2d834a5ac7bf3a0c806ad1508efc02dda3c8c61472a56132d7894c312dea/scipy-1.17.1-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:74cbb80d93260fe2ffa334efa24cb8f2f0f622a9b9febf8b483c0b865bfb3475", size = 35264159 }, + { url = "https://files.pythonhosted.org/packages/4d/77/d3ed4becfdbd217c52062fafe35a72388d1bd82c2d0ba5ca19d6fcc93e11/scipy-1.17.1-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:dbc12c9f3d185f5c737d801da555fb74b3dcfa1a50b66a1a93e09190f41fab50", size = 35102771 }, + { url = "https://files.pythonhosted.org/packages/bd/12/d19da97efde68ca1ee5538bb261d5d2c062f0c055575128f11a2730e3ac1/scipy-1.17.1-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:94055a11dfebe37c656e70317e1996dc197e1a15bbcc351bcdd4610e128fe1ca", size = 37665910 }, + { url = "https://files.pythonhosted.org/packages/06/1c/1172a88d507a4baaf72c5a09bb6c018fe2ae0ab622e5830b703a46cc9e44/scipy-1.17.1-cp313-cp313t-win_amd64.whl", hash = "sha256:e30bdeaa5deed6bc27b4cc490823cd0347d7dae09119b8803ae576ea0ce52e4c", size = 36562980 }, + { url = "https://files.pythonhosted.org/packages/70/b0/eb757336e5a76dfa7911f63252e3b7d1de00935d7705cf772db5b45ec238/scipy-1.17.1-cp313-cp313t-win_arm64.whl", hash = "sha256:a720477885a9d2411f94a93d16f9d89bad0f28ca23c3f8daa521e2dcc3f44d49", size = 24856543 }, + { url = "https://files.pythonhosted.org/packages/cf/83/333afb452af6f0fd70414dc04f898647ee1423979ce02efa75c3b0f2c28e/scipy-1.17.1-cp314-cp314-macosx_10_14_x86_64.whl", hash = "sha256:a48a72c77a310327f6a3a920092fa2b8fd03d7deaa60f093038f22d98e096717", size = 31584510 }, + { url = "https://files.pythonhosted.org/packages/ed/a6/d05a85fd51daeb2e4ea71d102f15b34fedca8e931af02594193ae4fd25f7/scipy-1.17.1-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:45abad819184f07240d8a696117a7aacd39787af9e0b719d00285549ed19a1e9", size = 28170131 }, + { url = "https://files.pythonhosted.org/packages/db/7b/8624a203326675d7746a254083a187398090a179335b2e4a20e2ddc46e83/scipy-1.17.1-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:3fd1fcdab3ea951b610dc4cef356d416d5802991e7e32b5254828d342f7b7e0b", size = 20342032 }, + { url = "https://files.pythonhosted.org/packages/c9/35/2c342897c00775d688d8ff3987aced3426858fd89d5a0e26e020b660b301/scipy-1.17.1-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:7bdf2da170b67fdf10bca777614b1c7d96ae3ca5794fd9587dce41eb2966e866", size = 22678766 }, + { url = "https://files.pythonhosted.org/packages/ef/f2/7cdb8eb308a1a6ae1e19f945913c82c23c0c442a462a46480ce487fdc0ac/scipy-1.17.1-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:adb2642e060a6549c343603a3851ba76ef0b74cc8c079a9a58121c7ec9fe2350", size = 32957007 }, + { url = "https://files.pythonhosted.org/packages/0b/2e/7eea398450457ecb54e18e9d10110993fa65561c4f3add5e8eccd2b9cd41/scipy-1.17.1-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:eee2cfda04c00a857206a4330f0c5e3e56535494e30ca445eb19ec624ae75118", size = 35221333 }, + { url = "https://files.pythonhosted.org/packages/d9/77/5b8509d03b77f093a0d52e606d3c4f79e8b06d1d38c441dacb1e26cacf46/scipy-1.17.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:d2650c1fb97e184d12d8ba010493ee7b322864f7d3d00d3f9bb97d9c21de4068", size = 35042066 }, + { url = "https://files.pythonhosted.org/packages/f9/df/18f80fb99df40b4070328d5ae5c596f2f00fffb50167e31439e932f29e7d/scipy-1.17.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:08b900519463543aa604a06bec02461558a6e1cef8fdbb8098f77a48a83c8118", size = 37612763 }, + { url = "https://files.pythonhosted.org/packages/4b/39/f0e8ea762a764a9dc52aa7dabcfad51a354819de1f0d4652b6a1122424d6/scipy-1.17.1-cp314-cp314-win_amd64.whl", hash = "sha256:3877ac408e14da24a6196de0ddcace62092bfc12a83823e92e49e40747e52c19", size = 37290984 }, + { url = "https://files.pythonhosted.org/packages/7c/56/fe201e3b0f93d1a8bcf75d3379affd228a63d7e2d80ab45467a74b494947/scipy-1.17.1-cp314-cp314-win_arm64.whl", hash = "sha256:f8885db0bc2bffa59d5c1b72fad7a6a92d3e80e7257f967dd81abb553a90d293", size = 25192877 }, + { url = "https://files.pythonhosted.org/packages/96/ad/f8c414e121f82e02d76f310f16db9899c4fcde36710329502a6b2a3c0392/scipy-1.17.1-cp314-cp314t-macosx_10_14_x86_64.whl", hash = "sha256:1cc682cea2ae55524432f3cdff9e9a3be743d52a7443d0cba9017c23c87ae2f6", size = 31949750 }, + { url = "https://files.pythonhosted.org/packages/7c/b0/c741e8865d61b67c81e255f4f0a832846c064e426636cd7de84e74d209be/scipy-1.17.1-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:2040ad4d1795a0ae89bfc7e8429677f365d45aa9fd5e4587cf1ea737f927b4a1", size = 28585858 }, + { url = "https://files.pythonhosted.org/packages/ed/1b/3985219c6177866628fa7c2595bfd23f193ceebbe472c98a08824b9466ff/scipy-1.17.1-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:131f5aaea57602008f9822e2115029b55d4b5f7c070287699fe45c661d051e39", size = 20757723 }, + { url = "https://files.pythonhosted.org/packages/c0/19/2a04aa25050d656d6f7b9e7b685cc83d6957fb101665bfd9369ca6534563/scipy-1.17.1-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:9cdc1a2fcfd5c52cfb3045feb399f7b3ce822abdde3a193a6b9a60b3cb5854ca", size = 23043098 }, + { url = "https://files.pythonhosted.org/packages/86/f1/3383beb9b5d0dbddd030335bf8a8b32d4317185efe495374f134d8be6cce/scipy-1.17.1-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6e3dcd57ab780c741fde8dc68619de988b966db759a3c3152e8e9142c26295ad", size = 33030397 }, + { url = "https://files.pythonhosted.org/packages/41/68/8f21e8a65a5a03f25a79165ec9d2b28c00e66dc80546cf5eb803aeeff35b/scipy-1.17.1-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a9956e4d4f4a301ebf6cde39850333a6b6110799d470dbbb1e25326ac447f52a", size = 35281163 }, + { url = "https://files.pythonhosted.org/packages/84/8d/c8a5e19479554007a5632ed7529e665c315ae7492b4f946b0deb39870e39/scipy-1.17.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:a4328d245944d09fd639771de275701ccadf5f781ba0ff092ad141e017eccda4", size = 35116291 }, + { url = "https://files.pythonhosted.org/packages/52/52/e57eceff0e342a1f50e274264ed47497b59e6a4e3118808ee58ddda7b74a/scipy-1.17.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:a77cbd07b940d326d39a1d1b37817e2ee4d79cb30e7338f3d0cddffae70fcaa2", size = 37682317 }, + { url = "https://files.pythonhosted.org/packages/11/2f/b29eafe4a3fbc3d6de9662b36e028d5f039e72d345e05c250e121a230dd4/scipy-1.17.1-cp314-cp314t-win_amd64.whl", hash = "sha256:eb092099205ef62cd1782b006658db09e2fed75bffcae7cc0d44052d8aa0f484", size = 37345327 }, + { url = "https://files.pythonhosted.org/packages/07/39/338d9219c4e87f3e708f18857ecd24d22a0c3094752393319553096b98af/scipy-1.17.1-cp314-cp314t-win_arm64.whl", hash = "sha256:200e1050faffacc162be6a486a984a0497866ec54149a01270adc8a59b7c7d21", size = 25489165 }, +] + +[[package]] +name = "six" +version = "1.17.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050 }, +] + +[[package]] +name = "threadpoolctl" +version = "3.6.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/b7/4d/08c89e34946fce2aec4fbb45c9016efd5f4d7f24af8e5d93296e935631d8/threadpoolctl-3.6.0.tar.gz", hash = "sha256:8ab8b4aa3491d812b623328249fab5302a68d2d71745c8a4c719a2fcaba9f44e", size = 21274 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/32/d5/f9a850d79b0851d1d4ef6456097579a9005b31fea68726a4ae5f2d82ddd9/threadpoolctl-3.6.0-py3-none-any.whl", hash = "sha256:43a0b8fd5a2928500110039e43a5eed8480b918967083ea48dc3ab9f13c4a7fb", size = 18638 }, +] + +[[package]] +name = "tzdata" +version = "2026.2" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254 } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321 }, +] + +[[package]] +name = "wp-gbm-bakeoff" +version = "0.1.0" +source = { virtual = "." } +dependencies = [ + { name = "lightgbm" }, + { name = "numpy" }, + { name = "pandas" }, + { name = "pytest" }, + { name = "scikit-learn" }, +] + +[package.metadata] +requires-dist = [ + { name = "lightgbm", specifier = ">=4.6.0" }, + { name = "numpy", specifier = ">=2.4.6" }, + { name = "pandas", specifier = ">=3.0.3" }, + { name = "pytest", specifier = ">=9.1.0" }, + { name = "scikit-learn", specifier = ">=1.9.0" }, +] diff --git a/scripts/wp/gbm-prototype/validate_parity.ts b/scripts/wp/gbm-prototype/validate_parity.ts new file mode 100644 index 000000000..9a72eefe4 --- /dev/null +++ b/scripts/wp/gbm-prototype/validate_parity.ts @@ -0,0 +1,78 @@ +#!/usr/bin/env bun +/** + * On-real-data TS↔Python parity check for the GBM artifact. Loads + * artifacts/wp/model-gbm.json, reads each data/py_preds_.json (rows the + * Python serving path scored), runs predictWinProbability through the SHIPPED + * TS inference + isotonic, and asserts max |tsP - pyP| < 1e-6 per mode. + */ +import { featureHash } from "@/lib/win-probability/features"; +import { + predictWinProbability, + type ModelArtifact, +} from "@/lib/win-probability/model"; +import type { ModeFamily } from "@/lib/win-probability/types"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const FAMILIES: ModeFamily[] = ["control", "escort_hybrid", "flashpoint"]; +const TOL = 1e-6; + +const here = import.meta.dir; +const artPath = path.resolve( + here, + "..", + "..", + "..", + "artifacts", + "wp", + "model-gbm.json" +); +const artifact = JSON.parse(fs.readFileSync(artPath, "utf8")) as ModelArtifact; + +// The file already carries the literal hash; assert it matches the live code so +// predictWinProbability won't (correctly) refuse to load, then set it verbatim. +const liveHash = featureHash(); +if (artifact.featureHash !== liveHash) { + throw new Error( + `artifact featureHash ${artifact.featureHash} != code ${liveHash}` + ); +} +artifact.featureHash = liveHash; + +type PyPred = { row: number[]; p: number }; + +let allPass = true; +for (const fam of FAMILIES) { + const preds = JSON.parse( + fs.readFileSync(path.join(here, "data", `py_preds_${fam}.json`), "utf8") + ) as PyPred[]; + let maxDiff = 0; + let worst: { tsP: number; pyP: number; row: number[] } | null = null; + for (const { row, p: pyP } of preds) { + const tsP = predictWinProbability(artifact, fam, row); + if (tsP === null) + throw new Error(`${fam}: predictWinProbability returned null`); + const diff = Math.abs(tsP - pyP); + if (diff > maxDiff) { + maxDiff = diff; + worst = { tsP, pyP, row }; + } + } + const pass = maxDiff < TOL; + allPass &&= pass; + console.log( + `${fam}: n=${preds.length} maxAbsDiff=${maxDiff.toExponential(3)} ${pass ? "PASS" : "FAIL"}` + ); + if (!pass && worst) { + console.log(` worst: tsP=${worst.tsP} pyP=${worst.pyP}`); + console.log(` row=${JSON.stringify(worst.row)}`); + } +} + +if (!allPass) { + console.log("\nPARITY FAIL — do not upload."); + process.exit(1); +} +console.log( + "\nPARITY PASS — TS inference + isotonic reproduce Python on real data." +); diff --git a/scripts/wp/train.ts b/scripts/wp/train.ts new file mode 100644 index 000000000..af28d21a7 --- /dev/null +++ b/scripts/wp/train.ts @@ -0,0 +1,67 @@ +#!/usr/bin/env bun + +import { FEATURE_NAMES } from "@/lib/win-probability/features"; +import { buildArtifact } from "@/lib/win-probability/training/train-core"; +import { + type DatasetRow, + MODE_FAMILIES, + type ModeFamily, +} from "@/lib/win-probability/types"; +import * as fs from "node:fs"; +import * as path from "node:path"; + +const OUT_DIR = "artifacts/wp"; + +function readCsv(family: ModeFamily): DatasetRow[] { + const file = path.join(OUT_DIR, `dataset-${family}.csv`); + if (!fs.existsSync(file)) return []; + const lines = fs.readFileSync(file, "utf8").trim().split("\n").slice(1); + return lines.map((line) => { + const parts = line.split(","); + return { + matchId: Number(parts[0]), + roundId: parts[1], + label: Number(parts[2]) as 0 | 1, + features: parts.slice(3).map(Number), + }; + }); +} + +async function main() { + const rowsByFamily = Object.fromEntries( + MODE_FAMILIES.map((f) => [f, readCsv(f)]) + ) as Record; + + const { artifact, reports, trainedFamilies } = buildArtifact(rowsByFamily, 1); + for (const family of MODE_FAMILIES) { + console.log(`\n=== ${family} ===`); + for (const line of reports[family]) console.log(` ${line}`); + const model = artifact.modeFamilies[family]; + if (model !== null && model.kind !== "gbm") { + console.log(" weights:"); + FEATURE_NAMES.forEach((name, i) => { + console.log(` ${name}: ${model.weights[i].toFixed(4)}`); + }); + } + } + + if (trainedFamilies.length === 0) { + console.error("\nNo family passed gates — refusing to write an artifact."); + process.exit(1); + } + + const out = path.join(OUT_DIR, "model-v1.json"); + fs.writeFileSync(out, JSON.stringify(artifact, null, 2)); + console.log(`\nWrote ${out} (families: ${trainedFamilies.join(", ")})`); + + if (process.argv.includes("--upload")) { + const { publishArtifact } = + await import("@/lib/win-probability/artifact-store"); + const published = await publishArtifact(artifact); + console.log( + `Published ${published.key} (model version ${published.modelVersion})` + ); + } +} + +await main(); diff --git a/scripts/wp/upload-gbm.ts b/scripts/wp/upload-gbm.ts new file mode 100644 index 000000000..0becf6559 --- /dev/null +++ b/scripts/wp/upload-gbm.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env bun +// One-off: publish the locally-built, parity-validated GBM artifact to R2. +import { readFileSync } from "node:fs"; +import { publishArtifact } from "@/lib/win-probability/artifact-store"; +import { featureHash } from "@/lib/win-probability/features"; +import type { ModelArtifact } from "@/lib/win-probability/model"; + +const art = JSON.parse( + readFileSync("artifacts/wp/model-gbm.json", "utf8") +) as ModelArtifact; + +if (art.featureHash !== featureHash()) { + throw new Error(`hash ${art.featureHash} != code ${featureHash()}`); +} +art.createdAt = new Date().toISOString(); + +const published = await publishArtifact(art); +console.log( + `published ${published.key} (model version ${published.modelVersion})` +); + +for (const [fam, m] of Object.entries(art.modeFamilies)) { + if (m === null) { + console.log(` ${fam}: null`); + continue; + } + const kind = "trees" in m ? "gbm" : "lr"; + console.log( + ` ${fam}: ${kind} logLoss=${m.metrics?.logLoss?.toFixed(4) ?? "?"}` + ); +} diff --git a/src/app/(auth)/auth-error/layout.tsx b/src/app/(auth)/auth-error/layout.tsx index 2d81e1b98..d96e75ceb 100644 --- a/src/app/(auth)/auth-error/layout.tsx +++ b/src/app/(auth)/auth-error/layout.tsx @@ -1,4 +1,11 @@ import { Footer } from "@/components/footer"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("authError.metadata"); + return { title: t("title"), description: t("description") }; +} export default function AuthErrorLayout({ children, diff --git a/src/app/(auth)/auth-error/page.tsx b/src/app/(auth)/auth-error/page.tsx index 71df69a4d..65c2993e4 100644 --- a/src/app/(auth)/auth-error/page.tsx +++ b/src/app/(auth)/auth-error/page.tsx @@ -7,15 +7,14 @@ type Error = | "AccessDenied" | "Verification" | "AuthorizedCallbackError" + | "AdapterError" | "Default"; export default async function AuthErrorPage(props: PageProps<"/auth-error">) { const searchParams = await props.searchParams; const t = await getTranslations("authError"); - const error = searchParams.error as Error; - - const errorMessages = { + const errorMessages: Record = { Configuration: t("errors.configuration"), AccessDenied: t("errors.accessDenied"), AuthorizedCallbackError: t("errors.authorizedCallbackError"), @@ -23,6 +22,11 @@ export default async function AuthErrorPage(props: PageProps<"/auth-error">) { AdapterError: t("errors.adapterError"), Default: t("errors.default"), }; + const rawError = searchParams.error; + const error: Error = + typeof rawError === "string" && Object.hasOwn(errorMessages, rawError) + ? (rawError as Error) + : "Default"; return (
diff --git a/src/app/(auth)/sign-in/page.tsx b/src/app/(auth)/sign-in/page.tsx index af4003f33..c3ba4473a 100644 --- a/src/app/(auth)/sign-in/page.tsx +++ b/src/app/(auth)/sign-in/page.tsx @@ -40,10 +40,29 @@ export async function generateMetadata( }; } +const APP_ORIGIN = "https://parsertime.app"; + +function hasUnsafeRedirectChars(value: string) { + return [...value].some((char) => { + const code = char.charCodeAt(0); + return char === "\\" || code <= 31 || code === 127; + }); +} + function getSafeCallbackUrl(callbackUrl: string | undefined): string { - if (callbackUrl?.startsWith("/") && !callbackUrl.startsWith("//")) { - return callbackUrl; + if (!callbackUrl || hasUnsafeRedirectChars(callbackUrl)) { + return "/dashboard"; } + + try { + const url = new URL(callbackUrl, APP_ORIGIN); + if (url.origin === APP_ORIGIN) { + return `${url.pathname}${url.search}${url.hash}`; + } + } catch { + // Fall through to the default dashboard path. + } + return "/dashboard"; } diff --git a/src/app/(auth)/verify-request/layout.tsx b/src/app/(auth)/verify-request/layout.tsx index e2a7d0998..e266d7fbf 100644 --- a/src/app/(auth)/verify-request/layout.tsx +++ b/src/app/(auth)/verify-request/layout.tsx @@ -1,4 +1,11 @@ import { Footer } from "@/components/footer"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("verifyRequest.metadata"); + return { title: t("title"), description: t("description") }; +} export default function RequestLayout({ children, diff --git a/src/app/[team]/compare/page.tsx b/src/app/[team]/compare/page.tsx index ecc167ba0..9fe5102f3 100644 --- a/src/app/[team]/compare/page.tsx +++ b/src/app/[team]/compare/page.tsx @@ -6,7 +6,7 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; diff --git a/src/app/[team]/map-groups/page.tsx b/src/app/[team]/map-groups/page.tsx index 07d7480d8..a8505c1c2 100644 --- a/src/app/[team]/map-groups/page.tsx +++ b/src/app/[team]/map-groups/page.tsx @@ -6,7 +6,7 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; diff --git a/src/app/[team]/ops/page.tsx b/src/app/[team]/ops/page.tsx new file mode 100644 index 000000000..a344d85be --- /dev/null +++ b/src/app/[team]/ops/page.tsx @@ -0,0 +1,91 @@ +import { BlacklistManager } from "@/components/team-ops/blacklist-manager"; +import { DashboardLayout } from "@/components/dashboard-layout"; +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import { canManageTeam } from "@/lib/auth"; +import { + listBlacklist, + getBlacklistSuggestions, +} from "@/lib/team-ops/blacklist"; +import type { PagePropsWithLocale } from "@/types/next"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +export async function generateMetadata( + props: PagePropsWithLocale<"/[team]/ops"> +): Promise { + const params = await props.params; + const t = await getTranslations({ + locale: params.locale, + namespace: "teamOps", + }); + + return { + title: t("title"), + }; +} + +export default async function TeamOpsPage( + props: PagePropsWithLocale<"/[team]/ops"> +) { + const params = await props.params; + const session = await auth(); + + if (!session?.user?.email) { + notFound(); + } + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) { + notFound(); + } + + const teamId = parseInt(params.team); + if (isNaN(teamId)) { + notFound(); + } + + if (!(await canManageTeam(teamId, user))) { + notFound(); + } + + const [rows, suggestions] = await Promise.all([ + listBlacklist(teamId), + getBlacklistSuggestions(teamId), + ]); + + const t = await getTranslations({ + locale: params.locale, + namespace: "teamOps", + }); + + return ( + +
+
+
+

{t("title")}

+

+ {t("blacklist.subtitle")} +

+
+
+ + {/* Blacklist section — additional sections slot in below */} +
+

{t("blacklist.heading")}

+ +
+
+
+ ); +} diff --git a/src/app/[team]/scrim/[scrimId]/edit/page.tsx b/src/app/[team]/scrim/[scrimId]/edit/page.tsx index 7eaa7cd14..099b06bf1 100644 --- a/src/app/[team]/scrim/[scrimId]/edit/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/edit/page.tsx @@ -11,9 +11,28 @@ import { auth } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; -import type { Route } from "next"; +import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; +export async function generateMetadata( + props: PageProps<"/[team]/scrim/[scrimId]/edit"> +): Promise { + const params = await props.params; + const t = await getTranslations("scrimPage.editMetadata"); + const scrim = await AppRuntime.runPromise( + ScrimService.pipe( + Effect.flatMap((svc) => svc.getScrim(parseInt(params.scrimId))) + ) + ); + + if (!scrim) return { title: "Edit Scrim | Parsertime" }; + + return { + title: t("title", { scrimName: scrim.name }), + description: t("description", { scrimName: scrim.name }), + }; +} + export default async function EditScrimPage( props: PageProps<"/[team]/scrim/[scrimId]/edit"> ) { @@ -44,13 +63,12 @@ export default async function EditScrimPage( ] ); - const maps = ( - await prisma.map.findMany({ - where: { - scrimId: scrim.id, - }, - }) - ).sort((a, b) => a.id - b.id); + const maps = await prisma.map.findMany({ + where: { + scrimId: scrim.id, + }, + orderBy: [{ order: "asc" }, { id: "asc" }], + }); const [heroBansByMap, teamNamesByMap] = await Promise.all([ Promise.all( diff --git a/src/app/[team]/scrim/[scrimId]/loading.tsx b/src/app/[team]/scrim/[scrimId]/loading.tsx index 2b88c9467..aa6e6b05d 100644 --- a/src/app/[team]/scrim/[scrimId]/loading.tsx +++ b/src/app/[team]/scrim/[scrimId]/loading.tsx @@ -9,22 +9,21 @@ export default async function ScrimLoading() { return ( -
-
- -
- -
- -
+
+ + + -
-

- {t("maps.title")} -

+ -
- {Array.from({ length: 6 }).map((_, index) => ( + + +
+

+ {t("maps.title")} +

+
+ {Array.from({ length: 8 }).map((_, index) => (
-
+
@@ -16,17 +19,15 @@ export default function MapDashboardLoading() {
-
-
-
-

- -

-
+ +
+
-

- -

+

+ +

diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx index 0abb9b6f4..3fc37eeee 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/page.tsx @@ -1,68 +1,69 @@ +import { AppHeader } from "@/components/app-header"; import { MapCharts } from "@/components/charts/map/map-charts"; -import { MainNav } from "@/components/dashboard/main-nav"; -import { Search } from "@/components/dashboard/search"; import { DirectionalTransition } from "@/components/directional-transition"; -import { GuestNav } from "@/components/guest-nav"; -import { LocaleSwitcher } from "@/components/locale-switcher"; import { ComparePlayers } from "@/components/map/compare-players"; import { DefaultOverview } from "@/components/map/default-overview"; +import { FightInitiationInspector } from "@/components/map/fight-initiation-inspector"; import { HeatmapTab } from "@/components/map/heatmap/heatmap-tab"; -import { ReplayTab } from "@/components/map/replay/replay-tab"; import { HeroBans } from "@/components/map/hero-bans"; import { Killfeed } from "@/components/map/killfeed"; import { MapEvents } from "@/components/map/map-events"; import { MapTabs } from "@/components/map/map-tabs"; +import { MapTabsSkeleton } from "@/components/map/map-tabs-skeleton"; +import { MatchStoryTab } from "@/components/map/match-story/match-story-tab"; import { PlayerSwitcher } from "@/components/map/player-switcher"; -import { MobileNav } from "@/components/mobile-nav"; -import { Notifications } from "@/components/notifications"; +import { ReplayTab } from "@/components/map/replay/replay-tab"; +import { RoutesTab } from "@/components/map/routes/routes-tab"; import { ReplayCode } from "@/components/scrim/replay-code"; -import { ModeToggle } from "@/components/theme-switcher"; import { TipTap } from "@/components/tiptap/tiptap"; -import { MapTabsSkeleton } from "@/components/map/map-tabs-skeleton"; -import { Suspense, ViewTransition } from "react"; -import { UserNav } from "@/components/user-nav"; +import { StatsViewBeacon } from "@/components/usage/stats-view-beacon"; import { VodOverview } from "@/components/vods/vod-overview"; +import { MatchStoryService } from "@/data/map/match-story-service"; import { PlayerService } from "@/data/player"; -import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, isAuthedToViewMap } from "@/lib/auth"; import { - aiChat, - coachingCanvas, - dataLabeling, - positionalData, - scoutingTool, - tempoChart, - tournament, -} from "@/lib/flags"; -import { resolveMapDataId } from "@/lib/map-data-resolver"; + getFightInitiationForMapData, + type MapInitiationResult, +} from "@/lib/fight-initiation"; +import { positionalData, tempoChart } from "@/lib/flags"; +import { resolveScrimMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; -import { getColorblindMode, translateMapName } from "@/lib/utils"; +import { getColorblindMode } from "@/lib/server-utils"; +import { translateMapName } from "@/lib/utils"; import type { PagePropsWithLocale, SearchParams } from "@/types/next"; +import { Effect } from "effect"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; +import { Suspense, ViewTransition } from "react"; export async function generateMetadata( props: PagePropsWithLocale<"/[team]/scrim/[scrimId]/map/[mapId]"> ): Promise { const params = await props.params; - const mapId = decodeURIComponent(params.mapId); - const metadataMapDataId = await resolveMapDataId(parseInt(mapId)); + const scrimId = parseInt(params.scrimId); + const mapId = parseInt(decodeURIComponent(params.mapId)); + const canViewMap = + Number.isSafeInteger(scrimId) && + Number.isSafeInteger(mapId) && + (await isAuthedToViewMap(scrimId, mapId)); const t = await getTranslations({ locale: params.locale, namespace: "mapPage.mapMetadata", }); - const mapName = await prisma.matchStart.findFirst({ - where: { - MapDataId: metadataMapDataId, - }, - select: { - map_name: true, - }, - }); + const mapName = canViewMap + ? await prisma.matchStart.findFirst({ + where: { + MapDataId: await resolveScrimMapDataId(scrimId, mapId), + }, + select: { + map_name: true, + }, + }) + : null; const translatedMapName = await translateMapName(mapName?.map_name ?? "Map"); @@ -95,7 +96,7 @@ export default async function MapDashboardPage( const params = await props.params; const searchParams = await props.searchParams; const id = parseInt(params.mapId); - const mapDataId = await resolveMapDataId(id); + const mapDataId = await resolveScrimMapDataId(parseInt(params.scrimId), id); const session = await auth(); const user = await AppRuntime.runPromise( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) @@ -116,13 +117,10 @@ export default async function MapDashboardPage( visibility, heroBans, noteContent, - scoutingEnabled, tempoChartEnabled, positionalDataEnabled, - aiChatEnabled, - dataToolsEnabled, - tournamentEnabled, - coachingCanvasEnabled, + matchStory, + fightInitiation, ] = await Promise.all([ AppRuntime.runPromise( PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) @@ -149,13 +147,24 @@ export default async function MapDashboardPage( }, select: { content: true }, }), - scoutingTool(), tempoChart(), positionalData(), - aiChat(), - dataLabeling(), - tournament(), - coachingCanvas(), + // A story failure must never break the map page — the tab just hides. + AppRuntime.runPromise( + MatchStoryService.pipe( + Effect.flatMap((svc) => svc.getMatchStory(mapDataId)), + Effect.catchAll(() => Effect.succeed(null)) + ) + ), + getFightInitiationForMapData(mapDataId).catch( + () => + ({ + available: false, + labels: [], + summary: null, + rounds: [], + }) satisfies MapInitiationResult + ), ]); const translatedMapName = await translateMapName( @@ -164,88 +173,38 @@ export default async function MapDashboardPage( return ( +
-
-
- - - -
- - - - {session ? ( - <> - - - - ) : ( - - )} -
-
-
- -
- - - {session ? ( - <> - - - - ) : ( - - )} -
-
-
-
-
-

- - ← {t("back")} - -

-
+ } + session={session} + user={user} + guestMode={visibility?.guestMode ?? false} + /> +
+
-

+

{translatedMapName} -

+
-
+
{map?.replayCode && ( )} @@ -281,8 +240,30 @@ export default async function MapDashboardPage( { value: "charts", label: t("tabs.charts"), - content: , + content: ( + + ), }, + ...(matchStory !== null + ? [ + { + value: "story", + label: t("tabs.story"), + content: ( + + ), + }, + ] + : []), ...(positionalDataEnabled ? [ { @@ -295,6 +276,11 @@ export default async function MapDashboardPage( label: t("tabs.replay"), content: , }, + { + value: "routes", + label: t("tabs.routes"), + content: , + }, ] : []), { @@ -306,10 +292,17 @@ export default async function MapDashboardPage( id={id} team1Color={team1} team2Color={team2} - tempoChartEnabled={tempoChartEnabled} + includePositional={positionalDataEnabled} /> ), }, + { + value: "initiation", + label: t("tabs.initiation"), + content: ( + + ), + }, { value: "compare", label: t("tabs.compare"), @@ -319,13 +312,11 @@ export default async function MapDashboardPage( value: "notes", label: t("tabs.notes"), content: ( -
- -
+ ), }, { diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/loading.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/loading.tsx index ad4b1daa9..7826c4c70 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/loading.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/loading.tsx @@ -1,23 +1,23 @@ import { DirectionalTransition } from "@/components/directional-transition"; -import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { CardIcon } from "@/components/ui/card-icon"; +import { StatPanel } from "@/components/player/stat-panel"; import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { getTranslations } from "next-intl/server"; +const STAT_BLOCK_SLOTS = ["a", "b", "c", "d"] as const; +const HERO_STAT_SLOTS = ["a", "b", "c", "d", "e", "f"] as const; + export default async function PlayerDashboardLoading() { - const t = await getTranslations("mapPage.player.overview"); + const t = await getTranslations("mapPage.player"); return (
-
-
+
+
@@ -32,154 +32,84 @@ export default async function PlayerDashboardLoading() {
-
-
-
-

- -

+ +
+
+ + +
-
-

- -

+ +
+
-
- + +
+ + + + +
-
- - - - {t("matchTime")} - - - - - - - -
- -
-
- - - -
- - - - {t("fletaTitle")} - - - - - - - - - - - -
- -
-
- - - -
- - - - {t("firstPickTitle")} - - - - - - - - - -
- -
-
- - - -
- - - - {t("firstDeathTitle")} - - - - - - -
- + + + + + {t("dashboard.overview")} + + + {t("dashboard.analytics")} + + {t("dashboard.charts")} + + + + +
+ {STAT_BLOCK_SLOTS.map((slot) => ( +
+ + +
- - - - - -
-
- - - {t("playerStats")} - - -
-

- -

-
-
-
- - - - - - - - - - - - - - - - - - - - - - - - -
+ ))} +
+ + +
+ +
+
+ +
+
+ +
-
-
-
- +
+ {HERO_STAT_SLOTS.map((slot) => ( +
+ + +
-
+ ))}
-
-
-
+ +
+
+
+ +
+
+
diff --git a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx index 3acdd9705..e19a11f0d 100644 --- a/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]/page.tsx @@ -1,30 +1,24 @@ +import { AppHeader } from "@/components/app-header"; import { PlayerCharts } from "@/components/charts/player/player-charts"; -import { MainNav } from "@/components/dashboard/main-nav"; -import { Search } from "@/components/dashboard/search"; import { DirectionalTransition } from "@/components/directional-transition"; -import { GuestNav } from "@/components/guest-nav"; -import { LocaleSwitcher } from "@/components/locale-switcher"; import { PlayerSwitcher } from "@/components/map/player-switcher"; -import { MobileNav } from "@/components/mobile-nav"; -import { Notifications } from "@/components/notifications"; import { PlayerAnalytics } from "@/components/player/analytics"; import { DefaultOverview } from "@/components/player/default-overview"; -import { ModeToggle } from "@/components/theme-switcher"; +import { PlayerTelemetry } from "@/components/player/player-telemetry"; +import { Link } from "@/components/ui/link"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { UserNav } from "@/components/user-nav"; import { PlayerService } from "@/data/player"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { aiChat, dataLabeling, scoutingTool } from "@/lib/flags"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; -import { toTitleCase } from "@/lib/utils"; +import { translateHeroName, translateMapName } from "@/lib/utils"; +import { heroRoleMapping, type HeroName } from "@/types/heroes"; import type { PagePropsWithLocale } from "@/types/next"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; -import Link from "next/link"; export async function generateMetadata( props: PagePropsWithLocale<"/[team]/scrim/[scrimId]/map/[mapId]/player/[playerId]"> @@ -81,6 +75,16 @@ export default async function PlayerDashboardPage( }, }); + const playerEntry = mostPlayedHeroes.find( + (entry) => entry.player_name === playerName + ); + const topHero = playerEntry?.player_hero as HeroName | undefined; + const role = topHero ? heroRoleMapping[topHero] : null; + const heroDisplayName = topHero ? await translateHeroName(topHero) : null; + const translatedMapName = await translateMapName( + mapName?.map_name ?? t("dashboard") + ); + const session = await auth(); const user = await AppRuntime.runPromise( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) @@ -95,94 +99,81 @@ export default async function PlayerDashboardPage( }, })) ?? { guestMode: false }; - const [scoutingEnabled, aiChatEnabled, dataToolsEnabled] = await Promise.all([ - scoutingTool(), - aiChat(), - dataLabeling(), - ]); - return (
-
-
- - - -
- - - - {session ? ( - <> - - - - ) : ( - - )} -
-
-
- -
- - - {session ? ( - <> - - - - ) : ( - - )} -
-
-
-
-
-

- - ← {t("back")} - - {" | "} - - {t("viewStats")} → - -

+ } + session={session} + user={user} + guestMode={visibility.guestMode} + /> +
+ + +
+

+ {playerName} +

-
-

- {toTitleCase(mapName?.map_name ?? t("dashboard"))} -

+ +
+ {translatedMapName} + {role && ( + <> + + {role} + + )} + {heroDisplayName && ( + <> + + {heroDisplayName} + + )} + {playerEntry?.player_team && ( + <> + + {playerEntry.player_team} + + )}
- - + + + {t("overview")} {t("analytics")} {t("charts")} + {t("telemetry")} @@ -193,6 +184,9 @@ export default async function PlayerDashboardPage( + + +
diff --git a/src/app/[team]/scrim/[scrimId]/page.tsx b/src/app/[team]/scrim/[scrimId]/page.tsx index 7e052b2a1..cdb613a9b 100644 --- a/src/app/[team]/scrim/[scrimId]/page.tsx +++ b/src/app/[team]/scrim/[scrimId]/page.tsx @@ -1,27 +1,42 @@ +import { ScrimFeedbackBanner } from "@/components/team-ops/scrim-feedback-banner"; import { DashboardLayout } from "@/components/dashboard-layout"; import { DirectionalTransition } from "@/components/directional-transition"; import { AddMapCard } from "@/components/map/add-map"; import { ClientDate } from "@/components/scrim/client-date"; import { CompareSelectedButton } from "@/components/scrim/compare-selected-button"; import { MapCardWithSelection } from "@/components/scrim/map-card-with-selection"; -import { ScrimOverviewCard } from "@/components/scrim/scrim-overview-card"; +import { PositionalStatsSection } from "@/components/scrim/positional-stats-section"; +import { ScrimOverviewUnavailable } from "@/components/scrim/scrim-overview-unavailable"; +import { ScrimWpaSection } from "@/components/scrim/scrim-wpa-section"; +import { Suspense } from "react"; +import { + ScrimOverviewSection, + WinLossBadge, + WinRateBadge, +} from "@/components/scrim/scrim-overview-section"; import { Alert, AlertDescription, AlertTitle } from "@/components/ui/alert"; -import { Badge } from "@/components/ui/badge"; import { Link } from "@/components/ui/link"; import { Tooltip, TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import { ScrimService } from "@/data/scrim"; +import { + ScrimOverviewService, + ScrimPositionalArtifactsService, + ScrimPositionalStatsService, + ScrimService, +} from "@/data/scrim"; +import { ScrimInitiationService } from "@/data/scrim/initiation-service"; +import { resolveScrimMapWinners } from "@/data/scrim/map-winner-names"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; -import { mapComparison, overviewCard } from "@/lib/flags"; +import { auth, canManageTeam, isAuthedToViewScrim } from "@/lib/auth"; +import { mapComparison, overviewCard, positionalData } from "@/lib/flags"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { BadgeCheck } from "lucide-react"; import { ExclamationTriangleIcon, Pencil2Icon } from "@radix-ui/react-icons"; import type { Metadata, Route } from "next"; @@ -36,16 +51,22 @@ export async function generateMetadata( locale: params.locale, namespace: "scrimPage.metadata", }); - const scrimId = decodeURIComponent(params.scrimId); + const scrimId = Number(params.scrimId); + const canViewScrim = + Number.isSafeInteger(scrimId) && + scrimId > 0 && + (await isAuthedToViewScrim(scrimId)); - const scrim = await prisma.scrim.findFirst({ - where: { - id: parseInt(scrimId), - }, - select: { - name: true, - }, - }); + const scrim = canViewScrim + ? await prisma.scrim.findFirst({ + where: { + id: scrimId, + }, + select: { + name: true, + }, + }) + : null; const scrimName = scrim?.name ?? t("scrim"); @@ -60,7 +81,7 @@ export async function generateMetadata( siteName: "Parsertime", images: [ { - url: `https://parsertime.app/api/og?title=${t("ogImage", { scrimName })}`, + url: `https://parsertime.app/api/og?title=${encodeURIComponent(t("ogImage", { scrimName }))}`, width: 1200, height: 630, }, @@ -85,13 +106,40 @@ export default async function ScrimDashboardPage( const teamId = scrim.teamId; - const maps = ( - await prisma.map.findMany({ - where: { - scrimId: id, + const maps = await prisma.map.findMany({ + where: { + scrimId: id, + }, + orderBy: [{ order: "asc" }, { id: "asc" }], + }); + + // Per-map team names (from the round's MatchStart) power the winner-override + // dialog. The set-winner endpoint validates the submitted winner against the + // map's own MatchStart names, so the dialog must offer them verbatim. Keyed + // by Map.id via MapData. + const mapTeamNames = new Map(); + if (maps.length > 0) { + const mapDataRows = await prisma.mapData.findMany({ + where: { scrimId: id }, + select: { + Map: { select: { id: true } }, + match_start: { + select: { team_1_name: true, team_2_name: true }, + take: 1, + }, }, - }) - ).sort((a, b) => a.id - b.id); + }); + for (const row of mapDataRows) { + const mapId = row.Map?.id; + const ms = row.match_start[0]; + if (mapId != null && ms && !mapTeamNames.has(mapId)) { + mapTeamNames.set(mapId, { + team1: ms.team_1_name, + team2: ms.team_2_name, + }); + } + } + } const user = await AppRuntime.runPromise( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) @@ -112,6 +160,19 @@ export default async function ScrimDashboardPage( user?.role === $Enums.UserRole.MANAGER || user?.role === $Enums.UserRole.ADMIN; + const feedbackScrim = await prisma.scrim.findUnique({ + where: { id }, + select: { + id: true, + teamId: true, + opponentTeamId: true, + feedback: { select: { id: true } }, + opponentTeam: { select: { name: true } }, + }, + }); + + const canManage = await canManageTeam(feedbackScrim?.teamId, user); + const visibility = (await prisma.scrim.findFirst({ where: { id: parseInt(params.scrimId), @@ -121,143 +182,316 @@ export default async function ScrimDashboardPage( }, })) ?? { guestMode: false }; - const [mapComparisonEnabled, overviewCardEnabled, opponentFullName] = - await Promise.all([ - mapComparison(), - overviewCard(), - scrim.opponentTeamAbbr - ? prisma.scoutingMatch - .findFirst({ - where: { - OR: [ - { team1: scrim.opponentTeamAbbr }, - { team2: scrim.opponentTeamAbbr }, - ], - }, - select: { - team1: true, - team1FullName: true, - team2: true, - team2FullName: true, - }, - }) - .then((m) => { - if (!m) return scrim.opponentTeamAbbr; - return m.team1 === scrim.opponentTeamAbbr - ? m.team1FullName - : m.team2FullName; - }) - : Promise.resolve(null), - ]); + const [ + mapComparisonEnabled, + overviewCardEnabled, + showPositional, + opponentFullName, + ] = await Promise.all([ + mapComparison(), + overviewCard(), + positionalData(), + scrim.opponentTeamAbbr + ? prisma.scoutingMatch + .findFirst({ + where: { + OR: [ + { team1: scrim.opponentTeamAbbr }, + { team2: scrim.opponentTeamAbbr }, + ], + }, + select: { + team1: true, + team1FullName: true, + team2: true, + team2FullName: true, + }, + }) + .then((m) => { + if (!m) return scrim.opponentTeamAbbr; + return m.team1 === scrim.opponentTeamAbbr + ? m.team1FullName + : m.team2FullName; + }) + : Promise.resolve(null), + ]); + + // The overview's roster-identity heuristic (getTeamRoster) anchors on the + // most-frequent player across the team's maps, which can't reliably tell + // which side is "our team" until the team has at least two scrims. Mirror + // the team stats page (totalScrimCount < 2 -> placeholder) and skip the + // overview for new teams rather than render a possibly-inverted record. + const totalScrimCount = teamId + ? await prisma.scrim.count({ where: { teamId } }) + : 0; + const isNewTeam = teamId !== null && totalScrimCount < 2; + + const overviewData = + overviewCardEnabled && maps.length > 0 && teamId && !isNewTeam + ? await AppRuntime.runPromise( + ScrimOverviewService.pipe( + Effect.flatMap((svc) => svc.getScrimOverview(id, teamId)) + ) + ) + : null; + const showOverview = + overviewData !== null && + overviewData.mapCount > 0 && + overviewData.teamPlayers.length > 0; + const showOverviewUnavailable = + overviewCardEnabled && isNewTeam && maps.length > 0; + + const positionalStats = + showPositional && maps.length > 0 + ? await AppRuntime.runPromise( + ScrimPositionalStatsService.pipe( + Effect.flatMap((svc) => svc.getScrimPositionalStats(id)) + ) + ) + : null; + + const positionalArtifacts = + showPositional && maps.length > 0 && teamId + ? await AppRuntime.runPromise( + ScrimPositionalArtifactsService.pipe( + Effect.flatMap((svc) => svc.getScrimPositionalArtifacts(id, teamId)) + ) + ) + : null; + + const scrimInitiation = + overviewCardEnabled && maps.length > 0 && teamId && !isNewTeam + ? await AppRuntime.runPromise( + ScrimInitiationService.pipe( + Effect.flatMap((svc) => svc.getScrimInitiation(id)) + ) + ) + : null; + + // Build per-map winner metadata for the map cards' W/L badge + override + // dialog. The resolved winner prefers the stored Map.winner (manual override + // or auto-detected); otherwise it falls back to the overview's resolved + // result mapped back to a team name. ourTeamName is consistent across a + // scrim's maps, so the overview's global value drives the Won/Lost badge. + const ourTeamName = overviewData?.ourTeamName ?? null; + const opponentTeamName = overviewData?.opponentTeamName ?? null; + const mapResultById = new Map( + (overviewData?.mapResults ?? []).map((r) => [r.mapId, r.winner]) + ); + // When the overview is gated off (new team, individual scrim, or flag off) + // there is no ourTeamName to classify Won/Lost and no mapResults to fall back + // on. Resolve the literal winning team name per map instead so the card can + // still surface the result (as a neutral winner badge). + const ungatedWinners = overviewData ? null : await resolveScrimMapWinners(id); + const mapMetaById = new Map< + number, + { + team1Name: string | null; + team2Name: string | null; + resolvedWinner: string | null; + } + >(); + for (const m of maps) { + const names = mapTeamNames.get(m.id) ?? null; + let resolvedWinner: string | null = m.winner ?? null; + if (!resolvedWinner) { + if (overviewData) { + const result = mapResultById.get(m.id); + if (result === "our_team") resolvedWinner = ourTeamName; + else if (result === "opponent") resolvedWinner = opponentTeamName; + else if (result === "draw") resolvedWinner = "N/A"; + } else { + resolvedWinner = ungatedWinners?.get(m.id) ?? null; + } + } + mapMetaById.set(m.id, { + team1Name: names?.team1 ?? null, + team2Name: names?.team2 ?? null, + resolvedWinner, + }); + } return ( -
- {/* Header Section */} -
-

- - ← {t("back")} - - {teamId && ( - <> - {" | "} - - {t("viewStats")} → - - - )} -

-
-

- - {scrim?.name ?? t("newScrim")}{" "} - {hasPerms && ( +
+ + +
+
+

+ {scrim?.name ?? t("newScrim")} +

+ {hasPerms && ( + + - - - - - {t("edit")} - + - )} - -

+ + {t("edit")} + + )}
-
-

- -

- {scrim.opponentTeamAbbr && ( + {showOverview && ( +
+ + +
+ )} +
+ +
+ + + {t("meta.mapCount", { count: maps.length })} + {scrim.opponentTeamAbbr && ( + <> + - - + View OWCS scouting report - )} -
+ + )}
- {/* Overview Card */} - {overviewCardEnabled && maps.length > 0 && teamId && ( - - )} + {feedbackScrim?.opponentTeamId != null && + feedbackScrim.opponentTeam && + !feedbackScrim.feedback && + canManage && ( +
+ +
+ )} - {/* Drop Zone (managers/owners only) */} - {hasPerms && } + {showOverview ? ( +
+ + + + } + /> +
+ ) : showOverviewUnavailable ? ( +
+ +
+ ) : ( + showPositional && + positionalStats && ( +
+ +
+ ) + )} - {/* Maps Section */} -
-
-

- {t("maps.title")} -

+ {hasPerms && ( +
+
+ )} + +
+

+ {t("maps.title")} +

{maps.length > 0 ? ( -
- {maps.map((map) => ( - - ))} +
+ {maps.map((map) => { + const meta = mapMetaById.get(map.id); + return ( + + ); + })}
) : ( - - + +
- {/* Compare Selected Button */} {mapComparisonEnabled && teamId && ( )} diff --git a/src/app/api/(cron)/delete-empty-scrims/route.ts b/src/app/api/(cron)/delete-empty-scrims/route.ts index a534a0e9b..f183e73cc 100644 --- a/src/app/api/(cron)/delete-empty-scrims/route.ts +++ b/src/app/api/(cron)/delete-empty-scrims/route.ts @@ -5,8 +5,42 @@ import { } from "@/lib/axiom/metrics"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; +import { timingSafeEqual } from "node:crypto"; +import type { NextRequest } from "next/server"; + +type CronAuthResult = + | { ok: true } + | { ok: false; status: number; body: string }; + +function authorizeCron(req: NextRequest): CronAuthResult { + const secret = process.env.CRON_SECRET; + if (!secret) { + return { ok: false, status: 500, body: "Server misconfigured" }; + } + + const header = req.headers.get("Authorization"); + const provided = header?.startsWith("Bearer ") ? header.slice(7) : null; + if (!provided || provided.length !== secret.length) { + return { ok: false, status: 401, body: "Unauthorized" }; + } + + try { + if (timingSafeEqual(Buffer.from(provided), Buffer.from(secret))) { + return { ok: true }; + } + } catch { + // Fall through to the unauthorized response. + } + + return { ok: false, status: 401, body: "Unauthorized" }; +} + +export async function DELETE(req: NextRequest) { + const auth = authorizeCron(req); + if (!auth.ok) { + return new Response(auth.body, { status: auth.status }); + } -export async function DELETE() { const start = performance.now(); cronJobCounter.add(1, { job: "delete-empty-scrims" }); @@ -31,7 +65,13 @@ export async function DELETE() { for (const scrim of scrimsWithoutMaps) { Logger.info(`Deleting scrim ${scrim.id}`); - await prisma.scrim.delete({ where: { id: scrim.id } }); + await prisma.scrim.deleteMany({ + where: { + id: scrim.id, + maps: { none: {} }, + tournamentMatch: null, + }, + }); } cronDeletedItemsCounter.add(scrimsWithoutMaps.length, { @@ -45,6 +85,6 @@ export async function DELETE() { } // This is necessary for using Vercel Cron Jobs -export async function GET() { - return await DELETE(); +export async function GET(req: NextRequest) { + return await DELETE(req); } diff --git a/src/app/api/(cron)/delete-unused-blobs/route.ts b/src/app/api/(cron)/delete-unused-blobs/route.ts index d3f421e7a..5b49aee42 100644 --- a/src/app/api/(cron)/delete-unused-blobs/route.ts +++ b/src/app/api/(cron)/delete-unused-blobs/route.ts @@ -6,12 +6,37 @@ import { import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { del, list } from "@vercel/blob"; +import type { NextRequest } from "next/server"; const VALID_IMAGE_URL_HOSTS = { vercel_blob: "public.blob.vercel-storage.com", }; +const BLOB_DELETE_GRACE_MS = 60 * 60 * 1000; + +function isCronAuthorized(req: NextRequest) { + const secret = process.env.CRON_SECRET; + return ( + Boolean(secret) && req.headers.get("Authorization") === `Bearer ${secret}` + ); +} + +function isVercelBlobUrl(urlString: string): boolean { + try { + const url = new URL(urlString); + return ( + url.protocol === "https:" && + url.hostname.endsWith(`.${VALID_IMAGE_URL_HOSTS.vercel_blob}`) + ); + } catch { + return false; + } +} + +export async function DELETE(req: NextRequest) { + if (!isCronAuthorized(req)) { + return new Response("Unauthorized", { status: 401 }); + } -export async function DELETE() { const start = performance.now(); cronJobCounter.add(1, { job: "delete-unused-blobs" }); @@ -23,9 +48,7 @@ export async function DELETE() { const userImages = usersWithImages.flatMap((user) => [user.image, user.bannerImage].filter(Boolean) ); - const userBlobs = userImages.filter((url) => - url.includes(VALID_IMAGE_URL_HOSTS.vercel_blob) - ); + const userBlobs = userImages.filter(isVercelBlobUrl); const teamsWithImages = await prisma.team.findMany({ where: { image: { not: null } }, @@ -33,16 +56,18 @@ export async function DELETE() { }); const teamImages = teamsWithImages.map((team) => team.image).filter(Boolean); - const teamBlobs = teamImages.filter((url) => - url.includes(VALID_IMAGE_URL_HOSTS.vercel_blob) - ); + const teamBlobs = teamImages.filter(isVercelBlobUrl); // Get all blobs const { blobs } = await list(); const activeBlobs = new Set([...userBlobs, ...teamBlobs]); + const now = Date.now(); const filteredBlobs = blobs .filter((blob) => !activeBlobs.has(blob.url)) + .filter( + (blob) => now - new Date(blob.uploadedAt).getTime() > BLOB_DELETE_GRACE_MS + ) .map((blob) => blob.url); for (const url of filteredBlobs) { @@ -62,6 +87,6 @@ export async function DELETE() { } // This is necessary for using Vercel Cron Jobs -export async function GET() { - return await DELETE(); +export async function GET(req: NextRequest) { + return await DELETE(req); } diff --git a/src/app/api/(email)/send-team-invite/route.ts b/src/app/api/(email)/send-team-invite/route.ts index 8eea6b49f..8f4d1e0ca 100644 --- a/src/app/api/(email)/send-team-invite/route.ts +++ b/src/app/api/(email)/send-team-invite/route.ts @@ -1,8 +1,6 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import TeamInviteUserEmail from "@/components/email/team-invite"; import { auditLog } from "@/lib/audit-logs"; +import { canManageTeam, getCurrentUser } from "@/lib/auth"; import { email } from "@/lib/email"; import { createShortLink } from "@/lib/link-service"; import { Logger } from "@/lib/logger"; @@ -12,7 +10,14 @@ import { isTaggedError } from "@/lib/utils"; import { render } from "@react-email/render"; import { track } from "@vercel/analytics/server"; import { checkBotId } from "botid/server"; +import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; +import { z } from "zod"; + +const SendInviteSchema = z.object({ + email: z.email().max(254), + token: z.string().min(1).max(128), +}); export async function POST(req: NextRequest) { const verification = await checkBotId(); @@ -20,39 +25,49 @@ export async function POST(req: NextRequest) { return new Response("Access denied", { status: 403 }); } - const inviteeEmail = req.nextUrl.searchParams.get("email"); - const inviteToken = req.nextUrl.searchParams.get("token"); + const user = await getCurrentUser(); + if (!user) unauthorized(); + + const parsed = SendInviteSchema.safeParse({ + email: req.nextUrl.searchParams.get("email"), + token: req.nextUrl.searchParams.get("token"), + }); + if (!parsed.success) { + return new Response("Invalid request", { status: 400 }); + } + + const { email: inviteeEmail, token: inviteToken } = parsed.data; + const normalizedInviteeEmail = inviteeEmail.toLowerCase(); const baseUrl = process.env.NODE_ENV === "production" ? "https://parsertime.app" : "http://localhost:3000"; - if (!inviteeEmail || !inviteToken) { - return new Response("Missing email or token", { status: 400 }); - } - - const teamInviteToken = await prisma.teamInviteToken.findFirst({ + const teamInviteToken = await prisma.teamInviteToken.findUnique({ where: { token: inviteToken }, + select: { email: true, expires: true, teamId: true }, }); - if (!teamInviteToken) return new Response("Token not found", { status: 404 }); - - const inviter = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser(teamInviteToken?.email)) - ) - ); - if (!inviter) return new Response("Inviter not found", { status: 404 }); + if (!teamInviteToken) return new Response("Invalid invite", { status: 404 }); + if (teamInviteToken.expires <= new Date()) { + return new Response("Invite expired", { status: 410 }); + } + if (teamInviteToken.email.toLowerCase() !== normalizedInviteeEmail) { + return new Response("Forbidden", { status: 403 }); + } + if (!(await canManageTeam(teamInviteToken.teamId, user))) { + return new Response("Forbidden", { status: 403 }); + } const team = await prisma.team.findFirst({ where: { id: teamInviteToken.teamId }, }); if (!team) return new Response("Team not found", { status: 404 }); - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(inviteeEmail))) - ); - if (!user) return new Response("User not found", { status: 404 }); + const invitee = await prisma.user.findUnique({ + where: { email: normalizedInviteeEmail }, + select: { id: true, image: true, name: true }, + }); const shortLink = await createShortLink( `${baseUrl}/team/join/${inviteToken}` @@ -61,27 +76,31 @@ export async function POST(req: NextRequest) { const emailHtml = await render( TeamInviteUserEmail({ username: inviteeEmail, - userImage: user.image ?? `https://avatar.vercel.sh/${user.name}.png`, - invitedByUsername: inviter.name ?? "Unknown", - invitedByEmail: inviter.email ?? "Unknown", + userImage: + invitee?.image ?? + `https://avatar.vercel.sh/${normalizedInviteeEmail}.png`, + invitedByUsername: user.name ?? "Unknown", + invitedByEmail: user.email ?? "Unknown", teamName: team.name ?? "Unknown", teamImage: team.image ?? `https://avatar.vercel.sh/${team.name}.png`, inviteLink: shortLink, }) ); - try { - await notifications.createInAppNotification({ - userId: user.id, - title: `You've been invited to join ${team.name} on Parsertime`, - description: `You've been invited to join ${team.name} by ${inviter.name}. Click this notification to accept the invitation.`, - href: `/team/join/${inviteToken}`, - }); - } catch (error) { - if (error && typeof error === "object" && "_tag" in error) { - Logger.error("Error creating in-app notification", error._tag); + if (invitee) { + try { + await notifications.createInAppNotification({ + userId: invitee.id, + title: `You've been invited to join ${team.name} on Parsertime`, + description: `You've been invited to join ${team.name} by ${user.name}. Click this notification to accept the invitation.`, + href: `/team/join/${inviteToken}`, + }); + } catch (error) { + if (error && typeof error === "object" && "_tag" in error) { + Logger.error("Error creating in-app notification", error._tag); + } + // fail silently, just log the error as notifications are not critical } - // fail silently, just log the error as notifications are not critical } try { @@ -128,13 +147,13 @@ export async function POST(req: NextRequest) { after(async () => { await Promise.all([ auditLog.createAuditLog({ - userEmail: inviter.email, + userEmail: user.email, action: "TEAM_INVITE_SENT", target: inviteeEmail, details: `Invited ${inviteeEmail} to join ${team.name}`, }), track("Team Invite Sent", { - user: inviter.email, + user: user.email, team: team.name, invitee: inviteeEmail, }), diff --git a/src/app/api/admin/audit-logs/route.ts b/src/app/api/admin/audit-logs/route.ts index f5506e354..f886a531b 100644 --- a/src/app/api/admin/audit-logs/route.ts +++ b/src/app/api/admin/audit-logs/route.ts @@ -3,7 +3,11 @@ import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import { $Enums, type AuditLogAction, type Prisma } from "@prisma/client"; +import { + $Enums, + type AuditLogAction, + type Prisma, +} from "@/generated/prisma/browser"; import { forbidden, unauthorized } from "next/navigation"; import { type NextRequest, NextResponse } from "next/server"; diff --git a/src/app/api/admin/impersonate-user/route.ts b/src/app/api/admin/impersonate-user/route.ts index 4e3881ff0..5c4fadf95 100644 --- a/src/app/api/admin/impersonate-user/route.ts +++ b/src/app/api/admin/impersonate-user/route.ts @@ -3,7 +3,7 @@ import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth, getImpersonateUrl } from "@/lib/auth"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; import { z } from "zod"; diff --git a/src/app/api/admin/map-calibration/[id]/align/apply/route.ts b/src/app/api/admin/map-calibration/[id]/align/apply/route.ts new file mode 100644 index 000000000..5e90ac295 --- /dev/null +++ b/src/app/api/admin/map-calibration/[id]/align/apply/route.ts @@ -0,0 +1,271 @@ +import { getCurrentUser, isAdminUser } from "@/lib/auth"; +import { dataLabeling } from "@/lib/flags"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { remapCalibration } from "@/lib/map-calibration/remap"; +import type { PixelAffine } from "@/lib/map-calibration/types"; +import { r2 } from "@/lib/r2"; +import { forbidden, unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type Params = { params: Promise<{ id: string }> }; + +const ApplySchema = z.object({ + pixelAffine: z.object({ + a: z.number().finite(), + b: z.number().finite(), + c: z.number().finite(), + d: z.number().finite(), + tx: z.number().finite(), + ty: z.number().finite(), + }), + stagedOriginalKey: z.string().min(1).max(2048), + stagedDisplayKey: z.string().min(1).max(2048), + newWidth: z.number().int().positive(), + newHeight: z.number().int().positive(), +}); + +function slugForMapName(mapName: string) { + return mapName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + +export async function POST(request: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "POST", + path: "/api/admin/map-calibration/[id]/align/apply", + timestamp: new Date().toISOString(), + }; + + try { + const [{ id }, rawBody] = await Promise.all([props.params, request.json()]); + const parsed = ApplySchema.safeParse(rawBody); + if (!parsed.success) { + wideEvent.status_code = 400; + wideEvent.outcome = "validation_error"; + wideEvent.error = { message: "Invalid request" }; + return NextResponse.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + if (!(await dataLabeling())) forbidden(); + + const numericId = parseInt(id, 10); + const body = parsed.data; + wideEvent.user = { id: user.id, email: user.email }; + wideEvent.calibration_id = numericId; + if (Number.isNaN(numericId)) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Invalid calibration id" }; + return NextResponse.json( + { error: "Invalid calibration id" }, + { status: 400 } + ); + } + + const calibration = await prisma.mapCalibration.findUnique({ + where: { id: numericId }, + include: { anchors: true }, + }); + if (!calibration) { + wideEvent.status_code = 404; + wideEvent.outcome = "not_found"; + wideEvent.error = { message: "Calibration not found" }; + return NextResponse.json( + { error: "Calibration not found" }, + { status: 404 } + ); + } + if (calibration.anchors.length < 3) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Map needs at least 3 anchors to re-align" }; + return NextResponse.json( + { error: "Map needs at least 3 anchors to re-align" }, + { status: 400 } + ); + } + + const slug = slugForMapName(calibration.mapName); + const expectedStagedOriginal = `map-images/${slug}/staging-original.png`; + const expectedStagedDisplay = `map-images/${slug}/staging-display.png`; + if ( + body.stagedOriginalKey !== expectedStagedOriginal || + body.stagedDisplayKey !== expectedStagedDisplay + ) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Staged keys do not match map" }; + return NextResponse.json( + { error: "Staged keys do not match map" }, + { status: 400 } + ); + } + + // Compute the remap up front; if anchors are degenerate this throws before + // any destructive R2/DB work. + const remap = remapCalibration( + calibration.anchors, + body.pixelAffine as PixelAffine + ); + + const liveOriginal = `map-images/${slug}/original.png`; + const liveDisplay = `map-images/${slug}/display.png`; + const backupOriginal = `map-images/${slug}/backup-original.png`; + const backupDisplay = `map-images/${slug}/backup-display.png`; + const backupSnapshot = `map-images/${slug}/backup-calibration.json`; + + // Single-slot backup: each apply overwrites the previous backup, so only + // the most recent swap is revertible (matches the "revert last swap" design). + // 1) Back up the current live image + a JSON snapshot of prior calibration. + const [curOriginal, existingDisplay] = await Promise.all([ + r2.download(calibration.imageUrl), + calibration.displayImageKey + ? r2.download(calibration.displayImageKey) + : Promise.resolve(null), + ]); + const snapshot = { + version: 1, + savedAt: new Date().toISOString(), + imageUrl: calibration.imageUrl, + displayImageKey: calibration.displayImageKey, + imageWidth: calibration.imageWidth, + imageHeight: calibration.imageHeight, + affine: { + a: calibration.affineA, + b: calibration.affineB, + c: calibration.affineC, + d: calibration.affineD, + tx: calibration.affineTx, + ty: calibration.affineTy, + }, + anchors: calibration.anchors.map((a) => ({ + id: a.id, + worldX: a.worldX, + worldY: a.worldY, + imageU: a.imageU, + imageV: a.imageV, + label: a.label, + })), + }; + await Promise.all([ + r2.upload({ + key: backupOriginal, + body: curOriginal, + contentType: "image/png", + }), + existingDisplay + ? r2.upload({ + key: backupDisplay, + body: existingDisplay, + contentType: "image/png", + }) + : Promise.resolve(), + r2.upload({ + key: backupSnapshot, + body: Buffer.from(JSON.stringify(snapshot)), + contentType: "application/json", + }), + ]); + + // 2) Promote staged bytes onto the canonical live keys. + const [stagedOriginalBytes, stagedDisplayBytes] = await Promise.all([ + r2.download(body.stagedOriginalKey), + r2.download(body.stagedDisplayKey), + ]); + await Promise.all([ + r2.upload({ + key: liveOriginal, + body: stagedOriginalBytes, + contentType: "image/png", + }), + r2.upload({ + key: liveDisplay, + body: stagedDisplayBytes, + contentType: "image/png", + }), + ]); + + // If the DB transaction below fails, R2 already holds the new image at the + // live keys. Recover by copying backupOriginal/backupDisplay back over the + // live keys; backup-calibration.json holds the prior DB state (anchors + + // affine + dims). The revert route automates this. + // 3) Remap the DB atomically: update anchors + affine + dims. + const t = remap.transform; + await prisma.$transaction([ + ...remap.anchors.map((a) => + prisma.mapCalibrationAnchor.update({ + where: { id: a.id }, + data: { imageU: a.imageU, imageV: a.imageV }, + }) + ), + prisma.mapCalibration.update({ + where: { id: numericId }, + data: { + imageUrl: liveOriginal, + displayImageKey: liveDisplay, + imageWidth: body.newWidth, + imageHeight: body.newHeight, + affineA: t.a, + affineB: t.b, + affineC: t.c, + affineD: t.d, + affineTx: t.tx, + affineTy: t.ty, + }, + }), + ]); + + // 4) Clean up staging (best-effort). + void Promise.allSettled([ + r2.delete(body.stagedOriginalKey), + r2.delete(body.stagedDisplayKey), + ]); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.map_name = calibration.mapName; + wideEvent.residual = remap.residualError; + wideEvent.staged_keys = { + original: body.stagedOriginalKey, + display: body.stagedDisplayKey, + }; + wideEvent.backup_display_skipped = !calibration.displayImageKey; + + return NextResponse.json({ + ok: true, + residualError: remap.residualError, + }); + } catch (error) { + if ( + error instanceof Error && + (error.message === "NEXT_UNAUTHORIZED" || + error.message === "NEXT_FORBIDDEN") + ) { + throw error; + } + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + return NextResponse.json( + { error: "An unknown error occurred" }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/admin/map-calibration/[id]/align/revert/route.ts b/src/app/api/admin/map-calibration/[id]/align/revert/route.ts new file mode 100644 index 000000000..b095f8802 --- /dev/null +++ b/src/app/api/admin/map-calibration/[id]/align/revert/route.ts @@ -0,0 +1,182 @@ +import { getCurrentUser, isAdminUser } from "@/lib/auth"; +import { dataLabeling } from "@/lib/flags"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { r2 } from "@/lib/r2"; +import { forbidden, unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type Params = { params: Promise<{ id: string }> }; + +type Snapshot = { + version: number; + imageWidth: number; + imageHeight: number; + affine: { + a: number | null; + b: number | null; + c: number | null; + d: number | null; + tx: number | null; + ty: number | null; + }; + anchors: { + id: number; + imageU: number; + imageV: number; + }[]; +}; + +function slugForMapName(mapName: string) { + return mapName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + +export async function POST(_request: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "POST", + path: "/api/admin/map-calibration/[id]/align/revert", + timestamp: new Date().toISOString(), + }; + + try { + const { id } = await props.params; + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + if (!(await dataLabeling())) forbidden(); + + const numericId = parseInt(id, 10); + if (Number.isNaN(numericId)) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Invalid calibration id" }; + return NextResponse.json( + { error: "Invalid calibration id" }, + { status: 400 } + ); + } + wideEvent.user = { id: user.id, email: user.email }; + wideEvent.calibration_id = numericId; + + const calibration = await prisma.mapCalibration.findUnique({ + where: { id: numericId }, + select: { mapName: true }, + }); + if (!calibration) { + wideEvent.status_code = 404; + wideEvent.outcome = "not_found"; + wideEvent.error = { message: "Calibration not found" }; + return NextResponse.json( + { error: "Calibration not found" }, + { status: 404 } + ); + } + + const slug = slugForMapName(calibration.mapName); + const liveOriginal = `map-images/${slug}/original.png`; + const liveDisplay = `map-images/${slug}/display.png`; + const backupOriginal = `map-images/${slug}/backup-original.png`; + const backupDisplay = `map-images/${slug}/backup-display.png`; + const backupSnapshot = `map-images/${slug}/backup-calibration.json`; + + let snapshotBytes: Buffer; + let backupOriginalBytes: Buffer; + try { + [snapshotBytes, backupOriginalBytes] = await Promise.all([ + r2.download(backupSnapshot), + r2.download(backupOriginal), + ]); + } catch (downloadError) { + // r2.download wraps all S3 errors (not just NoSuchKey) into one type, so + // this also fires on a real R2 outage — log the underlying cause so the + // wide event can distinguish "no backup" from "R2 unavailable". + wideEvent.status_code = 404; + wideEvent.outcome = "no_backup"; + wideEvent.error = { + message: + downloadError instanceof Error + ? downloadError.message + : "No backup available to revert", + }; + return NextResponse.json( + { error: "No backup available to revert" }, + { status: 404 } + ); + } + + const snapshot = JSON.parse(snapshotBytes.toString()) as Snapshot; + const backupDisplayBytes = await r2 + .download(backupDisplay) + .catch(() => backupOriginalBytes); + + // Restore image bytes onto the live keys. + await Promise.all([ + r2.upload({ + key: liveOriginal, + body: backupOriginalBytes, + contentType: "image/png", + }), + r2.upload({ + key: liveDisplay, + body: backupDisplayBytes, + contentType: "image/png", + }), + ]); + + // Restore the DB rows. + await prisma.$transaction([ + ...snapshot.anchors.map((a) => + prisma.mapCalibrationAnchor.update({ + where: { id: a.id }, + data: { imageU: a.imageU, imageV: a.imageV }, + }) + ), + prisma.mapCalibration.update({ + where: { id: numericId }, + data: { + imageUrl: liveOriginal, + displayImageKey: liveDisplay, + imageWidth: snapshot.imageWidth, + imageHeight: snapshot.imageHeight, + affineA: snapshot.affine.a, + affineB: snapshot.affine.b, + affineC: snapshot.affine.c, + affineD: snapshot.affine.d, + affineTx: snapshot.affine.tx, + affineTy: snapshot.affine.ty, + }, + }), + ]); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.map_name = calibration.mapName; + wideEvent.anchors_restored = snapshot.anchors.length; + return NextResponse.json({ ok: true }); + } catch (error) { + if ( + error instanceof Error && + (error.message === "NEXT_UNAUTHORIZED" || + error.message === "NEXT_FORBIDDEN") + ) { + throw error; + } + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + return NextResponse.json( + { error: "An unknown error occurred" }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/admin/map-calibration/[id]/align/route.ts b/src/app/api/admin/map-calibration/[id]/align/route.ts new file mode 100644 index 000000000..b56eeb902 --- /dev/null +++ b/src/app/api/admin/map-calibration/[id]/align/route.ts @@ -0,0 +1,233 @@ +import { getCurrentUser, isAdminUser } from "@/lib/auth"; +import { dataLabeling } from "@/lib/flags"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { r2 } from "@/lib/r2"; +import { forbidden, unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; +import sharp from "sharp"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type Params = { params: Promise<{ id: string }> }; + +function slugForMapName(mapName: string) { + return mapName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + +function stagingKeys(slug: string) { + return { + original: `map-images/${slug}/staging-original.png`, + display: `map-images/${slug}/staging-display.png`, + }; +} + +// Stages a newly-uploaded render and returns the keys/URLs the review UI needs. +// The old->new pixel transform is computed by the local CLI (scripts/map-align) +// and supplied by the operator on the apply step, so this route never aligns. +export async function POST(request: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "POST", + path: "/api/admin/map-calibration/[id]/align", + timestamp: new Date().toISOString(), + }; + + try { + const { id } = await props.params; + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + if (!(await dataLabeling())) forbidden(); + + const numericId = parseInt(id, 10); + if (Number.isNaN(numericId)) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Invalid calibration id" }; + return NextResponse.json( + { error: "Invalid calibration id" }, + { status: 400 } + ); + } + wideEvent.user = { id: user.id, email: user.email }; + wideEvent.calibration_id = numericId; + + const body = (await request.json()) as { rawKey?: string }; + const rawKey = body.rawKey; + if (!rawKey || typeof rawKey !== "string") { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Missing rawKey" }; + return NextResponse.json({ error: "Missing rawKey" }, { status: 400 }); + } + + const calibration = await prisma.mapCalibration.findUnique({ + where: { id: numericId }, + include: { anchors: true }, + }); + if (!calibration) { + wideEvent.status_code = 404; + wideEvent.outcome = "not_found"; + wideEvent.error = { message: "Calibration not found" }; + return NextResponse.json( + { error: "Calibration not found" }, + { status: 404 } + ); + } + + const slug = slugForMapName(calibration.mapName); + if (!rawKey.startsWith(`map-images/${slug}/raw-`)) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "rawKey does not match map" }; + return NextResponse.json( + { error: "rawKey does not match map" }, + { status: 400 } + ); + } + if (calibration.anchors.length < 3) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Map needs at least 3 anchors to re-align" }; + return NextResponse.json( + { error: "Map needs at least 3 anchors to re-align" }, + { status: 400 } + ); + } + + // Process the raw upload into staging keys (mirrors the upload route). + const buffer = await r2.download(rawKey); + const metadata = await sharp(buffer).metadata(); + const newWidth = metadata.width; + const newHeight = metadata.height; + if (!newWidth || !newHeight) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Could not determine image dimensions" }; + return NextResponse.json( + { error: "Could not determine image dimensions" }, + { status: 400 } + ); + } + + const pngBuffer = await sharp(buffer).png().toBuffer(); + const displayBuffer = + newWidth > 2560 + ? await sharp(buffer).resize(2560).png().toBuffer() + : pngBuffer; + + const keys = stagingKeys(slug); + await r2.upload({ + key: keys.original, + body: pngBuffer, + contentType: "image/png", + }); + await r2.upload({ + key: keys.display, + body: displayBuffer, + contentType: "image/png", + }); + await r2.delete(rawKey); + wideEvent.staged_keys = keys; + + // Presign the new staged display image and the old display image for the + // review UI's old<->new compare. + const [stagedDisplayUrl, oldDisplayUrl] = await Promise.all([ + r2.getPresignedUrl({ key: keys.display, expiresIn: 3600 }), + r2.getPresignedUrl({ + key: calibration.displayImageKey ?? calibration.imageUrl, + expiresIn: 3600, + }), + ]); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.map_name = calibration.mapName; + + return NextResponse.json({ + stagedOriginalKey: keys.original, + stagedDisplayKey: keys.display, + stagedDisplayUrl, + oldDisplayUrl, + newWidth, + newHeight, + }); + } catch (error) { + if ( + error instanceof Error && + (error.message === "NEXT_UNAUTHORIZED" || + error.message === "NEXT_FORBIDDEN") + ) { + throw error; + } + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + return NextResponse.json( + { error: "An unknown error occurred" }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} + +export async function DELETE(_request: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "DELETE", + path: "/api/admin/map-calibration/[id]/align", + timestamp: new Date().toISOString(), + }; + + try { + const { id } = await props.params; + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + if (!(await dataLabeling())) forbidden(); + + const calibration = await prisma.mapCalibration.findUnique({ + where: { id: parseInt(id, 10) }, + select: { mapName: true }, + }); + if (calibration) { + const keys = stagingKeys(slugForMapName(calibration.mapName)); + void Promise.allSettled([ + r2.delete(keys.original), + r2.delete(keys.display), + ]); + } + + wideEvent.status_code = 204; + wideEvent.outcome = "success"; + return new Response(null, { status: 204 }); + } catch (error) { + if ( + error instanceof Error && + (error.message === "NEXT_UNAUTHORIZED" || + error.message === "NEXT_FORBIDDEN") + ) { + throw error; + } + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + return NextResponse.json( + { error: "An unknown error occurred" }, + { status: 500 } + ); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/admin/map-calibration/[id]/anchors/[anchorId]/route.ts b/src/app/api/admin/map-calibration/[id]/anchors/[anchorId]/route.ts index e11d2b938..58d90bc01 100644 --- a/src/app/api/admin/map-calibration/[id]/anchors/[anchorId]/route.ts +++ b/src/app/api/admin/map-calibration/[id]/anchors/[anchorId]/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { dataLabeling } from "@/lib/flags"; @@ -34,13 +31,9 @@ export async function PUT(req: Request, props: Params) { }; try { - const session = await auth(); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); @@ -68,7 +61,13 @@ export async function PUT(req: Request, props: Params) { const anchor = await prisma.mapCalibrationAnchor.update({ where: { id: parseInt(anchorId, 10) }, - data: body, + data: { + worldX: body.worldX, + worldY: body.worldY, + imageU: body.imageU, + imageV: body.imageV, + label: body.label, + }, }); wideEvent.status_code = 200; @@ -98,13 +97,9 @@ export async function DELETE(_req: Request, props: Params) { }; try { - const session = await auth(); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); diff --git a/src/app/api/admin/map-calibration/[id]/anchors/route.ts b/src/app/api/admin/map-calibration/[id]/anchors/route.ts index db5f1c4d2..69f2a6026 100644 --- a/src/app/api/admin/map-calibration/[id]/anchors/route.ts +++ b/src/app/api/admin/map-calibration/[id]/anchors/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { dataLabeling } from "@/lib/flags"; @@ -19,13 +16,9 @@ export async function GET(_req: Request, props: Params) { }; try { - const session = await auth(); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); @@ -68,13 +61,9 @@ export async function POST(req: Request, props: Params) { }; try { - const session = await auth(); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); diff --git a/src/app/api/admin/map-calibration/[id]/route.ts b/src/app/api/admin/map-calibration/[id]/route.ts index 77522ec1e..ec2274812 100644 --- a/src/app/api/admin/map-calibration/[id]/route.ts +++ b/src/app/api/admin/map-calibration/[id]/route.ts @@ -1,16 +1,27 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { dataLabeling } from "@/lib/flags"; import { r2 } from "@/lib/r2"; import { forbidden, unauthorized } from "next/navigation"; import { NextResponse } from "next/server"; +import { z } from "zod"; type Params = { params: Promise<{ id: string }> }; +const UpdateMapCalibrationSchema = z.object({ + affineA: z.number().finite().nullable().optional(), + affineB: z.number().finite().nullable().optional(), + affineC: z.number().finite().nullable().optional(), + affineD: z.number().finite().nullable().optional(), + affineTx: z.number().finite().nullable().optional(), + affineTy: z.number().finite().nullable().optional(), + imageUrl: z.string().min(1).max(2048).optional(), + imageWidth: z.number().int().positive().optional(), + imageHeight: z.number().int().positive().optional(), + displayImageKey: z.string().min(1).max(2048).optional(), +}); + export async function GET(_req: Request, props: Params) { const startTime = Date.now(); const wideEvent: Record = { @@ -20,13 +31,10 @@ export async function GET(_req: Request, props: Params) { }; try { - const [session, { id }] = await Promise.all([auth(), props.params]); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const { id } = await props.params; + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); @@ -85,33 +93,37 @@ export async function PUT(req: Request, props: Params) { }; try { - const [session, { id }, body] = await Promise.all([ - auth(), - props.params, - req.json() as Promise<{ - affineA?: number | null; - affineB?: number | null; - affineC?: number | null; - affineD?: number | null; - affineTx?: number | null; - affineTy?: number | null; - imageUrl?: string; - imageWidth?: number; - imageHeight?: number; - displayImageKey?: string; - }>, - ]); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const [{ id }, rawBody] = await Promise.all([props.params, req.json()]); + const parsedBody = UpdateMapCalibrationSchema.safeParse(rawBody); + if (!parsedBody.success) { + wideEvent.status_code = 400; + wideEvent.outcome = "validation_error"; + return NextResponse.json( + { error: "Invalid request", details: parsedBody.error.flatten() }, + { status: 400 } + ); + } + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); const numericId = parseInt(id, 10); + const body = parsedBody.data; + const updateData = { + affineA: body.affineA, + affineB: body.affineB, + affineC: body.affineC, + affineD: body.affineD, + affineTx: body.affineTx, + affineTy: body.affineTy, + imageUrl: body.imageUrl, + imageWidth: body.imageWidth, + imageHeight: body.imageHeight, + displayImageKey: body.displayImageKey, + }; wideEvent.user = { id: user.id, email: user.email }; wideEvent.calibration_id = numericId; @@ -138,7 +150,7 @@ export async function PUT(req: Request, props: Params) { return tx.mapCalibration.update({ where: { id: numericId }, data: { - ...body, + ...updateData, affineA: null, affineB: null, affineC: null, @@ -160,7 +172,7 @@ export async function PUT(req: Request, props: Params) { const calibration = await prisma.mapCalibration.update({ where: { id: numericId }, - data: body, + data: updateData, include: { anchors: true }, }); @@ -192,13 +204,10 @@ export async function DELETE(_req: Request, props: Params) { }; try { - const [session, { id }] = await Promise.all([auth(), props.params]); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const { id } = await props.params; + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); diff --git a/src/app/api/admin/map-calibration/[id]/zones/[zoneId]/route.ts b/src/app/api/admin/map-calibration/[id]/zones/[zoneId]/route.ts new file mode 100644 index 000000000..fbe931039 --- /dev/null +++ b/src/app/api/admin/map-calibration/[id]/zones/[zoneId]/route.ts @@ -0,0 +1,142 @@ +import { getCurrentUser, isAdminUser } from "@/lib/auth"; +import { dataLabeling } from "@/lib/flags"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import type { Prisma } from "@/generated/prisma/client"; +import { forbidden, unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +type Params = { params: Promise<{ id: string; zoneId: string }> }; + +const VertexSchema = z.tuple([z.number(), z.number()]); +const UpdateZoneSchema = z.object({ + name: z.string().min(1).max(64).optional(), + category: z.enum(["POINT", "LANE"]).optional(), + laneRole: z.enum(["MAIN", "FLANK"]).nullable().optional(), + status: z.enum(["DRAFT", "PUBLISHED"]).optional(), + vertices: z.array(VertexSchema).min(3).max(200).optional(), +}); + +export async function PATCH(req: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "PATCH", + path: "/api/admin/map-calibration/[id]/zones/[zoneId]", + timestamp: new Date().toISOString(), + }; + + try { + const { id, zoneId } = await props.params; + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + + const enabled = await dataLabeling(); + if (!enabled) forbidden(); + + const parsedBody = UpdateZoneSchema.safeParse(await req.json()); + if (!parsedBody.success) { + wideEvent.status_code = 400; + wideEvent.outcome = "validation_error"; + return NextResponse.json( + { error: "Invalid request", details: parsedBody.error.flatten() }, + { status: 400 } + ); + } + const numericId = parseInt(id, 10); + const numericZoneId = parseInt(zoneId, 10); + const body = parsedBody.data; + wideEvent.user = { id: user.id, email: user.email }; + wideEvent.calibration_id = numericId; + wideEvent.zone_id = numericZoneId; + + const existing = await prisma.mapZone.findFirst({ + where: { id: numericZoneId, calibrationId: numericId }, + }); + if (!existing) { + wideEvent.status_code = 404; + wideEvent.outcome = "not_found"; + return NextResponse.json({ error: "Zone not found" }, { status: 404 }); + } + + const zone = await prisma.mapZone.update({ + where: { id: existing.id }, + data: { + ...body, + vertices: + body.vertices !== undefined + ? (body.vertices as unknown as Prisma.InputJsonValue) + : undefined, + }, + }); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + + return NextResponse.json(zone); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} + +export async function DELETE(_req: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "DELETE", + path: "/api/admin/map-calibration/[id]/zones/[zoneId]", + timestamp: new Date().toISOString(), + }; + + try { + const { id, zoneId } = await props.params; + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + + const enabled = await dataLabeling(); + if (!enabled) forbidden(); + + const numericId = parseInt(id, 10); + const numericZoneId = parseInt(zoneId, 10); + wideEvent.user = { id: user.id, email: user.email }; + wideEvent.calibration_id = numericId; + wideEvent.zone_id = numericZoneId; + + const existing = await prisma.mapZone.findFirst({ + where: { id: numericZoneId, calibrationId: numericId }, + }); + if (!existing) { + wideEvent.status_code = 404; + wideEvent.outcome = "not_found"; + return NextResponse.json({ error: "Zone not found" }, { status: 404 }); + } + + await prisma.mapZone.delete({ where: { id: existing.id } }); + + wideEvent.status_code = 204; + wideEvent.outcome = "success"; + + return new Response(null, { status: 204 }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/admin/map-calibration/[id]/zones/generate/route.ts b/src/app/api/admin/map-calibration/[id]/zones/generate/route.ts new file mode 100644 index 000000000..447e7736b --- /dev/null +++ b/src/app/api/admin/map-calibration/[id]/zones/generate/route.ts @@ -0,0 +1,57 @@ +import { getCurrentUser, isAdminUser } from "@/lib/auth"; +import { dataLabeling } from "@/lib/flags"; +import { Logger } from "@/lib/logger"; +import { classifyZonesForCalibration } from "@/lib/zones/classify"; +import { forbidden, unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(_req: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "POST", + path: "/api/admin/map-calibration/[id]/zones/generate", + timestamp: new Date().toISOString(), + }; + + try { + const { id } = await props.params; + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + + const enabled = await dataLabeling(); + if (!enabled) forbidden(); + + const numericId = parseInt(id, 10); + wideEvent.user = { id: user.id, email: user.email }; + wideEvent.calibration_id = numericId; + + const result = await classifyZonesForCalibration(numericId, user.id); + if (!result.ok) { + wideEvent.status_code = 422; + wideEvent.outcome = "classify_failed"; + wideEvent.reason = result.reason; + return NextResponse.json({ error: result.reason }, { status: 422 }); + } + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.point_zones = result.pointZones; + wideEvent.lane_zones = result.laneZones; + + return NextResponse.json(result); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/admin/map-calibration/[id]/zones/route.ts b/src/app/api/admin/map-calibration/[id]/zones/route.ts new file mode 100644 index 000000000..878031cb2 --- /dev/null +++ b/src/app/api/admin/map-calibration/[id]/zones/route.ts @@ -0,0 +1,127 @@ +import { getCurrentUser, isAdminUser } from "@/lib/auth"; +import { dataLabeling } from "@/lib/flags"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import type { Prisma } from "@/generated/prisma/client"; +import { forbidden, unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; +import { z } from "zod"; + +type Params = { params: Promise<{ id: string }> }; + +const VertexSchema = z.tuple([z.number(), z.number()]); +const CreateZoneSchema = z.object({ + name: z.string().min(1).max(64), + category: z.enum(["POINT", "LANE"]), + laneRole: z.enum(["MAIN", "FLANK"]).nullable().optional(), + vertices: z.array(VertexSchema).min(3).max(200), +}); + +export async function GET(_req: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "GET", + path: "/api/admin/map-calibration/[id]/zones", + timestamp: new Date().toISOString(), + }; + + try { + const { id } = await props.params; + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + + const enabled = await dataLabeling(); + if (!enabled) forbidden(); + + wideEvent.user = { id: user.id, email: user.email }; + wideEvent.calibration_id = parseInt(id, 10); + + const zones = await prisma.mapZone.findMany({ + where: { calibrationId: parseInt(id, 10) }, + orderBy: [{ category: "asc" }, { id: "asc" }], + }); + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + wideEvent.zone_count = zones.length; + + return NextResponse.json(zones); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} + +export async function POST(req: Request, props: Params) { + const startTime = Date.now(); + const wideEvent: Record = { + method: "POST", + path: "/api/admin/map-calibration/[id]/zones", + timestamp: new Date().toISOString(), + }; + + try { + const { id } = await props.params; + const user = await getCurrentUser(); + if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); + + const enabled = await dataLabeling(); + if (!enabled) forbidden(); + + const parsedBody = CreateZoneSchema.safeParse(await req.json()); + if (!parsedBody.success) { + wideEvent.status_code = 400; + wideEvent.outcome = "validation_error"; + return NextResponse.json( + { error: "Invalid request", details: parsedBody.error.flatten() }, + { status: 400 } + ); + } + + const numericId = parseInt(id, 10); + const body = parsedBody.data; + wideEvent.user = { id: user.id, email: user.email }; + wideEvent.calibration_id = numericId; + wideEvent.category = body.category; + + const zone = await prisma.mapZone.create({ + data: { + calibrationId: numericId, + name: body.name, + category: body.category, + laneRole: body.laneRole ?? null, + vertices: body.vertices as unknown as Prisma.InputJsonValue, + status: "DRAFT", + source: "MANUAL", + createdBy: user.id, + }, + }); + + wideEvent.status_code = 201; + wideEvent.outcome = "success"; + wideEvent.zone_id = zone.id; + + return NextResponse.json(zone, { status: 201 }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/admin/map-calibration/compute-transform/route.ts b/src/app/api/admin/map-calibration/compute-transform/route.ts index 1706776d6..958156bba 100644 --- a/src/app/api/admin/map-calibration/compute-transform/route.ts +++ b/src/app/api/admin/map-calibration/compute-transform/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { computeMapTransform } from "@/lib/map-calibration/compute-transform"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -18,13 +15,9 @@ export async function POST(req: Request) { }; try { - const session = await auth(); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); diff --git a/src/app/api/admin/map-calibration/route.ts b/src/app/api/admin/map-calibration/route.ts index 066dd277d..ade1fd7bf 100644 --- a/src/app/api/admin/map-calibration/route.ts +++ b/src/app/api/admin/map-calibration/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -17,13 +14,9 @@ export async function GET() { }; try { - const session = await auth(); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); @@ -63,13 +56,9 @@ export async function POST(req: Request) { }; try { - const session = await auth(); - if (!session) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); diff --git a/src/app/api/admin/map-calibration/upload/presign/route.ts b/src/app/api/admin/map-calibration/upload/presign/route.ts index 34345c02e..b633e4566 100644 --- a/src/app/api/admin/map-calibration/upload/presign/route.ts +++ b/src/app/api/admin/map-calibration/upload/presign/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; import { r2 } from "@/lib/r2"; @@ -10,6 +7,16 @@ import { NextResponse } from "next/server"; export const runtime = "nodejs"; +const ALLOWED_IMAGE_CONTENT_TYPES = new Set([ + "image/jpeg", + "image/png", + "image/webp", +]); + +function slugForMapName(mapName: string) { + return mapName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + export async function POST(request: Request): Promise { const startTime = Date.now(); const wideEvent: Record = { @@ -19,13 +26,9 @@ export async function POST(request: Request): Promise { }; try { - const session = await auth(); - if (!session?.user) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); @@ -38,6 +41,7 @@ export async function POST(request: Request): Promise { }; const { mapName, contentType } = body; + const uploadContentType = contentType ?? "image/png"; if (!mapName || typeof mapName !== "string" || mapName.trim() === "") { wideEvent.status_code = 400; @@ -49,7 +53,27 @@ export async function POST(request: Request): Promise { ); } - const slug = mapName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); + if (!ALLOWED_IMAGE_CONTENT_TYPES.has(uploadContentType)) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Unsupported contentType field" }; + return NextResponse.json( + { error: "Unsupported content type" }, + { status: 400 } + ); + } + + const slug = slugForMapName(mapName); + if (!slug) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Missing or invalid mapName field" }; + return NextResponse.json( + { error: "Missing or invalid mapName field" }, + { status: 400 } + ); + } + const rawKey = `map-images/${slug}/raw-${Date.now()}`; wideEvent.map_name = mapName; @@ -58,7 +82,7 @@ export async function POST(request: Request): Promise { const uploadUrl = await r2.getPresignedUploadUrl({ key: rawKey, - contentType: contentType ?? "application/octet-stream", + contentType: uploadContentType, expiresIn: 3600, }); @@ -84,8 +108,7 @@ export async function POST(request: Request): Promise { return NextResponse.json( { - error: - error instanceof Error ? error.message : "An unknown error occurred", + error: "An unknown error occurred", }, { status: 500 } ); diff --git a/src/app/api/admin/map-calibration/upload/route.ts b/src/app/api/admin/map-calibration/upload/route.ts index 1a1eb2264..62f291d1e 100644 --- a/src/app/api/admin/map-calibration/upload/route.ts +++ b/src/app/api/admin/map-calibration/upload/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; import { r2 } from "@/lib/r2"; @@ -12,6 +9,12 @@ import sharp from "sharp"; export const runtime = "nodejs"; export const maxDuration = 60; +const MAX_UPLOAD_BYTES = 15 * 1024 * 1024; + +function slugForMapName(mapName: string) { + return mapName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); +} + export async function POST(request: Request): Promise { const startTime = Date.now(); const wideEvent: Record = { @@ -21,13 +24,9 @@ export async function POST(request: Request): Promise { }; try { - const session = await auth(); - if (!session?.user) unauthorized(); - - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); + if (!isAdminUser(user)) forbidden(); const enabled = await dataLabeling(); if (!enabled) forbidden(); @@ -64,8 +63,28 @@ export async function POST(request: Request): Promise { wideEvent.map_name = mapName; wideEvent.raw_key = rawKey; + const slug = slugForMapName(mapName); + if (!slug || !rawKey.startsWith(`map-images/${slug}/raw-`)) { + wideEvent.status_code = 400; + wideEvent.outcome = "error"; + wideEvent.error = { message: "rawKey does not match mapName" }; + return NextResponse.json( + { error: "rawKey does not match mapName" }, + { status: 400 } + ); + } + const buffer = await r2.download(rawKey); wideEvent.file_size = buffer.length; + if (buffer.length > MAX_UPLOAD_BYTES) { + wideEvent.status_code = 413; + wideEvent.outcome = "error"; + wideEvent.error = { message: "Image upload is too large" }; + return NextResponse.json( + { error: "Image upload is too large" }, + { status: 413 } + ); + } const metadata = await sharp(buffer).metadata(); const imageWidth = metadata.width; @@ -83,7 +102,6 @@ export async function POST(request: Request): Promise { wideEvent.image_dimensions = { width: imageWidth, height: imageHeight }; - const slug = mapName.toLowerCase().replace(/[^a-z0-9]+/g, "-"); wideEvent.slug = slug; const pngBuffer = await sharp(buffer).png().toBuffer(); @@ -137,8 +155,7 @@ export async function POST(request: Request): Promise { return NextResponse.json( { - error: - error instanceof Error ? error.message : "An unknown error occurred", + error: "An unknown error occurred", }, { status: 500 } ); diff --git a/src/app/api/admin/team-search/route.ts b/src/app/api/admin/team-search/route.ts index ed926f587..3ce6f2fb1 100644 --- a/src/app/api/admin/team-search/route.ts +++ b/src/app/api/admin/team-search/route.ts @@ -4,8 +4,8 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import type { Prisma } from "@prisma/client"; -import { $Enums } from "@prisma/client"; +import type { Prisma } from "@/generated/prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { forbidden, unauthorized } from "next/navigation"; import { type NextRequest, NextResponse } from "next/server"; diff --git a/src/app/api/admin/user-search/route.ts b/src/app/api/admin/user-search/route.ts index 61d968c75..c2e7e2431 100644 --- a/src/app/api/admin/user-search/route.ts +++ b/src/app/api/admin/user-search/route.ts @@ -3,8 +3,8 @@ import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import type { Prisma } from "@prisma/client"; -import { $Enums } from "@prisma/client"; +import type { Prisma } from "@/generated/prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { forbidden, unauthorized } from "next/navigation"; import { type NextRequest, NextResponse } from "next/server"; diff --git a/src/app/api/availability/[scheduleId]/responses/route.ts b/src/app/api/availability/[scheduleId]/responses/route.ts index 448f2ce89..49e93d2d6 100644 --- a/src/app/api/availability/[scheduleId]/responses/route.ts +++ b/src/app/api/availability/[scheduleId]/responses/route.ts @@ -5,13 +5,31 @@ import { } from "@/lib/availability/password"; import { normalizeNameKey, sanitizeSlots } from "@/lib/availability/slots"; import prisma from "@/lib/prisma"; +import { Ratelimit } from "@upstash/ratelimit"; +import { checkBotId } from "botid/server"; +import { ipAddress } from "@vercel/functions"; +import { kv } from "@vercel/kv"; import type { NextRequest } from "next/server"; import { z } from "zod"; +const submitRatelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(20, "1 m"), + analytics: true, + prefix: "ratelimit:availability:submit", +}); + +const nameRatelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(6, "1 m"), + analytics: true, + prefix: "ratelimit:availability:name", +}); + const BodySchema = z.object({ name: z.string().trim().min(1).max(40), password: z.string().min(1).max(100).optional(), - slots: z.array(z.number().int().nonnegative()), + slots: z.array(z.number().int().nonnegative()).max(7 * 24 * 4), submittedFromTz: z.string().max(64).optional(), }); @@ -20,6 +38,11 @@ type RouteCtx = { params: Promise<{ scheduleId: string }> }; export async function POST(req: NextRequest, ctx: RouteCtx) { const { scheduleId } = await ctx.params; + const verification = await checkBotId(); + if (verification.isBot) { + return new Response("Access denied", { status: 403 }); + } + const parsed = BodySchema.safeParse(await req.json()); if (!parsed.success) { return Response.json( @@ -40,6 +63,14 @@ export async function POST(req: NextRequest, ctx: RouteCtx) { const slots = sanitizeSlots(parsed.data.slots, settings); const nameKey = normalizeNameKey(parsed.data.name); const displayName = parsed.data.name.trim(); + const identifier = ipAddress(req) ?? "127.0.0.1"; + const [submitLimit, nameLimit] = await Promise.all([ + submitRatelimit.limit(`${identifier}:${scheduleId}`), + nameRatelimit.limit(`${identifier}:${scheduleId}:${nameKey}`), + ]); + if (!submitLimit.success || !nameLimit.success) { + return new Response("Rate limit exceeded", { status: 429 }); + } const session = await auth(); const sessionUser = session?.user?.email @@ -55,6 +86,9 @@ export async function POST(req: NextRequest, ctx: RouteCtx) { if (existing) { const ownedBySessionUser = existing.userId === sessionUser?.id; + if (existing.userId && !ownedBySessionUser) { + return new Response("Forbidden", { status: 403 }); + } if (!ownedBySessionUser) { if (existing.passwordHash) { if (!parsed.data.password) { @@ -89,6 +123,13 @@ export async function POST(req: NextRequest, ctx: RouteCtx) { return Response.json({ response: updated }); } + const responseCount = await prisma.availabilityResponse.count({ + where: { scheduleId }, + }); + if (responseCount >= 200) { + return new Response("Response limit reached", { status: 403 }); + } + const passwordHash = sessionUser ? null : parsed.data.password diff --git a/src/app/api/availability/[scheduleId]/responses/verify-name/route.ts b/src/app/api/availability/[scheduleId]/responses/verify-name/route.ts index 5c71ec19b..b9ff458a3 100644 --- a/src/app/api/availability/[scheduleId]/responses/verify-name/route.ts +++ b/src/app/api/availability/[scheduleId]/responses/verify-name/route.ts @@ -1,9 +1,20 @@ import { normalizeNameKey } from "@/lib/availability/slots"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; +import { Ratelimit } from "@upstash/ratelimit"; +import { checkBotId } from "botid/server"; +import { ipAddress } from "@vercel/functions"; +import { kv } from "@vercel/kv"; import type { NextRequest } from "next/server"; import { z } from "zod"; +const verifyNameRatelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(30, "1 m"), + analytics: true, + prefix: "ratelimit:availability:verify-name", +}); + const BodySchema = z.object({ name: z.string().trim().min(1).max(40), }); @@ -13,12 +24,25 @@ type RouteCtx = { params: Promise<{ scheduleId: string }> }; export async function POST(req: NextRequest, ctx: RouteCtx) { const { scheduleId } = await ctx.params; + const verification = await checkBotId(); + if (verification.isBot) { + return new Response("Access denied", { status: 403 }); + } + const parsed = BodySchema.safeParse(await req.json()); if (!parsed.success) { return Response.json({ error: "Invalid body" }, { status: 400 }); } const nameKey = normalizeNameKey(parsed.data.name); + const identifier = ipAddress(req) ?? "127.0.0.1"; + const { success } = await verifyNameRatelimit.limit( + `${identifier}:${scheduleId}:${nameKey}` + ); + if (!success) { + return new Response("Rate limit exceeded", { status: 429 }); + } + const existing = await prisma.availabilityResponse.findUnique({ where: { scheduleId_nameKey: { scheduleId, nameKey } }, select: { id: true, userId: true, passwordHash: true, slots: true }, diff --git a/src/app/api/bot/compare/route.ts b/src/app/api/bot/compare/route.ts index c438fc915..8a8824f52 100644 --- a/src/app/api/bot/compare/route.ts +++ b/src/app/api/bot/compare/route.ts @@ -4,7 +4,7 @@ import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { removeDuplicateRows } from "@/lib/utils"; import { trace } from "@opentelemetry/api"; -import { Prisma, type PlayerStat } from "@prisma/client"; +import { Prisma, type PlayerStat } from "@/generated/prisma/client"; import type { NextRequest } from "next/server"; async function getPlayerStats(playerName: string) { diff --git a/src/app/api/bot/guilds/[guildId]/channels/route.ts b/src/app/api/bot/guilds/[guildId]/channels/route.ts index 3f4a21928..16c7fa627 100644 --- a/src/app/api/bot/guilds/[guildId]/channels/route.ts +++ b/src/app/api/bot/guilds/[guildId]/channels/route.ts @@ -1,5 +1,6 @@ import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; import { context, propagation } from "@opentelemetry/api"; export async function GET( @@ -27,6 +28,27 @@ export async function GET( ); } + const discordAccount = await prisma.account.findFirst({ + where: { + provider: "discord", + user: { email: session.user.email }, + }, + select: { providerAccountId: true }, + }); + + if (!discordAccount) { + wideEvent.outcome = "discord_not_linked"; + wideEvent.status_code = 403; + return Response.json( + { + success: false, + error: "Discord account not linked", + code: "discord_not_linked", + }, + { status: 403 } + ); + } + const botApiUrl = process.env.BOT_API_URL; const botSecret = process.env.BOT_SECRET; @@ -43,7 +65,7 @@ export async function GET( propagation.inject(context.active(), traceHeaders); const response = await fetch( - `${botApiUrl}/api/guilds/${encodeURIComponent(guildId)}/channels`, + `${botApiUrl}/api/guilds/${encodeURIComponent(guildId)}/channels?userId=${encodeURIComponent(discordAccount.providerAccountId)}`, { headers: { Authorization: `Bearer ${botSecret}`, @@ -52,6 +74,15 @@ export async function GET( } ); + if (response.status === 403) { + wideEvent.outcome = "forbidden"; + wideEvent.status_code = 403; + return Response.json( + { success: false, error: "You are not a member of that server" }, + { status: 403 } + ); + } + if (!response.ok) { wideEvent.outcome = "upstream_error"; wideEvent.status_code = response.status; diff --git a/src/app/api/bot/guilds/route.ts b/src/app/api/bot/guilds/route.ts index aba202712..028d96a65 100644 --- a/src/app/api/bot/guilds/route.ts +++ b/src/app/api/bot/guilds/route.ts @@ -1,5 +1,6 @@ import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; import { context, propagation } from "@opentelemetry/api"; export async function GET() { @@ -21,6 +22,27 @@ export async function GET() { ); } + const discordAccount = await prisma.account.findFirst({ + where: { + provider: "discord", + user: { email: session.user.email }, + }, + select: { providerAccountId: true }, + }); + + if (!discordAccount) { + wideEvent.outcome = "discord_not_linked"; + wideEvent.status_code = 403; + return Response.json( + { + success: false, + error: "Discord account not linked", + code: "discord_not_linked", + }, + { status: 403 } + ); + } + const botApiUrl = process.env.BOT_API_URL; const botSecret = process.env.BOT_SECRET; @@ -36,12 +58,15 @@ export async function GET() { const traceHeaders: Record = {}; propagation.inject(context.active(), traceHeaders); - const response = await fetch(`${botApiUrl}/api/guilds`, { - headers: { - Authorization: `Bearer ${botSecret}`, - ...traceHeaders, - }, - }); + const response = await fetch( + `${botApiUrl}/api/guilds?userId=${encodeURIComponent(discordAccount.providerAccountId)}`, + { + headers: { + Authorization: `Bearer ${botSecret}`, + ...traceHeaders, + }, + } + ); if (!response.ok) { wideEvent.outcome = "upstream_error"; diff --git a/src/app/api/bot/notifications/route.ts b/src/app/api/bot/notifications/route.ts index 3eec3191e..4531915e7 100644 --- a/src/app/api/bot/notifications/route.ts +++ b/src/app/api/bot/notifications/route.ts @@ -1,8 +1,25 @@ -import { auth } from "@/lib/auth"; -import { verifyTeamAccess } from "@/lib/bot-auth"; +import { auth, canManageTeam } from "@/lib/auth"; +import { + verifyUserCanUseChannel, + verifyUserInGuild, +} from "@/lib/bot-discord-access"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import type { NextRequest } from "next/server"; +import { z } from "zod"; + +const DiscordSnowflakeSchema = z.string().regex(/^\d{17,20}$/); + +const CreateConfigSchema = z.object({ + guildId: DiscordSnowflakeSchema, + channelId: DiscordSnowflakeSchema, + teamIds: z.array(z.number().int().positive()).min(1).max(25), +}); + +const UpdateConfigSchema = z.object({ + configId: z.string().min(1), + teamIds: z.array(z.number().int().positive()).min(1).max(25), +}); export async function GET() { const wideEvent: Record = { @@ -85,7 +102,15 @@ export async function POST(request: NextRequest) { const user = await prisma.user.findUnique({ where: { email: session.user.email }, - select: { id: true }, + select: { + id: true, + role: true, + accounts: { + where: { provider: "discord" }, + select: { providerAccountId: true }, + take: 1, + }, + }, }); if (!user) { @@ -97,13 +122,22 @@ export async function POST(request: NextRequest) { ); } - const body = (await request.json()) as { - guildId?: string; - channelId?: string; - teamIds?: number[]; - }; + const discordAccount = user.accounts[0]; + if (!discordAccount) { + wideEvent.outcome = "discord_not_linked"; + wideEvent.status_code = 403; + return Response.json( + { + success: false, + error: "Discord account not linked", + code: "discord_not_linked", + }, + { status: 403 } + ); + } - if (!body.guildId || !body.channelId || !body.teamIds?.length) { + const parsed = CreateConfigSchema.safeParse(await request.json()); + if (!parsed.success) { wideEvent.outcome = "bad_request"; wideEvent.status_code = 400; return Response.json( @@ -115,8 +149,56 @@ export async function POST(request: NextRequest) { ); } + const body = { + ...parsed.data, + teamIds: [...new Set(parsed.data.teamIds)], + }; + + const membership = await verifyUserInGuild( + discordAccount.providerAccountId, + body.guildId + ); + if (!membership.ok) { + if (membership.reason === "misconfigured") { + wideEvent.outcome = "misconfigured"; + wideEvent.status_code = 503; + return Response.json( + { success: false, error: "Bot service not configured" }, + { status: 503 } + ); + } + wideEvent.outcome = "forbidden"; + wideEvent.status_code = 403; + return Response.json( + { success: false, error: "You are not a member of that server" }, + { status: 403 } + ); + } + + const channelAccess = await verifyUserCanUseChannel( + discordAccount.providerAccountId, + body.guildId, + body.channelId + ); + if (!channelAccess.ok) { + if (channelAccess.reason === "misconfigured") { + wideEvent.outcome = "misconfigured"; + wideEvent.status_code = 503; + return Response.json( + { success: false, error: "Bot service not configured" }, + { status: 503 } + ); + } + wideEvent.outcome = "forbidden"; + wideEvent.status_code = 403; + return Response.json( + { success: false, error: "You cannot use that channel" }, + { status: 403 } + ); + } + for (const teamId of body.teamIds) { - const hasAccess = await verifyTeamAccess(user.id, teamId); + const hasAccess = await canManageTeam(teamId, user); if (!hasAccess) { wideEvent.outcome = "forbidden"; wideEvent.status_code = 403; @@ -182,7 +264,7 @@ export async function PATCH(request: NextRequest) { const user = await prisma.user.findUnique({ where: { email: session.user.email }, - select: { id: true }, + select: { id: true, role: true }, }); if (!user) { @@ -194,12 +276,8 @@ export async function PATCH(request: NextRequest) { ); } - const body = (await request.json()) as { - configId?: string; - teamIds?: number[]; - }; - - if (!body.configId || !body.teamIds?.length) { + const parsed = UpdateConfigSchema.safeParse(await request.json()); + if (!parsed.success) { wideEvent.outcome = "bad_request"; wideEvent.status_code = 400; return Response.json( @@ -208,6 +286,11 @@ export async function PATCH(request: NextRequest) { ); } + const body = { + ...parsed.data, + teamIds: [...new Set(parsed.data.teamIds)], + }; + const existing = await prisma.botNotificationConfig.findFirst({ where: { id: body.configId, createdBy: user.id }, }); @@ -222,7 +305,7 @@ export async function PATCH(request: NextRequest) { } for (const teamId of body.teamIds) { - const hasAccess = await verifyTeamAccess(user.id, teamId); + const hasAccess = await canManageTeam(teamId, user); if (!hasAccess) { wideEvent.outcome = "forbidden"; wideEvent.status_code = 403; diff --git a/src/app/api/bot/profile/route.ts b/src/app/api/bot/profile/route.ts index f145e92c2..1e4a026dc 100644 --- a/src/app/api/bot/profile/route.ts +++ b/src/app/api/bot/profile/route.ts @@ -6,7 +6,7 @@ import { removeDuplicateRows } from "@/lib/utils"; import { authenticateBotSecret } from "@/lib/bot-auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { Prisma, type PlayerStat } from "@prisma/client"; +import { Prisma, type PlayerStat } from "@/generated/prisma/client"; import { trace } from "@opentelemetry/api"; import type { NextRequest } from "next/server"; @@ -99,7 +99,7 @@ export async function GET(request: NextRequest) { INNER JOIN maxTime m ON ps."match_time" = m.max_time AND ps."MapDataId" = m."MapDataId" WHERE ps."MapDataId" IN (${Prisma.join(mapDataIds)}) - AND ps."player_name" ILIKE ${playerName} + AND lower(ps."player_name") = lower(${playerName}) ` ); diff --git a/src/app/api/broadcast/tournament/[tournamentId]/route.ts b/src/app/api/broadcast/tournament/[tournamentId]/route.ts index 61b4b9382..890c9e131 100644 --- a/src/app/api/broadcast/tournament/[tournamentId]/route.ts +++ b/src/app/api/broadcast/tournament/[tournamentId]/route.ts @@ -1,6 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { BroadcastService } from "@/data/tournament"; +import { auth, canViewTournament, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { Ratelimit } from "@upstash/ratelimit"; import { ipAddress } from "@vercel/functions"; @@ -25,6 +26,13 @@ export async function GET( }; try { + const session = await auth(); + if (!session?.user?.email) { + event.outcome = "unauthorized"; + event.statusCode = 401; + return Response.json({ error: "Unauthorized" }, { status: 401 }); + } + const identifier = ipAddress(request) ?? "127.0.0.1"; const { success } = await ratelimit.limit(identifier); @@ -47,6 +55,13 @@ export async function GET( } event.tournamentId = tournamentId; + const user = await getCurrentUser(); + if (!(await canViewTournament(tournamentId, user))) { + event.outcome = "forbidden"; + event.statusCode = 403; + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + const data = await AppRuntime.runPromise( BroadcastService.pipe( Effect.flatMap((svc) => svc.getTournamentBroadcastData(tournamentId)) @@ -63,9 +78,7 @@ export async function GET( event.statusCode = 200; event.playerCount = data.players.length; return Response.json(data, { - headers: { - "Cache-Control": "public, s-maxage=30, stale-while-revalidate=60", - }, + headers: { "Cache-Control": "private, no-store" }, }); } catch (error) { event.outcome = "error"; diff --git a/src/app/api/chat/conversations/[id]/route.ts b/src/app/api/chat/conversations/[id]/route.ts index c59544d17..8c300333d 100644 --- a/src/app/api/chat/conversations/[id]/route.ts +++ b/src/app/api/chat/conversations/[id]/route.ts @@ -3,7 +3,7 @@ import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import type { Prisma } from "@prisma/client"; +import type { Prisma } from "@/generated/prisma/client"; import { unauthorized } from "next/navigation"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; diff --git a/src/app/api/chat/conversations/route.ts b/src/app/api/chat/conversations/route.ts index 053716d86..6932cd42e 100644 --- a/src/app/api/chat/conversations/route.ts +++ b/src/app/api/chat/conversations/route.ts @@ -3,7 +3,7 @@ import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import type { Prisma } from "@prisma/client"; +import type { Prisma } from "@/generated/prisma/client"; import { unauthorized } from "next/navigation"; import { NextResponse } from "next/server"; diff --git a/src/app/api/chat/route.ts b/src/app/api/chat/route.ts index 13f8aa2bb..6215ee917 100644 --- a/src/app/api/chat/route.ts +++ b/src/app/api/chat/route.ts @@ -1,13 +1,24 @@ +import CreditLowBalanceEmail from "@/components/email/credit-low-balance"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { systemPrompt } from "@/lib/ai/system-prompt"; +import { queryToolsSystemPrompt, systemPrompt } from "@/lib/ai/system-prompt"; import { chatTelemetry } from "@/lib/ai/telemetry"; import { buildTools } from "@/lib/ai/tools"; import { auth } from "@/lib/auth"; -import { flagsToBaggage, setRequestContext } from "@/lib/axiom/baggage"; +import { UsageEventName } from "@/lib/usage/names"; +import { usage } from "@/lib/usage/server"; +import { flagsToBaggage, withRequestContext } from "@/lib/axiom/baggage"; +import { + MIN_BALANCE_TO_CHAT_CENTS, + calculateChargeCents, +} from "@/lib/chat-pricing"; +import { attemptAutoRefill, chargeUser, getUserBalance } from "@/lib/credits"; +import { email } from "@/lib/email"; import { resolveAllFlags, toFlagValues } from "@/lib/flags-helpers"; +import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; +import { render } from "@react-email/render"; import { Ratelimit } from "@upstash/ratelimit"; import { kv } from "@vercel/kv"; import { @@ -16,6 +27,7 @@ import { streamText, type UIMessage, } from "ai"; +import { after } from "next/server"; import { unauthorized } from "next/navigation"; export const maxDuration = 60; @@ -26,6 +38,8 @@ const ratelimit = new Ratelimit({ analytics: true, prefix: "ratelimit:ai-chat", }); +const MAX_CHAT_REQUEST_CHARS = 120_000; +const MAX_CHAT_OUTPUT_TOKENS = 4_096; export async function POST(req: Request) { const session = await auth(); @@ -43,7 +57,34 @@ export async function POST(req: Request) { }); } + void usage.track({ + name: UsageEventName.AI_CHAT_MESSAGE, + userId: userData.id, + }); + + const balanceCents = await getUserBalance(userData.id); + if (balanceCents < MIN_BALANCE_TO_CHAT_CENTS) { + const chatCount = await prisma.chatConversation.count({ + where: { userId: userData.id }, + }); + return Response.json( + { + blocked: true, + balanceCents, + minimumBalanceCents: MIN_BALANCE_TO_CHAT_CENTS, + hasChats: chatCount > 0, + }, + { status: 402 } + ); + } + const { messages } = (await req.json()) as { messages: UIMessage[] }; + if (!Array.isArray(messages)) { + return new Response("Invalid chat request", { status: 400 }); + } + if (JSON.stringify(messages).length > MAX_CHAT_REQUEST_CHARS) { + return new Response("Chat request is too large", { status: 413 }); + } const user = await prisma.user.findUnique({ where: { id: userData.id }, @@ -58,30 +99,102 @@ export async function POST(req: Request) { const allowedTeamIds = new Set(userTeams.map((t) => t.id)); const flags = await resolveAllFlags(); - setRequestContext({ + const requestContext = { user_id: userData.id, billing_plan: userData.billingPlan, team_ids: userTeams.map((t) => String(t.id)).join(","), flags: flagsToBaggage(toFlagValues(flags)), + }; + + const tools = buildTools({ + userId: userData.id, + allowedTeamIds, + userTeams, + enableQueryTools: flags.queryBuilderEnabled, }); + const modelMessages = await convertToModelMessages(messages); - const tools = buildTools({ userId: userData.id, allowedTeamIds, userTeams }); + return withRequestContext(requestContext, () => { + const system = flags.queryBuilderEnabled + ? `${systemPrompt}\n${queryToolsSystemPrompt}` + : systemPrompt; + const result = streamText({ + model: "openai/gpt-5.4", + system, + messages: modelMessages, + tools, + maxOutputTokens: MAX_CHAT_OUTPUT_TOKENS, + stopWhen: stepCountIs(5), + experimental_telemetry: { + isEnabled: true, + functionId: "parsertime-chat", + recordInputs: true, + recordOutputs: true, + metadata: { userId: userData.id }, + integrations: [chatTelemetry(userData.id)], + }, + onFinish: ({ usage }) => { + after(async () => { + const inputTokens = usage.inputTokens ?? 0; + const outputTokens = usage.outputTokens ?? 0; + if (inputTokens === 0 && outputTokens === 0) return; - const result = streamText({ - model: "openai/gpt-5.4", - system: systemPrompt, - messages: await convertToModelMessages(messages), - tools, - stopWhen: stepCountIs(5), - experimental_telemetry: { - isEnabled: true, - functionId: "parsertime-chat", - recordInputs: true, - recordOutputs: true, - metadata: { userId: userData.id }, - integrations: [chatTelemetry(userData.id)], - }, - }); + const chargeCents = calculateChargeCents({ + inputTokens, + outputTokens, + }); + try { + const charge = await chargeUser(userData.id, { + amountCents: chargeCents, + description: `AI chat: ${inputTokens} in / ${outputTokens} out`, + metadata: { inputTokens, outputTokens, model: "openai/gpt-5.4" }, + }); + if (charge.autoRefillTriggered) { + const refill = await attemptAutoRefill(userData.id); + if (!refill.ok) { + Logger.warn("auto-refill did not fire", { + userId: userData.id, + reason: refill.reason, + }); + } + } + if (charge.lowBalanceWarningTriggered) { + try { + const user = await prisma.user.findUnique({ + where: { id: userData.id }, + }); + if (user) { + await email.sendEmail({ + to: user.email, + from: "noreply@lux.dev", + subject: "Your Parsertime AI credits are running low", + html: await render( + CreditLowBalanceEmail({ + user, + balanceCents: charge.balanceAfterCents, + }) + ), + }); + } + } catch (error) { + Logger.error("failed to send low-balance warning", { + userId: userData.id, + error: error instanceof Error ? error.message : String(error), + }); + } + } + } catch (error) { + Logger.error("failed to charge chat usage", { + userId: userData.id, + inputTokens, + outputTokens, + error: error instanceof Error ? error.message : String(error), + }); + } + }); + }, + }); - return result.toUIMessageStreamResponse(); + return result.toUIMessageStreamResponse(); + }); } diff --git a/src/app/api/coaching/fight-positions/route.ts b/src/app/api/coaching/fight-positions/route.ts index 459e8ca57..a8cbc6915 100644 --- a/src/app/api/coaching/fight-positions/route.ts +++ b/src/app/api/coaching/fight-positions/route.ts @@ -1,4 +1,4 @@ -import { auth } from "@/lib/auth"; +import { auth, canViewMapData, getCurrentUser } from "@/lib/auth"; import { resolveMapDataId } from "@/lib/map-data-resolver"; import prisma from "@/lib/prisma"; import { NextResponse } from "next/server"; @@ -32,6 +32,20 @@ export async function GET(request: Request) { const id = await resolveMapDataId(parseInt(mapDataId)); const fightStart = parseFloat(start); const fightEnd = parseFloat(end); + if ( + !Number.isFinite(id) || + !Number.isFinite(fightStart) || + !Number.isFinite(fightEnd) || + fightEnd < fightStart || + fightEnd - fightStart > 120 + ) { + return NextResponse.json({ error: "Invalid time range" }, { status: 400 }); + } + + const user = await getCurrentUser(); + if (!(await canViewMapData(id, user))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } const [kills, damage, healing, ability1, ability2] = await Promise.all([ prisma.kill.findMany({ diff --git a/src/app/api/compare/groups/[id]/route.ts b/src/app/api/compare/groups/[id]/route.ts index 4e7aa47fb..45c6e61bf 100644 --- a/src/app/api/compare/groups/[id]/route.ts +++ b/src/app/api/compare/groups/[id]/route.ts @@ -4,7 +4,7 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; diff --git a/src/app/api/compare/groups/route.ts b/src/app/api/compare/groups/route.ts index 8fbc8ec06..5400366ee 100644 --- a/src/app/api/compare/groups/route.ts +++ b/src/app/api/compare/groups/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, canViewMaps, canViewTeam, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import type { HeroName } from "@/types/heroes"; @@ -35,9 +32,7 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -65,6 +60,13 @@ export async function GET(request: NextRequest) { return new Response("Invalid team ID", { status: 400 }); } + if (!(await canViewTeam(teamId, user))) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + wideEvent.error = { message: "User cannot view team" }; + return new Response("Forbidden", { status: 403 }); + } + wideEvent.team = { id: teamId }; wideEvent.filters = { player_name: playerNameParam, @@ -152,9 +154,7 @@ export async function POST(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -213,7 +213,7 @@ export async function POST(request: NextRequest) { ); } - if (team.users.length === 0 && team.ownerId !== user.id) { + if (!(await canViewTeam(teamId, user))) { wideEvent.status_code = 403; wideEvent.outcome = "forbidden"; wideEvent.error = { message: "User is not a member of this team" }; @@ -226,6 +226,19 @@ export async function POST(request: NextRequest) { ); } + if (!(await canViewMaps(mapIds, user))) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_map_ids"; + wideEvent.error = { message: "User cannot view one or more maps" }; + return NextResponse.json( + { + success: false, + error: "Map IDs must belong to an accessible scrim", + }, + { status: 400 } + ); + } + wideEvent.team = { id: teamId, name: team.name }; wideEvent.group = { name, diff --git a/src/app/api/compare/map-groups/[id]/route.ts b/src/app/api/compare/map-groups/[id]/route.ts index 68e7958a2..993779c01 100644 --- a/src/app/api/compare/map-groups/[id]/route.ts +++ b/src/app/api/compare/map-groups/[id]/route.ts @@ -1,11 +1,9 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { MapGroupService } from "@/data/map"; -import { auth } from "@/lib/auth"; +import { auth, canManageTeam, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; import { z } from "zod"; @@ -18,12 +16,18 @@ const UpdateMapGroupSchema = z.object({ .optional(), description: z.string().max(500, "Description is too long").optional(), mapIds: z - .array(z.number()) + .array(z.number().int().positive()) .min(1, "At least one map must be selected") .optional(), category: z.string().max(50, "Category is too long").optional(), }); +function parsePositiveInt(value: string) { + if (!/^[1-9]\d*$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + export async function PUT( request: NextRequest, { params }: { params: Promise<{ id: string }> } @@ -44,9 +48,7 @@ export async function PUT( return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -57,9 +59,9 @@ export async function PUT( wideEvent.user = { id: user.id, email: user.email }; const { id: idParam } = await params; - const groupId = parseInt(idParam); + const groupId = parsePositiveInt(idParam); - if (isNaN(groupId)) { + if (!groupId) { wideEvent.status_code = 400; wideEvent.outcome = "invalid_group_id"; wideEvent.error = { message: "Invalid group ID" }; @@ -95,15 +97,7 @@ export async function PUT( const group = await prisma.mapGroup.findUnique({ where: { id: groupId }, include: { - team: { - select: { - ownerId: true, - users: { - where: { id: user.id }, - select: { id: true }, - }, - }, - }, + team: { select: { id: true, ownerId: true } }, }, }); @@ -120,40 +114,45 @@ export async function PUT( ); } - const isOwner = group.createdBy === user.id; - const isTeamOwner = group.team.ownerId === user.id; - const isAdmin = - user.role === $Enums.UserRole.ADMIN || - user.role === $Enums.UserRole.MANAGER; + const canManage = await canManageTeam(group.team.id, user); - if (!isOwner && !isTeamOwner && !isAdmin) { + if (!canManage) { wideEvent.status_code = 403; wideEvent.outcome = "forbidden"; wideEvent.error = { message: "User does not have permission to update this group", }; wideEvent.permissions = { - is_owner: isOwner, - is_team_owner: isTeamOwner, - is_admin: isAdmin, + can_manage_team: canManage, }; return NextResponse.json( { success: false, - error: - "You must be the group creator, team owner, or admin to update this group", + error: "You must manage this team to update this group", }, { status: 403 } ); } wideEvent.permissions = { - is_owner: isOwner, - is_team_owner: isTeamOwner, - is_admin: isAdmin, + can_manage_team: canManage, }; const { name, description, mapIds, category } = validatedData.data; + if (mapIds) { + const validMaps = await prisma.map.count({ + where: { + id: { in: mapIds }, + Scrim: { teamId: group.team.id }, + }, + }); + if (validMaps !== new Set(mapIds).size) { + return NextResponse.json( + { success: false, error: "Map IDs must belong to the team" }, + { status: 400 } + ); + } + } const updatedGroup = await AppRuntime.runPromise( MapGroupService.pipe( @@ -244,9 +243,7 @@ export async function DELETE( return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -257,9 +254,9 @@ export async function DELETE( wideEvent.user = { id: user.id, email: user.email }; const { id: idParam } = await params; - const groupId = parseInt(idParam); + const groupId = parsePositiveInt(idParam); - if (isNaN(groupId)) { + if (!groupId) { wideEvent.status_code = 400; wideEvent.outcome = "invalid_group_id"; wideEvent.error = { message: "Invalid group ID" }; @@ -271,15 +268,7 @@ export async function DELETE( const group = await prisma.mapGroup.findUnique({ where: { id: groupId }, include: { - team: { - select: { - ownerId: true, - users: { - where: { id: user.id }, - select: { id: true }, - }, - }, - }, + team: { select: { id: true, ownerId: true } }, }, }); @@ -296,39 +285,28 @@ export async function DELETE( ); } - const isOwner = group.createdBy === user.id; - const isTeamOwner = group.team.ownerId === user.id; - const isAdmin = - user.role === $Enums.UserRole.ADMIN || - user.role === $Enums.UserRole.MANAGER; - const isTeamMember = group.team.users.length > 0; + const canManage = await canManageTeam(group.team.id, user); - if (!isOwner && !isTeamOwner && !isAdmin) { + if (!canManage) { wideEvent.status_code = 403; wideEvent.outcome = "forbidden"; wideEvent.error = { message: "User does not have permission to delete this group", }; wideEvent.permissions = { - is_owner: isOwner, - is_team_owner: isTeamOwner, - is_admin: isAdmin, - is_team_member: isTeamMember, + can_manage_team: canManage, }; return NextResponse.json( { success: false, - error: - "You must be the group creator, team owner, or admin to delete this group", + error: "You must manage this team to delete this group", }, { status: 403 } ); } wideEvent.permissions = { - is_owner: isOwner, - is_team_owner: isTeamOwner, - is_admin: isAdmin, + can_manage_team: canManage, }; await AppRuntime.runPromise( diff --git a/src/app/api/compare/map-groups/route.ts b/src/app/api/compare/map-groups/route.ts index b750750be..45854b1e0 100644 --- a/src/app/api/compare/map-groups/route.ts +++ b/src/app/api/compare/map-groups/route.ts @@ -1,8 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { MapGroupService } from "@/data/map"; -import { auth } from "@/lib/auth"; +import { auth, canManageTeam, canViewTeam, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import type { NextRequest } from "next/server"; @@ -13,10 +12,18 @@ const CreateMapGroupSchema = z.object({ name: z.string().min(1, "Name is required").max(100, "Name is too long"), description: z.string().max(500, "Description is too long").optional(), teamId: z.number().int().positive("Team ID must be positive"), - mapIds: z.array(z.number()).min(1, "At least one map must be selected"), + mapIds: z + .array(z.number().int().positive()) + .min(1, "At least one map must be selected"), category: z.string().max(50, "Category is too long").optional(), }); +function parsePositiveInt(value: string | null) { + if (!value || !/^[1-9]\d*$/.test(value)) return null; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : null; +} + export async function GET(request: NextRequest) { const startTime = Date.now(); const wideEvent: Record = { @@ -34,9 +41,7 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -56,14 +61,21 @@ export async function GET(request: NextRequest) { return new Response("Team ID is required", { status: 400 }); } - const teamId = parseInt(teamIdParam); - if (isNaN(teamId)) { + const teamId = parsePositiveInt(teamIdParam); + if (!teamId) { wideEvent.status_code = 400; wideEvent.outcome = "invalid_team_id"; wideEvent.error = { message: "Invalid team ID" }; return new Response("Invalid team ID", { status: 400 }); } + if (!(await canViewTeam(teamId, user))) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + wideEvent.error = { message: "User cannot view team" }; + return new Response("Forbidden", { status: 403 }); + } + wideEvent.team = { id: teamId }; wideEvent.filters = { category: categoryParam, @@ -148,9 +160,7 @@ export async function POST(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -208,19 +218,38 @@ export async function POST(request: NextRequest) { ); } - if (team.users.length === 0 && team.ownerId !== user.id) { + if (!(await canManageTeam(teamId, user))) { wideEvent.status_code = 403; wideEvent.outcome = "forbidden"; - wideEvent.error = { message: "User is not a member of this team" }; + wideEvent.error = { message: "User cannot manage this team" }; return NextResponse.json( { success: false, - error: "You must be a member of this team to create map groups", + error: "You must manage this team to create map groups", }, { status: 403 } ); } + const validMaps = await prisma.map.count({ + where: { + id: { in: mapIds }, + Scrim: { teamId }, + }, + }); + if (validMaps !== new Set(mapIds).size) { + wideEvent.status_code = 400; + wideEvent.outcome = "invalid_map_ids"; + wideEvent.error = { message: "Map IDs must belong to the team" }; + return NextResponse.json( + { + success: false, + error: "Map IDs must belong to the team", + }, + { status: 400 } + ); + } + wideEvent.team = { id: teamId, name: team.name }; wideEvent.group = { name, diff --git a/src/app/api/compare/maps/route.ts b/src/app/api/compare/maps/route.ts index 071ddf96b..7ea391999 100644 --- a/src/app/api/compare/maps/route.ts +++ b/src/app/api/compare/maps/route.ts @@ -1,11 +1,10 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { ComparisonAggregationService } from "@/data/comparison"; -import { auth } from "@/lib/auth"; +import { auth, canViewTeam, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; -import { MapType } from "@prisma/client"; +import { MapType } from "@/generated/prisma/browser"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; @@ -26,9 +25,7 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -67,6 +64,13 @@ export async function GET(request: NextRequest) { return new Response("Invalid team ID", { status: 400 }); } + if (!(await canViewTeam(teamId, user))) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + wideEvent.error = { message: "User cannot view team" }; + return new Response("Forbidden", { status: 403 }); + } + const dateFrom = dateFromParam ? new Date(dateFromParam) : undefined; const dateTo = dateToParam ? new Date(dateToParam) : undefined; diff --git a/src/app/api/compare/players/route.ts b/src/app/api/compare/players/route.ts index 84ed2a7f8..46b99cfe6 100644 --- a/src/app/api/compare/players/route.ts +++ b/src/app/api/compare/players/route.ts @@ -1,8 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { ComparisonAggregationService } from "@/data/comparison"; -import { auth } from "@/lib/auth"; +import { auth, canViewMaps, canViewTeam, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; @@ -24,9 +23,7 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -52,6 +49,13 @@ export async function GET(request: NextRequest) { return new Response("Invalid team ID", { status: 400 }); } + if (!(await canViewTeam(teamId, user))) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + wideEvent.error = { message: "User cannot view team" }; + return new Response("Forbidden", { status: 403 }); + } + wideEvent.team = { id: teamId }; const mapIdsParam = request.nextUrl.searchParams.get("mapIds"); @@ -79,6 +83,13 @@ export async function GET(request: NextRequest) { } } + if (mapIds && !(await canViewMaps(mapIds, user))) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden_maps"; + wideEvent.error = { message: "User cannot view one or more maps" }; + return new Response("Forbidden", { status: 403 }); + } + const players = await AppRuntime.runPromise( ComparisonAggregationService.pipe( Effect.flatMap((svc) => svc.getTeamPlayers(teamId, mapIds)) diff --git a/src/app/api/compare/stats/route.ts b/src/app/api/compare/stats/route.ts index ab1f6614b..654dc45d9 100644 --- a/src/app/api/compare/stats/route.ts +++ b/src/app/api/compare/stats/route.ts @@ -1,8 +1,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { ComparisonAggregationService } from "@/data/comparison"; -import { auth } from "@/lib/auth"; +import { auth, canViewMaps, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; import type { NextRequest } from "next/server"; @@ -32,9 +31,7 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; @@ -86,6 +83,13 @@ export async function GET(request: NextRequest) { }); } + if (!(await canViewMaps(validMapIds.data, user))) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + wideEvent.error = { message: "User cannot view one or more maps" }; + return new Response("Forbidden", { status: 403 }); + } + const validPlayerName = PlayerNameSchema.safeParse(playerName); if (!validPlayerName.success) { wideEvent.status_code = 400; diff --git a/src/app/api/compare/team-vs-team/route.ts b/src/app/api/compare/team-vs-team/route.ts index 9263c673c..c59eeeacd 100644 --- a/src/app/api/compare/team-vs-team/route.ts +++ b/src/app/api/compare/team-vs-team/route.ts @@ -1,12 +1,22 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { TeamComparisonService } from "@/data/team"; +import { auth, canViewMaps, canViewTeam, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import type { HeroName } from "@/types/heroes"; import { NextResponse } from "next/server"; export async function GET(request: Request) { try { + const session = await auth(); + if (!session?.user?.email) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + const user = await getCurrentUser(); + if (!user) { + return NextResponse.json({ error: "User not found" }, { status: 404 }); + } + const { searchParams } = new URL(request.url); const mapIdsParam = searchParams.get("mapIds"); @@ -39,6 +49,13 @@ export async function GET(request: Request) { ); } + if ( + !(await canViewTeam(teamId, user)) || + !(await canViewMaps(mapIds, user)) + ) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + const heroes = heroesParam ? (heroesParam.split(",") as HeroName[]) : undefined; diff --git a/src/app/api/credits/auto-refill/route.ts b/src/app/api/credits/auto-refill/route.ts new file mode 100644 index 000000000..33f707e45 --- /dev/null +++ b/src/app/api/credits/auto-refill/route.ts @@ -0,0 +1,48 @@ +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import { TOPUP_MIN_CENTS } from "@/lib/chat-pricing"; +import { setAutoRefillConfig } from "@/lib/credits"; +import { Effect } from "effect"; +import type { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { z } from "zod"; + +const configSchema = z + .object({ + enabled: z.boolean().optional(), + thresholdCents: z.number().int().min(0).max(10_000).optional(), + amountCents: z.number().int().min(TOPUP_MIN_CENTS).max(10_000).optional(), + }) + .refine( + (data) => + data.enabled !== undefined || + data.thresholdCents !== undefined || + data.amountCents !== undefined, + { message: "Provide at least one field to update." } + ); + +export async function PATCH(request: NextRequest) { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) unauthorized(); + + const parsed = configSchema.safeParse(await request.json()); + if (!parsed.success) { + return Response.json( + { error: parsed.error.issues[0]?.message ?? "Invalid request" }, + { status: 400 } + ); + } + + const updated = await setAutoRefillConfig(user.id, parsed.data); + return Response.json({ + autoRefillEnabled: updated.autoRefillEnabled, + autoRefillThresholdCents: updated.autoRefillThresholdCents, + autoRefillAmountCents: updated.autoRefillAmountCents, + }); +} diff --git a/src/app/api/credits/balance/route.ts b/src/app/api/credits/balance/route.ts new file mode 100644 index 000000000..6d0c4c49b --- /dev/null +++ b/src/app/api/credits/balance/route.ts @@ -0,0 +1,26 @@ +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import { getOrInitUserCredits } from "@/lib/credits"; +import { Effect } from "effect"; +import { unauthorized } from "next/navigation"; + +export async function GET() { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) unauthorized(); + + const credits = await getOrInitUserCredits(user.id); + + return Response.json({ + balanceCents: credits.balanceCents, + autoRefillEnabled: credits.autoRefillEnabled, + autoRefillThresholdCents: credits.autoRefillThresholdCents, + autoRefillAmountCents: credits.autoRefillAmountCents, + hasPaymentMethod: credits.stripePaymentMethodId !== null, + }); +} diff --git a/src/app/api/credits/topup/route.ts b/src/app/api/credits/topup/route.ts new file mode 100644 index 000000000..d8ad098db --- /dev/null +++ b/src/app/api/credits/topup/route.ts @@ -0,0 +1,48 @@ +import { TOPUP_MIN_CENTS } from "@/lib/chat-pricing"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import { createTopupCheckout } from "@/lib/stripe"; +import type { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; +import { z } from "zod"; + +const topupSchema = z.object({ + amountCents: z + .number() + .int() + .min( + TOPUP_MIN_CENTS, + `Minimum top-up is $${(TOPUP_MIN_CENTS / 100).toFixed(2)}.` + ) + .max(1_000_00, "Top-up cannot exceed $1,000.00."), +}); + +export async function POST(request: NextRequest) { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const parsed = topupSchema.safeParse(await request.json()); + if (!parsed.success) { + return Response.json( + { error: parsed.error.issues[0]?.message ?? "Invalid amount" }, + { status: 400 } + ); + } + + try { + const checkoutSession = await createTopupCheckout( + session, + parsed.data.amountCents + ); + return Response.json({ url: checkoutSession.url }); + } catch (error) { + Logger.error("failed to create topup checkout session", { + userEmail: session.user.email, + error: error instanceof Error ? error.message : String(error), + }); + return Response.json( + { error: "Failed to create checkout session" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/credits/transactions/route.ts b/src/app/api/credits/transactions/route.ts new file mode 100644 index 000000000..52c740a13 --- /dev/null +++ b/src/app/api/credits/transactions/route.ts @@ -0,0 +1,45 @@ +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { Effect } from "effect"; +import type { NextRequest } from "next/server"; +import { unauthorized } from "next/navigation"; + +const DEFAULT_LIMIT = 20; +const MAX_LIMIT = 100; + +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) unauthorized(); + + const rawLimit = request.nextUrl.searchParams.get("limit"); + const requested = + rawLimit === null || rawLimit.trim() === "" + ? DEFAULT_LIMIT + : Number(rawLimit); + const limit = Number.isFinite(requested) + ? Math.min(Math.max(1, Math.trunc(requested)), MAX_LIMIT) + : DEFAULT_LIMIT; + + const transactions = await prisma.creditTransaction.findMany({ + where: { userId: user.id }, + orderBy: { createdAt: "desc" }, + take: limit, + select: { + id: true, + type: true, + amountCents: true, + balanceAfterCents: true, + description: true, + createdAt: true, + }, + }); + + return Response.json({ transactions }); +} diff --git a/src/app/api/cron/fsr-recompute/route.ts b/src/app/api/cron/fsr-recompute/route.ts new file mode 100644 index 000000000..7b90eea54 --- /dev/null +++ b/src/app/api/cron/fsr-recompute/route.ts @@ -0,0 +1,84 @@ +import { Logger } from "@/lib/logger"; +import { recomputeAllFsr } from "@/lib/fsr/compute"; +import { timingSafeEqual } from "node:crypto"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +type AuthResult = + | { ok: true } + | { ok: false; status: number; reason: "missing_secret" | "unauthorized" }; + +function authorizeCron(req: Request): AuthResult { + const expected = process.env.CRON_SECRET; + // Fail closed when the secret is unset — without this guard, a missing env + // var collapses the comparison string to "Bearer undefined" and any caller + // sending that literal would pass. + if (!expected) { + return { ok: false, status: 500, reason: "missing_secret" }; + } + const header = req.headers.get("Authorization"); + const provided = header?.startsWith("Bearer ") ? header.slice(7) : null; + if (!provided || provided.length !== expected.length) { + return { ok: false, status: 401, reason: "unauthorized" }; + } + try { + const ok = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!ok) return { ok: false, status: 401, reason: "unauthorized" }; + } catch { + return { ok: false, status: 401, reason: "unauthorized" }; + } + return { ok: true }; +} + +export async function GET(req: Request): Promise { + const startTime = Date.now(); + const wideEvent: Record = { + event: "fsr.cron.recompute", + method: "GET", + path: "/api/cron/fsr-recompute", + timestamp: new Date().toISOString(), + }; + + try { + const auth = authorizeCron(req); + if (!auth.ok) { + wideEvent.outcome = "denied"; + wideEvent.auth_reason = auth.reason; + wideEvent.status_code = auth.status; + const body = + auth.reason === "missing_secret" + ? "Server misconfigured" + : "Unauthorized"; + return new Response(body, { status: auth.status }); + } + wideEvent.auth_reason = "ok"; + + const fsr = await recomputeAllFsr(); + wideEvent.fsr = { + groups_loaded: fsr.groupsLoaded, + cells_written: fsr.cellsWritten, + players_written: fsr.playersWritten, + baselines_written: fsr.baselinesWritten, + stale_rows_dropped: fsr.staleRowsDropped, + duration_ms: fsr.durationMs, + skipped: fsr.skipped ?? false, + }; + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + + return Response.json({ ok: true, fsr: wideEvent.fsr }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/cron/scrape-patch-notes/route.ts b/src/app/api/cron/scrape-patch-notes/route.ts new file mode 100644 index 000000000..c294c4eb7 --- /dev/null +++ b/src/app/api/cron/scrape-patch-notes/route.ts @@ -0,0 +1,78 @@ +import { upsertScrapedPatches } from "@/data/overwatch/patches-service"; +import { Logger } from "@/lib/logger"; +import { scrapeRecent } from "@/lib/overwatch/patch-scraper"; +import { timingSafeEqual } from "node:crypto"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type AuthResult = + | { ok: true } + | { ok: false; status: number; reason: "missing_secret" | "unauthorized" }; + +function authorizeCron(req: Request): AuthResult { + const expected = process.env.CRON_SECRET; + if (!expected) { + return { ok: false, status: 500, reason: "missing_secret" }; + } + const header = req.headers.get("Authorization"); + const provided = header?.startsWith("Bearer ") ? header.slice(7) : null; + if (!provided || provided.length !== expected.length) { + return { ok: false, status: 401, reason: "unauthorized" }; + } + try { + const ok = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!ok) return { ok: false, status: 401, reason: "unauthorized" }; + } catch { + return { ok: false, status: 401, reason: "unauthorized" }; + } + return { ok: true }; +} + +export async function GET(req: Request): Promise { + const startTime = Date.now(); + const wideEvent: Record = { + event: "overwatch.cron.scrape_patches", + method: "GET", + path: "/api/cron/scrape-patch-notes", + timestamp: new Date().toISOString(), + }; + + try { + const auth = authorizeCron(req); + if (!auth.ok) { + wideEvent.outcome = "denied"; + wideEvent.auth_reason = auth.reason; + wideEvent.status_code = auth.status; + const body = + auth.reason === "missing_secret" + ? "Server misconfigured" + : "Unauthorized"; + return new Response(body, { status: auth.status }); + } + + const scraped = await scrapeRecent(); + wideEvent.scraped_count = scraped.length; + + const result = await upsertScrapedPatches(scraped); + wideEvent.inserted = result.inserted; + wideEvent.updated = result.updated; + wideEvent.skipped = result.skipped; + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + + return Response.json({ ok: true, ...result, scraped: scraped.length }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/cron/tempo-baseline/route.ts b/src/app/api/cron/tempo-baseline/route.ts new file mode 100644 index 000000000..0cdfab4e7 --- /dev/null +++ b/src/app/api/cron/tempo-baseline/route.ts @@ -0,0 +1,75 @@ +import { Logger } from "@/lib/logger"; +import { recomputeTempoBaselines } from "@/lib/tempo/compute"; +import { timingSafeEqual } from "node:crypto"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +type AuthResult = + | { ok: true } + | { ok: false; status: number; reason: "missing_secret" | "unauthorized" }; + +function authorizeCron(req: Request): AuthResult { + const expected = process.env.CRON_SECRET; + // Fail closed when the secret is unset — without this guard, a missing env + // var collapses the comparison string to "Bearer undefined" and any caller + // sending that literal would pass. + if (!expected) { + return { ok: false, status: 500, reason: "missing_secret" }; + } + const header = req.headers.get("Authorization"); + const provided = header?.startsWith("Bearer ") ? header.slice(7) : null; + if (!provided || provided.length !== expected.length) { + return { ok: false, status: 401, reason: "unauthorized" }; + } + try { + const ok = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!ok) return { ok: false, status: 401, reason: "unauthorized" }; + } catch { + return { ok: false, status: 401, reason: "unauthorized" }; + } + return { ok: true }; +} + +export async function GET(req: Request): Promise { + const startTime = Date.now(); + const wideEvent: Record = { + event: "tempo.cron.recompute", + method: "GET", + path: "/api/cron/tempo-baseline", + timestamp: new Date().toISOString(), + }; + + try { + const auth = authorizeCron(req); + if (!auth.ok) { + wideEvent.outcome = "denied"; + wideEvent.auth_reason = auth.reason; + wideEvent.status_code = auth.status; + const body = + auth.reason === "missing_secret" + ? "Server misconfigured" + : "Unauthorized"; + return new Response(body, { status: auth.status }); + } + wideEvent.auth_reason = "ok"; + + const result = await recomputeTempoBaselines(); + wideEvent.tempo = result; + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + + return Response.json({ ok: true, tempo: result }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/cron/tsr-recompute/route.ts b/src/app/api/cron/tsr-recompute/route.ts new file mode 100644 index 000000000..17ffb4ae5 --- /dev/null +++ b/src/app/api/cron/tsr-recompute/route.ts @@ -0,0 +1,140 @@ +import { Logger } from "@/lib/logger"; +import { recomputeAllTeamTsrSnapshots } from "@/lib/matchmaker/snapshot"; +import { + discoverAllTrackedChampionships, + ingestPlayerHistory, +} from "@/lib/tsr/ingest"; +import { recomputeAllTsrs } from "@/lib/tsr/replay"; +import prisma from "@/lib/prisma"; +import { timingSafeEqual } from "node:crypto"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +const REINGEST_BATCH_SIZE = 50; + +type AuthResult = + | { ok: true } + | { ok: false; status: number; reason: "missing_secret" | "unauthorized" }; + +function authorizeCron(req: Request): AuthResult { + const expected = process.env.CRON_SECRET; + // Fail closed when the secret is unset — without this guard, a missing env + // var collapses the comparison string to "Bearer undefined" and any caller + // sending that literal would pass. + if (!expected) { + return { ok: false, status: 500, reason: "missing_secret" }; + } + const header = req.headers.get("Authorization"); + const provided = header?.startsWith("Bearer ") ? header.slice(7) : null; + if (!provided || provided.length !== expected.length) { + return { ok: false, status: 401, reason: "unauthorized" }; + } + try { + const ok = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!ok) return { ok: false, status: 401, reason: "unauthorized" }; + } catch { + return { ok: false, status: 401, reason: "unauthorized" }; + } + return { ok: true }; +} + +export async function GET(req: Request): Promise { + const startTime = Date.now(); + const wideEvent: Record = { + event: "tsr.cron.recompute", + method: "GET", + path: "/api/cron/tsr-recompute", + timestamp: new Date().toISOString(), + }; + + try { + const auth = authorizeCron(req); + if (!auth.ok) { + wideEvent.outcome = "denied"; + wideEvent.auth_reason = auth.reason; + wideEvent.status_code = auth.status; + const body = + auth.reason === "missing_secret" + ? "Server misconfigured" + : "Unauthorized"; + return new Response(body, { status: auth.status }); + } + wideEvent.auth_reason = "ok"; + + const discovery = await discoverAllTrackedChampionships(); + wideEvent.discovery = { + organizers: discovery.length, + inserted: discovery.reduce((s, d) => s + d.inserted, 0), + updated: discovery.reduce((s, d) => s + d.updated, 0), + unclassified: discovery.reduce((s, d) => s + d.unclassified, 0), + }; + + const stalePlayers = await prisma.faceitPlayer.findMany({ + where: { rosterEntries: { some: {} } }, + orderBy: { lastSyncedAt: "asc" }, + take: REINGEST_BATCH_SIZE, + select: { faceitPlayerId: true }, + }); + let reingestIngested = 0; + let reingestSkipped = 0; + let reingestFailures = 0; + for (const p of stalePlayers) { + try { + const r = await ingestPlayerHistory(p.faceitPlayerId, { maxPages: 5 }); + reingestIngested += r.ingested; + reingestSkipped += r.skipped; + await prisma.faceitPlayer.update({ + where: { faceitPlayerId: p.faceitPlayerId }, + data: { lastSyncedAt: new Date() }, + }); + } catch (err) { + reingestFailures += 1; + Logger.warn({ + event: "tsr.cron.reingest_failed", + faceit_player_id: p.faceitPlayerId, + error_message: err instanceof Error ? err.message : "unknown", + }); + } + } + wideEvent.reingest = { + players_attempted: stalePlayers.length, + matches_ingested: reingestIngested, + matches_skipped: reingestSkipped, + failures: reingestFailures, + }; + + const replay = await recomputeAllTsrs(); + wideEvent.replay = { + matches_replayed: replay.matchesReplayed, + players_updated: replay.playersUpdated, + stale_rows_dropped: replay.staleRowsDropped, + duration_ms: replay.durationMs, + }; + + const snapshotResult = await recomputeAllTeamTsrSnapshots(); + wideEvent.team_snapshots = snapshotResult; + + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + + return Response.json({ + ok: true, + discovery: wideEvent.discovery, + reingest: wideEvent.reingest, + replay: wideEvent.replay, + team_snapshots: wideEvent.team_snapshots, + }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/cron/usage-rollup/route.ts b/src/app/api/cron/usage-rollup/route.ts new file mode 100644 index 000000000..7289705be --- /dev/null +++ b/src/app/api/cron/usage-rollup/route.ts @@ -0,0 +1,163 @@ +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { + aggregateActiveUsers, + aggregateFeatureRollups, + aggregatePageRollups, + dayKey, +} from "@/lib/usage/rollup"; +import { timingSafeEqual } from "node:crypto"; + +export const runtime = "nodejs"; +export const maxDuration = 300; + +type AuthResult = + | { ok: true } + | { ok: false; status: number; reason: "missing_secret" | "unauthorized" }; + +function authorizeCron(req: Request): AuthResult { + const expected = process.env.CRON_SECRET; + if (!expected) { + return { ok: false, status: 500, reason: "missing_secret" }; + } + const header = req.headers.get("Authorization"); + const provided = header?.startsWith("Bearer ") ? header.slice(7) : null; + if (!provided || provided.length !== expected.length) { + return { ok: false, status: 401, reason: "unauthorized" }; + } + try { + if (!timingSafeEqual(Buffer.from(provided), Buffer.from(expected))) { + return { ok: false, status: 401, reason: "unauthorized" }; + } + } catch { + return { ok: false, status: 401, reason: "unauthorized" }; + } + return { ok: true }; +} + +/** Aggregate a single UTC day and upsert its rollups (idempotent). */ +async function rollupDay(day: string): Promise { + const start = new Date(`${day}T00:00:00.000Z`); + const end = new Date(`${day}T00:00:00.000Z`); + end.setUTCDate(end.getUTCDate() + 1); + + const rows = await prisma.usageEvent.findMany({ + where: { ts: { gte: start, lt: end } }, + select: { + name: true, + environment: true, + userId: true, + teamId: true, + path: true, + }, + }); + + const features = aggregateFeatureRollups(rows, day); + const pages = aggregatePageRollups(rows, day); + const actives = aggregateActiveUsers(rows, day); + + await prisma.$transaction([ + ...features.map((f) => + prisma.dailyFeatureRollup.upsert({ + where: { + environment_day_name: { + environment: f.environment, + day: f.day, + name: f.name, + }, + }, + create: f, + update: { + totalEvents: f.totalEvents, + uniqueUsers: f.uniqueUsers, + uniqueTeams: f.uniqueTeams, + }, + }) + ), + ...pages.map((p) => + prisma.dailyPageRollup.upsert({ + where: { + environment_day_path: { + environment: p.environment, + day: p.day, + path: p.path, + }, + }, + create: p, + update: { views: p.views, uniqueUsers: p.uniqueUsers }, + }) + ), + ...actives.map((a) => + prisma.userActiveDay.upsert({ + where: { + environment_day_userId: { + environment: a.environment, + day: a.day, + userId: a.userId, + }, + }, + create: a, + update: {}, + }) + ), + ]); + + return rows.length; +} + +export async function GET(req: Request): Promise { + const startTime = Date.now(); + const wideEvent: Record = { + event: "usage.cron.rollup", + method: "GET", + path: "/api/cron/usage-rollup", + timestamp: new Date().toISOString(), + }; + + try { + const auth = authorizeCron(req); + if (!auth.ok) { + wideEvent.outcome = "denied"; + wideEvent.auth_reason = auth.reason; + wideEvent.status_code = auth.status; + const body = + auth.reason === "missing_secret" + ? "Server misconfigured" + : "Unauthorized"; + return new Response(body, { status: auth.status }); + } + wideEvent.auth_reason = "ok"; + + // Roll up yesterday, and self-heal up to 7 prior days with missing rollups. + const today = new Date(); + const processed: Record = {}; + for (let back = 1; back <= 7; back++) { + const d = new Date(today); + d.setUTCDate(d.getUTCDate() - back); + const day = dayKey(d); + const existing = await prisma.dailyFeatureRollup.count({ + where: { day }, + }); + if (back === 1 || existing === 0) { + processed[day] = await rollupDay(day); + } + } + + wideEvent.processed = processed; + wideEvent.status_code = 200; + wideEvent.outcome = "success"; + + return Response.json({ ok: true, processed }); + } catch (error) { + wideEvent.status_code = 500; + wideEvent.outcome = "error"; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + throw error; + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/cron/wp-publish/route.ts b/src/app/api/cron/wp-publish/route.ts new file mode 100644 index 000000000..0e2f8bd3d --- /dev/null +++ b/src/app/api/cron/wp-publish/route.ts @@ -0,0 +1,154 @@ +import { Logger } from "@/lib/logger"; +import { + loadLatestArtifact, + publishArtifact, +} from "@/lib/win-probability/artifact-store"; +import { featureHash } from "@/lib/win-probability/features"; +import type { FamilyModel, ModelArtifact } from "@/lib/win-probability/model"; +import { chooseFamily } from "@/lib/win-probability/training/champion-challenger"; +import { MODE_FAMILIES, type ModeFamily } from "@/lib/win-probability/types"; +import { timingSafeEqual } from "node:crypto"; +import { gunzipSync } from "node:zlib"; + +export const runtime = "nodejs"; +export const maxDuration = 60; + +type AuthResult = + | { ok: true } + | { ok: false; status: number; reason: "missing_secret" | "unauthorized" }; + +function authorizeCron(req: Request): AuthResult { + const expected = process.env.CRON_SECRET; + // Fail closed when the secret is unset — see wp-retrain for the rationale. + if (!expected) { + return { ok: false, status: 500, reason: "missing_secret" }; + } + const header = req.headers.get("Authorization"); + const provided = header?.startsWith("Bearer ") ? header.slice(7) : null; + if (!provided || provided.length !== expected.length) { + return { ok: false, status: 401, reason: "unauthorized" }; + } + try { + const ok = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!ok) return { ok: false, status: 401, reason: "unauthorized" }; + } catch { + return { ok: false, status: 401, reason: "unauthorized" }; + } + return { ok: true }; +} + +/** The gzipped candidate the Python trainer POSTs: per-mode trained families + * plus per-mode gate flags. The incumbent never travels the wire — this route + * loads it from R2 and runs champion/challenger here. */ +type CandidatePayload = { + schemaVersion: 1; + featureHash: string; + modeFamilies: Record; + gates: Partial>; +}; + +/** Read the raw body and decode it as the candidate payload. The trainer sends + * gzip (Content-Encoding: gzip); a plain-JSON body (manual curl) still parses. */ +async function readCandidate(req: Request): Promise { + const raw = Buffer.from(await req.arrayBuffer()); + const encoding = req.headers.get("Content-Encoding")?.toLowerCase() ?? ""; + const json = encoding.includes("gzip") + ? gunzipSync(raw).toString("utf8") + : raw.toString("utf8"); + return JSON.parse(json) as CandidatePayload; +} + +/** + * Publish-callback for the Python trainer: it POSTs a gzipped candidate + * (per-mode trained families + gate flags) here. This route loads the live + * incumbent from R2, runs the per-mode champion/challenger decision, and + * single-sources the R2 publish in TS. The Python function never touches R2. + */ +export async function POST(req: Request): Promise { + const startTime = Date.now(); + const wideEvent: Record = { + event: "wp.cron.publish", + method: "POST", + path: "/api/cron/wp-publish", + timestamp: new Date().toISOString(), + }; + + try { + const auth = authorizeCron(req); + if (!auth.ok) { + wideEvent.outcome = "denied"; + wideEvent.auth_reason = auth.reason; + wideEvent.status_code = auth.status; + const body = + auth.reason === "missing_secret" + ? "Server misconfigured" + : "Unauthorized"; + return new Response(body, { status: auth.status }); + } + wideEvent.auth_reason = "ok"; + + const candidate = await readCandidate(req); + + // Refuse a candidate trained against a different feature set — the code's + // feature order is the contract, and a mismatch would mis-index features. + if (candidate.featureHash !== featureHash()) { + wideEvent.outcome = "feature_hash_mismatch"; + wideEvent.status_code = 400; + wideEvent.artifact_hash = candidate.featureHash; + wideEvent.code_hash = featureHash(); + return new Response("Feature hash mismatch", { status: 400 }); + } + + // Champion/challenger decided here against the live R2 incumbent. + const live = await loadLatestArtifact(); + const chosen: Record = { + control: null, + escort_hybrid: null, + push: null, + flashpoint: null, + }; + for (const mode of MODE_FAMILIES) { + chosen[mode] = chooseFamily( + candidate.modeFamilies[mode] ?? null, + candidate.gates[mode] ?? false, + live?.modeFamilies[mode] ?? null + ); + } + + const artifact: ModelArtifact = { + schemaVersion: 1, + modelVersion: 0, // the publish step reassigns the real version + createdAt: new Date().toISOString(), + featureHash: candidate.featureHash, + modeFamilies: chosen, + }; + + const published = await publishArtifact(artifact); + wideEvent.published_key = published.key; + wideEvent.model_version = published.modelVersion; + wideEvent.outcome = "success"; + wideEvent.status_code = 200; + const shipped = Object.fromEntries( + MODE_FAMILIES.map((mode) => [mode, chosen[mode]?.kind ?? null]) + ); + wideEvent.shipped = shipped; + return Response.json({ + key: published.key, + modelVersion: published.modelVersion, + shipped, + }); + } catch (error) { + wideEvent.outcome = "error"; + wideEvent.status_code = 500; + wideEvent.error_message = + error instanceof Error ? error.message : "unknown"; + return new Response("Internal error", { status: 500 }); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + if (wideEvent.outcome === "error") { + Logger.error(wideEvent); + } else { + Logger.info(wideEvent); + } + } +} diff --git a/src/app/api/cron/wp-retrain/route.ts b/src/app/api/cron/wp-retrain/route.ts new file mode 100644 index 000000000..f5f231003 --- /dev/null +++ b/src/app/api/cron/wp-retrain/route.ts @@ -0,0 +1,190 @@ +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { datasetToCsv } from "@/lib/win-probability/training/csv"; +import { + buildRows, + fetchEventLog, +} from "@/lib/win-probability/training/extract"; +import { + type DatasetRow, + MODE_FAMILIES, + type ModeFamily, +} from "@/lib/win-probability/types"; +import { put } from "@vercel/blob"; +import { waitUntil } from "@vercel/functions"; +import { randomUUID, timingSafeEqual } from "node:crypto"; + +export const runtime = "nodejs"; +// The export (map fetch + buildRows over every map) is the long pole; training +// now runs in a separate Python function, so this route only extracts, writes +// the matrices to Blob, and fires the trainer. +export const maxDuration = 300; + +const BATCH_SIZE = 50; + +type AuthResult = + | { ok: true } + | { ok: false; status: number; reason: "missing_secret" | "unauthorized" }; + +function authorizeCron(req: Request): AuthResult { + const expected = process.env.CRON_SECRET; + // Fail closed when the secret is unset — without this guard, a missing env + // var collapses the comparison string to "Bearer undefined" and any caller + // sending that literal would pass. + if (!expected) { + return { ok: false, status: 500, reason: "missing_secret" }; + } + const header = req.headers.get("Authorization"); + const provided = header?.startsWith("Bearer ") ? header.slice(7) : null; + if (!provided || provided.length !== expected.length) { + return { ok: false, status: 401, reason: "unauthorized" }; + } + try { + const ok = timingSafeEqual(Buffer.from(provided), Buffer.from(expected)); + if (!ok) return { ok: false, status: 401, reason: "unauthorized" }; + } catch { + return { ok: false, status: 401, reason: "unauthorized" }; + } + return { ok: true }; +} + +export async function GET(req: Request): Promise { + const startTime = Date.now(); + const wideEvent: Record = { + event: "wp.cron.retrain", + method: "GET", + path: "/api/cron/wp-retrain", + timestamp: new Date().toISOString(), + }; + + try { + const auth = authorizeCron(req); + if (!auth.ok) { + wideEvent.outcome = "denied"; + wideEvent.auth_reason = auth.reason; + wideEvent.status_code = auth.status; + const body = + auth.reason === "missing_secret" + ? "Server misconfigured" + : "Unauthorized"; + return new Response(body, { status: auth.status }); + } + wideEvent.auth_reason = "ok"; + + const maps = await prisma.matchStart.findMany({ + where: { map_type: { not: "Clash" }, MapDataId: { not: null } }, + select: { MapDataId: true }, + distinct: ["MapDataId"], + }); + wideEvent.maps_total = maps.length; + + const rowsByFamily: Record = { + control: [], + escort_hybrid: [], + push: [], + flashpoint: [], + }; + let mapsExported = 0; + let mapsSkipped = 0; + + for (let i = 0; i < maps.length; i += BATCH_SIZE) { + const batch = maps.slice(i, i + BATCH_SIZE); + const logs = await Promise.all( + batch.map((m) => fetchEventLog(m.MapDataId!)) + ); + for (let j = 0; j < batch.length; j++) { + const log = logs[j]; + if (log === null) { + mapsSkipped++; + continue; + } + const rows = buildRows(log, batch[j].MapDataId!); + if (rows.length === 0) { + mapsSkipped++; + continue; + } + rowsByFamily[log.modeFamily].push(...rows); + mapsExported++; + } + } + wideEvent.maps_exported = mapsExported; + wideEvent.maps_skipped = mapsSkipped; + wideEvent.rows_by_family = Object.fromEntries( + MODE_FAMILIES.map((f) => [f, rowsByFamily[f].length]) + ); + + // Export each non-empty mode's feature matrix to Blob, then trigger the + // Python trainer. Training (and the eventual R2 publish via the + // wp-publish callback) happens out-of-band — this route never trains. + const runId = randomUUID(); + const token = process.env.BLOB_READ_WRITE_TOKEN; + const exportedModes: ModeFamily[] = []; + // Capture each put() result's URL so the trainer fetches the exact blob — + // the default random suffix makes paths unguessable from runId alone. + const urls: Partial> = {}; + for (const family of MODE_FAMILIES) { + const rows = rowsByFamily[family]; + if (rows.length === 0) continue; + const blob = await put( + `wp-train/${runId}/dataset-${family}.csv`, + datasetToCsv(rows), + { + access: "public", + contentType: "text/csv", + token, + } + ); + urls[family] = blob.url; + exportedModes.push(family); + } + wideEvent.run_id = runId; + wideEvent.exported_modes = exportedModes; + + if (exportedModes.length === 0) { + // Nothing to train on — skip the trigger and report the empty run. + wideEvent.outcome = "no_data_exported"; + wideEvent.status_code = 200; + return Response.json({ exported: true, runId, modes: exportedModes }); + } + + // Fire-and-trigger: kick the Python trainer but don't await its training. + // waitUntil keeps the function alive to deliver the trigger while this + // route returns immediately (its maxDuration is 300 and must not block on + // training). The trainer fetches the passed blob URLs, then POSTs the + // finished artifact back to /api/cron/wp-publish. + const origin = "https://parsertime.app"; + waitUntil( + fetch(`${origin}/api/wp-train`, { + method: "POST", + headers: { + Authorization: `Bearer ${process.env.CRON_SECRET}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ runId, urls }), + }).catch((error: unknown) => { + Logger.warn({ + event: "wp.cron.retrain.trigger_failed", + run_id: runId, + error_message: error instanceof Error ? error.message : "unknown", + }); + }) + ); + + wideEvent.outcome = "success"; + wideEvent.status_code = 200; + return Response.json({ exported: true, runId, modes: exportedModes }); + } catch (error) { + wideEvent.outcome = "error"; + wideEvent.status_code = 500; + wideEvent.error_message = + error instanceof Error ? error.message : "unknown"; + return new Response("Internal error", { status: 500 }); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + if (wideEvent.outcome === "error") { + Logger.error(wideEvent); + } else { + Logger.info(wideEvent); + } + } +} diff --git a/src/app/api/dashboard/overview/route.ts b/src/app/api/dashboard/overview/route.ts new file mode 100644 index 000000000..15d1dd557 --- /dev/null +++ b/src/app/api/dashboard/overview/route.ts @@ -0,0 +1,69 @@ +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import { + canViewTeamOverview, + getAllOverview, + getTeamOverview, + type DashboardOverview, +} from "@/lib/dashboard/overview"; +import { Logger } from "@/lib/logger"; +import { Effect } from "effect"; +import { unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; +import { NextResponse } from "next/server"; + +export const runtime = "nodejs"; + +function parseOptionalPositiveInt(value: string | null): number | undefined { + if (value === null || !/^[1-9]\d*$/.test(value)) return undefined; + const parsed = Number(value); + return Number.isSafeInteger(parsed) ? parsed : undefined; +} + +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session?.user?.email) unauthorized(); + + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!userData) unauthorized(); + + const searchParams = req.nextUrl.searchParams; + const teamId = parseOptionalPositiveInt(searchParams.get("teamId")); + const adminMode = searchParams.get("adminMode") === "true"; + + try { + let payload: DashboardOverview; + + if (teamId && !adminMode) { + const allowed = await canViewTeamOverview( + userData.id, + userData.role, + teamId + ); + payload = allowed + ? await getTeamOverview(teamId) + : await getAllOverview({ + userId: userData.id, + role: userData.role, + adminMode: false, + }); + } else { + payload = await getAllOverview({ + userId: userData.id, + role: userData.role, + adminMode, + }); + } + + return NextResponse.json(payload); + } catch (error) { + Logger.error("Error building dashboard overview:", error); + return NextResponse.json( + { error: "Failed to load overview" }, + { status: 500 } + ); + } +} diff --git a/src/app/api/data-labeling/matches/route.ts b/src/app/api/data-labeling/matches/route.ts index d2a9b2f0e..b0b58a22e 100644 --- a/src/app/api/data-labeling/matches/route.ts +++ b/src/app/api/data-labeling/matches/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -32,15 +29,18 @@ export async function GET(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; wideEvent.error = { message: "User not found" }; return new Response("User not found", { status: 404 }); } + if (!isAdminUser(user)) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + return new Response("Forbidden", { status: 403 }); + } wideEvent.user = { id: user.id, email: user.email }; diff --git a/src/app/api/data-labeling/save-comp/route.ts b/src/app/api/data-labeling/save-comp/route.ts index c00d6f321..0470c8fa1 100644 --- a/src/app/api/data-labeling/save-comp/route.ts +++ b/src/app/api/data-labeling/save-comp/route.ts @@ -1,7 +1,4 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; @@ -64,15 +61,18 @@ export async function POST(request: NextRequest) { return new Response("Unauthorized", { status: 401 }); } - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) { wideEvent.status_code = 404; wideEvent.outcome = "user_not_found"; wideEvent.error = { message: "User not found" }; return new Response("User not found", { status: 404 }); } + if (!isAdminUser(user)) { + wideEvent.status_code = 403; + wideEvent.outcome = "forbidden"; + return new Response("Forbidden", { status: 403 }); + } wideEvent.user = { id: user.id, email: user.email }; @@ -147,10 +147,11 @@ export async function POST(request: NextRequest) { data: { team1Comp, team2Comp }, }); + await tx.scoutingHeroAssignment.deleteMany({ + where: { mapResultId }, + }); + if (heroAssignments && heroAssignments.length > 0) { - await tx.scoutingHeroAssignment.deleteMany({ - where: { mapResultId }, - }); await tx.scoutingHeroAssignment.createMany({ data: heroAssignments.map((a) => ({ mapResultId, diff --git a/src/app/api/faceit/get-players/route.ts b/src/app/api/faceit/get-players/route.ts new file mode 100644 index 000000000..5e5fbfa57 --- /dev/null +++ b/src/app/api/faceit/get-players/route.ts @@ -0,0 +1,40 @@ +import { AppRuntime } from "@/data/runtime"; +import { FaceitPlayerScoutingService } from "@/data/faceit"; +import { Effect } from "effect"; +import { auth } from "@/lib/auth"; +import { unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; + +export type GetFaceitPlayersResponse = { + players: { + faceitPlayerId: string; + nickname: string; + battletag: string | null; + matchCount: number; + topFsr: number | null; + }[]; +}; + +export async function GET() { + const session = await auth(); + + if (!session) { + unauthorized(); + } + + const players = await AppRuntime.runPromise( + FaceitPlayerScoutingService.pipe( + Effect.flatMap((svc) => svc.getFaceitPlayers()) + ) + ); + + return NextResponse.json({ + players: players.map((p) => ({ + faceitPlayerId: p.faceitPlayerId, + nickname: p.nickname, + battletag: p.battletag, + matchCount: p.matchCount, + topFsr: p.topFsr, + })), + }); +} diff --git a/src/app/api/faceit/get-teams/route.ts b/src/app/api/faceit/get-teams/route.ts new file mode 100644 index 000000000..348d7a70b --- /dev/null +++ b/src/app/api/faceit/get-teams/route.ts @@ -0,0 +1,32 @@ +import { AppRuntime } from "@/data/runtime"; +import { FaceitTeamScoutingService } from "@/data/faceit"; +import { Effect } from "effect"; +import { auth } from "@/lib/auth"; +import { unauthorized } from "next/navigation"; +import { NextResponse } from "next/server"; + +export type GetFaceitTeamsResponse = { + teams: { faceitTeamId: string; name: string; matchCount: number }[]; +}; + +export async function GET() { + const session = await auth(); + + if (!session) { + unauthorized(); + } + + const teams = await AppRuntime.runPromise( + FaceitTeamScoutingService.pipe( + Effect.flatMap((svc) => svc.getFaceitTeams()) + ) + ); + + return NextResponse.json({ + teams: teams.map((t) => ({ + faceitTeamId: t.faceitTeamId, + name: t.name, + matchCount: t.matchCount, + })), + }); +} diff --git a/src/app/api/faceit/webhook/route.ts b/src/app/api/faceit/webhook/route.ts new file mode 100644 index 000000000..7ee7c050c --- /dev/null +++ b/src/app/api/faceit/webhook/route.ts @@ -0,0 +1,187 @@ +import { Logger } from "@/lib/logger"; +import { ingestMatchById } from "@/lib/tsr/ingest"; +import { recomputeAllTsrs } from "@/lib/tsr/replay"; +import { kv } from "@vercel/kv"; +import { createHmac, timingSafeEqual } from "node:crypto"; + +export const runtime = "nodejs"; + +type FaceitWebhookEvent = { + transaction_id?: string; + event?: string; + event_id?: string; + timestamp?: number; + retry_count?: number; + version?: number; + payload?: { + id?: string; + organizer_id?: string; + region?: string; + }; +}; + +const HANDLED_EVENTS = new Set([ + "match_status_finished", + "match_status_cancelled", + "match_status_aborted", +]); +const MAX_EVENT_AGE_MS = 10 * 60 * 1000; +const MAX_EVENT_FUTURE_SKEW_MS = 5 * 60 * 1000; +const EVENT_DEDUPE_TTL_SECONDS = 7 * 24 * 60 * 60; + +function eventTimestampToMs(timestamp: number | undefined): number | null { + if (timestamp === undefined) return null; + if (!Number.isFinite(timestamp)) return null; + return timestamp > 1_000_000_000_000 ? timestamp : timestamp * 1000; +} + +function verifySignature(rawBody: string, header: string | null): boolean { + const secret = process.env.FACEIT_WEBHOOK_SECRET; + if (!secret) { + // No secret configured — refuse rather than process unauthenticated input. + return false; + } + if (!header) return false; + const expected = createHmac("sha256", secret).update(rawBody).digest("hex"); + // FACEIT delivers the signature as `sha256=` per their webhook docs. + const provided = header.startsWith("sha256=") ? header.slice(7) : header; + if (provided.length !== expected.length) return false; + try { + return timingSafeEqual( + Buffer.from(provided, "hex"), + Buffer.from(expected, "hex") + ); + } catch { + return false; + } +} + +export async function POST(req: Request): Promise { + const startTime = Date.now(); + const wideEvent: Record = { + event: "tsr.faceit.webhook", + method: "POST", + path: "/api/faceit/webhook", + timestamp: new Date().toISOString(), + }; + + try { + const rawBody = await req.text(); + const sig = + req.headers.get("x-faceit-signature-256") ?? + req.headers.get("x-faceit-signature"); + + if (!verifySignature(rawBody, sig)) { + wideEvent.outcome = "denied"; + wideEvent.signature_valid = false; + wideEvent.status_code = 401; + return new Response("Unauthorized", { status: 401 }); + } + wideEvent.signature_valid = true; + + let event: FaceitWebhookEvent; + try { + event = JSON.parse(rawBody) as FaceitWebhookEvent; + } catch (err) { + wideEvent.outcome = "bad_request"; + wideEvent.status_code = 400; + wideEvent.parse_error = err instanceof Error ? err.message : "unknown"; + return new Response("Bad Request", { status: 400 }); + } + + const eventName = event.event ?? ""; + wideEvent.faceit_event = eventName; + wideEvent.faceit_event_id = event.event_id; + wideEvent.faceit_transaction_id = event.transaction_id; + wideEvent.retry_count = event.retry_count; + + if (!HANDLED_EVENTS.has(eventName)) { + wideEvent.outcome = "ignored"; + wideEvent.status_code = 200; + return Response.json({ ok: true, ignored: true, event: eventName }); + } + + const timestampMs = eventTimestampToMs(event.timestamp); + if (!timestampMs) { + wideEvent.outcome = "bad_request"; + wideEvent.status_code = 400; + wideEvent.parse_error = "missing_event_timestamp"; + return new Response("Bad Request", { status: 400 }); + } + const now = Date.now(); + if ( + now - timestampMs > MAX_EVENT_AGE_MS || + timestampMs - now > MAX_EVENT_FUTURE_SKEW_MS + ) { + wideEvent.outcome = "stale_event"; + wideEvent.status_code = 400; + return new Response("Stale event", { status: 400 }); + } + + const eventDedupId = event.event_id ?? event.transaction_id; + if (!eventDedupId) { + wideEvent.outcome = "bad_request"; + wideEvent.status_code = 400; + wideEvent.parse_error = "missing_event_id"; + return new Response("Bad Request", { status: 400 }); + } + const dedupeResult = await kv.set(`faceit:webhook:${eventDedupId}`, "1", { + nx: true, + ex: EVENT_DEDUPE_TTL_SECONDS, + }); + if (dedupeResult !== "OK") { + wideEvent.outcome = "duplicate"; + wideEvent.status_code = 200; + return Response.json({ ok: true, duplicate: true }); + } + + const matchId = event.payload?.id; + wideEvent.match_id = matchId; + wideEvent.organizer_id = event.payload?.organizer_id; + wideEvent.region = event.payload?.region; + + if (!matchId) { + wideEvent.outcome = "bad_request"; + wideEvent.status_code = 400; + wideEvent.parse_error = "missing_payload_id"; + return new Response("Bad Request", { status: 400 }); + } + + const result = await ingestMatchById(matchId); + wideEvent.ingested = result.ingested; + wideEvent.ingest_reason = result.reason; + wideEvent.affected_player_count = result.affectedPlayerIds.length; + + if (result.ingested) { + // Recompute is region-agnostic and cheap; fire and forget so the + // webhook ack stays fast. The recompute emits its own wide event. + recomputeAllTsrs().catch((err) => { + Logger.error({ + event: "tsr.faceit.webhook.recompute_failed", + match_id: matchId, + error_message: err instanceof Error ? err.message : "unknown", + }); + }); + } + + wideEvent.outcome = "success"; + wideEvent.status_code = 200; + return Response.json({ + ok: true, + matchId, + ingested: result.ingested, + reason: result.reason, + }); + } catch (error) { + wideEvent.outcome = "error"; + wideEvent.status_code = 500; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + return new Response("Internal Server Error", { status: 500 }); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/internal/availability/pending-reminders/route.ts b/src/app/api/internal/availability/pending-reminders/route.ts index 29b4cd35a..3dab14a0d 100644 --- a/src/app/api/internal/availability/pending-reminders/route.ts +++ b/src/app/api/internal/availability/pending-reminders/route.ts @@ -7,7 +7,7 @@ import { isWithinReminderWindow, wasFiredThisWeek, } from "@/lib/availability/reminder"; -import { weekStartInTz } from "@/lib/availability/tz"; +import { isValidTimeZone, weekStartInTz } from "@/lib/availability/tz"; import prisma from "@/lib/prisma"; import type { NextRequest } from "next/server"; @@ -27,13 +27,18 @@ export async function GET(req: NextRequest) { const pending: ReminderJob[] = []; for (const s of enabled) { - if (!isWithinReminderWindow(now, s)) continue; - - const weekStart = weekStartInTz(now, s.timezone); - if (wasFiredThisWeek(s.lastReminderFiredAt, weekStart)) continue; - - const job = await buildReminderJob(s.teamId, baseUrl); - if (job) pending.push(job); + try { + if (!isValidTimeZone(s.timezone)) continue; + if (!isWithinReminderWindow(now, s)) continue; + + const weekStart = weekStartInTz(now, s.timezone, s.reminderDayOfWeek); + if (wasFiredThisWeek(s.lastReminderFiredAt, weekStart)) continue; + + const job = await buildReminderJob(s.teamId, baseUrl); + if (job) pending.push(job); + } catch { + continue; + } } return Response.json({ pending }); diff --git a/src/app/api/leaderboard/adjusted-csr/route.ts b/src/app/api/leaderboard/adjusted-csr/route.ts new file mode 100644 index 000000000..04db81137 --- /dev/null +++ b/src/app/api/leaderboard/adjusted-csr/route.ts @@ -0,0 +1,85 @@ +import { + getAdjustedCSRLeaderboard, + type AdjustedCSRLeaderboardParams, +} from "@/lib/adjusted-csr"; +import { Logger } from "@/lib/logger"; +import { type HeroName, heroRoleMapping } from "@/types/heroes"; + +export const runtime = "nodejs"; + +function parseInt32(value: string | null, fallback: number): number { + if (!value) return fallback; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +function resolveHero(value: string | null): HeroName | null { + if (!value) return null; + const heroLower = value.toLowerCase(); + return ( + (Object.keys(heroRoleMapping) as HeroName[]).find( + (hero) => hero.toLowerCase() === heroLower + ) ?? null + ); +} + +export async function GET(req: Request): Promise { + const startTime = Date.now(); + const url = new URL(req.url); + const wideEvent: Record = { + event: "adjusted_csr.leaderboard.query", + method: "GET", + path: "/api/leaderboard/adjusted-csr", + timestamp: new Date().toISOString(), + }; + + try { + const hero = resolveHero(url.searchParams.get("hero")); + if (!hero) { + wideEvent.outcome = "bad_request"; + wideEvent.status_code = 400; + return Response.json( + { success: false, error: "Valid hero query parameter is required" }, + { status: 400 } + ); + } + + const playerParam = url.searchParams.get("player")?.trim(); + const player = + playerParam !== undefined && playerParam.length > 0 + ? playerParam + : undefined; + const query: AdjustedCSRLeaderboardParams = { + hero, + player, + limit: parseInt32(url.searchParams.get("limit"), 50), + minMaps: parseInt32(url.searchParams.get("minMaps"), 10), + minTimeSeconds: parseInt32(url.searchParams.get("minTimeSeconds"), 60), + }; + + wideEvent.hero = query.hero; + wideEvent.has_player = !!query.player; + wideEvent.limit = query.limit; + wideEvent.min_maps = query.minMaps; + wideEvent.min_time_seconds = query.minTimeSeconds; + + const data = await getAdjustedCSRLeaderboard(query); + const rowCount = Array.isArray(data) ? data.length : data ? 1 : 0; + wideEvent.outcome = "success"; + wideEvent.status_code = 200; + wideEvent.rows = rowCount; + + return Response.json({ success: true, data }); + } catch (error) { + wideEvent.outcome = "error"; + wideEvent.status_code = 500; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + return new Response("Internal Server Error", { status: 500 }); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/leaderboard/tsr/route.ts b/src/app/api/leaderboard/tsr/route.ts new file mode 100644 index 000000000..241826429 --- /dev/null +++ b/src/app/api/leaderboard/tsr/route.ts @@ -0,0 +1,75 @@ +import { Logger } from "@/lib/logger"; +import { + type TsrLeaderboardQuery, + type TsrSortKey, + queryTsrLeaderboard, +} from "@/lib/tsr/leaderboard"; +import { FaceitTier, TsrRegion } from "@/generated/prisma/browser"; + +export const runtime = "nodejs"; + +const VALID_SORTS: TsrSortKey[] = ["rating", "matches", "recent"]; + +function parseEnum( + value: string | null, + values: readonly T[] +): T | undefined { + if (!value) return undefined; + return values.includes(value as T) ? (value as T) : undefined; +} + +function parseInt32(value: string | null, fallback: number): number { + if (!value) return fallback; + const n = Number.parseInt(value, 10); + return Number.isFinite(n) && n >= 0 ? n : fallback; +} + +export async function GET(req: Request): Promise { + const startTime = Date.now(); + const url = new URL(req.url); + const wideEvent: Record = { + event: "tsr.leaderboard.query", + method: "GET", + path: "/api/leaderboard/tsr", + timestamp: new Date().toISOString(), + }; + + try { + const query: TsrLeaderboardQuery = { + region: parseEnum( + url.searchParams.get("region"), + Object.values(TsrRegion) + ), + tier: parseEnum(url.searchParams.get("tier"), Object.values(FaceitTier)), + sort: parseEnum(url.searchParams.get("sort"), VALID_SORTS) ?? "rating", + q: url.searchParams.get("q") ?? undefined, + offset: parseInt32(url.searchParams.get("offset"), 0), + limit: parseInt32(url.searchParams.get("limit"), 50), + }; + wideEvent.region = query.region ?? "any"; + wideEvent.tier = query.tier ?? "any"; + wideEvent.sort = query.sort; + wideEvent.has_search = !!query.q; + wideEvent.offset = query.offset; + wideEvent.limit = query.limit; + + const snapshot = await queryTsrLeaderboard(query); + wideEvent.outcome = "success"; + wideEvent.status_code = 200; + wideEvent.rows = snapshot.rows.length; + wideEvent.matched_count = snapshot.meta.matchedCount; + + return Response.json(snapshot); + } catch (error) { + wideEvent.outcome = "error"; + wideEvent.status_code = 500; + wideEvent.error = { + message: error instanceof Error ? error.message : "Unknown error", + type: error instanceof Error ? error.name : "Error", + }; + return new Response("Internal Server Error", { status: 500 }); + } finally { + wideEvent.duration_ms = Date.now() - startTime; + Logger.info(wideEvent); + } +} diff --git a/src/app/api/matchmaker/send/route.ts b/src/app/api/matchmaker/send/route.ts new file mode 100644 index 000000000..b5facca16 --- /dev/null +++ b/src/app/api/matchmaker/send/route.ts @@ -0,0 +1,88 @@ +import { Effect } from "effect"; +import { auth } from "@/lib/auth"; +import { sendScrimRequest } from "@/lib/matchmaker/send"; +import { Logger } from "@/lib/logger"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { z } from "zod"; +import { unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; + +const bodySchema = z.object({ + fromTeamId: z.number().int().positive(), + toTeamId: z.number().int().positive(), +}); + +export async function POST(request: NextRequest) { + const startTime = Date.now(); + const event: Record = { + route: "POST /api/matchmaker/send", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session) { + event.outcome = "unauthorized"; + event.status_code = 401; + unauthorized(); + } + + const parsed = bodySchema.safeParse(await request.json()); + if (!parsed.success) { + event.outcome = "bad_request"; + event.status_code = 400; + return Response.json({ error: "Invalid request body" }, { status: 400 }); + } + + if (parsed.data.fromTeamId === parsed.data.toTeamId) { + event.outcome = "self_target"; + event.status_code = 400; + return Response.json( + { error: "Cannot send a scrim request to your own team" }, + { status: 400 } + ); + } + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) { + event.outcome = "user_not_found"; + event.status_code = 401; + return Response.json({ error: "User not found" }, { status: 401 }); + } + event.user_id = user.id; + event.from_team_id = parsed.data.fromTeamId; + event.to_team_id = parsed.data.toTeamId; + + const result = await sendScrimRequest({ + fromTeamId: parsed.data.fromTeamId, + toTeamId: parsed.data.toTeamId, + sentByUserId: user.id, + }); + + if (!result.ok) { + event.outcome = "rejected"; + event.status_code = result.status; + event.reason = result.reason; + return Response.json({ error: result.reason }, { status: result.status }); + } + + event.outcome = "success"; + event.status_code = 200; + event.request_id = result.requestId; + return Response.json({ requestId: result.requestId }); + } catch (error) { + event.outcome = "error"; + event.status_code = 500; + event.error = { + message: error instanceof Error ? error.message : String(error), + type: error instanceof Error ? error.name : "UnknownError", + }; + throw error; + } finally { + event.duration_ms = Date.now() - startTime; + Logger.info(event); + } +} diff --git a/src/app/api/notifications/route.ts b/src/app/api/notifications/route.ts index 9e7151f5b..99e593bca 100644 --- a/src/app/api/notifications/route.ts +++ b/src/app/api/notifications/route.ts @@ -1,10 +1,9 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, isAdminUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { notifications } from "@/lib/notifications"; -import { getSession } from "next-auth/react"; import { notFound, unauthorized } from "next/navigation"; import type { NextRequest } from "next/server"; import { z } from "zod"; @@ -63,8 +62,8 @@ export async function GET(request: NextRequest) { } export async function POST(request: NextRequest) { - const session = await getSession(); - if (!session) unauthorized(); + const session = await auth(); + if (!session?.user?.email) unauthorized(); try { const body = createNotificationSchema.safeParse(await request.json()); @@ -72,30 +71,14 @@ export async function POST(request: NextRequest) { return new Response("Invalid request body", { status: 400 }); } - // Determine the target user ID - let targetUserId: string; - - if (body.data.userId) { - // Explicit user ID provided - verify session user has permission - const sessionUser = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser(session.user.email)) - ) - ); - if (!sessionUser) unauthorized(); - - // For now, allow any authenticated user to create notifications for any user - // TODO: Add role-based checks here - targetUserId = body.data.userId; - } else { - // Fallback to session user (backward compatibility) - const sessionUser = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser(session.user.email)) - ) - ); - if (!sessionUser) unauthorized(); - targetUserId = sessionUser.id; + const sessionUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!sessionUser) unauthorized(); + + const targetUserId = body.data.userId ?? sessionUser.id; + if (targetUserId !== sessionUser.id && !isAdminUser(sessionUser)) { + return new Response("Forbidden", { status: 403 }); } const notification = await notifications.createInAppNotification({ diff --git a/src/app/api/player/csr-radar/route.ts b/src/app/api/player/csr-radar/route.ts new file mode 100644 index 000000000..5a815271a --- /dev/null +++ b/src/app/api/player/csr-radar/route.ts @@ -0,0 +1,71 @@ +import { auth } from "@/lib/auth"; +import { getCompositeSRLeaderboard } from "@/lib/hero-rating"; +import { type HeroName, heroRoleMapping } from "@/types/heroes"; +import { unauthorized } from "next/navigation"; +import { NextResponse, type NextRequest } from "next/server"; +import z from "zod"; + +const QuerySchema = z.object({ + hero: z.string().min(1), + player: z.string().min(1).normalize("NFD"), +}); + +type LeaderboardRow = + Awaited> extends (infer U)[] + ? U + : never; + +function serializeRow(row: LeaderboardRow) { + return { + composite_sr: Number(row.composite_sr), + player_name: row.player_name, + rank: Number(row.rank), + role: row.role, + percentile: row.percentile, + elims_per10: + row.elims_per10 !== undefined ? Number(row.elims_per10) : undefined, + fb_per10: row.fb_per10 !== undefined ? Number(row.fb_per10) : undefined, + deaths_per10: Number(row.deaths_per10), + damage_per10: Number(row.damage_per10), + healing_per10: + row.healing_per10 !== undefined ? Number(row.healing_per10) : undefined, + blocked_per10: + row.blocked_per10 !== undefined ? Number(row.blocked_per10) : undefined, + solo_per10: + row.solo_per10 !== undefined ? Number(row.solo_per10) : undefined, + ults_per10: + row.ults_per10 !== undefined ? Number(row.ults_per10) : undefined, + }; +} + +export async function GET(request: NextRequest) { + const session = await auth(); + if (!session) unauthorized(); + + const parsed = QuerySchema.safeParse({ + hero: request.nextUrl.searchParams.get("hero"), + player: request.nextUrl.searchParams.get("player"), + }); + if (!parsed.success) { + return new Response("Bad request", { status: 400 }); + } + + const { hero, player } = parsed.data; + if (!Object.keys(heroRoleMapping).includes(hero)) { + return new Response("Unknown hero", { status: 400 }); + } + + const leaderboard = await getCompositeSRLeaderboard({ + hero: hero as HeroName, + limit: 10000, + }); + + const playerRow = leaderboard.find( + (row) => row.player_name.toLowerCase() === player.toLowerCase() + ); + + return NextResponse.json({ + player: playerRow ? serializeRow(playerRow) : null, + leaderboard: leaderboard.map(serializeRow), + }); +} diff --git a/src/app/api/player/stats/route.ts b/src/app/api/player/stats/route.ts index 769b32329..de746bc74 100644 --- a/src/app/api/player/stats/route.ts +++ b/src/app/api/player/stats/route.ts @@ -2,7 +2,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { ScrimService } from "@/data/scrim"; -import { auth } from "@/lib/auth"; +import { auth, getViewableScrimIds } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; @@ -48,7 +48,10 @@ export async function GET(request: NextRequest) { distinct: ["scrimId"], }); - const scrimIds = playerScrims.map((scrim) => scrim.scrimId); + const scrimIds = await getViewableScrimIds( + playerScrims.map((scrim) => scrim.scrimId), + user + ); const allScrims = await prisma.scrim.findMany({ where: { id: { in: scrimIds } }, @@ -98,6 +101,16 @@ export async function GET(request: NextRequest) { : "one-month"; const permittedScrimIds = data[permitted].map((scrim) => scrim.id); + const responseScrims = { + "one-week": data["one-week"], + "two-weeks": data["two-weeks"], + "one-month": data["one-month"], + "three-months": timeframe2 || timeframe3 ? data["three-months"] : [], + "six-months": timeframe2 || timeframe3 ? data["six-months"] : [], + "one-year": timeframe3 ? data["one-year"] : [], + "all-time": timeframe3 ? data["all-time"] : [], + custom: [], + }; try { const { allPlayerStats, allPlayerKills, mapWinrates, allPlayerDeaths } = @@ -133,7 +146,7 @@ export async function GET(request: NextRequest) { success: true, data: { playerName: name, - scrims: data, + scrims: responseScrims, stats: allPlayerStats, kills: allPlayerKills, mapWinrates, diff --git a/src/app/api/player/top-3-heroes/route.ts b/src/app/api/player/top-3-heroes/route.ts index 82f351ce7..7a6de9003 100644 --- a/src/app/api/player/top-3-heroes/route.ts +++ b/src/app/api/player/top-3-heroes/route.ts @@ -2,15 +2,15 @@ import { auth } from "@/lib/auth"; import { getCompositeSRLeaderboard } from "@/lib/hero-rating"; import prisma from "@/lib/prisma"; import type { HeroName } from "@/types/heroes"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; import { NextResponse, type NextRequest } from "next/server"; import z from "zod"; -type Top3Heroes = { +type Top3Hero = { player_hero: string; total_time_played: number; -}[]; +}; const PlayerSchema = z.string().min(1).normalize("NFD"); @@ -24,28 +24,15 @@ export async function GET(request: NextRequest) { const validPlayer = PlayerSchema.safeParse(player); if (!validPlayer.success) return new Response("Bad request", { status: 400 }); - const top3Heroes = await prisma.$queryRaw` - WITH final_rows AS ( - SELECT DISTINCT ON ("MapDataId", player_name, player_hero) - player_hero, - hero_time_played - FROM - "PlayerStat" - WHERE - player_name ILIKE ${validPlayer.data} - AND hero_time_played > 0 - ORDER BY - "MapDataId", - player_name, - player_hero, - round_number DESC, - id DESC - ) + const top3Heroes = await prisma.$queryRaw` SELECT player_hero, SUM(hero_time_played) AS total_time_played FROM - final_rows + "PlayerStat" + WHERE + player_name = ${validPlayer.data} + AND hero_time_played > 0 GROUP BY player_hero ORDER BY @@ -66,7 +53,7 @@ export async function GET(request: NextRequest) { const mapsPlayed = await prisma.playerStat.groupBy({ by: ["MapDataId"], where: { - player_name: validPlayer.data, + player_name: { equals: validPlayer.data }, player_hero: hero.player_hero as HeroName, hero_time_played: { gt: 60, diff --git a/src/app/api/replay/ghost/route.ts b/src/app/api/replay/ghost/route.ts new file mode 100644 index 000000000..3b4602bba --- /dev/null +++ b/src/app/api/replay/ghost/route.ts @@ -0,0 +1,43 @@ +import { ReplayService } from "@/data/map/replay"; +import { AppRuntime } from "@/data/runtime"; +import { auth, canViewMapData, getCurrentUser } from "@/lib/auth"; +import { resolveMapDataId } from "@/lib/map-data-resolver"; +import { Effect } from "effect"; +import { NextResponse } from "next/server"; + +export async function GET(request: Request) { + const session = await auth(); + if (!session) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }); + } + + const { searchParams } = new URL(request.url); + const mapDataIdParam = searchParams.get("mapDataId"); + if (!mapDataIdParam || Number.isNaN(parseInt(mapDataIdParam))) { + return NextResponse.json({ error: "Missing mapDataId" }, { status: 400 }); + } + + const id = await resolveMapDataId(parseInt(mapDataIdParam)); + const user = await getCurrentUser(); + if (!(await canViewMapData(id, user))) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + const data = await AppRuntime.runPromise( + ReplayService.pipe(Effect.flatMap((svc) => svc.getReplayData(id))) + ); + + if (data.type !== "ready") { + return NextResponse.json({ error: data.type }, { status: 422 }); + } + + // Slim payload: the ghost reuses the PRIMARY's calibration (same map), + // so we strip calibration/presigned URLs entirely. + return NextResponse.json({ + positionSamples: data.positionSamples, + displayEvents: data.displayEvents, + roundStarts: data.calibration.roundStarts, + team1Name: data.team1Name, + team2Name: data.team2Name, + }); +} diff --git a/src/app/api/reporting/submit-bug-report/route.ts b/src/app/api/reporting/submit-bug-report/route.ts index d6c777637..1219d2669 100644 --- a/src/app/api/reporting/submit-bug-report/route.ts +++ b/src/app/api/reporting/submit-bug-report/route.ts @@ -2,36 +2,66 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; import { newBugReportWebhookConstructor, sendDiscordWebhook, } from "@/lib/webhooks"; +import { Ratelimit } from "@upstash/ratelimit"; +import { ipAddress } from "@vercel/functions"; +import { kv } from "@vercel/kv"; +import { checkBotId } from "botid/server"; import { after, type NextRequest, userAgent } from "next/server"; import { z } from "zod"; const BugReportSchema = z.object({ - title: z.string().min(1), - description: z.string().min(1), - email: z.email(), - url: z.string().min(1), + title: z.string().min(1).max(120), + description: z.string().min(1).max(4000), + email: z.email().max(254), + url: z.string().min(1).max(2048), +}); + +const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(3, "1 m"), + analytics: true, + prefix: "ratelimit:bug-report", }); export async function POST(req: NextRequest) { + const verification = await checkBotId(); + if (verification.isBot) { + return new Response("Access denied", { status: 403 }); + } + + const identifier = ipAddress(req) ?? "127.0.0.1"; + const { success } = await ratelimit.limit(identifier); + if (!success) { + Logger.warn("Rate limit exceeded for bug report", { ip: identifier }); + return new Response("Rate limit exceeded", { status: 429 }); + } + const ua = userAgent(req); const body = BugReportSchema.safeParse(await req.json()); if (!body.success) return new Response("Invalid request", { status: 400 }); - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(body.data.email))) - ); + const session = await auth(); + const reporterEmail = session?.user?.email ?? body.data.email; + const isAuthedReport = Boolean(session?.user?.email); + const user = isAuthedReport + ? await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(reporterEmail))) + ) + : null; const wh = newBugReportWebhookConstructor( body.data.title, body.data.description, - body.data.email, + reporterEmail, body.data.url, - user?.billingPlan ?? "N/A", + isAuthedReport ? (user?.billingPlan ?? "N/A") : "N/A", ua.ua, ua.browser, ua.os, @@ -42,7 +72,7 @@ export async function POST(req: NextRequest) { after(async () => { await auditLog.createAuditLog({ - userEmail: user?.email ?? "Unknown", + userEmail: isAuthedReport ? (user?.email ?? reporterEmail) : "Anonymous", action: "BUG_REPORT_SUBMITTED", target: body.data.title, details: `Bug report submitted: ${body.data.title}`, diff --git a/src/app/api/reports/send-usage-report/route.ts b/src/app/api/reports/send-usage-report/route.ts index efa037857..903daa0fa 100644 --- a/src/app/api/reports/send-usage-report/route.ts +++ b/src/app/api/reports/send-usage-report/route.ts @@ -3,6 +3,7 @@ import { email } from "@/lib/email"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { render } from "@react-email/render"; +import { timingSafeEqual } from "node:crypto"; function formatDate(date: Date): string { return date.toLocaleDateString("en-US", { @@ -12,10 +13,35 @@ function formatDate(date: Date): string { }); } -export async function GET(request: Request) { +function isAuthorizedCronRequest(request: Request) { + const expected = process.env.CRON_SECRET; + if (!expected) return { ok: false, status: 500 }; + const authHeader = request.headers.get("Authorization"); - if (authHeader !== `Bearer ${process.env.CRON_SECRET}`) { - return new Response("Unauthorized", { status: 401 }); + const provided = authHeader?.startsWith("Bearer ") + ? authHeader.slice(7) + : null; + if (!provided || provided.length !== expected.length) { + return { ok: false, status: 401 }; + } + + try { + return { + ok: timingSafeEqual(Buffer.from(provided), Buffer.from(expected)), + status: 401, + }; + } catch { + return { ok: false, status: 401 }; + } +} + +export async function GET(request: Request) { + const auth = isAuthorizedCronRequest(request); + if (!auth.ok) { + return new Response( + auth.status === 500 ? "Server misconfigured" : "Unauthorized", + { status: auth.status } + ); } const now = new Date(); diff --git a/src/app/api/scrim/add-map-stream/route.ts b/src/app/api/scrim/add-map-stream/route.ts new file mode 100644 index 000000000..787fe23c1 --- /dev/null +++ b/src/app/api/scrim/add-map-stream/route.ts @@ -0,0 +1,145 @@ +import { auditLog } from "@/lib/audit-logs"; +import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; +import { mapAddedCounter, scrimParsingDuration } from "@/lib/axiom/metrics"; +import { Logger } from "@/lib/logger"; +import { createNewMap } from "@/lib/parser"; +import prisma from "@/lib/prisma"; +import { createNdjsonStream } from "@/lib/progress-stream"; +import { normalizeMapForScrim } from "@/lib/team-normalization"; +import { resolveSetWinnerOutcome } from "@/lib/scrim/set-winner-validation"; +import { UsageEventName } from "@/lib/usage/names"; +import { usage } from "@/lib/usage/server"; +import { track } from "@vercel/analytics/server"; +import type { NextRequest } from "next/server"; +import type { AddMapRequestData } from "../add-map/route"; + +/** + * Streaming variant of POST /api/scrim/add-map. Behaves identically up to the + * point of insertion, then returns an NDJSON progress stream instead of a flat + * "OK". The non-streaming route is kept intact as a fallback; reverting this + * feature is just pointing the client back at it. + */ +export async function POST(req: NextRequest) { + const startTime = Date.now(); + const event: Record = { + route: "POST /api/scrim/add-map-stream", + timestamp: new Date().toISOString(), + }; + + const session = await auth(); + const id = req.nextUrl.searchParams.get("id") ?? ""; + const data = (await req.json()) as AddMapRequestData; + + if (!session?.user?.email) { + return new Response("Unauthorized", { status: 401 }); + } + + const scrimId = parseInt(id); + if (!Number.isInteger(scrimId)) { + return new Response("Invalid scrim ID", { status: 400 }); + } + event.scrim_id = scrimId; + + if ( + data.winnerSource != null && + data.winnerSource !== "auto_coords" && + data.winnerSource !== "manual" + ) { + return new Response("Invalid winner source", { status: 400 }); + } + + const user = await getCurrentUser(); + if (!(await canEditScrim(scrimId, user))) { + return new Response("Forbidden", { status: 403 }); + } + + const scrim = await prisma.scrim.findUnique({ + where: { id: scrimId }, + select: { + autoAssignTeamNames: true, + team1Name: true, + team2Name: true, + teamId: true, + }, + }); + + // Validate the chosen winner against the RAW uploaded team names before any + // normalization renames them. + if (data.winner) { + const winnerOutcome = resolveSetWinnerOutcome(data.winner, { + team1: String(data.map.match_start[0][4]), + team2: String(data.map.match_start[0][5]), + }); + if (!winnerOutcome.ok) { + return new Response(winnerOutcome.error, { status: 400 }); + } + } + + let mapData = data.map; + let mapWinner = data.winner ?? null; + if (scrim?.autoAssignTeamNames && scrim.teamId && scrim.team1Name) { + const result = await normalizeMapForScrim( + mapData, + scrim.teamId, + scrim.team1Name, + scrim.team2Name, + data.winner ?? null + ); + mapData = result.map; + mapWinner = result.winner; + } + + return createNdjsonStream(async (emit) => { + try { + const parseStart = performance.now(); + const result = await createNewMap( + { + map: mapData, + scrimId, + order: data.order, + heroBans: data.heroBans, + winner: mapWinner, + winnerSource: data.winnerSource ?? null, + }, + session, + (completed, total) => emit({ type: "progress", completed, total }) + ); + const parseDuration = performance.now() - parseStart; + scrimParsingDuration.record(parseDuration); + mapAddedCounter.add(1); + void usage.track({ + name: UsageEventName.SCRIM_MAP_ADD, + userId: user?.id, + teamId: scrim?.teamId, + }); + + emit({ type: "done", mapId: result.mapId }); + event.outcome = "success"; + event.parse_duration_ms = Math.round(parseDuration); + + try { + await Promise.all([ + track("Create Map", { user: session.user.email }), + auditLog.createAuditLog({ + userEmail: session.user.email, + action: "MAP_CREATED", + target: id, + details: `Map created: ${id}`, + }), + ]); + } catch { + // Analytics/audit are best-effort; the map is already persisted. + } + } catch (e) { + event.outcome = "error"; + event.error = e instanceof Error ? e.message : String(e); + emit({ + type: "error", + message: e instanceof Error ? e.message : "Internal Server Error", + }); + } finally { + event.duration_ms = Date.now() - startTime; + Logger.info(event); + } + }); +} diff --git a/src/app/api/scrim/add-map/route.ts b/src/app/api/scrim/add-map/route.ts index f94a79100..95f8ed6d4 100644 --- a/src/app/api/scrim/add-map/route.ts +++ b/src/app/api/scrim/add-map/route.ts @@ -1,22 +1,28 @@ import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { mapAddedCounter, scrimParsingDuration } from "@/lib/axiom/metrics"; import { Logger } from "@/lib/logger"; import { createNewMap } from "@/lib/parser"; import prisma from "@/lib/prisma"; import { normalizeMapForScrim } from "@/lib/team-normalization"; +import { resolveSetWinnerOutcome } from "@/lib/scrim/set-winner-validation"; +import { UsageEventName } from "@/lib/usage/names"; +import { usage } from "@/lib/usage/server"; import type { ParserData } from "@/types/parser"; +import type { UploadWinnerSource } from "@/lib/winner-source"; import { track } from "@vercel/analytics/server"; -import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; export type AddMapRequestData = { map: ParserData; + order?: number; heroBans?: { hero: string; team: string; banPosition: number; }[]; + winner?: string | null; + winnerSource?: UploadWinnerSource | null; }; export async function POST(req: NextRequest) { @@ -36,18 +42,40 @@ export async function POST(req: NextRequest) { event.has_hero_bans = (data.heroBans?.length ?? 0) > 0; event.hero_ban_count = data.heroBans?.length ?? 0; + if ( + data.winnerSource != null && + data.winnerSource !== "auto_coords" && + data.winnerSource !== "manual" + ) { + event.outcome = "invalid_winner_source"; + event.status_code = 400; + return new Response("Invalid winner source", { status: 400 }); + } + if (!session?.user?.email) { event.outcome = "unauthorized"; event.status_code = 401; - unauthorized(); + return new Response("Unauthorized", { status: 401 }); } event.user_email = session.user.email; const scrimId = parseInt(id); + if (!Number.isInteger(scrimId)) { + event.outcome = "invalid_scrim_id"; + event.status_code = 400; + return new Response("Invalid scrim ID", { status: 400 }); + } event.scrim_id = scrimId; let mapData = data.map; + const user = await getCurrentUser(); + if (!(await canEditScrim(scrimId, user))) { + event.outcome = "forbidden"; + event.status_code = 403; + return new Response("Forbidden", { status: 403 }); + } + const scrim = await prisma.scrim.findUnique({ where: { id: scrimId }, select: { @@ -61,14 +89,32 @@ export async function POST(req: NextRequest) { event.team_id = scrim?.teamId; event.auto_assign_team_names = scrim?.autoAssignTeamNames ?? false; + // Validate the chosen winner against the RAW uploaded team names before any + // normalization renames them. + if (data.winner) { + const winnerOutcome = resolveSetWinnerOutcome(data.winner, { + team1: String(data.map.match_start[0][4]), + team2: String(data.map.match_start[0][5]), + }); + if (!winnerOutcome.ok) { + event.outcome = "invalid_winner"; + event.status_code = 400; + return new Response(winnerOutcome.error, { status: 400 }); + } + } + + let mapWinner = data.winner ?? null; if (scrim?.autoAssignTeamNames && scrim.teamId && scrim.team1Name) { event.normalized_teams = true; - mapData = await normalizeMapForScrim( + const result = await normalizeMapForScrim( mapData, scrim.teamId, scrim.team1Name, - scrim.team2Name + scrim.team2Name, + data.winner ?? null ); + mapData = result.map; + mapWinner = result.winner; } const parseStart = performance.now(); @@ -76,13 +122,21 @@ export async function POST(req: NextRequest) { { map: mapData, scrimId, + order: data.order, heroBans: data.heroBans, + winner: mapWinner, + winnerSource: data.winnerSource ?? null, }, session ); const parseDuration = performance.now() - parseStart; scrimParsingDuration.record(parseDuration); mapAddedCounter.add(1); + void usage.track({ + name: UsageEventName.SCRIM_MAP_ADD, + userId: user?.id, + teamId: scrim?.teamId, + }); event.parse_duration_ms = Math.round(parseDuration); event.outcome = "success"; diff --git a/src/app/api/scrim/create-scrim-stream/route.ts b/src/app/api/scrim/create-scrim-stream/route.ts new file mode 100644 index 000000000..29d216fff --- /dev/null +++ b/src/app/api/scrim/create-scrim-stream/route.ts @@ -0,0 +1,193 @@ +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auditLog } from "@/lib/audit-logs"; +import { auth, canManageTeam } from "@/lib/auth"; +import { + rateLimitHitCounter, + scrimCreatedCounter, + scrimParsingDuration, +} from "@/lib/axiom/metrics"; +import { UsageEventName } from "@/lib/usage/names"; +import { usage } from "@/lib/usage/server"; +import { sendScrimNotifications } from "@/lib/bot-events"; +import { Logger } from "@/lib/logger"; +import { createNewScrimFromParsedData } from "@/lib/parser"; +import { Permission } from "@/lib/permissions"; +import { createNdjsonStream } from "@/lib/progress-stream"; +import { normalizeMapForScrim } from "@/lib/team-normalization"; +import { resolveSetWinnerOutcome } from "@/lib/scrim/set-winner-validation"; +import { resolveScrimLink } from "@/lib/team-ops/scrim-feedback"; +import prisma from "@/lib/prisma"; +import { Ratelimit } from "@upstash/ratelimit"; +import { ipAddress } from "@vercel/functions"; +import { kv } from "@vercel/kv"; +import { Effect } from "effect"; +import { unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; +import { createScrimSchema } from "../create-scrim/route"; + +/** + * Streaming variant of POST /api/scrim/create-scrim. Runs the same guards + * (auth, rate limit, validation, permission, normalization) and then streams + * NDJSON progress while the first map's rows insert, finishing with a "done" + * event that carries the new scrim id. The non-streaming route stays intact as + * a fallback. + */ +export async function POST(request: NextRequest) { + const startTime = Date.now(); + const event: Record = { + route: "POST /api/scrim/create-scrim-stream", + timestamp: new Date().toISOString(), + }; + + const session = await auth(); + if (!session) { + unauthorized(); + } + + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(5, "1 m"), + analytics: true, + }); + const identifier = ipAddress(request) ?? "127.0.0.1"; + const { success } = await ratelimit.limit(identifier); + if (!success) { + rateLimitHitCounter.add(1, { endpoint: "scrim.create" }); + return new Response("Rate limit exceeded", { status: 429 }); + } + + const parsed = createScrimSchema.safeParse(await request.json()); + if (!parsed.success) { + return Response.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 } + ); + } + const data = parsed.data; + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) { + return new Response("User not found", { status: 404 }); + } + + const canCreateScrim = await new Permission("create-scrim").check(); + if (!canCreateScrim) { + return new Response("Forbidden", { status: 403 }); + } + + const parsedTeamId = Number(data.team); + const teamId = parsedTeamId === 0 ? null : parsedTeamId; + event.team_id = teamId; + if (teamId && !(await canManageTeam(teamId, user))) { + return new Response("Forbidden", { status: 403 }); + } + + // Validate the chosen winner against the RAW uploaded team names before any + // normalization renames them. + if (data.winner) { + const winnerOutcome = resolveSetWinnerOutcome(data.winner, { + team1: String(data.map.match_start[0][4]), + team2: String(data.map.match_start[0][5]), + }); + if (!winnerOutcome.ok) { + return Response.json({ error: winnerOutcome.error }, { status: 400 }); + } + } + + if (data.autoAssignTeamNames && teamId && data.team1Name) { + const result = await normalizeMapForScrim( + data.map, + teamId, + data.team1Name, + data.team2Name ?? null, + data.winner ?? null + ); + data.map = result.map; + data.winner = result.winner; + } + + return createNdjsonStream(async (emit) => { + try { + const parseStart = performance.now(); + const newScrimId = await createNewScrimFromParsedData( + data, + session, + (completed, total) => emit({ type: "progress", completed, total }) + ); + const parseDuration = performance.now() - parseStart; + scrimParsingDuration.record(parseDuration); + scrimCreatedCounter.add(1); + void usage.track({ + name: UsageEventName.SCRIM_CREATE, + userId: user.id, + teamId, + }); + + // Linking is best-effort: the scrim is already created, so a failed link + // update must not surface as a creation error (which would prompt a retry + // and a duplicate scrim). + if (teamId && data.scrimRequestId && data.opponentTeamId) { + try { + const link = await resolveScrimLink({ + teamId, + scrimRequestId: data.scrimRequestId, + opponentTeamId: data.opponentTeamId, + }); + if (link) { + await prisma.scrim.update({ + where: { id: newScrimId }, + data: { + scrimRequestId: link.scrimRequestId, + opponentTeamId: link.opponentTeamId, + }, + }); + event.linked_request = true; + } + } catch (linkErr) { + event.link_error = + linkErr instanceof Error ? linkErr.message : String(linkErr); + } + } + + emit({ type: "done", scrimId: newScrimId }); + event.outcome = "success"; + event.scrim_id = newScrimId; + event.parse_duration_ms = Math.round(parseDuration); + + try { + await auditLog.createAuditLog({ + userEmail: session.user.email, + action: "SCRIM_CREATED", + target: `${data.name} (Team: ${data.team})`, + details: `Scrim created: ${data.name}`, + }); + if (teamId) { + await sendScrimNotifications(teamId, { + event: "scrim.created", + data: { + scrimName: data.name, + scrimId: newScrimId, + createdBy: session.user.email, + teamId, + }, + }); + } + } catch { + // Audit/notifications are best-effort; the scrim is already created. + } + } catch (e) { + event.outcome = "error"; + event.error = e instanceof Error ? e.message : String(e); + emit({ + type: "error", + message: e instanceof Error ? e.message : "Internal Server Error", + }); + } finally { + event.duration_ms = Date.now() - startTime; + Logger.info(event); + } + }); +} diff --git a/src/app/api/scrim/create-scrim/route.ts b/src/app/api/scrim/create-scrim/route.ts index 7e3149939..75b88bebd 100644 --- a/src/app/api/scrim/create-scrim/route.ts +++ b/src/app/api/scrim/create-scrim/route.ts @@ -2,28 +2,36 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; -import { setRequestContext } from "@/lib/axiom/baggage"; +import { auth, canManageTeam } from "@/lib/auth"; +import { withRequestContext } from "@/lib/axiom/baggage"; import { rateLimitHitCounter, scrimCreatedCounter, scrimParsingDuration, } from "@/lib/axiom/metrics"; +import { UsageEventName } from "@/lib/usage/names"; +import { usage } from "@/lib/usage/server"; import { sendScrimNotifications } from "@/lib/bot-events"; import { Logger } from "@/lib/logger"; import { createNewScrimFromParsedData } from "@/lib/parser"; +import { Permission } from "@/lib/permissions"; import { normalizeMapForScrim } from "@/lib/team-normalization"; +import prisma from "@/lib/prisma"; +import { resolveScrimLink } from "@/lib/team-ops/scrim-feedback"; +import { resolveSetWinnerOutcome } from "@/lib/scrim/set-winner-validation"; import { newSuspiciousActivityWebhookConstructor, sendDiscordWebhook, } from "@/lib/webhooks"; import type { ParserData } from "@/types/parser"; -import type { User } from "@prisma/client"; +import type { UploadWinnerSource } from "@/lib/winner-source"; +import type { User } from "@/generated/prisma/client"; import { Ratelimit } from "@upstash/ratelimit"; import { ipAddress } from "@vercel/functions"; import { kv } from "@vercel/kv"; import { unauthorized } from "next/navigation"; import { after, type NextRequest, userAgent } from "next/server"; +import { z } from "zod"; export type CreateScrimRequestData = { name: string; @@ -40,8 +48,55 @@ export type CreateScrimRequestData = { team: string; banPosition: number; }[]; + winner?: string | null; + winnerSource?: UploadWinnerSource | null; + scrimRequestId?: string | null; + opponentTeamId?: number | null; }; +const parserDataSchema = z.custom((value) => { + if (typeof value !== "object" || value === null || Array.isArray(value)) { + return false; + } + + const map = value as Record; + return ( + Array.isArray(map.match_start) && + Array.isArray(map.round_end) && + Array.isArray(map.player_stat) + ); +}); + +export const createScrimSchema = z.object({ + name: z.string().min(1).max(100), + team: z + .string() + .regex(/^\d+$/) + .refine((value) => Number.isSafeInteger(Number(value)), { + message: "Team ID is too large", + }), + date: z.string().min(1), + map: parserDataSchema, + replayCode: z.string().max(100), + opponentTeamAbbr: z.string().max(20).nullable().optional(), + autoAssignTeamNames: z.boolean().optional(), + team1Name: z.string().max(100).nullable().optional(), + team2Name: z.string().max(100).nullable().optional(), + heroBans: z + .array( + z.object({ + hero: z.string().min(1).max(100), + team: z.string().min(1).max(100), + banPosition: z.number().int().min(0), + }) + ) + .default([]), + winner: z.string().min(1).max(100).nullable().optional(), + winnerSource: z.enum(["auto_coords", "manual"]).nullable().optional(), + scrimRequestId: z.string().min(1).nullable().optional(), + opponentTeamId: z.number().int().positive().nullable().optional(), +}); + export async function POST(request: NextRequest) { const startTime = Date.now(); const event: Record = { @@ -117,7 +172,17 @@ export async function POST(request: NextRequest) { return new Response("Rate limit exceeded", { status: 429 }); } - const data = (await request.json()) as CreateScrimRequestData; + const parsed = createScrimSchema.safeParse(await request.json()); + if (!parsed.success) { + event.outcome = "validation_error"; + event.status_code = 400; + return Response.json( + { error: "Invalid request", details: parsed.error.flatten() }, + { status: 400 } + ); + } + + const data = parsed.data; event.scrim_name = data.name; event.team_id_raw = data.team; event.has_hero_bans = data.heroBans.length > 0; @@ -136,58 +201,129 @@ export async function POST(request: NextRequest) { event.user_id = user?.id; event.billing_plan = user?.billingPlan; - if (user) { - setRequestContext({ - user_id: user.id, - billing_plan: user.billingPlan, - }); + if (!user) { + event.outcome = "user_not_found"; + event.status_code = 404; + return new Response("User not found", { status: 404 }); } - const teamId = parseInt(data.team) === 0 ? null : parseInt(data.team); - event.team_id = teamId; + return await withRequestContext( + { + user_id: user.id, + billing_plan: user.billingPlan, + }, + async () => { + const canCreateScrim = await new Permission("create-scrim").check(); + if (!canCreateScrim) { + event.outcome = "permission_denied"; + event.status_code = 403; + return new Response("Forbidden", { status: 403 }); + } - if (data.autoAssignTeamNames && teamId && data.team1Name) { - event.normalized_teams = true; - data.map = await normalizeMapForScrim( - data.map, - teamId, - data.team1Name, - data.team2Name ?? null - ); - } + const parsedTeamId = Number(data.team); + const teamId = parsedTeamId === 0 ? null : parsedTeamId; + event.team_id = teamId; + if (teamId && !(await canManageTeam(teamId, user))) { + event.outcome = "forbidden_team"; + event.status_code = 403; + return new Response("Forbidden", { status: 403 }); + } - const parseStart = performance.now(); - await createNewScrimFromParsedData(data, session); - const parseDuration = performance.now() - parseStart; - scrimParsingDuration.record(parseDuration); - scrimCreatedCounter.add(1); - - event.parse_duration_ms = Math.round(parseDuration); - event.outcome = "success"; - event.status_code = 200; - - after(async () => { - await auditLog.createAuditLog({ - userEmail: session.user.email, - action: "SCRIM_CREATED", - target: `${data.name} (Team: ${data.team})`, - details: `Scrim created: ${data.name}`, - }); + // Validate the chosen winner against the RAW uploaded team names + // before any normalization renames them. + if (data.winner) { + const winnerOutcome = resolveSetWinnerOutcome(data.winner, { + team1: String(data.map.match_start[0][4]), + team2: String(data.map.match_start[0][5]), + }); + if (!winnerOutcome.ok) { + event.outcome = "invalid_winner"; + event.status_code = 400; + return Response.json( + { error: winnerOutcome.error }, + { status: 400 } + ); + } + } - if (teamId) { - await sendScrimNotifications(teamId, { - event: "scrim.created", - data: { - scrimName: data.name, - scrimId: 0, // Scrim ID not returned from parser - createdBy: session.user.email, + if (data.autoAssignTeamNames && teamId && data.team1Name) { + event.normalized_teams = true; + const result = await normalizeMapForScrim( + data.map, teamId, - }, + data.team1Name, + data.team2Name ?? null, + data.winner ?? null + ); + data.map = result.map; + data.winner = result.winner; + } + + const parseStart = performance.now(); + const newScrimId = await createNewScrimFromParsedData(data, session); + const parseDuration = performance.now() - parseStart; + scrimParsingDuration.record(parseDuration); + scrimCreatedCounter.add(1); + void usage.track({ + name: UsageEventName.SCRIM_CREATE, + userId: user.id, + teamId, }); - } - }); - return new Response("OK", { status: 200 }); + event.parse_duration_ms = Math.round(parseDuration); + event.scrim_id = newScrimId; + event.outcome = "success"; + event.status_code = 200; + + // Linking is best-effort: the scrim is already created, so a failed + // link update must not turn a successful creation into a 500. + if (teamId && data.scrimRequestId && data.opponentTeamId) { + try { + const link = await resolveScrimLink({ + teamId, + scrimRequestId: data.scrimRequestId, + opponentTeamId: data.opponentTeamId, + }); + if (link) { + await prisma.scrim.update({ + where: { id: newScrimId }, + data: { + scrimRequestId: link.scrimRequestId, + opponentTeamId: link.opponentTeamId, + }, + }); + event.linked_request = true; + } + } catch (linkErr) { + event.link_error = + linkErr instanceof Error ? linkErr.message : String(linkErr); + } + } + + after(async () => { + await auditLog.createAuditLog({ + userEmail: session.user.email, + action: "SCRIM_CREATED", + target: `${data.name} (Team: ${data.team})`, + details: `Scrim created: ${data.name}`, + }); + + if (teamId) { + await sendScrimNotifications(teamId, { + event: "scrim.created", + data: { + scrimName: data.name, + scrimId: newScrimId, + createdBy: session.user.email, + teamId, + }, + }); + } + }); + + return Response.json({ scrimId: newScrimId }, { status: 200 }); + } + ); } catch (error) { event.outcome = "error"; event.status_code = 500; diff --git a/src/app/api/scrim/get-scrims/route.ts b/src/app/api/scrim/get-scrims/route.ts index 6184b2d7a..baa9e443c 100644 --- a/src/app/api/scrim/get-scrims/route.ts +++ b/src/app/api/scrim/get-scrims/route.ts @@ -4,11 +4,44 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { $Enums, type Prisma } from "@prisma/client"; +import { $Enums, type Prisma } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; import type { NextRequest } from "next/server"; import { NextResponse } from "next/server"; +type ParsedPositiveInt = + | { ok: true; value: number | undefined } + | { ok: false; response: NextResponse }; + +function parseOptionalPositiveInt( + value: string | null, + name: string +): ParsedPositiveInt { + if (value === null) return { ok: true, value: undefined }; + if (!/^[1-9]\d*$/.test(value)) { + return { + ok: false, + response: NextResponse.json( + { error: `${name} must be a positive integer` }, + { status: 400 } + ), + }; + } + + const parsed = Number(value); + if (!Number.isSafeInteger(parsed)) { + return { + ok: false, + response: NextResponse.json( + { error: `${name} is too large` }, + { status: 400 } + ), + }; + } + + return { ok: true, value: parsed }; +} + export async function GET(req: NextRequest) { const session = await auth(); if (!session?.user?.email) unauthorized(); @@ -19,12 +52,33 @@ export async function GET(req: NextRequest) { if (!userData) unauthorized(); const searchParams = req.nextUrl.searchParams; - const cursor = searchParams.get("cursor"); - const page = searchParams.get("page"); - const limit = Math.min(parseInt(searchParams.get("limit") ?? "15"), 50); // Cap at 50 + const parsedCursor = parseOptionalPositiveInt( + searchParams.get("cursor"), + "cursor" + ); + if (!parsedCursor.ok) return parsedCursor.response; + + const parsedPage = parseOptionalPositiveInt(searchParams.get("page"), "page"); + if (!parsedPage.ok) return parsedPage.response; + + const parsedLimit = parseOptionalPositiveInt( + searchParams.get("limit"), + "limit" + ); + if (!parsedLimit.ok) return parsedLimit.response; + + const parsedTeamId = parseOptionalPositiveInt( + searchParams.get("teamId"), + "teamId" + ); + if (!parsedTeamId.ok) return parsedTeamId.response; + + const cursor = parsedCursor.value; + const page = parsedPage.value; + const limit = Math.min(parsedLimit.value ?? 15, 50); const search = searchParams.get("search") ?? ""; const filter = searchParams.get("filter") ?? ""; - const teamId = searchParams.get("teamId"); + const teamId = parsedTeamId.value; const adminMode = searchParams.get("adminMode") === "true"; const lastPage = searchParams.get("lastPage") === "true"; @@ -41,9 +95,7 @@ export async function GET(req: NextRequest) { }; // Filter by team if teamId is provided - if (teamId) { - whereClause.teamId = parseInt(teamId); - } + if (teamId) whereClause.teamId = teamId; // Apply search filter at database level if (search) { @@ -113,8 +165,7 @@ export async function GET(req: NextRequest) { if (lastPage) { skipValue = Math.max(0, totalCount - limit); } else if (page) { - const pageNum = parseInt(page); - skipValue = Math.max(0, (pageNum - 1) * limit); + skipValue = Math.max(0, (page - 1) * limit); } // Get scrims with pagination @@ -130,7 +181,7 @@ export async function GET(req: NextRequest) { ? { skip: 1, // Skip the cursor cursor: { - id: parseInt(cursor), + id: cursor, }, } : {}), @@ -160,7 +211,7 @@ export async function GET(req: NextRequest) { ? { skip: 1, // Skip the cursor cursor: { - id: parseInt(cursor), + id: cursor, }, } : {}), @@ -214,9 +265,8 @@ export async function GET(req: NextRequest) { if (lastPage) { hasMore = false; } else if (page) { - const pageNum = parseInt(page); const totalPages = Math.ceil(totalCount / limit); - hasMore = pageNum < totalPages; + hasMore = page < totalPages; } else { hasMore = scrims.length === limit; } diff --git a/src/app/api/scrim/map/[mapId]/set-winner/route.ts b/src/app/api/scrim/map/[mapId]/set-winner/route.ts new file mode 100644 index 000000000..f6dedc4ea --- /dev/null +++ b/src/app/api/scrim/map/[mapId]/set-winner/route.ts @@ -0,0 +1,93 @@ +import { auditLog } from "@/lib/audit-logs"; +import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; +import { resolveSetWinnerOutcome } from "@/lib/scrim/set-winner-validation"; +import { unauthorized, unstable_rethrow } from "next/navigation"; +import { after, type NextRequest } from "next/server"; +import { z } from "zod"; + +const bodySchema = z.object({ winner: z.string().min(1).max(100) }); + +export async function POST( + request: NextRequest, + { params }: { params: Promise<{ mapId: string }> } +) { + const event: Record = { + route: "POST /api/scrim/map/[mapId]/set-winner", + timestamp: new Date().toISOString(), + }; + + try { + const session = await auth(); + if (!session?.user?.email) { + event.outcome = "unauthorized"; + unauthorized(); + } + + const mapId = Number((await params).mapId); + if (!Number.isInteger(mapId)) { + return Response.json({ error: "Invalid map id" }, { status: 400 }); + } + event.map_id = mapId; + + const parsed = bodySchema.safeParse(await request.json()); + if (!parsed.success) { + return Response.json({ error: "Invalid request body" }, { status: 400 }); + } + + const map = await prisma.map.findUnique({ + where: { id: mapId }, + select: { id: true, scrimId: true, mapData: { select: { id: true } } }, + }); + if (!map?.scrimId) { + return Response.json({ error: "Map not found" }, { status: 404 }); + } + + const user = await getCurrentUser(); + if (!(await canEditScrim(map.scrimId, user))) { + event.outcome = "forbidden"; + return Response.json({ error: "Forbidden" }, { status: 403 }); + } + + const mapDataIds = map.mapData.map((md) => md.id); + const matchStart = await prisma.matchStart.findFirst({ + where: { MapDataId: { in: mapDataIds } }, + select: { team_1_name: true, team_2_name: true }, + }); + + const outcome = resolveSetWinnerOutcome(parsed.data.winner, { + team1: matchStart?.team_1_name ?? "", + team2: matchStart?.team_2_name ?? "", + }); + if (!outcome.ok) { + event.outcome = "invalid_winner"; + return Response.json({ error: outcome.error }, { status: 400 }); + } + + await prisma.map.update({ + where: { id: mapId }, + data: { winner: parsed.data.winner, winnerSource: "manual" }, + }); + + after(async () => { + await auditLog.createAuditLog({ + userEmail: session.user.email, + action: "MAP_UPDATED", + target: `Scrim ID: ${map.scrimId}`, + details: `Winner set for map ${mapId}: ${parsed.data.winner}`, + }); + }); + + event.outcome = "success"; + event.winner = parsed.data.winner; + Logger.info(event); + return Response.json({ success: true }); + } catch (error) { + unstable_rethrow(error); + event.outcome = "error"; + event.error = error instanceof Error ? error.message : String(error); + Logger.error(event); + return Response.json({ error: "Failed to set winner" }, { status: 500 }); + } +} diff --git a/src/app/api/scrim/remove-map/route.ts b/src/app/api/scrim/remove-map/route.ts index 832c17613..6e3226ed3 100644 --- a/src/app/api/scrim/remove-map/route.ts +++ b/src/app/api/scrim/remove-map/route.ts @@ -1,38 +1,23 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { ScrimService } from "@/data/scrim"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { mapDeletionDuration, mapRemovedCounter } from "@/lib/axiom/metrics"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const params = req.nextUrl.searchParams; const id = params.get("id"); - const token = req.headers.get("Authorization"); const session = await auth(); - if (!session) { - if (token !== process.env.DEV_TOKEN) { - Logger.warn("Unauthorized request to remove map: ", id); - unauthorized(); - } - Logger.log("Authorized removal of map with dev token"); + if (!session?.user?.email) { + Logger.warn("Unauthorized request to remove map: ", id); + unauthorized(); } - const user = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => - svc.getUser(session?.user?.email ?? "lucas@lux.dev") - ) - ) - ); + const user = await getCurrentUser(); if (!user) return new Response("User not found", { status: 404 }); if (!id) return new Response("Missing ID", { status: 400 }); @@ -42,32 +27,10 @@ export async function POST(req: NextRequest) { }); if (!map) return new Response("Map not found", { status: 404 }); - const scrim = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(map.scrimId!))) - ); + const scrim = await prisma.scrim.findUnique({ where: { id: map.scrimId! } }); if (!scrim) return new Response("Scrim not found", { status: 404 }); - let isManager = false; - - if (scrim.teamId !== 0 && scrim.teamId !== null) { - // scrim is associated with a team - const managers = await prisma.team.findFirst({ - where: { id: scrim.teamId ?? 0 }, - select: { managers: true }, - }); - - // check if user is a manager - isManager = - managers?.managers.some((manager) => manager.userId === user.id) ?? false; - } - - const hasPerms = - user.role === $Enums.UserRole.ADMIN || // Admins can delete anything - user.role === $Enums.UserRole.MANAGER || // Managers can delete anything - user.id === scrim.creatorId || // Creators can delete their own maps - isManager; // Managers of the scrim's team can delete the map - - if (!hasPerms) { + if (!(await canEditScrim(scrim.id, user))) { unauthorized(); } diff --git a/src/app/api/scrim/remove-scrim/route.ts b/src/app/api/scrim/remove-scrim/route.ts index 325890500..f5b68e8de 100644 --- a/src/app/api/scrim/remove-scrim/route.ts +++ b/src/app/api/scrim/remove-scrim/route.ts @@ -1,69 +1,32 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { ScrimService } from "@/data/scrim"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, canEditScrim, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import { notifications } from "@/lib/notifications"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const params = req.nextUrl.searchParams; const id = params.get("id"); - const token = req.headers.get("Authorization"); const session = await auth(); - if (!session) { - if (token !== process.env.DEV_TOKEN) { - Logger.warn("Unauthorized request to remove scrim: ", id); - unauthorized(); - } - Logger.log("Authorized removal of scrim with dev token"); + if (!session?.user?.email) { + Logger.warn("Unauthorized request to remove scrim: ", id); + unauthorized(); } - const user = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => - svc.getUser(session?.user?.email ?? "lucas@lux.dev") - ) - ) - ); + const user = await getCurrentUser(); if (!user) return new Response("User not found", { status: 404 }); if (!id) return new Response("Missing ID", { status: 400 }); - const scrim = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(parseInt(id)))) - ); + const scrim = await prisma.scrim.findUnique({ where: { id: parseInt(id) } }); if (!scrim) return new Response("Scrim not found", { status: 404 }); - let isManager = false; - - if (scrim.teamId !== 0) { - // scrim is associated with a team - const managers = await prisma.team.findFirst({ - where: { id: scrim.teamId ?? 0 }, - select: { managers: true }, - }); - - // check if user is a manager - isManager = - managers?.managers.some((manager) => manager.userId === user.id) ?? false; - } - - const hasPerms = - user.role === $Enums.UserRole.ADMIN || // Admins can delete anything - user.role === $Enums.UserRole.MANAGER || // Managers can delete anything - user.id === scrim.creatorId || // Creators can delete their own scrims - isManager; // Managers of the scrim's team can delete the scrim - - if (!hasPerms) unauthorized(); + if (!(await canEditScrim(scrim.id, user))) unauthorized(); const scrimId = parseInt(id); diff --git a/src/app/api/scrim/update-scrim-options/route.ts b/src/app/api/scrim/update-scrim-options/route.ts index fda8bb901..5c1468308 100644 --- a/src/app/api/scrim/update-scrim-options/route.ts +++ b/src/app/api/scrim/update-scrim-options/route.ts @@ -1,11 +1,6 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { ScrimService } from "@/data/scrim"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, canEditScrim, canManageTeam, getCurrentUser } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; import { z } from "zod"; @@ -44,65 +39,81 @@ export async function POST(req: NextRequest) { const body = UpdateScrimSchema.safeParse(await req.json()); if (!body.success) return new Response("Invalid request", { status: 400 }); - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); + const user = await getCurrentUser(); if (!user) unauthorized(); - const userIsManager = await prisma.teamManager.findFirst({ - where: { userId: user.id }, + const scrim = await prisma.scrim.findUnique({ + where: { id: body.data.scrimId }, + select: { id: true, name: true, teamId: true, creatorId: true }, }); - - const scrim = await AppRuntime.runPromise( - ScrimService.pipe(Effect.flatMap((svc) => svc.getScrim(body.data.scrimId))) - ); if (!scrim) return new Response("Scrim not found", { status: 404 }); - const hasPerms = - userIsManager !== null || // user is a manager of the team - user.id === scrim.creatorId || // user created the scrim - user.role === $Enums.UserRole.ADMIN || // user is an admin - user.role === $Enums.UserRole.MANAGER; // user is a manager + if (!(await canEditScrim(scrim.id, user))) unauthorized(); - if (!hasPerms) unauthorized(); + const targetTeamId = parseInt(body.data.teamId); + if (!Number.isInteger(targetTeamId)) { + return new Response("Invalid team ID", { status: 400 }); + } - await prisma.scrim.update({ - where: { id: body.data.scrimId }, - data: { - name: body.data.name, - teamId: parseInt(body.data.teamId), - date: new Date(body.data.date), - guestMode: body.data.guestMode, - opponentTeamAbbr: body.data.opponentTeamAbbr ?? null, - }, + if (targetTeamId !== 0 && !(await canManageTeam(targetTeamId, user))) { + return new Response("Forbidden target team", { status: 403 }); + } + + const mapIds = body.data.maps.map((map) => map.id); + const maps = await prisma.map.findMany({ + where: { id: { in: mapIds }, scrimId: body.data.scrimId }, + select: { id: true, mapData: { select: { id: true }, take: 1 } }, }); + if (maps.length !== new Set(mapIds).size) { + return new Response("Invalid map IDs", { status: 400 }); + } + + const mapDataIdsByMapId = new Map( + maps.map((map) => [map.id, map.mapData[0]?.id]) + ); + if ([...mapDataIdsByMapId.values()].some((id) => id === undefined)) { + return new Response("Map data not found", { status: 400 }); + } + + await prisma.$transaction(async (tx) => { + await tx.scrim.update({ + where: { id: body.data.scrimId }, + data: { + name: body.data.name, + teamId: targetTeamId === 0 ? null : targetTeamId, + date: new Date(body.data.date), + guestMode: body.data.guestMode, + opponentTeamAbbr: body.data.opponentTeamAbbr ?? null, + }, + }); - if (body.data.maps && body.data.maps.length > 0) { for (const mapUpdate of body.data.maps) { - await prisma.map.update({ + await tx.map.update({ where: { id: mapUpdate.id }, data: { replayCode: mapUpdate.replayCode }, }); if (mapUpdate.heroBans) { - await prisma.heroBan.deleteMany({ - where: { MapDataId: mapUpdate.id }, + const mapDataId = mapDataIdsByMapId.get(mapUpdate.id)!; + + await tx.heroBan.deleteMany({ + where: { MapDataId: mapDataId }, }); if (mapUpdate.heroBans.length > 0) { - await prisma.heroBan.createMany({ + await tx.heroBan.createMany({ data: mapUpdate.heroBans.map((ban) => ({ scrimId: body.data.scrimId, hero: ban.hero, team: ban.team, banPosition: ban.banPosition, - MapDataId: mapUpdate.id, + MapDataId: mapDataId, })), }); } } } - } + }); after(async () => { await auditLog.createAuditLog({ diff --git a/src/app/api/stripe/checkout/route.ts b/src/app/api/stripe/checkout/route.ts new file mode 100644 index 000000000..f62d15b74 --- /dev/null +++ b/src/app/api/stripe/checkout/route.ts @@ -0,0 +1,43 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import { createCheckout, getCustomerPortalUrl } from "@/lib/stripe"; +import { $Enums } from "@/generated/prisma/browser"; +import { NextResponse, type NextRequest } from "next/server"; +import { z } from "zod"; + +const CheckoutQuerySchema = z.object({ + tier: z.enum(["Basic", "Premium"]), +}); + +export async function GET(req: NextRequest) { + const session = await auth(); + if (!session?.user?.email) { + return NextResponse.redirect(new URL("/dashboard", req.url)); + } + + const parsed = CheckoutQuerySchema.safeParse({ + tier: req.nextUrl.searchParams.get("tier"), + }); + if (!parsed.success) { + return NextResponse.redirect(new URL("/pricing", req.url)); + } + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) { + return NextResponse.redirect(new URL("/dashboard", req.url)); + } + + if (user.billingPlan === $Enums.BillingPlan.FREE) { + const checkout = await createCheckout(session, parsed.data.tier); + if (!checkout.url) { + return NextResponse.redirect(new URL("/pricing", req.url)); + } + return NextResponse.redirect(checkout.url); + } + + return NextResponse.redirect(await getCustomerPortalUrl(user)); +} diff --git a/src/app/api/stripe/webhooks/route.ts b/src/app/api/stripe/webhooks/route.ts index 52c3652a1..2b073ad59 100644 --- a/src/app/api/stripe/webhooks/route.ts +++ b/src/app/api/stripe/webhooks/route.ts @@ -1,7 +1,21 @@ import { stripeWebhookCounter } from "@/lib/axiom/metrics"; import { handleSubscriptionEvent } from "@/lib/billing-plans"; +import CreditTopupEmail from "@/components/email/credit-topup"; +import { email } from "@/lib/email"; +import { + clearPendingAutoRefill, + creditUser, + getOrInitUserCredits, + saveDefaultPaymentMethod, +} from "@/lib/credits"; import { Logger } from "@/lib/logger"; +import prisma from "@/lib/prisma"; import { stripe } from "@/lib/stripe"; +import { + sendDiscordWebhook, + userToppedUpWebhookConstructor, +} from "@/lib/webhooks"; +import { render } from "@react-email/render"; import { track } from "@vercel/analytics/server"; import type Stripe from "stripe"; @@ -9,9 +23,13 @@ const relevantEvents = new Set([ "customer.created", "customer.deleted", "checkout.session.completed", + "checkout.session.async_payment_succeeded", + "checkout.session.async_payment_failed", "customer.subscription.created", "customer.subscription.deleted", "customer.subscription.updated", + "payment_intent.succeeded", + "payment_intent.payment_failed", ]); export async function POST(req: Request) { @@ -65,6 +83,45 @@ export async function POST(req: Request) { checkoutSession.customer as string, true ); + } else if ( + checkoutSession.mode === "payment" && + checkoutSession.metadata?.type === "ai_chat_topup" + ) { + await handleTopupCheckoutCompleted(checkoutSession, event.id); + } + break; + } + case "checkout.session.async_payment_succeeded": { + const checkoutSession = event.data.object; + if ( + checkoutSession.mode === "payment" && + checkoutSession.metadata?.type === "ai_chat_topup" + ) { + await handleTopupCheckoutCompleted(checkoutSession, event.id); + } + break; + } + case "checkout.session.async_payment_failed": { + const checkoutSession = event.data.object; + if ( + checkoutSession.mode === "payment" && + checkoutSession.metadata?.type === "ai_chat_topup" + ) { + handleTopupCheckoutFailed(checkoutSession); + } + break; + } + case "payment_intent.succeeded": { + const paymentIntent = event.data.object; + if (paymentIntent.metadata?.type === "ai_chat_auto_refill") { + await handleAutoRefillSucceeded(paymentIntent, event.id); + } + break; + } + case "payment_intent.payment_failed": { + const paymentIntent = event.data.object; + if (paymentIntent.metadata?.type === "ai_chat_auto_refill") { + await handleAutoRefillFailed(paymentIntent); } break; } @@ -87,3 +144,215 @@ export async function POST(req: Request) { } return new Response(JSON.stringify({ received: true })); } + +async function handleTopupCheckoutCompleted( + session: Stripe.Checkout.Session, + eventId: string +) { + const userId = session.metadata?.userId; + const amountCents = session.amount_total ?? 0; + + if ( + !userId || + amountCents <= 0 || + session.payment_status !== "paid" || + session.status !== "complete" + ) { + Logger.warn("topup checkout missing userId or amount", { + sessionId: session.id, + }); + return; + } + + if (typeof session.payment_intent !== "string") { + Logger.warn("topup checkout missing payment intent", { + sessionId: session.id, + userId, + }); + return; + } + + const paymentIntent = await stripe.paymentIntents.retrieve( + session.payment_intent + ); + if (paymentIntent.status !== "succeeded") { + Logger.warn("topup checkout payment intent has not succeeded", { + sessionId: session.id, + paymentIntentId: paymentIntent.id, + status: paymentIntent.status, + }); + return; + } + + if (paymentIntent.amount_received < amountCents) { + Logger.warn("topup checkout paid amount is below expected amount", { + sessionId: session.id, + paymentIntentId: paymentIntent.id, + amountCents, + amountReceived: paymentIntent.amount_received, + }); + return; + } + + const result = await creditUser(userId, { + amountCents, + type: "TOPUP", + description: `Top-up via Stripe Checkout (${session.id})`, + stripeEventId: paymentIntent.id, + metadata: { sessionId: session.id, eventId }, + }); + + if (!result.ok) { + Logger.info("topup checkout event already processed", { + eventId, + userId, + }); + return; + } + + try { + const paymentMethodId = + typeof paymentIntent.payment_method === "string" + ? paymentIntent.payment_method + : paymentIntent.payment_method?.id; + if (paymentMethodId) { + await saveDefaultPaymentMethod(userId, paymentMethodId); + } + } catch (error) { + Logger.warn("failed to save payment method from topup", { + userId, + sessionId: session.id, + error: error instanceof Error ? error.message : String(error), + }); + } + + await notifyCreditPurchase( + userId, + amountCents, + result.balanceAfterCents, + "topup" + ); +} + +function handleTopupCheckoutFailed(session: Stripe.Checkout.Session) { + Logger.warn("topup checkout async payment failed", { + sessionId: session.id, + userId: session.metadata?.userId, + }); +} + +async function handleAutoRefillSucceeded( + paymentIntent: Stripe.PaymentIntent, + eventId: string +) { + const userId = paymentIntent.metadata?.userId; + const amountCents = paymentIntent.amount_received ?? paymentIntent.amount; + + if (!userId || amountCents <= 0) { + Logger.warn("auto-refill payment missing userId or amount", { + paymentIntentId: paymentIntent.id, + }); + return; + } + + const result = await creditUser(userId, { + amountCents, + type: "AUTO_REFILL", + description: `Auto-refill via Stripe PaymentIntent (${paymentIntent.id})`, + stripeEventId: eventId, + metadata: { paymentIntentId: paymentIntent.id }, + }); + + if (!result.ok) { + Logger.info("auto-refill event already processed", { + eventId, + userId, + }); + await clearPendingAutoRefill(userId); + return; + } + + await clearPendingAutoRefill(userId); + await notifyCreditPurchase( + userId, + amountCents, + result.balanceAfterCents, + "auto_refill" + ); +} + +async function handleAutoRefillFailed(paymentIntent: Stripe.PaymentIntent) { + const userId = paymentIntent.metadata?.userId; + if (!userId) return; + Logger.warn("auto-refill payment failed", { + paymentIntentId: paymentIntent.id, + userId, + }); + await clearPendingAutoRefill(userId); +} + +async function notifyCreditPurchase( + userId: string, + amountCents: number, + balanceAfterCents: number, + source: "topup" | "auto_refill" +) { + try { + const [user, credits] = await Promise.all([ + prisma.user.findUnique({ where: { id: userId } }), + getOrInitUserCredits(userId), + ]); + if (!user) return; + + if (process.env.DISCORD_WEBHOOK_URL) { + try { + await sendDiscordWebhook( + process.env.DISCORD_WEBHOOK_URL, + userToppedUpWebhookConstructor( + user, + amountCents, + balanceAfterCents, + source + ) + ); + } catch (error) { + Logger.error("failed to send credit Discord alert", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + } + } + + try { + await email.sendEmail({ + to: user.email, + from: "noreply@lux.dev", + subject: + source === "auto_refill" + ? "Your Parsertime credits were auto-refilled" + : "Your Parsertime AI credits receipt", + html: await render( + CreditTopupEmail({ + user, + amountCents, + balanceAfterCents, + source, + autoRefillEnabled: credits.autoRefillEnabled, + autoRefillThresholdCents: credits.autoRefillThresholdCents, + autoRefillAmountCents: credits.autoRefillAmountCents, + }) + ), + }); + } catch (error) { + Logger.error("failed to send credit topup receipt", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + } + } catch (error) { + Logger.error("credit purchase notification failed", { + userId, + error: error instanceof Error ? error.message : String(error), + }); + } +} diff --git a/src/app/api/team-ops/blacklist/route.ts b/src/app/api/team-ops/blacklist/route.ts new file mode 100644 index 000000000..c9affdf99 --- /dev/null +++ b/src/app/api/team-ops/blacklist/route.ts @@ -0,0 +1,64 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth, canManageTeam } from "@/lib/auth"; +import { + addBlacklistEntry, + removeBlacklistEntry, +} from "@/lib/team-ops/blacklist"; +import { unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +const addSchema = z.object({ + ownerTeamId: z.number().int().positive(), + blockedTeamId: z.number().int().positive().nullable(), + blockedTeamName: z.string().trim().min(1).max(100), + reason: z.string().trim().max(500).nullish(), +}); + +const removeSchema = z.object({ + ownerTeamId: z.number().int().positive(), + id: z.string().min(1), +}); + +async function requireManager(ownerTeamId: number) { + const session = await auth(); + if (!session) unauthorized(); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) return null; + if (!(await canManageTeam(ownerTeamId, user))) return null; + return user; +} + +export async function POST(request: NextRequest) { + const parsed = addSchema.safeParse(await request.json()); + if (!parsed.success) return new Response("Invalid request", { status: 400 }); + const user = await requireManager(parsed.data.ownerTeamId); + if (!user) return new Response("Forbidden", { status: 403 }); + + await addBlacklistEntry({ + ownerTeamId: parsed.data.ownerTeamId, + blockedTeamId: parsed.data.blockedTeamId, + blockedTeamName: parsed.data.blockedTeamName, + reason: parsed.data.reason ?? null, + createdBy: user.id, + source: "MANUAL", + }); + return Response.json({ ok: true }); +} + +export async function DELETE(request: NextRequest) { + const parsed = removeSchema.safeParse(await request.json()); + if (!parsed.success) return new Response("Invalid request", { status: 400 }); + const user = await requireManager(parsed.data.ownerTeamId); + if (!user) return new Response("Forbidden", { status: 403 }); + + await removeBlacklistEntry({ + ownerTeamId: parsed.data.ownerTeamId, + id: parsed.data.id, + }); + return Response.json({ ok: true }); +} diff --git a/src/app/api/team-ops/recent-requests/route.ts b/src/app/api/team-ops/recent-requests/route.ts new file mode 100644 index 000000000..fa0d40493 --- /dev/null +++ b/src/app/api/team-ops/recent-requests/route.ts @@ -0,0 +1,33 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth, canManageTeam } from "@/lib/auth"; +import { getRecentLinkableRequests } from "@/lib/team-ops/scrim-feedback"; +import { unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; + +export type RecentRequestsResponse = { + requests: { + scrimRequestId: string; + opponentTeamId: number; + opponentTeamName: string; + }[]; +}; + +export async function GET(request: NextRequest) { + const teamId = Number(new URL(request.url).searchParams.get("teamId")); + if (!Number.isInteger(teamId) || teamId <= 0) { + return Response.json({ requests: [] } satisfies RecentRequestsResponse); + } + const session = await auth(); + if (!session) unauthorized(); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user || !(await canManageTeam(teamId, user))) { + return Response.json({ requests: [] } satisfies RecentRequestsResponse); + } + return Response.json({ + requests: await getRecentLinkableRequests(teamId), + } satisfies RecentRequestsResponse); +} diff --git a/src/app/api/team-ops/scrim-feedback/route.ts b/src/app/api/team-ops/scrim-feedback/route.ts new file mode 100644 index 000000000..2e831bbc7 --- /dev/null +++ b/src/app/api/team-ops/scrim-feedback/route.ts @@ -0,0 +1,56 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth, canManageTeam } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { recordScrimFeedback } from "@/lib/team-ops/scrim-feedback"; +import { unauthorized } from "next/navigation"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +const schema = z.object({ + scrimId: z.number().int().positive(), + verdict: z.enum(["GOOD", "NEUTRAL", "BLACKLISTED", "DISMISSED"]), + reason: z.string().trim().max(500).nullish(), +}); + +export async function POST(request: NextRequest) { + const parsed = schema.safeParse(await request.json()); + if (!parsed.success) return new Response("Invalid request", { status: 400 }); + + const session = await auth(); + if (!session) unauthorized(); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!user) return new Response("Forbidden", { status: 403 }); + + const scrim = await prisma.scrim.findUnique({ + where: { id: parsed.data.scrimId }, + select: { + teamId: true, + opponentTeamId: true, + feedback: { select: { id: true } }, + opponentTeam: { select: { name: true } }, + }, + }); + if (!scrim?.teamId || !scrim.opponentTeamId) { + return new Response("Not linked", { status: 404 }); + } + // Authorize before leaking any per-scrim state (e.g. the 409 below). + if (!(await canManageTeam(scrim.teamId, user))) { + return new Response("Forbidden", { status: 403 }); + } + if (scrim.feedback) return new Response("Already recorded", { status: 409 }); + + await recordScrimFeedback({ + scrimId: parsed.data.scrimId, + ownerTeamId: scrim.teamId, + opponentTeamId: scrim.opponentTeamId, + opponentTeamName: scrim.opponentTeam?.name ?? "Unknown", + verdict: parsed.data.verdict, + reason: parsed.data.reason ?? null, + userId: user.id, + }); + return Response.json({ ok: true }); +} diff --git a/src/app/api/team/[teamId]/availability/schedules/ensure-current/route.ts b/src/app/api/team/[teamId]/availability/schedules/ensure-current/route.ts index 4474c45d3..f32808a4f 100644 --- a/src/app/api/team/[teamId]/availability/schedules/ensure-current/route.ts +++ b/src/app/api/team/[teamId]/availability/schedules/ensure-current/route.ts @@ -1,6 +1,10 @@ import { authenticateBotSecret } from "@/lib/bot-auth"; import { isTeamOwnerOrManager } from "@/lib/auth"; -import { weekEndInTz, weekStartInTz } from "@/lib/availability/tz"; +import { + isValidTimeZone, + weekEndInTz, + weekStartInTz, +} from "@/lib/availability/tz"; import prisma from "@/lib/prisma"; import type { NextRequest } from "next/server"; @@ -24,9 +28,16 @@ export async function POST(req: NextRequest, ctx: RouteCtx) { create: { teamId }, update: {}, }); + if (!isValidTimeZone(settings.timezone)) { + return new Response("Invalid team timezone", { status: 400 }); + } const now = new Date(); - const weekStart = weekStartInTz(now, settings.timezone); + const weekStart = weekStartInTz( + now, + settings.timezone, + settings.reminderDayOfWeek + ); const weekEnd = weekEndInTz(weekStart, settings.timezone); const schedule = await prisma.availabilitySchedule.upsert({ diff --git a/src/app/api/team/[teamId]/availability/settings/route.ts b/src/app/api/team/[teamId]/availability/settings/route.ts index 9e5539c70..74a56c47c 100644 --- a/src/app/api/team/[teamId]/availability/settings/route.ts +++ b/src/app/api/team/[teamId]/availability/settings/route.ts @@ -1,8 +1,19 @@ -import { isAuthedToViewTeam, isTeamOwnerOrManager } from "@/lib/auth"; +import { auth, isAuthedToViewTeam, isTeamOwnerOrManager } from "@/lib/auth"; +import { isValidTimeZone } from "@/lib/availability/tz"; +import { + verifyUserCanUseChannel, + verifyUserInGuild, +} from "@/lib/bot-discord-access"; import prisma from "@/lib/prisma"; import type { NextRequest } from "next/server"; import { z } from "zod"; +const DiscordSnowflakeSchema = z.string().regex(/^\d{17,20}$/); +const OptionalDiscordSnowflakeSchema = z.preprocess( + (value) => (typeof value === "string" && value.trim() === "" ? null : value), + DiscordSnowflakeSchema.nullable().optional() +); + const SettingsSchema = z.object({ slotMinutes: z.union([z.literal(15), z.literal(30), z.literal(60)]), hoursStart: z.number().int().min(0).max(23), @@ -12,9 +23,9 @@ const SettingsSchema = z.object({ reminderDayOfWeek: z.number().int().min(0).max(6), reminderHour: z.number().int().min(0).max(23), reminderMinute: z.number().int().min(0).max(59), - reminderRoleId: z.string().nullable().optional(), - reminderGuildId: z.string().nullable().optional(), - reminderChannelId: z.string().nullable().optional(), + reminderRoleId: OptionalDiscordSnowflakeSchema, + reminderGuildId: OptionalDiscordSnowflakeSchema, + reminderChannelId: OptionalDiscordSnowflakeSchema, }); type RouteCtx = { params: Promise<{ teamId: string }> }; @@ -56,6 +67,10 @@ export async function PUT(req: NextRequest, ctx: RouteCtx) { } const data = parsed.data; + if (!isValidTimeZone(data.timezone)) { + return Response.json({ error: "Invalid timezone" }, { status: 400 }); + } + if (data.hoursEnd <= data.hoursStart) { return Response.json( { error: "hoursEnd must be greater than hoursStart" }, @@ -71,6 +86,71 @@ export async function PUT(req: NextRequest, ctx: RouteCtx) { ); } + if (data.reminderGuildId || data.reminderChannelId) { + if (!data.reminderGuildId || !data.reminderChannelId) { + return Response.json( + { error: "reminderGuildId and reminderChannelId must be set together" }, + { status: 400 } + ); + } + + const session = await auth(); + const user = session?.user?.email + ? await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + accounts: { + where: { provider: "discord" }, + select: { providerAccountId: true }, + take: 1, + }, + }, + }) + : null; + const discordAccount = user?.accounts[0]; + if (!discordAccount) { + return Response.json( + { error: "Discord account not linked", code: "discord_not_linked" }, + { status: 403 } + ); + } + + const membership = await verifyUserInGuild( + discordAccount.providerAccountId, + data.reminderGuildId + ); + if (!membership.ok) { + if (membership.reason === "misconfigured") { + return Response.json( + { error: "Bot service not configured" }, + { status: 503 } + ); + } + return Response.json( + { error: "You are not a member of that server" }, + { status: 403 } + ); + } + + const channelAccess = await verifyUserCanUseChannel( + discordAccount.providerAccountId, + data.reminderGuildId, + data.reminderChannelId + ); + if (!channelAccess.ok) { + if (channelAccess.reason === "misconfigured") { + return Response.json( + { error: "Bot service not configured" }, + { status: 503 } + ); + } + return Response.json( + { error: "You cannot use that channel" }, + { status: 403 } + ); + } + } + const settings = await prisma.teamAvailabilitySettings.upsert({ where: { teamId }, create: { teamId, ...data }, diff --git a/src/app/api/team/[teamId]/stats-summary/route.ts b/src/app/api/team/[teamId]/stats-summary/route.ts new file mode 100644 index 000000000..7a14aedf5 --- /dev/null +++ b/src/app/api/team/[teamId]/stats-summary/route.ts @@ -0,0 +1,110 @@ +import { + clampTimeframe, + computeDateRange, + type TimeframePermissions, +} from "@/app/stats/team/[teamId]/_lib/context"; +import { AppRuntime } from "@/data/runtime"; +import { TeamStatsService } from "@/data/team"; +import { getTeamSubstituteNames } from "@/data/team/substitutes"; +import { isAuthedToViewTeam } from "@/lib/auth"; +import { Permission } from "@/lib/permissions"; +import prisma from "@/lib/prisma"; +import { isValidTimeframe, type Timeframe } from "@/lib/timeframe"; +import { computeTeamTsr, matchesAnyName } from "@/lib/tsr/team"; +import { Effect } from "effect"; +import type { NextRequest } from "next/server"; + +export type TeamStatsSummaryResponse = { + team: { name: string; image: string | null }; + permissions: TimeframePermissions; + effectiveTimeframe: Timeframe; + totalScrimCount: number; + winrates: { + overallWins: number; + overallLosses: number; + overallWinrate: number; + }; + teamTsr: Awaited>; +}; + +/** + * Authed summary used by the persistent team-stats header (rendered in the + * layout as a client component). Auth lives here and in each page, never in the + * layout itself. + */ +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ teamId: string }> } +) { + const { teamId: teamIdParam } = await params; + const teamId = Number(teamIdParam); + if (!Number.isInteger(teamId) || teamId <= 0) { + return Response.json({ error: "invalid team" }, { status: 400 }); + } + if (!(await isAuthedToViewTeam(teamId))) { + return Response.json({ error: "forbidden" }, { status: 403 }); + } + + const team = await prisma.team.findFirst({ + where: { id: teamId }, + include: { users: true }, + }); + if (!team) { + return Response.json({ error: "not found" }, { status: 404 }); + } + + const sp = new URL(request.url).searchParams; + const [timeframe1, timeframe2, timeframe3] = await Promise.all([ + new Permission("stats-timeframe-1").check(), + new Permission("stats-timeframe-2").check(), + new Permission("stats-timeframe-3").check(), + ]); + const permissions: TimeframePermissions = { + "stats-timeframe-1": timeframe1, + "stats-timeframe-2": timeframe2, + "stats-timeframe-3": timeframe3, + }; + + const rawTimeframe = sp.get("timeframe"); + const requestedTimeframe: Timeframe = isValidTimeframe(rawTimeframe) + ? rawTimeframe + : "one-week"; + const effectiveTimeframe = clampTimeframe(requestedTimeframe, permissions); + const dateRange = computeDateRange( + effectiveTimeframe, + sp.get("from") ?? undefined, + sp.get("to") ?? undefined + ); + + const [substituteNames, totalScrimCount, winrates, scrimRows] = + await Promise.all([ + getTeamSubstituteNames(teamId), + prisma.scrim.count({ where: { teamId } }), + AppRuntime.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) + ) + ), + prisma.scrim.findMany({ where: { teamId }, select: { id: true } }), + ]); + + const teamTsr = await computeTeamTsr( + team.users + .map((u) => ({ id: u.id, name: u.name, battletag: u.battletag })) + .filter((u) => !matchesAnyName(u, substituteNames)), + scrimRows.map((r) => r.id) + ); + + return Response.json({ + team: { name: team.name, image: team.image }, + permissions, + effectiveTimeframe, + totalScrimCount, + winrates: { + overallWins: winrates.overallWins, + overallLosses: winrates.overallLosses, + overallWinrate: winrates.overallWinrate, + }, + teamTsr, + } satisfies TeamStatsSummaryResponse); +} diff --git a/src/app/api/team/avatar-upload/route.ts b/src/app/api/team/avatar-upload/route.ts index e01510784..3a1088edb 100644 --- a/src/app/api/team/avatar-upload/route.ts +++ b/src/app/api/team/avatar-upload/route.ts @@ -1,27 +1,13 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { canManageTeam, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { Ratelimit } from "@upstash/ratelimit"; import { track } from "@vercel/analytics/server"; import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; import { kv } from "@vercel/kv"; -import { unauthorized } from "next/navigation"; import { type NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest): Promise { - const session = await auth(); - if (!session?.user) unauthorized(); - - const userId = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); - if (!userId) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - const body = (await request.json()) as HandleUploadBody; const teamId = request.nextUrl.searchParams.get("teamId"); @@ -29,41 +15,52 @@ export async function POST(request: NextRequest): Promise { return NextResponse.json({ error: "teamId is required" }, { status: 400 }); } - // Create a new ratelimiter, that allows 5 requests per 1 minute - const ratelimit = new Ratelimit({ - redis: kv, - limiter: Ratelimit.slidingWindow(5, "1 m"), - analytics: true, - }); - - // Limit the requests to 5 per minute per user - const identifier = `api/image-upload/${userId.id}`; - const { success } = await ratelimit.limit(identifier); - - if (!success) { - Logger.warn( - `Rate limit exceeded for team: ${teamId} for user: ${userId.id}` - ); - return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 }); - } - Logger.info(`Uploading avatar for team: ${teamId}`); try { const jsonResponse = await handleUpload({ body, request, - onBeforeGenerateToken: async () => /* clientPayload?: string, */ + onBeforeGenerateToken: async (pathname) => /* clientPayload?: string, */ { // Generate a client token for the browser to upload the file // ⚠️ Authenticate and authorize users before generating the token. // Otherwise, you're allowing anonymous uploads. + const authedUser = await getCurrentUser(); + if (!authedUser) throw new Error("Unauthorized"); + + const parsedTeamId = parseInt(teamId, 10); + if (!Number.isInteger(parsedTeamId)) throw new Error("Invalid team ID"); + if (!(await canManageTeam(parsedTeamId, authedUser))) { + throw new Error("Forbidden"); + } + + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(5, "1 m"), + analytics: true, + }); + const { success } = await ratelimit.limit( + `api/image-upload/${authedUser.id}` + ); + if (!success) throw new Error("Rate limit exceeded"); + const team = await prisma.team.findUnique({ - where: { id: parseInt(teamId) }, + where: { id: parsedTeamId }, }); if (!team) throw new Error("Team not found"); + if (pathname !== `team-avatars/${team.id}.png`) { + throw new Error("Invalid upload path"); + } - return { tokenPayload: JSON.stringify({ teamId: team.id }) }; + return { + tokenPayload: JSON.stringify({ + teamId: team.id, + authorizedUserId: authedUser.id, + }), + allowedContentTypes: ["image/png", "image/jpeg", "image/webp"], + maximumSizeInBytes: 5 * 1024 * 1024, + }; }, onUploadCompleted: async ({ blob, tokenPayload }) => { // Get notified of client upload completion @@ -75,10 +72,10 @@ export async function POST(request: NextRequest): Promise { try { // Run any logic after the file upload completed - const { teamId } = JSON.parse(tokenPayload!) as { teamId: string }; + const { teamId } = JSON.parse(tokenPayload!) as { teamId: number }; const team = await prisma.team.findUnique({ - where: { id: parseInt(teamId) }, + where: { id: teamId }, }); if (!team) throw new Error("Team not found"); diff --git a/src/app/api/team/create-team-invite/route.ts b/src/app/api/team/create-team-invite/route.ts index 2612f35aa..387fcaef0 100644 --- a/src/app/api/team/create-team-invite/route.ts +++ b/src/app/api/team/create-team-invite/route.ts @@ -1,33 +1,37 @@ -import { auth } from "@/lib/auth"; +import { auth, canManageTeam, getCurrentUser } from "@/lib/auth"; import { generateRandomToken } from "@/lib/invite-token"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { TEAM_MEMBER_LIMIT } from "@/lib/usage"; import { unauthorized } from "next/navigation"; import type { NextRequest } from "next/server"; +import { z } from "zod"; + +const CreateTeamInviteSchema = z.object({ + id: z.coerce.number().int().positive(), + email: z + .email() + .max(254) + .transform((email) => email.toLowerCase()), +}); export async function POST(req: NextRequest) { const session = await auth(); - const token = req.headers.get("Authorization"); - - const testingEmail = "lucas@lux.dev"; - if (!session) { - if (token !== process.env.DEV_TOKEN) { - Logger.warn("Unauthorized request to create team invite"); - unauthorized(); - } - Logger.log("Authorized request to create team invite using dev token"); + if (!session?.user?.email) { + Logger.warn("Unauthorized request to create team invite"); + unauthorized(); } - const teamId = req.nextUrl.searchParams.get("id") - ? parseInt(req.nextUrl.searchParams.get("id")!) - : null; - - if (!teamId) { - Logger.error("No team id provided to create team invite"); - return new Response("No team id provided", { status: 400 }); + const parsed = CreateTeamInviteSchema.safeParse({ + id: req.nextUrl.searchParams.get("id"), + email: req.nextUrl.searchParams.get("email"), + }); + if (!parsed.success) { + Logger.error("Invalid request to create team invite"); + return new Response("Invalid request", { status: 400 }); } + const { id: teamId, email: inviteeEmail } = parsed.data; const teamData = await prisma.team.findFirst({ where: { id: teamId }, @@ -35,6 +39,11 @@ export async function POST(req: NextRequest) { }); if (!teamData) return new Response("Team not found", { status: 404 }); + const user = await getCurrentUser(); + if (!(await canManageTeam(teamId, user))) { + return new Response("Forbidden", { status: 403 }); + } + const teamCreator = await prisma.user.findFirst({ where: { id: teamData.ownerId }, }); @@ -87,7 +96,7 @@ export async function POST(req: NextRequest) { data: { token: inviteToken, teamId, - email: session?.user?.email ?? testingEmail, + email: inviteeEmail, expires: new Date(expiresAt), }, }); diff --git a/src/app/api/team/create-team/route.ts b/src/app/api/team/create-team/route.ts index a0c28fa68..0f826dbc1 100644 --- a/src/app/api/team/create-team/route.ts +++ b/src/app/api/team/create-team/route.ts @@ -8,6 +8,8 @@ import { teamCreatedCounter, teamQuotaHitCounter, } from "@/lib/axiom/metrics"; +import { UsageEventName } from "@/lib/usage/names"; +import { usage } from "@/lib/usage/server"; import { Logger } from "@/lib/logger"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; @@ -22,6 +24,7 @@ const TeamCreationRequestSchema = z.object({ name: z.string().min(2).max(30), users: z.array(z.object({ id: z.string() })).optional(), }); +const TEAM_CREATION_LOCK_NAMESPACE = 42_111; export async function POST(request: NextRequest) { const ratelimit = new Ratelimit({ @@ -41,71 +44,87 @@ export async function POST(request: NextRequest) { } const session = await auth(); + if (!session?.user?.email) { + Logger.warn("Unauthorized request to create team API"); + return new Response("Unauthorized", { status: 401 }); + } const userId = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) ); if (!userId) return new Response("Unauthorized", { status: 401 }); const permission = await new Permission("create-team").check(); if (!permission) return new Response("Unauthorized", { status: 401 }); - const numberOfTeams = await prisma.team.count({ - where: { ownerId: userId.id }, - }); + const req = TeamCreationRequestSchema.safeParse(await request.json()); + if (!req.success) return new Response("Invalid request", { status: 400 }); const planLimit = TEAM_CREATION_LIMIT[userId.billingPlan as keyof typeof TEAM_CREATION_LIMIT]; - if (planLimit !== undefined) { - if (numberOfTeams >= planLimit && userId.role !== "ADMIN") { - teamQuotaHitCounter.add(1, { plan: userId.billingPlan }); - return new Response( - "You have hit the limit of teams that your account can create. Please upgrade your plan or contact support.", - { status: 403 } - ); - } - } else { - if (userId.role !== "ADMIN") - return new Response("Unauthorized", { status: 401 }); + if (planLimit === undefined && userId.role !== "ADMIN") { + return new Response("Unauthorized", { status: 401 }); } - const req = TeamCreationRequestSchema.safeParse(await request.json()); - if (!req.success) return new Response("Invalid request", { status: 400 }); + const result = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${TEAM_CREATION_LOCK_NAMESPACE}::integer, hashtext(${userId.id}))`; - if (!session) { - Logger.warn("Unauthorized request to create team API"); - return new Response("Unauthorized", { status: 401 }); - } + const numberOfTeams = await tx.team.count({ + where: { ownerId: userId.id }, + }); - const usersById = await prisma.user.findMany({ - where: { - id: { in: req.data.users?.map((user: { id: string }) => user.id) ?? [] }, - AND: { id: userId?.id, role: "ADMIN" }, - }, - }); + if ( + planLimit !== undefined && + numberOfTeams >= planLimit && + userId.role !== "ADMIN" + ) { + return { ok: false as const, reason: "quota" as const }; + } - const team = await prisma.team.create({ - data: { - name: req.data.name, - updatedAt: new Date(), - users: { - connect: [ - ...usersById.map((user) => { - return { id: user.id }; - }), - ], + const usersById = await tx.user.findMany({ + where: { + id: { + in: req.data.users?.map((user: { id: string }) => user.id) ?? [], + }, + AND: { id: userId.id, role: "ADMIN" }, }, - ownerId: userId.id ?? "", - }, - }); + }); - await prisma.user.update({ - where: { id: userId.id ?? "" }, - data: { teams: { connect: [{ id: team.id }] } }, + const team = await tx.team.create({ + data: { + name: req.data.name, + updatedAt: new Date(), + users: { + connect: usersById.map((user) => ({ id: user.id })), + }, + ownerId: userId.id, + }, + }); + + await tx.user.update({ + where: { id: userId.id }, + data: { teams: { connect: [{ id: team.id }] } }, + }); + + return { ok: true as const, team }; }); + if (!result.ok) { + teamQuotaHitCounter.add(1, { plan: userId.billingPlan }); + return new Response( + "You have hit the limit of teams that your account can create. Please upgrade your plan or contact support.", + { status: 403 } + ); + } + + const { team } = result; teamCreatedCounter.add(1, { plan: userId.billingPlan }); + void usage.track({ + name: UsageEventName.TEAM_CREATE, + userId: userId.id, + props: { plan: userId.billingPlan }, + }); after(async () => { await auditLog.createAuditLog({ diff --git a/src/app/api/team/delete-expired-tokens/route.ts b/src/app/api/team/delete-expired-tokens/route.ts index 958536a87..4fcfe96b9 100644 --- a/src/app/api/team/delete-expired-tokens/route.ts +++ b/src/app/api/team/delete-expired-tokens/route.ts @@ -1,7 +1,15 @@ import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; +import type { NextRequest } from "next/server"; -export async function DELETE() { +function isAuthorizedCronRequest(req: NextRequest) { + const secret = process.env.CRON_SECRET; + return Boolean( + secret && req.headers.get("Authorization") === `Bearer ${secret}` + ); +} + +async function deleteExpiredTokens() { const now = new Date(); const tokens = await prisma.teamInviteToken.findMany({ where: { expires: { lt: now } }, @@ -19,7 +27,17 @@ export async function DELETE() { return new Response("OK", { status: 200 }); } +export async function DELETE(req: NextRequest) { + if (!isAuthorizedCronRequest(req)) { + return new Response("Unauthorized", { status: 401 }); + } + return await deleteExpiredTokens(); +} + // This is necessary for using Vercel Cron Jobs -export async function GET() { - return await DELETE(); +export async function GET(req: NextRequest) { + if (!isAuthorizedCronRequest(req)) { + return new Response("Unauthorized", { status: 401 }); + } + return await deleteExpiredTokens(); } diff --git a/src/app/api/team/demote-user/route.ts b/src/app/api/team/demote-user/route.ts index b4308c8b3..3bb1b0192 100644 --- a/src/app/api/team/demote-user/route.ts +++ b/src/app/api/team/demote-user/route.ts @@ -1,9 +1,5 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; -import { Logger } from "@/lib/logger"; +import { auth, canManageTeam, getCurrentUser } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; @@ -16,12 +12,9 @@ const DemoteUserSchema = z.object({ export async function POST(req: NextRequest) { const session = await auth(); - const authToken = req.headers.get("Authorization"); - const devTokenAuthed = authToken === process.env.DEV_TOKEN; if (!session?.user?.email) { - if (!devTokenAuthed) unauthorized(); - Logger.log("Authorized with dev token"); + unauthorized(); } const body = DemoteUserSchema.safeParse(await req.json()); @@ -35,23 +28,13 @@ export async function POST(req: NextRequest) { const user = await prisma.user.findFirst({ where: { id: userId } }); if (!user) return new Response("User not found", { status: 404 }); - const authedUser = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) - ); + const authedUser = await getCurrentUser(); - if (!authedUser && !devTokenAuthed) unauthorized(); + if (!authedUser) unauthorized(); - if (!devTokenAuthed) { - const managers = await prisma.team.findFirst({ - where: { id: parseInt(teamId) }, - select: { managers: { where: { userId: authedUser?.id } } }, - }); - - const isManager = managers?.managers.some( - (manager) => manager.userId === authedUser?.id - ); - - if (team.ownerId !== authedUser?.id && !isManager) unauthorized(); + if (!(await canManageTeam(parseInt(teamId), authedUser))) unauthorized(); + if (team.ownerId === user.id) { + return new Response("Cannot demote the team owner", { status: 400 }); } // demote user to member @@ -61,7 +44,7 @@ export async function POST(req: NextRequest) { after(async () => { await auditLog.createAuditLog({ - userEmail: authedUser!.email, + userEmail: authedUser.email, action: "TEAM_MEMBER_DEMOTED", target: team.name, details: `Demoted ${user.name} to member`, diff --git a/src/app/api/team/get-teams-with-perms/route.ts b/src/app/api/team/get-teams-with-perms/route.ts index b0ec697df..df3524874 100644 --- a/src/app/api/team/get-teams-with-perms/route.ts +++ b/src/app/api/team/get-teams-with-perms/route.ts @@ -4,7 +4,7 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; export async function GET() { @@ -18,6 +18,7 @@ export async function GET() { const userId = await AppRuntime.runPromise( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) ); + if (!userId) unauthorized(); // find teams where the user is the owner or a manager const teams = await prisma.team.findMany({ diff --git a/src/app/api/team/get-teams/route.ts b/src/app/api/team/get-teams/route.ts index 1081d764e..6e9fdfaa8 100644 --- a/src/app/api/team/get-teams/route.ts +++ b/src/app/api/team/get-teams/route.ts @@ -4,7 +4,7 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import type { Team } from "@prisma/client"; +import type { Team } from "@/generated/prisma/client"; import { unauthorized } from "next/navigation"; export type GetTeamsResponse = { @@ -22,6 +22,7 @@ export async function GET() { const userId = await AppRuntime.runPromise( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) ); + if (!userId) unauthorized(); const teams = await prisma.team.findMany({ where: { diff --git a/src/app/api/team/join-team/route.ts b/src/app/api/team/join-team/route.ts index 7f63acb31..e3aad5604 100644 --- a/src/app/api/team/join-team/route.ts +++ b/src/app/api/team/join-team/route.ts @@ -7,61 +7,61 @@ import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const session = await auth(); - const authToken = req.headers.get("Authorization"); - const testingEmail = "lucas@lux.dev"; - - if (!session) { - if (authToken !== process.env.DEV_TOKEN) { - Logger.warn("Unauthorized request to create team invite"); - unauthorized(); - } - Logger.log("Authorized request to create team invite using dev token"); + if (!session?.user?.email) { + Logger.warn("Unauthorized request to join team"); + unauthorized(); } const token = req.nextUrl.searchParams.get("token"); + const userEmail = session.user.email.toLowerCase(); if (!token) { Logger.error("No token provided to join team"); return new Response("No token provided", { status: 400 }); } - const teamInviteToken = await prisma.teamInviteToken.findUnique({ - where: { token }, + const joinedTeam = await prisma.$transaction(async (tx) => { + const teamInviteToken = await tx.teamInviteToken.findUnique({ + where: { token }, + }); + + if (!teamInviteToken || teamInviteToken.expires <= new Date()) return null; + if (teamInviteToken.email.toLowerCase() !== userEmail) return null; + + const deleted = await tx.teamInviteToken.deleteMany({ + where: { token, expires: { gt: new Date() } }, + }); + if (deleted.count !== 1) return null; + + return await tx.team.update({ + where: { id: teamInviteToken.teamId }, + data: { + users: { connect: { email: userEmail } }, + }, + select: { id: true, name: true }, + }); }); - if (!teamInviteToken) { + if (!joinedTeam) { Logger.error("Invalid or expired token provided to join team"); return new Response("Invalid token provided", { status: 400 }); } - await prisma.team.update({ - where: { id: teamInviteToken.teamId }, - data: { - users: { connect: { email: session?.user?.email ?? testingEmail } }, - }, - }); - - await prisma.teamInviteToken.delete({ where: { token } }); - - Logger.info( - `User ${session?.user?.email ?? testingEmail} joined team ${ - teamInviteToken.teamId - }` - ); + Logger.info(`User ${session.user.email} joined team ${joinedTeam.id}`); const teams = await prisma.team.findMany({ - where: { users: { some: { email: session?.user?.email ?? testingEmail } } }, + where: { users: { some: { email: session.user.email } } }, }); Logger.info(`User now belongs to team: ${JSON.stringify(teams)}`); after(async () => { await auditLog.createAuditLog({ - userEmail: session?.user?.email ?? testingEmail, + userEmail: session.user.email, action: "TEAM_JOINED", - target: `${teams[0].name}`, - details: `Joined team ${teams[0].name} (Team ID: ${teamInviteToken.teamId})`, + target: `${joinedTeam.name}`, + details: `Joined team ${joinedTeam.name} (Team ID: ${joinedTeam.id})`, }); }); diff --git a/src/app/api/team/mark-substitute/route.ts b/src/app/api/team/mark-substitute/route.ts new file mode 100644 index 000000000..a77f0d987 --- /dev/null +++ b/src/app/api/team/mark-substitute/route.ts @@ -0,0 +1,55 @@ +import { auditLog } from "@/lib/audit-logs"; +import { auth, canManageTeam, getCurrentUser } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; +import { z } from "zod"; + +const MarkSubstituteSchema = z.object({ + teamId: z.string(), + playerName: z.string().min(1), +}); + +export async function POST(req: NextRequest) { + const session = await auth(); + + if (!session?.user?.email) { + unauthorized(); + } + + const body = MarkSubstituteSchema.safeParse(await req.json()); + if (!body.success) return new Response("Invalid request", { status: 400 }); + + const { teamId, playerName } = body.data; + + const team = await prisma.team.findFirst({ + where: { id: parseInt(teamId) }, + }); + if (!team) return new Response("Team not found", { status: 404 }); + + const authedUser = await getCurrentUser(); + if (!authedUser) { + return new Response("Unauthorized", { status: 401 }); + } + + if (!(await canManageTeam(parseInt(teamId), authedUser))) unauthorized(); + + await prisma.teamSubstitute.upsert({ + where: { + teamId_playerName: { teamId: parseInt(teamId), playerName }, + }, + update: {}, + create: { teamId: parseInt(teamId), playerName, createdBy: authedUser.id }, + }); + + after(async () => { + await auditLog.createAuditLog({ + userEmail: authedUser.email, + action: "TEAM_SUBSTITUTE_MARKED", + target: playerName, + details: `Marked ${playerName} as a substitute for team ${team.name}`, + }); + }); + + return new Response("OK", { status: 200 }); +} diff --git a/src/app/api/team/promote-user/route.ts b/src/app/api/team/promote-user/route.ts index 71236a086..42ef328ad 100644 --- a/src/app/api/team/promote-user/route.ts +++ b/src/app/api/team/promote-user/route.ts @@ -1,9 +1,5 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; -import { Logger } from "@/lib/logger"; +import { auth, canManageTeam, getCurrentUser } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; @@ -16,13 +12,9 @@ const PromoteUserSchema = z.object({ export async function POST(req: NextRequest) { const session = await auth(); - const authToken = req.headers.get("Authorization"); - const devTokenAuthed = authToken === process.env.DEV_TOKEN; if (!session?.user?.email) { - if (!devTokenAuthed) unauthorized(); - - Logger.log("Authorized with dev token"); + unauthorized(); } const body = PromoteUserSchema.safeParse(await req.json()); @@ -40,35 +32,32 @@ export async function POST(req: NextRequest) { const user = await prisma.user.findFirst({ where: { id: userId } }); if (!user) return new Response("User not found", { status: 404 }); - const authedUser = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) - ); + const authedUser = await getCurrentUser(); - if (!authedUser && !devTokenAuthed) { + if (!authedUser) { return new Response("Unauthorized", { status: 401 }); } - if (!devTokenAuthed) { - const managers = await prisma.team.findFirst({ - where: { id: parseInt(teamId) }, - select: { managers: { where: { userId: authedUser?.id } } }, - }); - - const isManager = managers?.managers.some( - (manager) => manager.userId === authedUser?.id - ); + if (!(await canManageTeam(parseInt(teamId), authedUser))) unauthorized(); - if (team.ownerId !== authedUser?.id && !isManager) unauthorized(); + const targetMembership = await prisma.team.findFirst({ + where: { id: parseInt(teamId), users: { some: { id: userId } } }, + select: { id: true }, + }); + if (!targetMembership) { + return new Response("Target user is not a team member", { status: 400 }); } // add user as a manager - await prisma.teamManager.create({ - data: { userId, teamId: parseInt(teamId) }, + await prisma.teamManager.upsert({ + where: { teamId_userId: { teamId: parseInt(teamId), userId } }, + update: {}, + create: { userId, teamId: parseInt(teamId) }, }); after(async () => { await auditLog.createAuditLog({ - userEmail: authedUser!.email, + userEmail: authedUser.email, action: "TEAM_MEMBER_PROMOTED", target: user.email, details: `Promoted ${user.name ?? user.email} to manager of team ${team.name}`, diff --git a/src/app/api/team/remove-team/route.ts b/src/app/api/team/remove-team/route.ts index 95a1708c6..f7cf6cd9d 100644 --- a/src/app/api/team/remove-team/route.ts +++ b/src/app/api/team/remove-team/route.ts @@ -1,11 +1,7 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, getCurrentUser, isAdminUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; @@ -15,33 +11,21 @@ export async function POST(req: NextRequest) { const id = parseInt(params.get("id") ?? ""); if (!id) return new Response("Missing ID", { status: 400 }); - const token = req.headers.get("Authorization"); - const session = await auth(); - if (!session) { - if (token !== process.env.DEV_TOKEN) { - Logger.warn("Unauthorized request to remove team: ", id); - unauthorized(); - } - Logger.log("Authorized removal of team with dev token"); + if (!session?.user?.email) { + Logger.warn("Unauthorized request to remove team: ", id); + unauthorized(); } - const user = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => - svc.getUser(session?.user?.email ?? "lucas@lux.dev") - ) - ) - ); + const user = await getCurrentUser(); if (!user) return new Response("User not found", { status: 404 }); const team = await prisma.team.findFirst({ where: { id } }); if (!team) return new Response("Team not found", { status: 404 }); const hasPerms = - user.role === $Enums.UserRole.ADMIN || // Admins can delete anything - user.role === $Enums.UserRole.MANAGER || // Managers can delete anything + isAdminUser(user) || // Admins can delete anything user.id === team.ownerId; // Creators can delete their own teams if (!hasPerms) unauthorized(); diff --git a/src/app/api/team/remove-user-from-team/route.ts b/src/app/api/team/remove-user-from-team/route.ts index e58139b48..b33fed11f 100644 --- a/src/app/api/team/remove-user-from-team/route.ts +++ b/src/app/api/team/remove-user-from-team/route.ts @@ -4,7 +4,7 @@ import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import type { User } from "@prisma/client"; +import type { User } from "@/generated/prisma/client"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; import { z } from "zod"; diff --git a/src/app/api/team/targets/route.ts b/src/app/api/team/targets/route.ts index 8721661b1..5755a8384 100644 --- a/src/app/api/team/targets/route.ts +++ b/src/app/api/team/targets/route.ts @@ -4,6 +4,7 @@ import { UserService } from "@/data/user"; import { TargetsService } from "@/data/player"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; +import { $Enums } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; import type { NextRequest } from "next/server"; import { z } from "zod"; @@ -69,11 +70,6 @@ export async function POST(req: NextRequest) { ); if (!user) unauthorized(); - // Premium check - if (user.billingPlan === "FREE" && user.role !== "ADMIN") { - return new Response("Premium plan required", { status: 403 }); - } - const body = CreateTargetSchema.safeParse(await req.json()); if (!body.success) { return Response.json(body.error.flatten(), { status: 400 }); @@ -89,6 +85,25 @@ export async function POST(req: NextRequest) { note, } = body.data; + const team = await prisma.team.findUnique({ + where: { id: teamId }, + select: { ownerId: true }, + }); + if (!team) return new Response("Team not found", { status: 404 }); + + const teamOwner = await prisma.user.findUnique({ + where: { id: team.ownerId }, + select: { billingPlan: true }, + }); + if (!teamOwner) return new Response("Team owner not found", { status: 404 }); + + const hasTeamEntitlement = + user.role === $Enums.UserRole.ADMIN || + teamOwner.billingPlan === $Enums.BillingPlan.PREMIUM; + if (!hasTeamEntitlement) { + return new Response("Premium plan required", { status: 403 }); + } + // Check manager/owner perms const hasPerms = user.role === "ADMIN" || (await checkManagerPerms(user.id, teamId)); diff --git a/src/app/api/team/transfer-ownership/route.ts b/src/app/api/team/transfer-ownership/route.ts index db62776a8..b3055f6a4 100644 --- a/src/app/api/team/transfer-ownership/route.ts +++ b/src/app/api/team/transfer-ownership/route.ts @@ -1,17 +1,12 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, getCurrentUser, isAdminUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; import { forbidden, unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; export async function POST(req: NextRequest) { const params = req.nextUrl.searchParams; - const token = req.headers.get("Authorization"); const id = parseInt(params.get("id") ?? ""); if (!id) return new Response("Missing ID", { status: 400 }); @@ -21,36 +16,24 @@ export async function POST(req: NextRequest) { const session = await auth(); - if (!session) { - if (token !== process.env.DEV_TOKEN) { - Logger.warn("Unauthorized request to remove team: ", id); - unauthorized(); - } - Logger.log("Authorized removal of team with dev token"); + if (!session?.user?.email) { + Logger.warn("Unauthorized request to transfer team: ", id); + unauthorized(); } - const user = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => - svc.getUser(session?.user?.email ?? "lucas@lux.dev") - ) - ) - ); + const user = await getCurrentUser(); if (!user) return new Response("User not found", { status: 404 }); const team = await prisma.team.findFirst({ where: { id } }); if (!team) return new Response("Team not found", { status: 404 }); const hasPerms = - user.role === $Enums.UserRole.ADMIN || // Admins can transfer anything - user.role === $Enums.UserRole.MANAGER || // Managers can transfer anything + isAdminUser(user) || // Admins can transfer anything user.id === team.ownerId; // Creators can transfer their own teams if (!hasPerms) forbidden(); - const newOwner = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(owner))) - ); + const newOwner = await prisma.user.findUnique({ where: { email: owner } }); if (!newOwner) return new Response("New owner not found", { status: 404 }); await prisma.team.update({ diff --git a/src/app/api/team/unmark-substitute/route.ts b/src/app/api/team/unmark-substitute/route.ts new file mode 100644 index 000000000..ffe98a32c --- /dev/null +++ b/src/app/api/team/unmark-substitute/route.ts @@ -0,0 +1,52 @@ +import { auditLog } from "@/lib/audit-logs"; +import { auth, canManageTeam, getCurrentUser } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { unauthorized } from "next/navigation"; +import { after, type NextRequest } from "next/server"; +import { z } from "zod"; + +const UnmarkSubstituteSchema = z.object({ + teamId: z.string(), + playerName: z.string().min(1), +}); + +export async function POST(req: NextRequest) { + const session = await auth(); + + if (!session?.user?.email) { + unauthorized(); + } + + const body = UnmarkSubstituteSchema.safeParse(await req.json()); + if (!body.success) return new Response("Invalid request", { status: 400 }); + + const { teamId, playerName } = body.data; + + const team = await prisma.team.findFirst({ + where: { id: parseInt(teamId) }, + }); + if (!team) return new Response("Team not found", { status: 404 }); + + const authedUser = await getCurrentUser(); + if (!authedUser) { + return new Response("Unauthorized", { status: 401 }); + } + + if (!(await canManageTeam(parseInt(teamId), authedUser))) unauthorized(); + + // deleteMany is idempotent: it does not throw when no row matches. + await prisma.teamSubstitute.deleteMany({ + where: { teamId: parseInt(teamId), playerName }, + }); + + after(async () => { + await auditLog.createAuditLog({ + userEmail: authedUser.email, + action: "TEAM_SUBSTITUTE_UNMARKED", + target: playerName, + details: `Removed ${playerName} as a substitute for team ${team.name}`, + }); + }); + + return new Response("OK", { status: 200 }); +} diff --git a/src/app/api/team/update-name/route.ts b/src/app/api/team/update-name/route.ts index 7574be64c..ebeb8c942 100644 --- a/src/app/api/team/update-name/route.ts +++ b/src/app/api/team/update-name/route.ts @@ -1,5 +1,5 @@ import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, canManageTeam, getCurrentUser } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; @@ -30,6 +30,26 @@ export async function POST(req: NextRequest) { const body = TeamNameUpdateSchema.safeParse(await req.json()); if (!body.success) return new Response("Invalid request", { status: 400 }); + const user = await getCurrentUser(); + if (!(await canManageTeam(body.data.teamId, user))) { + return new Response("Forbidden", { status: 403 }); + } + + if (body.data.scoutingTeamAbbr) { + const scoutingTeam = await prisma.scoutingMatch.findFirst({ + where: { + OR: [ + { team1: body.data.scoutingTeamAbbr }, + { team2: body.data.scoutingTeamAbbr }, + ], + }, + select: { id: true }, + }); + if (!scoutingTeam) { + return new Response("Invalid scouting team", { status: 400 }); + } + } + await prisma.team.update({ where: { id: body.data.teamId }, data: { diff --git a/src/app/api/tournament/[id]/route.ts b/src/app/api/tournament/[id]/route.ts index 2108f3cae..cd4efb6a9 100644 --- a/src/app/api/tournament/[id]/route.ts +++ b/src/app/api/tournament/[id]/route.ts @@ -1,8 +1,8 @@ import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, canManageTournament, getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { TournamentStatus } from "@prisma/client"; +import { TournamentStatus } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; import { z } from "zod"; @@ -62,15 +62,8 @@ export async function PATCH( return Response.json({ error: "Tournament not found" }, { status: 404 }); } - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { id: true, role: true }, - }); - if ( - user?.id !== tournament.creatorId && - user?.role !== "ADMIN" && - user?.role !== "MANAGER" - ) { + const user = await getCurrentUser(); + if (!(await canManageTournament(id, user))) { event.outcome = "forbidden"; event.statusCode = 403; return Response.json({ error: "Forbidden" }, { status: 403 }); @@ -184,15 +177,8 @@ export async function DELETE( return Response.json({ error: "Tournament not found" }, { status: 404 }); } - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { id: true, role: true }, - }); - if ( - user?.id !== tournament.creatorId && - user?.role !== "ADMIN" && - user?.role !== "MANAGER" - ) { + const user = await getCurrentUser(); + if (!(await canManageTournament(id, user))) { event.outcome = "forbidden"; event.statusCode = 403; return Response.json({ error: "Forbidden" }, { status: 403 }); diff --git a/src/app/api/tournament/create/route.ts b/src/app/api/tournament/create/route.ts index 41dcddf60..b438eaeaf 100644 --- a/src/app/api/tournament/create/route.ts +++ b/src/app/api/tournament/create/route.ts @@ -2,7 +2,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { auth, canViewTeam } from "@/lib/auth"; import { generateDoubleEliminationBracket, generateRoundRobinSEBracket, @@ -12,7 +12,9 @@ import { } from "@/lib/tournaments/bracket"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { TournamentFormat } from "@prisma/client"; +import { UsageEventName } from "@/lib/usage/names"; +import { usage } from "@/lib/usage/server"; +import { TournamentFormat } from "@/generated/prisma/browser"; import { Ratelimit } from "@upstash/ratelimit"; import { ipAddress } from "@vercel/functions"; import { kv } from "@vercel/kv"; @@ -29,23 +31,52 @@ const bestOfSchema = z message: "Best-of must be an odd number", }); -const createTournamentSchema = z.object({ - name: z.string().min(2).max(50), - format: z.nativeEnum(TournamentFormat), - bestOf: bestOfSchema, - playoffBestOf: bestOfSchema.optional(), - advancingTeams: z.number().int().min(2).optional(), - teams: z - .array( - z.object({ - name: z.string().min(1).max(50), - teamId: z.number().int().optional(), - seed: z.number().int().min(1), - }) - ) - .min(2) - .max(64), -}); +const createTournamentSchema = z + .object({ + name: z.string().min(2).max(50), + format: z.nativeEnum(TournamentFormat), + bestOf: bestOfSchema, + playoffBestOf: bestOfSchema.optional(), + advancingTeams: z.number().int().min(2).optional(), + teams: z + .array( + z.object({ + name: z.string().min(1).max(50), + teamId: z.number().int().optional(), + seed: z.number().int().min(1), + }) + ) + .min(2) + .max(64), + }) + .superRefine((value, ctx) => { + const teamCount = value.teams.length; + if (value.format === TournamentFormat.DOUBLE_ELIMINATION && teamCount < 4) { + ctx.addIssue({ + code: "custom", + path: ["teams"], + message: "Double elimination requires at least 4 teams.", + }); + } + if (value.format === TournamentFormat.ROUND_ROBIN_SE && teamCount < 3) { + ctx.addIssue({ + code: "custom", + path: ["teams"], + message: "Round robin requires at least 3 teams.", + }); + } + if ( + value.format === TournamentFormat.ROUND_ROBIN_SE && + value.advancingTeams !== undefined && + value.advancingTeams > teamCount + ) { + ctx.addIssue({ + code: "custom", + path: ["advancingTeams"], + message: "Advancing teams cannot exceed the team count.", + }); + } + }); export type CreateTournamentRequestData = z.infer< typeof createTournamentSchema @@ -124,6 +155,18 @@ export async function POST(request: NextRequest) { return Response.json({ error: "User not found" }, { status: 404 }); } + const linkedTeamIds = teams + .map((team) => team.teamId) + .filter((id): id is number => id !== undefined); + const linkedTeamAccess = await Promise.all( + [...new Set(linkedTeamIds)].map((teamId) => canViewTeam(teamId, user)) + ); + if (linkedTeamAccess.some((allowed) => !allowed)) { + event.outcome = "forbidden_team"; + event.statusCode = 403; + return Response.json({ error: "Forbidden linked team" }, { status: 403 }); + } + const bracket = format === TournamentFormat.DOUBLE_ELIMINATION ? generateDoubleEliminationBracket(teams.length) @@ -295,6 +338,11 @@ export async function POST(request: NextRequest) { event.tournamentId = tournament.id; + void usage.track({ + name: UsageEventName.TOURNAMENT_CREATE, + userId: user.id, + }); + after(async () => { await auditLog.createAuditLog({ userEmail: session.user.email, diff --git a/src/app/api/tournament/map/[tournamentMapId]/route.ts b/src/app/api/tournament/map/[tournamentMapId]/route.ts index e2cabfe20..a0bf278a6 100644 --- a/src/app/api/tournament/map/[tournamentMapId]/route.ts +++ b/src/app/api/tournament/map/[tournamentMapId]/route.ts @@ -5,7 +5,7 @@ import { auditLog } from "@/lib/audit-logs"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { unauthorized } from "next/navigation"; +import { unauthorized, unstable_rethrow } from "next/navigation"; import { after, type NextRequest } from "next/server"; export async function DELETE( @@ -79,47 +79,58 @@ export async function DELETE( return Response.json({ error: "Forbidden" }, { status: 403 }); } - const mapId = tournamentMap.mapId; + if (match.status === "COMPLETED" && match.winnerId) { + event.outcome = "match_completed"; + event.statusCode = 409; + return Response.json( + { error: "Cannot delete maps after a match is completed" }, + { status: 409 } + ); + } - await prisma.tournamentMap.delete({ - where: { id: tournamentMapId }, - }); + const mapId = tournamentMap.mapId; - if (mapId) { - await prisma.map.delete({ where: { id: mapId } }); - } + await prisma.$transaction(async (tx) => { + await tx.tournamentMap.delete({ + where: { id: tournamentMapId }, + }); - const remainingMaps = await prisma.tournamentMap.findMany({ - where: { matchId: match.id }, - }); + if (mapId) { + await tx.map.delete({ where: { id: mapId } }); + } - let team1Wins = 0; - let team2Wins = 0; - for (const map of remainingMaps) { - if (map.winnerOverride === match.team1?.name) team1Wins++; - else if (map.winnerOverride === match.team2?.name) team2Wins++; - } + const remainingMaps = await tx.tournamentMap.findMany({ + where: { matchId: match.id }, + }); - const bestOf = match.round.bestOf ?? match.tournament.bestOf; - const winsNeeded = Math.ceil(bestOf / 2); - const isDecided = team1Wins >= winsNeeded || team2Wins >= winsNeeded; - - await prisma.tournamentMatch.update({ - where: { id: match.id }, - data: { - team1Score: team1Wins, - team2Score: team2Wins, - status: isDecided - ? "COMPLETED" - : remainingMaps.length > 0 - ? "ONGOING" - : "UPCOMING", - winnerId: isDecided - ? team1Wins >= winsNeeded - ? match.team1Id - : match.team2Id - : null, - }, + let team1Wins = 0; + let team2Wins = 0; + for (const map of remainingMaps) { + if (map.winnerOverride === match.team1?.name) team1Wins++; + else if (map.winnerOverride === match.team2?.name) team2Wins++; + } + + const bestOf = match.round.bestOf ?? match.tournament.bestOf; + const winsNeeded = Math.ceil(bestOf / 2); + const isDecided = team1Wins >= winsNeeded || team2Wins >= winsNeeded; + + await tx.tournamentMatch.update({ + where: { id: match.id }, + data: { + team1Score: team1Wins, + team2Score: team2Wins, + status: isDecided + ? "COMPLETED" + : remainingMaps.length > 0 + ? "ONGOING" + : "UPCOMING", + winnerId: isDecided + ? team1Wins >= winsNeeded + ? match.team1Id + : match.team2Id + : null, + }, + }); }); await AppRuntime.runPromise( @@ -140,10 +151,11 @@ export async function DELETE( event.outcome = "success"; event.statusCode = 200; return Response.json({ success: true }); - } catch (e) { + } catch (error) { + unstable_rethrow(error); event.outcome = "error"; event.statusCode = 500; - event.error = e instanceof Error ? e.message : String(e); + event.error = error instanceof Error ? error.message : String(error); return Response.json({ error: "Failed to delete map" }, { status: 500 }); } finally { event.durationMs = Date.now() - startTime; diff --git a/src/app/api/tournament/map/[tournamentMapId]/set-winner/route.ts b/src/app/api/tournament/map/[tournamentMapId]/set-winner/route.ts index 9e756e95a..aab25244e 100644 --- a/src/app/api/tournament/map/[tournamentMapId]/set-winner/route.ts +++ b/src/app/api/tournament/map/[tournamentMapId]/set-winner/route.ts @@ -3,7 +3,7 @@ import { auth } from "@/lib/auth"; import { advanceMatch } from "@/lib/tournaments/advancement"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { unauthorized } from "next/navigation"; +import { unauthorized, unstable_rethrow } from "next/navigation"; import { after, type NextRequest } from "next/server"; import { z } from "zod"; @@ -11,6 +11,8 @@ const setWinnerSchema = z.object({ winner: z.string().min(1), }); +const TOURNAMENT_MATCH_LOCK_NAMESPACE = 61_042; + export async function POST( req: NextRequest, { params }: { params: Promise<{ tournamentMapId: string }> } @@ -77,6 +79,14 @@ export async function POST( const match = tournamentMap.match; event.matchId = match.id; + if (!match.team1?.name || !match.team2?.name) { + event.outcome = "missing_teams"; + event.statusCode = 400; + return Response.json( + { error: "Match teams must be assigned before setting a winner" }, + { status: 400 } + ); + } const user = await prisma.user.findUnique({ where: { email: session.user.email }, @@ -92,73 +102,119 @@ export async function POST( return Response.json({ error: "Forbidden" }, { status: 403 }); } - await prisma.tournamentMap.update({ - where: { id: tournamentMapId }, - data: { winnerOverride: parsed.data.winner }, - }); + if (![match.team1.name, match.team2.name].includes(parsed.data.winner)) { + event.outcome = "invalid_winner"; + event.statusCode = 400; + return Response.json({ error: "Invalid winner" }, { status: 400 }); + } - const allMaps = await prisma.tournamentMap.findMany({ - where: { matchId: match.id }, - }); + const mutation = await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${TOURNAMENT_MATCH_LOCK_NAMESPACE}::integer, ${match.id}::integer)`; + + const lockedMatch = await tx.tournamentMatch.findUnique({ + where: { id: match.id }, + include: { + tournament: true, + team1: true, + team2: true, + round: true, + maps: true, + }, + }); + if (!lockedMatch) return { status: "not_found" as const }; + if (lockedMatch.status === "COMPLETED" && lockedMatch.winnerId) { + return { status: "match_completed" as const }; + } + + await tx.tournamentMap.update({ + where: { id: tournamentMapId }, + data: { winnerOverride: parsed.data.winner }, + }); - let team1Wins = 0; - let team2Wins = 0; + let team1Wins = 0; + let team2Wins = 0; + + for (const map of lockedMatch.maps) { + const winner = + map.id === tournamentMapId ? parsed.data.winner : map.winnerOverride; + if (winner === lockedMatch.team1?.name) team1Wins++; + else if (winner === lockedMatch.team2?.name) team2Wins++; + } + + const bestOf = lockedMatch.round.bestOf ?? lockedMatch.tournament.bestOf; + const winsNeeded = Math.ceil(bestOf / 2); + const isDecided = team1Wins >= winsNeeded || team2Wins >= winsNeeded; + const winnerId = + team1Wins >= winsNeeded + ? lockedMatch.team1Id + : team2Wins >= winsNeeded + ? lockedMatch.team2Id + : null; + + await tx.tournamentMatch.update({ + where: { id: lockedMatch.id }, + data: { + team1Score: team1Wins, + team2Score: team2Wins, + status: isDecided ? "COMPLETED" : "ONGOING", + winnerId: isDecided ? winnerId : null, + }, + }); - for (const map of allMaps) { - const winner = - map.id === tournamentMapId ? parsed.data.winner : map.winnerOverride; - if (winner === match.team1?.name) team1Wins++; - else if (winner === match.team2?.name) team2Wins++; - } + const loserId = winnerId + ? winnerId === lockedMatch.team1Id + ? lockedMatch.team2Id + : lockedMatch.team1Id + : null; - const bestOf = match.round.bestOf ?? match.tournament.bestOf; - const winsNeeded = Math.ceil(bestOf / 2); - const isDecided = team1Wins >= winsNeeded || team2Wins >= winsNeeded; - const winnerId = - team1Wins >= winsNeeded - ? match.team1Id - : team2Wins >= winsNeeded - ? match.team2Id - : null; - - event.matchDecided = isDecided; - - await prisma.tournamentMatch.update({ - where: { id: match.id }, - data: { - team1Score: team1Wins, - team2Score: team2Wins, - status: isDecided ? "COMPLETED" : "ONGOING", - winnerId: isDecided ? winnerId : null, - }, - }); - - if (isDecided && winnerId) { - const loserId = - winnerId === match.team1Id ? match.team2Id : match.team1Id; - - await advanceMatch( - { - id: match.id, - tournamentId: match.tournamentId, - bracketPosition: match.bracketPosition, - team1Id: match.team1Id, - team2Id: match.team2Id, + return { + status: "ok" as const, + isDecided, + winnerId, + loserId, + match: { + id: lockedMatch.id, + tournamentId: lockedMatch.tournamentId, + bracketPosition: lockedMatch.bracketPosition, + team1Id: lockedMatch.team1Id, + team2Id: lockedMatch.team2Id, round: { - roundNumber: match.round.roundNumber, - bracket: match.round.bracket, + roundNumber: lockedMatch.round.roundNumber, + bracket: lockedMatch.round.bracket, }, tournament: { - format: match.tournament.format, - bestOf: match.tournament.bestOf, - grandFinalReset: match.tournament.grandFinalReset, + format: lockedMatch.tournament.format, + bestOf: lockedMatch.tournament.bestOf, + grandFinalReset: lockedMatch.tournament.grandFinalReset, }, }, - winnerId, - loserId + }; + }); + + if (mutation.status === "not_found") { + event.outcome = "not_found"; + event.statusCode = 404; + return Response.json( + { error: "Tournament map not found" }, + { status: 404 } ); } + if (mutation.status === "match_completed") { + event.outcome = "match_completed"; + event.statusCode = 409; + return Response.json( + { error: "Cannot change winners after a match is completed" }, + { status: 409 } + ); + } + + event.matchDecided = mutation.isDecided; + + if (mutation.isDecided && mutation.winnerId && mutation.loserId) { + await advanceMatch(mutation.match, mutation.winnerId, mutation.loserId); + } + after(async () => { await auditLog.createAuditLog({ userEmail: session.user.email, @@ -172,6 +228,7 @@ export async function POST( event.statusCode = 200; return Response.json({ success: true }); } catch (e) { + unstable_rethrow(e); event.outcome = "error"; event.statusCode = 500; event.error = e instanceof Error ? e.message : String(e); diff --git a/src/app/api/tournament/match/[matchId]/add-map/route.ts b/src/app/api/tournament/match/[matchId]/add-map/route.ts index bdb18e7d0..8281ef036 100644 --- a/src/app/api/tournament/match/[matchId]/add-map/route.ts +++ b/src/app/api/tournament/match/[matchId]/add-map/route.ts @@ -5,8 +5,11 @@ import { createNewMap } from "@/lib/parser"; import prisma from "@/lib/prisma"; import { advanceMatch } from "@/lib/tournaments/advancement"; import { calculateWinner } from "@/lib/winrate"; +import { Prisma } from "@/generated/prisma/client"; import type { ParserData } from "@/types/parser"; -import { unauthorized } from "next/navigation"; +import { Ratelimit } from "@upstash/ratelimit"; +import { kv } from "@vercel/kv"; +import { unauthorized, unstable_rethrow } from "next/navigation"; import { after, type NextRequest } from "next/server"; type AddTournamentMapRequest = { @@ -14,6 +17,149 @@ type AddTournamentMapRequest = { heroBans?: { hero: string; team: string; banPosition: number }[]; gameNumber: number; }; +type HeroBan = NonNullable[number]; + +const MAX_EVENT_ROWS = 50_000; +const MAX_TOTAL_EVENT_ROWS = 200_000; +const MAX_ROW_STRING_LENGTH = 256; +const MAX_HERO_BANS = 20; +const TOURNAMENT_MATCH_LOCK_NAMESPACE = 61_042; + +const parserEventKeys = [ + "ability_1_used", + "ability_2_used", + "damage", + "defensive_assist", + "dva_remech", + "echo_duplicate_end", + "echo_duplicate_start", + "healing", + "hero_spawn", + "hero_swap", + "kill", + "match_end", + "match_start", + "mercy_rez", + "objective_captured", + "objective_updated", + "offensive_assist", + "payload_progress", + "player_stat", + "point_progress", + "remech_charged", + "round_end", + "round_start", + "setup_complete", + "ultimate_charged", + "ultimate_end", + "ultimate_start", +] as const; + +const requiredParserEventKeys = [ + "defensive_assist", + "hero_spawn", + "hero_swap", + "kill", + "match_start", + "objective_captured", + "objective_updated", + "offensive_assist", + "payload_progress", + "player_stat", + "point_progress", + "round_end", + "round_start", + "setup_complete", + "ultimate_charged", + "ultimate_end", + "ultimate_start", +] as const; + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function hasOversizedString(row: unknown[]) { + return row.some( + (cell) => typeof cell === "string" && cell.length > MAX_ROW_STRING_LENGTH + ); +} + +function isBoundedParserData(value: unknown): value is ParserData { + if (!isRecord(value)) return false; + + let totalRows = 0; + + for (const key of requiredParserEventKeys) { + if (!Array.isArray(value[key])) return false; + } + + for (const key of parserEventKeys) { + const rows = value[key]; + if (rows === undefined) continue; + if (!Array.isArray(rows) || rows.length > MAX_EVENT_ROWS) return false; + totalRows += rows.length; + if (totalRows > MAX_TOTAL_EVENT_ROWS) return false; + + for (const row of rows) { + if (!Array.isArray(row) || hasOversizedString(row)) return false; + } + } + + return true; +} + +function isHeroBan(value: unknown): value is HeroBan { + return ( + isRecord(value) && + typeof value.hero === "string" && + value.hero.length > 0 && + value.hero.length <= MAX_ROW_STRING_LENGTH && + typeof value.team === "string" && + value.team.length > 0 && + value.team.length <= MAX_ROW_STRING_LENGTH && + typeof value.banPosition === "number" && + Number.isInteger(value.banPosition) && + value.banPosition >= 0 + ); +} + +function parseAddTournamentMapRequest( + value: unknown +): AddTournamentMapRequest | null { + if (!isRecord(value)) return null; + const gameNumber = value.gameNumber; + if (typeof gameNumber !== "number" || !Number.isInteger(gameNumber)) { + return null; + } + if (gameNumber < 1) return null; + if (!isBoundedParserData(value.map)) return null; + + let heroBans: HeroBan[] | undefined; + if (value.heroBans !== undefined) { + const rawHeroBans = value.heroBans; + if ( + !Array.isArray(rawHeroBans) || + rawHeroBans.length > MAX_HERO_BANS || + !rawHeroBans.every(isHeroBan) + ) { + return null; + } + heroBans = rawHeroBans; + } + + return { + map: value.map, + heroBans, + gameNumber, + }; +} + +const tournamentMapUploadLimiter = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(3, "1 m"), + analytics: true, +}); export async function POST( req: NextRequest, @@ -42,13 +188,22 @@ export async function POST( return Response.json({ error: "Invalid match ID" }, { status: 400 }); } - const data = (await req.json()) as AddTournamentMapRequest; - event.gameNumber = data.gameNumber; - if (!data.map) { + const { success: uploadAllowed } = await tournamentMapUploadLimiter.limit( + `${session.user.email}:${matchId}` + ); + if (!uploadAllowed) { + event.outcome = "rate_limited"; + event.statusCode = 429; + return Response.json({ error: "Rate limit exceeded" }, { status: 429 }); + } + + const data = parseAddTournamentMapRequest(await req.json()); + if (!data) { event.outcome = "invalid_map_data"; event.statusCode = 400; return Response.json({ error: "Invalid map data" }, { status: 400 }); } + event.gameNumber = data.gameNumber; const match = await prisma.tournamentMatch.findUnique({ where: { id: matchId }, @@ -69,6 +224,26 @@ export async function POST( event.tournamentId = match.tournamentId; + if ( + match.status === "COMPLETED" || + match.tournament.status === "COMPLETED" || + match.tournament.status === "CANCELLED" + ) { + event.outcome = "match_locked"; + event.statusCode = 409; + return Response.json( + { error: "Cannot add maps to this match" }, + { status: 409 } + ); + } + + const bestOf = match.round.bestOf ?? match.tournament.bestOf; + if (data.gameNumber > bestOf) { + event.outcome = "invalid_game_number"; + event.statusCode = 400; + return Response.json({ error: "Invalid game number" }, { status: 400 }); + } + const user = await prisma.user.findUnique({ where: { email: session.user.email }, select: { id: true, role: true }, @@ -83,6 +258,15 @@ export async function POST( return Response.json({ error: "Forbidden" }, { status: 403 }); } + if (match.maps.some((map) => map.gameNumber === data.gameNumber)) { + event.outcome = "duplicate_game_number"; + event.statusCode = 409; + return Response.json( + { error: "A map already exists for this game number" }, + { status: 409 } + ); + } + if (!match.scrimId) { event.outcome = "no_scrim"; event.statusCode = 500; @@ -92,18 +276,45 @@ export async function POST( ); } - await createNewMap( + let tournamentMap; + try { + tournamentMap = await prisma.tournamentMap.create({ + data: { + matchId: match.id, + gameNumber: data.gameNumber, + }, + }); + } catch (error) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === "P2002" + ) { + event.outcome = "duplicate_game_number"; + event.statusCode = 409; + return Response.json( + { error: "A map already exists for this game number" }, + { status: 409 } + ); + } + throw error; + } + + const createdMap = await createNewMap( { map: data.map, scrimId: match.scrimId, heroBans: data.heroBans, }, session - ); + ).catch(async (error) => { + await prisma.tournamentMap + .delete({ where: { id: tournamentMap.id } }) + .catch(() => undefined); + throw error; + }); - const newMap = await prisma.map.findFirst({ - where: { scrimId: match.scrimId }, - orderBy: { createdAt: "desc" }, + const newMap = await prisma.map.findUnique({ + where: { id: createdMap.mapId }, include: { mapData: { include: { @@ -119,19 +330,17 @@ export async function POST( }); if (!newMap) { + await prisma.tournamentMap + .delete({ where: { id: tournamentMap.id } }) + .catch(() => undefined); + await prisma.map + .delete({ where: { id: createdMap.mapId } }) + .catch(() => undefined); event.outcome = "map_creation_failed"; event.statusCode = 500; return Response.json({ error: "Failed to create map" }, { status: 500 }); } - const tournamentMap = await prisma.tournamentMap.create({ - data: { - matchId: match.id, - gameNumber: data.gameNumber, - mapId: newMap.id, - }, - }); - const mapData = newMap.mapData[0]; let winner: string | null = null; @@ -188,14 +397,42 @@ export async function POST( } else { winner = result; } + } + } - await prisma.tournamentMap.update({ - where: { id: tournamentMap.id }, - data: { winnerOverride: winner }, - }); + const finalization = await finalizeTournamentMap({ + tournamentMapId: tournamentMap.id, + matchId: match.id, + mapId: newMap.id, + winner, + }); - await updateMatchScores(match.id); - } + if (finalization.status === "not_found") { + event.outcome = "map_creation_failed"; + event.statusCode = 500; + return Response.json({ error: "Failed to create map" }, { status: 500 }); + } + + if (finalization.status === "match_completed") { + event.outcome = "match_locked"; + event.statusCode = 409; + return Response.json( + { error: "Cannot add maps to this match" }, + { status: 409 } + ); + } + + if ( + finalization.isDecided && + finalization.winnerId && + finalization.loserId && + finalization.match + ) { + await advanceMatch( + finalization.match, + finalization.winnerId, + finalization.loserId + ); } event.winner = winner ?? "auto"; @@ -219,6 +456,7 @@ export async function POST( needsManualWinner: winner === null, }); } catch (e) { + unstable_rethrow(e); event.outcome = "error"; event.statusCode = 500; event.error = e instanceof Error ? e.message : String(e); @@ -233,54 +471,113 @@ export async function POST( } } -async function updateMatchScores(matchId: number) { - const match = await prisma.tournamentMatch.findUnique({ - where: { id: matchId }, - include: { - tournament: true, - team1: true, - team2: true, - round: true, - maps: true, - }, - }); +async function finalizeTournamentMap({ + tournamentMapId, + matchId, + mapId, + winner, +}: { + tournamentMapId: number; + matchId: number; + mapId: number; + winner: string | null; +}) { + return await prisma.$transaction(async (tx) => { + await tx.$executeRaw`SELECT pg_advisory_xact_lock(${TOURNAMENT_MATCH_LOCK_NAMESPACE}::integer, ${matchId}::integer)`; + + const tournamentMap = await tx.tournamentMap.findUnique({ + where: { id: tournamentMapId }, + select: { id: true, matchId: true }, + }); - if (!match?.team1 || !match.team2) return; + if (!tournamentMap || tournamentMap.matchId !== matchId) { + await tx.map.delete({ where: { id: mapId } }).catch(() => undefined); + return { status: "not_found" as const }; + } - let team1Wins = 0; - let team2Wins = 0; + const match = await tx.tournamentMatch.findUnique({ + where: { id: matchId }, + include: { + tournament: true, + team1: true, + team2: true, + round: true, + maps: true, + }, + }); - for (const map of match.maps) { - if (map.winnerOverride === match.team1.name) team1Wins++; - else if (map.winnerOverride === match.team2.name) team2Wins++; - } + if (!match) { + await tx.tournamentMap.delete({ where: { id: tournamentMapId } }); + await tx.map.delete({ where: { id: mapId } }).catch(() => undefined); + return { status: "not_found" as const }; + } - const bestOf = match.round.bestOf ?? match.tournament.bestOf; - const winsNeeded = Math.ceil(bestOf / 2); + if (match.status === "COMPLETED" && match.winnerId) { + await tx.tournamentMap.delete({ where: { id: tournamentMapId } }); + await tx.map.delete({ where: { id: mapId } }).catch(() => undefined); + return { status: "match_completed" as const }; + } - const isDecided = team1Wins >= winsNeeded || team2Wins >= winsNeeded; - const winnerId = - team1Wins >= winsNeeded - ? match.team1Id - : team2Wins >= winsNeeded - ? match.team2Id - : null; - - await prisma.tournamentMatch.update({ - where: { id: matchId }, - data: { - team1Score: team1Wins, - team2Score: team2Wins, - status: isDecided ? "COMPLETED" : "ONGOING", - winnerId: isDecided ? winnerId : null, - }, - }); + await tx.tournamentMap.update({ + where: { id: tournamentMapId }, + data: { + mapId, + winnerOverride: winner, + }, + }); - if (isDecided && winnerId) { - const loserId = winnerId === match.team1Id ? match.team2Id : match.team1Id; + if (!winner || !match.team1 || !match.team2) { + return { + status: "ok" as const, + isDecided: false, + winnerId: null, + loserId: null, + match: null, + }; + } - await advanceMatch( - { + let team1Wins = 0; + let team2Wins = 0; + + for (const map of match.maps) { + const mapWinner = + map.id === tournamentMapId ? winner : map.winnerOverride; + if (mapWinner === match.team1.name) team1Wins++; + else if (mapWinner === match.team2.name) team2Wins++; + } + + const bestOf = match.round.bestOf ?? match.tournament.bestOf; + const winsNeeded = Math.ceil(bestOf / 2); + const isDecided = team1Wins >= winsNeeded || team2Wins >= winsNeeded; + const winnerId = + team1Wins >= winsNeeded + ? match.team1Id + : team2Wins >= winsNeeded + ? match.team2Id + : null; + + await tx.tournamentMatch.update({ + where: { id: matchId }, + data: { + team1Score: team1Wins, + team2Score: team2Wins, + status: isDecided ? "COMPLETED" : "ONGOING", + winnerId: isDecided ? winnerId : null, + }, + }); + + const loserId = winnerId + ? winnerId === match.team1Id + ? match.team2Id + : match.team1Id + : null; + + return { + status: "ok" as const, + isDecided, + winnerId, + loserId, + match: { id: match.id, tournamentId: match.tournamentId, bracketPosition: match.bracketPosition, @@ -296,8 +593,6 @@ async function updateMatchScores(matchId: number) { grandFinalReset: match.tournament.grandFinalReset, }, }, - winnerId, - loserId - ); - } + }; + }); } diff --git a/src/app/api/usage/ingest/route.ts b/src/app/api/usage/ingest/route.ts new file mode 100644 index 000000000..7e79bf3e7 --- /dev/null +++ b/src/app/api/usage/ingest/route.ts @@ -0,0 +1,73 @@ +import { Effect } from "effect"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import { Logger } from "@/lib/logger"; +import { normalizePath } from "@/lib/usage/normalize"; +import { usage } from "@/lib/usage/server"; +import { Ratelimit } from "@upstash/ratelimit"; +import { ipAddress } from "@vercel/functions"; +import { kv } from "@vercel/kv"; +import type { NextRequest } from "next/server"; +import { z } from "zod"; + +export const runtime = "nodejs"; + +const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(120, "1 m"), + analytics: true, + prefix: "ratelimit:usage-ingest", +}); + +const IngestSchema = z.object({ + name: z.string().min(1).max(120), + path: z.string().max(2048).optional(), + sessionId: z.string().max(128).optional(), + teamId: z.number().int().optional(), + props: z.record(z.string(), z.unknown()).optional(), +}); + +export async function POST(req: NextRequest) { + const identifier = ipAddress(req) ?? "127.0.0.1"; + const { success } = await ratelimit.limit(identifier); + if (!success) { + return new Response("Rate limit exceeded", { status: 429 }); + } + + let body: unknown; + try { + body = await req.json(); + } catch { + return new Response("Bad request", { status: 400 }); + } + + const parsed = IngestSchema.safeParse(body); + if (!parsed.success) { + return new Response("Bad request", { status: 400 }); + } + + // Resolve the user from the session (do not trust a client-supplied userId). + const session = await auth(); + let userId: string | null = null; + if (session?.user?.email) { + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ).catch(() => null); + userId = user?.id ?? null; + } + + // environment is stamped server-side inside usage.track via resolveUsageEnv. + void usage.track({ + name: parsed.data.name, + source: "CLIENT", + userId, + teamId: parsed.data.teamId ?? null, + path: parsed.data.path ? normalizePath(parsed.data.path) : null, + sessionId: parsed.data.sessionId ?? null, + props: parsed.data.props ?? null, + }); + + Logger.info("usage.ingest", { name: parsed.data.name }); + return new Response(null, { status: 204 }); +} diff --git a/src/app/api/user/app-settings/route.ts b/src/app/api/user/app-settings/route.ts index 5bd091d4d..0cf8761af 100644 --- a/src/app/api/user/app-settings/route.ts +++ b/src/app/api/user/app-settings/route.ts @@ -4,7 +4,7 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; import type { NextRequest } from "next/server"; import { z } from "zod"; @@ -27,32 +27,23 @@ export async function GET() { } try { - let appSettings = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getAppSettings(session.user.email)) - ) + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) ); - - // If no app settings exist, create default ones - if (!appSettings) { - const user = await AppRuntime.runPromise( - UserService.pipe( - Effect.flatMap((svc) => svc.getUser(session.user.email)) - ) - ); - if (!user) { - Logger.error("User not found when creating app settings"); - return new Response("User not found", { status: 404 }); - } - - appSettings = await prisma.appSettings.create({ - data: { - userId: user.id, - colorblindMode: $Enums.ColorblindMode.OFF, - }, - }); + if (!user) { + Logger.error("User not found when creating app settings"); + return new Response("User not found", { status: 404 }); } + const appSettings = await prisma.appSettings.upsert({ + where: { userId: user.id }, + update: {}, + create: { + userId: user.id, + colorblindMode: $Enums.ColorblindMode.OFF, + }, + }); + const response: GetAppSettingsResponse = { id: appSettings.id, userId: appSettings.userId, @@ -100,34 +91,21 @@ export async function PUT(request: NextRequest) { return new Response("User not found", { status: 404 }); } - // Try to find existing settings first - const existingSettings = await prisma.appSettings.findFirst({ + const appSettings = await prisma.appSettings.upsert({ where: { userId: user.id }, + update: { + colorblindMode: validatedData.colorblindMode, + customTeam1Color: validatedData.customTeam1Color, + customTeam2Color: validatedData.customTeam2Color, + }, + create: { + userId: user.id, + colorblindMode: validatedData.colorblindMode, + customTeam1Color: validatedData.customTeam1Color, + customTeam2Color: validatedData.customTeam2Color, + }, }); - let appSettings; - if (existingSettings) { - // Update existing settings - appSettings = await prisma.appSettings.update({ - where: { id: existingSettings.id }, - data: { - colorblindMode: validatedData.colorblindMode, - customTeam1Color: validatedData.customTeam1Color, - customTeam2Color: validatedData.customTeam2Color, - }, - }); - } else { - // Create new settings - appSettings = await prisma.appSettings.create({ - data: { - userId: user.id, - colorblindMode: validatedData.colorblindMode, - customTeam1Color: validatedData.customTeam1Color, - customTeam2Color: validatedData.customTeam2Color, - }, - }); - } - const response: GetAppSettingsResponse = { id: appSettings.id, userId: appSettings.userId, diff --git a/src/app/api/user/avatar-upload/route.ts b/src/app/api/user/avatar-upload/route.ts index d1144aa12..52876403f 100644 --- a/src/app/api/user/avatar-upload/route.ts +++ b/src/app/api/user/avatar-upload/route.ts @@ -1,88 +1,78 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; import { Ratelimit } from "@upstash/ratelimit"; import { track } from "@vercel/analytics/server"; import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; import { kv } from "@vercel/kv"; -import { unauthorized } from "next/navigation"; import { type NextRequest, NextResponse } from "next/server"; export async function POST(request: NextRequest): Promise { - const session = await auth(); - - if (!session?.user) { - unauthorized(); - } - - const authedUser = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); - - if (!authedUser) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } - const body = (await request.json()) as HandleUploadBody; const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (body.type === "blob.generate-client-token" && !userId) { return NextResponse.json({ error: "userId is required" }, { status: 400 }); } - // Create a new ratelimiter, that allows 5 requests per 1 minute - const ratelimit = new Ratelimit({ - redis: kv, - limiter: Ratelimit.slidingWindow(5, "1 m"), - analytics: true, + Logger.info("Handling avatar upload request", { + userId: userId ?? "callback", + type: body.type, }); - // Limit the requests to 5 per minute per user - const identifier = `api/image-upload/${authedUser.id}`; - const { success } = await ratelimit.limit(identifier); - - if (!success) { - Logger.warn(`Rate limit exceeded for user: ${authedUser.id}`); - return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 }); - } - - Logger.info(`Uploading avatar for user: ${userId}`); - try { const jsonResponse = await handleUpload({ body, request, - onBeforeGenerateToken: async () => { + onBeforeGenerateToken: async (pathname) => { // pathname: string /* clientPayload?: string, */ // Generate a client token for the browser to upload the file // ⚠️ Authenticate and authorize users before generating the token. // Otherwise, you're allowing anonymous uploads. - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new Error("User not found"); + const authedUser = await getCurrentUser(); + if (!authedUser) throw new Error("Unauthorized"); + if (authedUser.id !== userId) throw new Error("Forbidden"); + if (pathname !== `avatars/${authedUser.id}.png`) { + throw new Error("Invalid upload path"); + } - return { tokenPayload: JSON.stringify({ userId: user.id }) }; + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(5, "1 m"), + analytics: true, + }); + const { success } = await ratelimit.limit( + `api/image-upload/${authedUser.id}` + ); + if (!success) throw new Error("Rate limit exceeded"); + + return { + tokenPayload: JSON.stringify({ userId: authedUser.id }), + allowedContentTypes: ["image/png", "image/jpeg", "image/webp"], + maximumSizeInBytes: 5 * 1024 * 1024, + }; }, onUploadCompleted: async ({ blob, tokenPayload }) => { // Get notified of client upload completion // ⚠️ This will not work on `localhost` websites, // Use ngrok or similar to get the full upload flow - Logger.info(`blob upload completed: ${blob.url} for user: ${userId}`); - await track("Image Upload", { label: "User Avatar" }); - try { // Run any logic after the file upload completed - const { userId } = JSON.parse(tokenPayload!) as { userId: string }; - - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new Error("User not found"); + const { userId } = JSON.parse(tokenPayload ?? "{}") as { + userId?: string; + }; + if (!userId) throw new Error("Missing token payload userId"); + + Logger.info("Avatar blob upload completed", { + blobUrl: blob.url, + userId, + }); + await track("Image Upload", { label: "User Avatar" }); await prisma.user.update({ - where: { id: user?.id }, + where: { id: userId }, data: { image: blob.url }, }); } catch { diff --git a/src/app/api/user/award-titles/route.ts b/src/app/api/user/award-titles/route.ts index e969a63b4..9808e8131 100644 --- a/src/app/api/user/award-titles/route.ts +++ b/src/app/api/user/award-titles/route.ts @@ -3,8 +3,9 @@ import { Logger } from "@/lib/logger"; import { notifications } from "@/lib/notifications"; import prisma from "@/lib/prisma"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { get } from "@vercel/edge-config"; +import type { NextRequest } from "next/server"; // Title display names for notifications const TITLE_DISPLAY_NAMES: Record<$Enums.Title, string> = { @@ -63,8 +64,11 @@ async function awardTitleWithNotification( ); } -export async function GET() { - // No auth since this will run via cron +export async function GET(req: NextRequest) { + const secret = process.env.CRON_SECRET; + if (!secret || req.headers.get("Authorization") !== `Bearer ${secret}`) { + return new Response("Unauthorized", { status: 401 }); + } // Fetch all user data in parallel const [ diff --git a/src/app/api/user/banner-upload/route.ts b/src/app/api/user/banner-upload/route.ts index 2d0837ecd..4a7ffc859 100644 --- a/src/app/api/user/banner-upload/route.ts +++ b/src/app/api/user/banner-upload/route.ts @@ -1,76 +1,74 @@ -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; +import { $Enums, type User } from "@/generated/prisma/browser"; import { Ratelimit } from "@upstash/ratelimit"; import { track } from "@vercel/analytics/server"; import { handleUpload, type HandleUploadBody } from "@vercel/blob/client"; import { kv } from "@vercel/kv"; -import { unauthorized } from "next/navigation"; import { type NextRequest, NextResponse } from "next/server"; -export async function POST(request: NextRequest): Promise { - const session = await auth(); - - if (!session?.user) { - unauthorized(); - } - - const authedUser = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) - ); - - if (!authedUser) { - return NextResponse.json({ error: "User not found" }, { status: 404 }); - } +function canUploadBanner(user: Pick) { + return user.billingPlan === $Enums.BillingPlan.PREMIUM || isAdminUser(user); +} +export async function POST(request: NextRequest): Promise { const body = (await request.json()) as HandleUploadBody; const userId = request.nextUrl.searchParams.get("userId"); - if (!userId) { + if (body.type === "blob.generate-client-token" && !userId) { return NextResponse.json({ error: "userId is required" }, { status: 400 }); } - const ratelimit = new Ratelimit({ - redis: kv, - limiter: Ratelimit.slidingWindow(5, "1 m"), - analytics: true, + Logger.info("Handling banner upload request", { + userId: userId ?? "callback", + type: body.type, }); - const identifier = `api/image-upload/${authedUser.id}`; - const { success } = await ratelimit.limit(identifier); - - if (!success) { - Logger.warn(`Rate limit exceeded for user: ${authedUser.id}`); - return NextResponse.json({ error: "Rate limit exceeded" }, { status: 429 }); - } - - Logger.info(`Uploading banner for user: ${userId}`); - try { const jsonResponse = await handleUpload({ body, request, - onBeforeGenerateToken: async () => { - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new Error("User not found"); + onBeforeGenerateToken: async (pathname) => { + const authedUser = await getCurrentUser(); + if (!authedUser) throw new Error("Unauthorized"); + if (authedUser.id !== userId) throw new Error("Forbidden"); + if (!canUploadBanner(authedUser)) throw new Error("Premium required"); + if (pathname !== `banners/${authedUser.id}.png`) { + throw new Error("Invalid upload path"); + } - return { tokenPayload: JSON.stringify({ userId: user.id }) }; + const ratelimit = new Ratelimit({ + redis: kv, + limiter: Ratelimit.slidingWindow(5, "1 m"), + analytics: true, + }); + const { success } = await ratelimit.limit( + `api/image-upload/${authedUser.id}` + ); + if (!success) throw new Error("Rate limit exceeded"); + + return { + tokenPayload: JSON.stringify({ userId: authedUser.id }), + allowedContentTypes: ["image/png", "image/jpeg", "image/webp"], + maximumSizeInBytes: 5 * 1024 * 1024, + }; }, onUploadCompleted: async ({ blob, tokenPayload }) => { - Logger.info(`blob upload completed: ${blob.url} for user: ${userId}`); - await track("Image Upload", { label: "User Banner" }); - try { - const { userId } = JSON.parse(tokenPayload!) as { userId: string }; - - const user = await prisma.user.findUnique({ where: { id: userId } }); - if (!user) throw new Error("User not found"); + const { userId } = JSON.parse(tokenPayload ?? "{}") as { + userId?: string; + }; + if (!userId) throw new Error("Missing token payload userId"); + + Logger.info("Banner blob upload completed", { + blobUrl: blob.url, + userId, + }); + await track("Image Upload", { label: "User Banner" }); await prisma.user.update({ - where: { id: user?.id }, + where: { id: userId }, data: { bannerImage: blob.url }, }); } catch { diff --git a/src/app/api/user/delete-account/route.ts b/src/app/api/user/delete-account/route.ts index a3655e2a3..fe72b0ba8 100644 --- a/src/app/api/user/delete-account/route.ts +++ b/src/app/api/user/delete-account/route.ts @@ -22,16 +22,17 @@ export async function DELETE() { ); if (!user) unauthorized(); - await track("User Deleted Account", { email: user.email }); - - const wh = deleteUserWebhookConstructor(user); - await sendDiscordWebhook(process.env.DISCORD_WEBHOOK_URL, wh); - - Logger.info(`User ${user.email} deleted their account`); - await prisma.user.delete({ where: { id: user.id } }); after(async () => { + await Promise.allSettled([ + track("User Deleted Account", { email: user.email }), + sendDiscordWebhook( + process.env.DISCORD_WEBHOOK_URL, + deleteUserWebhookConstructor(user) + ), + ]); + Logger.info(`User ${user.email} deleted their account`); await auditLog.createAuditLog({ userEmail: user.email, action: "USER_ACCOUNT_DELETED", diff --git a/src/app/api/user/update-banner/route.ts b/src/app/api/user/update-banner/route.ts index 3818deff5..c288bf29b 100644 --- a/src/app/api/user/update-banner/route.ts +++ b/src/app/api/user/update-banner/route.ts @@ -1,33 +1,53 @@ import { auditLog } from "@/lib/audit-logs"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { Logger } from "@/lib/logger"; import prisma from "@/lib/prisma"; +import { $Enums, type User } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; import { after, type NextRequest } from "next/server"; import { z } from "zod"; -const BannerUpdateSchema = z.object({ - userId: z.string().min(1), - bannerImage: z.url(), -}); +function canUseBanner(user: Pick) { + return user.billingPlan === $Enums.BillingPlan.PREMIUM || isAdminUser(user); +} + +function isExpectedBlobUrl(urlString: string, userId: string) { + try { + const url = new URL(urlString); + return ( + url.protocol === "https:" && + url.hostname.endsWith(".public.blob.vercel-storage.com") && + url.pathname.startsWith(`/banners/${userId}`) + ); + } catch { + return false; + } +} + +const BannerUpdateSchema = z + .object({ + userId: z.string().min(1), + bannerImage: z.url(), + }) + .refine((data) => isExpectedBlobUrl(data.bannerImage, data.userId), { + message: "Invalid banner image URL", + path: ["bannerImage"], + }); export async function POST(req: NextRequest) { - const session = await auth(); - if (!session?.user) unauthorized(); + const user = await getCurrentUser(); + if (!user) unauthorized(); const body = BannerUpdateSchema.safeParse(await req.json()); if (!body.success) { return new Response("Invalid request", { status: 400 }); } - const user = await prisma.user.findUnique({ - where: { id: body.data.userId }, - }); - if (!user) return new Response("Not found", { status: 404 }); - - if (user.email !== session.user.email) { + if (body.data.userId !== user.id) { unauthorized(); } + if (!canUseBanner(user)) + return new Response("Premium required", { status: 403 }); await prisma.user.update({ where: { id: user.id }, diff --git a/src/app/api/user/update-battletag/route.ts b/src/app/api/user/update-battletag/route.ts index ba5ea10d2..ab9f7e25d 100644 --- a/src/app/api/user/update-battletag/route.ts +++ b/src/app/api/user/update-battletag/route.ts @@ -6,16 +6,23 @@ import { after, type NextRequest } from "next/server"; import { z } from "zod"; const UpdateBattletagSchema = z.object({ - battletag: z - .string() - .min(2, { - message: "Battletag must be at least 2 characters.", - }) - .max(12, { - message: "Battletag must not be longer than 12 characters.", - }) - .trim() - .regex(/^(?!.*?:).*$/), + battletag: z.preprocess( + (value) => + typeof value === "string" && value.trim() === "" ? null : value, + z.union([ + z + .string() + .min(2, { + message: "Battletag must be at least 2 characters.", + }) + .max(12, { + message: "Battletag must not be longer than 12 characters.", + }) + .trim() + .regex(/^(?!.*?:).*$/), + z.null(), + ]) + ), }); export async function POST(req: NextRequest) { diff --git a/src/app/api/user/update-title/route.ts b/src/app/api/user/update-title/route.ts index ab6a8e982..dd729829f 100644 --- a/src/app/api/user/update-title/route.ts +++ b/src/app/api/user/update-title/route.ts @@ -1,6 +1,6 @@ -import { auth } from "@/lib/auth"; +import { auth, getCurrentUser, isAdminUser } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { unauthorized } from "next/navigation"; import type { NextRequest } from "next/server"; import { z } from "zod"; @@ -20,6 +20,13 @@ export async function POST(req: NextRequest) { return new Response("Invalid request body", { status: 400 }); } + const currentUser = await getCurrentUser(); + if (!currentUser) unauthorized(); + + if (body.data.userId !== currentUser.id && !isAdminUser(currentUser)) { + return new Response("Forbidden", { status: 403 }); + } + // list of all titles this user has unlocked const userTitles = await prisma.user.findUnique({ where: { id: body.data.userId }, @@ -27,21 +34,14 @@ export async function POST(req: NextRequest) { }); if (userTitles?.titles.includes(body.data.newTitle)) { - // set the applied title to the new title - const existingTitle = await prisma.appliedTitle.findFirst({ - where: { userId: body.data.userId }, - }); - - if (existingTitle) { - await prisma.appliedTitle.update({ - where: { id: existingTitle.id }, - data: { title: body.data.newTitle }, + await prisma.$transaction(async (tx) => { + await tx.appliedTitle.deleteMany({ + where: { userId: body.data.userId }, }); - } else { - await prisma.appliedTitle.create({ + await tx.appliedTitle.create({ data: { userId: body.data.userId, title: body.data.newTitle }, }); - } + }); } else { // deny the request return new Response("Title not allowed to be applied to this user", { diff --git a/src/app/api/vod/route.ts b/src/app/api/vod/route.ts index 877882c1b..d7a910952 100644 --- a/src/app/api/vod/route.ts +++ b/src/app/api/vod/route.ts @@ -1,27 +1,30 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, canEditScrim } from "@/lib/auth"; import { Logger } from "@/lib/logger"; +import { parseVodUrl } from "@/lib/vods"; import { NextResponse } from "next/server"; import z from "zod"; import prisma from "@/lib/prisma"; import { unauthorized } from "next/navigation"; -const ALLOWED_DOMAINS = [ - "https://www.youtube.com/", - "https://youtube.com/", - "https://youtu.be/", - "https://www.twitch.tv/", - "https://twitch.tv/", -]; const VodSchema = z.object({ mapId: z.number().min(1), vodUrl: z .string() - .url() - .refine((url) => { - return ALLOWED_DOMAINS.some((domain) => url.startsWith(domain)); + .min(1) + .transform((url, ctx) => { + const parsedVod = parseVodUrl(url); + if (!parsedVod) { + ctx.addIssue({ + code: "custom", + message: "Invalid VOD URL", + }); + return z.NEVER; + } + + return parsedVod.normalizedUrl; }), }); @@ -49,6 +52,17 @@ export async function POST(req: Request) { const { mapId, vodUrl } = body.data; + const map = await prisma.map.findUnique({ + where: { id: mapId }, + select: { scrimId: true }, + }); + if (!map?.scrimId) { + return new NextResponse("Map not found", { status: 404 }); + } + if (!(await canEditScrim(map.scrimId, user))) { + return new NextResponse("Forbidden", { status: 403 }); + } + const updatedVod = await prisma.map.update({ where: { id: mapId }, data: { vod: vodUrl }, diff --git a/src/app/chat/layout.tsx b/src/app/chat/layout.tsx index 7d1502730..10fc940f8 100644 --- a/src/app/chat/layout.tsx +++ b/src/app/chat/layout.tsx @@ -1,12 +1,15 @@ import { ChatSidebar } from "@/components/chat/chat-sidebar"; import { DashboardLayout } from "@/components/dashboard-layout"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; -export const metadata: Metadata = { - title: "Analyst | Parsertime", - description: - "Your AI analyst for Overwatch scrim data, player performance, and team trends.", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("analyst.metadata"); + return { + title: t("title"), + description: t("description"), + }; +} export default function ChatLayout({ children, diff --git a/src/app/coaching/canvas/page.tsx b/src/app/coaching/canvas/page.tsx index df8d60edd..908d96bc7 100644 --- a/src/app/coaching/canvas/page.tsx +++ b/src/app/coaching/canvas/page.tsx @@ -10,14 +10,17 @@ export default async function CoachingCanvasPage() { const t = await getTranslations("coaching"); return ( -
-
-
-

{t("title")}

-

{t("subtitle")}

-
-
-
+
+
+

+ {t("eyebrow")} +

+

+ {t("title")} +

+

{t("subtitle")}

+
+
diff --git a/src/app/contact/layout.tsx b/src/app/contact/layout.tsx index e316a281c..2c91831de 100644 --- a/src/app/contact/layout.tsx +++ b/src/app/contact/layout.tsx @@ -1,5 +1,32 @@ import { Footer } from "@/components/footer"; import { Header } from "@/components/header"; +import type { Metadata } from "next"; +import { getLocale, getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("contactPage.metadata"); + const locale = await getLocale(); + + return { + title: t("title"), + description: t("description"), + openGraph: { + title: t("ogTitle"), + description: t("ogDescription"), + url: "https://parsertime.app/contact", + type: "website", + siteName: "Parsertime", + images: [ + { + url: `https://parsertime.app/opengraph-image.png`, + width: 1200, + height: 630, + }, + ], + locale, + }, + }; +} export default function ContactLayout({ children }: LayoutProps<"/contact">) { return ( diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 236540882..b4e15ffe1 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -6,8 +6,9 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; +import { getPendingFeedbackCount } from "@/lib/team-ops/scrim-feedback"; import type { PagePropsWithLocale } from "@/types/next"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -50,31 +51,44 @@ export default async function DashboardPage() { getTranslations("dashboard"), ]); - const userData = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) - ); + const email = session?.user?.email; + + const [userData, manageableTeams] = await Promise.all([ + AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(email))) + ), + AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getTeamsWithPerms(email))) + ), + ]); const isAdmin = userData?.role === $Enums.UserRole.ADMIN; + const manageableTeamIds = manageableTeams.map((team) => team.id); + const pendingFeedbackCount = await getPendingFeedbackCount(manageableTeamIds); + return ( -
-
-

- {t("title")} -

+
+
+

{t("title")}

- + {pendingFeedbackCount > 0 && ( +

+ {t("pendingFeedback", { count: pendingFeedbackCount })} +

+ )} + {isAdmin && ( - + {t("overview")} {t("admin")} )} - + - + diff --git a/src/app/data-labeling/match/[matchId]/page.tsx b/src/app/data-labeling/match/[matchId]/page.tsx index 18f5d93fa..468a3592f 100644 --- a/src/app/data-labeling/match/[matchId]/page.tsx +++ b/src/app/data-labeling/match/[matchId]/page.tsx @@ -2,8 +2,9 @@ import { MatchLabelingView } from "@/components/data-labeling/match-labeling-vie import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { DataLabelingService } from "@/data/admin"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; type Params = { matchId: string }; @@ -14,6 +15,9 @@ export default async function MatchLabelingPage({ }) { const enabled = await dataLabeling(); if (!enabled) notFound(); + const user = await getCurrentUser(); + if (!user) redirect("/sign-in"); + if (!isAdminUser(user)) notFound(); const { matchId } = await params; const id = Number(matchId); diff --git a/src/app/data-labeling/page.tsx b/src/app/data-labeling/page.tsx index b0862c996..5ba9b91ef 100644 --- a/src/app/data-labeling/page.tsx +++ b/src/app/data-labeling/page.tsx @@ -1,11 +1,15 @@ import { UnlabeledMatchList } from "@/components/data-labeling/unlabeled-match-list"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import { getTranslations } from "next-intl/server"; -import { notFound } from "next/navigation"; +import { notFound, redirect } from "next/navigation"; export default async function DataLabelingPage() { const enabled = await dataLabeling(); if (!enabled) notFound(); + const user = await getCurrentUser(); + if (!user) redirect("/sign-in"); + if (!isAdminUser(user)) notFound(); const t = await getTranslations("dataLabeling"); diff --git a/src/app/debug/layout.tsx b/src/app/debug/layout.tsx index b98291f96..cce99fbf5 100644 --- a/src/app/debug/layout.tsx +++ b/src/app/debug/layout.tsx @@ -1,4 +1,11 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("debugPage.metadata"); + return { title: t("title"), description: t("description") }; +} export default function DebugLayout({ children }: LayoutProps<"/debug">) { return {children}; diff --git a/src/app/debug/page.tsx b/src/app/debug/page.tsx index 5e9518a6c..9741589dc 100644 --- a/src/app/debug/page.tsx +++ b/src/app/debug/page.tsx @@ -10,8 +10,8 @@ import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { Link } from "@/components/ui/link"; import { Separator } from "@/components/ui/separator"; -import { parseData } from "@/lib/parser-client"; -import { ParserDataSchema } from "@/lib/schema"; +import { parseData } from "@/lib/parser/client"; +import { ParserDataSchema } from "@/lib/parser/schema"; import { cn, detectCorruptedData } from "@/lib/utils"; import { PlusCircledIcon } from "@radix-ui/react-icons"; import { JsonEditor } from "json-edit-react"; diff --git a/src/app/demo/page.tsx b/src/app/demo/page.tsx index 4e4117303..30c333050 100644 --- a/src/app/demo/page.tsx +++ b/src/app/demo/page.tsx @@ -166,7 +166,12 @@ export default async function MapDashboardPage() { /> - + @@ -179,12 +184,7 @@ export default async function MapDashboardPage() { - + diff --git a/src/app/demo/player/[playerId]/page.tsx b/src/app/demo/player/[playerId]/page.tsx index 30793202f..f63e86f7d 100644 --- a/src/app/demo/player/[playerId]/page.tsx +++ b/src/app/demo/player/[playerId]/page.tsx @@ -5,6 +5,7 @@ import { LocaleSwitcher } from "@/components/locale-switcher"; import { PlayerSwitcher } from "@/components/map/player-switcher"; import { PlayerAnalytics } from "@/components/player/analytics"; import { DefaultOverview } from "@/components/player/default-overview"; +import { PlayerTelemetry } from "@/components/player/player-telemetry"; import { ModeToggle } from "@/components/theme-switcher"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { Effect } from "effect"; @@ -17,6 +18,15 @@ import type { PagePropsWithLocale } from "@/types/next"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; +import { notFound } from "next/navigation"; + +function decodePlayerId(playerId: string) { + try { + return decodeURIComponent(playerId); + } catch { + return null; + } +} export async function generateMetadata( props: PagePropsWithLocale<"/demo/player/[playerId]"> @@ -26,7 +36,7 @@ export async function generateMetadata( locale: params.locale, namespace: "mapPage.playerMetadata", }); - const playerName = decodeURIComponent(params.playerId); + const playerName = decodePlayerId(params.playerId) ?? "Player"; return { title: t("title", { playerName }), @@ -58,11 +68,22 @@ export default async function PlayerDashboardDemoPage( const t = await getTranslations("mapPage.player.dashboard"); const id = 10148; const mapDataId = await resolveMapDataId(id); - const playerName = decodeURIComponent(params.playerId); + const playerName = decodePlayerId(params.playerId); + + if (!playerName) { + notFound(); + } const mostPlayedHeroes = await AppRuntime.runPromise( PlayerService.pipe(Effect.flatMap((svc) => svc.getMostPlayedHeroes(id))) ); + const playerExists = mostPlayedHeroes.some( + (player) => player.player_name === playerName + ); + + if (!playerExists) { + notFound(); + } const mapName = await prisma.matchStart.findFirst({ where: { @@ -109,6 +130,7 @@ export default async function PlayerDashboardDemoPage( {t("overview")} {t("analytics")} {t("charts")} + {t("telemetry")} @@ -119,6 +141,9 @@ export default async function PlayerDashboardDemoPage( + + +
diff --git a/src/app/faceit/layout.tsx b/src/app/faceit/layout.tsx new file mode 100644 index 000000000..faa264124 --- /dev/null +++ b/src/app/faceit/layout.tsx @@ -0,0 +1,9 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; + +export default function FaceitLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/faceit/page.tsx b/src/app/faceit/page.tsx new file mode 100644 index 000000000..aa9922fcf --- /dev/null +++ b/src/app/faceit/page.tsx @@ -0,0 +1,42 @@ +import { FaceitTeamSearch } from "@/components/faceit/team-search"; +import { AppRuntime } from "@/data/runtime"; +import { FaceitTeamScoutingService } from "@/data/faceit"; +import { Effect } from "effect"; +import { faceitScouting } from "@/lib/flags"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("faceitScoutingPage"); + return { title: t("metadata.searchTitle"), description: t("subtitle") }; +} + +export default async function FaceitPage() { + const enabled = await faceitScouting(); + if (!enabled) notFound(); + + const t = await getTranslations("faceitScoutingPage"); + const teams = await AppRuntime.runPromise( + FaceitTeamScoutingService.pipe( + Effect.flatMap((svc) => svc.getFaceitTeams()) + ) + ); + + return ( +
+
+
+

+ {t("searchEyebrow")} +

+

{t("title")}

+

+ {t("subtitle")} +

+
+ +
+
+ ); +} diff --git a/src/app/faceit/player/[playerId]/page.tsx b/src/app/faceit/player/[playerId]/page.tsx new file mode 100644 index 000000000..2acd25f56 --- /dev/null +++ b/src/app/faceit/player/[playerId]/page.tsx @@ -0,0 +1,79 @@ +import { AppRuntime } from "@/data/runtime"; +import { FaceitPlayerScoutingService } from "@/data/faceit"; +import { Effect } from "effect"; +import { faceitScouting } from "@/lib/flags"; +import prisma from "@/lib/prisma"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { PlayerProfileHeader } from "@/components/faceit/player-profile-header"; +import { PlayerThreatAssessment } from "@/components/faceit/player-threat-assessment"; +import { PlayerFsrCard } from "@/components/faceit/player-fsr-card"; +import { PlayerStatProfile } from "@/components/faceit/player-stat-profile"; +import { PlayerRoleUsage } from "@/components/faceit/player-role-usage"; +import { PlayerMapWinrates } from "@/components/faceit/player-map-winrates"; +import { PlayerMatchHistory } from "@/components/faceit/player-match-history"; +import { PlayerTeams } from "@/components/faceit/player-teams"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ playerId: string }>; +}): Promise { + const { playerId } = await params; + const t = await getTranslations("faceitPlayerPage.metadata"); + const player = await prisma.faceitPlayer.findUnique({ + where: { faceitPlayerId: playerId }, + select: { faceitNickname: true }, + }); + const name = player?.faceitNickname ?? "FACEIT player"; + return { + title: t("profileTitle", { player: name }), + description: t("profileDescription", { player: name }), + }; +} + +export default async function FaceitPlayerPage({ + params, +}: { + params: Promise<{ playerId: string }>; +}) { + const enabled = await faceitScouting(); + if (!enabled) notFound(); + const { playerId } = await params; + const profile = await AppRuntime.runPromise( + FaceitPlayerScoutingService.pipe( + Effect.flatMap((svc) => svc.getFaceitPlayerProfile(playerId)) + ) + ); + if (!profile) notFound(); + const primary = + profile.fsrRoles.find((r) => r.primary) ?? profile.fsrRoles[0] ?? null; + + return ( +
+
+ + + {profile.rated ? ( + <> + + {primary ? : null} + + + ) : null} + + + +
+
+ ); +} diff --git a/src/app/faceit/player/page.tsx b/src/app/faceit/player/page.tsx new file mode 100644 index 000000000..39041d3fd --- /dev/null +++ b/src/app/faceit/player/page.tsx @@ -0,0 +1,40 @@ +import { FaceitPlayerSearch } from "@/components/faceit/player-search"; +import { AppRuntime } from "@/data/runtime"; +import { FaceitPlayerScoutingService } from "@/data/faceit"; +import { Effect } from "effect"; +import { faceitScouting } from "@/lib/flags"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("faceitPlayerPage"); + return { title: t("metadata.searchTitle"), description: t("subtitle") }; +} + +export default async function FaceitPlayerSearchPage() { + const enabled = await faceitScouting(); + if (!enabled) notFound(); + const t = await getTranslations("faceitPlayerPage"); + const players = await AppRuntime.runPromise( + FaceitPlayerScoutingService.pipe( + Effect.flatMap((svc) => svc.getFaceitPlayers()) + ) + ); + return ( +
+
+
+

+ {t("searchEyebrow")} +

+

{t("title")}

+

+ {t("subtitle")} +

+
+ +
+
+ ); +} diff --git a/src/app/faceit/team/[teamId]/page.tsx b/src/app/faceit/team/[teamId]/page.tsx new file mode 100644 index 000000000..2ff5a9370 --- /dev/null +++ b/src/app/faceit/team/[teamId]/page.tsx @@ -0,0 +1,79 @@ +import { AppRuntime } from "@/data/runtime"; +import { FaceitTeamScoutingService } from "@/data/faceit"; +import { Effect } from "effect"; +import { faceitScouting } from "@/lib/flags"; +import prisma from "@/lib/prisma"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { notFound } from "next/navigation"; +import { FaceitTeamHeader } from "@/components/faceit/faceit-team-header"; +import { FaceitGamePlan } from "@/components/faceit/faceit-game-plan"; +import { FaceitTeamOverview } from "@/components/faceit/faceit-team-overview"; +import { FaceitPatchTimeline } from "@/components/faceit/faceit-patch-timeline"; +import { FaceitMapPerformance } from "@/components/faceit/faceit-map-performance"; +import { FaceitHeroBanEnvironment } from "@/components/faceit/faceit-hero-ban-environment"; +import { FaceitRoster } from "@/components/faceit/faceit-roster"; + +export async function generateMetadata({ + params, +}: { + params: Promise<{ teamId: string }>; +}): Promise { + const { teamId } = await params; + const t = await getTranslations("faceitScoutingPage.metadata"); + const team = await prisma.faceitTeam.findUnique({ + where: { faceitTeamId: teamId }, + select: { name: true }, + }); + const name = team?.name ?? "FACEIT team"; + return { + title: t("profileTitle", { team: name }), + description: t("profileDescription", { team: name }), + }; +} + +export default async function FaceitTeamPage({ + params, + searchParams, +}: { + params: Promise<{ teamId: string }>; + searchParams: Promise<{ combined?: string }>; +}) { + const enabled = await faceitScouting(); + if (!enabled) notFound(); + + const { teamId } = await params; + const { combined: combinedParam } = await searchParams; + const combined = combinedParam === "1"; + + const profile = await AppRuntime.runPromise( + FaceitTeamScoutingService.pipe( + Effect.flatMap((svc) => svc.getFaceitTeamProfile(teamId, { combined })) + ) + ); + if (!profile) notFound(); + + return ( +
+
+ + + + + + + +
+
+ ); +} diff --git a/src/app/falling-halftone.ts b/src/app/falling-halftone.ts new file mode 100644 index 000000000..d9c43f919 --- /dev/null +++ b/src/app/falling-halftone.ts @@ -0,0 +1,4 @@ +// GENERATED by scripts/generate-halftone.ts — do not edit by hand. +// Source: public/falling.png. Re-run: bun scripts/generate-halftone.ts +export const fallingHalftoneSvg = + ''; diff --git a/src/app/globals.css b/src/app/globals.css index 733b08a3a..47c2750df 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -1,41 +1,44 @@ @import "tailwindcss"; @import "tw-animate-css"; -@custom-variant dark (&:is(.dark *)); +@custom-variant dark (&:is(.dark *, .disguised *)); :root { - --background: oklch(1 0 0); - --foreground: oklch(0.145 0 0); - --card: oklch(1 0 0); - --card-foreground: oklch(0.145 0 0); - --popover: oklch(1 0 0); - --popover-foreground: oklch(0.145 0 0); - --primary: oklch(0.51 0.23 277); - --primary-foreground: oklch(0.96 0.02 272); - --secondary: oklch(0.967 0.001 286.375); - --secondary-foreground: oklch(0.21 0.006 285.885); - --muted: oklch(0.97 0 0); - --muted-foreground: oklch(0.556 0 0); - --accent: oklch(0.97 0 0); - --accent-foreground: oklch(0.205 0 0); + --background: oklch(0.985 0.003 250); + --foreground: oklch(0.185 0.005 250); + --card: oklch(0.998 0.002 250); + --card-foreground: oklch(0.185 0.005 250); + --popover: oklch(0.998 0.002 250); + --popover-foreground: oklch(0.185 0.005 250); + --primary: oklch(0.55 0.17 68); + --primary-foreground: oklch(0.985 0.003 250); + --secondary: oklch(0.965 0.003 250); + --secondary-foreground: oklch(0.21 0.005 250); + --muted: oklch(0.965 0.003 250); + --muted-foreground: oklch(0.5 0.005 250); + --accent: oklch(0.965 0.003 250); + --accent-foreground: oklch(0.21 0.005 250); --destructive: oklch(0.58 0.22 27); - --border: oklch(0.922 0 0); - --input: oklch(0.922 0 0); - --ring: oklch(0.708 0 0); - --chart-1: oklch(0.85 0.18 275); - --chart-2: oklch(0.72 0.22 276); - --chart-3: oklch(0.6 0.24 277); - --chart-4: oklch(0.5 0.25 277); - --chart-5: oklch(0.4 0.22 277); + --destructive-foreground: oklch(0.98 0.002 250); + --border: oklch(0.915 0.004 250); + --input: oklch(0.915 0.004 250); + --ring: oklch(0.55 0.17 68); + --chart-1: oklch(0.55 0.17 68); + --chart-2: oklch(0.32 0.09 58); + --chart-3: oklch(0.72 0.16 78); + --chart-4: oklch(0.42 0.12 62); + --chart-5: oklch(0.22 0.04 50); + --chart-win: var(--primary); + --chart-loss: var(--destructive); --radius: 0.625rem; - --sidebar: oklch(0.985 0 0); - --sidebar-foreground: oklch(0.145 0 0); - --sidebar-primary: oklch(0.51 0.23 277); - --sidebar-primary-foreground: oklch(0.96 0.02 272); - --sidebar-accent: oklch(0.97 0 0); - --sidebar-accent-foreground: oklch(0.205 0 0); - --sidebar-border: oklch(0.922 0 0); - --sidebar-ring: oklch(0.708 0 0); + --sidebar: oklch(0.975 0.003 250); + --sidebar-foreground: oklch(0.185 0.005 250); + --sidebar-primary: oklch(0.55 0.17 68); + --sidebar-primary-foreground: oklch(0.985 0.003 250); + --sidebar-accent: oklch(0.945 0.004 250); + --sidebar-accent-foreground: oklch(0.21 0.005 250); + --sidebar-border: oklch(0.915 0.004 250); + --sidebar-ring: oklch(0.55 0.17 68); --team-1-off: oklch(0.753 0.153965 232.1809); --team-2-off: oklch(0.6218 0.224 17.51); --team-1-deuteranopia: oklch(0.6074 0.1504 244.22); @@ -47,37 +50,78 @@ } .dark { - --background: oklch(0.145 0 0); - --foreground: oklch(0.985 0 0); - --card: oklch(0.205 0 0); - --card-foreground: oklch(0.985 0 0); - --popover: oklch(0.205 0 0); - --popover-foreground: oklch(0.985 0 0); - --primary: oklch(0.59 0.2 277); - --primary-foreground: oklch(0.96 0.02 272); - --secondary: oklch(0.274 0.006 286.033); - --secondary-foreground: oklch(0.985 0 0); - --muted: oklch(0.269 0 0); - --muted-foreground: oklch(0.708 0 0); - --accent: oklch(0.371 0 0); - --accent-foreground: oklch(0.985 0 0); - --destructive: oklch(0.704 0.191 22.216); + --background: oklch(0.14 0.003 250); + --foreground: oklch(0.98 0.002 250); + --card: oklch(0.175 0.004 250); + --card-foreground: oklch(0.98 0.002 250); + --popover: oklch(0.175 0.004 250); + --popover-foreground: oklch(0.98 0.002 250); + --primary: oklch(0.82 0.17 78); + --primary-foreground: oklch(0.185 0.02 80); + --secondary: oklch(0.225 0.005 250); + --secondary-foreground: oklch(0.98 0.002 250); + --muted: oklch(0.22 0.004 250); + --muted-foreground: oklch(0.68 0.005 250); + --accent: oklch(0.27 0.005 250); + --accent-foreground: oklch(0.98 0.002 250); + --destructive: oklch(0.65 0.22 27); + --destructive-foreground: oklch(0.98 0.002 250); --border: oklch(1 0 0 / 10%); - --input: oklch(1 0 0 / 15%); - --ring: oklch(0.556 0 0); - --chart-1: oklch(0.88 0.2 275); - --chart-2: oklch(0.75 0.24 276); - --chart-3: oklch(0.65 0.26 277); - --chart-4: oklch(0.55 0.27 277); - --chart-5: oklch(0.45 0.24 277); - --sidebar: oklch(0.205 0 0); - --sidebar-foreground: oklch(0.985 0 0); - --sidebar-primary: oklch(0.68 0.16 277); - --sidebar-primary-foreground: oklch(0.96 0.02 272); - --sidebar-accent: oklch(0.269 0 0); - --sidebar-accent-foreground: oklch(0.985 0 0); - --sidebar-border: oklch(1 0 0 / 10%); - --sidebar-ring: oklch(0.556 0 0); + --input: oklch(1 0 0 / 12%); + --ring: oklch(0.82 0.17 78); + --chart-1: oklch(0.82 0.17 78); + --chart-2: oklch(0.48 0.11 68); + --chart-3: oklch(0.92 0.1 88); + --chart-4: oklch(0.65 0.17 72); + --chart-5: oklch(0.35 0.06 55); + --chart-win: var(--primary); + --chart-loss: var(--destructive); + --sidebar: oklch(0.16 0.003 250); + --sidebar-foreground: oklch(0.98 0.002 250); + --sidebar-primary: oklch(0.82 0.17 78); + --sidebar-primary-foreground: oklch(0.185 0.02 80); + --sidebar-accent: oklch(0.22 0.004 250); + --sidebar-accent-foreground: oklch(0.98 0.002 250); + --sidebar-border: oklch(1 0 0 / 8%); + --sidebar-ring: oklch(0.82 0.17 78); +} + +.disguised { + color-scheme: dark; + --background: oklch(0.14 0.003 250); + --foreground: oklch(0.98 0.002 250); + --card: oklch(0.175 0.004 250); + --card-foreground: oklch(0.98 0.002 250); + --popover: oklch(0.175 0.004 250); + --popover-foreground: oklch(0.98 0.002 250); + --primary: #f7b150; + --primary-foreground: oklch(0.185 0.02 80); + --secondary: oklch(0.225 0.005 250); + --secondary-foreground: oklch(0.98 0.002 250); + --muted: oklch(0.22 0.004 250); + --muted-foreground: oklch(0.68 0.005 250); + --accent: oklch(0.27 0.005 250); + --accent-foreground: oklch(0.98 0.002 250); + --destructive: oklch(0.65 0.22 27); + --destructive-foreground: oklch(0.98 0.002 250); + --border: oklch(1 0 0 / 10%); + --input: oklch(1 0 0 / 12%); + --ring: #f7b150; + --chart-1: #f7b150; + --chart-2: oklch(0.48 0.11 68); + --chart-3: oklch(0.92 0.1 88); + --chart-4: oklch(0.65 0.17 72); + --chart-5: oklch(0.35 0.06 55); + --chart-win: var(--primary); + --chart-loss: var(--destructive); + --sidebar: oklch(0.16 0.003 250); + --sidebar-foreground: oklch(0.98 0.002 250); + --sidebar-primary: #f7b150; + --sidebar-primary-foreground: oklch(0.185 0.02 80); + --sidebar-accent: oklch(0.22 0.004 250); + --sidebar-accent-foreground: oklch(0.98 0.002 250); + --sidebar-border: oklch(1 0 0 / 8%); + --sidebar-ring: #f7b150; } @theme inline { @@ -105,6 +149,8 @@ --color-chart-3: var(--chart-3); --color-chart-4: var(--chart-4); --color-chart-5: var(--chart-5); + --color-chart-win: var(--chart-win); + --color-chart-loss: var(--chart-loss); --radius-sm: calc(var(--radius) - 4px); --radius-md: calc(var(--radius) - 2px); --radius-lg: var(--radius); @@ -125,6 +171,10 @@ --color-team-2-tritanopia: var(--team-2-tritanopia); --color-team-1-protanopia: var(--team-1-protanopia); --color-team-2-protanopia: var(--team-2-protanopia); + --font-sans: + var(--font-switzer), ui-sans-serif, system-ui, -apple-system, sans-serif; + --font-mono: + var(--font-geist-mono), ui-monospace, "SF Mono", Menlo, monospace; } @keyframes collapsible-down { @@ -154,6 +204,7 @@ cubic-bezier(0.215, 0.61, 0.355, 1); --animate-collapsible-up: collapsible-up 150ms cubic-bezier(0.215, 0.61, 0.355, 1); + --animate-dot-matrix: dot-matrix 1.2s ease-in-out infinite; } @layer base { @@ -197,6 +248,16 @@ } /* ── Shared Keyframes ── */ +@keyframes dot-matrix { + 0%, + 100% { + opacity: 0.12; + } + 50% { + opacity: 1; + } +} + @keyframes fade { from { filter: blur(3px); @@ -336,7 +397,7 @@ background-size: 24px 24px; } -:is(.dark *) .bracket-grid { +:is(.dark *, .disguised *) .bracket-grid { background-image: radial-gradient( circle, oklch(0.4 0 0 / 0.4) 1px, @@ -344,3 +405,87 @@ ); background-size: 24px 24px; } + +/* ── 404 / not-found poster ── */ +/* Mobile only: constant red art field with a gentle diagonal gradient for + depth (paired with .not-found-grain on top). On desktop the art panel is + transparent and the full-width .not-found-canvas provides the red, so this + must NOT paint at lg+ (otherwise it double-tones over the canvas). */ +@media (width < 64rem) { + .not-found-art { + background: linear-gradient(155deg, #f5443e 0%, #ee1c25 48%, #c8141c 100%); + } +} + +/* Desktop only: a single full-width layer behind both columns. The red bleeds + from the right across a wide transition into the chrome column's color + (themed --background), so there is no hard seam. A faint diagonal adds depth. + Because it is one continuous gradient, the boundary simply dissolves. */ +.not-found-canvas { + background: linear-gradient(to right, var(--background) 14%, #ee1c25 82%); +} + +/* Confine the grain to the solid-red region so it never roughens the soft + dark→red transition on the left. */ +.not-found-grain-right { + -webkit-mask-image: linear-gradient(to right, transparent 56%, #000 74%); + mask-image: linear-gradient(to right, transparent 56%, #000 74%); +} + +/* Film grain over the red art panel — softens an otherwise harsh solid. */ +.not-found-grain { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0.45; + mix-blend-mode: overlay; + background-size: 200px 200px; + background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='200' height='200'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.6' numOctaves='2' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3CfeComponentTransfer%3E%3CfeFuncR type='linear' slope='2.2' intercept='-0.6'/%3E%3CfeFuncG type='linear' slope='2.2' intercept='-0.6'/%3E%3CfeFuncB type='linear' slope='2.2' intercept='-0.6'/%3E%3C/feComponentTransfer%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E"); +} + +@keyframes not-found-fall { + from { + opacity: 0; + transform: translateY(-2.5rem); + } + 60% { + opacity: 1; + } + to { + opacity: 1; + transform: translateY(0); + } +} + +@keyframes not-found-rise { + from { + opacity: 0; + transform: translateY(0.75rem); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.not-found-figure { + animation: not-found-fall 800ms cubic-bezier(0.22, 1, 0.36, 1) both; +} +.not-found-eyebrow { + animation: not-found-rise 500ms ease-out 120ms both; +} +.not-found-title { + animation: not-found-rise 500ms ease-out 200ms both; +} +.not-found-desc { + animation: not-found-rise 500ms ease-out 300ms both; +} + +@media (prefers-reduced-motion: reduce) { + .not-found-figure, + .not-found-eyebrow, + .not-found-title, + .not-found-desc { + animation: none; + } +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 15ce130cd..ec83263d6 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,3 +1,4 @@ +import { BrandThemeProvider } from "@/components/brand-theme-provider"; import { CommandDialogMenu } from "@/components/command-menu"; import { CommandMenuProvider } from "@/components/command-menu-provider"; import { DevTools } from "@/components/devtools"; @@ -12,17 +13,21 @@ import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { register } from "@/instrumentation"; import { auth } from "@/lib/auth"; +import { DSG_TEAM_ID } from "@/lib/brand-theme"; import { WebVitals } from "@/lib/axiom/client"; import { resolveAllFlags, toFlagValues } from "@/lib/flags-helpers"; import { QueryProvider } from "@/lib/query"; import { cn } from "@/lib/utils"; +import { UsageBeacon } from "@/components/usage/usage-beacon"; import { Analytics } from "@vercel/analytics/react"; import { SpeedInsights } from "@vercel/speed-insights/next"; import { FlagValues } from "flags/react"; import type { Metadata } from "next"; import { NextIntlClientProvider } from "next-intl"; +import { Suspense } from "react"; import { getLocale, getMessages, getTranslations } from "next-intl/server"; -import { Geist, Geist_Mono } from "next/font/google"; +import { Geist_Mono } from "next/font/google"; +import localFont from "next/font/local"; import { NuqsAdapter } from "nuqs/adapters/next/app"; import "./globals.css"; @@ -52,9 +57,21 @@ export async function generateMetadata(): Promise { }; } -const geistSans = Geist({ - subsets: ["latin"], - weight: ["400", "700"], +const switzer = localFont({ + src: [ + { + path: "../../public/fonts/Switzer-Variable.woff2", + weight: "100 900", + style: "normal", + }, + { + path: "../../public/fonts/Switzer-VariableItalic.woff2", + weight: "100 900", + style: "italic", + }, + ], + variable: "--font-switzer", + display: "swap", }); const geistMono = Geist_Mono({ @@ -62,7 +79,7 @@ const geistMono = Geist_Mono({ variable: "--font-geist-mono", }); -register(); +void register(); export default async function RootLayout({ children }: LayoutProps<"/">) { const locale = await getLocale(); @@ -76,35 +93,50 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { ); } + let isDsgMember = false; + + if (session) { + isDsgMember = await AppRuntime.runPromise( + UserService.pipe( + Effect.flatMap((svc) => + svc.isMemberOfTeam(session.user.email, DSG_TEAM_ID) + ) + ) + ); + } + const flags = await resolveAllFlags(); return ( - - - - {children} - - + + + + + {children} + + + @@ -112,6 +144,9 @@ export default async function RootLayout({ children }: LayoutProps<"/">) { + + + diff --git a/src/app/leaderboard/csr/page.tsx b/src/app/leaderboard/csr/page.tsx new file mode 100644 index 000000000..5b89dd7ac --- /dev/null +++ b/src/app/leaderboard/csr/page.tsx @@ -0,0 +1,190 @@ +import { HeroSelector } from "@/components/leaderboard/hero-selector"; +import { LeaderboardSubnav } from "@/components/leaderboard/leaderboard-subnav"; +import { LeaderboardWithStats } from "@/components/leaderboard/leaderboard-with-stats"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { getCompositeSRLeaderboard } from "@/lib/hero-rating"; +import { getHeroNames, toHero } from "@/lib/utils"; +import { heroRoleMapping, type HeroName } from "@/types/heroes"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("leaderboardPage.csrPage.metadata"); + return { + title: t("title"), + description: t("description"), + }; +} + +export default async function CsrLeaderboardPage(props: { + searchParams: Promise<{ hero?: string }>; +}) { + const [t, heroNames] = await Promise.all([ + getTranslations("leaderboardPage.csrPage"), + getHeroNames(), + ]); + const searchParams = await props.searchParams; + const heroParam = searchParams.hero; + const currentHero = ( + heroParam && Object.keys(heroRoleMapping).includes(heroParam) + ? heroParam + : undefined + ) as HeroName | undefined; + + let leaderboard: Awaited> = []; + + if (currentHero) { + leaderboard = await getCompositeSRLeaderboard({ + hero: currentHero, + limit: 50, + }); + } + + const serializedData = Array.isArray(leaderboard) + ? leaderboard.map((row) => ({ + ...row, + elims_per10: row.elims_per10 ? Number(row.elims_per10) : undefined, + fb_per10: row.fb_per10 ? Number(row.fb_per10) : undefined, + deaths_per10: Number(row.deaths_per10), + damage_per10: Number(row.damage_per10), + healing_per10: row.healing_per10 + ? Number(row.healing_per10) + : undefined, + blocked_per10: row.blocked_per10 + ? Number(row.blocked_per10) + : undefined, + taken_per10: row.taken_per10 ? Number(row.taken_per10) : undefined, + solo_per10: row.solo_per10 ? Number(row.solo_per10) : undefined, + ults_per10: row.ults_per10 ? Number(row.ults_per10) : undefined, + minutes_played: Number(row.minutes_played), + composite_z_score: Number(row.composite_z_score), + percentile: row.percentile, + })) + : []; + const role = currentHero ? heroRoleMapping[currentHero] : undefined; + const currentHeroName = currentHero + ? (heroNames.get(toHero(currentHero)) ?? currentHero) + : null; + + return ( +
+
+
+

+ {currentHeroName + ? t("eyebrowWithHero", { hero: currentHeroName }) + : t("eyebrow")} +

+

+ {t("title")} +

+

+ {t("description")} +

+
+
+ +
+ + +
+ + {!currentHero || !role ? ( +
+
+

+ {t("empty.eyebrow")} +

+

+ {t("empty.title")} +

+

+ {t("empty.description")} +

+
+ + + + + {t("accordion.calculated.title")} + + +
+

{t("accordion.calculated.intro")}

+
+

+ {t("accordion.calculated.formulaTitle")} +

+

{t("accordion.calculated.formula")}

+
    +
  • {t("accordion.calculated.normalized")}
  • +
  • {t("accordion.calculated.positiveStats")}
  • +
  • {t("accordion.calculated.negativeStats")}
  • +
+
+
+

+ {t("accordion.calculated.roleWeightingTitle")} +

+

+ {t("accordion.calculated.roleWeighting")} +

+

+ {t("accordion.calculated.uniqueWeightings")} +

+
+
+

+ {t("accordion.calculated.finalScalingTitle")} +

+

+ {t("accordion.calculated.finalScaling")} +

+
+                      2500 + (Z * (1250 / (1 + |Z| / 3)))
+                    
+
+
+
+
+ + {t("accordion.goodSr.title")} + +
+

{t("accordion.goodSr.body")}

+
+
+
+ + {t("accordion.rank.title")} + +
+

{t("accordion.rank.body")}

+
+
+
+ + + {t("accordion.interactive.title")} + + +
+

{t("accordion.interactive.body")}

+
+
+
+
+
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/leaderboard/page.tsx b/src/app/leaderboard/page.tsx index 18c479f5f..0105aed7c 100644 --- a/src/app/leaderboard/page.tsx +++ b/src/app/leaderboard/page.tsx @@ -1,414 +1,86 @@ -import { HeroSelector } from "@/components/leaderboard/hero-selector"; -import { LeaderboardWithStats } from "@/components/leaderboard/leaderboard-with-stats"; import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { getCompositeSRLeaderboard } from "@/lib/hero-rating"; -import { heroRoleMapping, type HeroName } from "@/types/heroes"; - -export default async function LeaderboardPage(props: { - searchParams: Promise<{ hero?: string }>; -}) { - const searchParams = await props.searchParams; - const heroParam = searchParams.hero; - const currentHero = ( - heroParam && Object.keys(heroRoleMapping).includes(heroParam) - ? heroParam - : undefined - ) as HeroName | undefined; - - let leaderboard: Awaited> = []; - - if (currentHero) { - leaderboard = await getCompositeSRLeaderboard({ - hero: currentHero, - limit: 50, - }); - } - - const serializedData = Array.isArray(leaderboard) - ? leaderboard.map((row) => ({ - ...row, - elims_per10: row.elims_per10 ? Number(row.elims_per10) : undefined, - fb_per10: row.fb_per10 ? Number(row.fb_per10) : undefined, - deaths_per10: Number(row.deaths_per10), - damage_per10: Number(row.damage_per10), - healing_per10: row.healing_per10 - ? Number(row.healing_per10) - : undefined, - blocked_per10: row.blocked_per10 - ? Number(row.blocked_per10) - : undefined, - taken_per10: row.taken_per10 ? Number(row.taken_per10) : undefined, - solo_per10: row.solo_per10 ? Number(row.solo_per10) : undefined, - ults_per10: row.ults_per10 ? Number(row.ults_per10) : undefined, - minutes_played: Number(row.minutes_played), - composite_z_score: Number(row.composite_z_score), - percentile: row.percentile, - })) - : []; - const role = currentHero ? heroRoleMapping[currentHero] : undefined; - - return ( -
-
-

Leaderboard

- -
- - {!currentHero || !role ? ( -
-
-

Hero Leaderboard

-

- Select a hero above to view the top players for that hero. The - leaderboard uses a Composite Skill Rating (CSR) system that - evaluates performance across multiple statistics compared to the - average, weighted by their importance for each hero. -

-
- -
- - - - How is Composite SR (CSR) Calculated? - - -
-

- CSR is a skill rating derived from your statistical - performance compared to the average player on a specific - hero. -

- -
-

The Formula

-

- We calculate a Z-Score for each key - statistic, which measures how many standard deviations - you are above or below the average. -

-
    -
  • - Stats are normalized to "per 10 minutes". -
  • -
  • - Positive stats (e.g., Eliminations) reward higher - values. -
  • -
  • - Negative stats (e.g., Deaths, Damage Taken) reward - lower values. -
  • -
-
- -
-

Role Weighting

-

- Each role prioritizes different stats. For example: -

-
    -
  • - Tank: Prioritizes low Deaths (30%), - Eliminations (20%), and Solo Kills (15%). -
  • -
  • - Damage: Prioritizes Eliminations - (30%), Final Blows (20%), and Damage Dealt (20%). -
  • -
  • - Support: Prioritizes Healing (35%) - and low Deaths (25%). -
  • -
-

- *Specific heroes like Mercy have unique weightings. -

-
- -
-

Final CSR Calculation

-

- The weighted Z-scores are summed and converted to an CSR - scale centered at 2500 (average). -

-
-                        2500 + (Z_Score * (1250 / (1 + |Z_Score| / 3)))
-                      
-

- This formula ensures that extreme outliers don't - break the scale, while rewarding consistent high - performance. -

-
-
-
-
- - What is a good SR? - -

- What is a good SR? In this leaderboard - system, player scores (SR) are distributed along a{" "} - bell curve, also - known as a normal distribution. This means most - players will have scores clustered around the{" "} - average, and fewer players will have - extremely high or low scores. -

-

- The average SR is{" "} - 2500, representing - the skill level of a typical player. Scores above 2500 are - considered{" "} - above average, while - those below 2500 are{" "} - below average. -

-

- The concept of the bell curve ensures that: -

-
    -
  • Most players will have SRs close to 2500.
  • -
  • - A "good" SR is typically anything{" "} - above the average{" "} - (2500). -
  • -
  • - The further your SR is above 2500, the rarer and more - impressive your ranking. -
  • -
-

- Standard deviation is used to measure how - spread out the scores are around the average. If your SR is - one standard deviation (about 300-400 SR) above - 2500, you're already in roughly the top 16% of players. - The higher your SR relative to 2500, the fewer players have - achieved that score. -

-
-
- - - How do I get on the leaderboard? - - -
-
-

Minimum Requirements

-

- To appear on the leaderboard, you must meet the - following criteria: -

-
    -
  • - At least 10 maps played on the hero. - This ensures the data is statistically significant and - represents consistent performance. -
  • -
  • - At least 60 seconds of playtime per - map. Brief hero swaps mid-game won't count toward - your map total. -
  • -
-
- -
-

Leaderboard Display

-

- The leaderboard displays the{" "} - top 50 players for each hero. Even if - you don't appear in the top 50, your rank is still - calculated and visible on your profile page, allowing - you to track your progress over time. -

-
- -
-

Improving Your Rank

-

- Focus on the statistics that matter most for your - hero's role. Review the role weighting information - above to understand which stats contribute most to your - Composite SR calculation. -

-
-
-
-
- - - How do I use the interactive leaderboard? - - -
-

- The leaderboard features an interactive interface that - lets you explore detailed player statistics and - performance data. -

- -
-

Selecting a Player

-

- Click on any player row in the leaderboard table to view - their detailed statistics. The selected row will be - highlighted, and a detailed stats panel will appear: -

-
    -
  • - On desktop, the stats panel appears - in a sticky column on the right side of the screen. -
  • -
  • - On mobile, the stats panel appears - below the table for easier viewing. -
  • -
-
- -
-

Table Features

-

- The leaderboard table displays key information for each - player: -

-
    -
  • - Rank: Player's position on the - leaderboard -
  • -
  • - Player: Clickable name that links to - their profile page -
  • -
  • - SR: Composite Skill Rating -
  • -
  • - Maps: Number of maps played on the - hero -
  • -
  • - Time: Total minutes played -
  • -
  • - Per 10 min stats: Eliminations, - Deaths, Damage, and role-specific stats (Healing for - Supports, Blocked for Tanks) -
  • -
-
- -
-

Accessibility

-

- All player rows are keyboard accessible. Use{" "} - Tab to navigate and Enter or{" "} - Space to select a player. -

-
-
-
-
- - - What information is shown in the detailed stats panel? - - -
-

- When you select a player, the detailed stats panel - provides a comprehensive view of their performance, - including visualizations and statistical breakdowns. -

- -
-

Overview Cards

-

- The top section displays four key metrics: -

-
    -
  • - Composite SR: The player's - overall skill rating -
  • -
  • - Percentile: What percentage of - players they outperform (e.g., "Top 5% - - Exceptional") -
  • -
  • - Maps Played: Total number of maps on - this hero -
  • -
  • - Time Played: Total minutes of - gameplay -
  • -
-
- -
-

SR Distribution Chart

-

- This visualization shows where the selected player ranks - compared to all other players on the leaderboard. It - helps you understand their position within the - distribution of skill ratings. -

-
+ LeaderboardHub, + type MetricStats, +} from "@/components/leaderboard/leaderboard-hub"; +import { getInitialTsrLeaderboard } from "@/lib/tsr/leaderboard"; +import type { Metadata } from "next"; +import { getFormatter, getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("leaderboardPage.hub.metadata"); + return { + title: t("title"), + description: t("description"), + }; +} -
-

- Performance Breakdown (Radar Chart) -

-

- A radar chart displays how the player's statistics - compare to the average player. Each axis represents a - different stat (Eliminations, Deaths, Damage, etc.), - allowing you to quickly identify strengths and areas for - improvement. -

-
+export default async function LeaderboardHubPage() { + const [tsr, t, formatter] = await Promise.all([ + getInitialTsrLeaderboard(), + getTranslations("leaderboardPage.hub"), + getFormatter(), + ]); + + const statsById: Partial> = { + csr: { + ribbon: [ + { + label: t("stats.perHero"), + value: t("stats.topCount", { count: 50 }), + }, + { + label: t("stats.minSample"), + value: t("stats.mapCount", { count: 10 }), + }, + { + label: t("stats.scale"), + value: t("stats.scaleValue", { max: 5000 }), + }, + ], + status: t("stats.csrStatus"), + }, + tsr: { + ribbon: [ + { + label: t("stats.active"), + value: formatter.number(tsr.meta.totalActive), + }, + { + label: t("stats.trackedPlayers"), + value: formatter.number(tsr.meta.totalAll), + }, + { + label: t("stats.trackedMatches"), + value: formatter.number(tsr.meta.totalTrackedMatches), + }, + { + label: t("stats.topRating"), + value: tsr.meta.topRating + ? formatter.number(tsr.meta.topRating) + : "—", + }, + ], + status: + tsr.meta.totalAll > 0 + ? t("stats.lastRecompute", { + when: formatRecompute(tsr.meta.computedAt, t), + }) + : t("stats.awaitingSeed"), + }, + }; + + return ; +} -
-

- Detailed Stats (per 10 minutes) -

-

- The bottom section lists all available statistics - normalized to per 10 minutes, including: -

-
    -
  • Eliminations, Final Blows, Solo Kills
  • -
  • Deaths, Damage Dealt, Damage Taken
  • -
  • Healing (for Support heroes)
  • -
  • Damage Blocked (for Tank heroes)
  • -
  • Ultimates Used
  • -
-

- *Stats are normalized to per 10 minutes to allow fair - comparison regardless of playtime. -

-
-
-
-
-
-
-
- ) : ( - - )} -
- ); +function formatRecompute( + date: Date | null, + t: Awaited> +): string { + if (!date) return "—"; + const minutes = Math.floor((Date.now() - date.getTime()) / 60_000); + if (minutes < 1) return t("relative.justNow"); + if (minutes < 60) return t("relative.minutesAgo", { count: minutes }); + const hours = Math.floor(minutes / 60); + if (hours < 24) return t("relative.hoursAgo", { count: hours }); + return t("relative.daysAgo", { count: Math.floor(hours / 24) }); } diff --git a/src/app/leaderboard/tsr/actions.ts b/src/app/leaderboard/tsr/actions.ts new file mode 100644 index 000000000..3de8e223e --- /dev/null +++ b/src/app/leaderboard/tsr/actions.ts @@ -0,0 +1,9 @@ +"use server"; + +import { getTsrBreakdown, type TsrBreakdown } from "@/lib/tsr/breakdown"; + +export async function loadTsrBreakdown( + faceitPlayerId: string +): Promise { + return getTsrBreakdown(faceitPlayerId); +} diff --git a/src/app/leaderboard/tsr/page.tsx b/src/app/leaderboard/tsr/page.tsx new file mode 100644 index 000000000..2ea474ff5 --- /dev/null +++ b/src/app/leaderboard/tsr/page.tsx @@ -0,0 +1,14 @@ +import { TsrLeaderboard } from "@/components/leaderboard/tsr-leaderboard"; +import { getInitialTsrLeaderboard } from "@/lib/tsr/leaderboard"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Tournament Skill Rating | Parsertime", + description: + "Elo-style rating from FACEIT-hosted Overwatch 2 tournament results, recency weighted and anchored at peak tier reached.", +}; + +export default async function TsrLeaderboardPage() { + const snapshot = await getInitialTsrLeaderboard(); + return ; +} diff --git a/src/app/map-calibration/[mapName]/page.tsx b/src/app/map-calibration/[mapName]/page.tsx index 4e6e8a5c0..681cf549e 100644 --- a/src/app/map-calibration/[mapName]/page.tsx +++ b/src/app/map-calibration/[mapName]/page.tsx @@ -1,6 +1,11 @@ import { CalibrationEditor } from "@/components/admin/map-calibration/calibration-editor"; -import { auth } from "@/lib/auth"; +import { + ZoneSection, + type MapZoneDto, +} from "@/components/admin/map-calibration/zone-section"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; +import type { MapTransform } from "@/lib/map-calibration/types"; import prisma from "@/lib/prisma"; import { r2 } from "@/lib/r2"; import { notFound, redirect } from "next/navigation"; @@ -10,15 +15,16 @@ export default async function MapCalibrationEditorPage({ }: { params: Promise<{ mapName: string }>; }) { - const [enabled, session, { mapName }] = await Promise.all([ + const [enabled, user, { mapName }] = await Promise.all([ dataLabeling(), - auth(), + getCurrentUser(), params, ]); if (!enabled) notFound(); - if (!session?.user) { + if (!user) { redirect("/sign-in"); } + if (!isAdminUser(user)) notFound(); const decodedMapName = decodeURIComponent(mapName); @@ -55,12 +61,48 @@ export default async function MapCalibrationEditorPage({ let calibrationWithUrl: | (typeof calibration & { imagePresignedUrl?: string }) | null = calibration; + let presignedImageUrl: string | null = null; + let zones: MapZoneDto[] = []; + let transform: MapTransform | null = null; if (calibration) { - const imagePresignedUrl = await r2.getPresignedUrl({ - key: calibration.imageUrl, - expiresIn: 3600, - }); + const [imagePresignedUrl, zoneRows] = await Promise.all([ + r2.getPresignedUrl({ key: calibration.imageUrl, expiresIn: 3600 }), + prisma.mapZone.findMany({ + where: { calibrationId: calibration.id }, + orderBy: { id: "asc" }, + }), + ]); calibrationWithUrl = { ...calibration, imagePresignedUrl }; + presignedImageUrl = imagePresignedUrl; + zones = zoneRows.map((z) => ({ + id: z.id, + name: z.name, + category: z.category, + status: z.status, + source: z.source, + laneRole: z.laneRole, + vertices: z.vertices as unknown as [number, number][], + })); + + // Build the affine transform the same way load-calibration.ts does: + // null if any affine field is null. + if ( + calibration.affineA != null && + calibration.affineB != null && + calibration.affineC != null && + calibration.affineD != null && + calibration.affineTx != null && + calibration.affineTy != null + ) { + transform = { + a: calibration.affineA, + b: calibration.affineB, + c: calibration.affineC, + d: calibration.affineD, + tx: calibration.affineTx, + ty: calibration.affineTy, + }; + } } return ( @@ -69,6 +111,16 @@ export default async function MapCalibrationEditorPage({ mapName={decodedMapName} calibration={calibrationWithUrl} /> + {calibration && presignedImageUrl ? ( + + ) : null}
); } diff --git a/src/app/map-calibration/page.tsx b/src/app/map-calibration/page.tsx index 05d93d63e..22fdeaf10 100644 --- a/src/app/map-calibration/page.tsx +++ b/src/app/map-calibration/page.tsx @@ -1,17 +1,20 @@ import { MapCalibrationList } from "@/components/admin/map-calibration/map-calibration-list"; -import { auth } from "@/lib/auth"; +import { getCurrentUser, isAdminUser } from "@/lib/auth"; import { dataLabeling } from "@/lib/flags"; import prisma from "@/lib/prisma"; +import { getTranslations } from "next-intl/server"; import { notFound, redirect } from "next/navigation"; export default async function MapCalibrationPage() { const enabled = await dataLabeling(); if (!enabled) notFound(); + const t = await getTranslations("mapCalibrationPage"); - const session = await auth(); - if (!session?.user) { + const user = await getCurrentUser(); + if (!user) { redirect("/sign-in"); } + if (!isAdminUser(user)) notFound(); const calibrations = await prisma.mapCalibration.findMany({ include: { anchors: true }, @@ -21,11 +24,8 @@ export default async function MapCalibrationPage() {
-

Map Calibration

-

- Calibrate coordinate transforms for top-down map images. Each map - needs anchor points mapping world coordinates to image pixels. -

+

{t("title")}

+

{t("description")}

diff --git a/src/app/matchmaker/[teamId]/page.tsx b/src/app/matchmaker/[teamId]/page.tsx new file mode 100644 index 000000000..609d18c33 --- /dev/null +++ b/src/app/matchmaker/[teamId]/page.tsx @@ -0,0 +1,83 @@ +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { getMatchmakerCandidates } from "@/lib/matchmaker/candidates"; +import { SearcherSummary } from "@/components/matchmaker/searcher-summary"; +import { CandidateRow } from "@/components/matchmaker/candidate-row"; +import Link from "next/link"; +import { redirect } from "next/navigation"; +import type { Route } from "next"; +import { UserRole } from "@/generated/prisma/browser"; +import { getTranslations } from "next-intl/server"; + +type PageProps = { params: Promise<{ teamId: string }> }; + +export default async function MatchmakerListPage({ params }: PageProps) { + const t = await getTranslations("matchmaker"); + const { teamId: rawTeamId } = await params; + const teamId = parseInt(rawTeamId, 10); + if (Number.isNaN(teamId)) redirect("/matchmaker"); + + const session = await auth(); + if (!session?.user?.email) redirect("/sign-in"); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + id: true, + role: true, + teams: { where: { id: teamId }, select: { id: true } }, + }, + }); + if (!user) redirect("/team"); + const isAdmin = user.role === UserRole.ADMIN; + if (user.teams.length === 0 && !isAdmin) redirect("/team"); + + const result = await getMatchmakerCandidates(teamId); + + if (result.kind === "no-snapshot") { + return ( +
+
+

+ {t("no-snapshot-title")} +

+

+ {t("no-snapshot-description")} +

+ + {t("back-to-team")} + +
+
+ ); + } + + return ( +
+ + {result.candidates.length === 0 ? ( +
+

+ {t("no-candidates-title")} +

+

+ {t("no-candidates-description")} +

+
+ ) : ( +
+ {result.candidates.map((c) => ( + + ))} +
+ )} +
+ ); +} diff --git a/src/app/matchmaker/[teamId]/vs/[opposingTeamId]/page.tsx b/src/app/matchmaker/[teamId]/vs/[opposingTeamId]/page.tsx new file mode 100644 index 000000000..0be82029f --- /dev/null +++ b/src/app/matchmaker/[teamId]/vs/[opposingTeamId]/page.tsx @@ -0,0 +1,294 @@ +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import Link from "next/link"; +import type { Route } from "next"; +import { computeTeamTsr } from "@/lib/tsr/team"; +import { getTierBucket } from "@/lib/tsr/tier-bucket"; +import { + computeAvailabilityOverlapHours, + loadCurrentTeamAvailability, +} from "@/lib/matchmaker/availability"; +import { getMatchmakerCandidates } from "@/lib/matchmaker/candidates"; +import { SkillDeviation } from "@/components/matchmaker/skill-deviation"; +import { SendRequestButton } from "@/components/matchmaker/send-request-button"; +import { TeamTsrCard } from "@/components/team/team-tsr-card"; +import { FaceitTier, UserRole } from "@/generated/prisma/browser"; +import { getFormatter, getTranslations } from "next-intl/server"; + +type PageProps = { + params: Promise<{ teamId: string; opposingTeamId: string }>; +}; + +const COOLDOWN_HOURS = 24; +const DAILY_LIMIT = 10; + +export default async function MatchmakerDetailPage({ params }: PageProps) { + const [t, formatter] = await Promise.all([ + getTranslations("matchmaker"), + getFormatter(), + ]); + const { teamId: rawFromId, opposingTeamId: rawToId } = await params; + const fromTeamId = parseInt(rawFromId, 10); + const toTeamId = parseInt(rawToId, 10); + if (Number.isNaN(fromTeamId) || Number.isNaN(toTeamId)) { + redirect(`/matchmaker/${rawFromId}` as Route); + } + + const session = await auth(); + if (!session?.user?.email) redirect("/sign-in"); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + id: true, + role: true, + teams: { where: { id: fromTeamId }, select: { id: true } }, + }, + }); + if (!user) redirect("/team"); + const isAdmin = user.role === UserRole.ADMIN; + if (user.teams.length === 0 && !isAdmin) redirect("/team"); + + const since = new Date(Date.now() - COOLDOWN_HOURS * 3_600_000); + const [candidateResult, recentInboundRequest] = await Promise.all([ + getMatchmakerCandidates(fromTeamId), + prisma.scrimRequest.findFirst({ + where: { + fromTeamId: toTeamId, + toTeamId: fromTeamId, + createdAt: { gt: since }, + }, + select: { id: true }, + }), + ]); + if (candidateResult.kind !== "ok") { + redirect(`/matchmaker/${fromTeamId}` as Route); + } + const targetIsEligible = candidateResult.candidates.some( + (candidate) => candidate.teamId === toTeamId + ); + if (!targetIsEligible && !recentInboundRequest) { + redirect(`/matchmaker/${fromTeamId}` as Route); + } + + const [fromSnap, toSnap, fromTeam, toTeam] = await Promise.all([ + prisma.teamTsrSnapshot.findUnique({ where: { teamId: fromTeamId } }), + prisma.teamTsrSnapshot.findUnique({ where: { teamId: toTeamId } }), + prisma.team.findUnique({ + where: { id: fromTeamId }, + select: { + name: true, + ownerId: true, + managers: { select: { userId: true } }, + users: { select: { id: true, name: true, battletag: true } }, + scrims: { select: { id: true } }, + }, + }), + prisma.team.findUnique({ + where: { id: toTeamId }, + select: { + name: true, + users: { select: { id: true, name: true, battletag: true } }, + scrims: { select: { id: true } }, + }, + }), + ]); + + if (!fromSnap || !toSnap || !fromTeam || !toTeam) { + redirect(`/matchmaker/${fromTeamId}` as Route); + } + + const isManager = + isAdmin || + fromTeam.ownerId === user.id || + fromTeam.managers.some((m) => m.userId === user.id); + + const [recentToTarget, sentToday] = await Promise.all([ + prisma.scrimRequest.findFirst({ + where: { + fromTeamId, + toTeamId, + createdAt: { gt: since }, + }, + }), + prisma.scrimRequest.count({ + where: { fromTeamId, createdAt: { gt: since } }, + }), + ]); + + const [fromTsrDetail, toTsrDetail, searcherAvail, opponentAvail] = + await Promise.all([ + computeTeamTsr( + fromTeam.users.map((u) => ({ + id: u.id, + name: u.name, + battletag: u.battletag, + })), + fromTeam.scrims.map((s) => s.id) + ), + computeTeamTsr( + toTeam.users.map((u) => ({ + id: u.id, + name: u.name, + battletag: u.battletag, + })), + toTeam.scrims.map((s) => s.id) + ), + loadCurrentTeamAvailability(fromTeamId), + loadCurrentTeamAvailability(toTeamId), + ]); + + const overlapHours = computeAvailabilityOverlapHours({ + a: searcherAvail, + b: opponentAvail, + }); + + const fromBucket = getTierBucket(fromSnap.rating); + const cannedMessage = t("request-message", { + fromTeamName: fromTeam.name, + fromBracketLabel: getBracketLabel(fromBucket.band, fromBucket.tier, t), + fromTsr: fromSnap.rating, + delta: formatSignedDelta(fromSnap.rating - toSnap.rating, formatter, t), + }); + + let disabledReason: string | null = null; + if (!isManager) disabledReason = t("send-button-not-manager"); + else if (recentToTarget) disabledReason = t("send-button-recent"); + else if (sentToday >= DAILY_LIMIT) + disabledReason = t("send-button-limit-short"); + + return ( +
+
+
+

+ {t("detail-eyebrow")} +

+

+ {fromTeam.name} vs {toTeam.name} +

+
+ + {t("back-to-candidates")} + +
+ +
+
+

+ {t("your-team")} +

+ +
+
+

+ {t("their-team")} +

+ +
+
+ + + + {overlapHours > 0 && ( +
+

+ {t("availability-overlap-title")} +

+

+ {t.rich("availability-overlap-detail", { + hours: overlapHours, + value: (chunks) => ( + + {chunks} + + ), + })} +

+
+ )} + +
+
+

+ {t("message-preview")} +

+

+ {cannedMessage} +

+
+ +
+
+ ); +} + +function formatSignedDelta( + delta: number, + formatter: Awaited>, + t: Awaited> +) { + const value = formatter.number(Math.abs(delta)); + if (delta > 0) return t("delta-positive", { value }); + if (delta < 0) return t("delta-negative", { value }); + return t("delta-zero"); +} + +function getBracketLabel( + band: string | null, + tier: FaceitTier, + t: Awaited> +) { + const tierLabel = getTierLabel(tier, t); + if (!band) return tierLabel; + return t("bracket-with-band", { + band: getBandLabel(band, t), + tier: tierLabel, + }); +} + +function getTierLabel( + tier: FaceitTier, + t: Awaited> +) { + switch (tier) { + case FaceitTier.UNCLASSIFIED: + return t("tiers.unclassified"); + case FaceitTier.OPEN: + return t("tiers.open"); + case FaceitTier.CAH: + return t("tiers.cah"); + case FaceitTier.ADVANCED: + return t("tiers.advanced"); + case FaceitTier.EXPERT: + return t("tiers.expert"); + case FaceitTier.MASTERS: + return t("tiers.masters"); + case FaceitTier.OWCS: + return t("tiers.owcs"); + } +} + +function getBandLabel( + band: string, + t: Awaited> +) { + switch (band) { + case "Low": + return t("bands.low"); + case "Mid": + return t("bands.mid"); + case "High": + return t("bands.high"); + default: + return band; + } +} diff --git a/src/app/matchmaker/layout.tsx b/src/app/matchmaker/layout.tsx new file mode 100644 index 000000000..9fdc6cabd --- /dev/null +++ b/src/app/matchmaker/layout.tsx @@ -0,0 +1,20 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("matchmaker.metadata"); + + return { + title: t("title"), + description: t("description"), + }; +} + +export default function MatchmakerLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/matchmaker/page.tsx b/src/app/matchmaker/page.tsx new file mode 100644 index 000000000..1dc5b0a68 --- /dev/null +++ b/src/app/matchmaker/page.tsx @@ -0,0 +1,74 @@ +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { redirect } from "next/navigation"; +import { + MatchmakerHub, + type HubTeam, +} from "@/components/matchmaker/matchmaker-hub"; +import { getTierBucket } from "@/lib/tsr/tier-bucket"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("matchmaker.metadata"); + return { + title: t("title"), + description: t("description"), + }; +} + +export default async function MatchmakerHubPage() { + const session = await auth(); + if (!session?.user?.email) redirect("/sign-in"); + + const user = await prisma.user.findUnique({ + where: { email: session.user.email }, + select: { + teams: { + where: { readonly: false }, + select: { + id: true, + name: true, + teamTsrSnapshot: { + select: { + rating: true, + region: true, + bracketTier: true, + bracketBand: true, + }, + }, + }, + orderBy: { name: "asc" }, + }, + }, + }); + + const hubTeams: HubTeam[] = (user?.teams ?? []).map((t) => { + const snap = t.teamTsrSnapshot; + if (!snap) { + return { + id: t.id, + name: t.name, + hasSnapshot: false, + bracketLabel: null, + bracketBand: null, + region: null, + rating: null, + bracketTier: null, + }; + } + const bucket = getTierBucket(snap.rating); + return { + id: t.id, + name: t.name, + hasSnapshot: true, + bracketLabel: bucket.label, + bracketBand: bucket.band, + region: snap.region, + rating: snap.rating, + bracketTier: snap.bracketTier, + }; + }); + + return ; +} diff --git a/src/app/not-found.tsx b/src/app/not-found.tsx index 7fd49a05f..289e401d8 100644 --- a/src/app/not-found.tsx +++ b/src/app/not-found.tsx @@ -1,15 +1,38 @@ import { Link } from "@/components/ui/link"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; +import { fallingHalftoneSvg } from "./falling-halftone"; export default async function NotFound() { const t = await getTranslations("notFound"); return ( -
-
-
- +
+ {/* Desktop only: full-width red→chrome bleed behind both columns. */} +
-
-
-

- {t("404")} -

-

- {t("header")} -

-

- {t("description")} -

-
- - {t("backHome")} - + +
+
+

+ {t("404")} +

+

+ {t("header")} +

+

+ {t("description")} +

+
+ + {t("backHome")} + +
-
-
-
- -
-
-
- + +
+ {t("contact")} +
diff --git a/src/app/notifications/layout.tsx b/src/app/notifications/layout.tsx index 9329ea8b0..54ad8a2b2 100644 --- a/src/app/notifications/layout.tsx +++ b/src/app/notifications/layout.tsx @@ -1,4 +1,11 @@ import { DashboardLayout } from "@/components/dashboard-layout"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("notifications.metadata"); + return { title: t("title"), description: t("description") }; +} export default function NotificationsLayout({ children, diff --git a/src/app/page.tsx b/src/app/page.tsx index 3f24aec73..7c08540d2 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,6 +1,6 @@ import { ClosedBetaBanner } from "@/components/home/banner"; import { LandingPage } from "@/components/home/landing-page"; -import { NewLandingPage } from "@/components/home/new-landing/landing-page"; +import { V3LandingPage } from "@/components/home/v3/landing-page"; import type { Availability } from "@/lib/auth"; import { newLandingPage } from "@/lib/flags"; import { get } from "@vercel/edge-config"; @@ -16,7 +16,7 @@ export default async function Home() { return ( <> {isPrivate && } - {showNewLanding ? : } + {showNewLanding ? : } ); } diff --git a/src/app/pricing/page.tsx b/src/app/pricing/page.tsx index 7f64af7c8..6986db7d7 100644 --- a/src/app/pricing/page.tsx +++ b/src/app/pricing/page.tsx @@ -15,7 +15,6 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { createCheckout, getCustomerPortalUrl } from "@/lib/stripe"; import { toTitleCase } from "@/lib/utils"; import type { Metadata, Route } from "next"; import { getLocale, getTranslations } from "next-intl/server"; @@ -103,18 +102,15 @@ export default async function PricingPage() { UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) ); - const plan = toTitleCase(user?.billingPlan ?? ""); - const isLoggedIn = !!session?.user; + const plan = toTitleCase(user?.billingPlan ?? "FREE"); + const isLoggedIn = !!user; - async function getLink(tier: string) { - if (session) { - if (plan === "Free") { - const checkout = await createCheckout(session, tier); - return checkout.url as Route; - } - return await getCustomerPortalUrl(user!); + function getLink(tier: string) { + if (!session?.user?.email || !user) { + return "/dashboard"; } - return "/dashboard"; + + return `/api/stripe/checkout?tier=${encodeURIComponent(tier)}` as Route; } const tiers: TierData[] = [ @@ -138,7 +134,7 @@ export default async function PricingPage() { { name: t("tiers.basic"), id: "tier-basic", - href: ((await getLink("Basic")) as Route) ?? "/sign-in", + href: getLink("Basic"), priceMonthly: t("tiers.basicMonthly"), description: t("tiers.basicDescription"), mostPopular: true, @@ -157,7 +153,7 @@ export default async function PricingPage() { { name: t("tiers.premium"), id: "tier-premium", - href: ((await getLink("Premium")) as Route) ?? "/sign-in", + href: getLink("Premium"), priceMonthly: t("tiers.premiumMonthly"), description: t("tiers.premiumDescription"), mostPopular: false, diff --git a/src/app/profile/[playerName]/page.tsx b/src/app/profile/[playerName]/page.tsx index 2c7b0eaf5..6cbd9a02c 100644 --- a/src/app/profile/[playerName]/page.tsx +++ b/src/app/profile/[playerName]/page.tsx @@ -1,18 +1,23 @@ import { Achievements } from "@/components/profile/achievements"; +import { CalculatedStatsBlock } from "@/components/profile/calculated-stats-block"; import { HeroMasteryGrid } from "@/components/profile/hero-mastery-grid"; import { HeroRating } from "@/components/profile/hero-rating"; import { PersonalRecords } from "@/components/profile/personal-records"; import { PlayStyleIndicator } from "@/components/profile/play-style-indicator"; +import { PositioningCard } from "@/components/profile/positioning-card"; import { ProfileHeader } from "@/components/profile/profile-header"; +import { RankedSummary } from "@/components/profile/ranked-summary"; import { RecentActivityCalendar } from "@/components/profile/recent-activity-calendar"; -import { StatFluctuationCards } from "@/components/profile/stat-fluctuation-cards"; +import { SkillRatingDetail } from "@/components/profile/skill-rating-card"; import { RangePicker, type Timeframe, } from "@/components/stats/player/range-picker"; +import { SectionHeader } from "@/components/stats/team/section-header"; +import { StatRibbon } from "@/components/stats/team/stat-ribbon"; import { PlayerTargetsTab } from "@/components/targets/player-targets-tab"; -import { Link } from "@/components/ui/link"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { RankedService } from "@/data/ranked"; import { ScrimService } from "@/data/scrim"; import { calculateTargetProgress, @@ -22,24 +27,20 @@ import { import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, getViewableScrimIds } from "@/lib/auth"; +import { positionalData } from "@/lib/flags"; import { getCompositeSRLeaderboard } from "@/lib/hero-rating"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; +import { getPlayerTsrByBattletag } from "@/lib/tsr/lookup"; import type { RoleName } from "@/lib/target-stats"; -import { - cn, - getHeroRatingBorderColor, - toHero, - toTimestampWithHours, -} from "@/lib/utils"; +import { cn, toHero, toTimestampWithHours } from "@/lib/utils"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; import type { PagePropsWithLocale } from "@/types/next"; -import { $Enums, type Scrim } from "@prisma/client"; +import { $Enums, Prisma, type Scrim } from "@/generated/prisma/client"; import { getTranslations } from "next-intl/server"; import Image from "next/image"; -// Helper type for hero data type HeroData = { player_hero: string; total_time_played: number; @@ -49,14 +50,26 @@ type HeroData = { rank: number; }; +const tabTriggerClass = + "text-muted-foreground hover:text-foreground data-[state=active]:text-foreground border-0 border-b-2 border-b-transparent data-[state=active]:border-b-primary rounded-none bg-transparent px-0 pb-3 pt-1 font-mono text-[11px] tracking-[0.16em] uppercase shadow-none data-[state=active]:shadow-none data-[state=active]:bg-transparent dark:bg-transparent dark:data-[state=active]:bg-transparent dark:data-[state=active]:border-b-primary transition-colors"; + +const ROLE_HUE_CLASS: Record<"Tank" | "Damage" | "Support", string> = { + Tank: "bg-sky-500/80", + Damage: "bg-rose-500/80", + Support: "bg-emerald-500/80", +}; + export default async function ProfilePage( props: PagePropsWithLocale<"/profile/[playerName]"> ) { const params = await props.params; const name = decodeURIComponent(params.playerName); const t = await getTranslations("heroes"); + const session = await auth(); + const sessionUser = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + ); - // Attempt to fetch user data const user = await prisma.user.findFirst({ where: { OR: [ @@ -66,6 +79,15 @@ export default async function ProfilePage( }, }); + const playerScrims = await prisma.playerStat.findMany({ + where: { player_name: { equals: name, mode: "insensitive" } }, + select: { scrimId: true }, + distinct: ["scrimId"], + }); + const scrimIds = playerScrims.map((scrim) => scrim.scrimId); + const viewableScrimIds = await getViewableScrimIds(scrimIds, sessionUser); + const canUseFullProfileRatings = viewableScrimIds.length === scrimIds.length; + const [timeframe1, timeframe2, timeframe3] = await Promise.all([ new Permission("stats-timeframe-1").check(), new Permission("stats-timeframe-2").check(), @@ -95,14 +117,15 @@ export default async function ProfilePage( email: user?.email ?? null, }; - // 1. Fetch all heroes played by the user, sorted by time played - // We use raw query for speed and aggregation type HeroPlayTime = { player_hero: string; total_time_played: number; }; - const heroesPlayed = await prisma.$queryRaw` + const heroesPlayed = + viewableScrimIds.length === 0 + ? [] + : await prisma.$queryRaw` WITH final_rows AS ( SELECT DISTINCT ON ("MapDataId", player_name, player_hero) player_hero, @@ -110,7 +133,8 @@ export default async function ProfilePage( FROM "PlayerStat" WHERE - player_name ILIKE ${name} + lower(player_name) = lower(${name}) + AND "scrimId" IN (${Prisma.join(viewableScrimIds)}) AND hero_time_played > 0 ORDER BY "MapDataId", @@ -130,16 +154,15 @@ export default async function ProfilePage( total_time_played DESC `; - // Uncomment this to rate only the top 10 heroes to avoid too many DB calls - // const topHeroesToRate = heroesPlayed.slice(0, 10); - const heroRatings = await Promise.all( heroesPlayed.map(async (hero) => { - const compositeLeaderboard = await getCompositeSRLeaderboard({ - hero: hero.player_hero as HeroName, - player: name, - limit: 300, - }); + const compositeLeaderboard = canUseFullProfileRatings + ? await getCompositeSRLeaderboard({ + hero: hero.player_hero as HeroName, + player: name, + limit: 300, + }) + : null; if (!compositeLeaderboard) { const mapsPlayed = await prisma.playerStat.groupBy({ @@ -148,6 +171,7 @@ export default async function ProfilePage( player_name: { equals: name, mode: "insensitive" }, player_hero: hero.player_hero as HeroName, hero_time_played: { gt: 60 }, + scrimId: { in: viewableScrimIds }, }, }); @@ -170,7 +194,6 @@ export default async function ProfilePage( }) ); - // Merge back into full list const allHeroesData: HeroData[] = heroesPlayed.map((hero) => { const ratedHero = heroRatings.find( (h) => h.player_hero === hero.player_hero @@ -179,7 +202,7 @@ export default async function ProfilePage( return { ...hero, hero_rating: 0, - mapsPlayed: 0, // We didn't fetch this for non-top heroes to save time + mapsPlayed: 0, percentile: "0", rank: 0, }; @@ -187,7 +210,18 @@ export default async function ProfilePage( const top3Heroes = allHeroesData.slice(0, 3); - // Calculate Role Data + const peakHero = allHeroesData.reduce( + (best, h) => (h.hero_rating > (best?.hero_rating ?? 0) ? h : best), + null + ); + const peakCsr = { + rating: peakHero?.hero_rating ?? 0, + hero: peakHero?.player_hero ?? null, + mapsPlayed: peakHero?.mapsPlayed ?? 0, + }; + + const tsrSnapshot = await getPlayerTsrByBattletag([user?.battletag, name]); + const roleData: Record< "Tank" | "Damage" | "Support", { time: number; sr: number } @@ -202,26 +236,13 @@ export default async function ProfilePage( if (role) { roleData[role].time += hero.total_time_played; if (hero.hero_rating > 0) { - // Use max hero SR as the "Role SR" for now, as it represents peak performance roleData[role].sr = Math.max(roleData[role].sr, hero.hero_rating); } } }); - const calculatedStats = await prisma.calculatedStat.findMany({ - where: { playerName: { equals: name, mode: "insensitive" } }, - }); - - const playerScrims = await prisma.playerStat.findMany({ - where: { player_name: { equals: name, mode: "insensitive" } }, - select: { scrimId: true }, - distinct: ["scrimId"], - }); - - const scrimIds = playerScrims.map((scrim) => scrim.scrimId); - const allScrims = await prisma.scrim.findMany({ - where: { id: { in: scrimIds } }, + where: { id: { in: viewableScrimIds } }, }); const oneWeek = new Date(); @@ -268,6 +289,25 @@ export default async function ProfilePage( : "one-month"; const permittedScrimIds = data[permitted].map((scrim) => scrim.id); + const responseScrims = { + "one-week": data["one-week"], + "two-weeks": data["two-weeks"], + "one-month": data["one-month"], + "three-months": timeframe2 || timeframe3 ? data["three-months"] : [], + "six-months": timeframe2 || timeframe3 ? data["six-months"] : [], + "one-year": timeframe3 ? data["one-year"] : [], + "all-time": timeframe3 ? data["all-time"] : [], + custom: [], + }; + + const calculatedStats = await prisma.calculatedStat.findMany({ + where: { + playerName: { equals: name, mode: "insensitive" }, + scrimId: { in: permittedScrimIds }, + }, + }); + + const showPositioning = await positionalData(); const { stats, kills, deaths, mapWinrates } = await AppRuntime.runPromise( Effect.all( @@ -297,20 +337,29 @@ export default async function ProfilePage( ) ); - // Calculate max time for bar chart scaling const maxTimePlayed = Math.max( - ...allHeroesData.map((h) => h.total_time_played) + ...allHeroesData.map((h) => h.total_time_played), + 1 ); - // Targets tab: visible on own profile or for site admins - const session = await auth(); - const sessionUser = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + const totalHours = allHeroesData.reduce( + (acc, hero) => acc + hero.total_time_played, + 0 ); + const totalHoursLabel = + totalHours / 3600 >= 10 + ? `${Math.floor(totalHours / 3600)}h` + : `${(totalHours / 3600).toFixed(1)}h`; + + const placedHeroes = allHeroesData.filter((h) => h.hero_rating > 0).length; + const uniqueScrimDates = new Set( + allScrims.map((s) => new Date(s.date).toISOString().split("T")[0]) + ).size; + const isOwnProfile = user && session?.user?.email && session.user.email === user.email; const isAdmin = sessionUser?.role === $Enums.UserRole.ADMIN; - const canViewTargets = isOwnProfile ?? isAdmin; + const canViewTargets = [isOwnProfile, isAdmin].some(Boolean); let targetProgress: TargetProgress[] = []; let targetScrimStats: { @@ -322,18 +371,9 @@ export default async function ProfilePage( let playerPrimaryRole: RoleName = "Damage"; if (canViewTargets) { - // Use the profile user's teamId, or fall back to looking up from their targets - let targetTeamId = user?.teamId ?? null; - if (!targetTeamId) { - const anyTarget = await prisma.playerTarget.findFirst({ - where: { playerName: { equals: name, mode: "insensitive" } }, - select: { teamId: true }, - }); - targetTeamId = anyTarget?.teamId ?? null; - } + const targetTeamId = user?.teamId ?? null; if (targetTeamId) { - // Determine primary role if (allHeroesData.length > 0) { const topRole = heroRoleMapping[allHeroesData[0].player_hero as HeroName]; @@ -363,304 +403,324 @@ export default async function ProfilePage( } } + const showRanked = user?.rankedStatsPublic === true; + const rankedMatches = showRanked + ? await AppRuntime.runPromise( + RankedService.pipe( + Effect.flatMap((svc) => svc.getMatchesForUser(user.id)) + ) + ) + : []; + return ( -
- - - - - Overview - Progression - Statistics - {canViewTargets && Targets} - {user && Achievements} +
+ 0 ? peakCsr.rating.toLocaleString() : "—", + sub: + peakCsr.rating > 0 && peakCsr.hero + ? `on ${t(toHero(peakCsr.hero))}` + : "Unplaced", + }, + { + label: "TSR", + value: tsrSnapshot ? tsrSnapshot.rating.toLocaleString() : "—", + sub: tsrSnapshot + ? `${tsrSnapshot.region} · ${tsrSnapshot.matchCount}m` + : "no FACEIT data", + }, + { + label: "Heroes", + value: allHeroesData.length.toString(), + sub: placedHeroes > 0 ? `${placedHeroes} placed` : "—", + }, + { + label: "Hours", + value: totalHoursLabel, + sub: `${allScrims.length} scrims`, + }, + ]} + /> + + + + + Overview + + + Progression + + + Statistics + + {canViewTargets && ( + + Targets + + )} + {user && ( + + Achievements + + )} + {showRanked && rankedMatches.length > 0 && ( + + Ranked + + )} - -
- {/* Left Column: Most Played Heroes */} -
-
-
-

- Most Played Heroes -

-
- {top3Heroes.map((hero, index) => ( -
-
- {hero.player_hero} -
-
- -
- {t(toHero(hero.player_hero))} -
-
-
- ))} - {top3Heroes.length === 0 && ( -
- No data available -
- )} -
-
-
- - - - - - One Week - - - Two Weeks - - - One Month - - - Three Months - - - Six Months - - - One Year - - - All Time - - - {!timeframe3 && ( -
- - Upgrade to view more timeframes - -
- )} - - - - - - - - - - - - - - - - - - - - - -
-
- {/* Right Column: Comparison / Role Stats */} -
-
-
-

- Hero Comparison -

- - Time Played - -
- {/* Comparison Bars */} -
- {allHeroesData.slice(0, 5).map((hero) => ( -
-
-
- - {t(toHero(hero.player_hero))} - - {hero.hero_rating > 0 ? ( - - ) : ( - - Unplaced - - )} -
-
-
-
-
- - {toTimestampWithHours(hero.total_time_played)} - -
-
-
- ))} - {allHeroesData.length === 0 && ( -
- No data available -
- )} -
+ + 0 ? peakCsr.rating.toLocaleString() : "—", + sub: + peakCsr.rating > 0 && peakCsr.hero + ? `${t(toHero(peakCsr.hero))} · ${peakCsr.mapsPlayed} maps` + : "no placed heroes", + emphasis: peakCsr.rating > 0, + }, + { + label: "TSR", + value: tsrSnapshot ? tsrSnapshot.rating.toLocaleString() : "—", + sub: tsrSnapshot + ? `${tsrSnapshot.region} · ${tsrSnapshot.matchCount} matches` + : "no FACEIT tournaments", + }, + { + label: "Heroes", + value: allHeroesData.length.toString(), + sub: `${placedHeroes} placed · ${allHeroesData.length - placedHeroes} unplaced`, + }, + { + label: "Days active", + value: uniqueScrimDates.toString(), + sub: + allScrims.length > 0 + ? `${allScrims.length} scrims logged` + : "no scrims", + }, + ]} + columns={4} + /> -
-
-
Role
-
Time Played
-
Peak SR
-
-
-
-
-
- Tank -
-
- {roleData.Tank.time > 0 - ? toTimestampWithHours(roleData.Tank.time) - : "-"} -
-
- {roleData.Tank.sr > 0 ? ( - - ) : ( - "-" - )} -
-
-
-
-
- Damage -
-
- {roleData.Damage.time > 0 - ? toTimestampWithHours(roleData.Damage.time) - : "-"} -
-
- {roleData.Damage.sr > 0 ? ( - - ) : ( - "-" - )} +
+ + +
+ + {top3Heroes.length > 0 ? ( +
+ +
+ {top3Heroes.map((hero) => ( +
+ {hero.player_hero} +
+ + {t(toHero(hero.player_hero))} + + + {toTimestampWithHours(hero.total_time_played)} + +
+
-
-
-
- Support +
+ ))} +
+
+ ) : null} + + {allHeroesData.length > 0 ? ( +
+ +
    + {allHeroesData.slice(0, 8).map((hero) => { + const widthPct = Math.max( + 2, + (hero.total_time_played / maxTimePlayed) * 100 + ); + return ( +
  • +
    + {hero.player_hero} + + {t(toHero(hero.player_hero))} +
    -
    - {roleData.Support.time > 0 - ? toTimestampWithHours(roleData.Support.time) - : "-"} +
    +
    -
    - {roleData.Support.sr > 0 ? ( + + {toTimestampWithHours(hero.total_time_played)} + +
    + {hero.hero_rating > 0 ? ( ) : ( - "-" + + Unplaced + )}
    -
    - +
  • + ); + })} +
+
+ ) : null} + +
+ +
+ {(["Tank", "Damage", "Support"] as const).map((role) => ( +
+
+ + + {role} + +
+
+ + {roleData[role].time > 0 + ? toTimestampWithHours(roleData[role].time) + : "—"} + + {roleData[role].sr > 0 ? ( + + ) : ( + + no peak + + )}
-
+ ))}
-
+ + + + +
+ + +
+ +
+ + +
+ + {showPositioning && ( +
+ + +
+ )} - -
- {/* Left Column: Play Style Indicator */} -
- -
- {/* Right Column: Personal Records */} -
- -
-
+ +
+ + +
+ +
+ + +
- + + {canViewTargets && ( - + )} - + + {showRanked && rankedMatches.length > 0 && ( + + + + )}
); diff --git a/src/app/query/page.tsx b/src/app/query/page.tsx new file mode 100644 index 000000000..9cc1d6e0b --- /dev/null +++ b/src/app/query/page.tsx @@ -0,0 +1,58 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; +import { QueryBuilder } from "@/components/query-builder/query-builder"; +import { auth } from "@/lib/auth"; +import { queryBuilder } from "@/lib/flags"; +import { getViewableTeams, listSavedQueries } from "@/lib/query-builder/server"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { notFound, redirect } from "next/navigation"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("queryBuilderPage.metadata"); + return { title: t("title"), description: t("description") }; +} + +export default async function QueryPage() { + const enabled = await queryBuilder(); + if (!enabled) notFound(); + + const session = await auth(); + if (!session?.user?.email) redirect("/"); + + const [teams, savedQueries, t] = await Promise.all([ + getViewableTeams(), + listSavedQueries(), + getTranslations("queryBuilderPage"), + ]); + + if (teams.length === 0) { + return ( + +
+
+

+ {t("title")} +

+

+ {t("subtitle")} +

+
+
+

+ {t("noAccessTitle")} +

+

+ {t("noAccessBody")} +

+
+
+
+ ); + } + + return ( + + + + ); +} diff --git a/src/app/ranked/actions.ts b/src/app/ranked/actions.ts new file mode 100644 index 000000000..71f8c09f2 --- /dev/null +++ b/src/app/ranked/actions.ts @@ -0,0 +1,76 @@ +"use server"; + +import { RankedService } from "@/data/ranked"; +import { AppRuntime } from "@/data/runtime"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { getHeroRole } from "@/types/heroes"; +import { mapNameToMapTypeMapping, type MapName } from "@/types/map"; +import { Effect } from "effect"; +import { revalidatePath } from "next/cache"; +import { + validateMatchInput, + type ActionResult, + type MatchInput, +} from "./validation"; + +export async function createMatches( + matches: MatchInput[] +): Promise { + const session = await auth(); + const email = session?.user?.email; + if (!email) return { success: false, error: "Not authenticated" }; + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) return { success: false, error: "Not authenticated" }; + + for (let i = 0; i < matches.length; i++) { + const error = validateMatchInput(matches[i], i); + if (error) return { success: false, error }; + } + + for (const match of matches) { + const mapType = mapNameToMapTypeMapping[match.map as MapName]; + await AppRuntime.runPromise( + RankedService.pipe( + Effect.flatMap((svc) => + svc.createMatch(user.id, { + map: match.map, + mapType: String(mapType), + result: match.result, + groupSize: match.groupSize, + playedAt: new Date(match.playedAt), + heroes: match.heroes.map((h) => ({ + hero: h.hero, + role: getHeroRole(h.hero), + percentage: h.percentage, + })), + }) + ) + ) + ); + } + + revalidatePath("/ranked"); + return { success: true }; +} + +export async function deleteRankedMatch( + matchId: string +): Promise { + const session = await auth(); + const email = session?.user?.email; + if (!email) return { success: false, error: "Not authenticated" }; + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) return { success: false, error: "Not authenticated" }; + + await AppRuntime.runPromise( + RankedService.pipe( + Effect.flatMap((svc) => svc.deleteMatch(user.id, matchId)) + ) + ); + + revalidatePath("/ranked"); + return { success: true }; +} diff --git a/src/app/ranked/import-action.ts b/src/app/ranked/import-action.ts new file mode 100644 index 000000000..343c87cf7 --- /dev/null +++ b/src/app/ranked/import-action.ts @@ -0,0 +1,41 @@ +"use server"; + +import { parseRankedBundle } from "@/lib/ranked/export-schema"; +import { importRankedBundle } from "@/lib/ranked/importer"; +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; + +export type ImportResult = { + success: boolean; + error?: string; + imported?: number; + skipped?: number; + invalid?: number; +}; + +export async function importRankedJson(raw: string): Promise { + const session = await auth(); + const email = session?.user?.email; + if (!email) return { success: false, error: "Not authenticated" }; + + const user = await prisma.user.findUnique({ where: { email } }); + if (!user) return { success: false, error: "Not authenticated" }; + + let json: unknown; + try { + json = JSON.parse(raw); + } catch { + return { success: false, error: "File is not valid JSON" }; + } + + const parsed = parseRankedBundle(json); + if (!parsed.ok) return { success: false, error: parsed.error }; + + const { imported, skipped, invalid } = await importRankedBundle( + user.id, + parsed.bundle + ); + revalidatePath("/ranked"); + return { success: true, imported, skipped, invalid }; +} diff --git a/src/app/ranked/layout.tsx b/src/app/ranked/layout.tsx new file mode 100644 index 000000000..8dfb48f3d --- /dev/null +++ b/src/app/ranked/layout.tsx @@ -0,0 +1,5 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; + +export default function Layout({ children }: LayoutProps<"/ranked">) { + return {children}; +} diff --git a/src/app/ranked/loading.tsx b/src/app/ranked/loading.tsx new file mode 100644 index 000000000..e21c61c25 --- /dev/null +++ b/src/app/ranked/loading.tsx @@ -0,0 +1,80 @@ +/* oxlint-disable react/no-array-index-key */ +import { Skeleton } from "@/components/ui/skeleton"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; + +const tabTriggerClass = + "text-muted-foreground hover:text-foreground data-[state=active]:text-foreground border-0 border-b-2 border-b-transparent data-[state=active]:border-b-primary rounded-none bg-transparent px-0 pb-3 pt-1 font-mono text-[11px] tracking-[0.16em] uppercase shadow-none data-[state=active]:shadow-none data-[state=active]:bg-transparent dark:bg-transparent dark:data-[state=active]:bg-transparent dark:data-[state=active]:border-b-primary transition-colors"; + +const TAB_LABELS = [ + "Overview", + "Heroes", + "Maps", + "Time", + "Patches", + "Groups", + "Roles", +]; + +export default function RankedLoading() { + return ( +
+
+ + +
+ +
+
+ + + +
+ +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ + + + {TAB_LABELS.map((label) => ( + + {label} + + ))} + + + + +
+ + +
+
+
+
+
+ ); +} + +function SkeletonSection({ bodyHeight }: { bodyHeight: number }) { + return ( +
+
+ + + +
+ +
+ ); +} diff --git a/src/app/ranked/page.tsx b/src/app/ranked/page.tsx new file mode 100644 index 000000000..fd563169e --- /dev/null +++ b/src/app/ranked/page.tsx @@ -0,0 +1,94 @@ +import { DashboardContent } from "@/components/ranked/dashboard-content"; +import { ImportCard } from "@/components/ranked/import-card"; +import { MatchForm } from "@/components/ranked/match-form"; +import { Button } from "@/components/ui/button"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { getOverwatchPatches } from "@/data/overwatch/patches-service"; +import { RankedService } from "@/data/ranked"; +import { AppRuntime } from "@/data/runtime"; +import { UserService } from "@/data/user"; +import { auth } from "@/lib/auth"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { Crosshair, Plus } from "lucide-react"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; + +export async function generateMetadata( + props: PagePropsWithLocale<"/ranked"> +): Promise { + const { locale } = await props.params; + const t = await getTranslations({ locale, namespace: "ranked.metadata" }); + return { title: t("title"), description: t("description") }; +} + +export default async function RankedPage() { + const [session, t] = await Promise.all([auth(), getTranslations("ranked")]); + const email = session?.user?.email; + if (!email) redirect("/sign-in"); + + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(email))) + ); + if (!user) redirect("/sign-in"); + + const [matches, patches] = await Promise.all([ + AppRuntime.runPromise( + RankedService.pipe( + Effect.flatMap((svc) => svc.getMatchesForUser(user.id)) + ) + ), + getOverwatchPatches(), + ]); + + return ( +
+
+

+ Personal analytics +

+

+ {t("title")} +

+
+ {matches.length === 0 ? ( +
+ + + + + + {t("emptyTitle")} + {t("emptyDescription")} + + + + + {t("trackFirst")} + + } + /> + + +
+ +
+
+ ) : ( +
+ +
+ )} +
+ ); +} diff --git a/src/app/ranked/privacy-action.ts b/src/app/ranked/privacy-action.ts new file mode 100644 index 000000000..f64146681 --- /dev/null +++ b/src/app/ranked/privacy-action.ts @@ -0,0 +1,20 @@ +"use server"; + +import { auth } from "@/lib/auth"; +import prisma from "@/lib/prisma"; +import { revalidatePath } from "next/cache"; + +export async function setRankedStatsPublic( + isPublic: boolean +): Promise<{ success: boolean }> { + const session = await auth(); + const email = session?.user?.email; + if (!email) return { success: false }; + + await prisma.user.update({ + where: { email }, + data: { rankedStatsPublic: isPublic }, + }); + revalidatePath("/settings/accounts"); + return { success: true }; +} diff --git a/src/app/ranked/validation.ts b/src/app/ranked/validation.ts new file mode 100644 index 000000000..275ab67e3 --- /dev/null +++ b/src/app/ranked/validation.ts @@ -0,0 +1,45 @@ +import { heroRoleMapping } from "@/types/heroes"; +import { mapNameToMapTypeMapping } from "@/types/map"; + +export type MatchHeroInput = { hero: string; percentage: number }; +export type MatchInput = { + map: string; + result: "win" | "loss" | "draw"; + groupSize: number; + playedAt: string; + heroes: MatchHeroInput[]; +}; +export type ActionResult = { success: boolean; error?: string }; + +const HERO_NAMES = Object.keys(heroRoleMapping); + +export function validateMatchInput( + match: MatchInput, + index: number +): string | null { + if (!(match.map in mapNameToMapTypeMapping)) { + return `Match ${index + 1}: Invalid map "${match.map}"`; + } + if (!["win", "loss", "draw"].includes(match.result)) { + return `Match ${index + 1}: Invalid result`; + } + if (match.groupSize < 1 || match.groupSize > 5) { + return `Match ${index + 1}: Group size must be 1-5`; + } + if (match.heroes.length === 0) { + return `Match ${index + 1}: At least one hero required`; + } + const total = match.heroes.reduce((sum, h) => sum + h.percentage, 0); + if (total !== 100) { + return `Match ${index + 1}: Hero percentages must sum to 100 (got ${total})`; + } + for (const hero of match.heroes) { + if (!HERO_NAMES.includes(hero.hero)) { + return `Match ${index + 1}: Invalid hero "${hero.hero}"`; + } + if (hero.percentage < 1 || hero.percentage > 100) { + return `Match ${index + 1}: Hero percentage must be 1-100`; + } + } + return null; +} diff --git a/src/app/reports/[id]/page.tsx b/src/app/reports/[id]/page.tsx index b61569139..2a0e5397e 100644 --- a/src/app/reports/[id]/page.tsx +++ b/src/app/reports/[id]/page.tsx @@ -13,13 +13,28 @@ export async function generateMetadata({ params: Promise<{ id: string }>; }): Promise { const { id } = await params; + const session = await auth(); + if (!session?.user?.email) { + return { title: "Report | Parsertime" }; + } + + const userData = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) + ); + if (!userData) { + return { title: "Report | Parsertime" }; + } + const report = await prisma.chatReport.findUnique({ where: { id }, - select: { title: true }, + select: { title: true, userId: true }, }); return { - title: report ? `${report.title} | Parsertime` : "Report | Parsertime", + title: + report?.userId === userData.id + ? `${report.title} | Parsertime` + : "Report | Parsertime", }; } @@ -46,6 +61,7 @@ export default async function ReportPage({ }); if (!report) notFound(); + if (report.userId !== userData.id) notFound(); return (
diff --git a/src/app/reports/layout.tsx b/src/app/reports/layout.tsx index daa27bc36..dd7b06b3a 100644 --- a/src/app/reports/layout.tsx +++ b/src/app/reports/layout.tsx @@ -1,10 +1,15 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; -export const metadata: Metadata = { - title: "Reports | Parsertime", - description: "View shared AI-generated scrim analysis reports.", -}; +export async function generateMetadata(): Promise { + const t = await getTranslations("reportsPage.metadata"); + + return { + title: t("title"), + description: t("description"), + }; +} export default function ReportsLayout({ children, diff --git a/src/app/reports/page.tsx b/src/app/reports/page.tsx index 1e15c49d8..f86a6bd33 100644 --- a/src/app/reports/page.tsx +++ b/src/app/reports/page.tsx @@ -1,3 +1,4 @@ +import { DirectionalTransition } from "@/components/directional-transition"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; @@ -21,5 +22,9 @@ export default async function ReportsPage() { include: { user: { select: { name: true } } }, }); - return ; + return ( + + + + ); } diff --git a/src/app/reports/reports-list.tsx b/src/app/reports/reports-list.tsx index 55b73633f..7366cb8eb 100644 --- a/src/app/reports/reports-list.tsx +++ b/src/app/reports/reports-list.tsx @@ -1,36 +1,41 @@ "use client"; -import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; -import { Card, CardContent, CardHeader } from "@/components/ui/card"; -import { Link } from "@/components/ui/link"; -import { cn } from "@/lib/utils"; -import type { ChatReport } from "@prisma/client"; -import { CalendarIcon } from "@radix-ui/react-icons"; +import { + Empty, + EmptyContent, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { Input } from "@/components/ui/input"; +import type { ChatReport } from "@/generated/prisma/browser"; import { ChevronLeft, ChevronRight, - FileText, MessageSquareText, Search, } from "lucide-react"; import type { Route } from "next"; +import { useFormatter, useTranslations } from "next-intl"; +import NextLink from "next/link"; import { useMemo, useState } from "react"; const PAGE_SIZE = 12; type ReportWithUser = ChatReport & { user: { name: string | null } }; -function formatRelativeDate(date: Date): string { +function formatRelativeDate( + date: Date, + format: ReturnType +): string { const now = new Date(); const diff = now.getTime() - date.getTime(); const days = Math.floor(diff / (1000 * 60 * 60 * 24)); - if (days === 0) return "Today"; - if (days === 1) return "Yesterday"; - if (days < 7) return `${days} days ago`; - if (days < 30) return `${Math.floor(days / 7)} weeks ago`; - return date.toLocaleDateString("en-US", { + if (days < 30) return format.relativeTime(date); + return format.dateTime(date, { month: "short", day: "numeric", year: date.getFullYear() !== now.getFullYear() ? "numeric" : undefined, @@ -52,43 +57,42 @@ function extractPreview(content: string): string { return firstLine.trim().slice(0, 120) + (firstLine.length > 120 ? "…" : ""); } -function ReportCard({ report }: { report: ReportWithUser }) { +function ReportRow({ report }: { report: ReportWithUser }) { + const format = useFormatter(); const preview = useMemo( () => extractPreview(report.content), [report.content] ); return ( - - -
- - -

- {report.title} -

- -
-
- - - {formatRelativeDate(report.createdAt)} - -
-
-
- - -

- {preview} -

-
- - + +
+

+ {report.title} +

+

{preview}

+
+
+ + {formatRelativeDate(report.createdAt, format)} + +
+
); } export function ReportsList({ reports }: { reports: ReportWithUser[] }) { + const t = useTranslations("reportsPage.list"); const [search, setSearch] = useState(""); const [page, setPage] = useState(1); @@ -101,7 +105,6 @@ export function ReportsList({ reports }: { reports: ReportWithUser[] }) { ); }, [reports, search]); - // Reset to page 1 when search changes const totalPages = Math.max(1, Math.ceil(filtered.length / PAGE_SIZE)); const currentPage = Math.min(page, totalPages); const paged = filtered.slice( @@ -110,85 +113,69 @@ export function ReportsList({ reports }: { reports: ReportWithUser[] }) { ); return ( -
- {/* Header */} -
-
-
- -
-
-

- Reports -

-

- AI-generated analysis reports from your chat conversations. -

-
-
+
+
+

{t("title")}

+

{t("description")}

- {/* Search + count */} {reports.length > 0 && (
- - + { setSearch(e.target.value); setPage(1); }} - className={cn( - "border-input bg-background placeholder:text-muted-foreground h-9 w-full rounded-md border py-2 pr-3 pl-9 text-sm", - "focus-visible:ring-ring focus-visible:ring-2 focus-visible:outline-none" - )} + className="pl-8" />
- - {filtered.length} report{filtered.length !== 1 ? "s" : ""} - + + {t("reportCount", { count: filtered.length })} +
)} - {/* Grid */} {reports.length === 0 ? ( -
-
- -
-

No reports yet

-

- Reports are created from Analyst conversations. Ask the Analyst to - generate a report and it will appear here. -

- - Start a chat - -
+ + + + + + {t("emptyTitle")} + {t("emptyDescription")} + + + + + ) : filtered.length === 0 ? ( -
-

- No reports match “{search}” -

+
+ {t("noMatches", { query: search })}
) : ( <> -
- {paged.map((report) => ( - - ))} +
+
+ {paged.map((report) => ( + + ))} +
{totalPages > 1 && (
-

- Page {currentPage} of {totalPages} +

+ {t("pageStatus", { currentPage, totalPages })}

diff --git a/src/app/scouting/page.tsx b/src/app/scouting/page.tsx index bd4c339a5..44ba637cb 100644 --- a/src/app/scouting/page.tsx +++ b/src/app/scouting/page.tsx @@ -3,9 +3,19 @@ import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/scouting"; import { Effect } from "effect"; import { scoutingTool } from "@/lib/flags"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; +export async function generateMetadata(): Promise { + const t = await getTranslations("scoutingPage.metadata"); + return { + title: t("title"), + description: t("description"), + openGraph: { title: t("ogTitle"), description: t("ogDescription") }, + }; +} + export default async function ScoutingPage() { const scoutingEnabled = await scoutingTool(); if (!scoutingEnabled) notFound(); @@ -19,8 +29,13 @@ export default async function ScoutingPage() {
+

+ {t("searchEyebrow")} +

{t("title")}

-

{t("subtitle")}

+

+ {t("subtitle")} +

diff --git a/src/app/scouting/player/[slug]/page.tsx b/src/app/scouting/player/[slug]/page.tsx index d278fa97b..6f5c1f6c4 100644 --- a/src/app/scouting/player/[slug]/page.tsx +++ b/src/app/scouting/player/[slug]/page.tsx @@ -1,21 +1,39 @@ -import { PlayerHeroPool } from "@/components/scouting/player-hero-pool"; -import { PlayerHeroZScores } from "@/components/scouting/player-hero-zscores"; -import { PlayerKillAnalysis } from "@/components/scouting/player-kill-analysis"; -import { PlayerMapWinrates } from "@/components/scouting/player-map-winrates"; -import { PlayerPerformanceRadar } from "@/components/scouting/player-performance-radar"; -import { PlayerProfileHeader } from "@/components/scouting/player-profile-header"; -import { PlayerScrimOverview } from "@/components/scouting/player-scrim-overview"; -import { PlayerStrengthsWeaknesses } from "@/components/scouting/player-strengths-weaknesses"; -import { PlayerTournamentHistory } from "@/components/scouting/player-tournament-history"; +import { ScoutingPlayerHeader } from "@/components/scouting/scouting-player-header"; +import { ScoutingPlayerMapWinrates } from "@/components/scouting/scouting-player-map-winrates"; +import { ScoutingPlayerRead } from "@/components/scouting/scouting-player-read"; +import { ScoutingPlayerScrimProfile } from "@/components/scouting/scouting-player-scrim-profile"; +import { ScoutingPlayerTournaments } from "@/components/scouting/scouting-player-tournaments"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService, ScoutingAnalyticsService } from "@/data/player"; import { scoutingTool } from "@/lib/flags"; import { ArrowLeft } from "lucide-react"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { notFound } from "next/navigation"; +export async function generateMetadata(props: { + params: Promise<{ slug: string }>; +}): Promise { + const { slug } = await props.params; + const t = await getTranslations("scoutingPage.player.metadata"); + const profile = await AppRuntime.runPromise( + ScoutingService.pipe( + Effect.flatMap((svc) => svc.getPlayerProfile(decodeURIComponent(slug))) + ) + ); + const player = profile?.name ?? decodeURIComponent(slug); + return { + title: t("profileTitle", { player }), + description: t("profileDescription", { player }), + openGraph: { + title: t("profileTitle", { player }), + description: t("profileDescription", { player }), + }, + }; +} + export default async function ScoutingPlayerPage( props: PageProps<"/scouting/player/[slug]"> ) { @@ -33,55 +51,45 @@ export default async function ScoutingPlayerPage( const analytics = await AppRuntime.runPromise( ScoutingAnalyticsService.pipe( - Effect.flatMap((svc) => svc.getPlayerScoutingAnalytics(profile.name)) + Effect.flatMap((svc) => + svc.getPublicPlayerScoutingAnalytics(profile.name) + ) ) ); return ( -
-
- -
+
+
+
+ +
- - - - - {analytics.scrimData && ( - <> - - - - )} + - + {analytics.scrimData ? ( + + ) : null} - {analytics.scrimData && ( - - )} - - - + +
); } diff --git a/src/app/scouting/player/page.tsx b/src/app/scouting/player/page.tsx index b98c160f4..17e714790 100644 --- a/src/app/scouting/player/page.tsx +++ b/src/app/scouting/player/page.tsx @@ -3,9 +3,19 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/player"; import { scoutingTool } from "@/lib/flags"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; +export async function generateMetadata(): Promise { + const t = await getTranslations("scoutingPage.player.metadata"); + return { + title: t("title"), + description: t("description"), + openGraph: { title: t("ogTitle"), description: t("ogDescription") }, + }; +} + export default async function ScoutPlayerPage() { const scoutingEnabled = await scoutingTool(); if (!scoutingEnabled) notFound(); @@ -19,8 +29,13 @@ export default async function ScoutPlayerPage() {
+

+ {t("searchEyebrow")} +

{t("title")}

-

{t("subtitle")}

+

+ {t("subtitle")} +

diff --git a/src/app/scouting/team/[teamAbbr]/page.tsx b/src/app/scouting/team/[teamAbbr]/page.tsx index 47e4fd015..a5e97843f 100644 --- a/src/app/scouting/team/[teamAbbr]/page.tsx +++ b/src/app/scouting/team/[teamAbbr]/page.tsx @@ -1,33 +1,60 @@ -import { BanStrategy } from "@/components/scouting/ban-strategy"; -import { HeroBanChart } from "@/components/scouting/hero-ban-chart"; -import { MapVetoAdvisor } from "@/components/scouting/map-veto-advisor"; -import { MatchHistoryTable } from "@/components/scouting/match-history-table"; -import { MethodologyCard } from "@/components/scouting/methodology-card"; -import { PlayerMatchups } from "@/components/scouting/player-matchups"; import { ScoutForTeamPicker } from "@/components/scouting/scout-for-team-picker"; +import { ScoutingFaceitLink } from "@/components/scouting/scouting-faceit-link"; +import { ScoutingHeroBans } from "@/components/scouting/scouting-hero-bans"; +import { ScoutingMapPerformance } from "@/components/scouting/scouting-map-performance"; +import { ScoutingPlayerMatchups } from "@/components/scouting/scouting-player-matchups"; import { ScoutingReport } from "@/components/scouting/scouting-report"; -import { TeamOverviewEnhanced } from "@/components/scouting/team-overview-enhanced"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { ScoutingTeamHeader } from "@/components/scouting/scouting-team-header"; +import { ScoutingTeamOverview } from "@/components/scouting/scouting-team-overview"; import { HeroBanIntelligenceService, MapIntelligenceService, } from "@/data/intelligence"; import { IntelligenceService } from "@/data/player"; import { AppRuntime } from "@/data/runtime"; -import { OpponentStrengthService, ScoutingService } from "@/data/scouting"; +import { + OpponentStrengthService, + ScoutingFaceitLinkService, + ScoutingService, +} from "@/data/scouting"; +import type { FaceitTeamLink } from "@/data/scouting/types"; import { Effect } from "effect"; import { auth } from "@/lib/auth"; import { resolveDataAvailability } from "@/lib/data-availability"; -import { scoutingTool } from "@/lib/flags"; +import { faceitScouting, scoutingTool } from "@/lib/flags"; import { generateInsights } from "@/lib/insights"; import prisma from "@/lib/prisma"; import { ArrowLeft } from "lucide-react"; +import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { notFound } from "next/navigation"; type UserTeamOption = { id: number; name: string }; +export async function generateMetadata(props: { + params: Promise<{ teamAbbr: string }>; +}): Promise { + const { teamAbbr } = await props.params; + const t = await getTranslations("scoutingPage.team.metadata"); + const profile = await AppRuntime.runPromise( + ScoutingService.pipe( + Effect.flatMap((svc) => + svc.getScoutingTeamProfile(decodeURIComponent(teamAbbr)) + ) + ) + ); + const team = profile?.team.fullName ?? decodeURIComponent(teamAbbr); + return { + title: t("title", { team }), + description: t("description", { team }), + openGraph: { + title: t("ogTitle", { team }), + description: t("ogDescription", { team }), + }, + }; +} + async function getUserTeams(): Promise<{ teams: UserTeamOption[]; userId: string | null; @@ -99,25 +126,29 @@ export default async function ScoutingTeamPage( const userTeamId = resolveScoutForTeamId(searchParams.scoutFor, userTeams); const hasUserTeamLink = userTeamId !== null; - const [{ strengthRating, strengthPercentile }, dataAvailability] = - await Promise.all([ - AppRuntime.runPromise( - Effect.all( - { - strengthRating: OpponentStrengthService.pipe( - Effect.flatMap((svc) => svc.getTeamStrengthRating(teamAbbr)) - ), - strengthPercentile: OpponentStrengthService.pipe( - Effect.flatMap((svc) => svc.getTeamStrengthPercentile(teamAbbr)) - ), - }, - { concurrency: "unbounded" } - ) - ), - resolveDataAvailability(teamAbbr, userTeamId), - ]); + const [ + { strengthRating, strengthPercentile }, + dataAvailability, + faceitEnabled, + ] = await Promise.all([ + AppRuntime.runPromise( + Effect.all( + { + strengthRating: OpponentStrengthService.pipe( + Effect.flatMap((svc) => svc.getTeamStrengthRating(teamAbbr)) + ), + strengthPercentile: OpponentStrengthService.pipe( + Effect.flatMap((svc) => svc.getTeamStrengthPercentile(teamAbbr)) + ), + }, + { concurrency: "unbounded" } + ) + ), + resolveDataAvailability(teamAbbr, userTeamId), + faceitScouting(), + ]); - const [mapIntelligence, banIntelligence, playerIntelligence] = + const [mapIntelligence, banIntelligence, playerIntelligence, faceitLink] = await AppRuntime.runPromise( Effect.all( { @@ -142,12 +173,25 @@ export default async function ScoutingTeamPage( ) ) : Effect.succeed(null), + faceitLink: + faceitEnabled && profile.team.fullName + ? ScoutingFaceitLinkService.pipe( + Effect.flatMap((svc) => + svc.getFaceitTeamLink(profile.team.fullName) + ) + ) + : Effect.succeed(null), }, { concurrency: "unbounded" } ) ).then( (r) => - [r.mapIntelligence, r.banIntelligence, r.playerIntelligence] as const + [ + r.mapIntelligence, + r.banIntelligence, + r.playerIntelligence, + r.faceitLink, + ] as const ); const insightReport = generateInsights({ @@ -161,8 +205,8 @@ export default async function ScoutingTeamPage( }); return ( -
-
+
+
-
-
-

- {profile.team.abbreviation} -

- - {profile.team.fullName} - -
-
- - {overview.wins}W – {overview.losses}L - - - {overview.winRate.toFixed(1)}% {t("overview.winRate")} - - - {overview.weightedWinRate.toFixed(1)}%{" "} - {t("overview.weightedWinRate")} - - -
-
-
+ - - - {t("tabs.overview")} - {t("tabs.maps")} - {t("tabs.heroBans")} - {t("tabs.players")} - {t("tabs.report")} - + - - - - - + {faceitLink ? : null} - - - - + - - - - - + - - - - + - - - - - + +
); } - -function FormStreak({ form }: { form: ("win" | "loss")[] }) { - if (form.length === 0) return null; - - let streak = 1; - const currentResult = form[0]; - for (let i = 1; i < form.length; i++) { - if (form[i] === currentResult) streak++; - else break; - } - - const label = currentResult === "win" ? "W" : "L"; - - return ( - - {label} - {streak} - - ); -} diff --git a/src/app/settings/accounts/page.tsx b/src/app/settings/accounts/page.tsx index de6c656b8..9378a734b 100644 --- a/src/app/settings/accounts/page.tsx +++ b/src/app/settings/accounts/page.tsx @@ -1,5 +1,6 @@ import { DiscordLoginButton } from "@/components/settings/discord-login-button"; import { NotificationConfig } from "@/components/settings/notification-config"; +import { RankedPrivacyToggle } from "@/components/ranked/ranked-privacy-toggle"; import { Button } from "@/components/ui/button"; import { Card, @@ -84,6 +85,15 @@ export default async function LinkedAccountSettingsPage() { + + + {t("ranked.title")} + {t("ranked.description")} + + + + +
); diff --git a/src/app/settings/admin/analytics/layout.tsx b/src/app/settings/admin/analytics/layout.tsx index 742ed4dfc..15c73549b 100644 --- a/src/app/settings/admin/analytics/layout.tsx +++ b/src/app/settings/admin/analytics/layout.tsx @@ -3,7 +3,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; export default async function AdminAnalyticsLayout({ children, diff --git a/src/app/settings/admin/analytics/page.tsx b/src/app/settings/admin/analytics/page.tsx index 07e1ca681..04204d3a1 100644 --- a/src/app/settings/admin/analytics/page.tsx +++ b/src/app/settings/admin/analytics/page.tsx @@ -1,9 +1,19 @@ +import { ActiveTeamsPieChart } from "@/components/admin/active-teams-pie-chart"; +import { ActiveUsersPieChart } from "@/components/admin/active-users-pie-chart"; import { BillingPlanPieChart } from "@/components/admin/billing-plan-pie-chart"; +import { MonthlyActiveTeamsChart } from "@/components/admin/monthly-active-teams-chart"; +import { MonthlyActiveUsersChart } from "@/components/admin/monthly-active-users-chart"; import { MonthlyUserChart } from "@/components/admin/monthly-user-chart"; import { ScrimActivityChart } from "@/components/admin/scrim-activity-chart"; import { SignupMethodPieChart } from "@/components/admin/signup-method-pie-chart"; import { TeamCreationChart } from "@/components/admin/team-creation-chart"; import { TeamManagerPieChart } from "@/components/admin/team-manager-pie-chart"; +import { ActiveUsersChart } from "@/components/admin/usage/active-users-chart"; +import { EnvSelector } from "@/components/admin/usage/env-selector"; +import { FeatureAdoptionChart } from "@/components/admin/usage/feature-adoption-chart"; +import { FunnelChart } from "@/components/admin/usage/funnel-chart"; +import { HotColdTable } from "@/components/admin/usage/hot-cold-table"; +import { UsageScorecard } from "@/components/admin/usage/scorecard"; import { NoAuthCard } from "@/components/auth/no-auth"; import { Card, @@ -13,41 +23,86 @@ import { CardTitle, } from "@/components/ui/card"; import { Separator } from "@/components/ui/separator"; -import { Effect } from "effect"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; -import { $Enums } from "@prisma/client"; +import { projectMonthEnd } from "@/lib/admin/project-month-end"; +import { + getDailyActiveSeries, + getFeatureAdoption, + getFunnels, + getPageHeat, + getScorecard, +} from "@/lib/usage/queries"; +import { $Enums } from "@/generated/prisma/browser"; +import type { UsageEnv } from "@/generated/prisma/client"; +import { Effect } from "effect"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; -async function getMonthlyUserData() { - const now = new Date(); - const monthlyData = []; - - // Get data for the last 12 months - for (let i = 11; i >= 0; i--) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); - - const userCount = await prisma.user.count({ - where: { - createdAt: { - gte: monthStart, - lt: monthEnd, - }, - }, - }); - - const monthName = monthStart.toLocaleDateString("en-US", { month: "long" }); - monthlyData.push({ - month: monthName, - users: userCount, +type MonthWindow = { + monthStart: Date; + monthEnd: Date; + longLabel: string; + shortLabel: string; +}; + +function buildMonthWindowsRange( + startDate: Date, + endDate: Date = new Date() +): MonthWindow[] { + const cursor = new Date(startDate.getFullYear(), startDate.getMonth(), 1); + const endCursor = new Date(endDate.getFullYear(), endDate.getMonth(), 1); + const windows: MonthWindow[] = []; + while (cursor.getTime() <= endCursor.getTime()) { + const next = new Date(cursor.getFullYear(), cursor.getMonth() + 1, 1); + windows.push({ + monthStart: new Date(cursor), + monthEnd: next, + longLabel: cursor.toLocaleDateString("en-US", { month: "long" }), + shortLabel: cursor.toLocaleDateString("en-US", { + month: "short", + year: "numeric", + }), }); + cursor.setMonth(cursor.getMonth() + 1); } + return windows; +} + +async function getMonthlyUserData(firstUserDate: Date) { + const windows = buildMonthWindowsRange(firstUserDate); + + const counts = await Promise.all( + windows.map(({ monthStart, monthEnd }) => + prisma.user.count({ + where: { createdAt: { gte: monthStart, lt: monthEnd } }, + }) + ) + ); - return monthlyData; + const projectedDelta = projectMonthEnd( + counts[counts.length - 1] ?? 0, + new Date() + ).projectedDelta; + + const historical = windows.map((w, i) => ({ + month: w.shortLabel, + users: counts[i] ?? 0, + projected: i === windows.length - 1 ? projectedDelta : 0, + })); + + const lastTwelveWindows = windows.slice(-12); + const lastTwelveCounts = counts.slice(-12); + const twelveMonth = lastTwelveWindows.map((w, i) => ({ + month: w.longLabel, + users: lastTwelveCounts[i] ?? 0, + projected: i === lastTwelveWindows.length - 1 ? projectedDelta : 0, + })); + + return { twelveMonth, historical }; } async function getScrimActivityData() { @@ -95,32 +150,37 @@ async function getScrimActivityData() { return dataArray; } -async function getTeamCreationData() { - const now = new Date(); - const monthlyData = []; - - // Get data for the last 12 months - for (let i = 11; i >= 0; i--) { - const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1); - const monthEnd = new Date(now.getFullYear(), now.getMonth() - i + 1, 1); - - const teamCount = await prisma.team.count({ - where: { - createdAt: { - gte: monthStart, - lt: monthEnd, - }, - }, - }); +async function getTeamCreationData(firstUserDate: Date) { + const windows = buildMonthWindowsRange(firstUserDate); - const monthName = monthStart.toLocaleDateString("en-US", { month: "long" }); - monthlyData.push({ - month: monthName, - teams: teamCount, - }); - } + const counts = await Promise.all( + windows.map(({ monthStart, monthEnd }) => + prisma.team.count({ + where: { createdAt: { gte: monthStart, lt: monthEnd } }, + }) + ) + ); + + const projectedDelta = projectMonthEnd( + counts[counts.length - 1] ?? 0, + new Date() + ).projectedDelta; + + const historical = windows.map((w, i) => ({ + month: w.shortLabel, + teams: counts[i] ?? 0, + projected: i === windows.length - 1 ? projectedDelta : 0, + })); + + const lastTwelveWindows = windows.slice(-12); + const lastTwelveCounts = counts.slice(-12); + const twelveMonth = lastTwelveWindows.map((w, i) => ({ + month: w.longLabel, + teams: lastTwelveCounts[i] ?? 0, + projected: i === lastTwelveWindows.length - 1 ? projectedDelta : 0, + })); - return monthlyData; + return { twelveMonth, historical }; } async function getTeamManagerData() { @@ -240,7 +300,125 @@ async function getBillingPlanData() { })); } -export default async function AdminAnalyticsPage() { +async function getUserActivityData(firstUserDate: Date) { + const windows = buildMonthWindowsRange(firstUserDate); + + const [totalUsers, counts] = await Promise.all([ + prisma.user.count(), + Promise.all( + windows.map(({ monthStart, monthEnd }) => + prisma.user.count({ + where: { + teams: { + some: { + scrims: { + some: { createdAt: { gte: monthStart, lt: monthEnd } }, + }, + }, + }, + }, + }) + ) + ), + ]); + + const historical = windows.map((w, i) => ({ + month: w.shortLabel, + activeUsers: counts[i] ?? 0, + inProgress: i === windows.length - 1, + })); + + const lastTwelveWindows = windows.slice(-12); + const lastTwelveCounts = counts.slice(-12); + const twelveMonth = lastTwelveWindows.map((w, i) => ({ + month: w.longLabel, + activeUsers: lastTwelveCounts[i] ?? 0, + inProgress: i === lastTwelveWindows.length - 1, + })); + + const currentMonthActive = counts[counts.length - 1] ?? 0; + const inactive = Math.max(totalUsers - currentMonthActive, 0); + const safeTotal = totalUsers > 0 ? totalUsers : 1; + + const pieData: { + status: "Active" | "Inactive"; + count: number; + percentage: number; + }[] = [ + { + status: "Active", + count: currentMonthActive, + percentage: Math.round((currentMonthActive / safeTotal) * 100), + }, + { + status: "Inactive", + count: inactive, + percentage: Math.round((inactive / safeTotal) * 100), + }, + ]; + + return { monthlyActivity: { twelveMonth, historical }, pieData }; +} + +async function getTeamActivityData(firstUserDate: Date) { + const windows = buildMonthWindowsRange(firstUserDate); + + const [totalTeams, counts] = await Promise.all([ + prisma.team.count(), + Promise.all( + windows.map(({ monthStart, monthEnd }) => + prisma.team.count({ + where: { + scrims: { + some: { createdAt: { gte: monthStart, lt: monthEnd } }, + }, + }, + }) + ) + ), + ]); + + const historical = windows.map((w, i) => ({ + month: w.shortLabel, + activeTeams: counts[i] ?? 0, + inProgress: i === windows.length - 1, + })); + + const lastTwelveWindows = windows.slice(-12); + const lastTwelveCounts = counts.slice(-12); + const twelveMonth = lastTwelveWindows.map((w, i) => ({ + month: w.longLabel, + activeTeams: lastTwelveCounts[i] ?? 0, + inProgress: i === lastTwelveWindows.length - 1, + })); + + const currentMonthActive = counts[counts.length - 1] ?? 0; + const inactive = Math.max(totalTeams - currentMonthActive, 0); + const safeTotal = totalTeams > 0 ? totalTeams : 1; + + const pieData: { + status: "Active" | "Inactive"; + count: number; + percentage: number; + }[] = [ + { + status: "Active", + count: currentMonthActive, + percentage: Math.round((currentMonthActive / safeTotal) * 100), + }, + { + status: "Inactive", + count: inactive, + percentage: Math.round((inactive / safeTotal) * 100), + }, + ]; + + return { monthlyActivity: { twelveMonth, historical }, pieData }; +} + +export default async function AdminAnalyticsPage(props: { + searchParams: Promise<{ env?: string }>; +}) { const session = await auth(); if (!session?.user) { redirect("/sign-in"); @@ -257,6 +435,21 @@ export default async function AdminAnalyticsPage() { } const t = await getTranslations("settingsPage.admin.analytics"); + + const sp = await props.searchParams; + const envParam = (Array.isArray(sp.env) ? sp.env[0] : sp.env) ?? "PRODUCTION"; + const env: UsageEnv = ["PRODUCTION", "PREVIEW", "DEVELOPMENT"].includes( + envParam + ) + ? (envParam as UsageEnv) + : "PRODUCTION"; + + const firstUser = await prisma.user.findFirst({ + orderBy: { createdAt: "asc" }, + select: { createdAt: true }, + }); + const firstUserDate = firstUser?.createdAt ?? new Date(); + const [ monthlyUserData, scrimActivityData, @@ -264,13 +457,27 @@ export default async function AdminAnalyticsPage() { teamManagerData, signupMethodData, billingPlanData, + userActivityData, + teamActivityData, + scorecard, + adoption, + activeSeries, + pageHeat, + funnels, ] = await Promise.all([ - getMonthlyUserData(), + getMonthlyUserData(firstUserDate), getScrimActivityData(), - getTeamCreationData(), + getTeamCreationData(firstUserDate), getTeamManagerData(), getSignupMethodData(), getBillingPlanData(), + getUserActivityData(firstUserDate), + getTeamActivityData(firstUserDate), + getScorecard(env), + getFeatureAdoption(env), + getDailyActiveSeries(env), + getPageHeat(env), + getFunnels(env), ]); return ( @@ -280,76 +487,206 @@ export default async function AdminAnalyticsPage() {

{t("description")}

-
-
- - - {t("userGrowth.title")} - {t("userGrowth.description")} - - - - - +
+ +
+ + + + + + {t("tabs.growth")} + {t("tabs.adoption")} + {t("tabs.activity")} + {t("tabs.funnels")} + + + +
+ + + {t("activeUsers.title")} + + {t("activeUsers.description")} + + + + + + + + + {t("monthlyActiveUsers.title")} + + {t("monthlyActiveUsers.description")} + + + + + + +
+
+ + + {t("activeTeams.title")} + + {t("activeTeams.description")} + + + + + + + + + {t("monthlyActiveTeams.title")} + + {t("monthlyActiveTeams.description")} + + + + + + +
+
+ + + {t("userGrowth.title")} + {t("userGrowth.description")} + + + + + + + + {t("signupMethods.title")} + + {t("signupMethods.description")} + + + + + + +
+
+ + + {t("billingPlans.title")} + + {t("billingPlans.description")} + + + + + + + + + {t("scrimActivity.title")} + + {t("scrimActivity.description")} + + + + + + +
+
+ + + {t("teamCreations.title")} + + {t("teamCreations.description")} + + + + + + + + + {t("teamManagerDistribution.title")} + + {t("teamManagerDistribution.description")} + + + + + + +
+
+ + - {t("signupMethods.title")} + {t("usage.adoptionTitle")} - {t("signupMethods.description")} + {t("usage.adoptionDescription")} - - - -
-
- - - {t("billingPlans.title")} - {t("billingPlans.description")} - - - + + + + - {t("scrimActivity.title")} + {t("usage.activeUsersTitle")} - {t("scrimActivity.description")} + {t("usage.activeUsersDescription")} - + -
-
- {t("teamCreations.title")} - - {t("teamCreations.description")} - + {t("usage.hotColdTitle")} + {t("usage.hotColdDescription")} - + + + + - {t("teamManagerDistribution.title")} + {t("usage.funnels.title")} - {t("teamManagerDistribution.description")} + {t("usage.funnels.description")} - - + + {funnels.map((f) => ( + + ))} -
-
+ +
); } diff --git a/src/app/settings/admin/audit-logs/layout.tsx b/src/app/settings/admin/audit-logs/layout.tsx index 1bbc1fc3c..08a291523 100644 --- a/src/app/settings/admin/audit-logs/layout.tsx +++ b/src/app/settings/admin/audit-logs/layout.tsx @@ -3,7 +3,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; export default async function AdminLayout({ children, diff --git a/src/app/settings/admin/audit-logs/page.tsx b/src/app/settings/admin/audit-logs/page.tsx index c401a364a..e6a9e3841 100644 --- a/src/app/settings/admin/audit-logs/page.tsx +++ b/src/app/settings/admin/audit-logs/page.tsx @@ -4,7 +4,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { redirect } from "next/navigation"; export default async function AuditLogsPage() { diff --git a/src/app/settings/admin/impersonate-user/layout.tsx b/src/app/settings/admin/impersonate-user/layout.tsx index f2ef7ec62..915731486 100644 --- a/src/app/settings/admin/impersonate-user/layout.tsx +++ b/src/app/settings/admin/impersonate-user/layout.tsx @@ -3,7 +3,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; export default async function AdminLayout({ children, diff --git a/src/app/settings/admin/impersonate-user/page.tsx b/src/app/settings/admin/impersonate-user/page.tsx index 3526bd6f4..1ec1de588 100644 --- a/src/app/settings/admin/impersonate-user/page.tsx +++ b/src/app/settings/admin/impersonate-user/page.tsx @@ -5,7 +5,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; diff --git a/src/app/settings/admin/page.tsx b/src/app/settings/admin/page.tsx index ad5988a60..419446450 100644 --- a/src/app/settings/admin/page.tsx +++ b/src/app/settings/admin/page.tsx @@ -14,7 +14,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { getTranslations } from "next-intl/server"; import { redirect } from "next/navigation"; diff --git a/src/app/settings/billing/page.tsx b/src/app/settings/billing/page.tsx index 4dfdbf232..d214a380a 100644 --- a/src/app/settings/billing/page.tsx +++ b/src/app/settings/billing/page.tsx @@ -1,3 +1,4 @@ +import { CreditsCard } from "@/components/settings/credits-card"; import { UsageCard } from "@/components/settings/usage-card"; import { Separator } from "@/components/ui/separator"; import { Effect } from "effect"; @@ -57,13 +58,16 @@ export default async function SettingsBillingPage() {

{t("description")}

- +
+ + +
); } diff --git a/src/app/settings/layout.tsx b/src/app/settings/layout.tsx index d412cf646..6f30bbd27 100644 --- a/src/app/settings/layout.tsx +++ b/src/app/settings/layout.tsx @@ -5,7 +5,7 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import type { Metadata, Route } from "next"; import { getTranslations } from "next-intl/server"; diff --git a/src/app/stats/[playerName]/page.tsx b/src/app/stats/[playerName]/page.tsx index 8d11c52be..acd3eb24e 100644 --- a/src/app/stats/[playerName]/page.tsx +++ b/src/app/stats/[playerName]/page.tsx @@ -8,11 +8,11 @@ import { ScrimService } from "@/data/scrim"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, getViewableScrimIds } from "@/lib/auth"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; -import type { Kill, PlayerStat, Scrim } from "@prisma/client"; +import type { Kill, PlayerStat, Scrim } from "@/generated/prisma/client"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; @@ -82,7 +82,10 @@ export default async function PlayerStats( distinct: ["scrimId"], }); - const scrimIds = playerScrims.map((scrim) => scrim.scrimId); + const scrimIds = await getViewableScrimIds( + playerScrims.map((scrim) => scrim.scrimId), + user + ); const allScrims = await prisma.scrim.findMany({ where: { id: { in: scrimIds } }, @@ -121,13 +124,13 @@ export default async function PlayerStats( const yearScrims = allScrims.filter((scrim) => scrim.date >= year); const data: Record = { - "one-week": oneWeekScrims, - "two-weeks": twoWeeksScrims, - "one-month": monthScrims, - "three-months": threeMonthsScrims, - "six-months": sixMonthsScrims, - "one-year": yearScrims, - "all-time": allScrims, + "one-week": timeframe1 ? oneWeekScrims : [], + "two-weeks": timeframe1 ? twoWeeksScrims : [], + "one-month": timeframe1 ? monthScrims : [], + "three-months": timeframe2 ? threeMonthsScrims : [], + "six-months": timeframe2 ? sixMonthsScrims : [], + "one-year": timeframe3 ? yearScrims : [], + "all-time": timeframe3 ? allScrims : [], custom: [], }; @@ -178,26 +181,25 @@ export default async function PlayerStats( allPlayerDeaths = result.allPlayerDeaths; } catch { return ( -
-
-

+
+
+

{name}

+

{t("title", { name, suffix: name.endsWith("s") ? "'" : "'s" })} -

+

- -
-
+ +
+

{t("statsFail", { name })} -

- - ← {t("back")} - -
-
+

+ + ← {t("back")} +
@@ -205,14 +207,13 @@ export default async function PlayerStats( } return ( -
-
-

- {t("title", { name, suffix: name.endsWith("s") ? "'" : "'s" })} -

+
+
+

{name}

[ timeframe, @@ -56,7 +53,6 @@ async function fetchPlayerStats( ]) ) as Record; - // Convert date strings in mapWinrates const convertedMapWinrates = data.data.mapWinrates.map((winrate) => ({ ...winrate, date: new Date(winrate.date), @@ -115,99 +111,102 @@ export default function ComparePage() { const bothPlayersLoaded = player1Query.data?.success && player2Query.data?.success; + const isLoading = player1Query.isLoading || player2Query.isLoading; + const isError = player1Query.isError || player2Query.isError; return ( -
-
-

{t("title")}

-
- - - - {t("enterPlayerNames")} - - -
-
-
- - setPlayer1Input(e.target.value)} - /> -
-
- - setPlayer2Input(e.target.value)} - /> -
-
-
- - {(player1Name ?? player2Name) && ( - - )} -
-
-
-
- - {(player1Query.isLoading || player2Query.isLoading) && ( - - -
- - {t("loading")} -
-
-
+
+
+
+

+ {t("eyebrow")} +

+

+ {t("title")} +

+
+ {(player1Name ?? player2Name) ? ( + + ) : null} +
+ +
+
+ + setPlayer1Input(e.target.value)} + /> +
+
+ + setPlayer2Input(e.target.value)} + /> +
+ +
+ + {isLoading && ( +
+ + {t("loading")} +
)} - {(player1Query.isError || player2Query.isError) && ( - - -
-

{t("errorLoading")}

-

- {player1Query.isError && t("errorPlayer1")} - {player1Query.isError && player2Query.isError - ? t("errorBoth") - : ""} - {player2Query.isError && t("errorPlayer2")} -

-
-
-
+ {isError && ( +
+

+ {t("errorLoading")} +

+

+ {player1Query.isError && t("errorPlayer1")} + {player1Query.isError && player2Query.isError ? t("errorBoth") : ""} + {player2Query.isError && t("errorPlayer2")} +

+
)} {bothPlayersLoaded && player1Query.data?.data && player2Query.data?.data && ( - +
+ +
)} - {!player1Name && !player2Name && ( - - -

{t("enterPlayersPrompt")}

-
-
+ {!player1Name && !player2Name && !isLoading && ( +

+ {t("enterPlayersPrompt")} +

)}
); diff --git a/src/app/stats/hero/[heroName]/page.tsx b/src/app/stats/hero/[heroName]/page.tsx index 20fae4be3..d99621bc0 100644 --- a/src/app/stats/hero/[heroName]/page.tsx +++ b/src/app/stats/hero/[heroName]/page.tsx @@ -8,13 +8,13 @@ import { HeroService } from "@/data/hero"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, getViewableScrimIds } from "@/lib/auth"; import { Permission } from "@/lib/permissions"; import prisma from "@/lib/prisma"; import { translateHeroName } from "@/lib/utils"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; import type { PagePropsWithLocale } from "@/types/next"; -import type { Kill, PlayerStat, Scrim } from "@prisma/client"; +import type { Kill, PlayerStat, Scrim } from "@/generated/prisma/client"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; @@ -86,7 +86,10 @@ export default async function HeroStats( distinct: ["scrimId"], }); - const scrimIds = heroScrims.map((scrim) => scrim.scrimId); + const scrimIds = await getViewableScrimIds( + heroScrims.map((scrim) => scrim.scrimId), + user + ); const allScrims = await prisma.scrim.findMany({ where: { id: { in: scrimIds } }, @@ -125,17 +128,22 @@ export default async function HeroStats( const yearScrims = allScrims.filter((scrim) => scrim.date >= year); const data: Record = { - "one-week": oneWeekScrims, - "two-weeks": twoWeeksScrims, - "one-month": monthScrims, - "three-months": threeMonthsScrims, - "six-months": sixMonthsScrims, - "one-year": yearScrims, - "all-time": allScrims, + "one-week": timeframe1 ? oneWeekScrims : [], + "two-weeks": timeframe1 ? twoWeeksScrims : [], + "one-month": timeframe1 ? monthScrims : [], + "three-months": timeframe2 ? threeMonthsScrims : [], + "six-months": timeframe2 ? sixMonthsScrims : [], + "one-year": timeframe3 ? yearScrims : [], + "all-time": timeframe3 ? allScrims : [], custom: [], }; - const allScrimIds = allScrims.map((scrim) => scrim.id); + const permitted = timeframe3 + ? "all-time" + : timeframe2 + ? "six-months" + : "one-month"; + const permittedScrimIds = data[permitted].map((scrim) => scrim.id); let allHeroStats: PlayerStat[]; let allHeroKills: Kill[]; @@ -146,13 +154,19 @@ export default async function HeroStats( Effect.all( [ HeroService.pipe( - Effect.flatMap((svc) => svc.getAllStatsForHero(allScrimIds, hero)) + Effect.flatMap((svc) => + svc.getAllStatsForHero(permittedScrimIds, hero) + ) ), HeroService.pipe( - Effect.flatMap((svc) => svc.getAllKillsForHero(allScrimIds, hero)) + Effect.flatMap((svc) => + svc.getAllKillsForHero(permittedScrimIds, hero) + ) ), HeroService.pipe( - Effect.flatMap((svc) => svc.getAllDeathsForHero(allScrimIds, hero)) + Effect.flatMap((svc) => + svc.getAllDeathsForHero(permittedScrimIds, hero) + ) ), ], { concurrency: "unbounded" } @@ -160,26 +174,24 @@ export default async function HeroStats( ); } catch { return ( -
-
-

- {t("header", { hero: translatedHeroName })} -

+
+
+

+ {translatedHeroName} +

- -
-
+ +
+

{t("heroFail", { hero: translatedHeroName })} -

- - ← {t("back")} - -
-
+

+ + ← {t("back")} +
@@ -187,11 +199,11 @@ export default async function HeroStats( } return ( -
-
-

- {t("header", { hero: translatedHeroName })} -

+
+
+

+ {translatedHeroName} +

-
-

{t("title")}

-
- -
- - - {t("tank")} - - -
- {tankHeroes.map((hero) => ( - - {t("altText", - - {heroNames.get(toHero(hero)) ?? hero} - - - ))} -
-
-
- - - {t("damage")} - - -
- {damageHeroes.map((hero) => ( - - {t("altText", - - {heroNames.get(toHero(hero)) ?? hero} - - - ))} -
-
-
- - - {t("support")} - - -
- {supportHeroes.map((hero) => ( - - {t("altText", - - {heroNames.get(toHero(hero)) ?? hero} - - - ))} -
-
-
- -

+

+
+

+ {t("pickerTitle")} +

+

{t("description")}

+ +
+ {groups.map((group) => ( +
+

+ {group.title} +

+ +
+ {group.heroes.map((hero) => { + const heroLabel = heroNames.get(toHero(hero)) ?? hero; + return ( + + {t("altText", + + {heroLabel} + + + ); + })} +
+
+
+ ))} +
); } diff --git a/src/app/stats/map/page.tsx b/src/app/stats/map/page.tsx new file mode 100644 index 000000000..1a20cfe7a --- /dev/null +++ b/src/app/stats/map/page.tsx @@ -0,0 +1,29 @@ +import { MapHeroTrends } from "@/components/stats/map/map-hero-trends"; +import { MapHeroTrendsService } from "@/data/map"; +import { getOverwatchPatches } from "@/data/overwatch/patches-service"; +import { AppRuntime } from "@/data/runtime"; +import { Effect } from "effect"; +import type { Metadata } from "next"; + +export const metadata: Metadata = { + title: "Map Hero Trends | Parsertime", + description: "Recent hero pick rates, winrates, and playtime trends by map.", +}; + +export default async function MapHeroStatsPage() { + const [data, patches] = await Promise.all([ + AppRuntime.runPromise( + MapHeroTrendsService.pipe( + Effect.flatMap((svc) => svc.getRecentMapHeroTrends()) + ) + ), + getOverwatchPatches(), + ]); + return ( + + ); +} diff --git a/src/app/stats/page.tsx b/src/app/stats/page.tsx index 8463dd060..4dc59abfb 100644 --- a/src/app/stats/page.tsx +++ b/src/app/stats/page.tsx @@ -1,25 +1,15 @@ import { PlayerHoverCard } from "@/components/player/hover-card"; -import { Searchbar } from "@/components/stats/searchbar"; +import { StatBlock, StatGrid, StatPanel } from "@/components/player/stat-panel"; +import { SectionHeader } from "@/components/section-header"; import { - Card, - CardContent, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { CardIcon } from "@/components/ui/card-icon"; + LeaderboardCard, + type LeaderboardRow, +} from "@/components/stats/leaderboard-card"; +import { Searchbar } from "@/components/stats/searchbar"; import { Link } from "@/components/ui/link"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; +import { getApproximateCounts } from "@/lib/approximate-count"; import prisma from "@/lib/prisma"; import { - cn, format, round, toTimestampWithDays, @@ -31,15 +21,17 @@ import { getTranslations } from "next-intl/server"; export default async function StatsPage() { const t = await getTranslations("statsPage"); - const [userNum, scrimNum, killNum, statNum, mapNum, calculatedStatNum] = - await Promise.all([ - prisma.user.count(), - prisma.scrim.count(), - prisma.kill.count(), - prisma.playerStat.count(), - prisma.mapData.count(), - prisma.calculatedStat.count(), - ]); + // Large tables use approximate counts (pg_class.reltuples) to avoid scanning + // ~1M+ rows per request; User/Scrim are small enough for exact counts. + const [userNum, scrimNum, approxCounts] = await Promise.all([ + prisma.user.count(), + prisma.scrim.count(), + getApproximateCounts(["Kill", "PlayerStat", "MapData", "CalculatedStat"]), + ]); + const killNum = approxCounts.Kill; + const statNum = approxCounts.PlayerStat; + const mapNum = approxCounts.MapData; + const calculatedStatNum = approxCounts.CalculatedStat; type MostPlayedHeroes = { player_hero: string; @@ -114,6 +106,8 @@ export default async function StatsPage() { "Kill" k INNER JOIN "UltimateEnd" ue ON k.victim_name = ue.player_name AND k.match_time = ue.match_time + AND k."MapDataId" = ue."MapDataId" + AND k."scrimId" = ue."scrimId" WHERE k.victim_hero = 'Lúcio' AND ue.player_hero = 'Lúcio' @@ -123,709 +117,180 @@ export default async function StatsPage() { coincidence_count DESC LIMIT 3;`; - return ( -
-
-

{t("title")}

-
+ function playerLink(name: string) { + return ( + + + {name} + + + ); + } - + const heroRows: LeaderboardRow[] = mostPlayedHeroes.map((row) => ({ + key: row.player_hero, + name: row.player_hero, + value: toTimestampWithDays(row.total_time_played), + })); -
-

- {t("globalStats")} -

-
+ const killRows: LeaderboardRow[] = topAttackerStats.map((row) => ({ + key: row.attacker_name, + name: playerLink(row.attacker_name), + value: format(row._count.attacker_name), + })); -
- - - - {t("users.title")} - - - - - - - -
{format(userNum)}
-
- -

{t("users.footer")}

-
-
- - - - {t("scrims.title")} - - - - - - - - - - - - - -
{format(scrimNum)}
-
- -

- {t("scrims.footer")} -

-
-
- - - - {t("kills.title")} - - - - - - - - - - -
{format(killNum)}
-
- -

{t("kills.footer")}

-
-
- - - - {t("playerStat.title")} - - - - - - - -
- {format(statNum + calculatedStatNum)} -
-
- -

- {t("playerStat.footer")} -

-
-
-
+ const damageRows: LeaderboardRow[] = topDamageStats.map((row) => ({ + key: row.player_name, + name: playerLink(row.player_name), + value: format(round(row._sum.hero_damage_dealt!)), + })); + + const healingRows: LeaderboardRow[] = topHealerStats.map((row) => ({ + key: row.player_name, + name: playerLink(row.player_name), + value: format(round(row._sum.healing_dealt!)), + })); -
-

- {t("leaderboard")} -

+ const blockedRows: LeaderboardRow[] = damageBlockedStats.map((row) => ({ + key: row.player_name, + name: playerLink(row.player_name), + value: format(round(row._sum.damage_blocked!)), + })); + + const deathRows: LeaderboardRow[] = highestPlayerDeaths.map((row) => ({ + key: row.victim_name, + name: playerLink(row.victim_name), + value: format(row._count.victim_name), + })); + + const timePlayedRows: LeaderboardRow[] = heroTimePlayedStats.map((row) => ({ + key: row.player_name, + name: playerLink(row.player_name), + value: toTimestampWithHours(row._sum.hero_time_played!), + })); + + const ajaxRows: LeaderboardRow[] = ajaxes.map((row) => ({ + key: row.player_name, + name: playerLink(row.player_name), + value: row.coincidence_count.toString(), + })); + + function valueColumn(label: string) { + return { label, align: "right" as const }; + } + + return ( +
+
+

{t("title")}

-
- - - - {t("top3MostPlayed.title")} - - - - - - - - - - - - {t("top3MostPlayed.rank")} - {t("top3MostPlayed.hero")} - {t("top3MostPlayed.timePlayed")} - - - - {mostPlayedHeroes.map((row, idx) => ( - - - - - - - - - - - - {row.player_hero} - - {toTimestampWithDays(row.total_time_played)} - - - ))} - -
-
- -

- {t("top3MostPlayed.footer", { mapNum })} -

-
-
- - - - {t("top3Kills.title")} - - - - - - - - - - - - - - {t("top3Kills.rank")} - {t("top3Kills.player")} - {t("top3Kills.kills")} - - - - {topAttackerStats.map((row, idx) => ( - - - - - - - - - - - - - - - {row.attacker_name} - - - - {format(row._count.attacker_name)} - - ))} - -
-
- -

- {t("top3Kills.footer", { mapNum })} -

-
-
- - - - {t("top3Dmg.title")} - - - - - - - - - - {t("top3Dmg.rank")} - {t("top3Dmg.player")} - {t("top3Dmg.heroDmgDealt")} - - - - {topDamageStats.map((row, idx) => ( - - - - - - - - - - - - - - - {row.player_name} - - - - - {format(round(row._sum.hero_damage_dealt!))} - - - ))} - -
-
- -

- {t("top3Dmg.footer", { mapNum })} -

-
-
- - - - {t("top3Healing.title")} - - - - - - - - - - {t("top3Healing.rank")} - {t("top3Healing.player")} - {t("top3Healing.healingDealt")} - - - - {topHealerStats.map((row, idx) => ( - - - - - - - - - - - - - - - {row.player_name} - - - - - {" "} - {format(round(row._sum.healing_dealt!))} - - - ))} - -
-
- -

- {t("top3Healing.footer", { mapNum })} -

-
-
- - - - {t("top3DmgBlocked.title")} - - - - - - - - - - {t("top3DmgBlocked.rank")} - {t("top3DmgBlocked.player")} - {t("top3DmgBlocked.dmgBlocked")} - - - - {damageBlockedStats.map((row, idx) => ( - - - - - - - - - - - - - - - {row.player_name} - - - - - {" "} - {format(round(row._sum.damage_blocked!))} - - - ))} - -
-
- -

- {t("top3DmgBlocked.footer", { mapNum })} -

-
-
- - - - {t("top3Deaths.title")} - - - - - - - - - - - - - - {t("top3Deaths.rank")} - {t("top3Deaths.player")} - {t("top3Deaths.deaths")} - - - - {highestPlayerDeaths.map((row, idx) => ( - - - - - - - - - - - - - - - {row.victim_name} - - - - - {" "} - {format(round(row._count.victim_name))} - - - ))} - -
-
- -

- {t("top3Deaths.footer", { mapNum })} -

-
-
- - - - {t("top3TimePlayed.title")} - - - - - - - - - - - - - {t("top3TimePlayed.rank")} - {t("top3TimePlayed.player")} - {t("top3TimePlayed.timePlayed")} - - - - {heroTimePlayedStats.map((row, idx) => ( - - - - - - - - - - - - - - - {row.player_name} - - - - - {toTimestampWithHours(row._sum.hero_time_played!)} - - - ))} - -
-
- -

- {t("top3TimePlayed.footer", { mapNum })} -

-
-
- - - - {t("top3Ajax.title")} - - - - - - - - - - - - {t("top3Ajax.rank")} - {t("top3Ajax.player")} - {t("top3Ajax.ajaxes")} - - - - {ajaxes.map((row, idx) => ( - - - - - - - - - - - - - - - {row.player_name} - - - - {row.coincidence_count.toString()} - - ))} - -
-
- -

- {t("top3Ajax.footer", { mapNum })} -

-
-
+ +
+
+ +
+ + + + + + + + + +
+ +
+ + +
+ + + + + + + + +
+
+
); } diff --git a/src/app/stats/team/[teamId]/_lib/context.ts b/src/app/stats/team/[teamId]/_lib/context.ts new file mode 100644 index 000000000..5c0770c6f --- /dev/null +++ b/src/app/stats/team/[teamId]/_lib/context.ts @@ -0,0 +1,206 @@ +import { AppRuntime } from "@/data/runtime"; +import { type TeamDateRange, TeamStatsService } from "@/data/team"; +import { getTeamSubstituteNames } from "@/data/team/substitutes"; +import { UserService } from "@/data/user"; +import { $Enums } from "@/generated/prisma/browser"; +import { auth, canManageTeam } from "@/lib/auth"; +import { positionalData, simulationTool } from "@/lib/flags"; +import { Permission } from "@/lib/permissions"; +import prisma from "@/lib/prisma"; +import { isValidTimeframe, type Timeframe } from "@/lib/timeframe"; +import { computeTeamTsr, matchesAnyName } from "@/lib/tsr/team"; +import { addMonths, addWeeks, addYears } from "date-fns"; +import { Effect } from "effect"; +import { notFound } from "next/navigation"; + +export type TimeframePermissions = { + "stats-timeframe-1": boolean; + "stats-timeframe-2": boolean; + "stats-timeframe-3": boolean; +}; + +export function computeDateRange( + timeframe: Timeframe, + customFrom?: string, + customTo?: string +): TeamDateRange | undefined { + const now = new Date(); + + switch (timeframe) { + case "one-week": + return { from: addWeeks(now, -1), to: now }; + case "two-weeks": + return { from: addWeeks(now, -2), to: now }; + case "one-month": + return { from: addMonths(now, -1), to: now }; + case "three-months": + return { from: addMonths(now, -3), to: now }; + case "six-months": + return { from: addMonths(now, -6), to: now }; + case "one-year": + return { from: addYears(now, -1), to: now }; + case "all-time": + return undefined; + case "custom": { + if (customFrom && customTo) { + const from = new Date(customFrom); + const to = new Date(customTo); + if (!isNaN(from.getTime()) && !isNaN(to.getTime())) { + return { from, to }; + } + } + return { from: addWeeks(now, -1), to: now }; + } + } +} + +export function clampTimeframe( + requested: Timeframe, + permissions: Record +): Timeframe { + const tier3Only: Timeframe[] = ["one-year", "all-time", "custom"]; + const tier2Only: Timeframe[] = ["three-months", "six-months"]; + + if (tier3Only.includes(requested) && !permissions["stats-timeframe-3"]) { + return permissions["stats-timeframe-2"] ? "six-months" : "one-month"; + } + if (tier2Only.includes(requested) && !permissions["stats-timeframe-2"]) { + return "one-month"; + } + return requested; +} + +async function loadTeam(teamId: number) { + return prisma.team.findFirst({ + where: { id: teamId }, + include: { users: true }, + }); +} + +export type TeamWithUsers = NonNullable>>; + +export type TeamStatsShell = + | { gated: true; team: TeamWithUsers; totalScrimCount: number } + | { + gated: false; + team: TeamWithUsers; + teamId: number; + isManager: boolean; + substituteNames: Set; + totalScrimCount: number; + permissions: TimeframePermissions; + effectiveTimeframe: Timeframe; + dateRange: TeamDateRange | undefined; + positionalEnabled: boolean; + simulationEnabled: boolean; + }; + +/** + * Shared setup for every team-stats route: auth, team + access check, the + * insufficient-scrims gate, permission-clamped timeframe, and the nav feature + * flags. Range-dependent pages call this, then fetch only their own slice. + */ +export async function loadTeamStatsShell( + teamIdParam: string, + searchParams: { [key: string]: string | string[] | undefined } +): Promise { + const session = await auth(); + const user = await AppRuntime.runPromise( + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user.email))) + ); + if (!user) notFound(); + + const teamId = parseInt(teamIdParam); + const team = await loadTeam(teamId); + if (!team) notFound(); + + const userIsMember = team.users.some((u) => u.id === user.id); + if (!userIsMember && user.role !== $Enums.UserRole.ADMIN) notFound(); + + const totalScrimCount = await prisma.scrim.count({ where: { teamId } }); + if (totalScrimCount < 2) { + return { gated: true, team, totalScrimCount }; + } + + const [isManager, substituteNames] = await Promise.all([ + canManageTeam(teamId, user), + getTeamSubstituteNames(teamId), + ]); + + const [timeframe1, timeframe2, timeframe3] = await Promise.all([ + new Permission("stats-timeframe-1").check(), + new Permission("stats-timeframe-2").check(), + new Permission("stats-timeframe-3").check(), + ]); + const permissions: TimeframePermissions = { + "stats-timeframe-1": timeframe1, + "stats-timeframe-2": timeframe2, + "stats-timeframe-3": timeframe3, + }; + + const rawTimeframe = + typeof searchParams.timeframe === "string" ? searchParams.timeframe : null; + const requestedTimeframe: Timeframe = isValidTimeframe(rawTimeframe) + ? rawTimeframe + : "one-week"; + const effectiveTimeframe = clampTimeframe(requestedTimeframe, permissions); + + const customFrom = + typeof searchParams.from === "string" ? searchParams.from : undefined; + const customTo = + typeof searchParams.to === "string" ? searchParams.to : undefined; + const dateRange = computeDateRange(effectiveTimeframe, customFrom, customTo); + + const [positionalEnabled, simulationEnabled] = await Promise.all([ + positionalData(), + simulationTool(), + ]); + + return { + gated: false, + team, + teamId, + isManager, + substituteNames, + totalScrimCount, + permissions, + effectiveTimeframe, + dateRange, + positionalEnabled, + simulationEnabled, + }; +} + +/** + * The range-dependent summary ribbon data (record/winrate + TSR) shown in the + * shared header on every tab. Cheap and Effect-cached, so re-running per + * navigation is fine. + */ +export async function loadTeamStatsHeaderData( + shell: Extract +) { + const { teamId, team, dateRange, substituteNames } = shell; + const [winrates, teamTsr] = await Promise.all([ + AppRuntime.runPromise( + TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) + ) + ), + prisma.scrim + .findMany({ where: { teamId }, select: { id: true } }) + .then((rows) => + computeTeamTsr( + team.users + .map((u) => ({ + id: u.id, + name: u.name, + battletag: u.battletag, + })) + // Substitutes are excluded from the team's aggregate skill rating. + .filter((u) => !matchesAnyName(u, substituteNames)), + rows.map((r) => r.id) + ) + ), + ]); + return { winrates, teamTsr }; +} diff --git a/src/app/stats/team/[teamId]/charts/page.tsx b/src/app/stats/team/[teamId]/charts/page.tsx new file mode 100644 index 000000000..eb852dcda --- /dev/null +++ b/src/app/stats/team/[teamId]/charts/page.tsx @@ -0,0 +1,41 @@ +import { TeamChartsTab } from "@/components/stats/team/charts/team-charts-tab"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; +import { TeamScatterService } from "@/data/team"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/charts"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const { scatterData } = await AppRuntime.runPromise( + Effect.all( + { + scatterData: TeamScatterService.pipe( + Effect.flatMap((svc) => svc.getPlayerScatterStats(teamId, dateRange)) + ), + }, + { concurrency: "unbounded" } + ) + ); + + return ( +
+ +
+ ); +} diff --git a/src/app/stats/team/[teamId]/heroes/page.tsx b/src/app/stats/team/[teamId]/heroes/page.tsx new file mode 100644 index 000000000..a09ebc2a9 --- /dev/null +++ b/src/app/stats/team/[teamId]/heroes/page.tsx @@ -0,0 +1,96 @@ +import { HeroBanImpactCard } from "@/components/stats/team/hero-ban-impact-card"; +import { HeroOurBansCard } from "@/components/stats/team/hero-our-bans-card"; +import { HeroPoolContainer } from "@/components/stats/team/hero-pool-container"; +import { StatRibbon } from "@/components/stats/team/stat-ribbon"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; +import { + TeamAnalyticsService, + TeamBanImpactService, + TeamHeroPoolService, +} from "@/data/team"; +import { calculateHeroPickrateMatrix } from "@/lib/hero-pickrate-utils"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/heroes"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const { heroPool, banImpactAnalysis, heroPickrateRawData } = + await AppRuntime.runPromise( + Effect.all( + { + heroPool: TeamHeroPoolService.pipe( + Effect.flatMap((svc) => + svc.getHeroPoolAnalysis(teamId, dateRange?.from, dateRange?.to) + ) + ), + banImpactAnalysis: TeamBanImpactService.pipe( + Effect.flatMap((svc) => + svc.getTeamBanImpactAnalysis(teamId, dateRange) + ) + ), + heroPickrateRawData: TeamAnalyticsService.pipe( + Effect.flatMap((svc) => + svc.getHeroPickrateRawData(teamId, dateRange) + ) + ), + }, + { concurrency: "unbounded" } + ) + ); + const heroPickrateMatrix = calculateHeroPickrateMatrix(heroPickrateRawData); + + return ( +
+ + + + +
+ ); +} diff --git a/src/app/stats/team/[teamId]/layout.tsx b/src/app/stats/team/[teamId]/layout.tsx index 9c5513651..ddf357b8f 100644 --- a/src/app/stats/team/[teamId]/layout.tsx +++ b/src/app/stats/team/[teamId]/layout.tsx @@ -1,3 +1,9 @@ +import { RangeTransitionProvider } from "@/components/stats/team/range-transition-context"; +import { TeamStatsContent } from "@/components/stats/team/team-stats-content"; +import { TeamStatsHeaderClient } from "@/components/stats/team/team-stats-header-client"; +import { TeamStatsTabsNav } from "@/components/stats/team/team-stats-tabs-nav"; +import { isAuthedToViewTeam } from "@/lib/auth"; +import { positionalData, simulationTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; import type { Metadata } from "next"; import { getLocale, getTranslations } from "next-intl/server"; @@ -10,10 +16,16 @@ export async function generateMetadata( const locale = await getLocale(); const teamId = parseInt(params.teamId); - const team = await prisma.team.findFirst({ - where: { id: teamId }, - select: { name: true }, - }); + const canViewTeam = + Number.isSafeInteger(teamId) && + teamId > 0 && + (await isAuthedToViewTeam(teamId)); + const team = canViewTeam + ? await prisma.team.findFirst({ + where: { id: teamId }, + select: { name: true }, + }) + : null; const teamName = team?.name ?? t("defaultTeam"); @@ -38,8 +50,33 @@ export async function generateMetadata( }; } -export default function TeamStatsLayout({ - children, -}: LayoutProps<"/stats/team/[teamId]">) { - return children; +// The persistent shell: header (client, authed via API) + tab nav. This layout +// performs NO authorization — auth lives in each page's loadTeamStatsShell and +// in the header's stats-summary API route, since layouts do not re-render on +// soft navigation and are not a valid security boundary. Only non-sensitive +// data (feature flags, the teamId already in the URL) is read here. +export default async function TeamStatsLayout( + props: LayoutProps<"/stats/team/[teamId]"> +) { + const params = await props.params; + const teamId = parseInt(params.teamId); + + const [positionalEnabled, simulationEnabled] = await Promise.all([ + positionalData(), + simulationTool(), + ]); + + return ( +
+ + + + {props.children} + +
+ ); } diff --git a/src/app/stats/team/[teamId]/loading.tsx b/src/app/stats/team/[teamId]/loading.tsx index 363326696..7c8a3a0d9 100644 --- a/src/app/stats/team/[teamId]/loading.tsx +++ b/src/app/stats/team/[teamId]/loading.tsx @@ -1,203 +1,69 @@ /* oxlint-disable react/no-array-index-key */ -import { Card, CardContent, CardHeader } from "@/components/ui/card"; import { Skeleton } from "@/components/ui/skeleton"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +// The header and tab nav live in the persistent layout, so this loading +// boundary only skeletons the page content that streams in per tab. export default function TeamStatsLoading() { return ( -
- {/* Header Section Skeleton */} -
- -
- -
- - -
-
-
- - {/* Tabbed Content Skeleton */} - - - Overview - Performance - Heroes - Trends - Maps - Teamfights - - - {/* Overview Tab Skeleton */} - - {/* Quick Stats Skeleton */} - - - - - -
- {Array.from({ length: 4 }).map((_, i) => ( -
- - -
- ))} -
-
-
- - {/* Team Roster + Recent Activity Grid Skeleton */} -
- {/* Team Roster Skeleton */} - - - - - -
- {Array.from({ length: 6 }).map((_, i) => ( -
- -
- - -
-
- ))} -
-
-
+
+ + + +
+ ); +} - {/* Recent Activity Calendar Skeleton */} - - - - - - - - -
+function SkeletonRibbon() { + return ( +
+ {Array.from({ length: 4 }).map((_, i) => ( +
+ + + +
+ ))} +
+ ); +} - {/* Top Maps + Strengths/Weaknesses Grid Skeleton */} -
- {/* Top Maps Skeleton */} - - - - - -
- {Array.from({ length: 5 }).map((_, i) => ( -
-
- - -
- -
- ))} -
-
-
+function SkeletonSection({ bodyHeight }: { bodyHeight: number }) { + return ( +
+
+ + +
+ +
+ ); +} - {/* Strengths/Weaknesses Skeleton */} - - - - - -
-
- -
- - -
-
-
- -
- - -
-
-
-
-
+function SkeletonTable({ rows }: { rows: number }) { + return ( +
+
+ + +
+
+ + {Array.from({ length: rows }).map((_, i) => ( +
+ + +
+ + + + +
- - {/* Role Balance Radar Skeleton */} - - - - - -
- -
-
-
- - - {/* Other Tabs Skeleton (shown when switching tabs during loading) */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
+ ))} +
+ ); } diff --git a/src/app/stats/team/[teamId]/maps/page.tsx b/src/app/stats/team/[teamId]/maps/page.tsx new file mode 100644 index 000000000..aa1d74dee --- /dev/null +++ b/src/app/stats/team/[teamId]/maps/page.tsx @@ -0,0 +1,116 @@ +import { MapModePerformanceCard } from "@/components/stats/team/map-mode-performance-card"; +import { MapWinrateGallery } from "@/components/stats/team/map-winrate-gallery"; +import { PlayerMapPerformanceCard } from "@/components/stats/team/player-map-performance-card"; +import { StatRibbon } from "@/components/stats/team/stat-ribbon"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; +import { + TeamAnalyticsService, + TeamMapModeService, + TeamStatsService, +} from "@/data/team"; +import { getMapNames } from "@/lib/utils"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/maps"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const [ + { allMapsPlaytime, mapModePerformance, winrates }, + mapNames, + playerMapPerformance, + ] = await Promise.all([ + AppRuntime.runPromise( + Effect.all( + { + allMapsPlaytime: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTopMapsByPlaytime(teamId, dateRange)) + ), + mapModePerformance: TeamMapModeService.pipe( + Effect.flatMap((svc) => + svc.getMapModePerformance(teamId, dateRange) + ) + ), + winrates: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) + ), + }, + { concurrency: "unbounded" } + ) + ), + getMapNames(), + AppRuntime.runPromise( + TeamAnalyticsService.pipe( + Effect.flatMap((svc) => + svc.getPlayerMapPerformanceMatrix(teamId, dateRange) + ) + ) + ), + ]); + + const mapPlaytimes: Record = {}; + allMapsPlaytime.forEach((map) => { + mapPlaytimes[map.name] = map.playtime; + }); + + return ( +
+ m.gamesPlayed > 0 + ).length + ), + sub: "with games", + }, + ]} + columns={4} + /> + + + +
+ ); +} diff --git a/src/app/stats/team/[teamId]/page.tsx b/src/app/stats/team/[teamId]/page.tsx index ce12adf6d..288cca742 100644 --- a/src/app/stats/team/[teamId]/page.tsx +++ b/src/app/stats/team/[teamId]/page.tsx @@ -1,230 +1,61 @@ -import { RecentActivityCalendar } from "@/components/profile/recent-activity-calendar"; -import { BestRoleTriosCard } from "@/components/stats/team/best-role-trios-card"; -import { HeroBanImpactCard } from "@/components/stats/team/hero-ban-impact-card"; -import { HeroOurBansCard } from "@/components/stats/team/hero-our-bans-card"; -import { HeroPoolContainer } from "@/components/stats/team/hero-pool-container"; -import { MapModePerformanceCard } from "@/components/stats/team/map-mode-performance-card"; -import { MapWinrateGallery } from "@/components/stats/team/map-winrate-gallery"; -import { MatchupWinrateTab } from "@/components/stats/team/matchup-winrate-tab"; -import { PlayerMapPerformanceCard } from "@/components/stats/team/player-map-performance-card"; -import { QuickStatsCard } from "@/components/stats/team/quick-stats-card"; -import { RecentFormCard } from "@/components/stats/team/recent-form-card"; -import { RoleBalanceRadar } from "@/components/stats/team/role-balance-radar"; -import { RolePerformanceCard } from "@/components/stats/team/role-performance-card"; -import { SimulatorTab } from "@/components/stats/team/simulator-tab"; -import { StrengthsWeaknessesCard } from "@/components/stats/team/strengths-weaknesses-card"; -import { SwapOverviewCard } from "@/components/stats/team/swap-overview-card"; -import { SwapPairsCard } from "@/components/stats/team/swap-pairs-card"; -import { SwapPlayerBreakdownCard } from "@/components/stats/team/swap-player-breakdown-card"; -import { SwapTimingCard } from "@/components/stats/team/swap-timing-card"; -import { SwapWinrateImpactCard } from "@/components/stats/team/swap-winrate-impact-card"; -import { TeamFightStatsCard } from "@/components/stats/team/team-fight-stats-card"; -import { TeamRangePicker } from "@/components/stats/team/team-range-picker"; +import { MapPerformanceTable } from "@/components/stats/team/map-performance-table"; +import { OverviewInsightsBand } from "@/components/stats/team/overview-insights-band"; +import { QuickStatsRibbon } from "@/components/stats/team/quick-stats-ribbon"; import { TeamRosterGrid } from "@/components/stats/team/team-roster-grid"; -import { TopMapsCard } from "@/components/stats/team/top-maps-card"; -import { UltPlayerRankingsCard } from "@/components/stats/team/ult-player-rankings-card"; -import { UltRoleBreakdownCard } from "@/components/stats/team/ult-role-breakdown-card"; -import { AbilityImpactAnalysisCard } from "@/components/stats/team/ability-impact-analysis-card"; -import { UltImpactAnalysisCard } from "@/components/stats/team/ult-impact-analysis-card"; -import { UltUsageOverviewCard } from "@/components/stats/team/ult-usage-overview-card"; -import { UltimateEconomyCard } from "@/components/stats/team/ultimate-economy-card"; -import { WinLossStreaksCard } from "@/components/stats/team/win-loss-streaks-card"; -import { WinProbabilityInsights } from "@/components/stats/team/win-probability-insights"; -import { WinrateOverTimeChart } from "@/components/stats/team/winrate-over-time-chart"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; -import { TeamAnalyticsService, TeamPredictionService } from "@/data/team"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; import { - type TeamDateRange, - TeamStatsService, - TeamFightStatsService, - TeamRoleStatsService, - TeamTrendsService, - TeamMapModeService, - TeamQuickWinsService, TeamHeroPoolService, - TeamHeroSwapService, - TeamBanImpactService, - TeamAbilityImpactService, - TeamUltService, - TeamMatchupService, + TeamQuickWinsService, + TeamRoleStatsService, TeamSharedDataService, + TeamStatsService, } from "@/data/team"; -import { Effect } from "effect"; -import { AppRuntime } from "@/data/runtime"; -import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; -import { simulationTool, ultimateImpactTool } from "@/lib/flags"; -import { calculateHeroPickrateMatrix } from "@/lib/hero-pickrate-utils"; -import { Permission } from "@/lib/permissions"; -import prisma from "@/lib/prisma"; -import { isValidTimeframe, type Timeframe } from "@/lib/timeframe"; +import { getTempoBaselines } from "@/lib/tempo/read"; import { getMapNames } from "@/lib/utils"; import type { PagePropsWithLocale } from "@/types/next"; -import { $Enums } from "@prisma/client"; -import { addMonths, addWeeks, addYears } from "date-fns"; -import Image from "next/image"; -import { notFound } from "next/navigation"; - -function computeDateRange( - timeframe: Timeframe, - customFrom?: string, - customTo?: string -): TeamDateRange | undefined { - const now = new Date(); - - switch (timeframe) { - case "one-week": - return { from: addWeeks(now, -1), to: now }; - case "two-weeks": - return { from: addWeeks(now, -2), to: now }; - case "one-month": - return { from: addMonths(now, -1), to: now }; - case "three-months": - return { from: addMonths(now, -3), to: now }; - case "six-months": - return { from: addMonths(now, -6), to: now }; - case "one-year": - return { from: addYears(now, -1), to: now }; - case "all-time": - return undefined; - case "custom": { - if (customFrom && customTo) { - const from = new Date(customFrom); - const to = new Date(customTo); - if (!isNaN(from.getTime()) && !isNaN(to.getTime())) { - return { from, to }; - } - } - return { from: addWeeks(now, -1), to: now }; - } - } -} +import { Effect } from "effect"; +import { loadTeamStatsShell } from "./_lib/context"; -function clampTimeframe( - requested: Timeframe, - permissions: Record -): Timeframe { - const tier3Only: Timeframe[] = ["one-year", "all-time", "custom"]; - const tier2Only: Timeframe[] = ["three-months", "six-months"]; +// The heaviest dashboard in the app: dozens of services over a shared +// connection pool. The platform default (20s on preview) is too tight for +// a cold cache; warm renders are far faster. +export const maxDuration = 60; - if (tier3Only.includes(requested) && !permissions["stats-timeframe-3"]) { - return permissions["stats-timeframe-2"] ? "six-months" : "one-month"; - } - if (tier2Only.includes(requested) && !permissions["stats-timeframe-2"]) { - return "one-month"; - } - return requested; -} - -export default async function TeamStatsPage( +export default async function TeamStatsOverviewPage( props: PagePropsWithLocale<"/stats/team/[teamId]"> & { searchParams: Promise<{ [key: string]: string | string[] | undefined }>; } ) { - const session = await auth(); - const user = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user.email))) - ); - if (!user) notFound(); - const params = await props.params; const searchParams = await props.searchParams; - const teamId = parseInt(params.teamId); - - const team = await prisma.team.findFirst({ - where: { id: teamId }, - include: { users: true }, - }); - if (!team) notFound(); - - const userIsMember = team.users.some((teamUser) => teamUser.id === user.id); - if (!userIsMember && user.role !== $Enums.UserRole.ADMIN) notFound(); - - const [timeframe1, timeframe2, timeframe3] = await Promise.all([ - new Permission("stats-timeframe-1").check(), - new Permission("stats-timeframe-2").check(), - new Permission("stats-timeframe-3").check(), - ]); - - const permissions = { - "stats-timeframe-1": timeframe1, - "stats-timeframe-2": timeframe2, - "stats-timeframe-3": timeframe3, - }; - - const rawTimeframe = - typeof searchParams.timeframe === "string" ? searchParams.timeframe : null; - const requestedTimeframe: Timeframe = isValidTimeframe(rawTimeframe) - ? rawTimeframe - : "one-week"; - const effectiveTimeframe = clampTimeframe(requestedTimeframe, permissions); - - const customFrom = - typeof searchParams.from === "string" ? searchParams.from : undefined; - const customTo = - typeof searchParams.to === "string" ? searchParams.to : undefined; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } - const dateRange = computeDateRange(effectiveTimeframe, customFrom, customTo); + const { teamId, dateRange, isManager, substituteNames } = shell; const [ { - teamRoster, - winrates, - top5Maps, - allMapsPlaytime, - bestMapByWinrate, - blindSpotMap, - fightStats, + quickStats, roleStats, roleBalance, - bestTrios, - weeklyWinrate, - monthlyWinrate, - recentForm, - streakInfo, - mapModePerformance, - quickStats, - ultStats, - heroSwapStats, - banImpactAnalysis, - ultImpactAnalysis, - abilityImpactAnalysis, - matchupWinrateData, + bestMapByWinrate, + blindSpotMap, + top5Maps, + allMapsPlaytime, heroPool, + teamRoster, + winrates, }, - scrims, mapNames, - playerMapPerformance, - simulatorContext, - simulationToolEnabled, - ultimateImpactToolEnabled, - heroPickrateRawData, ] = await Promise.all([ AppRuntime.runPromise( Effect.all( { - teamRoster: TeamSharedDataService.pipe( - Effect.flatMap((svc) => svc.getTeamRoster(teamId)) - ), - winrates: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) - ), - top5Maps: TeamStatsService.pipe( - Effect.flatMap((svc) => - svc.getTop5MapsByPlaytime(teamId, dateRange) - ) - ), - allMapsPlaytime: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getTopMapsByPlaytime(teamId, dateRange)) - ), - bestMapByWinrate: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getBestMapByWinrate(teamId, dateRange)) - ), - blindSpotMap: TeamStatsService.pipe( - Effect.flatMap((svc) => svc.getBlindSpotMap(teamId, dateRange)) - ), - fightStats: TeamFightStatsService.pipe( - Effect.flatMap((svc) => svc.getTeamFightStats(teamId, dateRange)) + quickStats: TeamQuickWinsService.pipe( + Effect.flatMap((svc) => svc.getQuickWinsStats(teamId, dateRange)) ), roleStats: TeamRoleStatsService.pipe( Effect.flatMap((svc) => @@ -236,261 +67,72 @@ export default async function TeamStatsPage( svc.getRoleBalanceAnalysis(teamId, dateRange) ) ), - bestTrios: TeamRoleStatsService.pipe( - Effect.flatMap((svc) => svc.getBestRoleTrios(teamId, dateRange)) - ), - weeklyWinrate: TeamTrendsService.pipe( - Effect.flatMap((svc) => - svc.getWinrateOverTime(teamId, "week", dateRange) - ) - ), - monthlyWinrate: TeamTrendsService.pipe( - Effect.flatMap((svc) => - svc.getWinrateOverTime(teamId, "month", dateRange) - ) - ), - recentForm: TeamTrendsService.pipe( - Effect.flatMap((svc) => svc.getRecentForm(teamId, dateRange)) - ), - streakInfo: TeamTrendsService.pipe( - Effect.flatMap((svc) => svc.getStreakInfo(teamId, dateRange)) - ), - mapModePerformance: TeamMapModeService.pipe( - Effect.flatMap((svc) => - svc.getMapModePerformance(teamId, dateRange) - ) - ), - quickStats: TeamQuickWinsService.pipe( - Effect.flatMap((svc) => svc.getQuickWinsStats(teamId, dateRange)) - ), - ultStats: TeamUltService.pipe( - Effect.flatMap((svc) => svc.getTeamUltStats(teamId, dateRange)) + bestMapByWinrate: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getBestMapByWinrate(teamId, dateRange)) ), - heroSwapStats: TeamHeroSwapService.pipe( - Effect.flatMap((svc) => svc.getTeamHeroSwapStats(teamId, dateRange)) + blindSpotMap: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getBlindSpotMap(teamId, dateRange)) ), - banImpactAnalysis: TeamBanImpactService.pipe( + top5Maps: TeamStatsService.pipe( Effect.flatMap((svc) => - svc.getTeamBanImpactAnalysis(teamId, dateRange) + svc.getTop5MapsByPlaytime(teamId, dateRange) ) ), - ultImpactAnalysis: TeamUltService.pipe( - Effect.flatMap((svc) => svc.getTeamUltImpact(teamId, dateRange)) - ), - abilityImpactAnalysis: TeamAbilityImpactService.pipe( - Effect.flatMap((svc) => svc.getTeamAbilityImpact(teamId, dateRange)) - ), - matchupWinrateData: TeamMatchupService.pipe( - Effect.flatMap((svc) => - svc.getMatchupWinrateData(teamId, dateRange) - ) + allMapsPlaytime: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTopMapsByPlaytime(teamId, dateRange)) ), heroPool: TeamHeroPoolService.pipe( Effect.flatMap((svc) => svc.getHeroPoolAnalysis(teamId, dateRange?.from, dateRange?.to) ) ), + teamRoster: TeamSharedDataService.pipe( + Effect.flatMap((svc) => svc.getTeamRoster(teamId)) + ), + winrates: TeamStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamWinrates(teamId, dateRange)) + ), }, { concurrency: "unbounded" } ) ), - prisma.scrim.findMany({ - where: { - teamId, - ...(dateRange && { date: { gte: dateRange.from, lte: dateRange.to } }), - }, - }), getMapNames(), - AppRuntime.runPromise( - TeamAnalyticsService.pipe( - Effect.flatMap((svc) => - svc.getPlayerMapPerformanceMatrix(teamId, dateRange) - ) - ) - ), - AppRuntime.runPromise( - TeamPredictionService.pipe( - Effect.flatMap((svc) => svc.getSimulatorContext(teamId, dateRange)) - ) - ), - simulationTool(), - ultimateImpactTool(), - AppRuntime.runPromise( - TeamAnalyticsService.pipe( - Effect.flatMap((svc) => svc.getHeroPickrateRawData(teamId, dateRange)) - ) - ), ]); - const heroPickrateMatrix = calculateHeroPickrateMatrix(heroPickrateRawData); - - const mapPlaytimes: Record = {}; - allMapsPlaytime.forEach((map) => { - mapPlaytimes[map.name] = map.playtime; - }); - const totalGames = winrates.overallWins + winrates.overallLosses; + const baselines = await getTempoBaselines(); return ( -
-
-
- {team.name} -
-

{team.name}

- {totalGames > 0 && ( -
- - Overall Record: {winrates.overallWins}W -{" "} - {winrates.overallLosses}L - - - {winrates.overallWinrate.toFixed(1)}% Win Rate - -
- )} -
-
- -
- - {/* Tabbed Content */} - - - Overview - Performance - Heroes - Trends - Maps - Swaps - Teamfights - Ultimates - Winrates - {simulationToolEnabled && ( - Simulator - )} - - - {/* Overview Tab */} - - {/* Quick Stats */} - - -
- {/* Team Roster */} - - - {/* Recent Activity Calendar */} - -
- - {/* Two Column Layout: Top Maps + Strengths/Weaknesses */} -
- - -
- - {/* Role Balance Overview */} -
- -
-
- - {/* Performance Tab */} - - - - - - {/* Heroes Tab */} - - - - - - - {/* Trends Tab */} - - -
- - -
-
- - {/* Maps Tab */} - - - - - - - {/* Swaps Tab */} - - - - - - - - - {/* Teamfights Tab */} - - - - - - - {/* Ultimates Tab */} - - - {ultimateImpactToolEnabled && ( - - )} - - - - - - {/* Winrates Tab */} - - - - - {/* Simulator Tab */} - - - -
+
+ + + + + + +
); } diff --git a/src/app/stats/team/[teamId]/performance/page.tsx b/src/app/stats/team/[teamId]/performance/page.tsx new file mode 100644 index 000000000..d95e20841 --- /dev/null +++ b/src/app/stats/team/[teamId]/performance/page.tsx @@ -0,0 +1,89 @@ +import { BestRoleTriosCard } from "@/components/stats/team/best-role-trios-card"; +import { RolePerformanceCard } from "@/components/stats/team/role-performance-card"; +import { StatRibbon } from "@/components/stats/team/stat-ribbon"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; +import { TeamRoleStatsService } from "@/data/team"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/performance"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const { roleStats, roleBalance, bestTrios } = await AppRuntime.runPromise( + Effect.all( + { + roleStats: TeamRoleStatsService.pipe( + Effect.flatMap((svc) => + svc.getRolePerformanceStats(teamId, dateRange) + ) + ), + roleBalance: TeamRoleStatsService.pipe( + Effect.flatMap((svc) => svc.getRoleBalanceAnalysis(teamId, dateRange)) + ), + bestTrios: TeamRoleStatsService.pipe( + Effect.flatMap((svc) => svc.getBestRoleTrios(teamId, dateRange)) + ), + }, + { concurrency: "unbounded" } + ) + ); + + return ( +
+ 0 + ? `${(roleStats.Tank.totalPlaytime / 3600).toFixed(1)}h played` + : "no playtime", + }, + { + label: "Damage K/D", + value: roleStats.Damage.kd.toFixed(2), + sub: + roleStats.Damage.totalPlaytime > 0 + ? `${(roleStats.Damage.totalPlaytime / 3600).toFixed(1)}h played` + : "no playtime", + }, + { + label: "Support K/D", + value: roleStats.Support.kd.toFixed(2), + sub: + roleStats.Support.totalPlaytime > 0 + ? `${(roleStats.Support.totalPlaytime / 3600).toFixed(1)}h played` + : "no playtime", + }, + { + label: "Best role", + value: roleBalance.strongestRole ?? "—", + sub: roleBalance.strongestRole + ? "leading K/D" + : "insufficient data", + emphasis: !!roleBalance.strongestRole, + }, + ]} + columns={4} + /> + + +
+ ); +} diff --git a/src/app/stats/team/[teamId]/positional/page.tsx b/src/app/stats/team/[teamId]/positional/page.tsx new file mode 100644 index 000000000..b1fee6b00 --- /dev/null +++ b/src/app/stats/team/[teamId]/positional/page.tsx @@ -0,0 +1,59 @@ +import { + PositionalStatsCards, + PositionalStatsEmpty, +} from "@/components/stats/team/positional-stats-cards"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; +import { + TeamPositionalArtifactsService, + TeamPositionalStatsService, +} from "@/data/team"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { notFound } from "next/navigation"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/positional"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + if (!shell.positionalEnabled) notFound(); + + const { teamId } = shell; + + const { positionalStats, positionalArtifacts } = await AppRuntime.runPromise( + Effect.all( + { + positionalStats: TeamPositionalStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamPositionalStats(teamId)) + ), + positionalArtifacts: TeamPositionalArtifactsService.pipe( + Effect.flatMap((svc) => svc.getTeamPositionalArtifacts(teamId)) + ), + }, + { concurrency: "unbounded" } + ) + ); + + return ( +
+ {positionalStats ? ( + + ) : ( + + )} +
+ ); +} diff --git a/src/app/stats/team/[teamId]/simulator/page.tsx b/src/app/stats/team/[teamId]/simulator/page.tsx new file mode 100644 index 000000000..bef23ac69 --- /dev/null +++ b/src/app/stats/team/[teamId]/simulator/page.tsx @@ -0,0 +1,38 @@ +import { SimulatorTab } from "@/components/stats/team/simulator-tab"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; +import { TeamPredictionService } from "@/data/team"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { notFound } from "next/navigation"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/simulator"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + if (!shell.simulationEnabled) notFound(); + + const { teamId, dateRange } = shell; + + const simulatorContext = await AppRuntime.runPromise( + TeamPredictionService.pipe( + Effect.flatMap((svc) => svc.getSimulatorContext(teamId, dateRange)) + ) + ); + + return ( +
+ +
+ ); +} diff --git a/src/app/stats/team/[teamId]/swaps/page.tsx b/src/app/stats/team/[teamId]/swaps/page.tsx new file mode 100644 index 000000000..d852f8f6a --- /dev/null +++ b/src/app/stats/team/[teamId]/swaps/page.tsx @@ -0,0 +1,88 @@ +import { StatRibbon } from "@/components/stats/team/stat-ribbon"; +import { SwapOverviewCard } from "@/components/stats/team/swap-overview-card"; +import { SwapPairsCard } from "@/components/stats/team/swap-pairs-card"; +import { SwapPlayerBreakdownCard } from "@/components/stats/team/swap-player-breakdown-card"; +import { SwapTimingCard } from "@/components/stats/team/swap-timing-card"; +import { SwapWinrateImpactCard } from "@/components/stats/team/swap-winrate-impact-card"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; +import { TeamHeroSwapService } from "@/data/team"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/swaps"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const { heroSwapStats } = await AppRuntime.runPromise( + Effect.all( + { + heroSwapStats: TeamHeroSwapService.pipe( + Effect.flatMap((svc) => svc.getTeamHeroSwapStats(teamId, dateRange)) + ), + }, + { concurrency: "unbounded" } + ) + ); + + return ( +
+ 0 + ? `${heroSwapStats.swapWinrate.toFixed(0)}%` + : "—", + sub: + heroSwapStats.swapWins + heroSwapStats.swapLosses > 0 + ? `${heroSwapStats.swapWins}–${heroSwapStats.swapLosses}` + : "no swap maps", + }, + { + label: "No-swap WR", + value: + heroSwapStats.noSwapWins + heroSwapStats.noSwapLosses > 0 + ? `${heroSwapStats.noSwapWinrate.toFixed(0)}%` + : "—", + sub: + heroSwapStats.noSwapWins + heroSwapStats.noSwapLosses > 0 + ? `${heroSwapStats.noSwapWins}–${heroSwapStats.noSwapLosses}` + : "no static maps", + }, + ]} + columns={4} + /> + + + + + +
+ ); +} diff --git a/src/app/stats/team/[teamId]/teamfights/page.tsx b/src/app/stats/team/[teamId]/teamfights/page.tsx new file mode 100644 index 000000000..08d1e020b --- /dev/null +++ b/src/app/stats/team/[teamId]/teamfights/page.tsx @@ -0,0 +1,106 @@ +import { AbilityImpactAnalysisCard } from "@/components/stats/team/ability-impact-analysis-card"; +import { StatRibbon } from "@/components/stats/team/stat-ribbon"; +import { TeamFightStatsCard } from "@/components/stats/team/team-fight-stats-card"; +import { TeamInitiationCard } from "@/components/stats/team/team-initiation-card"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { WinProbabilityInsights } from "@/components/stats/team/win-probability-insights"; +import { AppRuntime } from "@/data/runtime"; +import { TeamInitiationService } from "@/data/team/initiation-service"; +import { TeamAbilityImpactService, TeamFightStatsService } from "@/data/team"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/teamfights"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const { fightStats, initiation, abilityImpactAnalysis } = + await AppRuntime.runPromise( + Effect.all( + { + fightStats: TeamFightStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamFightStats(teamId, dateRange)) + ), + initiation: TeamInitiationService.pipe( + Effect.flatMap((svc) => svc.getTeamInitiation(teamId, dateRange)) + ), + abilityImpactAnalysis: TeamAbilityImpactService.pipe( + Effect.flatMap((svc) => svc.getTeamAbilityImpact(teamId, dateRange)) + ), + }, + { concurrency: "unbounded" } + ) + ); + + return ( +
+ 0 + ? `${fightStats.overallWinrate.toFixed(0)}%` + : "—", + sub: + fightStats.totalFights > 0 + ? `${fightStats.fightsWon}–${fightStats.fightsLost} of ${fightStats.totalFights}` + : "no fights", + emphasis: true, + }, + { + label: "First pick", + value: + fightStats.firstPickFights > 0 + ? `${fightStats.firstPickWinrate.toFixed(0)}%` + : "—", + sub: + fightStats.firstPickFights > 0 + ? `${fightStats.firstPickFights} fights` + : "no data", + }, + { + label: "First death", + value: + fightStats.firstDeathFights > 0 + ? `${fightStats.firstDeathWinrate.toFixed(0)}%` + : "—", + sub: + fightStats.firstDeathFights > 0 + ? `${fightStats.firstDeathFights} fights` + : "no data", + }, + { + label: "Dry fight WR", + value: + fightStats.dryFights > 0 + ? `${fightStats.dryFightWinrate.toFixed(0)}%` + : "—", + sub: + fightStats.dryFights > 0 + ? `${fightStats.dryFights} fights` + : "no dry fights", + }, + ]} + columns={4} + /> + + + + +
+ ); +} diff --git a/src/app/stats/team/[teamId]/tendencies/page.tsx b/src/app/stats/team/[teamId]/tendencies/page.tsx new file mode 100644 index 000000000..6d761aef9 --- /dev/null +++ b/src/app/stats/team/[teamId]/tendencies/page.tsx @@ -0,0 +1,51 @@ +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { FightMapContent } from "@/components/team/fight-map-content"; +import { AppRuntime } from "@/data/runtime"; +import { FightFieldService } from "@/data/team/fight-field-service"; +import { loadCalibration } from "@/lib/map-calibration/load-calibration"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { notFound } from "next/navigation"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/tendencies"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + if (!shell.positionalEnabled) notFound(); + + const { teamId } = shell; + + // Fight map (tendencies): engagement-outcome field per map, pooled over a + // fixed recent-scrims window — independent of the timeframe picker. Gated + // by the positional flag, since the field is built from coordinate data. + const fightMapData = await AppRuntime.runPromise( + FightFieldService.pipe(Effect.flatMap((svc) => svc.getFightFields(teamId))) + ).then(async (views) => { + const entries = await Promise.all( + views.map( + async (view) => + [view.mapName, await loadCalibration(view.mapName)] as const + ) + ); + return { views, calibrations: Object.fromEntries(entries) }; + }); + + return ( +
+ +
+ ); +} diff --git a/src/app/stats/team/[teamId]/trends/page.tsx b/src/app/stats/team/[teamId]/trends/page.tsx new file mode 100644 index 000000000..d0c373088 --- /dev/null +++ b/src/app/stats/team/[teamId]/trends/page.tsx @@ -0,0 +1,115 @@ +import { RecentFormCard } from "@/components/stats/team/recent-form-card"; +import { StatRibbon } from "@/components/stats/team/stat-ribbon"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { WinLossStreaksCard } from "@/components/stats/team/win-loss-streaks-card"; +import { WinrateOverTimeChart } from "@/components/stats/team/winrate-over-time-chart"; +import { AppRuntime } from "@/data/runtime"; +import { TeamTrendsService } from "@/data/team"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/trends"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const { weeklyWinrate, monthlyWinrate, recentForm, streakInfo } = + await AppRuntime.runPromise( + Effect.all( + { + weeklyWinrate: TeamTrendsService.pipe( + Effect.flatMap((svc) => + svc.getWinrateOverTime(teamId, "week", dateRange) + ) + ), + monthlyWinrate: TeamTrendsService.pipe( + Effect.flatMap((svc) => + svc.getWinrateOverTime(teamId, "month", dateRange) + ) + ), + recentForm: TeamTrendsService.pipe( + Effect.flatMap((svc) => svc.getRecentForm(teamId, dateRange)) + ), + streakInfo: TeamTrendsService.pipe( + Effect.flatMap((svc) => svc.getStreakInfo(teamId, dateRange)) + ), + }, + { concurrency: "unbounded" } + ) + ); + + return ( +
+ 0 + ? `${recentForm.last5Winrate.toFixed(0)}%` + : "—", + sub: + recentForm.last5.length > 0 + ? `${recentForm.last5.filter((m) => m.result === "win").length}–${recentForm.last5.filter((m) => m.result === "loss").length}` + : "no data", + emphasis: true, + }, + { + label: "Last 10", + value: + recentForm.last10.length > 0 + ? `${recentForm.last10Winrate.toFixed(0)}%` + : "—", + sub: + recentForm.last10.length > 0 + ? `${recentForm.last10.filter((m) => m.result === "win").length}–${recentForm.last10.filter((m) => m.result === "loss").length}` + : "no data", + }, + { + label: "Last 20", + value: + recentForm.last20.length > 0 + ? `${recentForm.last20Winrate.toFixed(0)}%` + : "—", + sub: + recentForm.last20.length > 0 + ? `${recentForm.last20.filter((m) => m.result === "win").length}–${recentForm.last20.filter((m) => m.result === "loss").length}` + : "no data", + }, + { + label: "Current streak", + value: + streakInfo.currentStreak.count > 0 + ? `${streakInfo.currentStreak.count}${streakInfo.currentStreak.type === "win" ? "W" : "L"}` + : "—", + sub: + streakInfo.longestWinStreak.count > 0 + ? `${streakInfo.longestWinStreak.count}W best run` + : "no streaks yet", + }, + ]} + columns={4} + /> + +
+ + +
+
+ ); +} diff --git a/src/app/stats/team/[teamId]/ultimates/page.tsx b/src/app/stats/team/[teamId]/ultimates/page.tsx new file mode 100644 index 000000000..dd38cf825 --- /dev/null +++ b/src/app/stats/team/[teamId]/ultimates/page.tsx @@ -0,0 +1,110 @@ +import { StatRibbon } from "@/components/stats/team/stat-ribbon"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { UltCombosCard } from "@/components/stats/team/ult-combos-card"; +import { UltEconomyCard } from "@/components/stats/team/ult-economy-card"; +import { UltImpactAnalysisCard } from "@/components/stats/team/ult-impact-analysis-card"; +import { UltPlayerRankingsCard } from "@/components/stats/team/ult-player-rankings-card"; +import { UltResponseCard } from "@/components/stats/team/ult-response-card"; +import { UltRoleBreakdownCard } from "@/components/stats/team/ult-role-breakdown-card"; +import { UltUsageOverviewCard } from "@/components/stats/team/ult-usage-overview-card"; +import { UltimateEconomyCard } from "@/components/stats/team/ultimate-economy-card"; +import { AppRuntime } from "@/data/runtime"; +import { TeamFightStatsService, TeamUltService } from "@/data/team"; +import { ultimateImpactTool } from "@/lib/flags"; +import { getTempoBaselines } from "@/lib/tempo/read"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/ultimates"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const [ + { ultStats, fightStats, ultImpactAnalysis, ultCombos, ultEconomy }, + ultimateImpactToolEnabled, + ] = await Promise.all([ + AppRuntime.runPromise( + Effect.all( + { + ultStats: TeamUltService.pipe( + Effect.flatMap((svc) => svc.getTeamUltStats(teamId, dateRange)) + ), + fightStats: TeamFightStatsService.pipe( + Effect.flatMap((svc) => svc.getTeamFightStats(teamId, dateRange)) + ), + ultImpactAnalysis: TeamUltService.pipe( + Effect.flatMap((svc) => svc.getTeamUltImpact(teamId, dateRange)) + ), + ultCombos: TeamUltService.pipe( + Effect.flatMap((svc) => svc.getTeamUltCombos(teamId, dateRange)) + ), + ultEconomy: TeamUltService.pipe( + Effect.flatMap((svc) => svc.getTeamUltEconomy(teamId, dateRange)) + ), + }, + { concurrency: "unbounded" } + ) + ), + ultimateImpactTool(), + ]); + + const baselines = await getTempoBaselines(); + + return ( +
+ + + {ultimateImpactToolEnabled && ( + + )} + + + + + + +
+ ); +} diff --git a/src/app/stats/team/[teamId]/winrates/page.tsx b/src/app/stats/team/[teamId]/winrates/page.tsx new file mode 100644 index 000000000..30a6de7c2 --- /dev/null +++ b/src/app/stats/team/[teamId]/winrates/page.tsx @@ -0,0 +1,41 @@ +import { MatchupWinrateTab } from "@/components/stats/team/matchup-winrate-tab"; +import { TeamStatsGate } from "@/components/stats/team/team-stats-gate"; +import { AppRuntime } from "@/data/runtime"; +import { TeamMatchupService } from "@/data/team"; +import type { PagePropsWithLocale } from "@/types/next"; +import { Effect } from "effect"; +import { loadTeamStatsShell } from "../_lib/context"; + +export const maxDuration = 60; + +export default async function Page( + props: PagePropsWithLocale<"/stats/team/[teamId]/winrates"> & { + searchParams: Promise<{ [key: string]: string | string[] | undefined }>; + } +) { + const params = await props.params; + const searchParams = await props.searchParams; + const shell = await loadTeamStatsShell(params.teamId, searchParams); + if (shell.gated) { + return ; + } + + const { teamId, dateRange } = shell; + + const { matchupWinrateData } = await AppRuntime.runPromise( + Effect.all( + { + matchupWinrateData: TeamMatchupService.pipe( + Effect.flatMap((svc) => svc.getMatchupWinrateData(teamId, dateRange)) + ), + }, + { concurrency: "unbounded" } + ) + ); + + return ( +
+ +
+ ); +} diff --git a/src/app/stats/team/page.tsx b/src/app/stats/team/page.tsx index 231911e42..b71100ec8 100644 --- a/src/app/stats/team/page.tsx +++ b/src/app/stats/team/page.tsx @@ -1,17 +1,44 @@ import { TeamSelector } from "@/components/stats/team/selector"; -import { - Accordion, - AccordionContent, - AccordionItem, - AccordionTrigger, -} from "@/components/ui/accordion"; -import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; +import { Effect } from "effect"; import { redirect } from "next/navigation"; +const SURFACES: { label: string; summary: string }[] = [ + { + label: "Overview", + summary: + "Last-10 record, best day of the week, average fight length, first-pick rate, and a role-balance radar. The single screen for a quick read.", + }, + { + label: "Performance", + summary: + "Per-role splits with K/D, ult efficiency, damage and healing per 10. Surfaces the role trios that actually win games.", + }, + { + label: "Heroes", + summary: + "Hero pool depth, win rate by hero, pickrate heatmap by player and map. Across any timeframe.", + }, + { + label: "Trends", + summary: + "Week-over-week and monthly win rate, recent form, win and loss streaks. Trajectory, not just totals.", + }, + { + label: "Maps", + summary: + "Win rate by mode and by map, with a per-player matrix so map specialists are visible at a glance.", + }, + { + label: "Teamfights", + summary: + "Fight win rate, ult economy, dry-fight frequency, and the conditions that flip a fight outcome.", + }, +]; + export default async function TeamStatsPage() { const session = await auth(); const user = await AppRuntime.runPromise( @@ -29,600 +56,165 @@ export default async function TeamStatsPage() { }); return ( -
-
-

Team Stats

+
+
+

+ Team analytics +

+

+ Team stats +

+

+ Pick a team and read how it's playing. Six surfaces answer the + questions a coach asks between scrim and review. +

+
+ +
+
+
+

+ Selection · Start here +

+

+ Which team to read? +

+

+ Only teams you're a member of. +

+ +
+ +
+
+ +
+
+
+
+ Your teams +
+
+ {teams.length} +
+
+
+
+ Surfaces +
+
6
+
+
+
+ Refresh +
+
As scrims upload
+
+
+ +
+

+ What you're reading +

+

+ Aggregations across every scrim a team has uploaded. Player + performance rolls up into role splits, hero pools, win rate + trends, and a fight-by-fight breakdown of how the team wins or + loses team fights. +

+
+ +
+ + +
+
+
+ +
+
+

+ Surfaces · Six tabs +

+

+ What lives on each tab +

+

+ Same data, six framings. +

+
+ +
+ {SURFACES.map((s) => ( +
+
+ {s.label} +
+
+ {s.summary} +
+
+ ))} +
+
+
+ ); +} -
-
-

Team Statistics

-

- Select a team below to view comprehensive statistics and performance - metrics for your team. Team stats are calculated by aggregating - individual player performances and analyzing team-wide trends across - matches and scrims. Only teams you are a member of will be shown. -

-
- -
- -
- -
- - - - Overview: What's - Your Team's Overall Performance? - - -
-

- The Overview tab provides a high-level snapshot of your - team's performance and composition. -

- -
-

Quick Stats Card

-

- Displays key performance indicators at a glance: -

-
    -
  • - Last 10 Games: Win rate and record for - your most recent matches -
  • -
  • - Best Day of Week: Identifies which day - your team performs best (requires 3+ games per day) -
  • -
  • - Average Fight Duration: Mean length of - team fights in seconds -
  • -
  • - First Pick Success Rate: Win rate when - your team gets the first pick in a fight -
  • -
-
- -
-

Team Roster Grid

-

- Visual overview of all team members, showing their roles - and basic information. -

-
- -
-

Recent Activity Calendar

-

- Calendar heatmap showing when your team has played scrims - and matches, helping identify activity patterns and - consistency. -

-
- -
-

Top Maps Card

-

- Shows your top 5 most played maps with win rates, helping - identify your team's comfort zones and frequently - played maps. -

-
- -
-

Strengths & Weaknesses

-

- Highlights your best map by win rate and your blind spot - map (most played map with lowest win rate), providing - actionable insights for practice priorities. -

-
- -
-

Role Balance Radar

-

- Interactive radar chart comparing Tank, Damage, and - Support roles across four key metrics: -

-
    -
  • - Eliminations: K/D ratio normalized - across roles -
  • -
  • - Survivability: Deaths per minute (lower - is better) -
  • -
  • - Ult Usage: Ultimate efficiency and - timing -
  • -
  • - Activity: Total playtime across roles -
  • -
-

- Includes balance score, strongest/weakest role - identification, and actionable insights for improving role - balance. -

-
-
-
-
- - - - Performance: Which Roles - Are Your Strongest? - - -
-

- The Performance tab dives deep into role-specific metrics - and team composition effectiveness. -

- -
-

Role Performance Card

-

- Detailed statistics for each role (Tank, Damage, Support) - including: -

-
    -
  • Win rates and match records per role
  • -
  • Per-10-minute normalized statistics
  • -
  • K/D ratios and survivability metrics
  • -
  • Ultimate efficiency and usage rates
  • -
  • Damage, healing, and mitigation statistics
  • -
-
- -
-

Best Role Trios Card

-

- Identifies the most successful 3-player role combinations - (e.g., 1 Tank, 2 Damage, 2 Support) with: -

-
    -
  • Win rates for each role trio composition
  • -
  • Number of games played with each composition
  • -
  • Ranking of most effective trios
  • -
-

- Helps identify which role distributions work best for your - team. -

-
-
-
-
- - - - Heroes: What Heroes Does - Your Team Actually Play? - - -
-

- The Heroes tab provides comprehensive analysis of your - team's hero pool and pick patterns. -

- -
-

Timeframe Selection

-

- Filter hero data by multiple timeframes (subject to - permissions): -

-
    -
  • Last Week, 2 Weeks, or Month
  • -
  • Last 3 or 6 Months
  • -
  • Last Year or All Time
  • -
  • Custom date range picker
  • -
-

- Permissions control access to longer timeframes, allowing - teams to unlock extended historical data. -

-
- -
-

Hero Pool Overview Card

-

- Comprehensive analysis of your team's hero usage: -

-
    -
  • - Hero Diversity: Number of unique heroes - played and diversity score -
  • -
  • - Most Played Heroes: Top heroes by - playtime with win rates -
  • -
  • - Hero Win Rates: Success rates for each - hero your team plays -
  • -
  • - Role Distribution: Breakdown of hero - picks by role -
  • -
  • - Pool Depth Analysis: Insights into - whether your hero pool is too narrow or well-balanced -
  • -
-
- -
-

Hero Pickrate Heatmap

-

- Interactive heatmap visualization showing: -

-
    -
  • - Hero pick rates across different maps and game modes -
  • -
  • Color-coded intensity showing frequency of picks
  • -
  • Map-specific hero preferences
  • -
  • Patterns in hero selection strategies
  • -
-

- Helps identify which heroes your team favors on specific - maps and whether you're adapting compositions - effectively. -

-
-
-
-
- - - - Trends: Is Your Team - Getting Better? - - -
-

- The Trends tab tracks your team's performance over time - and identifies patterns. -

- -
-

Winrate Over Time Chart

-

Dual-timeline visualization showing:

-
    -
  • - Weekly Trends: Win rate changes week by - week -
  • -
  • - Monthly Trends: Longer-term performance - patterns -
  • -
  • Trend lines indicating improvement or decline
  • -
  • Game counts per time period
  • -
-

- Helps identify if your team is improving, declining, or - maintaining consistent performance. -

-
- -
-

Recent Form Card

-

- Analysis of your team's most recent performance: -

-
    -
  • Win rate in recent games
  • -
  • - Performance trajectory (improving/declining/stable) -
  • -
  • Comparison to overall win rate
  • -
  • Form rating and insights
  • -
-
- -
-

Win/Loss Streaks Card

-

Tracks consecutive wins and losses:

-
    -
  • Current win or loss streak
  • -
  • Longest win streak achieved
  • -
  • Longest loss streak experienced
  • -
  • Streak patterns and frequency
  • -
-

- Helps identify momentum patterns and consistency in - performance. -

-
-
-
-
- - - - Maps: Which Maps Are Your - Best and Worst? - - -
-

- The Maps tab provides detailed analysis of your team's - performance across different maps and game modes. -

- -
-

Map Mode Performance Card

-

- Breakdown of performance by game mode: -

-
    -
  • - Control: Win rates and performance on - control point maps -
  • -
  • - Escort: Performance on payload escort - maps -
  • -
  • - Hybrid: Combined control and escort - maps -
  • -
  • - Push: Robot push map performance -
  • -
  • - Flashpoint: Control point cluster maps -
  • -
-

- Helps identify which game modes your team excels at or - struggles with. -

-
- -
-

Map Winrate Gallery

-

- Visual gallery showing all maps with: -

-
    -
  • Win rate for each individual map
  • -
  • Total playtime on each map
  • -
  • Color-coded performance indicators
  • -
  • Quick visual comparison across all maps
  • -
-

- Makes it easy to spot your strongest and weakest maps at a - glance. -

-
- -
-

- Player Map Performance Card -

-

- Matrix showing individual player performance across - different maps: -

-
    -
  • Win rates for each player on each map
  • -
  • Performance heatmap by player and map
  • -
  • Identification of map specialists
  • -
  • Team composition planning insights
  • -
-

- Helps optimize lineups by identifying which players - perform best on specific maps. -

-
-
-
-
- - - - Teamfights: How Do You - Win Team Fights? - - -
-

- The Teamfights tab provides deep analysis of team fight - performance, ultimate economy, and fight outcomes. -

- -
-

Team Fight Stats Card

-

Comprehensive team fight metrics:

-
    -
  • - Overall Fight Winrate: Percentage of - team fights won -
  • -
  • - First Pick Winrate: Win rate when your - team gets the first elimination -
  • -
  • - First Death Winrate: Win rate when your - team loses a player first -
  • -
  • - First Ultimate Winrate: Win rate when - your team uses the first ultimate -
  • -
  • - Dry Fights: Percentage of fights with - no ultimates used and win rate in those fights -
  • -
  • - Average Ultimates Per Fight: Mean - number of ultimates used in non-dry fights -
  • -
-
- -
-

Ultimate Economy Card

-

- Analysis of ultimate usage and efficiency: -

-
    -
  • Ultimate usage rates by role
  • -
  • Ultimate economy efficiency
  • -
  • Coordination and timing metrics
  • -
  • Comparison of ultimate advantage scenarios
  • -
-

- Helps identify if your team is maximizing ultimate value - and coordinating ultimates effectively. -

-
- -
-

Win Probability Insights

-

- Statistical analysis of fight outcomes: -

-
    -
  • - Win probability based on fight conditions (first pick, - ultimate advantage, etc.) -
  • -
  • Expected vs. actual win rates
  • -
  • Fight outcome predictions
  • -
  • Key factors influencing fight success
  • -
-

- Provides data-driven insights into what conditions lead to - successful team fights. -

-
-
-
-
- - - - How to Use: How Can I - Turn This Data Into Wins? - - -
-

- Team stats are designed to help you make data-driven - decisions about practice priorities, composition choices, - and strategic improvements. -

- -
-

Getting Started

-
    -
  • - Start with the Overview tab to get a high-level - understanding of your team's performance -
  • -
  • - Review Quick Stats to identify immediate strengths and - weaknesses -
  • -
  • - Check the Role Balance Radar to see if any roles need - more attention -
  • -
-
- -
-

Practice Planning

-
    -
  • - Use the Maps tab to identify maps that need more - practice (low win rates with high playtime) -
  • -
  • - Review Player Map Performance to optimize lineups for - specific maps -
  • -
  • - Check Map Mode Performance to focus practice on weaker - game modes -
  • -
-
- -
-

Composition Strategy

-
    -
  • - Review Best Role Trios to understand which role - distributions work best -
  • -
  • - Use the Heroes tab to identify if your hero pool is too - narrow or needs expansion -
  • -
  • - Check Hero Pickrate Heatmap to see if you're - adapting compositions effectively across maps -
  • -
-
- -
-

Team Fight Improvement

-
    -
  • - Review Team Fight Stats to identify fight win conditions - (first pick, ultimate advantage, etc.) -
  • -
  • - Use Ultimate Economy insights to improve ultimate - coordination -
  • -
  • - Check Win Probability Insights to understand what - factors most influence fight success -
  • -
-
- -
-

Tracking Progress

-
    -
  • - Use the Trends tab to monitor improvement over time -
  • -
  • - Review Recent Form to see if recent changes are having - positive effects -
  • -
  • - Track Win/Loss Streaks to identify consistency patterns -
  • -
-
-
-
-
-
-
-
+function BulletGroup({ + label, + items, + tone, +}: { + label: string; + items: string[]; + tone: "foreground" | "muted"; +}) { + return ( +
+

+ {label} +

+
    + {items.map((item) => ( +
  • + + · + + {item} +
  • + ))} +
); } diff --git a/src/app/team/[teamId]/(app)/availability/page.tsx b/src/app/team/[teamId]/(app)/availability/page.tsx index fd33bb282..0bd7726cc 100644 --- a/src/app/team/[teamId]/(app)/availability/page.tsx +++ b/src/app/team/[teamId]/(app)/availability/page.tsx @@ -6,13 +6,22 @@ import prisma from "@/lib/prisma"; import { revalidatePath } from "next/cache"; import Link from "next/link"; import { notFound } from "next/navigation"; +import { getFormatter, getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; type PageProps = { params: Promise<{ teamId: string }> }; +export async function generateMetadata(): Promise { + const t = await getTranslations("availability.metadata"); + return { title: t("title"), description: t("description") }; +} + export default async function AvailabilityIndexPage({ params }: PageProps) { const { teamId: raw } = await params; const teamId = parseInt(raw); if (!Number.isFinite(teamId)) notFound(); + const t = await getTranslations("availability.indexPage"); + const format = await getFormatter(); const canManage = await isTeamOwnerOrManager(teamId); @@ -36,20 +45,31 @@ export default async function AvailabilityIndexPage({ params }: PageProps) { const tz = settings?.timezone ?? "America/New_York"; const now = new Date(); - const currentWeekStart = weekStartInTz(now, tz); + const currentWeekStart = weekStartInTz( + now, + tz, + settings?.reminderDayOfWeek ?? 0 + ); const currentWeekEnd = weekEndInTz(currentWeekStart, tz); const currentSchedule = schedules.find( (s) => s.weekStart.getTime() === currentWeekStart.getTime() ); + function formatDate(date: Date) { + return format.dateTime(date, { + dateStyle: "medium", + }); + } return (
-

{team.name} — Availability

+

+ {t("title", { teamName: team.name })} +

{canManage && ( - + )}
@@ -58,17 +78,18 @@ export default async function AvailabilityIndexPage({ params }: PageProps) {

- Availability is not configured for this team yet.{" "} - {canManage ? ( - - Set it up - - ) : ( - "Ask an owner or manager to configure it." - )} + {canManage + ? t.rich("notConfiguredManager", { + setup: (chunks) => ( + + {chunks} + + ), + }) + : t("notConfiguredViewer")}

@@ -78,16 +99,19 @@ export default async function AvailabilityIndexPage({ params }: PageProps) { - Current week: {currentWeekStart.toLocaleDateString()} –{" "} - {currentWeekEnd.toLocaleDateString()} + {t("currentWeek", { + start: formatDate(currentWeekStart), + end: formatDate(currentWeekEnd), + })} {currentSchedule ? ( <>

- {currentSchedule._count.responses} response - {currentSchedule._count.responses === 1 ? "" : "s"} so far. + {t("responsesSoFar", { + count: currentSchedule._count.responses, + })}

- +
@@ -109,7 +133,7 @@ export default async function AvailabilityIndexPage({ params }: PageProps) { {schedules.length > 1 && ( - Past weeks + {t("pastWeeks")}
    @@ -121,17 +145,21 @@ export default async function AvailabilityIndexPage({ params }: PageProps) { className="flex items-center justify-between py-2" > - {s.weekStart.toLocaleDateString()} –{" "} - {s.weekEnd.toLocaleDateString()}{" "} + {t("pastWeekRange", { + start: formatDate(s.weekStart), + end: formatDate(s.weekEnd), + })}{" "} - ({s._count.responses} responses) + {t("pastWeekResponses", { + count: s._count.responses, + })} - View + {t("view")} ))} @@ -154,7 +182,11 @@ async function startCurrentWeek(teamId: number) { update: {}, }); const now = new Date(); - const weekStart = weekStartInTz(now, settings.timezone); + const weekStart = weekStartInTz( + now, + settings.timezone, + settings.reminderDayOfWeek + ); const weekEnd = weekEndInTz(weekStart, settings.timezone); await prisma.availabilitySchedule.upsert({ where: { teamId_weekStart: { teamId, weekStart } }, @@ -164,10 +196,12 @@ async function startCurrentWeek(teamId: number) { revalidatePath(`/team/${teamId}/availability`); } -function StartCurrentWeekForm({ teamId }: { teamId: number }) { +async function StartCurrentWeekForm({ teamId }: { teamId: number }) { + const t = await getTranslations("availability.indexPage"); + return (
    - +
    ); } diff --git a/src/app/team/[teamId]/(app)/availability/settings/page.tsx b/src/app/team/[teamId]/(app)/availability/settings/page.tsx index c1314d87c..bde11d063 100644 --- a/src/app/team/[teamId]/(app)/availability/settings/page.tsx +++ b/src/app/team/[teamId]/(app)/availability/settings/page.tsx @@ -2,13 +2,21 @@ import { AvailabilitySettingsForm } from "@/components/availability/availability import { isTeamOwnerOrManager } from "@/lib/auth"; import prisma from "@/lib/prisma"; import { notFound, redirect } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import type { Metadata } from "next"; type PageProps = { params: Promise<{ teamId: string }> }; +export async function generateMetadata(): Promise { + const t = await getTranslations("availability.settingsPage.metadata"); + return { title: t("title"), description: t("description") }; +} + export default async function AvailabilitySettingsPage({ params }: PageProps) { const { teamId: raw } = await params; const teamId = parseInt(raw); if (!Number.isFinite(teamId)) notFound(); + const t = await getTranslations("availability.settingsPage"); if (!(await isTeamOwnerOrManager(teamId))) { redirect(`/team/${teamId}/availability`); @@ -35,10 +43,8 @@ export default async function AvailabilitySettingsPage({ params }: PageProps) { return (
    -

    Availability settings

    -

    - Configure how the shared availability calendar works for your team. -

    +

    {t("title")}

    +

    {t("description")}

    diff --git a/src/app/team/[teamId]/(app)/layout.tsx b/src/app/team/[teamId]/(app)/layout.tsx index fc44cfea5..d5414604d 100644 --- a/src/app/team/[teamId]/(app)/layout.tsx +++ b/src/app/team/[teamId]/(app)/layout.tsx @@ -1,4 +1,5 @@ import { NoAuthCard } from "@/components/auth/no-auth"; +import { DashboardLayout } from "@/components/dashboard-layout"; import { isAuthedToViewTeam } from "@/lib/auth"; export default async function TeamLayout(props: LayoutProps<"/team/[teamId]">) { @@ -13,7 +14,5 @@ export default async function TeamLayout(props: LayoutProps<"/team/[teamId]">) { return NoAuthCard(); } - // Must be wrapped in an element due to Next.js Server Component typing - // oxlint-disable-next-line react/jsx-no-useless-fragment - return <>{children}; + return {children}; } diff --git a/src/app/team/[teamId]/(app)/page.tsx b/src/app/team/[teamId]/(app)/page.tsx index 3a0a552bf..871d61dce 100644 --- a/src/app/team/[teamId]/(app)/page.tsx +++ b/src/app/team/[teamId]/(app)/page.tsx @@ -3,6 +3,7 @@ import { DangerZone } from "@/components/team/danger-zone"; import { TeamMemberCard } from "@/components/team/team-member-card"; import { TeamMemberUsage } from "@/components/team/team-member-usage"; import { TeamSettingsForm } from "@/components/team/team-settings-form"; +import { TeamTsrCard } from "@/components/team/team-tsr-card"; import { UserCardButtons } from "@/components/team/user-card-buttons"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { @@ -14,11 +15,12 @@ import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { ScoutingService } from "@/data/scouting"; import { UserService } from "@/data/user"; -import { auth } from "@/lib/auth"; +import { auth, isAuthedToViewTeam } from "@/lib/auth"; import { scoutingTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; +import { computeTeamTsr } from "@/lib/tsr/team"; import type { PagePropsWithLocale } from "@/types/next"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import { Lock } from "lucide-react"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; @@ -28,12 +30,18 @@ export async function generateMetadata( ): Promise { const params = await props.params; const t = await getTranslations("teamPage.teamMetadata"); - const teamId = decodeURIComponent(params.teamId); - - const team = await prisma.team.findFirst({ - where: { id: parseInt(teamId) }, - select: { name: true }, - }); + const teamId = Number(params.teamId); + const canViewTeam = + Number.isSafeInteger(teamId) && + teamId > 0 && + (await isAuthedToViewTeam(teamId)); + + const team = canViewTeam + ? await prisma.team.findFirst({ + where: { id: teamId }, + select: { name: true }, + }) + : null; const teamName = team?.name ?? t("defaultTeam"); @@ -48,7 +56,7 @@ export async function generateMetadata( siteName: "Parsertime", images: [ { - url: `https://parsertime.app/api/og?title=${t("ogImage", { teamName })}`, + url: `https://parsertime.app/api/og?title=${encodeURIComponent(t("ogImage", { teamName }))}`, width: 1200, height: 630, }, @@ -73,6 +81,7 @@ export default async function Team( teamManagers, scoutingEnabled, scoutingTeams, + teamScrims, ] = await Promise.all([ prisma.team.findFirst({ where: { id: teamId } }), prisma.team.findFirst({ @@ -101,9 +110,20 @@ export default async function Team( AppRuntime.runPromise( ScoutingService.pipe(Effect.flatMap((svc) => svc.getScoutingTeams())) ), + prisma.scrim.findMany({ where: { teamId }, select: { id: true } }), ]); const teamMembers = teamMembersData ?? { users: [] }; + const teamScrimIds = teamScrims.map((s) => s.id); + + const teamTsr = await computeTeamTsr( + teamMembers.users.map((u) => ({ + id: u.id, + name: u.name, + battletag: u.battletag, + })), + teamScrimIds + ); const user = await AppRuntime.runPromise( UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) @@ -114,10 +134,11 @@ export default async function Team( } const hasPerms = - userIsManager(user!) || - user?.id === teamData?.ownerId || - user?.role === $Enums.UserRole.MANAGER || - user?.role === $Enums.UserRole.ADMIN; + !!user && + (userIsManager(user) || + user.id === teamData?.ownerId || + user.role === $Enums.UserRole.MANAGER || + user.role === $Enums.UserRole.ADMIN); const teamOwner = await prisma.user.findFirst({ where: { id: teamData?.ownerId }, @@ -147,6 +168,8 @@ export default async function Team( )} + +

    {t("members")}

    @@ -183,15 +206,17 @@ export default async function Team( />
- - -
- - + {hasPerms && ( + + +
+ + + )}
); diff --git a/src/app/team/[teamId]/(app)/targets/page.tsx b/src/app/team/[teamId]/(app)/targets/page.tsx index 47392450b..741682a45 100644 --- a/src/app/team/[teamId]/(app)/targets/page.tsx +++ b/src/app/team/[teamId]/(app)/targets/page.tsx @@ -13,15 +13,23 @@ import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { RoleName } from "@/lib/target-stats"; import { type HeroName, heroRoleMapping } from "@/types/heroes"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; type Props = { params: Promise<{ teamId: string }>; }; +export async function generateMetadata(): Promise { + const t = await getTranslations("targets.metadata"); + return { title: t("title"), description: t("description") }; +} + export default async function TeamTargetsPage(props: Props) { const params = await props.params; const teamId = parseInt(params.teamId); + const t = await getTranslations("targets"); const session = await auth(); const user = await AppRuntime.runPromise( @@ -31,7 +39,7 @@ export default async function TeamTargetsPage(props: Props) { if (!user) { return (
-

Please sign in to view targets.

+

{t("signInRequired")}

); } @@ -41,7 +49,7 @@ export default async function TeamTargetsPage(props: Props) { if (!team) { return (
-

Team not found.

+

{t("teamNotFound")}

); } @@ -57,18 +65,17 @@ export default async function TeamTargetsPage(props: Props) { if (!isPremium) { return (
-

Player Targets

+

{t("title")}

-

Premium Feature

+

{t("premiumFeature")}

- Player targets require a Premium plan. Upgrade to set measurable - improvement goals for your players. + {t("premiumRequired")} {t("upgradePrompt")}

- View Pricing + {t("viewPricing")}
@@ -132,11 +139,14 @@ export default async function TeamTargetsPage(props: Props) { const topHero = await prisma.$queryRaw< { player_hero: string; total_time: number }[] >` - SELECT player_hero, SUM(hero_time_played) AS total_time - FROM "PlayerStat" - WHERE player_name ILIKE ${playerName} - AND hero_time_played > 0 - GROUP BY player_hero + SELECT ps.player_hero, SUM(ps.hero_time_played) AS total_time + FROM "PlayerStat" ps + INNER JOIN "MapData" md ON md.id = ps."MapDataId" + INNER JOIN "Scrim" s ON s.id = md."scrimId" + WHERE ps.player_name ILIKE ${playerName} + AND ps.hero_time_played > 0 + AND s."teamId" = ${teamId} + GROUP BY ps.player_hero ORDER BY total_time DESC LIMIT 1 `; @@ -184,7 +194,7 @@ export default async function TeamTargetsPage(props: Props) { return (
-

Player Targets

+

{t("title")}

; }; +export async function generateMetadata(): Promise { + const t = await getTranslations("availability.fillView.metadata"); + return { title: t("title"), description: t("description") }; +} + export default async function PublicFillPage({ params }: PageProps) { const { teamId: raw, scheduleId } = await params; const teamId = parseInt(raw); diff --git a/src/app/team/[teamId]/availability/layout.tsx b/src/app/team/[teamId]/availability/layout.tsx new file mode 100644 index 000000000..1c93d872f --- /dev/null +++ b/src/app/team/[teamId]/availability/layout.tsx @@ -0,0 +1,9 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; + +export default function PublicAvailabilityLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/team/join/[token]/page.tsx b/src/app/team/join/[token]/page.tsx index 7b60512d2..fcb90d17f 100644 --- a/src/app/team/join/[token]/page.tsx +++ b/src/app/team/join/[token]/page.tsx @@ -11,54 +11,36 @@ export default async function TokenPage( const session = await auth(); const token = params.token; - if (!session) redirect(`/sign-in?callbackUrl=/team/join/${token}`); + if (!session?.user?.email) + redirect(`/sign-in?callbackUrl=/team/join/${token}`); - try { - const teamCreatedAt = new Date(atob(token)); + const userEmail = session.user.email.toLowerCase(); - const team = await prisma.team.findFirst({ - where: { createdAt: teamCreatedAt }, - }); - - if (!team) { - Logger.error("Team not found for date token"); - redirect("/team/join?error=invalid-token"); - } - - await prisma.team.update({ - where: { id: team.id }, - data: { users: { connect: { email: session?.user?.email ?? "" } } }, - }); - - Logger.info(`User now belongs to team: ${JSON.stringify(team)}`); - } catch { - const teamInviteToken = await prisma.teamInviteToken.findUnique({ + const joinedTeam = await prisma.$transaction(async (tx) => { + const teamInviteToken = await tx.teamInviteToken.findUnique({ where: { token }, }); - if (!teamInviteToken) { - Logger.error("Invalid or expired token provided to join team"); - redirect("/team/join?error=invalid-token"); - } + if (!teamInviteToken || teamInviteToken.expires <= new Date()) return null; + if (teamInviteToken.email.toLowerCase() !== userEmail) return null; - await prisma.team.update({ - where: { id: teamInviteToken.teamId }, - data: { users: { connect: { email: session?.user?.email ?? "" } } }, + const deleted = await tx.teamInviteToken.deleteMany({ + where: { token, expires: { gt: new Date() } }, }); + if (deleted.count !== 1) return null; - await prisma.teamInviteToken.delete({ where: { token } }); - - Logger.info( - `User ${session?.user?.email} joined team ${teamInviteToken.teamId}` - ); - - const teams = await prisma.team.findMany({ - where: { users: { some: { email: session?.user?.email } } }, + return await tx.team.update({ + where: { id: teamInviteToken.teamId }, + data: { users: { connect: { email: userEmail } } }, + select: { id: true }, }); + }); - Logger.info(`User now belongs to team: ${JSON.stringify(teams)}`); - redirect("/team/join/success"); + if (!joinedTeam) { + Logger.error("Invalid or expired token provided to join team"); + redirect("/team/join?error=invalid-token"); } + Logger.info(`User ${session.user.email} joined team ${joinedTeam.id}`); redirect("/team/join/success"); } diff --git a/src/app/team/join/layout.tsx b/src/app/team/join/layout.tsx new file mode 100644 index 000000000..a025b8e3c --- /dev/null +++ b/src/app/team/join/layout.tsx @@ -0,0 +1,16 @@ +import { DashboardLayout } from "@/components/dashboard-layout"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; + +export async function generateMetadata(): Promise { + const t = await getTranslations("teamPage.joinMetadata"); + return { title: t("title"), description: t("description") }; +} + +export default function PublicTeamJoinLayout({ + children, +}: { + children: React.ReactNode; +}) { + return {children}; +} diff --git a/src/app/team/layout.tsx b/src/app/team/layout.tsx index 00f31158f..c238366d2 100644 --- a/src/app/team/layout.tsx +++ b/src/app/team/layout.tsx @@ -1,8 +1,3 @@ -import { DashboardLayout } from "@/components/dashboard-layout"; - export default function TeamLayout({ children }: LayoutProps<"/team">) { - // guestMode lets public routes (e.g. availability fill link) render for - // unauthenticated visitors without triggering GuestNav's redirect-to-sign-in. - // Authed pages under /team still gate themselves (isAuthedToViewTeam, etc.). - return {children}; + return children; } diff --git a/src/app/team/page.tsx b/src/app/team/page.tsx index 055049c90..0d2da155e 100644 --- a/src/app/team/page.tsx +++ b/src/app/team/page.tsx @@ -1,4 +1,5 @@ import { TeamSearch } from "@/components/admin/team-search"; +import { DashboardLayout } from "@/components/dashboard-layout"; import { EmptyTeamView } from "@/components/team/empty-team-view"; import { UserTeamsList } from "@/components/team/user-teams-list"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; @@ -8,9 +9,10 @@ import { UserService } from "@/data/user"; import { auth } from "@/lib/auth"; import prisma from "@/lib/prisma"; import type { PagePropsWithLocale } from "@/types/next"; -import { $Enums } from "@prisma/client"; +import { $Enums } from "@/generated/prisma/browser"; import type { Metadata } from "next"; import { getTranslations } from "next-intl/server"; +import { redirect } from "next/navigation"; export async function generateMetadata( props: PagePropsWithLocale<"/team"> @@ -42,9 +44,12 @@ export default async function TeamPage() { const t = await getTranslations("teamPage"); const session = await auth(); + if (!session?.user?.email) { + redirect("/sign-in"); + } const userData = await AppRuntime.runPromise( - UserService.pipe(Effect.flatMap((svc) => svc.getUser(session?.user?.email))) + UserService.pipe(Effect.flatMap((svc) => svc.getUser(session.user.email))) ); const userTeams = await prisma.team.findMany({ @@ -55,7 +60,7 @@ export default async function TeamPage() { userData?.role === $Enums.UserRole.ADMIN || userData?.role === $Enums.UserRole.MANAGER; - return ( + const content = (
@@ -85,4 +90,6 @@ export default async function TeamPage() {
); + + return {content}; } diff --git a/src/app/tournaments/[id]/match/[matchId]/page.tsx b/src/app/tournaments/[id]/match/[matchId]/page.tsx index b0c9d166c..351084fd8 100644 --- a/src/app/tournaments/[id]/match/[matchId]/page.tsx +++ b/src/app/tournaments/[id]/match/[matchId]/page.tsx @@ -6,14 +6,40 @@ import { Badge } from "@/components/ui/badge"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; import { TournamentService } from "@/data/tournament"; -import { auth } from "@/lib/auth"; +import { auth, canViewTournament, getCurrentUser } from "@/lib/auth"; import { tournament } from "@/lib/flags"; -import prisma from "@/lib/prisma"; import { ArrowLeft } from "lucide-react"; -import type { Route } from "next"; +import type { Metadata, Route } from "next"; +import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { notFound } from "next/navigation"; +export async function generateMetadata(props: { + params: Promise<{ id: string; matchId: string }>; +}): Promise { + const { matchId } = await props.params; + const t = await getTranslations("tournamentsPage.match.metadata"); + const id = Number(matchId); + + const match = Number.isNaN(id) + ? null + : await AppRuntime.runPromise( + TournamentService.pipe( + Effect.flatMap((svc) => svc.getTournamentMatch(id)) + ) + ); + + if (!match) return { title: "Match | Parsertime" }; + + const team1 = match.team1?.name ?? "TBD"; + const team2 = match.team2?.name ?? "TBD"; + + return { + title: t("title", { team1, team2 }), + description: t("description", { team1, team2 }), + }; +} + export default async function TournamentMatchPage(props: { params: Promise<{ id: string; matchId: string }>; }) { @@ -32,6 +58,9 @@ export default async function TournamentMatchPage(props: { ); if (!match || match.tournamentId !== tournamentId) notFound(); + const user = await getCurrentUser(); + if (!(await canViewTournament(tournamentId, user))) notFound(); + const isCompleted = match.status === "COMPLETED"; const team1IsWinner = isCompleted && match.winnerId === match.team1Id; const team2IsWinner = isCompleted && match.winnerId === match.team2Id; @@ -44,10 +73,6 @@ export default async function TournamentMatchPage(props: { const session = await auth(); let canUpload = false; if (session?.user?.email) { - const user = await prisma.user.findUnique({ - where: { email: session.user.email }, - select: { id: true, role: true }, - }); canUpload = user?.id === match.tournament.creatorId || user?.role === "ADMIN" || diff --git a/src/app/tournaments/[id]/page.tsx b/src/app/tournaments/[id]/page.tsx index a37af6bd6..b1e8211d3 100644 --- a/src/app/tournaments/[id]/page.tsx +++ b/src/app/tournaments/[id]/page.tsx @@ -7,15 +7,38 @@ import { TournamentActions } from "@/components/tournament/tournament-actions"; import { Badge } from "@/components/ui/badge"; import { AppRuntime } from "@/data/runtime"; import { TournamentService } from "@/data/tournament"; -import { auth } from "@/lib/auth"; +import { auth, canViewTournament, getCurrentUser } from "@/lib/auth"; import { tournament } from "@/lib/flags"; import prisma from "@/lib/prisma"; import { Effect } from "effect"; import { ArrowLeft } from "lucide-react"; -import type { Route } from "next"; +import type { Metadata, Route } from "next"; +import { getTranslations } from "next-intl/server"; import Link from "next/link"; import { notFound } from "next/navigation"; +export async function generateMetadata(props: { + params: Promise<{ id: string }>; +}): Promise { + const { id } = await props.params; + const t = await getTranslations("tournamentsPage.detail.metadata"); + const tournamentId = Number(id); + + const record = Number.isNaN(tournamentId) + ? null + : await prisma.tournament.findUnique({ + where: { id: tournamentId }, + select: { name: true }, + }); + + if (!record) return { title: "Tournament | Parsertime" }; + + return { + title: t("title", { name: record.name }), + description: t("description", { name: record.name }), + }; +} + export default async function TournamentDetailPage(props: { params: Promise<{ id: string }>; }) { @@ -26,6 +49,9 @@ export default async function TournamentDetailPage(props: { const id = Number(params.id); if (Number.isNaN(id)) notFound(); + const user = await getCurrentUser(); + if (!(await canViewTournament(id, user))) notFound(); + const data = await AppRuntime.runPromise( TournamentService.pipe( Effect.flatMap((svc) => svc.getTournamentBracket(id)) diff --git a/src/app/tournaments/[id]/stats/[teamId]/page.tsx b/src/app/tournaments/[id]/stats/[teamId]/page.tsx index d267a4449..c83662a90 100644 --- a/src/app/tournaments/[id]/stats/[teamId]/page.tsx +++ b/src/app/tournaments/[id]/stats/[teamId]/page.tsx @@ -37,16 +37,40 @@ import { } from "@/data/tournament-team"; import { Effect } from "effect"; import { AppRuntime } from "@/data/runtime"; -import { auth } from "@/lib/auth"; +import { auth, canViewTournament, getCurrentUser } from "@/lib/auth"; import { tournament, simulationTool, ultimateImpactTool } from "@/lib/flags"; import prisma from "@/lib/prisma"; +import { getTempoBaselines } from "@/lib/tempo/read"; import { getMapNames } from "@/lib/utils"; import { ArrowLeft } from "lucide-react"; -import type { Route } from "next"; +import type { Metadata, Route } from "next"; +import { getTranslations } from "next-intl/server"; import Image from "next/image"; import Link from "next/link"; import { notFound } from "next/navigation"; +export async function generateMetadata(props: { + params: Promise<{ id: string; teamId: string }>; +}): Promise { + const { teamId } = await props.params; + const t = await getTranslations("tournamentsPage.teamStats.metadata"); + const id = Number(teamId); + + const team = Number.isNaN(id) + ? null + : await prisma.tournamentTeam.findUnique({ + where: { id }, + select: { name: true }, + }); + + if (!team) return { title: "Tournament Stats | Parsertime" }; + + return { + title: t("title", { team: team.name }), + description: t("description", { team: team.name }), + }; +} + export default async function TournamentTeamStatsPage(props: { params: Promise<{ id: string; teamId: string }>; }) { @@ -61,6 +85,9 @@ export default async function TournamentTeamStatsPage(props: { const tournamentTeamId = Number(params.teamId); if (Number.isNaN(tournamentId) || Number.isNaN(tournamentTeamId)) notFound(); + const user = await getCurrentUser(); + if (!(await canViewTournament(tournamentId, user))) notFound(); + const tournamentData = await prisma.tournament.findFirst({ where: { id: tournamentId }, select: { id: true, name: true }, @@ -257,6 +284,8 @@ export default async function TournamentTeamStatsPage(props: { ultimateImpactTool(), ]); + const baselines = await getTempoBaselines(); + const mapPlaytimes: Record = {}; allMapsPlaytime.forEach((map) => { mapPlaytimes[map.name] = map.playtime; @@ -323,12 +352,17 @@ export default async function TournamentTeamStatsPage(props: { - +
@@ -404,7 +438,11 @@ export default async function TournamentTeamStatsPage(props: {
- + {ultimateImpactToolEnabled && ( )} diff --git a/src/app/tournaments/create/page.tsx b/src/app/tournaments/create/page.tsx index 9ee5ad769..3c364d986 100644 --- a/src/app/tournaments/create/page.tsx +++ b/src/app/tournaments/create/page.tsx @@ -1,8 +1,15 @@ import { DashboardLayout } from "@/components/dashboard-layout"; import { CreateTournamentButton } from "@/components/tournament/create-tournament-button"; import { tournament } from "@/lib/flags"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { notFound } from "next/navigation"; +export async function generateMetadata(): Promise { + const t = await getTranslations("tournamentsPage.create.metadata"); + return { title: t("title"), description: t("description") }; +} + export default async function CreateTournamentPage() { const tournamentEnabled = await tournament(); if (!tournamentEnabled) notFound(); diff --git a/src/app/tournaments/page.tsx b/src/app/tournaments/page.tsx index 568ad2654..b52188d8c 100644 --- a/src/app/tournaments/page.tsx +++ b/src/app/tournaments/page.tsx @@ -6,8 +6,15 @@ import { AppRuntime } from "@/data/runtime"; import { TournamentService } from "@/data/tournament"; import { auth } from "@/lib/auth"; import { tournament } from "@/lib/flags"; +import type { Metadata } from "next"; +import { getTranslations } from "next-intl/server"; import { notFound, redirect } from "next/navigation"; +export async function generateMetadata(): Promise { + const t = await getTranslations("tournamentsPage.metadata"); + return { title: t("title"), description: t("description") }; +} + export default async function TournamentsPage() { const tournamentEnabled = await tournament(); if (!tournamentEnabled) notFound(); diff --git a/src/components/admin/active-teams-pie-chart.tsx b/src/components/admin/active-teams-pie-chart.tsx new file mode 100644 index 000000000..65be5f9ba --- /dev/null +++ b/src/components/admin/active-teams-pie-chart.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Cell, Pie, PieChart } from "recharts"; + +type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<"light" | "dark", string> } + ); +}; + +export type ActiveTeamsPieData = { + status: "Active" | "Inactive"; + count: number; + percentage: number; +}; + +type ActiveTeamsPieChartProps = { + data: ActiveTeamsPieData[]; +}; + +export function ActiveTeamsPieChart({ data }: ActiveTeamsPieChartProps) { + const chartConfig: ChartConfig = { + Active: { + label: "Active", + color: "var(--chart-4)", + }, + Inactive: { + label: "Inactive", + color: "var(--chart-1)", + }, + }; + + const COLORS: Record = { + Active: "var(--chart-4)", + Inactive: "var(--chart-1)", + }; + + return ( + + + + {data.map((entry) => ( + + ))} + + } + formatter={(value, name) => { + const entry = data.find((d) => d.status === name); + return [ + `${String(value)} (${entry?.percentage ?? 0}%)`, + String(name), + ]; + }} + /> + } /> + + + ); +} diff --git a/src/components/admin/active-users-pie-chart.tsx b/src/components/admin/active-users-pie-chart.tsx new file mode 100644 index 000000000..7f4040f30 --- /dev/null +++ b/src/components/admin/active-users-pie-chart.tsx @@ -0,0 +1,78 @@ +"use client"; + +import { + ChartContainer, + ChartLegend, + ChartLegendContent, + ChartTooltip, + ChartTooltipContent, +} from "@/components/ui/chart"; +import { Cell, Pie, PieChart } from "recharts"; + +type ChartConfig = { + [k in string]: { + label?: React.ReactNode; + icon?: React.ComponentType; + } & ( + | { color?: string; theme?: never } + | { color?: never; theme: Record<"light" | "dark", string> } + ); +}; + +export type ActiveUsersPieData = { + status: "Active" | "Inactive"; + count: number; + percentage: number; +}; + +type ActiveUsersPieChartProps = { + data: ActiveUsersPieData[]; +}; + +export function ActiveUsersPieChart({ data }: ActiveUsersPieChartProps) { + const chartConfig: ChartConfig = { + Active: { + label: "Active", + color: "var(--chart-2)", + }, + Inactive: { + label: "Inactive", + color: "var(--chart-1)", + }, + }; + + const COLORS: Record = { + Active: "var(--chart-2)", + Inactive: "var(--chart-1)", + }; + + return ( + + + + {data.map((entry) => ( + + ))} + + } + formatter={(value, name) => { + const entry = data.find((d) => d.status === name); + return [ + `${String(value)} (${entry?.percentage ?? 0}%)`, + String(name), + ]; + }} + /> + } /> + + + ); +} diff --git a/src/components/admin/audit-log.tsx b/src/components/admin/audit-log.tsx index 91f082cf7..30191e603 100644 --- a/src/components/admin/audit-log.tsx +++ b/src/components/admin/audit-log.tsx @@ -34,10 +34,10 @@ import { TableRow, } from "@/components/ui/table"; import { cn } from "@/lib/utils"; -import type { AuditLog } from "@prisma/client"; -import { $Enums } from "@prisma/client"; +import type { AuditLog } from "@/generated/prisma/browser"; +import { $Enums } from "@/generated/prisma/browser"; import { useInfiniteQuery } from "@tanstack/react-query"; -import { addWeeks, format } from "date-fns"; +import { addWeeks } from "date-fns"; import { Bot, Bug, @@ -65,7 +65,7 @@ import { VenetianMask, X, } from "lucide-react"; -import { useTranslations } from "next-intl"; +import { useFormatter, useTranslations } from "next-intl"; import { useEffect, useRef, useState } from "react"; import type { DateRange } from "react-day-picker"; import { useDebounce } from "use-debounce"; @@ -73,31 +73,26 @@ import { useDebounce } from "use-debounce"; const actionTypes = { // User Management Actions [$Enums.AuditLogAction.USER_BAN]: { - label: "User Ban", className: "border-red-500 text-red-500", iconClassName: "text-red-500", icon: UserX, }, [$Enums.AuditLogAction.USER_UNBAN]: { - label: "User Unban", className: "border-emerald-500 text-emerald-500", iconClassName: "text-emerald-500", icon: UserCheck, }, [$Enums.AuditLogAction.USER_AVATAR_UPDATED]: { - label: "User Avatar Updated", className: "border-blue-500 text-blue-500", iconClassName: "text-blue-500", icon: ImageIcon, }, [$Enums.AuditLogAction.USER_ACCOUNT_DELETED]: { - label: "User Account Deleted", className: "border-red-600 text-red-600", iconClassName: "text-red-600", icon: UserMinus, }, [$Enums.AuditLogAction.USER_NAME_UPDATED]: { - label: "User Name Updated", className: "border-blue-400 text-blue-400", iconClassName: "text-blue-400", icon: Edit, @@ -105,19 +100,16 @@ const actionTypes = { // Security & Admin Actions [$Enums.AuditLogAction.TRUST_SCORE_ADJUST]: { - label: "Trust Score Adjust", className: "border-indigo-500 text-indigo-500", iconClassName: "text-indigo-500", icon: Shield, }, [$Enums.AuditLogAction.IMPERSONATE_USER]: { - label: "Impersonate User", className: "border-purple-500 text-purple-500", iconClassName: "text-purple-500", icon: VenetianMask, }, [$Enums.AuditLogAction.SUSPICIOUS_ACTIVITY_DETECTED]: { - label: "Suspicious Activity Detected", className: "border-orange-500 text-orange-500", iconClassName: "text-orange-500", icon: ShieldAlert, @@ -125,67 +117,56 @@ const actionTypes = { // Team Management Actions [$Enums.AuditLogAction.TEAM_CREATED]: { - label: "Team Created", className: "border-green-500 text-green-500", iconClassName: "text-green-500", icon: Plus, }, [$Enums.AuditLogAction.TEAM_UPDATED]: { - label: "Team Updated", className: "border-blue-500 text-blue-500", iconClassName: "text-blue-500", icon: Edit, }, [$Enums.AuditLogAction.TEAM_DELETED]: { - label: "Team Deleted", className: "border-red-500 text-red-500", iconClassName: "text-red-500", icon: Trash2, }, [$Enums.AuditLogAction.TEAM_AVATAR_UPDATED]: { - label: "Team Avatar Updated", className: "border-cyan-500 text-cyan-500", iconClassName: "text-cyan-500", icon: ImageIcon, }, [$Enums.AuditLogAction.TEAM_INVITE_SENT]: { - label: "Team Invite Sent", className: "border-violet-500 text-violet-500", iconClassName: "text-violet-500", icon: Mail, }, [$Enums.AuditLogAction.TEAM_JOINED]: { - label: "Team Joined", className: "border-green-400 text-green-400", iconClassName: "text-green-400", icon: UserPlus, }, [$Enums.AuditLogAction.TEAM_LEFT]: { - label: "Team Left", className: "border-yellow-500 text-yellow-500", iconClassName: "text-yellow-500", icon: UserMinus, }, [$Enums.AuditLogAction.TEAM_MEMBER_PROMOTED]: { - label: "Team Member Promoted", className: "border-emerald-600 text-emerald-600", iconClassName: "text-emerald-600", icon: TrendingUp, }, [$Enums.AuditLogAction.TEAM_MEMBER_DEMOTED]: { - label: "Team Member Demoted", className: "border-orange-400 text-orange-400", iconClassName: "text-orange-400", icon: TrendingDown, }, [$Enums.AuditLogAction.TEAM_MEMBER_REMOVED]: { - label: "Team Member Removed", className: "border-red-400 text-red-400", iconClassName: "text-red-400", icon: Minus, }, [$Enums.AuditLogAction.TEAM_OWNERSHIP_TRANSFERRED]: { - label: "Team Ownership Transferred", className: "border-amber-500 text-amber-500", iconClassName: "text-amber-500", icon: Crown, @@ -193,19 +174,16 @@ const actionTypes = { // Scrim Management Actions [$Enums.AuditLogAction.SCRIM_CREATED]: { - label: "Scrim Created", className: "border-teal-500 text-teal-500", iconClassName: "text-teal-500", icon: Target, }, [$Enums.AuditLogAction.SCRIM_UPDATED]: { - label: "Scrim Updated", className: "border-teal-400 text-teal-400", iconClassName: "text-teal-400", icon: Edit, }, [$Enums.AuditLogAction.SCRIM_DELETED]: { - label: "Scrim Deleted", className: "border-red-300 text-red-300", iconClassName: "text-red-300", icon: Trash2, @@ -213,19 +191,16 @@ const actionTypes = { // Map Management Actions [$Enums.AuditLogAction.MAP_CREATED]: { - label: "Map Created", className: "border-slate-500 text-slate-500", iconClassName: "text-slate-500", icon: Map, }, [$Enums.AuditLogAction.MAP_UPDATED]: { - label: "Map Updated", className: "border-slate-400 text-slate-400", iconClassName: "text-slate-400", icon: Edit, }, [$Enums.AuditLogAction.MAP_DELETED]: { - label: "Map Deleted", className: "border-slate-600 text-slate-600", iconClassName: "text-slate-600", icon: Trash2, @@ -233,14 +208,24 @@ const actionTypes = { // System Actions [$Enums.AuditLogAction.BUG_REPORT_SUBMITTED]: { - label: "Bug Report Submitted", className: "border-pink-500 text-pink-500", iconClassName: "text-pink-500", icon: Bug, }, }; -function getActionBadge(action: string) { +function getActionLabel( + t: ReturnType>, + action: string +) { + if (action in actionTypes) return t(`actions.${action}`); + return action.replace("_", " "); +} + +function getActionBadge( + action: string, + t: ReturnType> +) { const actionInfo = actionTypes[action as keyof typeof actionTypes]; if (actionInfo) { @@ -248,7 +233,7 @@ function getActionBadge(action: string) { return ( - {actionInfo.label} + {getActionLabel(t, action)} ); } @@ -256,7 +241,7 @@ function getActionBadge(action: string) { return ( - {action.replace("_", " ")} + {getActionLabel(t, action)} ); } @@ -269,6 +254,7 @@ export function AuditLog({ height?: string; }) { const t = useTranslations("settingsPage.admin.audit-log"); + const formatter = useFormatter(); const loadMoreRef = useRef(null); const TODAY = new Date(); @@ -364,24 +350,30 @@ export function AuditLog({ const logs: AuditLog[] = data ? data.pages.flatMap((page) => page.items) : []; function formatDateRange() { - if (!dateRange) return "Select date range"; + if (!dateRange) return t("date-range.select"); if (dateRange.from && dateRange.to) { + const from = formatter.dateTime(dateRange.from, { dateStyle: "medium" }); + const to = formatter.dateTime(dateRange.to, { dateStyle: "medium" }); if (dateRange.from.toDateString() === dateRange.to.toDateString()) { - return format(dateRange.from, "PPP"); + return from; } - return `${format(dateRange.from, "PP")} - ${format(dateRange.to, "PP")}`; + return t("date-range.range", { from, to }); } if (dateRange.from) { - return `From ${format(dateRange.from, "PP")}`; + return t("date-range.from", { + date: formatter.dateTime(dateRange.from, { dateStyle: "medium" }), + }); } if (dateRange.to) { - return `Until ${format(dateRange.to, "PP")}`; + return t("date-range.until", { + date: formatter.dateTime(dateRange.to, { dateStyle: "medium" }), + }); } - return "Select date range"; + return t("date-range.select"); } function toggleActionType(actionType: string) { @@ -473,7 +465,7 @@ export function AuditLog({ /> )} {actionInfo - ? actionInfo.label + ? getActionLabel(t, actionType) : actionType.replace("_", " ")} @@ -491,7 +483,7 @@ export function AuditLog({ size="icon" onClick={clearFilters} className="h-9 w-9" - aria-label="Clear filters" + aria-label={t("clear-filters")} > @@ -523,26 +515,21 @@ export function AuditLog({ /> )} - {selectedActions.map((action) => { - const actionInfo = actionTypes[action as keyof typeof actionTypes]; - return ( - - {actionInfo ? actionInfo.label : action.replace("_", " ")} - - setSelectedActions((prev) => - prev.filter((a) => a !== action) - ) - } - /> - - ); - })} + {selectedActions.map((action) => ( + + {getActionLabel(t, action)} + + setSelectedActions((prev) => prev.filter((a) => a !== action)) + } + /> + + ))} {dateRange && ( {formatDateRange()} @@ -633,6 +620,8 @@ export function AuditLog({ function AuditLogHoverCard({ log }: { log: AuditLog }) { const t = useTranslations("settingsPage.admin.audit-log"); + const formatter = useFormatter(); + const createdAt = new Date(log.createdAt); return ( @@ -642,15 +631,15 @@ function AuditLogHoverCard({ log }: { log: AuditLog }) { {log.userEmail === "System" && } {log.userEmail} - {getActionBadge(log.action)} + {getActionBadge(log.action, t)} {log.target} {log.details} {t("table.date-time-format", { - date: format(new Date(log.createdAt), "PP"), - time: format(new Date(log.createdAt), "p"), + date: formatter.dateTime(createdAt, { dateStyle: "medium" }), + time: formatter.dateTime(createdAt, { timeStyle: "short" }), })} @@ -672,7 +661,7 @@ function AuditLogHoverCard({ log }: { log: AuditLog }) {

{t("hover.action")}

- {getActionBadge(log.action)} + {getActionBadge(log.action, t)}
@@ -702,7 +691,10 @@ function AuditLogHoverCard({ log }: { log: AuditLog }) { {t("hover.timestamp")}

- {format(new Date(log.createdAt), "PPPp")} + {formatter.dateTime(createdAt, { + dateStyle: "long", + timeStyle: "short", + })}

diff --git a/src/components/admin/download-audit-logs.tsx b/src/components/admin/download-audit-logs.tsx index bbd4f0f45..3d159f417 100644 --- a/src/components/admin/download-audit-logs.tsx +++ b/src/components/admin/download-audit-logs.tsx @@ -6,7 +6,8 @@ import { TooltipContent, TooltipTrigger, } from "@/components/ui/tooltip"; -import type { AuditLog } from "@prisma/client"; +import { serializeCsv } from "@/lib/csv"; +import type { AuditLog } from "@/generated/prisma/browser"; import { Download, Loader2 } from "lucide-react"; import { useTranslations } from "next-intl"; import { useState } from "react"; @@ -21,7 +22,6 @@ export function DownloadAuditLogs({ logs }: DownloadAuditLogsProps) { const [isDownloading, setIsDownloading] = useState(false); function convertToCSV(logs: AuditLog[]) { - // Define CSV headers const headers = [ "User Email", "Action", @@ -30,21 +30,17 @@ export function DownloadAuditLogs({ logs }: DownloadAuditLogsProps) { "Timestamp (UTC)", ]; - // Convert logs to CSV rows const rows = logs.map((log) => { return [ log.userEmail, log.action, log.target, - // Escape quotes in details to prevent CSV formatting issues - `"${log.details.replace(/"/g, '""')}"`, - // Use ISO string for consistent UTC timestamp - new Date(log.createdAt).toISOString(), + log.details, + new Date(log.createdAt), ]; }); - // Combine headers and rows - return [headers, ...rows].map((row) => row.join(",")).join("\n"); + return serializeCsv([headers, ...rows]); } function handleDownload() { diff --git a/src/components/admin/map-calibration/anchor-dialog.tsx b/src/components/admin/map-calibration/anchor-dialog.tsx index aa3327c08..d2bfe722b 100644 --- a/src/components/admin/map-calibration/anchor-dialog.tsx +++ b/src/components/admin/map-calibration/anchor-dialog.tsx @@ -12,6 +12,7 @@ import { import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; +import { useFormatter, useTranslations } from "next-intl"; import { useState } from "react"; type AnchorDialogProps = { @@ -35,6 +36,8 @@ export function AnchorDialog({ imageV, onSubmit, }: AnchorDialogProps) { + const t = useTranslations("mapCalibrationPage.anchorDialog"); + const formatter = useFormatter(); const { team1, team2 } = useColorblindMode(); const [worldX, setWorldX] = useState(""); const [worldY, setWorldY] = useState(""); @@ -64,32 +67,28 @@ export function AnchorDialog({ - Add Anchor Point + {t("title")} - Image position: ({imageU}, {imageV}). Enter the corresponding - in-game world coordinates. Overwatch uses ( - - X - - , Y,{" "} - - Z - - ) where Y is vertical — enter only{" "} - - X - {" "} - and{" "} - - Z - - . + {t.rich("description", { + imageU: formatter.number(imageU), + imageV: formatter.number(imageV), + x: (chunks) => ( + + {chunks} + + ), + z: (chunks) => ( + + {chunks} + + ), + })}
- + setWorldX(e.target.value)} - placeholder="e.g. 42.5" + placeholder={t("worldXPlaceholder")} // oxlint-disable-next-line jsx-a11y/no-autofocus -- intentional focus management in dialog autoFocus />
- + setWorldY(e.target.value)} - placeholder="e.g. -18.3" + placeholder={t("worldZPlaceholder")} />
- + setLabel(e.target.value)} - placeholder="e.g. Point A, Spawn door" + placeholder={t("labelPlaceholder")} />
@@ -134,10 +133,10 @@ export function AnchorDialog({ variant="outline" onClick={() => onOpenChange(false)} > - Cancel + {t("cancel")}
diff --git a/src/components/admin/map-calibration/anchor-list.tsx b/src/components/admin/map-calibration/anchor-list.tsx index ebb213514..7267edfac 100644 --- a/src/components/admin/map-calibration/anchor-list.tsx +++ b/src/components/admin/map-calibration/anchor-list.tsx @@ -10,6 +10,7 @@ import { TableRow, } from "@/components/ui/table"; import { Trash2 } from "lucide-react"; +import { useFormatter, useTranslations } from "next-intl"; type Anchor = { id: number; @@ -26,10 +27,13 @@ type AnchorListProps = { }; export function AnchorList({ anchors, onDelete }: AnchorListProps) { + const t = useTranslations("mapCalibrationPage.anchorList"); + const formatter = useFormatter(); + if (anchors.length === 0) { return (

- No anchor points yet. Click on the map image to place one. + {t("empty")}

); } @@ -39,9 +43,9 @@ export function AnchorList({ anchors, onDelete }: AnchorListProps) { # - Label - World (X, Z) - Image (U, V) + {t("label")} + {t("worldCoordinates")} + {t("imageCoordinates")} @@ -55,10 +59,21 @@ export function AnchorList({ anchors, onDelete }: AnchorListProps) { )} - ({anchor.worldX.toFixed(2)}, {anchor.worldY.toFixed(2)}) + ( + {formatter.number(anchor.worldX, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + ,{" "} + {formatter.number(anchor.worldY, { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} + ) - ({Math.round(anchor.imageU)}, {Math.round(anchor.imageV)}) + ({formatter.number(Math.round(anchor.imageU))},{" "} + {formatter.number(Math.round(anchor.imageV))}) diff --git a/src/components/admin/map-calibration/calibration-editor.tsx b/src/components/admin/map-calibration/calibration-editor.tsx index 44dd4e692..b2c868cd4 100644 --- a/src/components/admin/map-calibration/calibration-editor.tsx +++ b/src/components/admin/map-calibration/calibration-editor.tsx @@ -4,6 +4,7 @@ import { AnchorDialog } from "@/components/admin/map-calibration/anchor-dialog"; import { AnchorList } from "@/components/admin/map-calibration/anchor-list"; import { MapCanvas } from "@/components/admin/map-calibration/map-canvas"; import { MapImageUpload } from "@/components/admin/map-calibration/map-image-upload"; +import { ReplaceRenderDialog } from "@/components/admin/map-calibration/replace-render-dialog"; import { PreviewMode } from "@/components/admin/map-calibration/preview-mode"; import { TransformDisplay } from "@/components/admin/map-calibration/transform-display"; import { Button } from "@/components/ui/button"; @@ -12,6 +13,7 @@ import { ArrowLeft, Calculator, Save } from "lucide-react"; import type { Route } from "next"; import Link from "next/link"; import { useRouter } from "next/navigation"; +import { useFormatter, useTranslations } from "next-intl"; import { useCallback, useEffect, useState } from "react"; import { toast } from "sonner"; @@ -57,6 +59,8 @@ export function CalibrationEditor({ mapName, calibration: initialCalibration, }: CalibrationEditorProps) { + const t = useTranslations("mapCalibrationPage.editor"); + const formatter = useFormatter(); const router = useRouter(); const [calibration, setCalibration] = useState( initialCalibration @@ -126,7 +130,7 @@ export function CalibrationEditor({ setCalibration(data); } } else { - toast.error("Failed to create calibration record."); + toast.error(t("createRecordError")); } } @@ -155,7 +159,7 @@ export function CalibrationEditor({ ); setTransformSaved(false); } else { - toast.error("Failed to add anchor point."); + toast.error(t("addAnchorError")); } } @@ -178,7 +182,7 @@ export function CalibrationEditor({ ); setTransformSaved(false); } else { - toast.error("Failed to delete anchor point."); + toast.error(t("deleteAnchorError")); } } @@ -203,7 +207,7 @@ export function CalibrationEditor({ setTransformSaved(false); } else { const err = (await res.json()) as { error?: string }; - toast.error(err.error ?? "Failed to compute transform."); + toast.error(err.error ?? t("computeTransformError")); } } finally { setComputing(false); @@ -230,10 +234,10 @@ export function CalibrationEditor({ if (res.ok) { setTransformSaved(true); - toast.success("Transform saved."); + toast.success(t("transformSaved")); router.refresh(); } else { - toast.error("Failed to save transform."); + toast.error(t("saveTransformError")); } } finally { setSaving(false); @@ -245,9 +249,7 @@ export function CalibrationEditor({
-

- No image uploaded for this map yet. -

+

{t("noImageUploaded")}

+ {calibration.anchors.length >= 3 ? ( + ({ + imageU: a.imageU, + imageV: a.imageV, + label: a.label, + }))} + onApplied={() => router.refresh()} + /> + ) : null}
@@ -317,7 +331,10 @@ export function CalibrationEditor({

- Anchor Points ({calibration.anchors.length}) + {t("anchorPoints", { + count: calibration.anchors.length, + formattedCount: formatter.number(calibration.anchors.length), + })}

- {computing ? "Computing…" : "Compute Transform"} + {computing ? t("computing") : t("computeTransform")} {computedTransform && !transformSaved ? ( ) : null}
{calibration.anchors.length < 3 ? (

- Place at least 3 anchor points to compute a transform. More points - improve accuracy. + {t("minimumAnchorsHint")}

) : null} @@ -393,12 +409,14 @@ function Header({ mapName: string; children?: React.ReactNode; }) { + const t = useTranslations("mapCalibrationPage.editor"); + return (
diff --git a/src/components/admin/map-calibration/map-calibration-list.tsx b/src/components/admin/map-calibration/map-calibration-list.tsx index f1be93c39..d95b5b24b 100644 --- a/src/components/admin/map-calibration/map-calibration-list.tsx +++ b/src/components/admin/map-calibration/map-calibration-list.tsx @@ -10,8 +10,12 @@ import { } from "@/components/ui/card"; import { Input } from "@/components/ui/input"; import { ToggleGroup, ToggleGroupItem } from "@/components/ui/toggle-group"; -import type { MapCalibration, MapCalibrationAnchor } from "@prisma/client"; +import type { + MapCalibration, + MapCalibrationAnchor, +} from "@/generated/prisma/browser"; import type { Route } from "next"; +import { useTranslations } from "next-intl"; import Link from "next/link"; import { useMemo, useState } from "react"; @@ -33,6 +37,9 @@ const MAP_TYPES = [ */ const CALIBRATION_MAPS: { name: string; type: string }[] = [ { name: "Aatlis", type: "Flashpoint" }, + { name: "Antarctic Peninsula: Drilling Rig", type: "Control" }, + { name: "Antarctic Peninsula: Icebreaker", type: "Control" }, + { name: "Antarctic Peninsula: Anomaly", type: "Control" }, { name: "Blizzard World", type: "Hybrid" }, { name: "Busan: Downtown", type: "Control" }, { name: "Busan: Meka Base", type: "Control" }, @@ -76,20 +83,25 @@ const CALIBRATION_MAPS: { name: string; type: string }[] = [ function getCalibrationStatus( calibration: CalibrationWithAnchors | undefined -): { label: string; variant: "default" | "secondary" | "destructive" } { +): + | { key: "noImage"; count?: never; variant: "destructive" } + | { key: "calibrated"; count?: never; variant: "default" } + | { key: "anchors"; count: number; variant: "secondary" } + | { key: "imageUploaded"; count?: never; variant: "secondary" } { if (!calibration) { - return { label: "No image", variant: "destructive" }; + return { key: "noImage", variant: "destructive" }; } if (calibration.affineA !== null) { - return { label: "Calibrated", variant: "default" }; + return { key: "calibrated", variant: "default" }; } if (calibration.anchors.length > 0) { return { - label: `${calibration.anchors.length} anchor${calibration.anchors.length === 1 ? "" : "s"}`, + key: "anchors", + count: calibration.anchors.length, variant: "secondary", }; } - return { label: "Image uploaded", variant: "secondary" }; + return { key: "imageUploaded", variant: "secondary" }; } export function MapCalibrationList({ @@ -97,6 +109,8 @@ export function MapCalibrationList({ }: { calibrations: CalibrationWithAnchors[]; }) { + const t = useTranslations("mapCalibrationPage.list"); + const tMapTypes = useTranslations("mapCalibrationPage.mapTypes"); const [search, setSearch] = useState(""); const [typeFilter, setTypeFilter] = useState([]); @@ -129,7 +143,7 @@ export function MapCalibrationList({
setSearch(e.target.value)} @@ -144,7 +158,7 @@ export function MapCalibrationList({ > {MAP_TYPES.map((t) => ( - {t} + {mapTypeLabel(tMapTypes, t)} ))} @@ -152,7 +166,7 @@ export function MapCalibrationList({ {filtered.length === 0 ? (

- No maps match your filters. + {t("noMatches")}

) : (
@@ -171,16 +185,24 @@ export function MapCalibrationList({ {name} - {status.label} + + {status.key === "anchors" + ? t("status.anchors", { count: status.count }) + : t(`status.${status.key}`)} +
- {type} + + {mapTypeLabel(tMapTypes, type)} + {calibration ? (

- {calibration.anchors.length} anchor - {calibration.anchors.length === 1 ? "" : "s"} - {calibration.affineA !== null && " · transform saved"} + {t("anchorSummary", { + count: calibration.anchors.length, + })} + {calibration.affineA !== null && + t("transformSavedSuffix")}

) : null} @@ -193,3 +215,23 @@ export function MapCalibrationList({
); } + +function mapTypeLabel( + t: ReturnType, + type: string +): string { + switch (type) { + case "Control": + return t("control"); + case "Escort": + return t("escort"); + case "Flashpoint": + return t("flashpoint"); + case "Hybrid": + return t("hybrid"); + case "Push": + return t("push"); + default: + return type; + } +} diff --git a/src/components/admin/map-calibration/map-canvas.tsx b/src/components/admin/map-calibration/map-canvas.tsx index cbd27e757..3d0819e08 100644 --- a/src/components/admin/map-calibration/map-canvas.tsx +++ b/src/components/admin/map-calibration/map-canvas.tsx @@ -5,6 +5,7 @@ import { imageToWorld, worldToImage, } from "@/lib/map-calibration/world-to-image"; +import { useFormatter, useTranslations } from "next-intl"; import { useCallback, useEffect, useRef, useState } from "react"; type Anchor = { @@ -54,6 +55,8 @@ export function MapCanvas({ testPoints, onImageClick, }: MapCanvasProps) { + const t = useTranslations("mapCalibrationPage.canvas"); + const formatter = useFormatter(); const canvasRef = useRef(null); const containerRef = useRef(null); const imageRef = useRef(null); @@ -66,8 +69,12 @@ export function MapCanvas({ const [showGrid, setShowGrid] = useState(true); useEffect(() => { + // No crossOrigin: the canvas only draws the image, never reads its pixels + // back (no getImageData/toDataURL), so tainting is irrelevant. Setting + // crossOrigin would make the load fail whenever the R2 response lacks CORS + // headers — which is exactly why presigned map images would hang loading. + setImageLoaded(false); const img = new Image(); - img.crossOrigin = "anonymous"; img.onload = () => { imageRef.current = img; setImageLoaded(true); @@ -82,6 +89,10 @@ export function MapCanvas({ setView({ offsetX: 0, offsetY: 0, zoom: fitZoom }); } }; + img.onerror = () => { + // eslint-disable-next-line no-console + console.error(`MapCanvas: failed to load image ${imageUrl}`); + }; img.src = imageUrl; }, [imageUrl, imageWidth, imageHeight]); @@ -386,7 +397,7 @@ export function MapCanvas({ > {!imageLoaded ? (
-

Loading map image…

+

{t("loading")}

) : null}
@@ -410,12 +421,16 @@ export function MapCanvas({ : "text-white/50 hover:text-white/80" }`} > - Grid {showGrid ? "ON" : "OFF"} + {showGrid ? t("gridOn") : t("gridOff")} ) : null} - {Math.round(view.zoom * 100)}% · Scroll to zoom · Drag to pan · Click - to place + {t("instructions", { + zoom: formatter.number(view.zoom, { + style: "percent", + maximumFractionDigits: 0, + }), + })}
diff --git a/src/components/admin/map-calibration/map-image-upload.tsx b/src/components/admin/map-calibration/map-image-upload.tsx index d17e06c29..772a7e624 100644 --- a/src/components/admin/map-calibration/map-image-upload.tsx +++ b/src/components/admin/map-calibration/map-image-upload.tsx @@ -10,6 +10,7 @@ import { DialogTrigger, } from "@/components/ui/dialog"; import { Upload } from "lucide-react"; +import { useTranslations } from "next-intl"; import { useRef, useState } from "react"; import { toast } from "sonner"; @@ -27,6 +28,7 @@ export function MapImageUpload({ mapName, onUploadComplete, }: MapImageUploadProps) { + const t = useTranslations("mapCalibrationPage.upload"); const [open, setOpen] = useState(false); const [uploading, setUploading] = useState(false); const [status, setStatus] = useState(""); @@ -35,7 +37,7 @@ export function MapImageUpload({ async function handleUpload(file: File) { setUploading(true); try { - setStatus("Requesting upload URL…"); + setStatus(t("requestingUploadUrl")); const presignRes = await fetch( "/api/admin/map-calibration/upload/presign", { @@ -48,14 +50,14 @@ export function MapImageUpload({ } ); - if (!presignRes.ok) throw new Error("Failed to get upload URL"); + if (!presignRes.ok) throw new Error(t("uploadUrlError")); const { uploadUrl, rawKey } = (await presignRes.json()) as { uploadUrl: string; rawKey: string; }; - setStatus("Uploading image to storage\u2026"); + setStatus(t("uploadingToStorage")); const uploadRes = await fetch(uploadUrl, { method: "PUT", body: file, @@ -64,16 +66,16 @@ export function MapImageUpload({ }, }); - if (!uploadRes.ok) throw new Error("Failed to upload image to storage"); + if (!uploadRes.ok) throw new Error(t("storageUploadError")); - setStatus("Processing image\u2026"); + setStatus(t("processingImage")); const processRes = await fetch("/api/admin/map-calibration/upload", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ rawKey, mapName }), }); - if (!processRes.ok) throw new Error("Failed to process image"); + if (!processRes.ok) throw new Error(t("processImageError")); const data = (await processRes.json()) as { imageKey: string; @@ -90,7 +92,7 @@ export function MapImageUpload({ ); setOpen(false); } catch { - toast.error("Failed to upload image."); + toast.error(t("uploadError")); } finally { setUploading(false); setStatus(""); @@ -102,16 +104,13 @@ export function MapImageUpload({ - Upload Map Image - - Upload a top-down orthographic image for {mapName}. Supports PNG and - JPEG up to 150MB. - + {t("title")} + {t("description", { mapName })}
{ const file = e.target.files?.[0]; @@ -129,7 +128,7 @@ export function MapImageUpload({ /> {uploading ? (

- {status || "Uploading…"} This may take a moment for large images. + {status || t("uploading")} {t("largeImageNote")}

) : null}
diff --git a/src/components/admin/map-calibration/preview-mode.tsx b/src/components/admin/map-calibration/preview-mode.tsx index 6be2bfe52..7cc1ae08c 100644 --- a/src/components/admin/map-calibration/preview-mode.tsx +++ b/src/components/admin/map-calibration/preview-mode.tsx @@ -4,6 +4,7 @@ import { Button } from "@/components/ui/button"; import { Input } from "@/components/ui/input"; import { Label } from "@/components/ui/label"; import { useColorblindMode } from "@/hooks/use-colorblind-mode"; +import { useTranslations } from "next-intl"; import { useState } from "react"; type TestPoint = { @@ -25,6 +26,7 @@ export function PreviewMode({ pointCount, disabled, }: PreviewModeProps) { + const t = useTranslations("mapCalibrationPage.preview"); const { team1, team2 } = useColorblindMode(); const [worldX, setWorldX] = useState(""); const [worldY, setWorldY] = useState(""); @@ -44,32 +46,26 @@ export function PreviewMode({ return (
-

Preview Test Points

+

{t("title")}

- Enter world coordinates to verify they project to the expected map - position. From Overwatch's ( - - X - - , Y,{" "} - - Z - - ), use only{" "} - - X - {" "} - and{" "} - - Z - - . + {t.rich("description", { + x: (chunks) => ( + + {chunks} + + ), + z: (chunks) => ( + + {chunks} + + ), + })}

- Add Test Point + {t("addTestPoint")} {pointCount > 0 && ( )}
diff --git a/src/components/admin/map-calibration/replace-render-dialog.tsx b/src/components/admin/map-calibration/replace-render-dialog.tsx new file mode 100644 index 000000000..a9eb9c69a --- /dev/null +++ b/src/components/admin/map-calibration/replace-render-dialog.tsx @@ -0,0 +1,422 @@ +"use client"; + +import { MapCanvas } from "@/components/admin/map-calibration/map-canvas"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, + DialogTrigger, +} from "@/components/ui/dialog"; +import { applyPixelAffine } from "@/lib/map-calibration/remap"; +import type { PixelAffine } from "@/lib/map-calibration/types"; +import { Upload } from "lucide-react"; +import { useTranslations } from "next-intl"; +import { useState } from "react"; +import { toast } from "sonner"; + +type Anchor = { + imageU: number; + imageV: number; + label: string | null; +}; + +type StagedResult = { + stagedOriginalKey: string; + stagedDisplayKey: string; + stagedDisplayUrl: string; + oldDisplayUrl: string; + newWidth: number; + newHeight: number; +}; + +type ParsedTransform = { + pixelAffine: PixelAffine; + inliers: number | null; + residual: number | null; +}; + +type ReplaceRenderDialogProps = { + calibrationId: number; + mapName: string; + anchors: Anchor[]; + onApplied: () => void; +}; + +// Heuristics for the "verify carefully" banner (informational only — never gates +// Confirm). Residual is mean reprojection error in original-image pixels; inliers +// is the RANSAC inlier match count from the alignment script. +const LOW_CONFIDENCE_RESIDUAL = 5; +const LOW_CONFIDENCE_INLIERS = 25; + +// Parse the alignment CLI's output. Accepts either the full payload +// { pixelAffine: {...}, inliers, residual } or a bare { a,b,c,d,tx,ty }. +// Returns null if the shape/values are invalid. Caller wraps JSON.parse errors. +function parseTransform(text: string): ParsedTransform | null { + const raw: unknown = JSON.parse(text); + if (typeof raw !== "object" || raw === null) return null; + const obj = raw as Record; + const affineSource = ("pixelAffine" in obj ? obj.pixelAffine : obj) as Record< + string, + unknown + > | null; + if (typeof affineSource !== "object" || affineSource === null) return null; + const keys = ["a", "b", "c", "d", "tx", "ty"] as const; + const affine: Record<(typeof keys)[number], number> = { + a: 0, + b: 0, + c: 0, + d: 0, + tx: 0, + ty: 0, + }; + for (const k of keys) { + const v = affineSource[k]; + if (typeof v !== "number" || !Number.isFinite(v)) return null; + affine[k] = v; + } + const inliers = typeof obj.inliers === "number" ? obj.inliers : null; + const residual = typeof obj.residual === "number" ? obj.residual : null; + return { pixelAffine: affine as PixelAffine, inliers, residual }; +} + +export function ReplaceRenderDialog({ + calibrationId, + mapName, + anchors, + onApplied, +}: ReplaceRenderDialogProps) { + const t = useTranslations("mapCalibrationPage.replaceRender"); + const [open, setOpen] = useState(false); + const [status, setStatus] = useState(""); + const [busy, setBusy] = useState(false); + const [staged, setStaged] = useState(null); + const [transformText, setTransformText] = useState(""); + const [parsed, setParsed] = useState(null); + const [parseError, setParseError] = useState(false); + const [compareMode, setCompareMode] = useState<"blink" | "swipe">("blink"); + const [showOld, setShowOld] = useState(false); + const [swipe, setSwipe] = useState(50); + + function reset() { + setStaged(null); + setTransformText(""); + setParsed(null); + setParseError(false); + setStatus(""); + setBusy(false); + setCompareMode("blink"); + setShowOld(false); + setSwipe(50); + } + + async function handleFile(file: File) { + setBusy(true); + setStaged(null); + setParsed(null); + setTransformText(""); + setParseError(false); + try { + setStatus(t("uploading")); + const presignRes = await fetch( + "/api/admin/map-calibration/upload/presign", + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ mapName, contentType: file.type }), + } + ); + if (!presignRes.ok) throw new Error("presign"); + const { uploadUrl, rawKey } = (await presignRes.json()) as { + uploadUrl: string; + rawKey: string; + }; + + const put = await fetch(uploadUrl, { + method: "PUT", + body: file, + headers: { "Content-Type": file.type }, + }); + if (!put.ok) throw new Error("upload"); + + const stageRes = await fetch( + `/api/admin/map-calibration/${calibrationId}/align`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rawKey }), + } + ); + if (!stageRes.ok) throw new Error("stage"); + setStaged((await stageRes.json()) as StagedResult); + } catch { + toast.error(t("applyError")); + } finally { + setBusy(false); + setStatus(""); + } + } + + function handleTransformChange(text: string) { + setTransformText(text); + if (text.trim() === "") { + setParsed(null); + setParseError(false); + return; + } + try { + const result = parseTransform(text); + setParsed(result); + setParseError(result === null); + } catch { + setParsed(null); + setParseError(true); + } + } + + async function discardStaging() { + await fetch(`/api/admin/map-calibration/${calibrationId}/align`, { + method: "DELETE", + }).catch(() => undefined); + } + + async function handleConfirm() { + if (!staged || !parsed) return; + setBusy(true); + setStatus(t("applying")); + try { + const res = await fetch( + `/api/admin/map-calibration/${calibrationId}/align/apply`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + pixelAffine: parsed.pixelAffine, + stagedOriginalKey: staged.stagedOriginalKey, + stagedDisplayKey: staged.stagedDisplayKey, + newWidth: staged.newWidth, + newHeight: staged.newHeight, + }), + } + ); + if (!res.ok) throw new Error("apply"); + toast.success(t("applied")); + setStaged(null); + setOpen(false); + reset(); + onApplied(); + } catch { + toast.error(t("applyError")); + } finally { + setBusy(false); + setStatus(""); + } + } + + async function handleCancel() { + if (staged) await discardStaging(); + setOpen(false); + reset(); + } + + const projectedAnchors: Anchor[] = + staged && parsed + ? anchors.map((a) => { + const { u, v } = applyPixelAffine( + parsed.pixelAffine, + a.imageU, + a.imageV + ); + return { imageU: u, imageV: v, label: a.label }; + }) + : []; + + const lowConfidence = + parsed != null && + ((parsed.residual != null && parsed.residual > LOW_CONFIDENCE_RESIDUAL) || + (parsed.inliers != null && parsed.inliers < LOW_CONFIDENCE_INLIERS)); + + return ( + { + if (!next && staged) void discardStaging(); + setOpen(next); + if (!next) reset(); + }} + > + + + + + + {t("title", { mapName })} + {t("description")} + + + {!staged ? ( +
+ { + const file = e.target.files?.[0]; + e.target.value = ""; + if (file) void handleFile(file); + }} + disabled={busy} + /> + {busy ? ( +

+ {status} +

+ ) : null} +
+ ) : ( +
+
+

{t("staged")}

+ + {t("scriptHint")} + + +