# Open Status (Moonarc)

One line that says whether a place is open now and until when, or when it opens next, read from the week's hours in the place's own time zone, whatever the reader's clock says. A dot beside it is lit while it is open, and the words say it either way. A day may run past midnight (a close before its open). A small script writes the line on load and every half minute while the page is open; without JavaScript the fallback sentence stands in its place, or nothing does. The hours are checked at build: a time that is not HH:MM or a zone Intl does not know stops it.

- Import: `import OpenStatus from '@moonarc/core/OpenStatus'`
- Install: `npx astro add moonarc` · copy-paste: `npx shadcn@latest add https://moonarc.dev/r/open-status.json`
- Tier B · category data · trigger load
- Readout: `<OpenStatus hours={[{ day: 'mon', label: 'Monday', open: '08:00', close: '18:00' }, …]} timezone="Europe/London" />`
- Browser support: widely (every browser); elsewhere: without JavaScript the fallback sentence shows, or nothing
- Measured cost: 902 B raw JS · 608 B gzip · + runtime (with dependencies 2.6 kB raw; CSS 4.7 kB raw)
- Page: https://moonarc.dev/components/open-status/

## Props

| Prop | Type | Default | Description |
|---|---|---|---|
| `hours` | `{ day, label, open?, close? }[]` | none | The week; a day left out, or without times, is closed. Times in 24-hour HH:MM. |
| `timezone` | `string` | none | The place's IANA time zone, e.g. Europe/London. |
| `openText` | `string` | `'Open now, until {close}'` | The line while it is open. |
| `closedText` | `string` | `'Closed now, opens {day} at {open}'` | The line while it is closed. |
| `todayWord` | `string` | `'today'` | The {day} of an opening later today. |
| `tomorrowWord` | `string` | `'tomorrow'` | The {day} of an opening tomorrow; further days take their label. |
| `fallback` | `string` | none | What shows without JavaScript and before the script runs. |

## Usage

```astro
<OpenStatus
  timezone="Europe/London"
  fallback="Open Monday to Friday, 8:00 to 18:00"
  hours={[
    { day: 'mon', label: 'Monday', open: '08:00', close: '18:00' },
    { day: 'tue', label: 'Tuesday', open: '08:00', close: '18:00' },
    { day: 'wed', label: 'Wednesday', open: '08:00', close: '20:00' },
    { day: 'thu', label: 'Thursday', open: '08:00', close: '18:00' },
    { day: 'fri', label: 'Friday', open: '08:00', close: '16:00' },
  ]}
/>
```

## Reduced motion

The lit dot does not pulse.

## With ClientRouter

Bound per element through the runtime; its timer stops when the page is left.

## Craft

- Intl.DateTimeFormat with the place's timeZone gives the weekday and the time there: a reader in another country sees the place's hours, not their own.
- Times compare as HH:MM strings, which sort as the clock does, so no date arithmetic runs at all.
- The dot pulses three times when it lights, then rests: a status, not a loop.

## Replaces

- opening-hours widgets
- Google Business open now badge

## Source

```astro
---
/**
 * OpenStatus — one line that says whether a place is open now and until when, or when it opens next, read from the
 * week's hours in the place's own time zone (Intl), whatever the reader's clock says. A dot beside it is lit while it is
 * open; the words say it either way. A small script writes the line on load and every half minute while the page is
 * open. Without JavaScript the `fallback` sentence stands in its place, or nothing does.
 */
import type { HTMLAttributes } from 'astro/types';

type Day = 'mon' | 'tue' | 'wed' | 'thu' | 'fri' | 'sat' | 'sun';
interface Hours {
  day: Day;
  /** The day's name, as "opens {day}" reads it. */
  label: string;
  /** "08:00" and "18:00" in 24-hour time; both unset for a closed day. A close before the open runs past midnight. */
  open?: string;
  close?: string;
}

interface Props extends HTMLAttributes<'p'> {
  /** The week, any order; a day left out is closed. */
  hours: Hours[];
  /** The place's IANA time zone, e.g. 'Europe/London'. */
  timezone: string;
  /** The line's words: {close}, {open} and {day} are filled in. */
  openText?: string;
  closedText?: string;
  todayWord?: string;
  tomorrowWord?: string;
  /** What shows without JavaScript, or before the script has run: "Open Monday to Friday, 8:00 to 18:00". */
  fallback?: string;
}

const {
  hours, timezone, openText = 'Open now, until {close}', closedText = 'Closed now, opens {day} at {open}', todayWord = 'today', tomorrowWord = 'tomorrow',
  fallback, class: className, ...rest
} = Astro.props;
const order: Day[] = ['mon', 'tue', 'wed', 'thu', 'fri', 'sat', 'sun'];
const time = /^([01]\d|2[0-3]):[0-5]\d$/;
for (const h of hours) {
  if (!order.includes(h.day)) throw new Error(`OpenStatus: "${h.day}" is not a day; it takes ${order.join(', ')}.`);
  if (!!h.open !== !!h.close) throw new Error(`OpenStatus: ${h.day} sets ${h.open ? 'open' : 'close'} alone; a day sets both or neither.`);
  for (const t of [h.open, h.close]) if (t && !time.test(t)) throw new Error(`OpenStatus: ${h.day} has the time "${t}"; write 24-hour HH:MM, such as 08:30.`);
}
try { new Intl.DateTimeFormat('en-GB', { timeZone: timezone }); } catch { throw new Error(`OpenStatus: "${timezone}" is not an IANA time zone, such as Europe/London.`); }
const week = order.map((d) => hours.find((h) => h.day === d));
const data = JSON.stringify(week.map((h) => (h?.open && h.close ? [h.open, h.close] : 0)));
const names = JSON.stringify(week.map((h, i) => h?.label ?? order[i]));
---

<p class:list={['ma-open', className]} data-ma-open data-tz={timezone} data-hours={data} data-days={names} data-open-text={openText} data-closed-text={closedText} data-today={todayWord} data-tomorrow={tomorrowWord} hidden={!fallback} {...rest}>
  <span class="ma-open__dot" aria-hidden="true"></span><span class="ma-open__text">{fallback}</span>
</p>

<script>
  import { onMount } from '../lib/runtime';

  onMount<HTMLElement>('[data-ma-open]', (el, { signal }) => {
    const d = el.dataset;
    const hours: (0 | [string, string])[] = JSON.parse(d.hours!);
    const days: string[] = JSON.parse(d.days!);
    const text = el.querySelector<HTMLElement>('.ma-open__text')!;
    const fmt = new Intl.DateTimeFormat('en-GB', { timeZone: d.tz, weekday: 'short', hour: '2-digit', minute: '2-digit', hourCycle: 'h23' });
    const tick = () => {
      const p: Record<string, string> = {};
      for (const { type, value } of fmt.formatToParts()) p[type] = value;
      const today = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].indexOf(p.weekday!);
      const now = `${p.hour}:${p.minute}`;
      // open today, or still open from yesterday's late hours (a close before its open runs past midnight)
      const h = hours[today];
      const y = hours[(today + 6) % 7];
      const late = !!y && y[1] < y[0] && now < y[1];
      const open = late || (!!h && (h[1] < h[0] ? now >= h[0] : now >= h[0] && now < h[1]));
      let line = '';
      if (open) line = d.openText!.replace('{close}', ((late ? y : h) as [string, string])[1]);
      else
        for (let k = 0; k < 8; k++) {
          const x = hours[(today + k) % 7];
          if (x && (k || now < x[0])) {
            line = d.closedText!.replace('{day}', k === 0 ? d.today! : k === 1 ? d.tomorrow! : days[(today + k) % 7]!).replace('{open}', x[0]);
            break;
          }
        }
      el.toggleAttribute('data-open', open);
      text.textContent = line;
      el.hidden = !line;
    };
    tick();
    const timer = setInterval(tick, 30000);
    signal.addEventListener('abort', () => clearInterval(timer));
  });
</script>

<style is:global>
  @layer components {
    :where(.ma-open) { --ma-open-on: oklch(0.62 0.16 150); display: flex; align-items: center; gap: 0.55rem; margin: 0; font-weight: 550; }
    :where(.ma-open[hidden]) { display: none; }
    :where(.ma-open__dot) { flex: none; inline-size: 0.6rem; block-size: 0.6rem; border-radius: 999px; box-shadow: inset 0 0 0 2px currentColor; opacity: 0.6; }
    :where(.ma-open[data-open] .ma-open__dot) { background: var(--ma-open-on); box-shadow: 0 0 0 0.2rem color-mix(in oklab, var(--ma-open-on) 25%, transparent); opacity: 1; animation: ma-open-pulse 2.4s var(--ma-ease-out, ease-out) 3; }
    @keyframes ma-open-pulse { 50% { box-shadow: 0 0 0 0.45rem color-mix(in oklab, var(--ma-open-on) 0%, transparent); } }
    @media (prefers-reduced-motion: reduce) {
      :where(.ma-open[data-open] .ma-open__dot) { animation: none; }
    }
  }
</style>

```
