Task #662: Google-Calendar-style room booking calendar view

Replaced the Protocol bookings "Calendar" view (previously an agenda list
grouped by day) with a two-panel Google-Calendar layout in
artifacts/tx-os/src/pages/protocol.tsx:
- LEFT: a mini month day-picker (reuses components/ui/calendar.tsx,
  react-day-picker single mode) driving a selectedDay state. Days that
  have bookings get a small sky dot via a `booked` modifier.
- RIGHT: a single-day vertical time-grid (BookingDayGrid) over working
  hours 07:00–22:00 (56px/hour). Bookings render as absolutely-positioned
  blocks: top = start offset, height = duration (min 22px), colored by
  status (BOOKING_BLOCK_STYLE) with an inline-start accent border and a
  title/room/time label.
- Overlapping bookings are laid out side-by-side via interval-partition
  column assignment (layoutDayBookings), chained overlaps share a column
  count.
- A live RED current-time line + red dot (useNow, 60s) shows only when the
  selected day is today and within grid hours.

Details:
- View-only (no drag/create); single-day grid only. No backend changes.
- RTL/i18n: logical props (insetInlineStart, border-s) so it mirrors in
  Arabic; added protocol.bookings.emptyDay to ar.json + en.json.
- Blocks with no visible intersection with the grid range are skipped
  (fixes a phantom-block bug for bookings fully before 7am / after 10pm,
  found in code review).
- Removed the now-unused groupBookingsByDay helper.
- List toggle preserved. tx-os typecheck passes.
This commit is contained in:
Replit Agent
2026-07-07 10:27:15 +00:00
parent a324c42b1f
commit f46199ac5f
3 changed files with 271 additions and 40 deletions
+1
View File
@@ -1723,6 +1723,7 @@
"endsAt": "إلى",
"viewList": "قائمة",
"viewCalendar": "تقويم",
"emptyDay": "لا توجد حجوزات في هذا اليوم.",
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
"copyLink": "نسخ الرابط",
"linkCopied": "تم نسخ الرابط",
+1
View File
@@ -1596,6 +1596,7 @@
"endsAt": "To",
"viewList": "List",
"viewCalendar": "Calendar",
"emptyDay": "No bookings on this day.",
"shareLinkTitle": "Public booking link (share with guests)",
"copyLink": "Copy link",
"linkCopied": "Link copied",
+269 -40
View File
@@ -57,6 +57,8 @@ import {
AlertDialogTitle,
} from "@/components/ui/alert-dialog";
import { AppDock } from "@/components/app-dock";
import { Calendar } from "@/components/ui/calendar";
import { useNow } from "@/components/clock";
import { useToast } from "@/hooks/use-toast";
import { apiJson, ApiError } from "@/lib/api-json";
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
@@ -226,36 +228,6 @@ function fromLocalInput(v: string): string {
return new Date(v).toISOString();
}
// Group bookings by calendar day (ascending) for the calendar view.
function groupBookingsByDay(
items: Booking[],
isAr: boolean,
): Array<{ key: string; label: string; items: Booking[] }> {
const map = new Map<string, Booking[]>();
for (const b of items) {
const d = new Date(b.startsAt);
const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(
2,
"0",
)}-${String(d.getDate()).padStart(2, "0")}`;
if (!map.has(key)) map.set(key, []);
map.get(key)!.push(b);
}
return Array.from(map.entries())
.sort(([a], [b]) => a.localeCompare(b))
.map(([key, group]) => ({
key,
label: new Date(`${key}T00:00:00`).toLocaleDateString(
isAr ? "ar" : "en",
{ weekday: "long", year: "numeric", month: "long", day: "numeric" },
),
items: group.sort(
(a, b) =>
new Date(a.startsAt).getTime() - new Date(b.startsAt).getTime(),
),
}));
}
const STATUS_PILL: Record<string, string> = {
pending: "bg-amber-100 text-amber-800",
approved: "bg-emerald-100 text-emerald-700",
@@ -266,6 +238,225 @@ const STATUS_PILL: Record<string, string> = {
completed: "bg-emerald-100 text-emerald-700",
};
// ---- Day time-grid (Google-Calendar style) constants & helpers ----
const GRID_HOUR_START = 7; // 7 AM
const GRID_HOUR_END = 22; // 10 PM
const GRID_PX_PER_HOUR = 56;
// Block fill + inline-start accent border, colored by booking status.
const BOOKING_BLOCK_STYLE: Record<string, string> = {
pending: "bg-amber-100 border-amber-400 text-amber-900",
approved: "bg-emerald-100 border-emerald-500 text-emerald-900",
issued: "bg-emerald-100 border-emerald-500 text-emerald-900",
rejected: "bg-rose-100 border-rose-400 text-rose-900",
cancelled: "bg-slate-100 border-slate-300 text-slate-500",
scheduled: "bg-sky-100 border-sky-400 text-sky-900",
completed: "bg-emerald-100 border-emerald-500 text-emerald-900",
};
function sameYmd(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
function minutesOfDay(iso: string): number {
const d = new Date(iso);
return d.getHours() * 60 + d.getMinutes();
}
type PlacedBooking = {
b: Booking;
s: number; // start minutes-of-day
e: number; // end minutes-of-day (>= s + 15)
col: number;
cols: number;
};
// Assign side-by-side columns to overlapping bookings (interval
// partitioning), clustering chained overlaps so each cluster shares a
// column count. View-only layout — no drag/resize.
function layoutDayBookings(items: Booking[]): PlacedBooking[] {
const evs = items
.map((b) => {
const s = minutesOfDay(b.startsAt);
const e = Math.max(minutesOfDay(b.endsAt), s + 15);
return { b, s, e, col: 0, cols: 1 };
})
.sort((a, b) => a.s - b.s || a.e - b.e);
const out: PlacedBooking[] = [];
let i = 0;
while (i < evs.length) {
let j = i + 1;
let clusterEnd = evs[i].e;
const cluster = [evs[i]];
while (j < evs.length && evs[j].s < clusterEnd) {
cluster.push(evs[j]);
clusterEnd = Math.max(clusterEnd, evs[j].e);
j++;
}
const colEnds: number[] = [];
for (const ev of cluster) {
let placed = false;
for (let c = 0; c < colEnds.length; c++) {
if (colEnds[c] <= ev.s) {
ev.col = c;
colEnds[c] = ev.e;
placed = true;
break;
}
}
if (!placed) {
ev.col = colEnds.length;
colEnds.push(ev.e);
}
}
const cols = colEnds.length;
for (const ev of cluster) {
ev.cols = cols;
out.push(ev);
}
i = j;
}
return out;
}
function BookingDayGrid({
bookings,
selectedDay,
now,
isAr,
roomName,
emptyText,
}: {
bookings: Booking[];
selectedDay: Date;
now: Date;
isAr: boolean;
roomName: (id: number) => string;
emptyText: string;
}) {
const hours: number[] = [];
for (let h = GRID_HOUR_START; h <= GRID_HOUR_END; h++) hours.push(h);
const dayItems = bookings.filter((b) =>
sameYmd(new Date(b.startsAt), selectedDay),
);
const placed = layoutDayBookings(dayItems);
const isToday = sameYmd(now, selectedDay);
const nowMin = now.getHours() * 60 + now.getMinutes();
const showNow =
isToday && nowMin >= GRID_HOUR_START * 60 && nowMin <= GRID_HOUR_END * 60;
const nowTop = ((nowMin - GRID_HOUR_START * 60) / 60) * GRID_PX_PER_HOUR;
const gridHeight = (GRID_HOUR_END - GRID_HOUR_START) * GRID_PX_PER_HOUR;
const hourLabel = (h: number) => {
const d = new Date();
d.setHours(h, 0, 0, 0);
return new Intl.DateTimeFormat(isAr ? "ar-u-nu-latn" : "en-US", {
hour: "numeric",
hour12: true,
}).format(d);
};
const timeLabel = (iso: string) =>
new Intl.DateTimeFormat(isAr ? "ar-u-nu-latn" : "en-US", {
hour: "numeric",
minute: "2-digit",
hour12: true,
}).format(new Date(iso));
return (
<div>
{dayItems.length === 0 && (
<div className="mb-2 text-center text-xs text-slate-400">
{emptyText}
</div>
)}
<div className="flex" style={{ height: gridHeight + 8 }}>
<div className="relative w-14 shrink-0" style={{ height: gridHeight }}>
{hours.map((h) => (
<div
key={h}
className="absolute inset-x-0 -translate-y-1/2 pe-1 text-end text-[10px] text-slate-400"
style={{ top: (h - GRID_HOUR_START) * GRID_PX_PER_HOUR }}
>
{hourLabel(h)}
</div>
))}
</div>
<div
className="relative flex-1 border-s border-slate-200"
style={{ height: gridHeight }}
>
{hours.map((h) => (
<div
key={h}
className="absolute inset-x-0 border-t border-slate-100"
style={{ top: (h - GRID_HOUR_START) * GRID_PX_PER_HOUR }}
/>
))}
{placed.map((ev) => {
const topM = Math.max(ev.s, GRID_HOUR_START * 60);
const botM = Math.min(ev.e, GRID_HOUR_END * 60);
// Skip bookings with no visible intersection with the grid range.
if (botM <= topM) return null;
const top = ((topM - GRID_HOUR_START * 60) / 60) * GRID_PX_PER_HOUR;
const height = Math.max(
((botM - topM) / 60) * GRID_PX_PER_HOUR,
22,
);
const widthPct = 100 / ev.cols;
return (
<div
key={ev.b.id}
className={cn(
"absolute overflow-hidden rounded-md border-s-4 px-1.5 py-0.5 text-[11px] leading-tight shadow-sm",
BOOKING_BLOCK_STYLE[ev.b.status] ??
"bg-slate-100 border-slate-300 text-slate-700",
)}
style={{
top,
height,
insetInlineStart: `calc(${ev.col * widthPct}% + 2px)`,
width: `calc(${widthPct}% - 4px)`,
}}
title={`${ev.b.title}${roomName(ev.b.roomId)}`}
>
<div className="truncate font-medium">{ev.b.title}</div>
<div className="truncate opacity-80">
{roomName(ev.b.roomId)}
</div>
{height >= 44 && (
<div className="truncate opacity-70" dir="ltr">
{timeLabel(ev.b.startsAt)} {timeLabel(ev.b.endsAt)}
</div>
)}
</div>
);
})}
{showNow && (
<div
className="pointer-events-none absolute inset-x-0 z-10"
style={{ top: nowTop }}
>
<div className="relative border-t-2 border-red-500">
<div
className="absolute -top-[5px] h-2.5 w-2.5 rounded-full bg-red-500"
style={{ insetInlineStart: -5 }}
/>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default function ProtocolPage() {
const { t, i18n } = useTranslation();
const lang = i18n.language;
@@ -371,6 +562,18 @@ export default function ProtocolPage() {
id: number;
} | null>(null);
const [bookingsView, setBookingsView] = useState<"list" | "calendar">("list");
const [selectedDay, setSelectedDay] = useState<Date>(() => new Date());
const now = useNow(60000);
const bookedDates = useMemo(() => {
const map = new Map<string, Date>();
for (const b of bookings.data ?? []) {
const d = new Date(b.startsAt);
const key = `${d.getFullYear()}-${d.getMonth()}-${d.getDate()}`;
if (!map.has(key))
map.set(key, new Date(d.getFullYear(), d.getMonth(), d.getDate()));
}
return Array.from(map.values());
}, [bookings.data]);
const publicBookingUrl = `${window.location.origin}${import.meta.env.BASE_URL.replace(/\/$/, "")}/protocol/request`;
const [linkCopied, setLinkCopied] = useState(false);
const linkCopiedTimer = useRef<number | null>(null);
@@ -757,17 +960,43 @@ export default function ProtocolPage() {
{(bookings.data ?? []).map((b) => renderBookingCard(b))}
</div>
) : (
<div className="space-y-4">
{groupBookingsByDay(bookings.data ?? [], isAr).map((group) => (
<div key={group.key}>
<div className="text-sm font-semibold text-slate-600 mb-2 sticky top-16 bg-slate-50 py-1">
{group.label}
</div>
<div className="grid gap-2">
{group.items.map((b) => renderBookingCard(b))}
</div>
<div className="flex flex-col gap-4 lg:flex-row">
<div className="shrink-0 self-center lg:self-start">
<div className="rounded-xl border border-slate-200 bg-white p-2">
<Calendar
mode="single"
required
selected={selectedDay}
onSelect={(d) => d && setSelectedDay(d)}
defaultMonth={selectedDay}
modifiers={{ booked: bookedDates }}
modifiersClassNames={{
booked:
"relative after:absolute after:bottom-1 after:start-1/2 after:-translate-x-1/2 after:h-1 after:w-1 after:rounded-full after:bg-sky-500",
}}
/>
</div>
))}
</div>
<div className="min-w-0 flex-1 rounded-xl border border-slate-200 bg-white p-3">
<div className="mb-3 text-sm font-semibold text-slate-600">
{selectedDay.toLocaleDateString(isAr ? "ar" : "en", {
weekday: "long",
year: "numeric",
month: "long",
day: "numeric",
})}
</div>
<div className="max-h-[70vh] overflow-y-auto">
<BookingDayGrid
bookings={bookings.data ?? []}
selectedDay={selectedDay}
now={now}
isAr={isAr}
roomName={roomName}
emptyText={t("protocol.bookings.emptyDay")}
/>
</div>
</div>
</div>
)}
</section>