Compare commits

..

8 Commits

Author SHA1 Message Date
TeamCity 74f82bc0ba ci: bump version to v0.4.2 2026-06-17 09:19:46 +00:00
lq64 1d2c217da8 fix: NCS-122 send WS token via first-message auth instead of query param (#13)
Remove token from WebSocket URL query parameters in ChallengeWebSocketService
and GameApiService. Instead, send {"type":"auth","token":"..."} as the first
text frame after the connection opens, matching the new backend auth protocol.

---------

Co-authored-by: LQ63 <lkhermann@web.de>
Reviewed-on: #13
2026-06-17 10:50:16 +02:00
TeamCity 3d1b330396 ci: bump version to v0.4.1 2026-06-17 07:20:31 +00:00
Janis a54957aa74 fix(auth): attach Bearer token to /api/bots requests (#12)
OfficialBotService posts to /api/bots/official/join-tournament, but
the auth interceptor's protected-endpoint whitelist omitted /api/bots,
so no Authorization header was sent and the request hit the official-
bots service anonymously -> 401.

Add /api/bots to the whitelist.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Reviewed-on: #12
2026-06-17 09:08:55 +02:00
TeamCity c9bf6d21fc ci: bump version to v0.4.0 2026-06-17 06:22:59 +00:00
Janis f9420e5848 feat: NCWF-5/6/7/8/9 chess analysis page and engine integration (#11)
Co-authored-by: Janis Eccarius <eccariusjanis@gmail.com>
Reviewed-on: #11
2026-06-17 08:17:55 +02:00
TeamCity a3e51bade5 ci: bump version to v0.3.0 2026-06-10 20:41:14 +00:00
lq64 48959daae3 feat: bots (#9)
Co-authored-by: Lala, Shahd <Shahd.Lala@sybit.de>
Reviewed-on: #9
Co-authored-by: Leon Hermann <lq@blackhole.local>
Co-committed-by: Leon Hermann <lq@blackhole.local>
2026-06-10 22:38:06 +02:00
28 changed files with 781 additions and 578 deletions
-5
View File
@@ -19,7 +19,6 @@ 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.
@@ -57,7 +56,6 @@ 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.
@@ -66,7 +64,6 @@ 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?
@@ -76,7 +73,6 @@ 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`
@@ -90,7 +86,6 @@ 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)
+8 -13
View File
@@ -19,7 +19,6 @@ 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.
@@ -54,7 +53,6 @@ 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.
@@ -63,7 +61,6 @@ 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)?
@@ -73,7 +70,6 @@ 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`
@@ -87,7 +83,6 @@ 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)
@@ -101,14 +96,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.
+7 -15
View File
@@ -7,12 +7,10 @@ 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.
@@ -20,7 +18,6 @@ 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.
@@ -32,18 +29,17 @@ 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.
@@ -53,7 +49,6 @@ 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).
@@ -73,13 +68,11 @@ 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?
@@ -88,7 +81,6 @@ 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.
+7 -11
View File
@@ -3,7 +3,6 @@
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 .`)
@@ -16,7 +15,6 @@ 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
@@ -44,7 +42,6 @@ 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.
@@ -69,7 +66,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
@@ -79,7 +76,6 @@ 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.
@@ -129,12 +125,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,7 +4,6 @@ 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 .`)
@@ -41,7 +40,6 @@ 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
@@ -85,7 +83,6 @@ 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.
@@ -142,12 +139,10 @@ 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:
+5 -11
View File
@@ -12,7 +12,6 @@ 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.
@@ -20,7 +19,6 @@ 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").
@@ -28,7 +26,6 @@ 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?
@@ -60,7 +57,6 @@ 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.
@@ -78,7 +74,6 @@ 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)
@@ -89,7 +84,6 @@ 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:
@@ -100,12 +94,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.
+41 -40
View File
@@ -1,77 +1,78 @@
## [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))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.2.8...0.0.0) (2026-06-10)
### Features
* bots ([#9](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/9)) ([48959da](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/48959daae36e709ea7782ca04fdde699854f8e66))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.3.0...0.0.0) (2026-06-17)
### Features
* NCWF-5/6/7/8/9 chess analysis page and engine integration ([#11](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/11)) ([f9420e5](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/f9420e5848d8724bcb0e9cf08f08b871c91cf4ba))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.0...0.0.0) (2026-06-17)
### Bug Fixes
* **auth:** attach Bearer token to /api/bots requests ([#12](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/12)) ([a54957a](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/a54957aa74ef15bf2dd439d386e221ac134c5c5c))
## [0.0.0](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/compare/0.4.1...0.0.0) (2026-06-17)
### Bug Fixes
* NCS-122 send WS token via first-message auth instead of query param ([#13](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/issues/13)) ([1d2c217](https://git.janis-eccarius.de/NowChess/NowChess-Frontend/commit/1d2c217da8982d361e2eb7de26f6447171a1dd43))
+13 -4
View File
@@ -18,7 +18,9 @@
"builder": "@angular/build:application", "builder": "@angular/build:application",
"options": { "options": {
"browser": "src/main.ts", "browser": "src/main.ts",
"polyfills": ["zone.js"], "polyfills": [
"zone.js"
],
"tsConfig": "tsconfig.app.json", "tsConfig": "tsconfig.app.json",
"assets": [ "assets": [
{ {
@@ -36,7 +38,9 @@
"output": "/assets/ChessAssets" "output": "/assets/ChessAssets"
} }
], ],
"styles": ["src/styles.css"] "styles": [
"src/styles.css"
]
}, },
"configurations": { "configurations": {
"production": { "production": {
@@ -102,7 +106,10 @@
"test": { "test": {
"builder": "@angular/build:karma", "builder": "@angular/build:karma",
"options": { "options": {
"polyfills": ["zone.js", "zone.js/testing"], "polyfills": [
"zone.js",
"zone.js/testing"
],
"tsConfig": "tsconfig.spec.json", "tsConfig": "tsconfig.spec.json",
"assets": [ "assets": [
{ {
@@ -120,7 +127,9 @@
"output": "/assets/ChessAssets" "output": "/assets/ChessAssets"
} }
], ],
"styles": ["src/styles.css"] "styles": [
"src/styles.css"
]
} }
} }
} }
+5 -2
View File
@@ -55,6 +55,7 @@ tags:
description: Export a game as FEN or PGN description: Export a game as FEN or PGN
paths: paths:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Game lifecycle # Game lifecycle
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -466,6 +467,7 @@ paths:
# ============================================================================= # =============================================================================
components: components:
securitySchemes: securitySchemes:
bearerAuth: bearerAuth:
type: http type: http
@@ -515,6 +517,7 @@ components:
$ref: '#/components/schemas/ApiError' $ref: '#/components/schemas/ApiError'
schemas: schemas:
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
# Requests # Requests
# ------------------------------------------------------------------------- # -------------------------------------------------------------------------
@@ -548,7 +551,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
@@ -584,7 +587,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]
+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}"
}; };
+1 -1
View File
@@ -17,5 +17,5 @@ export const routes: Routes = [
{ path: 'bots', component: BotsComponent }, { path: 'bots', component: BotsComponent },
{ path: 'analysis', component: AnalysisComponent }, { path: 'analysis', component: AnalysisComponent },
{ path: 'game/:gameId', component: GameComponent }, { path: 'game/:gameId', component: GameComponent },
{ path: '**', redirectTo: '' }, { path: '**', redirectTo: '' }
]; ];
+1 -1
View File
@@ -7,7 +7,7 @@ describe('App', () => {
beforeEach(async () => { beforeEach(async () => {
await TestBed.configureTestingModule({ await TestBed.configureTestingModule({
imports: [App], imports: [App],
providers: [provideRouter([]), provideHttpClient()], providers: [provideRouter([]), provideHttpClient()]
}).compileComponents(); }).compileComponents();
}); });
+176 -288
View File
@@ -1,4 +1,5 @@
<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>
@@ -7,315 +8,202 @@
<!-- 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 <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7"
width="14" stroke-linecap="round" stroke-linejoin="round">
height="14" <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" />
viewBox="0 0 24 24" <circle cx="12" cy="12" r="3" />
fill="none" </svg>
stroke="currentColor" Watch
stroke-width="1.7" </button>
stroke-linecap="round" <button type="button" class="nc-link" (click)="goToTournaments()">Tournaments</button>
stroke-linejoin="round" <button type="button" class="nc-link" (click)="goToBots()">Bots</button>
> <button type="button" class="nc-link" (click)="goToAnalysis()">Analysis</button>
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /> </div>
<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>
@if (notifOpen) { <!-- Notifications bell -->
<div class="nc-popover nc-notif" (click)="$event.stopPropagation()"> <div class="nc-dropdown-wrap" data-dropdown="notif">
<div class="nc-notif-header"> <button type="button" class="nc-bell" [class.is-open]="notifOpen" (click)="toggleNotif($event)"
<span class="nc-notif-header-title">Challenges</span> aria-label="Notifications">
</div> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6"
stroke-linecap="round" stroke-linejoin="round">
<div class="nc-notif-list"> <path d="M6 8a6 6 0 0 1 12 0c0 7 3 9 3 9H3s3-2 3-9" />
@if (incomingChallenges.length === 0) { <path d="M10.3 21a1.94 1.94 0 0 0 3.4 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>
<!-- Games quick-access -->
<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"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M14.5 17.5L3 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 1-4 4" />
</svg> </svg>
Games @if (incomingChallenges.length > 0) {
<span class="nc-badge">{{ incomingChallenges.length }}</span>
}
</button> </button>
<!-- Profile --> @if (notifOpen) {
<div class="nc-dropdown-wrap" data-dropdown="profile"> <div class="nc-popover nc-notif" (click)="$event.stopPropagation()">
<button <div class="nc-notif-header">
type="button" <span class="nc-notif-header-title">Challenges</span>
class="nc-profile" </div>
[class.is-open]="profileOpen"
(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-notif-list">
<div class="nc-popover nc-menu" (click)="$event.stopPropagation()"> @if (incomingChallenges.length === 0) {
<div class="nc-menu-header"> <div class="nc-notif-empty">No pending challenges</div>
<div class="nc-avatar nc-avatar-md">{{ getInitial() }}</div> }
<div> @for (challenge of incomingChallenges; track challenge.id) {
<div class="nc-menu-user-name">{{ user.username }}</div> <div class="nc-notif-row is-unread">
<div class="nc-menu-user-sub">{{ user.rating }} · &#64;{{ user.username }}</div> <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-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-notif-footer">
<button type="button" class="nc-view-all" (click)="goToChallenges()">View all challenges</button>
</div>
</div> </div>
} @else { }
<!-- Logged-out auth buttons --> </div>
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()"> <!-- Games quick-access -->
Register <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"
stroke-linecap="round" stroke-linejoin="round">
<path d="M14.5 17.5L3 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 1-4 4"/>
</svg>
Games
</button>
<!-- Profile -->
<div class="nc-dropdown-wrap" data-dropdown="profile">
<button type="button" class="nc-profile" [class.is-open]="profileOpen" (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> </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>
} @else {
<!-- Logged-out auth buttons -->
<button type="button" class="nc-auth-btn" (click)="openLoginDialog()">Login</button>
<button type="button" class="nc-auth-btn nc-auth-btn--primary" (click)="openRegisterDialog()">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()" />
} }
+27 -32
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,36 +46,35 @@ export class ToolbarComponent implements OnInit {
ngOnInit(): void { ngOnInit(): void {
this.destroyRef.onDestroy(() => this.stopPolling()); this.destroyRef.onDestroy(() => this.stopPolling());
this.authService.currentUser$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((user) => { this.authService.currentUser$
this.currentUser = user; .pipe(takeUntilDestroyed(this.destroyRef))
if (user) { .subscribe(user => {
this.challengeWs.connect(); this.currentUser = user;
this.startPolling(); if (user) {
} else { this.challengeWs.connect();
this.challengeWs.disconnect(); this.startPolling();
this.stopPolling(); } else {
this.navigatedChallengeIds.clear(); this.challengeWs.disconnect();
this.challengeEventService.clear(); this.stopPolling();
} 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$.pipe(takeUntilDestroyed(this.destroyRef)).subscribe((isDark) => { this.themeService.darkMode$
this.isDarkMode = isDark;
});
this.challengeEventService
.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe((challenges) => { .subscribe(isDark => { this.isDarkMode = isDark; });
this.incomingChallenges = challenges;
}); this.challengeEventService.getIncomingChallenges$()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(challenges => { this.incomingChallenges = challenges; });
} }
private startPolling(): void { private startPolling(): void {
@@ -92,7 +91,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);
@@ -105,7 +104,7 @@ export class ToolbarComponent implements OnInit {
} }
} }
} }
}, }
}); });
} }
@@ -234,14 +233,12 @@ 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: () => { error: () => { this.acceptingId = null; }
this.acceptingId = null;
},
}); });
} }
@@ -254,9 +251,7 @@ export class ToolbarComponent implements OnInit {
this.decliningId = null; this.decliningId = null;
this.challengeEventService.removeChallenge(challenge.id); this.challengeEventService.removeChallenge(challenge.id);
}, },
error: () => { error: () => { this.decliningId = null; }
this.decliningId = null;
},
}); });
} }
} }
@@ -64,6 +64,18 @@
} }
.page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; } .page-title { font-size: 22px; font-weight: 700; margin: 0; letter-spacing: -0.02em; }
.page-actions { display: flex; align-items: center; gap: 8px; }
.btn-servers {
display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px;
border: 1px solid var(--nc-border-strong);
background: transparent; color: var(--nc-text-muted);
font-size: 13px; font-weight: 600; cursor: pointer;
transition: color 0.15s, border-color 0.15s;
}
.btn-servers:hover { color: var(--nc-text); border-color: var(--nc-text-muted); }
.btn-new { .btn-new {
display: inline-flex; align-items: center; gap: 6px; display: inline-flex; align-items: center; gap: 6px;
padding: 7px 14px; border-radius: 8px; border: none; padding: 7px 14px; border-radius: 8px; border: none;
@@ -327,3 +339,74 @@
color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase; color: var(--nc-success); letter-spacing: 0.06em; text-transform: uppercase;
} }
.pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; } .pairing-ongoing svg { animation: pulse 1.4s ease-in-out infinite; }
/* Official bot join section */
.join-divider {
display: flex; align-items: center; gap: 10px;
margin: 20px 0 14px;
}
.join-divider::before, .join-divider::after {
content: ''; flex: 1; height: 1px; background: var(--nc-border);
}
.join-divider-label {
font-size: 10px; font-weight: 600; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--nc-text-dim); white-space: nowrap;
}
.official-bot-grid {
display: grid; grid-template-columns: repeat(4, 1fr); gap: 8px;
margin-bottom: 4px;
}
.official-bot-btn {
display: flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 4px; border-radius: 8px; border: 1px solid var(--nc-border);
background: var(--nc-surface); font-size: 12px; font-weight: 700;
cursor: pointer; transition: border-color 0.15s, background 0.15s, color 0.15s;
color: var(--nc-text-muted); text-transform: capitalize;
}
.official-bot-btn:disabled { opacity: 0.45; cursor: not-allowed; }
.official-btn-easy:hover:not(:disabled) { border-color: var(--nc-success); color: var(--nc-success); background: rgba(94,229,161,0.07); }
.official-btn-medium:hover:not(:disabled) { border-color: var(--nc-warn); color: var(--nc-warn); background: rgba(255,209,102,0.07); }
.official-btn-hard:hover:not(:disabled) { border-color: var(--nc-neon); color: var(--nc-neon); background: rgba(255,69,200,0.07); }
.official-btn-expert:hover:not(:disabled) { border-color: var(--nc-danger); color: var(--nc-danger); background: rgba(255,122,122,0.07); }
/* Servers dialog */
.servers-dialog { max-width: 480px; }
.servers-list {
display: flex; flex-direction: column; gap: 6px;
margin-bottom: 20px;
}
.server-row {
display: flex; align-items: center; gap: 10px;
padding: 10px 12px; border-radius: 8px;
border: 1px solid var(--nc-border); background: var(--nc-surface);
}
.server-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.server-label { font-size: 13px; font-weight: 600; color: var(--nc-text); }
.server-url {
font-size: 11px; color: var(--nc-text-dim);
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
font-family: monospace;
}
.server-remove-btn {
background: none; border: none; cursor: pointer;
color: var(--nc-text-dim); padding: 4px; border-radius: 6px;
display: flex; align-items: center; justify-content: center;
flex-shrink: 0; transition: color 0.15s, background 0.15s;
font-size: 13px;
}
.server-remove-btn:hover:not(:disabled) { color: var(--nc-danger); background: rgba(255,122,122,0.1); }
.server-remove-btn:disabled { opacity: 0.4; cursor: not-allowed; }
.server-add-form {
border-top: 1px solid var(--nc-border);
padding-top: 16px;
display: flex; flex-direction: column; gap: 0;
}
.server-add-heading {
font-size: 11px; font-weight: 700; text-transform: uppercase;
letter-spacing: 0.08em; color: var(--nc-text-muted);
margin: 0 0 14px;
}
@@ -16,15 +16,25 @@
<header class="page-header"> <header class="page-header">
<div class="page-title-row"> <div class="page-title-row">
<h1 class="page-title">Tournaments</h1> <h1 class="page-title">Tournaments</h1>
@if (currentUser) { <div class="page-actions">
<button type="button" class="btn-new" (click)="openCreateDialog()"> @if (currentUser) {
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" <button type="button" class="btn-servers" (click)="openServersDialog()">
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"> <svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/> stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
</svg> <rect x="2" y="2" width="20" height="8" rx="2"/><rect x="2" y="14" width="20" height="8" rx="2"/>
New tournament <line x1="6" y1="6" x2="6.01" y2="6"/><line x1="6" y1="18" x2="6.01" y2="18"/>
</button> </svg>
} Servers
</button>
<button type="button" class="btn-new" (click)="openCreateDialog()">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
New tournament
</button>
}
</div>
</div> </div>
<div class="tabs" role="tablist"> <div class="tabs" role="tablist">
<button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')"> <button type="button" class="tab-btn" [class.active]="tab === 'started'" (click)="setTab('started')">
@@ -192,12 +202,12 @@
<span class="dialog-brand">Join with a bot</span> <span class="dialog-brand">Join with a bot</span>
<button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button> <button type="button" class="dialog-close" (click)="closeJoinDialog()">×</button>
</div> </div>
<p class="join-hint">Select one of your bots to enter this tournament. Its token will be rotated to authenticate the join.</p> <p class="join-hint">Select an official (engine-backed) bot to enter this tournament. These are the bots that actually play their moves.</p>
@if (botsLoading) { @if (botsLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading bots…</div> <div class="dialog-loading"><span class="pulse"></span>Loading bots…</div>
} @else if (userBots.length === 0) { } @else if (userBots.length === 0) {
<p class="join-empty">You have no bots yet. Go to <strong>Bots</strong> in the nav to create one first.</p> <p class="join-empty">No official bots are available. The official-bots engine service must be running to register them.</p>
} @else { } @else {
<div class="bot-pick-list"> <div class="bot-pick-list">
@for (bot of userBots; track bot.id) { @for (bot of userBots; track bot.id) {
@@ -218,6 +228,97 @@
@if (joinError) { @if (joinError) {
<div class="dialog-error">{{ joinError }}</div> <div class="dialog-error">{{ joinError }}</div>
} }
<div class="join-divider">
<span class="join-divider-label">or join with an official bot</span>
</div>
<div class="official-bot-grid">
@for (d of officialDifficulties; track d) {
<button type="button" class="official-bot-btn"
[class]="'official-btn-' + d"
[disabled]="!!joiningOfficialDifficulty || !!joiningBotId"
(click)="joinWithOfficialBot(d)">
@if (joiningOfficialDifficulty === d) {
<span class="pulse"></span>
}
{{ d | titlecase }}
</button>
}
</div>
@if (officialJoinError) {
<div class="dialog-error">{{ officialJoinError }}</div>
}
</div>
</div>
}
@if (showServersDialog) {
<div class="dialog-overlay" (click)="closeServersDialog()">
<div class="dialog-card servers-dialog" (click)="$event.stopPropagation()">
<div class="dialog-head">
<span class="dialog-brand">Tournament servers</span>
<button type="button" class="dialog-close" (click)="closeServersDialog()">×</button>
</div>
<p class="join-hint">External tournament servers aggregated into this view. Tournaments from all servers appear in the list.</p>
@if (serversLoading) {
<div class="dialog-loading"><span class="pulse"></span>Loading servers…</div>
} @else if (servers.length === 0) {
<p class="join-empty">No external servers registered yet.</p>
} @else {
<div class="servers-list">
@for (s of servers; track s.id) {
<div class="server-row">
<div class="server-info">
<span class="server-label">{{ s.label }}</span>
<span class="server-url">{{ s.url }}</span>
</div>
<button type="button" class="server-remove-btn"
[disabled]="removingServerId === s.id"
(click)="removeServer(s.id)"
title="Remove server">
@if (removingServerId === s.id) { … } @else {
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor"
stroke-width="2.2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="3 6 5 6 21 6"/><path d="M19 6l-1 14a2 2 0 0 1-2 2H8a2 2 0 0 1-2-2L5 6"/>
<path d="M10 11v6"/><path d="M14 11v6"/>
<path d="M9 6V4a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2"/>
</svg>
}
</button>
</div>
}
</div>
}
<div class="server-add-form">
<h4 class="server-add-heading">Add server</h4>
<div class="dialog-field">
<label class="dialog-label">Label</label>
<input type="text" class="dialog-input" [(ngModel)]="newServerLabel"
placeholder="e.g. Local Dev Server" />
</div>
<div class="dialog-field">
<label class="dialog-label">URL</label>
<input type="url" class="dialog-input" [(ngModel)]="newServerUrl"
placeholder="http://host:8089" />
</div>
@if (addServerError) {
<div class="dialog-error">{{ addServerError }}</div>
}
<div class="dialog-actions">
<button type="button" class="btn-ghost" (click)="closeServersDialog()">Close</button>
<button type="button" class="btn-primary"
[disabled]="addingServer || !newServerLabel.trim() || !newServerUrl.trim()"
(click)="addServer()">
{{ addingServer ? 'Adding…' : 'Add' }}
</button>
</div>
</div>
</div> </div>
</div> </div>
} }
@@ -1,11 +1,13 @@
import { Component, DestroyRef, OnInit, inject } from '@angular/core'; import { Component, DestroyRef, OnInit, inject } from '@angular/core';
import { CommonModule } from '@angular/common'; import { CommonModule, TitleCasePipe } from '@angular/common';
import { Router, RouterLink } from '@angular/router'; import { Router, RouterLink } from '@angular/router';
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms'; import { FormBuilder, FormGroup, FormsModule, ReactiveFormsModule, Validators } from '@angular/forms';
import { takeUntilDestroyed } from '@angular/core/rxjs-interop'; import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
import { TournamentService } from '../../services/tournament.service'; import { TournamentService } from '../../services/tournament.service';
import { AuthService } from '../../services/auth.service'; import { AuthService } from '../../services/auth.service';
import { BotService } from '../../services/bot.service'; import { BotService } from '../../services/bot.service';
import { OfficialBotService } from '../../services/official-bot.service';
import { TournamentServerService, ExternalTournamentServer } from '../../services/tournament-server.service';
import { Bot } from '../../models/bot.models'; import { Bot } from '../../models/bot.models';
import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models'; import { Tournament, TournamentResult, RoundPairings } from '../../models/tournament.models';
import { CurrentUser } from '../../models/auth.models'; import { CurrentUser } from '../../models/auth.models';
@@ -15,7 +17,7 @@ type StatusTab = 'started' | 'created' | 'finished';
@Component({ @Component({
selector: 'app-tournaments', selector: 'app-tournaments',
standalone: true, standalone: true,
imports: [CommonModule, RouterLink, ReactiveFormsModule], imports: [CommonModule, RouterLink, FormsModule, ReactiveFormsModule, TitleCasePipe],
templateUrl: './tournaments.component.html', templateUrl: './tournaments.component.html',
styleUrl: './tournaments.component.css' styleUrl: './tournaments.component.css'
}) })
@@ -25,6 +27,8 @@ export class TournamentsComponent implements OnInit {
private readonly authService = inject(AuthService); private readonly authService = inject(AuthService);
private readonly fb = inject(FormBuilder); private readonly fb = inject(FormBuilder);
private readonly botService = inject(BotService); private readonly botService = inject(BotService);
private readonly officialBotService = inject(OfficialBotService);
private readonly tournamentServerService = inject(TournamentServerService);
private readonly router = inject(Router); private readonly router = inject(Router);
loading = true; loading = true;
@@ -58,6 +62,19 @@ export class TournamentsComponent implements OnInit {
joiningBotId: string | null = null; joiningBotId: string | null = null;
joinError: string | null = null; joinError: string | null = null;
readonly officialDifficulties = ['easy', 'medium', 'hard', 'expert'] as const;
joiningOfficialDifficulty: string | null = null;
officialJoinError: string | null = null;
showServersDialog = false;
servers: ExternalTournamentServer[] = [];
serversLoading = false;
newServerLabel = '';
newServerUrl = '';
addingServer = false;
addServerError: string | null = null;
removingServerId: string | null = null;
ngOnInit(): void { ngOnInit(): void {
this.authService.currentUser$ this.authService.currentUser$
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
@@ -158,7 +175,7 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = tournamentId; this.joinDialogTournamentId = tournamentId;
this.joinError = null; this.joinError = null;
this.botsLoading = true; this.botsLoading = true;
this.botService.list() this.botService.listOfficial()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({ .subscribe({
next: bots => { this.userBots = bots; this.botsLoading = false; }, next: bots => { this.userBots = bots; this.botsLoading = false; },
@@ -170,40 +187,110 @@ export class TournamentsComponent implements OnInit {
this.joinDialogTournamentId = null; this.joinDialogTournamentId = null;
this.joiningBotId = null; this.joiningBotId = null;
this.joinError = null; this.joinError = null;
this.joiningOfficialDifficulty = null;
this.officialJoinError = null;
}
joinWithOfficialBot(difficulty: string): void {
if (!this.joinDialogTournamentId || this.joiningOfficialDifficulty || this.joiningBotId) return;
this.joiningOfficialDifficulty = difficulty;
this.officialJoinError = null;
const tid = this.joinDialogTournamentId;
this.officialBotService.joinTournament(tid, difficulty).subscribe({
next: () => {
this.joiningOfficialDifficulty = null;
this.closeJoinDialog();
this.tournamentService.get(tid)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(updated => {
this.created = this.created.map(x => x.id === tid ? updated : x);
this.started = this.started.map(x => x.id === tid ? updated : x);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: err => {
this.joiningOfficialDifficulty = null;
this.officialJoinError = err.error?.error ?? 'Failed to join with official bot.';
}
});
} }
joinWithBot(bot: Bot): void { joinWithBot(bot: Bot): void {
if (!this.joinDialogTournamentId || this.joiningBotId) return; if (!this.joinDialogTournamentId || this.joiningBotId) return;
this.joiningBotId = bot.id; this.joiningBotId = bot.id;
this.joinError = null; this.joinError = null;
this.botService.rotateToken(bot.id).subscribe({ this.tournamentService.join(this.joinDialogTournamentId, bot.id, bot.name).subscribe({
next: token => { next: () => {
this.tournamentService.joinWithBotToken(this.joinDialogTournamentId!, token).subscribe({
next: () => {
this.joiningBotId = null;
const tid = this.joinDialogTournamentId!;
this.closeJoinDialog();
this.tournamentService.get(tid)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(updated => {
this.created = this.created.map(x => x.id === tid ? updated : x);
this.started = this.started.map(x => x.id === tid ? updated : x);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: err => {
this.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
}
});
},
error: () => {
this.joiningBotId = null; this.joiningBotId = null;
this.joinError = 'Failed to get bot token.'; const tid = this.joinDialogTournamentId!;
this.closeJoinDialog();
this.tournamentService.get(tid)
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe(updated => {
this.created = this.created.map(x => x.id === tid ? updated : x);
this.started = this.started.map(x => x.id === tid ? updated : x);
if (this.selectedTournament?.id === tid) this.selectedTournament = updated;
});
},
error: err => {
this.joiningBotId = null;
this.joinError = err.error?.message ?? err.error?.error ?? 'Failed to join tournament.';
} }
}); });
} }
openServersDialog(): void {
this.newServerLabel = '';
this.newServerUrl = '';
this.addServerError = null;
this.showServersDialog = true;
this.serversLoading = true;
this.tournamentServerService.list()
.pipe(takeUntilDestroyed(this.destroyRef))
.subscribe({
next: res => { this.servers = res.servers; this.serversLoading = false; },
error: () => { this.serversLoading = false; }
});
}
closeServersDialog(): void {
this.showServersDialog = false;
}
addServer(): void {
const label = this.newServerLabel.trim();
const url = this.newServerUrl.trim();
if (!label || !url || this.addingServer) return;
this.addingServer = true;
this.addServerError = null;
this.tournamentServerService.register(label, url).subscribe({
next: server => {
this.addingServer = false;
this.servers = [...this.servers, server];
this.newServerLabel = '';
this.newServerUrl = '';
this.loadTournaments();
},
error: err => {
this.addingServer = false;
this.addServerError = err.error?.error ?? 'Failed to add server.';
}
});
}
removeServer(id: string): void {
if (this.removingServerId) return;
this.removingServerId = id;
this.tournamentServerService.remove(id).subscribe({
next: () => {
this.removingServerId = null;
this.servers = this.servers.filter(s => s.id !== id);
this.loadTournaments();
},
error: () => { this.removingServerId = null; }
});
}
private loadTournaments(): void { private loadTournaments(): void {
this.tournamentService.list() this.tournamentService.list()
.pipe(takeUntilDestroyed(this.destroyRef)) .pipe(takeUntilDestroyed(this.destroyRef))
+2 -1
View File
@@ -9,7 +9,8 @@ export const authInterceptor: HttpInterceptorFn = (req, next) => {
req.url.includes('/api/account/official-bots') || req.url.includes('/api/account/official-bots') ||
req.url.includes('/api/board/game') || req.url.includes('/api/board/game') ||
req.url.includes('/api/challenge') || req.url.includes('/api/challenge') ||
req.url.includes('/api/tournament'); req.url.includes('/api/tournament') ||
req.url.includes('/api/bots');
if (token && isProtectedEndpoint && !req.headers.has('Authorization')) { if (token && isProtectedEndpoint && !req.headers.has('Authorization')) {
req = req.clone({ req = req.clone({
+5
View File
@@ -7,11 +7,16 @@ import { Bot, BotWithToken } from '../models/bot.models';
export class BotService { export class BotService {
private readonly http = inject(HttpClient); private readonly http = inject(HttpClient);
private readonly base = '/api/account/bots'; private readonly base = '/api/account/bots';
private readonly officialBase = '/api/account/official-bots';
list(): Observable<Bot[]> { list(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.base); return this.http.get<Bot[]>(this.base);
} }
listOfficial(): Observable<Bot[]> {
return this.http.get<Bot[]>(this.officialBase);
}
create(name: string): Observable<BotWithToken> { create(name: string): Observable<BotWithToken> {
return this.http.post<BotWithToken>(this.base, { name }); return this.http.post<BotWithToken>(this.base, { name });
} }
+96 -91
View File
@@ -6,110 +6,115 @@ import { ChallengeService } from './challenge.service';
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class ChallengeWebSocketService { export class ChallengeWebSocketService {
private readonly challengeEventService = inject(ChallengeEventService); private readonly challengeEventService = inject(ChallengeEventService);
private readonly challengeService = inject(ChallengeService); private readonly challengeService = inject(ChallengeService);
private readonly router = inject(Router); private readonly router = inject(Router);
private ws: WebSocket | null = null; private ws: WebSocket | null = null;
private reconnectAttempts = 0; private reconnectAttempts = 0;
private readonly maxReconnectAttempts = 5; private readonly maxReconnectAttempts = 5;
private readonly reconnectDelay = 3000; private readonly reconnectDelay = 3000;
private intentionalClose = false; private intentionalClose = false;
connect(): void { connect(): void {
if (this.ws) return; if (this.ws) return;
const token = localStorage.getItem('token'); const token = localStorage.getItem('token');
if (!token) return; if (!token) return;
const url = `${environment.userWsBaseUrl}/api/user/ws?token=${encodeURIComponent(token)}`; const url = `${environment.userWsBaseUrl}/api/user/ws`;
try { try {
this.intentionalClose = false; this.intentionalClose = false;
this.ws = new WebSocket(url); this.ws = new WebSocket(url);
this.ws.onopen = () => { this.ws.onopen = () => {
this.reconnectAttempts = 0;
};
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
}
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0; this.reconnectAttempts = 0;
if (this.ws) { this.ws?.send(JSON.stringify({ type: 'auth', token }));
this.ws.close(); };
this.ws = null;
this.ws.onmessage = (event) => {
this.handleMessage(event.data as string);
};
this.ws.onerror = () => {
// onclose fires right after, handles reconnect
};
this.ws.onclose = () => {
this.ws = null;
if (!this.intentionalClose) {
this.attemptReconnect();
} }
};
} catch {
this.attemptReconnect();
}
}
disconnect(): void {
this.intentionalClose = true;
this.reconnectAttempts = 0;
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
private handleMessage(data: string): void {
let message: Record<string, unknown>;
try {
message = JSON.parse(data) as Record<string, unknown>;
} catch {
return;
} }
private handleMessage(data: string): void { switch (message['type']) {
let message: Record<string, unknown>; case 'CONNECTED':
try { break;
message = JSON.parse(data) as Record<string, unknown>;
} catch { case 'challengeCreated': {
return; const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: (challenge) => this.challengeEventService.onChallengeReceived(challenge),
error: () => {
/* challenge may have already expired */
},
});
} }
break;
}
switch (message['type']) { case 'challengeAccepted': {
case 'CONNECTED': const challengeId = message['challengeId'] as string | undefined;
break; const gameId = message['gameId'] as string | undefined;
if (challengeId) {
case 'challengeCreated': { this.challengeEventService.removeChallenge(challengeId);
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeService.getChallenge(challengeId).subscribe({
next: challenge => this.challengeEventService.onChallengeReceived(challenge),
error: () => { /* challenge may have already expired */ }
});
}
break;
}
case 'challengeAccepted': {
const challengeId = message['challengeId'] as string | undefined;
const gameId = message['gameId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
case 'challengeDeclined':
case 'challengeExpired':
case 'challengeCancelled': {
const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
} }
} if (gameId) {
void this.router.navigate(['/game', gameId]);
}
break;
}
private attemptReconnect(): void { case 'challengeDeclined':
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return; case 'challengeExpired':
this.reconnectAttempts++; case 'challengeCancelled': {
setTimeout(() => { this.connect(); }, this.reconnectDelay); const challengeId = message['challengeId'] as string | undefined;
if (challengeId) {
this.challengeEventService.removeChallenge(challengeId);
}
break;
}
} }
}
private attemptReconnect(): void {
if (this.intentionalClose || this.reconnectAttempts >= this.maxReconnectAttempts) return;
this.reconnectAttempts++;
setTimeout(() => {
this.connect();
}, this.reconnectDelay);
}
} }
+3 -6
View File
@@ -92,11 +92,8 @@ export class GameApiService {
} }
streamGame(gameId: string): Observable<GameStreamEvent> { streamGame(gameId: string): Observable<GameStreamEvent> {
const token = localStorage.getItem('token'); const wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`;
let wsUrl = `${this.resolveWsBase()}${this.apiPath}/${gameId}/ws`; const token = localStorage.getItem('token') ?? '';
if (token) { return this.streamHandler.createGameStream(wsUrl, gameId, token);
wsUrl += `?token=${encodeURIComponent(token)}`;
}
return this.streamHandler.createGameStream(wsUrl, gameId);
} }
} }
+22
View File
@@ -0,0 +1,22 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface JoinTournamentResponse {
botId: string;
difficulty: string;
status: string;
}
@Injectable({ providedIn: 'root' })
export class OfficialBotService {
private readonly http = inject(HttpClient);
private readonly base = '/api/bots/official';
joinTournament(tournamentId: string, difficulty: string): Observable<JoinTournamentResponse> {
return this.http.post<JoinTournamentResponse>(`${this.base}/join-tournament`, {
tournamentId,
difficulty,
});
}
}
+3 -2
View File
@@ -6,7 +6,7 @@ const WS_CONNECT_TIMEOUT_MS = 3000;
@Injectable({ providedIn: 'root' }) @Injectable({ providedIn: 'root' })
export class StreamHandlerService { export class StreamHandlerService {
createGameStream(wsUrl: string, gameId: string): Observable<GameStreamEvent> { createGameStream(wsUrl: string, gameId: string, token: string): Observable<GameStreamEvent> {
return new Observable<GameStreamEvent>((observer) => { return new Observable<GameStreamEvent>((observer) => {
const ws = new WebSocket(wsUrl); const ws = new WebSocket(wsUrl);
let connected = false; let connected = false;
@@ -14,7 +14,7 @@ export class StreamHandlerService {
const emitErrorEvent = (message: string): void => { const emitErrorEvent = (message: string): void => {
const errorEvent: ErrorEvent = { const errorEvent: ErrorEvent = {
type: 'error', type: 'error',
error: { code: 'STREAM_ERROR', message } error: { code: 'STREAM_ERROR', message },
}; };
observer.next(errorEvent); observer.next(errorEvent);
}; };
@@ -36,6 +36,7 @@ export class StreamHandlerService {
connected = true; connected = true;
clearTimeout(connectionTimeoutId); clearTimeout(connectionTimeoutId);
console.log(`[StreamHandler] WebSocket connected for ${gameId}`); console.log(`[StreamHandler] WebSocket connected for ${gameId}`);
ws.send(JSON.stringify({ type: 'auth', token }));
}; };
ws.onmessage = (message) => { ws.onmessage = (message) => {
@@ -0,0 +1,31 @@
import { Injectable, inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
export interface ExternalTournamentServer {
id: string;
label: string;
url: string;
}
export interface ExternalTournamentServerList {
servers: ExternalTournamentServer[];
}
@Injectable({ providedIn: 'root' })
export class TournamentServerService {
private readonly http = inject(HttpClient);
private readonly base = '/api/tournament/servers';
list(): Observable<ExternalTournamentServerList> {
return this.http.get<ExternalTournamentServerList>(this.base);
}
register(label: string, url: string): Observable<ExternalTournamentServer> {
return this.http.post<ExternalTournamentServer>(this.base, { label, url });
}
remove(id: string): Observable<void> {
return this.http.delete<void>(`${this.base}/${id}`);
}
}
+2 -4
View File
@@ -40,10 +40,8 @@ export class TournamentService {
return this.http.post<Tournament>(`${this.base}/${id}/start`, null); return this.http.post<Tournament>(`${this.base}/${id}/start`, null);
} }
joinWithBotToken(id: string, botToken: string): Observable<void> { join(id: string, botId: string, botName: string): Observable<void> {
return this.http.post<void>(`${this.base}/${id}/join`, null, { return this.http.post<void>(`${this.base}/${id}/join`, { botId, botName });
headers: new HttpHeaders({ Authorization: `Bearer ${botToken}` })
});
} }
roundPairings(id: string, round: number): Observable<RoundPairings> { roundPairings(id: string, round: number): Observable<RoundPairings> {
+6 -2
View File
@@ -6,6 +6,10 @@
"outDir": "./out-tsc/app", "outDir": "./out-tsc/app",
"types": [] "types": []
}, },
"include": ["src/**/*.ts"], "include": [
"exclude": ["src/**/*.spec.ts"] "src/**/*.ts"
],
"exclude": [
"src/**/*.spec.ts"
]
} }
+7 -2
View File
@@ -4,7 +4,12 @@
"extends": "./tsconfig.json", "extends": "./tsconfig.json",
"compilerOptions": { "compilerOptions": {
"outDir": "./out-tsc/spec", "outDir": "./out-tsc/spec",
"types": ["jasmine"] "types": [
"jasmine"
]
}, },
"include": ["src/**/*.d.ts", "src/**/*.spec.ts"] "include": [
"src/**/*.d.ts",
"src/**/*.spec.ts"
]
} }
+2 -2
View File
@@ -1,3 +1,3 @@
MAJOR=0 MAJOR=0
MINOR=3 MINOR=4
PATCH=0 PATCH=2