feat: add waiting-list off-duty and return quick buttons

This commit is contained in:
tothbt committed 2026-11-02 18:01:13 +01:00
1 parent 848e12e2d6
commit 7c5384621a
13 files changed
+450 -9

No files matched your search

+16
View File
@@ -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',
+8 -2
View File
@@ -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
});
}
}
@@ -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);
});
});
+81
View File
@@ -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 });
}
}
);
}
}
+36
View File
@@ -161,6 +161,18 @@
<ion-label>{{ umpire|fullname }}</ion-label>
</div>
<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>
}
</ion-list>
@@ -179,6 +191,18 @@
<ion-label>{{ umpire|fullname }}</ion-label>
</div>
<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>
}
</ion-list>
@@ -198,6 +222,18 @@
<ion-label>{{ umpire|fullname }}</ion-label>
</div>
<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>
}
</ion-list>
+15
View File
@@ -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;
}
}
+75
View File
@@ -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<WaitingServiceJudgesService>;
let umpireCourt: jasmine.SpyObj<CourtUmpireService>;
let judgeCourt: jasmine.SpyObj<CourtServiceJudgeService>;
let duty: jasmine.SpyObj<WaitingDutyService>;
let alerts: jasmine.SpyObj<AlertController>;
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<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 () => {
settings.update((value) => ({ ...value, showAlert: true }));
const present = jasmine.createSpy('present').and.resolveTo();
+45 -1
View File
@@ -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<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) {
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);
}
}
+15
View File
@@ -35,6 +35,21 @@
</ion-toggle>
</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-toggle
[checked]="currentSettings.showAlert"
+15
View File
@@ -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;
}
+8
View File
@@ -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> {
await this.settingsService.update({
showAlert: event.detail.checked