diff --git a/src/main/java/com/itsthevine/web/Hours.java b/src/main/java/com/itsthevine/web/Hours.java new file mode 100644 index 0000000..2dd83fa --- /dev/null +++ b/src/main/java/com/itsthevine/web/Hours.java @@ -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?" + * + *

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 week() { + List 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"); + } +} diff --git a/src/main/java/com/itsthevine/web/Shop.java b/src/main/java/com/itsthevine/web/Shop.java new file mode 100644 index 0000000..b716c79 --- /dev/null +++ b/src/main/java/com/itsthevine/web/Shop.java @@ -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. + * + *

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. + * + *

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 = "contact@itsthevine.com"; + + /** + * 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 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 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() { + } +} diff --git a/src/main/java/com/itsthevine/web/SiteModel.java b/src/main/java/com/itsthevine/web/SiteModel.java index 88fd017..23ace1b 100644 --- a/src/main/java/com/itsthevine/web/SiteModel.java +++ b/src/main/java/com/itsthevine/web/SiteModel.java @@ -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() { + 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(); } } diff --git a/src/main/java/com/itsthevine/web/StructuredData.java b/src/main/java/com/itsthevine/web/StructuredData.java new file mode 100644 index 0000000..e17fa8c --- /dev/null +++ b/src/main/java/com/itsthevine/web/StructuredData.java @@ -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. + * + *

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. + * + *

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 diff --git a/src/main/resources/templates/fragments/header.html b/src/main/resources/templates/fragments/header.html index f831cc7..4fe0403 100644 --- a/src/main/resources/templates/fragments/header.html +++ b/src/main/resources/templates/fragments/header.html @@ -21,9 +21,15 @@

- +
@@ -38,7 +44,10 @@
diff --git a/src/main/resources/templates/fragments/visit.html b/src/main/resources/templates/fragments/visit.html index 6b7c4de..11b1dd9 100644 --- a/src/main/resources/templates/fragments/visit.html +++ b/src/main/resources/templates/fragments/visit.html @@ -2,40 +2,40 @@
-

215 E Main Street
Princeville, IL 61559

+

215 E Main Street

+

Princeville, IL 61559

- (309) 701-0660 + (309) 701-0660

- contact@itsthevine.com + contact@itsthevine.com

+ + + + + Open until 2:00pm + diff --git a/src/main/resources/templates/history.html b/src/main/resources/templates/history.html index 1087c7d..c0fcac6 100644 --- a/src/main/resources/templates/history.html +++ b/src/main/resources/templates/history.html @@ -10,7 +10,7 @@
-
+

Since 2024

Our story

diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index f10b913..55a4d02 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -16,7 +16,7 @@

-
+

A coffeehouse and bakery in downtown Princeville, Illinois. @@ -56,9 +56,9 @@

-
-

Our story

-

+

+

Our story

+

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.

@@ -99,7 +99,7 @@

Visit us

-
+

Our hours

diff --git a/src/main/resources/templates/products.html b/src/main/resources/templates/products.html index 0b5f7bf..99ad768 100644 --- a/src/main/resources/templates/products.html +++ b/src/main/resources/templates/products.html @@ -51,7 +51,7 @@
-
+

Want one of these, or something else?

diff --git a/src/main/styles/base.css b/src/main/styles/base.css index 8d38502..28d7957 100644 --- a/src/main/styles/base.css +++ b/src/main/styles/base.css @@ -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; + } +} diff --git a/src/test/java/com/itsthevine/web/HoursTest.java b/src/test/java/com/itsthevine/web/HoursTest.java new file mode 100644 index 0000000..c985857 --- /dev/null +++ b/src/test/java/com/itsthevine/web/HoursTest.java @@ -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. + * + *

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); + } +}