Archived
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.
This commit is contained in:
@@ -0,0 +1,101 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZonedDateTime;
|
||||
import java.time.format.TextStyle;
|
||||
import java.util.ArrayList;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* The opening times, read two ways: as the list on the page, and as the answer to "are they open now?"
|
||||
*
|
||||
* <p>Both used to be impossible. The hours were three lines of 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 ({@link Shop#WEEK}), and this turns them into both.
|
||||
*/
|
||||
@Service
|
||||
public class Hours {
|
||||
|
||||
/** One line of the printed list: "Tuesday – Friday", "7:00am – 2:00pm". Closed days say so. */
|
||||
public record Span(String days, String hours, boolean closed) {}
|
||||
|
||||
/** "Open until 2:00pm", or "Closed · opens Tuesday at 7:00am". */
|
||||
public record Status(boolean open, String summary) {}
|
||||
|
||||
/**
|
||||
* The week, with consecutive days that keep the same times collapsed into one line — which is what a
|
||||
* shop sign does, and what the three hand-written lines used to do by hand.
|
||||
*/
|
||||
public List<Span> week() {
|
||||
List<Span> spans = new ArrayList<>();
|
||||
int i = 0;
|
||||
while (i < Shop.READING_ORDER.size()) {
|
||||
DayOfWeek first = Shop.READING_ORDER.get(i);
|
||||
Shop.Opening opening = Shop.WEEK.get(first);
|
||||
|
||||
int j = i;
|
||||
while (j + 1 < Shop.READING_ORDER.size()
|
||||
&& sameHours(opening, Shop.WEEK.get(Shop.READING_ORDER.get(j + 1)))) {
|
||||
j++;
|
||||
}
|
||||
DayOfWeek last = Shop.READING_ORDER.get(j);
|
||||
|
||||
String days = first == last ? name(first) : name(first) + " – " + name(last);
|
||||
spans.add(opening == null
|
||||
? new Span(days, "Closed", true)
|
||||
: new Span(days, time(opening.opens()) + " – " + time(opening.closes()), false));
|
||||
i = j + 1;
|
||||
}
|
||||
return spans;
|
||||
}
|
||||
|
||||
public Status now() {
|
||||
return at(ZonedDateTime.now(Shop.ZONE));
|
||||
}
|
||||
|
||||
/**
|
||||
* @param when the moment to answer for; taken as an argument so a test can ask about a Sunday
|
||||
* afternoon without waiting for one
|
||||
*/
|
||||
public Status at(ZonedDateTime when) {
|
||||
LocalTime time = when.toLocalTime();
|
||||
Shop.Opening today = Shop.WEEK.get(when.getDayOfWeek());
|
||||
|
||||
if (today != null && !time.isBefore(today.opens()) && time.isBefore(today.closes())) {
|
||||
return new Status(true, "Open until " + time(today.closes()));
|
||||
}
|
||||
if (today != null && time.isBefore(today.opens())) {
|
||||
return new Status(false, "Opens at " + time(today.opens()));
|
||||
}
|
||||
|
||||
// Closed for the day, so the next answer is on a later day. Seven steps at most, and the shop is
|
||||
// open five of them — the loop cannot run out.
|
||||
for (int ahead = 1; ahead <= 7; ahead++) {
|
||||
DayOfWeek day = when.getDayOfWeek().plus(ahead);
|
||||
Shop.Opening next = Shop.WEEK.get(day);
|
||||
if (next != null) {
|
||||
String when_ = ahead == 1 ? "tomorrow" : name(day);
|
||||
return new Status(false, "Closed · opens " + when_ + " at " + time(next.opens()));
|
||||
}
|
||||
}
|
||||
return new Status(false, "Closed");
|
||||
}
|
||||
|
||||
private static boolean sameHours(Shop.Opening a, Shop.Opening b) {
|
||||
return a == null ? b == null : a.equals(b);
|
||||
}
|
||||
|
||||
private static String name(DayOfWeek day) {
|
||||
return day.getDisplayName(TextStyle.FULL, Locale.US);
|
||||
}
|
||||
|
||||
/** "7:00am", "12:00pm" — the way the shop's own sign writes it, not "07:00". */
|
||||
private static String time(LocalTime at) {
|
||||
int hour = at.getHour() % 12 == 0 ? 12 : at.getHour() % 12;
|
||||
return "%d:%02d%s".formatted(hour, at.getMinute(), at.getHour() < 12 ? "am" : "pm");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.LocalTime;
|
||||
import java.time.ZoneId;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* The shop itself: where it is, how to reach it, and when it is open.
|
||||
*
|
||||
* <p>These facts were prose in three templates. They are now stated once, in Java, because they are read
|
||||
* by more than the eye: the footer and the contact page print them, the header works out from them
|
||||
* whether the shop is open right now, and {@code StructuredData} hands the same opening times to a search
|
||||
* engine — which is how a bakery ends up with its hours on the results page.
|
||||
*
|
||||
* <p>Hardcoded rather than a table with an admin screen, deliberately: opening times change about once a
|
||||
* year, and a table would need a screen, a migration and a way to say "closed Christmas Eve" to be worth
|
||||
* anything. When that day comes this is the one place to lift.
|
||||
*/
|
||||
public final class Shop {
|
||||
|
||||
/** The shop is in Princeville, Illinois; the container runs on UTC, which turns over first. */
|
||||
public static final ZoneId ZONE = ZoneId.of("America/Chicago");
|
||||
|
||||
public static final String NAME = "The Vine Coffeehouse + Bakery";
|
||||
public static final String STREET = "215 E Main Street";
|
||||
public static final String CITY = "Princeville";
|
||||
public static final String STATE = "IL";
|
||||
public static final String POSTCODE = "61559";
|
||||
/** For a tel: link. */
|
||||
public static final String PHONE = "+13097010660";
|
||||
/** For a human. */
|
||||
public static final String PHONE_SPOKEN = "(309) 701-0660";
|
||||
public static final String EMAIL = "[email protected]";
|
||||
|
||||
/**
|
||||
* When the doors are open. Days that aren't here are closed days, which is what makes Sunday and
|
||||
* Monday collapse into one line by themselves.
|
||||
*/
|
||||
public static final Map<DayOfWeek, Opening> WEEK = Map.of(
|
||||
DayOfWeek.TUESDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
|
||||
DayOfWeek.WEDNESDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
|
||||
DayOfWeek.THURSDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
|
||||
DayOfWeek.FRIDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(14, 0)),
|
||||
DayOfWeek.SATURDAY, new Opening(LocalTime.of(7, 0), LocalTime.of(12, 0)));
|
||||
|
||||
/**
|
||||
* The week as it is read, starting on the first open day rather than on Monday — the list on the page
|
||||
* has always begun "Tuesday – Friday", because that is when the shop's week begins.
|
||||
*/
|
||||
public static final List<DayOfWeek> READING_ORDER = List.of(
|
||||
DayOfWeek.TUESDAY, DayOfWeek.WEDNESDAY, DayOfWeek.THURSDAY, DayOfWeek.FRIDAY,
|
||||
DayOfWeek.SATURDAY, DayOfWeek.SUNDAY, DayOfWeek.MONDAY);
|
||||
|
||||
public record Opening(LocalTime opens, LocalTime closes) {}
|
||||
|
||||
private Shop() {
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,5 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.time.ZoneId;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
@@ -17,21 +16,74 @@ import org.springframework.web.bind.annotation.ModelAttribute;
|
||||
@ControllerAdvice
|
||||
public class SiteModel {
|
||||
|
||||
/** The shop is in Princeville, Illinois; the container runs on UTC, which turns over first. */
|
||||
private static final ZoneId SHOP_TIME = ZoneId.of("America/Chicago");
|
||||
|
||||
private final SitePhotos photos;
|
||||
private final Hours hours;
|
||||
private final StructuredData structuredData;
|
||||
private final String baseUrl;
|
||||
private final String build;
|
||||
|
||||
public SiteModel(SitePhotos photos,
|
||||
public SiteModel(SitePhotos photos, Hours hours, StructuredData structuredData,
|
||||
@Value("${site.base-url:https://itsthevine.com}") String baseUrl,
|
||||
@Value("${site.build:dev}") String build) {
|
||||
this.photos = photos;
|
||||
this.hours = hours;
|
||||
this.structuredData = structuredData;
|
||||
this.baseUrl = baseUrl.replaceAll("/+$", "");
|
||||
this.build = build;
|
||||
}
|
||||
|
||||
/** The opening times as the page prints them — see Hours#week. */
|
||||
@ModelAttribute("hours")
|
||||
public java.util.List<Hours.Span> hours() {
|
||||
return hours.week();
|
||||
}
|
||||
|
||||
/** Whether the shop is open at this moment, for the line in the header. */
|
||||
@ModelAttribute("openNow")
|
||||
public Hours.Status openNow() {
|
||||
return hours.now();
|
||||
}
|
||||
|
||||
// The shop's own facts, so no template states an address or a phone number itself. Four attributes
|
||||
// rather than one object because a template reading `${shopStreet}` needs no explaining.
|
||||
|
||||
@ModelAttribute("shopStreet")
|
||||
public String shopStreet() {
|
||||
return Shop.STREET;
|
||||
}
|
||||
|
||||
@ModelAttribute("shopTown")
|
||||
public String shopTown() {
|
||||
return Shop.CITY + ", " + Shop.STATE + " " + Shop.POSTCODE;
|
||||
}
|
||||
|
||||
@ModelAttribute("shopPhone")
|
||||
public String shopPhone() {
|
||||
return Shop.PHONE;
|
||||
}
|
||||
|
||||
@ModelAttribute("shopPhoneSpoken")
|
||||
public String shopPhoneSpoken() {
|
||||
return Shop.PHONE_SPOKEN;
|
||||
}
|
||||
|
||||
@ModelAttribute("shopEmail")
|
||||
public String shopEmail() {
|
||||
return Shop.EMAIL;
|
||||
}
|
||||
|
||||
/** schema.org JSON-LD for the head. Written by Jackson; see StructuredData. */
|
||||
@ModelAttribute("structuredData")
|
||||
public String structuredData() {
|
||||
return structuredData.bakery();
|
||||
}
|
||||
|
||||
/** What a shared link should show. */
|
||||
@ModelAttribute("shareImage")
|
||||
public String shareImage() {
|
||||
return photos.of("gallery/Outside.webp");
|
||||
}
|
||||
|
||||
/** Lets a template ask for a photo by key: {@code ${photos.of('gallery/Outside.webp')}}. */
|
||||
@ModelAttribute("photos")
|
||||
public SitePhotos photos() {
|
||||
@@ -64,6 +116,6 @@ public class SiteModel {
|
||||
/** The footer's copyright year. */
|
||||
@ModelAttribute("year")
|
||||
public int year() {
|
||||
return ZonedDateTime.now(SHOP_TIME).getYear();
|
||||
return ZonedDateTime.now(Shop.ZONE).getYear();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import java.time.DayOfWeek;
|
||||
import java.time.format.TextStyle;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.TreeMap;
|
||||
|
||||
import org.springframework.beans.factory.annotation.Value;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
import tools.jackson.databind.ObjectMapper;
|
||||
|
||||
/**
|
||||
* The shop, described for machines: a schema.org Bakery in the page head.
|
||||
*
|
||||
* <p>This is how a search engine learns that itsthevine.com is a bakery in Princeville with a phone number
|
||||
* and Saturday hours — the difference between a blue link and a listing that shows "Open ⋅ closes 2pm".
|
||||
* For a shop whose customers find it by searching its town, that is worth more than anything else on this
|
||||
* page.
|
||||
*
|
||||
* <p>Built from {@link Shop} and rendered once in the head, so the hours a crawler reads cannot drift from
|
||||
* the ones a visitor reads. Serialised with Jackson rather than string-built: every value here is a
|
||||
* constant of ours today, but the one day somebody interpolates a name into it, hand-built JSON in a
|
||||
* {@code <script>} is an injection.
|
||||
*/
|
||||
@Service
|
||||
public class StructuredData {
|
||||
|
||||
private final ObjectMapper json;
|
||||
private final SitePhotos photos;
|
||||
private final String baseUrl;
|
||||
|
||||
public StructuredData(ObjectMapper json, SitePhotos photos,
|
||||
@Value("${site.base-url:https://itsthevine.com}") String baseUrl) {
|
||||
this.json = json;
|
||||
this.photos = photos;
|
||||
this.baseUrl = baseUrl.replaceAll("/+$", "");
|
||||
}
|
||||
|
||||
public String bakery() {
|
||||
Map<String, Object> shop = new LinkedHashMap<>();
|
||||
shop.put("@context", "https://schema.org");
|
||||
shop.put("@type", "Bakery");
|
||||
shop.put("name", Shop.NAME);
|
||||
shop.put("url", baseUrl);
|
||||
shop.put("image", photos.of("gallery/Outside.webp"));
|
||||
shop.put("telephone", Shop.PHONE);
|
||||
shop.put("email", Shop.EMAIL);
|
||||
// "$" rather than a number: schema.org wants a band, and ours is one coffee to a wedding cake.
|
||||
shop.put("priceRange", "$");
|
||||
shop.put("servesCuisine", List.of("Bakery", "Coffee", "Sandwiches"));
|
||||
shop.put("address", Map.of(
|
||||
"@type", "PostalAddress",
|
||||
"streetAddress", Shop.STREET,
|
||||
"addressLocality", Shop.CITY,
|
||||
"addressRegion", Shop.STATE,
|
||||
"postalCode", Shop.POSTCODE,
|
||||
"addressCountry", "US"));
|
||||
shop.put("openingHoursSpecification", openingHours());
|
||||
|
||||
// Jackson 3 (tools.jackson, what Boot 4 ships) throws unchecked, and there is nothing to catch
|
||||
// anyway: the input is a map of our own constants.
|
||||
return json.writeValueAsString(shop);
|
||||
}
|
||||
|
||||
/**
|
||||
* Days that keep the same times are given as one specification with several days on it, which is both
|
||||
* shorter and what the vocabulary is for.
|
||||
*/
|
||||
private List<Map<String, Object>> openingHours() {
|
||||
Map<Shop.Opening, List<String>> byHours = new TreeMap<>(
|
||||
(a, b) -> a.opens().equals(b.opens()) ? a.closes().compareTo(b.closes()) : a.opens().compareTo(b.opens()));
|
||||
for (DayOfWeek day : Shop.READING_ORDER) {
|
||||
Shop.Opening opening = Shop.WEEK.get(day);
|
||||
if (opening != null) {
|
||||
byHours.computeIfAbsent(opening, o -> new java.util.ArrayList<>())
|
||||
.add(day.getDisplayName(TextStyle.FULL, Locale.US));
|
||||
}
|
||||
}
|
||||
|
||||
return byHours.entrySet().stream()
|
||||
.map(entry -> Map.<String, Object>of(
|
||||
"@type", "OpeningHoursSpecification",
|
||||
"dayOfWeek", entry.getValue(),
|
||||
"opens", entry.getKey().opens().toString(),
|
||||
"closes", entry.getKey().closes().toString()))
|
||||
.toList();
|
||||
}
|
||||
}
|
||||
@@ -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 & 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">
|
||||
<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 – Friday</span>
|
||||
<span class="font-medium whitespace-nowrap">7:00am – 2:00pm</span>
|
||||
</li>
|
||||
<li th:class="'flex justify-between gap-4 ' + ${rowClass}">
|
||||
<span>Saturday</span>
|
||||
<span class="font-medium whitespace-nowrap">7:00am – 12:00pm</span>
|
||||
</li>
|
||||
<li th:class="'flex justify-between gap-4 ' + ${rowClass}">
|
||||
<span>Sunday – 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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -83,3 +83,42 @@ select.field {
|
||||
background-size: 1.1rem;
|
||||
padding-right: 2.4rem;
|
||||
}
|
||||
|
||||
/*
|
||||
* PRINT. The catering page is a price list, and a bakery prints price lists — so what comes out of a
|
||||
* printer should be the prices, not a screenshot of a website. The chrome goes, the sage bands go (they
|
||||
* would eat a cartridge), the cards keep their edges so the columns stay readable, and links stop being
|
||||
* blue-and-underlined because paper has no links.
|
||||
*/
|
||||
@media print {
|
||||
header,
|
||||
footer,
|
||||
.no-print {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
body {
|
||||
background: #fff;
|
||||
color: #000;
|
||||
opacity: 1;
|
||||
animation: none;
|
||||
}
|
||||
|
||||
/* Sage-on-white blocks become plain text: the words matter, the ink does not. */
|
||||
[class*="bg-bakery-8"],
|
||||
[class*="bg-bakery-9"] {
|
||||
background: #fff !important;
|
||||
color: #000 !important;
|
||||
}
|
||||
|
||||
a {
|
||||
text-decoration: none;
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
/* A card should not be split across a page break mid-price. */
|
||||
article,
|
||||
section {
|
||||
break-inside: avoid;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,71 @@
|
||||
package com.itsthevine.web;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.time.ZonedDateTime;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
/**
|
||||
* The opening times, as a list and as an answer.
|
||||
*
|
||||
* <p>No Spring here on purpose: this is a calculation over a constant, and it should be testable at the
|
||||
* speed of a calculation. Every "now" is passed in, so the Sunday-afternoon case doesn't need a Sunday.
|
||||
*/
|
||||
class HoursTest {
|
||||
|
||||
private final Hours hours = new Hours();
|
||||
|
||||
@Test
|
||||
void collapsesTheWeekTheWayASignWouldWriteIt() {
|
||||
assertThat(hours.week()).extracting(Hours.Span::days, Hours.Span::hours)
|
||||
.containsExactly(
|
||||
org.assertj.core.groups.Tuple.tuple("Tuesday – Friday", "7:00am – 2:00pm"),
|
||||
org.assertj.core.groups.Tuple.tuple("Saturday", "7:00am – 12:00pm"),
|
||||
org.assertj.core.groups.Tuple.tuple("Sunday – Monday", "Closed"));
|
||||
}
|
||||
|
||||
@Test
|
||||
void theWeekStartsOnTheDayTheShopsWeekStarts() {
|
||||
// Not Monday: the shop is shut on Monday, and a list that opens with a closed day reads as an
|
||||
// apology. This is the order the printed list has always used.
|
||||
assertThat(hours.week().getFirst().days()).startsWith("Tuesday");
|
||||
assertThat(hours.week().getLast().closed()).isTrue();
|
||||
}
|
||||
|
||||
@Test
|
||||
void saysHowLongIsLeftWhenTheShopIsOpen() {
|
||||
Hours.Status wednesdayMorning = hours.at(chicago("2026-07-29T09:15"));
|
||||
assertThat(wednesdayMorning.open()).isTrue();
|
||||
assertThat(wednesdayMorning.summary()).isEqualTo("Open until 2:00pm");
|
||||
}
|
||||
|
||||
@Test
|
||||
void countsTheMinuteOfOpeningAsOpenAndTheMinuteOfClosingAsShut() {
|
||||
// The two edges anyone would get wrong, and the ones a customer stands at the door for.
|
||||
assertThat(hours.at(chicago("2026-07-29T07:00")).open()).isTrue();
|
||||
assertThat(hours.at(chicago("2026-07-29T13:59")).open()).isTrue();
|
||||
assertThat(hours.at(chicago("2026-07-29T14:00")).open()).isFalse();
|
||||
assertThat(hours.at(chicago("2026-07-29T06:59")).open()).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void tellsYouWhenItOpensAgainRatherThanJustSayingClosed() {
|
||||
// Saturday afternoon, shut since noon: the next open day is Tuesday, three days away.
|
||||
Hours.Status saturdayAfternoon = hours.at(chicago("2026-08-01T15:00"));
|
||||
assertThat(saturdayAfternoon.open()).isFalse();
|
||||
assertThat(saturdayAfternoon.summary()).isEqualTo("Closed · opens Tuesday at 7:00am");
|
||||
|
||||
// Monday: tomorrow reads better than naming the day.
|
||||
assertThat(hours.at(chicago("2026-08-03T10:00")).summary())
|
||||
.isEqualTo("Closed · opens tomorrow at 7:00am");
|
||||
|
||||
// Before opening on a day it does open, the day itself is redundant.
|
||||
assertThat(hours.at(chicago("2026-07-29T06:30")).summary()).isEqualTo("Opens at 7:00am");
|
||||
}
|
||||
|
||||
private static ZonedDateTime chicago(String localDateTime) {
|
||||
return LocalDateTime.parse(localDateTime).atZone(Shop.ZONE);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user