╔══════════════════════════════════════════════════════════════════╗
║  FREE SAMPLE — zicula-booking-kit                                ║
║  Excerpt from the kit's worker.mjs (zero npm dependencies):      ║
║  the IANA-timezone time helpers and the availability engine      ║
║  (gridForDate) that enforces opening hours, minimum notice,      ║
║  blocked days, service durations + buffers, and rejects          ║
║  overlapping bookings. Real code, copied as-is.                  ║
║  NOT included here and in the full kit only: the booking API,    ║
║  server-rendered booking page, admin panel, ICS generator,       ║
║  calendar feed and optional Resend email + 24-h reminders.       ║
╚══════════════════════════════════════════════════════════════════╝

// ─────────────────────────────────────────────────────────────────────────────
// Nothing below needs editing for a normal setup.
// ─────────────────────────────────────────────────────────────────────────────

const REFK = /^[A-Z2-7]{8}$/;
const DATEK = /^\d{4}-\d{2}-\d{2}$/;
const TIMEK = /^\d{2}:\d{2}$/;
const MAX_NOTE = 800;

const json = (data, status = 200) =>
  new Response(JSON.stringify(data), {
    status,
    headers: { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" },
  });

const esc = (s) =>
  String(s).replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));

function pad2(n) { return String(n).padStart(2, "0"); }

// ── Time helpers (IANA-timezone aware, works in Workers and Node) ───────────

function tzOffsetMs(utcMs, tz) {
  const dtf = new Intl.DateTimeFormat("en-US", {
    timeZone: tz, hour12: false,
    year: "numeric", month: "2-digit", day: "2-digit",
    hour: "2-digit", minute: "2-digit", second: "2-digit",
  });
  const p = {};
  for (const { type, value } of dtf.formatToParts(new Date(utcMs))) p[type] = value;
  const asUtc = Date.UTC(+p.year, +p.month - 1, +p.day, +p.hour % 24, +p.minute, +p.second);
  return asUtc - Math.floor(utcMs / 1000) * 1000;
}

function dateKeyInTz(utcMs, tz) {
  const p = new Intl.DateTimeFormat("en-US", {
    timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit",
  }).formatToParts(new Date(utcMs)).reduce((a, x) => (a[x.type] = x.value, a), {});
  return `${p.year}-${p.month}-${p.day}`;
}

function weekdayInTz(utcMs, tz) {
  return new Intl.DateTimeFormat("en-US", { timeZone: tz, weekday: "short" })
    .format(new Date(utcMs));
}

function weekdayIndexInTz(utcMs, tz) {
  return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]
    .indexOf(weekdayInTz(utcMs, tz));
}

// Wall-clock time in `tz` → UTC epoch ms (two-pass, handles DST).
function wallToUtcMs(dateKey, timeKey, tz) {
  const [y, m, d] = dateKey.split("-").map(Number);
  const [hh, mm] = timeKey.split(":").map(Number);
  const guess = Date.UTC(y, m - 1, d, hh, mm);
  return guess - tzOffsetMs(guess, tz);
}

function utcMsToStamp(utcMs) {
  const d = new Date(utcMs);
  return (
    d.getUTCFullYear() + pad2(d.getUTCMonth() + 1) + pad2(d.getUTCDate()) +
    "T" + pad2(d.getUTCHours()) + pad2(d.getUTCMinutes()) + pad2(d.getUTCSeconds()) + "Z"
  );
}

function addDaysKey(dateKey, days) {
  const [y, m, d] = dateKey.split("-").map(Number);
  const t = new Date(Date.UTC(y, m - 1, d + days));
  return `${t.getUTCFullYear()}-${pad2(t.getUTCMonth() + 1)}-${pad2(t.getUTCDate())}`;
}

function cmpTime(a, b) { return a < b ? -1 : a > b ? 1 : 0; }
function minutesOf(t) { const [h, m] = t.split(":").map(Number); return h * 60 + m; }
function timeOf(mins) { return pad2(Math.floor(mins / 60)) + ":" + pad2(mins % 60); }

// ── Availability ─────────────────────────────────────────────────────────────

function gridForDate(dateKey, service, nowMs, state) {
  const cfg = state.cfg;
  const wd = weekdayIndexInTz(wallToUtcMs(dateKey, "12:00", cfg.timezone), cfg.timezone);
  const win = cfg.hours[wd];
  if (!win) return { closed: true, slots: [] };

  const blocks = state.blocks[dateKey] || {};
  const bookings = (state.days[dateKey] || []).filter((b) => b.status === "active");
  const cutoff = nowMs + cfg.leadTimeMinutes * 60000;
  const step = cfg.slotIntervalMinutes;
  const endGuard = minutesOf(win[1]) - service.minutes - service.buffer;
  const slots = [];

  for (let m = minutesOf(win[0]); m <= endGuard; m += step) {
    const t = timeOf(m);
    const startMs = wallToUtcMs(dateKey, t, cfg.timezone);
    const endMs = startMs + (service.minutes + service.buffer) * 60000;
    let why = "";
    if (startMs < cutoff) why = "notice";
    else if (blocks.day || (blocks.slots || []).includes(t)) why = "blocked";
    else if (bookings.some((b) => startMs < b.endMs && b.startMs < endMs)) why = "taken";
    if (!why) slots.push({ t });
  }
  return { closed: false, slots };
}

function annotate(bookings, cfg) {
  return bookings.map((b) => {
    const startMs = wallToUtcMs(b.date, b.time, cfg.timezone);
    return { ...b, startMs, endMs: startMs + (b.minutes + b.buffer) * 60000 };
  });
}

