diff --git a/.claude/commands/create-defect.md b/.claude/commands/create-defect.md index 82e19e9..935c7a5 100644 --- a/.claude/commands/create-defect.md +++ b/.claude/commands/create-defect.md @@ -19,6 +19,7 @@ If `$ARGUMENTS` already answers some of these, skip those questions. ## Step 2 — Research (if needed) 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}`. - Check `.spec.ts` files for existing coverage of the broken area. - Do NOT guess at root cause. Surface findings before drafting. @@ -56,6 +57,7 @@ Environment / Notes ``` Rules: + - Steps must be minimal and reproducible. - Expected vs actual: concrete and unambiguous. - Omit "Environment / Notes" section if not relevant. @@ -64,6 +66,7 @@ Rules: Show the draft to the user. **Use `AskUserQuestion` tool to ask:** + - Are steps to reproduce complete and accurate? - Severity: Blocker / Critical / Major / Minor / Trivial? - Any related tickets or recent changes to link? @@ -73,6 +76,7 @@ Incorporate feedback. Repeat until user approves. ## Step 5 — Determine Project > **Project routing rules (always apply these):** +> > - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` @@ -86,6 +90,7 @@ If ambiguous, ask the user. ## Step 6 — Create Issue Call `mcp__youtrack__create_issue` with: + - `project`: determined in Step 5 - `summary`: concise title describing what is broken (≤72 chars, sentence case) - `description`: full formatted defect report from Step 3 (Markdown) diff --git a/.claude/commands/create-story.md b/.claude/commands/create-story.md index 25edb03..8ede476 100644 --- a/.claude/commands/create-story.md +++ b/.claude/commands/create-story.md @@ -19,6 +19,7 @@ If `$ARGUMENTS` already answers some of these, skip those questions. ## Step 2 — Research (if needed) If the topic involves unfamiliar UI flows, component structure, or technical constraints: + - Search the repo for relevant code under `src/app/` (use `Grep`/`Bash`). - Use `WebSearch` if the topic involves Angular APIs, external standards or protocols. - Do NOT guess. Surface findings before drafting. @@ -53,6 +54,7 @@ Implementation Notes ``` Rules: + - User story line: plain English, present tense, from user's perspective. - Acceptance criteria: testable, unambiguous, one condition each. - 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. **Use `AskUserQuestion` tool to ask:** + - Are the acceptance criteria complete and correct? - Any implementation constraints to add? - Priority (if known)? @@ -70,6 +73,7 @@ Incorporate feedback. Repeat until user approves. ## Step 5 — Determine Project > **Project routing rules (always apply these):** +> > - Frontend code (UI, UX, web app, components, services) → `NCWF` > - Backend code (game engine, bots, API, services, coordinator) → `NCS` > - Infrastructure (Kubernetes, pipelines, CI/CD, DB setup, cloud infra) → `NCI` @@ -83,6 +87,7 @@ If still ambiguous, ask the user. ## Step 6 — Create Issue Call `mcp__youtrack__create_issue` with: + - `project`: determined in Step 5 - `summary`: concise title derived from the "I want to" clause (≤72 chars, sentence case) - `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`: -| Situation | Relation to use | -|-----------|----------------| -| This story must be done before another | `blocks` | -| Another story must be done before this | `is blocked by` | -| Stories share domain or are related | `relates to` | -| This is a child of an epic/story | `subtask of` | -| This is a parent grouping subtasks | `parent for` | -| This depends on another ticket's output | `depends on` | +| Situation | Relation to use | +| --------------------------------------- | --------------- | +| This story must be done before another | `blocks` | +| Another story must be done before this | `is blocked by` | +| Stories share domain or are related | `relates to` | +| This is a child of an epic/story | `subtask of` | +| This is a parent grouping subtasks | `parent for` | +| 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. diff --git a/.claude/commands/estimate-issues.md b/.claude/commands/estimate-issues.md index cc8ed37..153f696 100644 --- a/.claude/commands/estimate-issues.md +++ b/.claude/commands/estimate-issues.md @@ -7,10 +7,12 @@ This is the **NowChess-Frontend** repo. Sprint mode defaults to project `NCWF`. ## Step 1 — Determine Scope **Single-issue mode** (`$ARGUMENTS` set): + - Call `mcp__youtrack__get_issue` on `$ARGUMENTS`. - Proceed with that issue only. **Sprint mode** (`$ARGUMENTS` empty): + - 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}`. - 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 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. 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. @@ -29,17 +32,18 @@ For each top-level issue from Step 1: ## Step 3 — Estimate Leaf Nodes For each leaf node: + 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. 3. Assign estimate using this scale: -| Size | Criteria | Estimate | -|------|----------|----------| -| Trivial | Style tweak, copy change, 1-file tweak | 30m | -| Small | 1–3 files, single component/service, no unknowns | 1h–2h | -| Medium | 3–6 files, new component + service wiring, some design | 3h–5h | -| Large | 6+ files, cross-feature, non-trivial state/routing | 1d–2d | -| XL | New feature area, major refactor, research spike | 3d–5d | +| Size | Criteria | Estimate | +| ------- | ------------------------------------------------------ | -------- | +| Trivial | Style tweak, copy change, 1-file tweak | 30m | +| Small | 1–3 files, single component/service, no unknowns | 1h–2h | +| Medium | 3–6 files, new component + service wiring, some design | 3h–5h | +| Large | 6+ files, cross-feature, non-trivial state/routing | 1d–2d | +| XL | New feature area, major refactor, research spike | 3d–5d | 4. Record: estimate + one-line reasoning. 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**. Compute display-only rolled-up totals: + - Parent total = sum of all descendant leaf estimates (including pre-estimated ones). - 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. If sprint mode, show grand total at bottom: + ``` Sprint total: Xd Yh Zm (N issues, M leaves to update) ``` **Use `AskUserQuestion` tool:** + - Does the breakdown look right? - Any estimates to adjust before writing to YouTrack? @@ -81,6 +88,7 @@ Incorporate all feedback before proceeding. ## Step 6 — Write Estimates 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. - YouTrack period format: `"30m"`, `"1h 30m"`, `"1d"`, `"2d 4h"`. - Skip leaves already pre-estimated. diff --git a/.claude/commands/fix-defect.md b/.claude/commands/fix-defect.md index e46441c..71aee61 100644 --- a/.claude/commands/fix-defect.md +++ b/.claude/commands/fix-defect.md @@ -3,6 +3,7 @@ Automated defect-fix workflow. Ticket ID: `$ARGUMENTS` This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). Gates: + - Build: `npm run build` - Test: `npm test -- --watch=false --browsers=ChromeHeadless` - 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 Derive branch name from ticket: + - `type` from YouTrack issue type: `bug` → `fix`, `feature`/`task` → `feat`, `refactor` → `refactor`, else `chore` - `scope` from affected area (kebab-case, omit if unclear) - `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. 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"). 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. @@ -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. - 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.** -If any step fails, iterate until all pass. + If any step fails, iterate until all pass. ## Step 5 — Review @@ -76,6 +79,7 @@ Display findings grouped by severity. ## Step 5b — Apply Review Findings If the review produced any findings (any severity): + 1. Implement all agreed fixes. 2. Run `npm run build` — 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: -| Situation | Relation | -|-----------|---------| -| This fix blocks another open ticket | `blocks` | -| Another ticket must ship first | `is blocked by` | -| Related defect or story | `relates to` | -| Duplicate of another defect | `duplicates` | +| Situation | Relation | +| ----------------------------------- | --------------- | +| This fix blocks another open ticket | `blocks` | +| Another ticket must ship first | `is blocked by` | +| Related defect or story | `relates to` | +| 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. diff --git a/.claude/commands/implement-feature.md b/.claude/commands/implement-feature.md index fa2edb1..fb02ce1 100644 --- a/.claude/commands/implement-feature.md +++ b/.claude/commands/implement-feature.md @@ -4,6 +4,7 @@ Automated feature-implementation workflow. Ticket ID: `$ARGUMENTS` This is the **NowChess-Frontend** repo (Angular 20 / TypeScript). In-project = `NCWF`. Gates: + - Build: `npm run build` - Test: `npm test -- --watch=false --browsers=ChromeHeadless` - 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 Derive branch name from the root ticket `$ARGUMENTS`: + - `type` from YouTrack issue type: `feature`/`task` → `feat`, `refactor` → `refactor`, `bug` → `fix`, else `chore` - `scope` from affected area (kebab-case, omit if unclear) - `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 If the review produced any findings (any severity): + 1. Implement all agreed fixes. 2. Run `npm run build` — 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: ### Blocked in-project tasks + 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.) ### Cross-project tasks (NCS / NCI / other) + For every out-of-project task discovered in the tree (whether it was a subtask or a blocker), output one entry: diff --git a/.claude/commands/split-story.md b/.claude/commands/split-story.md index 40bcf1e..1ebe9b1 100644 --- a/.claude/commands/split-story.md +++ b/.claude/commands/split-story.md @@ -12,6 +12,7 @@ Extract and display: summary, description, acceptance criteria, implementation n ## Step 2 — Research (if needed) If the story involves unfamiliar UI flows or technical constraints: + - Search repo for relevant code under `src/app/` (`Grep`/`Bash`). - Use `WebSearch` for Angular APIs, external standards or protocols. - 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 Analyse the story and propose a set of subtasks. Rules: + - Each subtask = one unit of work, completable independently or in sequence. - No subtask should exceed ~2 days of work. - 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: **Use `AskUserQuestion` tool:** + - Does the split look right? - Any subtasks to add, remove, or merge? - 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: + - 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). - 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 For each subtask call `mcp__youtrack__create_issue` with: + - `project`: from Step 5 - `summary`: subtask title (≤72 chars, sentence case) - `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 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). 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: -| Situation | Relation | -|-----------|---------| -| Parent story blocks another epic/story | `blocks` | +| Situation | Relation | +| ---------------------------------------- | --------------- | +| Parent story blocks another epic/story | `blocks` | | Story depends on another epic completing | `is blocked by` | -| Related story in same domain | `relates to` | -| This story duplicates or supersedes | `duplicates` | +| Related story in same domain | `relates to` | +| This story duplicates or supersedes | `duplicates` | Suggest links to the user and call `mcp__youtrack__link_issues` on confirmation. diff --git a/CHANGELOG.md b/CHANGELOG.md index 643029b..ca63010 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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) ### Features -* 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)) -* 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)) +- 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)) +- 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)) ### Bug Fixes -* 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)) -* gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d)) -* GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02)) -* 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 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)) -* structure ([3e8c7c4](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/3e8c7c4057e55aeec7cee8c24f6751ff24912c93)) +- 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)) +- gitignore ([4da882f](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/4da882f481ba7a008aac868fb37de7cb2bafea5d)) +- GITIGNORE ([8df2d05](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/8df2d0550ab17c9afb2d19c414eac700a75add02)) +- 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 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)) +- 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) ### 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) ### 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) ### 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) ### 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) ### Bug Fixes -* 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)) +- 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)) + ## [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) ### 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.7...0.0.0) (2026-06-10) ### 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)) diff --git a/angular.json b/angular.json index 53f40a6..d6c8d66 100644 --- a/angular.json +++ b/angular.json @@ -18,9 +18,7 @@ "builder": "@angular/build:application", "options": { "browser": "src/main.ts", - "polyfills": [ - "zone.js" - ], + "polyfills": ["zone.js"], "tsConfig": "tsconfig.app.json", "assets": [ { @@ -38,9 +36,7 @@ "output": "/assets/ChessAssets" } ], - "styles": [ - "src/styles.css" - ] + "styles": ["src/styles.css"] }, "configurations": { "production": { @@ -106,10 +102,7 @@ "test": { "builder": "@angular/build:karma", "options": { - "polyfills": [ - "zone.js", - "zone.js/testing" - ], + "polyfills": ["zone.js", "zone.js/testing"], "tsConfig": "tsconfig.spec.json", "assets": [ { @@ -127,9 +120,7 @@ "output": "/assets/ChessAssets" } ], - "styles": [ - "src/styles.css" - ] + "styles": ["src/styles.css"] } } } diff --git a/docs/board-api-spec.yaml b/docs/board-api-spec.yaml index 61bf241..b6947ef 100644 --- a/docs/board-api-spec.yaml +++ b/docs/board-api-spec.yaml @@ -55,7 +55,6 @@ tags: description: Export a game as FEN or PGN paths: - # --------------------------------------------------------------------------- # Game lifecycle # --------------------------------------------------------------------------- @@ -467,7 +466,6 @@ paths: # ============================================================================= components: - securitySchemes: bearerAuth: type: http @@ -517,7 +515,6 @@ components: $ref: '#/components/schemas/ApiError' schemas: - # ------------------------------------------------------------------------- # Requests # ------------------------------------------------------------------------- @@ -551,7 +548,7 @@ components: pgn: type: string description: PGN text (headers and move list) - example: "1. e4 e5 2. Nf3 Nc6 *" + example: '1. e4 e5 2. Nf3 Nc6 *' # ------------------------------------------------------------------------- # Game state @@ -587,7 +584,7 @@ components: pgn: type: string description: PGN move text for the full game so far - example: "1. e4" + example: '1. e4' turn: type: string enum: [white, black] diff --git a/proxy.conf.json b/proxy.conf.json index f243619..e65b255 100644 --- a/proxy.conf.json +++ b/proxy.conf.json @@ -1,4 +1,9 @@ { + "/api/analysis": { + "target": "http://localhost:8087", + "secure": false, + "changeOrigin": true + }, "/api/tournament": { "target": "http://localhost:8089", "secure": false, diff --git a/public/env.template.js b/public/env.template.js index 72477ac..165a040 100644 --- a/public/env.template.js +++ b/public/env.template.js @@ -1,4 +1,4 @@ window.__RUNTIME_CONFIG__ = { - API_URL: "${API_URL}", - WEBSOCKET_URL: "${WEBSOCKET_URL}" + API_URL: '${API_URL}', + WEBSOCKET_URL: '${WEBSOCKET_URL}', }; diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 54f7663..63b01eb 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -6,6 +6,7 @@ import { ChallengesComponent } from './pages/challenges/challenges.component'; import { GamesComponent } from './pages/games/games.component'; import { TournamentsComponent } from './pages/tournaments/tournaments.component'; import { BotsComponent } from './pages/bots/bots.component'; +import { AnalysisComponent } from './pages/analysis/analysis.component'; export const routes: Routes = [ { path: '', component: WelcomeComponent }, @@ -14,6 +15,7 @@ export const routes: Routes = [ { path: 'challenges', component: ChallengesComponent }, { path: 'tournaments', component: TournamentsComponent }, { path: 'bots', component: BotsComponent }, + { path: 'analysis', component: AnalysisComponent }, { path: 'game/:gameId', component: GameComponent }, - { path: '**', redirectTo: '' } + { path: '**', redirectTo: '' }, ]; diff --git a/src/app/app.spec.ts b/src/app/app.spec.ts index 2f546f6..91e7669 100644 --- a/src/app/app.spec.ts +++ b/src/app/app.spec.ts @@ -1,12 +1,13 @@ import { TestBed } from '@angular/core/testing'; import { provideRouter } from '@angular/router'; +import { provideHttpClient } from '@angular/common/http'; import { App } from './app'; describe('App', () => { beforeEach(async () => { await TestBed.configureTestingModule({ imports: [App], - providers: [provideRouter([])] + providers: [provideRouter([]), provideHttpClient()], }).compileComponents(); }); diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.css b/src/app/components/annotated-move-list/annotated-move-list.component.css new file mode 100644 index 0000000..6062a45 --- /dev/null +++ b/src/app/components/annotated-move-list/annotated-move-list.component.css @@ -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); +} diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.html b/src/app/components/annotated-move-list/annotated-move-list.component.html new file mode 100644 index 0000000..111aa14 --- /dev/null +++ b/src/app/components/annotated-move-list/annotated-move-list.component.html @@ -0,0 +1,48 @@ +@if (moves.length === 0) { +
No annotated moves yet.
+} @else { +
+ @for (pair of pairs; track $index) { + + +
+ {{ pair.white?.san ?? '' }} + @if (qualityLabel(pair.white)) { + {{ + qualityLabel(pair.white) + }} + } +
+ +
+ @if (pair.black) { + {{ pair.black.san }} + @if (qualityLabel(pair.black)) { + {{ + qualityLabel(pair.black) + }} + } + } @else { + + } +
+ } +
+} diff --git a/src/app/components/annotated-move-list/annotated-move-list.component.ts b/src/app/components/annotated-move-list/annotated-move-list.component.ts new file mode 100644 index 0000000..b7d7a66 --- /dev/null +++ b/src/app/components/annotated-move-list/annotated-move-list.component.ts @@ -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 = { + brilliant: '!!', + best: '!', + good: '', + inaccuracy: '?!', + mistake: '?', + blunder: '??', +}; + +const QUALITY_CLASSES: Record = { + 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(); + + 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)}`; + } +} diff --git a/src/app/components/eval-timeline/eval-timeline.component.css b/src/app/components/eval-timeline/eval-timeline.component.css new file mode 100644 index 0000000..fc29110 --- /dev/null +++ b/src/app/components/eval-timeline/eval-timeline.component.css @@ -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; +} diff --git a/src/app/components/eval-timeline/eval-timeline.component.html b/src/app/components/eval-timeline/eval-timeline.component.html new file mode 100644 index 0000000..1589d21 --- /dev/null +++ b/src/app/components/eval-timeline/eval-timeline.component.html @@ -0,0 +1,53 @@ +@if (points.length === 0) { +
No evaluation data yet.
+} @else { + +} diff --git a/src/app/components/eval-timeline/eval-timeline.component.ts b/src/app/components/eval-timeline/eval-timeline.component.ts new file mode 100644 index 0000000..9099dd9 --- /dev/null +++ b/src/app/components/eval-timeline/eval-timeline.component.ts @@ -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; + } +} diff --git a/src/app/components/toolbar/toolbar.component.html b/src/app/components/toolbar/toolbar.component.html index 8522bd5..d859706 100644 --- a/src/app/components/toolbar/toolbar.component.html +++ b/src/app/components/toolbar/toolbar.component.html @@ -1,5 +1,4 @@ @if (showLoginDialog) { - + } @if (showRegisterDialog) { - + } diff --git a/src/app/components/toolbar/toolbar.component.ts b/src/app/components/toolbar/toolbar.component.ts index 2aa57a8..52f96de 100644 --- a/src/app/components/toolbar/toolbar.component.ts +++ b/src/app/components/toolbar/toolbar.component.ts @@ -18,7 +18,7 @@ import { Challenge } from '../../models/challenge.models'; standalone: true, imports: [CommonModule, LoginDialogComponent, RegisterDialogComponent], templateUrl: './toolbar.component.html', - styleUrl: './toolbar.component.css' + styleUrl: './toolbar.component.css', }) export class ToolbarComponent implements OnInit { private readonly destroyRef = inject(DestroyRef); @@ -46,35 +46,36 @@ export class ToolbarComponent implements OnInit { ngOnInit(): void { this.destroyRef.onDestroy(() => this.stopPolling()); - this.authService.currentUser$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(user => { - this.currentUser = user; - if (user) { - this.challengeWs.connect(); - this.startPolling(); - } else { - this.challengeWs.disconnect(); - this.stopPolling(); - this.navigatedChallengeIds.clear(); - this.challengeEventService.clear(); - } - }); + this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { + this.currentUser = user; + if (user) { + this.challengeWs.connect(); + this.startPolling(); + } else { + this.challengeWs.disconnect(); + this.stopPolling(); + this.navigatedChallengeIds.clear(); + this.challengeEventService.clear(); + } + }); this.authDialogService.dialogState$ .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(state => { + .subscribe((state) => { this.showLoginDialog = state === 'login'; this.showRegisterDialog = state === 'register'; }); - this.themeService.darkMode$ - .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(isDark => { this.isDarkMode = isDark; }); + this.themeService.darkMode$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isDark) => { + this.isDarkMode = isDark; + }); - this.challengeEventService.getIncomingChallenges$() + this.challengeEventService + .getIncomingChallenges$() .pipe(takeUntilDestroyed(this.destroyRef)) - .subscribe(challenges => { this.incomingChallenges = challenges; }); + .subscribe((challenges) => { + this.incomingChallenges = challenges; + }); } private startPolling(): void { @@ -91,7 +92,7 @@ export class ToolbarComponent implements OnInit { private fetchChallenges(): void { this.challengeService.listChallenges().subscribe({ - next: response => { + next: (response) => { const incoming = response.in ?? response.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']); } + goToAnalysis(): void { + this.profileOpen = false; + this.notifOpen = false; + void this.router.navigate(['/analysis']); + } + onLoginSuccess(): void { this.closeLoginDialog(); } @@ -227,12 +234,14 @@ export class ToolbarComponent implements OnInit { if (this.acceptingId || this.decliningId) return; this.acceptingId = challenge.id; this.challengeService.acceptChallenge(challenge.id).subscribe({ - next: accepted => { + next: (accepted) => { this.acceptingId = null; this.challengeEventService.onChallengeAccepted(accepted); 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.challengeEventService.removeChallenge(challenge.id); }, - error: () => { this.decliningId = null; } + error: () => { + this.decliningId = null; + }, }); } } diff --git a/src/app/models/analysis.models.ts b/src/app/models/analysis.models.ts new file mode 100644 index 0000000..53f1bc1 --- /dev/null +++ b/src/app/models/analysis.models.ts @@ -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; +} diff --git a/src/app/pages/analysis/analysis.component.css b/src/app/pages/analysis/analysis.component.css new file mode 100644 index 0000000..0a6e8ae --- /dev/null +++ b/src/app/pages/analysis/analysis.component.css @@ -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; + } +} diff --git a/src/app/pages/analysis/analysis.component.html b/src/app/pages/analysis/analysis.component.html new file mode 100644 index 0000000..a2ce620 --- /dev/null +++ b/src/app/pages/analysis/analysis.component.html @@ -0,0 +1,355 @@ +
+
+ + + + + + + + @if (errorMessage) { +
{{ errorMessage }}
+ } + + +
+ +
+ + + +
+ + + @if (inputMode === 'fen') { +
+ + +
+ } + + + @if (inputMode === 'pgn') { +
+ + +
+ } + + + @if (inputMode === 'game') { +
+ + +
+ } + + +
+ + + +
+
+ + +
+ +
+
+ +
+ + + @if (fenHistory.length > 1) { + + } +
+ + + +
+
+
diff --git a/src/app/pages/analysis/analysis.component.ts b/src/app/pages/analysis/analysis.component.ts new file mode 100644 index 0000000..1aeb908 --- /dev/null +++ b/src/app/pages/analysis/analysis.component.ts @@ -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; + }, + }); + } +} diff --git a/src/app/services/analysis.service.ts b/src/app/services/analysis.service.ts new file mode 100644 index 0000000..4982c78 --- /dev/null +++ b/src/app/services/analysis.service.ts @@ -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 { + 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 { + 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'; + } +} diff --git a/src/app/services/game-api.service.ts b/src/app/services/game-api.service.ts index cf7aef6..5849015 100644 --- a/src/app/services/game-api.service.ts +++ b/src/app/services/game-api.service.ts @@ -7,8 +7,9 @@ import { GameState, GameStreamEvent, LegalMovesResponse, - PlayerInfo + PlayerInfo, } from '../models/game.models'; +import { AnalysisRequest, AnalysisResponse } from '../models/analysis.models'; import { StreamHandlerService } from './stream-handler.service'; @Injectable({ providedIn: 'root' }) @@ -28,11 +29,11 @@ export class GameApiService { const playerColor = Math.random() > 0.5 ? 'white' : 'black'; const playerInfo: PlayerInfo = { id: `player-${Date.now()}`, - displayName: 'You' + displayName: 'You', }; const botInfo: PlayerInfo = { id: `bot-${difficulty}`, - displayName: `Bot (${difficulty})` + displayName: `Bot (${difficulty})`, }; const payload = @@ -56,7 +57,9 @@ export class GameApiService { if (square) { params = params.set('square', square); } - return this.http.get(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { params }); + return this.http.get(`${this.apiBase}${this.apiPath}/${gameId}/moves`, { + params, + }); } importFen(fen: string): Observable { @@ -75,6 +78,10 @@ export class GameApiService { return this.http.post(`${this.apiBase}${this.apiPath}/${gameId}/draw/offer`, {}); } + analyzePosition(request: AnalysisRequest): Observable { + return this.http.post(`${this.apiBase}/api/analysis/position`, request); + } + private resolveWsBase(): string { if (this.wsBase) { return this.wsBase; diff --git a/tsconfig.app.json b/tsconfig.app.json index 264f459..a0dcc37 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,10 +6,6 @@ "outDir": "./out-tsc/app", "types": [] }, - "include": [ - "src/**/*.ts" - ], - "exclude": [ - "src/**/*.spec.ts" - ] + "include": ["src/**/*.ts"], + "exclude": ["src/**/*.spec.ts"] } diff --git a/tsconfig.spec.json b/tsconfig.spec.json index 940b30a..e299354 100644 --- a/tsconfig.spec.json +++ b/tsconfig.spec.json @@ -4,12 +4,7 @@ "extends": "./tsconfig.json", "compilerOptions": { "outDir": "./out-tsc/spec", - "types": [ - "jasmine" - ] + "types": ["jasmine"] }, - "include": [ - "src/**/*.d.ts", - "src/**/*.spec.ts" - ] + "include": ["src/**/*.d.ts", "src/**/*.spec.ts"] } diff --git a/versions.env b/versions.env index 4c39cf4..99f6d57 100644 --- a/versions.env +++ b/versions.env @@ -1,3 +1,3 @@ MAJOR=0 -MINOR=2 -PATCH=8 +MINOR=3 +PATCH=0