Limit detailed view to lineal kin; rest as summary bullets

Per-recipient: only direct ancestors and direct descendants
within a configurable number of generations (default 3) get the
full row treatment (avatar, icon, timeline). Everyone else falls
through to a compact text-only bullet list at the bottom of the
same section.

- New tree preference NEWSLETTER_LINEAL_DEPTH (range 0–10,
  default 3) with a clearly-explained admin input.
- RelationshipPathFinder::linealKin() does two cheap recursive
  expansions (ancestors and descendants only — no spouse or
  sibling traversal) and returns the xref set. Memoised per
  recipient within a dispatch run.
- Avatar attachments are filtered per recipient to only the
  embeds actually referenced in their HTML, so summary-only rows
  no longer inflate per-email size with unused images.
- Recipients with no PREF_TREE_ACCOUNT_XREF (external admin
  addresses, users not linked to a record) see the entire
  newsletter in detail — no lineal anchor to filter against.
- German translations for the three new section kickers ("Other
  birthdays", etc.) and the admin input help text.
This commit is contained in:
2026-05-15 13:01:41 +02:00
parent 3bc25a2bdb
commit ff743e484f
6 changed files with 339 additions and 43 deletions
+17
View File
@@ -47,6 +47,7 @@ use Illuminate\Support\Collection;
$annivs = Configuration::includeAnniversaries($tree);
$subject = Configuration::subjectPrefix($tree);
$extras = $tree->getPreference(Configuration::PREF_EXTRA_RECIPIENTS, '');
$lineal = Configuration::linealDepth($tree);
$last_sent = Configuration::lastSentAt($tree);
?>
@@ -129,6 +130,22 @@ use Illuminate\Support\Collection;
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="lineal-<?= $id ?>">
<?= I18N::translate('Detailed view depth (generations)') ?>
</label>
<div class="col-sm-9">
<input class="form-control" type="number" style="max-width: 18rem;"
id="lineal-<?= $id ?>" name="lineal-<?= $id ?>"
value="<?= e((string) $lineal) ?>"
min="<?= Configuration::MIN_LINEAL_DEPTH ?>"
max="<?= Configuration::MAX_LINEAL_DEPTH ?>" required>
<small class="form-text text-muted">
<?= I18N::translate('Recipients see profile pictures, icons and the timeline only for their own direct ancestors and descendants within this many generations. Everyone else appears as a compact text list at the bottom of each section. Set to 0 to render the whole newsletter as text. Recipients with no linked tree record always see the full detailed view.') ?>
</small>
</div>
</div>
<div class="row mb-3">
<label class="col-sm-3 col-form-label" for="subject-<?= $id ?>">
<?= I18N::translate('Subject prefix') ?>
+153 -42
View File
@@ -20,8 +20,9 @@ use Illuminate\Support\Collection;
* @var int $lookahead_days
* @var int $historical_lookahead
* @var int $generated_at
* @var array<string,string> $avatar_cids xref => CID name
* @var array<string,string> $relationships xref => "your mother" etc. (per-recipient)
* @var array<string,string> $avatar_cids xref => CID name
* @var array<string,string> $relationships xref => "your mother" etc. (per-recipient)
* @var array<string,true> $detailed_xrefs xref-set — render in detail; others as summary bullet
* @var string $account_url
*/
@@ -51,7 +52,34 @@ $avatar_size = 56;
// ─── Helpers ────────────────────────────────────────────────────────────
$relationships = $relationships ?? [];
$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;
};
$linked_name = static function (Individual $individual) use ($palette, $relationships): string {
$name = strip_tags($individual->fullName());
@@ -309,6 +337,18 @@ $timeline_cell_style = 'width:170px;vertical-align:middle;'
// 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;';
$event_row = static function (Fact $fact, string $body_html, string $dot_color)
use (
$record_avatars,
@@ -382,68 +422,139 @@ $card_close = '</table>';
</tr>
<?php if (!$birthdays->isEmpty()) : ?>
<?php
$detailed = [];
$summary = [];
foreach ($birthdays as $fact) {
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
}
?>
<tr><td style="padding:8px 0 0;">
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming birthdays')) ?></h2>
<p style="<?= $section_kicker_style ?>">
<?= e(I18N::translate('Living kin who will celebrate this fortnight.')) ?>
</p>
<?= $card_open ?>
<?php foreach ($birthdays as $fact) : ?>
<?php
$age = $upcoming_age($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>'
. '<span style="vertical-align:middle;">'
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($birthday_label($age)) . '</span>'
. '</span>';
echo $event_row($fact, $body, $palette['birth']);
?>
<?php endforeach ?>
<?= $card_close ?>
<?php if ($detailed !== []) : ?>
<?= $card_open ?>
<?php foreach ($detailed as $fact) : ?>
<?php
$age = $upcoming_age($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('BIRT') . '</span>'
. '<span style="vertical-align:middle;">'
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($birthday_label($age)) . '</span>'
. '</span>';
echo $event_row($fact, $body, $palette['birth']);
?>
<?php endforeach ?>
<?= $card_close ?>
<?php endif ?>
<?php if ($summary !== []) : ?>
<div style="<?= $summary_kicker_style ?>">
<?= e(I18N::translate('Other birthdays')) ?>
</div>
<ul style="<?= $summary_list_style ?>">
<?php foreach ($summary as $fact) : ?>
<?php $age = $upcoming_age($fact); ?>
<li style="<?= $summary_item_style ?>">
<?= $record_label($fact) ?>
<span style="color:<?= $palette['ink3'] ?>;"><?= e($birthday_label($age)) ?></span>
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
</td></tr>
<?php endif ?>
<?php if ($include_anniversaries && $anniversaries !== null && !$anniversaries->isEmpty()) : ?>
<?php
$detailed = [];
$summary = [];
foreach ($anniversaries as $fact) {
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
}
?>
<tr><td style="padding:32px 0 0;">
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('Upcoming marriage anniversaries')) ?></h2>
<p style="<?= $section_kicker_style ?>">
<?= e(I18N::translate('Marriages still intact.')) ?>
</p>
<?= $card_open ?>
<?php foreach ($anniversaries as $fact) : ?>
<?php
$age = $upcoming_age($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>'
. '<span style="vertical-align:middle;">'
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($anniversary_label($age)) . '</span>'
. '</span>';
echo $event_row($fact, $body, $palette['marr']);
?>
<?php endforeach ?>
<?= $card_close ?>
<?php if ($detailed !== []) : ?>
<?= $card_open ?>
<?php foreach ($detailed as $fact) : ?>
<?php
$age = $upcoming_age($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon('MARR') . '</span>'
. '<span style="vertical-align:middle;">'
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($anniversary_label($age)) . '</span>'
. '</span>';
echo $event_row($fact, $body, $palette['marr']);
?>
<?php endforeach ?>
<?= $card_close ?>
<?php endif ?>
<?php if ($summary !== []) : ?>
<div style="<?= $summary_kicker_style ?>">
<?= e(I18N::translate('Other anniversaries')) ?>
</div>
<ul style="<?= $summary_list_style ?>">
<?php foreach ($summary as $fact) : ?>
<?php $age = $upcoming_age($fact); ?>
<li style="<?= $summary_item_style ?>">
<?= $record_label($fact) ?>
<span style="color:<?= $palette['ink3'] ?>;"><?= e($anniversary_label($age)) ?></span>
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
</td></tr>
<?php endif ?>
<?php if ($include_historical && $historical !== null && !$historical->isEmpty()) : ?>
<?php
$detailed = [];
$summary = [];
foreach ($historical as $fact) {
if ($is_detailed($fact)) { $detailed[] = $fact; } else { $summary[] = $fact; }
}
?>
<tr><td style="padding:32px 0 0;">
<h2 style="<?= $section_title_style ?>"><?= e(I18N::translate('On this month in history')) ?></h2>
<p style="<?= $section_kicker_style ?>">
<?= e(I18N::translate('Events in the next %d days for people who have passed away.', $historical_lookahead)) ?>
</p>
<?= $card_open ?>
<?php foreach ($historical as $fact) : ?>
<?php
$kind = $event_kind($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
. '<span style="vertical-align:middle;">'
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($fact->label()) . '</span>'
. '</span>';
echo $event_row($fact, $body, $event_color($kind));
?>
<?php endforeach ?>
<?= $card_close ?>
<?php if ($detailed !== []) : ?>
<?= $card_open ?>
<?php foreach ($detailed as $fact) : ?>
<?php
$kind = $event_kind($fact);
$body = '<span style="display:inline-block;vertical-align:middle;margin-right:12px;">' . $event_icon($kind) . '</span>'
. '<span style="vertical-align:middle;">'
. '<span style="font-weight:600;color:' . $palette['ink'] . ';">' . $record_label($fact) . '</span>'
. ' <span style="color:' . $palette['ink2'] . ';font-weight:300;">— ' . e($fact->label()) . '</span>'
. '</span>';
echo $event_row($fact, $body, $event_color($kind));
?>
<?php endforeach ?>
<?= $card_close ?>
<?php endif ?>
<?php if ($summary !== []) : ?>
<div style="<?= $summary_kicker_style ?>">
<?= e(I18N::translate('Other historical events')) ?>
</div>
<ul style="<?= $summary_list_style ?>">
<?php foreach ($summary as $fact) : ?>
<li style="<?= $summary_item_style ?>">
<?= $record_label($fact) ?>
<span style="color:<?= $palette['ink3'] ?>;"><?= e($fact->label()) ?></span>
<span style="color:<?= $palette['mute'] ?>;"> · <?= e($event_date_display($fact)) ?></span>
</li>
<?php endforeach ?>
</ul>
<?php endif ?>
</td></tr>
<?php endif ?>