fix: apply Angular and Ionic audit improvements

This commit is contained in:
tothbt committed 2026-11-04 21:20:14 +01:00
1 parent 9739ea55c6
commit 8dad54fcf1
35 files changed
+1042 -503

No files matched your search

+17 -2
View File
@@ -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()`. 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 ## Persistence model
@@ -137,5 +153,4 @@ Operations that move an official between states should preserve exclusivity even
## Known architectural questions ## 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/`? - Should domain entities and database setup remain in root-level `db.ts`, or move under `src/app/`?
+93
View File
@@ -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.
+9 -1
View File
@@ -1,11 +1,13 @@
const { defineConfig } = require('eslint/config'); const { defineConfig } = require('eslint/config');
const angular = require('angular-eslint'); const angular = require('angular-eslint');
const typescript = require('@typescript-eslint/eslint-plugin');
module.exports = defineConfig( module.exports = defineConfig(
{ ignores: ['projects/**', 'www/**', 'coverage/**'] }, { ignores: ['projects/**', 'www/**', 'coverage/**'] },
{ {
files: ['src/**/*.ts', 'db.ts'], files: ['src/**/*.ts', 'db.ts'],
extends: angular.configs.tsRecommended, extends: angular.configs.tsRecommended,
plugins: { '@typescript-eslint': typescript },
languageOptions: { languageOptions: {
parserOptions: { parserOptions: {
project: ['./tsconfig.eslint.json'], project: ['./tsconfig.eslint.json'],
@@ -14,8 +16,14 @@ module.exports = defineConfig(
}, },
processor: angular.processInlineTemplates, processor: angular.processInlineTemplates,
rules: { 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', '@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': [ '@angular-eslint/component-class-suffix': [
'error', 'error',
{ suffixes: ['Page', 'Component'] } { suffixes: ['Page', 'Component'] }
+3 -6
View File
@@ -26,7 +26,7 @@ export class AppComponent {
readonly themeService = inject(ThemeService); readonly themeService = inject(ThemeService);
constructor() { constructor() {
this.checkForUpdates(); void this.checkForUpdates();
} }
private async checkForUpdates(): Promise<void> { private async checkForUpdates(): Promise<void> {
@@ -34,12 +34,9 @@ export class AppComponent {
return; return;
} }
// Snap handles updates on Linux.
if (platform() === 'linux') {
return;
}
try { try {
// Snap handles updates on Linux.
if (platform() === 'linux') return;
const update = await check(); const update = await check();
if (update) { if (update) {
+1 -1
View File
@@ -10,7 +10,7 @@ import { TranslationService } from './i18n/translation.service';
export class FullnamePipe implements PipeTransform { export class FullnamePipe implements PipeTransform {
readonly translationService = inject(TranslationService); readonly translationService = inject(TranslationService);
transform(value: Umpire | undefined, ...args: unknown[]): string { transform(value: Umpire | undefined): string {
if (typeof value === 'undefined') { if (typeof value === 'undefined') {
return ''; return '';
} }
+47
View File
@@ -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');
});
});
+17 -7
View File
@@ -1,4 +1,5 @@
import { Injectable, signal } from '@angular/core'; import { DOCUMENT } from '@angular/common';
import { inject, Injectable, signal } from '@angular/core';
import { import {
translations, translations,
Language, Language,
@@ -10,12 +11,21 @@ import {
providedIn: 'root' providedIn: 'root'
}) })
export class TranslationService { export class TranslationService {
private readonly document = inject(DOCUMENT);
private readonly storageKey = 'language'; private readonly storageKey = 'language';
readonly language = signal<Language>(this.getInitialLanguage()); private readonly selectedLanguage = signal<Language>(
this.getInitialLanguage()
);
readonly language = this.selectedLanguage.asReadonly();
constructor() {
this.document.documentElement.lang = this.language();
}
setLanguage(language: Language): void { setLanguage(language: Language): void {
this.language.set(language); this.selectedLanguage.set(language);
this.document.documentElement.lang = language;
localStorage.setItem(this.storageKey, language); localStorage.setItem(this.storageKey, language);
} }
@@ -32,13 +42,13 @@ export class TranslationService {
private getInitialLanguage(): Language { private getInitialLanguage(): Language {
const stored = localStorage.getItem(this.storageKey); const stored = localStorage.getItem(this.storageKey);
if (stored && stored in translations) { if (stored === 'en' || stored === 'hu') {
return stored as Language; 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; return browserLanguage;
} }
+2 -3
View File
@@ -1,6 +1,3 @@
import { language, male, settings } from 'ionicons/icons';
import { noop } from 'rxjs';
export const translations = { export const translations = {
en: { en: {
courts: 'Courts', courts: 'Courts',
@@ -27,6 +24,7 @@ export const translations = {
newCourtOfficial: 'New Court Official', newCourtOfficial: 'New Court Official',
cancel: 'Cancel', cancel: 'Cancel',
save: 'Save', save: 'Save',
saveFailed: 'Could not save the court official. Please try again.',
firstName: 'First Name', firstName: 'First Name',
lastName: 'Last Name', lastName: 'Last Name',
country: 'Country', country: 'Country',
@@ -88,6 +86,7 @@ export const translations = {
newCourtOfficial: 'Új Tisztségviselő', newCourtOfficial: 'Új Tisztségviselő',
cancel: 'Mégse', cancel: 'Mégse',
save: 'Mentés', save: 'Mentés',
saveFailed: 'Nem sikerült menteni a tisztségviselőt. Próbáld újra.',
firstName: 'Keresztnév', firstName: 'Keresztnév',
lastName: 'Vezetéknév', lastName: 'Vezetéknév',
country: 'Ország', country: 'Ország',
@@ -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);
});
});
+167
View File
@@ -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<void>): Promise<void> {
return db.transaction(
'rw',
[
db.settings,
db.umpires,
db.waitingUmpires,
db.waitingServiceJudges,
db.courtUmpires,
db.courtServiceJudges
],
action
);
}
private async removeOfficial(id: number): Promise<void> {
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<void> {
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<void> {
await this.transaction(async () => {
await this.removeOfficial(id);
});
}
async moveToCourt(
id: number,
role: WaitingRole,
courtNo: number
): Promise<void> {
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<void> {
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<void> {
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);
}
});
}
}
@@ -15,6 +15,11 @@ describe('CourtServiceJudgeService persistence', () => {
await db.courtServiceJudges.clear(); 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 () => { it('saves an assignment and returns its generated ID', async () => {
const id = await service.save({ umpireId: 10, courtNo: 2 }); const id = await service.save({ umpireId: 10, courtNo: 2 });
expect(await db.courtServiceJudges.get(id)).toEqual({ expect(await db.courtServiceJudges.get(id)).toEqual({
@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { computed, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { CourtServiceJudge, db } from 'db'; import { CourtServiceJudge, db } from 'db';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
@@ -20,11 +20,8 @@ export class CourtServiceJudgeService {
* Get by court number (reactive) * Get by court number (reactive)
*/ */
getByCourtNo(courtNo: number) { getByCourtNo(courtNo: number) {
return toSignal<CourtServiceJudge | undefined>( return computed(() =>
liveQuery(() => this.umpires().find((item) => item.courtNo === courtNo)
db.courtServiceJudges.where('courtNo').equals(courtNo).first()
),
{ initialValue: undefined }
); );
} }
@@ -32,7 +29,7 @@ export class CourtServiceJudgeService {
* Create / update * Create / update
*/ */
async save(item: Omit<CourtServiceJudge, 'id'>): Promise<number> { async save(item: Omit<CourtServiceJudge, 'id'>): Promise<number> {
return db.courtServiceJudges.add(item as CourtServiceJudge); return db.courtServiceJudges.add({ ...item });
} }
async update( async update(
@@ -15,6 +15,11 @@ describe('CourtUmpireService persistence', () => {
await db.courtUmpires.clear(); 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 () => { it('saves an assignment and returns its generated ID', async () => {
const id = await service.save({ umpireId: 10, courtNo: 2 }); const id = await service.save({ umpireId: 10, courtNo: 2 });
expect(await db.courtUmpires.get(id)).toEqual({ expect(await db.courtUmpires.get(id)).toEqual({
+4 -5
View File
@@ -1,4 +1,4 @@
import { Injectable } from '@angular/core'; import { computed, Injectable } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop'; import { toSignal } from '@angular/core/rxjs-interop';
import { CourtUmpire, db } from 'db'; import { CourtUmpire, db } from 'db';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
@@ -20,9 +20,8 @@ export class CourtUmpireService {
* Get by court number (reactive single) * Get by court number (reactive single)
*/ */
getByCourtNo(courtNo: number) { getByCourtNo(courtNo: number) {
return toSignal<CourtUmpire | undefined>( return computed(() =>
liveQuery(() => db.courtUmpires.where('courtNo').equals(courtNo).first()), this.umpires().find((item) => item.courtNo === courtNo)
{ initialValue: undefined }
); );
} }
@@ -30,7 +29,7 @@ export class CourtUmpireService {
* Create / update * Create / update
*/ */
async save(item: Omit<CourtUmpire, 'id'>): Promise<number> { async save(item: Omit<CourtUmpire, 'id'>): Promise<number> {
return db.courtUmpires.add(item as CourtUmpire); return db.courtUmpires.add({ ...item });
} }
async update(id: number, changes: Partial<CourtUmpire>): Promise<number> { async update(id: number, changes: Partial<CourtUmpire>): Promise<number> {
+58 -6
View File
@@ -1,16 +1,68 @@
import { TestBed } from '@angular/core/testing'; import { TestBed } from '@angular/core/testing';
import { db } from 'db';
import { SettingsService } from './settings-service'; import { SettingsService } from './settings-service';
describe('SettingsService', () => { describe('SettingsService', () => {
let service: SettingsService; let service: SettingsService;
const clear = async () => {
beforeEach(() => { await Promise.all([
TestBed.configureTestingModule({}); db.settings.clear(),
db.courtUmpires.clear(),
db.courtServiceJudges.clear(),
db.waitingServiceJudges.clear()
]);
};
beforeEach(async () => {
await clear();
service = TestBed.inject(SettingsService); service = TestBed.inject(SettingsService);
await service.update({ numberOfCourts: 5, withServiceJudge: true });
});
afterEach(async () => {
TestBed.resetTestingModule();
await clear();
}); });
it('should be created', () => { it('initializes defaults before accepting an immediate update', async () => {
expect(service).toBeTruthy(); 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();
}); });
}); });
+36 -14
View File
@@ -23,24 +23,46 @@ export class SettingsService {
* Update settings * Update settings
*/ */
async update(changes: Partial<Omit<Settings, 'id'>>): Promise<void> { async update(changes: Partial<Omit<Settings, 'id'>>): Promise<void> {
const current = await db.settings.get(1); if (
changes.numberOfCourts !== undefined &&
if (!current) { (!Number.isSafeInteger(changes.numberOfCourts) ||
changes.numberOfCourts < 1)
)
return; return;
}
await db.settings.update(1, { await db.transaction(
...changes, 'rw',
...(!(changes.withServiceJudge ?? current.withServiceJudge) [
? { waitingListQuickButtons: false } 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() { private async ensureSettings() {
const existing = await db.settings.get(1); await db.transaction('rw', db.settings, async () => {
if (await db.settings.get(1)) return;
if (!existing) {
await db.settings.put({ await db.settings.put({
id: 1, id: 1,
numberOfCourts: 5, numberOfCourts: 5,
@@ -48,6 +70,6 @@ export class SettingsService {
showAlert: true, showAlert: true,
waitingListQuickButtons: false waitingListQuickButtons: false
}); });
} });
} }
} }
+37 -2
View File
@@ -12,18 +12,31 @@ describe('UmpireService persistence', () => {
}; };
beforeEach(async () => { 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); service = TestBed.inject(UmpireService);
}); });
afterEach(async () => { afterEach(async () => {
TestBed.resetTestingModule(); 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 () => { it('creates and retrieves a complete official record', async () => {
const id = await service.create(official); const id = await service.create(official);
expect(await service.getById(id)).toEqual({ ...official, id }); 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 () => { 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 () => { it('clears all officials', async () => {
await service.create(official); await service.create(official);
await service.create({ ...official, firstName: 'Sam' }); await service.create({ ...official, firstName: 'Sam' });
+46 -7
View File
@@ -1,14 +1,18 @@
import { Injectable } from '@angular/core'; import { inject, Injectable } from '@angular/core';
import { db } from 'db'; import { db } from 'db';
import { liveQuery } from 'dexie'; import { liveQuery } from 'dexie';
import { from } from 'rxjs'; 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'; import { toSignal } from '@angular/core/rxjs-interop';
@Injectable({ @Injectable({
providedIn: 'root' providedIn: 'root'
}) })
export class UmpireService { export class UmpireService {
private readonly waitingUmpires = inject(WaitingUmpiresService);
private readonly waitingJudges = inject(WaitingServiceJudgesService);
/** /**
* Reactive signal with all umpires * Reactive signal with all umpires
*/ */
@@ -23,9 +27,7 @@ export class UmpireService {
* Create * Create
*/ */
async create(umpire: Omit<Umpire, 'id'>): Promise<number> { async create(umpire: Omit<Umpire, 'id'>): Promise<number> {
return await db.umpires.add({ return db.umpires.add({ ...umpire });
...umpire
} as Umpire);
} }
/** /**
@@ -49,13 +51,50 @@ export class UmpireService {
* Delete * Delete
*/ */
async delete(id: number): Promise<void> { async delete(id: number): Promise<void> {
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 * Delete all
*/ */
async clear(): Promise<void> { async clear(): Promise<void> {
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();
}
);
} }
} }
@@ -22,31 +22,26 @@ export class WaitingServiceJudgesService {
* Add new umpire to queue with max(order) + 1 * Add new umpire to queue with max(order) + 1
*/ */
async add(serviceJudgeId: number, toPosition?: number): Promise<number> { async add(serviceJudgeId: number, toPosition?: number): Promise<number> {
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 last = await db.waitingServiceJudges.orderBy('order').last();
const id = await db.waitingServiceJudges.add({
const nextOrder = last ? last.order + 1 : 1;
return db.waitingServiceJudges.add({
serviceJudgeId, 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 * Remove from queue
*/ */
async remove(id: number): Promise<void> { async remove(id: number): Promise<void> {
await db.waitingServiceJudges.delete(id); await db.transaction('rw', db.waitingServiceJudges, async () => {
await db.waitingServiceJudges.delete(id);
await this.reinitOrders(); await this.reinitOrders();
});
} }
async removeByUmpireId(umpireId: number): Promise<void> { async removeByUmpireId(umpireId: number): Promise<void> {
@@ -106,7 +101,7 @@ export class WaitingServiceJudgesService {
.toArray(); .toArray();
for (const item of affected) { for (const item of affected) {
await db.waitingServiceJudges.update(item.id!, { await db.waitingServiceJudges.update(item.id, {
order: item.order + 1 order: item.order + 1
}); });
} }
@@ -123,13 +118,13 @@ export class WaitingServiceJudgesService {
.toArray(); .toArray();
for (const item of affected) { for (const item of affected) {
await db.waitingServiceJudges.update(item.id!, { await db.waitingServiceJudges.update(item.id, {
order: item.order - 1 order: item.order - 1
}); });
} }
} }
await db.waitingServiceJudges.update(current.id!, { await db.waitingServiceJudges.update(current.id, {
order: newOrder order: newOrder
}); });
}); });
@@ -145,7 +140,7 @@ export class WaitingServiceJudgesService {
const expectedOrder = i + 1; const expectedOrder = i + 1;
if (item.order !== expectedOrder) { if (item.order !== expectedOrder) {
await db.waitingServiceJudges.update(item.id!, { await db.waitingServiceJudges.update(item.id, {
order: expectedOrder order: expectedOrder
}); });
} }
@@ -154,7 +149,7 @@ export class WaitingServiceJudgesService {
} }
async getCurrentUmpire(): Promise<Umpire | undefined> { async getCurrentUmpire(): Promise<Umpire | undefined> {
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; return item ? db.umpires.get(item.serviceJudgeId) : undefined;
} }
+15 -20
View File
@@ -22,31 +22,26 @@ export class WaitingUmpiresService {
* Add new umpire to queue with max(order) + 1 * Add new umpire to queue with max(order) + 1
*/ */
async add(umpireId: number, toPosition?: number): Promise<number> { async add(umpireId: number, toPosition?: number): Promise<number> {
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 last = await db.waitingUmpires.orderBy('order').last();
const id = await db.waitingUmpires.add({
const nextOrder = last ? last.order + 1 : 1;
return db.waitingUmpires.add({
umpireId, 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 * Remove from queue
*/ */
async remove(id: number): Promise<void> { async remove(id: number): Promise<void> {
await db.waitingUmpires.delete(id); await db.transaction('rw', db.waitingUmpires, async () => {
await db.waitingUmpires.delete(id);
await this.reinitOrders(); await this.reinitOrders();
});
} }
async removeByUmpireId(umpireId: number): Promise<void> { async removeByUmpireId(umpireId: number): Promise<void> {
@@ -106,7 +101,7 @@ export class WaitingUmpiresService {
.toArray(); .toArray();
for (const item of affected) { for (const item of affected) {
await db.waitingUmpires.update(item.id!, { await db.waitingUmpires.update(item.id, {
order: item.order + 1 order: item.order + 1
}); });
} }
@@ -123,13 +118,13 @@ export class WaitingUmpiresService {
.toArray(); .toArray();
for (const item of affected) { for (const item of affected) {
await db.waitingUmpires.update(item.id!, { await db.waitingUmpires.update(item.id, {
order: item.order - 1 order: item.order - 1
}); });
} }
} }
await db.waitingUmpires.update(current.id!, { await db.waitingUmpires.update(current.id, {
order: newOrder order: newOrder
}); });
}); });
@@ -145,7 +140,7 @@ export class WaitingUmpiresService {
const expectedOrder = i + 1; const expectedOrder = i + 1;
if (item.order !== expectedOrder) { if (item.order !== expectedOrder) {
await db.waitingUmpires.update(item.id!, { await db.waitingUmpires.update(item.id, {
order: expectedOrder order: expectedOrder
}); });
} }
@@ -154,7 +149,7 @@ export class WaitingUmpiresService {
} }
async getCurrentUmpire(): Promise<Umpire | undefined> { async getCurrentUmpire(): Promise<Umpire | undefined> {
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; return item ? db.umpires.get(item.umpireId) : undefined;
} }
+39 -39
View File
@@ -11,16 +11,16 @@
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
<ion-grid [fixed]="'fixed'" class="bottom-lists"> <ion-grid [fixed]="true" class="bottom-lists">
<ion-row class="court-headers"> <ion-row class="court-headers">
<ion-col [size]="1" class="ion-display-none ion-display-xl-block"> <ion-col size="1" class="ion-display-none ion-display-xl-block">
<ion-item [lines]="'none'">{{ 'court' | translate }}</ion-item> <ion-item lines="none">{{ 'court' | translate }}</ion-item>
</ion-col> </ion-col>
<ion-col [size]="1" class="ion-display-xl-none"> <ion-col size="1" class="ion-display-xl-none">
<ion-item [lines]="'none'">{{ 'shortCourtName' | translate }}</ion-item> <ion-item lines="none">{{ 'shortCourtName' | translate }}</ion-item>
</ion-col> </ion-col>
<ion-col> <ion-col>
<ion-item [lines]="'none'"> <ion-item lines="none">
<ion-label class="ion-text-center"> <ion-label class="ion-text-center">
{{ 'umpire' | translate }} {{ 'umpire' | translate }}
</ion-label> </ion-label>
@@ -28,7 +28,7 @@
</ion-col> </ion-col>
@if (serviceJudgeEnabled()) { @if (serviceJudgeEnabled()) {
<ion-col> <ion-col>
<ion-item [lines]="'none'"> <ion-item lines="none">
<ion-label class="ion-text-center"> <ion-label class="ion-text-center">
{{ 'serviceJudge' | translate }} {{ 'serviceJudge' | translate }}
</ion-label> </ion-label>
@@ -37,24 +37,22 @@
} }
<ion-col size="2" sizeLg="1"></ion-col> <ion-col size="2" sizeLg="1"></ion-col>
</ion-row> </ion-row>
@for (item of @for (courtNo of courtIndexes(); track courtNo) {
[].constructor($safeNavigationMigration(settings()?.numberOfCourts)); track
$index) {
<ion-row> <ion-row>
<ion-col [size]="1" class="court-number"> <ion-col size="1" class="court-number">
<ion-list> <ion-list>
<ion-item [lines]="'none'"> {{ $index + 1 }}. </ion-item> <ion-item lines="none"> {{ courtNo }}. </ion-item>
</ion-list> </ion-list>
</ion-col> </ion-col>
<ion-col> <ion-col>
<ion-list <ion-list
[lines]="'none'" lines="none"
cdkDropList cdkDropList
[id]="`court-umpire-${$index + 1}`" [id]="`court-umpire-${courtNo}`"
[cdkDropListData]="umpireByCourt().get($index + 1) ? [umpireByCourt().get($index + 1)] : []" [cdkDropListData]="umpireByCourt().get(courtNo) ? [umpireByCourt().get(courtNo)] : []"
[cdkDropListEnterPredicate]="canDropUmpire" [cdkDropListEnterPredicate]="canDropUmpire"
(cdkDropListDropped)="dropToUmpire($event, $index + 1)"> (cdkDropListDropped)="dropToUmpire($event, courtNo)">
@if (umpireByCourt().get($index + 1); as umpire) { @if (umpireByCourt().get(courtNo); as umpire) {
<div class="custom-ion-item" cdkDrag [cdkDragData]="umpire"> <div class="custom-ion-item" cdkDrag [cdkDragData]="umpire">
<div *cdkDragPlaceholder class="custom-ion-item primary"> <div *cdkDragPlaceholder class="custom-ion-item primary">
@@ -69,12 +67,13 @@
@if (serviceJudgeEnabled()) { @if (serviceJudgeEnabled()) {
<ion-col> <ion-col>
<ion-list <ion-list
[lines]="'none'" lines="none"
cdkDropList cdkDropList
[id]="`court-service-judge-${$index + 1}`" [id]="`court-service-judge-${courtNo}`"
[cdkDropListData]="serviceJudgeByCourt().get($index + 1) ? [serviceJudgeByCourt().get($index + 1)] : []" [cdkDropListData]="serviceJudgeByCourt().get(courtNo) ? [serviceJudgeByCourt().get(courtNo)] : []"
(cdkDropListDropped)="dropToServiceJudge($event, $index + 1)"> [cdkDropListEnterPredicate]="canDropUmpire"
@if (serviceJudgeByCourt().get($index + 1); as umpire) { (cdkDropListDropped)="dropToServiceJudge($event, courtNo)">
@if (serviceJudgeByCourt().get(courtNo); as umpire) {
<div class="custom-ion-item" cdkDrag [cdkDragData]="umpire"> <div class="custom-ion-item" cdkDrag [cdkDragData]="umpire">
<div *cdkDragPlaceholder class="custom-ion-item primary"> <div *cdkDragPlaceholder class="custom-ion-item primary">
@@ -90,25 +89,26 @@
<ion-col size="2" sizeLg="1" class="court-actions"> <ion-col size="2" sizeLg="1" class="court-actions">
<ion-list> <ion-list>
<ion-item lines="none"> <ion-item lines="none">
@if ( (umpireByCourt().get($index + 1) && @if ( (umpireByCourt().get(courtNo) &&
serviceJudgeByCourt().get($index + 1)) || serviceJudgeByCourt().get(courtNo)) || (umpireByCourt().get(courtNo)
(umpireByCourt().get($index + 1) && !serviceJudgeEnabled()) ) { && !serviceJudgeEnabled()) ) {
<ion-button <ion-button
[color]="'danger'" color="danger"
(click)="showRemoveConfirmation($index + 1)"> [attr.aria-label]="('removeUmpireFromCourt' | translate) + ': ' + courtNo"
(click)="showRemoveConfirmation(courtNo)">
<ion-icon <ion-icon
slot="icon-only" slot="icon-only"
size="large" size="large"
name="exit-outline"></ion-icon> name="exit-outline"></ion-icon>
</ion-button> </ion-button>
} @else if ((!umpireByCourt().get($index + 1) && } @else if ((!umpireByCourt().get(courtNo) &&
!serviceJudgeByCourt().get($index + 1) && waitingUmpires().length && !serviceJudgeByCourt().get(courtNo) && waitingUmpires().length &&
waitingServiceJudges().length) || (!umpireByCourt().get($index + 1) waitingServiceJudges().length) || (!umpireByCourt().get(courtNo) &&
&& waitingUmpires().length && !serviceJudgeEnabled())) { waitingUmpires().length && !serviceJudgeEnabled())) {
<ion-button <ion-button
[color]="'success'" color="success"
[disabled]="" [attr.aria-label]="('sendUmpireToCourt' | translate) + ': ' + courtNo"
(click)="showAddConfirmation($index + 1)"> (click)="showAddConfirmation(courtNo)">
<ion-icon <ion-icon
slot="icon-only" slot="icon-only"
size="large" size="large"
@@ -121,7 +121,7 @@
</ion-row> </ion-row>
} }
</ion-grid> </ion-grid>
<ion-grid [fixed]="'fixed'" class="ion-margin-top bottom-lists"> <ion-grid [fixed]="true" class="ion-margin-top bottom-lists">
<ion-row> <ion-row>
<ion-col [size]="!serviceJudgeEnabled() ? 6 : 4"> <ion-col [size]="!serviceJudgeEnabled() ? 6 : 4">
<ion-item color="primary"> <ion-item color="primary">
@@ -131,7 +131,7 @@
</ion-item> </ion-item>
</ion-col> </ion-col>
@if (serviceJudgeEnabled()) { @if (serviceJudgeEnabled()) {
<ion-col [size]="4"> <ion-col size="4">
<ion-item color="primary"> <ion-item color="primary">
<ion-label class="ion-text-center"> <ion-label class="ion-text-center">
{{ 'serviceJudges' | translate }} {{ 'serviceJudges' | translate }}
@@ -150,7 +150,7 @@
<ion-row> <ion-row>
<ion-col> <ion-col>
<ion-list <ion-list
[lines]="'none'" lines="none"
cdkDropList cdkDropList
id="list-waiting-umpires" id="list-waiting-umpires"
[cdkDropListData]="waitingUmpires()" [cdkDropListData]="waitingUmpires()"
@@ -180,7 +180,7 @@
@if (serviceJudgeEnabled()) { @if (serviceJudgeEnabled()) {
<ion-col> <ion-col>
<ion-list <ion-list
[lines]="'none'" lines="none"
cdkDropList cdkDropList
id="list-waiting-service-judges" id="list-waiting-service-judges"
[cdkDropListData]="waitingServiceJudges()" [cdkDropListData]="waitingServiceJudges()"
@@ -210,7 +210,7 @@
} }
<ion-col> <ion-col>
<ion-list <ion-list
[lines]="'none'" lines="none"
cdkDropList cdkDropList
cdkDropListSortingDisabled cdkDropListSortingDisabled
id="list-on-rest" id="list-on-rest"
+30 -38
View File
@@ -4,6 +4,7 @@ import { signal } from '@angular/core';
import { AlertController } from '@ionic/angular'; import { AlertController } from '@ionic/angular';
import { CourtServiceJudge, CourtUmpire, Settings, Umpire } from 'db'; import { CourtServiceJudge, CourtUmpire, Settings, Umpire } from 'db';
import { WaitingDutyService } from '../services/waiting-duty.service'; import { WaitingDutyService } from '../services/waiting-duty.service';
import { CourtRotationService } from '../services/court-rotation.service';
import { Tab1Page } from './tab1.page'; import { Tab1Page } from './tab1.page';
import { SettingsService } from '../services/settings-service'; import { SettingsService } from '../services/settings-service';
import { UmpireService } from '../services/umpire.service'; import { UmpireService } from '../services/umpire.service';
@@ -37,6 +38,7 @@ describe('Tab1Page court rotation', () => {
let umpireCourt: jasmine.SpyObj<CourtUmpireService>; let umpireCourt: jasmine.SpyObj<CourtUmpireService>;
let judgeCourt: jasmine.SpyObj<CourtServiceJudgeService>; let judgeCourt: jasmine.SpyObj<CourtServiceJudgeService>;
let duty: jasmine.SpyObj<WaitingDutyService>; let duty: jasmine.SpyObj<WaitingDutyService>;
let rotation: jasmine.SpyObj<CourtRotationService>;
let alerts: jasmine.SpyObj<AlertController>; let alerts: jasmine.SpyObj<AlertController>;
beforeEach(() => { beforeEach(() => {
@@ -78,6 +80,15 @@ describe('Tab1Page court rotation', () => {
duty = jasmine.createSpyObj('WaitingDutyService', ['move', 'moveToRest']); duty = jasmine.createSpyObj('WaitingDutyService', ['move', 'moveToRest']);
duty.move.and.resolveTo(); duty.move.and.resolveTo();
duty.moveToRest.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']); alerts = jasmine.createSpyObj('AlertController', ['create']);
waitingUmpire.getCurrentUmpire.and.resolveTo(official(1)); waitingUmpire.getCurrentUmpire.and.resolveTo(official(1));
waitingJudge.getCurrentUmpire.and.resolveTo(official(2)); waitingJudge.getCurrentUmpire.and.resolveTo(official(2));
@@ -91,6 +102,7 @@ describe('Tab1Page court rotation', () => {
judgeCourt.removeByUmpireId.and.resolveTo(); judgeCourt.removeByUmpireId.and.resolveTo();
TestBed.configureTestingModule({ TestBed.configureTestingModule({
providers: [ providers: [
{ provide: CourtRotationService, useValue: rotation },
{ provide: WaitingDutyService, useValue: duty }, { provide: WaitingDutyService, useValue: duty },
{ provide: SettingsService, useValue: { settings } }, { provide: SettingsService, useValue: { settings } },
{ provide: UmpireService, useValue: { umpires } }, { provide: UmpireService, useValue: { umpires } },
@@ -179,7 +191,7 @@ describe('Tab1Page court rotation', () => {
alerts.create.and.resolveTo({ present } as unknown as HTMLIonAlertElement); alerts.create.and.resolveTo({ present } as unknown as HTMLIonAlertElement);
await component.showAddConfirmation(2); await component.showAddConfirmation(2);
expect(present).toHaveBeenCalledTimes(1); expect(present).toHaveBeenCalledTimes(1);
expect(umpireCourt.save).not.toHaveBeenCalled(); expect(rotation.assignNext).not.toHaveBeenCalled();
expect(judgeCourt.save).not.toHaveBeenCalled(); expect(judgeCourt.save).not.toHaveBeenCalled();
const options = alerts.create.calls.mostRecent().args[0]; const options = alerts.create.calls.mostRecent().args[0];
const confirm = options?.buttons?.find( const confirm = options?.buttons?.find(
@@ -190,14 +202,7 @@ describe('Tab1Page court rotation', () => {
confirm.handler(undefined); confirm.handler(undefined);
} }
await fixture.whenStable(); await fixture.whenStable();
expect(umpireCourt.save).toHaveBeenCalledOnceWith({ expect(rotation.assignNext).toHaveBeenCalledOnceWith(2, 1, 2);
courtNo: 2,
umpireId: 1
});
expect(judgeCourt.save).toHaveBeenCalledOnceWith({
courtNo: 2,
umpireId: 2
});
}); });
it('returns a lone umpire to the umpire queue after confirming rotation', async () => { 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(); const present = jasmine.createSpy('present').and.resolveTo();
alerts.create.and.resolveTo({ present } as unknown as HTMLIonAlertElement); alerts.create.and.resolveTo({ present } as unknown as HTMLIonAlertElement);
await component.showRemoveConfirmation(2); await component.showRemoveConfirmation(2);
expect(umpireCourt.removeByUmpireId).not.toHaveBeenCalled(); expect(rotation.rotate).not.toHaveBeenCalled();
const options = alerts.create.calls.mostRecent().args[0]; const options = alerts.create.calls.mostRecent().args[0];
const confirm = options?.buttons?.find( const confirm = options?.buttons?.find(
(button) => typeof button !== 'string' && button.role === 'confirm' (button) => typeof button !== 'string' && button.role === 'confirm'
@@ -220,32 +225,20 @@ describe('Tab1Page court rotation', () => {
confirm.handler(undefined); confirm.handler(undefined);
} }
await fixture.whenStable(); await fixture.whenStable();
expect(umpireCourt.removeByUmpireId).toHaveBeenCalledOnceWith(3); expect(rotation.rotate).toHaveBeenCalledOnceWith(2, 3, undefined);
expect(waitingUmpire.add).toHaveBeenCalledOnceWith(3);
expect(waitingJudge.add).not.toHaveBeenCalled();
expect(judgeCourt.removeByUmpireId).not.toHaveBeenCalled();
}); });
it('assigns both queue heads and removes them from their queues', async () => { it('assigns both queue heads and removes them from their queues', async () => {
await component.showAddConfirmation(3); await component.showAddConfirmation(3);
await fixture.whenStable(); await fixture.whenStable();
expect(umpireCourt.save).toHaveBeenCalledOnceWith({ expect(rotation.assignNext).toHaveBeenCalledOnceWith(3, 1, 2);
courtNo: 3,
umpireId: 1
});
expect(judgeCourt.save).toHaveBeenCalledOnceWith({
courtNo: 3,
umpireId: 2
});
expect(waitingUmpire.removeByUmpireId).toHaveBeenCalledOnceWith(1);
expect(waitingJudge.removeByUmpireId).toHaveBeenCalledOnceWith(2);
expect(alerts.create).not.toHaveBeenCalled(); expect(alerts.create).not.toHaveBeenCalled();
}); });
it('does not assign either role when the required service judge is missing', async () => { it('does not assign either role when the required service judge is missing', async () => {
waitingJudge.getCurrentUmpire.and.resolveTo(undefined); waitingJudge.getCurrentUmpire.and.resolveTo(undefined);
await component.showAddConfirmation(1); await component.showAddConfirmation(1);
expect(umpireCourt.save).not.toHaveBeenCalled(); expect(rotation.assignNext).not.toHaveBeenCalled();
expect(judgeCourt.save).not.toHaveBeenCalled(); expect(judgeCourt.save).not.toHaveBeenCalled();
expect(waitingUmpire.removeByUmpireId).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 () => { it('does not assign when the umpire queue is empty', async () => {
waitingUmpire.getCurrentUmpire.and.resolveTo(undefined); waitingUmpire.getCurrentUmpire.and.resolveTo(undefined);
await component.showAddConfirmation(1); await component.showAddConfirmation(1);
expect(umpireCourt.save).not.toHaveBeenCalled(); expect(rotation.assignNext).not.toHaveBeenCalled();
expect(judgeCourt.save).not.toHaveBeenCalled(); expect(judgeCourt.save).not.toHaveBeenCalled();
}); });
@@ -261,13 +254,8 @@ describe('Tab1Page court rotation', () => {
settings.update((value) => ({ ...value, withServiceJudge: false })); settings.update((value) => ({ ...value, withServiceJudge: false }));
await component.showAddConfirmation(2); await component.showAddConfirmation(2);
await fixture.whenStable(); await fixture.whenStable();
expect(umpireCourt.save).toHaveBeenCalledOnceWith({ expect(rotation.assignNext).toHaveBeenCalledOnceWith(2, 1, undefined);
courtNo: 2, expect(waitingJudge.getCurrentUmpire).not.toHaveBeenCalled();
umpireId: 1
});
expect(waitingUmpire.removeByUmpireId).toHaveBeenCalledOnceWith(1);
expect(judgeCourt.save).not.toHaveBeenCalled();
expect(waitingJudge.removeByUmpireId).not.toHaveBeenCalled();
}); });
it('swaps outgoing officials into the opposite role queues', async () => { 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 }]); courtJudges.set([{ id: 1, umpireId: 4, courtNo: 2 }]);
await component.showRemoveConfirmation(2); await component.showRemoveConfirmation(2);
await fixture.whenStable(); await fixture.whenStable();
expect(umpireCourt.removeByUmpireId).toHaveBeenCalledOnceWith(3); expect(rotation.rotate).toHaveBeenCalledOnceWith(2, 3, 4);
expect(judgeCourt.removeByUmpireId).toHaveBeenCalledOnceWith(4);
expect(waitingUmpire.add).toHaveBeenCalledOnceWith(4);
expect(waitingJudge.add).toHaveBeenCalledOnceWith(3);
}); });
it('leaves an incomplete court unchanged when service judges are required', async () => { it('leaves an incomplete court unchanged when service judges are required', async () => {
courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]); courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]);
await component.showRemoveConfirmation(2); await component.showRemoveConfirmation(2);
expect(umpireCourt.removeByUmpireId).not.toHaveBeenCalled(); expect(rotation.rotate).not.toHaveBeenCalled();
expect(waitingUmpire.add).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', () => { it('derives rest from all four duty states and reacts to assignment changes', () => {
courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]); courtUmpires.set([{ id: 1, umpireId: 3, courtNo: 2 }]);
courtJudges.set([{ id: 1, umpireId: 4, courtNo: 2 }]); courtJudges.set([{ id: 1, umpireId: 4, courtNo: 2 }]);
+72 -175
View File
@@ -19,6 +19,7 @@ import {
IonButton, IonButton,
AlertController AlertController
} from '@ionic/angular'; } from '@ionic/angular';
import { CourtRotationService } from '../services/court-rotation.service';
import { SettingsService } from '../services/settings-service'; import { SettingsService } from '../services/settings-service';
import { UmpireService } from '../services/umpire.service'; import { UmpireService } from '../services/umpire.service';
import { Umpire } from 'db'; import { Umpire } from 'db';
@@ -82,8 +83,10 @@ export class Tab1Page {
readonly courtServiceJudgeService = inject(CourtServiceJudgeService); readonly courtServiceJudgeService = inject(CourtServiceJudgeService);
readonly translationService = inject(TranslationService); readonly translationService = inject(TranslationService);
private alertController = inject(AlertController); readonly rotationService = inject(CourtRotationService);
private fullnamePipe = inject(FullnamePipe);
private readonly alertController = inject(AlertController);
private readonly fullnamePipe = inject(FullnamePipe);
/** /**
* Raw signals from services * Raw signals from services
@@ -313,125 +316,76 @@ export class Tab1Page {
{ {
text: this.translationService.translate('yes'), text: this.translationService.translate('yes'),
role: 'confirm', role: 'confirm',
handler: () => { handler: () => move()
void move();
}
} }
] ]
}); });
await alert.present(); await alert.present();
} }
async dropToRest(event: CdkDragDrop<Umpire[]>): Promise<void> { async dropToRest(
if (event.previousContainer === event.container) { event: CdkDragDrop<Umpire[], Umpire[], Umpire>
return; ): Promise<void> {
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 { } else {
const comingFrom = event.previousContainer.id; await this.rotationService.moveToRest(event.item.data.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);
} }
} }
dropToWaitingServiceJudge(event: CdkDragDrop<Umpire[]>) { async dropToWaitingServiceJudge(
const umpireToMove = event.item.data; event: CdkDragDrop<Umpire[], Umpire[], Umpire>
if (event.previousContainer === event.container) { ): Promise<void> {
this.waitingServiceJudgeService.moveToPosition( await this.rotationService.moveToQueue(
umpireToMove.id, event.item.data.id,
event.currentIndex + 1 'serviceJudge',
);
return;
}
const comingFrom = event.previousContainer.id;
this.waitingServiceJudgeService.add(
umpireToMove.id,
event.currentIndex + 1 event.currentIndex + 1
); );
this.removeFromOriginalPlace(umpireToMove, comingFrom);
} }
dropToWaitingUmpire(event: CdkDragDrop<Umpire[]>) { async dropToWaitingUmpire(
const umpireToMove = event.item.data; event: CdkDragDrop<Umpire[], Umpire[], Umpire>
if (event.previousContainer === event.container) { ): Promise<void> {
this.waitingUmpireService.moveToPosition( await this.rotationService.moveToQueue(
umpireToMove.id, event.item.data.id,
event.currentIndex + 1 'umpire',
); event.currentIndex + 1
return; );
} else {
const comingFrom = event.previousContainer.id;
this.waitingUmpireService.add(umpireToMove.id, event.currentIndex + 1);
this.removeFromOriginalPlace(umpireToMove, comingFrom);
}
} }
dropToUmpire(event: CdkDragDrop<(Umpire | undefined)[]>, courtNo: number) { async dropToUmpire(
const targetUmpires = event.container.data; event: CdkDragDrop<(Umpire | undefined)[], unknown, Umpire>,
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)[]>,
courtNo: number courtNo: number
) { ): Promise<void> {
const targetUmpires = event.container.data; if (event.container.data.length) return;
await this.rotationService.moveToCourt(
if (targetUmpires.length > 0) { event.item.data.id,
return; 'umpire',
} courtNo
);
const comingFrom = event.previousContainer.id;
const umpireToMove = event.item.data;
this.courtServiceJudgeService.save({ umpireId: umpireToMove.id, courtNo });
this.removeFromOriginalPlace(umpireToMove, comingFrom);
} }
private removeFromOriginalPlace( async dropToServiceJudge(
umpireToMove: Umpire, event: CdkDragDrop<(Umpire | undefined)[], unknown, Umpire>,
comingFrom: string courtNo: number
): void { ): Promise<void> {
if ('list-waiting-service-judges' === comingFrom) { if (event.container.data.length) return;
this.waitingServiceJudgeService.removeByUmpireId(umpireToMove.id); await this.rotationService.moveToCourt(
} event.item.data.id,
'serviceJudge',
if ('list-waiting-umpires' === comingFrom) { courtNo
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);
}
} }
canDropUmpire = ( readonly canDropUmpire = (
drag: CdkDrag<Umpire>, _drag: CdkDrag<Umpire>,
drop: CdkDropList<Umpire[]> drop: CdkDropList<(Umpire | undefined)[]>
): boolean => { ): boolean => drop.data.length === 0;
return drop.data.length === 0;
};
public async showRemoveConfirmation(courtNo: number) { public async showRemoveConfirmation(courtNo: number) {
const umpire = this.umpireByCourt().get(courtNo); const umpire = this.umpireByCourt().get(courtNo);
@@ -445,7 +399,7 @@ export class Tab1Page {
} }
if (!this.settings()?.showAlert) { if (!this.settings()?.showAlert) {
this.removeUmpiresFromCourt(courtNo); await this.rotationService.rotate(courtNo, umpire.id, serviceJudge?.id);
return; return;
} }
@@ -472,14 +426,8 @@ export class Tab1Page {
{ {
text: this.translationService.translate('yes'), text: this.translationService.translate('yes'),
role: 'confirm', role: 'confirm',
handler: () => { handler: () =>
if (this.serviceJudgeEnabled()) { this.rotationService.rotate(courtNo, umpire.id, serviceJudge?.id)
this.removeUmpiresFromCourt(courtNo);
return;
}
this.removeUmpireFromCourt(courtNo);
}
} }
] ]
}); });
@@ -487,41 +435,11 @@ export class Tab1Page {
await alert.present(); 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) { public async showAddConfirmation(courtNo: number) {
const umpire = await this.waitingUmpireService.getCurrentUmpire(); const umpire = await this.waitingUmpireService.getCurrentUmpire();
const serviceJudge = const serviceJudge = this.serviceJudgeEnabled()
await this.waitingServiceJudgeService.getCurrentUmpire(); ? await this.waitingServiceJudgeService.getCurrentUmpire()
: undefined;
if ( if (
typeof umpire === 'undefined' || typeof umpire === 'undefined' ||
@@ -531,16 +449,16 @@ export class Tab1Page {
} }
if (!this.settings()?.showAlert) { if (!this.settings()?.showAlert) {
if (this.serviceJudgeEnabled() && serviceJudge) { await this.rotationService.assignNext(
this.addUmpiresToCourt(courtNo, umpire, serviceJudge); courtNo,
return; umpire.id,
} serviceJudge?.id
this.addUmpireToCourt(courtNo, umpire); );
return; return;
} }
const alert = await this.alertController.create({ const alert = await this.alertController.create({
header: this.translationService.translate('court', { header: this.translationService.translate('courtConfirm', {
courtNo courtNo
}), }),
@@ -566,37 +484,16 @@ export class Tab1Page {
{ {
text: this.translationService.translate('yes'), text: this.translationService.translate('yes'),
role: 'confirm', role: 'confirm',
handler: () => { handler: () =>
if (this.serviceJudgeEnabled() && serviceJudge) { this.rotationService.assignNext(
this.addUmpiresToCourt(courtNo, umpire, serviceJudge); courtNo,
} else { umpire.id,
this.addUmpireToCourt(courtNo, umpire); serviceJudge?.id
} )
}
} }
] ]
}); });
await alert.present(); 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);
}
} }
+8 -7
View File
@@ -1,8 +1,8 @@
<ion-header [translucent]="true"> <ion-header [translucent]="true">
<ion-toolbar> <ion-toolbar>
<ion-title> {{ 'courtOfficials' | translate }} </ion-title> <ion-title> {{ 'courtOfficials' | translate }} </ion-title>
<ion-buttons [slot]="'end'"> <ion-buttons slot="end">
<ion-button [color]="'primary'" [fill]="'solid'" id="open-modal"> <ion-button color="primary" fill="solid" (click)="modal.present()">
<ion-icon slot="start" name="add"></ion-icon> <ion-icon slot="start" name="add"></ion-icon>
{{ 'add' | translate }} {{ 'add' | translate }}
</ion-button> </ion-button>
@@ -13,7 +13,7 @@
<ion-content [fullscreen]="true"> <ion-content [fullscreen]="true">
<ion-header collapse="condense"> <ion-header collapse="condense">
<ion-toolbar> <ion-toolbar>
<ion-title size="large">Játékvezetők</ion-title> <ion-title size="large">{{ 'courtOfficials' | translate }}</ion-title>
</ion-toolbar> </ion-toolbar>
</ion-header> </ion-header>
@@ -25,8 +25,9 @@
<ion-item> <ion-item>
<ion-label> {{ umpire | fullname }} </ion-label> <ion-label> {{ umpire | fullname }} </ion-label>
<ion-button <ion-button
[color]="'danger'" color="danger"
slot="end" slot="end"
[attr.aria-label]="('confirmDeleteUmpire' | translate) + ': ' + (umpire | fullname)"
(click)="showDeleteConfirmation(umpire)"> (click)="showDeleteConfirmation(umpire)">
<ion-icon slot="icon-only" name="trash"></ion-icon> <ion-icon slot="icon-only" name="trash"></ion-icon>
</ion-button> </ion-button>
@@ -39,19 +40,19 @@
</ion-row> </ion-row>
</ion-grid> </ion-grid>
<ion-modal trigger="open-modal" (willDismiss)="onWillDismiss($event)"> <ion-modal [canDismiss]="!saving">
<ng-template> <ng-template>
<ion-header> <ion-header>
<ion-toolbar> <ion-toolbar>
<ion-buttons slot="start"> <ion-buttons slot="start">
<ion-button (click)="cancel()" <ion-button [disabled]="saving" (click)="cancel()"
>{{ 'cancel' | translate }}</ion-button >{{ 'cancel' | translate }}</ion-button
> >
</ion-buttons> </ion-buttons>
<ion-title>{{ 'newCourtOfficial' | translate }}</ion-title> <ion-title>{{ 'newCourtOfficial' | translate }}</ion-title>
<ion-buttons slot="end"> <ion-buttons slot="end">
<ion-button <ion-button
[disabled]="!isValidUmpire()" [disabled]="saving || !isValidUmpire()"
(click)="confirm()" (click)="confirm()"
[strong]="true"> [strong]="true">
{{ 'save' | translate }} {{ 'save' | translate }}
+61 -1
View File
@@ -1,18 +1,78 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { UmpireService } from '../services/umpire.service';
import { Tab2Page } from './tab2.page'; import { Tab2Page } from './tab2.page';
describe('Tab2Page', () => { describe('Tab2Page', () => {
let component: Tab2Page; let component: Tab2Page;
let fixture: ComponentFixture<Tab2Page>; let fixture: ComponentFixture<Tab2Page>;
beforeEach(async () => { beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{
provide: UmpireService,
useValue: { umpires: signal([]), create: async () => 1 }
}
]
});
fixture = TestBed.createComponent(Tab2Page); fixture = TestBed.createComponent(Tab2Page);
component = fixture.componentInstance; component = fixture.componentInstance;
document.body.appendChild(fixture.nativeElement);
fixture.detectChanges(); fixture.detectChanges();
}); });
afterEach(() => fixture.nativeElement.remove());
it('should create', () => { it('should create', () => {
expect(component).toBeTruthy(); 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<number>((_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('');
});
}); });
+28 -38
View File
@@ -28,7 +28,6 @@ import { UmpireService } from '../services/umpire.service';
import { Umpire } from 'db'; import { Umpire } from 'db';
import { addIcons } from 'ionicons'; import { addIcons } from 'ionicons';
import { add, trash } from 'ionicons/icons'; import { add, trash } from 'ionicons/icons';
import { OverlayEventDetail } from '@ionic/core/components';
import { FormsModule } from '@angular/forms'; import { FormsModule } from '@angular/forms';
import { FullnamePipe } from '../fullname-pipe'; import { FullnamePipe } from '../fullname-pipe';
import { TranslatePipe } from '../i18n/translation.pipe'; import { TranslatePipe } from '../i18n/translation.pipe';
@@ -75,12 +74,14 @@ export class Tab2Page {
public readonly umpires = this.umpireService.umpires; public readonly umpires = this.umpireService.umpires;
saving = false;
public lastName: string = ''; public lastName: string = '';
public firstName: string = ''; public firstName: string = '';
public country: string = ''; public country: string = '';
public gender: string = ''; public gender: string = '';
public genders: { label: TranslationKey; code: string }[] = [ public readonly genders: { label: TranslationKey; code: string }[] = [
{ label: 'male', code: 'M' }, { label: 'male', code: 'M' },
{ label: 'female', code: 'W' } { label: 'female', code: 'W' }
]; ];
@@ -89,36 +90,32 @@ export class Tab2Page {
addIcons({ add, trash }); addIcons({ add, trash });
} }
public async onWillDismiss(event: CustomEvent<OverlayEventDetail>) { cancel(): Promise<boolean> {
if (event.detail.role !== 'confirm') { return this.modal.dismiss(null, 'cancel');
return;
}
const newUmpire: Umpire = event.detail.data;
await this.umpireService.create({
firstName: newUmpire.firstName,
lastName: newUmpire.lastName,
country: newUmpire.country,
gender: newUmpire.gender
});
} }
cancel() { async confirm(): Promise<void> {
this.modal.dismiss(null, 'cancel'); if (this.saving || !this.isValidUmpire()) return;
} this.saving = true;
try {
async confirm() { await this.umpireService.create({
await this.modal.dismiss( lastName: this.lastName.trim(),
{ firstName: this.firstName.trim(),
lastName: this.lastName, country: this.country.trim(),
firstName: this.firstName,
country: this.country,
gender: this.gender 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.lastName = '';
this.firstName = ''; this.firstName = '';
this.country = ''; this.country = '';
@@ -126,7 +123,7 @@ export class Tab2Page {
} }
isValidUmpire(): boolean { isValidUmpire(): boolean {
return this.lastName !== '' && this.firstName !== ''; return this.lastName.trim().length > 0 && this.firstName.trim().length > 0;
} }
public async showDeleteConfirmation(umpire: Umpire): Promise<void> { public async showDeleteConfirmation(umpire: Umpire): Promise<void> {
@@ -142,18 +139,11 @@ export class Tab2Page {
{ {
text: this.translationService.translate('yes'), text: this.translationService.translate('yes'),
role: 'confirm', role: 'confirm',
handler: () => { handler: () => this.umpireService.delete(umpire.id)
// TODO: also remove from first tab
this.removeUmpire(umpire);
}
} }
] ]
}); });
await alert.present(); await alert.present();
} }
private removeUmpire(umpire: Umpire): void {
this.umpireService.delete(umpire.id);
}
} }
+2 -1
View File
@@ -65,7 +65,8 @@
min="1" min="1"
labelPlacement="floating" labelPlacement="floating"
[value]="currentSettings.numberOfCourts" [value]="currentSettings.numberOfCourts"
(ionInput)="updateNumberOfCourts($event)"> step="1"
(ionChange)="updateNumberOfCourts($event)">
</ion-input> </ion-input>
</ion-item> </ion-item>
<ion-item> <ion-item>
+8 -1
View File
@@ -1,12 +1,19 @@
import { ComponentFixture, TestBed } from '@angular/core/testing'; import { ComponentFixture, TestBed } from '@angular/core/testing';
import { signal } from '@angular/core';
import { SettingsService } from '../services/settings-service';
import { Tab3Page } from './tab3.page'; import { Tab3Page } from './tab3.page';
describe('Tab3Page', () => { describe('Tab3Page', () => {
let component: Tab3Page; let component: Tab3Page;
let fixture: ComponentFixture<Tab3Page>; let fixture: ComponentFixture<Tab3Page>;
beforeEach(async () => { beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: SettingsService, useValue: { settings: signal(undefined) } }
]
});
fixture = TestBed.createComponent(Tab3Page); fixture = TestBed.createComponent(Tab3Page);
component = fixture.componentInstance; component = fixture.componentInstance;
fixture.detectChanges(); fixture.detectChanges();
+12 -44
View File
@@ -16,12 +16,13 @@ import {
IonLabel IonLabel
} from '@ionic/angular'; } from '@ionic/angular';
import { SettingsService } from '../services/settings-service'; 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 { TranslationService } from '../i18n/translation.service';
import { Language } from '../i18n/translations'; import { Language } from '../i18n/translations';
import type {
InputCustomEvent,
SelectCustomEvent,
ToggleCustomEvent
} from '@ionic/angular';
import { TranslatePipe } from '../i18n/translation.pipe'; import { TranslatePipe } from '../i18n/translation.pipe';
import { ThemePreference, ThemeService } from '../services/theme.service'; import { ThemePreference, ThemeService } from '../services/theme.service';
@@ -50,74 +51,41 @@ import { ThemePreference, ThemeService } from '../services/theme.service';
}) })
export class Tab3Page { export class Tab3Page {
readonly settingsService = inject(SettingsService); readonly settingsService = inject(SettingsService);
readonly courtUmpireService = inject(CourtUmpireService);
readonly courtServiceJudgeService = inject(CourtServiceJudgeService);
readonly waitingServiceJudgeService = inject(WaitingServiceJudgesService);
readonly translationService = inject(TranslationService); readonly translationService = inject(TranslationService);
readonly themeService = inject(ThemeService); readonly themeService = inject(ThemeService);
readonly settings = this.settingsService.settings; readonly settings = this.settingsService.settings;
async updateNumberOfCourts(event: CustomEvent): Promise<void> { async updateNumberOfCourts(event: InputCustomEvent): Promise<void> {
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);
});
await this.settingsService.update({ await this.settingsService.update({
numberOfCourts: Number(event.detail.value) numberOfCourts: Number(event.detail.value)
}); });
} }
async updateWithServiceJudge(event: CustomEvent): Promise<void> { async updateWithServiceJudge(event: ToggleCustomEvent): Promise<void> {
await this.settingsService.update({ await this.settingsService.update({
withServiceJudge: event.detail.checked 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( async updateWaitingListQuickButtons(event: ToggleCustomEvent): Promise<void> {
event: CustomEvent<{ checked: boolean }>
): Promise<void> {
await this.settingsService.update({ await this.settingsService.update({
waitingListQuickButtons: event.detail.checked waitingListQuickButtons: event.detail.checked
}); });
} }
async updateShowAlert(event: CustomEvent): Promise<void> { async updateShowAlert(event: ToggleCustomEvent): Promise<void> {
await this.settingsService.update({ await this.settingsService.update({
showAlert: event.detail.checked showAlert: event.detail.checked
}); });
} }
updateTheme(event: CustomEvent<{ value: ThemePreference }>): void { updateTheme(event: SelectCustomEvent<ThemePreference>): void {
this.themeService.setTheme(event.detail.value); this.themeService.setTheme(event.detail.value);
} }
updateLanguage(event: CustomEvent): void { updateLanguage(event: SelectCustomEvent<Language>): void {
this.translationService.setLanguage(event.detail.value as Language); this.translationService.setLanguage(event.detail.value);
} }
} }
+1 -8
View File
@@ -1,9 +1,4 @@
import { import { Component, ChangeDetectionStrategy } from '@angular/core';
Component,
EnvironmentInjector,
inject,
ChangeDetectionStrategy
} from '@angular/core';
import { import {
IonTabs, IonTabs,
IonTabBar, IonTabBar,
@@ -23,8 +18,6 @@ import { TranslatePipe } from '../i18n/translation.pipe';
imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, TranslatePipe] imports: [IonTabs, IonTabBar, IonTabButton, IonIcon, IonLabel, TranslatePipe]
}) })
export class TabsPage { export class TabsPage {
public environmentInjector = inject(EnvironmentInjector);
constructor() { constructor() {
addIcons({ gridOutline, peopleOutline, settingsOutline }); addIcons({ gridOutline, peopleOutline, settingsOutline });
} }
+14 -19
View File
@@ -10,20 +10,20 @@
*/ */
/* Core CSS required for Ionic components to work properly */ /* 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 */ /* Basic CSS for apps built with Ionic */
@import "@ionic/angular/css/normalize.css"; @import '@ionic/angular/css/normalize.css';
@import "@ionic/angular/css/structure.css"; @import '@ionic/angular/css/structure.css';
@import "@ionic/angular/css/typography.css"; @import '@ionic/angular/css/typography.css';
@import "@ionic/angular/css/display.css"; @import '@ionic/angular/css/display.css';
/* Optional CSS utils that can be commented out */ /* Optional CSS utils that can be commented out */
@import "@ionic/angular/css/padding.css"; @import '@ionic/angular/css/padding.css';
@import "@ionic/angular/css/float-elements.css"; @import '@ionic/angular/css/float-elements.css';
@import "@ionic/angular/css/text-alignment.css"; @import '@ionic/angular/css/text-alignment.css';
@import "@ionic/angular/css/text-transformation.css"; @import '@ionic/angular/css/text-transformation.css';
@import "@ionic/angular/css/flex-utils.css"; @import '@ionic/angular/css/flex-utils.css';
/** /**
* Ionic Dark Mode * Ionic Dark Mode
@@ -34,12 +34,7 @@
@import '@ionic/angular/css/palettes/dark.class.css'; @import '@ionic/angular/css/palettes/dark.class.css';
.wide.sc-ion-alert-md-h { ion-alert.wide {
.sc-ion-alert-md { --backdrop-opacity: 0.8;
--backdrop-opacity: 0.8; --min-width: min(400px, calc(100vw - 32px));
} }
&.sc-ion-alert-md-h {
--min-width: 400px;
}
}
+28 -20
View File
@@ -1,27 +1,35 @@
<!DOCTYPE html> <!doctype html>
<html lang="en"> <html lang="en">
<head>
<meta charset="utf-8" />
<title>Court Pilot</title>
<head> <base href="/" />
<meta charset="utf-8" />
<title>Court Pilot</title>
<base href="/" /> <meta name="color-scheme" content="light dark" />
<meta
name="viewport"
content="viewport-fit=cover, width=device-width, initial-scale=1.0" />
<meta name="format-detection" content="telephone=no" />
<meta name="msapplication-tap-highlight" content="no" />
<meta name="color-scheme" content="light dark" /> <link
<meta name="viewport" content="viewport-fit=cover, width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" /> rel="icon"
<meta name="format-detection" content="telephone=no" /> type="image/png"
<meta name="msapplication-tap-highlight" content="no" /> sizes="32x32"
href="assets/icon/favicon-32x32.png" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="assets/icon/favicon-16x16.png" />
<link rel="icon" type="image/png" sizes="32x32" href="assets/icon/favicon-32x32.png"> <!-- add to homescreen for ios -->
<link rel="icon" type="image/png" sizes="16x16" href="assets/icon/favicon-16x16.png"> <meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
<!-- add to homescreen for ios --> </head>
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</head>
<body>
<app-root></app-root>
</body>
<body>
<app-root></app-root>
</body>
</html> </html>
+3 -1
View File
@@ -18,4 +18,6 @@ bootstrapApplication(AppComponent, {
provideIonicAngular(), provideIonicAngular(),
provideRouter(routes, withPreloading(PreloadAllModules)) provideRouter(routes, withPreloading(PreloadAllModules))
] ]
}); }).catch((error: unknown) =>
console.error('Application bootstrap failed:', error)
);
+3 -1
View File
@@ -2,4 +2,6 @@
* Prevents Angular change detection from * Prevents Angular change detection from
* running with certain Web Component callbacks * running with certain Web Component callbacks
*/ */
(window as any).__Zone_disable_customElements = true; (
window as Window & { __Zone_disable_customElements?: boolean }
).__Zone_disable_customElements = true;
+1 -9
View File
@@ -6,13 +6,5 @@
"types": [] "types": []
}, },
"files": ["src/main.ts", "src/polyfills.ts"], "files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"], "include": ["src/**/*.d.ts"]
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
} }