5 Commits
Author SHA1 Message Date
tothbt 99cbbd7a07 chore: release 2.1.0
Build and Release Court Pilot / draft (push) Canceled after 0s
Build and Release Court Pilot / windows (push) Canceled after 0s
Build and Release Court Pilot / macos (aarch64-apple-darwin) (push) Canceled after 0s
Build and Release Court Pilot / macos (x86_64-apple-darwin) (push) Canceled after 0s
Build and Release Court Pilot / snap (push) Canceled after 0s
Build and Release Court Pilot / publish (push) Canceled after 0s
Build and Release Court Pilot / github-release (push) Canceled after 0s
2026-11-02 19:31:59 +01:00
tothbt 7c5384621a feat: add waiting-list off-duty and return quick buttons 2026-11-02 18:01:13 +01:00
tothbt 848e12e2d6 fix: configure test typings and root database linting 2026-11-02 18:00:01 +01:00
tothbt 2f7f5ba5af ci: publish release packages to GitHub Releases 2026-11-02 17:30:32 +01:00
tothbt e7d9fe0efb fix: align Tauri development server with configured port 2026-10-24 10:53:08 +02:00
26 changed files with 611 additions and 186 deletions

No files matched your search

+50
View File
@@ -66,6 +66,15 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build
- name: Upload Windows packages as GitHub artifact
uses: actions/upload-artifact@v4
with:
name: court-pilot-windows
if-no-files-found: error
path: |
src-tauri/target/release/bundle/msi/*.msi
src-tauri/target/release/bundle/nsis/*.exe
- name: Upload release assets to CrabNebula
uses: crabnebula-dev/cloud-release@v0
with:
@@ -116,6 +125,13 @@ jobs:
TAURI_SIGNING_PRIVATE_KEY_PASSWORD: ${{ secrets.TAURI_SIGNING_PRIVATE_KEY_PASSWORD }}
run: npm run tauri build -- --target ${{ matrix.target }} --bundles app,dmg
- name: Upload macOS packages as GitHub artifact
uses: actions/upload-artifact@v4
with:
name: court-pilot-macos-${{ matrix.target }}
if-no-files-found: error
path: src-tauri/target/${{ matrix.target }}/release/bundle/dmg/*.dmg
- name: Upload release assets to CrabNebula
uses: crabnebula-dev/cloud-release@v0
with:
@@ -228,3 +244,37 @@ jobs:
api-key: ${{ secrets.CN_API_KEY }}
path: ./src-tauri
working-directory: ./src-tauri
# ---------------------------------------------------------
# Publish GitHub release after CrabNebula publication succeeds
# ---------------------------------------------------------
github-release:
needs: publish
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-22.04
permissions:
contents: write
steps:
- name: Download release packages
uses: actions/download-artifact@v4
with:
pattern: court-pilot-*
path: release-assets
- name: Create GitHub release
env:
GH_TOKEN: ${{ github.token }}
GH_REPO: ${{ github.repository }}
RELEASE_TAG: ${{ github.ref_name }}
shell: bash
run: |
mapfile -d '' assets < <(find release-assets -type f -print0)
if [ "${#assets[@]}" -eq 0 ]; then
echo "No release packages found" >&2
exit 1
fi
gh release create "$RELEASE_TAG" "${assets[@]}" \
--verify-tag \
--title "$RELEASE_TAG" \
--generate-notes
+1 -1
View File
@@ -99,7 +99,7 @@ Do not manually edit generated output or installed dependencies:
- 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.
Tauri development intentionally uses port `8103`. `src-tauri/tauri.conf.json` expects that port, so its `beforeDevCommand` starts the project's Angular CLI with `serve --host=0.0.0.0 --port=8103`. Keep the command aligned with `devUrl`. Verify the full `npm run tauri dev` flow before claiming it works.
## Release versioning
+1 -1
View File
@@ -122,7 +122,7 @@
"lint": {
"builder": "@angular-eslint/builder:lint",
"options": {
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html"]
"lintFilePatterns": ["src/**/*.ts", "src/**/*.html", "db.ts"]
}
}
}
+20
View File
@@ -1,5 +1,7 @@
import Dexie, { type EntityTable } from 'dexie';
export type WaitingRole = 'umpire' | 'serviceJudge';
export interface Umpire {
id: number;
firstName: string;
@@ -7,6 +9,7 @@ export interface Umpire {
country: string;
gender: string;
courtNo?: number;
returnToQueue?: WaitingRole;
}
export interface CourtUmpire {
@@ -38,6 +41,7 @@ export interface Settings {
withServiceJudge: boolean;
numberOfCourts: number;
showAlert: boolean;
waitingListQuickButtons?: boolean;
}
const db = new Dexie('CourtPilot') as Dexie & {
@@ -58,4 +62,20 @@ db.version(1).stores({
waitingServiceJudges: '++id, order, serviceJudgeId'
});
db.version(2)
.stores({
umpires: '++id, lastName',
courtUmpires: '++id, courtNo, umpireId',
courtServiceJudges: '++id, courtNo, umpireId',
settings: '++id',
waitingUmpires: '++id, order, umpireId',
waitingServiceJudges: '++id, order, serviceJudgeId'
})
.upgrade(async (transaction) => {
await transaction
.table('settings')
.toCollection()
.modify({ waitingListQuickButtons: false });
});
export { db };
+21 -6
View File
@@ -21,11 +21,11 @@ flowchart TD
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 |
| 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.
@@ -37,7 +37,7 @@ Persistence services use Dexie's `liveQuery()` and Angular's `toSignal()` to exp
## Persistence model
`db.ts` declares a `CourtPilot` Dexie database at schema version 1.
`db.ts` declares a `CourtPilot` Dexie database at schema version 2.
```mermaid
erDiagram
@@ -85,6 +85,21 @@ component starts and toggles Ionic's `ion-palette-dark` class. Device theme chan
apply while the app is open when device-preferred is selected. This does not
change the Dexie schema or existing settings records.
### Waiting-list quick buttons
The optional waiting-list quick buttons default to disabled and require service
judges. Disabling service judges disables this setting. Both waiting lists have
an off-duty action; off-duty officials with a saved origin have a return action. Quick-button alerts
apply to both actions. Dragging an official from either waiting list to Not on Duty
also saves the origin, even when quick buttons are disabled. Returning appends to the saved role queue.
Schema version 2 adds `waitingListQuickButtons` to settings and optional
`returnToQueue` metadata to officials. The upgrade explicitly sets the setting to
false for existing settings rows. Existing officials need no transformation;
officials without return metadata have no return action. Queue indexes and
existing records are preserved. Older application versions do not understand the
new metadata; downgrading an IndexedDB database is not supported.
### 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.
+56 -2
View File
@@ -126,6 +126,15 @@ uses Vite 8.3.5 or newer within v8, and Capacitor's Xcode parser uses UUID 11.1.
or newer within v11. Xcode uses the compatible CommonJS `uuid.v4()` API.
Revisit these overrides when upstream packages include the patched versions.
The audit overrides also select patched `minimatch` 11 for modern consumers,
`glob` 14 for consumers of version 9 or newer, and `follow-redirects` 1.16.2 or
newer for Karma's HTTP proxy. These overrides retain Node.js 22 support. Keep
Karma's legacy `minimatch` 3 and `glob` 7 dependency paths intact: their callable
CommonJS APIs differ from the modern packages. npm still reports moderate
findings through those legacy test dependencies; resolving them requires an
upstream-compatible test-tooling update. Do not use `npm audit fix --force`,
which proposes incompatible downgrades of Angular and the test tooling.
## Tauri workflow
Run Tauri commands through the npm script:
@@ -137,10 +146,48 @@ 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.
Tauri development intentionally expects the frontend at `http://localhost:8103`. `devUrl` tells Tauri where to connect, and `beforeDevCommand` runs `npm run ng -- serve --host=0.0.0.0 --port=8103` to serve the frontend on the matching port without opening a browser. This uses the project's Angular CLI and does not require a global Ionic CLI installation. Keep these settings aligned 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.
### Local updater signing
Tauri builds require the private key matching the updater public key in
`src-tauri/tauri.conf.json`. Set the key path in the build shell; Tauri does not
load these values from `.env` files. Keep private keys and passwords outside the
repository.
```bash
export NVM_DIR="${NVM_DIR:-$HOME/.nvm}"
. "$NVM_DIR/nvm.sh"
export TAURI_SIGNING_PRIVATE_KEY="$HOME/.tauri/myapp.key"
# For a key without a password:
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD=""
nvm use lts/jod && npm run tauri build
```
For a password-protected key, replace the empty-password export with a hidden
prompt in your terminal:
```bash
read -r -s -p "Signing key password: " TAURI_SIGNING_PRIVATE_KEY_PASSWORD
echo
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD
```
If you keep the password in `$HOME/.tauri/signing-password`, restrict the file to
your account with `chmod 600 "$HOME/.tauri/signing-password"`, then load it for
each build without printing it:
```bash
export TAURI_SIGNING_PRIVATE_KEY_PASSWORD="$(cat "$HOME/.tauri/signing-password")"
```
This stores the password as plaintext outside the repository. The export applies
only to the current shell and its child processes.
See the [Tauri updater signing documentation](https://v2.tauri.app/plugin/updater/#building).
## 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`.
@@ -214,12 +261,19 @@ 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:
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. After CrabNebula publication succeeds, it also creates a GitHub Release for the pushed tag with generated release notes and attaches the Windows installers, both macOS DMGs, and the Linux Snap. Push only after explicit confirmation:
```sh
git push origin v1.2.0
```
The GitHub Release job uses the built-in `GITHUB_TOKEN` with job-scoped
`contents: write` permission; no additional secret is required. Existing
CrabNebula, updater-signing, and Snap Store secrets are still required. Manual
workflow runs on branches skip the GitHub Release job; runs on `v*` tags include
it. Use a new version tag for each release: creation fails if a GitHub Release
already exists for that tag.
## Generated and local directories
Do not manually edit or commit generated/dependency content unless a workflow explicitly requires an artifact:
+2 -2
View File
@@ -4,11 +4,11 @@ const angular = require('angular-eslint');
module.exports = defineConfig(
{ ignores: ['projects/**', 'www/**', 'coverage/**'] },
{
files: ['src/**/*.ts'],
files: ['src/**/*.ts', 'db.ts'],
extends: angular.configs.tsRecommended,
languageOptions: {
parserOptions: {
project: ['./tsconfig.json'],
project: ['./tsconfig.eslint.json'],
tsconfigRootDir: __dirname
}
},
+26 -164
View File
@@ -1,12 +1,12 @@
{
"name": "court-pilot",
"version": "2.0.0",
"version": "2.1.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "court-pilot",
"version": "2.0.0",
"version": "2.1.0",
"dependencies": {
"@angular/cdk": "^22.2.4",
"@angular/common": "^22.2.3",
@@ -4834,6 +4834,13 @@
"node": ">=18.0.0"
}
},
"node_modules/@isaacs/nonrecursive-glob-match": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/@isaacs/nonrecursive-glob-match/-/nonrecursive-glob-match-1.0.3.tgz",
"integrity": "sha512-SI7kyKHq4XXDRozw+r9GlRZkia1ZkFrpLW98r2IC2Bpr8YkFGE3fvjzmxCG0x78A++69HmDNrDvSMYBZmHGpKA==",
"dev": true,
"license": "BlueOak-1.0.0"
},
"node_modules/@istanbuljs/schema": {
"version": "0.1.6",
"resolved": "https://registry.npmjs.org/@istanbuljs/schema/-/schema-0.1.6.tgz",
@@ -5737,9 +5744,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5757,9 +5761,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5777,9 +5778,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5797,9 +5795,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5817,9 +5812,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5837,9 +5829,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -5857,9 +5846,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6095,9 +6081,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6115,9 +6098,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6135,9 +6115,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6155,9 +6132,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6175,9 +6149,6 @@
"riscv64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6195,9 +6166,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6215,9 +6183,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6235,9 +6200,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6452,9 +6414,6 @@
"arm"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6476,9 +6435,6 @@
"arm"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6500,9 +6456,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6524,9 +6477,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6548,9 +6498,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6572,9 +6519,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6939,9 +6883,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6959,9 +6900,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6979,9 +6917,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -6999,9 +6934,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7019,9 +6951,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7039,9 +6968,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7142,9 +7068,6 @@
"cpu": [
"arm64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7158,9 +7081,6 @@
"cpu": [
"arm64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7174,9 +7094,6 @@
"cpu": [
"x64"
],
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7190,9 +7107,6 @@
"cpu": [
"x64"
],
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -7379,9 +7293,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -7399,9 +7310,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -7419,9 +7327,6 @@
"riscv64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -7439,9 +7344,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -7459,9 +7361,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "Apache-2.0 OR MIT",
"optional": true,
"os": [
@@ -10835,9 +10734,9 @@
}
},
"node_modules/follow-redirects": {
"version": "1.16.1",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.1.tgz",
"integrity": "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg==",
"version": "1.16.2",
"resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.2.tgz",
"integrity": "sha512-/kVP2U8z596Ia0gwFrwyIeo74ta5aVmSeU1FfAUMAmcEP5EP7zydmag3SLiN64NaRKef3lxsq7i/g7ltJQDGow==",
"dev": true,
"funding": [
{
@@ -13087,9 +12986,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -13111,9 +13007,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -13135,9 +13028,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -13159,9 +13049,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MPL-2.0",
"optional": true,
"os": [
@@ -13680,16 +13567,17 @@
}
},
"node_modules/minimatch": {
"version": "10.2.6",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.6.tgz",
"integrity": "sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==",
"version": "11.0.4",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-11.0.4.tgz",
"integrity": "sha512-/UJ1dEoVQTjPNDh6SGlUo+ueLMmoHTas4q+8yaAR/5t6nLAIiBDJtoYUh20IYleix7ehiQkgczb84PE3uzHkxg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"brace-expansion": "^5.0.8"
"@isaacs/nonrecursive-glob-match": "^1.0.3",
"brace-expansion": "^5.0.12"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
@@ -14514,9 +14402,9 @@
}
},
"node_modules/path-scurry": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-2.0.2.tgz",
"integrity": "sha512-3O/iVVsJAPsOnpwWIeD+d6z/7PmqApyQePUtCndjatj/9I5LylHvt5qluFaBT3I5h3r1ejfR056c+FCv+NnNXg==",
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/path-scurry/-/path-scurry-3.0.0.tgz",
"integrity": "sha512-1pTbQqH1wIwMi/eleQ5leFTTCkzFsPcgXNjwOA8YGGW/W4/kHT3dssE7Z6NH+zuqNLOzOB0uaD61yl7cAcVhgg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
@@ -15324,15 +15212,15 @@
}
},
"node_modules/rimraf/node_modules/glob": {
"version": "13.0.6",
"resolved": "https://registry.npmjs.org/glob/-/glob-13.0.6.tgz",
"integrity": "sha512-Wjlyrolmm8uDpm/ogGyXZXb1Z+Ca2B8NbJwqBVg0axK9GbBeoS7yGV6vjXnYdGm6X53iehEuxxbyiKp8QmN4Vw==",
"version": "14.0.3",
"resolved": "https://registry.npmjs.org/glob/-/glob-14.0.3.tgz",
"integrity": "sha512-A1vjIwC3qpdCso45Iw127gnp7sPhqgY8lUQhDrArIs+GbmfaaVPFuoyy7WyLT9EPZCRZ7j7aLdwUhQmHWesEow==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"minimatch": "^10.2.2",
"minimatch": "^11.0.2",
"minipass": "^7.1.3",
"path-scurry": "^2.0.2"
"path-scurry": "^3.0.0"
},
"engines": {
"node": "18 || 20 || >=22"
@@ -15660,7 +15548,6 @@
"arm"
],
"dev": true,
"libc": "glibc",
"license": "MIT",
"optional": true,
"os": [
@@ -15678,7 +15565,6 @@
"arm64"
],
"dev": true,
"libc": "glibc",
"license": "MIT",
"optional": true,
"os": [
@@ -15696,7 +15582,6 @@
"arm"
],
"dev": true,
"libc": "musl",
"license": "MIT",
"optional": true,
"os": [
@@ -15714,7 +15599,6 @@
"arm64"
],
"dev": true,
"libc": "musl",
"license": "MIT",
"optional": true,
"os": [
@@ -15732,7 +15616,6 @@
"riscv64"
],
"dev": true,
"libc": "musl",
"license": "MIT",
"optional": true,
"os": [
@@ -15750,7 +15633,6 @@
"x64"
],
"dev": true,
"libc": "musl",
"license": "MIT",
"optional": true,
"os": [
@@ -15768,7 +15650,6 @@
"riscv64"
],
"dev": true,
"libc": "glibc",
"license": "MIT",
"optional": true,
"os": [
@@ -15786,7 +15667,6 @@
"x64"
],
"dev": true,
"libc": "glibc",
"license": "MIT",
"optional": true,
"os": [
@@ -17457,9 +17337,6 @@
"arm64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -17477,9 +17354,6 @@
"arm64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
@@ -17497,9 +17371,6 @@
"ppc64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -17517,9 +17388,6 @@
"s390x"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -17537,9 +17405,6 @@
"x64"
],
"dev": true,
"libc": [
"glibc"
],
"license": "MIT",
"optional": true,
"os": [
@@ -17557,9 +17422,6 @@
"x64"
],
"dev": true,
"libc": [
"musl"
],
"license": "MIT",
"optional": true,
"os": [
+6 -1
View File
@@ -1,6 +1,6 @@
{
"name": "court-pilot",
"version": "2.0.0",
"version": "2.1.0",
"author": "Richard Toth",
"homepage": "https://github.com/tricsusz/court-pilot",
"scripts": {
@@ -76,6 +76,11 @@
},
"xcode": {
"uuid": "^11.1.1"
},
"minimatch@>=9": "^11.0.1",
"glob@>=9": "^14.0.2",
"http-proxy": {
"follow-redirects": "^1.16.2"
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ license: MIT
base: core24
icon: snap/gui/court-pilot.png
version: '2.0.0'
version: '2.1.0'
summary: Badminton umpire rotation application
+1 -1
View File
@@ -519,7 +519,7 @@ dependencies = [
[[package]]
name = "court-pilot"
version = "2.0.0"
version = "2.1.0"
dependencies = [
"log",
"serde",
+1 -1
View File
@@ -1,6 +1,6 @@
[package]
name = "court-pilot"
version = "2.0.0"
version = "2.1.0"
description = "Court Pilot"
authors = ["Richard Toth"]
license = "MIT"
+2 -2
View File
@@ -2,12 +2,12 @@
"$schema": "../node_modules/@tauri-apps/cli/config.schema.json",
"productName": "court-pilot",
"mainBinaryName": "court-pilot",
"version": "2.0.0",
"version": "2.1.0",
"identifier": "com.courtpilot.dev",
"build": {
"frontendDist": "../www",
"devUrl": "http://localhost:8103",
"beforeDevCommand": "npm start",
"beforeDevCommand": "npm run ng -- serve --host=0.0.0.0 --port=8103",
"beforeBuildCommand": "ionic build"
},
"app": {
+16
View File
@@ -9,6 +9,14 @@ export const translations = {
themeLight: 'Light',
themeDark: 'Dark',
themeSystem: 'Device-preferred',
waitingListQuickButtons: 'Quick buttons for waiting lists',
waitingListQuickButtonsHelp:
'Move waiting officials off duty and return them to the end of their previous list. Only officials with a saved list have a return button. Requires Service Judge to be enabled.',
moveOffDuty: 'Move off duty',
returnToQueue: 'Return to waiting list',
confirmMoveOffDuty: 'Move this official to Not on Duty?',
confirmReturnToQueue:
'Return this official to the bottom of the waiting list?',
showAlerts: 'Show alerts on quick buttons',
serviceJudge: 'Service Judge',
language: 'Language',
@@ -62,6 +70,14 @@ export const translations = {
themeLight: 'Világos',
themeDark: 'Sötét',
themeSystem: 'Eszközbeállítás szerint',
waitingListQuickButtons: 'Gyors gombok a várólistákhoz',
waitingListQuickButtonsHelp:
'A várakozó tisztségviselőket pihenőre küldheted, majd visszaküldheted őket az előző várólistájuk végére. A visszaküldés gomb csak mentett várólista esetén jelenik meg. Az Adogatásbíró beállítás bekapcsolása szükséges.',
moveOffDuty: 'Pihenőre küldés',
returnToQueue: 'Vissza a várólistára',
confirmMoveOffDuty: 'Pihenőre küldöd ezt a tisztségviselőt?',
confirmReturnToQueue:
'Visszaküldöd ezt a tisztségviselőt a várólista végére?',
showAlerts: 'Figyelmeztetések megjelenítése a gyors gomboknál',
serviceJudge: 'Adogatásbíró',
language: 'Nyelv',
+8 -2
View File
@@ -29,7 +29,12 @@ export class SettingsService {
return;
}
await db.settings.update(1, changes);
await db.settings.update(1, {
...changes,
...(!(changes.withServiceJudge ?? current.withServiceJudge)
? { waitingListQuickButtons: false }
: {})
});
}
private async ensureSettings() {
@@ -40,7 +45,8 @@ export class SettingsService {
id: 1,
numberOfCourts: 5,
withServiceJudge: true,
showAlert: true
showAlert: true,
waitingListQuickButtons: false
});
}
}
@@ -0,0 +1,95 @@
import { TestBed } from '@angular/core/testing';
import { db } from 'db';
import { WaitingDutyService } from './waiting-duty.service';
describe('WaitingDutyService', () => {
let service: WaitingDutyService;
beforeEach(async () => {
await Promise.all([
db.settings.clear(),
db.umpires.clear(),
db.waitingUmpires.clear(),
db.waitingServiceJudges.clear(),
db.courtUmpires.clear(),
db.courtServiceJudges.clear()
]);
await db.settings.put({
id: 1,
withServiceJudge: true,
waitingListQuickButtons: true,
showAlert: false,
numberOfCourts: 5
});
await db.umpires.put({
id: 1,
firstName: 'Alex',
lastName: 'Smith',
gender: 'M',
country: 'GB'
});
service = TestBed.inject(WaitingDutyService);
});
afterEach(async () => {
TestBed.resetTestingModule();
await Promise.all([
db.settings.clear(),
db.umpires.clear(),
db.waitingUmpires.clear(),
db.waitingServiceJudges.clear(),
db.courtUmpires.clear(),
db.courtServiceJudges.clear()
]);
});
for (const role of ['umpire', 'serviceJudge'] as const) {
it(`remembers the ${role} queue and returns at its bottom only once`, async () => {
if (role === 'umpire')
await db.waitingUmpires.add({ umpireId: 1, order: 1 });
else await db.waitingServiceJudges.add({ serviceJudgeId: 1, order: 1 });
await service.move(1, 'rest');
expect((await db.umpires.get(1))?.returnToQueue).toBe(role);
expect(
(await db.waitingUmpires.count()) +
(await db.waitingServiceJudges.count())
).toBe(0);
if (role === 'umpire')
await db.waitingUmpires.add({ umpireId: 2, order: 1 });
else await db.waitingServiceJudges.add({ serviceJudgeId: 2, order: 1 });
await Promise.all([service.move(1, 'umpire'), service.move(1, 'umpire')]);
const items =
role === 'umpire'
? await db.waitingUmpires.orderBy('order').toArray()
: await db.waitingServiceJudges.orderBy('order').toArray();
expect(items.map((item) => item.order)).toEqual([1, 2]);
expect((await db.umpires.get(1))?.returnToQueue).toBeUndefined();
});
}
for (const role of ['umpire', 'serviceJudge'] as const) {
it(`saves the ${role} origin on a drag move even with quick buttons disabled`, async () => {
await db.settings.update(1, { waitingListQuickButtons: false });
if (role === 'umpire')
await db.waitingUmpires.add({ umpireId: 1, order: 1 });
else await db.waitingServiceJudges.add({ serviceJudgeId: 1, order: 1 });
await service.moveToRest(1);
expect((await db.umpires.get(1))?.returnToQueue).toBe(role);
expect(await db.waitingUmpires.count()).toBe(0);
expect(await db.waitingServiceJudges.count()).toBe(0);
});
}
it('does not return officials without a saved origin', async () => {
await service.move(1, 'umpire');
expect(await db.waitingUmpires.count()).toBe(0);
expect(await db.waitingServiceJudges.count()).toBe(0);
});
it('ignores stale return actions for assigned officials', async () => {
await db.courtUmpires.add({ umpireId: 1, courtNo: 1 });
await service.move(1, 'umpire');
expect(await db.waitingUmpires.count()).toBe(0);
});
it('does nothing when service judges are disabled', async () => {
await db.settings.update(1, { withServiceJudge: false });
await service.move(1, 'umpire');
expect(await db.waitingUmpires.count()).toBe(0);
});
});
+81
View File
@@ -0,0 +1,81 @@
import { Injectable, inject } from '@angular/core';
import { db, WaitingRole } from 'db';
import { WaitingUmpiresService } from './waiting-umpires.service';
import { WaitingServiceJudgesService } from './waiting-service-judges.service';
@Injectable({ providedIn: 'root' })
export class WaitingDutyService {
private readonly umpires = inject(WaitingUmpiresService);
private readonly judges = inject(WaitingServiceJudgesService);
async moveToRest(officialId: number): Promise<void> {
await this.moveOfficial(officialId, 'rest', false);
}
async move(
officialId: number,
destination: WaitingRole | 'rest'
): Promise<void> {
await this.moveOfficial(officialId, destination, true);
}
private async moveOfficial(
officialId: number,
destination: WaitingRole | 'rest',
requireQuickButtons: boolean
): Promise<void> {
await db.transaction(
'rw',
[
db.settings,
db.umpires,
db.waitingUmpires,
db.waitingServiceJudges,
db.courtUmpires,
db.courtServiceJudges
],
async () => {
const settings = await db.settings.get(1);
const official = await db.umpires.get(officialId);
if (
!official ||
(requireQuickButtons &&
(!settings?.withServiceJudge || !settings.waitingListQuickButtons))
)
return;
const umpire = await db.waitingUmpires
.where('umpireId')
.equals(officialId)
.first();
const judge = await db.waitingServiceJudges
.where('serviceJudgeId')
.equals(officialId)
.first();
const onCourt =
(await db.courtUmpires
.where('umpireId')
.equals(officialId)
.count()) ||
(await db.courtServiceJudges
.where('umpireId')
.equals(officialId)
.count());
if (onCourt) return;
if (destination === 'rest') {
if (!umpire && !judge) return;
await db.umpires.update(officialId, {
returnToQueue: umpire ? 'umpire' : 'serviceJudge'
});
await this.umpires.removeByUmpireId(officialId);
await this.judges.removeByUmpireId(officialId);
} else {
if (umpire || judge || !official.returnToQueue) return;
const role = official.returnToQueue;
if (role === 'serviceJudge') await this.judges.add(officialId);
else await this.umpires.add(officialId);
await db.umpires.update(officialId, { returnToQueue: undefined });
}
}
);
}
}
+36
View File
@@ -161,6 +161,18 @@
<ion-label>{{ umpire|fullname }}</ion-label>
</div>
<ion-label>{{ umpire|fullname }}</ion-label>
@if (waitingQuickButtonsEnabled()) {
<ion-button
class="waiting-duty-button"
size="small"
fill="solid"
[attr.aria-label]="('moveOffDuty' | translate) + ': ' + (umpire | fullname)"
[attr.title]="'moveOffDuty' | translate"
(pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation(); changeWaitingDuty(umpire, true)">
<ion-icon slot="icon-only" name="exit-outline"></ion-icon>
</ion-button>
}
</div>
}
</ion-list>
@@ -179,6 +191,18 @@
<ion-label>{{ umpire|fullname }}</ion-label>
</div>
<ion-label>{{ umpire|fullname }}</ion-label>
@if (waitingQuickButtonsEnabled()) {
<ion-button
class="waiting-duty-button"
size="small"
fill="solid"
[attr.aria-label]="('moveOffDuty' | translate) + ': ' + (umpire | fullname)"
[attr.title]="'moveOffDuty' | translate"
(pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation(); changeWaitingDuty(umpire, true)">
<ion-icon slot="icon-only" name="exit-outline"></ion-icon>
</ion-button>
}
</div>
}
</ion-list>
@@ -198,6 +222,18 @@
<ion-label>{{ umpire|fullname }}</ion-label>
</div>
<ion-label>{{ umpire|fullname }}</ion-label>
@if (waitingQuickButtonsEnabled() && umpire.returnToQueue) {
<ion-button
class="waiting-duty-button"
size="small"
fill="solid"
[attr.aria-label]="('returnToQueue' | translate) + ': ' + (umpire | fullname)"
[attr.title]="'returnToQueue' | translate"
(pointerdown)="$event.stopPropagation()"
(click)="$event.stopPropagation(); changeWaitingDuty(umpire, false)">
<ion-icon slot="icon-only" name="enter-outline"></ion-icon>
</ion-button>
}
</div>
}
</ion-list>
+15
View File
@@ -38,3 +38,18 @@
background-color: var(--ion-color-step-150, #d9d9d9);
}
}
.bottom-lists .custom-ion-item {
display: flex;
align-items: center;
gap: 4px;
ion-label {
flex: 1;
min-width: 0;
}
.waiting-duty-button {
flex-shrink: 0;
margin: 0;
}
}
+75
View File
@@ -1,7 +1,9 @@
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { CdkDragDrop } from '@angular/cdk/drag-drop';
import { signal } from '@angular/core';
import { AlertController } from '@ionic/angular';
import { CourtServiceJudge, CourtUmpire, Settings, Umpire } from 'db';
import { WaitingDutyService } from '../services/waiting-duty.service';
import { Tab1Page } from './tab1.page';
import { SettingsService } from '../services/settings-service';
import { UmpireService } from '../services/umpire.service';
@@ -34,6 +36,7 @@ describe('Tab1Page court rotation', () => {
let waitingJudge: jasmine.SpyObj<WaitingServiceJudgesService>;
let umpireCourt: jasmine.SpyObj<CourtUmpireService>;
let judgeCourt: jasmine.SpyObj<CourtServiceJudgeService>;
let duty: jasmine.SpyObj<WaitingDutyService>;
let alerts: jasmine.SpyObj<AlertController>;
beforeEach(() => {
@@ -72,6 +75,9 @@ describe('Tab1Page court rotation', () => {
['save', 'removeByUmpireId'],
{ umpires: courtJudges }
);
duty = jasmine.createSpyObj('WaitingDutyService', ['move', 'moveToRest']);
duty.move.and.resolveTo();
duty.moveToRest.and.resolveTo();
alerts = jasmine.createSpyObj('AlertController', ['create']);
waitingUmpire.getCurrentUmpire.and.resolveTo(official(1));
waitingJudge.getCurrentUmpire.and.resolveTo(official(2));
@@ -85,6 +91,7 @@ describe('Tab1Page court rotation', () => {
judgeCourt.removeByUmpireId.and.resolveTo();
TestBed.configureTestingModule({
providers: [
{ provide: WaitingDutyService, useValue: duty },
{ provide: SettingsService, useValue: { settings } },
{ provide: UmpireService, useValue: { umpires } },
{ provide: WaitingUmpiresService, useValue: waitingUmpire },
@@ -98,6 +105,74 @@ describe('Tab1Page court rotation', () => {
component = fixture.componentInstance;
});
for (const source of [
'list-waiting-umpires',
'list-waiting-service-judges'
]) {
it(`saves the waiting origin when dropped from ${source} to rest`, async () => {
await component.dropToRest({
previousContainer: { id: source },
container: { id: 'list-on-rest' },
item: { data: official(1) }
} as CdkDragDrop<Umpire[]>);
expect(duty.moveToRest).toHaveBeenCalledOnceWith(1);
expect(waitingUmpire.removeByUmpireId).not.toHaveBeenCalled();
expect(waitingJudge.removeByUmpireId).not.toHaveBeenCalled();
});
}
it('moves directly when quick-button alerts are disabled', async () => {
settings.update((value) => ({ ...value, waitingListQuickButtons: true }));
await component.changeWaitingDuty(official(1), true);
expect(duty.move).toHaveBeenCalledOnceWith(1, 'rest');
expect(alerts.create).not.toHaveBeenCalled();
});
it('ignores return actions without a saved origin', async () => {
settings.update((value) => ({
...value,
waitingListQuickButtons: true,
showAlert: true
}));
await component.changeWaitingDuty(official(5), false);
expect(duty.move).not.toHaveBeenCalled();
expect(alerts.create).not.toHaveBeenCalled();
});
it('confirms returning to the saved service-judge queue', async () => {
settings.update((value) => ({
...value,
waitingListQuickButtons: true,
showAlert: true
}));
alerts.create.and.resolveTo({
present: async () => undefined
} as unknown as HTMLIonAlertElement);
await component.changeWaitingDuty(
{ ...official(5), returnToQueue: 'serviceJudge' },
false
);
expect(duty.move).not.toHaveBeenCalled();
const buttons = alerts.create.calls.mostRecent().args[0]?.buttons;
const confirm = buttons?.find(
(button) => typeof button !== 'string' && button.role === 'confirm'
);
if (typeof confirm === 'object' && typeof confirm.handler === 'function')
confirm.handler(undefined);
expect(duty.move).toHaveBeenCalledOnceWith(5, 'serviceJudge');
});
it('hides and ignores quick actions when service judges are disabled', async () => {
settings.update((value) => ({
...value,
waitingListQuickButtons: true,
withServiceJudge: false
}));
expect(component.waitingQuickButtonsEnabled()).toBeFalse();
await component.changeWaitingDuty(official(1), true);
expect(duty.move).not.toHaveBeenCalled();
});
it('waits for confirmation before assigning officials', async () => {
settings.update((value) => ({ ...value, showAlert: true }));
const present = jasmine.createSpy('present').and.resolveTo();
+45 -1
View File
@@ -22,6 +22,7 @@ import {
import { SettingsService } from '../services/settings-service';
import { UmpireService } from '../services/umpire.service';
import { Umpire } from 'db';
import { WaitingDutyService } from '../services/waiting-duty.service';
import { WaitingUmpiresService } from '../services/waiting-umpires.service';
import { WaitingServiceJudgesService } from '../services/waiting-service-judges.service';
import { FullnamePipe } from '../fullname-pipe';
@@ -71,6 +72,8 @@ export class Tab1Page {
addIcons({ exitOutline, enterOutline });
}
readonly waitingDutyService = inject(WaitingDutyService);
readonly settingsService = inject(SettingsService);
readonly umpireService = inject(UmpireService);
readonly waitingUmpireService = inject(WaitingUmpiresService);
@@ -285,12 +288,53 @@ export class Tab1Page {
return this.settings()?.withServiceJudge ?? false;
});
dropToRest(event: CdkDragDrop<Umpire[]>) {
readonly waitingQuickButtonsEnabled = computed(
() =>
this.serviceJudgeEnabled() && !!this.settings()?.waitingListQuickButtons
);
async changeWaitingDuty(umpire: Umpire, toRest: boolean): Promise<void> {
if (!this.waitingQuickButtonsEnabled()) return;
const destination = toRest ? 'rest' : umpire.returnToQueue;
if (!destination) return;
const move = () => this.waitingDutyService.move(umpire.id, destination);
if (!this.settings()?.showAlert) {
await move();
return;
}
const alert = await this.alertController.create({
header: this.translationService.translate('confirmation'),
subHeader: this.translationService.translate(
toRest ? 'confirmMoveOffDuty' : 'confirmReturnToQueue'
),
message: this.fullnamePipe.transform(umpire),
buttons: [
{ text: this.translationService.translate('no'), role: 'cancel' },
{
text: this.translationService.translate('yes'),
role: 'confirm',
handler: () => {
void move();
}
}
]
});
await alert.present();
}
async dropToRest(event: CdkDragDrop<Umpire[]>): Promise<void> {
if (event.previousContainer === event.container) {
return;
} else {
const comingFrom = event.previousContainer.id;
const umpireToMove = event.item.data;
if (
comingFrom === 'list-waiting-umpires' ||
comingFrom === 'list-waiting-service-judges'
) {
await this.waitingDutyService.moveToRest(umpireToMove.id);
return;
}
this.removeFromOriginalPlace(umpireToMove, comingFrom);
}
}
+16 -1
View File
@@ -35,6 +35,21 @@
</ion-toggle>
</ion-item>
<ion-item>
<div class="waiting-list-setting">
<ion-toggle
[checked]="currentSettings.waitingListQuickButtons ?? false"
[disabled]="!currentSettings.withServiceJudge"
aria-describedby="waiting-list-help"
(ionChange)="updateWaitingListQuickButtons($event)">
{{ 'waitingListQuickButtons' | translate }}
</ion-toggle>
<p id="waiting-list-help" class="setting-help">
{{ 'waitingListQuickButtonsHelp' | translate }}
</p>
</div>
</ion-item>
<ion-item>
<ion-toggle
[checked]="currentSettings.showAlert"
@@ -79,7 +94,7 @@
<ion-col sizeLg="6" pushLg="3">
<ion-item>
<ion-label class="ion-text-center"
>{{ 'version' | translate }}: 2.0.0</ion-label
>{{ 'version' | translate }}: 2.1.0</ion-label
>
</ion-item>
</ion-col>
+15
View File
@@ -0,0 +1,15 @@
.waiting-list-setting {
width: 100%;
padding-block: 12px;
ion-toggle {
width: 100%;
}
}
.setting-help {
margin: 8px 0 0;
color: var(--ion-color-medium-shade);
font-size: 0.875rem;
line-height: 1.4;
}
+8
View File
@@ -99,6 +99,14 @@ export class Tab3Page {
}
}
async updateWaitingListQuickButtons(
event: CustomEvent<{ checked: boolean }>
): Promise<void> {
await this.settingsService.update({
waitingListQuickButtons: event.detail.checked
});
}
async updateShowAlert(event: CustomEvent): Promise<void> {
await this.settingsService.update({
showAlert: event.detail.checked
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"noEmit": true,
"types": ["jasmine"]
},
"include": ["src/**/*.ts", "db.ts"]
}
+5
View File
@@ -1,6 +1,11 @@
/* To learn more about this file see: https://angular.io/config/tsconfig. */
{
"compileOnSave": false,
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.spec.json" }
],
"compilerOptions": {
"paths": { "db": ["./db.ts"] },
"outDir": "./dist/out-tsc",