{"$schema":"https://ui.shadcn.com/schema/registry-item.json","name":"hours-poster-section","type":"registry:component","title":"Hours Poster Section","description":"Opening hours set as a poster — days hanging off the edge, closed rows struck through, today banded in the one accent — with an open/closed line on the shop's clock.","dependencies":[],"registryDependencies":[],"files":[{"path":"components/viberdy/hours-poster-section.tsx","content":"\"use client\";\n\nimport { useSyncExternalStore } from \"react\";\n\n/**\n * HoursPoster — opening hours set like a poster, not a table.\n *\n * Seven fixed-height rows. The day hangs off the left edge in heavy two-letter\n * caps, the hours sit in tabular monospace so the column holds, and a closed\n * day is struck through the ENTIRE row, so the negative space says closed\n * before the word does. Today is the one place the accent appears: a full\n * band behind that row. The status line (\"Open now · closes 18:00\") is\n * computed on the shop's clock, not the visitor's.\n *\n * NEVER RENDER THE TIME ON THE SERVER. A static page is built once and served\n * for hours, so a server-rendered \"today\" is both wrong and a hydration\n * mismatch. The clock is a useSyncExternalStore whose server snapshot is 0:\n * before hydration no row is today and the status shows an ellipsis; after\n * it, the store ticks at minute boundaries (and immediately when the tab\n * comes back, since background tabs throttle timers).\n *\n * Days are indexed Monday = 0 … Sunday = 6. A day with no ranges is closed. A\n * range that closes at or before it opens runs overnight. Needs Tailwind v4\n * (container queries are built in).\n */\n\nexport type DayHours = {\n  /** Open/close pairs as \"HH:MM\", 24-hour. None or empty = closed. */\n  ranges?: [string, string][];\n  /** \"Walk-ins\", \"By appointment\", \"Kitchen till 21:30\". */\n  note?: string;\n};\n\nconst DAYS = [\"Mo\", \"Tu\", \"We\", \"Th\", \"Fr\", \"Sa\", \"Su\"];\nconst DAY_NAMES = [\"Monday\", \"Tuesday\", \"Wednesday\", \"Thursday\", \"Friday\", \"Saturday\", \"Sunday\"];\nconst WEEK = 7 * 1440;\nconst HEX = /^#([0-9a-fA-F]{3}|[0-9a-fA-F]{6})$/;\n\nfunction hex(v: string, fallback: string) {\n  return HEX.test(v) ? v : fallback;\n}\nfunction rgba(colour: string, alpha: number) {\n  let h = colour.slice(1);\n  if (h.length === 3) h = h[0] + h[0] + h[1] + h[1] + h[2] + h[2];\n  const n = parseInt(h, 16);\n  return \"rgba(\" + ((n >> 16) & 255) + \",\" + ((n >> 8) & 255) + \",\" + (n & 255) + \",\" + alpha + \")\";\n}\n\n/* A minute-resolution clock shared by every instance on the page. */\nlet minuteNow = 0;\nfunction subscribeMinute(onChange: () => void) {\n  let timer = 0;\n  const tick = () => {\n    minuteNow = Math.floor(Date.now() / 60000);\n    onChange();\n    window.clearTimeout(timer);\n    // Aim just past the boundary; a timer that fires early reads the old minute.\n    timer = window.setTimeout(tick, 60000 - (Date.now() % 60000) + 50);\n  };\n  const onVisible = () => {\n    if (document.visibilityState === \"visible\") tick();\n  };\n  tick();\n  document.addEventListener(\"visibilitychange\", onVisible);\n  return () => {\n    window.clearTimeout(timer);\n    document.removeEventListener(\"visibilitychange\", onVisible);\n  };\n}\nconst readMinute = () => minuteNow;\nconst readMinuteServer = () => 0;\n\n/** Weekday (Mon = 0) and minute of day on the shop's wall clock; null for a bad zone. */\nfunction zoneNow(ms: number, timeZone: string): { day: number; minute: number } | null {\n  try {\n    const parts = new Intl.DateTimeFormat(\"en-GB\", {\n      timeZone,\n      weekday: \"short\",\n      hour: \"2-digit\",\n      minute: \"2-digit\",\n      hourCycle: \"h23\",\n    }).formatToParts(new Date(ms));\n    const get = (type: string) => parts.find((p) => p.type === type)?.value ?? \"\";\n    const day = [\"Mon\", \"Tue\", \"Wed\", \"Thu\", \"Fri\", \"Sat\", \"Sun\"].indexOf(get(\"weekday\"));\n    if (day < 0) return null;\n    return { day, minute: Number(get(\"hour\")) * 60 + Number(get(\"minute\")) };\n  } catch {\n    return null;\n  }\n}\n\nfunction toMinutes(hhmm: string) {\n  const [h, m] = hhmm.split(\":\").map(Number);\n  return (h || 0) * 60 + (m || 0);\n}\n\nfunction fmt(min: number, hour12: boolean) {\n  const m = ((min % 1440) + 1440) % 1440;\n  const h = Math.floor(m / 60);\n  const mm = String(m % 60).padStart(2, \"0\");\n  if (!hour12) return String(h).padStart(2, \"0\") + \":\" + mm;\n  return (h % 12 || 12) + (m % 60 ? \":\" + mm : \"\") + (h < 12 ? \"am\" : \"pm\");\n}\n\nfunction status(days: DayHours[], now: { day: number; minute: number }, hour12: boolean) {\n  const spans: { s: number; e: number }[] = [];\n  days.forEach((d, i) =>\n    (d.ranges ?? []).forEach(([o, c]) => {\n      const s = i * 1440 + toMinutes(o);\n      let e = i * 1440 + toMinutes(c);\n      if (e <= s) e += 1440; // overnight\n      spans.push({ s, e });\n    }),\n  );\n  const t = now.day * 1440 + now.minute;\n  for (const sp of spans) {\n    // A Sunday-night span that runs into Monday holds Monday's early minutes at t + WEEK.\n    if ((t >= sp.s && t < sp.e) || (t + WEEK >= sp.s && t + WEEK < sp.e)) {\n      return { open: true, text: \"Open now · closes \" + fmt(sp.e, hour12) };\n    }\n  }\n  if (spans.length === 0) return { open: false, text: \"By appointment\" };\n  let wait = Infinity;\n  let at = 0;\n  for (const sp of spans) {\n    const d = (((sp.s - t) % WEEK) + WEEK) % WEEK;\n    if (d < wait) {\n      wait = d;\n      at = sp.s;\n    }\n  }\n  const day = Math.floor(at / 1440) % 7;\n  const when =\n    day === now.day && wait < 1440 ? \"today\" : day === (now.day + 1) % 7 && wait < 2880 ? \"tomorrow\" : DAY_NAMES[day];\n  return { open: false, text: \"Closed · opens \" + when + \" \" + fmt(at, hour12) };\n}\n\nexport function HoursPoster({\n  days,\n  timeZone = \"Europe/London\",\n  title = \"Opening hours\",\n  phone,\n  note,\n  accent = \"#c8452c\",\n  paper = \"#f4f2ee\",\n  ink = \"#111111\",\n  hour12 = false,\n  today,\n  className = \"\",\n}: {\n  /** Seven entries, Monday first. */\n  days: DayHours[];\n  /** IANA zone the shop lives in. */\n  timeZone?: string;\n  title?: string;\n  /** Rendered as a tel: link. */\n  phone?: string;\n  /** Small print under the rows. */\n  note?: string;\n  /** The band behind today. Hex only. */\n  accent?: string;\n  paper?: string;\n  ink?: string;\n  hour12?: boolean;\n  /** Force which row is today (0 = Monday) — for screenshots and tests. */\n  today?: number;\n  className?: string;\n}) {\n  const minute = useSyncExternalStore(subscribeMinute, readMinute, readMinuteServer);\n  const now = minute ? zoneNow(minute * 60000, timeZone) : null;\n  const todayIndex = today ?? now?.day ?? -1;\n  const st = now ? status(days, now, hour12) : null;\n\n  const acc = hex(accent, \"#c8452c\");\n  const pa = hex(paper, \"#f4f2ee\");\n  const ik = hex(ink, \"#111111\");\n  const line = rgba(ik, 0.14);\n\n  return (\n    <section\n      className={\"@container relative flex h-full flex-col overflow-hidden font-sans \" + className}\n      style={{ background: pa, color: ik }}\n    >\n      <div className=\"flex items-start justify-between gap-6 px-6 pt-6 @3xl:px-12 @3xl:pt-8\">\n        <h2 className=\"font-mono text-[11px] uppercase tracking-[0.18em]\">{title}</h2>\n        <p className=\"text-right font-mono text-[11px] uppercase tracking-[0.18em]\" aria-live=\"polite\">\n          {st ? (\n            <>\n              <span\n                aria-hidden=\"true\"\n                className=\"mr-2 inline-block size-1.5 rounded-full align-middle\"\n                style={{ background: st.open ? acc : ik, opacity: st.open ? 1 : 0.35 }}\n              />\n              {st.text}\n            </>\n          ) : (\n            <span aria-hidden=\"true\">…</span>\n          )}\n        </p>\n      </div>\n\n      <ol className=\"mt-5 list-none @3xl:mt-7\">\n        {DAYS.map((d, i) => {\n          const day = days[i] ?? {};\n          const ranges = day.ranges ?? [];\n          const closed = ranges.length === 0;\n          const isToday = i === todayIndex;\n          const fg = isToday ? pa : ik;\n          return (\n            <li\n              key={d}\n              aria-current={isToday ? \"date\" : undefined}\n              className=\"relative flex h-16 items-center border-t @3xl:h-[72px]\"\n              style={{\n                borderColor: isToday ? \"transparent\" : line,\n                background: isToday ? acc : \"transparent\",\n                color: fg,\n              }}\n            >\n              <span className=\"sr-only\">{DAY_NAMES[i]}: </span>\n              {/* The day hangs off the left edge on purpose. */}\n              <span\n                aria-hidden=\"true\"\n                className=\"w-[34%] shrink-0 text-[52px] leading-none font-black tracking-[-0.06em] uppercase select-none @3xl:w-[40%] @3xl:text-[66px]\"\n                style={{ marginLeft: \"-0.09em\" }}\n              >\n                {d}\n              </span>\n              <span className=\"font-mono text-[14px] tracking-[0.02em] tabular-nums @3xl:text-[16px]\">\n                {closed\n                  ? \"Closed\"\n                  : ranges.map(([o, c]) => fmt(toMinutes(o), hour12) + \" – \" + fmt(toMinutes(c), hour12)).join(\"  ·  \")}\n              </span>\n              {day.note && (\n                <span className=\"ml-auto hidden pr-6 font-mono text-[11px] uppercase tracking-[0.14em] opacity-60 @3xl:block @3xl:pr-12\">\n                  {day.note}\n                </span>\n              )}\n              {closed && (\n                <span aria-hidden=\"true\" className=\"absolute inset-x-0 top-1/2 h-[2px]\" style={{ background: fg }} />\n              )}\n            </li>\n          );\n        })}\n      </ol>\n\n      <div\n        className=\"mt-auto flex items-end justify-between gap-6 border-t px-6 py-5 font-mono text-[11px] uppercase tracking-[0.14em] @3xl:px-12\"\n        style={{ borderColor: line }}\n      >\n        <span className=\"opacity-60\">{note}</span>\n        {phone && (\n          <a href={\"tel:\" + phone.replace(/[^+\\d]/g, \"\")} className=\"underline-offset-4 hover:underline\">\n            {phone}\n          </a>\n        )}\n      </div>\n    </section>\n  );\n}\n","type":"registry:component","target":"components/viberdy/hours-poster-section.tsx"}],"meta":{"tier":"free","tags":["opening-hours","local-business","typography","section","live"],"href":"https://viberdy.dev/library/hours-poster-section","aiPrompt":"Create a React + Tailwind (v4) component named HoursPoster: a small business's opening hours set as a typographic poster, with today's row highlighted and a live \"Open now / Closed\" status computed on the shop's own clock.\n\nData: `days`, an array of seven entries Monday first, each { ranges?: [string, string][] (open/close as \"HH:MM\", 24-hour; none = closed; a close at or before its open runs overnight), note?: string }. Other props: timeZone (IANA, default \"Europe/London\"), title, phone (rendered as a tel: link with non-digits stripped), note (small print), accent, paper, ink (hex only, validated with a regex and defaulted), hour12 (boolean), today (number 0–6 to force the highlighted row for tests and screenshots), className.\n\nLook (the section is a `@container`; use @3xl: variants for the wide layout):\n- Paper ground, ink text. Top row: the title in 11px uppercase monospace with 0.18em tracking on the left; the status on the right in the same style, with a 6px dot before it (accent when open, ink at 35% when closed), aria-live=\"polite\".\n- Seven rows of FIXED height (64px, 72px wide), separated by hairlines set inline as rgba(ink, 0.14). Each row: the day as two-letter caps (\"Mo\" … \"Su\") at 52px / 66px wide, font-black, tracking -0.06em, leading 1, with a NEGATIVE left margin of -0.09em so the letter hangs off the section's left edge (the section is overflow-hidden); the hours in 14px / 16px tabular monospace as \"09:00 – 18:00\", multiple ranges joined with \" · \"; the note on the right in 11px uppercase monospace at 60% opacity, hidden on narrow containers.\n- A closed row shows \"Closed\" and a 2px rule struck across the WHOLE row at mid-height.\n- Today's row is the only place the accent appears: a full-width band with paper-coloured text, and aria-current=\"date\". Each row starts with a visually hidden full day name for screen readers, and the two-letter day is aria-hidden.\n- A bottom bar: small print left, the phone link right, in monospace caps.\n\nThe clock:\n- Never render the time on the server. Use useSyncExternalStore with a module-level minute store: server snapshot 0 (no row is today, the status shows an ellipsis), client snapshot Math.floor(Date.now() / 60000). The subscribe function ticks immediately, then re-arms a setTimeout aimed 50ms past the next minute boundary, and also ticks on visibilitychange when the tab becomes visible.\n- Convert the minute to the shop's weekday and minute-of-day with Intl.DateTimeFormat in `timeZone` (weekday short, hour and minute 2-digit, hourCycle \"h23\", formatToParts). Catch the RangeError from an invalid zone and treat it as unknown.\n- Status: flatten every range into minutes since Monday 00:00 (add 1440 to a close that is not after its open); if now falls inside a span (also test now + 7 days for Sunday-night spans that run into Monday) show \"Open now · closes HH:MM\"; otherwise find the nearest future span (modulo the week) and show \"Closed · opens today 09:00\" / \"tomorrow 09:00\" / \"Thursday 09:00\". With no ranges at all show \"By appointment\".\n\nOutput one self-contained \"use client\" component with no dependencies beyond React, exporting the DayHours type.","license":"https://viberdy.dev/license"}}