$birthdays * @var Collection|null $anniversaries * @var Collection|null $historical * @var bool $include_anniversaries * @var bool $include_historical * @var int $lookahead_days * @var int $historical_lookahead * @var int $generated_at * @var array $avatar_cids xref => CID name * @var array $relationships xref => "your mother" etc. (per-recipient) * @var array $detailed_xrefs xref-set — render in detail; others as summary bullet * @var string $account_url */ // ─── BockenTheme light-mode palette ───────────────────────────────────── // Pulled from src/scss/theme.scss + config/_theme-variables.scss in the // Bocken theme so the newsletter and the website read as one product. $palette = [ 'bg' => '#f8f6f1', // body / page background (--color-bg-primary) 'surface' => '#efecea', // raised section cards (--color-bg-secondary) 'elevated' => '#dfdcd8', // tertiary surface (--color-bg-elevated) 'border' => '#ddd', // hairline borders (--color-border) 'ink' => '#2a2a2a', // primary text (--color-text-primary) 'ink2' => '#555', // secondary text (--color-text-secondary) 'ink3' => '#777', // tertiary text (--color-text-tertiary) 'mute' => '#aaa', // muted text (--color-text-muted) 'link' => '#5E81AC', // nord10 — primary link 'link_hov' => '#81A1C1', // nord9 'accent' => '#BF616A', // nord11 — red FAB accent '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"; $avatar_size = 56; // ─── Helpers ──────────────────────────────────────────────────────────── $relationships = $relationships ?? []; $detailed_xrefs = $detailed_xrefs ?? []; /** * Returns true if this fact's primary record should render as a full * detailed row (avatar + icon + timeline), or false for the compact * text-only summary bullet at the bottom of the section. * * A Family fact (anniversary) counts as detailed if either spouse is * in the recipient's lineal set. */ $is_detailed = static function (Fact $fact) use ($detailed_xrefs): bool { $record = $fact->record(); if ($record instanceof Individual) { return isset($detailed_xrefs[$record->xref()]); } if ($record instanceof Family) { foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual && isset($detailed_xrefs[$spouse->xref()])) { return true; } } } return false; }; /** * 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;'; return '' . e($name) . ''; }; /** * 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; }; $event_date_display = static function (Fact $fact): string { $date = $fact->date(); if (!$date instanceof Date || !$date->isOK()) { return ''; } 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()); return end($parts); }; /** * Compact inline SVG glyph for each event type. Coloured to match the * BockenTheme Nord palette: birth in nord10 blue, death in nord3 * graphite, marriage in nord15 mauve. */ $event_icon = static function (string $kind) use ($palette): string { $svg_open = ' $svg_open . 'viewBox="0 0 24 24" width="18" height="18">' . '' . '', // Latin obelus — the typographic mark for "died" in obituaries. 'DEAT' => $svg_open . 'viewBox="0 0 24 24" width="14" height="18">' . '' . '' . '', // Two interlocking rings — universal mark of marriage. 'MARR' => $svg_open . 'viewBox="0 0 36 22" width="28" height="18">' . '' . '' . '', default => '', }; }; /** * Map an event type to the colour used for that row's timeline dot * (so the right rail reads as a colour-coded ribbon). */ $event_color = static function (string $kind) use ($palette): string { return match ($kind) { 'BIRT' => $palette['birth'], 'DEAT' => $palette['death'], 'MARR' => $palette['marr'], default => $palette['ink3'], }; }; $avatar = static function (Individual|null $individual) use ($avatar_cids, $avatar_size, $palette, $font_stack): string { if (!$individual instanceof Individual) { return ''; } $alt = e(strip_tags($individual->fullName())); $shadow = 'box-shadow:0 0 0 1px ' . $palette['border'] . ',0 1px 3px rgba(0,0,0,0.08);'; if (isset($avatar_cids[$individual->xref()])) { $cid = $avatar_cids[$individual->xref()]; $inner = '' . $alt . ''; } else { // Initial-disc fallback. Hue is hashed from the xref so the // same person keeps the same colour across editions. $hue = hexdec(substr(md5($individual->xref()), 0, 2)) * 360 / 255; $first = strip_tags($individual->getAllNames()[0]['givn'] ?? $individual->xref()); $last = strip_tags($individual->getAllNames()[0]['surn'] ?? ''); $initials = e(mb_strtoupper(mb_substr($first, 0, 1) . mb_substr($last, 0, 1))); $inner = '' . $initials . ''; } return '' . $inner . ''; }; $record_avatars = static function (Fact $fact) use ($avatar): string { $record = $fact->record(); if ($record instanceof Individual) { return $avatar($record); } if ($record instanceof Family) { $parts = []; foreach ([$record->husband(), $record->wife()] as $spouse) { if ($spouse instanceof Individual) { $parts[] = $avatar($spouse); } } return '' . '' . (isset($parts[1]) ? '' : '') . '
' . ($parts[0] ?? '') . '' . $parts[1] . '
'; } return ''; }; // Locale-aware ordinal: German uses "N.", English uses st/nd/rd/th. $ordinal = static function (int $n): string { if (str_starts_with(I18N::languageTag(), 'de')) { return $n . '.'; } $abs = abs($n); $mod100 = $abs % 100; if ($mod100 >= 11 && $mod100 <= 13) { return $n . 'th'; } return $n . match ($abs % 10) { 1 => 'st', 2 => 'nd', 3 => 'rd', default => 'th', }; }; $upcoming_age = static function (Fact $fact): int { static $gregorian = null; $gregorian ??= new \Fisharebest\ExtCalendar\GregorianCalendar(); $date = $fact->date(); if (!$date->isOK()) { return 0; } $event_year = $date->gregorianYear(); $upcoming_jd = $fact->jd ?? 0; if ($upcoming_jd > 0) { [$upcoming_year] = $gregorian->jdToYmd($upcoming_jd); } else { $upcoming_year = (int) date('Y'); } return max(0, $upcoming_year - $event_year); }; $birthday_label = static function (int $age) use ($ordinal): string { return $age > 0 ? I18N::translate('%s birthday', $ordinal($age)) : I18N::translate('Birthday'); }; $anniversary_label = static function (int $age) use ($ordinal): string { return $age > 0 ? I18N::translate('%s wedding anniversary', $ordinal($age)) : I18N::translate('Wedding anniversary'); }; $masthead_date = static function (int $timestamp): string { if (str_starts_with(I18N::languageTag(), 'de')) { $months = [ 1 => 'Januar', 'Februar', 'März', 'April', 'Mai', 'Juni', 'Juli', 'August', 'September', 'Oktober', 'November', 'Dezember', ]; return (int) date('j', $timestamp) . '. ' . $months[(int) date('n', $timestamp)] . ' ' . date('Y', $timestamp); } return date('F j, Y', $timestamp); }; // ─── Row styles ───────────────────────────────────────────────────────── $section_title_style = 'margin:0 0 4px;' . 'font-family:' . $font_stack . ';' . 'font-weight:400;font-size:24px;line-height:1.2;' . 'color:' . $palette['ink'] . ';' . 'letter-spacing:-0.005em;'; $section_kicker_style = 'margin:0 0 18px;' . 'font-family:' . $font_stack . ';' . 'font-style:italic;font-weight:300;font-size:14px;' . 'color:' . $palette['ink3'] . ';'; $card_padding_y = 18; $card_corner_radius = 8; $row_gap = 12; // vertical gap between consecutive cards // Inner card table — full rounded surface containing the avatar + // content cells. Each event row drops one of these into the outer // section table's left column. $card_outer_style = 'width:100%;' . 'background:' . $palette['surface'] . ';' . 'border:1px solid ' . $palette['border'] . ';' . 'border-radius:' . $card_corner_radius . 'px;' . 'box-shadow:0 1px 3px rgba(0,0,0,0.04);'; $avatar_inner_td = 'width:72px;vertical-align:middle;' . 'padding:' . $card_padding_y . 'px 0 ' . $card_padding_y . 'px 18px;'; $content_inner_td = 'vertical-align:middle;' . 'padding:' . $card_padding_y . 'px 18px;' . 'font-family:' . $font_stack . ';font-size:15px;line-height:1.4;' . 'font-weight:300;color:' . $palette['ink'] . ';'; // Outer row cells: card on the left, a small gutter, then the rail. $outer_card_td = 'vertical-align:middle;padding-bottom:' . $row_gap . 'px;'; $outer_gutter_td = 'width:16px;padding-bottom:' . $row_gap . 'px;'; $outer_rail_td = 'width:140px;vertical-align:middle;' . 'padding:0 14px ' . $row_gap . 'px 24px;' . 'border-left:4px solid ' . $palette['rail'] . ';' . 'font-family:' . $font_stack . ';' . 'color:' . $palette['ink3'] . ';white-space:nowrap;'; // 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;' . 'font-family:' . $font_stack . ';' . 'font-weight:500;font-size:11px;letter-spacing:0.16em;text-transform:uppercase;' . 'color:' . $palette['ink3'] . ';' . 'padding-top:10px;border-top:1px dashed ' . $palette['border'] . ';'; $summary_list_style = 'list-style:none;margin:0;padding:0;' . 'font-family:' . $font_stack . ';font-size:13px;line-height:1.7;font-weight:300;' . 'color:' . $palette['ink2'] . ';'; $summary_item_style = 'padding:3px 0;'; // 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. // Each event row builds a fully-rounded inner card (a nested table) // and parks it in the outer row's left TD; the rail TD on the right // carries the continuous timeline rail as a left-border. The outer // table uses border-collapse:collapse so adjacent rail TDs merge // their left-borders into ONE unbroken line across the row gaps. // // $show_dot suppresses the dot AND the day+month line on rows whose // upcoming-day matches the previous (only the year is then shown). $event_row = static function ( Fact $fact, string $body_html, bool $show_dot, ) use ( $record_avatars, $date_parts, $card_outer_style, $avatar_inner_td, $content_inner_td, $outer_card_td, $outer_gutter_td, $outer_rail_td, $palette, ): string { $card_html = '' . '' . '' . '
' . $record_avatars($fact) . '' . $body_html . '
'; $parts = $date_parts($fact); // The rail TD has padding-left:24 + border-left:4. Inline // content therefore starts 26px right of the rail centre // (with border-collapse:collapse the 4px border straddles the // cell boundary, putting half its width into our cell). A // 14px-wide dot wants margin-left = -(26 + 7) = -33 to land // its centre on the rail. $dot_html = $show_dot ? '' : ''; if ($show_dot) { $date_html = '' . '
' . e($parts['day_month']) . '
' . '
' . e($parts['year']) . '
' . '
'; } else { $date_html = '' . e($parts['year']) . ''; } return '' . '' . $card_html . '' . '' . '' . $dot_html . $date_html . '' . ''; }; // Outer section table uses border-collapse:collapse so the rail // border-lefts merge into a continuous line across all row gaps. $card_open = ''; $card_close = '
'; // The rail visual is built from three pieces: // // 1. $timeline_top_cap — a 4px tall stub at the start of the // section whose border-top-left-radius rounds the top end of // the rail. // 2. Event rows — each carries the rail as a border-left // on its right-hand TD; with border-collapse:collapse those // adjacent borders merge into one continuous line. // 3. $timeline_bottom_cap — a longer stub after the last event // that extends the rail visually below the cards, with a // border-bottom-left-radius rounding the bottom end. // // Then $timeline_arrow_row sits below the rail with no border (so // the rail terminates cleanly and the arrow is visually separated) // and renders the chevron arrowhead in inline SVG. $timeline_top_cap = '' . '' . '' . ''; $timeline_bottom_cap = '' . '' . '' . ''; // Chevron arrowhead — inline SVG, stroke-linecap:round + // stroke-linejoin:round so the strokes have soft ends. Sits below // the rail's rounded tail in its own row so the rail terminates // cleanly without the chevron's strokes painting over the round. $arrow_svg = '' . '' . ''; $timeline_arrow_row = '' . '' . '' . '' . $arrow_svg . '' . ''; ?> <?= e(I18N::translate('Family newsletter — %s', $masthead_date($generated_at))) ?>
isEmpty()) : ?> isEmpty()) : ?> isEmpty()) : ?>

title()) ?>

·

' . $event_icon('BIRT') . '' . '' . '' . $record_label($fact) . '' . ' — ' . e($birthday_label($age)) . '' . ''; $show_dot = ($fact->jd ?? 0) !== $prev_jd; $prev_jd = $fact->jd ?? 0; echo $event_row($fact, $body, $show_dot); ?>
  • ·

' . $event_icon('MARR') . '' . '' . '' . $record_label($fact) . '' . ' — ' . e($anniversary_label($age)) . '' . ''; $show_dot = ($fact->jd ?? 0) !== $prev_jd; $prev_jd = $fact->jd ?? 0; echo $event_row($fact, $body, $show_dot); ?>
  • ·

' . $event_icon($kind) . '' . '' . '' . $record_label($fact) . '' . ' — ' . e($fact->label()) . '' . ''; $show_dot = ($fact->jd ?? 0) !== $prev_jd; $prev_jd = $fact->jd ?? 0; echo $event_row($fact, $body, $show_dot); ?>
  • label()) ?> ·

title())) ?>
' . e(I18N::translate('My account')) . '', ) ?>