mirror of
https://github.com/karatektus/pl_mail.git
synced 2026-10-25 10:04:18 +00:00
main
10
Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
7824821c0a |
feat(calendar): an event of a day or more is one bar, its hours shaded in the grid
A trip from Friday 17:00 to Sunday 21:00 was a block down each of its
three days, and pointing at one lifted that day alone: Saturday's,
titled at a midnight nobody had scrolled to, rose as a grey column with
nothing on it. The blocks also took a lane from every other meeting of
the weekend.
A timed event of 24 hours or more - the line Google and Outlook draw -
now leaves the hours for the all-day band, as one bar across the days
it covers ("17:00 Bodensee ... until Sun 21:00"), and the hours it takes
are shaded under each day's blocks, which stay full width on top of
them. The bar and every day's shading carry one key, and
span_link_controller lights all of them when the pointer is on any. An
overnight meeting under a day stays in the grid, and its two halves
light together too.
- DayGridLayout::band() lays the band out once over the range, in
lanes; DayGrid::$shaded carries the hours. The shared page's builder
lays out the same band, so its grid draws the same bars.
- The band draws bars across columns, so an all-day event over several
days is one bar too, and every bar is tinted like a block in the grid:
bare, a bar's start and end sat days apart with nothing between.
- A bar prints its times on the full clock. The band has no axis, so a
compact "5:00" was two times on a twelve-hour clock.
- A stacked chip aligns its rows to the top. A half-hour meeting in a
96px column shrank its rows below their text, and a centred title
spilled up over the time above it.
calendar-multiday.spec.ts drives one through the dialog and hovers it
from both ends; DayGridLayoutTest pins the placement, the lanes and the
24-hour line from both sides.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
66b9701bab |
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>
|
||
|
|
889130b447 |
fix(ui): inline scripts the production CSP refused now run
The enforced script-src drops an unnonced inline script silently. The
booking form's hours, the booking page's visitor zone and the restart
page's way back become Stimulus controllers (they arrive through Turbo,
where no nonce can follow); the public layout's theme bootstrap gets the
nonce. The public layout and the error page also called importmap('app')
without the nonce and linked no stylesheets since app.js stopped
importing CSS, so they ran unstyled and without JavaScript. Layouts now
carry a csp-nonce meta for Turbo. A static test keeps it that way.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
|
||
|
|
9835756aec |
Make the calendar hold together at every width
Seven columns used to divide whatever width there was, so a phone — or a narrow docked pane — gave each of them about fifty pixels, and a block showed its colour and little else. A column has a floor now: below the width where seven of them fit the grid pans sideways under an hour gutter that stays pinned, and four days are legible where none were. The time-grid shell's own header costed that fix as disproportionate once — "three scroll containers kept in step by JavaScript" — and it is not, because the three grids already share one scroller; all that was left was making each one's first cell sticky. A BLOCK IS TWO ROWS NOW. The time on one line with the calendar's colour as a bar down the left, the title under it, which is a whole title in a 96px column where one line was four characters of each. Day view keeps the single line and is told so rather than measured for it: a day column is the whole grid and is never the width that makes stacking worth doing. The agenda's date rail is gone. A day is a heading with its events under it, and the times sit in a lane that is a grid track rather than a fixed guess — sized against every time in the view, so it is 43px where an "all day" needs it and 33px where nothing does. The month gives up its words entirely below 5.5rem of cell and draws one bar per entry, which is what a phone can honestly say in forty pixels, and "N more" is gone along with a count that a variable cell height could never make true; the date is the link into the day instead. The grid opens on now rather than on a fixed 07:00, and a chip too small for its own title opens to it while the pointer rests on it. Width only: growing the height made the chip inside stop following its parent, and a click aimed at the middle of a Day-view block landed under the button and opened nothing. Two bugs fell out of the work. An all-day entry sorted below the morning east of UTC, because its start is a floating midnight in UTC while a timed one's is an instant — the time grid never showed it, since it puts all-day entries in a band of their own and never asks. And a grid block had never had a background at all: an inline colour was overriding the surface class written beside it, so the only paint it carried was a tenth of a calendar colour over whatever happened to be behind it. Opening on now has one consequence worth naming: a fixture at 10:00 is on screen all morning and scrolled above the viewport by the afternoon, and `boundingBox()` answers for an off-screen element with coordinates that land under the pinned day headings. calendar-timegrid's drag helpers scroll the block into view before measuring it now; without that the suite passed for hours and then did not, and the failure read as "dragging is broken". Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
||
|
|
a351ed2a65 |
fix(i18n): render dates through the reader's locale, not PHP's date()
I-04/I-05: calendar column heads read MON TUE WED over a German interface and
mail was stamped "Aug 3, 13:04", because PHP's date() has no locale — date('D')
is "Mon" for every reader there has ever been.
|intl_date takes an ICU skeleton rather than a pattern: it says which fields to
show and lets the locale arrange them, which is the part that actually differs.
German writes the day before the month, so translating the month name and
keeping "Aug 3" would have been the same bug with better vocabulary.
Times deliberately do not go through it. Whether 13:04 is written "1:04 pm" is a
preference and lives in ClockGlobal; templates showing both now compose the two.
Two tests asserted the old English wording, one by building it with the very
format() call being removed. ICU's en writes "Aug 4, 2026" where the app used to
hand-arrange "4 Aug 2026"; the expectations follow the locale now.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
||
|
|
5ff870c3b7 |
fix(search,routing,a11y): prefix+substring search, page clamps, 404s, contrast
H-04 search found only exact whole words. The tsvector tokenizer emits "help.wirhub.de" as one host lexeme, so "wirhub" is a substring of a lexeme and no full-text query can reach it, while "Testmai" needs a prefix operator websearch_to_tsquery has no way to express. Free text now compiles to three OR-ed passes — websearch as before, a to_tsquery with :* per term, and an ILIKE needle for single terms of 3+ characters — with pg_trgm GIN indexes so the third is index-backed. Deliberate websearch syntax (quotes, OR, negation) suppresses the extra passes rather than widening what was narrowed on purpose. H-06 page numbers past the end rendered "201-193 of 193" over an empty list. The six role views were six copies of the same eight lines, so they are now one. H-07 /mail/thread/abc and ?tab=quatsch were 500s: a non-numeric id reaching the entity resolver, and MessageCategory::from on a URL parameter. Error pages are branded and localised, and deliberately standalone — the app shell asks for a session, which an anonymous 404 does not have. A-01/A-02 <html> had no lang at all, and the viewport forbade zoom. A-07 accent ink was effectively always white (2.34:1 on the reported pink); it is now chosen by WCAG relative luminance on gamma-expanded channels, scoring both candidates instead of guessing at a threshold. Every theme's ink ramp now clears 4.5:1 on its own surface and still runs strongest-to-faintest. I-02 search.title existed in no catalogue. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
a4d3d42445 |
fix(sharing): the switcher carries the day the reader was looking at
Built from range.anchor, the view links carried the normalised anchor -- the 1st for a month, Monday for a week -- so switching month to day opened the 1st, which on a rolling window can be a day the link does not publish. The range now exposes the clamped, un-normalised focus and the switcher links carry that. Caught by PublicCalendarStyleTest on its first real run. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
2325e79673 |
Give the shared calendar the same four views the owner has, and drop the list under the grid
The public page was a month grid with a day-by-day list appended below it, which was itself a correction of a page that had been a list and nothing else. The list was right that a calendar needs its detail somewhere and wrong about where: appended under a grid it is a second reading of the same fortnight nobody asked for twice, and it left this the one calendar in the application with no way to look at a week or a day. So the list is now the AGENDA — one of four views reached from a switcher, drawn by the shells the owner's own calendar draws through. Nothing was thrown away: the property the list was built around lives on inside that view, where every shared day in range is printed including the empty ones, so "free on the 4th" is something the page says rather than a gap the reader has to notice. The owner's own agenda skips its empty days, and _agenda_shell's header says why the two disagree on purpose. The view and the anchor date travel in the path — /share/<token>/week/2026-08-10 — and that is not a stylistic echo of the authenticated calendar. This page must not start a session, so the URL is the only place the choice can live; every view is therefore bookmarkable and back works, with no JS state to lose. `?month=` is still read, because the URLs carrying it are in other people's mail. One builder for all four, not four builders. What a page IS comes from CalendarView::range(), the same method the owner's reader asks, so the two calendars cannot disagree about how many days a week has or which Monday a month starts on. What is left in SharedCalendarRangeBuilder is the three things true of a shared calendar and nothing else: the window, the clamping, and paging bounded by what the next page would actually publish. It still reads nothing — no repository, no link, no event — so every view is a rearrangement of objects ShareLinkReader had already stripped, which is the whole reason three of them could be added at all. The window rule holds on all four. Days the link does not cover are dimmed in the grids, labelled "Not shared" and drawn WITHOUT hour lines in the time-grids — an empty published column with twenty-four ruled hours in it is exactly what a reader reads an availability off — left out of the agenda, and named in a legend everywhere. Paging stops at the window in both directions, as a disabled span so the row does not change shape. Bounding paging by "does the next page publish anything of its own" rather than by overlap is what keeps the month honest: July's 42 cells DO reach into an August-only window, by two spill-in cells July's page lists nowhere. "Today" comes back, conditionally. The page dropped it when it was month-only, on the argument that a control which does nothing whenever the window excludes today lies about what it does. Kept and narrowed rather than reversed: rendered exactly when the window contains today, which a week or a day view can be paged well away from in a way a single month could not. A busy/free link gets all four with no branch anywhere — an entry with nothing revealed renders as the localised "Busy" wherever a title would go, which is what the chip and the row already did. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> |
||
|
|
fd3530b51c |
Make the public calendar pages look like the calendar they came from
A shared link opened a list of days and a booking page opened a wall of times. Both were correct, both were themed against the `:root` fallback — which is not a theme anybody picked, it is what a stylesheet resolves against before a user is known — and neither was recognisable as belonging to the application it came out of. The shared page is now a month grid with the day list under it, and the grid is literally the authenticated calendar's: calendar/_view_month.html.twig was pulled apart into calendar/_month_shell.html.twig — the weekday strip, six equal rows, hairline gutters, today/weekend/spill-in cells — which both pages embed. The month view keeps its own chips, its "new event" affordance and its "N more" link and renders exactly what it did. The booking page is a week: seven columns of slot pills with prev/next, opening on the first week that has anything in it, in a card that names the appointment, its length and its zone. Both pages wear the OWNER's appearance — theme, accent, radius, density — via PublicAppearanceResolver, which hands the template three strings rather than the User so it cannot print a name it does not have. Paper for an account that never chose; a `system` theme resolves against the reader's own machine. No user id is passed to the renderer, so a custom uploaded background is dropped rather than 404ing with the owner's id in the URL. Still no session and still no cookie, which the tests assert. Redaction is unchanged and re-asserted over the new markup. Chips carry no calendar colour, deliberately: ShareLinkReader's rule is that a shared page says nothing about the owner's calendars, and colours would let a busy/free link be grouped by which diary each block came from. Days the window does not cover are dimmed, kept out of the list and named in a legend, because a blank cell that read as a free one would have the page claiming availability nobody published; paging is bounded to the months the window touches for the same reason. Also fixes a translation key that has been on screen since the feature shipped: the window under the heading asked for `calendar.share.window`, a node with two children, and printed the key itself. |
||
|
|
b0a1d07977 |
Share a calendar by link, and let people book a time on it
Two public-facing halves of one idea: showing somebody your calendar without giving them an account, and letting them take an hour of it. **What a link reveals is a per-link decision.** Busy/free is the floor; ticking boxes adds title, location, description or participants, and the ticks are editable afterwards without breaking the URL that has already been sent. The redaction is a DTO rather than a set of conditionals in a template: ShareLinkReader answers SharedOccurrence, and the public templates never see a CalendarEvent at all. A busy/free link therefore cannot leak a title through a tooltip, a data attribute, a JSON payload or an .ics, because the object it is rendering has not got one. SharedIcsBuilder is deliberately not IcsExporter for the same reason — it serialises the redaction, so there is no code path where the real event reaches a stranger. The shared .ics carries a synthetic UID derived from the link digest, so a recipient who also holds the genuine invitation cannot correlate the two. EventPrivacy finally does the job it was named for. Privacy is the ceiling and the checkboxes are the floor: Secret never appears at all, because its existence is the detail, and Private stays a busy block however many boxes are ticked. **Booking.** The owner says which weekdays and hours, how long a slot is, how much buffer sits either side, how much notice they need and how far ahead they will go, which calendar the booking lands on and which calendars count as busy. A stranger sees the slots that are actually free, picks one, and gets a confirmation carrying a real .ics. Double-booking is refused by the database, not by the code. A unique index on (page, starts_at) over a booking row that is written in the same flush as the event, so Postgres rejecting the second insert takes its event with it and the loser is told the slot has gone. Check-then- insert was rejected outright: two requests both read a free slot and both send a confirmation, and the constraint then rejects one of them after a stranger has already been told they have the time. A check is not a lock. starts_at is denormalised onto the booking on purpose — if the constraint spanned the event's own column, the owner dragging a booked meeting on the time grid would silently free an hour that somebody else is holding. A booked event is its own EventSource case: trusted, because it must reach the provider, and never rewritable by mail, because a booker has no standing to move an hour by sending a message. Not an ExtractionKind, which would have put bookings behind every "found in your email" affordance and into Happening Soon. The booker's name and address live on the booking row and deliberately not in JSCalendar participants — pushing an attendee list makes the provider mail a stranger a meeting request nobody asked it to send. Tokens are hashed at rest for both halves, following the device-pairing precedent, which costs the ability to ever show one again: regenerate is the recovery, and there can be no copy button. The public pages get a layout of their own rather than the app's. The app layout renders a CSRF token into a meta tag, which starts a session — one per visitor and one per .ics poll on a link that may be in a stranger's calendar client. The booking POST therefore carries no CSRF token, and the guard is the unguessable URL plus a rate limiter of six attempts an hour per address. The GET is deliberately unbounded: limiting it would let one stranger take a published page offline. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |