feat: add better tab view
CI / build-and-test (push) Canceled after 0s

This commit is contained in:
2026-10-28 00:37:16 +01:00
parent bca6e88138
commit ca3af671cf
37 changed files with 740 additions and 79 deletions
+90
View File
@@ -0,0 +1,90 @@
import { TabEventDto } from '../devices/dto/tab-event.dto';
import { tabSummary, VenueTabs } from './tab-state';
import { tabOrders } from './tab-orders';
const at = (minutes: number) => new Date(Date.UTC(2026, 9, 28, 12, 30 + minutes));
let counter = 0;
function event(fields: Partial<TabEventDto> & { type: TabEventDto['type'] }): TabEventDto {
return { id: `e${++counter}`, tabId: 't1', customerName: 'Jan', occurredAt: at(0).toISOString(), ...fields } as TabEventDto;
}
const duvel = { lineId: 'duvel', productId: 'duvel', productName: 'Duvel', unitPriceInCents: 400 };
const cola = { lineId: 'cola', productId: 'cola', productName: 'Cola', unitPriceInCents: 250 };
const beer = { lineId: 'beer', productId: 'beer', productName: 'Beer', unitPriceInCents: 300 };
function tabWith(changes: [minutes: number, fields: Partial<TabEventDto> & { type: TabEventDto['type'] }][]) {
const tabs = new VenueTabs([]);
tabs.apply(event({ type: 'tab_opened' }), at(0));
for (const [minutes, fields] of changes) tabs.apply(event(fields), at(minutes));
return tabs;
}
describe('tabOrders', () => {
it('shows the drinks of each order under the time it started', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }],
[1, { type: 'line_added', quantity: 1, orderId: 'o1', ...cola }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
[39, { type: 'line_added', quantity: 1, orderId: 'o2', ...beer }],
]);
expect(tabOrders(tabs.get('t1')!.lines)).toEqual([
{
orderId: 'o1', startedAt: at(0).toISOString(),
items: [
{ productName: 'Duvel', quantity: 2, unitPriceInCents: 400, removed: 0 },
{ productName: 'Cola', quantity: 1, unitPriceInCents: 250, removed: 0 },
],
},
{
orderId: 'o2', startedAt: at(38).toISOString(),
items: [
{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400, removed: 0 },
{ productName: 'Beer', quantity: 1, unitPriceInCents: 300, removed: 0 },
],
},
]);
});
it('groups drinks from older app versions by time: a gap of over 5 minutes starts a new order', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 1, ...duvel }],
[4, { type: 'line_added', quantity: 1, ...cola }],
[20, { type: 'line_added', quantity: 1, ...beer }],
]);
const orders = tabOrders(tabs.get('t1')!.lines);
expect(orders.map((order) => [order.orderId, order.startedAt, order.items.map((item) => item.productName)])).toEqual([
[null, at(0).toISOString(), ['Duvel', 'Cola']],
[null, at(20).toISOString(), ['Beer']],
]);
});
it('takes a drink off the order it is removed from, and leaves the others', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 2, orderId: 'o1', ...duvel }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
[40, { type: 'line_removed', quantity: 1, orderId: 'o1', ...duvel }],
]);
expect(tabOrders(tabs.get('t1')!.lines).map((order) => [order.orderId, order.items[0].quantity, order.items[0].removed]))
.toEqual([['o1', 1, 1], ['o2', 1, 0]]);
// The open tab only shows what is still on it
expect(tabSummary({ ...tabs.get('t1')!, updatedAt: at(40) }).orders.map((order) => order.items))
.toEqual([[{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }], [{ productName: 'Duvel', quantity: 1, unitPriceInCents: 400 }]]);
});
it('still takes the latest drinks off for a removal without an order (older app versions)', () => {
const tabs = tabWith([
[0, { type: 'line_added', quantity: 1, orderId: 'o1', ...duvel }],
[38, { type: 'line_added', quantity: 1, orderId: 'o2', ...duvel }],
]);
const removal = event({ type: 'line_removed', quantity: 5, ...duvel });
expect(tabs.removable(removal)).toBe(2);
expect(tabs.removable({ ...removal, orderId: 'o1' })).toBe(1);
expect(tabs.removable({ ...removal, orderId: null })).toBe(0);
});
});
+62
View File
@@ -0,0 +1,62 @@
import { isActive, Line, Unit } from './tab-state';
/** Drinks rung up without an order (older app versions) that follow each other this closely form one order. */
export const ORDER_GAP_MS = 5 * 60 * 1000;
export type OrderLine = { productName: string; quantity: number; unitPriceInCents: number; removed: number };
/** Drinks ordered together, as the terminal shows them under one "12:32 today" divider. */
export type TabOrder = {
// Null for drinks from older app versions, grouped by time instead
orderId: string | null;
// When its first drink was rung up
startedAt: string;
items: OrderLine[];
};
/**
* Splits a tab's (or settlement's) lines into its orders, oldest first. Within an order every line shows how many of
* its drinks are still on it and how many were taken off again.
*/
export function tabOrders(lines: Line[]): TabOrder[] {
const drinks = lines
.flatMap((line) => line.units.map((unit) => ({ line, unit })))
.sort((a, b) => a.unit.addedAt.localeCompare(b.unit.addedAt) || a.unit.id.localeCompare(b.unit.id));
const orders: { orderId: string | null; startedAt: string; units: Map<Line, Unit[]> }[] = [];
const byId = new Map<string, (typeof orders)[number]>();
let unordered: { order: (typeof orders)[number]; lastAt: number } | null = null;
for (const { line, unit } of drinks) {
let order: (typeof orders)[number];
if (unit.orderId) {
order = byId.get(unit.orderId) ?? { orderId: unit.orderId, startedAt: unit.addedAt, units: new Map() };
if (!byId.has(unit.orderId)) {
byId.set(unit.orderId, order);
orders.push(order);
}
} else {
const at = Date.parse(unit.addedAt);
if (!unordered || at - unordered.lastAt > ORDER_GAP_MS) {
unordered = { order: { orderId: null, startedAt: unit.addedAt, units: new Map() }, lastAt: at };
orders.push(unordered.order);
}
unordered.lastAt = at;
order = unordered.order;
}
order.units.set(line, [...(order.units.get(line) ?? []), unit]);
}
return orders
.sort((a, b) => a.startedAt.localeCompare(b.startedAt))
.map(({ orderId, startedAt, units }) => ({
orderId,
startedAt,
items: [...units].map(([line, lineUnits]) => ({
productName: line.productName,
quantity: lineUnits.filter(isActive).length,
unitPriceInCents: line.unitPriceInCents,
removed: lineUnits.filter((unit) => !isActive(unit)).length,
})),
}));
}
+25 -6
View File
@@ -1,5 +1,6 @@
import { TabEventDto } from '../devices/dto/tab-event.dto';
import { OpenTabDto } from '../devices/dto/sync.dto';
import { tabOrders } from './tab-orders';
/**
* The venue's open tabs and the rules that change them. Pure: the ledger loads the state, applies events here and
@@ -7,8 +8,11 @@ import { OpenTabDto } from '../devices/dto/sync.dto';
* derived the same way on both sides (`<event id>:<index>`).
*/
/** One piece ever put on a line. */
export type Unit = { id: string; addedAt: string; removedAt: string | null };
/**
* One piece ever put on a line. [orderId] is the order it was rung up in on the terminal: drinks ordered together are
* shown together. Missing for drinks from older app versions.
*/
export type Unit = { id: string; addedAt: string; removedAt: string | null; orderId?: string | null };
export type Line = {
id: string;
@@ -46,6 +50,16 @@ export const isActive = (unit: Unit) => unit.removedAt === null;
export const lineQuantity = (line: Line) => line.units.filter(isActive).length;
/**
* The drinks of [line] a line_removed event may take off: those of its order (null: drinks without one), or any when
* the event has no order (older app versions).
*/
function removableUnits(line: Line, event: { orderId?: string | null }): Unit[] {
const active = line.units.filter(isActive);
if (event.orderId === undefined) return active;
return active.filter((unit) => (unit.orderId ?? null) === event.orderId);
}
export class VenueTabs {
private readonly tabs: Map<string, TabState>;
/** Tabs to write back, and tabs to delete. */
@@ -110,8 +124,9 @@ export class VenueTabs {
tab.lines.push(line);
}
const addedAt = occurredAt.toISOString();
const orderId = event.orderId ?? null;
for (let index = 0; index < event.quantity!; index++) {
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null });
line.units.push({ id: `${event.id}:${index}`, addedAt, removedAt: null, orderId });
}
this.touch(tabId);
return null;
@@ -121,8 +136,8 @@ export class VenueTabs {
const tab = this.tabs.get(tabId);
const line = tab && findLine(tab, event);
if (!line) return null;
// The latest ones: the drinks that were just rung up by mistake
const active = line.units.filter(isActive);
// The latest ones of the order: the drinks that were just rung up by mistake
const active = removableUnits(line, event);
for (const unit of active.slice(Math.max(0, active.length - event.quantity!))) {
unit.removedAt = occurredAt.toISOString();
}
@@ -178,7 +193,7 @@ export class VenueTabs {
removable(event: TabEventDto): number {
const tab = event.tabId ? this.tabs.get(event.tabId) : undefined;
const line = tab && findLine(tab, event);
return line ? Math.min(event.quantity ?? 0, lineQuantity(line)) : 0;
return line ? Math.min(event.quantity ?? 0, removableUnits(line, event).length) : 0;
}
/** Settles everything still on the tab, as the dashboard does. Null when there is nothing to settle. */
@@ -311,6 +326,10 @@ export function tabSummary(tab: { id: string; customerName: string; version: num
totalInCents: lines.reduce((sum, line) => sum + line.quantity * line.unitPriceInCents, 0),
itemCount: lines.reduce((sum, line) => sum + line.quantity, 0),
items: lines,
// Only what is still on the tab: drinks taken off again are left out
orders: tabOrders(tab.lines as Line[])
.map((order) => ({ ...order, items: order.items.filter((item) => item.quantity > 0).map(({ removed, ...item }) => item) }))
.filter((order) => order.items.length > 0),
updatedAt: tab.updatedAt,
};
}
+4 -1
View File
@@ -5,6 +5,7 @@ import { PrismaService } from '../prisma/prisma.service';
import { RealtimeService } from '../realtime/realtime.service';
import { customerNameKey } from '../planned-tabs/customer-name';
import { SettledLine, tabSummary } from './tab-state';
import { tabOrders } from './tab-orders';
import { VenueLedger } from './venue-ledger';
@Injectable()
@@ -72,7 +73,8 @@ export class TabsService {
return {
settlements: rows.map((row) => {
const items = (row.lines as unknown as SettledLine[]).map((line) => ({
const lines = row.lines as unknown as SettledLine[];
const items = lines.map((line) => ({
productName: line.productName,
quantity: line.quantity,
unitPriceInCents: line.unitPriceInCents,
@@ -88,6 +90,7 @@ export class TabsService {
amountInCents: row.amountInCents,
itemCount: items.reduce((count, item) => count + item.quantity, 0),
items,
orders: tabOrders(lines),
settledBy: (row.deviceId ? deviceNames.get(row.deviceId) : row.requestedBy) ?? null,
onDashboard: row.deviceId === null,
};