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
+17 -9
View File
@@ -102,15 +102,23 @@ export async function checkBoltTracking(): Promise<void> {
for (const group of candidates) {
let deliveryAt: string | undefined;
let orderState: string | undefined;
let clearToken = false;
try {
const order = await pollBoltOrder(group.boltTrackingToken!);
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 if (typeof order.expected_time_to_client_in_seconds === 'number') {
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
} else {
orderState = order.order_state || undefined;
if (TERMINAL_STATE_REGEX.test(order.order_state ?? '')) {
clearToken = true;
} else if (typeof order.expected_time_to_client_in_seconds === 'number') {
deliveryAt = computeDeliveryHHMM(order.expected_time_to_client_in_seconds);
}
}
} catch (e) {
const failures = (consecutiveFailures.get(group.id) ?? 0) + 1;
@@ -121,17 +129,17 @@ export async function checkBoltTracking(): Promise<void> {
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 => {
const d = current ?? data!;
const g = d.groups?.find(x => x.id === group.id);
if (g?.boltTrackingToken) {
if (clearToken) {
g.boltTrackingToken = undefined;
} else {
g.deliveryAt = deliveryAt;
}
if (timeChanged) g.deliveryAt = deliveryAt;
if (stateChanged) g.boltOrderState = orderState;
if (clearToken) g.boltTrackingToken = undefined;
}
return d;
});
+7 -1
View File
@@ -152,6 +152,7 @@ export async function setGroupState(login: string, groupId: string, newState: Gr
group.deliveryAt = undefined;
group.qrGenerated = undefined;
group.boltTrackingToken = undefined;
group.boltOrderState = undefined;
for (const ml of memberLogins) {
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 (!shareUrl) {
group.boltTrackingToken = undefined;
group.boltOrderState = undefined;
} else {
if (group.state !== GroupState.ORDERED) throw new Error('Sledování Bolt lze nastavit pouze ve stavu "objednáno"');
const token = extractBoltToken(shareUrl);
if (!token) throw new Error('Neplatný odkaz na sledování objednávky Bolt');
group.boltTrackingToken = token;
if (token !== group.boltTrackingToken) {
group.boltTrackingToken = token;
// Stav patří k předchozí objednávce — vyčistíme, doplní ho první poll
group.boltOrderState = undefined;
}
}
return saveExtraData(data, date);
}
+5
View File
@@ -4,6 +4,7 @@ import { parseToken } from "../utils";
import { getWebsocket } from "../websocket";
import { createGroup, deleteGroup, addGroupMember, removeGroupMember, updateGroupMember, setGroupState, updateGroupTimes, updateGroupFees, setGroupBoltTracking, getOrderDates } from "../groups";
import { GroupState } from "../../../types/gen/types.gen";
import { checkBoltTracking } from "../boltTracking";
const router = express.Router();
@@ -171,6 +172,10 @@ router.post("/setBoltTracking", async (req: Request, res, next) => {
const data = await setGroupBoltTracking(login, id, shareUrl);
broadcastExtra(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); }
});
+31 -1
View File
@@ -97,10 +97,27 @@ describe('setGroupBoltTracking', () => {
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);
const d = await setGroupBoltTracking(CREATOR, groupId, '', TODAY);
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 () => {
@@ -143,6 +160,7 @@ describe('checkBoltTracking', () => {
const after = computeDeliveryHHMM(1800);
const group = await getGroup();
expect([before, after]).toContain(group.deliveryAt);
expect(group.boltOrderState).toBe('waiting_preparation');
expect(group.boltTrackingToken).toBe(TOKEN);
expect(mockEmit).toHaveBeenCalledWith('message', expect.anything());
expect(mockedAxios.post).toHaveBeenCalledWith(
@@ -167,6 +185,7 @@ describe('checkBoltTracking', () => {
await checkBoltTracking();
const group = await getGroup();
expect(group.boltTrackingToken).toBeUndefined();
expect(group.boltOrderState).toBe('delivered');
expect(group.deliveryAt).toMatch(/^\d{2}:\d{2}$/);
});
@@ -175,6 +194,17 @@ describe('checkBoltTracking', () => {
await checkBoltTracking();
const group = await getGroup();
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 () => {