diff --git a/resources/views/email.phtml b/resources/views/email.phtml
index c9c70ee..4f764f2 100644
--- a/resources/views/email.phtml
+++ b/resources/views/email.phtml
@@ -44,6 +44,8 @@ $palette = [
'birth' => '#5E81AC', // nord10 — birth (blue)
'death' => '#4C566A', // nord3 — death (graphite)
'marr' => '#B48EAD', // nord15 — marriage (purple/pink)
+ 'rail' => '#cdc7be', // muted warm grey for the timeline rail
+ 'dot' => '#5E81AC', // single unified colour for every timeline dot
];
$font_stack = "'Open Sans', Helvetica, Arial, 'Noto Sans', sans-serif";
@@ -81,46 +83,63 @@ $is_detailed = static function (Fact $fact) use ($detailed_xrefs): bool {
return false;
};
-$linked_name = static function (Individual $individual) use ($palette, $relationships): string {
+/**
+ * Just the linked name (no relationship), used both inline and inside
+ * the multi-line name+relationship block below.
+ */
+$linked_name = static function (Individual $individual) use ($palette): string {
$name = strip_tags($individual->fullName());
$url = $individual->url();
$style = 'color:' . $palette['ink'] . ';text-decoration:none;'
. 'border-bottom:1px solid ' . $palette['border'] . ';'
. 'padding-bottom:1px;';
- $html = '' . e($name) . '';
+ return '' . e($name) . '';
+};
- if (isset($relationships[$individual->xref()])) {
- $html .= ' ('
- . e(strip_tags($relationships[$individual->xref()]))
- . ')';
+/**
+ * Inline list of names ("Hans Doe" or "Hans Doe & Lotte Doe") plus
+ * the combined relationship line on a second line beneath.
+ */
+$record_label = static function (Fact $fact) use ($linked_name, $relationships, $palette): string {
+ $record = $fact->record();
+
+ $names = [];
+ $rels = [];
+
+ if ($record instanceof Individual) {
+ $names[] = $linked_name($record);
+ if (isset($relationships[$record->xref()])) {
+ $rels[] = strip_tags($relationships[$record->xref()]);
+ }
+ } elseif ($record instanceof Family) {
+ foreach ([$record->husband(), $record->wife()] as $spouse) {
+ if ($spouse instanceof Individual) {
+ $names[] = $linked_name($spouse);
+ if (isset($relationships[$spouse->xref()])) {
+ $rels[] = strip_tags($relationships[$spouse->xref()]);
+ }
+ }
+ }
+ } else {
+ return e($record->xref());
+ }
+
+ $html = implode(' & ', $names);
+
+ if ($rels !== []) {
+ // Drop near-duplicate labels ("your father", "your father")
+ // so couples don't print the same relationship twice.
+ $rels = array_values(array_unique(array_map(static fn ($r) => e($r), $rels)));
+ $html .= '
'
+ . implode(' & ', $rels)
+ . '
';
}
return $html;
};
-$record_label = static function (Fact $fact) use ($linked_name): string {
- $record = $fact->record();
-
- if ($record instanceof Individual) {
- return $linked_name($record);
- }
-
- if ($record instanceof Family) {
- $parts = [];
-
- foreach ([$record->husband(), $record->wife()] as $spouse) {
- if ($spouse instanceof Individual) {
- $parts[] = $linked_name($spouse);
- }
- }
-
- return implode(' & ', $parts);
- }
-
- return e($record->xref());
-};
-
$event_date_display = static function (Fact $fact): string {
$date = $fact->date();
@@ -131,6 +150,43 @@ $event_date_display = static function (Fact $fact): string {
return strip_tags($date->display());
};
+/**
+ * Split a Fact's event date into (day+month, year) for the timeline
+ * rail where the day-of-the-anniversary is the meaningful sort key
+ * and the year is supporting information.
+ *
+ * @return array{day_month:string,year:string}
+ */
+$date_parts = static function (Fact $fact): array {
+ static $gregorian = null;
+ static $months_de = [
+ 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni',
+ 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember',
+ ];
+ static $months_en = [
+ 1 => 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun',
+ 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec',
+ ];
+
+ $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar();
+
+ $date = $fact->date();
+
+ if (!$date->isOK()) {
+ return ['day_month' => '', 'year' => ''];
+ }
+
+ [$year, $month, $day] = $gregorian->jdToYmd($date->minimumJulianDay());
+
+ if (str_starts_with(I18N::languageTag(), 'de')) {
+ $day_month = $day . '. ' . mb_strtoupper($months_de[$month] ?? '');
+ } else {
+ $day_month = mb_strtoupper(($months_en[$month] ?? '') . ' ' . $day);
+ }
+
+ return ['day_month' => $day_month, 'year' => (string) $year];
+};
+
$event_kind = static function (Fact $fact): string {
$parts = explode(':', $fact->tag());
@@ -319,22 +375,35 @@ $section_kicker_style = 'margin:0 0 18px;'
. 'font-style:italic;font-weight:300;font-size:14px;'
. 'color:' . $palette['ink3'] . ';';
-$divider_style = 'border-bottom:1px solid ' . $palette['border'] . ';';
+// The card surface is built per-row out of avatar+content TDs (the
+// outer table itself stays transparent). The timeline TD lives on the
+// page background and carries a thick rail on its left edge.
+$card_padding_y = 18;
+$card_corner_radius = 8;
-$avatar_cell_style = 'width:72px;vertical-align:middle;padding:18px 0 18px 18px;';
+$avatar_cell_base = 'width:72px;vertical-align:middle;'
+ . 'padding:' . $card_padding_y . 'px 0 ' . $card_padding_y . 'px 18px;'
+ . 'background:' . $palette['surface'] . ';'
+ . 'border-left:1px solid ' . $palette['border'] . ';';
-$content_cell_style = 'vertical-align:middle;'
- . 'padding:18px 18px 18px 16px;'
+$content_cell_base = 'vertical-align:middle;'
+ . 'padding:' . $card_padding_y . 'px 18px ' . $card_padding_y . 'px 16px;'
+ . 'background:' . $palette['surface'] . ';'
+ . 'border-right:1px solid ' . $palette['border'] . ';'
. 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;'
. 'font-weight:300;color:' . $palette['ink'] . ';';
-$timeline_cell_style = 'width:170px;vertical-align:middle;'
- . 'padding:18px 18px 18px 26px;'
- . 'border-left:1px solid ' . $palette['border'] . ';'
+$timeline_cell_base = 'width:140px;vertical-align:middle;'
+ . 'padding:' . $card_padding_y . 'px 14px ' . $card_padding_y . 'px 24px;'
+ . 'border-left:4px solid ' . $palette['rail'] . ';'
+ . 'background:' . $palette['bg'] . ';'
. 'font-family:' . $font_stack . ';'
- . 'font-weight:500;font-size:11px;letter-spacing:0.12em;text-transform:uppercase;'
. 'color:' . $palette['ink3'] . ';white-space:nowrap;';
+// A 16px wide gutter cell sits between the card and the timeline so
+// the rail visibly separates from the card edge.
+$gutter_cell_style = 'width:16px;background:' . $palette['bg'] . ';';
+
// Renders one event row in the consistent 3-column layout shared by
// every section (avatar | content | date on the timeline rail).
$summary_kicker_style = 'margin:18px 0 8px;'
@@ -349,36 +418,85 @@ $summary_list_style = 'list-style:none;margin:0;padding:0;'
$summary_item_style = 'padding:3px 0;';
-$event_row = static function (Fact $fact, string $body_html, string $dot_color)
+// Renders one event row in the 4-column layout
+// [avatar] [content] [gutter] [timeline rail + date]
+//
+// The avatar+content TDs carry the card surface (background + side
+// borders + corner radii on the first/last rows so the visual card
+// wraps just those two cells). The timeline TD sits on the page
+// background with a thick coloured rail on its left edge.
+//
+// $position is 'first', 'middle', or 'last' — used to add corner
+// radii and outer borders only where they belong.
+//
+// $show_dot suppresses the dot on rows whose upcoming-day matches
+// the previous row, so each calendar day has exactly one dot.
+$event_row = static function (
+ Fact $fact,
+ string $body_html,
+ bool $is_first,
+ bool $is_last,
+ bool $show_dot,
+)
use (
$record_avatars,
- $event_date_display,
- $avatar_cell_style,
- $content_cell_style,
- $timeline_cell_style,
- $divider_style,
+ $date_parts,
+ $avatar_cell_base,
+ $content_cell_base,
+ $timeline_cell_base,
+ $gutter_cell_style,
+ $card_corner_radius,
+ $palette,
): string {
- return ''
- . '| ' . $record_avatars($fact) . ' | '
- . '' . $body_html . ' | '
- . ''
- . ''
- . e($event_date_display($fact))
- . ' | '
+ $avatar_extra = '';
+ $content_extra = '';
+
+ if ($is_first) {
+ $avatar_extra .= 'border-top:1px solid ' . $palette['border'] . ';'
+ . 'border-top-left-radius:' . $card_corner_radius . 'px;';
+ $content_extra .= 'border-top:1px solid ' . $palette['border'] . ';'
+ . 'border-top-right-radius:' . $card_corner_radius . 'px;';
+ }
+
+ if ($is_last) {
+ $avatar_extra .= 'border-bottom:1px solid ' . $palette['border'] . ';'
+ . 'border-bottom-left-radius:' . $card_corner_radius . 'px;';
+ $content_extra .= 'border-bottom:1px solid ' . $palette['border'] . ';'
+ . 'border-bottom-right-radius:' . $card_corner_radius . 'px;';
+ } else {
+ $avatar_extra .= 'border-bottom:1px solid ' . $palette['border'] . ';';
+ $content_extra .= 'border-bottom:1px solid ' . $palette['border'] . ';';
+ }
+
+ $parts = $date_parts($fact);
+
+ $dot_html = $show_dot
+ ? ''
+ : '';
+
+ $date_html =
+ ''
+ . '' . e($parts['day_month']) . '
'
+ . '' . e($parts['year']) . '
'
+ . '';
+
+ return '
'
+ . '| ' . $record_avatars($fact) . ' | '
+ . '' . $body_html . ' | '
+ . ' | '
+ . '' . $dot_html . $date_html . ' | '
. '
';
};
-// Wraps a list of rows in the "card" surface — rounded background
-// patch matching .card on the website.
+// Outer section table is transparent. Each event row contributes its
+// own card-surfaced avatar+content TDs plus the timeline TD.
$card_open = '';
+ . 'style="width:100%;border-collapse:separate;border-spacing:0;">';
$card_close = '
';
?>
@@ -436,15 +554,18 @@ $card_close = '';
= $card_open ?>
-
+
+ $fact) : ?>
' . $event_icon('BIRT') . ''
+ $age = $upcoming_age($fact);
+ $body = '' . $event_icon('BIRT') . ''
. ''
. '' . $record_label($fact) . ''
. ' — ' . e($birthday_label($age)) . ''
. '';
- echo $event_row($fact, $body, $palette['birth']);
+ $show_dot = ($fact->jd ?? 0) !== $prev_jd;
+ $prev_jd = $fact->jd ?? 0;
+ echo $event_row($fact, $body, $i === 0, $i === $total - 1, $show_dot);
?>
= $card_close ?>
@@ -482,15 +603,18 @@ $card_close = '';
= $card_open ?>
-
+
+ $fact) : ?>
' . $event_icon('MARR') . ''
+ $age = $upcoming_age($fact);
+ $body = '' . $event_icon('MARR') . ''
. ''
. '' . $record_label($fact) . ''
. ' — ' . e($anniversary_label($age)) . ''
. '';
- echo $event_row($fact, $body, $palette['marr']);
+ $show_dot = ($fact->jd ?? 0) !== $prev_jd;
+ $prev_jd = $fact->jd ?? 0;
+ echo $event_row($fact, $body, $i === 0, $i === $total - 1, $show_dot);
?>
= $card_close ?>
@@ -528,15 +652,18 @@ $card_close = '';
= $card_open ?>
-
+
+ $fact) : ?>
' . $event_icon($kind) . ''
+ $kind = $event_kind($fact);
+ $body = '' . $event_icon($kind) . ''
. ''
. '' . $record_label($fact) . ''
. ' — ' . e($fact->label()) . ''
. '';
- echo $event_row($fact, $body, $event_color($kind));
+ $show_dot = ($fact->jd ?? 0) !== $prev_jd;
+ $prev_jd = $fact->jd ?? 0;
+ echo $event_row($fact, $body, $i === 0, $i === $total - 1, $show_dot);
?>
= $card_close ?>