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
+1021 -482

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()`.
`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/`?
+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 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'] }
+3 -6
View File
@@ -26,7 +26,7 @@ export class AppComponent {
readonly themeService = inject(ThemeService);
constructor() {
this.checkForUpdates();
void this.checkForUpdates();
}
private async checkForUpdates(): Promise<void> {
@@ -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) {
+1 -1
View File
@@ -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 '';
}
+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 {
translations,
Language,
@@ -10,12 +11,21 @@ import {
providedIn: 'root'
})
export class TranslationService {
private readonly document = inject(DOCUMENT);
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 {
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;
}
+2 -3
View File
@@ -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',
@@ -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();
});
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({
@@ -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<CourtServiceJudge | undefined>(
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<CourtServiceJudge, 'id'>): Promise<number> {
return db.courtServiceJudges.add(item as CourtServiceJudge);
return db.courtServiceJudges.add({ ...item });
}
async update(
@@ -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({
+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 { 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<CourtUmpire | undefined>(
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<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> {
+58 -6
View File
@@ -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();
});
});
+36 -14
View File
@@ -23,24 +23,46 @@ export class SettingsService {
* Update settings
*/
async update(changes: Partial<Omit<Settings, 'id'>>): Promise<void> {
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
});
}
});
}
}
+37 -2
View File
@@ -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' });
+44 -5
View File
@@ -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<Umpire, 'id'>): Promise<number> {
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<void> {
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<void> {
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
*/
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 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) {
if (toPosition !== undefined)
await this.moveToPosition(serviceJudgeId, toPosition);
}
return id;
});
}
/**
* Remove from queue
*/
async remove(id: number): Promise<void> {
await db.transaction('rw', db.waitingServiceJudges, async () => {
await db.waitingServiceJudges.delete(id);
await this.reinitOrders();
});
}
async removeByUmpireId(umpireId: number): Promise<void> {
@@ -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<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;
}
+12 -17
View File
@@ -22,31 +22,26 @@ export class WaitingUmpiresService {
* Add new umpire to queue with max(order) + 1
*/
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 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) {
if (toPosition !== undefined)
await this.moveToPosition(umpireId, toPosition);
}
return id;
});
}
/**
* Remove from queue
*/
async remove(id: number): Promise<void> {
await db.transaction('rw', db.waitingUmpires, async () => {
await db.waitingUmpires.delete(id);
await this.reinitOrders();
});
}
async removeByUmpireId(umpireId: number): Promise<void> {
@@ -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<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;
}
+39 -39
View File
@@ -11,16 +11,16 @@
</ion-toolbar>
</ion-header>
<ion-grid [fixed]="'fixed'" class="bottom-lists">
<ion-grid [fixed]="true" class="bottom-lists">
<ion-row class="court-headers">
<ion-col [size]="1" class="ion-display-none ion-display-xl-block">
<ion-item [lines]="'none'">{{ 'court' | translate }}</ion-item>
<ion-col size="1" class="ion-display-none ion-display-xl-block">
<ion-item lines="none">{{ 'court' | translate }}</ion-item>
</ion-col>
<ion-col [size]="1" class="ion-display-xl-none">
<ion-item [lines]="'none'">{{ 'shortCourtName' | translate }}</ion-item>
<ion-col size="1" class="ion-display-xl-none">
<ion-item lines="none">{{ 'shortCourtName' | translate }}</ion-item>
</ion-col>
<ion-col>
<ion-item [lines]="'none'">
<ion-item lines="none">
<ion-label class="ion-text-center">
{{ 'umpire' | translate }}
</ion-label>
@@ -28,7 +28,7 @@
</ion-col>
@if (serviceJudgeEnabled()) {
<ion-col>
<ion-item [lines]="'none'">
<ion-item lines="none">
<ion-label class="ion-text-center">
{{ 'serviceJudge' | translate }}
</ion-label>
@@ -37,24 +37,22 @@
}
<ion-col size="2" sizeLg="1"></ion-col>
</ion-row>
@for (item of
[].constructor($safeNavigationMigration(settings()?.numberOfCourts)); track
$index) {
@for (courtNo of courtIndexes(); track courtNo) {
<ion-row>
<ion-col [size]="1" class="court-number">
<ion-col size="1" class="court-number">
<ion-list>
<ion-item [lines]="'none'"> {{ $index + 1 }}. </ion-item>
<ion-item lines="none"> {{ courtNo }}. </ion-item>
</ion-list>
</ion-col>
<ion-col>
<ion-list
[lines]="'none'"
lines="none"
cdkDropList
[id]="`court-umpire-${$index + 1}`"
[cdkDropListData]="umpireByCourt().get($index + 1) ? [umpireByCourt().get($index + 1)] : []"
[id]="`court-umpire-${courtNo}`"
[cdkDropListData]="umpireByCourt().get(courtNo) ? [umpireByCourt().get(courtNo)] : []"
[cdkDropListEnterPredicate]="canDropUmpire"
(cdkDropListDropped)="dropToUmpire($event, $index + 1)">
@if (umpireByCourt().get($index + 1); as umpire) {
(cdkDropListDropped)="dropToUmpire($event, courtNo)">
@if (umpireByCourt().get(courtNo); as umpire) {
<div class="custom-ion-item" cdkDrag [cdkDragData]="umpire">
<div *cdkDragPlaceholder class="custom-ion-item primary">
@@ -69,12 +67,13 @@
@if (serviceJudgeEnabled()) {
<ion-col>
<ion-list
[lines]="'none'"
lines="none"
cdkDropList
[id]="`court-service-judge-${$index + 1}`"
[cdkDropListData]="serviceJudgeByCourt().get($index + 1) ? [serviceJudgeByCourt().get($index + 1)] : []"
(cdkDropListDropped)="dropToServiceJudge($event, $index + 1)">
@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) {
<div class="custom-ion-item" cdkDrag [cdkDragData]="umpire">
<div *cdkDragPlaceholder class="custom-ion-item primary">
@@ -90,25 +89,26 @@
<ion-col size="2" sizeLg="1" class="court-actions">
<ion-list>
<ion-item lines="none">
@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()) ) {
<ion-button
[color]="'danger'"
(click)="showRemoveConfirmation($index + 1)">
color="danger"
[attr.aria-label]="('removeUmpireFromCourt' | translate) + ': ' + courtNo"
(click)="showRemoveConfirmation(courtNo)">
<ion-icon
slot="icon-only"
size="large"
name="exit-outline"></ion-icon>
</ion-button>
} @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())) {
<ion-button
[color]="'success'"
[disabled]=""
(click)="showAddConfirmation($index + 1)">
color="success"
[attr.aria-label]="('sendUmpireToCourt' | translate) + ': ' + courtNo"
(click)="showAddConfirmation(courtNo)">
<ion-icon
slot="icon-only"
size="large"
@@ -121,7 +121,7 @@
</ion-row>
}
</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-col [size]="!serviceJudgeEnabled() ? 6 : 4">
<ion-item color="primary">
@@ -131,7 +131,7 @@
</ion-item>
</ion-col>
@if (serviceJudgeEnabled()) {
<ion-col [size]="4">
<ion-col size="4">
<ion-item color="primary">
<ion-label class="ion-text-center">
{{ 'serviceJudges' | translate }}
@@ -150,7 +150,7 @@
<ion-row>
<ion-col>
<ion-list
[lines]="'none'"
lines="none"
cdkDropList
id="list-waiting-umpires"
[cdkDropListData]="waitingUmpires()"
@@ -180,7 +180,7 @@
@if (serviceJudgeEnabled()) {
<ion-col>
<ion-list
[lines]="'none'"
lines="none"
cdkDropList
id="list-waiting-service-judges"
[cdkDropListData]="waitingServiceJudges()"
@@ -210,7 +210,7 @@
}
<ion-col>
<ion-list
[lines]="'none'"
lines="none"
cdkDropList
cdkDropListSortingDisabled
id="list-on-rest"
+30 -38
View File
@@ -4,6 +4,7 @@ import { signal } from '@angular/core';
import { AlertController } from '@ionic/angular';
import { CourtServiceJudge, CourtUmpire, Settings, Umpire } from 'db';
import { WaitingDutyService } from '../services/waiting-duty.service';
import { CourtRotationService } from '../services/court-rotation.service';
import { Tab1Page } from './tab1.page';
import { SettingsService } from '../services/settings-service';
import { UmpireService } from '../services/umpire.service';
@@ -37,6 +38,7 @@ describe('Tab1Page court rotation', () => {
let umpireCourt: jasmine.SpyObj<CourtUmpireService>;
let judgeCourt: jasmine.SpyObj<CourtServiceJudgeService>;
let duty: jasmine.SpyObj<WaitingDutyService>;
let rotation: jasmine.SpyObj<CourtRotationService>;
let alerts: jasmine.SpyObj<AlertController>;
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 }]);
+72 -175
View File
@@ -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<Umpire[]>): Promise<void> {
if (event.previousContainer === event.container) {
return;
} else {
const comingFrom = event.previousContainer.id;
const umpireToMove = event.item.data;
async dropToRest(
event: CdkDragDrop<Umpire[], Umpire[], Umpire>
): Promise<void> {
if (event.previousContainer === event.container) return;
const source = event.previousContainer.id;
if (
comingFrom === 'list-waiting-umpires' ||
comingFrom === 'list-waiting-service-judges'
source === 'list-waiting-umpires' ||
source === 'list-waiting-service-judges'
) {
await this.waitingDutyService.moveToRest(umpireToMove.id);
return;
}
this.removeFromOriginalPlace(umpireToMove, comingFrom);
}
}
dropToWaitingServiceJudge(event: CdkDragDrop<Umpire[]>) {
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,
event.currentIndex + 1
);
this.removeFromOriginalPlace(umpireToMove, comingFrom);
}
dropToWaitingUmpire(event: CdkDragDrop<Umpire[]>) {
const umpireToMove = event.item.data;
if (event.previousContainer === event.container) {
this.waitingUmpireService.moveToPosition(
umpireToMove.id,
event.currentIndex + 1
);
return;
await this.waitingDutyService.moveToRest(event.item.data.id);
} else {
const comingFrom = event.previousContainer.id;
this.waitingUmpireService.add(umpireToMove.id, event.currentIndex + 1);
this.removeFromOriginalPlace(umpireToMove, comingFrom);
await this.rotationService.moveToRest(event.item.data.id);
}
}
dropToUmpire(event: CdkDragDrop<(Umpire | undefined)[]>, courtNo: number) {
const targetUmpires = event.container.data;
if (targetUmpires.length > 0) {
return;
async dropToWaitingServiceJudge(
event: CdkDragDrop<Umpire[], Umpire[], Umpire>
): Promise<void> {
await this.rotationService.moveToQueue(
event.item.data.id,
'serviceJudge',
event.currentIndex + 1
);
}
const comingFrom = event.previousContainer.id;
const umpireToMove = event.item.data;
this.courtUmpireService.save({ umpireId: umpireToMove.id, courtNo });
this.removeFromOriginalPlace(umpireToMove, comingFrom);
async dropToWaitingUmpire(
event: CdkDragDrop<Umpire[], Umpire[], Umpire>
): Promise<void> {
await this.rotationService.moveToQueue(
event.item.data.id,
'umpire',
event.currentIndex + 1
);
}
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;
): Promise<void> {
if (event.container.data.length) return;
await this.rotationService.moveToCourt(
event.item.data.id,
'umpire',
courtNo
);
}
const comingFrom = event.previousContainer.id;
const umpireToMove = event.item.data;
this.courtServiceJudgeService.save({ umpireId: umpireToMove.id, courtNo });
this.removeFromOriginalPlace(umpireToMove, comingFrom);
async dropToServiceJudge(
event: CdkDragDrop<(Umpire | undefined)[], unknown, Umpire>,
courtNo: number
): Promise<void> {
if (event.container.data.length) return;
await this.rotationService.moveToCourt(
event.item.data.id,
'serviceJudge',
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);
}
}
canDropUmpire = (
drag: CdkDrag<Umpire>,
drop: CdkDropList<Umpire[]>
): boolean => {
return drop.data.length === 0;
};
readonly canDropUmpire = (
_drag: CdkDrag<Umpire>,
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);
}
}
+8 -7
View File
@@ -1,8 +1,8 @@
<ion-header [translucent]="true">
<ion-toolbar>
<ion-title> {{ 'courtOfficials' | translate }} </ion-title>
<ion-buttons [slot]="'end'">
<ion-button [color]="'primary'" [fill]="'solid'" id="open-modal">
<ion-buttons slot="end">
<ion-button color="primary" fill="solid" (click)="modal.present()">
<ion-icon slot="start" name="add"></ion-icon>
{{ 'add' | translate }}
</ion-button>
@@ -13,7 +13,7 @@
<ion-content [fullscreen]="true">
<ion-header collapse="condense">
<ion-toolbar>
<ion-title size="large">Játékvezetők</ion-title>
<ion-title size="large">{{ 'courtOfficials' | translate }}</ion-title>
</ion-toolbar>
</ion-header>
@@ -25,8 +25,9 @@
<ion-item>
<ion-label> {{ umpire | fullname }} </ion-label>
<ion-button
[color]="'danger'"
color="danger"
slot="end"
[attr.aria-label]="('confirmDeleteUmpire' | translate) + ': ' + (umpire | fullname)"
(click)="showDeleteConfirmation(umpire)">
<ion-icon slot="icon-only" name="trash"></ion-icon>
</ion-button>
@@ -39,19 +40,19 @@
</ion-row>
</ion-grid>
<ion-modal trigger="open-modal" (willDismiss)="onWillDismiss($event)">
<ion-modal [canDismiss]="!saving">
<ng-template>
<ion-header>
<ion-toolbar>
<ion-buttons slot="start">
<ion-button (click)="cancel()"
<ion-button [disabled]="saving" (click)="cancel()"
>{{ 'cancel' | translate }}</ion-button
>
</ion-buttons>
<ion-title>{{ 'newCourtOfficial' | translate }}</ion-title>
<ion-buttons slot="end">
<ion-button
[disabled]="!isValidUmpire()"
[disabled]="saving || !isValidUmpire()"
(click)="confirm()"
[strong]="true">
{{ 'save' | translate }}
+61 -1
View File
@@ -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<Tab2Page>;
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<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('');
});
});
+27 -37
View File
@@ -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<OverlayEventDetail>) {
if (event.detail.role !== 'confirm') {
return;
cancel(): Promise<boolean> {
return this.modal.dismiss(null, 'cancel');
}
const newUmpire: Umpire = event.detail.data;
async confirm(): Promise<void> {
if (this.saving || !this.isValidUmpire()) return;
this.saving = true;
try {
await this.umpireService.create({
firstName: newUmpire.firstName,
lastName: newUmpire.lastName,
country: newUmpire.country,
gender: newUmpire.gender
});
}
cancel() {
this.modal.dismiss(null, 'cancel');
}
async confirm() {
await this.modal.dismiss(
{
lastName: this.lastName,
firstName: this.firstName,
country: this.country,
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<void> {
@@ -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);
}
}
+2 -1
View File
@@ -65,7 +65,8 @@
min="1"
labelPlacement="floating"
[value]="currentSettings.numberOfCourts"
(ionInput)="updateNumberOfCourts($event)">
step="1"
(ionChange)="updateNumberOfCourts($event)">
</ion-input>
</ion-item>
<ion-item>
+8 -1
View File
@@ -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<Tab3Page>;
beforeEach(async () => {
beforeEach(() => {
TestBed.configureTestingModule({
providers: [
{ provide: SettingsService, useValue: { settings: signal(undefined) } }
]
});
fixture = TestBed.createComponent(Tab3Page);
component = fixture.componentInstance;
fixture.detectChanges();
+12 -44
View File
@@ -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<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);
});
async updateNumberOfCourts(event: InputCustomEvent): Promise<void> {
await this.settingsService.update({
numberOfCourts: Number(event.detail.value)
});
}
async updateWithServiceJudge(event: CustomEvent): Promise<void> {
async updateWithServiceJudge(event: ToggleCustomEvent): Promise<void> {
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<void> {
async updateWaitingListQuickButtons(event: ToggleCustomEvent): Promise<void> {
await this.settingsService.update({
waitingListQuickButtons: event.detail.checked
});
}
async updateShowAlert(event: CustomEvent): Promise<void> {
async updateShowAlert(event: ToggleCustomEvent): Promise<void> {
await this.settingsService.update({
showAlert: event.detail.checked
});
}
updateTheme(event: CustomEvent<{ value: ThemePreference }>): void {
updateTheme(event: SelectCustomEvent<ThemePreference>): void {
this.themeService.setTheme(event.detail.value);
}
updateLanguage(event: CustomEvent): void {
this.translationService.setLanguage(event.detail.value as Language);
updateLanguage(event: SelectCustomEvent<Language>): void {
this.translationService.setLanguage(event.detail.value);
}
}
+1 -8
View File
@@ -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 });
}
+12 -17
View File
@@ -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 {
ion-alert.wide {
--backdrop-opacity: 0.8;
}
&.sc-ion-alert-md-h {
--min-width: 400px;
}
--min-width: min(400px, calc(100vw - 32px));
}
+18 -10
View File
@@ -1,27 +1,35 @@
<!DOCTYPE html>
<!doctype html>
<html lang="en">
<head>
<head>
<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, minimum-scale=1.0, maximum-scale=1.0, user-scalable=no" />
<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" />
<link rel="icon" type="image/png" 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" />
<link
rel="icon"
type="image/png"
sizes="16x16"
href="assets/icon/favicon-16x16.png" />
<!-- add to homescreen for ios -->
<meta name="mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black" />
</head>
</head>
<body>
<body>
<app-root></app-root>
</body>
</body>
</html>
+3 -1
View File
@@ -18,4 +18,6 @@ bootstrapApplication(AppComponent, {
provideIonicAngular(),
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
* 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": []
},
"files": ["src/main.ts", "src/polyfills.ts"],
"include": ["src/**/*.d.ts"],
"angularCompilerOptions": {
"extendedDiagnostics": {
"checks": {
"nullishCoalescingNotNullable": "suppress",
"optionalChainNotNullable": "suppress"
}
}
}
"include": ["src/**/*.d.ts"]
}