Spaces:
Runtime error
Runtime error
File size: 6,205 Bytes
bdd1640 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 | import { useEffect, useMemo, useRef, useState } from "react";
/** A post's time as the user picked it: their own wall clock, "2026-10-07T11:00", always on :00 or :30. The backend
* reads it in their time zone (userTimeZone) and stores UTC. */
export type Wall = string;
const pad = (n: number) => String(n).padStart(2, "0");
const dayKey = (d: Date) => `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
export const userTimeZone = (): string => Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
export function toWall(d: Date): Wall {
return `${dayKey(d)}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
}
/** The wall time as a Date in this browser (which is in the user's zone). */
export function wallToDate(w: Wall): Date {
const [day, time] = w.split("T");
const [y, m, d] = day.split("-").map(Number);
const [h, min] = time.split(":").map(Number);
return new Date(y, m - 1, d, h, min);
}
/** The next half hour from now (11:07 -> 11:30). */
export function nextSlot(from = new Date()): Date {
const d = new Date(from);
d.setSeconds(0, 0);
d.setMinutes(d.getMinutes() < 30 ? 30 : 60);
return d;
}
export const onSlot = (d: Date) => d.getMinutes() % 30 === 0 && d.getSeconds() === 0;
/** Weeks start on Monday: the 6 weeks around the month. */
export function monthGrid(month: Date): Date[] {
const first = new Date(month.getFullYear(), month.getMonth(), 1);
const offset = (first.getDay() + 6) % 7;
return Array.from({ length: 42 }, (_, i) => new Date(first.getFullYear(), first.getMonth(), 1 - offset + i));
}
/** "GMT+5" for the zone right now. */
function zoneOffset(): string {
const part = new Intl.DateTimeFormat(undefined, { timeZoneName: "short" }).formatToParts(new Date())
.find((p) => p.type === "timeZoneName");
return part?.value ?? "";
}
const WEEKDAYS = ["M", "T", "W", "T", "F", "S", "S"];
const SLOTS = Array.from({ length: 48 }, (_, i) => `${pad(Math.floor(i / 2))}:${i % 2 ? "30" : "00"}`);
/** When a post goes out: a day on a small month calendar and one of the day's 48 half hours, in the user's own time
* zone (named under it). Past days and past slots are disabled. */
export function SlotPicker({ value, onChange }: { value: Wall; onChange: (w: Wall) => void }) {
const picked = wallToDate(value);
const [open, setOpen] = useState(false);
const [month, setMonth] = useState(() => new Date(picked.getFullYear(), picked.getMonth(), 1));
const days = useMemo(() => monthGrid(month), [month]);
const now = new Date();
const today = dayKey(now);
const pickedDay = value.split("T")[0];
const pickedTime = value.split("T")[1];
const thisMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const times = useRef<HTMLDivElement>(null);
// Opened: the time list scrolls (itself only) to the picked slot.
useEffect(() => {
const list = times.current;
const on = list?.querySelector<HTMLElement>(".slot-time.on");
if (list && on) list.scrollTop = on.offsetTop - list.clientHeight / 2 + on.clientHeight / 2;
}, [open, pickedDay]);
const slotDate = (day: string, time: string) => wallToDate(`${day}T${time}`);
/** A day keeps its slot when it's still ahead there, else takes the day's first one still ahead. */
const pickDay = (d: Date) => {
const day = dayKey(d);
const time = slotDate(day, pickedTime) > now ? pickedTime : SLOTS.find((t) => slotDate(day, t) > now);
if (time) onChange(`${day}T${time}`);
};
const label = picked.toLocaleString(undefined, { weekday: "short", month: "short", day: "numeric", hour: "numeric",
minute: "2-digit" });
return (
<div className="slot-picker">
<button type="button" className="input input-sm slot-trigger" aria-expanded={open} onClick={() => setOpen(!open)}>
{label}
</button>
<span className="muted small">Your time zone: {userTimeZone()}{zoneOffset() ? ` (${zoneOffset()})` : ""}</span>
{open && (
<div className="slot-panel">
<div className="slot-cal">
<div className="slot-cal-head">
<button type="button" className="btn btn-small" aria-label="Previous month"
disabled={month <= thisMonth}
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() - 1, 1))}>‹</button>
<strong>{month.toLocaleDateString(undefined, { month: "long", year: "numeric" })}</strong>
<button type="button" className="btn btn-small" aria-label="Next month"
onClick={() => setMonth(new Date(month.getFullYear(), month.getMonth() + 1, 1))}>›</button>
</div>
<div className="slot-days" role="grid">
{WEEKDAYS.map((w, i) => <span key={i} className="slot-weekday muted small">{w}</span>)}
{days.map((d) => {
const key = dayKey(d);
const past = key < today;
return (
<button key={key} type="button" disabled={past}
className={`slot-day${d.getMonth() !== month.getMonth() ? " out" : ""}${key === today ? " today" : ""}${key === pickedDay ? " on" : ""}`}
aria-pressed={key === pickedDay} onClick={() => pickDay(d)}>
{d.getDate()}
</button>
);
})}
</div>
</div>
<div className="slot-times" role="listbox" aria-label="Time" ref={times}>
{SLOTS.map((t) => {
const at = slotDate(pickedDay, t);
const on = t === pickedTime;
return (
<button key={t} type="button" role="option" aria-selected={on} disabled={at <= now}
className={`slot-time${on ? " on" : ""}`}
onClick={() => {
onChange(`${pickedDay}T${t}`);
setOpen(false);
}}>
{at.toLocaleTimeString(undefined, { hour: "numeric", minute: "2-digit" })}
</button>
);
})}
</div>
</div>
)}
</div>
);
}
|