Treat all Now Serving cards equally and announce every ticket change.
Deploy Ladill Queue / deploy (push) Successful in 1m56s
Deploy Ladill Queue / deploy (push) Successful in 1m56s
Remove newest-card highlighting so every active service point looks the same, sort the board stably by destination, and have the display announce whenever a counter’s ticket changes (falling back when a server voice row is missing). Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -35,7 +35,8 @@ class DisplayService
|
|||||||
->get();
|
->get();
|
||||||
|
|
||||||
// One active ticket per destination (service point / counter). Older called/serving
|
// One active ticket per destination (service point / counter). Older called/serving
|
||||||
// tickets at the same window must not crowd the public board.
|
// tickets at the same window must not crowd the public board. Order is stable by
|
||||||
|
// destination so every serving point is presented equally (no "newest" primacy).
|
||||||
$nowServing = Ticket::query()
|
$nowServing = Ticket::query()
|
||||||
->whereIn('service_queue_id', $queueIds)
|
->whereIn('service_queue_id', $queueIds)
|
||||||
->whereIn('status', ['called', 'serving'])
|
->whereIn('status', ['called', 'serving'])
|
||||||
@@ -60,9 +61,20 @@ class DisplayService
|
|||||||
'counter' => $point?->displayDestination() ?? $point?->name,
|
'counter' => $point?->displayDestination() ?? $point?->name,
|
||||||
'destination' => $point?->displayDestination(),
|
'destination' => $point?->displayDestination(),
|
||||||
'staff_display_name' => $point?->staff_display_name,
|
'staff_display_name' => $point?->staff_display_name,
|
||||||
|
'point_key' => $point
|
||||||
|
? 'counter:'.$point->id
|
||||||
|
: 'queue:'.$t->service_queue_id,
|
||||||
'announcement_text' => TicketPresenter::announcementText($t, $point),
|
'announcement_text' => TicketPresenter::announcementText($t, $point),
|
||||||
|
'called_at' => optional($t->called_at)?->toIso8601String(),
|
||||||
];
|
];
|
||||||
})
|
})
|
||||||
|
->sortBy(fn (array $item) => sprintf(
|
||||||
|
'%s|%s|%s',
|
||||||
|
mb_strtolower((string) ($item['queue_name'] ?? '')),
|
||||||
|
mb_strtolower((string) ($item['destination'] ?? $item['counter'] ?? '')),
|
||||||
|
(string) ($item['ticket_number'] ?? ''),
|
||||||
|
))
|
||||||
|
->values()
|
||||||
->all();
|
->all();
|
||||||
|
|
||||||
$waiting = Ticket::query()
|
$waiting = Ticket::query()
|
||||||
|
|||||||
@@ -491,20 +491,9 @@ html:has(.qms-display) body {
|
|||||||
box-shadow: 0 16px 32px -20px rgb(15 23 42 / 0.2);
|
box-shadow: 0 16px 32px -20px rgb(15 23 42 / 0.2);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qms-display__ticket--newest {
|
|
||||||
border-color: rgb(129 140 248);
|
|
||||||
background: linear-gradient(145deg, rgb(238 242 255) 0%, #fff 46%);
|
|
||||||
box-shadow: 0 24px 48px -24px rgb(79 70 229 / 0.45);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qms-display__ticket-accent {
|
.qms-display__ticket-accent {
|
||||||
height: 4px;
|
height: 4px;
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
background: rgb(203 213 225);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qms-display__ticket--newest .qms-display__ticket-accent {
|
|
||||||
height: 6px;
|
|
||||||
background: linear-gradient(90deg, rgb(67 56 202), rgb(124 58 237), rgb(99 102 241));
|
background: linear-gradient(90deg, rgb(67 56 202), rgb(124 58 237), rgb(99 102 241));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -539,11 +528,6 @@ html:has(.qms-display) body {
|
|||||||
color: #fff;
|
color: #fff;
|
||||||
}
|
}
|
||||||
|
|
||||||
.qms-display__ticket:not(.qms-display__ticket--newest) .qms-display__badge {
|
|
||||||
background: rgb(241 245 249);
|
|
||||||
color: rgb(71 85 105);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qms-display__queue-name {
|
.qms-display__queue-name {
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
@@ -573,10 +557,6 @@ html:has(.qms-display) body {
|
|||||||
color: rgb(30 41 59);
|
color: rgb(30 41 59);
|
||||||
}
|
}
|
||||||
|
|
||||||
.qms-display__ticket--newest .qms-display__ticket-number {
|
|
||||||
color: rgb(67 56 202);
|
|
||||||
}
|
|
||||||
|
|
||||||
.qms-display__destination {
|
.qms-display__destination {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
min-width: 0;
|
min-width: 0;
|
||||||
|
|||||||
@@ -202,6 +202,9 @@ export function registerKioskFlow(Alpine) {
|
|||||||
Alpine.data('displayBoard', (config = {}) => ({
|
Alpine.data('displayBoard', (config = {}) => ({
|
||||||
payload: config.initial ?? null,
|
payload: config.initial ?? null,
|
||||||
announcedIds: {},
|
announcedIds: {},
|
||||||
|
servingSignatures: {},
|
||||||
|
servingPrimed: false,
|
||||||
|
pendingLocalAnnouncements: [],
|
||||||
isAnnouncing: false,
|
isAnnouncing: false,
|
||||||
speechReady: false,
|
speechReady: false,
|
||||||
voices: [],
|
voices: [],
|
||||||
@@ -229,6 +232,10 @@ export function registerKioskFlow(Alpine) {
|
|||||||
}
|
}
|
||||||
this.payload = await res.json();
|
this.payload = await res.json();
|
||||||
this.pollError = null;
|
this.pollError = null;
|
||||||
|
this.trackServingChanges(
|
||||||
|
this.payload?.now_serving ?? [],
|
||||||
|
this.payload?.announcements ?? [],
|
||||||
|
);
|
||||||
this.maybeAnnounce(this.payload?.announcements ?? []);
|
this.maybeAnnounce(this.payload?.announcements ?? []);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
this.pollError = e.message || 'Could not refresh display data';
|
this.pollError = e.message || 'Could not refresh display data';
|
||||||
@@ -236,6 +243,64 @@ export function registerKioskFlow(Alpine) {
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
servingPointKey(item) {
|
||||||
|
return item?.point_key
|
||||||
|
|| item?.destination
|
||||||
|
|| item?.counter
|
||||||
|
|| item?.queue_name
|
||||||
|
|| item?.ticket_number
|
||||||
|
|| 'unknown';
|
||||||
|
},
|
||||||
|
|
||||||
|
trackServingChanges(nowServing, serverAnnouncements = []) {
|
||||||
|
const nextSignatures = {};
|
||||||
|
const changed = [];
|
||||||
|
|
||||||
|
for (const item of nowServing) {
|
||||||
|
if (! item?.ticket_number) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const key = this.servingPointKey(item);
|
||||||
|
const signature = `${item.ticket_number}|${item.called_at || ''}`;
|
||||||
|
nextSignatures[key] = signature;
|
||||||
|
|
||||||
|
if (this.servingPrimed && this.servingSignatures[key] !== signature) {
|
||||||
|
changed.push(item);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
this.servingSignatures = nextSignatures;
|
||||||
|
if (! this.servingPrimed) {
|
||||||
|
this.servingPrimed = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const item of changed) {
|
||||||
|
const ticket = String(item.ticket_number);
|
||||||
|
const coveredByServer = (serverAnnouncements || []).some(
|
||||||
|
(announcement) => announcement?.message && String(announcement.message).includes(ticket),
|
||||||
|
);
|
||||||
|
if (coveredByServer) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const message = item.announcement_text
|
||||||
|
|| `Ticket ${item.ticket_number}, please proceed to ${item.destination || item.counter || 'the service point'}.`;
|
||||||
|
const localId = `local:${this.servingPointKey(item)}:${item.ticket_number}:${item.called_at || Date.now()}`;
|
||||||
|
if (this.announcedIds[localId]) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
this.pendingLocalAnnouncements.push({
|
||||||
|
id: localId,
|
||||||
|
message,
|
||||||
|
locale: 'en',
|
||||||
|
voice: 'female',
|
||||||
|
volume: 80,
|
||||||
|
repeat_count: 1,
|
||||||
|
local: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
primeSpeech() {
|
primeSpeech() {
|
||||||
if (! window.speechSynthesis) {
|
if (! window.speechSynthesis) {
|
||||||
return;
|
return;
|
||||||
@@ -277,17 +342,22 @@ export function registerKioskFlow(Alpine) {
|
|||||||
},
|
},
|
||||||
|
|
||||||
maybeAnnounce(announcements) {
|
maybeAnnounce(announcements) {
|
||||||
if (! window.speechSynthesis || ! this.speechReady || this.isAnnouncing || ! announcements.length) {
|
if (! window.speechSynthesis || ! this.speechReady || this.isAnnouncing) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const next = announcements.find((item) => item?.id && item?.message && ! this.announcedIds[item.id]);
|
const queue = [
|
||||||
|
...(Array.isArray(announcements) ? announcements : []),
|
||||||
|
...this.pendingLocalAnnouncements,
|
||||||
|
];
|
||||||
|
const next = queue.find((item) => item?.id && item?.message && ! this.announcedIds[item.id]);
|
||||||
if (! next) {
|
if (! next) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
this.isAnnouncing = true;
|
this.isAnnouncing = true;
|
||||||
this.announcedIds[next.id] = true;
|
this.announcedIds[next.id] = true;
|
||||||
|
this.pendingLocalAnnouncements = this.pendingLocalAnnouncements.filter((item) => item.id !== next.id);
|
||||||
this.speakAnnouncement(next);
|
this.speakAnnouncement(next);
|
||||||
},
|
},
|
||||||
|
|
||||||
@@ -307,8 +377,13 @@ export function registerKioskFlow(Alpine) {
|
|||||||
|
|
||||||
const finish = () => {
|
const finish = () => {
|
||||||
release();
|
release();
|
||||||
this.ackPlayed(item.id);
|
if (! item.local) {
|
||||||
this.maybeAnnounce(this.payload?.announcements ?? []);
|
this.ackPlayed(item.id);
|
||||||
|
}
|
||||||
|
this.maybeAnnounce([
|
||||||
|
...(this.payload?.announcements ?? []),
|
||||||
|
...this.pendingLocalAnnouncements,
|
||||||
|
]);
|
||||||
};
|
};
|
||||||
|
|
||||||
const fail = (reason) => {
|
const fail = (reason) => {
|
||||||
@@ -400,6 +475,8 @@ export function registerKioskFlow(Alpine) {
|
|||||||
// ignore storage failures
|
// ignore storage failures
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Seed signatures from SSR payload without announcing historical tickets.
|
||||||
|
this.trackServingChanges(this.payload?.now_serving ?? []);
|
||||||
this.poll();
|
this.poll();
|
||||||
setInterval(() => this.poll(), this.pollMs);
|
setInterval(() => this.poll(), this.pollMs);
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -82,11 +82,8 @@
|
|||||||
x-show="(payload?.now_serving ?? []).length"
|
x-show="(payload?.now_serving ?? []).length"
|
||||||
:data-ticket-count="Math.min((payload?.now_serving ?? []).length, 12)"
|
:data-ticket-count="Math.min((payload?.now_serving ?? []).length, 12)"
|
||||||
>
|
>
|
||||||
<template x-for="(item, index) in (payload?.now_serving ?? [])" :key="item.ticket_number + (item.counter ?? '')">
|
<template x-for="item in (payload?.now_serving ?? [])" :key="(item.destination || item.counter || item.queue_name || '') + ':' + item.ticket_number">
|
||||||
<article
|
<article class="qms-display__ticket rounded-2xl lg:rounded-3xl">
|
||||||
class="qms-display__ticket rounded-2xl lg:rounded-3xl"
|
|
||||||
:class="{ 'qms-display__ticket--newest': index === 0 }"
|
|
||||||
>
|
|
||||||
<div class="qms-display__ticket-accent" aria-hidden="true"></div>
|
<div class="qms-display__ticket-accent" aria-hidden="true"></div>
|
||||||
<div class="qms-display__ticket-body">
|
<div class="qms-display__ticket-body">
|
||||||
<div class="qms-display__ticket-meta">
|
<div class="qms-display__ticket-meta">
|
||||||
|
|||||||
@@ -101,9 +101,7 @@ class DisplayVoiceTest extends TestCase
|
|||||||
|
|
||||||
$this->get(route('qms.display.public', $screen->access_token))
|
$this->get(route('qms.display.public', $screen->access_token))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertSee('qms-display__tickets', false)
|
->assertDontSee('qms-display__ticket--newest')
|
||||||
->assertSee('data-ticket-count', false)
|
|
||||||
->assertSee('qms-display__ticket--newest', false)
|
|
||||||
->assertSee('Please proceed to')
|
->assertSee('Please proceed to')
|
||||||
->assertSee('A001')
|
->assertSee('A001')
|
||||||
->assertSee('Reception')
|
->assertSee('Reception')
|
||||||
@@ -161,13 +159,16 @@ class DisplayVoiceTest extends TestCase
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
$this->getJson(route('qms.display.data', $screen->access_token))
|
$payload = $this->getJson(route('qms.display.data', $screen->access_token))
|
||||||
->assertOk()
|
->assertOk()
|
||||||
->assertJsonCount(2, 'now_serving')
|
->assertJsonCount(2, 'now_serving')
|
||||||
->assertJsonPath('now_serving.0.ticket_number', 'A005')
|
->json('now_serving');
|
||||||
->assertJsonPath('now_serving.0.counter', 'Room 14')
|
|
||||||
->assertJsonPath('now_serving.1.ticket_number', 'A003')
|
$byCounter = collect($payload)->keyBy('counter');
|
||||||
->assertJsonPath('now_serving.1.counter', 'Room 12');
|
$this->assertSame('A003', $byCounter['Room 12']['ticket_number']);
|
||||||
|
$this->assertSame('A005', $byCounter['Room 14']['ticket_number']);
|
||||||
|
// Stable alphabetical order by destination — equal presentation, no newest-first primacy.
|
||||||
|
$this->assertSame(['Room 12', 'Room 14'], collect($payload)->pluck('counter')->all());
|
||||||
}
|
}
|
||||||
|
|
||||||
public function test_display_falls_back_to_branch_queues_when_none_assigned(): void
|
public function test_display_falls_back_to_branch_queues_when_none_assigned(): void
|
||||||
|
|||||||
Reference in New Issue
Block a user