From 0d534a0e80fd6b34c4b9352fb4f293dc57d355fc Mon Sep 17 00:00:00 2001 From: austin Date: Sun, 26 Jul 2026 17:24:59 -0500 Subject: [PATCH] Say it in the bakery's words, and stop rendering the spreadsheet MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The page still read like the source: a grid of cells saying "6+6", "4 dz", "B&G cake", "12 items". That is a note to whoever is baking, not an offer to whoever is buying — and a table of those cells is a picture of the spreadsheet it came from. THE WORDS (V6, guarded on V4/V5's exact text so anything already reworded in the admin is left alone): cells become sentences — "A dozen", "A dozen, in two flavors", "Eighteen, in two or three flavors", "A 6-inch cake", "Two pans", "Four dozen", "Eight inch". Each table gets the line of copy its blurb field always had room for. The parties table had the same offset the weddings one did — quantities on the "Cupcakes" line, an empty "Sugar cookies" line below it — so it is merged the same way, and no line on the page is blank the whole way across any more. Notes read as sentences too: "Everything is baked in sixes, so each item comes in multiples of six." THE LOOK: one card per size instead of a price matrix. The size, the price, then a ticked list of what you get, in a three-up grid that stacks on a phone — so there is now ONE rendering rather than a table for wide screens and cards for narrow ones. A table is still the honest shape for a price matrix, and it is how the bakery keeps these; it just isn't how you sell them. The per-card button went away again after seeing it rendered: all three led to the same place (an enquiry is about the table, not the size) and on the tables whose sizes are headcounts it read "Ask about the 15–20 people". One pill per table now. 53 tests, updated to assert the new wording — including that no cell anywhere still matches "dz", "B&G" or "in cake", and that the price grid is gone. Co-Authored-By: Claude Opus 5 (1M context) --- .../db/migration/V6__catering_copy.sql | 126 +++++++++++++++ src/main/resources/templates/catering.html | 153 ++++++++---------- .../com/itsthevine/web/AdminPagesTest.java | 4 +- .../com/itsthevine/web/CateringMenuTest.java | 47 ++++-- .../itsthevine/web/SiteControllerTest.java | 17 +- 5 files changed, 235 insertions(+), 112 deletions(-) create mode 100644 src/main/resources/db/migration/V6__catering_copy.sql diff --git a/src/main/resources/db/migration/V6__catering_copy.sql b/src/main/resources/db/migration/V6__catering_copy.sql new file mode 100644 index 0000000..19e4323 --- /dev/null +++ b/src/main/resources/db/migration/V6__catering_copy.sql @@ -0,0 +1,126 @@ +-- The catering tables in the bakery's voice instead of the spreadsheet's shorthand. +-- +-- V4 kept the source wording deliberately: it was a spreadsheet written for the people who bake from it, +-- and inventing copy for somebody else's prices is not a migration's job. But the page is read by +-- customers, and "B&G cake", "2 pans", "4 dz" and "6+6" are notes-to-self, not an offer. So this rewrites +-- the cells as sentences, fills in the blurb each table always had room for, and merges the parties +-- lines the same way V5 merged the wedding ones — "Cupcakes" quantified as "1 dz sugar cookies or +-- cupcakes" with an empty "Sugar cookies" line beneath it is the same offset, and the same fix. +-- +-- Guarded on the exact text V4 and V5 left, as V5 was: anything the bakery has already reworded in the +-- admin is left exactly as they wrote it. Their wording wins over mine. + +-- --- names and the line under each heading ---------------------------------------------------------- + +update catering_package set name = 'Office boxes' + where name = 'Office'; + +update catering_package set blurb = 'Mini pastries for a morning meeting, boxed and ready to collect.' + where name = 'Office boxes' and blurb is null; + +update catering_package set blurb = 'A cake, and something to hand round, sized to your guest list.' + where name = 'Parties' and blurb is null; + +update catering_package set blurb = 'A cake for the couple, and dessert for everyone else.' + where name = 'Weddings' and blurb is null; + +-- --- office boxes ----------------------------------------------------------------------------------- + +update catering_row_value v set value = 'A dozen' + from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '12 items'; +update catering_row_value v set value = 'Eighteen' + from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '18 items'; +update catering_row_value v set value = 'Two dozen' + from catering_row r where r.id = v.row_id and r.label = 'Mini muffins' and v.value = '24 items'; + +-- "6+6" is six of one flavour and six of another, which is worth saying out loud. +update catering_row_value v set value = 'A dozen, in two flavors' + from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6'; +update catering_row_value v set value = 'Eighteen, in two or three flavors' + from catering_row r where r.id = v.row_id and r.label = 'Mini scones' and v.value = '6+6+6 or 12+6'; +update catering_row_value v set value = 'Two dozen, in up to four flavors' + from catering_row r where r.id = v.row_id and r.label = 'Mini scones' + and v.value = '6+6+6+6 or 12+6+6 or 12+12'; + +update catering_package_note set body = 'Everything is baked in sixes, so each item comes in multiples of six.' + where body = 'Minimum of 6 items per baked good. Flavors can''t be mixed and matched unless you''re ordering a large quantity.'; + +insert into catering_package_note (package_id, position, body) +select p.id, 1, 'Mixing flavors within one item needs a larger order — ask us and we will tell you.' + from catering_package p + where p.name = 'Office boxes' + and not exists (select 1 from catering_package_note n where n.package_id = p.id and n.position = 1); + +-- --- parties ---------------------------------------------------------------------------------------- + +update catering_row_value v set value = 'A 6-inch cake' + from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '6 in cake'; +update catering_row_value v set value = 'An 8-inch cake' + from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '8 in cake'; +update catering_row_value v set value = 'A 10-inch cake' + from catering_row r where r.id = v.row_id and r.label = 'Cake' and v.value = '10 in cake'; + +-- Same offset as the wedding table: the quantities on the "Cupcakes" line were always +-- cupcakes-or-sugar-cookies, and the "Sugar cookies" line below carried nothing at all. +update catering_row r set label = 'Cupcakes or sugar cookies' + from catering_package p + where p.id = r.package_id and p.name = 'Parties' and r.label = 'Cupcakes'; + +update catering_row_value v set value = 'A dozen, either one' + from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' + and v.value = '1 dz sugar cookies or cupcakes'; +update catering_row_value v set value = 'Eighteen, your choice' + from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' + and v.value = '1.5 dz your choice'; +update catering_row_value v set value = 'Two dozen, your choice' + from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' + and v.value = '2 dz your choice'; + +delete from catering_row r + using catering_package p + where p.id = r.package_id and p.name = 'Parties' and r.label = 'Sugar cookies' + and not exists (select 1 from catering_row_value v where v.row_id = r.id and v.value <> ''); + +update catering_package_note set body = 'An extra dozen is $20.' + where body = 'Add an extra dozen for $20.'; +update catering_package_note + set body = 'Cake and cupcake flavors can be different once you are ordering a dozen cupcakes or more.' + where body = 'Cake and cupcake flavors can''t be mixed unless you order at least 1 dz of cupcakes.'; + +-- --- weddings --------------------------------------------------------------------------------------- + +-- The label said it and the cell repeated it. The cell now carries the size instead. +update catering_row r set label = 'Bride & groom cake' + from catering_package p + where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Bride & groom cake (8 in)'; + +update catering_row_value v set value = 'Eight inch' + from catering_row r where r.id = v.row_id and r.label = 'Bride & groom cake' and v.value = 'B&G cake'; + +update catering_row r set label = 'Sheet cakes or 12×17 bars' + from catering_package p + where p.id = r.package_id and p.name = 'Weddings' and r.label = 'Sheet cakes or 12x17 bars'; + +update catering_row_value v set value = 'Two pans' + from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '2 pans'; +update catering_row_value v set value = 'Three pans' + from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '3 pans'; +update catering_row_value v set value = 'Four pans' + from catering_row r where r.id = v.row_id and r.label = 'Sheet cakes or 12×17 bars' and v.value = '4 pans'; + +update catering_row_value v set value = 'Four dozen' + from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '4 dz'; +update catering_row_value v set value = 'Five dozen' + from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '5 dz'; +update catering_row_value v set value = 'Six dozen' + from catering_row r where r.id = v.row_id and r.label = 'Cupcakes or sugar cookies' and v.value = '6 dz'; + +-- --- the page's own terms --------------------------------------------------------------------------- + +update catering_note + set body = 'These are a guide rather than a menu. We are happy to make changes, though the price may change with them.' + where body = 'We''re happy to make changes — the price may change with them.'; + +update catering_note + set body = 'If we can''t do something, we will tell you.' + where body = 'If we can''t do something we''ll tell you. These tables are mostly here to give you an idea of what''s possible.'; diff --git a/src/main/resources/templates/catering.html b/src/main/resources/templates/catering.html index dac1b04..29708d2 100644 --- a/src/main/resources/templates/catering.html +++ b/src/main/resources/templates/catering.html @@ -4,9 +4,9 @@
-

To take away

+

Order ahead

Goodie boxes & catering

Boxes for the office, packages for a party, and cakes and desserts for a wedding. @@ -30,7 +30,7 @@

Every one of these is a starting point. Tell us the date, the number of people and what you had in - mind, and we will work from it. + mind, and we will bake to it.

-
- -
-

Office

-

Blurb

+
+
+

Office boxes

+

+ Mini pastries for a morning meeting. +

- - + +
+
+

Small

+

$24

+ +

Ask us

- -
-
-
-

Small

- $24 - Ask us -
- -
-
-
Mini muffins
-
- 12 items - -
-
-
-
+
    +
  • + + + + Mini muffins + + — a dozen + +
  • +
+ +
-
-
+
+

Good to know

-
    -
  • Minimum of 6 items per baked good.
  • +
      +
    • Everything is baked in sixes.
- - Ask about office - - + class="mt-6 inline-block border border-bakery-300 hover:bg-bakery-100 text-bakery-800 + px-7 py-3 rounded-full font-medium tracking-wide transition-colors" + th:text="|Ask about ${#strings.toLowerCase(table.name)}|">Ask about office boxes
@@ -160,7 +133,7 @@

Before you order

    -
  • Prices may change.
  • +
  • These are a guide rather than a menu.
tables = catering.menu().packages(); assertThat(tables).extracting(CateringMenu.PackageView::name) - .containsExactly("Office", "Parties", "Weddings"); + .containsExactly("Office boxes", "Parties", "Weddings"); CateringMenu.PackageView office = tables.get(0); assertThat(office.tiers()).extracting(CateringMenu.TierView::label) @@ -69,9 +71,10 @@ class CateringMenuTest { .containsExactly("$24", "$32", "$40"); assertThat(office.rows()).extracting(CateringMenu.RowView::label) .containsExactly("Mini muffins", "Mini scones", "Mini cinnamon rolls"); - assertThat(office.rows().get(0).values()).containsExactly("12 items", "18 items", "24 items"); - assertThat(office.rows().get(1).values().get(2)).isEqualTo("6+6+6+6 or 12+6+6 or 12+12"); - assertThat(office.notes()).singleElement().asString().contains("Minimum of 6 items per baked good"); + 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"); + assertThat(office.blurb()).contains("morning meeting"); + 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"); @@ -79,6 +82,9 @@ class CateringMenuTest { .containsExactly("$236", "$310", "$386"); // Wedding delivery terms belong to the wedding table, not to the page. assertThat(tables.get(2).notes()).anySatisfy(note -> assertThat(note).contains("delivery fee")); + // No cell anywhere still speaks in shorthand. + assertThat(tables).allSatisfy(table -> assertThat(table.rows()).allSatisfy(row -> + assertThat(row.values()).noneMatch(value -> value.matches(".*\\b(dz|B&G|in cake)\\b.*")))); } @Test @@ -90,15 +96,28 @@ class CateringMenuTest { CateringMenu.PackageView weddings = catering.menu().packages().get(2); assertThat(weddings.rows()).extracting(CateringMenu.RowView::label) - .containsExactly("Bride & groom cake (8 in)", "Sheet cakes or 12x17 bars", + .containsExactly("Bride & groom cake", "Sheet cakes or 12×17 bars", "Cupcakes or sugar cookies"); - assertThat(weddings.rows().get(1).values()).containsExactly("2 pans", "3 pans", "4 pans"); - assertThat(weddings.rows().get(2).values()).containsExactly("4 dz", "5 dz", "6 dz"); + assertThat(weddings.rows().get(1).values()).containsExactly("Two pans", "Three pans", "Four pans"); + assertThat(weddings.rows().get(2).values()).containsExactly("Four dozen", "Five dozen", "Six dozen"); // Nothing left that is blank the whole way across. assertThat(weddings.rows()) .noneMatch(row -> row.values().stream().allMatch(String::isEmpty)); } + @Test + void thePartyLinesEachSaySomethingToo() { + // Same offset as the wedding table: the quantities on "Cupcakes" were always + // cupcakes-or-sugar-cookies, with an empty "Sugar cookies" line beneath. + CateringMenu.PackageView parties = catering.menu().packages().get(1); + assertThat(parties.rows()).extracting(CateringMenu.RowView::label) + .containsExactly("Cake", "Cupcakes or sugar cookies"); + assertThat(parties.rows().get(0).values()) + .containsExactly("A 6-inch cake", "An 8-inch cake", "A 10-inch cake"); + assertThat(parties.rows()) + .noneMatch(row -> row.values().stream().allMatch(String::isEmpty)); + } + @Test void everyLineCarriesOneEntryPerColumn() { // The invariant the whole aggregate exists to hold: if these ever fall out of step, a box is @@ -166,7 +185,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("12 items", "24 items"); + assertThat(saved.rows().get(0).values()).containsExactly("A dozen", "Two dozen"); assertThat(saved.rows()).allSatisfy(row -> assertThat(row.values()).hasSize(2)); } @@ -192,7 +211,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("12 items", "20 items", "24 items"))); + rows.set(0, new CateringMenu.RowEdit(muffins, "Mini muffins", List.of("A dozen", "Twenty", "Two dozen"))); catering.save(office.id(), new CateringMenu.PackageEdit( "Office boxes", "For meetings and staff mornings.", asEdits(office), rows, office.notes())); @@ -201,7 +220,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("12 items", "20 items", "24 items"); + assertThat(saved.rows().get(0).values()).containsExactly("A dozen", "Twenty", "Two dozen"); } @Test @@ -209,7 +228,7 @@ class CateringMenuTest { CateringMenu.PackageView fresh = catering.add("Holiday boxes"); assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name) - .containsExactly("Office", "Parties", "Weddings", "Holiday boxes"); + .containsExactly("Office boxes", "Parties", "Weddings", "Holiday boxes"); assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name) .doesNotContain("Holiday boxes"); @@ -233,7 +252,7 @@ class CateringMenuTest { entityManager.clear(); assertThat(catering.everything().packages()).extracting(CateringMenu.PackageView::name) - .containsExactly("Office", "Parties"); + .containsExactly("Office boxes", "Parties"); // The page's own terms outlive any one table. assertThat(catering.menu().notes()).hasSize(2); } @@ -244,9 +263,9 @@ class CateringMenuTest { List weddingsFirst = List.of(tables.get(2).id(), tables.get(0).id(), tables.get(1).id()); assertThat(catering.reorder(weddingsFirst)).extracting(CateringMenu.PackageView::name) - .containsExactly("Weddings", "Office", "Parties"); + .containsExactly("Weddings", "Office boxes", "Parties"); assertThat(catering.menu().packages()).extracting(CateringMenu.PackageView::name) - .containsExactly("Weddings", "Office", "Parties"); + .containsExactly("Weddings", "Office boxes", "Parties"); } @Test diff --git a/src/test/java/com/itsthevine/web/SiteControllerTest.java b/src/test/java/com/itsthevine/web/SiteControllerTest.java index 78d33b2..95a3e44 100644 --- a/src/test/java/com/itsthevine/web/SiteControllerTest.java +++ b/src/test/java/com/itsthevine/web/SiteControllerTest.java @@ -96,16 +96,21 @@ class SiteControllerTest { @Test void theCateringTablesAreRenderedFromTheDatabase() throws Exception { mvc.perform(get("/catering")).andExpect(status().isOk()) - .andExpect(content().string(containsString("Office"))) + .andExpect(content().string(containsString("Office boxes"))) .andExpect(content().string(containsString("Weddings"))) // Prices as the server writes them — the page never formats money. .andExpect(content().string(containsString("$24"))) .andExpect(content().string(containsString("$236"))) - // A cell, and a note. - .andExpect(content().string(containsString("6+6+6 or 12+6"))) - .andExpect(content().string(containsString("Minimum of 6 items per baked good"))) - // Both renderings of the same table are present; CSS decides which one is visible. - .andExpect(content().string(containsString("