Files
pl_mail/templates/calendar/_happening_soon.html.twig
8efe6d6621 Widen Happening soon and put Also spotted beside the list (#83)
feat(calendar): widen Happening Soon and put Also spotted beside the list

The panel opened at the narrowest dialog width, so a fortnight of
bookings pushed the undated "Also spotted" cards far down the scroll.
Both openers now ask for the wide dialog, and from the md breakpoint up
the panel is two columns: what is coming up (with "On your radar") on
the left, "Also spotted" on the right. Below md it stacks as before.

The two sections stay direct flex children of the radar scope, so
dismissing the last undated card (which removes its section) lets the
list take the full width instead of leaving an empty column. With
nothing in the main column, "Also spotted" has the panel to itself.

No migration.

docs: changelog entry for the wider Happening soon panel (#83)

fix(calendar): give Also spotted a wider column in Happening soon

At 18rem the cards under Also spotted truncated their titles and pushed
the status chip onto its own line. 24rem fits a typical issue or pull
request title with its chip on one line, checked in a browser with
seeded cards.

Co-authored-by: mani <15911342+Flouss@users.noreply.github.com>
Co-authored-by: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-13 18:30:19 +02:00

266 lines
17 KiB
Twig

{#
calendar/_happening_soon.html.twig
─────────────────────────────────────────────────────────────────────────────
"Happening Soon": everything expected within the fortnight — what plMail read
out of mail and what the owner put in the calendar themselves — loaded into the
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
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
one — is one HappeningSoonRow carrying both, exactly as the grid draws it as one
chip. Where it is more than one row of the database, the icon tile wears the
same conic-gradient dot _event_chip wears and names the calendars in its title,
because a list that silently merges is indistinguishable from a list that lost
something.
Four things per row, and each earns its place. The ICON, off the row rather than
straight off the enum, so a fortnight can be read by shape before it is read by
word — a plane among boxes is found without reading anything, and an event
nobody extracted gets a plain calendar mark rather than a hole in the column.
WHEN, because that is the question. WHAT. And WHICH MESSAGE it came from, where
there is one: an extracted row is a guess a program made about somebody's mail,
and "why is this on my calendar?" has to be answerable in one click or the guess
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 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. 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">
{# The time heads the panel when the clock lives on the button that opened
it (ClockPlacement::Topbar): whoever pressed it was, as often as not,
asking what time it is, and the answer comes with the date the button has
no room for. #}
{% if clock.placement == 'topbar' %}
{% import '_partials/_clock.html.twig' as clockui %}
<div {{ clockui.attrs() }}
data-clock-placement="soon"
class="flex flex-wrap items-baseline gap-x-2 px-6 pb-3">
<span class="flex items-baseline gap-1 tabular-nums">
<span data-ui--clock-target="bare" class="text-2xl font-semibold tracking-tight text-ink">{{ clockui.bare() }}</span>
{% if clock.is12Hour %}
<span data-ui--clock-target="meridiem" class="text-sm text-ink-faint">{{ clockui.meridiem() }}</span>
{% endif %}
</span>
<span class="text-sm text-ink-muted">{{ clockui.longDate() }}</span>
</div>
{% endif %}
{% 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 %}
{# 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">
{# ── Two columns from `md` up ─────────────────────────────────
What is coming up is the main column; "Also spotted" — the
undated activity (GitHub and the like) — sits to its right
rather than under it, so a long fortnight does not push it out of
sight. One Stimulus scope over everything, so the per-action
dismiss token is minted once rather than once per card. A
dismissed last card takes its whole section with it
(radar_controller), so the sections are direct flex children: the
main column then widens into the gap instead of leaving an empty
track behind. Below `md` it is the old single stack, and with
nothing in the main column "Also spotted" has the panel to
itself. #}
{% set hasMain = rows is not empty or insights is not empty %}
<div {% if insights is not empty or undatedInsights is not empty -%}
data-controller="insight--radar"
data-insight--radar-token-value="{{ csrf_token('insight-dismiss') }}"
{%- endif %}
class="md:flex md:items-start md:gap-6">
{% if hasMain %}
<div class="min-w-0 flex-1">
{% 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 %}
{% 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 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">
{# The row opens the event, whether or not a mail
is behind it. Before this the only thing on a
row you could act on was the provenance link,
so a hand-made appointment — which by
definition has no mail — was the one kind you
could not open at all. #}
<button type="button"
data-controller="ui--modal"
data-action="click->ui--modal#open"
data-ui--modal-src-value="{{ path('app_calendar_event_details', {
id: row.event.id,
instance: row.occurrence.recurrenceId|date('Y-m-d\\TH:i:s\\Z', 'UTC'),
}) }}"
{# NOT calendar.event.title — that is the label on the
title FIELD in the editor, and reusing it here headed
the details dialog with the word "Title" above a card
that shows the event's name, its dates and its
calendar. A heading naming one field of a form is
wrong over a summary of the whole event. #}
data-ui--modal-title-value="{{ 'calendar.event.details_heading'|trans }}"
class="block w-full truncate text-left text-sm font-medium text-ink
hover:text-accent hover:underline focus:outline-none
focus-visible:ring-2 focus-visible:ring-accent rounded"
title="{{ row_title }}">
{{ row_title }}
</button>
<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 ────────────────────────────────────── #}
{% if insights is not empty %}
<section data-insight-section
class="{{ rows is not empty ? 'mt-3 border-t border-line pt-4' : 'pt-1' }}">
<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 %}
{% include 'insight/_card.html.twig' with { insight: insight } only %}
{% endfor %}
</ul>
</section>
{% endif %}
</div>
{% endif %}
{% if undatedInsights is not empty %}
<section data-insight-section
class="{{ hasMain ? 'mt-4 md:mt-0 md:w-96 md:shrink-0 md:border-l md:border-line md:pl-6' : 'pt-1' }}">
<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 %}
{% include 'insight/_card.html.twig' with { insight: insight } only %}
{% endfor %}
</ul>
</section>
{% endif %}
</div>
</div>
{% endif %}
</turbo-frame>