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 = '';

- + + $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); ?> @@ -482,15 +603,18 @@ $card_close = '';

- + + $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); ?> @@ -528,15 +652,18 @@ $card_close = '';

- + + $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); ?>