diff --git a/src/main/java/com/itsthevine/web/AdminCateringController.java b/src/main/java/com/itsthevine/web/AdminCateringController.java index 393d1d5..c1d4528 100644 --- a/src/main/java/com/itsthevine/web/AdminCateringController.java +++ b/src/main/java/com/itsthevine/web/AdminCateringController.java @@ -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 -> { diff --git a/src/main/java/com/itsthevine/web/CateringMenu.java b/src/main/java/com/itsthevine/web/CateringMenu.java index bcc3d0a..fc36db0 100644 --- a/src/main/java/com/itsthevine/web/CateringMenu.java +++ b/src/main/java/com/itsthevine/web/CateringMenu.java @@ -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 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 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 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 rows = table.getRows().stream() .map(r -> new RowView(r.getId(), r.getLabel(), r.getValues())) diff --git a/src/main/java/com/itsthevine/web/domain/CateringPackage.java b/src/main/java/com/itsthevine/web/domain/CateringPackage.java index c1488e5..fba3b02 100644 --- a/src/main/java/com/itsthevine/web/domain/CateringPackage.java +++ b/src/main/java/com/itsthevine/web/domain/CateringPackage.java @@ -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 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); } diff --git a/src/main/java/com/itsthevine/web/domain/CateringTier.java b/src/main/java/com/itsthevine/web/domain/CateringTier.java index ebdee6e..1fd74fe 100644 --- a/src/main/java/com/itsthevine/web/domain/CateringTier.java +++ b/src/main/java/com/itsthevine/web/domain/CateringTier.java @@ -31,6 +31,16 @@ public class CateringTier extends BaseEntity { @Column(name = "price_cents") private Integer priceCents; + /** + * Roughly how many people this column feeds — "About 6–8 people". + * + *

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 \"15–20 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; } diff --git a/src/main/resources/db/migration/V7__mix_and_serves.sql b/src/main/resources/db/migration/V7__mix_and_serves.sql new file mode 100644 index 0000000..c80f76a --- /dev/null +++ b/src/main/resources/db/migration/V7__mix_and_serves.sql @@ -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 6–8 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 10–12 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 15–20 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 ("15–20 people", "125 people"), so a +-- `serves` line under the price would say it twice. Left null on purpose. diff --git a/src/main/resources/templates/admin/table.html b/src/main/resources/templates/admin/table.html index 9a450d2..0d9cb0d 100644 --- a/src/main/resources/templates/admin/table.html +++ b/src/main/resources/templates/admin/table.html @@ -47,6 +47,9 @@ th:aria-label="|Heading for column ${c.count}|"> + +

diff --git a/src/main/resources/templates/catering.html b/src/main/resources/templates/catering.html index 29708d2..d3a6a69 100644 --- a/src/main/resources/templates/catering.html +++ b/src/main/resources/templates/catering.html @@ -73,6 +73,9 @@

$24

Ask us

+ +

About 6–8 people

  • diff --git a/src/test/java/com/itsthevine/web/AdminPagesTest.java b/src/test/java/com/itsthevine/web/AdminPagesTest.java index ba4a87f..066072c 100644 --- a/src/test/java/com/itsthevine/web/AdminPagesTest.java +++ b/src/test/java/com/itsthevine/web/AdminPagesTest.java @@ -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 diff --git a/src/test/java/com/itsthevine/web/CateringMenuTest.java b/src/test/java/com/itsthevine/web/CateringMenuTest.java index 83d3337..27f929b 100644 --- a/src/test/java/com/itsthevine/web/CateringMenuTest.java +++ b/src/test/java/com/itsthevine/web/CateringMenuTest.java @@ -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 6–8 people", "About 10–12 people", "About 15–20 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("15–20 people", "20–30 people", "30–40 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 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 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 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 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()); } diff --git a/src/test/java/com/itsthevine/web/SiteControllerTest.java b/src/test/java/com/itsthevine/web/SiteControllerTest.java index 95a3e44..78880be 100644 --- a/src/test/java/com/itsthevine/web/SiteControllerTest.java +++ b/src/test/java/com/itsthevine/web/SiteControllerTest.java @@ -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 6–8 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 15–20 people". // The size label is in the HTML as written; the small caps are CSS.