feat(insights): the radar — cards in Happening Soon and above the conversation

Dated insights join the panel as cards (kind chip, status pill, Track,
open-the-mail, dismiss); undated GitHub activity gets its own section.
Dismissal is a CSRF'd POST through OwnershipVoter — MailInsight joins the
voter rather than growing the twentieth hand-rolled ownership check — and
the reading pane shows a compact strip for the conversation's own insights,
deliberately without a dismiss: hiding a radar card is one thing, hiding a
fact stated in the mail you are reading is another.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Fable 5 committed 2026-08-17 19:34:37 +02:00
1 parent 506a3aad5e
commit 38b9a40ab9
11 files changed
+962 -81

No files matched your search

@@ -0,0 +1,60 @@
import { Controller } from "@hotwired/stimulus";
/**
* The radar's dismiss button — POSTs the wave-away, then takes the card off
* the screen.
*
* The token rides in as a Stimulus VALUE from the template rather than through
* csrf.js, because it is the per-action `insight-dismiss` token the endpoint
* checks, not the shared `ajax` one — the same split csrf.js itself documents
* for settings/account_order_controller. One controller scope wraps the whole
* radar, so the token is minted once and every card's button reaches it; the
* URL differs per card and travels as an action param on the button.
*
* The card is removed only AFTER the server says ok. An optimistic removal
* would eat the card on an expired session and hand it back on the next open,
* which reads as the dismissal "not sticking" — the one behaviour the
* dismissedAt column exists to rule out (see MailInsight).
*/
export default class extends Controller {
static values = { token: String };
async dismiss(event) {
const button = event.currentTarget;
const card = button.closest("[data-insight-card]");
// No double-fire from a slow network's second click. The endpoint is
// idempotent anyway; this is about not looking broken, not safety.
button.disabled = true;
let response;
try {
response = await fetch(event.params.url, {
method: "POST",
headers: {
"Content-Type": "application/json",
"X-CSRF-Token": this.tokenValue,
},
});
} catch {
return;
} finally {
button.disabled = false;
}
if (!response.ok || !card) {
return;
}
const list = card.closest("[data-insight-list]");
card.remove();
// A subheading over nothing reads as a section that failed to load,
// so the last card takes its section with it.
if (list && !list.querySelector("[data-insight-card]")) {
(list.closest("[data-insight-section]") ?? list).remove();
}
}
}
@@ -5,7 +5,10 @@ declare(strict_types=1);
namespace App\Controller\Calendar;
use App\Entity\User\User;
use App\Repository\Insight\MailInsightRepository;
use App\Service\Calendar\HappeningSoonReader;
use DateTimeImmutable;
use DateTimeZone;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\Routing\Attribute\Route;
@@ -53,6 +56,7 @@ final class HappeningSoonController extends AbstractController
{
public function __construct(
private readonly HappeningSoonReader $reader,
private readonly MailInsightRepository $insights,
) {
}
@@ -68,8 +72,23 @@ final class HappeningSoonController extends AbstractController
#[Route('/soon', name: 'soon', methods: ['GET'])]
public function panel(): Response
{
$user = $this->currentUser();
$now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
// The radar reads straight off MailInsightRepository rather than
// growing a reader of its own or moving into HappeningSoonReader. That
// reader earns its layer by deciding things before a template may look
// — windows counted in occurrences, clustering, provenance resolved in
// one query — and its class doc is entirely about calendar rows. An
// insight arrives already shaped: windowed, ordered, capped and
// one-row-per-parcel by construction in the repository, so a service
// between here and there would be a pass-through with no decision in
// it, and folding it into the reader would make that class about two
// families it keeps apart on purpose (see InsightKind's doc).
return $this->render('calendar/_happening_soon.html.twig', [
'rows' => $this->reader->read($this->currentUser()),
'rows' => $this->reader->read($user),
'insights' => $this->insights->upcomingForUser($user, $now),
'undatedInsights' => $this->insights->recentUndatedForUser($user, $now),
]);
}
@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace App\Controller\Insight;
use App\Controller\ChecksCsrf;
use App\Entity\Insight\MailInsight;
use App\Security\Voter\OwnershipVoter;
use DateTimeImmutable;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
/**
* The user's verbs on an insight. There is exactly one: waving it away.
*
* Dismissal is a write to a column rather than a delete, and that is the
* entity's decision, not this controller's — MailInsight::$dismissedAt records
* at length why the row must survive its own dismissal (the dedupe key keeps
* absorbing the carrier's follow-up mails silently; deleting the row would
* resurrect the card on the next status update). This endpoint only stamps the
* time.
*
* A JSON POST rather than a form: the button lives on a card inside the
* Happening Soon panel, which is a turbo-frame fragment — a form submission
* would navigate the frame, and the whole point of dismissing is that the card
* leaves and everything else stays. The Stimulus side is
* assets/controllers/insight/radar_controller.js.
*/
#[Route('/insights', name: 'app_insight_')]
#[IsGranted('IS_AUTHENTICATED')]
final class InsightActionsController extends AbstractController
{
use ChecksCsrf;
/**
* `{id}` constrained to digits for the reason MailController::thread()
* states: a non-numeric id must 404 at the router rather than reach the
* entity resolver as a bigint that is not one.
*/
#[Route('/{id}/dismiss', name: 'dismiss', requirements: ['id' => '\d+'], methods: ['POST'])]
public function dismiss(MailInsight $insight, Request $request, EntityManagerInterface $em): JsonResponse
{
$this->assertCsrf($request, 'insight-dismiss');
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $insight);
// Idempotent: the second click of a slow double-click keeps the first
// click's timestamp rather than moving it.
if (null === $insight->dismissedAt) {
$insight->dismissedAt = new DateTimeImmutable();
$em->flush();
}
return $this->json(['ok' => true]);
}
}
+14 -1
View File
@@ -10,6 +10,7 @@ use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use App\Repository\Insight\MailInsightRepository;
use App\Repository\Label\LabelRepository;
use App\Repository\Mail\AccountRepository;
use App\Repository\Mail\MailboxRepository;
@@ -37,6 +38,7 @@ final class MailController extends AbstractController
private readonly LabelRepository $labelRepository,
private readonly AccountRepository $accountRepository,
private readonly ThreadListRenderer $listRenderer,
private readonly MailInsightRepository $insightRepository,
)
{
}
@@ -682,10 +684,15 @@ final class MailController extends AbstractController
$this->denyAccessUnlessGranted(OwnershipVoter::OWN, $account);
// The conversation's insight cards, drawn above the body — one indexed
// read by thread, empty for almost every thread and cheap when it is.
$insights = $this->insightRepository->forThread($thread);
if ($request->headers->get('X-Requested-With') === 'fetch') {
// JS fetch — return just the message content fragment
return $this->render('mail/_message_content.html.twig', [
'message' => $message,
'message' => $message,
'insights' => $insights,
]);
}
@@ -695,6 +702,7 @@ final class MailController extends AbstractController
'thread' => $thread,
'account' => $account,
'selectedMessage' => $message,
'insights' => $insights,
]);
}
@@ -721,11 +729,15 @@ final class MailController extends AbstractController
$messages = $this->messageRepository->forThreadInConversationOrder($thread);
$latest = [] === $messages ? null : $messages[array_key_last($messages)];
// Same read as message() above, for the same card in the same place.
$insights = $this->insightRepository->forThread($thread);
if ($request->headers->get('X-Requested-With') === 'fetch') {
return $this->render('mail/_thread_content.html.twig', [
'thread' => $thread,
'messages' => $messages,
'latest' => $latest,
'insights' => $insights,
]);
}
@@ -734,6 +746,7 @@ final class MailController extends AbstractController
'account' => $account,
'messages' => $messages,
'latest' => $latest,
'insights' => $insights,
]);
}
}
+11 -4
View File
@@ -6,6 +6,7 @@ namespace App\Security\Voter;
use App\Entity\Calendar\Calendar;
use App\Entity\Calendar\CalendarEvent;
use App\Entity\Insight\MailInsight;
use App\Entity\Integration\Integration;
use App\Entity\Label\Label;
use App\Entity\Mail\Account;
@@ -35,10 +36,11 @@ use Symfony\Component\Security\Core\Authorization\Voter\Voter;
*
* ── Why one voter and not one per entity ─────────────────────────────────────
* The idiomatic shape is a voter per entity, and it was rejected here because
* the rule genuinely does not vary: owning is `$owner === $user` for all nine
* types. Nine classes would have been nine copies of that comparison with nine
* different ways to reach `$owner`, which is the duplication this replaces
* wearing a different hat. What varies is only how the owner is REACHED, so
* the rule genuinely does not vary: owning is `$owner === $user` for every
* type below. A class per type would have been that many copies of the same
* comparison with as many different ways to reach `$owner`, which is the
* duplication this replaces wearing a different hat. What varies is only how
* the owner is REACHED, so
* that — and only that — is the match below.
*
* Rules that are not ownership stay with their controller, because they are not
@@ -77,6 +79,7 @@ final class OwnershipVoter extends Voter
|| $subject instanceof CalendarEvent
|| $subject instanceof Integration
|| $subject instanceof Label
|| $subject instanceof MailInsight
|| $subject instanceof MailRule
|| $subject instanceof Message
|| $subject instanceof MessagePart
@@ -131,6 +134,10 @@ final class OwnershipVoter extends Voter
$subject instanceof CalendarEvent => $subject->usr,
$subject instanceof Integration => $subject->usr,
$subject instanceof Label => $subject->usr,
// Through the account, like Message: an insight belongs to whoever
// owns the mailbox it was read out of, and its $account is the one
// link the mapping declares non-nullable.
$subject instanceof MailInsight => $subject->account->usr,
$subject instanceof MailRule => $subject->usr,
$subject instanceof Message => $subject->account->usr,
$subject instanceof MessagePart => $subject->message?->account->usr,
+279 -75
View File
@@ -6,9 +6,13 @@ out of mail and what the owner put in the calendar themselves — loaded into th
app's body-level modal frame.
Accepts:
rows list<HappeningSoonRow> — already windowed, ordered, COLLAPSED and capped
by HappeningSoonReader; this template windows nothing, sorts nothing and
merges nothing
rows list<HappeningSoonRow> — already windowed, ordered, COLLAPSED
and capped by HappeningSoonReader; this template windows
nothing, sorts nothing and merges nothing
insights list<MailInsight> — the radar's dated cards, windowed and
ordered by MailInsightRepository::upcomingForUser()
undatedInsights list<MailInsight> — the undated family (GitHub activity),
from recentUndatedForUser()
One meeting is one row. A meeting that reached plMail twice — extracted from its
invitation onto the account's calendar, mirrored from the provider onto a Remote
@@ -29,109 +33,309 @@ is unfalsifiable. That link is the whole reason extracted events are marked as
extracted, and its absence on a hand-made row is the correct answer — nothing
made that one but the person reading it.
Below the calendar list sits THE RADAR: the mail insights (MailInsight) whose
whole point is that they are NOT calendar events — a parcel has an ETA days
wide, a pull request has no date at all, and forcing either into an occurrence
row would put fake times on real things (see InsightKind's doc). They share
this panel rather than growing one of their own for the reason the controller
records about pages: both answer "what is coming up?" and nobody would open two
dialogs to ask it once. Cards rather than lines, because an insight carries
verbs — track the parcel, open the mail, wave it away — and a line has nowhere
to put them. An empty radar renders NOTHING, not an empty state: the calendar
list above is the panel's answer, and a second "nothing here" under a real list
would read as a fault.
The provenance link carries `data-turbo-frame="_top"`. Without it Turbo reads a
link inside a <turbo-frame> as a navigation OF that frame, so clicking "from
this email" would load the whole mailbox page into the dialog and leave the user
inside a modal with no way back to their mail. The event editor's form documents
the same trap from the other direction.
the same trap from the other direction. The radar's "open the mail" link carries
it for the same reason.
A row whose message is gone renders without the link rather than with a dead
one. An event keeps its kind after the mail behind it is expunged
provider-side, and a link to a message that is not there is worse than no link.
The radar handles a thread gone the same way — MailInsight::$thread is SET NULL
on delete, and the card simply loses its mail link.
The empty state is written as an answer, not as an absence. "Nothing coming up"
plus a line saying what would appear here — a panel that opens on blank space
reads as a panel that failed to load, and this one is empty for most people on
most days, which is the normal case and must not look like a fault.
most days, which is the normal case and must not look like a fault. It shows
only when EVERYTHING is empty: an empty calendar over a live radar is not an
empty panel.
#}
{% set rows = rows|default([]) %}
{% set insights = insights|default([]) %}
{% set undatedInsights = undatedInsights|default([]) %}
<turbo-frame id="modal" class="flex-1 min-h-0 flex flex-col">
{% if rows is empty %}
{% if rows is empty and insights is empty and undatedInsights is empty %}
<div class="flex flex-col items-center justify-center gap-2 px-6 pb-8 pt-4 text-center">
<i class="fa-regular fa-calendar-check text-2xl text-ink-faint" aria-hidden="true"></i>
<p class="text-sm text-ink">{{ 'calendar.soon.empty'|trans }}</p>
<p class="text-xs text-ink-faint max-w-xs">{{ 'calendar.soon.empty_hint'|trans }}</p>
</div>
{% else %}
<ul class="flex-1 min-h-0 overflow-y-auto px-6 pb-5 divide-y divide-line">
{% for row in rows %}
{# Both fall back rather than rendering empty: a booking mail
with no Subject: header is rare and a blank line where the
provenance should be reads as a bug. #}
{% set row_title = row.event.title ?: 'calendar.soon.untitled'|trans %}
{% set row_from = 'calendar.soon.from'|trans({
'%subject%': row.source ? (row.source.subject ?: 'calendar.soon.untitled'|trans) : '',
}) %}
{# One scroller for list and radar together — two nested scroll regions
in a modal is the "why won't this move" bug in waiting. #}
<div class="flex-1 min-h-0 overflow-y-auto px-6 pb-5">
{# Equal slices in the members' order, the same construction and
the same reading as the merged chip's dot — one affordance
learned once. Built only for a merged row: a cluster of one
has a single calendar and a plain coloured dot would add a
mark to every line for no information. #}
{% if row.cluster.isMerged %}
{% set slices = [] %}
{% for color in row.cluster.colors %}
{% set slices = slices|merge([
color
~ ' ' ~ (loop.index0 * 100 / row.cluster.colors|length)|round(3) ~ '%'
~ ' ' ~ (loop.index * 100 / row.cluster.colors|length)|round(3) ~ '%',
]) %}
{% endfor %}
{% set row_on = 'calendar.chip.on_calendars'|trans({
'%calendars%': row.cluster.calendarNames|join(', '),
}) %}
{% endif %}
<li class="flex items-start gap-3 py-3">
{# The icon carries the kind's name as text for anyone who
cannot see it, so the row still says "Flight" rather than
leaving the shape to do all the work. #}
<span class="relative mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center
rounded-lg bg-sunken text-ink-muted">
<i class="{{ row.icon }} text-sm" aria-hidden="true"></i>
{% if rows is not empty %}
<ul class="divide-y divide-line">
{% for row in rows %}
{# Both fall back rather than rendering empty: a booking mail
with no Subject: header is rare and a blank line where the
provenance should be reads as a bug. #}
{% set row_title = row.event.title ?: 'calendar.soon.untitled'|trans %}
{% set row_from = 'calendar.soon.from'|trans({
'%subject%': row.source ? (row.source.subject ?: 'calendar.soon.untitled'|trans) : '',
}) %}
{# Equal slices in the members' order, the same construction and
the same reading as the merged chip's dot — one affordance
learned once. Built only for a merged row: a cluster of one
has a single calendar and a plain coloured dot would add a
mark to every line for no information. #}
{% if row.cluster.isMerged %}
<span class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full
ring-2 ring-surface"
style="background: conic-gradient({{ slices|join(', ') }})"
title="{{ row_on }}"></span>
<span class="sr-only">{{ row_on }}</span>
{% set slices = [] %}
{% for color in row.cluster.colors %}
{% set slices = slices|merge([
color
~ ' ' ~ (loop.index0 * 100 / row.cluster.colors|length)|round(3) ~ '%'
~ ' ' ~ (loop.index * 100 / row.cluster.colors|length)|round(3) ~ '%',
]) %}
{% endfor %}
{% set row_on = 'calendar.chip.on_calendars'|trans({
'%calendars%': row.cluster.calendarNames|join(', '),
}) %}
{% endif %}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink" title="{{ row_title }}">
{{ row_title }}
</p>
<li class="flex items-start gap-3 py-3">
<p class="mt-0.5 text-xs text-ink-muted">
<span class="sr-only">{{ row.transKey|trans }} · </span>
{# An all-day row is floating — a wall date at
midnight with no zone — so `false` keeps it in
its own, and the reader's clock does not move it
onto the day before. #}
{{ row.startsAt|intl_date('EEEdMMM', row.event.isAllDay ? false : null) }}
{%- if not row.event.isAllDay %} · {{ row.startsAt|date(clock.time) }}{% endif %}
</p>
{# The icon carries the kind's name as text for anyone who
cannot see it, so the row still says "Flight" rather than
leaving the shape to do all the work. #}
<span class="relative mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center
rounded-lg bg-sunken text-ink-muted">
<i class="{{ row.icon }} text-sm" aria-hidden="true"></i>
{% if row.source is not null %}
<a href="{{ path('app_mail_message', { id: row.source.id }) }}"
data-turbo-frame="_top"
class="mt-1 inline-flex max-w-full items-center gap-1.5
text-xs text-ink-faint hover:text-accent hover:underline"
title="{{ row_from }}">
<i class="fa-regular fa-envelope text-[10px] shrink-0" aria-hidden="true"></i>
<span class="truncate">{{ row_from }}</span>
</a>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% if row.cluster.isMerged %}
<span class="absolute -right-0.5 -top-0.5 h-2.5 w-2.5 rounded-full
ring-2 ring-surface"
style="background: conic-gradient({{ slices|join(', ') }})"
title="{{ row_on }}"></span>
<span class="sr-only">{{ row_on }}</span>
{% endif %}
</span>
<div class="min-w-0 flex-1">
<p class="truncate text-sm font-medium text-ink" title="{{ row_title }}">
{{ row_title }}
</p>
<p class="mt-0.5 text-xs text-ink-muted">
<span class="sr-only">{{ row.transKey|trans }} · </span>
{# An all-day row is floating — a wall date at
midnight with no zone — so `false` keeps it in
its own, and the reader's clock does not move it
onto the day before. #}
{{ row.startsAt|intl_date('EEEdMMM', row.event.isAllDay ? false : null) }}
{%- if not row.event.isAllDay %} · {{ row.startsAt|date(clock.time) }}{% endif %}
</p>
{% if row.source is not null %}
<a href="{{ path('app_mail_message', { id: row.source.id }) }}"
data-turbo-frame="_top"
class="mt-1 inline-flex max-w-full items-center gap-1.5
text-xs text-ink-faint hover:text-accent hover:underline"
title="{{ row_from }}">
<i class="fa-regular fa-envelope text-[10px] shrink-0" aria-hidden="true"></i>
<span class="truncate">{{ row_from }}</span>
</a>
{% endif %}
</div>
</li>
{% endfor %}
</ul>
{% endif %}
{# ── The radar ─────────────────────────────────────────────────
One Stimulus scope over both sections, so the per-action
dismiss token is minted once rather than once per card. #}
{% if insights is not empty or undatedInsights is not empty %}
<div data-controller="insight--radar"
data-insight--radar-token-value="{{ csrf_token('insight-dismiss') }}"
class="{{ rows is not empty ? 'mt-3 border-t border-line pt-4' : 'pt-1' }}">
{% if insights is not empty %}
<section data-insight-section>
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
{{ 'insights.panel.heading'|trans }}
</h3>
<ul class="mt-2 space-y-2" data-insight-list>
{% for insight in insights %}
{% set trackingUrl = insight.payload.trackingUrl|default(null) %}
{% set status = insight.payload.status|default(null) %}
{# Only the vocabulary the catalogue knows. An
extractor's stray status must not print a raw
translation key onto a card. #}
{% set statusTone = {
'announced': 'border-ink-faint/40 text-ink-muted',
'in_transit': 'border-sky-500/40 text-sky-700 dark:text-sky-300',
'out_for_delivery': 'border-amber-500/40 text-amber-700 dark:text-amber-300',
'delivered': 'border-green-500/40 text-green-700 dark:text-green-300',
}[status] ?? null %}
<li data-insight-card
data-insight-id="{{ insight.id }}"
data-insight-kind="{{ insight.kind.value }}"
class="rounded-pane border border-line bg-surface p-3 shadow-sm">
<div class="flex items-start gap-3">
{% include 'insight/_kind_chip.html.twig' with { kind: insight.kind } only %}
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="truncate text-sm font-semibold text-ink" title="{{ insight.title }}">
{{ insight.title }}
</p>
<button type="button"
data-action="click->insight--radar#dismiss"
data-insight--radar-url-param="{{ path('app_insight_dismiss', { id: insight.id }) }}"
aria-label="{{ 'insights.panel.dismiss'|trans }}"
title="{{ 'insights.panel.dismiss'|trans }}"
class="-mr-1.5 -mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full
text-ink-faint hover:bg-hover hover:text-ink transition-colors">
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i>
</button>
</div>
<p class="mt-0.5 text-xs text-ink-faint">
{{ insight.happensAt|intl_date('EEEdMMM') }}
{#- A parcel's ETA is a day, not an hour — its
midnight is a placeholder, not a time. -#}
{%- if insight.happensAt|date('His') != '000000' %} · {{ insight.happensAt|date(clock.time) }}{% endif %}
{% if statusTone is not null %}
<span data-insight-status="{{ status }}"
class="ml-1 inline-flex items-center rounded-full border px-2 py-px
text-[11px] leading-[1.35] font-medium {{ statusTone }}">
{{ ('insights.status.' ~ status)|trans }}
</span>
{% endif %}
</p>
{% if trackingUrl is not empty or insight.thread is not null %}
<div class="mt-2 flex flex-wrap items-center gap-1.5">
{% if trackingUrl is not empty %}
<a href="{{ trackingUrl }}"
target="_blank" rel="noopener noreferrer"
data-insight-track
class="inline-flex items-center gap-1.5 rounded-full bg-accent px-2.5 py-1
text-xs font-medium text-accent-ink hover:bg-accent-strong transition-colors">
<i class="fa-solid fa-location-arrow text-[10px]" aria-hidden="true"></i>
{{ 'insights.panel.track'|trans }}
</a>
{% endif %}
{% if insight.thread is not null %}
<a href="{{ path('app_mail_thread', { id: insight.thread.id }) }}"
data-turbo-frame="_top"
data-insight-open-mail
class="inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-1
text-xs font-medium text-ink-muted hover:bg-hover hover:text-ink transition-colors">
<i class="fa-regular fa-envelope text-[10px]" aria-hidden="true"></i>
{{ 'insights.panel.open_mail'|trans }}
</a>
{% endif %}
</div>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
{% if undatedInsights is not empty %}
<section data-insight-section class="{{ insights is not empty ? 'mt-4' : '' }}">
<h3 class="text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
{{ 'insights.panel.undated_heading'|trans }}
</h3>
<ul class="mt-2 space-y-2" data-insight-list>
{% for insight in undatedInsights %}
{% set repo = insight.payload.repo|default(null) %}
{% set number = insight.payload.number|default(null) %}
{% set action = insight.payload.action|default(null) %}
{% set url = insight.payload.url|default(null) %}
<li data-insight-card
data-insight-id="{{ insight.id }}"
data-insight-kind="{{ insight.kind.value }}"
class="rounded-pane border border-line bg-surface p-3 shadow-sm">
<div class="flex items-start gap-3">
{% include 'insight/_kind_chip.html.twig' with { kind: insight.kind } only %}
<div class="min-w-0 flex-1">
<div class="flex items-start justify-between gap-2">
<p class="truncate text-sm font-semibold text-ink" title="{{ insight.title }}">
{{ insight.title }}
</p>
<button type="button"
data-action="click->insight--radar#dismiss"
data-insight--radar-url-param="{{ path('app_insight_dismiss', { id: insight.id }) }}"
aria-label="{{ 'insights.panel.dismiss'|trans }}"
title="{{ 'insights.panel.dismiss'|trans }}"
class="-mr-1.5 -mt-1 flex h-6 w-6 shrink-0 items-center justify-center rounded-full
text-ink-faint hover:bg-hover hover:text-ink transition-colors">
<i class="fa-solid fa-xmark text-xs" aria-hidden="true"></i>
</button>
</div>
{% if repo is not empty or number is not null or action is not empty %}
<p class="mt-0.5 flex flex-wrap items-center gap-x-1.5 gap-y-1 text-xs text-ink-faint">
{% if repo is not empty %}<span class="truncate">{{ repo }}</span>{% endif %}
{% if number is not null %}<span class="shrink-0">#{{ number }}</span>{% endif %}
{% if action is not empty %}
<span data-insight-action="{{ action }}"
class="inline-flex items-center rounded-full border border-ink-faint/40 px-2 py-px
text-[11px] leading-[1.35] font-medium text-ink-muted">
{{ action }}
</span>
{% endif %}
</p>
{% endif %}
{% if url is not empty %}
<div class="mt-2">
{# The brand's own name rather than a
catalogue key — "GitHub" is not a word
any locale translates. #}
<a href="{{ url }}"
target="_blank" rel="noopener noreferrer"
data-insight-open-url
class="inline-flex items-center gap-1.5 rounded-full border border-line px-2.5 py-1
text-xs font-medium text-ink-muted hover:bg-hover hover:text-ink transition-colors">
<i class="fa-brands fa-github text-[11px]" aria-hidden="true"></i>
GitHub
<i class="fa-solid fa-arrow-up-right-from-square text-[9px]" aria-hidden="true"></i>
</a>
</div>
{% endif %}
</div>
</div>
</li>
{% endfor %}
</ul>
</section>
{% endif %}
</div>
{% endif %}
</div>
{% endif %}
</turbo-frame>
+33
View File
@@ -0,0 +1,33 @@
{#
insight/_kind_chip.html.twig
─────────────────────────────────────────────────────────────────────────────
The kind's icon on its coloured tile, shared by the radar panel and the
reading-pane strip so a parcel is the same amber square everywhere it appears.
Accepts:
kind InsightKind
size optional Tailwind sizing classes, default 'h-9 w-9'
One colour per kind, filled at 600 weight with white iconography, and
deliberately NOT the theme tokens: a 600-weight fill reads identically on both
themes, where a tokened tint would drift with whichever surface it lands on.
Both GitHub kinds share slate on purpose — issue and pull request are told
apart by their icon, and two greys nobody can name apart would only pretend to
say more. Full class names, spelled out per kind, because Tailwind's scanner
reads this file as text and a composed 'bg-' ~ colour would compile to nothing.
The kind's name rides along for anyone who cannot see the tile, so a card
still says "Parcel" rather than leaving the colour to do all the work.
#}
{% set tone = {
'parcel': 'bg-amber-600',
'flight': 'bg-sky-600',
'ticket': 'bg-violet-600',
'github-issue': 'bg-slate-600',
'github-pr': 'bg-slate-600',
}[kind.value] ?? 'bg-slate-600' %}
<span class="flex {{ size|default('h-9 w-9') }} shrink-0 items-center justify-center rounded-lg {{ tone }} text-white shadow-sm">
<i class="{{ kind.icon }} text-sm" aria-hidden="true"></i>
<span class="sr-only">{{ kind.transKey|trans }}</span>
</span>
+109
View File
@@ -0,0 +1,109 @@
{#
mail/_insight_strip.html.twig
─────────────────────────────────────────────────────────────────────────────
The insights a conversation carries, as a compact strip above the message
body: icon tile, title, when, and the one action worth a button — tracking the
parcel, opening the thing on GitHub. The mail below states these facts in
prose; the strip states them in a glance, which is the whole trade of an
insight (see MailInsight).
Included from both reading panes — _message_content and _thread_content — with
`only`, so it names its whole contract:
Accepts:
insights list<MailInsight> MailInsightRepository::forThread(), and possibly
containing DISMISSED rows on purpose: dismissal hides a card from
the radar panel, not from the mail that plainly states the fact —
the repository documents that choice.
Renders nothing when the thread carries nothing, which is almost every thread:
the include must stay free for the common case, and an empty "insights" box
over an ordinary mail would claim the extractor looked and found this mail
wanting.
No dismiss button here, deliberately. Dismissal means "stop showing this in
the panel", and this is not the panel — a card that vanished from the mail
that produced it would make the extraction unfalsifiable, the same reasoning
the Happening Soon rows give for their provenance link.
#}
{% set insights = insights|default([]) %}
{% if insights is not empty %}
<div class="mb-4" data-insight-strip>
<p class="mb-1.5 text-[11px] font-semibold uppercase tracking-wider text-ink-faint">
{{ 'insights.card.heading'|trans }}
</p>
<div class="flex flex-wrap gap-2">
{% for insight in insights %}
{% set trackingUrl = insight.payload.trackingUrl|default(null) %}
{% set externalUrl = insight.payload.url|default(null) %}
{% set status = insight.payload.status|default(null) %}
{% set statusTone = {
'announced': 'border-ink-faint/40 text-ink-muted',
'in_transit': 'border-sky-500/40 text-sky-700 dark:text-sky-300',
'out_for_delivery': 'border-amber-500/40 text-amber-700 dark:text-amber-300',
'delivered': 'border-green-500/40 text-green-700 dark:text-green-300',
}[status] ?? null %}
<div data-insight-card
data-insight-kind="{{ insight.kind.value }}"
class="flex min-w-0 max-w-full items-center gap-2.5 rounded-pane border border-line
bg-surface py-2 pl-2 pr-3 shadow-sm">
{% include 'insight/_kind_chip.html.twig' with { kind: insight.kind, size: 'h-8 w-8' } only %}
<div class="min-w-0">
<p class="truncate text-sm font-semibold text-ink" title="{{ insight.title }}">
{{ insight.title }}
</p>
<p class="truncate text-xs text-ink-faint">
{% if insight.happensAt is not null %}
{{ insight.happensAt|intl_date('EEEdMMM') }}
{#- A parcel's ETA is a day, not an hour — its midnight
is a placeholder, not a time. -#}
{%- if insight.happensAt|date('His') != '000000' %} · {{ insight.happensAt|date(clock.time) }}{% endif %}
{% elseif insight.payload.repo|default(null) is not empty %}
{{ insight.payload.repo }}{% if insight.payload.number|default(null) is not null %} · #{{ insight.payload.number }}{% endif %}
{% else %}
{{ insight.kind.transKey|trans }}
{% endif %}
{% if statusTone is not null %}
<span data-insight-status="{{ status }}"
class="ml-1 inline-flex items-center rounded-full border px-2 py-px
text-[11px] leading-[1.35] font-medium {{ statusTone }}">
{{ ('insights.status.' ~ status)|trans }}
</span>
{% endif %}
</p>
</div>
{# One primary action per card: the tracking page where there
is one, else the thing on GitHub. Never both — a compact
card with two buttons is a wide card with none pressed. #}
{% if trackingUrl is not empty %}
<a href="{{ trackingUrl }}"
target="_blank" rel="noopener noreferrer"
data-insight-track
class="ml-1 inline-flex shrink-0 items-center gap-1.5 rounded-full bg-accent px-2.5 py-1
text-xs font-medium text-accent-ink hover:bg-accent-strong transition-colors">
<i class="fa-solid fa-location-arrow text-[10px]" aria-hidden="true"></i>
{{ 'insights.panel.track'|trans }}
</a>
{% elseif externalUrl is not empty %}
<a href="{{ externalUrl }}"
target="_blank" rel="noopener noreferrer"
data-insight-open-url
class="ml-1 inline-flex shrink-0 items-center gap-1.5 rounded-full border border-line px-2.5 py-1
text-xs font-medium text-ink-muted hover:bg-hover hover:text-ink transition-colors">
<i class="fa-brands fa-github text-[11px]" aria-hidden="true"></i>
GitHub
<i class="fa-solid fa-arrow-up-right-from-square text-[9px]" aria-hidden="true"></i>
</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
@@ -47,6 +47,9 @@
</p>
</div>
{# What plMail read out of this conversation, before the conversation. #}
{% include 'mail/_insight_strip.html.twig' with { insights: insights|default([]) } only %}
{# Attachments above the body (H-13) — see _thread_message for why. #}
{% if message.hasAttachments %}
<div class="flex flex-wrap items-center gap-2 mb-4">
+3
View File
@@ -68,6 +68,9 @@ self-contained: no {% extends %}, no block dependencies.
{% endif %}
</div>
{# What plMail read out of this conversation, before the conversation. #}
{% include 'mail/_insight_strip.html.twig' with { insights: insights|default([]) } only %}
{# `messages` is the controller's conversation-ordered list; the raw
association is the fallback for any include that has not been given
it (its receivedAt-only order is why the list is passed at all). #}
+370
View File
@@ -0,0 +1,370 @@
<?php
declare(strict_types=1);
namespace App\Tests\Controller\Insight;
use App\Domain\Enum\Insight\InsightKind;
use App\Domain\Enum\Mail\MessageCategory;
use App\Domain\Enum\Mail\ThreadingMethod;
use App\Entity\Insight\MailInsight;
use App\Entity\Mail\Account;
use App\Entity\Mail\Message;
use App\Entity\Mail\MessageThread;
use App\Entity\User\User;
use DateTimeImmutable;
use DateTimeZone;
use Doctrine\DBAL\Connection;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
/**
* The radar: mail insights on the Happening Soon panel, and the strip above a
* conversation.
*
* Pinned as REQUESTS rather than against the repository, for the reason
* HappeningSoonPanelTest gives: what matters is the markup a user's browser
* receives — a card per insight, a dismiss that writes dismissedAt and only
* dismissedAt, and a strip on the mail itself. Everything is selected by the
* data attributes the templates stamp (`data-insight-card`,
* `data-insight-kind`, `data-insight-strip`), never by translated strings: the
* catalogues belong to another workstream, and a test that greps for "Track"
* would fail in German for reasons no assertion states.
*
* Fixtures follow HappeningSoonPanelTest exactly — a fresh user inside a
* transaction that is never committed — rather than borrowing the seeded
* e2e-admin, whose mailbox this suite would otherwise have to leave exactly as
* it found it.
*/
final class RadarPanelTest extends WebTestCase
{
private EntityManagerInterface $em;
private Connection $connection;
private User $user;
private Account $account;
private DateTimeImmutable $now;
protected function tearDown(): void
{
if (true === isset($this->connection) && true === $this->connection->isTransactionActive()) {
$this->connection->rollBack();
}
parent::tearDown();
}
public function testTheRadarDrawsACardPerInsight(): void
{
$client = $this->signIn();
$this->parcel();
$this->pullRequest();
$crawler = $client->request('GET', '/calendar/soon');
self::assertResponseIsSuccessful();
self::assertCount(2, $crawler->filter('[data-insight-card]'));
// The dated card: kind, status chip and the tracking link with the
// attributes that make it safe to open.
$parcel = $crawler->filter('[data-insight-card][data-insight-kind="parcel"]');
self::assertCount(1, $parcel);
self::assertCount(1, $parcel->filter('[data-insight-status="in_transit"]'));
$track = $parcel->filter('a[data-insight-track]');
self::assertCount(1, $track);
self::assertSame('https://tracking.example.test/PKG-1', $track->attr('href'));
self::assertSame('_blank', $track->attr('target'));
self::assertStringContainsString('noopener', (string) $track->attr('rel'));
// The undated card under its own section: repo, number, action and the
// external link.
$github = $crawler->filter('[data-insight-card][data-insight-kind="github-pr"]');
self::assertCount(1, $github);
self::assertCount(1, $github->filter('[data-insight-action="review_requested"]'));
self::assertStringContainsString('plmail/pl_mail', $github->text());
self::assertStringContainsString('#42', $github->text());
self::assertSame(
'https://github.example.test/plmail/pl_mail/pull/42',
$github->filter('a[data-insight-open-url]')->attr('href'),
);
// Dated and undated live under separate subheadings.
self::assertCount(2, $crawler->filter('[data-insight-section]'));
}
/** An empty radar adds nothing — no section, no heading, no empty state of its own. */
public function testAnEmptyRadarRendersNothingExtra(): void
{
$client = $this->signIn();
$crawler = $client->request('GET', '/calendar/soon');
self::assertResponseIsSuccessful();
self::assertCount(0, $crawler->filter('[data-insight-card]'));
self::assertCount(0, $crawler->filter('[data-insight-section]'));
}
public function testDismissWithATokenSetsDismissedAtAndTheCardIsGone(): void
{
$client = $this->signIn();
$insight = $this->parcel();
$client->request('POST', "/insights/{$insight->id}/dismiss", [
'_token' => $this->token($client),
]);
self::assertResponseIsSuccessful();
self::assertSame(
['ok' => true],
json_decode((string) $client->getResponse()->getContent(), true),
);
self::assertNotNull($this->reread($insight)->dismissedAt, 'the dismissal was not written');
$crawler = $client->request('GET', '/calendar/soon');
self::assertCount(0, $crawler->filter('[data-insight-card]'), 'a dismissed card came back');
}
public function testATokenlessDismissWritesNothing(): void
{
$client = $this->signIn();
$insight = $this->parcel();
$client->request('POST', "/insights/{$insight->id}/dismiss");
self::assertSame(403, $client->getResponse()->getStatusCode());
self::assertNull($this->reread($insight)->dismissedAt, 'a tokenless POST dismissed the card');
}
/** A valid token proves the page, not the ownership — a stranger's insight stays. */
public function testAForeignInsightCannotBeDismissed(): void
{
$client = $this->signIn();
// The signed-in user needs a card of their own so the panel renders
// the radar — the token is minted there.
$this->parcel();
$stranger = $this->seedUser('radar-stranger-');
$mailbox = $this->seedAccount($stranger);
$foreign = $this->insight($mailbox, InsightKind::Parcel, 'Not your parcel', $this->now->modify('+3 days'));
$client->request('POST', "/insights/{$foreign->id}/dismiss", [
'_token' => $this->token($client),
]);
self::assertSame(403, $client->getResponse()->getStatusCode());
self::assertNull($this->reread($foreign)->dismissedAt, "a stranger's POST dismissed the card");
}
public function testTheThreadViewShowsTheInsightStrip(): void
{
$client = $this->signIn();
$thread = $this->thread('Your order has shipped');
$this->parcel($thread);
$crawler = $client->request('GET', "/mail/thread/{$thread->id}");
self::assertResponseIsSuccessful();
self::assertCount(1, $crawler->filter('[data-insight-strip]'));
$card = $crawler->filter('[data-insight-strip] [data-insight-card][data-insight-kind="parcel"]');
self::assertCount(1, $card);
self::assertCount(1, $card->filter('a[data-insight-track]'));
}
/** And a thread nobody extracted anything from carries no strip at all. */
public function testAThreadWithoutInsightsCarriesNoStrip(): void
{
$client = $this->signIn();
$thread = $this->thread('Just a mail');
$crawler = $client->request('GET', "/mail/thread/{$thread->id}");
self::assertResponseIsSuccessful();
self::assertCount(0, $crawler->filter('[data-insight-strip]'));
}
// ── Fixtures ──────────────────────────────────────────────────────────
private function parcel(?MessageThread $thread = null): MailInsight
{
return $this->insight(
$this->account,
InsightKind::Parcel,
'DHL parcel from Zalando',
$this->now->modify('+2 days')->setTime(9, 0),
[
'status' => 'in_transit',
'trackingUrl' => 'https://tracking.example.test/PKG-1',
],
$thread,
);
}
private function pullRequest(): MailInsight
{
return $this->insight(
$this->account,
InsightKind::GithubPullRequest,
'Make the radar sweep',
null,
[
'repo' => 'plmail/pl_mail',
'number' => 42,
'action' => 'review_requested',
'url' => 'https://github.example.test/plmail/pl_mail/pull/42',
],
);
}
/** @param array<string, mixed> $payload */
private function insight(
Account $account,
InsightKind $kind,
string $title,
?DateTimeImmutable $happensAt,
array $payload = [],
?MessageThread $thread = null,
): MailInsight {
$insight = new MailInsight();
$insight->account = $account;
$insight->kind = $kind;
$insight->title = $title;
$insight->payload = $payload;
$insight->happensAt = $happensAt;
$insight->extractor = 'radar-test';
$insight->dedupeKey = uniqid('radar-', true);
$insight->thread = $thread;
$this->em->persist($insight);
$this->em->flush();
return $insight;
}
/** The shape SeedsMarkerFixtures::thread() seeds, minus the label the radar does not need. */
private function thread(string $subject): MessageThread
{
$thread = new MessageThread();
$thread->account = $this->account;
$thread->subject = $subject;
$thread->normalizedSubject = mb_strtolower($subject);
$thread->threadingMethod = ThreadingMethod::SubjectFallback;
$thread->lastMessageAt = $this->now;
$thread->category = MessageCategory::Primary;
$thread->messageCount = 1;
$thread->unreadCount = 0;
$this->em->persist($thread);
$message = new Message();
$message->account = $this->account;
$message->thread = $thread;
$message->messageId = uniqid('radar-', true) . '@example.test';
$message->subject = $subject;
$message->fromAddress = 'shop@example.test';
$message->receivedAt = $this->now->modify('-1 hour');
$message->sentAt = $message->receivedAt;
$message->seenAt = $message->receivedAt;
$message->flags = [];
$message->hasAttachments = false;
$thread->addMessage($message);
$this->em->persist($message);
$this->em->flush();
return $thread;
}
/**
* The per-action token, read off the rendered radar — which is also the
* only place the real button gets it from (AppearancePaneStateTest reads
* its token the same way).
*/
private function token(KernelBrowser $client): string
{
$crawler = $client->request('GET', '/calendar/soon');
return (string) $crawler
->filter('[data-controller="insight--radar"]')
->attr('data-insight--radar-token-value');
}
/** The row as the database now has it, not as the last request left it. */
private function reread(MailInsight $insight): MailInsight
{
$id = $insight->id;
$this->em->clear();
$found = $this->em->find(MailInsight::class, $id);
self::assertInstanceOf(MailInsight::class, $found);
return $found;
}
private function signIn(): KernelBrowser
{
$client = static::createClient();
$client->disableReboot();
$container = static::getContainer();
$this->em = $container->get(EntityManagerInterface::class);
$this->connection = $container->get(Connection::class);
$this->now = new DateTimeImmutable('now', new DateTimeZone('UTC'));
// Never committed, so the suite leaves nothing behind and can be run
// repeatedly against the same database.
$this->connection->beginTransaction();
$this->user = $this->seedUser('radar-panel-');
$this->account = $this->seedAccount($this->user);
$client->loginUser($this->user);
return $client;
}
private function seedUser(string $prefix): User
{
$user = new User();
$user->email = $prefix . uniqid('', true) . '@example.test';
$user->nameFirst = 'Radar';
$user->nameLast = 'Panel';
$user->roles = ['ROLE_USER'];
$user->password = '$2y$04$abcdefghijklmnopqrstuvwxyz0123456789ABCDEFGHIJKLMNOP';
$this->em->persist($user);
$this->em->flush();
return $user;
}
private function seedAccount(User $user): Account
{
$account = new Account();
$account->usr = $user;
$account->name = 'Radar fixture';
$account->email = uniqid('radar-', true) . '@example.test';
$account->username = uniqid('radar-', true);
$account->imapHost = 'localhost';
$account->imapPort = 993;
$account->imapEncryption = 'ssl';
$account->smtpHost = 'localhost';
$account->smtpPort = 587;
$account->smtpEncryption = 'starttls';
$account->password = 'x';
$account->authType = 'password';
$account->isActive = true;
$this->em->persist($account);
$this->em->flush();
return $account;
}
}