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>
  );
}