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 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