feat: add waiting-list off-duty and return quick buttons
This commit is contained in:
1 parent
848e12e2d6
commit
7c5384621a
13 files changed
+446
-5
No files matched your search
@@ -1,5 +1,7 @@
|
|||||||
import Dexie, { type EntityTable } from 'dexie';
|
import Dexie, { type EntityTable } from 'dexie';
|
||||||
|
|
||||||
|
export type WaitingRole = 'umpire' | 'serviceJudge';
|
||||||
|
|
||||||
export interface Umpire {
|
export interface Umpire {
|
||||||
id: number;
|
id: number;
|
||||||
firstName: string;
|
firstName: string;
|
||||||
@@ -7,6 +9,7 @@ export interface Umpire {
|
|||||||
country: string;
|
country: string;
|
||||||
gender: string;
|
gender: string;
|
||||||
courtNo?: number;
|
courtNo?: number;
|
||||||
|
returnToQueue?: WaitingRole;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CourtUmpire {
|
export interface CourtUmpire {
|
||||||
@@ -38,6 +41,7 @@ export interface Settings {
|
|||||||
withServiceJudge: boolean;
|
withServiceJudge: boolean;
|
||||||
numberOfCourts: number;
|
numberOfCourts: number;
|
||||||
showAlert: boolean;
|
showAlert: boolean;
|
||||||
|
waitingListQuickButtons?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = new Dexie('CourtPilot') as Dexie & {
|
const db = new Dexie('CourtPilot') as Dexie & {
|
||||||
@@ -58,4 +62,20 @@ db.version(1).stores({
|
|||||||
waitingServiceJudges: '++id, order, serviceJudgeId'
|
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 };
|
export { db };
|
||||||
+17
-2
@@ -22,7 +22,7 @@ flowchart TD
|
|||||||
The root route lazy-loads the tab routes. The three primary routes are:
|
The root route lazy-loads the tab routes. The three primary routes are:
|
||||||
|
|
||||||
| Route | Source | Responsibility |
|
| Route | Source | Responsibility |
|
||||||
|---|---|---|
|
| ---------------- | --------------- | ---------------------------------------------------------------------- |
|
||||||
| `/tabs/pilot` | `src/app/tab1/` | Waiting queues, court assignments, drag and drop, and rotation actions |
|
| `/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/umpires` | `src/app/tab2/` | Court-official roster management |
|
||||||
| `/tabs/settings` | `src/app/tab3/` | Court count, theme, service-judge mode, alerts, and language |
|
| `/tabs/settings` | `src/app/tab3/` | Court count, theme, service-judge mode, alerts, and language |
|
||||||
@@ -37,7 +37,7 @@ Persistence services use Dexie's `liveQuery()` and Angular's `toSignal()` to exp
|
|||||||
|
|
||||||
## Persistence model
|
## 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
|
```mermaid
|
||||||
erDiagram
|
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
|
apply while the app is open when device-preferred is selected. This does not
|
||||||
change the Dexie schema or existing settings records.
|
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
|
### 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.
|
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.
|
||||||
|
|||||||
@@ -9,6 +9,14 @@ export const translations = {
|
|||||||
themeLight: 'Light',
|
themeLight: 'Light',
|
||||||
themeDark: 'Dark',
|
themeDark: 'Dark',
|
||||||
themeSystem: 'Device-preferred',
|
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',
|
showAlerts: 'Show alerts on quick buttons',
|
||||||
serviceJudge: 'Service Judge',
|
serviceJudge: 'Service Judge',
|
||||||
language: 'Language',
|
language: 'Language',
|
||||||
@@ -62,6 +70,14 @@ export const translations = {
|
|||||||
themeLight: 'Világos',
|
themeLight: 'Világos',
|
||||||
themeDark: 'Sötét',
|
themeDark: 'Sötét',
|
||||||
themeSystem: 'Eszközbeállítás szerint',
|
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',
|
showAlerts: 'Figyelmeztetések megjelenítése a gyors gomboknál',
|
||||||
serviceJudge: 'Adogatásbíró',
|
serviceJudge: 'Adogatásbíró',
|
||||||
language: 'Nyelv',
|
language: 'Nyelv',
|
||||||
|
|||||||
@@ -29,7 +29,12 @@ export class SettingsService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
await db.settings.update(1, changes);
|
await db.settings.update(1, {
|
||||||
|
...changes,
|
||||||
|
...(!(changes.withServiceJudge ?? current.withServiceJudge)
|
||||||
|
? { waitingListQuickButtons: false }
|
||||||
|
: {})
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureSettings() {
|
private async ensureSettings() {
|
||||||
@@ -40,7 +45,8 @@ export class SettingsService {
|
|||||||
id: 1,
|
id: 1,
|
||||||
numberOfCourts: 5,
|
numberOfCourts: 5,
|
||||||
withServiceJudge: true,
|
withServiceJudge: true,
|
||||||
showAlert: true
|
showAlert: true,
|
||||||
|
waitingListQuickButtons: false
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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<void> {
|
||||||
|
await this.moveOfficial(officialId, 'rest', false);
|
||||||
|
}
|
||||||
|
|
||||||
|
async move(
|
||||||
|
officialId: number,
|
||||||
|
destination: WaitingRole | 'rest'
|
||||||
|
): Promise<void> {
|
||||||
|
await this.moveOfficial(officialId, destination, true);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async moveOfficial(
|
||||||
|
officialId: number,
|
||||||
|
destination: WaitingRole | 'rest',
|
||||||
|
requireQuickButtons: boolean
|
||||||
|
): Promise<void> {
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -161,6 +161,18 @@
|
|||||||
<ion-label>{{ umpire|fullname }}</ion-label>
|
<ion-label>{{ umpire|fullname }}</ion-label>
|
||||||
</div>
|
</div>
|
||||||
<ion-label>{{ umpire|fullname }}</ion-label>
|
<ion-label>{{ umpire|fullname }}</ion-label>
|
||||||
|
@if (waitingQuickButtonsEnabled()) {
|
||||||
|
<ion-button
|
||||||
|
class="waiting-duty-button"
|
||||||
|
size="small"
|
||||||
|
fill="solid"
|
||||||
|
[attr.aria-label]="('moveOffDuty' | translate) + ': ' + (umpire | fullname)"
|
||||||
|
[attr.title]="'moveOffDuty' | translate"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(click)="$event.stopPropagation(); changeWaitingDuty(umpire, true)">
|
||||||
|
<ion-icon slot="icon-only" name="exit-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</ion-list>
|
</ion-list>
|
||||||
@@ -179,6 +191,18 @@
|
|||||||
<ion-label>{{ umpire|fullname }}</ion-label>
|
<ion-label>{{ umpire|fullname }}</ion-label>
|
||||||
</div>
|
</div>
|
||||||
<ion-label>{{ umpire|fullname }}</ion-label>
|
<ion-label>{{ umpire|fullname }}</ion-label>
|
||||||
|
@if (waitingQuickButtonsEnabled()) {
|
||||||
|
<ion-button
|
||||||
|
class="waiting-duty-button"
|
||||||
|
size="small"
|
||||||
|
fill="solid"
|
||||||
|
[attr.aria-label]="('moveOffDuty' | translate) + ': ' + (umpire | fullname)"
|
||||||
|
[attr.title]="'moveOffDuty' | translate"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(click)="$event.stopPropagation(); changeWaitingDuty(umpire, true)">
|
||||||
|
<ion-icon slot="icon-only" name="exit-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</ion-list>
|
</ion-list>
|
||||||
@@ -198,6 +222,18 @@
|
|||||||
<ion-label>{{ umpire|fullname }}</ion-label>
|
<ion-label>{{ umpire|fullname }}</ion-label>
|
||||||
</div>
|
</div>
|
||||||
<ion-label>{{ umpire|fullname }}</ion-label>
|
<ion-label>{{ umpire|fullname }}</ion-label>
|
||||||
|
@if (waitingQuickButtonsEnabled() && umpire.returnToQueue) {
|
||||||
|
<ion-button
|
||||||
|
class="waiting-duty-button"
|
||||||
|
size="small"
|
||||||
|
fill="solid"
|
||||||
|
[attr.aria-label]="('returnToQueue' | translate) + ': ' + (umpire | fullname)"
|
||||||
|
[attr.title]="'returnToQueue' | translate"
|
||||||
|
(pointerdown)="$event.stopPropagation()"
|
||||||
|
(click)="$event.stopPropagation(); changeWaitingDuty(umpire, false)">
|
||||||
|
<ion-icon slot="icon-only" name="enter-outline"></ion-icon>
|
||||||
|
</ion-button>
|
||||||
|
}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
</ion-list>
|
</ion-list>
|
||||||
|
|||||||
@@ -38,3 +38,18 @@
|
|||||||
background-color: var(--ion-color-step-150, #d9d9d9);
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,9 @@
|
|||||||
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
import { ComponentFixture, TestBed } from '@angular/core/testing';
|
||||||
|
import { CdkDragDrop } from '@angular/cdk/drag-drop';
|
||||||
import { signal } from '@angular/core';
|
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 { 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';
|
||||||
@@ -34,6 +36,7 @@ describe('Tab1Page court rotation', () => {
|
|||||||
let waitingJudge: jasmine.SpyObj<WaitingServiceJudgesService>;
|
let waitingJudge: jasmine.SpyObj<WaitingServiceJudgesService>;
|
||||||
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 alerts: jasmine.SpyObj<AlertController>;
|
let alerts: jasmine.SpyObj<AlertController>;
|
||||||
|
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
@@ -72,6 +75,9 @@ describe('Tab1Page court rotation', () => {
|
|||||||
['save', 'removeByUmpireId'],
|
['save', 'removeByUmpireId'],
|
||||||
{ umpires: courtJudges }
|
{ umpires: courtJudges }
|
||||||
);
|
);
|
||||||
|
duty = jasmine.createSpyObj('WaitingDutyService', ['move', 'moveToRest']);
|
||||||
|
duty.move.and.resolveTo();
|
||||||
|
duty.moveToRest.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));
|
||||||
@@ -85,6 +91,7 @@ describe('Tab1Page court rotation', () => {
|
|||||||
judgeCourt.removeByUmpireId.and.resolveTo();
|
judgeCourt.removeByUmpireId.and.resolveTo();
|
||||||
TestBed.configureTestingModule({
|
TestBed.configureTestingModule({
|
||||||
providers: [
|
providers: [
|
||||||
|
{ provide: WaitingDutyService, useValue: duty },
|
||||||
{ provide: SettingsService, useValue: { settings } },
|
{ provide: SettingsService, useValue: { settings } },
|
||||||
{ provide: UmpireService, useValue: { umpires } },
|
{ provide: UmpireService, useValue: { umpires } },
|
||||||
{ provide: WaitingUmpiresService, useValue: waitingUmpire },
|
{ provide: WaitingUmpiresService, useValue: waitingUmpire },
|
||||||
@@ -98,6 +105,74 @@ describe('Tab1Page court rotation', () => {
|
|||||||
component = fixture.componentInstance;
|
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<Umpire[]>);
|
||||||
|
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 () => {
|
it('waits for confirmation before assigning officials', async () => {
|
||||||
settings.update((value) => ({ ...value, showAlert: true }));
|
settings.update((value) => ({ ...value, showAlert: true }));
|
||||||
const present = jasmine.createSpy('present').and.resolveTo();
|
const present = jasmine.createSpy('present').and.resolveTo();
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
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';
|
||||||
|
import { WaitingDutyService } from '../services/waiting-duty.service';
|
||||||
import { WaitingUmpiresService } from '../services/waiting-umpires.service';
|
import { WaitingUmpiresService } from '../services/waiting-umpires.service';
|
||||||
import { WaitingServiceJudgesService } from '../services/waiting-service-judges.service';
|
import { WaitingServiceJudgesService } from '../services/waiting-service-judges.service';
|
||||||
import { FullnamePipe } from '../fullname-pipe';
|
import { FullnamePipe } from '../fullname-pipe';
|
||||||
@@ -71,6 +72,8 @@ export class Tab1Page {
|
|||||||
addIcons({ exitOutline, enterOutline });
|
addIcons({ exitOutline, enterOutline });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
readonly waitingDutyService = inject(WaitingDutyService);
|
||||||
|
|
||||||
readonly settingsService = inject(SettingsService);
|
readonly settingsService = inject(SettingsService);
|
||||||
readonly umpireService = inject(UmpireService);
|
readonly umpireService = inject(UmpireService);
|
||||||
readonly waitingUmpireService = inject(WaitingUmpiresService);
|
readonly waitingUmpireService = inject(WaitingUmpiresService);
|
||||||
@@ -285,12 +288,53 @@ export class Tab1Page {
|
|||||||
return this.settings()?.withServiceJudge ?? false;
|
return this.settings()?.withServiceJudge ?? false;
|
||||||
});
|
});
|
||||||
|
|
||||||
dropToRest(event: CdkDragDrop<Umpire[]>) {
|
readonly waitingQuickButtonsEnabled = computed(
|
||||||
|
() =>
|
||||||
|
this.serviceJudgeEnabled() && !!this.settings()?.waitingListQuickButtons
|
||||||
|
);
|
||||||
|
|
||||||
|
async changeWaitingDuty(umpire: Umpire, toRest: boolean): Promise<void> {
|
||||||
|
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<Umpire[]>): Promise<void> {
|
||||||
if (event.previousContainer === event.container) {
|
if (event.previousContainer === event.container) {
|
||||||
return;
|
return;
|
||||||
} else {
|
} else {
|
||||||
const comingFrom = event.previousContainer.id;
|
const comingFrom = event.previousContainer.id;
|
||||||
const umpireToMove = event.item.data;
|
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);
|
this.removeFromOriginalPlace(umpireToMove, comingFrom);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,6 +35,21 @@
|
|||||||
</ion-toggle>
|
</ion-toggle>
|
||||||
</ion-item>
|
</ion-item>
|
||||||
|
|
||||||
|
<ion-item>
|
||||||
|
<div class="waiting-list-setting">
|
||||||
|
<ion-toggle
|
||||||
|
[checked]="currentSettings.waitingListQuickButtons ?? false"
|
||||||
|
[disabled]="!currentSettings.withServiceJudge"
|
||||||
|
aria-describedby="waiting-list-help"
|
||||||
|
(ionChange)="updateWaitingListQuickButtons($event)">
|
||||||
|
{{ 'waitingListQuickButtons' | translate }}
|
||||||
|
</ion-toggle>
|
||||||
|
<p id="waiting-list-help" class="setting-help">
|
||||||
|
{{ 'waitingListQuickButtonsHelp' | translate }}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</ion-item>
|
||||||
|
|
||||||
<ion-item>
|
<ion-item>
|
||||||
<ion-toggle
|
<ion-toggle
|
||||||
[checked]="currentSettings.showAlert"
|
[checked]="currentSettings.showAlert"
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
.waiting-list-setting {
|
||||||
|
width: 100%;
|
||||||
|
padding-block: 12px;
|
||||||
|
|
||||||
|
ion-toggle {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.setting-help {
|
||||||
|
margin: 8px 0 0;
|
||||||
|
color: var(--ion-color-medium-shade);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
}
|
||||||
@@ -99,6 +99,14 @@ export class Tab3Page {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async updateWaitingListQuickButtons(
|
||||||
|
event: CustomEvent<{ checked: boolean }>
|
||||||
|
): Promise<void> {
|
||||||
|
await this.settingsService.update({
|
||||||
|
waitingListQuickButtons: event.detail.checked
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async updateShowAlert(event: CustomEvent): Promise<void> {
|
async updateShowAlert(event: CustomEvent): Promise<void> {
|
||||||
await this.settingsService.update({
|
await this.settingsService.update({
|
||||||
showAlert: event.detail.checked
|
showAlert: event.detail.checked
|
||||||
|
|||||||
Reference in new issue
Block a user