The shop knows when it is open, and says so — to visitors and to Google

Three things a bakery's site should do that this one couldn't, all from the same fact.

THE HOURS WERE MARKUP, so the only thing the site could do with them was print them. A visitor at half
past two on a Sunday had to work out for themselves that the shop was shut. They are data now (Shop.WEEK),
and `Hours` reads them two ways: as the list the footer and the contact page print — consecutive days with
the same times collapsed into one line, the way a sign does it, instead of three hand-written rows — and
as an answer. The header now says "Open until 2:00pm", or "Closed · opens Tuesday at 7:00am", worked out
on the server in the shop's own timezone. Right now, on a Sunday evening, it says the latter.

THE SHOP IS NOW DESCRIBED TO A SEARCH ENGINE: schema.org Bakery in the head, with the address, the phone
number and those same opening times. For a shop whose customers find it by searching its town, that is the
difference between a blue link and a listing that shows "Open ⋅ closes 2pm" — and it cannot drift from
what the page says, because it is built from the same record. Serialised with Jackson rather than
string-built, so the day somebody interpolates a name into it, it is not an injection. (Jackson 3 —
`tools.jackson` — which is what Boot 4 ships; the fasterxml import does not compile.)

A SHARED LINK NOW HAS A PICTURE. og:image, og:image:alt and a large summary card: it was a grey box with a
title, which is what every link to this site has looked like in a message.

AND IT PRINTS. The catering page is a price list and a bakery prints price lists — Ctrl-P now gives the
prices on paper rather than a screenshot of a website: no chrome, no sage bands eating a cartridge, cards
that don't split across a page break, and the buttons that only exist to be clicked marked `no-print`.

Five new tests, all of them free of Spring, because this is a calculation over a constant: the week
collapses as a sign would write it, the minute of opening counts as open and the minute of closing does
not, and a Saturday afternoon is told when Tuesday starts.

Also swept the last one-off styles the earlier pass missed — max-w-3xl/4xl, an inline letter-spacing the
.h2 class already carries, and the mobile menu's link string.

Co-Authored-By: Claude Opus 5 (1M context) <[email protected]>
This commit is contained in:
2026-07-26 19:27:41 -05:00
co-authored by Claude Opus 5
parent 9e80590687
commit a657600078
13 changed files with 474 additions and 41 deletions
+3 -3
View File
@@ -15,7 +15,7 @@
<div class="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80"></div>
<div class="relative container w-full min-w-0">
<div class="max-w-3xl mx-auto text-center">
<div class="measure text-center">
<p class="eyebrow text-bakery-200">Order ahead</p>
<h1 class="h1 mt-3 text-bakery-50">Goodie boxes &amp; catering</h1>
<p class="lede mt-5 text-bakery-100">
@@ -117,7 +117,7 @@
</ul>
</div>
<a th:href="|/contact?about=${#uris.escapeQueryParam(table.name)}|"
class="pill-outline mt-6"
class="pill-outline mt-6 no-print"
th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office boxes</a>
</div>
</section>
@@ -134,7 +134,7 @@
<!--/* The terms that apply whichever table you were reading, and the one real call to action. On sage,
like the homepage's story band, so the page ends rather than trailing off. */-->
<section class="section bg-bakery-800 text-white">
<section class="section bg-bakery-800 text-white no-print">
<div class="container">
<div class="measure text-center">
<h2 class="h3">Before you order</h2>
@@ -27,6 +27,10 @@
<meta property="og:title" th:content="${title}">
<meta property="og:description" th:content="${description}">
<meta property="og:url" th:content="${baseUrl + (path == '/' ? '' : path)}">
<!--/* Without an image a shared link is a grey box with a title. This is the storefront. */-->
<meta property="og:image" th:content="${shareImage}">
<meta property="og:image:alt" content="The Vine Coffeehouse + Bakery on Main Street, Princeville">
<meta name="twitter:card" content="summary_large_image">
<meta property="og:type" content="website">
<meta property="og:locale" content="en_US">
<meta name="keywords" content="bakery, coffeehouse, pastries, custom cakes, cinnamon rolls, paninis, Princeville IL">
@@ -34,6 +38,11 @@
<!--/* ?v= is the commit sha in a deployment. One hand-written stylesheet has no content hash in its
name, so without it a returning visitor keeps the CSS they cached before the deploy. */-->
<link rel="stylesheet" th:href="|/css/site.css?v=${build}|">
<!--/* The shop as a search engine reads it: a Bakery, its address, its phone number and its opening
times, from the same record the page prints. It is how a local shop gets "Open ⋅ closes 2pm"
next to its listing. Unescaped because it is JSON, written by Jackson — see StructuredData. */-->
<script type="application/ld+json" th:utext="${structuredData}"></script>
</head>
<body></body>
</html>
@@ -21,9 +21,15 @@
<div class="flex items-center justify-between gap-2 h-20 md:h-24">
<div th:replace="~{fragments/lockup :: small('text-bakery-700')}"></div>
<nav class="hidden md:flex items-center gap-8">
<th:block th:replace="~{:: links('label text-bakery-700 hover:text-bakery-900 transition-colors')}"></th:block>
</nav>
<div class="hidden md:flex items-center gap-8">
<nav class="flex items-center gap-8">
<th:block th:replace="~{:: links('label text-bakery-700 hover:text-bakery-900 transition-colors')}"></th:block>
</nav>
<!--/* Worked out on the server from the same opening times the footer prints. */-->
<p th:replace="~{fragments/visit :: open-now(
${openNow.open} ? 'bg-bakery-500' : 'bg-bakery-300',
'text-sm text-bakery-600 whitespace-nowrap')}"></p>
</div>
<details class="group md:hidden shrink-0">
<summary class="p-2 cursor-pointer list-none" aria-label="Menu">
@@ -38,7 +44,10 @@
</summary>
<div class="absolute inset-x-0 top-full z-30 h-[calc(100dvh-5rem)] bg-bakery-50">
<nav class="container flex flex-col">
<th:block th:replace="~{:: links('text-bakery-800 hover:text-bakery-600 transition py-4 text-lg border-b border-bakery-100')}"></th:block>
<th:block th:replace="~{:: links('text-bakery-800 hover:text-bakery-600 transition-colors py-4 text-lg border-b border-bakery-100')}"></th:block>
<p th:replace="~{fragments/visit :: open-now(
${openNow.open} ? 'bg-bakery-500' : 'bg-bakery-300',
'text-sm text-bakery-600 py-4')}"></p>
</nav>
</div>
</details>
@@ -2,40 +2,40 @@
<html xmlns:th="http://www.thymeleaf.org">
<body>
<!--/*
The opening hours and the address, in one place.
The opening times and the address, printed from the shop's own record rather than from markup.
They were on the homepage and in the footer already, and the moment the contact page wanted them too
that became three copies of the one fact a customer most often comes here for. A shop that changes its
Saturday hours should change them once.
They were three hand-written lines repeated in three templates. Now `Hours` collapses the week into the
lines a sign would show — consecutive days that keep the same times become one row — and the same data
answers "are they open right now?" in the header and describes the shop to a search engine. A shop that
changes its Saturday hours changes them in Shop.java, once.
Both fragments take the classes that vary by where they sit — cream text in the footer, sage on white
in a card — so the content is shared without the styling being averaged into something that suits
neither.
Both fragments take the classes that vary by where they sit — cream in the footer, sage on white in a
card — so the content is shared without the styling being averaged into something that suits neither.
*/-->
<ul th:fragment="hours(rowClass)" class="space-y-3">
<li th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span>Tuesday &ndash; Friday</span>
<span class="font-medium whitespace-nowrap">7:00am &ndash; 2:00pm</span>
</li>
<li th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span>Saturday</span>
<span class="font-medium whitespace-nowrap">7:00am &ndash; 12:00pm</span>
</li>
<li th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span>Sunday &ndash; Monday</span>
<span class="font-medium">Closed</span>
<li th:each="span : ${hours}" th:class="'flex justify-between gap-4 ' + ${rowClass}">
<span th:text="${span.days}">Tuesday Friday</span>
<span class="font-medium whitespace-nowrap" th:text="${span.hours}">7:00am 2:00pm</span>
</li>
</ul>
<address th:fragment="address(linkClass)" class="not-italic space-y-3">
<p>215 E Main Street<br>Princeville, IL 61559</p>
<p th:text="${shopStreet}">215 E Main Street</p>
<p th:text="${shopTown}">Princeville, IL 61559</p>
<p>
<a href="tel:+13097010660" th:class="${linkClass}">(309) 701-0660</a>
<a th:href="|tel:${shopPhone}|" th:class="${linkClass}" th:text="${shopPhoneSpoken}">(309) 701-0660</a>
</p>
<p class="break-words">
<a href="mailto:[email protected]" th:class="${linkClass}">[email protected]</a>
<a th:href="|mailto:${shopEmail}|" th:class="${linkClass}" th:text="${shopEmail}">[email protected]</a>
</p>
</address>
<!--/* "Open until 2:00pm", or when it opens next. The one thing a visitor at 2:30 on a Sunday wants. */-->
<span th:fragment="open-now(dotClass, textClass)"
th:class="'inline-flex items-center gap-2 ' + ${textClass}">
<span th:class="'h-2 w-2 rounded-full shrink-0 ' + ${dotClass}" aria-hidden="true"></span>
<span th:text="${openNow.summary}">Open until 2:00pm</span>
</span>
</body>
</html>
+1 -1
View File
@@ -10,7 +10,7 @@
<div class="absolute inset-0 bg-bakery-900/80"></div>
<div class="absolute inset-0 bg-gradient-to-t from-bakery-900 via-bakery-800/60 to-bakery-900/80"></div>
<div class="relative container w-full min-w-0">
<div class="max-w-3xl mx-auto text-center">
<div class="measure text-center">
<p class="eyebrow text-bakery-200">Since 2024</p>
<h1 class="h1 mt-3 text-bakery-50">Our story</h1>
<p class="lede mt-5 text-bakery-100">
+5 -5
View File
@@ -16,7 +16,7 @@
<!--/* w-full/min-w-0 keep this flex item from sizing to its max-content width and blowing out the
page on narrow screens. */-->
<div class="relative container w-full min-w-0">
<div class="max-w-3xl mx-auto text-center">
<div class="measure text-center">
<div th:replace="~{fragments/lockup :: large('text-bakery-50 mb-10')}"></div>
<p class="lede sm:text-xl text-bakery-100 mb-10">
A coffeehouse and bakery in downtown Princeville, Illinois.
@@ -56,9 +56,9 @@
<!--/* Our story */-->
<section class="section bg-bakery-800 text-white">
<div class="container">
<div class="max-w-3xl mx-auto text-center">
<h2 class="h2 mb-8" style="letter-spacing: 0.01em">Our story</h2>
<p class="text-base md:text-lg text-bakery-100 mb-8 leading-relaxed">
<div class="measure text-center">
<h2 class="h2 mb-8">Our story</h2>
<p class="lede text-bakery-100 mb-8">
Morissa Bennett opened The Vine in 2024. We bake in our own kitchen on Main Street:
cinnamon rolls, cookies, custom cakes, sandwiches, paninis, and coffee.
</p>
@@ -99,7 +99,7 @@
<section id="visit" class="section bg-bakery-50 scroll-mt-24">
<div class="container">
<h2 class="h2 text-center text-bakery-900 mb-12">Visit us</h2>
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 max-w-4xl mx-auto">
<div class="grid grid-cols-1 md:grid-cols-2 gap-8 md:gap-12 measure-wide">
<div class="panel p-6 md:p-8">
<h2 class="h3 text-bakery-900 mb-6">Our hours</h2>
<div th:replace="~{fragments/visit :: hours('text-bakery-800')}"></div>
+1 -1
View File
@@ -51,7 +51,7 @@
</div>
<!--/* A catalogue with no way to order from it is a gallery. */-->
<section class="section bg-bakery-800 text-white">
<section class="section bg-bakery-800 text-white no-print">
<div class="container">
<div class="measure text-center">
<h2 class="h3">Want one of these, or something else?</h2>