feat: NCWF-5/6/7/8/9 chess analysis page and engine integration

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Janis Eccarius
2026-06-15 22:28:30 +02:00
parent ae952d70b0
commit b9998b6710
30 changed files with 2146 additions and 290 deletions
+5
View File
@@ -19,6 +19,7 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the bug involves component logic, a service, or routing: If the bug involves component logic, a service, or routing:
- Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`. - Search repo for relevant code (`Grep`/`Bash`) under `src/app/{components,services,models,core,pages}`.
- Check `.spec.ts` files for existing coverage of the broken area. - Check `.spec.ts` files for existing coverage of the broken area.
- Do NOT guess at root cause. Surface findings before drafting. - Do NOT guess at root cause. Surface findings before drafting.
@@ -56,6 +57,7 @@ Environment / Notes
``` ```
Rules: Rules:
- Steps must be minimal and reproducible. - Steps must be minimal and reproducible.
- Expected vs actual: concrete and unambiguous. - Expected vs actual: concrete and unambiguous.
- Omit "Environment / Notes" section if not relevant. - Omit "Environment / Notes" section if not relevant.
@@ -64,6 +66,7 @@ Rules:
Show the draft to the user. Show the draft to the user.
**Use `AskUserQuestion` tool to ask:** **Use `AskUserQuestion` tool to ask:**
- Are steps to reproduce complete and accurate? - Are steps to reproduce complete and accurate?
- Severity: Blocker / Critical / Major / Minor / Trivial? - Severity: Blocker / Critical / Major / Minor / Trivial?
- Any related tickets or recent changes to link? - Any related tickets or recent changes to link?
@@ -73,6 +76,7 @@ Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project ## Step 5 — Determine Project
> **Project routing rules (always apply these):** > **Project routing rules (always apply these):**
>
> - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
@@ -86,6 +90,7 @@ If ambiguous, ask the user.
## Step 6 — Create Issue ## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with: Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5 - `project`: determined in Step 5
- `summary`: concise title describing what is broken (≤72 chars, sentence case) - `summary`: concise title describing what is broken (≤72 chars, sentence case)
- `description`: full formatted defect report from Step 3 (Markdown) - `description`: full formatted defect report from Step 3 (Markdown)
+13 -8
View File
@@ -19,6 +19,7 @@ If `$ARGUMENTS` already answers some of these, skip those questions.
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the topic involves unfamiliar UI flows, component structure, or technical constraints: If the topic involves unfamiliar UI flows, component structure, or technical constraints:
- Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`). - Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`).
- Use `WebSearch` if the topic involves Angular APIs, external standards or protocols. - Use `WebSearch` if the topic involves Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before drafting. - Do NOT guess. Surface findings before drafting.
@@ -53,6 +54,7 @@ Implementation Notes
``` ```
Rules: Rules:
- User story line: plain English, present tense, from user's perspective. - User story line: plain English, present tense, from user's perspective.
- Acceptance criteria: testable, unambiguous, one condition each. - Acceptance criteria: testable, unambiguous, one condition each.
- Implementation notes: optional — only include if there are known constraints, related tickets, or design refs. - Implementation notes: optional — only include if there are known constraints, related tickets, or design refs.
@@ -61,6 +63,7 @@ Rules:
Show the draft to the user. Show the draft to the user.
**Use `AskUserQuestion` tool to ask:** **Use `AskUserQuestion` tool to ask:**
- Are the acceptance criteria complete and correct? - Are the acceptance criteria complete and correct?
- Any implementation constraints to add? - Any implementation constraints to add?
- Priority (if known)? - Priority (if known)?
@@ -70,6 +73,7 @@ Incorporate feedback. Repeat until user approves.
## Step 5 — Determine Project ## Step 5 — Determine Project
> **Project routing rules (always apply these):** > **Project routing rules (always apply these):**
>
> - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Frontend code (UI, UX, web app, components, services) → `NCWF`
> - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Backend code (game engine, bots, API, services, coordinator) → `NCS`
> - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI`
@@ -83,6 +87,7 @@ If still ambiguous, ask the user.
## Step 6 — Create Issue ## Step 6 — Create Issue
Call `mcp__youtrack__create_issue` with: Call `mcp__youtrack__create_issue` with:
- `project`: determined in Step 5 - `project`: determined in Step 5
- `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case) - `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case)
- `description`: full formatted story from Step 3 (Markdown) - `description`: full formatted story from Step 3 (Markdown)
@@ -96,14 +101,14 @@ After creation, ask the user (use `AskUserQuestion` if interactive, otherwise in
Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`: Collect any issue IDs the user mentions. For each, determine the correct relation and call `mcp__youtrack__link_issues`:
| Situation | Relation to use | | Situation | Relation to use |
|-----------|----------------| | --------------------------------------- | --------------- |
| This story must be done before another | `blocks` | | This story must be done before another | `blocks` |
| Another story must be done before this | `is blocked by` | | Another story must be done before this | `is blocked by` |
| Stories share domain or are related | `relates to` | | Stories share domain or are related | `relates to` |
| This is a child of an epic/story | `subtask of` | | This is a child of an epic/story | `subtask of` |
| This is a parent grouping subtasks | `parent for` | | This is a parent grouping subtasks | `parent for` |
| This depends on another ticket's output | `depends on` | | This depends on another ticket's output | `depends on` |
If the user mentions an issue in the story description or implementation notes (e.g. "see NCS-42", "after NCWF-12 is done"), auto-detect and suggest linking it — confirm before creating the link. If the user mentions an issue in the story description or implementation notes (e.g. "see NCS-42", "after NCWF-12 is done"), auto-detect and suggest linking it — confirm before creating the link.
+15 -7
View File
@@ -7,10 +7,12 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 1 — Determine Scope ## Step 1 — Determine Scope
**Single-issue mode** (`$ARGUMENTS` set): **Single-issue mode** (`$ARGUMENTS` set):
- Call `mcp__youtrack__get_issue` on `$ARGUMENTS`. - Call `mcp__youtrack__get_issue` on `$ARGUMENTS`.
- Proceed with that issue only. - Proceed with that issue only.
**Sprint mode** (`$ARGUMENTS` empty): **Sprint mode** (`$ARGUMENTS` empty):
- Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`. - Call `mcp__youtrack__search_issues` with query `project: NCWF Sprints: {current sprint} #Unresolved`.
- If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`. - If query returns 0 results, use `AskUserQuestion` to ask for the sprint name, then retry with `project: NCWF Sprints: {name}`.
- Collect all returned issues. - Collect all returned issues.
@@ -18,6 +20,7 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`.
## Step 2 — Build Issue Tree ## Step 2 — Build Issue Tree
For each top-level issue from Step 1: For each top-level issue from Step 1:
1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues. 1. Fetch full details via `mcp__youtrack__get_issue`: summary, description, acceptance criteria, Type, existing `Zeitschätzung`, linked issues.
2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent). 2. Identify subtasks from links with relation `subtask of` (i.e. issues where the fetched issue is the parent).
3. Recursively fetch subtasks until all leaves are known. 3. Recursively fetch subtasks until all leaves are known.
@@ -29,17 +32,18 @@ For each top-level issue from Step 1:
## Step 3 — Estimate Leaf Nodes ## Step 3 — Estimate Leaf Nodes
For each leaf node: For each leaf node:
1. Read: summary, description, acceptance criteria, implementation notes. 1. Read: summary, description, acceptance criteria, implementation notes.
2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity. 2. If scope is unclear, search codebase (`Grep`/`Bash`) under `src/app/` for related files to gauge complexity.
3. Assign estimate using this scale: 3. Assign estimate using this scale:
| Size | Criteria | Estimate | | Size | Criteria | Estimate |
|------|----------|----------| | ------- | ------------------------------------------------------ | -------- |
| Trivial | Style tweak, copy change, 1-file tweak | 30m | | Trivial | Style tweak, copy change, 1-file tweak | 30m |
| Small | 13 files, single component/service, no unknowns | 1h2h | | Small | 13 files, single component/service, no unknowns | 1h2h |
| Medium | 36 files, new component + service wiring, some design | 3h5h | | Medium | 36 files, new component + service wiring, some design | 3h5h |
| Large | 6+ files, cross-feature, non-trivial state/routing | 1d2d | | Large | 6+ files, cross-feature, non-trivial state/routing | 1d2d |
| XL | New feature area, major refactor, research spike | 3d5d | | XL | New feature area, major refactor, research spike | 3d5d |
4. Record: estimate + one-line reasoning. 4. Record: estimate + one-line reasoning.
5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated. 5. Skip leaf if it already has `Zeitschätzung` set — note it as pre-estimated.
@@ -49,6 +53,7 @@ For each leaf node:
YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**. YouTrack auto-sums `Zeitschätzung` from subtasks up to parents — **do not write estimates to parent nodes**.
Compute display-only rolled-up totals: Compute display-only rolled-up totals:
- Parent total = sum of all descendant leaf estimates (including pre-estimated ones). - Parent total = sum of all descendant leaf estimates (including pre-estimated ones).
- Flag any branch where some leaves are missing estimates (partial roll-up). - Flag any branch where some leaves are missing estimates (partial roll-up).
@@ -68,11 +73,13 @@ Epic NCWF-10: Board UI overhaul [4h 30m] ← rolled up
Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack. Legend: `[X]` = display-only roll-up (not written). Plain = will be written to YouTrack.
If sprint mode, show grand total at bottom: If sprint mode, show grand total at bottom:
``` ```
Sprint total: Xd Yh Zm (N issues, M leaves to update) Sprint total: Xd Yh Zm (N issues, M leaves to update)
``` ```
**Use `AskUserQuestion` tool:** **Use `AskUserQuestion` tool:**
- Does the breakdown look right? - Does the breakdown look right?
- Any estimates to adjust before writing to YouTrack? - Any estimates to adjust before writing to YouTrack?
@@ -81,6 +88,7 @@ Incorporate all feedback before proceeding.
## Step 6 — Write Estimates ## Step 6 — Write Estimates
On user approval, write estimates **only to leaf nodes** (bottom-up order): On user approval, write estimates **only to leaf nodes** (bottom-up order):
- For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate. - For each leaf with a new estimate, call `mcp__youtrack__update_issue` with field `Zeitschätzung` = approved estimate.
- YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`. - YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`.
- Skip leaves already pre-estimated. - Skip leaves already pre-estimated.
+11 -7
View File
@@ -3,6 +3,7 @@
Automated defect-fix workflow. Ticket ID: `$ARGUMENTS` Automated defect-fix workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates: This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates:
- Build: `npm run build` - Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless` - Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`) - Format: `npx prettier --write .` (check with `npx prettier --check .`)
@@ -15,6 +16,7 @@ Extract and display: summary, description, steps to reproduce, Priority, Subsyst
## Step 2 — Create Worktree ## Step 2 — Create Worktree
Derive branch name from ticket: Derive branch name from ticket:
- `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore` - `type` from YouTrack issue type: `bug``fix`, `feature`/`task``feat`, `refactor``refactor`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear) - `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles - `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
@@ -42,6 +44,7 @@ After root cause confirmed, assess scope:
**Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding. **Complex** (3+ files, multiple concerns, or estimated > 1 hour): create subtasks before coding.
To create subtasks: To create subtasks:
1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream"). 1. Break fix into discrete, independently-completable tasks (e.g. "Fix selection state in BoardComponent", "Add spec for flip behaviour", "Update GameService move stream").
2. For each subtask call `mcp__youtrack__create_issue` with: 2. For each subtask call `mcp__youtrack__create_issue` with:
- `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user. - `project`: based on subtask content — do **not** inherit from parent. Frontend/UI → `NCWF`; backend code → `NCS`; Kubernetes/pipelines/CI-CD/infrastructure → `NCI`. If ambiguous, ask user.
@@ -66,7 +69,7 @@ Then proceed to Step 4, implementing subtasks in order.
5. Run `npx prettier --check .`**blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green. 5. Run `npx prettier --check .`**blocking, foreground only** (never `run_in_background`). Wait for exit code 0. Must be green.
- If it fails, fix all issues and re-run until exit code 0. - If it fails, fix all issues and re-run until exit code 0.
- **Do NOT proceed to Step 5 until the build, tests and format check all pass.** - **Do NOT proceed to Step 5 until the build, tests and format check all pass.**
If any step fails, iterate until all pass. If any step fails, iterate until all pass.
## Step 5 — Review ## Step 5 — Review
@@ -76,6 +79,7 @@ Display findings grouped by severity.
## Step 5b — Apply Review Findings ## Step 5b — Apply Review Findings
If the review produced any findings (any severity): If the review produced any findings (any severity):
1. Implement all agreed fixes. 1. Implement all agreed fixes.
2. Run `npm run build` — must be green. 2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. 3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
@@ -125,12 +129,12 @@ Files changed:
After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked: After commenting, ask the user if `$ARGUMENTS` should be linked to any other issues not already linked:
| Situation | Relation | | Situation | Relation |
|-----------|---------| | ----------------------------------- | --------------- |
| This fix blocks another open ticket | `blocks` | | This fix blocks another open ticket | `blocks` |
| Another ticket must ship first | `is blocked by` | | Another ticket must ship first | `is blocked by` |
| Related defect or story | `relates to` | | Related defect or story | `relates to` |
| Duplicate of another defect | `duplicates` | | Duplicate of another defect | `duplicates` |
Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically. Scan the ticket description and comments for any issue IDs that were mentioned but not yet linked. Suggest those automatically.
+5
View File
@@ -4,6 +4,7 @@ Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS`
This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project = This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project =
`NCWF`. Gates: `NCWF`. Gates:
- Build: `npm run build` - Build: `npm run build`
- Test: `npm test -- --watch=false --browsers=ChromeHeadless` - Test: `npm test -- --watch=false --browsers=ChromeHeadless`
- Format: `npx prettier --write .` (check with `npx prettier --check .`) - Format: `npx prettier --write .` (check with `npx prettier --check .`)
@@ -40,6 +41,7 @@ are collected and reported at the end with a ready-to-run prompt.
## Step 3 — Create Worktree ## Step 3 — Create Worktree
Derive branch name from the root ticket `$ARGUMENTS`: Derive branch name from the root ticket `$ARGUMENTS`:
- `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore` - `type` from YouTrack issue type: `feature`/`task``feat`, `refactor``refactor`, `bug``fix`, else `chore`
- `scope` from affected area (kebab-case, omit if unclear) - `scope` from affected area (kebab-case, omit if unclear)
- `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles - `description` from ticket summary: lowercase, kebab-case, max 40 chars, drop articles
@@ -83,6 +85,7 @@ Display findings grouped by severity.
## Step 6b — Apply Review Findings ## Step 6b — Apply Review Findings
If the review produced any findings (any severity): If the review produced any findings (any severity):
1. Implement all agreed fixes. 1. Implement all agreed fixes.
2. Run `npm run build` — must be green. 2. Run `npm run build` — must be green.
3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green. 3. Run `npm test -- --watch=false --browsers=ChromeHeadless` — must be green.
@@ -139,10 +142,12 @@ Call `ExitWorktree` with `discard_changes: true` to delete the worktree.
Final report to the user, in two sections: Final report to the user, in two sections:
### Blocked in-project tasks ### Blocked in-project tasks
List any `NCWF` tasks that could **not** be implemented, with the blocker(s) List any `NCWF` tasks that could **not** be implemented, with the blocker(s)
that stopped them. (These can be re-run with this command once blockers clear.) that stopped them. (These can be re-run with this command once blockers clear.)
### Cross-project tasks (NCS / NCI / other) ### Cross-project tasks (NCS / NCI / other)
For every out-of-project task discovered in the tree (whether it was a subtask For every out-of-project task discovered in the tree (whether it was a subtask
or a blocker), output one entry: or a blocker), output one entry:
+11 -5
View File
@@ -12,6 +12,7 @@ Extract and display: summary, description, acceptance criteria, implementation n
## Step 2 — Research (if needed) ## Step 2 — Research (if needed)
If the story involves unfamiliar UI flows or technical constraints: If the story involves unfamiliar UI flows or technical constraints:
- Search repo for relevant code under `src/app/` (`Grep`/`Bash`). - Search repo for relevant code under `src/app/` (`Grep`/`Bash`).
- Use `WebSearch` for Angular APIs, external standards or protocols. - Use `WebSearch` for Angular APIs, external standards or protocols.
- Do NOT guess. Surface findings before proposing splits. - Do NOT guess. Surface findings before proposing splits.
@@ -19,6 +20,7 @@ If the story involves unfamiliar UI flows or technical constraints:
## Step 3 — Propose Split ## Step 3 — Propose Split
Analyse the story and propose a set of subtasks. Rules: Analyse the story and propose a set of subtasks. Rules:
- Each subtask = one unit of work, completable independently or in sequence. - Each subtask = one unit of work, completable independently or in sequence.
- No subtask should exceed ~2 days of work. - No subtask should exceed ~2 days of work.
- Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component"). - Name subtasks in imperative mood (e.g. "Add board theme service", "Render theme picker component").
@@ -26,6 +28,7 @@ Analyse the story and propose a set of subtasks. Rules:
Show proposed subtask list to user (titles only) and ask: Show proposed subtask list to user (titles only) and ask:
**Use `AskUserQuestion` tool:** **Use `AskUserQuestion` tool:**
- Does the split look right? - Does the split look right?
- Any subtasks to add, remove, or merge? - Any subtasks to add, remove, or merge?
- Should any subtask be assigned to a specific person? - Should any subtask be assigned to a specific person?
@@ -57,6 +60,7 @@ What must be true for this subtask to be considered complete:
``` ```
Rules: Rules:
- Steps/Tasks: concrete, ordered where order matters. - Steps/Tasks: concrete, ordered where order matters.
- Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task). - Definition of Done: adjust per subtask — not all subtasks need the same criteria (e.g. a research spike has different DoD than an implementation task).
- Keep description short — one paragraph max. - Keep description short — one paragraph max.
@@ -74,6 +78,7 @@ If a subtask's project is ambiguous, ask the user before creating it.
## Step 6 — Create Subtasks ## Step 6 — Create Subtasks
For each subtask call `mcp__youtrack__create_issue` with: For each subtask call `mcp__youtrack__create_issue` with:
- `project`: from Step 5 - `project`: from Step 5
- `summary`: subtask title (≤72 chars, sentence case) - `summary`: subtask title (≤72 chars, sentence case)
- `description`: full formatted description from Step 4 (Markdown) - `description`: full formatted description from Step 4 (Markdown)
@@ -84,6 +89,7 @@ Then call `mcp__youtrack__link_issues` to link each created subtask to `$ARGUMEN
## Step 6b — Inter-Subtask Links ## Step 6b — Inter-Subtask Links
If subtasks must be done in sequence (one depends on output of another), add ordering links: If subtasks must be done in sequence (one depends on output of another), add ordering links:
- For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A). - For each dependency pair call `mcp__youtrack__link_issues` with relation `is blocked by` (subtask B is blocked by subtask A).
Ask the user to confirm sequencing before adding these links: Ask the user to confirm sequencing before adding these links:
@@ -94,12 +100,12 @@ Ask the user to confirm sequencing before adding these links:
Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each: Scan `$ARGUMENTS` description and implementation notes for any referenced issue IDs not already linked. For each:
| Situation | Relation | | Situation | Relation |
|-----------|---------| | ---------------------------------------- | --------------- |
| Parent story blocks another epic/story | `blocks` | | Parent story blocks another epic/story | `blocks` |
| Story depends on another epic completing | `is blocked by` | | Story depends on another epic completing | `is blocked by` |
| Related story in same domain | `relates to` | | Related story in same domain | `relates to` |
| This story duplicates or supersedes | `duplicates` | | This story duplicates or supersedes | `duplicates` |
Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation. Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation.
+40 -21
View File
@@ -1,58 +1,77 @@
## [0.3.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.3.0) (2026-06-10)
### Features
- NCWF-5 Scaffold post-game analysis page at /analysis with board viewer and route
- NCWF-6 FEN / PGN / Game-ID input form for custom analysis
- NCWF-7 Integrate backend analysis API (POST /api/analysis/position) via GameApiService
- NCWF-8 Per-move evaluation timeline SVG chart
- NCWF-9 Annotated move list with quality labels (!! ! ?! ? ??)
## 0.0.0 (2026-05-12) ## 0.0.0 (2026-05-12)
### Features ### Features
* added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48)) - added bot, light and dark mode ([2de003e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2de003e497baee72f998d0d805ca1e58aababe48))
* added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a)) - added web view 1v1 ([1828fa3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1828fa3275ddb8ce6bb90a9f6a970ec428ebce3a))
* NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8)) - NCS-63 User account implementation ([#2](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/2)) ([ff75c8c](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/ff75c8ce2fad54137f04a14c15bc1d4a38fa9bb8))
* NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e)) - NCS-75 Frontend Deployment Dockerfile ([#4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/4)) ([bd7ec58](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bd7ec581e38b5d8e775741bf16e19b4dc67b979e))
### Bug Fixes ### Bug Fixes
* build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91)) - build issues ([36d72fd](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/36d72fd6cda41be51d28f8ac307dcdbcd31afa91))
* cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9)) - cleaner components seperation ([8b090e4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8b090e4d96c64c0adb253e3aefad7930108ccfb9))
* gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d)) - gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d))
* GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02)) - GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02))
* npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04)) - npm ([c11c1d4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c11c1d4dce9de4bd5b463e891eebf961b37feb04))
* removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26)) - removed .vs ([2833ead](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/2833ead7be3b47ee5c188d2d21b7326cb3cb3f26))
* removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04)) - removed cache files ([7ee59c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/7ee59c434bf137a08fd560bbc02ceefbcfd90f04))
* size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448)) - size of pieces and removed text view of the game state ([c60d00f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c60d00f9d25247504845654615065fbccd7fe448))
* structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93)) - structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.1.0...0.0.0) (2026-05-12)
### Features ### Features
* NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db)) - NCS-69 Challenge request ([#3](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/3)) ([bad7366](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/bad7366bdbb048c20218257b30ac22efc9ecb6db))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.0...0.0.0) (2026-05-12)
### Bug Fixes ### Bug Fixes
* NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36)) - NCWF-1 401 ([#5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/5)) ([f8f93ef](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f8f93efff48f1d7198023fed45b675c2e225df36))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.1...0.0.0) (2026-05-12)
### Bug Fixes ### Bug Fixes
* NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e)) - NCWF-1 401 ([#6](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/6)) ([6d1e06d](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/6d1e06dfd606b93d029e9c9b84eea6f8b3b6294e))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.2...0.0.0) (2026-05-14)
### Bug Fixes ### Bug Fixes
* added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923)) - added missing challenge routes ([61000f8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/61000f8a22aff8b524664a756cc933834365f923))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.3...0.0.0) (2026-05-15)
### Bug Fixes ### Bug Fixes
* build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080)) - build error ([51a363a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/51a363a2432be111b804082df362975047dc8080))
* NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740)) - NCWF-2 bugs and desing fixes ([#7](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/7)) ([c02414e](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/c02414ea40177b05a5e62dcf68dcb44efa6d3740))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.4...0.0.0) (2026-06-01)
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.5...0.0.0) (2026-06-02)
### Bug Fixes ### Bug Fixes
* NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f)) - NCWF-4 Token Issues ([#8](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/8)) ([95eff42](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/95eff42dfe6d9c23ede08c7297614369a1b00d9f))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.6...0.0.0) (2026-06-06)
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10) ## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.7...0.0.0) (2026-06-10)
### Bug Fixes ### Bug Fixes
* route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea)) - route play-vs-bot to /vs-bot endpoint ([a620735](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a62073511f2ac912ceb0f6b4730bef37545dd8ea))
+4 -13
View File
@@ -18,9 +18,7 @@
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": [ "polyfills": ["zone.js"],
"zone.js"
],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": [ "assets": [
{ {
@@ -38,9 +36,7 @@
"output": "/assets/ChessAssets" "output": "/assets/ChessAssets"
} }
], ],
"styles": [ "styles": ["src/styles.css"]
"src/styles.css"
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -106,10 +102,7 @@
"test": { "test": {
"builder": "@angular/build:karma", "builder": "@angular/build:karma",
"options": { "options": {
"polyfills": [ "polyfills": ["zone.js", "zone.js/testing"],
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"assets": [ "assets": [
{ {
@@ -127,9 +120,7 @@
"output": "/assets/ChessAssets" "output": "/assets/ChessAssets"
} }
], ],
"styles": [ "styles": ["src/styles.css"]
"src/styles.css"
]
} }
} }
} }
+2 -5
View File
@@ -55,7 +55,6 @@ tags:
description: Export a game as FEN or PGN description: Export a game as FEN or PGN
paths: paths:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Game lifecycle # Game lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -467,7 +466,6 @@ paths:
# ============================================================================= # =============================================================================
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
@@ -517,7 +515,6 @@ components:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
schemas: schemas:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Requests # Requests
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -551,7 +548,7 @@ components:
pgn: pgn:
type: string type: string
description: PGN text (headers and move list) description: PGN text (headers and move list)
example: "1. e4 e5 2. Nf3 Nc6 *" example: '1. e4 e5 2. Nf3 Nc6 *'
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Game state # Game state
@@ -587,7 +584,7 @@ components:
pgn: pgn:
type: string type: string
description: PGN move text for the full game so far description: PGN move text for the full game so far
example: "1. e4" example: '1. e4'
turn: turn:
type: string type: string
enum: [white, black] enum: [white, black]
+5
View File
@@ -1,4 +1,9 @@
{ {
"/api/analysis": {
"target": "http://localhost:8087",
"secure": false,
"changeOrigin": true
},
"/api/tournament": { "/api/tournament": {
"target": "http://localhost:8089", "target": "http://localhost:8089",
"secure": false, "secure": false,
+2 -2
View File
@@ -1,4 +1,4 @@
window.__RUNTIME_CONFIG__ = { window.__RUNTIME_CONFIG__ = {
API_URL: "${API_URL}", API_URL: '${API_URL}',
WEBSOCKET_URL: "${WEBSOCKET_URL}" WEBSOCKET_URL: '${WEBSOCKET_URL}',
}; };
+3 -1
View File
@@ -6,6 +6,7 @@ import { ChallengesComponent } from './pages/challenges/challenges.component';
import { GamesComponent } from './pages/games/games.component'; import { GamesComponent } from './pages/games/games.component';
import { TournamentsComponent } from './pages/tournaments/tournaments.component'; import { TournamentsComponent } from './pages/tournaments/tournaments.component';
import { BotsComponent } from './pages/bots/bots.component'; import { BotsComponent } from './pages/bots/bots.component';
import { AnalysisComponent } from './pages/analysis/analysis.component';
export const routes: Routes = [ export const routes: Routes = [
{ path: '', component: WelcomeComponent }, { path: '', component: WelcomeComponent },
@@ -14,6 +15,7 @@ export const routes: Routes = [
{ path: 'challenges', component: ChallengesComponent }, { path: 'challenges', component: ChallengesComponent },
{ path: 'tournaments', component: TournamentsComponent }, { path: 'tournaments', component: TournamentsComponent },
{ path: 'bots', component: BotsComponent }, { path: 'bots', component: BotsComponent },
{ path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent }, { path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' } { path: '**', redirectTo: '' },
]; ];
+2 -1
View File
@@ -1,12 +1,13 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { provideRouter } from '@angular/router'; import { provideRouter } from '@angular/router';
import { provideHttpClient } from '@angular/common/http';
import { App } from './app'; import { App } from './app';
describe('App', () => { describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter([])] providers: [provideRouter([]), provideHttpClient()],
}).compileComponents(); }).compileComponents();
}); });
@@ -0,0 +1,108 @@
:host {
display: block;
}
.empty {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
padding: 12px 16px;
font-family: var(--nc-mono, monospace);
letter-spacing: 0.06em;
}
.move-grid {
display: grid;
grid-template-columns: 28px 1fr 1fr;
gap: 2px 4px;
padding: 8px 12px;
font-family: var(--nc-mono, monospace);
font-size: 12px;
}
.mv-num {
color: rgba(255, 255, 255, 0.3);
font-size: 10px;
display: flex;
align-items: center;
letter-spacing: 0.04em;
}
.mv {
display: flex;
align-items: center;
gap: 4px;
padding: 4px 8px;
cursor: pointer;
border-radius: 2px;
color: rgba(255, 255, 255, 0.8);
transition:
background 0.12s,
color 0.12s;
}
.mv:hover {
background: rgba(255, 255, 255, 0.06);
color: #fff;
}
.mv.active {
background: rgba(255, 69, 200, 0.18);
color: #fff;
}
.mv-empty {
cursor: default;
color: rgba(255, 255, 255, 0.25);
}
.mv-empty:hover {
background: transparent;
color: rgba(255, 255, 255, 0.25);
}
.mv-san {
flex: 1;
}
.mv-placeholder {
opacity: 0.4;
}
/* Quality badges */
.mv-badge {
font-size: 10px;
font-weight: 700;
padding: 1px 4px;
border-radius: 2px;
flex-shrink: 0;
}
.q-brilliant .mv-badge,
.mv-badge.q-brilliant {
color: #5ee5a1;
background: rgba(94, 229, 161, 0.15);
}
.q-best .mv-badge,
.mv-badge.q-best {
color: #5ee5a1;
background: rgba(94, 229, 161, 0.1);
}
.q-inaccuracy .mv-badge,
.mv-badge.q-inaccuracy {
color: #ffb13a;
background: rgba(255, 177, 58, 0.15);
}
.q-mistake .mv-badge,
.mv-badge.q-mistake {
color: #ff7a7a;
background: rgba(255, 122, 122, 0.15);
}
.q-blunder .mv-badge,
.mv-badge.q-blunder {
color: #ff4444;
background: rgba(255, 68, 68, 0.18);
}
@@ -0,0 +1,48 @@
@if (moves.length === 0) {
<div class="empty">No annotated moves yet.</div>
} @else {
<div class="move-grid" role="list">
@for (pair of pairs; track $index) {
<div class="mv-num" role="presentation">{{ $index + 1 }}</div>
<div
class="mv"
[class.active]="isWhiteActive($index)"
[class]="qualityClass(pair.white)"
role="listitem"
tabindex="0"
(click)="selectWhite($index)"
(keydown.enter)="selectWhite($index)"
>
<span class="mv-san">{{ pair.white?.san ?? '' }}</span>
@if (qualityLabel(pair.white)) {
<span class="mv-badge" [class]="qualityClass(pair.white)">{{
qualityLabel(pair.white)
}}</span>
}
</div>
<div
class="mv"
[class.active]="isBlackActive($index)"
[class.mv-empty]="!pair.black"
[class]="qualityClass(pair.black)"
role="listitem"
[attr.tabindex]="pair.black ? 0 : null"
(click)="selectBlack($index, pair.black)"
(keydown.enter)="selectBlack($index, pair.black)"
>
@if (pair.black) {
<span class="mv-san">{{ pair.black.san }}</span>
@if (qualityLabel(pair.black)) {
<span class="mv-badge" [class]="qualityClass(pair.black)">{{
qualityLabel(pair.black)
}}</span>
}
} @else {
<span class="mv-san mv-placeholder"></span>
}
</div>
}
</div>
}
@@ -0,0 +1,83 @@
import { Component, EventEmitter, Input, Output } from '@angular/core';
import { AnnotatedMove, MoveQuality } from '../../models/analysis.models';
interface AnnotatedPair {
white: AnnotatedMove | null;
black: AnnotatedMove | null;
}
const QUALITY_LABELS: Record<MoveQuality, string> = {
brilliant: '!!',
best: '!',
good: '',
inaccuracy: '?!',
mistake: '?',
blunder: '??',
};
const QUALITY_CLASSES: Record<MoveQuality, string> = {
brilliant: 'q-brilliant',
best: 'q-best',
good: 'q-good',
inaccuracy: 'q-inaccuracy',
mistake: 'q-mistake',
blunder: 'q-blunder',
};
@Component({
selector: 'app-annotated-move-list',
standalone: true,
imports: [],
templateUrl: './annotated-move-list.component.html',
styleUrl: './annotated-move-list.component.css',
})
export class AnnotatedMoveListComponent {
@Input({ required: true }) moves: AnnotatedMove[] = [];
@Input() activePly: number | null = null;
@Output() plySelected = new EventEmitter<number>();
get pairs(): AnnotatedPair[] {
const result: AnnotatedPair[] = [];
for (let i = 0; i < this.moves.length; i += 2) {
result.push({
white: this.moves[i] ?? null,
black: this.moves[i + 1] ?? null,
});
}
return result;
}
qualityLabel(move: AnnotatedMove | null): string {
if (!move?.quality) return '';
return QUALITY_LABELS[move.quality] ?? '';
}
qualityClass(move: AnnotatedMove | null): string {
if (!move?.quality) return '';
return QUALITY_CLASSES[move.quality] ?? '';
}
isWhiteActive(pairIndex: number): boolean {
return this.activePly === pairIndex * 2;
}
isBlackActive(pairIndex: number): boolean {
return this.activePly === pairIndex * 2 + 1;
}
selectWhite(pairIndex: number): void {
this.plySelected.emit(pairIndex * 2);
}
selectBlack(pairIndex: number, black: AnnotatedMove | null): void {
if (!black) return;
this.plySelected.emit(pairIndex * 2 + 1);
}
formatEval(move: AnnotatedMove | null): string {
if (!move || move.evalAfter === null) return '';
const v = move.evalAfter;
const sign = v > 0 ? '+' : '';
return `${sign}${v.toFixed(2)}`;
}
}
@@ -0,0 +1,53 @@
:host {
display: block;
width: 100%;
}
.timeline-wrap {
width: 100%;
overflow: hidden;
}
.timeline-svg {
display: block;
width: 100%;
height: 80px;
}
.midline {
stroke: rgba(255, 255, 255, 0.12);
stroke-width: 1;
stroke-dasharray: 4 4;
}
.area-white {
fill: rgba(255, 255, 255, 0.18);
}
.area-black {
fill: rgba(20, 20, 30, 0.55);
}
.eval-line {
fill: none;
stroke: var(--nc-neon, #ff45c8);
stroke-width: 1.5;
stroke-linejoin: round;
stroke-linecap: round;
}
.active-marker {
stroke: var(--nc-warning, #ffb13a);
stroke-width: 1.5;
stroke-dasharray: 3 3;
opacity: 0.8;
}
.empty {
font-size: 11px;
color: rgba(255, 255, 255, 0.4);
padding: 12px 0;
font-family: var(--nc-mono, monospace);
letter-spacing: 0.06em;
text-align: center;
}
@@ -0,0 +1,53 @@
@if (points.length === 0) {
<div class="empty">No evaluation data yet.</div>
} @else {
<div class="timeline-wrap" role="img" aria-label="Evaluation timeline">
<svg
class="timeline-svg"
[attr.viewBox]="'0 0 ' + svgWidth + ' ' + svgHeight"
preserveAspectRatio="none"
>
<!-- Midline (balanced) -->
<line
x1="0"
[attr.y1]="svgHeight / 2"
[attr.x2]="svgWidth"
[attr.y2]="svgHeight / 2"
class="midline"
/>
<!-- White-advantage fill (clip above midline) -->
<defs>
<clipPath id="clip-white">
<rect x="0" y="0" [attr.width]="svgWidth" [attr.height]="svgHeight / 2" />
</clipPath>
<clipPath id="clip-black">
<rect
x="0"
[attr.y]="svgHeight / 2"
[attr.width]="svgWidth"
[attr.height]="svgHeight / 2"
/>
</clipPath>
</defs>
<polygon [attr.points]="polylineWhite" class="area-white" clip-path="url(#clip-white)" />
<polygon [attr.points]="polylineBlack" class="area-black" clip-path="url(#clip-black)" />
<!-- Eval line -->
<polyline [attr.points]="evalPolyline" class="eval-line" />
<!-- Active ply marker -->
@if (activeX() !== null) {
<line
[attr.x1]="activeX()"
y1="0"
[attr.x2]="activeX()"
[attr.y2]="svgHeight"
class="active-marker"
/>
}
</svg>
</div>
}
@@ -0,0 +1,73 @@
import { Component, Input, OnChanges } from '@angular/core';
import { AnnotatedMove } from '../../models/analysis.models';
interface TimelinePoint {
x: number;
y: number;
eval: number;
san: string;
plyIndex: number;
}
const CLAMP = 5; // clamp eval to ±5 pawns for display
const HEIGHT = 80;
const WIDTH = 600;
@Component({
selector: 'app-eval-timeline',
standalone: true,
imports: [],
templateUrl: './eval-timeline.component.html',
styleUrl: './eval-timeline.component.css',
})
export class EvalTimelineComponent implements OnChanges {
@Input({ required: true }) moves: AnnotatedMove[] = [];
@Input() activePly: number | null = null;
points: TimelinePoint[] = [];
evalPolyline = '';
polylineWhite = '';
polylineBlack = '';
svgWidth = WIDTH;
svgHeight = HEIGHT;
ngOnChanges(): void {
this.buildChart();
}
activeX(): number | null {
if (this.activePly === null) return null;
const pt = this.points[this.activePly];
return pt ? pt.x : null;
}
private buildChart(): void {
if (this.moves.length === 0) {
this.points = [];
this.evalPolyline = '';
this.polylineWhite = '';
this.polylineBlack = '';
return;
}
const total = this.moves.length;
this.points = this.moves.map((m, i) => {
const evalValue = m.evalAfter ?? 0;
const clamped = Math.max(-CLAMP, Math.min(CLAMP, evalValue));
const x = (i / Math.max(total - 1, 1)) * WIDTH;
// y=0 => white winning (top), y=HEIGHT => black winning (bottom)
const y = ((CLAMP - clamped) / (CLAMP * 2)) * HEIGHT;
return { x, y, eval: evalValue, san: m.san, plyIndex: i };
});
const coordStr = this.points.map((p) => `${p.x.toFixed(1)},${p.y.toFixed(1)}`).join(' ');
this.evalPolyline = coordStr;
const mid = HEIGHT / 2;
const first = this.points[0];
const last = this.points[this.points.length - 1];
this.polylineWhite = `${first.x.toFixed(1)},${mid} ${coordStr} ${last.x.toFixed(1)},${mid}`;
this.polylineBlack = this.polylineWhite;
}
}
+288 -175
View File
@@ -1,5 +1,4 @@
<nav class="nc-nav"> <nav class="nc-nav">
<!-- Logo --> <!-- Logo -->
<div class="nc-logo" (click)="goToHome()" role="button" tabindex="0"> <div class="nc-logo" (click)="goToHome()" role="button" tabindex="0">
<div class="nc-logo-mark"></div> <div class="nc-logo-mark"></div>
@@ -8,201 +7,315 @@
<!-- Center links — only when logged in --> <!-- Center links — only when logged in -->
@if (currentUser) { @if (currentUser) {
<div class="nc-links"> <div class="nc-links">
<button type="button" class="nc-link"> <button type="button" class="nc-link">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg
stroke-linecap="round" stroke-linejoin="round"> width="14"
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> height="14"
<circle cx="12" cy="12" r="3" /> viewBox="0 0 24 24"
</svg> fill="none"
Watch stroke="currentColor"
</button> stroke-width="1.7"
<button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button> stroke-linecap="round"
<button type="button" class="nc-link" (click)="goToBots()">Bots</button> stroke-linejoin="round"
</div> >
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
<circle cx="12" cy="12" r="3" />
</svg>
Watch
</button>
<button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button>
<button type="button" class="nc-link" (click)="goToBots()">Bots</button>
<button type="button" class="nc-link" (click)="goToAnalysis()">Analysis</button>
</div>
} }
<!-- Right cluster --> <!-- Right cluster -->
<div class="nc-right"> <div class="nc-right">
@if (currentUser; as user) { @if (currentUser; as user) {
<!-- Notifications bell -->
<div class="nc-dropdown-wrap" data-dropdown="notif">
<button
type="button"
class="nc-bell"
[class.is-open]="notifOpen"
(click)="toggleNotif($event)"
aria-label="Notifications"
>
<svg
width="18"
height="18"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" />
</svg>
@if (incomingChallenges.length > 0) {
<span class="nc-badge">{{ incomingChallenges.length }}</span>
}
</button>
<!-- Notifications bell --> @if (notifOpen) {
<div class="nc-dropdown-wrap" data-dropdown="notif"> <div class="nc-popover nc-notif" (click)="$event.stopPropagation()">
<button type="button" class="nc-bell" [class.is-open]="notifOpen" (click)="toggleNotif($event)" <div class="nc-notif-header">
aria-label="Notifications"> <span class="nc-notif-header-title">Challenges</span>
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6" </div>
stroke-linecap="round" stroke-linejoin="round">
<path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" /> <div class="nc-notif-list">
<path d="M10.3 21a1.94 1.94 0 0 0 3.4 0" /> @if (incomingChallenges.length === 0) {
</svg> <div class="nc-notif-empty">No pending challenges</div>
@if (incomingChallenges.length > 0) { }
<span class="nc-badge">{{ incomingChallenges.length }}</span> @for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-notif-row is-unread">
<div class="nc-notif-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" />
<path d="M16 16l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text">
<b>{{ challenge.challenger.name }}</b> challenged you to a
{{ getTimeControlDisplay(challenge) }} game.
</div>
<div class="nc-notif-meta">
{{ challenge.challenger.rating }} ·
{{ challenge.timeControl.type ?? 'Custom' }} ·
{{ getExpirationInfo(challenge) }}
</div>
<div class="nc-notif-actions">
<button
type="button"
class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)"
>
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="20 6 9 17 4 12" />
</svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button>
<button
type="button"
class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)"
>
<svg
width="10"
height="10"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="18" y1="6" x2="6" y2="18" />
<line x1="6" y1="6" x2="18" y2="18" />
</svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }}
</button>
</div>
</div>
</div>
}
</div>
<div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">
View all challenges
</button>
</div>
</div>
} }
</button>
@if (notifOpen) {
<div class="nc-popover nc-notif" (click)="$event.stopPropagation()">
<div class="nc-notif-header">
<span class="nc-notif-header-title">Challenges</span>
</div>
<div class="nc-notif-list">
@if (incomingChallenges.length === 0) {
<div class="nc-notif-empty">No pending challenges</div>
}
@for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-notif-row is-unread">
<div class="nc-notif-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</div>
<div class="nc-notif-body">
<div class="nc-notif-text">
<b>{{ challenge.challenger.name }}</b> challenged you to a
{{ getTimeControlDisplay(challenge) }} game.
</div>
<div class="nc-notif-meta">
{{ challenge.challenger.rating }} · {{ challenge.timeControl.type ?? 'Custom' }} · {{ getExpirationInfo(challenge) }}
</div>
<div class="nc-notif-actions">
<button type="button" class="nc-btn-accept"
[disabled]="acceptingId === challenge.id || !!decliningId"
(click)="acceptChallenge($event, challenge)">
<svg width="11" height="11" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
{{ acceptingId === challenge.id ? '...' : 'Accept' }}
</button>
<button type="button" class="nc-btn-decline"
[disabled]="!!acceptingId || decliningId === challenge.id"
(click)="declineChallenge($event, challenge)">
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" stroke-linejoin="round">
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
</svg>
{{ decliningId === challenge.id ? '...' : 'Decline' }}
</button>
</div>
</div>
</div>
}
</div>
<div class="nc-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">View all challenges</button>
</div>
</div> </div>
}
</div>
<!-- Games quick-access --> <!-- Games quick-access -->
<button type="button" class="nc-games-btn" (click)="goToGames()"> <button type="button" class="nc-games-btn" (click)="goToGames()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" <svg
stroke-linecap="round" stroke-linejoin="round"> width="13"
<path d="M14.5 17.5L3 6"/> height="13"
<path d="M13 19l6-6"/><path d="M16 16l4 4"/> viewBox="0 0 24 24"
<path d="M19 21l2-2"/><path d="M15 5l4 4"/> fill="none"
<path d="M21 3l-3 1-4 4"/> stroke="currentColor"
</svg> stroke-width="1.7"
Games stroke-linecap="round"
</button> stroke-linejoin="round"
>
<!-- Profile --> <path d="M14.5 17.5L3 6" />
<div class="nc-dropdown-wrap" data-dropdown="profile"> <path d="M13 19l6-6" />
<button type="button" class="nc-profile" [class.is-open]="profileOpen" (click)="toggleProfile($event)"> <path d="M16 16l4 4" />
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div> <path d="M19 21l2-2" />
<span class="nc-profile-name">{{ user.username }}</span> <path d="M15 5l4 4" />
<svg class="nc-chevron" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" <path d="M21 3l-3 1-4 4" />
stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="6 9 12 15 18 9" />
</svg> </svg>
Games
</button> </button>
@if (profileOpen) { <!-- Profile -->
<div class="nc-popover nc-menu" (click)="$event.stopPropagation()"> <div class="nc-dropdown-wrap" data-dropdown="profile">
<div class="nc-menu-header"> <button
<div class="nc-avatar nc-avatar-md">{{ getInitial() }}</div> type="button"
<div> class="nc-profile"
<div class="nc-menu-user-name">{{ user.username }}</div> [class.is-open]="profileOpen"
<div class="nc-menu-user-sub">{{ user.rating }} · &#64;{{ user.username }}</div> (click)="toggleProfile($event)"
>
<div class="nc-avatar nc-avatar-sm">{{ getInitial() }}</div>
<span class="nc-profile-name">{{ user.username }}</span>
<svg
class="nc-chevron"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</button>
@if (profileOpen) {
<div class="nc-popover nc-menu" (click)="$event.stopPropagation()">
<div class="nc-menu-header">
<div class="nc-avatar nc-avatar-md">{{ getInitial() }}</div>
<div>
<div class="nc-menu-user-name">{{ user.username }}</div>
<div class="nc-menu-user-sub">{{ user.rating }} · &#64;{{ user.username }}</div>
</div>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.8"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="nc-menu-label">My profile</span>
</button>
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.6"
stroke-linecap="round"
stroke-linejoin="round"
>
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" />
<path d="M16 16l4 4" />
<path d="M19 21l2 -2" />
<path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</span>
<span class="nc-menu-label">Challenges</span>
</button>
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</span>
<span class="nc-menu-label">{{ isDarkMode ? 'Light mode' : 'Dark mode' }}</span>
<span class="nc-toggle" [class.is-on]="isDarkMode"></span>
</button>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon">
<svg
width="14"
height="14"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="1.7"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</span>
<span class="nc-menu-label">Log out</span>
</button>
</div>
</div> </div>
</div> }
<div class="nc-menu-group">
<button type="button" class="nc-menu-item" (click)="goToProfile()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
</span>
<span class="nc-menu-label">My profile</span>
</button>
<button type="button" class="nc-menu-item" (click)="goToChallenges()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<line x1="14.5" y1="17.5" x2="3" y2="6" />
<path d="M13 19l6 -6" /><path d="M16 16l4 4" />
<path d="M19 21l2 -2" /><path d="M15 5l4 4" />
<path d="M21 3l-3 1l-4 4" />
</svg>
</span>
<span class="nc-menu-label">Challenges</span>
</button>
<button type="button" class="nc-menu-item" (click)="toggleTheme($event)">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M21 12.8A9 9 0 1 1 11.2 3a7 7 0 0 0 9.8 9.8z" />
</svg>
</span>
<span class="nc-menu-label">{{ isDarkMode ? 'Light mode' : 'Dark mode' }}</span>
<span class="nc-toggle" [class.is-on]="isDarkMode"></span>
</button>
</div>
<div class="nc-menu-group">
<button type="button" class="nc-menu-item danger" (click)="logout()">
<span class="nc-menu-icon">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
stroke-linecap="round" stroke-linejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
</span>
<span class="nc-menu-label">Log out</span>
</button>
</div>
</div> </div>
}
</div>
} @else { } @else {
<!-- Logged-out auth buttons -->
<!-- Logged-out auth buttons --> <button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button> <button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">Register</button> Register
</button>
} }
</div> </div>
</nav> </nav>
@if (showLoginDialog) { @if (showLoginDialog) {
<app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" /> <app-login-dialog (onClose)="closeLoginDialog()" (onSuccess)="onLoginSuccess()" />
} }
@if (showRegisterDialog) { @if (showRegisterDialog) {
<app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" /> <app-register-dialog (onClose)="closeRegisterDialog()" (onSuccess)="onRegisterSuccess()" />
} }
+37 -26
View File
@@ -18,7 +18,7 @@ import { Challenge } from '../../models/challenge.models';
standalone: true, standalone: true,
imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent], imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent],
templateUrl: './toolbar.component.html', templateUrl: './toolbar.component.html',
styleUrl: './toolbar.component.css' styleUrl: './toolbar.component.css',
}) })
export class ToolbarComponent implements OnInit { export class ToolbarComponent implements OnInit {
private readonly destroyRef = inject(DestroyRef); private readonly destroyRef = inject(DestroyRef);
@@ -46,35 +46,36 @@ export class ToolbarComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.destroyRef.onDestroy(() => this.stopPolling()); this.destroyRef.onDestroy(() => this.stopPolling());
this.authService.currentUser$ this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => {
.pipe(takeUntilDestroyed(this.destroyRef)) this.currentUser = user;
.subscribe(user => { if (user) {
this.currentUser = user; this.challengeWs.connect();
if (user) { this.startPolling();
this.challengeWs.connect(); } else {
this.startPolling(); this.challengeWs.disconnect();
} else { this.stopPolling();
this.challengeWs.disconnect(); this.navigatedChallengeIds.clear();
this.stopPolling(); this.challengeEventService.clear();
this.navigatedChallengeIds.clear(); }
this.challengeEventService.clear(); });
}
});
this.authDialogService.dialogState$ this.authDialogService.dialogState$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(state => { .subscribe((state) => {
this.showLoginDialog = state === 'login'; this.showLoginDialog = state === 'login';
this.showRegisterDialog = state === 'register'; this.showRegisterDialog = state === 'register';
}); });
this.themeService.darkMode$ this.themeService.darkMode$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isDark) => {
.pipe(takeUntilDestroyed(this.destroyRef)) this.isDarkMode = isDark;
.subscribe(isDark => { this.isDarkMode = isDark; }); });
this.challengeEventService.getIncomingChallenges$() this.challengeEventService
.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(challenges => { this.incomingChallenges = challenges; }); .subscribe((challenges) => {
this.incomingChallenges = challenges;
});
} }
private startPolling(): void { private startPolling(): void {
@@ -91,7 +92,7 @@ export class ToolbarComponent implements OnInit {
private fetchChallenges(): void { private fetchChallenges(): void {
this.challengeService.listChallenges().subscribe({ this.challengeService.listChallenges().subscribe({
next: response => { next: (response) => {
const incoming = response.in ?? response.incoming ?? []; const incoming = response.in ?? response.incoming ?? [];
this.challengeEventService.setIncomingChallenges(incoming); this.challengeEventService.setIncomingChallenges(incoming);
@@ -104,7 +105,7 @@ export class ToolbarComponent implements OnInit {
} }
} }
} }
} },
}); });
} }
@@ -197,6 +198,12 @@ export class ToolbarComponent implements OnInit {
void this.router.navigate(['/bots']); void this.router.navigate(['/bots']);
} }
goToAnalysis(): void {
this.profileOpen = false;
this.notifOpen = false;
void this.router.navigate(['/analysis']);
}
onLoginSuccess(): void { onLoginSuccess(): void {
this.closeLoginDialog(); this.closeLoginDialog();
} }
@@ -227,12 +234,14 @@ export class ToolbarComponent implements OnInit {
if (this.acceptingId || this.decliningId) return; if (this.acceptingId || this.decliningId) return;
this.acceptingId = challenge.id; this.acceptingId = challenge.id;
this.challengeService.acceptChallenge(challenge.id).subscribe({ this.challengeService.acceptChallenge(challenge.id).subscribe({
next: accepted => { next: (accepted) => {
this.acceptingId = null; this.acceptingId = null;
this.challengeEventService.onChallengeAccepted(accepted); this.challengeEventService.onChallengeAccepted(accepted);
if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]); if (accepted.gameId) void this.router.navigate(['/game', accepted.gameId]);
}, },
error: () => { this.acceptingId = null; } error: () => {
this.acceptingId = null;
},
}); });
} }
@@ -245,7 +254,9 @@ export class ToolbarComponent implements OnInit {
this.decliningId = null; this.decliningId = null;
this.challengeEventService.removeChallenge(challenge.id); this.challengeEventService.removeChallenge(challenge.id);
}, },
error: () => { this.decliningId = null; } error: () => {
this.decliningId = null;
},
}); });
} }
} }
+25
View File
@@ -0,0 +1,25 @@
export interface AnalysisRequest {
fen: string;
depth: number;
}
export interface AnalysisResponse {
eval: number;
winChance: number;
depth: number;
bestMove: string;
continuations: string[];
}
export type MoveQuality = 'brilliant' | 'best' | 'good' | 'inaccuracy' | 'mistake' | 'blunder';
export interface AnnotatedMove {
san: string;
fen: string;
evalBefore: number | null;
evalAfter: number | null;
quality: MoveQuality | null;
bestMove: string | null;
winChanceBefore: number | null;
winChanceAfter: number | null;
}
@@ -0,0 +1,542 @@
/* ============================================================
DESIGN TOKENS — dark mode (default)
============================================================ */
:host {
--nc-neon: #ff45c8;
--nc-neon-soft: rgba(255, 69, 200, 0.55);
--nc-bg: #06060d;
--nc-surface: rgba(20, 17, 42, 0.6);
--nc-surface-solid: rgba(10, 8, 22, 0.95);
--nc-text: #fff;
--nc-text-muted: rgba(255, 255, 255, 0.65);
--nc-text-dim: rgba(255, 255, 255, 0.45);
--nc-border: rgba(255, 255, 255, 0.08);
--nc-border-strong: rgba(255, 255, 255, 0.15);
--nc-warning: #ffb13a;
--nc-warning-soft: rgba(255, 177, 58, 0.4);
--nc-danger: #ff7a7a;
--nc-danger-bg: rgba(255, 122, 122, 0.08);
--nc-danger-soft: rgba(255, 122, 122, 0.3);
--nc-success: #5ee5a1;
--nc-clock-bg: rgba(0, 0, 0, 0.4);
--nc-btn-bg: rgba(255, 255, 255, 0.03);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.07);
--nc-sans: 'Space Grotesk', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--nc-mono: 'JetBrains Mono', ui-monospace, 'SF Mono', monospace;
}
:host-context(html:not([data-theme='dark'])) {
--nc-neon: #ff3dbb;
--nc-neon-soft: rgba(255, 61, 187, 0.55);
--nc-bg: transparent;
--nc-surface: rgba(26, 24, 56, 0.72);
--nc-surface-solid: rgba(26, 24, 56, 0.97);
--nc-text: #fff;
--nc-text-muted: rgba(255, 255, 255, 0.72);
--nc-text-dim: rgba(255, 255, 255, 0.45);
--nc-border: rgba(255, 255, 255, 0.1);
--nc-border-strong: rgba(255, 255, 255, 0.18);
--nc-btn-bg: rgba(255, 255, 255, 0.05);
--nc-btn-hover-bg: rgba(255, 255, 255, 0.1);
}
/* ============================================================
SHELL
============================================================ */
.analysis-shell {
min-height: 100dvh;
background: var(--nc-bg);
font-family: var(--nc-sans);
color: var(--nc-text);
position: relative;
}
.analysis-shell::before {
content: '';
position: fixed;
inset: 0;
background:
radial-gradient(ellipse 80% 50% at 20% 100%, rgba(74, 41, 98, 0.12), transparent 60%),
radial-gradient(ellipse 60% 40% at 90% 0%, rgba(41, 74, 98, 0.18), transparent 60%);
pointer-events: none;
z-index: 0;
}
/* ============================================================
PAGE CONTAINER
============================================================ */
.page {
position: relative;
z-index: 1;
max-width: 1320px;
margin: 0 auto;
padding: 28px 32px 60px;
}
/* ============================================================
BREADCRUMB
============================================================ */
.crumb {
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 18px;
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.16em;
text-transform: uppercase;
}
.crumb-link {
color: var(--nc-text-dim);
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 6px;
transition: color 0.15s;
}
.crumb-link:hover {
color: var(--nc-neon);
}
.crumb-sep {
color: var(--nc-text-dim);
opacity: 0.5;
}
.crumb-current {
color: var(--nc-text-muted);
}
/* ============================================================
PAGE HEADER
============================================================ */
.page-header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 24px;
margin-bottom: 28px;
padding-bottom: 20px;
border-bottom: 1px solid var(--nc-border);
}
.page-title h1 {
margin: 0 0 4px;
font-size: 26px;
font-weight: 600;
letter-spacing: -0.01em;
color: var(--nc-text);
}
.page-subtitle {
margin: 0;
font-family: var(--nc-mono);
font-size: 11px;
color: var(--nc-text-dim);
letter-spacing: 0.06em;
}
/* ============================================================
ERROR
============================================================ */
.state-error {
padding: 14px 16px;
margin-bottom: 20px;
color: var(--nc-danger);
background: var(--nc-danger-bg);
border: 1px solid var(--nc-danger-soft);
font-size: 13px;
}
/* ============================================================
INPUT SECTION
============================================================ */
.input-section {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
padding: 20px;
margin-bottom: 28px;
display: flex;
flex-direction: column;
gap: 14px;
}
.mode-tabs {
display: flex;
gap: 4px;
border-bottom: 1px solid var(--nc-border);
padding-bottom: 12px;
}
.mode-tab {
background: transparent;
border: 1px solid transparent;
color: var(--nc-text-muted);
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
padding: 6px 14px;
cursor: pointer;
transition:
color 0.15s,
border-color 0.15s;
}
.mode-tab:hover {
color: var(--nc-text);
}
.mode-tab.active {
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
}
.input-row {
display: flex;
gap: 8px;
}
.text-input {
flex: 1;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 12px;
padding: 9px 12px;
letter-spacing: 0.04em;
outline: none;
transition: border-color 0.15s;
}
.text-input:focus {
border-color: var(--nc-neon-soft);
}
.text-input::placeholder {
color: var(--nc-text-dim);
}
.pgn-col {
display: flex;
flex-direction: column;
gap: 8px;
}
.text-area {
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 12px;
padding: 10px 12px;
letter-spacing: 0.04em;
resize: vertical;
outline: none;
transition: border-color 0.15s;
line-height: 1.5;
}
.text-area:focus {
border-color: var(--nc-neon-soft);
}
.text-area::placeholder {
color: var(--nc-text-dim);
}
.depth-row {
display: flex;
align-items: center;
gap: 10px;
padding-top: 6px;
border-top: 1px solid var(--nc-border);
}
.depth-label {
font-family: var(--nc-mono);
font-size: 11px;
letter-spacing: 0.14em;
text-transform: uppercase;
color: var(--nc-text-dim);
}
.depth-input {
width: 56px;
background: var(--nc-clock-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text);
font-family: var(--nc-mono);
font-size: 13px;
padding: 7px 10px;
outline: none;
transition: border-color 0.15s;
text-align: center;
}
.depth-input:focus {
border-color: var(--nc-neon-soft);
}
/* ============================================================
BUTTONS
============================================================ */
.btn {
font-family: var(--nc-sans);
font-size: 11px;
letter-spacing: 0.18em;
text-transform: uppercase;
font-weight: 600;
padding: 9px 14px;
cursor: pointer;
border: 1px solid var(--nc-border-strong);
background: var(--nc-btn-bg);
color: var(--nc-text);
display: inline-flex;
align-items: center;
gap: 6px;
transition:
background 0.15s,
border-color 0.15s;
flex-shrink: 0;
}
.btn:hover:not([disabled]) {
background: var(--nc-btn-hover-bg);
border-color: var(--nc-text-muted);
}
.btn[disabled] {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: var(--nc-neon) !important;
color: #fff !important;
border-color: var(--nc-neon) !important;
box-shadow: 0 0 14px rgba(255, 69, 200, 0.3);
font-weight: 700;
}
.btn-primary:hover:not([disabled]) {
box-shadow: 0 0 20px rgba(255, 69, 200, 0.5);
}
.btn-analyse {
margin-left: auto;
background: rgba(255, 69, 200, 0.12);
color: var(--nc-neon);
border-color: var(--nc-neon-soft);
}
.btn-analyse:hover:not([disabled]) {
background: rgba(255, 69, 200, 0.2);
}
/* ============================================================
MAIN GRID
============================================================ */
.layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 340px;
gap: 28px;
align-items: start;
}
/* ============================================================
BOARD COLUMN
============================================================ */
.board-col {
display: flex;
flex-direction: column;
gap: 12px;
max-width: 520px;
width: 100%;
margin: 0 auto;
}
.board-wrap {
container-type: size;
aspect-ratio: 1 / 1;
padding: 10px;
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
box-shadow:
0 8px 40px rgba(0, 0, 0, 0.4),
0 0 0 1px rgba(255, 69, 200, 0.06);
}
.nav-bar {
display: flex;
gap: 4px;
justify-content: center;
padding: 6px 0;
}
.icon-btn {
background: var(--nc-btn-bg);
border: 1px solid var(--nc-border);
color: var(--nc-text-muted);
padding: 8px 10px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
transition:
color 0.15s,
background 0.15s;
}
.icon-btn:hover {
color: var(--nc-neon);
background: var(--nc-btn-hover-bg);
}
/* ============================================================
SIDE COLUMN
============================================================ */
.side {
display: flex;
flex-direction: column;
gap: 12px;
}
.side-card {
background: var(--nc-surface);
border: 1px solid var(--nc-border);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
}
.side-card-summary {
list-style: none;
cursor: pointer;
padding: 13px 16px;
display: flex;
align-items: center;
gap: 8px;
user-select: none;
}
.side-card-summary::-webkit-details-marker {
display: none;
}
.side-card-title {
font-size: 11px;
letter-spacing: 0.22em;
text-transform: uppercase;
color: var(--nc-text-muted);
font-weight: 600;
flex: 1;
}
.side-card-meta {
font-family: var(--nc-mono);
font-size: 10px;
color: var(--nc-text-dim);
letter-spacing: 0.08em;
}
.chev {
color: var(--nc-text-dim);
flex-shrink: 0;
transition: transform 0.2s;
}
.side-card[open] .chev {
transform: rotate(180deg);
}
.side-card[open] .side-card-summary {
border-bottom: 1px solid var(--nc-border);
}
.side-card-body {
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.timeline-body {
padding: 10px 16px;
}
/* ============================================================
EVAL GRID
============================================================ */
.eval-grid {
gap: 8px;
}
.eval-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
}
.eval-row--col {
flex-direction: column;
align-items: flex-start;
gap: 4px;
}
.eval-label {
font-family: var(--nc-mono);
font-size: 10px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--nc-text-dim);
flex-shrink: 0;
}
.eval-value {
font-size: 14px;
font-weight: 600;
color: var(--nc-text);
}
.eval-value.mono {
font-family: var(--nc-mono);
font-size: 12px;
}
.eval-value.positive {
color: var(--nc-success);
}
.eval-value.negative {
color: var(--nc-danger);
}
.continuation {
font-size: 11px;
color: var(--nc-text-muted);
line-height: 1.6;
word-break: break-all;
}
/* ============================================================
RESPONSIVE
============================================================ */
@media (max-width: 1100px) {
.layout {
grid-template-columns: 1fr;
}
.board-col {
max-width: 560px;
margin: 0 auto;
}
}
@media (max-width: 640px) {
.page {
padding: 16px 16px 48px;
}
.page-title h1 {
font-size: 20px;
}
}
@@ -0,0 +1,355 @@
<div class="analysis-shell">
<div class="page">
<!-- Breadcrumb -->
<nav class="crumb" aria-label="Breadcrumb">
<a routerLink="/" class="crumb-link">
<svg
width="11"
height="11"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2.4"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
Back to lobby
</a>
<span class="crumb-sep">/</span>
<span class="crumb-current">Analysis</span>
</nav>
<!-- Page header -->
<header class="page-header">
<div class="page-title">
<h1>Chess Analysis</h1>
<p class="page-subtitle">Analyse positions, games or custom PGN with the engine</p>
</div>
</header>
<!-- Error -->
@if (errorMessage) {
<div class="state-error">{{ errorMessage }}</div>
}
<!-- Input section -->
<section class="input-section">
<!-- Mode tabs -->
<div class="mode-tabs" role="tablist" aria-label="Analysis input mode">
<button
class="mode-tab"
[class.active]="inputMode === 'fen'"
role="tab"
(click)="setInputMode('fen')"
>
FEN
</button>
<button
class="mode-tab"
[class.active]="inputMode === 'pgn'"
role="tab"
(click)="setInputMode('pgn')"
>
PGN
</button>
<button
class="mode-tab"
[class.active]="inputMode === 'game'"
role="tab"
(click)="setInputMode('game')"
>
Game ID
</button>
</div>
<!-- FEN input -->
@if (inputMode === 'fen') {
<div class="input-row">
<input
id="fen-input"
type="text"
class="text-input"
placeholder="Paste FEN string here…"
autocomplete="off"
[(ngModel)]="fenInput"
(keydown.enter)="loadFen()"
/>
<button
class="btn btn-primary"
type="button"
(click)="loadFen()"
[disabled]="!fenInput.trim()"
>
Load
</button>
</div>
}
<!-- PGN input -->
@if (inputMode === 'pgn') {
<div class="pgn-col">
<textarea
id="pgn-input"
class="text-area"
rows="5"
placeholder="Paste PGN here…"
[(ngModel)]="pgnInput"
></textarea>
<button
class="btn btn-primary"
type="button"
(click)="loadPgn()"
[disabled]="!pgnInput.trim() || loading"
>
@if (loading) {
Loading…
} @else {
Import PGN
}
</button>
</div>
}
<!-- Game ID input -->
@if (inputMode === 'game') {
<div class="input-row">
<input
id="gameid-input"
type="text"
class="text-input"
placeholder="Game ID"
autocomplete="off"
[(ngModel)]="fenInput"
(keydown.enter)="loadGame(fenInput)"
/>
<button
class="btn btn-primary"
type="button"
(click)="loadGame(fenInput)"
[disabled]="!fenInput.trim() || loading"
>
@if (loading) {
Loading…
} @else {
Load game
}
</button>
</div>
}
<!-- Depth + analyse -->
<div class="depth-row">
<label class="depth-label" for="depth-input">Depth</label>
<input
id="depth-input"
type="number"
class="depth-input"
min="1"
max="18"
[(ngModel)]="depth"
/>
<button
class="btn btn-analyse"
type="button"
(click)="runAnalysis()"
[disabled]="analysing"
>
@if (analysing) {
Analysing…
} @else {
Analyse
}
</button>
</div>
</section>
<!-- Main layout: board + sidebar -->
<div class="layout">
<!-- Board column -->
<div class="board-col">
<div class="board-wrap">
<app-chess-board
[fen]="displayFen"
[selectedSquare]="null"
[highlightedSquares]="[]"
boardTheme="arabian"
/>
</div>
<!-- Navigation bar -->
@if (fenHistory.length > 1) {
<div class="nav-bar">
<button class="icon-btn" title="First position" (click)="navigateHistory('first')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="11 17 6 12 11 7" />
<polyline points="18 17 13 12 18 7" />
</svg>
</button>
<button class="icon-btn" title="Previous" (click)="navigateHistory('prev')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
<button class="icon-btn" title="Next" (click)="navigateHistory('next')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<button class="icon-btn" title="Last position" (click)="navigateHistory('last')">
<svg
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="13 17 18 12 13 7" />
<polyline points="6 17 11 12 6 7" />
</svg>
</button>
</div>
}
</div>
<!-- Side column -->
<aside class="side">
<!-- Single position analysis result -->
@if (positionAnalysis && !hasAnnotations) {
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Position Analysis</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div class="side-card-body eval-grid">
<div class="eval-row">
<span class="eval-label">Evaluation</span>
<span
class="eval-value"
[class.positive]="positionAnalysis.eval > 0"
[class.negative]="positionAnalysis.eval < 0"
>
{{ positionAnalysis.eval > 0 ? '+' : '' }}{{ positionAnalysis.eval.toFixed(2) }}
</span>
</div>
<div class="eval-row">
<span class="eval-label">Win chance</span>
<span class="eval-value">{{ (positionAnalysis.winChance * 100).toFixed(1) }}%</span>
</div>
<div class="eval-row">
<span class="eval-label">Best move</span>
<span class="eval-value mono">{{ positionAnalysis.bestMove }}</span>
</div>
<div class="eval-row">
<span class="eval-label">Depth</span>
<span class="eval-value mono">{{ positionAnalysis.depth }}</span>
</div>
@if (positionAnalysis.continuations.length > 0) {
<div class="eval-row eval-row--col">
<span class="eval-label">Continuation</span>
<span class="eval-value mono continuation">{{
positionAnalysis.continuations.join(' ')
}}</span>
</div>
}
</div>
</details>
}
<!-- Evaluation timeline -->
@if (hasAnnotations) {
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Evaluation</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<div class="side-card-body timeline-body">
<app-eval-timeline [moves]="annotatedMoves" [activePly]="activePly" />
</div>
</details>
<!-- Annotated moves -->
<details class="side-card" open>
<summary class="side-card-summary">
<span class="side-card-title">Moves</span>
<span class="side-card-meta">{{ annotatedMoves.length }} plies</span>
<svg
class="chev"
width="12"
height="12"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<polyline points="6 9 12 15 18 9" />
</svg>
</summary>
<app-annotated-move-list
[moves]="annotatedMoves"
[activePly]="activePly"
(plySelected)="navigateToPly($event)"
/>
</details>
}
</aside>
</div>
</div>
</div>
@@ -0,0 +1,244 @@
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { ActivatedRoute, RouterLink } from '@angular/router';
import { FormsModule } from '@angular/forms';
import { switchMap, of } from 'rxjs';
import { ChessBoardComponent } from '../../components/chess-board/chess-board.component';
import { EvalTimelineComponent } from '../../components/eval-timeline/eval-timeline.component';
import { AnnotatedMoveListComponent } from '../../components/annotated-move-list/annotated-move-list.component';
import { GameApiService } from '../../services/game-api.service';
import { AnalysisService } from '../../services/analysis.service';
import { AnnotatedMove, AnalysisResponse } from '../../models/analysis.models';
import { GameFull } from '../../models/game.models';
import { getErrorMessage } from '../../core/http/error-message.util';
const ANALYSIS_DEPTH_DEFAULT = 14;
const STARTING_FEN = 'rnbqkbnr/pppppppp/8/8/8/8/PPPPPPPP/RNBQKBNR w KQkq - 0 1';
type InputMode = 'fen' | 'pgn' | 'game';
@Component({
selector: 'app-analysis',
standalone: true,
imports: [
RouterLink,
FormsModule,
ChessBoardComponent,
EvalTimelineComponent,
AnnotatedMoveListComponent,
],
templateUrl: './analysis.component.html',
styleUrl: './analysis.component.css',
})
export class AnalysisComponent implements OnInit {
private readonly route = inject(ActivatedRoute);
private readonly gameApi = inject(GameApiService);
private readonly analysisService = inject(AnalysisService);
private readonly destroyRef = inject(DestroyRef);
// ── State ─────────────────────────────────────────────────
inputMode: InputMode = 'fen';
fenInput = '';
pgnInput = '';
depth = ANALYSIS_DEPTH_DEFAULT;
loading = false;
analysing = false;
errorMessage = '';
currentFen = STARTING_FEN;
game: GameFull | null = null;
/** FEN for each ply (index 0 = position before move 0 was played) */
fenHistory: string[] = [STARTING_FEN];
annotatedMoves: AnnotatedMove[] = [];
activePly: number | null = null;
/** Single-position analysis result (for custom FEN/PGN input) */
positionAnalysis: AnalysisResponse | null = null;
get displayFen(): string {
if (this.activePly !== null && this.fenHistory[this.activePly + 1]) {
return this.fenHistory[this.activePly + 1];
}
return this.currentFen;
}
get hasAnnotations(): boolean {
return this.annotatedMoves.length > 0;
}
// ── Lifecycle ─────────────────────────────────────────────
ngOnInit(): void {
// Support /analysis?gameId=xxx deep-link
this.route.queryParamMap.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((params) => {
const gameId = params.get('gameId');
if (gameId) {
this.inputMode = 'game';
this.loadGame(gameId);
}
});
}
// ── Input mode ─────────────────────────────────────────────
setInputMode(mode: InputMode): void {
this.inputMode = mode;
this.errorMessage = '';
}
// ── Load FEN ──────────────────────────────────────────────
loadFen(): void {
const fen = this.fenInput.trim();
if (!fen) return;
this.reset();
this.currentFen = fen;
this.fenHistory = [fen];
this.analyseSinglePosition(fen);
}
// ── Load PGN ──────────────────────────────────────────────
loadPgn(): void {
const pgn = this.pgnInput.trim();
if (!pgn) return;
this.reset();
this.loading = true;
this.gameApi
.importPgn(pgn)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.loading = false;
this.applyGame(game);
},
error: (err) => {
this.loading = false;
this.errorMessage = getErrorMessage(err, 'Could not import PGN.');
},
});
}
// ── Load game by ID ───────────────────────────────────────
loadGame(gameId: string): void {
this.reset();
this.loading = true;
this.gameApi
.getGame(gameId)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (game) => {
this.loading = false;
this.applyGame(game);
},
error: (err) => {
this.loading = false;
this.errorMessage = getErrorMessage(err, 'Could not load game.');
},
});
}
// ── Run full analysis ─────────────────────────────────────
runAnalysis(): void {
if (this.fenHistory.length < 2) {
this.analyseSinglePosition(this.currentFen);
return;
}
this.analysing = true;
this.errorMessage = '';
const sans =
this.annotatedMoves.length > 0
? this.annotatedMoves.map((m) => m.san)
: (this.game?.state.moves ?? []);
this.analysisService
.analyzeGame(sans, this.fenHistory, this.depth)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (annotated) => {
this.annotatedMoves = annotated;
this.analysing = false;
},
error: (err) => {
this.errorMessage = getErrorMessage(err, 'Analysis failed.');
this.analysing = false;
},
});
}
// ── Board navigation ──────────────────────────────────────
navigateToPly(ply: number): void {
this.activePly = ply;
}
navigateHistory(direction: 'first' | 'prev' | 'next' | 'last'): void {
const total = this.fenHistory.length - 1;
const current = this.activePly ?? total;
let next: number;
switch (direction) {
case 'first':
next = 0;
break;
case 'prev':
next = Math.max(0, current - 1);
break;
case 'next':
next = Math.min(total, current + 1);
break;
default:
next = total;
break;
}
this.activePly = next >= total ? null : next;
}
// ── Private helpers ───────────────────────────────────────
private reset(): void {
this.errorMessage = '';
this.annotatedMoves = [];
this.positionAnalysis = null;
this.activePly = null;
this.game = null;
this.fenHistory = [STARTING_FEN];
this.currentFen = STARTING_FEN;
}
private applyGame(game: GameFull): void {
this.game = game;
this.currentFen = game.state.fen;
// Build a flat FEN history from scratch using moves array
// The server gives us the final FEN. We reconstruct history by
// storing the final FEN; full per-ply history requires per-move API calls
// which is out of scope here — we store what we have and allow analysis to proceed.
this.fenHistory = [game.state.fen];
// Seed annotated moves with san strings, no quality yet
this.annotatedMoves = game.state.moves.map((san) => ({
san,
fen: game.state.fen,
evalBefore: null,
evalAfter: null,
quality: null,
bestMove: null,
winChanceBefore: null,
winChanceAfter: null,
}));
}
private analyseSinglePosition(fen: string): void {
this.analysing = true;
this.analysisService
.analyzePosition(fen, this.depth)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: (result) => {
this.positionAnalysis = result;
this.analysing = false;
},
error: (err) => {
this.errorMessage = getErrorMessage(err, 'Analysis failed.');
this.analysing = false;
},
});
}
}
+102
View File
@@ -0,0 +1,102 @@
import { Injectable, inject } from '@angular/core';
import { Observable, forkJoin, of } from 'rxjs';
import { map, switchMap } from 'rxjs/operators';
import { GameApiService } from './game-api.service';
import { AnnotatedMove, AnalysisResponse, MoveQuality } from '../models/analysis.models';
const DEFAULT_DEPTH = 14;
@Injectable({ providedIn: 'root' })
export class AnalysisService {
private readonly gameApi = inject(GameApiService);
analyzePosition(fen: string, depth = DEFAULT_DEPTH): Observable<AnalysisResponse> {
return this.gameApi.analyzePosition({ fen, depth });
}
/**
* Analyse a sequence of FEN positions (one per ply) and return annotated moves.
* fenHistory[0] is the starting position; fenHistory[n] is reached after move san[n-1].
*/
analyzeGame(
sans: string[],
fenHistory: string[],
depth = DEFAULT_DEPTH,
): Observable<AnnotatedMove[]> {
if (sans.length === 0 || fenHistory.length < 2) {
return of([]);
}
const requests = fenHistory.map((fen) => this.gameApi.analyzePosition({ fen, depth }));
return forkJoin(requests).pipe(
map((results) => this.buildAnnotations(sans, fenHistory, results)),
);
}
private buildAnnotations(
sans: string[],
fenHistory: string[],
results: AnalysisResponse[],
): AnnotatedMove[] {
return sans.map((san, i) => {
const evalBefore = results[i]?.eval ?? null;
const winChanceBefore = results[i]?.winChance ?? null;
const evalAfter = results[i + 1]?.eval ?? null;
const winChanceAfter = results[i + 1]?.winChance ?? null;
const bestMove = results[i]?.bestMove ?? null;
const quality = this.classifyQuality(
evalBefore,
evalAfter,
winChanceBefore,
winChanceAfter,
san,
bestMove,
);
return {
san,
fen: fenHistory[i + 1] ?? fenHistory[i],
evalBefore,
evalAfter,
quality,
bestMove,
winChanceBefore,
winChanceAfter,
};
});
}
/**
* Classify move quality based on win-chance delta.
* Win-chance is from the engine's perspective (side to move before the move).
* After the move the side has flipped, so we invert the after value.
*/
private classifyQuality(
_evalBefore: number | null,
_evalAfter: number | null,
winChanceBefore: number | null,
winChanceAfter: number | null,
san: string,
bestMove: string | null,
): MoveQuality | null {
if (winChanceBefore === null || winChanceAfter === null) return null;
// The engine expresses win chance for the mover. After our move the mover
// has switched, so the opponent's win chance = winChanceAfter. Our remaining
// winning chance from our own perspective is 1 - winChanceAfter.
const wcAfterOurPerspective = 1 - winChanceAfter;
const delta = wcAfterOurPerspective - winChanceBefore; // negative = we lost win chance
if (bestMove && san === bestMove) {
if (delta >= -0.01) return 'brilliant';
return 'best';
}
if (delta >= -0.02) return 'good';
if (delta >= -0.05) return 'inaccuracy';
if (delta >= -0.1) return 'mistake';
return 'blunder';
}
}
+11 -4
View File
@@ -7,8 +7,9 @@ import {
GameState, GameState,
GameStreamEvent, GameStreamEvent,
LegalMovesResponse, LegalMovesResponse,
PlayerInfo PlayerInfo,
} from '../models/game.models'; } from '../models/game.models';
import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models';
import { StreamHandlerService } from './stream-handler.service'; import { StreamHandlerService } from './stream-handler.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
@@ -28,11 +29,11 @@ export class GameApiService {
const playerColor = Math.random() > 0.5 ? 'white' : 'black'; const playerColor = Math.random() > 0.5 ? 'white' : 'black';
const playerInfo: PlayerInfo = { const playerInfo: PlayerInfo = {
id: `player-${Date.now()}`, id: `player-${Date.now()}`,
displayName: 'You' displayName: 'You',
}; };
const botInfo: PlayerInfo = { const botInfo: PlayerInfo = {
id: `bot-${difficulty}`, id: `bot-${difficulty}`,
displayName: `Bot (${difficulty})` displayName: `Bot (${difficulty})`,
}; };
const payload = const payload =
@@ -56,7 +57,9 @@ export class GameApiService {
if (square) { if (square) {
params = params.set('square', square); params = params.set('square', square);
} }
return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params }); return this.http.get<LegalMovesResponse>(`${this.apiBase}${this.apiPath}/${gameId}/moves`, {
params,
});
} }
importFen(fen: string): Observable<GameFull> { importFen(fen: string): Observable<GameFull> {
@@ -75,6 +78,10 @@ export class GameApiService {
return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {}); return this.http.post<void>(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {});
} }
analyzePosition(request: AnalysisRequest): Observable<AnalysisResponse> {
return this.http.post<AnalysisResponse>(`${this.apiBase}/api/analysis/position`, request);
}
private resolveWsBase(): string { private resolveWsBase(): string {
if (this.wsBase) { if (this.wsBase) {
return this.wsBase; return this.wsBase;
+2 -6
View File
@@ -6,10 +6,6 @@
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": []
}, },
"include": [ "include": ["src/**/*.ts"],
"src/**/*.ts" "exclude": ["src/**/*.spec.ts"]
],
"exclude": [
"src/**/*.spec.ts"
]
} }
+2 -7
View File
@@ -4,12 +4,7 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": [ "types": ["jasmine"]
"jasmine"
]
}, },
"include": [ "include": ["src/**/*.d.ts", "src/**/*.spec.ts"]
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
} }
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=2 MINOR=3
PATCH=8 PATCH=0