Conversation
There was a problem hiding this comment.
Pull request overview
Adds a new “Stats” view to the app (movies + TV) backed by a new /profile/stats API, and renders multiple chart-based distributions to summarize a user’s watched history.
Changes:
- Introduces a new authenticated backend endpoint to compute per-user stats distributions (ratings/status/year/episode-count).
- Adds a new
/statsapp route with tabs for Movies / TV Shows and chart-based visualizations (Chart.js). - Extends the client API layer and shared TS types to consume the new stats response shape.
Reviewed changes
Copilot reviewed 10 out of 11 changed files in this pull request and generated 12 comments.
Show a summary per file
| File | Description |
|---|---|
| src/types.ts | Adds StatsResponse and related bucket/summary types for the new endpoint. |
| src/routes/(app)/stats/+page.svelte | New Stats page with movie/TV tabs and sections rendering summary + charts. |
| src/lib/util/api.ts | Adds getStats() client helper calling /profile/stats. |
| src/lib/stats/charts/BarChart.svelte | New reusable Chart.js bar chart component. |
| src/lib/stats/charts/DoughnutChart.svelte | New reusable Chart.js doughnut chart component. |
| src/lib/stats/charts/LineChart.svelte | New reusable Chart.js line chart component. |
| src/lib/nav/FaceMenu.svelte | Adds a “Stats” navigation entry in the user menu. |
| server/feature/profile/stats.go | Implements stats aggregation logic and response types on the server. |
| server/feature/profile/router.go | Registers GET /profile/stats route and parses type query. |
| package.json | Adds chart.js dependency. |
| package-lock.json | Locks chart.js (and transitive deps) into the dependency tree. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Look for the latest FINISHED activity | ||
| var finishTime *time.Time | ||
| for i := len(w.Activity) - 1; i >= 0; i-- { | ||
| a := w.Activity[i] | ||
| isFinishActivity := false |
There was a problem hiding this comment.
findWatchYear iterates w.Activity from the end assuming activities are ordered chronologically, but GORM Preload("Activity") does not guarantee ordering. This can return the wrong watch year depending on DB/default order. Fix by explicitly ordering the preload (e.g., Order("created_at asc") on the association) or sorting the slice before searching.
|
|
||
| func classifyEpisodeCount(eps uint32) string { | ||
| switch { | ||
| case eps <= 1: |
There was a problem hiding this comment.
classifyEpisodeCount treats eps==0 (unknown / not populated) as bucket "1", which will skew the distribution for shows without an episode count. Consider skipping eps==0 entirely or adding an "Unknown" bucket so the chart stays accurate.
| case eps <= 1: | |
| case eps == 0: | |
| return "Unknown" | |
| case eps == 1: |
| case "tv": | ||
| ct = entity.SHOW | ||
| default: | ||
| ct = entity.MOVIE |
There was a problem hiding this comment.
The "type" query parameter falls back to MOVIE for any unrecognized value, which can hide client bugs and make the endpoint behavior ambiguous. Consider validating the query strictly (only allow "movie"/"tv") and returning a 400 with a clear error for invalid values.
| case "tv": | |
| ct = entity.SHOW | |
| default: | |
| ct = entity.MOVIE | |
| case "movie": | |
| ct = entity.MOVIE | |
| case "tv": | |
| ct = entity.SHOW | |
| default: | |
| c.JSON(http.StatusBadRequest, router.ErrorResponse{Error: "invalid type parameter, must be 'movie' or 'tv'"}) | |
| return |
| <div class="chart-container"> | ||
| <canvas bind:this={canvas}></canvas> | ||
| </div> |
There was a problem hiding this comment.
The element has no accessible name/role, so screen readers won’t get any context for what the chart represents. Consider adding role="img" with an aria-label/aria-describedby, and/or rendering a small textual summary/table of the data for non-visual users.
| onMount(() => { | ||
| createChart(); | ||
| return () => chart?.destroy(); | ||
| }); | ||
|
|
||
| $effect(() => { | ||
| labels; | ||
| values; | ||
| if (canvas) createChart(); |
There was a problem hiding this comment.
createChart() is called in onMount and then again in the $effect, which will destroy/recreate the chart on initial render and on every prop change. Prefer creating once and then updating the existing chart (chart.data + chart.update()) to reduce allocations and avoid flicker.
| onMount(() => { | |
| createChart(); | |
| return () => chart?.destroy(); | |
| }); | |
| $effect(() => { | |
| labels; | |
| values; | |
| if (canvas) createChart(); | |
| function updateChart() { | |
| if (!chart) return; | |
| const theme = getThemeColors(); | |
| chart.data.labels = labels; | |
| if (chart.data.datasets[0]) { | |
| chart.data.datasets[0].data = values; | |
| chart.data.datasets[0].borderColor = theme.accent; | |
| chart.data.datasets[0].backgroundColor = theme.accent + "33"; | |
| } | |
| if (chart.options?.scales && "x" in chart.options.scales && "y" in chart.options.scales) { | |
| const xScale: any = chart.options.scales.x; | |
| const yScale: any = chart.options.scales.y; | |
| if (xScale.ticks) { | |
| xScale.ticks.color = theme.text; | |
| } | |
| if (yScale.ticks) { | |
| yScale.ticks.color = theme.text; | |
| } | |
| if (yScale.grid) { | |
| yScale.grid.color = theme.grid; | |
| } | |
| } | |
| chart.update(); | |
| } | |
| onMount(() => { | |
| createChart(); | |
| return () => chart?.destroy(); | |
| }); | |
| $effect(() => { | |
| const _labels = labels; | |
| const _values = values; | |
| if (!canvas) { | |
| return; | |
| } | |
| if (!chart) { | |
| createChart(); | |
| return; | |
| } | |
| updateChart(); |
| } | ||
|
|
||
| watched := new([]entity.Watched) | ||
| res = s.db.Model(&entity.Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched) |
There was a problem hiding this comment.
This query preloads all watched rows (and all Activity rows) for the user, then filters by content type in Go. For users with large histories this will be unnecessarily heavy and can slow the stats endpoint. Consider filtering by content type at the DB level (e.g., join Content and filter by content.type) and only preloading Activity when needed for watch-year, ideally with a constrained select/order.
| res = s.db.Model(&entity.Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched) | |
| res = s.db.Model(&entity.Watched{}). | |
| Joins("JOIN contents ON contents.id = watched.content_id"). | |
| Where("watched.user_id = ? AND contents.type = ?", userId, contentType). | |
| Preload("Content"). | |
| Preload("Activity"). | |
| Find(&watched) |
| if a.Type == entity.STATUS_CHANGED || a.Type == entity.STATUS_CHANGED_AUTO { | ||
| if a.Data == "FINISHED" { | ||
| isFinishActivity = true | ||
| } | ||
| } else if a.Type == entity.ADDED_WATCHED || a.Type == entity.IMPORTED_ADDED_WATCHED || a.Type == entity.IMPORTED_WATCHED { | ||
| if a.Data != "" { | ||
| var v map[string]any | ||
| err := json.Unmarshal([]byte(a.Data), &v) | ||
| if err == nil { | ||
| if status, ok := v["status"]; ok && status == "FINISHED" { | ||
| isFinishActivity = true | ||
| } | ||
| } | ||
| } | ||
| if a.Type == entity.IMPORTED_ADDED_WATCHED { | ||
| isFinishActivity = true | ||
| } | ||
| } |
There was a problem hiding this comment.
Imported activity types with provider suffixes (e.g., IMPORTED_WATCHED_JF / IMPORTED_ADDED_WATCHED_JF / *_PLEX) aren’t considered here, so imported watch dates from Jellyfin/Plex won’t be detected and watchYear will fall back to Watched.CreatedAt. Include the provider-specific imported activity types in this detection logic.
| createChart(); | ||
| return () => chart?.destroy(); | ||
| }); | ||
|
|
||
| $effect(() => { |
There was a problem hiding this comment.
createChart() is called in onMount and then again in the $effect, which will immediately destroy/recreate the chart on first render and on every prop change. Prefer creating once and then updating chart.data + chart.update() (or only creating in $effect when chart is undefined) to avoid extra work and flicker.
| <div class="chart-container"> | ||
| <canvas bind:this={canvas}></canvas> | ||
| </div> |
There was a problem hiding this comment.
The element has no accessible name/role, so assistive technologies won’t know what this chart is. Add role="img" plus aria-label/aria-describedby, and consider a text fallback (e.g., list of year/count pairs) so stats remain usable without the chart.
| // Runtime calculation (same logic as getProfile) | ||
| isFinished := false | ||
| if w.Status == entity.FINISHED { | ||
| isFinished = true | ||
| } else if includePrev && s.hasBeenPreviouslyWatched(&w.Activity) { | ||
| isFinished = true | ||
| } |
There was a problem hiding this comment.
includePrev relies on Service.hasBeenPreviouslyWatched(&w.Activity), but that helper currently only considers a subset of activity types (it doesn’t include STATUS_CHANGED_AUTO or provider-specific IMPORTED_*_JF/_PLEX types). As a result, previously-watched items imported from Jellyfin/Plex (or finished via auto status change) may be excluded from runtime/episode totals even when IncludePreviouslyWatched is enabled. Consider expanding hasBeenPreviouslyWatched to cover those activity types.
Changes made
Added some stats (inspired by https://anilist.co/user/xyz/stats/anime/overview)