feat: zobrazení stavu objednávky Bolt Food jako progress stepper
CI / Generate TypeScript types (push) Successful in 9s
CI / Server unit tests (push) Successful in 21s
CI / Build server (push) Successful in 24s
CI / Build client (push) Successful in 35s
CI / Playwright E2E tests (push) Successful in 1m22s
CI / Build and push Docker image (push) Successful in 41s
CI / Notify (push) Successful in 2s

Jak to funguje:
- OrderGroup má nové pole boltOrderState (raw order_state z Bolt API).
  Polling scheduler ho ukládá při každé změně a rozesílá přes Socket.io,
  takže stepper se posouvá živě všem uživatelům.
- Komponenta BoltOrderProgress vykresluje čtyři kroky
  (Přijato → Příprava → Na cestě → Doručeno) pod časy skupiny.
  Známé stavy se mapují explicitně, neznámé heuristikou podle
  klíčových slov, zrušená objednávka se zobrazí červeně.
  Tooltip ukazuje raw stav, aktivní krok pulzuje, dokud sledování běží.
- Po doručení (nebo zmizení objednávky z API) se token smaže,
  ale boltOrderState zůstává "delivered" — dokončený stepper je vidět
  po zbytek dne. Vynuluje se při změně/zrušení odkazu nebo návratu
  skupiny do stavu uzamčeno.
- Nastavení odkazu nově spustí okamžitý poll, stepper se tak objeví
  do vteřiny místo čekání na další tik scheduleru.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-06-11 12:28:26 +02:00
parent 9152425d2b
commit 84b95c6c70
8 changed files with 199 additions and 11 deletions
@@ -0,0 +1,70 @@
.bolt-progress {
display: flex;
align-items: flex-start;
max-width: 400px;
.bolt-step {
position: relative;
display: flex;
flex-direction: column;
align-items: center;
flex: 1;
min-width: 64px;
.bolt-dot {
position: relative;
z-index: 1;
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--luncher-border, #ced4da);
}
.bolt-label {
margin-top: 2px;
font-size: 0.7em;
color: var(--luncher-text-muted, #6c757d);
white-space: nowrap;
}
// Spojnice k předchozímu kroku
&:not(:first-child)::before {
content: '';
position: absolute;
top: 5px;
right: 50%;
width: 100%;
height: 2px;
background: var(--luncher-border, #ced4da);
}
&.done {
.bolt-dot {
background: var(--bs-success, #198754);
}
&:not(:first-child)::before {
background: var(--bs-success, #198754);
}
}
&.active .bolt-label {
font-weight: 600;
color: inherit;
}
}
// Pulzování aktivního kroku, dokud sledování běží
&.live .bolt-step.active .bolt-dot {
animation: bolt-pulse 2s ease-in-out infinite;
}
}
@keyframes bolt-pulse {
0%, 100% {
box-shadow: 0 0 0 0 rgba(25, 135, 84, 0.5);
}
50% {
box-shadow: 0 0 0 5px rgba(25, 135, 84, 0);
}
}
@@ -0,0 +1,60 @@
import { OverlayTrigger, Tooltip } from 'react-bootstrap';
import './BoltOrderProgress.scss';
const STEPS = ['Přijato', 'Příprava', 'Na cestě', 'Doručeno'];
/** Známé stavy objednávky z Bolt API → index kroku ve stepperu. */
const STATE_TO_STEP: Record<string, number> = {
created: 0,
pending: 0,
accepted: 0,
waiting_preparation: 0,
preparing: 1,
ready_for_pickup: 1,
waiting_courier: 1,
courier_assigned: 1,
picked_up: 2,
in_delivery: 2,
delivering: 2,
heading_to_client: 2,
delivered: 3,
finished: 3,
};
/** Neznámé stavy se mapují heuristicky podle klíčových slov. */
function stepForState(state: string): number | 'cancelled' {
const s = state.toLowerCase();
if (s in STATE_TO_STEP) return STATE_TO_STEP[s];
if (/cancel|reject|fail/.test(s)) return 'cancelled';
if (/delivered|finished/.test(s)) return 3;
if (/courier|picked|delivery|transport/.test(s)) return 2;
if (/prepar|ready|cook/.test(s)) return 1;
return 0;
}
interface Props {
/** Raw order_state z Bolt API (např. waiting_preparation) */
state: string;
/** Zda sledování stále běží (skupina má boltTrackingToken) */
tracking: boolean;
}
/** Mini progress stepper se stavem objednávky Bolt Food. */
export default function BoltOrderProgress({ state, tracking }: Props) {
const step = stepForState(state);
if (step === 'cancelled') {
return <small className="text-danger">Objednávka Bolt byla zrušena</small>;
}
return (
<OverlayTrigger overlay={<Tooltip>Stav z Bolt Food: {state}</Tooltip>}>
<div className={`bolt-progress${tracking && step < 3 ? ' live' : ''}`}>
{STEPS.map((label, i) => (
<div key={label} className={`bolt-step${i <= step ? ' done' : ''}${i === step ? ' active' : ''}`}>
<div className="bolt-dot" />
<div className="bolt-label">{label}</div>
</div>
))}
</div>
</OverlayTrigger>
);
}
+6
View File
@@ -23,6 +23,7 @@ import StoreAdminModal from '../components/modals/StoreAdminModal';
import PayForGroupModal from '../components/modals/PayForGroupModal'; import PayForGroupModal from '../components/modals/PayForGroupModal';
import EditGroupFeesModal from '../components/modals/EditGroupFeesModal'; import EditGroupFeesModal from '../components/modals/EditGroupFeesModal';
import PendingPayments from '../components/PendingPayments'; import PendingPayments from '../components/PendingPayments';
import BoltOrderProgress from '../components/BoltOrderProgress';
const SLOT = MealSlot.EXTRA; const SLOT = MealSlot.EXTRA;
const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/; const TIME_REGEX = /^([01]\d|2[0-3]):[0-5]\d$/;
@@ -797,6 +798,11 @@ export default function OrderGroupsPage() {
})()} })()}
</div> </div>
)} )}
{group.boltOrderState && (
<div className="mt-2">
<BoltOrderProgress state={group.boltOrderState} tracking={!!group.boltTrackingToken} />
</div>
)}
</div> </div>
)} )}
</Card.Body> </Card.Body>
+15 -7
View File
@@ -102,16 +102,24 @@ export async function checkBoltTracking(): Promise<void> {
for (const group of candidates) { for (const group of candidates) {
let deliveryAt: string | undefined; let deliveryAt: string | undefined;
let orderState: string | undefined;
let clearToken = false; let clearToken = false;
try { try {
const order = await pollBoltOrder(group.boltTrackingToken!); const order = await pollBoltOrder(group.boltTrackingToken!);
consecutiveFailures.delete(group.id); consecutiveFailures.delete(group.id);
if (!order || TERMINAL_STATE_REGEX.test(order.order_state ?? '')) { if (!order) {
// Objednávka z API zmizela — považujeme ji za doručenou
orderState = 'delivered';
clearToken = true;
} else {
orderState = order.order_state || undefined;
if (TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
clearToken = true; clearToken = true;
} else if (typeof order.expected_time_to_client_in_seconds === 'number') { } else if (typeof order.expected_time_to_client_in_seconds === 'number') {
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds); deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
} }
}
} catch (e) { } catch (e) {
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1; const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
consecutiveFailures.set(group.id, failures); consecutiveFailures.set(group.id, failures);
@@ -121,17 +129,17 @@ export async function checkBoltTracking(): Promise<void> {
clearToken = true; clearToken = true;
} }
if (!clearToken && (deliveryAt === undefined || deliveryAt === group.deliveryAt)) continue; const timeChanged = deliveryAt !== undefined && deliveryAt !== group.deliveryAt;
const stateChanged = orderState !== undefined && orderState !== group.boltOrderState;
if (!clearToken && !timeChanged && !stateChanged) continue;
updated = await storage.updateData<ClientData>(key, current => { updated = await storage.updateData<ClientData>(key, current => {
const d = current ?? data!; const d = current ?? data!;
const g = d.groups?.find(x => x.id === group.id); const g = d.groups?.find(x => x.id === group.id);
if (g?.boltTrackingToken) { if (g?.boltTrackingToken) {
if (clearToken) { if (timeChanged) g.deliveryAt = deliveryAt;
g.boltTrackingToken = undefined; if (stateChanged) g.boltOrderState = orderState;
} else { if (clearToken) g.boltTrackingToken = undefined;
g.deliveryAt = deliveryAt;
}
} }
return d; return d;
}); });
+6
View File
@@ -152,6 +152,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
group.deliveryAt = undefined; group.deliveryAt = undefined;
group.qrGenerated = undefined; group.qrGenerated = undefined;
group.boltTrackingToken = undefined; group.boltTrackingToken = undefined;
group.boltOrderState = undefined;
for (const ml of memberLogins) { for (const ml of memberLogins) {
group.members[ml] = { ...group.members[ml], paid: undefined }; group.members[ml] = { ...group.members[ml], paid: undefined };
} }
@@ -209,11 +210,16 @@ export async function setGroupBoltTracking(login: string, groupId: string, share
if (group.creatorLogin !== login) throw new Error('Sledování Bolt může nastavit pouze zakladatel'); if (group.creatorLogin !== login) throw new Error('Sledování Bolt může nastavit pouze zakladatel');
if (!shareUrl) { if (!shareUrl) {
group.boltTrackingToken = undefined; group.boltTrackingToken = undefined;
group.boltOrderState = undefined;
} else { } else {
if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"'); if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"');
const token = extractBoltToken(shareUrl); const token = extractBoltToken(shareUrl);
if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt'); if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt');
if (token !== group.boltTrackingToken) {
group.boltTrackingToken = token; group.boltTrackingToken = token;
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
group.boltOrderState = undefined;
}
} }
return saveExtraData(data, date); return saveExtraData(data, date);
} }
+5
View File
@@ -4,6 +4,7 @@ import { parseToken } from "../utils";
import { getWebsocket } from "../websocket"; import { getWebsocket } from "../websocket";
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups"; import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
import { GroupState } from "../../../types/gen/types.gen"; import { GroupState } from "../../../types/gen/types.gen";
import { checkBoltTracking } from "../boltTracking";
const router = express.Router(); const router = express.Router();
@@ -171,6 +172,10 @@ router.post("/setBoltTracking", async (req: Request, res, next) => {
const data = await setGroupBoltTracking(login, id, shareUrl); const data = await setGroupBoltTracking(login, id, shareUrl);
broadcastExtra(data); broadcastExtra(data);
res.status(200).json(data); res.status(200).json(data);
// Okamžitý poll, ať uživatel nečeká na další tik scheduleru
if (shareUrl) {
checkBoltTracking().catch(e => console.error('Bolt tracking: okamžitý poll selhal', e));
}
} catch (e: any) { next(e); } } catch (e: any) { next(e); }
}); });
+31 -1
View File
@@ -97,10 +97,27 @@ describe('setGroupBoltTracking', () => {
expect(d.groups![0].boltTrackingToken).toBe(TOKEN); expect(d.groups![0].boltTrackingToken).toBe(TOKEN);
}); });
test('prázdná hodnota sledování zruší', async () => { test('prázdná hodnota sledování zruší včetně stavu', async () => {
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY); await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY); const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY);
expect(d.groups![0].boltTrackingToken).toBeUndefined(); expect(d.groups![0].boltTrackingToken).toBeUndefined();
expect(d.groups![0].boltOrderState).toBeUndefined();
});
test('nový token vynuluje stav předchozí objednávky', async () => {
await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
await storage.updateData<ClientData>(`2025-01-10_extra`, (current) => {
current!.groups![0].boltOrderState = 'preparing';
return current!;
});
// Stejný token stav nemění
let d = await setGroupBoltTracking(CREATOR, groupId, SHARE_URL, TODAY);
expect(d.groups![0].boltOrderState).toBe('preparing');
// Jiný token stav vynuluje
const otherUrl = `https://food.bolt.eu/sharedActiveOrder/${'b'.repeat(64)}`;
d = await setGroupBoltTracking(CREATOR, groupId, otherUrl, TODAY);
expect(d.groups![0].boltOrderState).toBeUndefined();
expect(d.groups![0].boltTrackingToken).toBe('b'.repeat(64));
}); });
test('odmítne neplatný odkaz', async () => { test('odmítne neplatný odkaz', async () => {
@@ -143,6 +160,7 @@ describe('checkBoltTracking', () => {
const after = computeDeliveryHHMM(1800); const after = computeDeliveryHHMM(1800);
const group = await getGroup(); const group = await getGroup();
expect([before, after]).toContain(group.deliveryAt); expect([before, after]).toContain(group.deliveryAt);
expect(group.boltOrderState).toBe('waiting_preparation');
expect(group.boltTrackingToken).toBe(TOKEN); expect(group.boltTrackingToken).toBe(TOKEN);
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything()); expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
expect(mockedAxios.post).toHaveBeenCalledWith( expect(mockedAxios.post).toHaveBeenCalledWith(
@@ -167,6 +185,7 @@ describe('checkBoltTracking', () => {
await checkBoltTracking(); await checkBoltTracking();
const group = await getGroup(); const group = await getGroup();
expect(group.boltTrackingToken).toBeUndefined(); expect(group.boltTrackingToken).toBeUndefined();
expect(group.boltOrderState).toBe('delivered');
expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/); expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/);
}); });
@@ -175,6 +194,17 @@ describe('checkBoltTracking', () => {
await checkBoltTracking(); await checkBoltTracking();
const group = await getGroup(); const group = await getGroup();
expect(group.boltTrackingToken).toBeUndefined(); expect(group.boltTrackingToken).toBeUndefined();
expect(group.boltOrderState).toBe('delivered');
});
test('aktualizuje boltOrderState při změně stavu beze změny času', async () => {
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'waiting_preparation', expected_time_to_client_in_seconds: 1800 }));
await checkBoltTracking();
mockedAxios.post.mockResolvedValue(boltResponse({ order_state: 'preparing', expected_time_to_client_in_seconds: 1800 }));
await checkBoltTracking();
const group = await getGroup();
expect(group.boltOrderState).toBe('preparing');
expect(group.boltTrackingToken).toBe(TOKEN);
}); });
test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => { test('chybová odpověď Bolt API (code != 0) se počítá jako selhání', async () => {
+3
View File
@@ -810,6 +810,9 @@ OrderGroup:
boltTrackingToken: boltTrackingToken:
description: Token sdíleného sledování objednávky Bolt Food (poslední segment share URL). Pokud je vyplněn, server automaticky aktualizuje deliveryAt. description: Token sdíleného sledování objednávky Bolt Food (poslední segment share URL). Pokud je vyplněn, server automaticky aktualizuje deliveryAt.
type: string type: string
boltOrderState:
description: Poslední známý stav objednávky z Bolt API (raw order_state, např. waiting_preparation, preparing, delivered). Zůstává vyplněn i po ukončení sledování.
type: string
# --- NEVYŘÍZENÉ QR KÓDY --- # --- NEVYŘÍZENÉ QR KÓDY ---
PendingQr: PendingQr: