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) skipping existing start times (onConflictDoNothing), returns {created,skipped,slots}, 400 interval_too_short; matchesActiveSlot and public availability now filter by Riyadh weekday.
- UI (protocol.tsx): interval-generation form (from/to/duration + day toggle pills + workdays/all-days presets), 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, duplicate skipping, validation, weekday filtering in availability + booking rejection, viewer 403 on generate.
- Architect review: Pass; added the suggested authz test.
This commit is contained in:
Replit Agent
2026-07-08 20:16:33 +00:00
parent de407b6a8d
commit 19e6693200
6 changed files with 504 additions and 15 deletions
+23 -1
View File
@@ -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": "لا توجد أوقات متاحة في هذا اليوم"
}
}
}
+23 -1
View File
@@ -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"
}
}
}
+195 -7
View File
@@ -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<number[]>(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")}
</Button>
</div>
<div className="rounded-lg border border-slate-200 bg-slate-50 p-3 space-y-2">
<div>
<h4 className="text-sm font-semibold text-slate-700">
{t("protocol.slots.intervalTitle")}
</h4>
<p className="text-xs text-slate-500 mt-0.5">
{t("protocol.slots.intervalHint")}
</p>
</div>
<div className="flex flex-wrap items-end gap-2">
<div>
<Label className="text-xs">
{t("protocol.slots.intervalFrom")}
</Label>
<Input
type="time"
dir="ltr"
className="w-32"
value={intFrom}
onChange={(e) => setIntFrom(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">{t("protocol.slots.intervalTo")}</Label>
<Input
type="time"
dir="ltr"
className="w-32"
value={intTo}
onChange={(e) => setIntTo(e.target.value)}
/>
</div>
<div>
<Label className="text-xs">{t("protocol.slots.duration")}</Label>
<Input
type="number"
inputMode="numeric"
min={5}
max={480}
step={5}
className="w-24"
value={intDuration}
onChange={(e) => setIntDuration(e.target.value)}
/>
</div>
</div>
<div className="space-y-1.5">
<Label className="text-xs">{t("protocol.slots.days")}</Label>
<div className="flex flex-wrap gap-1.5">
{ALL_DAYS.map((d) => (
<button
key={d}
type="button"
onClick={() => toggleDay(d)}
className={cn(
"rounded-full border px-2.5 py-1 text-xs font-medium transition-colors",
intDays.includes(d)
? "border-sky-500 bg-sky-50 text-sky-700"
: "border-slate-200 bg-white text-slate-500 hover:border-slate-300",
)}
>
{t(`protocol.slots.dayNames.${d}`)}
</button>
))}
</div>
<div className="flex gap-2">
<button
type="button"
className="text-xs text-sky-600 hover:underline"
onClick={() => setIntDays(WORKDAYS)}
>
{t("protocol.slots.weekdaysPreset")}
</button>
<button
type="button"
className="text-xs text-sky-600 hover:underline"
onClick={() => setIntDays(ALL_DAYS)}
>
{t("protocol.slots.allDaysPreset")}
</button>
</div>
{intDays.length === 0 && (
<p className="text-xs text-amber-600">
{t("protocol.slots.pickDay")}
</p>
)}
</div>
<Button
size="sm"
disabled={!canGenerate || generateMut.isPending}
onClick={() => generateMut.mutate()}
>
<Plus size={16} className="me-1" />
{t("protocol.slots.generate")}
</Button>
</div>
{slots.length === 0 ? (
<p className="text-sm text-slate-400">{t("protocol.slots.empty")}</p>
) : (
@@ -2540,12 +2707,17 @@ function SlotsAdminSection({
className="flex items-center justify-between gap-2 rounded-lg border border-slate-200 px-3 py-2"
>
<div className="flex items-center gap-2 min-w-0">
<span
className="text-sm font-medium text-slate-700"
dir={isAr ? "rtl" : "ltr"}
>
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
</span>
<div className="min-w-0">
<span
className="block text-sm font-medium text-slate-700"
dir={isAr ? "rtl" : "ltr"}
>
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
</span>
<span className="block text-[11px] text-slate-500 truncate">
{fmtSlotDays(s.daysOfWeek, t, isAr)}
</span>
</div>
<span
className={cn(
"text-[11px] px-2 py-0.5 rounded-full shrink-0",
@@ -2928,6 +3100,17 @@ function BookingDialog({
return match ? String(match.id) : "";
});
const selectedSlot = slots.find((s) => 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({
/>
</SelectTrigger>
<SelectContent>
{slots.map((s) => (
{daySlots.map((s) => (
<SelectItem key={s.id} value={String(s.id)}>
<span dir={isAr ? "rtl" : "ltr"}>
{fmtSlotRange(s.startTime, s.durationMinutes, isAr)}
@@ -3147,6 +3330,11 @@ function BookingDialog({
))}
</SelectContent>
</Select>
{slotDate && daySlots.length === 0 && (
<p className="text-xs text-amber-600 mt-1">
{t("protocol.slots.noSlotsForDay")}
</p>
)}
</div>
</div>
) : (