Compare commits
8
Commits
a24632d7ba
..
v2.0.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
46bfa09745 | ||
|
|
c5f9fa5e49 | ||
|
|
512aa58949 | ||
|
|
0757e0df76 | ||
|
|
1d660a3c40 | ||
|
|
326575e55d | ||
|
|
349662a055 | ||
|
|
b68772ee7c |
No files matched your search
@@ -0,0 +1,68 @@
|
|||||||
|
---
|
||||||
|
name: angular-feature
|
||||||
|
description: Implement or modify Angular and Ionic features in Court Pilot while following its standalone-component, Dexie service, signal, localization, styling, and testing conventions. Use for changes to pages, routes, services, settings, and user-facing web or desktop UI behavior.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Angular Feature Workflow
|
||||||
|
|
||||||
|
Use this workflow for Angular/Ionic feature work in Court Pilot.
|
||||||
|
|
||||||
|
## 1. Establish the behavior
|
||||||
|
|
||||||
|
1. Read `AGENTS.md`.
|
||||||
|
2. Identify the affected route in `src/app/app.routes.ts` or `src/app/tabs/tabs.routes.ts`.
|
||||||
|
3. Read the page component, template, SCSS, related services, and adjacent specs.
|
||||||
|
4. Check `docs/domain-glossary.md` before changing rotation or assignment behavior.
|
||||||
|
5. Ask the maintainer rather than inventing a rule when a domain question remains unresolved.
|
||||||
|
|
||||||
|
## 2. Choose the correct layer
|
||||||
|
|
||||||
|
- Keep rendering and user interaction in the page/component.
|
||||||
|
- Keep table access and reusable persistence operations in `src/app/services/`.
|
||||||
|
- Use Angular signals for reactive state and `computed()` for derived state.
|
||||||
|
- Follow existing `liveQuery()` plus `toSignal()` patterns for reactive Dexie queries.
|
||||||
|
- If one operation writes multiple tables, consider a Dexie transaction so failure cannot leave a partial move.
|
||||||
|
- Treat `db.ts` changes as schema work. Add a new schema version for released-data changes; never rewrite an existing released schema in place.
|
||||||
|
|
||||||
|
## 3. Follow UI conventions
|
||||||
|
|
||||||
|
- Use standalone components.
|
||||||
|
- Import Ionic controls from `@ionic/angular/standalone` and list them in component `imports`.
|
||||||
|
- Use `inject()` for dependencies.
|
||||||
|
- Register new Ionicons with `addIcons()`.
|
||||||
|
- Keep page HTML and SCSS beside the page TypeScript file.
|
||||||
|
- Format touched files with the root `.prettierrc`; do not reformat unrelated files or apply personal overrides.
|
||||||
|
- Preserve strict typing; do not use `any` to bypass domain modeling.
|
||||||
|
|
||||||
|
## 4. Localize user-facing text
|
||||||
|
|
||||||
|
1. Add a typed key to both `en` and `hu` in `src/app/i18n/translations.ts`.
|
||||||
|
2. Keep interpolation placeholders identical in both languages.
|
||||||
|
3. Use `TranslatePipe` in templates.
|
||||||
|
4. Use `TranslationService` for alerts and TypeScript-created messages.
|
||||||
|
5. If the Hungarian translation is unknown, ask for it instead of inventing production copy.
|
||||||
|
|
||||||
|
## 5. Protect platform behavior
|
||||||
|
|
||||||
|
- Guard Tauri-only APIs with `isTauri()` when browser execution is possible.
|
||||||
|
- Remember that Linux updates are handled by Snap, while Windows and macOS use the Tauri updater.
|
||||||
|
- Do not assume Capacitor targets are active until their intended support is confirmed.
|
||||||
|
|
||||||
|
## 6. Add validation
|
||||||
|
|
||||||
|
- Add or update adjacent `*.spec.ts` coverage for changed behavior.
|
||||||
|
- Prefer behavior assertions over creation-only tests.
|
||||||
|
- Run the narrowest relevant test first when supported by the current Karma setup.
|
||||||
|
- Run `npm run lint`.
|
||||||
|
- Run `npm run build`.
|
||||||
|
- Report every command actually run and any validation that was unavailable.
|
||||||
|
|
||||||
|
## Completion checklist
|
||||||
|
|
||||||
|
- [ ] The implementation follows an existing page/service pattern.
|
||||||
|
- [ ] Domain invariants remain intact.
|
||||||
|
- [ ] New user-facing text exists in English and Hungarian.
|
||||||
|
- [ ] Persistence changes have an explicit migration strategy.
|
||||||
|
- [ ] Relevant tests were updated.
|
||||||
|
- [ ] Lint and build results were reported.
|
||||||
|
- [ ] Documentation was updated if behavior or architecture changed.
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
---
|
||||||
|
name: database-change
|
||||||
|
description: Modify Court Pilot's Dexie entities, indexes, or persisted data safely with a new schema version, explicit migration behavior, domain-invariant protection, tests, and documentation. Use whenever a task changes db.ts or the shape or meaning of stored records.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Database Change Workflow
|
||||||
|
|
||||||
|
Use this skill for any change to persisted Court Pilot data.
|
||||||
|
|
||||||
|
## Establish the migration
|
||||||
|
|
||||||
|
1. Read `AGENTS.md`, `docs/architecture.md`, and `docs/domain-glossary.md`.
|
||||||
|
2. Read `db.ts` and every service that reads or writes the affected tables.
|
||||||
|
3. Determine whether the change affects existing released records.
|
||||||
|
4. Define the old shape, new shape, defaults, invalid-data handling, and rollback limitations before editing.
|
||||||
|
5. Ask the maintainer if the migration depends on an undefined domain rule.
|
||||||
|
|
||||||
|
## Version the schema safely
|
||||||
|
|
||||||
|
- Never modify an existing released `db.version(...)` declaration in place.
|
||||||
|
- Add the next integer schema version.
|
||||||
|
- Declare the complete store schema required by that version.
|
||||||
|
- Use an explicit Dexie upgrade callback when records need transformation.
|
||||||
|
- Keep migration logic deterministic and safe to run once per database upgrade.
|
||||||
|
- Do not delete user data unless the task explicitly requires and documents it.
|
||||||
|
|
||||||
|
## Preserve domain invariants
|
||||||
|
|
||||||
|
After migration, every court official must be in exactly one operational state:
|
||||||
|
|
||||||
|
- not on duty
|
||||||
|
- waiting as umpire
|
||||||
|
- waiting as service judge
|
||||||
|
- assigned as umpire
|
||||||
|
- assigned as service judge
|
||||||
|
|
||||||
|
Preserve FIFO queue ordering. Normalize order values if the migration can create gaps or conflicts. Removing assignments because courts no longer exist must leave those officials not on duty unless the requested behavior says otherwise.
|
||||||
|
|
||||||
|
Use a Dexie transaction for multi-table changes that must succeed or fail together.
|
||||||
|
|
||||||
|
## Update all affected layers
|
||||||
|
|
||||||
|
- Update TypeScript entity interfaces and table declarations.
|
||||||
|
- Update services rather than moving raw database access into pages.
|
||||||
|
- Update computed UI state and templates when the new data affects presentation.
|
||||||
|
- Update both English and Hungarian translations for new user-facing concepts.
|
||||||
|
- Document the new schema version and migration behavior in `docs/architecture.md`.
|
||||||
|
|
||||||
|
## Validate
|
||||||
|
|
||||||
|
- Add migration coverage using a database created at the previous version when practical.
|
||||||
|
- Add tests for changed service behavior and domain invariants.
|
||||||
|
- Run `npm run lint`.
|
||||||
|
- Run `npm run build`.
|
||||||
|
- Run the relevant non-watching tests.
|
||||||
|
- Report the old and new schema versions and all commands actually run.
|
||||||
@@ -0,0 +1,138 @@
|
|||||||
|
---
|
||||||
|
name: prepare-release
|
||||||
|
description: Prepare a Court Pilot desktop release by recommending a semantic version from commits or code changes, letting the developer confirm or override it, synchronizing all version locations, validating builds, and reviewing Windows, macOS, CrabNebula, and Linux Snap publication. Use whenever a developer asks to prepare or version a release.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prepare Release
|
||||||
|
|
||||||
|
Use this skill only when the user explicitly asks to prepare a release. A release-preparation request authorizes creating the local release tag after the developer confirms the version and the synchronized version changes are committed and validated. It does not authorize creating a commit, pushing the tag, or publishing unless the developer explicitly requests those actions.
|
||||||
|
|
||||||
|
## Recommend the version
|
||||||
|
|
||||||
|
Do not change version files immediately. First determine the latest reachable release tag and the current application version, then review all changes since that tag.
|
||||||
|
|
||||||
|
Use Conventional Commits when the commit history is clear:
|
||||||
|
|
||||||
|
- `type!:` or a `BREAKING CHANGE:` footer → major bump
|
||||||
|
- `feat:` or `feat(scope):` → minor bump
|
||||||
|
- `fix:` or `perf:` → patch bump
|
||||||
|
- `docs:`, `test:`, `refactor:`, `build:`, `ci:`, `chore:`, and style-only changes do not require a release bump by themselves unless they alter shipped behavior or packaging
|
||||||
|
|
||||||
|
The highest required bump wins. For example, a range containing both `fix:` and `feat:` recommends a minor bump.
|
||||||
|
|
||||||
|
This repository contains commits that do not always follow Conventional Commits. If commit messages are missing, inconsistent, or ambiguous:
|
||||||
|
|
||||||
|
1. Inspect the diff and changed files from the latest release tag through `HEAD`.
|
||||||
|
2. Inspect relevant implementation and configuration—not only the diff summary—to determine shipped behavior.
|
||||||
|
3. Treat incompatible user data, behavior, configuration, or supported-platform changes as major.
|
||||||
|
4. Treat backward-compatible functionality as minor.
|
||||||
|
5. Treat backward-compatible corrections, performance improvements, packaging fixes, or security fixes as patch.
|
||||||
|
6. Mention unreleased working-tree changes separately; do not silently base a release solely on uncommitted work.
|
||||||
|
|
||||||
|
If there is no prior tag, inspect the complete relevant history and ask whether the release should establish the chosen initial version.
|
||||||
|
|
||||||
|
Present the developer with:
|
||||||
|
|
||||||
|
- current application version
|
||||||
|
- latest release tag
|
||||||
|
- relevant commits or code changes
|
||||||
|
- recommended version
|
||||||
|
- recommended bump type and concise reasoning
|
||||||
|
|
||||||
|
Then explicitly ask the developer to either accept the recommendation or enter a different version. Do not edit versions until they answer.
|
||||||
|
|
||||||
|
During release version confirmation, ask whether to run the test suite or skip it. Running tests is the default; skip them only when the developer explicitly chooses to skip. Record any skipped tests in the release validation report. Lint, build, and Cargo checks still apply.
|
||||||
|
|
||||||
|
Validate the selected value as Semantic Versioning. It should normally be greater than both the current released version and latest release tag. Warn and request confirmation for a lower, equal, or prerelease version.
|
||||||
|
|
||||||
|
Release tags use the selected value prefixed with `v`, such as `v1.2.0`.
|
||||||
|
|
||||||
|
## Synchronize the confirmed version manually
|
||||||
|
|
||||||
|
After the developer accepts the recommendation or supplies a manual version, set that exact version in:
|
||||||
|
|
||||||
|
1. `package.json`
|
||||||
|
2. The root package entries in `package-lock.json`
|
||||||
|
3. `src-tauri/tauri.conf.json`
|
||||||
|
4. `src-tauri/Cargo.toml`
|
||||||
|
5. The `court-pilot` entry in `src-tauri/Cargo.lock`, normally refreshed by Cargo
|
||||||
|
6. `snap/snapcraft.yaml`
|
||||||
|
7. The version displayed in `src/app/tab3/tab3.page.html`
|
||||||
|
|
||||||
|
Do not introduce a release-version script unless the user requests automation.
|
||||||
|
|
||||||
|
After editing `Cargo.toml`, run `cargo check` from `src-tauri/` and confirm Cargo refreshed its lockfile metadata.
|
||||||
|
|
||||||
|
## Review release configuration
|
||||||
|
|
||||||
|
- Confirm the tag version matches all files.
|
||||||
|
- Confirm the Tauri updater endpoint and public key are intentional.
|
||||||
|
- Confirm Linux still skips the in-app updater because Snap manages updates.
|
||||||
|
- Confirm `.github/workflows/release.yml` still builds Windows, both macOS architectures, and Linux Snap.
|
||||||
|
- Do not expose or print signing, CrabNebula, or Snap Store secrets.
|
||||||
|
- Note that platform packages cannot all be validated on one development host.
|
||||||
|
|
||||||
|
## Validate
|
||||||
|
|
||||||
|
Honor the developer's test choice: run the test command by default and omit it only when they explicitly chose to skip. Activate the required Node.js version, then run the checks in order. Keep `nvm use lts/jod` in the same shell session as the npm commands:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
nvm use lts/jod
|
||||||
|
npm ci
|
||||||
|
npm test -- --configuration=ci
|
||||||
|
npm run lint
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
From `src-tauri/`, run:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
Run a local Tauri build only when the host has the required native dependencies. Report platforms that were not built locally.
|
||||||
|
|
||||||
|
## Create the release tag
|
||||||
|
|
||||||
|
Do not tag uncommitted version changes: a tag points to a commit, not to the working tree. After validation:
|
||||||
|
|
||||||
|
1. Verify all synchronized version files are included in `HEAD` and the tracked working tree is clean. If they are not committed, ask the developer to commit them or explicitly authorize the agent to create the release commit.
|
||||||
|
2. Verify that `v<version>` does not already exist locally.
|
||||||
|
3. Create a lightweight tag, matching this repository's existing release-tag style:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GIT_EDITOR=true git tag v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
For version `1.2.0`, run exactly:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GIT_EDITOR=true git tag v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Verify that the tag resolves to the intended release commit.
|
||||||
|
5. Report the created local tag.
|
||||||
|
|
||||||
|
Do not push automatically. Explain that pushing the tag triggers `.github/workflows/release.yml`, then ask for explicit permission before running:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin v<version>
|
||||||
|
```
|
||||||
|
|
||||||
|
Never move, overwrite, or force-push an existing release tag without explicit maintainer instructions.
|
||||||
|
|
||||||
|
## Completion report
|
||||||
|
|
||||||
|
Report:
|
||||||
|
|
||||||
|
- latest release tag and current version used for comparison
|
||||||
|
- recommended bump and reasoning
|
||||||
|
- version selected by the developer
|
||||||
|
- files whose versions were synchronized
|
||||||
|
- test, lint, Angular build, and Cargo results, explicitly noting any tests skipped at the developer's request
|
||||||
|
- platform builds not run locally
|
||||||
|
- local release tag created and its target commit
|
||||||
|
- whether the tag remains local or was explicitly pushed
|
||||||
|
- any workflow or updater concerns
|
||||||
|
|
||||||
|
Stop before committing, pushing, or publishing unless the user explicitly asks for those actions.
|
||||||
+1
-5
@@ -8,8 +8,4 @@
|
|||||||
# You can see what browsers were selected by your queries by running:
|
# You can see what browsers were selected by your queries by running:
|
||||||
# npx browserslist
|
# npx browserslist
|
||||||
|
|
||||||
Chrome >=107
|
baseline widely available on 2026-05-07
|
||||||
Firefox >=106
|
|
||||||
Edge >=107
|
|
||||||
Safari >=16.1
|
|
||||||
iOS >=16.1
|
|
||||||
@@ -1,46 +0,0 @@
|
|||||||
{
|
|
||||||
"root": true,
|
|
||||||
"ignorePatterns": ["projects/**/*"],
|
|
||||||
"overrides": [
|
|
||||||
{
|
|
||||||
"files": ["*.ts"],
|
|
||||||
"parserOptions": {
|
|
||||||
"project": ["tsconfig.json"],
|
|
||||||
"createDefaultProgram": true
|
|
||||||
},
|
|
||||||
"extends": [
|
|
||||||
"plugin:@angular-eslint/recommended",
|
|
||||||
"plugin:@angular-eslint/template/process-inline-templates"
|
|
||||||
],
|
|
||||||
"rules": {
|
|
||||||
"@angular-eslint/component-class-suffix": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"suffixes": ["Page", "Component"]
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@angular-eslint/component-selector": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"type": "element",
|
|
||||||
"prefix": "app",
|
|
||||||
"style": "kebab-case"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"@angular-eslint/directive-selector": [
|
|
||||||
"error",
|
|
||||||
{
|
|
||||||
"type": "attribute",
|
|
||||||
"prefix": "app",
|
|
||||||
"style": "camelCase"
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
"files": ["*.html"],
|
|
||||||
"extends": ["plugin:@angular-eslint/template/recommended"],
|
|
||||||
"rules": {}
|
|
||||||
}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
@@ -22,7 +22,7 @@ jobs:
|
|||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Create CrabNebula draft release
|
- name: Create CrabNebula draft release
|
||||||
uses: crabnebula-dev/cloud-release@v0
|
uses: crabnebula-dev/cloud-release@v0
|
||||||
@@ -40,7 +40,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -73,6 +73,56 @@ jobs:
|
|||||||
api-key: ${{ secrets.CN_API_KEY }}
|
api-key: ${{ secrets.CN_API_KEY }}
|
||||||
path: ./src-tauri
|
path: ./src-tauri
|
||||||
|
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
# macOS Tauri builds (Apple Silicon and Intel)
|
||||||
|
# ---------------------------------------------------------
|
||||||
|
macos:
|
||||||
|
needs: draft
|
||||||
|
|
||||||
|
runs-on: macos-latest
|
||||||
|
strategy:
|
||||||
|
fail-fast: false
|
||||||
|
matrix:
|
||||||
|
target:
|
||||||
|
- aarch64-apple-darwin
|
||||||
|
- x86_64-apple-darwin
|
||||||
|
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
|
- name: Setup Node
|
||||||
|
uses: actions/setup-node@v4
|
||||||
|
with:
|
||||||
|
node-version: 22
|
||||||
|
cache: npm
|
||||||
|
|
||||||
|
- name: Setup Rust
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
toolchain: stable
|
||||||
|
target: ${{ matrix.target }}
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Install dependencies
|
||||||
|
run: npm ci
|
||||||
|
|
||||||
|
- name: Install Ionic CLI
|
||||||
|
run: npm install -g @ionic/cli
|
||||||
|
|
||||||
|
- name: Build Tauri macOS packages
|
||||||
|
env:
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY }}
|
||||||
|
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
|
||||||
|
run: npm run tauri build -- --target ${{ matrix.target }} --bundles app,dmg
|
||||||
|
|
||||||
|
- name: Upload release assets to CrabNebula
|
||||||
|
uses: crabnebula-dev/cloud-release@v0
|
||||||
|
with:
|
||||||
|
command: release upload ${{ env.CN_APPLICATION }} --framework tauri
|
||||||
|
api-key: ${{ secrets.CN_API_KEY }}
|
||||||
|
path: ./src-tauri
|
||||||
|
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
# Linux Tauri build + Snap
|
# Linux Tauri build + Snap
|
||||||
# ---------------------------------------------------------
|
# ---------------------------------------------------------
|
||||||
@@ -83,7 +133,7 @@ jobs:
|
|||||||
|
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Setup Node
|
- name: Setup Node
|
||||||
uses: actions/setup-node@v4
|
uses: actions/setup-node@v4
|
||||||
@@ -163,12 +213,13 @@ jobs:
|
|||||||
publish:
|
publish:
|
||||||
needs:
|
needs:
|
||||||
- windows
|
- windows
|
||||||
|
- macos
|
||||||
- snap
|
- snap
|
||||||
|
|
||||||
runs-on: ubuntu-22.04
|
runs-on: ubuntu-22.04
|
||||||
|
|
||||||
steps:
|
steps:
|
||||||
- uses: actions/checkout@v4
|
- uses: actions/checkout@v5
|
||||||
|
|
||||||
- name: Publish CrabNebula release
|
- name: Publish CrabNebula release
|
||||||
uses: crabnebula-dev/cloud-release@v0
|
uses: crabnebula-dev/cloud-release@v0
|
||||||
|
|||||||
Vendored
+3
-2
@@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"recommendations": [
|
"recommendations": [
|
||||||
"Webnative.webnative"
|
"Webnative.webnative",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
# Court Pilot Agent Instructions
|
||||||
|
|
||||||
|
## Project purpose
|
||||||
|
|
||||||
|
Court Pilot is a badminton umpire rotation and court-assignment application. It manages court officials, waiting queues, active court assignments, and optional service judges.
|
||||||
|
|
||||||
|
## Technology
|
||||||
|
|
||||||
|
- Angular 22 with standalone components and strict TypeScript
|
||||||
|
- Ionic Angular 9 UI components
|
||||||
|
- Angular signals and RxJS
|
||||||
|
- Dexie 4 / IndexedDB for local persistence
|
||||||
|
- Angular CDK drag and drop
|
||||||
|
- Tauri 2 for desktop packaging and updates
|
||||||
|
- Capacitor configuration is present, but native mobile targets are not currently committed
|
||||||
|
- Jasmine and Karma for unit tests
|
||||||
|
- SCSS for styling
|
||||||
|
|
||||||
|
## Important paths
|
||||||
|
|
||||||
|
- `src/app/` — Angular application
|
||||||
|
- `src/app/tab1/` — court rotation and assignments (`/tabs/pilot`)
|
||||||
|
- `src/app/tab2/` — court-official management (`/tabs/umpires`)
|
||||||
|
- `src/app/tab3/` — settings (`/tabs/settings`)
|
||||||
|
- `src/app/services/` — persistence and domain services
|
||||||
|
- `src/app/i18n/` — typed English and Hungarian translations
|
||||||
|
- `db.ts` — Dexie entities, database declaration, and schema version
|
||||||
|
- `src/theme/` — Ionic theme variables
|
||||||
|
- `src-tauri/` — Tauri/Rust desktop wrapper
|
||||||
|
- `.github/workflows/release.yml` — tagged desktop release workflow
|
||||||
|
- `snap/` — Linux Snap packaging
|
||||||
|
- `docs/` — architecture and domain documentation
|
||||||
|
|
||||||
|
## Generated and dependency directories
|
||||||
|
|
||||||
|
Do not manually edit generated output or installed dependencies:
|
||||||
|
|
||||||
|
- `node_modules/`
|
||||||
|
- `.angular/`
|
||||||
|
- `www/`
|
||||||
|
- `src-tauri/target/`
|
||||||
|
- `src-tauri/gen/`
|
||||||
|
- `snap-root/` when generated by release tooling
|
||||||
|
|
||||||
|
## Implementation conventions
|
||||||
|
|
||||||
|
- Preserve strict TypeScript and strict Angular template checks.
|
||||||
|
- Use standalone Angular components and import Ionic components from `@ionic/angular`.
|
||||||
|
- Prefer `inject()` for dependency injection, matching current components and services.
|
||||||
|
- Keep persistent-data access in services rather than directly in page components.
|
||||||
|
- Expose reactive Dexie queries through signals when following an existing service pattern.
|
||||||
|
- Use `readonly` for injected dependencies, signals, and derived state when reassignment is unnecessary.
|
||||||
|
- Use `computed()` for state derived from signals.
|
||||||
|
- Keep page-specific templates and SCSS beside their page component.
|
||||||
|
- Component selectors use the `app-` prefix and kebab-case.
|
||||||
|
- Component classes end in `Page` or `Component`.
|
||||||
|
- Directive selectors use the `app` prefix and camelCase.
|
||||||
|
- Format touched source and documentation files with Prettier. The root `.prettierrc` is the shared configuration; do not override it with personal formatting preferences.
|
||||||
|
- Avoid formatting unrelated files or lines as part of a focused change.
|
||||||
|
- Do not add dependencies unless the task clearly requires one.
|
||||||
|
|
||||||
|
## Domain and persistence rules
|
||||||
|
|
||||||
|
- Treat changes to `db.ts` as database migrations, not ordinary interface edits.
|
||||||
|
- Never change an existing Dexie schema version in place after release. Add a new `db.version(...)` declaration and document migration behavior.
|
||||||
|
- “Court official” is the umbrella term; “umpire” and “service judge” are roles.
|
||||||
|
- Every official must be in exactly one state: not on duty, waiting as umpire, waiting as service judge, assigned as umpire, or assigned as service judge.
|
||||||
|
- Preserve FIFO queue ordering by numeric `order`; the lowest order is assigned next.
|
||||||
|
- After a court rotation with service judges enabled, the former umpire joins the service-judge queue and the former service judge joins the umpire queue.
|
||||||
|
- When the court count is reduced, remove assignments on eliminated court numbers so those officials become not on duty.
|
||||||
|
- Confirm any other uncertain rotation rule with the maintainer; do not infer business rules solely from UI behavior.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
- Do not hardcode new user-facing text when it should be translated.
|
||||||
|
- Write both the English and Hungarian copy for every new user-facing string.
|
||||||
|
- Add every translation key to both `en` and `hu` in `src/app/i18n/translations.ts`.
|
||||||
|
- Use `TranslatePipe` in templates and `TranslationService` in TypeScript.
|
||||||
|
- Keep placeholders consistent between languages.
|
||||||
|
|
||||||
|
## Platform-specific behavior
|
||||||
|
|
||||||
|
- Guard Tauri APIs with `isTauri()` when code may execute in a browser.
|
||||||
|
- Linux updates are delivered through Snap; the in-app Tauri updater is intentionally skipped on Linux.
|
||||||
|
- Desktop updater changes must account for Windows and macOS release artifacts and the CrabNebula endpoint.
|
||||||
|
- Browser, Windows, macOS, and Linux Snap are supported targets.
|
||||||
|
- Android and iOS are not supported or planned at this stage; Capacitor configuration is retained only for possible future use.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
- Node.js must use the `lts/jod` NVM alias, as declared in `.nvmrc`.
|
||||||
|
- Documented commands target Bash on Linux, macOS, or Windows WSL. Use Bash explicitly for agent commands; fish's `nvm.fish` is unavailable in Bash.
|
||||||
|
- Before every npm command, explicitly load Bash NVM and select Node in the same shell session: `export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"; . "$NVM_DIR/nvm.sh" && nvm use lts/jod && npm run build`. Do not rely on `.bashrc` loading in noninteractive shells or fall back to system Node.
|
||||||
|
- If `${NVM_DIR:-$HOME/.nvm}/nvm.sh` is missing, ask the user for permission before installing Bash NVM. Follow `docs/development.md#shell-and-nvm-setup` after approval. Keep existing fish NVM separate.
|
||||||
|
- Install exact dependencies: `npm ci`
|
||||||
|
- Start the Ionic development server: `npm start`
|
||||||
|
- Build the web application: `npm run build`
|
||||||
|
- Run unit tests: `npm test`
|
||||||
|
- Run linting: `npm run lint`
|
||||||
|
- Run Tauri CLI commands: `npm run tauri -- <command>`
|
||||||
|
|
||||||
|
Tauri development intentionally uses port `8103`. `src-tauri/tauri.conf.json` expects that port, so its `beforeDevCommand` must start Ionic on `8103`; the current `npm start` script does not explicitly select it. Verify the full `npm run tauri dev` flow before claiming it works.
|
||||||
|
|
||||||
|
## Release versioning
|
||||||
|
|
||||||
|
For every release, keep the same semantic version in:
|
||||||
|
|
||||||
|
- `package.json` (and the generated root package entry in `package-lock.json`)
|
||||||
|
- `src-tauri/tauri.conf.json`
|
||||||
|
- `src-tauri/Cargo.toml` (and the generated root package entry in `src-tauri/Cargo.lock`)
|
||||||
|
- `snap/snapcraft.yaml`
|
||||||
|
|
||||||
|
The version displayed in the Settings UI must match these values. Before editing them, inspect Conventional Commits since the latest release tag and recommend the appropriate SemVer bump. If messages are ambiguous, inspect the code/configuration diff since that tag. Present the recommendation and reasoning, allow the developer to enter a different version, and wait for confirmation before changing files. Follow the manual checklist in `docs/development.md`; do not introduce release automation unless requested.
|
||||||
|
|
||||||
|
During release version confirmation, ask whether to run the test suite or skip it. Running tests is the default; skip them only when the developer explicitly chooses to skip. Record any skipped tests in the release validation report. Lint, build, and Cargo checks still apply.
|
||||||
|
|
||||||
|
After the confirmed version is synchronized, committed, and validated, create the matching lightweight local tag with `GIT_EDITOR=true git tag v<version>`. Never tag uncommitted version changes. Do not push the tag without explicit permission because pushing it triggers the release workflow.
|
||||||
|
|
||||||
|
## Validation expectations
|
||||||
|
|
||||||
|
Choose checks based on the affected area:
|
||||||
|
|
||||||
|
1. Run focused or related unit tests for behavior changes where practical.
|
||||||
|
2. Run `npm run lint` for TypeScript/template changes.
|
||||||
|
3. Run `npm run build` for application or build-configuration changes.
|
||||||
|
4. For Rust changes, run `cargo check` from `src-tauri/`.
|
||||||
|
5. For Tauri configuration or release changes, explain which platform-specific builds were not available locally.
|
||||||
|
6. Report commands actually run and any pre-existing failures; never claim unrun checks passed.
|
||||||
|
|
||||||
|
## Working practices
|
||||||
|
|
||||||
|
- Read the relevant page, service, and tests before changing behavior.
|
||||||
|
- Make focused changes and preserve unrelated user work.
|
||||||
|
- Update tests and documentation when changing behavior, data models, workflows, or commands.
|
||||||
|
- Prefer root-cause fixes over UI-only workarounds.
|
||||||
|
- Ask for clarification when a change depends on an undocumented tournament or rotation rule.
|
||||||
@@ -0,0 +1,157 @@
|
|||||||
|
# Court Pilot
|
||||||
|
|
||||||
|
Court Pilot is a local-first web and desktop application for managing badminton court officials, waiting rotations, and court assignments. It supports optional service judges and an English/Hungarian interface.
|
||||||
|
|
||||||
|
## Current stack
|
||||||
|
|
||||||
|
- Angular 22 and Ionic Angular 9
|
||||||
|
- Dexie / IndexedDB local persistence
|
||||||
|
- Angular CDK drag and drop
|
||||||
|
- Tauri 2 desktop application
|
||||||
|
- Jasmine and Karma tests
|
||||||
|
|
||||||
|
## Main workflows
|
||||||
|
|
||||||
|
The application has three routes:
|
||||||
|
|
||||||
|
- **Pilot** — assign officials to courts and manage waiting rotations
|
||||||
|
- **Court Officials** — maintain the official roster
|
||||||
|
- **Settings** — configure court count, service judges, confirmation alerts, and language
|
||||||
|
|
||||||
|
Application data is stored locally in the browser/webview through IndexedDB. No server-side API is currently present in this repository.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
For web development:
|
||||||
|
|
||||||
|
- NVM with the Node.js `lts/jod` release (Node.js 22)
|
||||||
|
- npm
|
||||||
|
- Ionic CLI available on the command line
|
||||||
|
|
||||||
|
For desktop development, also install:
|
||||||
|
|
||||||
|
- Rust stable; the crate declares a minimum Rust version of 1.77.2
|
||||||
|
- The platform prerequisites required by Tauri 2
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
These commands target **Bash** (Linux, macOS, or Windows WSL). From fish,
|
||||||
|
start `bash` first. Follow the [shell and NVM setup](docs/development.md#shell-and-nvm-setup)
|
||||||
|
before continuing. If Bash NVM is missing, agents must ask permission before
|
||||||
|
installing it. `.nvmrc` declares `lts/jod`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm install lts/jod
|
||||||
|
nvm use lts/jod && npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
Each npm command block loads Bash NVM explicitly and selects the required Node
|
||||||
|
version, including in fresh noninteractive shells.
|
||||||
|
|
||||||
|
If Ionic CLI is not already installed:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm install --global @ionic/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
## Web development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the Angular application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Run linting and tests:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm run lint
|
||||||
|
nvm use lts/jod && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
`npm test` starts Karma in its configured mode and may remain open in watch mode.
|
||||||
|
|
||||||
|
## Code formatting
|
||||||
|
|
||||||
|
Use the Prettier plugin in your editor to format files you change. The repository already provides `.prettierrc` at its root, and compatible editor plugins discover it automatically. Use the project configuration rather than personal Prettier overrides, and avoid reformatting unrelated files.
|
||||||
|
|
||||||
|
For VS Code, the recommended extension is `esbenp.prettier-vscode`. Use **Format Document** or enable format-on-save after selecting Prettier as the formatter.
|
||||||
|
|
||||||
|
## Desktop development
|
||||||
|
|
||||||
|
The Tauri CLI is available through:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm run tauri -- <command>
|
||||||
|
```
|
||||||
|
|
||||||
|
For example:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm run tauri -- dev
|
||||||
|
nvm use lts/jod && npm run tauri -- build
|
||||||
|
```
|
||||||
|
|
||||||
|
> Tauri development intentionally uses `http://localhost:8103`. The current `npm start` script does not explicitly select that port, so verify that Ionic is listening on `8103` when using `tauri dev`.
|
||||||
|
|
||||||
|
## Persistence
|
||||||
|
|
||||||
|
The database is declared in `db.ts` and currently uses Dexie schema version 1. Data includes:
|
||||||
|
|
||||||
|
- court officials
|
||||||
|
- active umpire assignments
|
||||||
|
- active service-judge assignments
|
||||||
|
- umpire and service-judge waiting queues
|
||||||
|
- application settings
|
||||||
|
|
||||||
|
Released schema changes must use a new Dexie version and an explicit migration strategy.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
Translations are defined in `src/app/i18n/translations.ts`. English (`en`) and Hungarian (`hu`) are currently supported. Add new user-facing keys to both languages.
|
||||||
|
|
||||||
|
## Desktop releases
|
||||||
|
|
||||||
|
Pushing a tag matching `v*` triggers `.github/workflows/release.yml`. The workflow:
|
||||||
|
|
||||||
|
1. Creates a CrabNebula draft release.
|
||||||
|
2. Builds and uploads Windows packages.
|
||||||
|
3. Builds and uploads Apple Silicon and Intel macOS packages.
|
||||||
|
4. Builds and publishes a Linux Snap.
|
||||||
|
5. Publishes the CrabNebula release.
|
||||||
|
|
||||||
|
The workflow requires repository secrets for CrabNebula, Tauri signing, and Snap publication. Linux updates are managed by Snap; Windows and macOS use the Tauri updater configured in `src-tauri/tauri.conf.json`.
|
||||||
|
|
||||||
|
## Supported platforms
|
||||||
|
|
||||||
|
Browser/web, Windows, macOS, and Linux Snap are supported. Android and iOS are not supported or planned at this stage, although Capacitor configuration remains for possible future use.
|
||||||
|
|
||||||
|
## Project documentation
|
||||||
|
|
||||||
|
- `AGENTS.md` — instructions for coding agents
|
||||||
|
- `docs/architecture.md` — current technical structure and data flow
|
||||||
|
- `docs/domain-glossary.md` — confirmed terminology and business rules
|
||||||
|
- `docs/development.md` — local setup, development workflows, and manual versioning
|
||||||
|
- `docs/testing.md` — test and validation guidance
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
MIT — see `LICENSE`.
|
||||||
@@ -0,0 +1,126 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
This document describes behavior confirmed from the current repository. Proposed changes should be labeled as proposals rather than added here as existing behavior.
|
||||||
|
|
||||||
|
## System overview
|
||||||
|
|
||||||
|
Court Pilot is a local-first Angular/Ionic application packaged as a Tauri desktop application. There is no backend API in the repository. Domain data is persisted in IndexedDB through Dexie.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
flowchart TD
|
||||||
|
Pages[Angular and Ionic pages] --> Services[Domain and persistence services]
|
||||||
|
Pages --> I18n[Translation service and pipe]
|
||||||
|
Services --> Dexie[Dexie]
|
||||||
|
Dexie --> IndexedDB[IndexedDB in browser or webview]
|
||||||
|
Tauri[Tauri desktop shell] --> WebApp[Built Angular application]
|
||||||
|
Tauri --> Updates[OS, process, and updater plugins]
|
||||||
|
WebApp --> Pages
|
||||||
|
```
|
||||||
|
|
||||||
|
## Frontend structure
|
||||||
|
|
||||||
|
The root route lazy-loads the tab routes. The three primary routes are:
|
||||||
|
|
||||||
|
| Route | Source | Responsibility |
|
||||||
|
|---|---|---|
|
||||||
|
| `/tabs/pilot` | `src/app/tab1/` | Waiting queues, court assignments, drag and drop, and rotation actions |
|
||||||
|
| `/tabs/umpires` | `src/app/tab2/` | Court-official roster management |
|
||||||
|
| `/tabs/settings` | `src/app/tab3/` | Court count, theme, service-judge mode, alerts, and language |
|
||||||
|
|
||||||
|
Pages are standalone Angular components and directly import the Ionic standalone components they use. Services are root-provided and injected with Angular's `inject()` function.
|
||||||
|
|
||||||
|
## Reactive state
|
||||||
|
|
||||||
|
Persistence services use Dexie's `liveQuery()` and Angular's `toSignal()` to expose reactive data. Pages derive lookup maps, ID sets, filtered lists, and other display state with `computed()`.
|
||||||
|
|
||||||
|
`Tab1Page` currently coordinates multi-service operations such as moving officials between waiting queues and courts. These operations span multiple Dexie tables but are not currently wrapped in database transactions.
|
||||||
|
|
||||||
|
## Persistence model
|
||||||
|
|
||||||
|
`db.ts` declares a `CourtPilot` Dexie database at schema version 1.
|
||||||
|
|
||||||
|
```mermaid
|
||||||
|
erDiagram
|
||||||
|
UMPIRE ||--o{ COURT_UMPIRE : assigned_as
|
||||||
|
UMPIRE ||--o{ COURT_SERVICE_JUDGE : assigned_as
|
||||||
|
UMPIRE ||--o{ WAITING_UMPIRE : queued_as
|
||||||
|
UMPIRE ||--o{ WAITING_SERVICE_JUDGE : queued_as
|
||||||
|
|
||||||
|
UMPIRE {
|
||||||
|
number id PK
|
||||||
|
string firstName
|
||||||
|
string lastName
|
||||||
|
string country
|
||||||
|
string gender
|
||||||
|
number courtNo
|
||||||
|
}
|
||||||
|
COURT_UMPIRE {
|
||||||
|
number id PK
|
||||||
|
number umpireId
|
||||||
|
number courtNo
|
||||||
|
}
|
||||||
|
COURT_SERVICE_JUDGE {
|
||||||
|
number id PK
|
||||||
|
number umpireId
|
||||||
|
number courtNo
|
||||||
|
}
|
||||||
|
WAITING_UMPIRE {
|
||||||
|
number id PK
|
||||||
|
number umpireId
|
||||||
|
number order
|
||||||
|
}
|
||||||
|
WAITING_SERVICE_JUDGE {
|
||||||
|
number id PK
|
||||||
|
number serviceJudgeId
|
||||||
|
number order
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Settings use a singleton row with ID `1`. Defaults are five courts, service judges enabled, and confirmation alerts enabled.
|
||||||
|
|
||||||
|
Theme preference is stored separately in `localStorage` under `theme`, using
|
||||||
|
`light`, `dark`, or `system`. Device-preferred (`system`) is the default when no
|
||||||
|
valid preference exists. `ThemeService` restores the selection when the root
|
||||||
|
component starts and toggles Ionic's `ion-palette-dark` class. Device theme changes
|
||||||
|
apply while the app is open when device-preferred is selected. This does not
|
||||||
|
change the Dexie schema or existing settings records.
|
||||||
|
|
||||||
|
### Schema-change policy
|
||||||
|
|
||||||
|
Once data may exist in released clients, do not modify schema version 1 in place. Add a new `db.version(n)` declaration and define upgrade behavior when existing records need transformation.
|
||||||
|
|
||||||
|
## Localization
|
||||||
|
|
||||||
|
`src/app/i18n/translations.ts` defines the translation catalog and derives TypeScript types from it. English is the fallback language; Hungarian is also supported. The selected language is stored in `localStorage` under `language`.
|
||||||
|
|
||||||
|
Templates use `TranslatePipe`. Imperative UI such as Ionic alerts uses `TranslationService`.
|
||||||
|
|
||||||
|
## Desktop integration
|
||||||
|
|
||||||
|
The Tauri shell loads the Angular build from `www/`. Its Rust layer registers:
|
||||||
|
|
||||||
|
- OS plugin
|
||||||
|
- process plugin
|
||||||
|
- updater plugin
|
||||||
|
- debug-only logging plugin
|
||||||
|
|
||||||
|
At application startup, the Angular root component checks for desktop updates when running under Tauri. It skips the in-app updater on Linux because Snap manages Linux updates.
|
||||||
|
|
||||||
|
Windows and macOS updater artifacts are distributed through CrabNebula. Linux is packaged and published as a strict-confinement Snap.
|
||||||
|
|
||||||
|
## Supported platforms
|
||||||
|
|
||||||
|
The supported targets are browser/web, Windows, macOS, and Linux Snap. Android and iOS are not supported or planned at this stage. `capacitor.config.ts` remains as possible future scaffolding, but agents must not treat mobile builds as active.
|
||||||
|
|
||||||
|
Tauri development intentionally expects the Ionic frontend on port `8103`. The command used by `beforeDevCommand` must explicitly or otherwise reliably start the frontend on that port.
|
||||||
|
|
||||||
|
## Domain invariants affecting architecture
|
||||||
|
|
||||||
|
Each court official must be represented in exactly one operational state: not on duty, one waiting queue, or one court assignment. The two role queues are FIFO by numeric `order`. When service judges rotate off a court, the officials swap role queues. Reducing the court count removes assignments from eliminated courts and leaves those officials not on duty.
|
||||||
|
|
||||||
|
Operations that move an official between states should preserve exclusivity even when multiple Dexie tables are involved.
|
||||||
|
|
||||||
|
## Known architectural questions
|
||||||
|
|
||||||
|
- Should moves spanning multiple tables use Dexie transactions to prevent partial updates?
|
||||||
|
- Should domain entities and database setup remain in root-level `db.ts`, or move under `src/app/`?
|
||||||
@@ -0,0 +1,233 @@
|
|||||||
|
# Development Guide
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
### Web development
|
||||||
|
|
||||||
|
- NVM
|
||||||
|
- Node.js `lts/jod` (at least Node.js 22.22.3), matching `.nvmrc` and the release workflow
|
||||||
|
- npm
|
||||||
|
- Ionic CLI available as `ionic`
|
||||||
|
|
||||||
|
### Shell and NVM setup
|
||||||
|
|
||||||
|
The command examples in this repository target **Bash** on Linux, macOS,
|
||||||
|
or Windows WSL. If your usual shell is fish, start `bash` before using them.
|
||||||
|
`nvm.fish` is a separate implementation; its functions are unavailable in Bash.
|
||||||
|
|
||||||
|
Check for the Bash NVM installation:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
test -s "${NVM_DIR:-$HOME/.nvm}/nvm.sh"
|
||||||
|
```
|
||||||
|
|
||||||
|
If this check fails, agents must ask the user for permission before installing
|
||||||
|
NVM. After approval, follow the official
|
||||||
|
[NVM installation instructions](https://github.com/nvm-sh/nvm#installing-and-updating).
|
||||||
|
Use the default `~/.nvm` location, or set `NVM_DIR` to your existing Bash NVM
|
||||||
|
installation. Keep an existing fish installation separate.
|
||||||
|
|
||||||
|
After NVM is installed, install the required Node release from the project root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm install lts/jod
|
||||||
|
```
|
||||||
|
|
||||||
|
Every npm command block below explicitly loads NVM and selects `lts/jod`.
|
||||||
|
This also works in noninteractive Bash sessions that do not load `.bashrc`.
|
||||||
|
Run each block from the project root. Stop if loading NVM or selecting Node fails;
|
||||||
|
do not fall back to the system Node version.
|
||||||
|
|
||||||
|
Angular 22 requires TypeScript 6.0 and Node.js 22.22.3 or newer on the
|
||||||
|
Node.js 22 line. If your installed `lts/jod` is older, run `nvm install lts/jod`
|
||||||
|
after loading Bash NVM.
|
||||||
|
|
||||||
|
Install exact project dependencies with:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm ci
|
||||||
|
```
|
||||||
|
|
||||||
|
If needed, install Ionic CLI globally:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm install --global @ionic/cli
|
||||||
|
```
|
||||||
|
|
||||||
|
The committed `.nvmrc` uses the named `lts/jod` alias. Do not replace it with another Node release or add `.tool-versions` unless the team intentionally changes version-management tools.
|
||||||
|
|
||||||
|
### Desktop development
|
||||||
|
|
||||||
|
Install Rust stable and the Tauri 2 prerequisites for your operating system. `src-tauri/Cargo.toml` declares Rust 1.77.2 as the minimum supported Rust version.
|
||||||
|
|
||||||
|
## Web workflow
|
||||||
|
|
||||||
|
Start the Ionic development server:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm start
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the Angular application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
The production web output is generated under `www/`. Do not edit that directory manually.
|
||||||
|
|
||||||
|
## Code formatting
|
||||||
|
|
||||||
|
Use an editor Prettier plugin for files you change. The root `.prettierrc` is the repository's shared formatting configuration and is discovered automatically by compatible plugins.
|
||||||
|
|
||||||
|
Its main rules are:
|
||||||
|
|
||||||
|
- 80-character print width
|
||||||
|
- two spaces, not tabs
|
||||||
|
- semicolons
|
||||||
|
- single quotes
|
||||||
|
- no trailing commas
|
||||||
|
- opening brackets kept on the same line where supported
|
||||||
|
|
||||||
|
For VS Code, install the recommended `esbenp.prettier-vscode` extension, select Prettier as the formatter, and use **Format Document** or format-on-save. Do not introduce personal overrides that conflict with `.prettierrc`, and do not reformat unrelated files during a focused change.
|
||||||
|
|
||||||
|
Prettier is currently an editor workflow; the repository does not declare a Prettier npm dependency or formatting script. Do not claim a CLI formatting check exists unless one is added later.
|
||||||
|
|
||||||
|
## Dependency compatibility
|
||||||
|
|
||||||
|
Angular major upgrades must run `ng update` one major at a time. The Angular
|
||||||
|
21 and 22 migrations preserve Zone.js with `provideZoneChangeDetection()`
|
||||||
|
and explicitly select `ChangeDetectionStrategy.Eager` for existing components.
|
||||||
|
Ionic 9 standalone components are imported from `@ionic/angular`.
|
||||||
|
|
||||||
|
ESLint 10 uses `eslint.config.cjs`; the old `.eslintrc.json` format is no longer
|
||||||
|
supported. The flat configuration retains the Angular template and selector rules.
|
||||||
|
Unused legacy ESLint plugins and the unused Jasmine spec reporter were removed.
|
||||||
|
Tests use the replacement `@angular/platform-browser/testing` API, so the
|
||||||
|
deprecated `@angular/platform-browser-dynamic` package is no longer needed.
|
||||||
|
The unused `@angular/animations` package was also removed; Ionic manages its
|
||||||
|
own animations.
|
||||||
|
|
||||||
|
The browser target follows Angular 22's widely available Baseline date,
|
||||||
|
2026-05-07. Older browser versions outside that baseline are no longer targeted.
|
||||||
|
|
||||||
|
Two scoped npm overrides apply upstream security fixes: Angular's build package
|
||||||
|
uses Vite 8.3.5 or newer within v8, and Capacitor's Xcode parser uses UUID 11.1.1
|
||||||
|
or newer within v11. Xcode uses the compatible CommonJS `uuid.v4()` API.
|
||||||
|
Revisit these overrides when upstream packages include the patched versions.
|
||||||
|
|
||||||
|
## Tauri workflow
|
||||||
|
|
||||||
|
Run Tauri commands through the npm script:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm run tauri dev
|
||||||
|
nvm use lts/jod && npm run tauri build
|
||||||
|
```
|
||||||
|
|
||||||
|
Tauri development intentionally expects the frontend at `http://localhost:8103`. `devUrl` only tells Tauri where to connect; the Ionic command must also listen on port `8103`. The current `npm start` script does not explicitly select that port, so verify the complete development flow when changing Tauri or serve configuration.
|
||||||
|
|
||||||
|
The desktop wrapper is under `src-tauri/`. Do not edit generated `src-tauri/target/` or `src-tauri/gen/` content.
|
||||||
|
|
||||||
|
## Data storage
|
||||||
|
|
||||||
|
Court Pilot has no backend API. It stores domain data in IndexedDB using Dexie. The database and schema are declared in root-level `db.ts`.
|
||||||
|
|
||||||
|
Changing an interface in `db.ts` may require a database migration for existing users. Follow `.agents/skills/database-change/SKILL.md` for schema work.
|
||||||
|
|
||||||
|
The selected UI language is stored separately in browser local storage under the `language` key.
|
||||||
|
|
||||||
|
## Localization workflow
|
||||||
|
|
||||||
|
English and Hungarian are both required for user-facing text.
|
||||||
|
|
||||||
|
1. Add the same translation key to `en` and `hu` in `src/app/i18n/translations.ts`.
|
||||||
|
2. Preserve identical placeholder names, such as `{courtNo}`, in both translations.
|
||||||
|
3. Use `TranslatePipe` in templates.
|
||||||
|
4. Use `TranslationService` for alerts or other strings created in TypeScript.
|
||||||
|
5. Build the application so strict translation-key and template typing is checked.
|
||||||
|
|
||||||
|
## Manual release versioning
|
||||||
|
|
||||||
|
Court Pilot uses one application version across its JavaScript, Rust, Tauri, and Snap packages. Releases are updated manually; there is no version synchronization script.
|
||||||
|
|
||||||
|
### Select the next version
|
||||||
|
|
||||||
|
Before changing files, review commits since the latest release tag and recommend a Semantic Versioning bump:
|
||||||
|
|
||||||
|
- Breaking change (`!` or `BREAKING CHANGE:`) → major
|
||||||
|
- Backward-compatible `feat` → minor
|
||||||
|
- Backward-compatible `fix` or `perf` → patch
|
||||||
|
- Documentation, tests, refactoring, build, CI, chores, and style changes normally do not require a bump by themselves
|
||||||
|
|
||||||
|
Use the highest applicable bump. Because older repository commits do not consistently follow Conventional Commits, inspect the implementation and configuration diff since the latest tag whenever the messages are ambiguous. Base the recommendation on shipped behavior, data compatibility, packaging, and supported platforms—not only commit wording.
|
||||||
|
|
||||||
|
Show the current version, latest tag, recommendation, bump type, and reasoning to the developer. The developer may accept the recommendation or enter a different semantic version. Do not update files until the developer confirms the final version. Warn before using a version that is not greater than the current release or before using a prerelease identifier.
|
||||||
|
|
||||||
|
During release version confirmation, ask whether to run the test suite or skip it. Running tests is the default; skip them only when the developer explicitly chooses to skip. Record any skipped tests in the release validation report. Lint, build, and Cargo checks still apply.
|
||||||
|
|
||||||
|
### Synchronize the selected version
|
||||||
|
|
||||||
|
After confirmation, set the same semantic version in:
|
||||||
|
|
||||||
|
1. `package.json` — top-level `version`
|
||||||
|
2. `package-lock.json` — root package `version`
|
||||||
|
3. `src-tauri/tauri.conf.json` — top-level `version`
|
||||||
|
4. `src-tauri/Cargo.toml` — `[package].version`
|
||||||
|
5. `src-tauri/Cargo.lock` — the `court-pilot` package entry, normally refreshed by Cargo
|
||||||
|
6. `snap/snapcraft.yaml` — top-level `version`
|
||||||
|
7. The version displayed by `src/app/tab3/tab3.page.html`
|
||||||
|
|
||||||
|
The current Settings page contains a hardcoded version. Until it is changed to consume build metadata, it must be updated manually for every release.
|
||||||
|
|
||||||
|
After editing `Cargo.toml`, run from `src-tauri/`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
This validates the Rust package and refreshes its lockfile metadata when needed.
|
||||||
|
|
||||||
|
Then verify that all version declarations match and run the checks in `docs/testing.md`, honoring the developer's test choice.
|
||||||
|
|
||||||
|
### Create the release tag
|
||||||
|
|
||||||
|
The synchronized version changes must be committed before tagging. Git tags point to commits and do not include uncommitted working-tree changes. Verify that the intended release commit contains every version update and that the tracked working tree is clean.
|
||||||
|
|
||||||
|
Release tags are lightweight tags, matching the repository's existing convention. Confirm the tag does not already exist, then create it with the selected version prefixed by `v`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
GIT_EDITOR=true git tag v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Replace `1.2.0` with the confirmed release version and verify the tag points to the intended commit. Do not overwrite or move an existing release tag.
|
||||||
|
|
||||||
|
Creating the tag is local. Pushing it starts `.github/workflows/release.yml`, which builds Windows and macOS artifacts for CrabNebula and builds/publishes the Linux Snap. Push only after explicit confirmation:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git push origin v1.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
## Generated and local directories
|
||||||
|
|
||||||
|
Do not manually edit or commit generated/dependency content unless a workflow explicitly requires an artifact:
|
||||||
|
|
||||||
|
- `node_modules/`
|
||||||
|
- `.angular/`
|
||||||
|
- `www/`
|
||||||
|
- `coverage/`
|
||||||
|
- `src-tauri/target/`
|
||||||
|
- `src-tauri/gen/`
|
||||||
|
- generated `snap-root/`
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
# Domain Glossary
|
||||||
|
|
||||||
|
This document records confirmed Court Pilot terminology and business rules. Agents must preserve these invariants and ask before defining additional rotation behavior.
|
||||||
|
|
||||||
|
## Confirmed terms
|
||||||
|
|
||||||
|
### Court official
|
||||||
|
|
||||||
|
A person maintained in the application roster. The persistence type is currently named `Umpire`, while parts of the UI use the broader label “Court Official.” A record contains first name, last name, country, and gender.
|
||||||
|
|
||||||
|
### Umpire
|
||||||
|
|
||||||
|
The primary official assigned to a court. An official can wait in the umpire queue, be assigned to a court as umpire, or be off duty.
|
||||||
|
|
||||||
|
### Service judge
|
||||||
|
|
||||||
|
An optional second role on a court. Service-judge behavior can be enabled or disabled globally in settings. The same official records are used for both umpire and service-judge roles.
|
||||||
|
|
||||||
|
### Court
|
||||||
|
|
||||||
|
A numbered play area. Courts are generated from `1` through the configured `numberOfCourts`; the default is five.
|
||||||
|
|
||||||
|
### Waiting queue
|
||||||
|
|
||||||
|
An ordered FIFO list of officials awaiting assignment. Umpires and service judges have separate queues and numeric order values. The lowest `order` value is assigned next. Officials may be reordered with drag and drop.
|
||||||
|
|
||||||
|
### Not on duty / rest
|
||||||
|
|
||||||
|
An official who is not found in either waiting queue or in either kind of court assignment. The code derives this state rather than persisting it separately.
|
||||||
|
|
||||||
|
### Send to court
|
||||||
|
|
||||||
|
Assign the first eligible waiting official or officials to a selected court. With service judges enabled, one official is taken from each role-specific queue.
|
||||||
|
|
||||||
|
### Remove from court
|
||||||
|
|
||||||
|
Remove assigned officials from a court and return them to waiting duty. With service judges enabled, roles intentionally swap when re-queuing: the prior service judge enters the umpire queue and the prior umpire enters the service-judge queue.
|
||||||
|
|
||||||
|
## Domain invariants
|
||||||
|
|
||||||
|
- “Court official” is the umbrella term; “umpire” and “service judge” are roles.
|
||||||
|
- Every official must be in exactly one state: not on duty, one waiting queue, or one active court role.
|
||||||
|
- An official must never occupy multiple queues or assignments at the same time.
|
||||||
|
- Queue assignment is FIFO by numeric `order`; the lowest value is next.
|
||||||
|
- Removing a court assignment with service judges enabled swaps the officials' role queues.
|
||||||
|
- Reducing `numberOfCourts` removes assignments above the new court count. Those officials become not on duty; they are not automatically added to a waiting queue.
|
||||||
|
|
||||||
|
## Current settings
|
||||||
|
|
||||||
|
- `numberOfCourts` — number of numbered courts shown
|
||||||
|
- `withServiceJudge` — whether service-judge assignments are active
|
||||||
|
- `showAlert` — whether quick court actions require confirmation
|
||||||
|
- language — stored separately in local storage; currently English or Hungarian
|
||||||
|
|
||||||
|
## Still-undecided behavior
|
||||||
|
|
||||||
|
Ask the maintainer before a task depends on any of these questions:
|
||||||
|
|
||||||
|
1. How should malformed queue data—duplicate or conflicting order values—be repaired?
|
||||||
|
2. Are gender and country free-form values, fixed choices, or governed by tournament data?
|
||||||
|
3. Will Court Pilot continue to manage one tournament/session at a time, or support multiple events later?
|
||||||
+102
@@ -0,0 +1,102 @@
|
|||||||
|
# Testing and Validation
|
||||||
|
|
||||||
|
## Test stack
|
||||||
|
|
||||||
|
Angular unit tests use Jasmine and Karma. Test files are colocated with their subjects as `*.spec.ts`. Karma launches Chrome by default and keeps running in watch mode for local development.
|
||||||
|
|
||||||
|
Jasmine is kept on the compatible 6.x line. Jasmine 7 does not support Karma
|
||||||
|
or Zone.js. The npm overrides make Karma adapters use the same Jasmine core
|
||||||
|
as the app rather than installing an older nested copy.
|
||||||
|
|
||||||
|
## Local test commands
|
||||||
|
|
||||||
|
These examples target Bash and run from the project root. Complete the
|
||||||
|
[shell and NVM setup](development.md#shell-and-nvm-setup) first. Agents must ask
|
||||||
|
permission before installing missing Bash NVM. Each npm block loads NVM explicitly.
|
||||||
|
|
||||||
|
Run the interactive test suite:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm test
|
||||||
|
```
|
||||||
|
|
||||||
|
Run the configured non-watching CI test mode:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm test -- --configuration=ci
|
||||||
|
```
|
||||||
|
|
||||||
|
The test environment requires an available Chrome installation. Test coverage output, when enabled by the test runner, is written under `coverage/app/`.
|
||||||
|
|
||||||
|
Run the suite once in headless Chrome with a coverage report:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm test -- --watch=false --browsers=ChromeHeadless --code-coverage
|
||||||
|
```
|
||||||
|
|
||||||
|
If Chrome is not auto-detected, set `CHROME_BIN` to its executable path
|
||||||
|
(for example, `/snap/bin/chromium`) before running the command. Open
|
||||||
|
`coverage/app/index.html` to inspect coverage by file.
|
||||||
|
|
||||||
|
The queue and assignment service tests exercise real browser IndexedDB, clearing
|
||||||
|
their tables before and after each test. Court rotation page tests use service
|
||||||
|
spies and signals to isolate assignments, confirmation, and role swaps.
|
||||||
|
|
||||||
|
## Lint and build
|
||||||
|
|
||||||
|
Lint TypeScript and Angular templates:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Build the production Angular application:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
|
||||||
|
. "$NVM_DIR/nvm.sh"
|
||||||
|
nvm use lts/jod && npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Check Rust changes from the Tauri directory:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
cargo check
|
||||||
|
```
|
||||||
|
|
||||||
|
## What to validate
|
||||||
|
|
||||||
|
Choose checks based on the changed area:
|
||||||
|
|
||||||
|
| Change | Expected validation |
|
||||||
|
| ------------------------------ | -------------------------------------------------------------------- |
|
||||||
|
| Documentation only | Markdown/editor diagnostics and `git diff --check` |
|
||||||
|
| Angular TypeScript or template | Related tests, `npm run lint`, and `npm run build` |
|
||||||
|
| Service or domain behavior | Behavior-focused service/page tests, lint, and build |
|
||||||
|
| Dexie schema or migration | Migration tests where practical, lint, and build |
|
||||||
|
| Styles only | Build and visual verification at relevant viewport sizes |
|
||||||
|
| Rust code | `cargo check` plus relevant Rust tests if introduced |
|
||||||
|
| Tauri/release configuration | Build/config checks available on the host; report untested platforms |
|
||||||
|
|
||||||
|
## Testing conventions
|
||||||
|
|
||||||
|
- Prefer behavior assertions over tests that only check whether a service or component was created.
|
||||||
|
- Cover domain invariants when changing court assignments or queues.
|
||||||
|
- Verify that an official cannot end up in multiple operational states.
|
||||||
|
- Verify FIFO ordering and contiguous queue order values after additions, removals, and moves.
|
||||||
|
- Verify the intentional umpire/service-judge role swap after rotation.
|
||||||
|
- Verify that reducing the court count removes assignments on eliminated courts and leaves those officials not on duty.
|
||||||
|
- For localization changes, exercise both English and Hungarian catalogs when practical.
|
||||||
|
- Do not hide existing failures by weakening unrelated assertions.
|
||||||
|
|
||||||
|
## Reporting validation
|
||||||
|
|
||||||
|
At task completion, list commands actually run and whether they passed. If a check cannot run—for example, Chrome is unavailable or a platform-specific desktop build cannot run on the host—state that explicitly. Distinguish pre-existing failures from failures introduced by the change.
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
const { defineConfig } = require('eslint/config');
|
||||||
|
const angular = require('angular-eslint');
|
||||||
|
|
||||||
|
module.exports = defineConfig(
|
||||||
|
{ ignores: ['projects/**', 'www/**', 'coverage/**'] },
|
||||||
|
{
|
||||||
|
files: ['src/**/*.ts'],
|
||||||
|
extends: angular.configs.tsRecommended,
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.json'],
|
||||||
|
tsconfigRootDir: __dirname
|
||||||
|
}
|
||||||
|
},
|
||||||
|
processor: angular.processInlineTemplates,
|
||||||
|
rules: {
|
||||||
|
// Preserve existing Ionic page updates while retaining Zone.js.
|
||||||
|
'@angular-eslint/prefer-on-push-component-change-detection': 'off',
|
||||||
|
'@angular-eslint/component-class-suffix': [
|
||||||
|
'error',
|
||||||
|
{ suffixes: ['Page', 'Component'] }
|
||||||
|
],
|
||||||
|
'@angular-eslint/component-selector': [
|
||||||
|
'error',
|
||||||
|
{ type: 'element', prefix: 'app', style: 'kebab-case' }
|
||||||
|
],
|
||||||
|
'@angular-eslint/directive-selector': [
|
||||||
|
'error',
|
||||||
|
{ type: 'attribute', prefix: 'app', style: 'camelCase' }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
files: ['src/**/*.html'],
|
||||||
|
extends: angular.configs.templateRecommended
|
||||||
|
}
|
||||||
|
);
|
||||||
Generated
+6953
-8365
File diff suppressed because it is too large.
Load diff
+55
-45
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "court-pilot",
|
"name": "court-pilot",
|
||||||
"version": "1.1.5",
|
"version": "2.0.0",
|
||||||
"author": "Richard Toth",
|
"author": "Richard Toth",
|
||||||
"homepage": "https://github.com/tricsusz/court-pilot",
|
"homepage": "https://github.com/tricsusz/court-pilot",
|
||||||
"scripts": {
|
"scripts": {
|
||||||
@@ -14,58 +14,68 @@
|
|||||||
},
|
},
|
||||||
"private": true,
|
"private": true,
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@angular/animations": "^20.3.28",
|
"@angular/cdk": "^22.2.4",
|
||||||
"@angular/cdk": "^20.2.14",
|
"@angular/common": "^22.2.3",
|
||||||
"@angular/common": "^20.3.28",
|
"@angular/compiler": "^22.2.3",
|
||||||
"@angular/compiler": "^20.3.28",
|
"@angular/core": "^22.2.3",
|
||||||
"@angular/core": "^20.3.28",
|
"@angular/forms": "^22.2.3",
|
||||||
"@angular/forms": "^20.3.28",
|
"@angular/platform-browser": "^22.2.3",
|
||||||
"@angular/platform-browser": "^20.3.28",
|
"@angular/router": "^22.2.3",
|
||||||
"@angular/platform-browser-dynamic": "^20.3.28",
|
"@capacitor/app": "8.1.2",
|
||||||
"@angular/router": "^20.3.28",
|
"@capacitor/core": "8.5.3",
|
||||||
"@capacitor/app": "8.1.0",
|
|
||||||
"@capacitor/core": "8.3.4",
|
|
||||||
"@capacitor/haptics": "8.0.2",
|
"@capacitor/haptics": "8.0.2",
|
||||||
"@capacitor/keyboard": "8.0.3",
|
"@capacitor/keyboard": "8.0.6",
|
||||||
"@capacitor/status-bar": "8.0.2",
|
"@capacitor/status-bar": "8.0.4",
|
||||||
"@ionic/angular": "^8.0.0",
|
"@ionic/angular": "^9.0.9",
|
||||||
"@tauri-apps/plugin-os": "^2.3.2",
|
"@tauri-apps/plugin-os": "^2.4.0",
|
||||||
"@tauri-apps/plugin-process": "^2.3.1",
|
"@tauri-apps/plugin-process": "^2.4.0",
|
||||||
"@tauri-apps/plugin-updater": "^2.10.1",
|
"@tauri-apps/plugin-updater": "^2.14.0",
|
||||||
"dexie": "^4.4.2",
|
"dexie": "^4.4.7",
|
||||||
"ionicons": "^7.0.0",
|
"ionicons": "^8.1.0",
|
||||||
"rxjs": "~7.8.0",
|
"rxjs": "~7.8.0",
|
||||||
"tslib": "^2.3.0",
|
"tslib": "^2.3.0",
|
||||||
"zone.js": "~0.15.0"
|
"zone.js": "~0.16.3"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@angular-devkit/build-angular": "^20.3.34",
|
"@angular-devkit/build-angular": "^22.2.4",
|
||||||
"@angular-eslint/builder": "^20.3.0",
|
"@angular-eslint/builder": "^22.6.0",
|
||||||
"@angular-eslint/eslint-plugin": "^20.3.0",
|
"@angular-eslint/eslint-plugin": "^22.6.0",
|
||||||
"@angular-eslint/eslint-plugin-template": "^20.3.0",
|
"@angular-eslint/eslint-plugin-template": "^22.6.0",
|
||||||
"@angular-eslint/schematics": "^20.3.0",
|
"@angular-eslint/schematics": "^22.6.0",
|
||||||
"@angular-eslint/template-parser": "^20.3.0",
|
"@angular-eslint/template-parser": "^22.6.0",
|
||||||
"@angular/cli": "^20.3.34",
|
"@angular/cli": "^22.2.4",
|
||||||
"@angular/compiler-cli": "^20.3.28",
|
"@angular/compiler-cli": "^22.2.3",
|
||||||
"@angular/language-service": "^20.3.28",
|
"@angular/language-service": "^22.2.3",
|
||||||
"@capacitor/cli": "8.3.4",
|
"@capacitor/cli": "8.5.3",
|
||||||
"@ionic/angular-toolkit": "^12.0.0",
|
"@ionic/angular-toolkit": "^13.0.0",
|
||||||
"@tauri-apps/cli": "^2.11.2",
|
"@tauri-apps/cli": "^2.12.4",
|
||||||
"@types/jasmine": "~5.1.0",
|
"@types/jasmine": "~6.0.0",
|
||||||
"@typescript-eslint/eslint-plugin": "^8.18.0",
|
"@typescript-eslint/eslint-plugin": "^8.72.0",
|
||||||
"@typescript-eslint/parser": "^8.18.0",
|
"@typescript-eslint/parser": "^8.72.0",
|
||||||
"eslint": "^9.16.0",
|
"eslint": "^10.13.0",
|
||||||
"eslint-plugin-import": "^2.29.1",
|
"istanbul-lib-instrument": "^6.0.3",
|
||||||
"eslint-plugin-jsdoc": "^48.2.1",
|
"jasmine-core": "~6.3.0",
|
||||||
"eslint-plugin-prefer-arrow": "1.2.2",
|
|
||||||
"jasmine-core": "~5.1.0",
|
|
||||||
"jasmine-spec-reporter": "~5.0.0",
|
|
||||||
"karma": "~6.4.0",
|
"karma": "~6.4.0",
|
||||||
"karma-chrome-launcher": "~3.2.0",
|
"karma-chrome-launcher": "~3.2.0",
|
||||||
"karma-coverage": "~2.2.0",
|
"karma-coverage": "~2.2.0",
|
||||||
"karma-jasmine": "~5.1.0",
|
"karma-jasmine": "~5.1.0",
|
||||||
"karma-jasmine-html-reporter": "~2.1.0",
|
"karma-jasmine-html-reporter": "~2.3.0",
|
||||||
"typescript": "~5.9.0"
|
"typescript": "~6.0.3",
|
||||||
|
"angular-eslint": "^22.6.0"
|
||||||
},
|
},
|
||||||
"description": "Manage court officials"
|
"description": "Manage court officials",
|
||||||
|
"overrides": {
|
||||||
|
"karma-jasmine": {
|
||||||
|
"jasmine-core": "$jasmine-core"
|
||||||
|
},
|
||||||
|
"karma-jasmine-html-reporter": {
|
||||||
|
"jasmine-core": "$jasmine-core"
|
||||||
|
},
|
||||||
|
"@angular/build": {
|
||||||
|
"vite": "^8.3.5"
|
||||||
|
},
|
||||||
|
"xcode": {
|
||||||
|
"uuid": "^11.1.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+1
-1
@@ -12,7 +12,7 @@ license: MIT
|
|||||||
base: core24
|
base: core24
|
||||||
icon: snap/gui/court-pilot.png
|
icon: snap/gui/court-pilot.png
|
||||||
|
|
||||||
version: '1.1.5'
|
version: '2.0.0'
|
||||||
|
|
||||||
summary: Badminton umpire rotation application
|
summary: Badminton umpire rotation application
|
||||||
|
|
||||||
|
|||||||
Generated
+208
-356
File diff suppressed because it is too large.
Load diff
@@ -1,6 +1,6 @@
|
|||||||
[package]
|
[package]
|
||||||
name = "court-pilot"
|
name = "court-pilot"
|
||||||
version = "0.1.0"
|
version = "2.0.0"
|
||||||
description = "Court Pilot"
|
description = "Court Pilot"
|
||||||
authors = ["Richard Toth"]
|
authors = ["Richard Toth"]
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
|
||||||
"productName": "court-pilot",
|
"productName": "court-pilot",
|
||||||
"mainBinaryName": "court-pilot",
|
"mainBinaryName": "court-pilot",
|
||||||
"version": "1.1.5",
|
"version": "2.0.0",
|
||||||
"identifier": "com.courtpilot.dev",
|
"identifier": "com.courtpilot.dev",
|
||||||
"build": {
|
"build": {
|
||||||
"frontendDist": "../www",
|
"frontendDist": "../www",
|
||||||
|
|||||||
@@ -1,21 +1,21 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
AlertController,
|
AlertController,
|
||||||
IonApp,
|
IonApp,
|
||||||
IonRouterOutlet,
|
IonRouterOutlet,
|
||||||
LoadingController
|
LoadingController
|
||||||
} from '@ionic/angular/standalone';
|
} from '@ionic/angular';
|
||||||
import { check, DownloadEvent } from '@tauri-apps/plugin-updater';
|
import { check, DownloadEvent } from '@tauri-apps/plugin-updater';
|
||||||
import { isTauri } from '@tauri-apps/api/core';
|
import { isTauri } from '@tauri-apps/api/core';
|
||||||
import { platform } from '@tauri-apps/plugin-os';
|
import { platform } from '@tauri-apps/plugin-os';
|
||||||
import { relaunch } from '@tauri-apps/plugin-process';
|
import { relaunch } from '@tauri-apps/plugin-process';
|
||||||
import { TranslationService } from './i18n/translation.service';
|
import { TranslationService } from './i18n/translation.service';
|
||||||
|
import { ThemeService } from './services/theme.service';
|
||||||
|
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-root',
|
selector: 'app-root',
|
||||||
templateUrl: 'app.component.html',
|
templateUrl: 'app.component.html',
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
imports: [IonApp, IonRouterOutlet]
|
imports: [IonApp, IonRouterOutlet]
|
||||||
})
|
})
|
||||||
export class AppComponent {
|
export class AppComponent {
|
||||||
@@ -23,6 +23,8 @@ export class AppComponent {
|
|||||||
readonly loadingController: LoadingController = inject(LoadingController);
|
readonly loadingController: LoadingController = inject(LoadingController);
|
||||||
readonly translationService: TranslationService = inject(TranslationService);
|
readonly translationService: TranslationService = inject(TranslationService);
|
||||||
|
|
||||||
|
readonly themeService = inject(ThemeService);
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.checkForUpdates();
|
this.checkForUpdates();
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
import { FullnamePipe } from './fullname-pipe';
|
import { FullnamePipe } from './fullname-pipe';
|
||||||
|
|
||||||
describe('FullnamePipePipe', () => {
|
describe('FullnamePipePipe', () => {
|
||||||
it('create an instance', () => {
|
it('create an instance', () => {
|
||||||
const pipe = new FullnamePipe();
|
const pipe = TestBed.runInInjectionContext(() => new FullnamePipe());
|
||||||
expect(pipe).toBeTruthy();
|
expect(pipe).toBeTruthy();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -5,6 +5,10 @@ export const translations = {
|
|||||||
en: {
|
en: {
|
||||||
courts: 'Courts',
|
courts: 'Courts',
|
||||||
numberOfCourts: 'Number of Courts',
|
numberOfCourts: 'Number of Courts',
|
||||||
|
theme: 'Theme',
|
||||||
|
themeLight: 'Light',
|
||||||
|
themeDark: 'Dark',
|
||||||
|
themeSystem: 'Device-preferred',
|
||||||
showAlerts: 'Show alerts on quick buttons',
|
showAlerts: 'Show alerts on quick buttons',
|
||||||
serviceJudge: 'Service Judge',
|
serviceJudge: 'Service Judge',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
@@ -54,6 +58,10 @@ export const translations = {
|
|||||||
hu: {
|
hu: {
|
||||||
courts: 'Pályák',
|
courts: 'Pályák',
|
||||||
numberOfCourts: 'Pályák száma',
|
numberOfCourts: 'Pályák száma',
|
||||||
|
theme: 'Téma',
|
||||||
|
themeLight: 'Világos',
|
||||||
|
themeDark: 'Sötét',
|
||||||
|
themeSystem: 'Eszközbeállítás szerint',
|
||||||
showAlerts: 'Figyelmeztetések megjelenítése a gyors gomboknál',
|
showAlerts: 'Figyelmeztetések megjelenítése a gyors gomboknál',
|
||||||
serviceJudge: 'Adogatásbíró',
|
serviceJudge: 'Adogatásbíró',
|
||||||
language: 'Nyelv',
|
language: 'Nyelv',
|
||||||
|
|||||||
@@ -1,16 +1,56 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { db } from 'db';
|
||||||
import { CourtServiceJudgeService } from './court.service.judge.service';
|
import { CourtServiceJudgeService } from './court.service.judge.service';
|
||||||
|
|
||||||
describe('CourtServiceJudgeService', () => {
|
describe('CourtServiceJudgeService persistence', () => {
|
||||||
let service: CourtServiceJudgeService;
|
let service: CourtServiceJudgeService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({});
|
await db.courtServiceJudges.clear();
|
||||||
service = TestBed.inject(CourtServiceJudgeService);
|
service = TestBed.inject(CourtServiceJudgeService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
afterEach(async () => {
|
||||||
expect(service).toBeTruthy();
|
TestBed.resetTestingModule();
|
||||||
|
await db.courtServiceJudges.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves an assignment and returns its generated ID', async () => {
|
||||||
|
const id = await service.save({ umpireId: 10, courtNo: 2 });
|
||||||
|
expect(await db.courtServiceJudges.get(id)).toEqual({
|
||||||
|
id,
|
||||||
|
umpireId: 10,
|
||||||
|
courtNo: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an assignment without changing its court', async () => {
|
||||||
|
const id = await service.save({ umpireId: 10, courtNo: 2 });
|
||||||
|
expect(await service.update(id, { umpireId: 20 })).toBe(1);
|
||||||
|
expect(await db.courtServiceJudges.get(id)).toEqual({
|
||||||
|
id,
|
||||||
|
umpireId: 20,
|
||||||
|
courtNo: 2
|
||||||
|
});
|
||||||
|
expect(await service.update(id + 100, { umpireId: 30 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes only the selected assignment', async () => {
|
||||||
|
const id = await service.save({ umpireId: 10, courtNo: 1 });
|
||||||
|
const retained = await service.save({ umpireId: 20, courtNo: 2 });
|
||||||
|
await service.delete(id);
|
||||||
|
expect(await db.courtServiceJudges.toArray()).toEqual([
|
||||||
|
{ id: retained, umpireId: 20, courtNo: 2 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes by official ID while retaining other courts', async () => {
|
||||||
|
await service.save({ umpireId: 10, courtNo: 1 });
|
||||||
|
const id = await service.save({ umpireId: 20, courtNo: 2 });
|
||||||
|
await service.removeByUmpireId(10);
|
||||||
|
await service.removeByUmpireId(999);
|
||||||
|
expect(await db.courtServiceJudges.toArray()).toEqual([
|
||||||
|
{ id, umpireId: 20, courtNo: 2 }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,16 +1,56 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { db } from 'db';
|
||||||
import { CourtUmpireService } from './court.umpire.service';
|
import { CourtUmpireService } from './court.umpire.service';
|
||||||
|
|
||||||
describe('CourtUmpireService', () => {
|
describe('CourtUmpireService persistence', () => {
|
||||||
let service: CourtUmpireService;
|
let service: CourtUmpireService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({});
|
await db.courtUmpires.clear();
|
||||||
service = TestBed.inject(CourtUmpireService);
|
service = TestBed.inject(CourtUmpireService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
afterEach(async () => {
|
||||||
expect(service).toBeTruthy();
|
TestBed.resetTestingModule();
|
||||||
|
await db.courtUmpires.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('saves an assignment and returns its generated ID', async () => {
|
||||||
|
const id = await service.save({ umpireId: 10, courtNo: 2 });
|
||||||
|
expect(await db.courtUmpires.get(id)).toEqual({
|
||||||
|
id,
|
||||||
|
umpireId: 10,
|
||||||
|
courtNo: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates an assignment without changing its court', async () => {
|
||||||
|
const id = await service.save({ umpireId: 10, courtNo: 2 });
|
||||||
|
expect(await service.update(id, { umpireId: 20 })).toBe(1);
|
||||||
|
expect(await db.courtUmpires.get(id)).toEqual({
|
||||||
|
id,
|
||||||
|
umpireId: 20,
|
||||||
|
courtNo: 2
|
||||||
|
});
|
||||||
|
expect(await service.update(id + 100, { umpireId: 30 })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes only the selected assignment', async () => {
|
||||||
|
const id = await service.save({ umpireId: 10, courtNo: 1 });
|
||||||
|
const retained = await service.save({ umpireId: 20, courtNo: 2 });
|
||||||
|
await service.delete(id);
|
||||||
|
expect(await db.courtUmpires.toArray()).toEqual([
|
||||||
|
{ id: retained, umpireId: 20, courtNo: 2 }
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes by official ID while retaining other courts', async () => {
|
||||||
|
await service.save({ umpireId: 10, courtNo: 1 });
|
||||||
|
const id = await service.save({ umpireId: 20, courtNo: 2 });
|
||||||
|
await service.removeByUmpireId(10);
|
||||||
|
await service.removeByUmpireId(999);
|
||||||
|
expect(await db.courtUmpires.toArray()).toEqual([
|
||||||
|
{ id, umpireId: 20, courtNo: 2 }
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { ThemePreference, ThemeService } from './theme.service';
|
||||||
|
|
||||||
|
describe('ThemeService', () => {
|
||||||
|
let deviceTheme: MediaQueryList;
|
||||||
|
let deviceThemeChanged: () => void;
|
||||||
|
let storedTheme: string | null;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
storedTheme = localStorage.getItem('theme');
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
deviceTheme = {
|
||||||
|
matches: false,
|
||||||
|
addEventListener: jasmine
|
||||||
|
.createSpy('addEventListener')
|
||||||
|
.and.callFake((_type: string, listener: () => void) => {
|
||||||
|
deviceThemeChanged = listener;
|
||||||
|
}),
|
||||||
|
removeEventListener: jasmine.createSpy('removeEventListener')
|
||||||
|
} as unknown as MediaQueryList;
|
||||||
|
spyOn(window, 'matchMedia').and.returnValue(deviceTheme);
|
||||||
|
TestBed.configureTestingModule({});
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
if (storedTheme === null) {
|
||||||
|
localStorage.removeItem('theme');
|
||||||
|
} else {
|
||||||
|
localStorage.setItem('theme', storedTheme);
|
||||||
|
}
|
||||||
|
document.documentElement.classList.remove('ion-palette-dark');
|
||||||
|
});
|
||||||
|
|
||||||
|
function expectDark(dark: boolean): void {
|
||||||
|
expect(
|
||||||
|
document.documentElement.classList.contains('ion-palette-dark')
|
||||||
|
).toBe(dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('defaults to the device preference and follows changes', () => {
|
||||||
|
const service = TestBed.inject(ThemeService);
|
||||||
|
expect(service.theme()).toBe('system');
|
||||||
|
expectDark(false);
|
||||||
|
expect(localStorage.getItem('theme')).toBeNull();
|
||||||
|
Object.defineProperty(deviceTheme, 'matches', { value: true });
|
||||||
|
deviceThemeChanged();
|
||||||
|
expectDark(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
for (const theme of ['light', 'dark', 'system'] as ThemePreference[]) {
|
||||||
|
it(`persists and restores ${theme} on startup`, () => {
|
||||||
|
Object.defineProperty(deviceTheme, 'matches', { value: true });
|
||||||
|
TestBed.inject(ThemeService).setTheme(theme);
|
||||||
|
expect(localStorage.getItem('theme')).toBe(theme);
|
||||||
|
expectDark(theme !== 'light');
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
document.documentElement.classList.remove('ion-palette-dark');
|
||||||
|
const restarted = TestBed.inject(ThemeService);
|
||||||
|
expect(restarted.theme()).toBe(theme);
|
||||||
|
expectDark(theme !== 'light');
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
it('keeps an explicit selection when the device theme changes', () => {
|
||||||
|
const service = TestBed.inject(ThemeService);
|
||||||
|
service.setTheme('dark');
|
||||||
|
deviceThemeChanged();
|
||||||
|
expectDark(true);
|
||||||
|
Object.defineProperty(deviceTheme, 'matches', { value: true });
|
||||||
|
service.setTheme('light');
|
||||||
|
deviceThemeChanged();
|
||||||
|
expectDark(false);
|
||||||
|
service.setTheme('system');
|
||||||
|
expectDark(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to the device preference for an invalid stored value', () => {
|
||||||
|
localStorage.setItem('theme', 'invalid');
|
||||||
|
expect(TestBed.inject(ThemeService).theme()).toBe('system');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes its device listener on destruction', () => {
|
||||||
|
TestBed.inject(ThemeService);
|
||||||
|
TestBed.resetTestingModule();
|
||||||
|
expect(deviceTheme.removeEventListener).toHaveBeenCalledWith(
|
||||||
|
'change',
|
||||||
|
deviceThemeChanged
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { DOCUMENT } from '@angular/common';
|
||||||
|
import { DestroyRef, Injectable, inject, signal } from '@angular/core';
|
||||||
|
|
||||||
|
export type ThemePreference = 'light' | 'dark' | 'system';
|
||||||
|
|
||||||
|
@Injectable({ providedIn: 'root' })
|
||||||
|
export class ThemeService {
|
||||||
|
private readonly document = inject(DOCUMENT);
|
||||||
|
private readonly destroyRef = inject(DestroyRef);
|
||||||
|
private readonly storageKey = 'theme';
|
||||||
|
private readonly deviceTheme = window.matchMedia(
|
||||||
|
'(prefers-color-scheme: dark)'
|
||||||
|
);
|
||||||
|
private readonly preference = signal<ThemePreference>(this.getInitialTheme());
|
||||||
|
|
||||||
|
readonly theme = this.preference.asReadonly();
|
||||||
|
|
||||||
|
constructor() {
|
||||||
|
this.applyTheme();
|
||||||
|
const onDeviceThemeChange = (): void => this.applyTheme();
|
||||||
|
this.deviceTheme.addEventListener('change', onDeviceThemeChange);
|
||||||
|
this.destroyRef.onDestroy(() => {
|
||||||
|
this.deviceTheme.removeEventListener('change', onDeviceThemeChange);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
setTheme(theme: ThemePreference): void {
|
||||||
|
this.preference.set(theme);
|
||||||
|
localStorage.setItem(this.storageKey, theme);
|
||||||
|
this.applyTheme();
|
||||||
|
}
|
||||||
|
|
||||||
|
private getInitialTheme(): ThemePreference {
|
||||||
|
const stored = localStorage.getItem(this.storageKey);
|
||||||
|
return stored === 'light' || stored === 'dark' ? stored : 'system';
|
||||||
|
}
|
||||||
|
|
||||||
|
private applyTheme(): void {
|
||||||
|
const theme = this.preference();
|
||||||
|
const dark =
|
||||||
|
theme === 'dark' || (theme === 'system' && this.deviceTheme.matches);
|
||||||
|
this.document.documentElement.classList.toggle('ion-palette-dark', dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,16 +1,62 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { db } from 'db';
|
||||||
import { UmpireService } from './umpire.service';
|
import { UmpireService } from './umpire.service';
|
||||||
|
|
||||||
describe('Umpire', () => {
|
describe('UmpireService persistence', () => {
|
||||||
let service: UmpireService;
|
let service: UmpireService;
|
||||||
|
const official = {
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Smith',
|
||||||
|
country: 'GB',
|
||||||
|
gender: 'M'
|
||||||
|
};
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({});
|
await db.umpires.clear();
|
||||||
service = TestBed.inject(UmpireService);
|
service = TestBed.inject(UmpireService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
afterEach(async () => {
|
||||||
expect(service).toBeTruthy();
|
TestBed.resetTestingModule();
|
||||||
|
await db.umpires.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('creates and retrieves a complete official record', async () => {
|
||||||
|
const id = await service.create(official);
|
||||||
|
expect(await service.getById(id)).toEqual({ ...official, id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates selected fields while retaining the rest of the record', async () => {
|
||||||
|
const id = await service.create(official);
|
||||||
|
expect(await service.update(id, { country: 'HU' })).toBe(1);
|
||||||
|
expect(await service.getById(id)).toEqual({
|
||||||
|
...official,
|
||||||
|
id,
|
||||||
|
country: 'HU'
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined and zero when reading or updating an absent official', async () => {
|
||||||
|
expect(await service.getById(999)).toBeUndefined();
|
||||||
|
expect(await service.update(999, { country: 'HU' })).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes one official and retains the other', async () => {
|
||||||
|
const id = await service.create(official);
|
||||||
|
const retained = await service.create({ ...official, firstName: 'Sam' });
|
||||||
|
await service.delete(id);
|
||||||
|
expect(await service.getById(id)).toBeUndefined();
|
||||||
|
expect(await service.getById(retained)).toEqual({
|
||||||
|
...official,
|
||||||
|
firstName: 'Sam',
|
||||||
|
id: retained
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears all officials', async () => {
|
||||||
|
await service.create(official);
|
||||||
|
await service.create({ ...official, firstName: 'Sam' });
|
||||||
|
await service.clear();
|
||||||
|
expect(await db.umpires.count()).toBe(0);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,16 +1,174 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { db } from 'db';
|
||||||
import { WaitingServiceJudgesService } from './waiting-service-judges.service';
|
import { WaitingServiceJudgesService } from './waiting-service-judges.service';
|
||||||
|
|
||||||
describe('WaitingServiceJudgesService', () => {
|
describe('WaitingServiceJudgesService queue behavior', () => {
|
||||||
let service: WaitingServiceJudgesService;
|
let service: WaitingServiceJudgesService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({});
|
await db.waitingServiceJudges.clear();
|
||||||
|
await db.umpires.clear();
|
||||||
service = TestBed.inject(WaitingServiceJudgesService);
|
service = TestBed.inject(WaitingServiceJudgesService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
afterEach(async () => {
|
||||||
expect(service).toBeTruthy();
|
TestBed.resetTestingModule();
|
||||||
|
await db.waitingServiceJudges.clear();
|
||||||
|
await db.umpires.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function queue() {
|
||||||
|
const items = await db.waitingServiceJudges.orderBy('order').toArray();
|
||||||
|
return items.map((item) => [item.serviceJudgeId, item.order]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
await service.add(10);
|
||||||
|
await service.add(20);
|
||||||
|
await service.add(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('starts an empty queue at one and appends in FIFO order', async () => {
|
||||||
|
await seed();
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[20, 2],
|
||||||
|
[30, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends after the greatest numeric order, regardless of insertion order', async () => {
|
||||||
|
await db.waitingServiceJudges.bulkAdd([
|
||||||
|
{ serviceJudgeId: 10, order: 9 },
|
||||||
|
{ serviceJudgeId: 20, order: 2 }
|
||||||
|
]);
|
||||||
|
await service.add(30);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[20, 2],
|
||||||
|
[10, 9],
|
||||||
|
[30, 10]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent appends with distinct consecutive orders', async () => {
|
||||||
|
await Promise.all([service.add(10), service.add(20), service.add(30)]);
|
||||||
|
const items = await queue();
|
||||||
|
expect(items.map((item) => item[1])).toEqual([1, 2, 3]);
|
||||||
|
expect(items.map((item) => item[0]).sort()).toEqual([10, 20, 30]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts at a requested position and shifts following officials', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.add(40, 2);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[40, 2],
|
||||||
|
[20, 3],
|
||||||
|
[30, 4]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an official toward the front', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.moveToPosition(30, 1);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[30, 1],
|
||||||
|
[10, 2],
|
||||||
|
[20, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an official toward the back', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.moveToPosition(10, 3);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[20, 1],
|
||||||
|
[30, 2],
|
||||||
|
[10, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the queue unchanged for a missing official or unchanged position', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.moveToPosition(999, 2);
|
||||||
|
await service.moveToPosition(20, 2);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[20, 2],
|
||||||
|
[30, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes by official ID and closes the gap', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.removeByUmpireId(20);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[30, 2]
|
||||||
|
]);
|
||||||
|
await service.removeByUmpireId(999);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[30, 2]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes by queue record ID rather than official ID', async () => {
|
||||||
|
await service.add(10);
|
||||||
|
const id = await service.add(20);
|
||||||
|
await service.add(30);
|
||||||
|
await service.remove(id);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[30, 2]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes gaps while preserving numeric ordering', async () => {
|
||||||
|
await db.waitingServiceJudges.bulkAdd([
|
||||||
|
{ serviceJudgeId: 10, order: 12 },
|
||||||
|
{ serviceJudgeId: 20, order: 4 }
|
||||||
|
]);
|
||||||
|
await service.reinitOrders();
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[20, 1],
|
||||||
|
[10, 2]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the queue head to the complete official record', async () => {
|
||||||
|
const official = {
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Smith',
|
||||||
|
country: 'GB',
|
||||||
|
gender: 'M'
|
||||||
|
};
|
||||||
|
const id = await db.umpires.add(official);
|
||||||
|
await service.add(id);
|
||||||
|
expect(await service.getCurrentUmpire()).toEqual({ ...official, id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an empty queue or a missing roster record', async () => {
|
||||||
|
expect(await service.getCurrentUmpire()).toBeUndefined();
|
||||||
|
await service.add(999);
|
||||||
|
expect(await service.getCurrentUmpire()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates only the selected record order', async () => {
|
||||||
|
const id = await service.add(10);
|
||||||
|
await service.add(20);
|
||||||
|
await service.updateOrder(id, 5);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[20, 2],
|
||||||
|
[10, 5]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the queue and restarts ordering at one', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.clear();
|
||||||
|
expect(await queue()).toEqual([]);
|
||||||
|
await service.add(40);
|
||||||
|
expect(await queue()).toEqual([[40, 1]]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,16 +1,174 @@
|
|||||||
import { TestBed } from '@angular/core/testing';
|
import { TestBed } from '@angular/core/testing';
|
||||||
|
import { db } from 'db';
|
||||||
import { WaitingUmpiresService } from './waiting-umpires.service';
|
import { WaitingUmpiresService } from './waiting-umpires.service';
|
||||||
|
|
||||||
describe('WaitingUmpiresService', () => {
|
describe('WaitingUmpiresService queue behavior', () => {
|
||||||
let service: WaitingUmpiresService;
|
let service: WaitingUmpiresService;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(async () => {
|
||||||
TestBed.configureTestingModule({});
|
await db.waitingUmpires.clear();
|
||||||
|
await db.umpires.clear();
|
||||||
service = TestBed.inject(WaitingUmpiresService);
|
service = TestBed.inject(WaitingUmpiresService);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should be created', () => {
|
afterEach(async () => {
|
||||||
expect(service).toBeTruthy();
|
TestBed.resetTestingModule();
|
||||||
|
await db.waitingUmpires.clear();
|
||||||
|
await db.umpires.clear();
|
||||||
|
});
|
||||||
|
|
||||||
|
async function queue() {
|
||||||
|
const items = await db.waitingUmpires.orderBy('order').toArray();
|
||||||
|
return items.map((item) => [item.umpireId, item.order]);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function seed() {
|
||||||
|
await service.add(10);
|
||||||
|
await service.add(20);
|
||||||
|
await service.add(30);
|
||||||
|
}
|
||||||
|
|
||||||
|
it('starts an empty queue at one and appends in FIFO order', async () => {
|
||||||
|
await seed();
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[20, 2],
|
||||||
|
[30, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('appends after the greatest numeric order, regardless of insertion order', async () => {
|
||||||
|
await db.waitingUmpires.bulkAdd([
|
||||||
|
{ umpireId: 10, order: 9 },
|
||||||
|
{ umpireId: 20, order: 2 }
|
||||||
|
]);
|
||||||
|
await service.add(30);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[20, 2],
|
||||||
|
[10, 9],
|
||||||
|
[30, 10]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('serializes concurrent appends with distinct consecutive orders', async () => {
|
||||||
|
await Promise.all([service.add(10), service.add(20), service.add(30)]);
|
||||||
|
const items = await queue();
|
||||||
|
expect(items.map((item) => item[1])).toEqual([1, 2, 3]);
|
||||||
|
expect(items.map((item) => item[0]).sort()).toEqual([10, 20, 30]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts at a requested position and shifts following officials', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.add(40, 2);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[40, 2],
|
||||||
|
[20, 3],
|
||||||
|
[30, 4]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an official toward the front', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.moveToPosition(30, 1);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[30, 1],
|
||||||
|
[10, 2],
|
||||||
|
[20, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('moves an official toward the back', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.moveToPosition(10, 3);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[20, 1],
|
||||||
|
[30, 2],
|
||||||
|
[10, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves the queue unchanged for a missing official or unchanged position', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.moveToPosition(999, 2);
|
||||||
|
await service.moveToPosition(20, 2);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[20, 2],
|
||||||
|
[30, 3]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes by official ID and closes the gap', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.removeByUmpireId(20);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[30, 2]
|
||||||
|
]);
|
||||||
|
await service.removeByUmpireId(999);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[30, 2]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes by queue record ID rather than official ID', async () => {
|
||||||
|
await service.add(10);
|
||||||
|
const id = await service.add(20);
|
||||||
|
await service.add(30);
|
||||||
|
await service.remove(id);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[10, 1],
|
||||||
|
[30, 2]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('normalizes gaps while preserving numeric ordering', async () => {
|
||||||
|
await db.waitingUmpires.bulkAdd([
|
||||||
|
{ umpireId: 10, order: 12 },
|
||||||
|
{ umpireId: 20, order: 4 }
|
||||||
|
]);
|
||||||
|
await service.reinitOrders();
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[20, 1],
|
||||||
|
[10, 2]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves the queue head to the complete official record', async () => {
|
||||||
|
const official = {
|
||||||
|
firstName: 'Alex',
|
||||||
|
lastName: 'Smith',
|
||||||
|
country: 'GB',
|
||||||
|
gender: 'M'
|
||||||
|
};
|
||||||
|
const id = await db.umpires.add(official);
|
||||||
|
await service.add(id);
|
||||||
|
expect(await service.getCurrentUmpire()).toEqual({ ...official, id });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns undefined for an empty queue or a missing roster record', async () => {
|
||||||
|
expect(await service.getCurrentUmpire()).toBeUndefined();
|
||||||
|
await service.add(999);
|
||||||
|
expect(await service.getCurrentUmpire()).toBeUndefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates only the selected record order', async () => {
|
||||||
|
const id = await service.add(10);
|
||||||
|
await service.add(20);
|
||||||
|
await service.updateOrder(id, 5);
|
||||||
|
expect(await queue()).toEqual([
|
||||||
|
[20, 2],
|
||||||
|
[10, 5]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('clears the queue and restarts ordering at one', async () => {
|
||||||
|
await seed();
|
||||||
|
await service.clear();
|
||||||
|
expect(await queue()).toEqual([]);
|
||||||
|
await service.add(40);
|
||||||
|
expect(await queue()).toEqual([[40, 1]]);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -12,7 +12,7 @@
|
|||||||
</ion-header>
|
</ion-header>
|
||||||
|
|
||||||
<ion-grid [fixed]="'fixed'" class="bottom-lists">
|
<ion-grid [fixed]="'fixed'" class="bottom-lists">
|
||||||
<ion-row>
|
<ion-row class="court-headers">
|
||||||
<ion-col [size]="1" class="ion-display-none ion-display-xl-block">
|
<ion-col [size]="1" class="ion-display-none ion-display-xl-block">
|
||||||
<ion-item [lines]="'none'">{{ 'court' | translate }}</ion-item>
|
<ion-item [lines]="'none'">{{ 'court' | translate }}</ion-item>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
@@ -21,21 +21,27 @@
|
|||||||
</ion-col>
|
</ion-col>
|
||||||
<ion-col>
|
<ion-col>
|
||||||
<ion-item [lines]="'none'">
|
<ion-item [lines]="'none'">
|
||||||
<ion-label class="ion-text-center"> {{ 'umpire' | translate }} </ion-label>
|
<ion-label class="ion-text-center">
|
||||||
|
{{ 'umpire' | translate }}
|
||||||
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
@if (serviceJudgeEnabled()) {
|
@if (serviceJudgeEnabled()) {
|
||||||
<ion-col>
|
<ion-col>
|
||||||
<ion-item [lines]="'none'">
|
<ion-item [lines]="'none'">
|
||||||
<ion-label class="ion-text-center"> {{ 'serviceJudge' | translate }} </ion-label>
|
<ion-label class="ion-text-center">
|
||||||
|
{{ 'serviceJudge' | translate }}
|
||||||
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
}
|
}
|
||||||
<ion-col size="2" sizeLg="1"></ion-col>
|
<ion-col size="2" sizeLg="1"></ion-col>
|
||||||
</ion-row>
|
</ion-row>
|
||||||
@for (item of [].constructor(settings()?.numberOfCourts); track $index) {
|
@for (item of
|
||||||
|
[].constructor($safeNavigationMigration(settings()?.numberOfCourts)); track
|
||||||
|
$index) {
|
||||||
<ion-row>
|
<ion-row>
|
||||||
<ion-col [size]="1">
|
<ion-col [size]="1" class="court-number">
|
||||||
<ion-list>
|
<ion-list>
|
||||||
<ion-item [lines]="'none'"> {{ $index + 1 }}. </ion-item>
|
<ion-item [lines]="'none'"> {{ $index + 1 }}. </ion-item>
|
||||||
</ion-list>
|
</ion-list>
|
||||||
@@ -81,7 +87,7 @@
|
|||||||
</ion-list>
|
</ion-list>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
}
|
}
|
||||||
<ion-col size="2" sizeLg="1">
|
<ion-col size="2" sizeLg="1" class="court-actions">
|
||||||
<ion-list>
|
<ion-list>
|
||||||
<ion-item lines="none">
|
<ion-item lines="none">
|
||||||
@if ( (umpireByCourt().get($index + 1) &&
|
@if ( (umpireByCourt().get($index + 1) &&
|
||||||
@@ -119,19 +125,25 @@
|
|||||||
<ion-row>
|
<ion-row>
|
||||||
<ion-col [size]="!serviceJudgeEnabled() ? 6 : 4">
|
<ion-col [size]="!serviceJudgeEnabled() ? 6 : 4">
|
||||||
<ion-item color="primary">
|
<ion-item color="primary">
|
||||||
<ion-label class="ion-text-center"> {{ 'umpires' | translate }} </ion-label>
|
<ion-label class="ion-text-center">
|
||||||
|
{{ 'umpires' | translate }}
|
||||||
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
@if (serviceJudgeEnabled()) {
|
@if (serviceJudgeEnabled()) {
|
||||||
<ion-col [size]="4">
|
<ion-col [size]="4">
|
||||||
<ion-item color="primary">
|
<ion-item color="primary">
|
||||||
<ion-label class="ion-text-center"> {{ 'serviceJudges' | translate }} </ion-label>
|
<ion-label class="ion-text-center">
|
||||||
|
{{ 'serviceJudges' | translate }}
|
||||||
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
}
|
}
|
||||||
<ion-col [size]="!serviceJudgeEnabled() ? 6 : 4">
|
<ion-col [size]="!serviceJudgeEnabled() ? 6 : 4">
|
||||||
<ion-item color="medium">
|
<ion-item color="medium">
|
||||||
<ion-label class="ion-text-center"> {{ 'notOnDuty' | translate }} </ion-label>
|
<ion-label class="ion-text-center">
|
||||||
|
{{ 'notOnDuty' | translate }}
|
||||||
|
</ion-label>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
</ion-row>
|
</ion-row>
|
||||||
|
|||||||
@@ -1,12 +1,40 @@
|
|||||||
.bottom-lists ion-list {
|
.bottom-lists ion-list {
|
||||||
min-height: 64px;
|
min-height: 64px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.custom-ion-item {
|
.custom-ion-item {
|
||||||
cursor: move;
|
cursor: move;
|
||||||
padding: 10px 15px;
|
padding: 10px 15px;
|
||||||
|
|
||||||
&.primary {
|
&.primary {
|
||||||
background-color: var(--ion-color-primary);
|
background-color: var(--ion-color-primary);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
:host-context(html:not(.ion-palette-dark)) ion-list[cdkDropList] {
|
||||||
|
background-color: var(--ion-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
:host-context(html:not(.ion-palette-dark)) {
|
||||||
|
ion-list[cdkDropList].cdk-drop-list-dragging,
|
||||||
|
.custom-ion-item.primary {
|
||||||
|
background-color: var(--ion-color-step-150, #d9d9d9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-actions ion-list {
|
||||||
|
background-color: var(--ion-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-actions ion-item {
|
||||||
|
--background: var(--ion-color-light);
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-headers ion-item,
|
||||||
|
.court-number ion-item {
|
||||||
|
--background: var(--ion-color-step-150, #d9d9d9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.court-number ion-list {
|
||||||
|
background-color: var(--ion-color-step-150, #d9d9d9);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,18 +1,245 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { signal } from '@angular/core';
|
||||||
|
import { AlertController } from '@ionic/angular';
|
||||||
|
import { CourtServiceJudge, CourtUmpire, Settings, Umpire } from 'db';
|
||||||
import { Tab1Page } from './tab1.page';
|
import { Tab1Page } from './tab1.page';
|
||||||
|
import { SettingsService } from '../services/settings-service';
|
||||||
|
import { UmpireService } from '../services/umpire.service';
|
||||||
|
import { WaitingUmpiresService } from '../services/waiting-umpires.service';
|
||||||
|
import { WaitingServiceJudgesService } from '../services/waiting-service-judges.service';
|
||||||
|
import { CourtUmpireService } from '../services/court.umpire.service';
|
||||||
|
import { CourtServiceJudgeService } from '../services/court.service.judge.service';
|
||||||
|
|
||||||
describe('Tab1Page', () => {
|
// Keep page tests independent of IndexedDB and test the public court actions.
|
||||||
|
describe('Tab1Page court rotation', () => {
|
||||||
let component: Tab1Page;
|
let component: Tab1Page;
|
||||||
let fixture: ComponentFixture<Tab1Page>;
|
let fixture: ComponentFixture<Tab1Page>;
|
||||||
|
const official = (id: number): Umpire => ({
|
||||||
|
id,
|
||||||
|
firstName: `Official ${id}`,
|
||||||
|
lastName: 'Smith',
|
||||||
|
country: 'GB',
|
||||||
|
gender: 'M'
|
||||||
|
});
|
||||||
|
const settings = signal<Settings>({
|
||||||
|
id: 1,
|
||||||
|
numberOfCourts: 3,
|
||||||
|
withServiceJudge: true,
|
||||||
|
showAlert: false
|
||||||
|
});
|
||||||
|
const umpires = signal<Umpire[]>([]);
|
||||||
|
const courtUmpires = signal<CourtUmpire[]>([]);
|
||||||
|
const courtJudges = signal<CourtServiceJudge[]>([]);
|
||||||
|
let waitingUmpire: jasmine.SpyObj<WaitingUmpiresService>;
|
||||||
|
let waitingJudge: jasmine.SpyObj<WaitingServiceJudgesService>;
|
||||||
|
let umpireCourt: jasmine.SpyObj<CourtUmpireService>;
|
||||||
|
let judgeCourt: jasmine.SpyObj<CourtServiceJudgeService>;
|
||||||
|
let alerts: jasmine.SpyObj<AlertController>;
|
||||||
|
|
||||||
beforeEach(async () => {
|
beforeEach(() => {
|
||||||
|
settings.set({
|
||||||
|
id: 1,
|
||||||
|
numberOfCourts: 3,
|
||||||
|
withServiceJudge: true,
|
||||||
|
showAlert: false
|
||||||
|
});
|
||||||
|
umpires.set([
|
||||||
|
official(1),
|
||||||
|
official(2),
|
||||||
|
official(3),
|
||||||
|
official(4),
|
||||||
|
official(5)
|
||||||
|
]);
|
||||||
|
courtUmpires.set([]);
|
||||||
|
courtJudges.set([]);
|
||||||
|
waitingUmpire = jasmine.createSpyObj(
|
||||||
|
'WaitingUmpiresService',
|
||||||
|
['getCurrentUmpire', 'add', 'removeByUmpireId'],
|
||||||
|
{ waitingUmpires: signal([{ id: 1, umpireId: 1, order: 1 }]) }
|
||||||
|
);
|
||||||
|
waitingJudge = jasmine.createSpyObj(
|
||||||
|
'WaitingServiceJudgesService',
|
||||||
|
['getCurrentUmpire', 'add', 'removeByUmpireId'],
|
||||||
|
{ waitingServiceJudges: signal([{ id: 1, serviceJudgeId: 2, order: 1 }]) }
|
||||||
|
);
|
||||||
|
umpireCourt = jasmine.createSpyObj(
|
||||||
|
'CourtUmpireService',
|
||||||
|
['save', 'removeByUmpireId'],
|
||||||
|
{ umpires: courtUmpires }
|
||||||
|
);
|
||||||
|
judgeCourt = jasmine.createSpyObj(
|
||||||
|
'CourtServiceJudgeService',
|
||||||
|
['save', 'removeByUmpireId'],
|
||||||
|
{ umpires: courtJudges }
|
||||||
|
);
|
||||||
|
alerts = jasmine.createSpyObj('AlertController', ['create']);
|
||||||
|
waitingUmpire.getCurrentUmpire.and.resolveTo(official(1));
|
||||||
|
waitingJudge.getCurrentUmpire.and.resolveTo(official(2));
|
||||||
|
waitingUmpire.add.and.resolveTo(1);
|
||||||
|
waitingJudge.add.and.resolveTo(1);
|
||||||
|
umpireCourt.save.and.resolveTo(1);
|
||||||
|
judgeCourt.save.and.resolveTo(1);
|
||||||
|
waitingUmpire.removeByUmpireId.and.resolveTo();
|
||||||
|
waitingJudge.removeByUmpireId.and.resolveTo();
|
||||||
|
umpireCourt.removeByUmpireId.and.resolveTo();
|
||||||
|
judgeCourt.removeByUmpireId.and.resolveTo();
|
||||||
|
TestBed.configureTestingModule({
|
||||||
|
providers: [
|
||||||
|
{ provide: SettingsService, useValue: { settings } },
|
||||||
|
{ provide: UmpireService, useValue: { umpires } },
|
||||||
|
{ provide: WaitingUmpiresService, useValue: waitingUmpire },
|
||||||
|
{ provide: WaitingServiceJudgesService, useValue: waitingJudge },
|
||||||
|
{ provide: CourtUmpireService, useValue: umpireCourt },
|
||||||
|
{ provide: CourtServiceJudgeService, useValue: judgeCourt },
|
||||||
|
{ provide: AlertController, useValue: alerts }
|
||||||
|
]
|
||||||
|
});
|
||||||
fixture = TestBed.createComponent(Tab1Page);
|
fixture = TestBed.createComponent(Tab1Page);
|
||||||
component = fixture.componentInstance;
|
component = fixture.componentInstance;
|
||||||
fixture.detectChanges();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should create', () => {
|
it('waits for confirmation before assigning officials', async () => {
|
||||||
expect(component).toBeTruthy();
|
settings.update((value) => ({ ...value, showAlert: true }));
|
||||||
|
const present = jasmine.createSpy('present').and.resolveTo();
|
||||||
|
alerts.create.and.resolveTo({ present } as unknown as HTMLIonAlertElement);
|
||||||
|
await component.showAddConfirmation(2);
|
||||||
|
expect(present).toHaveBeenCalledTimes(1);
|
||||||
|
expect(umpireCourt.save).not.toHaveBeenCalled();
|
||||||
|
expect(judgeCourt.save).not.toHaveBeenCalled();
|
||||||
|
const options = alerts.create.calls.mostRecent().args[0];
|
||||||
|
const confirm = options?.buttons?.find(
|
||||||
|
(button) => typeof button !== 'string' && button.role === 'confirm'
|
||||||
|
);
|
||||||
|
expect(confirm).toBeDefined();
|
||||||
|
if (typeof confirm === 'object' && typeof confirm.handler === 'function') {
|
||||||
|
confirm.handler(undefined);
|
||||||
|
}
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(umpireCourt.save).toHaveBeenCalledOnceWith({
|
||||||
|
courtNo: 2,
|
||||||
|
umpireId: 1
|
||||||
|
});
|
||||||
|
expect(judgeCourt.save).toHaveBeenCalledOnceWith({
|
||||||
|
courtNo: 2,
|
||||||
|
umpireId: 2
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns a lone umpire to the umpire queue after confirming rotation', async () => {
|
||||||
|
settings.update((value) => ({
|
||||||
|
...value,
|
||||||
|
showAlert: true,
|
||||||
|
withServiceJudge: false
|
||||||
|
}));
|
||||||
|
courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]);
|
||||||
|
const present = jasmine.createSpy('present').and.resolveTo();
|
||||||
|
alerts.create.and.resolveTo({ present } as unknown as HTMLIonAlertElement);
|
||||||
|
await component.showRemoveConfirmation(2);
|
||||||
|
expect(umpireCourt.removeByUmpireId).not.toHaveBeenCalled();
|
||||||
|
const options = alerts.create.calls.mostRecent().args[0];
|
||||||
|
const confirm = options?.buttons?.find(
|
||||||
|
(button) => typeof button !== 'string' && button.role === 'confirm'
|
||||||
|
);
|
||||||
|
expect(confirm).toBeDefined();
|
||||||
|
if (typeof confirm === 'object' && typeof confirm.handler === 'function') {
|
||||||
|
confirm.handler(undefined);
|
||||||
|
}
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(umpireCourt.removeByUmpireId).toHaveBeenCalledOnceWith(3);
|
||||||
|
expect(waitingUmpire.add).toHaveBeenCalledOnceWith(3);
|
||||||
|
expect(waitingJudge.add).not.toHaveBeenCalled();
|
||||||
|
expect(judgeCourt.removeByUmpireId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns both queue heads and removes them from their queues', async () => {
|
||||||
|
await component.showAddConfirmation(3);
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(umpireCourt.save).toHaveBeenCalledOnceWith({
|
||||||
|
courtNo: 3,
|
||||||
|
umpireId: 1
|
||||||
|
});
|
||||||
|
expect(judgeCourt.save).toHaveBeenCalledOnceWith({
|
||||||
|
courtNo: 3,
|
||||||
|
umpireId: 2
|
||||||
|
});
|
||||||
|
expect(waitingUmpire.removeByUmpireId).toHaveBeenCalledOnceWith(1);
|
||||||
|
expect(waitingJudge.removeByUmpireId).toHaveBeenCalledOnceWith(2);
|
||||||
|
expect(alerts.create).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not assign either role when the required service judge is missing', async () => {
|
||||||
|
waitingJudge.getCurrentUmpire.and.resolveTo(undefined);
|
||||||
|
await component.showAddConfirmation(1);
|
||||||
|
expect(umpireCourt.save).not.toHaveBeenCalled();
|
||||||
|
expect(judgeCourt.save).not.toHaveBeenCalled();
|
||||||
|
expect(waitingUmpire.removeByUmpireId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not assign when the umpire queue is empty', async () => {
|
||||||
|
waitingUmpire.getCurrentUmpire.and.resolveTo(undefined);
|
||||||
|
await component.showAddConfirmation(1);
|
||||||
|
expect(umpireCourt.save).not.toHaveBeenCalled();
|
||||||
|
expect(judgeCourt.save).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('assigns only an umpire when service judges are disabled', async () => {
|
||||||
|
settings.update((value) => ({ ...value, withServiceJudge: false }));
|
||||||
|
await component.showAddConfirmation(2);
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(umpireCourt.save).toHaveBeenCalledOnceWith({
|
||||||
|
courtNo: 2,
|
||||||
|
umpireId: 1
|
||||||
|
});
|
||||||
|
expect(waitingUmpire.removeByUmpireId).toHaveBeenCalledOnceWith(1);
|
||||||
|
expect(judgeCourt.save).not.toHaveBeenCalled();
|
||||||
|
expect(waitingJudge.removeByUmpireId).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('swaps outgoing officials into the opposite role queues', async () => {
|
||||||
|
courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]);
|
||||||
|
courtJudges.set([{ id: 1, umpireId: 4, courtNo: 2 }]);
|
||||||
|
await component.showRemoveConfirmation(2);
|
||||||
|
await fixture.whenStable();
|
||||||
|
expect(umpireCourt.removeByUmpireId).toHaveBeenCalledOnceWith(3);
|
||||||
|
expect(judgeCourt.removeByUmpireId).toHaveBeenCalledOnceWith(4);
|
||||||
|
expect(waitingUmpire.add).toHaveBeenCalledOnceWith(4);
|
||||||
|
expect(waitingJudge.add).toHaveBeenCalledOnceWith(3);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('leaves an incomplete court unchanged when service judges are required', async () => {
|
||||||
|
courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]);
|
||||||
|
await component.showRemoveConfirmation(2);
|
||||||
|
expect(umpireCourt.removeByUmpireId).not.toHaveBeenCalled();
|
||||||
|
expect(waitingUmpire.add).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('derives rest from all four duty states and reacts to assignment changes', () => {
|
||||||
|
courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]);
|
||||||
|
courtJudges.set([{ id: 1, umpireId: 4, courtNo: 2 }]);
|
||||||
|
expect(component.onRest().map((item) => item.id)).toEqual([5]);
|
||||||
|
courtJudges.set([]);
|
||||||
|
expect(component.onRest().map((item) => item.id)).toEqual([4, 5]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('ignores empty and stale court assignments in its court lookup', () => {
|
||||||
|
courtUmpires.set([
|
||||||
|
{ id: 1, umpireId: null, courtNo: 1 },
|
||||||
|
{ id: 2, umpireId: 999, courtNo: 2 },
|
||||||
|
{ id: 3, umpireId: 3, courtNo: 3 }
|
||||||
|
]);
|
||||||
|
expect([...component.umpireByCourt().entries()]).toEqual([
|
||||||
|
[3, official(3)]
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('updates court numbers and service judge availability from settings', () => {
|
||||||
|
expect(component.courtIndexes()).toEqual([1, 2, 3]);
|
||||||
|
settings.update((value) => ({
|
||||||
|
...value,
|
||||||
|
numberOfCourts: 1,
|
||||||
|
withServiceJudge: false
|
||||||
|
}));
|
||||||
|
expect(component.courtIndexes()).toEqual([1]);
|
||||||
|
expect(component.serviceJudgeEnabled()).toBeFalse();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Component, computed, inject } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
computed,
|
||||||
|
inject,
|
||||||
|
ChangeDetectionStrategy
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
IonHeader,
|
IonHeader,
|
||||||
IonToolbar,
|
IonToolbar,
|
||||||
@@ -13,7 +18,7 @@ import {
|
|||||||
IonIcon,
|
IonIcon,
|
||||||
IonButton,
|
IonButton,
|
||||||
AlertController
|
AlertController
|
||||||
} from '@ionic/angular/standalone';
|
} from '@ionic/angular';
|
||||||
import { SettingsService } from '../services/settings-service';
|
import { SettingsService } from '../services/settings-service';
|
||||||
import { UmpireService } from '../services/umpire.service';
|
import { UmpireService } from '../services/umpire.service';
|
||||||
import { Umpire } from 'db';
|
import { Umpire } from 'db';
|
||||||
@@ -27,7 +32,6 @@ import {
|
|||||||
CdkDropList,
|
CdkDropList,
|
||||||
CdkDropListGroup
|
CdkDropListGroup
|
||||||
} from '@angular/cdk/drag-drop';
|
} from '@angular/cdk/drag-drop';
|
||||||
import { CommonModule } from '@angular/common';
|
|
||||||
import { CourtUmpireService } from '../services/court.umpire.service';
|
import { CourtUmpireService } from '../services/court.umpire.service';
|
||||||
import { CourtServiceJudgeService } from '../services/court.service.judge.service';
|
import { CourtServiceJudgeService } from '../services/court.service.judge.service';
|
||||||
import { addIcons } from 'ionicons';
|
import { addIcons } from 'ionicons';
|
||||||
@@ -40,6 +44,7 @@ import { TranslationService } from '../i18n/translation.service';
|
|||||||
templateUrl: 'tab1.page.html',
|
templateUrl: 'tab1.page.html',
|
||||||
styleUrls: ['tab1.page.scss'],
|
styleUrls: ['tab1.page.scss'],
|
||||||
providers: [FullnamePipe],
|
providers: [FullnamePipe],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
imports: [
|
imports: [
|
||||||
IonButton,
|
IonButton,
|
||||||
IonIcon,
|
IonIcon,
|
||||||
@@ -56,7 +61,6 @@ import { TranslationService } from '../i18n/translation.service';
|
|||||||
FullnamePipe,
|
FullnamePipe,
|
||||||
CdkDropList,
|
CdkDropList,
|
||||||
CdkDrag,
|
CdkDrag,
|
||||||
CommonModule,
|
|
||||||
CdkDragPlaceholder,
|
CdkDragPlaceholder,
|
||||||
CdkDropListGroup,
|
CdkDropListGroup,
|
||||||
TranslatePipe
|
TranslatePipe
|
||||||
|
|||||||
@@ -1,4 +1,9 @@
|
|||||||
import { Component, inject, ViewChild } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
inject,
|
||||||
|
ViewChild,
|
||||||
|
ChangeDetectionStrategy
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
IonHeader,
|
IonHeader,
|
||||||
IonToolbar,
|
IonToolbar,
|
||||||
@@ -18,7 +23,7 @@ import {
|
|||||||
IonCol,
|
IonCol,
|
||||||
IonLabel,
|
IonLabel,
|
||||||
AlertController
|
AlertController
|
||||||
} from '@ionic/angular/standalone';
|
} from '@ionic/angular';
|
||||||
import { UmpireService } from '../services/umpire.service';
|
import { UmpireService } from '../services/umpire.service';
|
||||||
import { Umpire } from 'db';
|
import { Umpire } from 'db';
|
||||||
import { addIcons } from 'ionicons';
|
import { addIcons } from 'ionicons';
|
||||||
@@ -35,6 +40,7 @@ import { TranslationService } from '../i18n/translation.service';
|
|||||||
templateUrl: 'tab2.page.html',
|
templateUrl: 'tab2.page.html',
|
||||||
styleUrls: ['tab2.page.scss'],
|
styleUrls: ['tab2.page.scss'],
|
||||||
providers: [FullnamePipe],
|
providers: [FullnamePipe],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
imports: [
|
imports: [
|
||||||
IonLabel,
|
IonLabel,
|
||||||
IonCol,
|
IonCol,
|
||||||
|
|||||||
@@ -53,6 +53,23 @@
|
|||||||
(ionInput)="updateNumberOfCourts($event)">
|
(ionInput)="updateNumberOfCourts($event)">
|
||||||
</ion-input>
|
</ion-input>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
<ion-item>
|
||||||
|
<ion-select
|
||||||
|
[label]="'theme' | translate"
|
||||||
|
[value]="themeService.theme()"
|
||||||
|
(ionChange)="updateTheme($event)"
|
||||||
|
interface="popover">
|
||||||
|
<ion-select-option value="light">
|
||||||
|
{{ 'themeLight' | translate }}
|
||||||
|
</ion-select-option>
|
||||||
|
<ion-select-option value="dark">
|
||||||
|
{{ 'themeDark' | translate }}
|
||||||
|
</ion-select-option>
|
||||||
|
<ion-select-option value="system">
|
||||||
|
{{ 'themeSystem' | translate }}
|
||||||
|
</ion-select-option>
|
||||||
|
</ion-select>
|
||||||
|
</ion-item>
|
||||||
</ion-list>
|
</ion-list>
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -62,7 +79,7 @@
|
|||||||
<ion-col sizeLg="6" pushLg="3">
|
<ion-col sizeLg="6" pushLg="3">
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-label class="ion-text-center"
|
<ion-label class="ion-text-center"
|
||||||
>{{ 'version' | translate }}: 1.1.5</ion-label
|
>{{ 'version' | translate }}: 2.0.0</ion-label
|
||||||
>
|
>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
</ion-col>
|
</ion-col>
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { Component, inject } from '@angular/core';
|
import { Component, inject, ChangeDetectionStrategy } from '@angular/core';
|
||||||
import {
|
import {
|
||||||
IonHeader,
|
IonHeader,
|
||||||
IonToolbar,
|
IonToolbar,
|
||||||
@@ -14,7 +14,7 @@ import {
|
|||||||
IonSelect,
|
IonSelect,
|
||||||
IonSelectOption,
|
IonSelectOption,
|
||||||
IonLabel
|
IonLabel
|
||||||
} from '@ionic/angular/standalone';
|
} from '@ionic/angular';
|
||||||
import { SettingsService } from '../services/settings-service';
|
import { SettingsService } from '../services/settings-service';
|
||||||
import { CourtUmpireService } from '../services/court.umpire.service';
|
import { CourtUmpireService } from '../services/court.umpire.service';
|
||||||
import { CourtServiceJudgeService } from '../services/court.service.judge.service';
|
import { CourtServiceJudgeService } from '../services/court.service.judge.service';
|
||||||
@@ -23,11 +23,13 @@ import { WaitingServiceJudgesService } from '../services/waiting-service-judges.
|
|||||||
import { TranslationService } from '../i18n/translation.service';
|
import { TranslationService } from '../i18n/translation.service';
|
||||||
import { Language } from '../i18n/translations';
|
import { Language } from '../i18n/translations';
|
||||||
import { TranslatePipe } from '../i18n/translation.pipe';
|
import { TranslatePipe } from '../i18n/translation.pipe';
|
||||||
|
import { ThemePreference, ThemeService } from '../services/theme.service';
|
||||||
|
|
||||||
@Component({
|
@Component({
|
||||||
selector: 'app-tab3',
|
selector: 'app-tab3',
|
||||||
templateUrl: 'tab3.page.html',
|
templateUrl: 'tab3.page.html',
|
||||||
styleUrls: ['tab3.page.scss'],
|
styleUrls: ['tab3.page.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
imports: [
|
imports: [
|
||||||
IonRow,
|
IonRow,
|
||||||
IonCol,
|
IonCol,
|
||||||
@@ -53,6 +55,8 @@ export class Tab3Page {
|
|||||||
readonly waitingServiceJudgeService = inject(WaitingServiceJudgesService);
|
readonly waitingServiceJudgeService = inject(WaitingServiceJudgesService);
|
||||||
readonly translationService = inject(TranslationService);
|
readonly translationService = inject(TranslationService);
|
||||||
|
|
||||||
|
readonly themeService = inject(ThemeService);
|
||||||
|
|
||||||
readonly settings = this.settingsService.settings;
|
readonly settings = this.settingsService.settings;
|
||||||
|
|
||||||
async updateNumberOfCourts(event: CustomEvent): Promise<void> {
|
async updateNumberOfCourts(event: CustomEvent): Promise<void> {
|
||||||
@@ -101,6 +105,10 @@ export class Tab3Page {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateTheme(event: CustomEvent<{ value: ThemePreference }>): void {
|
||||||
|
this.themeService.setTheme(event.detail.value);
|
||||||
|
}
|
||||||
|
|
||||||
updateLanguage(event: CustomEvent): void {
|
updateLanguage(event: CustomEvent): void {
|
||||||
this.translationService.setLanguage(event.detail.value as Language);
|
this.translationService.setLanguage(event.detail.value as Language);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
import { Component, EnvironmentInjector, inject } from '@angular/core';
|
import {
|
||||||
|
Component,
|
||||||
|
EnvironmentInjector,
|
||||||
|
inject,
|
||||||
|
ChangeDetectionStrategy
|
||||||
|
} from '@angular/core';
|
||||||
import {
|
import {
|
||||||
IonTabs,
|
IonTabs,
|
||||||
IonTabBar,
|
IonTabBar,
|
||||||
IonTabButton,
|
IonTabButton,
|
||||||
IonIcon,
|
IonIcon,
|
||||||
IonLabel
|
IonLabel
|
||||||
} from '@ionic/angular/standalone';
|
} from '@ionic/angular';
|
||||||
import { addIcons } from 'ionicons';
|
import { addIcons } from 'ionicons';
|
||||||
import { gridOutline, peopleOutline, settingsOutline } from 'ionicons/icons';
|
import { gridOutline, peopleOutline, settingsOutline } from 'ionicons/icons';
|
||||||
import { TranslatePipe } from '../i18n/translation.pipe';
|
import { TranslatePipe } from '../i18n/translation.pipe';
|
||||||
@@ -14,6 +19,7 @@ import { TranslatePipe } from '../i18n/translation.pipe';
|
|||||||
selector: 'app-tabs',
|
selector: 'app-tabs',
|
||||||
templateUrl: 'tabs.page.html',
|
templateUrl: 'tabs.page.html',
|
||||||
styleUrls: ['tabs.page.scss'],
|
styleUrls: ['tabs.page.scss'],
|
||||||
|
changeDetection: ChangeDetectionStrategy.Eager,
|
||||||
imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, TranslatePipe]
|
imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, TranslatePipe]
|
||||||
})
|
})
|
||||||
export class TabsPage {
|
export class TabsPage {
|
||||||
|
|||||||
+1
-3
@@ -32,9 +32,7 @@
|
|||||||
* https://ionicframework.com/docs/theming/dark-mode
|
* https://ionicframework.com/docs/theming/dark-mode
|
||||||
*/
|
*/
|
||||||
|
|
||||||
/* @import "@ionic/angular/css/palettes/dark.always.css"; */
|
@import '@ionic/angular/css/palettes/dark.class.css';
|
||||||
/* @import "@ionic/angular/css/palettes/dark.class.css"; */
|
|
||||||
@import '@ionic/angular/css/palettes/dark.system.css';
|
|
||||||
|
|
||||||
.wide.sc-ion-alert-md-h {
|
.wide.sc-ion-alert-md-h {
|
||||||
.sc-ion-alert-md {
|
.sc-ion-alert-md {
|
||||||
|
|||||||
+11
-4
@@ -1,14 +1,21 @@
|
|||||||
|
import { provideZoneChangeDetection } from '@angular/core';
|
||||||
import { bootstrapApplication } from '@angular/platform-browser';
|
import { bootstrapApplication } from '@angular/platform-browser';
|
||||||
import { RouteReuseStrategy, provideRouter, withPreloading, PreloadAllModules } from '@angular/router';
|
import {
|
||||||
import { IonicRouteStrategy, provideIonicAngular } from '@ionic/angular/standalone';
|
RouteReuseStrategy,
|
||||||
|
provideRouter,
|
||||||
|
withPreloading,
|
||||||
|
PreloadAllModules
|
||||||
|
} from '@angular/router';
|
||||||
|
import { IonicRouteStrategy, provideIonicAngular } from '@ionic/angular';
|
||||||
|
|
||||||
import { routes } from './app/app.routes';
|
import { routes } from './app/app.routes';
|
||||||
import { AppComponent } from './app/app.component';
|
import { AppComponent } from './app/app.component';
|
||||||
|
|
||||||
bootstrapApplication(AppComponent, {
|
bootstrapApplication(AppComponent, {
|
||||||
providers: [
|
providers: [
|
||||||
|
provideZoneChangeDetection(),
|
||||||
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
{ provide: RouteReuseStrategy, useClass: IonicRouteStrategy },
|
||||||
provideIonicAngular(),
|
provideIonicAngular(),
|
||||||
provideRouter(routes, withPreloading(PreloadAllModules)),
|
provideRouter(routes, withPreloading(PreloadAllModules))
|
||||||
],
|
]
|
||||||
});
|
});
|
||||||
+5
-5
@@ -3,12 +3,12 @@
|
|||||||
import 'zone.js/testing';
|
import 'zone.js/testing';
|
||||||
import { getTestBed } from '@angular/core/testing';
|
import { getTestBed } from '@angular/core/testing';
|
||||||
import {
|
import {
|
||||||
BrowserDynamicTestingModule,
|
BrowserTestingModule,
|
||||||
platformBrowserDynamicTesting
|
platformBrowserTesting
|
||||||
} from '@angular/platform-browser-dynamic/testing';
|
} from '@angular/platform-browser/testing';
|
||||||
|
|
||||||
// First, initialize the Angular testing environment.
|
// First, initialize the Angular testing environment.
|
||||||
getTestBed().initTestEnvironment(
|
getTestBed().initTestEnvironment(
|
||||||
BrowserDynamicTestingModule,
|
BrowserTestingModule,
|
||||||
platformBrowserDynamicTesting(),
|
platformBrowserTesting()
|
||||||
);
|
);
|
||||||
@@ -1,3 +1,2 @@
|
|||||||
// For information on how to create your own theme, please refer to:
|
// For information on how to create your own theme, please refer to:
|
||||||
// https://ionicframework.com/docs/theming/
|
// https://ionicframework.com/docs/theming/
|
||||||
@import '@ionic/angular/css/palettes/dark.always.css';
|
|
||||||
@@ -2,5 +2,4 @@
|
|||||||
* Prevents Angular change detection from
|
* Prevents Angular change detection from
|
||||||
* running with certain Web Component callbacks
|
* running with certain Web Component callbacks
|
||||||
*/
|
*/
|
||||||
// eslint-disable-next-line no-underscore-dangle
|
|
||||||
(window as any).__Zone_disable_customElements = true;
|
(window as any).__Zone_disable_customElements = true;
|
||||||
+10
-7
@@ -5,11 +5,14 @@
|
|||||||
"outDir": "./out-tsc/app",
|
"outDir": "./out-tsc/app",
|
||||||
"types": []
|
"types": []
|
||||||
},
|
},
|
||||||
"files": [
|
"files": ["src/main.ts", "src/polyfills.ts"],
|
||||||
"src/main.ts",
|
"include": ["src/**/*.d.ts"],
|
||||||
"src/polyfills.ts"
|
"angularCompilerOptions": {
|
||||||
],
|
"extendedDiagnostics": {
|
||||||
"include": [
|
"checks": {
|
||||||
"src/**/*.d.ts"
|
"nullishCoalescingNotNullable": "suppress",
|
||||||
]
|
"optionalChainNotNullable": "suppress"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
+2
-3
@@ -2,7 +2,7 @@
|
|||||||
{
|
{
|
||||||
"compileOnSave": false,
|
"compileOnSave": false,
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"baseUrl": "./",
|
"paths": { "db": ["./db.ts"] },
|
||||||
"outDir": "./dist/out-tsc",
|
"outDir": "./dist/out-tsc",
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
@@ -14,11 +14,10 @@
|
|||||||
"sourceMap": true,
|
"sourceMap": true,
|
||||||
"declaration": false,
|
"declaration": false,
|
||||||
"experimentalDecorators": true,
|
"experimentalDecorators": true,
|
||||||
"moduleResolution": "node",
|
"moduleResolution": "bundler",
|
||||||
"importHelpers": true,
|
"importHelpers": true,
|
||||||
"target": "es2022",
|
"target": "es2022",
|
||||||
"module": "es2020",
|
"module": "es2020",
|
||||||
"lib": ["es2021", "dom"],
|
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"useDefineForClassFields": false
|
"useDefineForClassFields": false
|
||||||
},
|
},
|
||||||
|
|||||||
+11
-11
@@ -3,16 +3,16 @@
|
|||||||
"extends": "./tsconfig.json",
|
"extends": "./tsconfig.json",
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"outDir": "./out-tsc/spec",
|
"outDir": "./out-tsc/spec",
|
||||||
"types": [
|
"types": ["jasmine"]
|
||||||
"jasmine"
|
|
||||||
]
|
|
||||||
},
|
},
|
||||||
"files": [
|
"files": ["src/test.ts", "src/polyfills.ts"],
|
||||||
"src/test.ts",
|
"include": ["src/**/*.spec.ts", "src/**/*.d.ts"],
|
||||||
"src/polyfills.ts"
|
"angularCompilerOptions": {
|
||||||
],
|
"extendedDiagnostics": {
|
||||||
"include": [
|
"checks": {
|
||||||
"src/**/*.spec.ts",
|
"nullishCoalescingNotNullable": "suppress",
|
||||||
"src/**/*.d.ts"
|
"optionalChainNotNullable": "suppress"
|
||||||
]
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user