mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 01:54:10 +00:00
feat(calendar): the week view starts today, and is called 7 days
The week ran Monday to Sunday, so by Friday the docked pane spent four of its
columns on days that were over and pushed today towards its right edge, where
seven columns do not fit and the rest scrolls out of sight. It is now the
seven days from the anchor: today on arrival and after Today, the day you
moved to otherwise, and Previous and Next still step seven days. That is no
longer a calendar week, so it is labelled "7 days" ("7 Tage"); the URL segment
and the stored preference stay `week`, so bookmarks and remembered views keep
working.
Month keeps its Monday, as a grid of weeks. The public shared page keeps a
real Monday-to-Sunday week and the label "Week": it builds its own range, and
the toolbar now takes per-view label keys so the two can differ.
The grid fixtures' daily series starts today rather than on Monday, so its
five occurrences stay in view, and the README screenshot lays its events out
from today for the same reason.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
13385fd976
commit
66b9701bab
13 files changed
+86
-66
No files matched your search
@@ -1,4 +1,4 @@
|
||||
<!-- translated-from: features/calendar.md sha1:c163a4ba26701e250eb36e30ec68297d279b715d -->
|
||||
<!-- translated-from: features/calendar.md sha1:a28933caed23a0f78b04e26e2affeb017f027765 -->
|
||||
|
||||
# Kalender
|
||||
|
||||
@@ -41,7 +41,7 @@ bekommt, und was jeder bekommt, der direkt dorthin navigiert oder einem Link fol
|
||||
|
||||
## Die vier Ansichten
|
||||
|
||||
Tag, Woche, Monat und Agenda. Eine Ansicht ist Teil der URL — `/calendar/week/2026-08-05` — und
|
||||
Tag, 7 Tage, Monat und Agenda. Eine Ansicht ist Teil der URL — `/calendar/week/2026-08-05` — und
|
||||
nicht etwas, das sich der Browser merkt. Damit ist jede Ansicht jedes Datums als Lesezeichen
|
||||
brauchbar, die Zurück-Schaltfläche des Browsers tut das Naheliegende, und der Zeitraum dahinter
|
||||
bleibt eine einzige indizierte Abfrage.
|
||||
@@ -49,20 +49,21 @@ bleibt eine einzige indizierte Abfrage.
|
||||
| Ansicht | Was sie abdeckt |
|
||||
|---|---|
|
||||
| **Tag** | Den einen Tag. |
|
||||
| **Woche** | Montag bis Sonntag. |
|
||||
| **7 Tage** | Die sieben Tage ab heute — oder ab dem Tag, zu dem du gesprungen bist. Heute steht vorn, denn die Tage dieser Woche, die schon vorbei sind, sind das Unnützeste, wofür man Spalten ausgeben kann. In der Adresse steht weiterhin `week`. |
|
||||
| **Monat** | Sechs Wochen, beginnend am Montag der Woche, in die der Erste fällt — die Tage, die aus den Nachbarmonaten hereinragen, werden also gezeigt, wie ein Monatsraster das immer tut. |
|
||||
| **Agenda** | Eine fortlaufende Liste der nächsten 30 Tage, die die leere Zeit zwischen den Einträgen überspringt. |
|
||||
|
||||
Die Werkzeugleiste über dem Raster trägt **Zurück**, **Heute** und **Weiter**, das Datum oder den
|
||||
Zeitraum, den du gerade ansiehst, den Ansichtsumschalter und eine Schaltfläche **Neuer Termin**.
|
||||
Zurück und Weiter springen in der Monatsansicht um einen Monat, in der Wochenansicht um sieben Tage
|
||||
Zurück und Weiter springen in der Monatsansicht um einen Monat, in der 7-Tage-Ansicht um sieben Tage
|
||||
und überall sonst um einen Tag — die Agenda eingeschlossen, denn sie ist eine fortlaufende Liste und
|
||||
keine Seite.
|
||||
|
||||
Die volle Seite öffnet mit **Woche**. Die angedockte Fläche öffnet mit **Agenda** — sie ist 380
|
||||
Pixel einer geteilten Zeile, und ein Monatsraster in dieser Breite besteht überwiegend aus leeren
|
||||
Zellen. Die Fläche bietet trotzdem alle vier Ansichten an, als Symbole statt als Wörter, denn die
|
||||
Fassung, die den Umschalter ganz weggelassen hatte, ließ die Fläche nichts als ihre Agenda zeigen.
|
||||
Die volle Seite öffnet mit der Ansicht **7 Tage**. Die angedockte Fläche öffnet mit **Agenda** — sie
|
||||
ist 380 Pixel einer geteilten Zeile, und ein Monatsraster in dieser Breite besteht überwiegend aus
|
||||
leeren Zellen. Die Fläche bietet trotzdem alle vier Ansichten an, als Symbole statt als Wörter, denn
|
||||
die Fassung, die den Umschalter ganz weggelassen hatte, ließ die Fläche nichts als ihre Agenda
|
||||
zeigen.
|
||||
|
||||
In der Monatsansicht zeichnet eine Zelle so viele Einträge ihres Tages, wie hineinpassen, und blendet
|
||||
den letzten dort aus, wo der Platz endet — statt sie zu zählen. Früher stand dort **N weitere**, und
|
||||
@@ -89,9 +90,9 @@ anzufangen, wo die Zeit gerade aufgehört hat.
|
||||
|
||||
### Das Zeitraster
|
||||
|
||||
Tag und Woche werden als Zeitraster gezeichnet: die Stunden links untereinander, und jeder Termin
|
||||
dort, wo er tatsächlich beginnt, und so lang, wie er tatsächlich dauert. Termine, die sich
|
||||
überschneiden, werden in Spuren gelegt, sodass drei gleichzeitige Dinge drei schmalere Blöcke
|
||||
Die Ansichten Tag und 7 Tage werden als Zeitraster gezeichnet: die Stunden links untereinander, und
|
||||
jeder Termin dort, wo er tatsächlich beginnt, und so lang, wie er tatsächlich dauert. Termine, die
|
||||
sich überschneiden, werden in Spuren gelegt, sodass drei gleichzeitige Dinge drei schmalere Blöcke
|
||||
nebeneinander sind — und die Zahl der Spuren bleibt über eine ununterbrochene Kette von
|
||||
Überschneidungen hinweg gleich, statt sich von Block zu Block zu ändern.
|
||||
|
||||
|
||||
@@ -37,23 +37,23 @@ anyone gets who navigates there directly or follows a link.
|
||||
|
||||
## The four views
|
||||
|
||||
Day, Week, Month and Agenda. A view is part of the URL — `/calendar/week/2026-08-05` — rather than
|
||||
Day, 7 days, Month and Agenda. A view is part of the URL — `/calendar/week/2026-08-05` — rather than
|
||||
something the browser remembers, which means every view of every date is bookmarkable, the browser's
|
||||
back button does the obvious thing, and the range behind it stays one indexed query.
|
||||
|
||||
| View | What it covers |
|
||||
|---|---|
|
||||
| **Day** | The one day. |
|
||||
| **Week** | Monday to Sunday. |
|
||||
| **7 days** | The seven days from today — or from the day you moved to. Today comes first, because the days of this week that are already over are the least useful thing to spend columns on. The address still says `week`. |
|
||||
| **Month** | Six weeks, starting on the Monday of the week the 1st falls in — so the days spilling in from the neighbouring months are shown, as a month grid always does. |
|
||||
| **Agenda** | A rolling list of the next 30 days, skipping the empty time between entries. |
|
||||
|
||||
The toolbar above the grid holds **Previous**, **Today** and **Next**, the date or range you are
|
||||
looking at, the view switcher, and a **New event** button. Previous and Next step by a month in
|
||||
month view, by seven days in week view, and by a day everywhere else — agenda included, because it
|
||||
month view, by seven days in the 7-day view, and by a day everywhere else — agenda included, because it
|
||||
is a rolling list rather than a page.
|
||||
|
||||
The full page opens on **Week**. The docked pane opens on **Agenda** — it is 380 pixels of a shared
|
||||
The full page opens on **7 days**. The docked pane opens on **Agenda** — it is 380 pixels of a shared
|
||||
row, and a month grid in that width is a lot of empty cells. The pane still offers all four views,
|
||||
as icons rather than words, because the version that dropped the switcher entirely left the pane
|
||||
able to show nothing but its agenda.
|
||||
@@ -81,7 +81,7 @@ end.
|
||||
|
||||
### The time grid
|
||||
|
||||
Day and Week are drawn as a time grid: hours down the left, and every event drawn where it actually
|
||||
Day and 7 days are drawn as a time grid: hours down the left, and every event drawn where it actually
|
||||
starts and as long as it actually lasts. Overlapping events are placed in lanes, so three things
|
||||
happening at once are three narrower blocks side by side, and the lane count holds steady across an
|
||||
unbroken run of overlaps rather than changing block by block.
|
||||
|
||||
@@ -150,20 +150,17 @@ final class SeedGridEventsCommand extends Command
|
||||
|
||||
$zone = $this->zoneOf($calendar);
|
||||
$timed = new DateTimeImmutable('today ' . self::TIMED_AT, $zone);
|
||||
// Monday of the current week, not today.
|
||||
// Today, because the week view is the seven days from today.
|
||||
//
|
||||
// A daily series starting today has occurrences only on the days the
|
||||
// week has LEFT — five of them on a Monday and exactly one on a Sunday
|
||||
// — so every spec that needs two visible blocks passed six days out of
|
||||
// seven and failed on the seventh. That is the worst shape a test can
|
||||
// have: it looks like whatever was committed that day broke the
|
||||
// calendar, which is how an afternoon gets spent.
|
||||
//
|
||||
// Anchored to the start of the displayed week instead, the same five
|
||||
// occurrences are in view whichever day the suite runs. `first()` is
|
||||
// Anchored to the first day the grid shows, the five occurrences are
|
||||
// all in view whichever day the suite runs. That is the whole
|
||||
// requirement, and the anchor has to follow the view: while the week
|
||||
// ran Monday to Sunday this had to be Monday, and a series started
|
||||
// today had only the days the week had LEFT — one on a Sunday — so the
|
||||
// specs that need two blocks failed one day in seven. `first()` is
|
||||
// still the first block in the grid, which is what the drag specs
|
||||
// actually mean by it.
|
||||
$daily = new DateTimeImmutable('monday this week ' . self::DAILY_AT, $zone);
|
||||
$daily = new DateTimeImmutable('today ' . self::DAILY_AT, $zone);
|
||||
|
||||
$this->write($user, $calendar, self::TIMED, $timed, $timed->modify('+1 hour'), $zone);
|
||||
|
||||
|
||||
@@ -80,6 +80,15 @@ enum CalendarView: string
|
||||
* How far either side of the anchor date this view reaches, before the
|
||||
* timezone padding the reader adds. Month is asked for six weeks because a
|
||||
* month grid always shows the days spilling in from its neighbours.
|
||||
*
|
||||
* The week is the seven days FROM the anchor, not the Monday-to-Sunday
|
||||
* around it. The anchor is today unless somebody has navigated, and the
|
||||
* days of this week that have already happened are the least useful thing
|
||||
* a week view can spend its columns on — in the docked pane, where seven
|
||||
* columns do not fit, they pushed today off to the right. It is labelled
|
||||
* "7 days" for that reason. Month keeps its Monday: a grid of weeks is
|
||||
* what a month view is. The public shared page builds its own range and
|
||||
* keeps a real week (SharedCalendarRangeBuilder).
|
||||
*/
|
||||
public function range(\DateTimeImmutable $anchor): array
|
||||
{
|
||||
@@ -89,8 +98,8 @@ enum CalendarView: string
|
||||
$anchor->modify('+1 day')->setTime(0, 0),
|
||||
],
|
||||
self::Week => [
|
||||
$anchor->modify('monday this week')->setTime(0, 0),
|
||||
$anchor->modify('monday this week')->modify('+7 days')->setTime(0, 0),
|
||||
$anchor->setTime(0, 0),
|
||||
$anchor->modify('+7 days')->setTime(0, 0),
|
||||
],
|
||||
self::Month => [
|
||||
$anchor->modify('first day of this month')->modify('monday this week')->setTime(0, 0),
|
||||
|
||||
@@ -147,8 +147,8 @@ final readonly class CalendarRangeReader
|
||||
$end = $isFloating ? $cluster->primary->endsAt : $cluster->primary->endsAt->setTimezone($zone);
|
||||
|
||||
// A multi-day event belongs to every day it touches, not only the
|
||||
// one it started on — otherwise it vanishes from the week whose
|
||||
// Monday it began before.
|
||||
// one it started on — otherwise it vanishes from any range that
|
||||
// starts after the day it began.
|
||||
for ($day = $start->setTime(0, 0); $day < $end; $day = $day->modify('+1 day')) {
|
||||
$key = $day->format('Y-m-d');
|
||||
|
||||
|
||||
@@ -53,6 +53,11 @@ Variables:
|
||||
passes true — it is a bounded page of the published
|
||||
window rather than the authenticated agenda's rolling
|
||||
list from a date, so its two ends are the honest label
|
||||
labelKeys map optional, view value => translation key, for a caller
|
||||
whose view is not the one `calendar.view.*` names.
|
||||
The shared page passes week => monday_week: its week
|
||||
is still Monday to Sunday, while the authenticated
|
||||
one is the seven days from its anchor ("7 days")
|
||||
|
||||
Blocks:
|
||||
toolbar_actions the right-hand end, after the switcher
|
||||
@@ -60,6 +65,7 @@ Blocks:
|
||||
|
||||
{% set compact = compact|default(false) %}
|
||||
{% set frame = frame|default(null) %}
|
||||
{% set labelKeys = labelKeys|default({}) %}
|
||||
{% set spanLabel = spanLabel|default(view.value == 'week') %}
|
||||
|
||||
{% set viewIcons = {
|
||||
@@ -185,7 +191,7 @@ Blocks:
|
||||
<div class="flex items-center rounded-lg border border-line overflow-hidden" data-calendar-views>
|
||||
{% for candidate in enum('App\\Domain\\Enum\\Calendar\\CalendarView').cases() %}
|
||||
{% if viewUrls[candidate.value] is defined %}
|
||||
{% set label = ('calendar.view.' ~ candidate.value)|trans %}
|
||||
{% set label = (labelKeys[candidate.value] ?? ('calendar.view.' ~ candidate.value))|trans %}
|
||||
<a href="{{ viewUrls[candidate.value] }}"
|
||||
{% if frame %}data-turbo-frame="{{ frame }}"{% endif %}
|
||||
aria-current="{{ candidate == view ? 'page' : 'false' }}"
|
||||
|
||||
@@ -159,7 +159,11 @@ Variables:
|
||||
{# `spanLabel` for the agenda as well as the week: an agenda page here is
|
||||
a bounded slice of the published window rather than the authenticated
|
||||
agenda's endless rolling list, so its two ends are what "where you
|
||||
are" means. #}
|
||||
are" means.
|
||||
|
||||
labelKeys: the week here is still Monday to Sunday — see
|
||||
SharedCalendarRangeBuilder — so it is not the "7 days" the owner's
|
||||
own calendar calls its view. #}
|
||||
{% embed 'calendar/_toolbar_shell.html.twig' with {
|
||||
view: range.view,
|
||||
anchor: range.anchor,
|
||||
@@ -176,6 +180,7 @@ Variables:
|
||||
}),
|
||||
viewUrls: viewUrls,
|
||||
spanLabel: range.view.value == 'week' or range.view.value == 'agenda',
|
||||
labelKeys: {week: 'calendar.view.monday_week'},
|
||||
} %}{% endembed %}
|
||||
|
||||
{% if range.view.value == 'month' %}
|
||||
|
||||
@@ -85,7 +85,7 @@ test.describe("a week narrower than its columns", () => {
|
||||
// It panned at all — if it did not, the rest of this passes vacuously.
|
||||
expect(panned.scrollLeft).toBeGreaterThan(0);
|
||||
|
||||
// Monday went left by however far it panned...
|
||||
// The first day went left by however far it panned...
|
||||
expect(panned.before.day - panned.after.day).toBeCloseTo(panned.scrollLeft, 0);
|
||||
|
||||
// ...and the gutter did not move, which is the claim.
|
||||
|
||||
+21
-23
@@ -23,12 +23,10 @@ const TITLE = `E2E event ${Date.now()}`;
|
||||
* The chips this spec's event put on the calendar.
|
||||
*
|
||||
* Counted in the agenda view, which is where every recurring case below reads
|
||||
* them. Neither of the obvious alternatives works: the week is seven days from
|
||||
* Monday, so a daily series created "today" has as little as one occurrence
|
||||
* left in it when the suite runs on a Sunday and "leaves its siblings" has no
|
||||
* siblings; and the month grid renders only the first three events of each day,
|
||||
* so a count taken from it is a count of what fits. Agenda is thirty days from
|
||||
* today and draws all of them.
|
||||
* them. Neither of the obvious alternatives works: the week shows seven days of
|
||||
* a series that may run longer, and the month grid renders only the first three
|
||||
* events of each day, so a count taken from either is a count of what fits.
|
||||
* Agenda is thirty days from today and draws all of them.
|
||||
*/
|
||||
function chipsOf(page: Page) {
|
||||
return page.getByRole("button", { name: new RegExp(TITLE) });
|
||||
@@ -208,9 +206,11 @@ test.describe("calendar", () => {
|
||||
test("switches between day, week and month", async ({ page }) => {
|
||||
await page.goto("/calendar/week");
|
||||
|
||||
for (const view of ["Day", "Month", "Week"]) {
|
||||
await page.getByRole("link", { name: view, exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/calendar/${view.toLowerCase()}`));
|
||||
// The label and the URL segment part ways at the week: it is the seven
|
||||
// days from today, so it is called that, and `week` stays its address.
|
||||
for (const [label, segment] of [["Day", "day"], ["Month", "month"], ["7 days", "week"]]) {
|
||||
await page.getByRole("link", { name: label, exact: true }).click();
|
||||
await expect(page).toHaveURL(new RegExp(`/calendar/${segment}`));
|
||||
}
|
||||
});
|
||||
|
||||
@@ -228,15 +228,13 @@ test.describe("calendar", () => {
|
||||
|
||||
// Asserted in the MONTH view, not the week it was created in.
|
||||
//
|
||||
// A daily event is on every REMAINING day of the window it is created
|
||||
// in — which is six chips on a Monday and exactly one on a Sunday. This
|
||||
// passed for six days out of seven and failed on the seventh, which is
|
||||
// the worst shape a test can have: it looks like whatever was committed
|
||||
// that day broke the calendar.
|
||||
//
|
||||
// A month has remaining days whatever the weekday, so the claim — a
|
||||
// repeating event appears on more than one day — is the same one and no
|
||||
// longer depends on when it is run.
|
||||
// While the week ran Monday to Sunday, a daily event was on every
|
||||
// REMAINING day of it — six chips on a Monday and exactly one on a
|
||||
// Sunday — so this failed one day in seven, which is the worst shape a
|
||||
// test can have: it looks like whatever was committed that day broke
|
||||
// the calendar. A month has remaining days whatever the weekday, and
|
||||
// the claim — a repeating event appears on more than one day — does not
|
||||
// depend on what the week view happens to start on.
|
||||
await page.getByRole("link", { name: "Month", exact: true }).click();
|
||||
await expect(page).toHaveURL(/\/calendar\/month/);
|
||||
|
||||
@@ -382,10 +380,10 @@ test.describe("calendar", () => {
|
||||
* user can click produces two rows that share one. See
|
||||
* App\Command\Test\SeedDuplicateEventCommand.
|
||||
*
|
||||
* Counted in the agenda view, for the reason chipsOf() gives above: the week is
|
||||
* seven days from Monday and the month grid draws only the first three events
|
||||
* of a day, so agenda is the only view whose chip count is a count of what
|
||||
* exists rather than of what fits.
|
||||
* Counted in the agenda view, for the reason chipsOf() gives above: the week
|
||||
* shows only seven days and the month grid draws only the first three events of
|
||||
* a day, so agenda is the only view whose chip count is a count of what exists
|
||||
* rather than of what fits.
|
||||
*/
|
||||
test.describe("a meeting on two calendars", () => {
|
||||
const TITLE = "E2E duplicated meeting";
|
||||
@@ -716,7 +714,7 @@ test.describe("calendar pane", () => {
|
||||
await page.locator('[data-ui--split-target="handle"]').dblclick();
|
||||
await expect(pane).toHaveCSS("width", "380px");
|
||||
|
||||
await paneFrame.getByRole("link", { name: "Week" }).click();
|
||||
await paneFrame.getByRole("link", { name: "7 days" }).click();
|
||||
|
||||
// The transition is 180ms, so poll rather than reading once.
|
||||
await expect
|
||||
|
||||
@@ -287,24 +287,25 @@ test.describe("README screenshots", () => {
|
||||
*/
|
||||
test("calendar", async ({ page }) => {
|
||||
// Spread across the week, because every event landing on one day says
|
||||
// nothing about a week view. Dates are relative to Monday of the
|
||||
// current week, so the picture is the same shape whenever it is taken.
|
||||
// nothing about a week view. Dates are relative to today, the first of
|
||||
// the seven days it shows, so the picture is the same shape whenever it
|
||||
// is taken.
|
||||
// Anchored in the APP's zone, not the runner's, and the arithmetic done
|
||||
// at UTC noon. Two separate traps, both of which move every event in
|
||||
// this picture by a day:
|
||||
//
|
||||
// · `toISOString()` answers in UTC while the calendar renders in
|
||||
// APP_TIMEZONE. Run this at 00:20 in Berlin and every date comes
|
||||
// out as yesterday, which walks Monday's events off the week view.
|
||||
// out as yesterday, which walks the first day's events off the
|
||||
// week view.
|
||||
// · midnight + setDate() lands on 23:00 or 01:00 across a DST
|
||||
// boundary, and the date can go with it. Noon has an hour of slack
|
||||
// either side, so it never does.
|
||||
const today = new Intl.DateTimeFormat("en-CA", { timeZone: APP_TIMEZONE }).format(new Date());
|
||||
const monday = new Date(`${today}T12:00:00Z`);
|
||||
monday.setUTCDate(monday.getUTCDate() - ((monday.getUTCDay() + 6) % 7));
|
||||
const first = new Date(`${today}T12:00:00Z`);
|
||||
|
||||
const at = (dayOffset: number, time: string): string => {
|
||||
const day = new Date(monday);
|
||||
const day = new Date(first);
|
||||
day.setUTCDate(day.getUTCDate() + dayOffset);
|
||||
|
||||
return `${day.toISOString().slice(0, 10)}T${time}`;
|
||||
|
||||
@@ -1481,8 +1481,9 @@ calendar:
|
||||
view:
|
||||
agenda: Agenda
|
||||
day: Tag
|
||||
monday_week: Woche
|
||||
month: Monat
|
||||
week: Woche
|
||||
week: '7 Tage'
|
||||
|
||||
clipboard:
|
||||
copied: Kopiert
|
||||
|
||||
@@ -1457,8 +1457,9 @@ calendar:
|
||||
view:
|
||||
agenda: Agenda
|
||||
day: Day
|
||||
monday_week: Week
|
||||
month: Month
|
||||
week: Week
|
||||
week: '7 days'
|
||||
|
||||
clipboard:
|
||||
copied: Copied
|
||||
|
||||
@@ -1372,8 +1372,9 @@ calendar:
|
||||
view:
|
||||
agenda: The list
|
||||
day: Day
|
||||
monday_week: Week
|
||||
month: Month
|
||||
week: Week
|
||||
week: 'Seven suns'
|
||||
|
||||
clipboard:
|
||||
copied: Pocketed
|
||||
|
||||
Reference in new issue
Block a user