Skip to content

Stats#1022

Open
AlexPerathoner wants to merge 1 commit intosbondCo:devfrom
AlexPerathoner:feature/stats
Open

Stats#1022
AlexPerathoner wants to merge 1 commit intosbondCo:devfrom
AlexPerathoner:feature/stats

Conversation

@AlexPerathoner
Copy link
Contributor

Changes made

Added some stats (inspired by https://anilist.co/user/xyz/stats/anime/overview)

immagine immagine immagine

Copilot AI review requested due to automatic review settings March 2, 2026 23:38
Copy link

Copilot AI left a comment

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 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 /stats app 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.

Comment on lines +184 to +188
// 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
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.

func classifyEpisodeCount(eps uint32) string {
switch {
case eps <= 1:
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
case eps <= 1:
case eps == 0:
return "Unknown"
case eps == 1:

Copilot uses AI. Check for mistakes.
Comment on lines +50 to +53
case "tv":
ct = entity.SHOW
default:
ct = entity.MOVIE
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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

Copilot uses AI. Check for mistakes.
Comment on lines +103 to +105
<div class="chart-container">
<canvas bind:this={canvas}></canvas>
</div>
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +92 to +100
onMount(() => {
createChart();
return () => chart?.destroy();
});

$effect(() => {
labels;
values;
if (canvas) createChart();
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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();

Copilot uses AI. Check for mistakes.
}

watched := new([]entity.Watched)
res = s.db.Model(&entity.Watched{}).Preload("Content").Preload("Activity").Where("user_id = ?", userId).Find(&watched)
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
Comment on lines +190 to +207
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
}
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +90 to +94
createChart();
return () => chart?.destroy();
});

$effect(() => {
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +104 to +106
<div class="chart-container">
<canvas bind:this={canvas}></canvas>
</div>
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
Comment on lines +113 to +119
// Runtime calculation (same logic as getProfile)
isFinished := false
if w.Status == entity.FINISHED {
isFinished = true
} else if includePrev && s.hasBeenPreviouslyWatched(&w.Activity) {
isFinished = true
}
Copy link

Copilot AI Mar 2, 2026

Choose a reason for hiding this comment

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

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.

Copilot uses AI. Check for mistakes.
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