Catering, and a pure Java/Spring site: Thymeleaf front to back #10

Merged
austin merged 22 commits from feature/catering-tables into main 2026-07-26 21:26:57 -05:00
10 changed files with 145 additions and 31 deletions
Showing only changes of commit 5ab6febb4c - Show all commits
@@ -78,6 +78,7 @@ public class AdminCateringController {
private Long id;
private String label = "";
private String price = "";
private String serves = "";
public Long getId() { return id; }
public void setId(Long id) { this.id = id; }
@@ -87,6 +88,9 @@ public class AdminCateringController {
public String getPrice() { return price; }
public void setPrice(String price) { this.price = price; }
public String getServes() { return serves; }
public void setServes(String serves) { this.serves = serves; }
}
public static class LineForm {
@@ -172,7 +176,7 @@ public class AdminCateringController {
form.getName(),
form.getBlurb(),
form.getColumns().stream()
.map(c -> new CateringMenu.TierEdit(c.getId(), c.getLabel(), c.getPrice()))
.map(c -> new CateringMenu.TierEdit(c.getId(), c.getLabel(), c.getPrice(), c.getServes()))
.toList(),
form.getLines().stream()
.map(l -> new CateringMenu.RowEdit(l.getId(), l.getLabel(), l.getValues()))
@@ -302,6 +306,7 @@ public class AdminCateringController {
// The price comes back written out ("$24") and goes out again as whatever is left in the box;
// Money reads either.
column.setPrice(tier.price() == null ? "" : tier.price());
column.setServes(tier.serves() == null ? "" : tier.serves());
return column;
}).collect(Collectors.toCollection(ArrayList::new)));
form.setLines(table.rows().stream().map(row -> {
@@ -34,8 +34,11 @@ public class CateringMenu {
this.notes = notes;
}
/** A column. {@code price} is ready to print ("$24"), and null when the column doesn't state one. */
public record TierView(Long id, String label, String price) {}
/**
* A column. {@code price} is ready to print ("$24") and null when the column doesn't state one;
* {@code serves} is roughly how many people it feeds, and null when the label already says.
*/
public record TierView(Long id, String label, String price, String serves) {}
/** A line, with one entry per column, in column order. */
public record RowView(Long id, String label, List<String> values) {}
@@ -48,7 +51,7 @@ public class CateringMenu {
// What the admin screen sends back. A whole table at a time — see CateringPackage#arrange.
// `price` is the raw text from the box ("24", "$24.50", ""); Money decides what it means.
public record TierEdit(Long id, String label, String price) {}
public record TierEdit(Long id, String label, String price, String serves) {}
public record RowEdit(Long id, String label, List<String> values) {}
@@ -99,7 +102,7 @@ public class CateringMenu {
table.replaceNotes(clean(edit.notes()));
table.arrange(
orEmpty(edit.tiers()).stream()
.map(t -> new CateringPackage.Heading(t.id(), t.label(), t.price()))
.map(t -> new CateringPackage.Heading(t.id(), t.label(), t.price(), t.serves()))
.toList(),
orEmpty(edit.rows()).stream()
.map(r -> new CateringPackage.Line(r.id(), r.label(), orEmpty(r.values())))
@@ -171,7 +174,7 @@ public class CateringMenu {
private static PackageView toView(CateringPackage table) {
List<TierView> tiers = table.getTiers().stream()
.map(t -> new TierView(t.getId(), t.getLabel(), t.getPrice()))
.map(t -> new TierView(t.getId(), t.getLabel(), t.getPrice(), t.getServes()))
.toList();
List<RowView> rows = table.getRows().stream()
.map(r -> new RowView(r.getId(), r.getLabel(), r.getValues()))
@@ -78,7 +78,7 @@ public class CateringPackage extends BaseEntity {
* A column as the editor left it — its heading and its price. A null {@code id} is one they just
* added. (Named for the heading rather than the column so as not to shadow {@code @Column}.)
*/
public record Heading(Long id, String label, String price) {}
public record Heading(Long id, String label, String price, String serves) {}
/** A line as the editor left it, with one value per column — blanks included. */
public record Line(Long id, String label, List<String> values) {}
@@ -136,7 +136,7 @@ public class CateringPackage extends BaseEntity {
int columnNumber = 1;
for (Heading column : columns) {
CateringTier tier = column.id() == null ? new CateringTier(this) : tier(column.id());
tier.describe(column.label(), column.price());
tier.describe(column.label(), column.price(), column.serves());
tier.moveTo(columnNumber++);
arrangedTiers.add(tier);
}
@@ -31,6 +31,16 @@ public class CateringTier extends BaseEntity {
@Column(name = "price_cents")
private Integer priceCents;
/**
* Roughly how many people this column feeds — "About 68 people".
*
* <p>Free text, and optional. A column named "Small" says nothing about how many it feeds, which is
* the question anyone ordering for an office actually has; a column named "125 people" has already
* answered it and leaves this blank rather than saying it twice.
*/
@Column(length = 80)
private String serves;
@Column(name = "position", nullable = false)
private int position;
@@ -42,11 +52,16 @@ public class CateringTier extends BaseEntity {
this.cateringPackage = cateringPackage;
}
/** @param price as the editor typed it; empty for a column that doesn't state one */
void describe(String label, String price) {
/**
* @param price as the editor typed it; empty for a column that doesn't state one
* @param serves roughly how many people, or empty
*/
void describe(String label, String price, String serves) {
this.label = Text.required(label, 120,
"Every column needs a heading — a size like \"Large\", or who it feeds like \"1520 people\".");
this.priceCents = Money.cents(price);
String trimmed = Text.optional(serves, 80);
this.serves = trimmed.isEmpty() ? null : trimmed;
}
void moveTo(int position) {
@@ -66,6 +81,10 @@ public class CateringTier extends BaseEntity {
return Money.format(priceCents);
}
public String getServes() {
return serves;
}
public int getPosition() {
return position;
}
@@ -0,0 +1,66 @@
-- Two things the spreadsheet never said out loud, and the page was getting wrong.
--
-- 1. AN OFFICE BOX IS A TOTAL, NOT AN ASSORTMENT. A Small box is twelve items, mixed in sixes from the
-- minis — not twelve muffins AND twelve scones AND some cinnamon rolls, which is what three ticked
-- lines on a card claimed. The bakery confirmed it: mix them, in sixes, to the size's item count.
--
-- The fix keeps the shape everything else uses rather than inventing a mode for this one table: the
-- alternative goes INTO the line, the way "Cupcakes or sugar cookies" already does in the parties and
-- wedding tables. One line, "Any mix of…", and the size's cell carries the count. So the rule for the
-- whole page is now sayable in one sentence: lines are things you get (and), and a choice within a
-- line is written into its name (or).
--
-- 2. "SMALL" DOESN'T TELL ANYONE HOW MANY PEOPLE IT FEEDS. The party and wedding tables size themselves
-- by head count; the office table sizes itself by box. `serves` gives every column somewhere to say
-- it, and the admin gets a box for it. The office numbers below are estimates from the item counts and
-- are meant to be corrected by the people who pack the boxes.
alter table catering_tier add column serves varchar(80);
-- --- 1. the office box's contents ---------------------------------------------------------------------
update catering_row r
set label = 'Any mix of mini muffins, mini scones and mini cinnamon rolls'
from catering_package p
where p.id = r.package_id
and p.name = 'Office boxes'
and r.label = 'Mini muffins';
update catering_row_value v set value = '12 items'
from catering_row r
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 0 and v.value = 'A dozen';
update catering_row_value v set value = '18 items'
from catering_row r
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 1 and v.value = 'Eighteen';
update catering_row_value v set value = '24 items'
from catering_row r
where r.id = v.row_id and r.label like 'Any mix of%' and v.position = 2 and v.value = 'Two dozen';
-- The other two lines are now named inside the first one. Guarded on the wording V6 left, so a line the
-- bakery has since edited stays and can be tidied by hand.
delete from catering_row r
using catering_package p
where p.id = r.package_id
and p.name = 'Office boxes'
and r.label in ('Mini scones', 'Mini cinnamon rolls');
-- "6+6" was the spreadsheet showing how a dozen scones splits by flavour. With one mixed line, that
-- belongs in the note, where the bakery's own minimum already lives.
update catering_package_note
set body = 'Baked in sixes, so a dozen can be six muffins and six scones.'
where body = 'Everything is baked in sixes, so each item comes in multiples of six.';
-- --- 2. how many people each column feeds -------------------------------------------------------------
update catering_tier t set serves = 'About 68 people'
from catering_package p
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Small' and t.serves is null;
update catering_tier t set serves = 'About 1012 people'
from catering_package p
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Medium' and t.serves is null;
update catering_tier t set serves = 'About 1520 people'
from catering_package p
where p.id = t.package_id and p.name = 'Office boxes' and t.label = 'Large' and t.serves is null;
-- The party and wedding columns are already named by head count ("1520 people", "125 people"), so a
-- `serves` line under the price would say it twice. Left null on purpose.
@@ -47,6 +47,9 @@
th:aria-label="|Heading for column ${c.count}|">
<input class="field mt-1" th:field="*{columns[__${c.index}__].price}"
placeholder="$24 — leave empty to ask" th:aria-label="|Price for column ${c.count}|">
<!--/* Optional: only worth filling in when the heading isn't already a head count. */-->
<input class="field mt-1" th:field="*{columns[__${c.index}__].serves}"
placeholder="Serves — optional" th:aria-label="|How many people column ${c.count} feeds|">
<div class="mt-1 flex justify-center gap-1">
<button type="submit" name="do" th:value="|move-column:${c.index}:-1|" class="btn-icon"
th:disabled="${c.first}" aria-label="Move this column left">&larr;</button>
@@ -73,6 +73,9 @@
<p th:if="${tier.price}" class="mt-2 font-adbhashitha text-4xl text-bakery-900" th:text="${tier.price}">$24</p>
<!--/* No price means "ask us" — say so rather than leaving a hole in the card. */-->
<p th:unless="${tier.price}" class="mt-2 font-adbhashitha text-2xl text-bakery-700">Ask us</p>
<!--/* "Small" doesn't tell anyone how many it feeds. A column whose heading is already a
head count leaves this blank rather than saying it twice. */-->
<p th:if="${tier.serves}" class="mt-1 text-sm text-bakery-600" th:text="${tier.serves}">About 68 people</p>
<ul class="mt-6 space-y-3 text-bakery-800">
<li th:each="row : ${table.rows}" class="flex gap-3">
@@ -128,7 +128,7 @@ class AdminPagesTest {
// Indexed names are what let Spring bind the grid back into the right cells.
.andExpect(content().string(containsString("name=\"columns[0].label\"")))
.andExpect(content().string(containsString("name=\"lines[0].values[1]\"")))
.andExpect(content().string(containsString("value=\"Eighteen\"")))
.andExpect(content().string(containsString("value=\"18 items\"")))
// The price round-trips as text: it came out "$24" and goes back the same way.
.andExpect(content().string(containsString("value=\"$24\"")));
}
@@ -170,7 +170,7 @@ class AdminPagesTest {
// And nothing was written: the live page still says what it said.
mvc.perform(get("/api/catering"))
.andExpect(content().string(containsString("A dozen")));
.andExpect(content().string(containsString("12 items")));
}
@Test
@@ -69,12 +69,18 @@ class CateringMenuTest {
.containsExactly("Small", "Medium", "Large");
assertThat(office.tiers()).extracting(CateringMenu.TierView::price)
.containsExactly("$24", "$32", "$40");
// One line, not three: a Small box is twelve items mixed in sixes, not twelve of each thing. The
// choice is written into the line's name, which is the same rule the other two tables follow.
assertThat(office.rows()).extracting(CateringMenu.RowView::label)
.containsExactly("Mini muffins", "Mini scones", "Mini cinnamon rolls");
assertThat(office.rows().get(0).values()).containsExactly("A dozen", "Eighteen", "Two dozen");
assertThat(office.rows().get(1).values().get(2)).isEqualTo("Two dozen, in up to four flavors");
.containsExactly("Any mix of mini muffins, mini scones and mini cinnamon rolls");
assertThat(office.rows().get(0).values()).containsExactly("12 items", "18 items", "24 items");
// "Small" says nothing about how many it feeds; this does.
assertThat(office.tiers()).extracting(CateringMenu.TierView::serves)
.containsExactly("About 68 people", "About 1012 people", "About 1520 people");
// The party and wedding columns are named by head count already, so they don't repeat it.
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::serves).containsOnlyNulls();
assertThat(office.blurb()).contains("morning meeting");
assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("baked in sixes"));
assertThat(office.notes()).anySatisfy(note -> assertThat(note).contains("Baked in sixes"));
assertThat(tables.get(1).tiers()).extracting(CateringMenu.TierView::label)
.containsExactly("1520 people", "2030 people", "3040 people");
@@ -129,11 +135,17 @@ class CateringMenuTest {
@Test
void keepsBlankCellsRatherThanCollapsingThem() {
// "Mini cinnamon rolls" is named but not quantified in the spreadsheet. Dropping its blanks
// would shorten the line and shift everything after it.
CateringMenu.RowView rolls = catering.menu().packages().get(0).rows().get(2);
assertThat(rolls.label()).isEqualTo("Mini cinnamon rolls");
assertThat(rolls.values()).containsExactly("", "", "");
// No seeded line is blank across any more, so this uses a table of its own making: a blank cell
// still has to survive a save, because dropping one would shorten the line and shift the rest.
CateringMenu.PackageView fresh = catering.add("Trays");
catering.save(fresh.id(), new CateringMenu.PackageEdit("Trays", null,
List.of(new CateringMenu.TierEdit(null, "Small", "10", null),
new CateringMenu.TierEdit(null, "Large", "20", null)),
List.of(new CateringMenu.RowEdit(null, "Seasonal extras", List.of("", "A dozen"))),
List.of()));
CateringMenu.RowView row = catering.everything().packages().getLast().rows().getFirst();
assertThat(row.values()).containsExactly("", "A dozen");
}
@Test
@@ -185,7 +197,7 @@ class CateringMenuTest {
CateringMenu.PackageView saved = catering.everything().packages().get(0);
assertThat(saved.tiers()).extracting(CateringMenu.TierView::label).containsExactly("Small", "Large");
assertThat(saved.tiers()).extracting(CateringMenu.TierView::id).doesNotContain(medium);
assertThat(saved.rows().get(0).values()).containsExactly("A dozen", "Two dozen");
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "24 items");
assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2));
}
@@ -201,7 +213,7 @@ class CateringMenuTest {
assertThatThrownBy(() -> catering.save(office.id(), half))
.isInstanceOf(IllegalArgumentException.class)
.hasMessageContaining("Mini muffins")
.hasMessageContaining("Any mix of mini muffins")
.hasMessageContaining("3 entries but the table has 2 columns");
}
@@ -211,7 +223,7 @@ class CateringMenuTest {
Long muffins = office.rows().get(0).id();
List<CateringMenu.RowEdit> rows = new ArrayList<>(asLines(office));
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("A dozen", "Twenty", "Two dozen")));
rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("12 items", "20 items", "24 items")));
catering.save(office.id(), new CateringMenu.PackageEdit(
"Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes()));
@@ -220,7 +232,7 @@ class CateringMenuTest {
assertThat(saved.blurb()).isEqualTo("For meetings and staff mornings.");
// Same line, edited — not a new line that happens to read the same.
assertThat(saved.rows().get(0).id()).isEqualTo(muffins);
assertThat(saved.rows().get(0).values()).containsExactly("A dozen", "Twenty", "Two dozen");
assertThat(saved.rows().get(0).values()).containsExactly("12 items", "20 items", "24 items");
}
@Test
@@ -234,7 +246,7 @@ class CateringMenuTest {
// Once it has a column and a line, it's a price table and it belongs on the page.
catering.save(fresh.id(), new CateringMenu.PackageEdit("Holiday boxes", null,
List.of(new CateringMenu.TierEdit(null, "Dozen", "18")),
List.of(new CateringMenu.TierEdit(null, "Dozen", "18", "About 6 people")),
List.of(new CateringMenu.RowEdit(null, "Frosted cut-outs", List.of("12 items"))),
List.of()));
assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name)
@@ -304,7 +316,7 @@ class CateringMenuTest {
/** Note the round trip: what came back as "$24" goes out again as "$24" and must still mean 2400. */
private static List<CateringMenu.TierEdit> asEdits(CateringMenu.PackageView table) {
return table.tiers().stream()
.map(t -> new CateringMenu.TierEdit(t.id(), t.label(), t.price()))
.map(t -> new CateringMenu.TierEdit(t.id(), t.label(), t.price(), t.serves()))
.toList();
}
@@ -328,13 +340,13 @@ class CateringMenuTest {
private static CateringMenu.PackageEdit withColumnPrice(CateringMenu.PackageView table, String price) {
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), tiers.get(0).label(), price));
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), tiers.get(0).label(), price, null));
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
}
private static CateringMenu.PackageEdit withColumnLabel(CateringMenu.PackageView table, String label) {
List<CateringMenu.TierEdit> tiers = new ArrayList<>(asEdits(table));
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), label, tiers.get(0).price()));
tiers.set(0, new CateringMenu.TierEdit(tiers.get(0).id(), label, tiers.get(0).price(), null));
return new CateringMenu.PackageEdit(table.name(), table.blurb(), tiers, asLines(table), table.notes());
}
@@ -102,8 +102,11 @@ class SiteControllerTest {
.andExpect(content().string(containsString("$24")))
.andExpect(content().string(containsString("$236")))
// A cell and a note, in the wording a customer reads rather than the spreadsheet's.
.andExpect(content().string(containsString("Eighteen, in two or three flavors")))
.andExpect(content().string(containsString("baked in sixes")))
// The office box is a total mixed in sixes, not three separate things.
.andExpect(content().string(containsString("Any mix of mini muffins")))
.andExpect(content().string(containsString("18 items")))
.andExpect(content().string(containsString("About 68 people")))
.andExpect(content().string(containsString("Baked in sixes")))
// One card per size, and one enquiry link per table — not one per card, which would have
// read "Ask about the 1520 people".
// The size label is in the HTML as written; the small caps are CSS.