Store hours script (storeHours.js)
Staff changing hours, store details or holiday closures should use Update store hours, store details and holiday closures. This page is the developer reference for the script behind it.
js/storeHours.js answers two questions for the browser: what are a store’s details, and is it open right now. It reads two blocks the footer prints on every page, one built from the Store Finder records and one from the Operating_Exceptions custom config. The public entry points are isStoreOpen(), getStoreConfig() and hydrateStoreFields().
Where the data comes from
| Source | Feeds | Format |
|---|---|---|
Store Finder standard fields (stloc_name, stloc_street1, stloc_city, stloc_state, stloc_zip, stloc_phone, stloc_email, stloc_lat, stloc_lng, store_id) | Name, address, phone, email, coordinates, the object key | As typed. stloc_city is stored in capitals by the platform |
Store Finder custom field misc1 / misc2 (cPanel “weekdays open” / “weekdays close”, Short Text) | Monday to Friday open / close | HH:MM, 24-hour |
Store Finder custom field misc3 / misc4 (cPanel “Saturday open” / “Saturday close”, Short Text) | Saturday open / close | HH:MM, 24-hour |
Store Finder custom field misc5 (added 2026-09-23) | slug, the /locations/<slug> page | Lower-case slug |
CONFIG:Operating_Exceptions | storeHours.closing_exceptions, site-wide closures | JavaScript object literal, not JSON |
There are no Sunday fields. Sunday is hard-coded as closed for every store. The Store Finder allows at most five custom fields per store, so all are in use. Every custom field value is cut silently to 50 characters by the platform, whatever the field type; the cPanel field form doesn’t state the limit. Because stloc_city can’t be changed from capitals in cPanel, the shopper-facing templates wrap it in .store-city, which css/style.css displays with only the first letter in capitals.
The four records are:
store_id | Store | misc5 (live, 2026-10-21) |
|---|---|---|
"1" | Altapac Kotara | kotara |
"2" | Altapac Maitland | maitland |
"3" | Altapac Artarmon | artarmon |
"4" | Altapac Burwood | burwood |
The two footer blocks
footers/includes/scripts.template.html prints both blocks, then loads the script with defer:
<script type="application/json" id="altapac-stores">{ ...one member per store..., "_": null }</script><script> const storeHours = { "closing_exceptions": [@CONFIG:Operating_Exceptions@] };</script><script defer src="[%ntheme_asset%]js/storeHours.js[%/ntheme_asset%]"></script>The #altapac-stores JSON block
The block is built with [%list type:'store_location' limit:'50'%]. It holds one member per Store Finder record, keyed by store_id. This is the live Artarmon member on 2026-10-21:
"3": { "store_name": "Altapac Artarmon", "slug": "artarmon", "phone": "02 8999 8878", "email": "artarmon@altapac.com", "address": "Unit 13/87 Reserve Rd, ARTARMON NSW 2064", "lat": "-33.8154910000", "lng": "151.1873400000", "openingHours": { "weekdays": { "open": "07:30", "close": "17:00" }, "saturday": { "open": "09:00", "close": "15:00" }, "sunday": { "closed": true } }}- Every value is a string, including
latandlng. CallersparseFloatthem. addressis assembled asstreet1, CITY STATE ZIP. There is nostreet2.- An empty custom field prints as
"". The script treats an empty open or close time as closed. - Each member ends with a comma. The final
"_": nullmember absorbs the last one.getStoreIds()skips any key starting with_. - It’s a
type="application/json"block, so the browser never runs it. A bad value can only makeJSON.parsefail. It can’t break other scripts on the page.
The storeHours global
The inline script declares const storeHours with only closing_exceptions. The store members are merged into it later, on the first call to any reader (see Load order). closing_exceptions is never overwritten by the JSON block.
Operating_Exceptions is interpolated unquoted, so it must be a valid JavaScript expression. The live value is an object with single-quoted keys and values and a trailing comma:
{ '2026-04-03': 'Good Friday', '2026-06-08': 'King\'s Birthday', '2026-12-25': 'Christmas Day', }Both the single quotes and the trailing comma are invalid JSON, which is why this block stays out of #altapac-stores and must never go through JSON.parse.
Load order
- The parser reaches the JSON block and the inline
storeHoursscript. storeHours.jsisdeferred, so it runs after parsing and beforeDOMContentLoaded. Its last lines callhydrateStoreFields()straight away (or onDOMContentLoadedif the document is somehow still loading).- The contact modal and the product availability panel call
isStoreOpen()andgetStoreConfig()at or afterDOMContentLoaded.
loadStoreData() sets its storeDataLoaded latch on the first call, whether or not it found the block. That’s safe only because the JSON block comes before the deferred script in the same footer file. If you move the block below the script, or drop defer, a missing block on the first call becomes permanent for the page.
DEFAULT_STORE_ID
var DEFAULT_STORE_ID = "1";isStoreOpen() uses it when the argument is undefined, null or "". No caller in the theme relies on it. Every caller passes an id. One subtle path: isStoreOpen(el.getAttribute('data-store-id')) on an element without the attribute passes null, and gets Kotara’s status rather than false.
Public functions
Everything is a global function on window. The file isn’t a module.
| Function | Returns | Notes |
|---|---|---|
isStoreOpen(storeId?) | true or false | Is that store open now, in Sydney time. See How isStoreOpen decides |
getStoreConfig(storeId) | The store’s object from the JSON block, or null | null if the id is falsy, unknown, or the store data didn’t load. Requires an openingHours member |
getTodayHours(storeId) | { open, close }, { closed: true }, or null | Today’s hours by Sydney weekday. { closed: true } if either time is empty. null if the store isn’t found. Ignores closing exceptions. No theme caller |
getStoreIds() | Array of id strings, e.g. ["1","2","3","4"] | Skips closing_exceptions and _-prefixed keys. [] if storeHours is missing. No theme caller |
isClosingException(dateStr?, exceptions?) | true or false | Is dateStr (YYYY-MM-DD, default today in Sydney) in the closures list. exceptions overrides storeHours.closing_exceptions |
hydrateStoreFields(root?) | Nothing | Fills data-store-field elements under root (default document). See Store detail tags |
formatStoreHours(day) | A string such as "7:30am – 5pm", or "Closed" | day is { open, close } or { closed: true }. "Closed" if either time is missing or unparsable |
formatStoreTime(hhmm) | A string such as "7:30am" or "5pm", or null | Drops :00. "12:00" gives "12pm", "00:30" gives "12:30am" |
Internal helpers, also global: loadStoreData(), getOpeningHoursForStore(storeId) (with a fallback to a legacy top-level storeHours.openingHours that nothing sets any more), getClosingExceptionsList(), timeStringToMinutes(), getCurrentAEST() and formatDateYYYYMMDD(). formatDateDDMMYYYY() is left over from the old single-store version and nothing calls it.
How isStoreOpen decides
In order:
- Resolve the id, falling back to
DEFAULT_STORE_ID. - Look up that store’s
openingHours. None, sofalse. This covers an unknown id and store data that failed to load. - If today’s Sydney date is in
closing_exceptions,false. Closures apply to every store. - Pick
sunday(always{ closed: true }),saturdayorweekdaysby Sydney weekday. Closed, sofalse. - Convert open and close to minutes. If either is empty,
false. trueif the current Sydney minute is between open and close, inclusive at both ends. A store that closes at17:00still reads as open during the 17:00 minute.
”Sydney time” comes from getCurrentAEST(), which formats the current instant in Australia/Sydney and rebuilds a Date from the parts. Only its weekday, hours and minutes are read, so daylight saving is handled by Intl.
timeStringToMinutes() splits on : and nothing more. 08:00 and 8:00 both work. 8am, 8.00 and 5pm give NaN, every comparison fails, and the store reads as closed. formatStoreTime() returns null for the same values, so formatStoreHours() prints "Closed". The server-side templates print the raw text whatever it is, so a bad value shows as typed on the home page and contact page while the script calls the store closed.
Operating_Exceptions parsing
getClosingExceptionsList() accepts either shape:
- an object, where only the keys are used (the live format)
- an array of date strings, such as
['2026-12-25', '2027-01-01']
Anything else gives an empty list. The holiday names are never read or displayed.
isClosingException() does an exact string match against formatDateYYYYMMDD() of the Sydney date. Keys must be YYYY-MM-DD with leading zeros. 2026-12-5 and 25/12/2026 never match, so the stores read as open on that day.
To check a change to the config, open any product page with a Pick up tab. Store names mean the store data loaded. “Store 1” to “Store 4” means it didn’t. This check works at any hour, unlike the open or closed status.
Store detail tags (hydrateStoreFields)
hydrateStoreFields() lets CMS content show Store Finder values instead of hand-typed copies. It runs once when storeHours.js loads, over every [data-store-field] element in the document.
<span data-store-id="3" data-store-field="phone">02 8999 8878</span><a data-store-id="1" data-store-field="phone" href="tel:0249414444">Call the Kotara store</a>data-store-idis passed togetStoreConfig(), so it must be"1"to"4". A slug such askotara, or a missing attribute, matches nothing and the element is left alone.data-store-fieldpicks the value:
data-store-field | Value | Example |
|---|---|---|
name | store_name | Altapac Artarmon |
phone | phone, as typed | 02 8999 8878 |
email | email | artarmon@altapac.com |
address | address | Unit 13/87 Reserve Rd, ARTARMON NSW 2064 |
weekdays | formatStoreHours(openingHours.weekdays) | 7:30am – 5pm |
saturday | formatStoreHours(openingHours.saturday) | 9am – 3pm |
sunday | formatStoreHours(openingHours.sunday) | Closed |
How the element is changed:
- Not a link:
textContentis replaced. Any child elements, such as an icon inside the span, are removed. - A link (
<a>): onlyhrefchanges, and the link text is kept.phonesetstel:with every character except digits and+stripped.emailsetsmailto:. Other fields do nothing on a link. Put aspantag inside the link to show the value too. - Fallback: the existing text or
hrefstays when the store isn’t found, the store data didn’t load, the field name is unknown, or the value is empty.
The hours fields never come back empty while the store exists, because formatStoreHours() returns "Closed". So clearing a store’s Saturday fields replaces the fallback text with “Closed” rather than leaving it. address is never empty either, since it always contains the commas and spaces.
Closing exceptions don’t affect these tags. On a holiday they still show the normal weekly hours.
The function runs once. Content added to the page later, such as an AJAX-loaded panel, isn’t filled unless you call hydrateStoreFields(newRoot) yourself.
On 2026-10-21 the tags are used in Altapac-Content under pages/locations/ (the four location pages and the Artarmon and Burwood bullbars, canopies, roof racks and towbars sub-pages), for weekdays, saturday, sunday and phone.
Consumers
These call the script in the browser:
| Consumer | Calls | Shows | When the store data is missing |
|---|---|---|---|
Contact modal, every page (footers/includes/contactModal.template.html) | isStoreOpen(id) for each .contact-store[data-store-id] row, on DOMContentLoaded | ”open now” or “closed” per store. The Call button is enabled when any store is open, and dials CONFIG:COMPANY_PHONE, not a store number | Every store “closed”, Call stays disabled |
Product page Pick up panel (products/includes/availability.template.html) | getStoreConfig(id) for store_name, slug, lat, lng. isStoreOpen(id) for the nearest store holding stock | Linked store names, distance from the postcode, “Ready today at” when that store is open, otherwise “Ready at” | Labels read “Store 1” to “Store 4”, no links, no distance sort |
| CMS content tags | hydrateStoreFields(), via getStoreConfig(id) | See Store detail tags | Fallback text stays |
The Pick up panel maps warehouses to stores in its own STORES[] array: warehouses 4 and 9 to store "1", 7 to "2", 11 to "3" and 16 to "4". Both callers guard with typeof ... === 'function' and a try, so a missing script degrades to the “no data” branch.
These read the same Store Finder fields server-side, with their own [%list type:'store_location'%] loop. They don’t use storeHours.js and ignore closing exceptions:
| Template | Uses |
|---|---|
Home page store carousel (cms/home.template.html) | Name linked to /locations/[@misc5@], address, misc1–misc4 printed raw (08:00 - 17:00), store logo as the slide background |
Contact page “Visit Our Showrooms” (cms/contact.template.html) | Map, name, address, phone, email, raw hours, “Find Out More” link from misc5 |
Contact modal store rows (footers/includes/contactModal.template.html) | Name, address, raw hours, phone, email. The script only fills the status marker |
Structured data (headers/includes/structured_data/structured_data.template.html) | One LocalBusiness node per store. openingHoursSpecification only if misc1 and misc2 are both set, with Saturday added only if misc3 and misc4 are both set. @id and url from misc5, falling back to the /d/ page. Image, description and rating are hard-coded per store_id |
Store Finder pages /d/<name>/<id> (stockists/template.html, headers/includes/head.template.html) | Redirect to /locations/[@misc5@], and canonical to the same page (not live yet, AUD-83; on altapac.com today the canonical points at the /d/ page itself) |
So the raw text on the home page, contact page and modal can disagree with the script’s view. For example, 8am prints as typed but the script reads the store as closed.
Failure modes
| Cause | Script result | Visible symptom |
|---|---|---|
" or \ in any Store Finder field | JSON block fails to parse, no stores | ”Store 1” to “Store 4” on product pages, every store closed in the modal, tags keep fallback text, invalid JSON-LD |
Operating_Exceptions empty or not valid JavaScript | storeHours undeclared, no stores, no closures | Same as above, plus a console SyntaxError |
Closure date not YYYY-MM-DD | Never matches | Stores read as open on the holiday |
misc1–misc4 not H:MM or HH:MM | That day reads closed | Modal says closed in trading hours. Raw text prints elsewhere |
| One of a pair of hour fields empty | That day reads closed | Half a range such as 09:00 - on the home and contact pages |
misc1 or misc2 empty | Weekdays closed | Store’s openingHoursSpecification dropped entirely, including Saturday |
data-store-id is a slug | getStoreConfig() returns null | Tag keeps fallback text, which goes stale |
Changing the script
- Keep the JSON block above the deferred
storeHours.jsin the same footer file, and keepdefer. See Load order. - Never move
Operating_Exceptionsinto the JSON block or behindJSON.parse. The live value isn’t JSON. Hardening the empty-config bug means either converting the config to strict JSON first or giving the interpolation a default. - A new store gets its JSON member, carousel slide, contact card and modal row automatically. It also needs a
STORES[]row inavailability.template.htmland its own SEO arm in the structured data. The contact page tabs and enquiry forms are hand-built and need changing too. node scripts/storehours-check.cjsin Altapac-ReDev runsstoreHours.jsin a bare VM and asserts the contract the templates rely on.
Source files
Altapac-ReDev, branch development (77cdf1b), checked 2026-10-21. The live production theme’s storeHours.min.js matched this source on the same day, including hydrateStoreFields.
src/js/storeHours.jssrc/templates/footers/includes/scripts.template.html(JSON block lines 33–53,storeHoursglobal line 57, script tag line 60)src/templates/footers/includes/contactModal.template.htmlsrc/templates/products/includes/availability.template.html(STORES[]andstoreData()around lines 269–280,isOpenNow()around line 692)src/templates/headers/includes/structured_data/structured_data.template.htmlsrc/templates/cms/home.template.html,src/templates/cms/contact.template.htmlsrc/templates/stockists/template.html,src/templates/headers/includes/head.template.htmlscripts/storehours-check.cjsdocs/map/store-data/StoreData.md,docs/map/store-data/issues.md,docs/records/2026-09-15-store-finder-store-data/spec.md,docs/probes/RESULTS.md(Store Finder probe).claude/skills/base-templating/references/value-formats.md(Operating_Exceptionssection)