Skip to content

Commit 7cd6560

Browse files
karlitschekclaude
andcommitted
feat(stream): add an activity heatmap that drives the date filter
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>
1 parent ef2519a commit 7cd6560

43 files changed

Lines changed: 2330 additions & 62 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

appinfo/routes.php

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,9 @@
1212
['name' => 'APIv2#getDefault', 'url' => '/api/v2/activity', 'verb' => 'GET'],
1313
['name' => 'APIv2#listFilters', 'url' => '/api/v2/activity/filters', 'verb' => 'GET'],
1414
['name' => 'APIv2#getDownloadCount', 'url' => '/api/v2/activity/downloads/count', 'verb' => 'GET'],
15+
// Before the catch-all below, and a sub-path of it so `{filter}` cannot
16+
// swallow it either way
17+
['name' => 'APIv2#getHistogram', 'url' => '/api/v2/activity/{filter}/histogram', 'verb' => 'GET'],
1518
['name' => 'APIv2#getFilter', 'url' => '/api/v2/activity/{filter}', 'verb' => 'GET'],
1619
],
1720
'routes' => [

docs/endpoint-v2.md

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -203,3 +203,65 @@ In case the endpoint returns more fields, they should be ignored and are depreca
203203
]
204204
}
205205
```
206+
207+
# Daily activity counts
208+
209+
`GET /ocs/v2.php/apps/activity/api/v2/activity/{filter}/histogram`
210+
211+
Number of activities per calendar day, for the stream's heatmap.
212+
213+
Name | Type | Description
214+
---|---|---
215+
`days` | int (Optional) | Length of the window, ending today. Clamped to 1–366 (Default: `364`)
216+
`search` | string (Optional) | Only count activities whose file path contains this substring
217+
`actor` | string (Optional) | Only count activities authored by this account
218+
`object_type` / `object_id` | (Optional) | As on the stream endpoint, and only together
219+
220+
The window deliberately takes no `from`/`to`. The histogram is the control a
221+
reader picks a date range *with*, so it has to keep reporting the days outside
222+
the current selection. Every other restriction is honoured, which is what keeps
223+
the counts equal to the number of activities the stream itself would list — the
224+
two share one query builder internally for exactly that reason.
225+
226+
Days are bucketed in the account's own timezone (the `core`/`timezone` user
227+
setting), not in UTC, so an activity at 01:00 local time belongs to that local
228+
day. A real timezone rather than a fixed offset is used, so a window spanning a
229+
DST change still groups correctly.
230+
231+
## Response
232+
233+
```json
234+
{
235+
"from": "2025-08-03",
236+
"to": "2026-08-01",
237+
"counts": { "2025-08-04": 12, "2025-08-06": 3 },
238+
"max": 12,
239+
"total": 15,
240+
"partial_before": null
241+
}
242+
```
243+
244+
Name | Description
245+
---|---
246+
`from` / `to` | Inclusive window boundaries as local dates
247+
`counts` | Activities per day, keyed by date. **Days with no activity are omitted**, so the payload stays proportional to real activity rather than to the window length; a client fills the gaps with zero
248+
`max` | Busiest day in the window, for scaling a colour ramp. `0` when the window is empty
249+
`total` | Sum over the window
250+
`partial_before` | Normally `null`. Set to a date when the query hit its row guard, meaning counts before that date are incomplete — a client should mark those days rather than draw understated values
251+
252+
## HTTP Status
253+
254+
Status Code | Description
255+
---|---
256+
`200 OK` | Counts
257+
`400 Bad Request` | The search term is too short or too long, or the account name is too long
258+
`403 Forbidden` | The user is not logged in
259+
`404 Not Found` | The filter is unknown
260+
261+
## Cost
262+
263+
The query selects one column, restricted to a single `affecteduser` and a
264+
timestamp range, which is exactly the `activity_user_time` index. Bucketing into
265+
days happens in PHP rather than in SQL: grouping by day needs integer division or
266+
a modulo, neither of which `IQueryBuilder` can express, and a raw expression
267+
would have to be written four different ways for the databases this app supports.

js/ActivityComponent.vue_vue_type_script_setup_true_lang-BKMWNZYb.chunk.mjs.map

Lines changed: 0 additions & 1 deletion
This file was deleted.

js/ActivityComponent.vue_vue_type_script_setup_true_lang-BKMWNZYb.chunk.mjs renamed to js/ActivityComponent.vue_vue_type_script_setup_true_lang-BtJFcDdb.chunk.mjs

Lines changed: 13 additions & 13 deletions
Large diffs are not rendered by default.

js/ActivityComponent.vue_vue_type_script_setup_true_lang-BKMWNZYb.chunk.mjs.license renamed to js/ActivityComponent.vue_vue_type_script_setup_true_lang-BtJFcDdb.chunk.mjs.license

File renamed without changes.

js/ActivityComponent.vue_vue_type_script_setup_true_lang-BtJFcDdb.chunk.mjs.map

Lines changed: 1 addition & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

js/ActivityTab-Cxq6JF8r.chunk.mjs

Lines changed: 0 additions & 3 deletions
This file was deleted.

js/ActivityTab-Dpi1imUR.chunk.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
(function(){"use strict";try{if(typeof document<"u"){var t=document.createElement("style");t.appendChild(document.createTextNode(".download-summary[data-v-81e4514f]{display:flex;align-items:flex-start;padding:8px 0;margin-bottom:calc(var(--default-grid-baseline) * 2);color:var(--color-text-maxcontrast)}.download-summary__icon[data-v-81e4514f]{flex-shrink:0;display:flex;align-items:center;justify-content:center;width:20px;height:20px;margin-top:2px;opacity:.5}.download-summary__text[data-v-81e4514f]{padding:0 5px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.activity[data-v-50818749]{display:flex;flex-direction:column;overflow:hidden;height:100%}.activity__actions[data-v-50818749]{display:flex;flex-direction:column;width:100%}.activity__list[data-v-50818749]{flex-grow:1;overflow:scroll}.activity__empty-content[data-v-50818749]{height:100%}[data-v-50818749] .empty-content__icon span{background-size:64px;width:64px;height:64px}")),document.head.appendChild(t)}}catch(e){console.error("vite-plugin-css-injected-by-js",e)}})();
2+
import{l as R}from"./activity-sidebar.mjs";import{b as A,m as I,h as G,o as D}from"./NcCheckboxRadioSwitch-BVTMQSAg-OXnfnqVj.chunk.mjs";import{d as b,x as V,B as E,y as T,a as i,c as o,j as B,X as p,J as h,m as n,e as S,f as u,t as H,E as g,l as M,b as r,g as y,F as v,K as k,n as q}from"./translation-DoG5ZELJ-B36BKsSA.chunk.mjs";import{a as j}from"./index-BrNGPgve.chunk.mjs";import{j as F,f as O,A as W}from"./ActivityComponent.vue_vue_type_script_setup_true_lang-BtJFcDdb.chunk.mjs";import{l as f}from"./logger-CXg3FpL2.chunk.mjs";import{g as U,a as z,b as J}from"./api-fwrRGLr6.chunk.mjs";import"./preload-helper-DxYC2qmj.chunk.mjs";var w;(function(t){t[t.User=0]="User",t[t.Group=1]="Group",t[t.Link=3]="Link",t[t.Email=4]="Email",t[t.Remote=6]="Remote",t[t.Team=7]="Team",t[t.Guest=8]="Guest",t[t.RemoteGroup=9]="RemoteGroup",t[t.Room=10]="Room",t[t.Deck=12]="Deck",t[t.FederatedGroup=14]="FederatedGroup",t[t.ScienceMesh=15]="ScienceMesh"})(w||(w={}));const K=b({__name:"ActivitySidebarPlugin",props:{plugin:{},node:{}},emits:["reloadActivities"],setup(t,{emit:e}){const a=t,s=e,d=B();return V(()=>a.plugin.mount(d.value,{node:a.node,context:E()?.proxy??void 0,reload:()=>s("reloadActivities")})),T(()=>a.plugin.unmount()),(_,c)=>(i(),o("div",{ref_key:"attachTarget",ref:d},null,512))}}),X='<svg xmlns="http://www.w3.org/2000/svg" id="mdi-download-circle" viewBox="0 0 24 24"><path d="M12 2C17.5 2 22 6.5 22 12C22 17.5 17.5 22 12 22C6.5 22 2 17.5 2 12C2 6.5 6.5 2 12 2M8 17H16V15H8V17M16 10H13.5V6H10.5V10H8L12 14L16 10Z" /></svg>',Z=b({name:"DownloadSummary",components:{NcIconSvgWrapper:A},props:{fileId:{type:Number,required:!0}},data(){return{totalCount:0,monthlyCount:0,downloadSVG:X}},computed:{summaryText(){return this.monthlyCount>0&&this.monthlyCount<this.totalCount?p("activity","Downloaded %n time (%s in the last 30 days)","Downloaded %n times (%s in the last 30 days)",this.totalCount,[String(this.monthlyCount)]):p("activity","Downloaded %n time","Downloaded %n times",this.totalCount)}},watch:{fileId:{immediate:!0,handler(){this.fetchCounts()}}},methods:{async fetchCounts(){if(this.fileId){this.totalCount=0,this.monthlyCount=0;try{const t=await I.get(j("apps/activity/api/v2/activity/downloads/count"),{params:{format:"json",object_type:"files",object_id:this.fileId}});this.totalCount=t.data.ocs.data.total,this.monthlyCount=t.data.ocs.data.last30d}catch(t){f.error("Failed to fetch download counts",{error:t})}}},t:h,n:p}}),$={key:0,class:"download-summary"},Q={class:"download-summary__icon"},Y={class:"download-summary__text"};function tt(t,e,a,s,d,_){const c=n("NcIconSvgWrapper");return t.totalCount>0?(i(),o("div",$,[S("span",Q,[u(c,{svg:t.downloadSVG,size:20},null,8,["svg"])]),S("span",Y,H(t.summaryText),1)])):g("",!0)}const it=G(Z,[["render",tt],["__scopeId","data-v-81e4514f"]]),et=b({name:"ActivityTab",components:{ActivityComponent:O,DownloadSummary:it,NcEmptyContent:F,NcIconSvgWrapper:A,NcLoadingIcon:D,ActivitySidebarPlugin:K},props:{node:{type:Object,required:!0},folder:{type:Object,required:!1,default:void 0},view:{type:Object,required:!1,default:void 0}},expose:["update"],data(){return{error:"",loading:!0,activities:[],lightningBoltSVG:R,sidebarPlugins:[]}},computed:{hasPublicLink(){return Object.values(this.node?.attributes?.["share-types"]??{}).flat().includes(w.Link)}},watch:{node:{immediate:!0,async handler(){await this.update()}}},async mounted(){this.node&&await this.update()},methods:{async update(){this.sidebarPlugins=[];const t=J();t.length>0&&M(()=>{this.sidebarPlugins=t}),this.resetState(),await this.getActivities()},async getActivities(){try{this.loading=!0;const t=await this.processActivities(await this.loadRealActivities()),e=await z({node:this.node});this.activities=[...t,...e].sort((a,s)=>s.timestamp-a.timestamp)}catch(t){this.error=h("activity","Unable to load the activity list"),f.error("Error loading the activity list",{error:t})}finally{this.loading=!1}},resetState(){this.loading=!0,this.error="",this.activities=[]},async loadRealActivities(){try{const{data:t}=await I.get(j("apps/activity/api/v2/activity/filter"),{params:{format:"json",object_type:"files",object_id:this.node.fileid}});return t.ocs.data}catch(t){if(t.response!==void 0&&t.response.status===304)return[];throw t}},processActivities(t){t=t.map(a=>new W(a)),f.debug(`Processed ${t.length} activity(ies)`,{activities:t,node:this.node});const e=U();return t.filter(a=>!e||e.every(s=>s(a)))},t:h}}),at={key:0,class:"activity__actions"},ot={key:4,class:"activity__list"};function st(t,e,a,s,d,_){const c=n("NcIconSvgWrapper"),m=n("NcEmptyContent"),P=n("ActivitySidebarPlugin"),x=n("DownloadSummary"),L=n("NcLoadingIcon"),N=n("ActivityComponent");return i(),o("div",{class:q([{"icon-loading":t.loading},"activity"])},[t.error||!t.node?(i(),r(m,{key:0,name:t.error},{icon:y(()=>[u(c,{svg:t.lightningBoltSVG},null,8,["svg"])]),_:1},8,["name"])):(i(),o(v,{key:1},[t.sidebarPlugins.length>0?(i(),o("div",at,[(i(!0),o(v,null,k(t.sidebarPlugins,(l,C)=>(i(),r(P,{key:C,plugin:l,node:t.node,onReloadActivities:e[0]||(e[0]=nt=>t.getActivities())},null,8,["plugin","node"]))),128))])):g("",!0),t.hasPublicLink&&t.node.fileid?(i(),r(x,{key:1,fileId:t.node.fileid},null,8,["fileId"])):g("",!0),t.loading?(i(),r(m,{key:2,class:"activity__empty-content",name:t.t("activity","Loading activities")},{icon:y(()=>[u(L)]),_:1},8,["name"])):t.activities.length===0?(i(),r(m,{key:3,class:"activity__empty-content",name:t.t("activity","No activity yet")},{icon:y(()=>[u(c,{svg:t.lightningBoltSVG},null,8,["svg"])]),_:1},8,["name"])):(i(),o("ul",ot,[(i(!0),o(v,null,k(t.activities,l=>(i(),r(N,{key:l.id,activity:l,showPreviews:!1,onReload:e[1]||(e[1]=C=>t.getActivities())},null,8,["activity"]))),128))]))],64))],2)}const vt=G(et,[["render",st],["__scopeId","data-v-50818749"]]);export{vt as default};
3+
//# sourceMappingURL=ActivityTab-Dpi1imUR.chunk.mjs.map
Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

0 commit comments

Comments
 (0)