From 8dad54fcf1d6107146c22fb90e7df17e70b0fafa Mon Sep 17 00:00:00 2001 From: Richard Toth Date: Wed, 4 Nov 2026 21:20:14 +0100 Subject: [PATCH] fix: apply Angular and Ionic audit improvements --- docs/architecture.md | 19 +- docs/code-style-audit.md | 93 +++++++ eslint.config.cjs | 10 +- src/app/app.component.ts | 9 +- src/app/fullname-pipe.ts | 2 +- src/app/i18n/translation.service.spec.ts | 47 ++++ src/app/i18n/translation.service.ts | 24 +- src/app/i18n/translations.ts | 5 +- .../services/court-rotation.service.spec.ts | 151 +++++++++++ src/app/services/court-rotation.service.ts | 167 ++++++++++++ .../court.service.judge.service.spec.ts | 5 + .../services/court.service.judge.service.ts | 11 +- src/app/services/court.umpire.service.spec.ts | 5 + src/app/services/court.umpire.service.ts | 9 +- src/app/services/settings-service.spec.ts | 64 ++++- src/app/services/settings-service.ts | 50 +++- src/app/services/umpire.service.spec.ts | 39 ++- src/app/services/umpire.service.ts | 53 +++- .../waiting-service-judges.service.ts | 35 ++- src/app/services/waiting-umpires.service.ts | 35 ++- src/app/tab1/tab1.page.html | 78 +++--- src/app/tab1/tab1.page.spec.ts | 68 +++-- src/app/tab1/tab1.page.ts | 247 +++++------------- src/app/tab2/tab2.page.html | 15 +- src/app/tab2/tab2.page.spec.ts | 62 ++++- src/app/tab2/tab2.page.ts | 66 ++--- src/app/tab3/tab3.page.html | 3 +- src/app/tab3/tab3.page.spec.ts | 9 +- src/app/tab3/tab3.page.ts | 56 +--- src/app/tabs/tabs.page.ts | 9 +- src/global.scss | 33 +-- src/index.html | 48 ++-- src/main.ts | 4 +- src/zone-flags.ts | 4 +- tsconfig.app.json | 10 +- 35 files changed, 1042 insertions(+), 503 deletions(-) create mode 100644 docs/code-style-audit.md create mode 100644 src/app/i18n/translation.service.spec.ts create mode 100644 src/app/services/court-rotation.service.spec.ts create mode 100644 src/app/services/court-rotation.service.ts diff --git a/docs/architecture.md b/docs/architecture.md index 3453314..7f112e6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -33,7 +33,23 @@ Pages are standalone Angular components and directly import the Ionic standalone 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. +`CourtRotationService` coordinates drag-and-drop moves, court assignments, and +rotations inside Dexie transactions. It rechecks current persisted state before +writing, so repeated actions or stale confirmations cannot assign the same queue +head twice. Confirmation actions also verify that the role configuration still +matches the officials shown in the alert. Pages handle presentation and delegate +the state changes to services. + +`SettingsService` validates positive integer court counts and atomically updates +settings with assignment cleanup. `UmpireService` atomically deletes an official's +duty records and closes queue gaps when deleting that official. Roster forms await +persistence before dismissing their modal and keep the draft if saving fails. + +The Ionic 9 `@ionic/angular` entry point exports standalone components. Root and +tab outlet hosts explicitly use eager change detection so plain-field form state +continues to update under Angular 22. Translation and full-name pipes remain +impure because their results also depend on the selected language, beyond their +explicit pipe arguments. ## Persistence model @@ -137,5 +153,4 @@ Operations that move an official between states should preserve exclusivity even ## 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/`? diff --git a/docs/code-style-audit.md b/docs/code-style-audit.md new file mode 100644 index 0000000..4fd1d49 --- /dev/null +++ b/docs/code-style-audit.md @@ -0,0 +1,93 @@ +# Angular and Ionic code audit + +Reviewed on 2026-11-04 against the installed Angular 22 and Ionic 9 packages. +Scope: application components, templates, styles, services, localization, +bootstrap, routes, TypeScript configuration, and lint rules. + +## Corrections + +- Moved court and queue state changes from page orchestration into + `CourtRotationService`. Moves and rotations now use transactions, await writes, + check persisted court occupancy, and reject stale confirmations. A failed write + rolls back the entire operation. +- Fixed rotation without confirmation when service judges are disabled. The lone + umpire returns to the umpire queue. +- Moved court-count and service-judge cleanup into `SettingsService` transactions. + Court counts must be positive safe integers. Court count changes commit after + the input is confirmed rather than deleting assignments during each keystroke. +- Made roster deletion remove dependent duty records and normalize remaining + queue positions in the same transaction. Clearing the roster clears duty too. +- Made queue insertion/reordering and deletion/renumbering atomic. Queue heads + use the lowest numeric order, including when the first order is greater than one. +- Replaced method-created `toSignal()` court queries with `computed()` lookups + derived from existing service signals. Callers no longer need an injection + context or create a new database subscription for every lookup. +- Kept defensive copies of caller-owned records before Dexie inserts, so generated + IDs do not mutate form or caller state. Removed unnecessary entity casts. +- Made roster saves finish before modal dismissal. The form prevents duplicate + saves, rejects blank names, trims names and country, and preserves the draft + with a translated error alert when saving fails. Modal presentation uses its + Angular view reference instead of a global trigger ID. +- Replaced the court loop's array-constructor migration expression with the + existing computed court indexes and stable court-number tracking. +- Corrected Ionic grid boolean bindings, removed redundant literal property + bindings and an empty disabled binding, and applied the empty-court drag + predicate consistently to both roles. +- Localized the remaining hardcoded roster heading, added names for icon-only + actions, allowed browser zoom, and synchronized the document language with + the selected UI language. +- Exposed language state as a readonly signal and rejected inherited object keys + such as `toString` as saved language values. +- Replaced alert styles coupled to generated Ionic classes with the public host + class and CSS variables, with a viewport-aware minimum width. +- Added bootstrap error handling and moved the desktop platform lookup inside + the updater's error boundary. +- Removed unused dependencies, imports, pipe arguments, and non-null assertions; + added `readonly` where appropriate and typed Ionic events and CDK drag data. +- Enabled lint rules for unused variables and explicit `any`. Removed the blanket + suppression of optional-chain and nullish-coalescing compiler diagnostics. +- Formatted changed files with the repository Prettier configuration and updated + the architecture documentation. + +## Conventions retained + +- Ionic 9 exports standalone components from `@ionic/angular`; no import-path + migration is needed. The installed package does not export the old + `@ionic/angular/standalone` entry point. +- Eager change detection remains explicit on the root and Ionic routed components. + Ionic outlet ancestors must continue checking pages that use plain-field form + state. See [Ionic page lifecycle](https://ionicframework.com/docs/angular/lifecycle). +- Existing standalone components, lazy routes, `inject()`, strict typing, reactive + service signals, and computed page state already follow the project's approach. + See [Ionic standalone build options](https://ionicframework.com/docs/angular/build-options) + and [Angular component guidance](https://github.com/angular/angular/blob/main/packages/core/resources/best-practices.md). +- The translation and full-name pipes remain impure: their output depends on a + language signal in addition to the explicit pipe arguments. +- Template-driven forms remain appropriate for the small roster form. This audit + does not require a form-framework migration. +- No database schema, release versions, dependencies, or tournament rules changed. + +## Validation + +Final checks, using Node `lts/jod`: + +- `npm run lint` — passed. +- `npm run build` — passed, including strict template checking with diagnostic + suppressions removed. +- `CHROME_BIN=/snap/bin/chromium npm test -- --watch=false --browsers=ChromeHeadless` + — all 102 tests passed. Karma and Chromium ran outside the sandbox because the + sandbox cannot bind Karma's server port. +- Focused roster tests using `--include=src/app/tab2/tab2.page.spec.ts` — all four + passed before the final full-suite run. +- Prettier on touched files and `git diff --check` — passed. + +Regression coverage includes concurrent assignment attempts, failed rotation +rollback, stale confirmations, FIFO ordering with gaps, state exclusivity, +settings cleanup, cascade deletion, readonly language behavior, name localization, +and roster save failure and duplicate-submit behavior. + +Desktop installation, updater downloads, native platform builds, and manual +end-to-end drag interaction were not exercised by this frontend audit. +The existing Karma builder and Zone.js/Jasmine integration emit upstream +deprecation messages; migrating the runner or dependency versions is separate +from these application fixes. diff --git a/eslint.config.cjs b/eslint.config.cjs index c494f7f..7497621 100644 --- a/eslint.config.cjs +++ b/eslint.config.cjs @@ -1,11 +1,13 @@ const { defineConfig } = require('eslint/config'); const angular = require('angular-eslint'); +const typescript = require('@typescript-eslint/eslint-plugin'); module.exports = defineConfig( { ignores: ['projects/**', 'www/**', 'coverage/**'] }, { files: ['src/**/*.ts', 'db.ts'], extends: angular.configs.tsRecommended, + plugins: { '@typescript-eslint': typescript }, languageOptions: { parserOptions: { project: ['./tsconfig.eslint.json'], @@ -14,8 +16,14 @@ module.exports = defineConfig( }, processor: angular.processInlineTemplates, rules: { - // Preserve existing Ionic page updates while retaining Zone.js. + // Ionic outlets and plain-field forms need eager change detection. + // https://ionicframework.com/docs/angular/lifecycle '@angular-eslint/prefer-on-push-component-change-detection': 'off', + '@typescript-eslint/no-unused-vars': [ + 'error', + { argsIgnorePattern: '^_' } + ], + '@typescript-eslint/no-explicit-any': 'error', '@angular-eslint/component-class-suffix': [ 'error', { suffixes: ['Page', 'Component'] } diff --git a/src/app/app.component.ts b/src/app/app.component.ts index 2074584..84b3ad1 100644 --- a/src/app/app.component.ts +++ b/src/app/app.component.ts @@ -26,7 +26,7 @@ export class AppComponent { readonly themeService = inject(ThemeService); constructor() { - this.checkForUpdates(); + void this.checkForUpdates(); } private async checkForUpdates(): Promise { @@ -34,12 +34,9 @@ export class AppComponent { return; } - // Snap handles updates on Linux. - if (platform() === 'linux') { - return; - } - try { + // Snap handles updates on Linux. + if (platform() === 'linux') return; const update = await check(); if (update) { diff --git a/src/app/fullname-pipe.ts b/src/app/fullname-pipe.ts index f55a0d5..9e70aef 100644 --- a/src/app/fullname-pipe.ts +++ b/src/app/fullname-pipe.ts @@ -10,7 +10,7 @@ import { TranslationService } from './i18n/translation.service'; export class FullnamePipe implements PipeTransform { readonly translationService = inject(TranslationService); - transform(value: Umpire | undefined, ...args: unknown[]): string { + transform(value: Umpire | undefined): string { if (typeof value === 'undefined') { return ''; } diff --git a/src/app/i18n/translation.service.spec.ts b/src/app/i18n/translation.service.spec.ts new file mode 100644 index 0000000..905e860 --- /dev/null +++ b/src/app/i18n/translation.service.spec.ts @@ -0,0 +1,47 @@ +import { TestBed } from '@angular/core/testing'; +import { TranslationService } from './translation.service'; +import { TranslatePipe } from './translation.pipe'; +import { FullnamePipe } from '../fullname-pipe'; + +describe('TranslationService', () => { + let stored: string | null; + let documentLanguage: string; + beforeEach(() => { + documentLanguage = document.documentElement.lang; + stored = localStorage.getItem('language'); + localStorage.setItem('language', 'en'); + }); + afterEach(() => { + TestBed.resetTestingModule(); + document.documentElement.lang = documentLanguage; + if (stored === null) localStorage.removeItem('language'); + else localStorage.setItem('language', stored); + }); + + it('ignores inherited object keys in saved language values', () => { + localStorage.setItem('language', 'toString'); + const service = TestBed.inject(TranslationService); + expect(['en', 'hu']).toContain(service.language()); + expect(typeof service.translate('court')).toBe('string'); + }); + + it('updates translations and name order when the language changes', () => { + const service = TestBed.inject(TranslationService); + const translate = TestBed.runInInjectionContext(() => new TranslatePipe()); + const fullname = TestBed.runInInjectionContext(() => new FullnamePipe()); + const official = { + id: 1, + firstName: 'Alex', + lastName: 'Smith', + country: 'GB', + gender: 'M' + }; + expect(translate.transform('court')).toBe('Court'); + expect(fullname.transform(official)).toBe('Alex Smith'); + service.setLanguage('hu'); + expect(translate.transform('court')).toBe('Pálya'); + expect(fullname.transform(official)).toBe('Smith Alex'); + expect(localStorage.getItem('language')).toBe('hu'); + expect(document.documentElement.lang).toBe('hu'); + }); +}); diff --git a/src/app/i18n/translation.service.ts b/src/app/i18n/translation.service.ts index 7a0568f..c549ab4 100644 --- a/src/app/i18n/translation.service.ts +++ b/src/app/i18n/translation.service.ts @@ -1,4 +1,5 @@ -import { Injectable, signal } from '@angular/core'; +import { DOCUMENT } from '@angular/common'; +import { inject, Injectable, signal } from '@angular/core'; import { translations, Language, @@ -10,12 +11,21 @@ import { providedIn: 'root' }) export class TranslationService { + private readonly document = inject(DOCUMENT); private readonly storageKey = 'language'; - readonly language = signal(this.getInitialLanguage()); + private readonly selectedLanguage = signal( + this.getInitialLanguage() + ); + readonly language = this.selectedLanguage.asReadonly(); + + constructor() { + this.document.documentElement.lang = this.language(); + } setLanguage(language: Language): void { - this.language.set(language); + this.selectedLanguage.set(language); + this.document.documentElement.lang = language; localStorage.setItem(this.storageKey, language); } @@ -32,13 +42,13 @@ export class TranslationService { private getInitialLanguage(): Language { const stored = localStorage.getItem(this.storageKey); - if (stored && stored in translations) { - return stored as Language; + if (stored === 'en' || stored === 'hu') { + return stored; } - const browserLanguage = navigator.language.split('-')[0] as Language; + const browserLanguage = navigator.language.split('-')[0]; - if (browserLanguage in translations) { + if (browserLanguage === 'en' || browserLanguage === 'hu') { return browserLanguage; } diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index 0e67802..f0e4ef4 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -1,6 +1,3 @@ -import { language, male, settings } from 'ionicons/icons'; -import { noop } from 'rxjs'; - export const translations = { en: { courts: 'Courts', @@ -27,6 +24,7 @@ export const translations = { newCourtOfficial: 'New Court Official', cancel: 'Cancel', save: 'Save', + saveFailed: 'Could not save the court official. Please try again.', firstName: 'First Name', lastName: 'Last Name', country: 'Country', @@ -88,6 +86,7 @@ export const translations = { newCourtOfficial: 'Új Tisztségviselő', cancel: 'Mégse', save: 'Mentés', + saveFailed: 'Nem sikerült menteni a tisztségviselőt. Próbáld újra.', firstName: 'Keresztnév', lastName: 'Vezetéknév', country: 'Ország', diff --git a/src/app/services/court-rotation.service.spec.ts b/src/app/services/court-rotation.service.spec.ts new file mode 100644 index 0000000..6fa84ec --- /dev/null +++ b/src/app/services/court-rotation.service.spec.ts @@ -0,0 +1,151 @@ +import { TestBed } from '@angular/core/testing'; +import { db } from 'db'; +import { CourtRotationService } from './court-rotation.service'; +import { WaitingUmpiresService } from './waiting-umpires.service'; + +describe('CourtRotationService', () => { + let service: CourtRotationService; + const clear = async () => { + await Promise.all([ + db.settings.clear(), + db.umpires.clear(), + db.waitingUmpires.clear(), + db.waitingServiceJudges.clear(), + db.courtUmpires.clear(), + db.courtServiceJudges.clear() + ]); + }; + beforeEach(async () => { + await clear(); + await db.settings.put({ + id: 1, + numberOfCourts: 3, + withServiceJudge: true, + showAlert: false + }); + await db.umpires.bulkPut( + [1, 2, 3].map((id) => ({ + id, + firstName: `Official ${id}`, + lastName: 'Smith', + country: 'GB', + gender: 'M' + })) + ); + service = TestBed.inject(CourtRotationService); + }); + afterEach(async () => { + TestBed.resetTestingModule(); + await clear(); + }); + + it('assigns the lowest orders even when the queues do not start at one', async () => { + await db.waitingUmpires.bulkAdd([ + { umpireId: 1, order: 3 }, + { umpireId: 3, order: 4 } + ]); + await db.waitingServiceJudges.add({ serviceJudgeId: 2, order: 8 }); + await service.assignNext(1, 1, 2); + expect( + (await db.courtUmpires.toArray()).map((item) => item.umpireId) + ).toEqual([1]); + expect( + (await db.courtServiceJudges.toArray()).map((item) => item.umpireId) + ).toEqual([2]); + expect( + (await db.waitingUmpires.toArray()).map((item) => [ + item.umpireId, + item.order + ]) + ).toEqual([[3, 1]]); + expect(await db.waitingServiceJudges.count()).toBe(0); + }); + + it('serializes repeated assignments so officials cannot occupy two courts', async () => { + await db.waitingUmpires.add({ umpireId: 1, order: 1 }); + await db.waitingServiceJudges.add({ serviceJudgeId: 2, order: 1 }); + await Promise.all([ + service.assignNext(1, 1, 2), + service.assignNext(2, 1, 2) + ]); + expect(await db.courtUmpires.count()).toBe(1); + expect(await db.courtServiceJudges.count()).toBe(1); + }); + + it('ignores stale confirmations and occupied courts', async () => { + await db.waitingUmpires.add({ umpireId: 3, order: 1 }); + await db.waitingServiceJudges.add({ serviceJudgeId: 2, order: 1 }); + await service.assignNext(1, 1, 2); + expect(await db.courtUmpires.count()).toBe(0); + await db.courtUmpires.add({ courtNo: 1, umpireId: 1 }); + await service.assignNext(1, 3, 2); + expect(await db.courtUmpires.count()).toBe(1); + expect(await db.waitingUmpires.count()).toBe(1); + }); + + it('rotates both officials into the opposite queues', async () => { + await db.courtUmpires.add({ umpireId: 1, courtNo: 1 }); + await db.courtServiceJudges.add({ umpireId: 2, courtNo: 1 }); + await service.rotate(1, 1, 2); + expect(await db.courtUmpires.count()).toBe(0); + expect(await db.courtServiceJudges.count()).toBe(0); + expect((await db.waitingUmpires.toArray())[0].umpireId).toBe(2); + expect((await db.waitingServiceJudges.toArray())[0].serviceJudgeId).toBe(1); + }); + + it('returns a lone umpire when service judges are disabled', async () => { + await db.settings.update(1, { withServiceJudge: false }); + await db.courtUmpires.add({ umpireId: 1, courtNo: 1 }); + await service.rotate(1, 1); + expect(await db.courtUmpires.count()).toBe(0); + expect((await db.waitingUmpires.toArray())[0].umpireId).toBe(1); + }); + + it('rolls back court removal if requeuing fails', async () => { + await db.settings.update(1, { withServiceJudge: false }); + await db.courtUmpires.add({ umpireId: 1, courtNo: 1 }); + spyOn(TestBed.inject(WaitingUmpiresService), 'add').and.rejectWith( + new Error('write failed') + ); + await expectAsync(service.rotate(1, 1)).toBeRejectedWithError( + 'write failed' + ); + expect(await db.courtUmpires.count()).toBe(1); + expect(await db.waitingUmpires.count()).toBe(0); + }); + + it('moves and reorders officials without duplicating their states', async () => { + await db.waitingUmpires.bulkAdd([ + { umpireId: 1, order: 1 }, + { umpireId: 2, order: 2 } + ]); + await service.moveToQueue(2, 'umpire', 1); + expect( + (await db.waitingUmpires.orderBy('order').toArray()).map( + (item) => item.umpireId + ) + ).toEqual([2, 1]); + await service.moveToQueue(1, 'serviceJudge', 1); + await service.moveToCourt(1, 'umpire', 1); + expect(await db.waitingServiceJudges.count()).toBe(0); + expect( + (await db.waitingUmpires.toArray()).map((item) => item.umpireId) + ).toEqual([2]); + await service.moveToRest(1); + expect(await db.courtUmpires.count()).toBe(0); + }); + + it('does not move to an occupied or invalid court or a disabled role', async () => { + await db.waitingUmpires.add({ umpireId: 1, order: 1 }); + await db.courtUmpires.add({ umpireId: 2, courtNo: 1 }); + await service.moveToCourt(1, 'umpire', 1); + await service.moveToCourt(1, 'umpire', 4); + await db.settings.update(1, { withServiceJudge: false }); + await service.moveToQueue(1, 'serviceJudge', 1); + await service.moveToCourt(1, 'serviceJudge', 2); + expect(await db.waitingUmpires.count()).toBe(1); + expect(await db.waitingServiceJudges.count()).toBe(0); + expect(await db.courtUmpires.count()).toBe(1); + expect(await db.courtServiceJudges.count()).toBe(0); + }); +}); diff --git a/src/app/services/court-rotation.service.ts b/src/app/services/court-rotation.service.ts new file mode 100644 index 0000000..97e22f3 --- /dev/null +++ b/src/app/services/court-rotation.service.ts @@ -0,0 +1,167 @@ +import { inject, Injectable } from '@angular/core'; +import { db, WaitingRole } from 'db'; +import { WaitingUmpiresService } from './waiting-umpires.service'; +import { WaitingServiceJudgesService } from './waiting-service-judges.service'; + +/** Coordinates state changes atomically; pages only supply the requested action. */ +@Injectable({ providedIn: 'root' }) +export class CourtRotationService { + private readonly umpires = inject(WaitingUmpiresService); + private readonly judges = inject(WaitingServiceJudgesService); + + private transaction(action: () => Promise): Promise { + return db.transaction( + 'rw', + [ + db.settings, + db.umpires, + db.waitingUmpires, + db.waitingServiceJudges, + db.courtUmpires, + db.courtServiceJudges + ], + action + ); + } + + private async removeOfficial(id: number): Promise { + await db.waitingUmpires.where('umpireId').equals(id).delete(); + await db.waitingServiceJudges.where('serviceJudgeId').equals(id).delete(); + await db.courtUmpires.where('umpireId').equals(id).delete(); + await db.courtServiceJudges.where('umpireId').equals(id).delete(); + await this.umpires.reinitOrders(); + await this.judges.reinitOrders(); + await db.umpires.update(id, { returnToQueue: undefined }); + } + + async moveToQueue( + id: number, + role: WaitingRole, + position: number + ): Promise { + await this.transaction(async () => { + if (!(await db.umpires.get(id))) return; + if ( + role === 'serviceJudge' && + !(await db.settings.get(1))?.withServiceJudge + ) + return; + await this.removeOfficial(id); + if (role === 'umpire') await this.umpires.add(id, position); + else await this.judges.add(id, position); + }); + } + + async moveToRest(id: number): Promise { + await this.transaction(async () => { + await this.removeOfficial(id); + }); + } + + async moveToCourt( + id: number, + role: WaitingRole, + courtNo: number + ): Promise { + await this.transaction(async () => { + const settings = await db.settings.get(1); + if ( + !settings || + !Number.isInteger(courtNo) || + courtNo < 1 || + courtNo > settings.numberOfCourts || + (role === 'serviceJudge' && !settings.withServiceJudge) || + !(await db.umpires.get(id)) + ) + return; + const table = role === 'umpire' ? db.courtUmpires : db.courtServiceJudges; + if (await table.where('courtNo').equals(courtNo).count()) return; + await this.removeOfficial(id); + await table.add({ umpireId: id, courtNo }); + }); + } + + async assignNext( + courtNo: number, + expectedUmpireId: number, + expectedJudgeId?: number + ): Promise { + await this.transaction(async () => { + const settings = await db.settings.get(1); + if ( + !settings || + settings.withServiceJudge !== (expectedJudgeId !== undefined) || + !Number.isInteger(courtNo) || + courtNo < 1 || + courtNo > settings.numberOfCourts + ) + return; + if ( + (await db.courtUmpires.where('courtNo').equals(courtNo).count()) || + (await db.courtServiceJudges.where('courtNo').equals(courtNo).count()) + ) + return; + const umpire = await this.umpires.getCurrentUmpire(); + const judge = settings.withServiceJudge + ? await this.judges.getCurrentUmpire() + : undefined; + // A confirmation must never assign different officials after the queues change. + if ( + !umpire || + umpire.id !== expectedUmpireId || + (settings.withServiceJudge && + (!judge || judge.id !== expectedJudgeId || judge.id === umpire.id)) + ) + return; + await this.removeOfficial(umpire.id); + if (judge) await this.removeOfficial(judge.id); + await db.courtUmpires.add({ courtNo, umpireId: umpire.id }); + if (judge) + await db.courtServiceJudges.add({ courtNo, umpireId: judge.id }); + }); + } + + async rotate( + courtNo: number, + expectedUmpireId: number, + expectedJudgeId?: number + ): Promise { + await this.transaction(async () => { + const settings = await db.settings.get(1); + if ( + !settings || + settings.withServiceJudge !== (expectedJudgeId !== undefined) + ) + return; + const umpire = await db.courtUmpires + .where('courtNo') + .equals(courtNo) + .first(); + const judge = settings.withServiceJudge + ? await db.courtServiceJudges.where('courtNo').equals(courtNo).first() + : undefined; + if ( + !umpire || + umpire.umpireId !== expectedUmpireId || + (settings.withServiceJudge && + (!judge || + judge.umpireId !== expectedJudgeId || + judge.umpireId === umpire.umpireId)) + ) + return; + if ( + !(await db.umpires.get(expectedUmpireId)) || + (judge?.umpireId != null && !(await db.umpires.get(judge.umpireId))) + ) + return; + await this.removeOfficial(expectedUmpireId); + if (judge?.umpireId != null) { + await this.removeOfficial(judge.umpireId); + await this.umpires.add(judge.umpireId); + await this.judges.add(expectedUmpireId); + } else { + await this.umpires.add(expectedUmpireId); + } + }); + } +} diff --git a/src/app/services/court.service.judge.service.spec.ts b/src/app/services/court.service.judge.service.spec.ts index a94eeb6..9d2b05f 100644 --- a/src/app/services/court.service.judge.service.spec.ts +++ b/src/app/services/court.service.judge.service.spec.ts @@ -15,6 +15,11 @@ describe('CourtServiceJudgeService persistence', () => { await db.courtServiceJudges.clear(); }); + it('creates a reactive court lookup outside an injection context', () => { + const assignment = service.getByCourtNo(1); + expect(assignment()).toBeUndefined(); + }); + 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({ diff --git a/src/app/services/court.service.judge.service.ts b/src/app/services/court.service.judge.service.ts index 38c8241..d4d9567 100644 --- a/src/app/services/court.service.judge.service.ts +++ b/src/app/services/court.service.judge.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { computed, Injectable } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { CourtServiceJudge, db } from 'db'; import { liveQuery } from 'dexie'; @@ -20,11 +20,8 @@ export class CourtServiceJudgeService { * Get by court number (reactive) */ getByCourtNo(courtNo: number) { - return toSignal( - liveQuery(() => - db.courtServiceJudges.where('courtNo').equals(courtNo).first() - ), - { initialValue: undefined } + return computed(() => + this.umpires().find((item) => item.courtNo === courtNo) ); } @@ -32,7 +29,7 @@ export class CourtServiceJudgeService { * Create / update */ async save(item: Omit): Promise { - return db.courtServiceJudges.add(item as CourtServiceJudge); + return db.courtServiceJudges.add({ ...item }); } async update( diff --git a/src/app/services/court.umpire.service.spec.ts b/src/app/services/court.umpire.service.spec.ts index 8c25e3c..2e9f6c9 100644 --- a/src/app/services/court.umpire.service.spec.ts +++ b/src/app/services/court.umpire.service.spec.ts @@ -15,6 +15,11 @@ describe('CourtUmpireService persistence', () => { await db.courtUmpires.clear(); }); + it('creates a reactive court lookup outside an injection context', () => { + const assignment = service.getByCourtNo(1); + expect(assignment()).toBeUndefined(); + }); + 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({ diff --git a/src/app/services/court.umpire.service.ts b/src/app/services/court.umpire.service.ts index b8eb6a4..d4b26b9 100644 --- a/src/app/services/court.umpire.service.ts +++ b/src/app/services/court.umpire.service.ts @@ -1,4 +1,4 @@ -import { Injectable } from '@angular/core'; +import { computed, Injectable } from '@angular/core'; import { toSignal } from '@angular/core/rxjs-interop'; import { CourtUmpire, db } from 'db'; import { liveQuery } from 'dexie'; @@ -20,9 +20,8 @@ export class CourtUmpireService { * Get by court number (reactive single) */ getByCourtNo(courtNo: number) { - return toSignal( - liveQuery(() => db.courtUmpires.where('courtNo').equals(courtNo).first()), - { initialValue: undefined } + return computed(() => + this.umpires().find((item) => item.courtNo === courtNo) ); } @@ -30,7 +29,7 @@ export class CourtUmpireService { * Create / update */ async save(item: Omit): Promise { - return db.courtUmpires.add(item as CourtUmpire); + return db.courtUmpires.add({ ...item }); } async update(id: number, changes: Partial): Promise { diff --git a/src/app/services/settings-service.spec.ts b/src/app/services/settings-service.spec.ts index c6b89fc..a678ed8 100644 --- a/src/app/services/settings-service.spec.ts +++ b/src/app/services/settings-service.spec.ts @@ -1,16 +1,68 @@ import { TestBed } from '@angular/core/testing'; - +import { db } from 'db'; import { SettingsService } from './settings-service'; describe('SettingsService', () => { let service: SettingsService; - - beforeEach(() => { - TestBed.configureTestingModule({}); + const clear = async () => { + await Promise.all([ + db.settings.clear(), + db.courtUmpires.clear(), + db.courtServiceJudges.clear(), + db.waitingServiceJudges.clear() + ]); + }; + beforeEach(async () => { + await clear(); service = TestBed.inject(SettingsService); + await service.update({ numberOfCourts: 5, withServiceJudge: true }); + }); + afterEach(async () => { + TestBed.resetTestingModule(); + await clear(); }); - it('should be created', () => { - expect(service).toBeTruthy(); + it('initializes defaults before accepting an immediate update', async () => { + expect(await db.settings.get(1)).toEqual({ + id: 1, + numberOfCourts: 5, + withServiceJudge: true, + showAlert: true, + waitingListQuickButtons: false + }); + }); + + for (const count of [0, -1, 1.5, NaN, Infinity]) { + it(`rejects invalid court count ${count} without removing assignments`, async () => { + await db.courtUmpires.add({ courtNo: 5, umpireId: 1 }); + await service.update({ numberOfCourts: count }); + expect((await db.settings.get(1))?.numberOfCourts).toBe(5); + expect(await db.courtUmpires.count()).toBe(1); + }); + } + + it('removes only assignments on eliminated courts', async () => { + await db.courtUmpires.bulkAdd([ + { courtNo: 1, umpireId: 1 }, + { courtNo: 5, umpireId: 2 } + ]); + await db.courtServiceJudges.add({ courtNo: 5, umpireId: 3 }); + await service.update({ numberOfCourts: 2 }); + expect( + (await db.courtUmpires.toArray()).map((item) => item.courtNo) + ).toEqual([1]); + expect(await db.courtServiceJudges.count()).toBe(0); + }); + + it('clears service judge duty and quick buttons when disabling the role', async () => { + await db.courtServiceJudges.add({ courtNo: 1, umpireId: 1 }); + await db.waitingServiceJudges.add({ serviceJudgeId: 2, order: 1 }); + await service.update({ + withServiceJudge: false, + waitingListQuickButtons: true + }); + expect(await db.courtServiceJudges.count()).toBe(0); + expect(await db.waitingServiceJudges.count()).toBe(0); + expect((await db.settings.get(1))?.waitingListQuickButtons).toBeFalse(); }); }); diff --git a/src/app/services/settings-service.ts b/src/app/services/settings-service.ts index 1b453b1..58b6780 100644 --- a/src/app/services/settings-service.ts +++ b/src/app/services/settings-service.ts @@ -23,24 +23,46 @@ export class SettingsService { * Update settings */ async update(changes: Partial>): Promise { - const current = await db.settings.get(1); - - if (!current) { + if ( + changes.numberOfCourts !== undefined && + (!Number.isSafeInteger(changes.numberOfCourts) || + changes.numberOfCourts < 1) + ) return; - } - await db.settings.update(1, { - ...changes, - ...(!(changes.withServiceJudge ?? current.withServiceJudge) - ? { waitingListQuickButtons: false } - : {}) - }); + await db.transaction( + 'rw', + [ + db.settings, + db.courtUmpires, + db.courtServiceJudges, + db.waitingServiceJudges + ], + async () => { + await this.ensureSettings(); + const current = (await db.settings.get(1))!; + const next = { ...current, ...changes }; + if (!next.withServiceJudge) next.waitingListQuickButtons = false; + await db.courtUmpires + .where('courtNo') + .above(next.numberOfCourts) + .delete(); + await db.courtServiceJudges + .where('courtNo') + .above(next.numberOfCourts) + .delete(); + if (!next.withServiceJudge) { + await db.courtServiceJudges.clear(); + await db.waitingServiceJudges.clear(); + } + await db.settings.put(next); + } + ); } private async ensureSettings() { - const existing = await db.settings.get(1); - - if (!existing) { + await db.transaction('rw', db.settings, async () => { + if (await db.settings.get(1)) return; await db.settings.put({ id: 1, numberOfCourts: 5, @@ -48,6 +70,6 @@ export class SettingsService { showAlert: true, waitingListQuickButtons: false }); - } + }); } } diff --git a/src/app/services/umpire.service.spec.ts b/src/app/services/umpire.service.spec.ts index ac71987..ec1fc29 100644 --- a/src/app/services/umpire.service.spec.ts +++ b/src/app/services/umpire.service.spec.ts @@ -12,18 +12,31 @@ describe('UmpireService persistence', () => { }; beforeEach(async () => { - await db.umpires.clear(); + await Promise.all([ + db.umpires.clear(), + db.waitingUmpires.clear(), + db.waitingServiceJudges.clear(), + db.courtUmpires.clear(), + db.courtServiceJudges.clear() + ]); service = TestBed.inject(UmpireService); }); afterEach(async () => { TestBed.resetTestingModule(); - await db.umpires.clear(); + await Promise.all([ + db.umpires.clear(), + db.waitingUmpires.clear(), + db.waitingServiceJudges.clear(), + db.courtUmpires.clear(), + db.courtServiceJudges.clear() + ]); }); it('creates and retrieves a complete official record', async () => { const id = await service.create(official); expect(await service.getById(id)).toEqual({ ...official, id }); + expect('id' in official).toBeFalse(); }); it('updates selected fields while retaining the rest of the record', async () => { @@ -53,6 +66,28 @@ describe('UmpireService persistence', () => { }); }); + it('deletes dependent duty records and closes the queue gap', async () => { + const id = await service.create(official); + const retained = await service.create({ ...official, firstName: 'Sam' }); + await db.waitingUmpires.bulkAdd([ + { umpireId: id, order: 1 }, + { umpireId: retained, order: 2 } + ]); + await db.waitingServiceJudges.add({ serviceJudgeId: id, order: 1 }); + await db.courtUmpires.add({ umpireId: id, courtNo: 1 }); + await db.courtServiceJudges.add({ umpireId: id, courtNo: 2 }); + await service.delete(id); + expect( + (await db.waitingUmpires.toArray()).map((item) => [ + item.umpireId, + item.order + ]) + ).toEqual([[retained, 1]]); + expect(await db.waitingServiceJudges.count()).toBe(0); + expect(await db.courtUmpires.count()).toBe(0); + expect(await db.courtServiceJudges.count()).toBe(0); + }); + it('clears all officials', async () => { await service.create(official); await service.create({ ...official, firstName: 'Sam' }); diff --git a/src/app/services/umpire.service.ts b/src/app/services/umpire.service.ts index 5b23298..9da290d 100644 --- a/src/app/services/umpire.service.ts +++ b/src/app/services/umpire.service.ts @@ -1,14 +1,18 @@ -import { Injectable } from '@angular/core'; +import { inject, Injectable } from '@angular/core'; import { db } from 'db'; import { liveQuery } from 'dexie'; import { from } from 'rxjs'; -import { Umpire } from '../../../db'; +import { Umpire } from 'db'; +import { WaitingUmpiresService } from './waiting-umpires.service'; +import { WaitingServiceJudgesService } from './waiting-service-judges.service'; import { toSignal } from '@angular/core/rxjs-interop'; @Injectable({ providedIn: 'root' }) export class UmpireService { + private readonly waitingUmpires = inject(WaitingUmpiresService); + private readonly waitingJudges = inject(WaitingServiceJudgesService); /** * Reactive signal with all umpires */ @@ -23,9 +27,7 @@ export class UmpireService { * Create */ async create(umpire: Omit): Promise { - return await db.umpires.add({ - ...umpire - } as Umpire); + return db.umpires.add({ ...umpire }); } /** @@ -49,13 +51,50 @@ export class UmpireService { * Delete */ async delete(id: number): Promise { - await db.umpires.delete(id); + await db.transaction( + 'rw', + [ + db.umpires, + db.waitingUmpires, + db.waitingServiceJudges, + db.courtUmpires, + db.courtServiceJudges + ], + async () => { + await db.waitingUmpires.where('umpireId').equals(id).delete(); + await db.waitingServiceJudges + .where('serviceJudgeId') + .equals(id) + .delete(); + await db.courtUmpires.where('umpireId').equals(id).delete(); + await db.courtServiceJudges.where('umpireId').equals(id).delete(); + await this.waitingUmpires.reinitOrders(); + await this.waitingJudges.reinitOrders(); + await db.umpires.delete(id); + } + ); } /** * Delete all */ async clear(): Promise { - await db.umpires.clear(); + await db.transaction( + 'rw', + [ + db.umpires, + db.waitingUmpires, + db.waitingServiceJudges, + db.courtUmpires, + db.courtServiceJudges + ], + async () => { + await db.waitingUmpires.clear(); + await db.waitingServiceJudges.clear(); + await db.courtUmpires.clear(); + await db.courtServiceJudges.clear(); + await db.umpires.clear(); + } + ); } } diff --git a/src/app/services/waiting-service-judges.service.ts b/src/app/services/waiting-service-judges.service.ts index 2737d9a..84a66ba 100644 --- a/src/app/services/waiting-service-judges.service.ts +++ b/src/app/services/waiting-service-judges.service.ts @@ -22,31 +22,26 @@ export class WaitingServiceJudgesService { * Add new umpire to queue with max(order) + 1 */ async add(serviceJudgeId: number, toPosition?: number): Promise { - const id = await db.transaction('rw', db.waitingServiceJudges, async () => { + return db.transaction('rw', db.waitingServiceJudges, async () => { const last = await db.waitingServiceJudges.orderBy('order').last(); - - const nextOrder = last ? last.order + 1 : 1; - - return db.waitingServiceJudges.add({ + const id = await db.waitingServiceJudges.add({ serviceJudgeId, - order: nextOrder + order: last ? last.order + 1 : 1 }); + if (toPosition !== undefined) + await this.moveToPosition(serviceJudgeId, toPosition); + return id; }); - - if (toPosition !== undefined) { - await this.moveToPosition(serviceJudgeId, toPosition); - } - - return id; } /** * Remove from queue */ async remove(id: number): Promise { - await db.waitingServiceJudges.delete(id); - - await this.reinitOrders(); + await db.transaction('rw', db.waitingServiceJudges, async () => { + await db.waitingServiceJudges.delete(id); + await this.reinitOrders(); + }); } async removeByUmpireId(umpireId: number): Promise { @@ -106,7 +101,7 @@ export class WaitingServiceJudgesService { .toArray(); for (const item of affected) { - await db.waitingServiceJudges.update(item.id!, { + await db.waitingServiceJudges.update(item.id, { order: item.order + 1 }); } @@ -123,13 +118,13 @@ export class WaitingServiceJudgesService { .toArray(); for (const item of affected) { - await db.waitingServiceJudges.update(item.id!, { + await db.waitingServiceJudges.update(item.id, { order: item.order - 1 }); } } - await db.waitingServiceJudges.update(current.id!, { + await db.waitingServiceJudges.update(current.id, { order: newOrder }); }); @@ -145,7 +140,7 @@ export class WaitingServiceJudgesService { const expectedOrder = i + 1; if (item.order !== expectedOrder) { - await db.waitingServiceJudges.update(item.id!, { + await db.waitingServiceJudges.update(item.id, { order: expectedOrder }); } @@ -154,7 +149,7 @@ export class WaitingServiceJudgesService { } async getCurrentUmpire(): Promise { - const item = await db.waitingServiceJudges.where('order').equals(1).first(); + const item = await db.waitingServiceJudges.orderBy('order').first(); return item ? db.umpires.get(item.serviceJudgeId) : undefined; } diff --git a/src/app/services/waiting-umpires.service.ts b/src/app/services/waiting-umpires.service.ts index d32a954..fed81b9 100644 --- a/src/app/services/waiting-umpires.service.ts +++ b/src/app/services/waiting-umpires.service.ts @@ -22,31 +22,26 @@ export class WaitingUmpiresService { * Add new umpire to queue with max(order) + 1 */ async add(umpireId: number, toPosition?: number): Promise { - const id = await db.transaction('rw', db.waitingUmpires, async () => { + return db.transaction('rw', db.waitingUmpires, async () => { const last = await db.waitingUmpires.orderBy('order').last(); - - const nextOrder = last ? last.order + 1 : 1; - - return db.waitingUmpires.add({ + const id = await db.waitingUmpires.add({ umpireId, - order: nextOrder + order: last ? last.order + 1 : 1 }); + if (toPosition !== undefined) + await this.moveToPosition(umpireId, toPosition); + return id; }); - - if (toPosition !== undefined) { - await this.moveToPosition(umpireId, toPosition); - } - - return id; } /** * Remove from queue */ async remove(id: number): Promise { - await db.waitingUmpires.delete(id); - - await this.reinitOrders(); + await db.transaction('rw', db.waitingUmpires, async () => { + await db.waitingUmpires.delete(id); + await this.reinitOrders(); + }); } async removeByUmpireId(umpireId: number): Promise { @@ -106,7 +101,7 @@ export class WaitingUmpiresService { .toArray(); for (const item of affected) { - await db.waitingUmpires.update(item.id!, { + await db.waitingUmpires.update(item.id, { order: item.order + 1 }); } @@ -123,13 +118,13 @@ export class WaitingUmpiresService { .toArray(); for (const item of affected) { - await db.waitingUmpires.update(item.id!, { + await db.waitingUmpires.update(item.id, { order: item.order - 1 }); } } - await db.waitingUmpires.update(current.id!, { + await db.waitingUmpires.update(current.id, { order: newOrder }); }); @@ -145,7 +140,7 @@ export class WaitingUmpiresService { const expectedOrder = i + 1; if (item.order !== expectedOrder) { - await db.waitingUmpires.update(item.id!, { + await db.waitingUmpires.update(item.id, { order: expectedOrder }); } @@ -154,7 +149,7 @@ export class WaitingUmpiresService { } async getCurrentUmpire(): Promise { - const item = await db.waitingUmpires.where('order').equals(1).first(); + const item = await db.waitingUmpires.orderBy('order').first(); return item ? db.umpires.get(item.umpireId) : undefined; } diff --git a/src/app/tab1/tab1.page.html b/src/app/tab1/tab1.page.html index c5c1c34..8a6f7a5 100644 --- a/src/app/tab1/tab1.page.html +++ b/src/app/tab1/tab1.page.html @@ -11,16 +11,16 @@ - + - - {{ 'court' | translate }} + + {{ 'court' | translate }} - - {{ 'shortCourtName' | translate }} + + {{ 'shortCourtName' | translate }} - + {{ 'umpire' | translate }} @@ -28,7 +28,7 @@ @if (serviceJudgeEnabled()) { - + {{ 'serviceJudge' | translate }} @@ -37,24 +37,22 @@ } - @for (item of - [].constructor($safeNavigationMigration(settings()?.numberOfCourts)); track - $index) { + @for (courtNo of courtIndexes(); track courtNo) { - + - {{ $index + 1 }}. + {{ courtNo }}. - @if (umpireByCourt().get($index + 1); as umpire) { + (cdkDropListDropped)="dropToUmpire($event, courtNo)"> + @if (umpireByCourt().get(courtNo); as umpire) {
@@ -69,12 +67,13 @@ @if (serviceJudgeEnabled()) { - @if (serviceJudgeByCourt().get($index + 1); as umpire) { + [id]="`court-service-judge-${courtNo}`" + [cdkDropListData]="serviceJudgeByCourt().get(courtNo) ? [serviceJudgeByCourt().get(courtNo)] : []" + [cdkDropListEnterPredicate]="canDropUmpire" + (cdkDropListDropped)="dropToServiceJudge($event, courtNo)"> + @if (serviceJudgeByCourt().get(courtNo); as umpire) {
@@ -90,25 +89,26 @@ - @if ( (umpireByCourt().get($index + 1) && - serviceJudgeByCourt().get($index + 1)) || - (umpireByCourt().get($index + 1) && !serviceJudgeEnabled()) ) { + @if ( (umpireByCourt().get(courtNo) && + serviceJudgeByCourt().get(courtNo)) || (umpireByCourt().get(courtNo) + && !serviceJudgeEnabled()) ) { + color="danger" + [attr.aria-label]="('removeUmpireFromCourt' | translate) + ': ' + courtNo" + (click)="showRemoveConfirmation(courtNo)"> - } @else if ((!umpireByCourt().get($index + 1) && - !serviceJudgeByCourt().get($index + 1) && waitingUmpires().length && - waitingServiceJudges().length) || (!umpireByCourt().get($index + 1) - && waitingUmpires().length && !serviceJudgeEnabled())) { + } @else if ((!umpireByCourt().get(courtNo) && + !serviceJudgeByCourt().get(courtNo) && waitingUmpires().length && + waitingServiceJudges().length) || (!umpireByCourt().get(courtNo) && + waitingUmpires().length && !serviceJudgeEnabled())) { + color="success" + [attr.aria-label]="('sendUmpireToCourt' | translate) + ': ' + courtNo" + (click)="showAddConfirmation(courtNo)"> } - + @@ -131,7 +131,7 @@ @if (serviceJudgeEnabled()) { - + {{ 'serviceJudges' | translate }} @@ -150,7 +150,7 @@ { let umpireCourt: jasmine.SpyObj; let judgeCourt: jasmine.SpyObj; let duty: jasmine.SpyObj; + let rotation: jasmine.SpyObj; let alerts: jasmine.SpyObj; beforeEach(() => { @@ -78,6 +80,15 @@ describe('Tab1Page court rotation', () => { duty = jasmine.createSpyObj('WaitingDutyService', ['move', 'moveToRest']); duty.move.and.resolveTo(); duty.moveToRest.and.resolveTo(); + rotation = jasmine.createSpyObj('CourtRotationService', [ + 'assignNext', + 'rotate', + 'moveToQueue', + 'moveToCourt', + 'moveToRest' + ]); + rotation.assignNext.and.resolveTo(); + rotation.rotate.and.resolveTo(); alerts = jasmine.createSpyObj('AlertController', ['create']); waitingUmpire.getCurrentUmpire.and.resolveTo(official(1)); waitingJudge.getCurrentUmpire.and.resolveTo(official(2)); @@ -91,6 +102,7 @@ describe('Tab1Page court rotation', () => { judgeCourt.removeByUmpireId.and.resolveTo(); TestBed.configureTestingModule({ providers: [ + { provide: CourtRotationService, useValue: rotation }, { provide: WaitingDutyService, useValue: duty }, { provide: SettingsService, useValue: { settings } }, { provide: UmpireService, useValue: { umpires } }, @@ -179,7 +191,7 @@ describe('Tab1Page court rotation', () => { alerts.create.and.resolveTo({ present } as unknown as HTMLIonAlertElement); await component.showAddConfirmation(2); expect(present).toHaveBeenCalledTimes(1); - expect(umpireCourt.save).not.toHaveBeenCalled(); + expect(rotation.assignNext).not.toHaveBeenCalled(); expect(judgeCourt.save).not.toHaveBeenCalled(); const options = alerts.create.calls.mostRecent().args[0]; const confirm = options?.buttons?.find( @@ -190,14 +202,7 @@ describe('Tab1Page court rotation', () => { confirm.handler(undefined); } await fixture.whenStable(); - expect(umpireCourt.save).toHaveBeenCalledOnceWith({ - courtNo: 2, - umpireId: 1 - }); - expect(judgeCourt.save).toHaveBeenCalledOnceWith({ - courtNo: 2, - umpireId: 2 - }); + expect(rotation.assignNext).toHaveBeenCalledOnceWith(2, 1, 2); }); it('returns a lone umpire to the umpire queue after confirming rotation', async () => { @@ -210,7 +215,7 @@ describe('Tab1Page court rotation', () => { 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(); + expect(rotation.rotate).not.toHaveBeenCalled(); const options = alerts.create.calls.mostRecent().args[0]; const confirm = options?.buttons?.find( (button) => typeof button !== 'string' && button.role === 'confirm' @@ -220,32 +225,20 @@ describe('Tab1Page court rotation', () => { 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(); + expect(rotation.rotate).toHaveBeenCalledOnceWith(2, 3, undefined); }); 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(rotation.assignNext).toHaveBeenCalledOnceWith(3, 1, 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(rotation.assignNext).not.toHaveBeenCalled(); expect(judgeCourt.save).not.toHaveBeenCalled(); expect(waitingUmpire.removeByUmpireId).not.toHaveBeenCalled(); }); @@ -253,7 +246,7 @@ describe('Tab1Page court rotation', () => { 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(rotation.assignNext).not.toHaveBeenCalled(); expect(judgeCourt.save).not.toHaveBeenCalled(); }); @@ -261,13 +254,8 @@ describe('Tab1Page court rotation', () => { 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(); + expect(rotation.assignNext).toHaveBeenCalledOnceWith(2, 1, undefined); + expect(waitingJudge.getCurrentUmpire).not.toHaveBeenCalled(); }); it('swaps outgoing officials into the opposite role queues', async () => { @@ -275,19 +263,23 @@ describe('Tab1Page court rotation', () => { 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); + expect(rotation.rotate).toHaveBeenCalledOnceWith(2, 3, 4); }); 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(rotation.rotate).not.toHaveBeenCalled(); expect(waitingUmpire.add).not.toHaveBeenCalled(); }); + it('rotates a lone umpire without confirmation when service judges are disabled', async () => { + settings.update((value) => ({ ...value, withServiceJudge: false })); + courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]); + await component.showRemoveConfirmation(2); + expect(rotation.rotate).toHaveBeenCalledOnceWith(2, 3, undefined); + }); + 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 }]); diff --git a/src/app/tab1/tab1.page.ts b/src/app/tab1/tab1.page.ts index 5f34085..5253235 100644 --- a/src/app/tab1/tab1.page.ts +++ b/src/app/tab1/tab1.page.ts @@ -19,6 +19,7 @@ import { IonButton, AlertController } from '@ionic/angular'; +import { CourtRotationService } from '../services/court-rotation.service'; import { SettingsService } from '../services/settings-service'; import { UmpireService } from '../services/umpire.service'; import { Umpire } from 'db'; @@ -82,8 +83,10 @@ export class Tab1Page { readonly courtServiceJudgeService = inject(CourtServiceJudgeService); readonly translationService = inject(TranslationService); - private alertController = inject(AlertController); - private fullnamePipe = inject(FullnamePipe); + readonly rotationService = inject(CourtRotationService); + + private readonly alertController = inject(AlertController); + private readonly fullnamePipe = inject(FullnamePipe); /** * Raw signals from services @@ -313,125 +316,76 @@ export class Tab1Page { { text: this.translationService.translate('yes'), role: 'confirm', - handler: () => { - void move(); - } + handler: () => move() } ] }); await alert.present(); } - async dropToRest(event: CdkDragDrop): Promise { - if (event.previousContainer === event.container) { - return; + async dropToRest( + event: CdkDragDrop + ): Promise { + if (event.previousContainer === event.container) return; + const source = event.previousContainer.id; + if ( + source === 'list-waiting-umpires' || + source === 'list-waiting-service-judges' + ) { + await this.waitingDutyService.moveToRest(event.item.data.id); } 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); + await this.rotationService.moveToRest(event.item.data.id); } } - dropToWaitingServiceJudge(event: CdkDragDrop) { - const umpireToMove = event.item.data; - if (event.previousContainer === event.container) { - this.waitingServiceJudgeService.moveToPosition( - umpireToMove.id, - event.currentIndex + 1 - ); - return; - } - const comingFrom = event.previousContainer.id; - - this.waitingServiceJudgeService.add( - umpireToMove.id, + async dropToWaitingServiceJudge( + event: CdkDragDrop + ): Promise { + await this.rotationService.moveToQueue( + event.item.data.id, + 'serviceJudge', event.currentIndex + 1 ); - - this.removeFromOriginalPlace(umpireToMove, comingFrom); } - dropToWaitingUmpire(event: CdkDragDrop) { - const umpireToMove = event.item.data; - if (event.previousContainer === event.container) { - this.waitingUmpireService.moveToPosition( - umpireToMove.id, - event.currentIndex + 1 - ); - return; - } else { - const comingFrom = event.previousContainer.id; - this.waitingUmpireService.add(umpireToMove.id, event.currentIndex + 1); - - this.removeFromOriginalPlace(umpireToMove, comingFrom); - } + async dropToWaitingUmpire( + event: CdkDragDrop + ): Promise { + await this.rotationService.moveToQueue( + event.item.data.id, + 'umpire', + event.currentIndex + 1 + ); } - dropToUmpire(event: CdkDragDrop<(Umpire | undefined)[]>, courtNo: number) { - const targetUmpires = event.container.data; - - if (targetUmpires.length > 0) { - return; - } - - const comingFrom = event.previousContainer.id; - const umpireToMove = event.item.data; - this.courtUmpireService.save({ umpireId: umpireToMove.id, courtNo }); - - this.removeFromOriginalPlace(umpireToMove, comingFrom); - } - - dropToServiceJudge( - event: CdkDragDrop<(Umpire | undefined)[]>, + async dropToUmpire( + event: CdkDragDrop<(Umpire | undefined)[], unknown, Umpire>, courtNo: number - ) { - const targetUmpires = event.container.data; - - if (targetUmpires.length > 0) { - return; - } - - const comingFrom = event.previousContainer.id; - const umpireToMove = event.item.data; - this.courtServiceJudgeService.save({ umpireId: umpireToMove.id, courtNo }); - - this.removeFromOriginalPlace(umpireToMove, comingFrom); + ): Promise { + if (event.container.data.length) return; + await this.rotationService.moveToCourt( + event.item.data.id, + 'umpire', + courtNo + ); } - private removeFromOriginalPlace( - umpireToMove: Umpire, - comingFrom: string - ): void { - if ('list-waiting-service-judges' === comingFrom) { - this.waitingServiceJudgeService.removeByUmpireId(umpireToMove.id); - } - - if ('list-waiting-umpires' === comingFrom) { - this.waitingUmpireService.removeByUmpireId(umpireToMove.id); - } - - if (comingFrom.startsWith('court-umpire')) { - this.courtUmpireService.removeByUmpireId(umpireToMove.id); - } - - if (comingFrom.startsWith('court-service-judge')) { - this.courtServiceJudgeService.removeByUmpireId(umpireToMove.id); - } + async dropToServiceJudge( + event: CdkDragDrop<(Umpire | undefined)[], unknown, Umpire>, + courtNo: number + ): Promise { + if (event.container.data.length) return; + await this.rotationService.moveToCourt( + event.item.data.id, + 'serviceJudge', + courtNo + ); } - canDropUmpire = ( - drag: CdkDrag, - drop: CdkDropList - ): boolean => { - return drop.data.length === 0; - }; + readonly canDropUmpire = ( + _drag: CdkDrag, + drop: CdkDropList<(Umpire | undefined)[]> + ): boolean => drop.data.length === 0; public async showRemoveConfirmation(courtNo: number) { const umpire = this.umpireByCourt().get(courtNo); @@ -445,7 +399,7 @@ export class Tab1Page { } if (!this.settings()?.showAlert) { - this.removeUmpiresFromCourt(courtNo); + await this.rotationService.rotate(courtNo, umpire.id, serviceJudge?.id); return; } @@ -472,14 +426,8 @@ export class Tab1Page { { text: this.translationService.translate('yes'), role: 'confirm', - handler: () => { - if (this.serviceJudgeEnabled()) { - this.removeUmpiresFromCourt(courtNo); - return; - } - - this.removeUmpireFromCourt(courtNo); - } + handler: () => + this.rotationService.rotate(courtNo, umpire.id, serviceJudge?.id) } ] }); @@ -487,41 +435,11 @@ export class Tab1Page { await alert.present(); } - /** - * This is for removing both umpire and service judge from the court (when service judge is enabled). - */ - private async removeUmpiresFromCourt(courtNo: number) { - const umpire = this.umpireByCourt().get(courtNo); - const serviceJudge = this.serviceJudgeByCourt().get(courtNo); - - if (typeof umpire === 'undefined' || typeof serviceJudge === 'undefined') { - return; - } - - await this.courtUmpireService.removeByUmpireId(umpire.id); - await this.courtServiceJudgeService.removeByUmpireId(serviceJudge.id); - await this.waitingUmpireService.add(serviceJudge.id); - await this.waitingServiceJudgeService.add(umpire.id); - } - - /** - * This is for removing 1 umpire only (when service judge is not enabled). - */ - private async removeUmpireFromCourt(courtNo: number) { - const umpire = this.umpireByCourt().get(courtNo); - - if (typeof umpire === 'undefined') { - return; - } - - await this.courtUmpireService.removeByUmpireId(umpire.id); - await this.waitingUmpireService.add(umpire.id); - } - public async showAddConfirmation(courtNo: number) { const umpire = await this.waitingUmpireService.getCurrentUmpire(); - const serviceJudge = - await this.waitingServiceJudgeService.getCurrentUmpire(); + const serviceJudge = this.serviceJudgeEnabled() + ? await this.waitingServiceJudgeService.getCurrentUmpire() + : undefined; if ( typeof umpire === 'undefined' || @@ -531,16 +449,16 @@ export class Tab1Page { } if (!this.settings()?.showAlert) { - if (this.serviceJudgeEnabled() && serviceJudge) { - this.addUmpiresToCourt(courtNo, umpire, serviceJudge); - return; - } - this.addUmpireToCourt(courtNo, umpire); + await this.rotationService.assignNext( + courtNo, + umpire.id, + serviceJudge?.id + ); return; } const alert = await this.alertController.create({ - header: this.translationService.translate('court', { + header: this.translationService.translate('courtConfirm', { courtNo }), @@ -566,37 +484,16 @@ export class Tab1Page { { text: this.translationService.translate('yes'), role: 'confirm', - handler: () => { - if (this.serviceJudgeEnabled() && serviceJudge) { - this.addUmpiresToCourt(courtNo, umpire, serviceJudge); - } else { - this.addUmpireToCourt(courtNo, umpire); - } - } + handler: () => + this.rotationService.assignNext( + courtNo, + umpire.id, + serviceJudge?.id + ) } ] }); await alert.present(); } - - private async addUmpiresToCourt( - courtNo: number, - umpire: Umpire, - serviceJudge: Umpire - ) { - await this.courtUmpireService.save({ courtNo, umpireId: umpire.id }); - await this.courtServiceJudgeService.save({ - courtNo, - umpireId: serviceJudge.id - }); - - await this.waitingUmpireService.removeByUmpireId(umpire.id); - await this.waitingServiceJudgeService.removeByUmpireId(serviceJudge.id); - } - - private async addUmpireToCourt(courtNo: number, umpire: Umpire) { - await this.courtUmpireService.save({ courtNo, umpireId: umpire.id }); - await this.waitingUmpireService.removeByUmpireId(umpire.id); - } } diff --git a/src/app/tab2/tab2.page.html b/src/app/tab2/tab2.page.html index b08f7b1..1b945eb 100644 --- a/src/app/tab2/tab2.page.html +++ b/src/app/tab2/tab2.page.html @@ -1,8 +1,8 @@ {{ 'courtOfficials' | translate }} - - + + {{ 'add' | translate }} @@ -13,7 +13,7 @@ - Játékvezetők + {{ 'courtOfficials' | translate }} @@ -25,8 +25,9 @@ {{ umpire | fullname }} @@ -39,19 +40,19 @@ - + - {{ 'cancel' | translate }} {{ 'newCourtOfficial' | translate }} {{ 'save' | translate }} diff --git a/src/app/tab2/tab2.page.spec.ts b/src/app/tab2/tab2.page.spec.ts index 92c0cac..d51e411 100644 --- a/src/app/tab2/tab2.page.spec.ts +++ b/src/app/tab2/tab2.page.spec.ts @@ -1,18 +1,78 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { UmpireService } from '../services/umpire.service'; import { Tab2Page } from './tab2.page'; describe('Tab2Page', () => { let component: Tab2Page; let fixture: ComponentFixture; - beforeEach(async () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { + provide: UmpireService, + useValue: { umpires: signal([]), create: async () => 1 } + } + ] + }); fixture = TestBed.createComponent(Tab2Page); component = fixture.componentInstance; + document.body.appendChild(fixture.nativeElement); fixture.detectChanges(); }); + afterEach(() => fixture.nativeElement.remove()); + it('should create', () => { expect(component).toBeTruthy(); }); + it('rejects whitespace-only names', async () => { + component.lastName = ' '; + component.firstName = 'Alex'; + expect(component.isValidUmpire()).toBeFalse(); + const dismiss = spyOn(component.modal, 'dismiss'); + await component.confirm(); + expect(dismiss).not.toHaveBeenCalled(); + }); + + it('keeps the form open and preserves the draft when saving fails', async () => { + component.lastName = 'Smith'; + component.firstName = 'Alex'; + spyOn(component.umpireService, 'create').and.callFake( + () => + new Promise((_resolve, reject) => { + setTimeout(() => reject(new Error('write failed')), 0); + }) + ); + const present = jasmine.createSpy('present').and.resolveTo(); + spyOn(component.alertController, 'create').and.resolveTo({ + present + } as unknown as HTMLIonAlertElement); + spyOn(console, 'error'); + const dismiss = spyOn(component.modal, 'dismiss'); + await component.confirm(); + expect(present).toHaveBeenCalled(); + expect(dismiss).not.toHaveBeenCalled(); + expect(component.firstName).toBe('Alex'); + expect(component.saving).toBeFalse(); + }); + + it('persists trimmed names before closing and prevents repeated saves', async () => { + component.lastName = ' Smith '; + component.firstName = ' Alex '; + component.country = ' GB '; + const create = spyOn(component.umpireService, 'create').and.resolveTo(1); + const dismiss = spyOn(component.modal, 'dismiss').and.resolveTo(true); + await Promise.all([component.confirm(), component.confirm()]); + expect(create).toHaveBeenCalledOnceWith({ + firstName: 'Alex', + lastName: 'Smith', + country: 'GB', + gender: '' + }); + expect(dismiss).toHaveBeenCalledOnceWith(null, 'confirm'); + expect(component.firstName).toBe(''); + }); }); diff --git a/src/app/tab2/tab2.page.ts b/src/app/tab2/tab2.page.ts index dd7b4f7..216d91f 100644 --- a/src/app/tab2/tab2.page.ts +++ b/src/app/tab2/tab2.page.ts @@ -28,7 +28,6 @@ import { UmpireService } from '../services/umpire.service'; import { Umpire } from 'db'; import { addIcons } from 'ionicons'; import { add, trash } from 'ionicons/icons'; -import { OverlayEventDetail } from '@ionic/core/components'; import { FormsModule } from '@angular/forms'; import { FullnamePipe } from '../fullname-pipe'; import { TranslatePipe } from '../i18n/translation.pipe'; @@ -75,12 +74,14 @@ export class Tab2Page { public readonly umpires = this.umpireService.umpires; + saving = false; + public lastName: string = ''; public firstName: string = ''; public country: string = ''; public gender: string = ''; - public genders: { label: TranslationKey; code: string }[] = [ + public readonly genders: { label: TranslationKey; code: string }[] = [ { label: 'male', code: 'M' }, { label: 'female', code: 'W' } ]; @@ -89,36 +90,32 @@ export class Tab2Page { addIcons({ add, trash }); } - public async onWillDismiss(event: CustomEvent) { - if (event.detail.role !== 'confirm') { - return; - } - - const newUmpire: Umpire = event.detail.data; - - await this.umpireService.create({ - firstName: newUmpire.firstName, - lastName: newUmpire.lastName, - country: newUmpire.country, - gender: newUmpire.gender - }); + cancel(): Promise { + return this.modal.dismiss(null, 'cancel'); } - cancel() { - this.modal.dismiss(null, 'cancel'); - } - - async confirm() { - await this.modal.dismiss( - { - lastName: this.lastName, - firstName: this.firstName, - country: this.country, + async confirm(): Promise { + if (this.saving || !this.isValidUmpire()) return; + this.saving = true; + try { + await this.umpireService.create({ + lastName: this.lastName.trim(), + firstName: this.firstName.trim(), + country: this.country.trim(), gender: this.gender - }, - 'confirm' - ); - + }); + } catch (error) { + console.error('Failed to save court official:', error); + const alert = await this.alertController.create({ + header: this.translationService.translate('saveFailed'), + buttons: ['OK'] + }); + await alert.present(); + return; + } finally { + this.saving = false; + } + await this.modal.dismiss(null, 'confirm'); this.lastName = ''; this.firstName = ''; this.country = ''; @@ -126,7 +123,7 @@ export class Tab2Page { } isValidUmpire(): boolean { - return this.lastName !== '' && this.firstName !== ''; + return this.lastName.trim().length > 0 && this.firstName.trim().length > 0; } public async showDeleteConfirmation(umpire: Umpire): Promise { @@ -142,18 +139,11 @@ export class Tab2Page { { text: this.translationService.translate('yes'), role: 'confirm', - handler: () => { - // TODO: also remove from first tab - this.removeUmpire(umpire); - } + handler: () => this.umpireService.delete(umpire.id) } ] }); await alert.present(); } - - private removeUmpire(umpire: Umpire): void { - this.umpireService.delete(umpire.id); - } } diff --git a/src/app/tab3/tab3.page.html b/src/app/tab3/tab3.page.html index e7dccda..74e03b2 100644 --- a/src/app/tab3/tab3.page.html +++ b/src/app/tab3/tab3.page.html @@ -65,7 +65,8 @@ min="1" labelPlacement="floating" [value]="currentSettings.numberOfCourts" - (ionInput)="updateNumberOfCourts($event)"> + step="1" + (ionChange)="updateNumberOfCourts($event)"> diff --git a/src/app/tab3/tab3.page.spec.ts b/src/app/tab3/tab3.page.spec.ts index a03da99..ef994cd 100644 --- a/src/app/tab3/tab3.page.spec.ts +++ b/src/app/tab3/tab3.page.spec.ts @@ -1,12 +1,19 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { signal } from '@angular/core'; +import { SettingsService } from '../services/settings-service'; import { Tab3Page } from './tab3.page'; describe('Tab3Page', () => { let component: Tab3Page; let fixture: ComponentFixture; - beforeEach(async () => { + beforeEach(() => { + TestBed.configureTestingModule({ + providers: [ + { provide: SettingsService, useValue: { settings: signal(undefined) } } + ] + }); fixture = TestBed.createComponent(Tab3Page); component = fixture.componentInstance; fixture.detectChanges(); diff --git a/src/app/tab3/tab3.page.ts b/src/app/tab3/tab3.page.ts index 495f2b8..d67112e 100644 --- a/src/app/tab3/tab3.page.ts +++ b/src/app/tab3/tab3.page.ts @@ -16,12 +16,13 @@ import { IonLabel } from '@ionic/angular'; import { SettingsService } from '../services/settings-service'; -import { CourtUmpireService } from '../services/court.umpire.service'; -import { CourtServiceJudgeService } from '../services/court.service.judge.service'; -import { CourtServiceJudge } from 'db'; -import { WaitingServiceJudgesService } from '../services/waiting-service-judges.service'; import { TranslationService } from '../i18n/translation.service'; import { Language } from '../i18n/translations'; +import type { + InputCustomEvent, + SelectCustomEvent, + ToggleCustomEvent +} from '@ionic/angular'; import { TranslatePipe } from '../i18n/translation.pipe'; import { ThemePreference, ThemeService } from '../services/theme.service'; @@ -50,74 +51,41 @@ import { ThemePreference, ThemeService } from '../services/theme.service'; }) export class Tab3Page { readonly settingsService = inject(SettingsService); - readonly courtUmpireService = inject(CourtUmpireService); - readonly courtServiceJudgeService = inject(CourtServiceJudgeService); - readonly waitingServiceJudgeService = inject(WaitingServiceJudgesService); readonly translationService = inject(TranslationService); readonly themeService = inject(ThemeService); readonly settings = this.settingsService.settings; - async updateNumberOfCourts(event: CustomEvent): Promise { - this.courtUmpireService - .umpires() - .filter((cu) => { - return cu.courtNo > Number(event.detail.value); - }) - .map((cu) => { - this.courtUmpireService.delete(cu.id); - }); - - this.courtServiceJudgeService - .umpires() - .filter((cu) => { - return cu.courtNo > Number(event.detail.value); - }) - .map((cu) => { - this.courtServiceJudgeService.delete(cu.id); - }); - + async updateNumberOfCourts(event: InputCustomEvent): Promise { await this.settingsService.update({ numberOfCourts: Number(event.detail.value) }); } - async updateWithServiceJudge(event: CustomEvent): Promise { + async updateWithServiceJudge(event: ToggleCustomEvent): Promise { await this.settingsService.update({ withServiceJudge: event.detail.checked }); - - if (!event.detail.checked) { - this.courtServiceJudgeService.umpires().map((csj: CourtServiceJudge) => { - this.courtServiceJudgeService.delete(csj.id); - }); - - this.waitingServiceJudgeService.waitingServiceJudges().map((wsj) => { - this.waitingServiceJudgeService.remove(wsj.id); - }); - } } - async updateWaitingListQuickButtons( - event: CustomEvent<{ checked: boolean }> - ): Promise { + async updateWaitingListQuickButtons(event: ToggleCustomEvent): Promise { await this.settingsService.update({ waitingListQuickButtons: event.detail.checked }); } - async updateShowAlert(event: CustomEvent): Promise { + async updateShowAlert(event: ToggleCustomEvent): Promise { await this.settingsService.update({ showAlert: event.detail.checked }); } - updateTheme(event: CustomEvent<{ value: ThemePreference }>): void { + updateTheme(event: SelectCustomEvent): void { this.themeService.setTheme(event.detail.value); } - updateLanguage(event: CustomEvent): void { - this.translationService.setLanguage(event.detail.value as Language); + updateLanguage(event: SelectCustomEvent): void { + this.translationService.setLanguage(event.detail.value); } } diff --git a/src/app/tabs/tabs.page.ts b/src/app/tabs/tabs.page.ts index a835696..bf51500 100644 --- a/src/app/tabs/tabs.page.ts +++ b/src/app/tabs/tabs.page.ts @@ -1,9 +1,4 @@ -import { - Component, - EnvironmentInjector, - inject, - ChangeDetectionStrategy -} from '@angular/core'; +import { Component, ChangeDetectionStrategy } from '@angular/core'; import { IonTabs, IonTabBar, @@ -23,8 +18,6 @@ import { TranslatePipe } from '../i18n/translation.pipe'; imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, TranslatePipe] }) export class TabsPage { - public environmentInjector = inject(EnvironmentInjector); - constructor() { addIcons({ gridOutline, peopleOutline, settingsOutline }); } diff --git a/src/global.scss b/src/global.scss index c825b81..62cf710 100644 --- a/src/global.scss +++ b/src/global.scss @@ -10,20 +10,20 @@ */ /* Core CSS required for Ionic components to work properly */ -@import "@ionic/angular/css/core.css"; +@import '@ionic/angular/css/core.css'; /* Basic CSS for apps built with Ionic */ -@import "@ionic/angular/css/normalize.css"; -@import "@ionic/angular/css/structure.css"; -@import "@ionic/angular/css/typography.css"; -@import "@ionic/angular/css/display.css"; +@import '@ionic/angular/css/normalize.css'; +@import '@ionic/angular/css/structure.css'; +@import '@ionic/angular/css/typography.css'; +@import '@ionic/angular/css/display.css'; /* Optional CSS utils that can be commented out */ -@import "@ionic/angular/css/padding.css"; -@import "@ionic/angular/css/float-elements.css"; -@import "@ionic/angular/css/text-alignment.css"; -@import "@ionic/angular/css/text-transformation.css"; -@import "@ionic/angular/css/flex-utils.css"; +@import '@ionic/angular/css/padding.css'; +@import '@ionic/angular/css/float-elements.css'; +@import '@ionic/angular/css/text-alignment.css'; +@import '@ionic/angular/css/text-transformation.css'; +@import '@ionic/angular/css/flex-utils.css'; /** * Ionic Dark Mode @@ -34,12 +34,7 @@ @import '@ionic/angular/css/palettes/dark.class.css'; -.wide.sc-ion-alert-md-h { - .sc-ion-alert-md { - --backdrop-opacity: 0.8; - } - - &.sc-ion-alert-md-h { - --min-width: 400px; - } -} \ No newline at end of file +ion-alert.wide { + --backdrop-opacity: 0.8; + --min-width: min(400px, calc(100vw - 32px)); +} diff --git a/src/index.html b/src/index.html index e04f8cc..546a015 100644 --- a/src/index.html +++ b/src/index.html @@ -1,27 +1,35 @@ - + + + + Court Pilot - - - Court Pilot + - + + + + - - - - + + - - - - - - - - - - - + + + + + + + diff --git a/src/main.ts b/src/main.ts index 8d9dda6..e973e00 100644 --- a/src/main.ts +++ b/src/main.ts @@ -18,4 +18,6 @@ bootstrapApplication(AppComponent, { provideIonicAngular(), provideRouter(routes, withPreloading(PreloadAllModules)) ] -}); +}).catch((error: unknown) => + console.error('Application bootstrap failed:', error) +); diff --git a/src/zone-flags.ts b/src/zone-flags.ts index e999ae9..dab1fbc 100644 --- a/src/zone-flags.ts +++ b/src/zone-flags.ts @@ -2,4 +2,6 @@ * Prevents Angular change detection from * running with certain Web Component callbacks */ -(window as any).__Zone_disable_customElements = true; +( + window as Window & { __Zone_disable_customElements?: boolean } +).__Zone_disable_customElements = true; diff --git a/tsconfig.app.json b/tsconfig.app.json index f28af19..ff396d4 100644 --- a/tsconfig.app.json +++ b/tsconfig.app.json @@ -6,13 +6,5 @@ "types": [] }, "files": ["src/main.ts", "src/polyfills.ts"], - "include": ["src/**/*.d.ts"], - "angularCompilerOptions": { - "extendedDiagnostics": { - "checks": { - "nullishCoalescingNotNullable": "suppress", - "optionalChainNotNullable": "suppress" - } - } - } + "include": ["src/**/*.d.ts"] }