Add tree-home block; merge birthday/anniversary timeline

- New "Upcoming family events" block for the tree home page,
  rendering the same card + timeline visualisation as the
  newsletter email but adapted for web context: avatars resolve
  to media-file URLs (no CID), the silhouette placeholder reuses
  BockenTheme's .person-card .photo-placeholder rules so the
  Nord-mixed shades and dark-mode handling stay in sync with the
  full-diagram plugin, and per-viewer relationship labels surface
  when the signed-in user is linked to an Individual on the tree.
- Default window 30 days, configurable via the standard block
  config UI. Wide-screen wrapper caps at 760 px with a small
  right-side breathing margin.
- Block renders via AJAX and caches its HTML for 5 minutes per
  (tree, window, viewer, locale), so the tree home page paints
  instantly and repeat visits skip the heavy event/query +
  relationship-BFS work.
- Living-kin section is now a single date-sorted timeline that
  mixes birthdays and intact-couple anniversaries. Each row's
  icon + label key off the fact's tag, so a mixed run shares
  one rail. Applies to both block and email.
- Newsletter subscription menu entry removed from the header;
  the form is still reachable on the standard /my-account page
  via the registerCustomView override.
This commit is contained in:
2026-05-15 17:16:07 +02:00
parent 2f174bb229
commit 68b347a61f
3 changed files with 840 additions and 91 deletions
+224 -31
View File
@@ -11,43 +11,55 @@ declare(strict_types=1);
namespace EmailNewsletter;
use EmailNewsletter\Http\AccountUpdateDecorator;
use EmailNewsletter\Services\EventQueryService;
use EmailNewsletter\Services\NewsletterDispatchService;
use EmailNewsletter\Services\RelationshipPathFinder;
use Fisharebest\Webtrees\Auth;
use Fisharebest\Webtrees\Contracts\UserInterface;
use Fisharebest\Webtrees\FlashMessages;
use Fisharebest\Webtrees\Http\Exceptions\HttpAccessDeniedException;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountEdit;
use Fisharebest\Webtrees\Http\RequestHandlers\AccountUpdate;
use Fisharebest\Webtrees\I18N;
use Fisharebest\Webtrees\Menu;
use Fisharebest\Webtrees\Individual;
use Fisharebest\Webtrees\Registry;
use Fisharebest\Webtrees\Module\AbstractModule;
use Fisharebest\Webtrees\Module\ModuleBlockInterface;
use Fisharebest\Webtrees\Module\ModuleBlockTrait;
use Fisharebest\Webtrees\Module\ModuleConfigInterface;
use Fisharebest\Webtrees\Module\ModuleConfigTrait;
use Fisharebest\Webtrees\Module\ModuleCustomInterface;
use Fisharebest\Webtrees\Module\ModuleCustomTrait;
use Fisharebest\Webtrees\Module\ModuleMenuInterface;
use Fisharebest\Webtrees\Module\ModuleMenuTrait;
use Fisharebest\Webtrees\Services\TreeService;
use Fisharebest\Webtrees\Services\UserService;
use Fisharebest\Webtrees\User;
use Fisharebest\Webtrees\Tree;
use Fisharebest\Webtrees\User;
use Fisharebest\Webtrees\Validator;
use Fisharebest\Webtrees\View;
use Illuminate\Support\Str;
use Psr\Http\Message\ResponseInterface;
use Psr\Http\Message\ServerRequestInterface;
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleMenuInterface
class Module extends AbstractModule implements ModuleCustomInterface, ModuleConfigInterface, ModuleBlockInterface
{
use ModuleCustomTrait;
use ModuleConfigTrait;
use ModuleMenuTrait;
use ModuleBlockTrait;
private const string SETTING_CRON_TOKEN = Configuration::MODULE_PREF_CRON_TOKEN;
/**
* Default look-ahead window for the tree-home block. Distinct from
* the per-tree newsletter cadence — the block always shows the
* next 30 days regardless of how often the email is sent.
*/
private const int BLOCK_DEFAULT_WINDOW_DAYS = 30;
public function __construct(
private readonly NewsletterDispatchService $dispatch_service,
private readonly TreeService $tree_service,
private readonly UserService $user_service,
private readonly EventQueryService $event_query_service,
private readonly RelationshipPathFinder $relationship_finder,
) {
}
@@ -128,6 +140,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
'Other birthdays' => 'Weitere Geburtstage',
'Other anniversaries' => 'Weitere Hochzeitstage',
'Other historical events' => 'Weitere historische Ereignisse',
'Upcoming events' => 'Anstehende Ereignisse',
'Birthdays of living kin and anniversaries of intact couples in the next %d days.'
=> 'Geburtstage lebender Verwandter und Hochzeitstage bestehender Paare in den nächsten %d Tagen.',
'Other upcoming events' => 'Weitere anstehende Ereignisse',
'Detailed view distance' => 'Detailansicht-Abstand',
'A person is shown in detail (avatar, icon, timeline) when they sit within this many descent-steps of the recipient\'s direct lineage. Examples relative to the recipient: a sibling is distance 1 (one step down from the recipient\'s parent), a great-aunt is distance 1 (one step down from a great-grandparent), a nephew is distance 2, a first cousin is distance 2. Spouses share their partner\'s distance. Everyone outside this radius appears as a compact text bullet 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.'
=> 'Eine Person erscheint in der Detailansicht (Profilbild, Symbol, Zeitachse), wenn sie innerhalb dieser Anzahl Abstammungsschritte von der direkten Linie des Empfängers entfernt liegt. Beispiele bezogen auf den Empfänger: ein Geschwister hat Abstand 1 (ein Schritt abwärts vom Elternteil), eine Großtante hat Abstand 1 (ein Schritt abwärts vom Urgroßelternteil), ein Neffe hat Abstand 2, eine Cousine ersten Grades hat Abstand 2. Ehepartner teilen den Abstand ihres Partners. Alle außerhalb dieses Radius erscheinen als kompakte Textzeile am Ende des jeweiligen Abschnitts. Auf 0 setzen, um den gesamten Newsletter als Text darzustellen. Empfänger ohne verknüpftes Baumprofil sehen stets die vollständige Detailansicht.',
@@ -183,6 +199,10 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
'Pending' => 'Ausstehend',
'External recipients (%d)' => 'Externe Empfänger (%d)',
'Save to schedule delivery.' => 'Speichern, um die Zustellung zu starten.',
'Upcoming family events' => 'Anstehende Familienereignisse',
'No upcoming family events in the next %d days.'
=> 'Keine anstehenden Familienereignisse in den nächsten %d Tagen.',
'Living kin celebrating in the next %d days.' => 'Lebende Verwandte, die in den nächsten %d Tagen feiern.',
],
'nl' => [
'Email Newsletter' => 'E-mailnieuwsbrief',
@@ -212,30 +232,6 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
return $translations[$language] ?? [];
}
// ─── Menu ────────────────────────────────────────────────────────
public function defaultMenuOrder(): int
{
return 99;
}
public function getMenu(Tree $tree): Menu|null
{
if (!Auth::check()) {
return null;
}
if (!Configuration::isEnabled($tree)) {
return null;
}
return new Menu(
I18N::translate('Newsletter subscription'),
route(AccountEdit::class, ['tree' => $tree->name()]),
'menu-newsletter-subscription',
);
}
// ─── Admin config page ──────────────────────────────────────────
public function getAdminAction(): ResponseInterface
@@ -362,6 +358,203 @@ class Module extends AbstractModule implements ModuleCustomInterface, ModuleConf
return redirect($this->getConfigLink());
}
// ─── Tree-home block ────────────────────────────────────────────
/**
* Render an "Upcoming family events" block for the tree home page.
* Reuses the same visualisation as the newsletter email (cards,
* circular avatars, timeline rail, event icons) but adapted for
* web context: avatars resolve to media-file URLs instead of CID
* attachments, and relationship labels are computed against the
* viewer's tree-linked Individual when available.
*
* Default look-ahead window is 30 days; admins can override per
* block placement via the standard "configure" UI.
*
* @param array<string,string> $config
*/
public function getBlock(Tree $tree, int $block_id, string $context, array $config = []): string
{
$window = (int) ($config['window_days'] ?? self::BLOCK_DEFAULT_WINDOW_DAYS);
$window = max(1, min(365, $window));
// Cache the rendered block — relationship labels and avatar
// URL lookups are per-viewer, so the cache key includes the
// signed-in user id (0 for guests). 5-minute TTL is short
// enough that admin edits propagate within one refresh.
$viewer_id = Auth::user() instanceof User ? Auth::user()->id() : 0;
$cache_key = sprintf(
'email_newsletter_block_%d_%d_%d_%s',
$tree->id(),
$window,
$viewer_id,
I18N::languageTag(),
);
$content = Registry::cache()->file()->remember(
$cache_key,
fn (): string => $this->renderBlockContent($tree, $window),
300,
);
if ($context !== self::CONTEXT_EMBED) {
return view('modules/block-template', [
'block' => Str::kebab($this->name()),
'id' => $block_id,
'config_url' => '',
'title' => I18N::translate('Upcoming family events'),
'content' => $content,
]);
}
return $content;
}
/**
* Gather upcoming-event data and render the inner block HTML. Kept
* separate from getBlock() so the result can be wrapped in a
* file-cache without re-querying the database on every page load.
*/
private function renderBlockContent(Tree $tree, int $window): string
{
$birthdays = $this->event_query_service->upcomingBirthdays($tree, $window);
$anniversaries = Configuration::includeAnniversaries($tree)
? $this->event_query_service->upcomingAnniversaries($tree, $window)
: null;
$historical = $this->event_query_service->upcomingHistoricalEvents($tree, $window);
// Featured individuals — every Individual referenced by any
// fact in the block. Used to scope relationship labels and
// avatar URLs.
$featured = [];
foreach ([$birthdays, $anniversaries, $historical] as $facts) {
if ($facts === null) {
continue;
}
foreach ($facts as $fact) {
$record = $fact->record();
if ($record instanceof Individual) {
$featured[$record->xref()] = $record;
} elseif ($record instanceof \Fisharebest\Webtrees\Family) {
foreach ([$record->husband(), $record->wife()] as $spouse) {
if ($spouse instanceof Individual) {
$featured[$spouse->xref()] = $spouse;
}
}
}
}
}
$relationships = $this->viewerRelationships($tree, $featured);
$avatar_srcs = $this->collectBlockAvatarSrcs($featured);
return view($this->name() . '::block', [
'tree' => $tree,
'birthdays' => $birthdays,
'anniversaries' => $anniversaries,
'historical' => $historical,
'include_anniversaries' => Configuration::includeAnniversaries($tree),
'window_days' => $window,
'avatar_srcs' => $avatar_srcs,
'relationships' => $relationships,
]);
}
public function loadAjax(): bool
{
// Defer the block to an async fetch so the rest of the tree
// home page paints before our (cached) HTML arrives. Same
// pattern webtrees uses for heavy stats blocks.
return true;
}
public function isUserBlock(): bool
{
return false;
}
public function isTreeBlock(): bool
{
return true;
}
/**
* Map xref => avatar src URL. Only entries for individuals with a
* resolvable highlighted media file are present — the view treats
* absence as "render an initials disc".
*
* @param array<string,Individual> $featured
*
* @return array<string,string>
*/
private function collectBlockAvatarSrcs(array $featured): array
{
$srcs = [];
foreach ($featured as $xref => $individual) {
try {
$media_file = $individual->findHighlightedMediaFile();
} catch (\Throwable $ex) {
continue;
}
if ($media_file === null || !$media_file->isImage()) {
continue;
}
// 192 px source so the 56-px-rendered avatar stays crisp
// on retina displays — matches the email-side resize.
try {
$srcs[$xref] = $media_file->imageUrl(192, 192, 'crop');
} catch (\Throwable $ex) {
// imageUrl can throw on broken file paths; just skip.
}
}
return $srcs;
}
/**
* Build xref => "your mother" labels for the current viewer if
* they're signed in and linked to an Individual on this tree.
*
* @param array<string,Individual> $featured
*
* @return array<string,string>
*/
private function viewerRelationships(Tree $tree, array $featured): array
{
$viewer = Auth::user();
if (!$viewer instanceof User) {
return [];
}
$self_xref = $tree->getUserPreference($viewer, UserInterface::PREF_TREE_ACCOUNT_XREF);
if ($self_xref === '') {
return [];
}
$self = Registry::individualFactory()->make($self_xref, $tree);
if (!$self instanceof Individual) {
return [];
}
$map = [];
foreach ($featured as $xref => $individual) {
$label = $this->relationship_finder->label($self, $individual);
if ($label !== null && $label !== '') {
$map[$xref] = $label;
}
}
return $map;
}
// ─── Cron endpoint (token-gated, anonymous) ─────────────────────
/**