Opening hours
Weekly opening windows shown on the storefront, edited in the dashboard, and used to gate pickup checkout.
Opening hours
Slice 8 stored weekly opening windows and showed them. Slice 12 (Hours → Checkout-Gate) rejects pickup checkout when the hostname restaurant is closed at now. Display stays; gating is added.
One location per restaurant. Hours hang on restaurant_id. There is no timezone column; clock times are local, the same EUR/local convention as prices.
Weekday and windows
Weekday is an integer 0–6 (Monday–Sunday, ISO weekday minus 1). There was no prior domain vocabulary for days.
Each window has opens_at / closes_at as local HH:MM. opens_at must be before closes_at (no overnight window). Several windows on the same day are allowed. Adjacent windows (14:00 close then 14:00 open) are valid. Overlapping windows on the same weekday return 400.
An empty week (no windows) is valid to save. It means closed: checkout is 409.
Open-at rule
Domain: isOpenAt(windows, now: Date) → boolean. Weekday comes from now with the same 0–6 convention. A window matches when opensAt <= clock < closesAt (open inclusive, close exclusive). Adjacent windows share the boundary instant with the later window. An empty list is closed.
Checkout in checkoutStorefrontCart takes now: Date = new Date() (same injectable-clock pattern as expirePendingOrders). Tests freeze now; they must not use wall-clock.
Table
restaurant_opening_windows
| Column | Notes |
|---|---|
id | UUID |
restaurant_id | FK → restaurants.id (ON DELETE restrict). Tenant key. Indexed |
weekday | Integer 0–6 (Monday–Sunday) |
opens_at | PostgreSQL time (no time zone), public contract HH:MM |
closes_at | Same. Check constraint opens_at < closes_at |
created_at / updated_at | Timestamps |
Overlap is enforced in domain/API, not as a PostgreSQL exclusion constraint.
Public API
GET /storefront/restaurant includes hours.windows for the hostname tenant.
GET /storefront/hours is the same week as a dedicated resource ({ restaurantId, windows }).
- Unknown or inactive restaurant →
404(Restaurant not found) - Empty week →
200withwindows: [] - Query/body
restaurantIdis ignored - Cart and menu GETs stay 200 when closed
Example (Luigi, Host luigi.bytebite.test):
{
"restaurantId": "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaa1",
"windows": [
{ "weekday": 0, "opensAt": "11:00", "closesAt": "14:00" },
{ "weekday": 0, "opensAt": "17:00", "closesAt": "22:00" },
{ "weekday": 4, "opensAt": "11:00", "closesAt": "14:00" },
{ "weekday": 5, "opensAt": "12:00", "closesAt": "22:00" }
]
}POST /storefront/checkout is host-tenant scoped. Body restaurantId is ignored.
- Open window → existing checkout (pending, cash, unpaid, snapshot prices)
- Closed or empty windows → 409 (
Restaurant is closed). Cart is unchanged. No order row - Unknown or inactive host → 404, not 409
Dashboard API
Requires a staff session. Restaurant context is the path (ADR-021), not the storefront hostname. Hours GET/PUT are otherwise unchanged.
| Method | Path | Notes |
|---|---|---|
GET | /dashboard/restaurants/:restaurantId/hours | Week for that restaurant |
PUT | /dashboard/restaurants/:restaurantId/hours | Atomically replaces the week |
PUT body: { "windows": [ { "weekday": 0, "opensAt": "11:00", "closesAt": "14:00" } ] }. Extra restaurantId in the body is ignored.
Unknown restaurantId → 404. Overlap / opens_at >= closes_at / malformed times → 400. Luigi writes are not visible on Mario’s GET or Mario’s storefront host. Hours have no child id in the path; a Luigi week cannot be mutated through Mario’s restaurantId (Mario GET stays Mario’s rows; unknown ids are 404).
The dashboard UI is / (http://dashboard.bytebite.test/). It uses the same restaurant switcher as Menu and Orders.
Seeds
Luigi and Mario get disjoint example windows so isolation is obvious.
- Luigi: Mon 11:00–14:00 and 17:00–22:00, Fri 11:00–14:00, Sat 12:00–22:00
- Mario: Tue 12:00–21:00, Sun 12:00–16:00
Seeds still create no orders. Isolation is proven by real PUTs and frozen-clock checkouts in tests, not by seed orders.
Storefront
The tenant start page shows the week when windows is non-empty, plus Open/Closed. When closed, the primary checkout CTA is disabled and copy explains that pickup is unavailable. Guests can still browse the menu and keep a cart.
Frozen-clock isolation
Tests use a frozen local now, never new Date():
| Clock | Luigi host | Mario host |
|---|---|---|
| Monday 12:00 | 200 pending | closed 409 |
| Monday 15:00 or Tuesday | 409, no order | (Tuesday 13:00 Mario 200) |
Luigi windows: [] | 409 | unchanged |
| Unknown host | 404, not 409 | 404, not 409 |
Body restaurantId of the other tenant | hostname tenant only | hostname tenant only |
Order pause
restaurants.orders_paused is a separate switch. Weekly windows stay the only clock. Outside those windows checkout is still 409 Restaurant is closed, even if the pause is on. Inside the windows, a pause rejects pickup and delivery with 409 Restaurant is not taking orders and leaves the cart. The menu stays public. PUT of the weekly hours does not clear the pause. PATCH /dashboard/restaurants/:restaurantId with { ordersPaused } is 404 for an unknown restaurant.
Not in this slice
Holidays, special hours, vacation, timezone column, overnight windows, BitePay, e-mail, sound/PWA, production deploy. Dashboard hours writes already require a staff session (Staff authentication). Kitchen status: Kitchen status. SMS on accept/reject/timeout/ready: Transactional notifications.