fix: apply Angular and Ionic audit improvements
This commit is contained in:
1 parent
9739ea55c6
commit
8dad54fcf1
35 files changed
+1042
-503
No files matched your search
+17
-2
@@ -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/`?
|
||||||
@@ -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
@@ -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'] }
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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 '';
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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({
|
||||||
|
|||||||
@@ -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> {
|
||||||
|
|||||||
@@ -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();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -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
|
||||||
});
|
});
|
||||||
}
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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' });
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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"
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
@@ -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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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
@@ -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,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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user