Local
FoundationPreviewTome local-business layer — a timezone-correct, browser-safe open-state utility (split shifts, dated overrides, named schedules) and a LocalBusiness JSON-LD builder that takes a neutral input.
Add our registry to the
.npmrcat the root of your project. Free packages install without a token.@wabbit:registry=https://npm.wabbit.com/Then install:
npm install @wabbit/tome-local
Overview
@wabbit/tome-local
The time math and structured data every local-business surface needs, in one isomorphic package: a timezone-correct open-state utility that understands split shifts, dated overrides and named schedules, and a LocalBusiness JSON-LD builder that takes a neutral input so a site's own profile and a directory listing produce markup through one code path.
This release ships the two pure subpaths. The site profile global, its collections and the server helpers described in the layer design are deliberately not here yet: they land once a site commits to consuming them.
Installation
pnpm add @wabbit/tome-localNo peer dependencies, no runtime dependencies. Nothing in the package imports payload, server-only, React or a date library; timezone conversion uses the platform's Intl.
Module surface
| Entry | Safe in a client bundle | What it carries | |---|---|---| | @wabbit/tome-local | yes | everything below, re-exported | | @wabbit/tome-local/hours | yes | computeOpenState, getOpenState, listWindows, formatOpenStateLabel, validateWeeklyHours, date-key helpers, hours types | | @wabbit/tome-local/jsonld | yes | buildLocalBusinessJsonLd, serializeJsonLd, input types |
Server/client posture: both subpaths are isomorphic. Compute the state on the server for first paint, then recompute it in a client island on an interval, because a cached page otherwise tells visitors a shop is open when it closed an hour ago.
./hours — open now, closes at, opens next
import { computeOpenState, formatOpenStateLabel } from '@wabbit/tome-local/hours'
const state = computeOpenState({
hours: [
{ dayOfWeek: '1', opens: '11:00', closes: '14:00' }, // lunch
{ dayOfWeek: '1', opens: '17:00', closes: '22:00' }, // dinner
],
hoursOverrides: [{ date: '2026-12-25', closed: true, label: 'Christmas' }],
timezone: 'America/Chicago',
})
// → { openNow, closesInMinutes?, opensAt?, closesAt?, current?, next?, closedLabel? }
formatOpenStateLabel(state, { timezone: 'America/Chicago', locale: 'en-US' })
// → "Open now · closes at 10 PM" | "Opens tomorrow at 11 AM" | "Closed today · Christmas" | null- Stored shape. Weekly rows are
{ dayOfWeek: '0'–'6', opens: 'HH:mm', closes: 'HH:mm', closed? }in the business's IANA timezone ('0'is Sunday). Several rows per day are split shifts, and every row counts.closes: '24:00'is end of day; aclosesearlier thanopensruns past midnight. - Overrides replace the weekly rows for their date. Several rows on one date give several windows; a
closedrow closes the date.datemay be a bareYYYY-MM-DDor the ISO instant a database returns for a date-only field; an explicitdateKeywins when present.toDateOnlyKeyreads a date-only value as its UTC calendar date, which is what both Payload's admin day picker (12:00 UTC) and an API write of a bare date (00:00 UTC) store. - DST. Wall-clock times resolve per date. A time inside a spring-forward gap moves forward past the gap, and an ambiguous fall-back time takes the first occurrence.
- Merging. Touching or overlapping windows merge before
currentis chosen, so a business open through midnight reports its real closing time. getOpenState(source, now?, scheduleKey?)reads any{ timezone, hours, hoursOverrides, schedules? }record; ascheduleKeyselects a named schedule such as a kitchen or phone line.listWindows({ ..., from, days })lists every concrete window (one per row) for week views.validateWeeklyHours(hours)is a wrap-aware save-time validator: it rejects bad times,opens === closes, and any overlap anywhere in the week, including an overnight row running into the next morning.toLocalDateKey(instant, timezone)gives the calendar date there;isValidTimeZone(zone)checks an IANA name.LocalHoursSourceis the interface a consumer that needs hours (a booking engine, for example) reads without knowing what stores them.
./jsonld — LocalBusiness structured data
import { buildLocalBusinessJsonLd, serializeJsonLd } from '@wabbit/tome-local/jsonld'
const ld = buildLocalBusinessJsonLd({
schemaType: 'Plumber',
name: 'Example Plumbing',
telephone: '+18165550100',
address: { city: 'Kansas City', region: 'MO', country: 'US', visibility: 'hidden' },
timezone: 'America/Chicago',
hours: [{ dayOfWeek: '1', opens: '08:00', closes: '17:00' }],
areaServed: [{ name: 'Within 25 miles', kind: 'radius', center: { lat: 39.1, lng: -94.58 }, radiusMiles: 25 }],
})
// <script type="application/ld+json" dangerouslySetInnerHTML={{ __html: serializeJsonLd(ld) }} />- Every property is conditional on data; nothing is defaulted, including
addressCountry. openingHoursSpecificationhas one entry per weekly row (split shifts give two for one day),24:00is written as23:59, and overrides withinoverrideHorizonDays(default 30) carryvalidFrom/validThrough, with closed dates as00:00–00:00.areaServedmapscitytoCity,countyandregiontoAdministrativeArea,postal-codeto aPlacewith a postal address, andradiusto aGeoCirclein metres.- A
hiddenaddress emits locality, region, postal code and country only, and suppressesgeo. schemaType: 'Organization'drops hours, geo andpriceRange.- It never emits `aggregateRating` or `review`. First-party testimonials are not review markup. A site with a verified third-party review integration emits its own.
serializeJsonLdescapes<and the U+2028/U+2029 separators, so an editor field containing</script>cannot break out of the tag.
What this package deliberately does not do
No booking, slots or appointments; no review collection or rating aggregation; no geocoding; no vertical policy. A consumer that lists other businesses keeps its own compliance rules (which subtypes, what to omit) in the adapter it writes from its record to LocalBusinessInput.
Exports
@wabbit/tome-local@wabbit/tome-local/hours@wabbit/tome-local/jsonld
Changelog
775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
- 775f90a: Published packages now contain compiled JavaScript and type declarations under a one-line licence banner, and no longer include source maps. What you install: one compiled `.js` (ESM) and `.cjs` (CommonJS) file per source module, its `.d.ts` / `.d.cts` declarations, and the stylesheets, fonts and other assets a package already shipped. Every JavaScript module opens with a comment naming the package and its licence: `/*! @wabbit/<package> — © Wabbit, LLC. Wabbit Tome Commercial License (see LICENSE.md). Not for redistribution. */`. The `.map` files and the `sourceMappingURL` comments that pointed at them are gone, which roughly halves the size of each tarball. Debugging: the code is still unbundled and unminified, one readable file per module, so a stack trace points at real code with real names. Line numbers in a stack trace are one higher than before, because of the banner line. A `'use client'` directive stays the first statement of its module (the banner is a comment above it), so React Server Component boundaries are unchanged. No API change, no runtime behaviour change, and nothing to do on upgrade. In `@wabbit/tome-blocks-gallery`, the source snapshots `extractGallerySource` writes from an installed pack leave out the licence banner line, so a component or config snapshot starts at the code and a paid block's preview shows its first 15 lines of real code.
35b20ca: New package: `@wabbit/tome-local`, a browser-safe open-state utility and a LocalBusiness JSON-LD builder for local-business sites. - **`./hours`:** `computeOpenState` returns `openNow`, `closesInMinutes` and `opensAt`, plus `closesAt`, the `current` and `next` windows, and a `closedLabel` for a closed-today override. It handles several windows per day (split shifts), several override windows per date, overrides stored as a bare `YYYY-MM-DD` or as the ISO instant a database returns for a date-only field, and daylight-saving transitions (a time inside a spring-forward gap moves past the gap; an ambiguous fall-back time takes its first occurrence). Touching windows merge, so a business open through midnight reports its real closing time. Also `getOpenState` (any record with hours, plus named schedules), `listWindows`, `formatOpenStateLabel` ("Open now · closes at 10 PM", "Opens tomorrow at 11 AM", "Closed today · Holiday", with "today" in the business timezone), a wrap-aware `validateWeeklyHours`, the `toLocalDateKey` / `toDateOnlyKey` helpers and the `LocalHoursSource` interface. No `payload`, `server-only`, React or date-library import. - **`./jsonld`:** `buildLocalBusinessJsonLd` builds schema.org LocalBusiness (or a subtype, or Organization) markup from a neutral input: conditional fields only, `openingHoursSpecification` per weekly row with dated overrides inside a horizon, `areaServed` as City / AdministrativeArea / Place / GeoCircle, a hidden-address mode that omits the street line and coordinates, and never `aggregateRating` or `review`. `serializeJsonLd` escapes the output for an inline script tag.
- 35b20ca: New package: `@wabbit/tome-local`, a browser-safe open-state utility and a LocalBusiness JSON-LD builder for local-business sites. - **`./hours`:** `computeOpenState` returns `openNow`, `closesInMinutes` and `opensAt`, plus `closesAt`, the `current` and `next` windows, and a `closedLabel` for a closed-today override. It handles several windows per day (split shifts), several override windows per date, overrides stored as a bare `YYYY-MM-DD` or as the ISO instant a database returns for a date-only field, and daylight-saving transitions (a time inside a spring-forward gap moves past the gap; an ambiguous fall-back time takes its first occurrence). Touching windows merge, so a business open through midnight reports its real closing time. Also `getOpenState` (any record with hours, plus named schedules), `listWindows`, `formatOpenStateLabel` ("Open now · closes at 10 PM", "Opens tomorrow at 11 AM", "Closed today · Holiday", with "today" in the business timezone), a wrap-aware `validateWeeklyHours`, the `toLocalDateKey` / `toDateOnlyKey` helpers and the `LocalHoursSource` interface. No `payload`, `server-only`, React or date-library import. - **`./jsonld`:** `buildLocalBusinessJsonLd` builds schema.org LocalBusiness (or a subtype, or Organization) markup from a neutral input: conditional fields only, `openingHoursSpecification` per weekly row with dated overrides inside a horizon, `areaServed` as City / AdministrativeArea / Place / GeoCircle, a hidden-address mode that omits the street line and coordinates, and never `aggregateRating` or `review`. `serializeJsonLd` escapes the output for an inline script tag.