mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
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>
266 lines
17 KiB
Twig
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>
|