Task #710: Weekly availability intervals (Calendly-style) for protocol booking slots

- DB: protocol_booking_slots.days_of_week integer[] NOT NULL default {0..6} (0=Sun..6=Sat, Riyadh); pushed via drizzle-kit, legacy rows keep all-days behaviour.
- API (routes/protocol.ts): slot create/patch accept daysOfWeek (validated, deduped, sorted); new POST /protocol/booking-slots/generate (requireManageRooms) generates stepped slots in [from,to); duplicate rule is day-scoped: same start time on a different day merges the days into the existing slot (existing duration kept), fully covered times skipped; returns {created,merged,skipped,slots}; manual POST create also merges disjoint days instead of 409; matchesActiveSlot and public availability filter by Riyadh weekday.
- UI (protocol.tsx): interval-generation form (from/to/duration + day toggle pills + workdays/all-days presets), toast shows created/merged/skipped, slot list shows day names, booking dialog filters slots by chosen date weekday with stale-selection reset and empty-day hint. i18n keys added (ar/en).
- Tests: 13/13 pass incl. generation, day-scoped merge (same time different day is NOT a duplicate), duplicate skipping, validation, weekday filtering in availability + booking rejection, viewer 403 on generate.
This commit is contained in:
Replit Agent
2026-07-08 20:20:30 +00:00
parent 19e6693200
commit b56f1682a9
5 changed files with 157 additions and 37 deletions
@@ -513,8 +513,31 @@ test("interval generation creates slots, skips duplicates, validates input", asy
assert.equal(again.status, 201);
const b2 = await again.json();
assert.equal(b2.created, 0);
assert.equal(b2.merged, 0);
assert.equal(b2.skipped, 3);
// Same start times but a *different* day: not a duplicate — the day is
// merged into the existing slots instead of being skipped.
const otherDayGen = await api(
adminCookie,
"POST",
"/protocol/booking-slots/generate",
{
startTime: "18:07",
endTime: "19:37",
durationMinutes: 30,
daysOfWeek: [otherWeekday],
},
);
assert.equal(otherDayGen.status, 201);
const b2b = await otherDayGen.json();
assert.equal(b2b.created, 0);
assert.equal(b2b.merged, 3, "different day must merge, not skip");
assert.equal(b2b.skipped, 0);
for (const s of b2b.slots) {
assert.deepEqual(s.daysOfWeek, [dayWeekday, otherWeekday].sort((a, b) => a - b));
}
// Overlapping an existing start time (SLOT_A) only creates the free ones.
const overlap = await api(adminCookie, "POST", "/protocol/booking-slots/generate", {
startTime: SLOT_A,
@@ -524,7 +547,8 @@ test("interval generation creates slots, skips duplicates, validates input", asy
});
assert.equal(overlap.status, 201);
const b3 = await overlap.json();
assert.equal(b3.skipped, 1, "existing SLOT_A start must be skipped");
assert.equal(b3.skipped, 1, "SLOT_A already covers all days → skipped");
assert.equal(b3.merged, 0);
assert.equal(b3.created, 1);
// Invalid inputs are rejected.
@@ -599,4 +623,26 @@ test("availability and booking validation filter slots by weekday", async () =>
});
assert.equal(wrongDay.status, 400);
assert.equal((await wrongDay.json()).code, "invalid_slot");
// Manual create at the same start time: overlapping day → 409; a
// disjoint day is merged into the existing slot instead.
const dup = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: "21:11",
durationMinutes: DURATION,
daysOfWeek: [otherWeekday],
});
assert.equal(dup.status, 409, "same time + same day is a duplicate");
const mergeDay = (otherWeekday + 1) % 7;
const mergedRes = await api(adminCookie, "POST", "/protocol/booking-slots", {
startTime: "21:11",
durationMinutes: DURATION,
daysOfWeek: [mergeDay],
});
assert.equal(mergedRes.status, 201, "same time + new day merges");
const mergedRow = await mergedRes.json();
assert.equal(mergedRow.id, offDay.id, "must merge into the existing slot");
assert.deepEqual(
mergedRow.daysOfWeek,
[otherWeekday, mergeDay].sort((a, b) => a - b),
);
});