diff --git a/db.ts b/db.ts index b4c6157..acf2563 100644 --- a/db.ts +++ b/db.ts @@ -1,5 +1,7 @@ import Dexie, { type EntityTable } from 'dexie'; +export type WaitingRole = 'umpire' | 'serviceJudge'; + export interface Umpire { id: number; firstName: string; @@ -7,6 +9,7 @@ export interface Umpire { country: string; gender: string; courtNo?: number; + returnToQueue?: WaitingRole; } export interface CourtUmpire { @@ -38,6 +41,7 @@ export interface Settings { withServiceJudge: boolean; numberOfCourts: number; showAlert: boolean; + waitingListQuickButtons?: boolean; } const db = new Dexie('CourtPilot') as Dexie & { @@ -58,4 +62,20 @@ db.version(1).stores({ waitingServiceJudges: '++id, order, serviceJudgeId' }); +db.version(2) + .stores({ + umpires: '++id, lastName', + courtUmpires: '++id, courtNo, umpireId', + courtServiceJudges: '++id, courtNo, umpireId', + settings: '++id', + waitingUmpires: '++id, order, umpireId', + waitingServiceJudges: '++id, order, serviceJudgeId' + }) + .upgrade(async (transaction) => { + await transaction + .table('settings') + .toCollection() + .modify({ waitingListQuickButtons: false }); + }); + export { db }; diff --git a/docs/architecture.md b/docs/architecture.md index 555e3fe..3453314 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -21,11 +21,11 @@ flowchart TD The root route lazy-loads the tab routes. The three primary routes are: -| Route | Source | Responsibility | -|---|---|---| -| `/tabs/pilot` | `src/app/tab1/` | Waiting queues, court assignments, drag and drop, and rotation actions | -| `/tabs/umpires` | `src/app/tab2/` | Court-official roster management | -| `/tabs/settings` | `src/app/tab3/` | Court count, theme, service-judge mode, alerts, and language | +| Route | Source | Responsibility | +| ---------------- | --------------- | ---------------------------------------------------------------------- | +| `/tabs/pilot` | `src/app/tab1/` | Waiting queues, court assignments, drag and drop, and rotation actions | +| `/tabs/umpires` | `src/app/tab2/` | Court-official roster management | +| `/tabs/settings` | `src/app/tab3/` | Court count, theme, service-judge mode, alerts, and language | Pages are standalone Angular components and directly import the Ionic standalone components they use. Services are root-provided and injected with Angular's `inject()` function. @@ -37,7 +37,7 @@ Persistence services use Dexie's `liveQuery()` and Angular's `toSignal()` to exp ## Persistence model -`db.ts` declares a `CourtPilot` Dexie database at schema version 1. +`db.ts` declares a `CourtPilot` Dexie database at schema version 2. ```mermaid erDiagram @@ -85,6 +85,21 @@ component starts and toggles Ionic's `ion-palette-dark` class. Device theme chan apply while the app is open when device-preferred is selected. This does not change the Dexie schema or existing settings records. +### Waiting-list quick buttons + +The optional waiting-list quick buttons default to disabled and require service +judges. Disabling service judges disables this setting. Both waiting lists have +an off-duty action; off-duty officials with a saved origin have a return action. Quick-button alerts +apply to both actions. Dragging an official from either waiting list to Not on Duty +also saves the origin, even when quick buttons are disabled. Returning appends to the saved role queue. + +Schema version 2 adds `waitingListQuickButtons` to settings and optional +`returnToQueue` metadata to officials. The upgrade explicitly sets the setting to +false for existing settings rows. Existing officials need no transformation; +officials without return metadata have no return action. Queue indexes and +existing records are preserved. Older application versions do not understand the +new metadata; downgrading an IndexedDB database is not supported. + ### Schema-change policy Once data may exist in released clients, do not modify schema version 1 in place. Add a new `db.version(n)` declaration and define upgrade behavior when existing records need transformation. diff --git a/src/app/i18n/translations.ts b/src/app/i18n/translations.ts index a52e544..0e67802 100644 --- a/src/app/i18n/translations.ts +++ b/src/app/i18n/translations.ts @@ -9,6 +9,14 @@ export const translations = { themeLight: 'Light', themeDark: 'Dark', themeSystem: 'Device-preferred', + waitingListQuickButtons: 'Quick buttons for waiting lists', + waitingListQuickButtonsHelp: + 'Move waiting officials off duty and return them to the end of their previous list. Only officials with a saved list have a return button. Requires Service Judge to be enabled.', + moveOffDuty: 'Move off duty', + returnToQueue: 'Return to waiting list', + confirmMoveOffDuty: 'Move this official to Not on Duty?', + confirmReturnToQueue: + 'Return this official to the bottom of the waiting list?', showAlerts: 'Show alerts on quick buttons', serviceJudge: 'Service Judge', language: 'Language', @@ -62,6 +70,14 @@ export const translations = { themeLight: 'Világos', themeDark: 'Sötét', themeSystem: 'Eszközbeállítás szerint', + waitingListQuickButtons: 'Gyors gombok a várólistákhoz', + waitingListQuickButtonsHelp: + 'A várakozó tisztségviselőket pihenőre küldheted, majd visszaküldheted őket az előző várólistájuk végére. A visszaküldés gomb csak mentett várólista esetén jelenik meg. Az Adogatásbíró beállítás bekapcsolása szükséges.', + moveOffDuty: 'Pihenőre küldés', + returnToQueue: 'Vissza a várólistára', + confirmMoveOffDuty: 'Pihenőre küldöd ezt a tisztségviselőt?', + confirmReturnToQueue: + 'Visszaküldöd ezt a tisztségviselőt a várólista végére?', showAlerts: 'Figyelmeztetések megjelenítése a gyors gomboknál', serviceJudge: 'Adogatásbíró', language: 'Nyelv', diff --git a/src/app/services/settings-service.ts b/src/app/services/settings-service.ts index f7eaeba..1b453b1 100644 --- a/src/app/services/settings-service.ts +++ b/src/app/services/settings-service.ts @@ -29,7 +29,12 @@ export class SettingsService { return; } - await db.settings.update(1, changes); + await db.settings.update(1, { + ...changes, + ...(!(changes.withServiceJudge ?? current.withServiceJudge) + ? { waitingListQuickButtons: false } + : {}) + }); } private async ensureSettings() { @@ -40,7 +45,8 @@ export class SettingsService { id: 1, numberOfCourts: 5, withServiceJudge: true, - showAlert: true + showAlert: true, + waitingListQuickButtons: false }); } } diff --git a/src/app/services/waiting-duty.service.spec.ts b/src/app/services/waiting-duty.service.spec.ts new file mode 100644 index 0000000..252d6b6 --- /dev/null +++ b/src/app/services/waiting-duty.service.spec.ts @@ -0,0 +1,95 @@ +import { TestBed } from '@angular/core/testing'; +import { db } from 'db'; +import { WaitingDutyService } from './waiting-duty.service'; + +describe('WaitingDutyService', () => { + let service: WaitingDutyService; + beforeEach(async () => { + await Promise.all([ + db.settings.clear(), + db.umpires.clear(), + db.waitingUmpires.clear(), + db.waitingServiceJudges.clear(), + db.courtUmpires.clear(), + db.courtServiceJudges.clear() + ]); + await db.settings.put({ + id: 1, + withServiceJudge: true, + waitingListQuickButtons: true, + showAlert: false, + numberOfCourts: 5 + }); + await db.umpires.put({ + id: 1, + firstName: 'Alex', + lastName: 'Smith', + gender: 'M', + country: 'GB' + }); + service = TestBed.inject(WaitingDutyService); + }); + afterEach(async () => { + TestBed.resetTestingModule(); + await Promise.all([ + db.settings.clear(), + db.umpires.clear(), + db.waitingUmpires.clear(), + db.waitingServiceJudges.clear(), + db.courtUmpires.clear(), + db.courtServiceJudges.clear() + ]); + }); + for (const role of ['umpire', 'serviceJudge'] as const) { + it(`remembers the ${role} queue and returns at its bottom only once`, async () => { + if (role === 'umpire') + await db.waitingUmpires.add({ umpireId: 1, order: 1 }); + else await db.waitingServiceJudges.add({ serviceJudgeId: 1, order: 1 }); + await service.move(1, 'rest'); + expect((await db.umpires.get(1))?.returnToQueue).toBe(role); + expect( + (await db.waitingUmpires.count()) + + (await db.waitingServiceJudges.count()) + ).toBe(0); + if (role === 'umpire') + await db.waitingUmpires.add({ umpireId: 2, order: 1 }); + else await db.waitingServiceJudges.add({ serviceJudgeId: 2, order: 1 }); + await Promise.all([service.move(1, 'umpire'), service.move(1, 'umpire')]); + const items = + role === 'umpire' + ? await db.waitingUmpires.orderBy('order').toArray() + : await db.waitingServiceJudges.orderBy('order').toArray(); + expect(items.map((item) => item.order)).toEqual([1, 2]); + expect((await db.umpires.get(1))?.returnToQueue).toBeUndefined(); + }); + } + for (const role of ['umpire', 'serviceJudge'] as const) { + it(`saves the ${role} origin on a drag move even with quick buttons disabled`, async () => { + await db.settings.update(1, { waitingListQuickButtons: false }); + if (role === 'umpire') + await db.waitingUmpires.add({ umpireId: 1, order: 1 }); + else await db.waitingServiceJudges.add({ serviceJudgeId: 1, order: 1 }); + await service.moveToRest(1); + expect((await db.umpires.get(1))?.returnToQueue).toBe(role); + expect(await db.waitingUmpires.count()).toBe(0); + expect(await db.waitingServiceJudges.count()).toBe(0); + }); + } + + it('does not return officials without a saved origin', async () => { + await service.move(1, 'umpire'); + expect(await db.waitingUmpires.count()).toBe(0); + expect(await db.waitingServiceJudges.count()).toBe(0); + }); + + it('ignores stale return actions for assigned officials', async () => { + await db.courtUmpires.add({ umpireId: 1, courtNo: 1 }); + await service.move(1, 'umpire'); + expect(await db.waitingUmpires.count()).toBe(0); + }); + it('does nothing when service judges are disabled', async () => { + await db.settings.update(1, { withServiceJudge: false }); + await service.move(1, 'umpire'); + expect(await db.waitingUmpires.count()).toBe(0); + }); +}); diff --git a/src/app/services/waiting-duty.service.ts b/src/app/services/waiting-duty.service.ts new file mode 100644 index 0000000..03ac3f0 --- /dev/null +++ b/src/app/services/waiting-duty.service.ts @@ -0,0 +1,81 @@ +import { Injectable, inject } from '@angular/core'; +import { db, WaitingRole } from 'db'; +import { WaitingUmpiresService } from './waiting-umpires.service'; +import { WaitingServiceJudgesService } from './waiting-service-judges.service'; + +@Injectable({ providedIn: 'root' }) +export class WaitingDutyService { + private readonly umpires = inject(WaitingUmpiresService); + private readonly judges = inject(WaitingServiceJudgesService); + + async moveToRest(officialId: number): Promise { + await this.moveOfficial(officialId, 'rest', false); + } + + async move( + officialId: number, + destination: WaitingRole | 'rest' + ): Promise { + await this.moveOfficial(officialId, destination, true); + } + + private async moveOfficial( + officialId: number, + destination: WaitingRole | 'rest', + requireQuickButtons: boolean + ): Promise { + await db.transaction( + 'rw', + [ + db.settings, + db.umpires, + db.waitingUmpires, + db.waitingServiceJudges, + db.courtUmpires, + db.courtServiceJudges + ], + async () => { + const settings = await db.settings.get(1); + const official = await db.umpires.get(officialId); + if ( + !official || + (requireQuickButtons && + (!settings?.withServiceJudge || !settings.waitingListQuickButtons)) + ) + return; + const umpire = await db.waitingUmpires + .where('umpireId') + .equals(officialId) + .first(); + const judge = await db.waitingServiceJudges + .where('serviceJudgeId') + .equals(officialId) + .first(); + const onCourt = + (await db.courtUmpires + .where('umpireId') + .equals(officialId) + .count()) || + (await db.courtServiceJudges + .where('umpireId') + .equals(officialId) + .count()); + if (onCourt) return; + if (destination === 'rest') { + if (!umpire && !judge) return; + await db.umpires.update(officialId, { + returnToQueue: umpire ? 'umpire' : 'serviceJudge' + }); + await this.umpires.removeByUmpireId(officialId); + await this.judges.removeByUmpireId(officialId); + } else { + if (umpire || judge || !official.returnToQueue) return; + const role = official.returnToQueue; + if (role === 'serviceJudge') await this.judges.add(officialId); + else await this.umpires.add(officialId); + await db.umpires.update(officialId, { returnToQueue: undefined }); + } + } + ); + } +} diff --git a/src/app/tab1/tab1.page.html b/src/app/tab1/tab1.page.html index a972f4f..c5c1c34 100644 --- a/src/app/tab1/tab1.page.html +++ b/src/app/tab1/tab1.page.html @@ -161,6 +161,18 @@ {{ umpire|fullname }} {{ umpire|fullname }} + @if (waitingQuickButtonsEnabled()) { + + + + } } @@ -179,6 +191,18 @@ {{ umpire|fullname }} {{ umpire|fullname }} + @if (waitingQuickButtonsEnabled()) { + + + + } } @@ -198,6 +222,18 @@ {{ umpire|fullname }} {{ umpire|fullname }} + @if (waitingQuickButtonsEnabled() && umpire.returnToQueue) { + + + + } } diff --git a/src/app/tab1/tab1.page.scss b/src/app/tab1/tab1.page.scss index 2f312aa..e03c3ba 100644 --- a/src/app/tab1/tab1.page.scss +++ b/src/app/tab1/tab1.page.scss @@ -38,3 +38,18 @@ background-color: var(--ion-color-step-150, #d9d9d9); } } + +.bottom-lists .custom-ion-item { + display: flex; + align-items: center; + gap: 4px; + + ion-label { + flex: 1; + min-width: 0; + } + .waiting-duty-button { + flex-shrink: 0; + margin: 0; + } +} diff --git a/src/app/tab1/tab1.page.spec.ts b/src/app/tab1/tab1.page.spec.ts index 23162e9..e0d7e17 100644 --- a/src/app/tab1/tab1.page.spec.ts +++ b/src/app/tab1/tab1.page.spec.ts @@ -1,7 +1,9 @@ import { ComponentFixture, TestBed } from '@angular/core/testing'; +import { CdkDragDrop } from '@angular/cdk/drag-drop'; import { signal } from '@angular/core'; import { AlertController } from '@ionic/angular'; import { CourtServiceJudge, CourtUmpire, Settings, Umpire } from 'db'; +import { WaitingDutyService } from '../services/waiting-duty.service'; import { Tab1Page } from './tab1.page'; import { SettingsService } from '../services/settings-service'; import { UmpireService } from '../services/umpire.service'; @@ -34,6 +36,7 @@ describe('Tab1Page court rotation', () => { let waitingJudge: jasmine.SpyObj; let umpireCourt: jasmine.SpyObj; let judgeCourt: jasmine.SpyObj; + let duty: jasmine.SpyObj; let alerts: jasmine.SpyObj; beforeEach(() => { @@ -72,6 +75,9 @@ describe('Tab1Page court rotation', () => { ['save', 'removeByUmpireId'], { umpires: courtJudges } ); + duty = jasmine.createSpyObj('WaitingDutyService', ['move', 'moveToRest']); + duty.move.and.resolveTo(); + duty.moveToRest.and.resolveTo(); alerts = jasmine.createSpyObj('AlertController', ['create']); waitingUmpire.getCurrentUmpire.and.resolveTo(official(1)); waitingJudge.getCurrentUmpire.and.resolveTo(official(2)); @@ -85,6 +91,7 @@ describe('Tab1Page court rotation', () => { judgeCourt.removeByUmpireId.and.resolveTo(); TestBed.configureTestingModule({ providers: [ + { provide: WaitingDutyService, useValue: duty }, { provide: SettingsService, useValue: { settings } }, { provide: UmpireService, useValue: { umpires } }, { provide: WaitingUmpiresService, useValue: waitingUmpire }, @@ -98,6 +105,74 @@ describe('Tab1Page court rotation', () => { component = fixture.componentInstance; }); + for (const source of [ + 'list-waiting-umpires', + 'list-waiting-service-judges' + ]) { + it(`saves the waiting origin when dropped from ${source} to rest`, async () => { + await component.dropToRest({ + previousContainer: { id: source }, + container: { id: 'list-on-rest' }, + item: { data: official(1) } + } as CdkDragDrop); + expect(duty.moveToRest).toHaveBeenCalledOnceWith(1); + expect(waitingUmpire.removeByUmpireId).not.toHaveBeenCalled(); + expect(waitingJudge.removeByUmpireId).not.toHaveBeenCalled(); + }); + } + + it('moves directly when quick-button alerts are disabled', async () => { + settings.update((value) => ({ ...value, waitingListQuickButtons: true })); + await component.changeWaitingDuty(official(1), true); + expect(duty.move).toHaveBeenCalledOnceWith(1, 'rest'); + expect(alerts.create).not.toHaveBeenCalled(); + }); + + it('ignores return actions without a saved origin', async () => { + settings.update((value) => ({ + ...value, + waitingListQuickButtons: true, + showAlert: true + })); + await component.changeWaitingDuty(official(5), false); + expect(duty.move).not.toHaveBeenCalled(); + expect(alerts.create).not.toHaveBeenCalled(); + }); + + it('confirms returning to the saved service-judge queue', async () => { + settings.update((value) => ({ + ...value, + waitingListQuickButtons: true, + showAlert: true + })); + alerts.create.and.resolveTo({ + present: async () => undefined + } as unknown as HTMLIonAlertElement); + await component.changeWaitingDuty( + { ...official(5), returnToQueue: 'serviceJudge' }, + false + ); + expect(duty.move).not.toHaveBeenCalled(); + const buttons = alerts.create.calls.mostRecent().args[0]?.buttons; + const confirm = buttons?.find( + (button) => typeof button !== 'string' && button.role === 'confirm' + ); + if (typeof confirm === 'object' && typeof confirm.handler === 'function') + confirm.handler(undefined); + expect(duty.move).toHaveBeenCalledOnceWith(5, 'serviceJudge'); + }); + + it('hides and ignores quick actions when service judges are disabled', async () => { + settings.update((value) => ({ + ...value, + waitingListQuickButtons: true, + withServiceJudge: false + })); + expect(component.waitingQuickButtonsEnabled()).toBeFalse(); + await component.changeWaitingDuty(official(1), true); + expect(duty.move).not.toHaveBeenCalled(); + }); + it('waits for confirmation before assigning officials', async () => { settings.update((value) => ({ ...value, showAlert: true })); const present = jasmine.createSpy('present').and.resolveTo(); diff --git a/src/app/tab1/tab1.page.ts b/src/app/tab1/tab1.page.ts index 62df8ed..5f34085 100644 --- a/src/app/tab1/tab1.page.ts +++ b/src/app/tab1/tab1.page.ts @@ -22,6 +22,7 @@ import { import { SettingsService } from '../services/settings-service'; import { UmpireService } from '../services/umpire.service'; import { Umpire } from 'db'; +import { WaitingDutyService } from '../services/waiting-duty.service'; import { WaitingUmpiresService } from '../services/waiting-umpires.service'; import { WaitingServiceJudgesService } from '../services/waiting-service-judges.service'; import { FullnamePipe } from '../fullname-pipe'; @@ -71,6 +72,8 @@ export class Tab1Page { addIcons({ exitOutline, enterOutline }); } + readonly waitingDutyService = inject(WaitingDutyService); + readonly settingsService = inject(SettingsService); readonly umpireService = inject(UmpireService); readonly waitingUmpireService = inject(WaitingUmpiresService); @@ -285,12 +288,53 @@ export class Tab1Page { return this.settings()?.withServiceJudge ?? false; }); - dropToRest(event: CdkDragDrop) { + readonly waitingQuickButtonsEnabled = computed( + () => + this.serviceJudgeEnabled() && !!this.settings()?.waitingListQuickButtons + ); + + async changeWaitingDuty(umpire: Umpire, toRest: boolean): Promise { + if (!this.waitingQuickButtonsEnabled()) return; + const destination = toRest ? 'rest' : umpire.returnToQueue; + if (!destination) return; + const move = () => this.waitingDutyService.move(umpire.id, destination); + if (!this.settings()?.showAlert) { + await move(); + return; + } + const alert = await this.alertController.create({ + header: this.translationService.translate('confirmation'), + subHeader: this.translationService.translate( + toRest ? 'confirmMoveOffDuty' : 'confirmReturnToQueue' + ), + message: this.fullnamePipe.transform(umpire), + buttons: [ + { text: this.translationService.translate('no'), role: 'cancel' }, + { + text: this.translationService.translate('yes'), + role: 'confirm', + handler: () => { + void move(); + } + } + ] + }); + await alert.present(); + } + + async dropToRest(event: CdkDragDrop): Promise { if (event.previousContainer === event.container) { return; } else { const comingFrom = event.previousContainer.id; const umpireToMove = event.item.data; + if ( + comingFrom === 'list-waiting-umpires' || + comingFrom === 'list-waiting-service-judges' + ) { + await this.waitingDutyService.moveToRest(umpireToMove.id); + return; + } this.removeFromOriginalPlace(umpireToMove, comingFrom); } } diff --git a/src/app/tab3/tab3.page.html b/src/app/tab3/tab3.page.html index ed0442a..0957e7d 100644 --- a/src/app/tab3/tab3.page.html +++ b/src/app/tab3/tab3.page.html @@ -35,6 +35,21 @@ + +
+ + {{ 'waitingListQuickButtons' | translate }} + +

+ {{ 'waitingListQuickButtonsHelp' | translate }} +

+
+
+ + ): Promise { + await this.settingsService.update({ + waitingListQuickButtons: event.detail.checked + }); + } + async updateShowAlert(event: CustomEvent): Promise { await this.settingsService.update({ showAlert: event.detail.checked