From f762eb60aad5a4e909c7403815cc8aaeb3f3df39 Mon Sep 17 00:00:00 2001
From: DemchaAV
Date: Mon, 31 Aug 2026 07:59:29 +0100
Subject: [PATCH] feat(templates): give CvEntry a location, a mark and a
builder
---
CHANGELOG.md | 15 +-
.../support/NavySidebarSampleData.java | 15 +-
.../cv/data/CvEntryPlaceAndIconTest.java | 93 +++++++++++
.../cv/presets/NavySidebarFixtures.java | 15 +-
.../cv/presets/NavySidebarSmokeTest.java | 7 +-
.../document/templates/cv/data/CvEntry.java | 149 +++++++++++++++++-
.../templates/cv/presets/NavySidebar.java | 6 +-
.../cv/presets/NavySidebarAside.java | 6 +-
8 files changed, 286 insertions(+), 20 deletions(-)
create mode 100644 qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33772dcd5..7102a2d6c 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -7,6 +7,18 @@ follow semantic versioning; release dates are ISO 8601.
### Public API
+- **`CvEntry` carries a location and a mark, and gains a builder.** The record held a
+ title, a subtitle, a date and a body, which is enough for a dated block and not
+ enough for the designs that set the city beside the employer in its own colour, or
+ that open a project with an icon. Folding a location into the subtitle would have
+ merged two things a design styles apart; deriving an icon from the text would have
+ been guesswork. `CvEntry` now carries `place` and `icon` — both plain strings, blank
+ when absent, matching how `subtitle` and `date` already behave — and
+ `CvEntry.builder(title)` reaches them without counting six positions. The icon
+ vocabulary is preset-scoped: a token means something only to the preset that packages
+ it, and the presets that draw no marks ignore it. The four-argument constructor is
+ kept explicitly, so existing calls compile and link unchanged.
+
- **`CvIdentity` carries an optional portrait.** A CV design with a photograph in it
had nowhere to put one: the identity record held the name, the title, the contact
triple and the links, and a preset that wanted a face had to ship a silhouette of its
@@ -60,7 +72,8 @@ follow semantic versioning; release dates are ISO 8601.
`RowsSection` because this design writes the proficiency out — "Native", "Advanced" —
which a levelled skill could not carry back. The photograph comes from the new
`CvIdentity.portrait()`; an identity without one draws the ring around an empty navy
- disc. The phone, the email and each link are reachable from the PDF, with the
+ disc; its education entries read the `place` field for the campus line. The phone,
+ the email and each link are reachable from the PDF, with the
`tel:` and `mailto:` targets built from the values — the published sheet drew its
channels as plain text, and this is the one place the port deliberately improves on
it, at no cost to the render: annotations move no pixel and no layout node, which
diff --git a/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java b/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java
index 712d5dcdf..6068025ca 100644
--- a/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java
+++ b/examples/src/main/java/com/demcha/examples/support/NavySidebarSampleData.java
@@ -4,6 +4,7 @@
import com.demcha.compose.document.templates.core.identity.Contact;
import com.demcha.compose.document.templates.core.identity.Link;
import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
import com.demcha.compose.document.templates.cv.data.CvIdentity;
import com.demcha.compose.document.templates.cv.data.EntriesSection;
import com.demcha.compose.document.templates.cv.data.ParagraphSection;
@@ -60,10 +61,16 @@ public static CvDocument sample() {
+ " whether it worked. Happiest with a small team and a"
+ " short feedback loop."))
.section(Slot.SIDEBAR, EntriesSection.builder("Education")
- .entry("MSc Marketing Analytics", "University of Bristol",
- "2015 - 2016", "Bristol, UK")
- .entry("BA Business Management", "University of Leeds",
- "2012 - 2015", "Leeds, UK")
+ .entry(CvEntry.builder("MSc Marketing Analytics")
+ .subtitle("University of Bristol")
+ .date("2015 - 2016")
+ .place("Bristol, UK")
+ .build())
+ .entry(CvEntry.builder("BA Business Management")
+ .subtitle("University of Leeds")
+ .date("2012 - 2015")
+ .place("Leeds, UK")
+ .build())
.build())
.section(Slot.SIDEBAR, SkillsSection.of("Skills", SkillGroup.of("Core",
"Positioning",
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java
new file mode 100644
index 000000000..8b3b933bc
--- /dev/null
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/data/CvEntryPlaceAndIconTest.java
@@ -0,0 +1,93 @@
+package com.demcha.compose.document.templates.cv.data;
+
+import org.junit.jupiter.api.Test;
+
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+
+/**
+ * Pins the compatibility promise made when {@link CvEntry} grew a location
+ * and a mark: the four-argument constructor that predates them is still
+ * there and still means what it meant, so a caller written against it keeps
+ * compiling and linking.
+ */
+class CvEntryPlaceAndIconTest {
+
+ @Test
+ void theConstructorThatPredatesThemLeavesBothBlank() {
+ CvEntry entry = new CvEntry("Engineer", "Acme", "2021", "Did the work.");
+ assertThat(entry.place()).isEmpty();
+ assertThat(entry.icon()).isEmpty();
+ assertThat(entry.subtitle()).isEqualTo("Acme");
+ }
+
+ @Test
+ void nullsNormalizeToBlank() {
+ CvEntry entry = new CvEntry("Engineer", "Acme", "2021", "Body", null, null);
+ assertThat(entry.place()).isEmpty();
+ assertThat(entry.icon()).isEmpty();
+ }
+
+ @Test
+ void theOriginalFieldsStillRejectNull() {
+ assertThatThrownBy(() -> new CvEntry("Engineer", null, "2021", "Body", "Berlin", "cart"))
+ .isInstanceOf(NullPointerException.class)
+ .hasMessageContaining("subtitle");
+ }
+
+ @Test
+ void aBlankTitleIsStillRejected() {
+ assertThatThrownBy(() -> new CvEntry(" ", "Acme", "2021", "Body", "", ""))
+ .isInstanceOf(IllegalArgumentException.class)
+ .hasMessageContaining("title");
+ }
+
+ @Test
+ void theBuilderCarriesEveryFieldThrough() {
+ CvEntry entry = CvEntry.builder("Ledgerkit")
+ .subtitle("Java, PostgreSQL")
+ .date("2024")
+ .body("An open-source ledger.")
+ .place("Remote")
+ .icon("cart")
+ .build();
+ assertThat(entry).isEqualTo(new CvEntry("Ledgerkit", "Java, PostgreSQL", "2024",
+ "An open-source ledger.", "Remote", "cart"));
+ }
+
+ @Test
+ void theBuilderLeavesWhatItIsNotToldBlank() {
+ CvEntry entry = CvEntry.builder("Ledgerkit").build();
+ assertThat(entry.subtitle()).isEmpty();
+ assertThat(entry.date()).isEmpty();
+ assertThat(entry.body()).isEmpty();
+ assertThat(entry.place()).isEmpty();
+ assertThat(entry.icon()).isEmpty();
+ }
+
+ @Test
+ void theBuilderJoinsABodyGivenAsLines() {
+ // The presets that draw a bulleted entry read one bullet per line, so
+ // the list form saves every caller the same String.join.
+ CvEntry entry = CvEntry.builder("Engineer")
+ .body(List.of("Shipped a thing.", "Shipped another."))
+ .build();
+ assertThat(entry.body()).isEqualTo("Shipped a thing.\nShipped another.");
+ }
+
+ @Test
+ void theBuilderTreatsNullsAsBlank() {
+ CvEntry entry = CvEntry.builder("Engineer")
+ .subtitle(null)
+ .date(null)
+ .body((String) null)
+ .place(null)
+ .icon(null)
+ .build();
+ assertThat(entry.subtitle()).isEmpty();
+ assertThat(entry.body()).isEmpty();
+ assertThat(entry.place()).isEmpty();
+ }
+}
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java
index 923ebec22..c5464103a 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarFixtures.java
@@ -4,6 +4,7 @@
import com.demcha.compose.document.templates.core.identity.Contact;
import com.demcha.compose.document.templates.core.identity.Link;
import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
import com.demcha.compose.document.templates.cv.data.CvIdentity;
import com.demcha.compose.document.templates.cv.data.EntriesSection;
import com.demcha.compose.document.templates.cv.data.ParagraphSection;
@@ -67,10 +68,16 @@ public static CvDocument canonicalCv() {
+ " measurable results. Skilled in digital marketing, market"
+ " research, and cross-functional collaboration."))
.section(Slot.SIDEBAR, EntriesSection.builder("Education")
- .entry("Master of Science in Marketing", "New York University",
- "2020 - 2022", "New York, NY")
- .entry("Bachelor of Business Administration", "University of California",
- "2016 - 2020", "Los Angeles, CA")
+ .entry(CvEntry.builder("Master of Science in Marketing")
+ .subtitle("New York University")
+ .date("2020 - 2022")
+ .place("New York, NY")
+ .build())
+ .entry(CvEntry.builder("Bachelor of Business Administration")
+ .subtitle("University of California")
+ .date("2016 - 2020")
+ .place("Los Angeles, CA")
+ .build())
.build())
.section(Slot.SIDEBAR, SkillsSection.of("Skills", SkillGroup.of("Core",
"Digital Marketing",
diff --git a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java
index 96b6c30ce..d0d245e81 100644
--- a/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java
+++ b/qa/src/test/java/com/demcha/compose/document/templates/cv/presets/NavySidebarSmokeTest.java
@@ -7,6 +7,7 @@
import com.demcha.compose.document.templates.core.identity.Contact;
import com.demcha.compose.document.templates.core.identity.Link;
import com.demcha.compose.document.templates.cv.data.CvDocument;
+import com.demcha.compose.document.templates.cv.data.CvEntry;
import com.demcha.compose.document.templates.cv.data.CvIdentity;
import com.demcha.compose.document.templates.cv.data.CvSkill;
import com.demcha.compose.document.templates.cv.data.EntriesSection;
@@ -141,7 +142,11 @@ void degreesAndTitlesAreDrawnInCapitals() throws Exception {
String text = textOf(render(CvDocument.builder()
.identity(identity())
.section(EntriesSection.builder("Education")
- .entry("Master of Science", "Some University", "2019", "Berlin")
+ .entry(CvEntry.builder("Master of Science")
+ .subtitle("Some University")
+ .date("2019")
+ .place("Berlin")
+ .build())
.build())
.build()));
assertThat(text).contains("MASTER OF SCIENCE").doesNotContain("Master of Science");
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java
index 7e915cc72..025805f15 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/data/CvEntry.java
@@ -1,15 +1,19 @@
package com.demcha.compose.document.templates.cv.data;
+import java.util.List;
import java.util.Objects;
/**
* Timeline-style entry used inside an {@link EntriesSection}. Covers
- * both Education and Professional Experience — they share the same
- * four fields so authors don't have to learn two record types.
+ * Education, Professional Experience, Projects and anything else a preset
+ * lays out as a dated block — they share the same fields so authors don't
+ * have to learn a record type per section.
*
* Blank fields are honoured: a blank {@code date} omits the date
* column, a blank {@code subtitle} drops the italic line, a blank
- * {@code body} drops the description paragraph.
+ * {@code body} drops the description paragraph, a blank {@code place}
+ * drops the location, and a blank {@code icon} leaves the entry unmarked.
+ * A preset draws only what it has somewhere to put.
*
* @param title bold heading on the left (job title, degree)
* @param subtitle italic subtitle on the line below (employer,
@@ -19,19 +23,154 @@
* blank removes the date column
* @param body full-width prose paragraph beneath the subtitle;
* may contain inline markdown
+ * @param place where this happened — a city, a campus, "Remote". The
+ * designs that show it set it beside the employer or the
+ * years rather than inside them, which is why it is its own
+ * field; blank when absent
+ * @param icon token naming the mark a preset draws for this entry. The
+ * vocabulary is preset-scoped — each preset packages its own
+ * set — so a token means something only to the preset that
+ * declares it, and the presets that draw no marks ignore it;
+ * blank when absent
*/
-public record CvEntry(String title, String subtitle, String date, String body) {
+public record CvEntry(String title, String subtitle, String date, String body,
+ String place, String icon) {
/**
- * Validates that every field is non-null and that {@code title} is non-blank.
+ * Validates that the four original fields are non-null and that
+ * {@code title} is non-blank, treating a null {@code place} or
+ * {@code icon} as absent.
*/
public CvEntry {
Objects.requireNonNull(title, "title");
Objects.requireNonNull(subtitle, "subtitle");
Objects.requireNonNull(date, "date");
Objects.requireNonNull(body, "body");
+ place = place == null ? "" : place;
+ icon = icon == null ? "" : icon;
if (title.isBlank()) {
throw new IllegalArgumentException("title must not be blank");
}
}
+
+ /**
+ * Backward-compatible constructor for callers that predate the location
+ * and the mark. The entry simply carries neither.
+ *
+ * @param title bold heading on the left
+ * @param subtitle subtitle on the line below; blank collapses it
+ * @param date date column next to the title; blank removes it
+ * @param body prose paragraph beneath the subtitle
+ */
+ public CvEntry(String title, String subtitle, String date, String body) {
+ this(title, subtitle, date, body, "", "");
+ }
+
+ /**
+ * Creates a fluent builder, which is the readable way to reach the
+ * optional fields without counting positions.
+ *
+ * @param title the entry's heading (required, non-blank)
+ * @return new fluent builder
+ * @since 2.2.3
+ */
+ public static Builder builder(String title) {
+ return new Builder(title);
+ }
+
+ /**
+ * Mutable builder for {@link CvEntry}. Every field but the title starts
+ * blank, so an entry names only what it has.
+ *
+ * @since 2.2.3
+ */
+ public static final class Builder {
+ private final String title;
+ private String subtitle = "";
+ private String date = "";
+ private String body = "";
+ private String place = "";
+ private String icon = "";
+
+ private Builder(String title) {
+ this.title = title;
+ }
+
+ /**
+ * Sets the subtitle — the employer, the institution, the stack.
+ *
+ * @param value the subtitle; null becomes blank
+ * @return this builder for chaining
+ */
+ public Builder subtitle(String value) {
+ this.subtitle = value == null ? "" : value;
+ return this;
+ }
+
+ /**
+ * Sets the date column.
+ *
+ * @param value the date text; null becomes blank
+ * @return this builder for chaining
+ */
+ public Builder date(String value) {
+ this.date = value == null ? "" : value;
+ return this;
+ }
+
+ /**
+ * Sets the prose body.
+ *
+ * @param value the body; null becomes blank
+ * @return this builder for chaining
+ */
+ public Builder body(String value) {
+ this.body = value == null ? "" : value;
+ return this;
+ }
+
+ /**
+ * Sets the prose body from one line per element — the shape the
+ * presets that draw a bulleted entry read.
+ *
+ * @param lines the lines, in order; null becomes blank
+ * @return this builder for chaining
+ */
+ public Builder body(List lines) {
+ this.body = lines == null ? "" : String.join("\n", lines);
+ return this;
+ }
+
+ /**
+ * Sets where this happened.
+ *
+ * @param value the location; null becomes blank
+ * @return this builder for chaining
+ */
+ public Builder place(String value) {
+ this.place = value == null ? "" : value;
+ return this;
+ }
+
+ /**
+ * Sets the mark this entry asks for, in the vocabulary of the preset
+ * that will draw it.
+ *
+ * @param value the icon token; null becomes blank
+ * @return this builder for chaining
+ */
+ public Builder icon(String value) {
+ this.icon = value == null ? "" : value;
+ return this;
+ }
+
+ /**
+ * Builds the immutable {@link CvEntry}.
+ *
+ * @return the assembled entry
+ */
+ public CvEntry build() {
+ return new CvEntry(title, subtitle, date, body, place, icon);
+ }
+ }
}
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java
index 91f970705..eb8bc2bf8 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebar.java
@@ -65,9 +65,9 @@
* own title, since it is the one block with no heading; a skill's level,
* since there are no meters here; and a {@code SkillGroup}'s category. A
* berth is filled by the first section that names it, so a second section
- * naming the same berth is not drawn either. An education entry uses all
- * four of its fields — the degree, the institution, the body as the campus
- * line, and the years.
+ * naming the same berth is not drawn either. An education entry is drawn as
+ * four lines — the degree, the institution, the {@code place}, and the years
+ * — and its body has nowhere to go on this sheet.
*
* The portrait
*
diff --git a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java
index 47dad59df..0b546131a 100644
--- a/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java
+++ b/templates/src/main/java/com/demcha/compose/document/templates/cv/presets/NavySidebarAside.java
@@ -218,7 +218,9 @@ private record Channel(String token, String value, String href) {
/**
* Each degree as four lines: the qualification in capitals, the
- * institution, where it was, and the years.
+ * institution, where it was, and the years. The third line is the
+ * entry's {@code place} — the field the model gives a location, rather
+ * than the body, which this design has nowhere to put.
*/
private static void renderEducation(SectionBuilder section, EntriesSection education) {
sidebarHeading(section, education.title(), false);
@@ -231,7 +233,7 @@ private static void renderEducation(SectionBuilder section, EntriesSection educa
.margin(new DocumentInsets(0, 0, EDU_DEGREE_GAP, 0)));
line(section, "Institution_" + compact(entry.title()), entry.subtitle(),
EDU_LINE_GAP);
- line(section, "Campus_" + compact(entry.title()), entry.body(), EDU_LINE_GAP);
+ line(section, "Campus_" + compact(entry.title()), entry.place(), EDU_LINE_GAP);
line(section, "Years_" + compact(entry.title()), entry.date(), EDU_ENTRY_GAP);
}
}