Skip to content

Act on user feedback: calendar notification storm, statistics table and scheduling rate - #176

Merged
luflow merged 11 commits into
mainfrom
feature/andreas-feedback-2026-08
Aug 16, 2026
Merged

Act on user feedback: calendar notification storm, statistics table and scheduling rate#176
luflow merged 11 commits into
mainfrom
feature/andreas-feedback-2026-08

Conversation

@luflow

@luflow luflow commented Aug 15, 2026

Copy link
Copy Markdown
Owner

Acts on a round of user feedback. Four of the reported items are fixed here; the
rest are filed as issues, two of them waiting on the reporter.

Organization calendar no longer spams the activity stream

Answering an appointment produced two notifications: the app's own, and
Nextcloud's "X updated event Y in calendar Z" — attributed to the person who
answered, who has never seen the calendar.

The cause is that updateCalendarObject() raises that DAV activity
unconditionally, for the calendar owner and everybody the calendar is shared
with; the dav app never compares content. Carrying the response summary
therefore cost one such notification per answer, and would hit the whole share
circle the moment the calendar is shared.

Real-time writes stay. What changed:

  • Writes are skipped when the generated document says the same thing as the
    stored one, ignoring DTSTAMP / LAST-MODIFIED. Both derive from
    updatedAt, so a comment edit or a repeated identical answer used to produce
    a write that changed not one visible character.
  • An admin switch for the summary block, default on. Nextcloud's own
    counter-setting is per activity type rather than per calendar, so switching it
    off there silences every calendar the person has; this one is narrow. Flipping
    it backfills, so switching off also removes the block from events that already
    carry it.

Statistics

  • How often an acceptance got a place. A yes counts only once the inquiry is
    closed and somebody was actually scheduled, mirroring
    BookingService::isScheduledOut() — before closing nothing is decided, and an
    inquiry closed without scheduling anyone would measure the manager rather than
    the person. Same shape as the existing attendance rate. The response now
    carries schedulingEnabled so table, export and mobile read the rule from the
    one place that applied it.
  • The table fits the screen. Fully expanded it needs ~1800px while the page
    was capped at 1200, so the overflow was guaranteed on every screen and the
    scroll bar sat below the last row. Reported as "no horizontal scrollbar in
    Firefox"; it was neither. Columns are now individually selectable, the name
    column stays put while scrolling right.
  • The drill-down opens at the recent end instead of the start of the period.

Filed, not fixed

Notes

  • vendor-bin/psalm was stuck at 5.26.1 while the lock said 6.16.1, so the
    psalm gate had not actually run locally for a while. Updated, five findings
    fixed, CLAUDE.md now says a git pull does not update vendor-bin/*.
  • German is filled in for every new string, as it never comes back from
    Transifex.
  • No API field was removed or made required, so older mobile clients are
    unaffected; the scheduling columns gate on the existing bookingEnabled
    capability.

🤖 Generated with Claude Code

luflow added 11 commits August 15, 2026 22:25
Every updateCalendarObject() raises the DAV activity "X updated event Y in
calendar Z" for the calendar owner and everybody the calendar is shared with.
It fires unconditionally — the dav app never compares content — so a shared
organization calendar produced one such notification per answer, attributed to
the person who answered, who has never seen the calendar.

Two changes, both keeping the write real-time:

- Skip the write when the generated document says the same thing as the stored
  one, ignoring DTSTAMP and LAST-MODIFIED. Those are derived from updatedAt and
  change whenever the row is touched, so a comment edit or a repeated identical
  answer produced a write that changed not one visible character.
- Add an admin switch for the response summary block. Nextcloud's own
  counter-setting is per activity type, not per calendar, so switching it off
  there silences every calendar the person has; this one is narrow. Default on,
  and flipping it backfills, so switching off also takes the block out of the
  events that already carry it.

Unfolding an iCal document now lives in IcalService next to the folding it
inverts, rather than being written out a second time in the comparison.
Clicking a name opens that person's appointments in the order the timeline
needs them — oldest first. But the drill-down answers "what has this person
been doing lately", not "how did the season run", so it should open at the
recent end.

Reversed in getPersonDetail() rather than in the query: the same
findForStatistics() call feeds the chronological timeline chart, which has to
stay ascending. Doing it server-side keeps web and mobile in step, and changes
no field, so no client breaks on it.
Managers who use the planning mode want to know how often somebody who said
yes was then actually scheduled — the counterpart to the acceptance rate,
which only says how often they offered.

The denominator is the careful part. A yes counts only once the inquiry is
closed AND somebody was actually scheduled for it, mirroring
BookingService::isScheduledOut(): before closing nothing is decided, so a
person who accepted yesterday would otherwise read as "not scheduled", and an
inquiry closed without scheduling anyone is one where the feature was not
used — counting it would measure the manager rather than the person. This is
the same shape the attendance rate already uses ("over AND check-in list
worked"), now the third denominator in the evaluation.

The response carries schedulingEnabled so every consumer — table, export,
mobile — reads the rule from the one place that applied it, instead of asking
the config again in its own layer. The columns and the ODS export follow it.

The effective-status precedence (booking_status wins, booking_notified_status
is the fallback) moves into a static on BookingService, so the bulk query can
apply it without hydrating the entity it exists to avoid, and without
restating the rule.
Fully expanded the table shows fifteen columns of nowrap numbers and needs
roughly 1800px, while the page was capped at the 1200px every other view uses.
The overflow was therefore guaranteed on every screen, and the scroll
container's bar sits below the last row — out of sight on any list long enough
to be worth reading. Reported as "no horizontal scrollbar in Firefox"; it was
neither Firefox nor the reporter's install.

Three changes:

- The statistics page gets the width it actually needs. It is the one view in
  the app with a real case for it.
- Columns can be ticked individually, including the groups column, which is
  the widest one and the one that prompted the report. Compact/Full stay as the
  two presets; ticking refines whichever was picked last.
- The name column stays put while scrolling right, so the numbers under the
  cursor still belong to somebody.

Column descriptors move into src/utils/statisticsColumns.js because the table
and the picker have to agree on what exists and what it is called — including
the scheduling columns from the previous commit, which appear only where the
evaluation says the planning mode is on.

Row highlighting now travels through a custom property. The sticky name cell
paints its own background, which would otherwise out-specify the row states and
leave the frozen cell of every highlighted row unpainted.
German is owned by this repo and never comes back from Transifex, so a string
added with a fresh t() call stays English until it is filled in here.

Wording follows the neighbours: "Einplanungsquote" beside the existing
"Zusagequote" and "Anwesenheitsquote", and "Kalendereintrag" as the
organization calendar section already calls a synced event.
A git pull does not update vendor-bin/*; only composer install (via its
post-install-cmd) or composer bin all install does. Pull a bump and the old
binary stays behind — which is how psalm sat at 5.26.1 while the lock said
6.16.1, crashed on a PHP its version predates, and read as a broken local
environment rather than an install that was simply out of date. The gate had
not run for a while as a result.

Also record how the baseline counts, since it makes a self-inflicted error look
like somebody else's: occurrences are tallied per file and issue type, so one
more call of an already-baselined kind reports against an unrelated line.
The column picker sat in a filter-bar wrapper rather than beside the switches
it belongs to, so its label came out small, grey and left-aligned next to the
bold centred labels of Grouping and Detail — the button read as floating free.
It uses NcFormGroup now, the same component NcRadioGroup builds its label from,
so the three line up by construction instead of by a restated style.

The response-summary switch in the admin settings sat 8px below the target
calendar's hint paragraphs, which is the spacing between those paragraphs, so
it read as another remark about the calendar above it rather than as its own
decision. It gets the 24px the section otherwise uses between subsections.
Small cards naming who turned up most reliably, who accepts most often, who
answers "maybe" most, and — factually rather than as an award — who has not
been to an appointment the longest.

The shape is what keeps it from being a pillory. Rows are values, not people,
so everybody tied at a value shares one line and no rank order is invented
between equals: with seven people all on 90.9 % the card says so instead of
picking three of them alphabetically. Award cards additionally stop at the
totals average, so nobody is ever rendered as the tail of a ranking — on a
small team five rows would otherwise be the whole roster, last place included.
"Longest not attended" cuts the other way, because there the tail is the point.

Rate cards carry their denominator behind each name and ignore anyone below
half the widest basis in the period: somebody invited to their first
appointment and present at it holds 100 % and would otherwise lead every card
without having done anything.

Which cards show is a per-user choice like the columns, four on by default.
The scheduling cards appear only where the evaluation says the planning mode
is on.

Backend side, evaluate() now records when each person was last present. Kept
beside the tallies rather than inside them: a date has no additive merge, and
"the group's most recent attendance" is a number nobody asked for. Hoisting
startOf() out of the per-person loop while adding it also removes a
pre-existing duplicate call — it serializes the whole entity to read one field.
The skip introduced with the no-op change also changed what syncAppointment()
reports. It returned false when the event was already current, and
syncAllUpcoming() counts those returns — so the admin's sync button answered
"0 appointments synced" on an instance where everything was in order, which
reads as a failure rather than as nothing to do. Caught by the e2e suite,
which is not part of the local gate.

The return now means "the appointment is in the calendar afterwards", which is
what the button's own promise is: create or update the events for all upcoming
appointments. The write is still skipped; only the report changed.
"Longest not attended" answered when somebody was last there. What a manager
plans against is how often somebody drops out, so the card now reads the
absence rate and is titled plainly: "Absent more often".

Not 1 - attendanceRate. An appointment nobody ticked off counts in the base but
says nothing about whether the person was there, so only recorded absences
count — absent over attendanceBase, sitting beside the other rates in the tally
where it belongs.

That retires lastPresentAt, added two commits ago for the date card: the field,
its tests and its API type are gone again. With it goes the whole date
apparatus in the card module — the "never present" sentinel, the scoring
indirection it needed, and the two-way threshold. Every card now simply wants
its highest values, and the module is a good deal shorter for it.

The rate cards take Andreas' own wording, "Top …". The two counting cards keep
theirs: "Top" suits a rate, not a tally.
Three things the first cut got wrong, all visible the moment real data went
through it.

Every name sat on its own line with a leading comma. The button reset took the
colours and the border but not the box model, and Nextcloud styles every button
as a full-width block with its own min-height — the same trap as the row
backgrounds in the table.

The value sat beside the names as a nowrap column. Carrying the basis it ran to
half the card width and left the names a strip so narrow that "Christina Vogel"
broke in two. It heads its group now, and the names run on beneath it.

The separator leaned on the whitespace between the buttons, which rendered as a
space in front of every comma and stranded one at the start of a wrapped line.
It is a pseudo-element plus a column gap now, so nothing depends on invisible
text nodes.

Also: the basis is stated once per row instead of after every name — four
repetitions of "(4 appointments)" drowned out the names it was meant to qualify
— and rows are further apart than the lines within them, so it is visible which
names belong to which value.
@luflow
luflow merged commit 7a8bae9 into main Aug 16, 2026
3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant