Skip to content

feat(stream): add an activity heatmap that drives the date filter - #2839

Open
karlitschek wants to merge 6 commits into
feat/stream-search-and-filtersfrom
feat/activity-heatmap
Open

feat(stream): add an activity heatmap that drives the date filter#2839
karlitschek wants to merge 6 commits into
feat/stream-search-and-filtersfrom
feat/activity-heatmap

Conversation

@karlitschek

Copy link
Copy Markdown
Member

The date range added with the search filters was hidden behind a dropdown, so choosing one meant guessing which period had anything in it. A "Heatmap" entry in the app navigation now opens an overview drawing a year of activity as a calendar grid, and picking cells chooses a period: click a day, or shift-click a second one for a span. Clicking the only selected day deselects it, so the same cell toggles rather than trapping the reader in a one day view. "Show these activities" then opens the stream restricted to that period, carrying the range as query parameters because the stream already restores its filters from the URL, which also makes the result linkable.

It is a view of its own rather than a band above the feed: a chart that is permanently in the way of the list people came to read is a distraction, and this one answers a different question from the stream it feeds.

Counts come from a new endpoint,
GET /api/v2/activity/{filter}/histogram, returning activities per calendar day.

It shares its WHERE building with the stream query. That refactor is the point rather than tidiness: a histogram whose columns count rows the feed below it does not list is worse than no histogram, and the filter, type, app and favourites conditions are far too involved to keep correct in two places. The endpoint honours the active search and account for the same reason, but deliberately takes no from/to, because the histogram is the control a range is picked with and has to keep showing the days outside the selection.

Days are bucketed in the account's timezone rather than UTC, so an activity at 01:00 local time belongs to that local day. A real DateTimeZone is passed rather than a fixed offset, which is what keeps a window spanning a DST change correct. Bucketing runs in PHP: grouping by day needs integer division or a modulo, neither expressible through IQueryBuilder, and a raw expression would need four dialects for the databases this app tests against. The query stays cheap regardless — one column, one affecteduser, a timestamp range, which is the existing activity_user_time index.

Hitting the row guard is reported as partial_before instead of hidden, so the affected days are marked rather than drawn as measured but understated values.

On the colour: the ramp is the theme's own accent mixed into the surface at 45/63/81/100% in OKLab, so it follows a custom primary colour, and because --color-primary-element is contrast-adjusted per theme by the server the scale runs light-to-dark on white and dark-to-light on the dark surface — the anchor flip a sequential scale needs, without a second palette. The four steps were checked against both Nextcloud surfaces: monotone OKLCH lightness, adjacent gaps >= 0.09, single hue. The lightest step was raised until it clears 2:1 against the surface, because the palest tint is a day with one activity and had been indistinguishable from an empty one; "no activity" is a neutral instead, which reads as nothing rather than as a small value.

Days with no activity are omitted from the payload rather than sent as zeroes, so its size tracks real activity instead of the window length, and the client fills the gaps.

The grid is a real table with a caption and row headers, every cell is a button carrying the date and exact count as its accessible name, and focus shows the same readout as hover — so no value depends on either hovering or reading a colour. One cell is exposed to Tab at a time, with arrow keys moving a day vertically and a week horizontally, and Home/End jumping to the ends of the window. Selection is drawn as a ring, never a fill, since the fill is spoken for by the value and repainting it would misstate the count.

Rendered in both themes and inspected before shipping, which is what caught the window being too short: at 26 weeks the grid filled under half the column and left the legend stranded, so it defaults to 52.

🤖 AI (if applicable)

  • The content of this PR was partly or fully generated using AI

The date range added with the search filters was hidden behind a dropdown, so
choosing one meant guessing which period had anything in it. A "Heatmap" entry
in the app navigation now opens an overview drawing a year of activity as a
calendar grid, and picking cells chooses a period: click a day, or shift-click
a second one for a span. Clicking the only selected day deselects it, so the
same cell toggles rather than trapping the reader in a one day view. "Show
these activities" then opens the stream restricted to that period, carrying
the range as query parameters because the stream already restores its filters
from the URL, which also makes the result linkable.

It is a view of its own rather than a band above the feed: a chart that is
permanently in the way of the list people came to read is a distraction, and
this one answers a different question from the stream it feeds.

Counts come from a new endpoint,
`GET /api/v2/activity/{filter}/histogram`, returning activities per calendar
day.

It shares its WHERE building with the stream query. That refactor is the
point rather than tidiness: a histogram whose columns count rows the feed
below it does not list is worse than no histogram, and the filter, type, app
and favourites conditions are far too involved to keep correct in two
places. The endpoint honours the active search and account for the same
reason, but deliberately takes no from/to, because the histogram is the
control a range is picked *with* and has to keep showing the days outside
the selection.

Days are bucketed in the account's timezone rather than UTC, so an activity
at 01:00 local time belongs to that local day. A real DateTimeZone is passed
rather than a fixed offset, which is what keeps a window spanning a DST
change correct. Bucketing runs in PHP: grouping by day needs integer
division or a modulo, neither expressible through IQueryBuilder, and a raw
expression would need four dialects for the databases this app tests
against. The query stays cheap regardless — one column, one affecteduser, a
timestamp range, which is the existing activity_user_time index.

Hitting the row guard is reported as `partial_before` instead of hidden, so
the affected days are marked rather than drawn as measured but understated
values.

On the colour: the ramp is the theme's own accent mixed into the surface at
45/63/81/100% in OKLab, so it follows a custom primary colour, and because
--color-primary-element is contrast-adjusted per theme by the server the
scale runs light-to-dark on white and dark-to-light on the dark surface —
the anchor flip a sequential scale needs, without a second palette. The four
steps were checked against both Nextcloud surfaces: monotone OKLCH
lightness, adjacent gaps >= 0.09, single hue. The lightest step was raised
until it clears 2:1 against the surface, because the palest tint is a day
with *one* activity and had been indistinguishable from an empty one; "no
activity" is a neutral instead, which reads as nothing rather than as a
small value.

Days with no activity are omitted from the payload rather than sent as
zeroes, so its size tracks real activity instead of the window length, and
the client fills the gaps.

The grid is a real table with a caption and row headers, every cell is a
button carrying the date and exact count as its accessible name, and focus
shows the same readout as hover — so no value depends on either hovering or
reading a colour. One cell is exposed to Tab at a time, with arrow keys
moving a day vertically and a week horizontally, and Home/End jumping to the
ends of the window. Selection is drawn as a ring, never a fill, since the
fill is spoken for by the value and repainting it would misstate the count.

Rendered in both themes and inspected before shipping, which is what caught
the window being too short: at 26 weeks the grid filled under half the column
and left the legend stranded, so it defaults to 52.

Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Aug 1, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 95.05300% with 14 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/components/ActivityHeatmap.vue 93.54% 12 Missing ⚠️
src/utils/heatmap.ts 98.38% 1 Missing ⚠️
src/views/ActivityHeatmapView.vue 97.14% 1 Missing ⚠️

📢 Thoughts on this report? Let us know!

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a dedicated “Heatmap” view to explore activity density over time and use it to drive the stream’s date-range filter, backed by a new API histogram endpoint that shares query conditions with the stream to keep counts consistent.

Changes:

  • Add GET /api/v2/activity/{filter}/histogram endpoint returning per-day counts plus max, total, and truncation marker.
  • Introduce heatmap UI (grid/table + keyboard navigation + selection) and a new navigation entry + route.
  • Refactor stream query condition building to be shared with histogram counting; add backend + frontend unit tests.

Reviewed changes

Copilot reviewed 20 out of 43 changed files in this pull request and generated 5 comments.

Show a summary per file
File Description
tests/DataTest.php Adds integration-style tests for Data::getDailyCounts() bucketing and window behavior.
tests/Controller/APIv2ControllerTest.php Adds controller tests for histogram response shape, clamping, and parameter forwarding.
src/views/ActivityHeatmapView.vue New view wrapping the heatmap and translating selection into stream URL query params.
src/views/ActivityAppNavigation.vue Adds “Heatmap” entry to app navigation.
src/utils/heatmap.ts Heatmap domain helpers (binning, window fill, week grouping, selection).
src/routes.ts Registers /heatmap route before the filter catch-all.
src/components/ActivityHeatmap.vue New heatmap grid component with loading, accessibility, selection, and keyboard support.
src/tests/heatmap.test.ts Unit tests for heatmap utilities.
src/tests/ActivityHeatmapView.test.ts Unit tests for the view’s navigation/query behavior.
src/tests/ActivityHeatmap.test.ts Component tests for loading/rendering/selection/keyboard behavior.
lib/Data.php Extracts shared stream conditions and adds getDailyCounts() histogram bucketing + truncation marker.
lib/Controller/APIv2Controller.php Adds getHistogram() endpoint and wires in viewer timezone.
js/translation-DoG5ZELJ-B36BKsSA.chunk.mjs.license Generated license metadata update for rebuilt JS bundles.
js/settings-store-Qqd8_iJD.chunk.mjs Removes previous generated settings-store chunk (rebundle).
js/settings-store-DQkngcMb.chunk.mjs.license Generated license metadata for new settings-store chunk.
js/settings-store-DQkngcMb.chunk.mjs New generated settings-store chunk (rebundle).
js/NcCheckboxRadioSwitch-BVTMQSAg-OXnfnqVj.chunk.mjs.license Generated license metadata update for rebuilt JS bundles.
js/index-Dh8mfCs2.chunk.mjs.license Generated license metadata update for rebuilt JS bundles.
js/index-DCC5Jy0q.chunk.mjs.license Generated license metadata update for rebuilt JS bundles.
js/ActivityTab-Dpi1imUR.chunk.mjs.map Generated sourcemap update for rebuilt JS bundle.
js/ActivityTab-Dpi1imUR.chunk.mjs.license Generated license metadata update for rebuilt JS bundle.
js/ActivityTab-Dpi1imUR.chunk.mjs New generated ActivityTab chunk (rebundle).
js/ActivityTab-Cxq6JF8r.chunk.mjs Removes previous generated ActivityTab chunk (rebundle).
js/ActivityComponent.vue_vue_type_script_setup_true_lang-BtJFcDdb.chunk.mjs.license Generated license metadata update for rebuilt JS bundle.
js/activity-personalSettings.mjs Updates generated imports/chunk references after rebundle.
js/activity-adminSettings.mjs Updates generated imports/chunk references after rebundle.
docs/endpoint-v2.md Documents the new histogram endpoint and response fields.
appinfo/routes.php Registers the histogram route ahead of the catch-all filter route.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread lib/Data.php
Comment on lines +468 to +472
if ($rows > self::MAX_HISTOGRAM_ROWS && $oldestCounted !== null) {
// The day the guard cut off is only partly counted, so drop it and
// tell the client where the data stops being trustworthy instead of
// drawing a bar that understates the day
unset($counts[$oldestCounted]);
Comment thread lib/Controller/APIv2Controller.php
Comment thread src/components/ActivityHeatmap.vue
Comment thread src/components/ActivityHeatmap.vue
Comment thread tests/Controller/APIv2ControllerTest.php
karlitschek and others added 5 commits August 1, 2026 19:41
php-cs-fixer wants a multi-line @return description aligned under the
description column, which for this signature meant indenting the wrap to
column 68. The description belongs in the docblock body instead, so the
@return line stays a single type and there is nothing to align.

Reproduced and verified with php-cs-fixer 3.95.15, the version CI pins:
1 of 97 files before, 0 of 97 after.

Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
Signed-off-by: Frank Karlitschek <karlitschek@users.noreply.github.com>
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.

2 participants