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:
@@ -1723,6 +1723,7 @@
|
||||
"endsAt": "إلى",
|
||||
"viewList": "قائمة",
|
||||
"viewCalendar": "تقويم",
|
||||
"emptyDay": "لا توجد حجوزات في هذا اليوم.",
|
||||
"shareLinkTitle": "رابط الحجز العام (للمشاركة مع الضيوف)",
|
||||
"copyLink": "نسخ الرابط",
|
||||
"linkCopied": "تم نسخ الرابط",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user