Bound the split, reach every view from the pane, and polish the grids

Six things, all from looking at it rather than at the code:

  * idx_ceo_span is declared on the entity now, as a plain index, and still
    created USING gist by the migration. That is the trick Message already
    plays with idx_message_search_vector, which is really GIN: Doctrine
    matches an index on name and columns and never looks at the method. The
    schema diff is empty again, so the earlier "never run schema:update"
    warning is gone from todo.md with it.
  * The pane had no way to reach the month. The view switcher was simply
    dropped in compact mode, which left the docked calendar stuck on its
    agenda forever; it is there now as icons, which is what fits at 380px.
  * The drag had one bound and needed two. It clamped the pane against half
    the ROW, counting the sidebar as space the panes had, so on a narrower
    screen the mail list could be squeezed to a column of truncated
    subjects. It now measures what the two panes actually share and keeps
    420px on the mail side. Where both minimums cannot be met the mail wins:
    it is what the app is for, and the pane can be closed.
  * That also moves the dock to lg. Below 1024px there is no arrangement
    where a sidebar, a usable list and a usable calendar all fit, so it does
    not dock at all and the sidebar link opens the page instead.
  * The handle sat off-centre. The row's own gap-gutter falls to the left of
    the wrapper, so a one-gutter handle put its grip 1.5 gutters from the
    mail and 0.5 from the calendar. It spans the whole gap now.
  * Grids fill their height, today is a filled circle, weekends and
    spill-in days are tinted, and chips are one line — colour, time, title.
    Chips were centred because a <button> centres its text and the earlier
    fix from <a> to <button> did not account for that.

Persistence coalesces rather than chains. Chaining ordered the writes but
made the newest one — the one the user actually made — wait behind three
stale ones, and it was the first thing lost on navigation.

Per-day add buttons name their day, so a week is not eight buttons all
called "New event" to a screen reader.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Paul LütznerandClaude Opus 5 committed 2026-08-01 06:46:41 +02:00
1 parent 07570fa7c8
commit 90625192cc
16 files changed
+481 -168

No files matched your search

Generated
+3
View File
@@ -207,6 +207,9 @@
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-symfony" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan-doctrine" />
<path value="$PROJECT_DIR$/vendor/phpstan/phpstan" />
<path value="$PROJECT_DIR$/vendor/sabre/vobject" />
<path value="$PROJECT_DIR$/vendor/sabre/uri" />
<path value="$PROJECT_DIR$/vendor/sabre/xml" />
</include_path>
</component>
<component name="PhpProjectSharedConfiguration" php_language_level="8.4">
+3
View File
@@ -195,6 +195,9 @@
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpstan-doctrine" />
<excludeFolder url="file://$MODULE_DIR$/vendor/phpstan/phpstan-symfony" />
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/rate-limiter" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sabre/uri" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sabre/vobject" />
<excludeFolder url="file://$MODULE_DIR$/vendor/sabre/xml" />
</content>
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
+89 -26
View File
@@ -17,15 +17,26 @@ import { Controller } from "@hotwired/stimulus";
* width is already correct locally — the round trip is for the next page load
* and the user's other devices, not for this one.
*/
/* Below this the row cannot hold a sidebar, a usable mail pane and a usable
* calendar at once, so the pane does not dock at all and the trigger navigates
* to the full page instead. Matches the `lg:` the wrapper is shown at — the two
* have to agree or the toggle points at something that cannot appear. */
const DOCK_BREAKPOINT = "(min-width: 1024px)";
export default class extends Controller {
static targets = ["wrapper", "pane", "handle"];
static targets = ["wrapper", "pane", "handle", "main"];
static values = {
stateUrl: String,
token: String,
min: { type: Number, default: 320 },
max: { type: Number, default: 900 },
// What the mail side is never dragged below. The pane's own minimum is
// not enough on its own: without this the handle happily shrinks the
// message list to a column of truncated subjects.
mainMin: { type: Number, default: 420 },
open: { type: Boolean, default: false },
step: { type: Number, default: 24 },
default: { type: Number, default: 380 },
};
connect() {
@@ -43,6 +54,12 @@ export default class extends Controller {
* navigation is this method's job.
*/
toggle(event) {
// Narrower than the dock breakpoint there is nowhere to put it, so let
// the trigger do what its href says and open the calendar as a page.
if (!window.matchMedia(DOCK_BREAKPOINT).matches) {
return;
}
event.preventDefault();
this.openValue = !this.openValue;
@@ -52,7 +69,7 @@ export default class extends Controller {
}
this.wrapperTarget.classList.toggle("hidden", !this.openValue);
this.wrapperTarget.classList.toggle("md:flex", this.openValue);
this.wrapperTarget.classList.toggle("lg:flex", this.openValue);
// The frame is lazy and has no src until the pane is first opened, so
// opening it is what loads the calendar. Turbo takes it from there.
@@ -103,7 +120,7 @@ export default class extends Controller {
reset(event) {
event.preventDefault();
this._apply(380, { persist: true });
this._apply(this.defaultValue, { persist: true });
}
_onMove(event) {
@@ -130,21 +147,45 @@ export default class extends Controller {
}
/*
* The upper bound is the smaller of the configured maximum and half the
* row: a pane that can be dragged over the mail it sits beside is a pane
* that can hide it entirely, with no obvious way back.
* Bounded from both sides, against what the two panes actually share.
*
* The pane is a fixed width and the mail side is flex-1, so their combined
* width is whatever the row has left after the sidebar and stays constant
* as the boundary moves — which makes it the right thing to measure. The
* earlier version bounded the pane at half the ROW, which counted the
* sidebar as space the panes had and let the mail side be squeezed to a
* column of truncated subjects on a narrower screen.
*
* Where the two minimums cannot both be met, the mail side wins: it is the
* thing the app is for, and the pane can be closed.
*/
_apply(width, { persist = false } = {}) {
const ceiling = Math.min(this.maxValue, this.element.getBoundingClientRect().width / 2);
const combined = this._combinedWidth();
const ceiling = Math.min(this.maxValue, combined - this.mainMinValue);
const clamped = Math.max(this.minValue, Math.min(ceiling, width));
this.element.style.setProperty("--calendar-pane-width", `${Math.round(clamped)}px`);
// Only true on a viewport too narrow to hold both, where clamping up to
// the pane's minimum would eat into the mail side's.
const bounded = ceiling < this.minValue ? Math.max(0, ceiling) : clamped;
this.element.style.setProperty("--calendar-pane-width", `${Math.round(bounded)}px`);
if (persist) {
this._persist({ width: String(Math.round(clamped)) });
this._persist({ width: String(Math.round(bounded)) });
}
}
_combinedWidth() {
const pane = this.hasPaneTarget ? this.paneTarget.getBoundingClientRect().width : 0;
const main = this.hasMainTarget ? this.mainTarget.getBoundingClientRect().width : 0;
// No main pane to measure (the calendar page itself) — fall back to the
// row, which is the same thing minus a sidebar.
return main > 0
? main + pane
: this.element.getBoundingClientRect().width;
}
_currentWidth() {
return this.hasPaneTarget
? this.paneTarget.getBoundingClientRect().width
@@ -152,36 +193,58 @@ export default class extends Controller {
}
/*
* Queued, not fired-and-forgotten.
* Coalesced, not fired-and-forgotten and not queued one behind another.
*
* Several of these can be triggered in quick succession — a double-click to
* reset emits pointerup twice before the reset itself, and a drag right
* after it emits another. Sent concurrently they race, and the server keeps
* whichever finished last rather than whichever the user did last, so the
* pane comes back at a width nobody chose. Chaining makes the order the
* user's order.
* Several of these fire in quick succession — a double-click to reset emits
* pointerup twice before the reset itself, and a drag right after emits
* another. Sent concurrently they race, and the server keeps whichever
* finished last rather than whichever the user did last, so the pane comes
* back at a width nobody chose.
*
* Chaining fixes the order but makes it worse in the case that matters: the
* newest write, the one the user actually made, waits behind three stale
* ones and is the first thing lost if they navigate. So intermediate states
* are dropped instead — only the latest is ever in flight or pending, which
* is both correct and at most two requests.
*
* Still best-effort about failure: a write that does not land costs the
* memory, never the layout, which is already correct locally.
* memory, never the layout, which is already right locally.
*/
_persist(fields) {
if (!this.hasStateUrlValue) {
return;
}
this._pending = { ...(this._pending ?? {}), ...fields };
if (!this._inFlight) {
this._flush();
}
}
_flush() {
const fields = this._pending;
this._pending = null;
if (!fields) {
this._inFlight = null;
return;
}
const body = new FormData();
body.append("_token", this.tokenValue);
Object.entries(fields).forEach(([key, value]) => body.append(key, value));
this._queue = (this._queue ?? Promise.resolve())
.then(() =>
fetch(this.stateUrlValue, {
method: "POST",
body,
headers: { "X-Requested-With": "fetch" },
}),
)
.catch(() => {});
this._inFlight = fetch(this.stateUrlValue, {
method: "POST",
body,
headers: { "X-Requested-With": "fetch" },
})
.catch(() => {})
.finally(() => {
this._inFlight = null;
this._flush();
});
}
}
@@ -32,11 +32,14 @@ use Doctrine\ORM\Mapping as ORM;
* an `&&` overlap against a GiST index on it — see the repository for why that
* beats comparing starts_at and ends_at separately.
*
* That index (idx_ceo_span) exists only in the migration: this DBAL has no flag
* for `USING gist`, so the mapping cannot express it and every schema diff asks
* to drop it. Nothing in this project runs doctrine:schema:update — the
* entrypoint migrates — and it must stay that way, because dropping it turns
* every calendar view into a sequential scan without failing anything.
* idx_ceo_span is declared below as a plain index and created `USING gist` by
* the migration. That is deliberate and is the same trick Message plays with
* idx_message_search_vector, which is really GIN: Doctrine's comparator matches
* an index on its name and columns and never looks at the method, so declaring
* it keeps the mapping and the database agreeing while the migration decides
* how it is actually built. Without the declaration every schema diff asks to
* drop it, and dropping it turns every calendar view into a sequential scan
* without failing anything.
*
* Non-recurring events get exactly one row here too. One code path for reads is
* worth more than the rows saved by special-casing them.
@@ -45,6 +48,8 @@ use Doctrine\ORM\Mapping as ORM;
#[ORM\Table(name: 'calendar_event_occurrence')]
#[ORM\Index(name: 'idx_ceo_usr_starts', columns: ['usr_id', 'starts_at'])]
#[ORM\Index(name: 'idx_ceo_calendar_starts', columns: ['calendar_id', 'starts_at'])]
// Built `USING gist` by the migration — see the note above.
#[ORM\Index(name: 'idx_ceo_span', columns: ['calendar_id', 'span'])]
#[ORM\UniqueConstraint(name: 'uniq_ceo_event_recurrence', columns: ['event_id', 'recurrence_id'])]
class CalendarEventOccurrence
{
+13 -4
View File
@@ -77,15 +77,24 @@ Pagination variables expected from the controller (all optional, fallback to 0/1
{% include '_partials/_sidebar.html.twig' %}
</div>
{# Main content #}
<div class="flex flex-1 overflow-hidden relative main-pane">
{# Main content. Targeted by ui--split so the drag can enforce a
minimum on this side too — the pane knows how much room it is
taking only by measuring what is left. #}
<div class="flex flex-1 overflow-hidden relative main-pane"
{% if app.user %}data-ui--split-target="main"{% endif %}>
{# List pane #}
<div
id="message-list"
data-sync-scope="{% block list_scope %}*{% endblock %}"
data-mail--mail-pane-target="list"
class="flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
{# @container, so the rows below switch to their stacked
layout on the LIST's width. They used to switch on the
viewport's, which is a different number entirely once
the calendar pane is docked: a 1440px window with a
wide pane leaves the list narrower than a phone, and
every row stayed on one line and truncated to nothing. #}
class="@container flex flex-col flex-1 overflow-hidden {% block list_hidden %}{% endblock %}"
>
<turbo-frame id="inbox-list-frame" data-turbo-action="advance" class="contents">
@@ -124,7 +133,7 @@ Pagination variables expected from the controller (all optional, fallback to 0/1
{# Calendar — a sibling of .main-pane, so the handle between
them splits the row's width rather than shrinking one into
the other. Below md it is absent and the sidebar link goes
the other. Below lg it is absent and the sidebar link goes
to the full page instead. #}
{% if app.user %}
{% include '_partials/_calendar_pane.html.twig' %}
+18 -5
View File
@@ -23,15 +23,27 @@ than one that does not remember at all.
{# The controller lives on the row in _mailbox.html.twig, not here: it owns the
boundary between two panes, and its scope has to reach the sidebar's toggle
as well as this. Visibility is one conditional class rather than
`md:flex md:hidden` — those two are the same breakpoint and which wins comes
down to CSS source order, which is not something to rely on. #}
`lg:flex lg:hidden` — those two are the same breakpoint and which wins comes
down to CSS source order, which is not something to rely on.
lg, not md. Below 1024px the row cannot hold a sidebar, a usable mail pane
and a usable calendar at once: something has to be squeezed under its
minimum, and the honest answer is not to dock at all. Between md and lg the
sidebar's Calendar entry navigates to the full page instead — ui--split#toggle
checks the same breakpoint and steps out of the way. #}
<div
data-ui--split-target="wrapper"
class="{{ paneOpen ? 'hidden md:flex' : 'hidden' }} shrink-0"
class="{{ paneOpen ? 'hidden lg:flex' : 'hidden' }} shrink-0"
>
{# The handle. role=separator with aria-orientation is what makes it
reachable and meaningful without a mouse; the arrow keys are handled by
the controller. #}
the controller.
Two gutters wide and pulled left by one, so it spans the whole visual
space between the panes rather than sitting to one side of it. The row's
own gap-gutter falls to the LEFT of this wrapper, so a handle that was
merely one gutter wide put its grip 1.5 gutters from the mail pane and
0.5 from the calendar — visibly off-centre, and a thin target. #}
<div
data-ui--split-target="handle"
data-action="pointerdown->ui--split#start keydown->ui--split#nudge dblclick->ui--split#reset"
@@ -39,7 +51,8 @@ than one that does not remember at all.
aria-orientation="vertical"
aria-label="{{ 'calendar.pane.resize'|trans }}"
tabindex="0"
class="group relative w-gutter shrink-0 cursor-col-resize
class="group relative -ml-gutter w-[calc(var(--spacing-gutter)*2)] shrink-0
cursor-col-resize
flex items-center justify-center
focus:outline-none"
>
+1 -1
View File
@@ -8,7 +8,7 @@ Variables:
#}
{% set chipLabels = item.labels|filter(chipLabel => chipLabel.role is null and chipLabel.isVisible) %}
{% if chipLabels|length > 0 %}
<span class="hidden sm:inline-flex items-center gap-1 mr-0.5 shrink-0 align-middle">
<span class="hidden @lg:inline-flex items-center gap-1 mr-0.5 shrink-0 align-middle">
{% for chipLabel in chipLabels|slice(0, 3) %}
<span class="inline-flex items-center gap-1 px-1.5 py-px rounded text-[11px] font-medium max-w-28
bg-sunken text-ink-soft">
+6 -4
View File
@@ -118,15 +118,16 @@ the conversation it answers.
</button>
{# Content — switches between mobile (2-row) and desktop (1-row) #}
<div class="flex-1 min-w-0 flex flex-col md:flex-row md:items-center md:gap-4">
<div class="flex-1 min-w-0 flex flex-col @xl:flex-row @xl:items-center @xl:gap-4">
{# Row 1 (mobile) / sender column (desktop) #}
<div class="flex items-center justify-between md:justify-start md:w-40 md:flex-shrink-0">
<div class="flex items-center justify-between @xl:justify-start @xl:w-40 @xl:flex-shrink-0">
<span class="truncate text-sm {{ unread ? 'font-semibold text-gray-900 dark:text-gray-100' : 'font-normal text-gray-600 dark:text-gray-400' }}">
{{ sender }}
</span>
{# Date — mobile only (top-right), hidden on desktop #}
<span class="md:hidden flex-shrink-0 ml-2 text-xs text-gray-400 dark:text-gray-600">
<span data-row-date="stacked"
class="@xl:hidden flex-shrink-0 ml-2 text-xs text-gray-400 dark:text-gray-600">
{% if date %}
{% if date|date('Y-m-d') == 'now'|date('Y-m-d') %}
{{ date|date('g:i a') }}
@@ -153,7 +154,8 @@ the conversation it answers.
</div>
{# Date + hover actions slot — desktop only #}
<div class="hidden md:flex flex-shrink-0 w-36 relative h-full items-center">
<div data-row-date="inline"
class="hidden @xl:flex flex-shrink-0 w-36 relative h-full items-center">
<span class="absolute inset-0 flex items-center justify-end group-hover:invisible text-xs text-gray-400 dark:text-gray-600">
{% if hasAttachment %}
+29 -15
View File
@@ -1,38 +1,52 @@
{#
One occurrence, as it appears in any view.
A single line: the calendar's colour, the time, then the title taking whatever
is left. Time first because in a dense grid that is the column you scan — the
title is what you read once you have found the row. `g:i a` matches how the
mail list writes times; the grid drops the meridiem to save the width and keeps
it in the tooltip.
`text-left` is not decoration. This is a <button>, and a button centres its text
by default, which is what had every chip in the month grid sitting oddly
mid-cell.
Opens the editor in the body-level modal frame — never inside the calendar
pane, which carries backdrop-filter and would clip a fixed-position dialog to
itself.
itself. The controller has to be on the trigger: a Stimulus action only reaches
controllers mounted on an ancestor, and the modal's lives on #modal-backdrop.
#}
{% set event = occurrence.event %}
{% set showTime = showTime|default(true) and not event.isAllDay %}
<button type="button"
data-controller="ui--modal"
data-action="click->ui--modal#open"
data-ui--modal-src-value="{{ path('app_calendar_event_edit', { id: event.id }) }}"
data-ui--modal-title-value="{{ 'calendar.event.title'|trans }}"
class="group flex items-start gap-1.5 px-1.5 py-1 rounded-md text-xs
title="{{ event.title }}{{ showTime ? ' · ' ~ occurrence.startsAt|date('g:i a') : '' }}"
class="w-full text-left flex items-center gap-1.5
px-1.5 py-0.5 rounded-md
hover:bg-hover transition-colors min-w-0
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent
{{ occurrence.cancelled ? 'opacity-50' : '' }}"
title="{{ event.title }}"
>
<span class="mt-1 w-1.5 h-1.5 rounded-full shrink-0"
<span class="w-1.5 h-1.5 rounded-full shrink-0"
style="background-color: {{ event.calendar.color }}"
aria-hidden="true"></span>
<span class="min-w-0 flex-1">
<span class="block truncate {{ occurrence.cancelled ? 'line-through' : '' }}">
{%- if event.kind is not null -%}
<i class="{{ event.kind.icon }} mr-1 text-[10px] text-ink-faint" aria-hidden="true"></i>
{%- endif -%}
{{ event.title }}
{% if showTime %}
<span class="shrink-0 text-[11px] tabular-nums text-ink-faint">
{{ occurrence.startsAt|date('g:i') }}
</span>
{% if not event.isAllDay %}
<span class="block text-[11px] text-ink-faint">
{{ occurrence.startsAt|date('H:i') }}
</span>
{% endif %}
{% endif %}
<span class="min-w-0 flex-1 truncate text-xs text-ink
{{ occurrence.cancelled ? 'line-through' : '' }}">
{%- if event.kind is not null -%}
<i class="{{ event.kind.icon }} mr-1 text-[10px] text-ink-faint" aria-hidden="true"></i>
{%- endif -%}
{{ event.title }}
</span>
</button>
+60 -28
View File
@@ -1,64 +1,96 @@
{#
The row above the grid: where you are, how to move, and which view.
compact is set by the docked pane, which has room for the date and the arrows
and nothing else. The view switcher is a link per view rather than a select,
because a view is a URL — every one of these is bookmarkable and back works.
Every control is a link, because a view is a URL — each one is bookmarkable,
back works, and the range stays a single indexed query.
compact is set by the docked pane. It gets the same four views, as icons rather
than words: the pane is ~380px and "Day Week Month Agenda" spelled out leaves no
room for the date, but dropping the switcher entirely meant the pane could only
ever show its agenda — there was no way to reach the month from it at all.
#}
{% set compact = compact|default(false) %}
{% set frame = frame|default(null) %}
{% set paneQuery = compact ? '?pane=1' : '' %}
{% set prevDate = view.value == 'month'
? anchor|date_modify('-1 month')|date('Y-m-d')
: (view.value == 'week' ? anchor|date_modify('-7 days')|date('Y-m-d') : anchor|date_modify('-1 day')|date('Y-m-d')) %}
{% set nextDate = view.value == 'month'
? anchor|date_modify('+1 month')|date('Y-m-d')
: (view.value == 'week' ? anchor|date_modify('+7 days')|date('Y-m-d') : anchor|date_modify('+1 day')|date('Y-m-d')) %}
{# Agenda steps a day at a time like day view: it is a rolling list, not a page. #}
{% set step = view.value == 'month' ? '1 month' : (view.value == 'week' ? '7 days' : '1 day') %}
{% set prevDate = anchor|date_modify('-' ~ step)|date('Y-m-d') %}
{% set nextDate = anchor|date_modify('+' ~ step)|date('Y-m-d') %}
{% set viewIcons = {
day: 'fa-calendar-day',
week: 'fa-calendar-week',
month: 'fa-calendar-days',
agenda: 'fa-list-ul',
} %}
<div class="flex items-center gap-2 px-3 py-2 border-b border-line shrink-0">
<div class="flex items-center gap-0.5">
<a href="{{ path('app_calendar_view', { view: view.value, date: prevDate }) }}{{ compact ? '?pane=1' : '' }}"
<div class="flex items-center gap-0.5 shrink-0">
<a href="{{ path('app_calendar_view', { view: view.value, date: prevDate }) }}{{ paneQuery }}"
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
class="w-7 h-7 flex items-center justify-center rounded-lg text-ink-muted hover:bg-hover hover:text-ink transition-colors"
aria-label="{{ 'calendar.nav.previous'|trans }}"
><i class="fa-solid fa-chevron-left text-xs" aria-hidden="true"></i></a>
<a href="{{ path('app_calendar_view', { view: view.value, date: 'now'|date('Y-m-d') }) }}{{ compact ? '?pane=1' : '' }}"
<a href="{{ path('app_calendar_view', { view: view.value, date: 'now'|date('Y-m-d') }) }}{{ paneQuery }}"
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
class="px-2 h-7 flex items-center rounded-lg text-xs text-ink-muted hover:bg-hover hover:text-ink transition-colors"
>{{ 'calendar.today'|trans }}</a>
<a href="{{ path('app_calendar_view', { view: view.value, date: nextDate }) }}{{ compact ? '?pane=1' : '' }}"
<a href="{{ path('app_calendar_view', { view: view.value, date: nextDate }) }}{{ paneQuery }}"
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
class="w-7 h-7 flex items-center justify-center rounded-lg text-ink-muted hover:bg-hover hover:text-ink transition-colors"
aria-label="{{ 'calendar.nav.next'|trans }}"
><i class="fa-solid fa-chevron-right text-xs" aria-hidden="true"></i></a>
</div>
<h2 class="text-sm font-medium text-ink truncate">
{{ anchor|date(view.value == 'month' ? 'F Y' : 'j M Y') }}
{# A week is a range, and saying "1 Aug 2026" over a grid running 27 Jul to
2 Aug is just wrong. The year is dropped from the near end when both ends
share it, and the month too when the week does not cross one. #}
<h2 class="text-sm font-medium text-ink truncate min-w-0">
{%- if view.value == 'month' -%}
{{ anchor|date('F Y') }}
{%- elseif view.value == 'week' -%}
{% set weekStart = range.from %}
{% set weekEnd = range.to|date_modify('-1 day') %}
{{ weekStart|date(weekStart|date('Y') == weekEnd|date('Y')
? (weekStart|date('n') == weekEnd|date('n') ? 'j' : 'j M')
: 'j M Y') }}
– {{ weekEnd|date('j M Y') }}
{%- else -%}
{{ anchor|date('j M Y') }}
{%- endif -%}
</h2>
<div class="ml-auto flex items-center gap-1">
{% if not compact %}
<div class="flex items-center rounded-lg border border-line overflow-hidden">
{% for candidate in enum('App\\Domain\\Enum\\Calendar\\CalendarView').cases() %}
<a href="{{ path('app_calendar_view', { view: candidate.value, date: anchor|date('Y-m-d') }) }}"
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
class="px-2.5 py-1 text-xs transition-colors
{{ candidate == view ? 'bg-accent text-white' : 'text-ink-muted hover:bg-hover hover:text-ink' }}"
>{{ ('calendar.view.' ~ candidate.value)|trans }}</a>
{% endfor %}
</div>
{% endif %}
<div class="ml-auto flex items-center gap-1 shrink-0">
<div class="flex items-center rounded-lg border border-line overflow-hidden">
{% for candidate in enum('App\\Domain\\Enum\\Calendar\\CalendarView').cases() %}
{% set label = ('calendar.view.' ~ candidate.value)|trans %}
<a href="{{ path('app_calendar_view', { view: candidate.value, date: anchor|date('Y-m-d') }) }}{{ paneQuery }}"
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
aria-current="{{ candidate == view ? 'page' : 'false' }}"
{% if compact %}aria-label="{{ label }}" title="{{ label }}"{% endif %}
class="flex items-center justify-center text-xs transition-colors
{{ compact ? 'w-7 h-7' : 'px-2.5 py-1' }}
{{ candidate == view ? 'bg-accent text-white' : 'text-ink-muted hover:bg-hover hover:text-ink' }}"
>
{%- if compact -%}
<i class="fa-solid {{ viewIcons[candidate.value] }}" aria-hidden="true"></i>
{%- else -%}
{{ label }}
{%- endif -%}
</a>
{% endfor %}
</div>
<button type="button"
data-controller="ui--modal"
data-action="click->ui--modal#open"
data-ui--modal-src-value="{{ path('app_calendar_event_new') }}"
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
class="w-7 h-7 flex items-center justify-center rounded-lg
class="w-7 h-7 flex items-center justify-center rounded-lg shrink-0
bg-accent text-white hover:opacity-90 transition-opacity"
aria-label="{{ 'calendar.event.new'|trans }}"
><i class="fa-solid fa-plus text-xs" aria-hidden="true"></i></button>
+23 -9
View File
@@ -4,9 +4,14 @@ Agenda: a flat list, skipping empty days.
This is the pane's default view, and the reason the pane is useful at all — a
narrow column beside your mail should answer "what is coming up", not draw a
grid nobody can read at that width.
Empty days are skipped rather than shown blank: the value here is density, and
a fortnight of "nothing" between two events is noise. The grids are where an
empty day still needs to occupy its place.
#}
{% set dayKeys = days|keys %}
{% set today = 'now'|date('Y-m-d') %}
{% set anyEvents = false %}
<div class="divide-y divide-line">
@@ -14,19 +19,28 @@ grid nobody can read at that width.
{% set dayEvents = days[dayKey] %}
{% if dayEvents|length > 0 %}
{% set anyEvents = true %}
{% set isToday = dayKey == 'now'|date('Y-m-d') %}
{% set isToday = dayKey == today %}
<div class="px-3 py-2">
<div class="flex items-baseline gap-2 mb-1">
<span class="text-sm {{ isToday ? 'font-semibold text-accent' : 'text-ink' }}">
{{ isToday ? 'calendar.today'|trans : dayKey|date('D j M') }}
</span>
<div class="flex gap-3 px-3 py-2.5 {{ isToday ? 'bg-accent/5' : '' }}">
{# A fixed-width date rail, so the titles line up down the list
rather than starting wherever the weekday name happens to
end. #}
<div class="w-11 shrink-0 text-center">
<div class="text-[10px] uppercase tracking-wide {{ isToday ? 'text-accent' : 'text-ink-faint' }}">
{{ dayKey|date('D') }}
</div>
{% if isToday %}
<span class="text-[11px] text-ink-faint">{{ dayKey|date('D j M') }}</span>
<div class="mx-auto mt-0.5 w-6 h-6 flex items-center justify-center rounded-full
bg-accent text-white text-sm font-semibold tabular-nums">
{{ dayKey|date('j') }}
</div>
{% else %}
<div class="mt-0.5 text-sm text-ink tabular-nums">{{ dayKey|date('j') }}</div>
{% endif %}
<div class="text-[10px] text-ink-faint">{{ dayKey|date('M') }}</div>
</div>
<div class="space-y-0.5">
<div class="min-w-0 flex-1 space-y-0.5 self-center">
{% for occurrence in dayEvents %}
{% include 'calendar/_event_chip.html.twig' %}
{% endfor %}
@@ -37,7 +51,7 @@ grid nobody can read at that width.
</div>
{% if not anyEvents %}
<div class="flex flex-col items-center justify-center gap-2 py-10 text-center text-ink-faint">
<div class="flex flex-col items-center justify-center gap-2 py-12 px-4 text-center text-ink-faint">
<i class="fa-regular fa-calendar text-2xl" aria-hidden="true"></i>
<p class="text-sm">{{ 'calendar.empty'|trans }}</p>
<button type="button"
+46 -27
View File
@@ -1,48 +1,67 @@
{#
Day and week: one column per day, events listed inside it.
Day and week: one column per day.
Deliberately not a time-grid with absolutely positioned blocks. That is the
right shape for a wide screen and the wrong one for a 380px pane, and one
readable layout that works in both is worth more right now than two that each
work in one place. The time-grid belongs with drag-to-move, which is its own
piece of work.
readable layout that works in both is worth more than two that each work in one
place. The time-grid belongs with drag-to-move, which is its own piece of work.
The columns fill the height rather than collapsing to their content — a week
that stops a fifth of the way down the pane reads as a rendering fault, not as
an empty week. Each column keeps an add affordance at the bottom, so a day that
already has events is no harder to add to than an empty one.
#}
{% set dayKeys = days|keys %}
{% set today = 'now'|date('Y-m-d') %}
<div class="grid gap-px bg-line
{{ dayKeys|length > 1 ? 'grid-cols-1 md:grid-cols-7' : 'grid-cols-1' }}">
<div class="h-full min-h-0 grid gap-px bg-line
{{ dayKeys|length > 1 ? 'grid-cols-2 sm:grid-cols-4 md:grid-cols-7' : 'grid-cols-1' }}">
{% for dayKey in dayKeys %}
{% set dayEvents = days[dayKey] %}
{% set day = dayKey|date_modify('+0 days') %}
{% set isToday = dayKey == 'now'|date('Y-m-d') %}
{% set isToday = dayKey == today %}
{% set isWeekend = dayKey|date('N') >= 6 %}
<div class="bg-surface min-h-[7rem] flex flex-col">
<div class="flex items-baseline gap-2 px-2 py-1.5 border-b border-line">
<span class="text-[11px] uppercase tracking-wide text-ink-faint">
<div class="flex flex-col min-h-0 min-w-0
{{ isToday ? 'bg-accent/5' : (isWeekend ? 'bg-sunken/40' : 'bg-surface') }}">
{# Sticky so the date stays put once a busy day starts scrolling. #}
<div class="sticky top-0 z-10 flex items-baseline gap-1.5 px-2 py-1.5
border-b border-line backdrop-blur-sm
{{ isToday ? 'bg-accent/5' : (isWeekend ? 'bg-sunken/40' : 'bg-surface') }}">
<span class="text-[10px] uppercase tracking-wide {{ isToday ? 'text-accent' : 'text-ink-faint' }}">
{{ dayKey|date('D') }}
</span>
<span class="text-sm {{ isToday ? 'font-semibold text-accent' : 'text-ink' }}">
{{ dayKey|date('j') }}
</span>
{% if isToday %}
<span class="w-5 h-5 -my-0.5 flex items-center justify-center rounded-full
bg-accent text-white text-xs font-semibold tabular-nums">
{{ dayKey|date('j') }}
</span>
{% else %}
<span class="text-sm text-ink-muted tabular-nums">{{ dayKey|date('j') }}</span>
{% endif %}
</div>
<div class="flex-1 p-1 space-y-0.5">
<div class="flex-1 min-h-0 overflow-y-auto p-1 space-y-0.5">
{% for occurrence in dayEvents %}
{% include 'calendar/_event_chip.html.twig' %}
{% else %}
<button type="button"
data-controller="ui--modal"
data-action="click->ui--modal#open"
data-ui--modal-src-value="{{ path('app_calendar_event_new', { start: dayKey ~ ' 09:00' }) }}"
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
class="block h-full min-h-[3rem] rounded-md text-xs text-transparent
hover:text-ink-faint hover:bg-hover transition-colors
flex items-center justify-center"
>
<i class="fa-solid fa-plus" aria-hidden="true"></i>
</button>
{% endfor %}
{# Fills the rest of the column, so clicking anywhere below the
last event adds one there — and stays reachable on a day that
is already full. #}
<button type="button"
data-controller="ui--modal"
data-action="click->ui--modal#open"
data-ui--modal-src-value="{{ path('app_calendar_event_new', { start: dayKey ~ ' 09:00' }) }}"
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
aria-label="{{ 'calendar.event.new_on'|trans({ '%date%': dayKey|date('D j M') }) }}"
class="group/add w-full min-h-[2.5rem] {{ dayEvents|length > 0 ? '' : 'h-full' }}
flex items-start justify-center pt-1 rounded-md
text-ink-faint opacity-0 hover:opacity-100 focus:opacity-100
hover:bg-hover transition-opacity
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
><i class="fa-solid fa-plus text-[10px]" aria-hidden="true"></i></button>
</div>
</div>
{% endfor %}
+69 -40
View File
@@ -1,55 +1,84 @@
{#
Month: a six-week grid.
Month: a six-week grid that fills its container.
Always six weeks, never five-or-six. The range enum asks for 42 days from the
Monday on or before the first, so the grid does not change height between
months — a calendar that reflows as you page through it is disorienting, and it
means the surrounding layout never has to reserve for the taller case.
`grid-rows-6` with `h-full` is what makes the six weeks share the height evenly
instead of each row shrinking to its busiest cell, which left a ragged block of
empty space under the grid.
#}
{% set dayKeys = days|keys %}
{% set anchorMonth = anchor|date('n') %}
{% set today = 'now'|date('Y-m-d') %}
<div class="grid grid-cols-7 text-[11px] uppercase tracking-wide text-ink-faint border-b border-line">
{% for dayKey in dayKeys|slice(0, 7) %}
<div class="px-2 py-1.5">{{ dayKey|date('D') }}</div>
{% endfor %}
</div>
<div class="grid grid-cols-7 gap-px bg-line">
{% for dayKey in dayKeys %}
{% set dayEvents = days[dayKey] %}
{% set isToday = dayKey == 'now'|date('Y-m-d') %}
{% set isOtherMonth = dayKey|date('n') != anchorMonth %}
<div class="bg-surface min-h-[5.5rem] p-1 flex flex-col gap-0.5
{{ isOtherMonth ? 'opacity-50' : '' }}">
<div class="flex items-center justify-between px-1">
<span class="text-xs {{ isToday ? 'font-semibold text-accent' : 'text-ink-muted' }}">
{{ dayKey|date('j') }}
</span>
<button type="button"
data-controller="ui--modal"
data-action="click->ui--modal#open"
data-ui--modal-src-value="{{ path('app_calendar_event_new', { start: dayKey ~ ' 09:00' }) }}"
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
class="opacity-0 hover:opacity-100 focus:opacity-100 text-[10px] text-ink-faint"
aria-label="{{ 'calendar.event.new'|trans }}"
><i class="fa-solid fa-plus" aria-hidden="true"></i></button>
<div class="h-full min-h-0 flex flex-col">
<div class="grid grid-cols-7 shrink-0 border-b border-line">
{% for dayKey in dayKeys|slice(0, 7) %}
<div class="px-2 py-1.5 text-[10px] uppercase tracking-wide text-ink-faint">
{{ dayKey|date('D') }}
</div>
{% endfor %}
</div>
{# Three, then a count. A month cell that grows to fit everything
makes every other row taller for one busy day. #}
{% for occurrence in dayEvents|slice(0, 3) %}
{% include 'calendar/_event_chip.html.twig' %}
{% endfor %}
<div class="flex-1 min-h-0 grid grid-cols-7 grid-rows-6 gap-px bg-line">
{% for dayKey in dayKeys %}
{% set dayEvents = days[dayKey] %}
{% set isToday = dayKey == today %}
{% set isWeekend = dayKey|date('N') >= 6 %}
{% set isOtherMonth = dayKey|date('n') != anchorMonth %}
{% if dayEvents|length > 3 %}
<a href="{{ path('app_calendar_view', { view: 'day', date: dayKey }) }}"
class="px-1.5 text-[11px] text-ink-faint hover:text-ink">
{{ 'calendar.more_count'|trans({ '%count%': dayEvents|length - 3 }) }}
</a>
{% endif %}
</div>
{% endfor %}
{# The spill-in days are dimmed as a whole cell, not just by their
date number: a neighbouring month's events rendered at full
strength read as part of this month. #}
<div class="group/cell min-h-0 min-w-0 flex flex-col p-1 gap-0.5 overflow-hidden
{{ isToday ? 'bg-accent/5' : (isWeekend ? 'bg-sunken/40' : 'bg-surface') }}
{{ isOtherMonth ? 'opacity-55' : '' }}">
<div class="flex items-center justify-between shrink-0 px-0.5">
{% if isToday %}
<span class="w-5 h-5 flex items-center justify-center rounded-full
bg-accent text-white text-xs font-semibold tabular-nums">
{{ dayKey|date('j') }}
</span>
{% else %}
<span class="text-xs tabular-nums {{ isOtherMonth ? 'text-ink-faint' : 'text-ink-muted' }}">
{{ dayKey|date('j') }}
</span>
{% endif %}
<button type="button"
data-controller="ui--modal"
data-action="click->ui--modal#open"
data-ui--modal-src-value="{{ path('app_calendar_event_new', { start: dayKey ~ ' 09:00' }) }}"
data-ui--modal-title-value="{{ 'calendar.event.new'|trans }}"
aria-label="{{ 'calendar.event.new_on'|trans({ '%date%': dayKey|date('D j M') }) }}"
class="w-5 h-5 flex items-center justify-center rounded-md text-[10px] text-ink-faint
opacity-0 group-hover/cell:opacity-100 focus:opacity-100
hover:bg-hover transition-opacity
focus:outline-none focus-visible:ring-2 focus-visible:ring-accent"
><i class="fa-solid fa-plus" aria-hidden="true"></i></button>
</div>
{# Three, then a count. A month cell that grows to fit everything
makes every other row taller for one busy day. #}
<div class="min-h-0 flex-1 space-y-0.5 overflow-hidden">
{% for occurrence in dayEvents|slice(0, 3) %}
{% include 'calendar/_event_chip.html.twig' %}
{% endfor %}
</div>
{% if dayEvents|length > 3 %}
<a href="{{ path('app_calendar_view', { view: 'day', date: dayKey }) }}{{ compact|default(false) ? '?pane=1' : '' }}"
{% if frame|default(null) %}data-turbo-frame="{{ frame }}"{% endif %}
class="shrink-0 px-1.5 text-[11px] text-ink-faint hover:text-ink hover:underline">
{{ 'calendar.more_count'|trans({ '%count%': dayEvents|length - 3 }) }}
</a>
{% endif %}
</div>
{% endfor %}
</div>
</div>
+109 -4
View File
@@ -44,7 +44,7 @@ test.describe("calendar", () => {
test("creates an event and shows it in the week", async ({ page }) => {
await page.goto("/calendar/week");
await page.getByRole("button", { name: "New event" }).click();
await page.getByRole("button", { name: "New event", exact: true }).click();
const modal = page.locator("#modal-backdrop");
await expect(modal).toBeVisible();
@@ -67,7 +67,7 @@ test.describe("calendar", () => {
test("a repeating event appears on more than one day", async ({ page }) => {
await page.goto("/calendar/week");
await page.getByRole("button", { name: "New event" }).click();
await page.getByRole("button", { name: "New event", exact: true }).click();
const modal = page.locator("#modal-backdrop");
await modal.getByLabel("Title").fill(TITLE);
@@ -147,13 +147,24 @@ test.describe("calendar pane", () => {
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(box.x - 120, box.y + box.height / 2, { steps: 10 });
// The write happens on release and is deliberately not awaited by the
// UI, so wait for it here rather than racing the reload against it.
const persisted = page.waitForResponse(
async (response) =>
response.url().includes("/calendar/pane-state") &&
"POST" === response.request().method() &&
(await response.json()).width > Math.round(before.width),
);
await page.mouse.up();
await persisted;
const dragged = (await pane.boundingBox())!.width;
expect(dragged).toBeGreaterThan(before.width);
// The width is written on release, and rendered inline on the next
// paint — so the reload is the assertion, not the drag.
// The width is rendered inline on the next paint, so the reload is the
// assertion, not the drag.
await page.reload();
await expect(pane).toBeVisible();
expect((await pane.boundingBox())!.width).toBeCloseTo(dragged, 0);
@@ -179,6 +190,100 @@ test.describe("calendar pane", () => {
expect((await backdrop.boundingBox())!.width).toBeGreaterThan(paneWidth * 2);
});
test("reaches the month view from inside the pane", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await ensurePaneClosed(page);
await page.getByRole("link", { name: "Calendar" }).click();
const paneFrame = page.locator("turbo-frame#calendar-pane-frame");
await expect(paneFrame).toBeVisible();
// Icons, not words, at this width — but all four views are reachable,
// and switching one stays inside the frame rather than navigating the
// whole page away from the mail.
await paneFrame.getByRole("link", { name: "Month" }).click();
await expect(paneFrame.getByRole("link", { name: "Month" })).toHaveAttribute(
"aria-current",
"page",
);
await expect(page.locator("#message-list")).toBeVisible();
});
test("will not squeeze the mail pane below its minimum", async ({ page }) => {
await page.setViewportSize({ width: 1280, height: 900 });
await ensurePaneClosed(page);
await page.getByRole("link", { name: "Calendar" }).click();
const mainPane = page.locator('[data-ui--split-target="main"]');
const handle = page.locator('[data-ui--split-target="handle"]');
await expect(handle).toBeVisible();
// Drag far past anywhere sensible: the clamp is what is under test, not
// the arithmetic of a modest drag.
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(20, box.y + box.height / 2, { steps: 12 });
await page.mouse.up();
expect((await mainPane.boundingBox())!.width).toBeGreaterThanOrEqual(419);
});
test("centres its grip in the gap between the panes", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await ensurePaneClosed(page);
await page.getByRole("link", { name: "Calendar" }).click();
const mainPane = page.locator('[data-ui--split-target="main"]');
const pane = page.locator('[data-ui--split-target="pane"]');
const grip = page.locator('[data-ui--split-target="handle"] span');
const mainBox = (await mainPane.boundingBox())!;
const paneBox = (await pane.boundingBox())!;
const gripBox = (await grip.boundingBox())!;
// The row's own gap falls to the left of the wrapper, so a handle only
// one gutter wide sits off to the calendar's side of the gap.
const gapCentre = (mainBox.x + mainBox.width + paneBox.x) / 2;
expect(gripBox.x + gripBox.width / 2).toBeCloseTo(gapCentre, 0);
});
/**
* The rows below the list respond to the LIST's width, not the window's.
* Before the container query they switched on the viewport, so a wide
* window with a wide calendar pane left a list narrower than a phone still
* rendering one-line rows, truncated to nothing.
*/
test("stacks the mail rows once the pane has taken enough width", async ({ page }) => {
await page.setViewportSize({ width: 1440, height: 900 });
await ensurePaneClosed(page);
const inlineDate = page.locator('[data-row-date="inline"]').first();
const stackedDate = page.locator('[data-row-date="stacked"]').first();
// On display rather than visibility: the inline date column is laid out
// as a zero-height positioning context for an absolutely placed span,
// so Playwright calls it hidden either way. Which layout the container
// query picked is the actual subject here.
await expect(inlineDate).toHaveCSS("display", "flex");
await expect(stackedDate).toHaveCSS("display", "none");
await page.getByRole("link", { name: "Calendar" }).click();
await expect(page.locator("turbo-frame#calendar-pane-frame")).toBeVisible();
// Drag the pane out to its limit, which is where the list is tightest.
const handle = page.locator('[data-ui--split-target="handle"]');
const box = (await handle.boundingBox())!;
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2);
await page.mouse.down();
await page.mouse.move(20, box.y + box.height / 2, { steps: 12 });
await page.mouse.up();
await expect(stackedDate).toHaveCSS("display", "block");
await expect(inlineDate).toHaveCSS("display", "none");
});
test("is absent on a phone, where the link opens the page instead", async ({ page }) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/mail/inbox");
+1
View File
@@ -317,6 +317,7 @@ calendar:
Änderungen nicht mehr.
location: Ort
new: Neuer Termin
new_on: Neuer Termin am %date%
repeat: Wiederholung
save: Speichern
starts: Beginn
+1
View File
@@ -314,6 +314,7 @@ calendar:
about the same booking from overwriting your changes.
location: Location
new: New event
new_on: New event on %date%
repeat: Repeat
save: Save
starts: Starts