diff --git a/artifacts/api-server/src/routes/protocol.ts b/artifacts/api-server/src/routes/protocol.ts index 6911f763..caec4d1c 100644 --- a/artifacts/api-server/src/routes/protocol.ts +++ b/artifacts/api-server/src/routes/protocol.ts @@ -286,9 +286,16 @@ function isUniqueViolation(e: unknown): boolean { } const slotTimeRegex = /^([01]\d|2[0-3]):[0-5]\d$/; +// Days of week: 0=Sunday .. 6=Saturday (Riyadh calendar). Deduped + sorted. +const daysOfWeekSchema = z + .array(z.number().int().min(0).max(6)) + .min(1) + .max(7) + .transform((days) => [...new Set(days)].sort((a, b) => a - b)); const slotCreateSchema = z.object({ startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"), durationMinutes: z.number().int().min(5).max(480).optional(), + daysOfWeek: daysOfWeekSchema.optional(), isActive: z.boolean().optional(), sortOrder: z.number().int().min(0).max(100000).optional(), }); @@ -296,6 +303,31 @@ const slotPatchSchema = slotCreateSchema .partial() .refine((v) => Object.keys(v).length > 0, { message: "no fields to update" }); +// Interval generation ("Calendly-style"): every `durationMinutes` step in +// [startTime, endTime) becomes a slot on the given days. HH:mm arithmetic +// happens on Riyadh wall-clock minutes, no timezone math needed. +const slotIntervalSchema = z + .object({ + startTime: z.string().regex(slotTimeRegex, "startTime must be HH:mm"), + endTime: z.string().regex(slotTimeRegex, "endTime must be HH:mm"), + durationMinutes: z.number().int().min(5).max(480), + daysOfWeek: daysOfWeekSchema, + }) + .refine((v) => hhmmToMinutes(v.startTime) < hhmmToMinutes(v.endTime), { + message: "startTime must be before endTime", + path: ["endTime"], + }); + +function hhmmToMinutes(hhmm: string): number { + const [h, m] = hhmm.split(":").map(Number); + return h * 60 + m; +} +function minutesToHHmm(total: number): string { + const h = Math.floor(total / 60); + const m = total % 60; + return `${String(h).padStart(2, "0")}:${String(m).padStart(2, "0")}`; +} + const settingsPatchSchema = z.object({ // Minimum advance notice for PUBLIC booking requests, in minutes. // 0 disables the restriction; capped at 7 days. @@ -492,13 +524,19 @@ async function getMinLeadMinutes(executor: DbExecutor): Promise { return Number.isInteger(n) && n >= 0 ? n : DEFAULT_MIN_LEAD_MINUTES; } -type ActiveSlot = { id: number; startTime: string; durationMinutes: number }; +type ActiveSlot = { + id: number; + startTime: string; + durationMinutes: number; + daysOfWeek: number[]; +}; async function getActiveSlots(executor: DbExecutor): Promise { return executor .select({ id: protocolBookingSlotsTable.id, startTime: protocolBookingSlotsTable.startTime, durationMinutes: protocolBookingSlotsTable.durationMinutes, + daysOfWeek: protocolBookingSlotsTable.daysOfWeek, }) .from(protocolBookingSlotsTable) .where(eq(protocolBookingSlotsTable.isActive, true)) @@ -522,12 +560,26 @@ function matchesActiveSlot( return false; } const wall = riyadhHHmm(startsAt); + const weekday = riyadhWeekday(startsAt); const durationMs = endsAt.getTime() - startsAt.getTime(); return slots.some( - (s) => s.startTime === wall && durationMs === s.durationMinutes * 60_000, + (s) => + s.startTime === wall && + durationMs === s.durationMinutes * 60_000 && + s.daysOfWeek.includes(weekday), ); } +// Day of week (0=Sunday .. 6=Saturday) of the Riyadh calendar day that +// contains `d`. Riyadh is fixed UTC+3, no DST. +function riyadhWeekday(d: Date): number { + return new Date(d.getTime() + 3 * 60 * 60 * 1000).getUTCDay(); +} +// Same for a plain "YYYY-MM-DD" Riyadh calendar day. +function ymdWeekday(dateStr: string): number { + return new Date(`${dateStr}T00:00:00Z`).getUTCDay(); +} + const INVALID_SLOT_MESSAGE_AR = "الوقت المحدد غير متاح للحجز، يرجى اختيار أحد الأوقات المتاحة."; const LEAD_TIME_MESSAGE_AR = @@ -797,7 +849,10 @@ router.get( ]); const now = Date.now(); - const result = slots.map((s) => { + const weekday = ymdWeekday(dateRaw); + const result = slots + .filter((s) => s.daysOfWeek.includes(weekday)) + .map((s) => { const start = slotStartOn(dateRaw, s.startTime); const end = new Date(start.getTime() + s.durationMinutes * 60_000); const booked = busy.some( @@ -868,6 +923,7 @@ router.post( .values({ startTime: body.startTime, durationMinutes: body.durationMinutes ?? 30, + daysOfWeek: body.daysOfWeek ?? [0, 1, 2, 3, 4, 5, 6], isActive: body.isActive ?? true, sortOrder: body.sortOrder ?? 0, createdBy: req.session.userId!, @@ -895,6 +951,58 @@ router.post( }, ); +// Generate slots from a weekly availability interval (Calendly-style): +// every `durationMinutes` step in [startTime, endTime) whose full duration +// still fits becomes a slot on the given days. Start times that already +// exist are skipped (global start_time uniqueness), never overwritten. +router.post( + "/protocol/booking-slots/generate", + requireManageRooms, + async (req: Request, res: Response) => { + const body = parseBody(res, slotIntervalSchema, req.body); + if (!body) return; + const from = hhmmToMinutes(body.startTime); + const to = hhmmToMinutes(body.endTime); + const candidates: string[] = []; + for (let m = from; m + body.durationMinutes <= to; m += body.durationMinutes) { + candidates.push(minutesToHHmm(m)); + } + if (candidates.length === 0) { + res.status(400).json({ + error: "الفترة أقصر من مدة الوقت الواحد، لا يمكن توليد أي وقت.", + code: "interval_too_short", + }); + return; + } + const inserted = await db + .insert(protocolBookingSlotsTable) + .values( + candidates.map((startTime) => ({ + startTime, + durationMinutes: body.durationMinutes, + daysOfWeek: body.daysOfWeek, + createdBy: req.session.userId!, + })), + ) + .onConflictDoNothing({ target: protocolBookingSlotsTable.startTime }) + .returning(); + for (const row of inserted) { + await logAudit(db, { + action: "slot.create", + entityType: "booking_slot", + entityId: row.id, + newValue: row, + performedBy: req.session.userId!, + }); + } + res.status(201).json({ + created: inserted.length, + skipped: candidates.length - inserted.length, + slots: inserted, + }); + }, +); + router.patch( "/protocol/booking-slots/:id", requireManageRooms, @@ -918,6 +1026,9 @@ router.patch( ...(body.durationMinutes !== undefined ? { durationMinutes: body.durationMinutes } : {}), + ...(body.daysOfWeek !== undefined + ? { daysOfWeek: body.daysOfWeek } + : {}), ...(body.isActive !== undefined ? { isActive: body.isActive } : {}), ...(body.sortOrder !== undefined ? { sortOrder: body.sortOrder } : {}), }) diff --git a/artifacts/api-server/tests/protocol-booking-slots.test.mjs b/artifacts/api-server/tests/protocol-booking-slots.test.mjs index c1c9fc9d..173b2f3f 100644 --- a/artifacts/api-server/tests/protocol-booking-slots.test.mjs +++ b/artifacts/api-server/tests/protocol-booking-slots.test.mjs @@ -193,12 +193,13 @@ after(async () => { await pool.query(`DELETE FROM protocol_booking_slots`); for (const s of savedSlots) { await pool.query( - `INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, is_active, sort_order, created_at) - VALUES ($1, $2, $3, $4, $5, $6)`, + `INSERT INTO protocol_booking_slots (id, start_time, duration_minutes, days_of_week, is_active, sort_order, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7)`, [ s.id, s.start_time, s.duration_minutes, + s.days_of_week, s.is_active, s.sort_order, s.created_at, @@ -424,7 +425,7 @@ test("protocol viewer can only read active slots; admin reads/writes are 403", a for (const row of activeRows) { assert.deepEqual( Object.keys(row).sort(), - ["durationMinutes", "id", "startTime"], + ["daysOfWeek", "durationMinutes", "id", "startTime"], "active endpoint must expose only minimal fields", ); } @@ -438,6 +439,13 @@ test("protocol viewer can only read active slots; admin reads/writes are 403", a startTime: "20:20", }); assert.equal(post.status, 403, "viewer must not create slots"); + const gen = await api(viewerCookie, "POST", "/protocol/booking-slots/generate", { + startTime: "10:00", + endTime: "12:00", + durationMinutes: 30, + daysOfWeek: [1], + }); + assert.equal(gen.status, 403, "viewer must not generate slots"); const patch = await api(viewerCookie, "PATCH", "/protocol/settings", { minLeadMinutes: 5, }); @@ -461,3 +469,134 @@ test("deleting a slot removes it from the admin list", async () => { const rows = await list.json(); assert.ok(!rows.some((s) => s.id === slotBId)); }); +// --------------------------------------------------------------------------- +// Task #710: weekly availability intervals + per-slot days of week. +// --------------------------------------------------------------------------- + +const dayWeekday = new Date(`${day}T00:00:00Z`).getUTCDay(); +const otherWeekday = (dayWeekday + 1) % 7; + +test("slots created without daysOfWeek default to every day (legacy behaviour)", async () => { + const list = await api(adminCookie, "GET", "/protocol/booking-slots"); + assert.equal(list.status, 200); + const a = (await list.json()).find((s) => s.id === slotAId); + assert.ok(a, "slot A should still exist"); + assert.deepEqual(a.daysOfWeek, [0, 1, 2, 3, 4, 5, 6]); +}); + +test("interval generation creates slots, skips duplicates, validates input", async () => { + const gen = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { + startTime: "18:07", + endTime: "19:37", + durationMinutes: 30, + daysOfWeek: [dayWeekday], + }); + assert.equal(gen.status, 201); + const body = await gen.json(); + assert.equal(body.created, 3, "18:07, 18:37, 19:07 should be generated"); + assert.equal(body.skipped, 0); + assert.deepEqual( + body.slots.map((s) => s.startTime).sort(), + ["18:07", "18:37", "19:07"], + ); + for (const s of body.slots) { + assert.deepEqual(s.daysOfWeek, [dayWeekday]); + } + + // Re-running the exact same interval generates nothing new. + const again = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { + startTime: "18:07", + endTime: "19:37", + durationMinutes: 30, + daysOfWeek: [dayWeekday], + }); + assert.equal(again.status, 201); + const b2 = await again.json(); + assert.equal(b2.created, 0); + assert.equal(b2.skipped, 3); + + // 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, + endTime: isoLikeAdd(SLOT_A, 60), + durationMinutes: 30, + daysOfWeek: [0, 1, 2, 3, 4, 5, 6], + }); + 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.created, 1); + + // Invalid inputs are rejected. + const badRange = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { + startTime: "12:00", + endTime: "11:00", + durationMinutes: 30, + daysOfWeek: [1], + }); + assert.equal(badRange.status, 400, "from must be before to"); + const badDay = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { + startTime: "11:00", + endTime: "12:00", + durationMinutes: 30, + daysOfWeek: [7], + }); + assert.equal(badDay.status, 400, "day 7 is invalid"); + const tooShort = await api(adminCookie, "POST", "/protocol/booking-slots/generate", { + startTime: "11:00", + endTime: "11:10", + durationMinutes: 30, + daysOfWeek: [1], + }); + assert.equal(tooShort.status, 400); + assert.equal((await tooShort.json()).code, "interval_too_short"); +}); + +// "HH:mm" plus minutes, staying inside the same day (test helper). +function isoLikeAdd(hhmm, plusMinutes) { + const [h, m] = hhmm.split(":").map(Number); + const total = h * 60 + m + plusMinutes; + return `${String(Math.floor(total / 60)).padStart(2, "0")}:${String( + total % 60, + ).padStart(2, "0")}`; +} + +test("availability and booking validation filter slots by weekday", async () => { + // A slot only offered on a different weekday than `day`. + const res = await api(adminCookie, "POST", "/protocol/booking-slots", { + startTime: "21:11", + durationMinutes: DURATION, + daysOfWeek: [otherWeekday], + }); + assert.equal(res.status, 201); + const offDay = await res.json(); + assert.deepEqual(offDay.daysOfWeek, [otherWeekday]); + + // Public availability for `day` must not list the off-day slot but must + // still list slot A (all days). + const avail = await fetch( + `${API_BASE}/api/protocol/public/availability?roomId=${roomId}&date=${day}`, + ); + assert.equal(avail.status, 200); + const data = await avail.json(); + assert.ok( + !data.slots.some((s) => s.id === offDay.id), + "off-day slot must be hidden for this date", + ); + assert.ok( + data.slots.some((s) => s.id === slotAId), + "all-days slot must still be listed", + ); + + // Internal booking on the off-day slot's time is rejected: right start + // time, wrong weekday. + const wrongDay = await api(adminCookie, "POST", "/protocol/bookings", { + roomId, + title: "Wrong weekday booking", + requesterName: "Staff", + startsAt: isoAt("21:11"), + endsAt: isoAt("21:11", DURATION), + }); + assert.equal(wrongDay.status, 400); + assert.equal((await wrongDay.json()).code, "invalid_slot"); +}); diff --git a/artifacts/tx-os/src/locales/ar.json b/artifacts/tx-os/src/locales/ar.json index 20dbebb9..6f854c14 100644 --- a/artifacts/tx-os/src/locales/ar.json +++ b/artifacts/tx-os/src/locales/ar.json @@ -1911,7 +1911,29 @@ "leadSaved": "تم حفظ المهلة", "dateLabel": "التاريخ", "slotLabel": "الوقت", - "pickSlot": "اختر الوقت" + "pickSlot": "اختر الوقت", + "days": "الأيام", + "allDays": "كل الأيام", + "weekdaysPreset": "أيام العمل", + "allDaysPreset": "كل الأيام", + "dayNames": { + "0": "الأحد", + "1": "الاثنين", + "2": "الثلاثاء", + "3": "الأربعاء", + "4": "الخميس", + "5": "الجمعة", + "6": "السبت" + }, + "intervalTitle": "إضافة فترة توفر", + "intervalHint": "حدد فترة (من – إلى) والأيام ومدة الوقت، وستتولّد الأوقات تلقائيًا ضمن الفترة. الأوقات المكررة تُتجاهل.", + "intervalFrom": "من", + "intervalTo": "إلى", + "generate": "توليد الأوقات", + "generated": "تم توليد {{created}} وقتًا (تم تجاهل {{skipped}} مكررًا)", + "generatedNone": "لم يتولد أي وقت — كل الأوقات موجودة مسبقًا.", + "pickDay": "اختر يومًا واحدًا على الأقل", + "noSlotsForDay": "لا توجد أوقات متاحة في هذا اليوم" } } } diff --git a/artifacts/tx-os/src/locales/en.json b/artifacts/tx-os/src/locales/en.json index 14cb214e..6d0d9a92 100644 --- a/artifacts/tx-os/src/locales/en.json +++ b/artifacts/tx-os/src/locales/en.json @@ -1764,7 +1764,29 @@ "leadSaved": "Lead time saved", "dateLabel": "Date", "slotLabel": "Time", - "pickSlot": "Pick a time" + "pickSlot": "Pick a time", + "days": "Days", + "allDays": "Every day", + "weekdaysPreset": "Workdays", + "allDaysPreset": "All days", + "dayNames": { + "0": "Sun", + "1": "Mon", + "2": "Tue", + "3": "Wed", + "4": "Thu", + "5": "Fri", + "6": "Sat" + }, + "intervalTitle": "Add availability interval", + "intervalHint": "Pick a range (from – to), the days and the slot duration; slots are generated automatically within the range. Duplicate start times are skipped.", + "intervalFrom": "From", + "intervalTo": "To", + "generate": "Generate slots", + "generated": "{{created}} slot(s) generated ({{skipped}} duplicate(s) skipped)", + "generatedNone": "No slots generated — all start times already exist.", + "pickDay": "Pick at least one day", + "noSlotsForDay": "No slots available on this day" } } } diff --git a/artifacts/tx-os/src/pages/protocol.tsx b/artifacts/tx-os/src/pages/protocol.tsx index b1bbfc32..08be71a9 100644 --- a/artifacts/tx-os/src/pages/protocol.tsx +++ b/artifacts/tx-os/src/pages/protocol.tsx @@ -104,6 +104,7 @@ type Slot = { id: number; startTime: string; // "HH:mm" Riyadh wall clock durationMinutes: number; + daysOfWeek: number[]; // 0=Sunday .. 6=Saturday (Riyadh calendar) isActive: boolean; sortOrder: number; }; @@ -114,8 +115,32 @@ type ActiveSlotInfo = { id: number; startTime: string; durationMinutes: number; + daysOfWeek: number[]; }; +// Weekday (0=Sunday .. 6=Saturday) of a plain "YYYY-MM-DD" calendar day. +function ymdWeekday(ymd: string): number { + return new Date(`${ymd}T00:00:00Z`).getUTCDay(); +} + +const ALL_DAYS = [0, 1, 2, 3, 4, 5, 6]; +// Saudi work week: Sunday..Thursday. +const WORKDAYS = [0, 1, 2, 3, 4]; + +function fmtSlotDays( + days: number[], + t: (k: string) => string, + isAr: boolean, +): string { + if (ALL_DAYS.every((d) => days.includes(d))) { + return t("protocol.slots.allDays"); + } + return [...days] + .sort((a, b) => a - b) + .map((d) => t(`protocol.slots.dayNames.${d}`)) + .join(isAr ? "، " : ", "); +} + type BookingStatus = "pending" | "approved" | "rejected" | "cancelled"; type MeetingType = "internal" | "external"; type KeyAttendee = { @@ -2412,6 +2437,17 @@ function SlotsAdminSection({ const qc = useQueryClient(); const [newTime, setNewTime] = useState(""); const [newDuration, setNewDuration] = useState("30"); + // Interval-generation form (Calendly-style weekly availability). + const [intFrom, setIntFrom] = useState(""); + const [intTo, setIntTo] = useState(""); + const [intDuration, setIntDuration] = useState("30"); + const [intDays, setIntDays] = useState(WORKDAYS); + const toggleDay = (d: number) => + setIntDays((prev) => + prev.includes(d) + ? prev.filter((x) => x !== d) + : [...prev, d].sort((a, b) => a - b), + ); // Full slot list (incl. inactive) — admin-only endpoint; this component is // only rendered for canManageRooms. @@ -2450,6 +2486,34 @@ function SlotsAdminSection({ }, onError, }); + const generateMut = useMutation({ + mutationFn: () => + apiJson<{ created: number; skipped: number }>( + `${API}/protocol/booking-slots/generate`, + { + method: "POST", + body: JSON.stringify({ + startTime: intFrom, + endTime: intTo, + durationMinutes: Number(intDuration), + daysOfWeek: intDays, + }), + }, + ), + onSuccess: (r) => { + toast({ + title: + r.created === 0 + ? t("protocol.slots.generatedNone") + : t("protocol.slots.generated", { + created: r.created, + skipped: r.skipped, + }), + }); + changed(); + }, + onError, + }); const toggleMut = useMutation({ mutationFn: (s: Slot) => apiJson(`${API}/protocol/booking-slots/${s.id}`, { @@ -2480,6 +2544,12 @@ function SlotsAdminSection({ }); const canAdd = /^\d{2}:\d{2}$/.test(newTime) && Number(newDuration) >= 5; + const canGenerate = + /^\d{2}:\d{2}$/.test(intFrom) && + /^\d{2}:\d{2}$/.test(intTo) && + intFrom < intTo && + Number(intDuration) >= 5 && + intDays.length > 0; const leadDirty = leadInput !== null && settings.data !== undefined && @@ -2530,6 +2600,103 @@ function SlotsAdminSection({ {t("protocol.slots.add")} + +
+
+

+ {t("protocol.slots.intervalTitle")} +

+

+ {t("protocol.slots.intervalHint")} +

+
+
+
+ + setIntFrom(e.target.value)} + /> +
+
+ + setIntTo(e.target.value)} + /> +
+
+ + setIntDuration(e.target.value)} + /> +
+
+
+ +
+ {ALL_DAYS.map((d) => ( + + ))} +
+
+ + +
+ {intDays.length === 0 && ( +

+ {t("protocol.slots.pickDay")} +

+ )} +
+ +
{slots.length === 0 ? (

{t("protocol.slots.empty")}

) : ( @@ -2540,12 +2707,17 @@ function SlotsAdminSection({ className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 px-3 py-2" >
- - {fmtSlotRange(s.startTime, s.durationMinutes, isAr)} - +
+ + {fmtSlotRange(s.startTime, s.durationMinutes, isAr)} + + + {fmtSlotDays(s.daysOfWeek, t, isAr)} + +
String(s.id) === slotId); + // Only slots offered on the chosen date's weekday (Riyadh calendar) can + // be picked — same rule the server enforces. + const daySlots = slotDate + ? slots.filter((s) => s.daysOfWeek.includes(ymdWeekday(slotDate))) + : slots; + useEffect(() => { + if (slotId && !daySlots.some((s) => String(s.id) === slotId)) { + setSlotId(""); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [slotDate]); const [notes, setNotes] = useState(edit?.notes ?? ""); const [requesterPhone, setRequesterPhone] = useState( edit?.requesterPhone ?? "", @@ -3138,7 +3321,7 @@ function BookingDialog({ /> - {slots.map((s) => ( + {daySlots.map((s) => ( {fmtSlotRange(s.startTime, s.durationMinutes, isAr)} @@ -3147,6 +3330,11 @@ function BookingDialog({ ))} + {slotDate && daySlots.length === 0 && ( +

+ {t("protocol.slots.noSlotsForDay")} +

+ )}
) : ( diff --git a/lib/db/src/schema/protocol.ts b/lib/db/src/schema/protocol.ts index cd5e9a1a..c551a7f1 100644 --- a/lib/db/src/schema/protocol.ts +++ b/lib/db/src/schema/protocol.ts @@ -361,6 +361,13 @@ export const protocolBookingSlotsTable = pgTable( // padded). Uniqueness is enforced so two slots can't start together. startTime: varchar("start_time", { length: 5 }).notNull(), durationMinutes: integer("duration_minutes").notNull().default(30), + // Days of week (0=Sunday .. 6=Saturday, Riyadh calendar) on which this + // slot is offered. Defaults to every day so pre-existing rows keep their + // old behaviour. + daysOfWeek: integer("days_of_week") + .array() + .notNull() + .default([0, 1, 2, 3, 4, 5, 6]), isActive: boolean("is_active").notNull().default(true), sortOrder: integer("sort_order").notNull().default(0), createdBy: integer("created_by").references(() => usersTable.id, {