From 9943e586b70a477db474d9cc611cba45f2c78cde Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Fri, 17 Jul 2026 20:10:18 +0200 Subject: [PATCH 01/15] Add stats page with activity heatmap and watch statistics --- src/app/app.html | 6 + src/app/app.routes.ts | 4 + .../stats-heatmap/stats-heatmap.component.ts | 161 +++++++++ src/app/components/stats/stats.component.ts | 342 ++++++++++++++++++ 4 files changed, 513 insertions(+) create mode 100644 src/app/components/stats/stats-heatmap/stats-heatmap.component.ts create mode 100644 src/app/components/stats/stats.component.ts diff --git a/src/app/app.html b/src/app/app.html index c9d61e6..6f47484 100644 --- a/src/app/app.html +++ b/src/app/app.html @@ -29,6 +29,12 @@ class="text-light-font dark:text-dark-font no-underline font-medium py-2 transition-colors duration-200 hover:text-accent-primary [&.active]:text-accent-primary [&.active]:border-b-2 [&.active]:border-accent-primary" >History + Stats m.WatchHistoryComponent, ), }, + { + path: 'stats', + loadComponent: () => import('./components/stats/stats.component').then(m => m.StatsComponent) + }, { path: 'settings', loadComponent: () => diff --git a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts new file mode 100644 index 0000000..62b764a --- /dev/null +++ b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts @@ -0,0 +1,161 @@ +import { Component, computed, input } from '@angular/core'; +import { HistoryEntry } from '../../../models/storage.model'; + +interface DayCell { + date: string; + count: number; + level: 0 | 1 | 2 | 3; +} + +interface MonthLabel { + label: string; + weekIndex: number; +} + +@Component({ + selector: 'app-stats-heatmap', + template: ` +
+
+
+ @for (month of monthLabels(); track month.label + month.weekIndex) { + + {{ month.label }} + + } +
+ +
+
+ @for (dayLabel of dayLabels; track dayLabel) { + + {{ dayLabel }} + + } +
+ +
+ @for (week of weeks(); track $index) { +
+ @for (day of week; track day.date) { +
+
+ {{ day.count }} {{ day.count === 1 ? 'entry' : 'entries' }} + {{ formatTooltipDate(day.date) }} +
+
+ } +
+ } +
+
+ +
+ Less +
+
+
+
+ More +
+
+
+ ` +}) +export class StatsHeatmapComponent { + historyEntries = input.required(); + + dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', '']; + + private entryCountByDate = computed(() => { + const map = new Map(); + for (const entry of this.historyEntries()) { + const dateKey = entry.date.slice(0, 10); + map.set(dateKey, (map.get(dateKey) ?? 0) + 1); + } + return map; + }); + + private allDays = computed(() => { + const today = new Date(); + const end = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + const start = new Date(end); + start.setDate(start.getDate() - 364); + + const startDay = start.getDay(); + const adjustedStart = new Date(start); + adjustedStart.setDate(adjustedStart.getDate() - startDay); + + const days: DayCell[] = []; + const current = new Date(adjustedStart); + const counts = this.entryCountByDate(); + + while (current <= end) { + const dateKey = current.toISOString().slice(0, 10); + const count = counts.get(dateKey) ?? 0; + let level: 0 | 1 | 2 | 3 = 0; + if (count >= 3) level = 3; + else if (count >= 2) level = 2; + else if (count >= 1) level = 1; + + days.push({ date: dateKey, count, level }); + current.setDate(current.getDate() + 1); + } + + return days; + }); + + weeks = computed(() => { + const days = this.allDays(); + const result: DayCell[][] = []; + for (let i = 0; i < days.length; i += 7) { + result.push(days.slice(i, i + 7)); + } + return result; + }); + + monthLabels = computed(() => { + const weeks = this.weeks(); + const labels: MonthLabel[] = []; + let lastMonth = -1; + + for (let i = 0; i < weeks.length; i++) { + const firstDayOfWeek = weeks[i][0]; + if (!firstDayOfWeek) continue; + + const date = new Date(firstDayOfWeek.date); + const month = date.getMonth(); + + if (month !== lastMonth) { + labels.push({ + label: date.toLocaleDateString('en-US', { month: 'short' }), + weekIndex: i + }); + lastMonth = month; + } + } + + return labels; + }); + + getCellClass(level: 0 | 1 | 2 | 3): string { + switch (level) { + case 0: return 'bg-light-bg-tertiary dark:bg-dark-bg-tertiary'; + case 1: return 'bg-accent-primary/25'; + case 2: return 'bg-accent-primary/50'; + case 3: return 'bg-accent-primary'; + } + } + + formatTooltipDate(dateStr: string): string { + const date = new Date(dateStr + 'T00:00:00'); + return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); + } +} diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts new file mode 100644 index 0000000..eb05cc0 --- /dev/null +++ b/src/app/components/stats/stats.component.ts @@ -0,0 +1,342 @@ +import { Component, computed, inject } from '@angular/core'; +import { RouterLink } from '@angular/router'; +import { WatchListService } from '../../services/watch-list.service'; +import { StatsHeatmapComponent } from './stats-heatmap/stats-heatmap.component'; +import { Item } from '../../models/item.model'; + +@Component({ + selector: 'app-stats', + imports: [RouterLink, StatsHeatmapComponent], + template: ` +
+

Stats

+ + @if (totalItems() === 0 && history().length === 0) { +
+
📊
+

No data to show yet.

+

Add items and start watching to see your stats!

+
Browse Items +
+ } @else { +
+
+

Overview

+
+
+
{{ totalItems() }}
+
Total Items
+
+
+
{{ inProgressCount() }}
+
Watching
+
+
+
{{ completedCount() }}
+
Completed
+
+
+
{{ droppedCount() }}
+
Dropped
+
+
+
{{ totalEpisodesWatched() }}
+
Episodes
+
+
+
{{ history().length }}
+
Watch Entries
+
+
+
+ +
+

Activity

+
+ +
+
+ +
+

Streaks

+
+
+
{{ currentStreak() }}
+
Current Streak (days)
+
+
+
{{ longestStreak() }}
+
Longest Streak (days)
+
+
+
{{ totalDaysWatched() }}
+
Total Days Watched
+
+
+
+ + @if (mostWatchedItems().length > 0) { +
+

Most Watched

+
+ @for (item of mostWatchedItems(); track item.id; let i = $index) { +
+ {{ i + 1 }} +
+ + {{ item.title }} + + {{ item.type }} + {{ item.watchHistory.length }} + entries +
+ } +
+
+ } + +
+

Breakdown

+
+
+

Series

+
+
+ In Progress + {{ seriesByStatus('in-progress') }} +
+
+ Completed + {{ seriesByStatus('completed') }} +
+
+ Dropped + {{ seriesByStatus('dropped') }} +
+
+ Not Started + {{ seriesByStatus('not-started') }} +
+
+ Avg. Episodes / Active + {{ avgEpisodesPerActiveSeries() }} +
+
+
+
+

Movies

+
+
+ In Progress + {{ moviesByStatus('in-progress') }} +
+
+ Completed + {{ moviesByStatus('completed') }} +
+
+ Dropped + {{ moviesByStatus('dropped') }} +
+
+ Not Started + {{ moviesByStatus('not-started') }} +
+
+ Avg. Time to Complete + {{ avgTimeToComplete() }} +
+
+
+
+
+ +
+

Watch Patterns

+
+
+

Most Active Day

+
{{ mostActiveDay() }}
+
{{ mostActiveDayCount() }} entries
+
+
+

Most Active Time

+
{{ mostActiveTimeOfDay() }}
+
{{ mostActiveTimeOfDayCount() }} entries
+
+
+
+
+ } +
+ ` +}) +export class StatsComponent { + private watchListService = inject(WatchListService); + + items = this.watchListService.items; + history = this.watchListService.history; + + totalItems = computed(() => this.items().length); + + inProgressCount = computed(() => this.items().filter(i => i.status === 'in-progress').length); + completedCount = computed(() => this.items().filter(i => i.status === 'completed').length); + droppedCount = computed(() => this.items().filter(i => i.status === 'dropped').length); + + totalEpisodesWatched = computed(() => { + let count = 0; + for (const item of this.items()) { + if (item.type === 'series') { + count += item.watchHistory.length; + } + } + return count; + }); + + seriesByStatus = (status: Item['status']): number => + this.items().filter(i => i.type === 'series' && i.status === status).length; + + moviesByStatus = (status: Item['status']): number => + this.items().filter(i => i.type === 'movie' && i.status === status).length; + + mostWatchedItems = computed(() => { + return [...this.items()] + .filter(i => i.watchHistory.length > 0) + .sort((a, b) => b.watchHistory.length - a.watchHistory.length) + .slice(0, 10); + }); + + avgEpisodesPerActiveSeries = computed(() => { + const activeSeries = this.items().filter(i => i.type === 'series' && i.status === 'in-progress'); + if (activeSeries.length === 0) return '0'; + const totalEps = activeSeries.reduce((sum, s) => sum + s.watchHistory.length, 0); + return (totalEps / activeSeries.length).toFixed(1); + }); + + avgTimeToComplete = computed(() => { + const completed = this.items().filter(i => i.status === 'completed'); + if (completed.length === 0) return 'N/A'; + const totalMs = completed.reduce((sum, item) => { + const created = new Date(item.createdAt).getTime(); + const lastWatch = item.watchHistory.length > 0 + ? new Date(item.watchHistory[item.watchHistory.length - 1].date).getTime() + : created; + return sum + (lastWatch - created); + }, 0); + const avgDays = totalMs / completed.length / (1000 * 60 * 60 * 24); + if (avgDays < 1) return '< 1 day'; + if (avgDays === 1) return '1 day'; + return `${Math.round(avgDays)} days`; + }); + + private uniqueWatchDates = computed(() => { + const dates = new Set(); + for (const entry of this.history()) { + dates.add(entry.date.slice(0, 10)); + } + return dates; + }); + + totalDaysWatched = computed(() => this.uniqueWatchDates().size); + + currentStreak = computed(() => this.computeCurrentStreak()); + longestStreak = computed(() => this.computeLongestStreak()); + + private computeCurrentStreak(): number { + const dates = this.uniqueWatchDates(); + if (dates.size === 0) return 0; + + const today = new Date(); + + let streak = 0; + const current = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + + while (true) { + const key = current.toISOString().slice(0, 10); + if (dates.has(key)) { + streak++; + current.setDate(current.getDate() - 1); + } else { + break; + } + } + + return streak; + } + + private computeLongestStreak(): number { + const dates = [...this.uniqueWatchDates()].sort(); + if (dates.length === 0) return 0; + + let longest = 1; + let current = 1; + + for (let i = 1; i < dates.length; i++) { + const prev = new Date(dates[i - 1] + 'T00:00:00'); + const curr = new Date(dates[i] + 'T00:00:00'); + const diffDays = (curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24); + + if (diffDays === 1) { + current++; + longest = Math.max(longest, current); + } else { + current = 1; + } + } + + return longest; + } + + private dayOfWeekCounts = computed(() => { + const counts = new Array(7).fill(0); + const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; + for (const entry of this.history()) { + const day = new Date(entry.date).getDay(); + counts[day]++; + } + return { counts, dayNames }; + }); + + mostActiveDay = computed(() => { + const { counts, dayNames } = this.dayOfWeekCounts(); + const maxIdx = counts.indexOf(Math.max(...counts)); + return dayNames[maxIdx]; + }); + + mostActiveDayCount = computed(() => { + const { counts } = this.dayOfWeekCounts(); + return Math.max(...counts); + }); + + private timeOfDayCounts = computed(() => { + const periods = { 'Morning': 0, 'Afternoon': 0, 'Evening': 0, 'Night': 0 }; + for (const entry of this.history()) { + const hour = new Date(entry.date).getHours(); + if (hour >= 6 && hour < 12) periods['Morning']++; + else if (hour >= 12 && hour < 18) periods['Afternoon']++; + else if (hour >= 18 && hour < 22) periods['Evening']++; + else periods['Night']++; + } + return periods; + }); + + mostActiveTimeOfDay = computed(() => { + const periods = this.timeOfDayCounts(); + let maxKey = 'Morning'; + let maxVal = 0; + for (const [key, val] of Object.entries(periods)) { + if (val > maxVal) { + maxVal = val; + maxKey = key; + } + } + return maxKey; + }); + + mostActiveTimeOfDayCount = computed(() => { + const periods = this.timeOfDayCounts(); + return Math.max(...Object.values(periods)); + }); +} From c55d49dfa0b4ec944cb96812ec5ab542a85afa02 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:27:13 +0200 Subject: [PATCH 02/15] Fix heatmap and streaks to use local dates instead of UTC --- .../stats-heatmap/stats-heatmap.component.ts | 22 ++++++++++++++---- src/app/components/stats/stats.component.ts | 23 +++++++++++++++---- 2 files changed, 36 insertions(+), 9 deletions(-) diff --git a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts index 62b764a..c7c05f4 100644 --- a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts +++ b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts @@ -74,10 +74,22 @@ export class StatsHeatmapComponent { dayLabels = ['', 'Mon', '', 'Wed', '', 'Fri', '']; + private static toLocalDateKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + + private static toLocalDateKeyFromISO(iso: string): string { + const d = new Date(iso); + return StatsHeatmapComponent.toLocalDateKey(d); + } + private entryCountByDate = computed(() => { const map = new Map(); for (const entry of this.historyEntries()) { - const dateKey = entry.date.slice(0, 10); + const dateKey = StatsHeatmapComponent.toLocalDateKeyFromISO(entry.date); map.set(dateKey, (map.get(dateKey) ?? 0) + 1); } return map; @@ -98,7 +110,7 @@ export class StatsHeatmapComponent { const counts = this.entryCountByDate(); while (current <= end) { - const dateKey = current.toISOString().slice(0, 10); + const dateKey = StatsHeatmapComponent.toLocalDateKey(current); const count = counts.get(dateKey) ?? 0; let level: 0 | 1 | 2 | 3 = 0; if (count >= 3) level = 3; @@ -130,7 +142,8 @@ export class StatsHeatmapComponent { const firstDayOfWeek = weeks[i][0]; if (!firstDayOfWeek) continue; - const date = new Date(firstDayOfWeek.date); + const parts = firstDayOfWeek.date.split('-'); + const date = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); const month = date.getMonth(); if (month !== lastMonth) { @@ -155,7 +168,8 @@ export class StatsHeatmapComponent { } formatTooltipDate(dateStr: string): string { - const date = new Date(dateStr + 'T00:00:00'); + const parts = dateStr.split('-'); + const date = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); } } diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index eb05cc0..f78388d 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -231,10 +231,21 @@ export class StatsComponent { return `${Math.round(avgDays)} days`; }); + private static toLocalDateKey(date: Date): string { + const y = date.getFullYear(); + const m = String(date.getMonth() + 1).padStart(2, '0'); + const d = String(date.getDate()).padStart(2, '0'); + return `${y}-${m}-${d}`; + } + + private static toLocalDateKeyFromISO(iso: string): string { + return StatsComponent.toLocalDateKey(new Date(iso)); + } + private uniqueWatchDates = computed(() => { const dates = new Set(); for (const entry of this.history()) { - dates.add(entry.date.slice(0, 10)); + dates.add(StatsComponent.toLocalDateKeyFromISO(entry.date)); } return dates; }); @@ -254,7 +265,7 @@ export class StatsComponent { const current = new Date(today.getFullYear(), today.getMonth(), today.getDate()); while (true) { - const key = current.toISOString().slice(0, 10); + const key = StatsComponent.toLocalDateKey(current); if (dates.has(key)) { streak++; current.setDate(current.getDate() - 1); @@ -274,9 +285,11 @@ export class StatsComponent { let current = 1; for (let i = 1; i < dates.length; i++) { - const prev = new Date(dates[i - 1] + 'T00:00:00'); - const curr = new Date(dates[i] + 'T00:00:00'); - const diffDays = (curr.getTime() - prev.getTime()) / (1000 * 60 * 60 * 24); + const [py, pm, pd] = dates[i - 1].split('-').map(Number); + const [cy, cm, cd] = dates[i].split('-').map(Number); + const prevTime = new Date(py, pm - 1, pd).getTime(); + const currTime = new Date(cy, cm - 1, cd).getTime(); + const diffDays = (currTime - prevTime) / (1000 * 60 * 60 * 24); if (diffDays === 1) { current++; From 8197e0f2bb21af407c315f03d7b03dcec49d2358 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:49:31 +0200 Subject: [PATCH 03/15] Fix longest streak calculation to be DST-safe using calendar arithmetic --- src/app/components/stats/stats.component.ts | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index f78388d..c058809 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -277,6 +277,16 @@ export class StatsComponent { return streak; } + private static daysBetween(y1: number, m1: number, d1: number, y2: number, m2: number, d2: number): number { + const toOrdinal = (y: number, m: number, d: number): number => { + const a = Math.floor((14 - m) / 12); + const y2 = y + 4800 - a; + const m2 = m + 12 * a - 3; + return d + Math.floor((153 * m2 + 2) / 5) + 365 * y2 + Math.floor(y2 / 4) - Math.floor(y2 / 100) + Math.floor(y2 / 400) - 32045; + }; + return toOrdinal(y2, m2, d2) - toOrdinal(y1, m1, d1); + } + private computeLongestStreak(): number { const dates = [...this.uniqueWatchDates()].sort(); if (dates.length === 0) return 0; @@ -287,11 +297,8 @@ export class StatsComponent { for (let i = 1; i < dates.length; i++) { const [py, pm, pd] = dates[i - 1].split('-').map(Number); const [cy, cm, cd] = dates[i].split('-').map(Number); - const prevTime = new Date(py, pm - 1, pd).getTime(); - const currTime = new Date(cy, cm - 1, cd).getTime(); - const diffDays = (currTime - prevTime) / (1000 * 60 * 60 * 24); - if (diffDays === 1) { + if (StatsComponent.daysBetween(py, pm, pd, cy, cm, cd) === 1) { current++; longest = Math.max(longest, current); } else { From 5bc2bcc9d40c54847239c2fdb2a0bf40cbfbe718 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:50:24 +0200 Subject: [PATCH 04/15] Filter avgTimeToComplete to movies only --- src/app/components/stats/stats.component.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index c058809..42d75b6 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -216,7 +216,7 @@ export class StatsComponent { }); avgTimeToComplete = computed(() => { - const completed = this.items().filter(i => i.status === 'completed'); + const completed = this.items().filter(i => i.type === 'movie' && i.status === 'completed'); if (completed.length === 0) return 'N/A'; const totalMs = completed.reduce((sum, item) => { const created = new Date(item.createdAt).getTime(); From 052d9bcc864a3d6c45480322873bfbc0bc4fa6fb Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:55:24 +0200 Subject: [PATCH 05/15] Let prettier fix files --- src/app/app.routes.ts | 2 +- .../stats-heatmap/stats-heatmap.component.ts | 43 ++- src/app/components/stats/stats.component.ts | 310 +++++++++++++----- 3 files changed, 264 insertions(+), 91 deletions(-) diff --git a/src/app/app.routes.ts b/src/app/app.routes.ts index 643473b..55171e3 100644 --- a/src/app/app.routes.ts +++ b/src/app/app.routes.ts @@ -36,7 +36,7 @@ export const routes: Routes = [ }, { path: 'stats', - loadComponent: () => import('./components/stats/stats.component').then(m => m.StatsComponent) + loadComponent: () => import('./components/stats/stats.component').then((m) => m.StatsComponent), }, { path: 'settings', diff --git a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts index c7c05f4..4c8497b 100644 --- a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts +++ b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts @@ -31,7 +31,9 @@ interface MonthLabel {
@for (dayLabel of dayLabels; track dayLabel) { - + {{ dayLabel }} } @@ -46,9 +48,15 @@ interface MonthLabel { [class]="getCellClass(day.level)" [attr.data-tooltip]="day.date + ': ' + day.count + ' entry/entries'" > -
- {{ day.count }} {{ day.count === 1 ? 'entry' : 'entries' }} - {{ formatTooltipDate(day.date) }} +
+ {{ day.count }} {{ day.count === 1 ? 'entry' : 'entries' }} + {{ + formatTooltipDate(day.date) + }}
} @@ -59,7 +67,9 @@ interface MonthLabel {
Less -
+
@@ -67,7 +77,7 @@ interface MonthLabel {
- ` + `, }) export class StatsHeatmapComponent { historyEntries = input.required(); @@ -149,7 +159,7 @@ export class StatsHeatmapComponent { if (month !== lastMonth) { labels.push({ label: date.toLocaleDateString('en-US', { month: 'short' }), - weekIndex: i + weekIndex: i, }); lastMonth = month; } @@ -160,16 +170,25 @@ export class StatsHeatmapComponent { getCellClass(level: 0 | 1 | 2 | 3): string { switch (level) { - case 0: return 'bg-light-bg-tertiary dark:bg-dark-bg-tertiary'; - case 1: return 'bg-accent-primary/25'; - case 2: return 'bg-accent-primary/50'; - case 3: return 'bg-accent-primary'; + case 0: + return 'bg-light-bg-tertiary dark:bg-dark-bg-tertiary'; + case 1: + return 'bg-accent-primary/25'; + case 2: + return 'bg-accent-primary/50'; + case 3: + return 'bg-accent-primary'; } } formatTooltipDate(dateStr: string): string { const parts = dateStr.split('-'); const date = new Date(Number(parts[0]), Number(parts[1]) - 1, Number(parts[2])); - return date.toLocaleDateString('en-US', { weekday: 'short', month: 'short', day: 'numeric', year: 'numeric' }); + return date.toLocaleDateString('en-US', { + weekday: 'short', + month: 'short', + day: 'numeric', + year: 'numeric', + }); } } diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index 42d75b6..1c363b3 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -12,47 +12,87 @@ import { Item } from '../../models/item.model';

Stats

@if (totalItems() === 0 && history().length === 0) { -
+
📊
-

No data to show yet.

-

Add items and start watching to see your stats!

- Browse Items +

+ No data to show yet. +

+

+ Add items and start watching to see your stats! +

+ Browse Items
} @else {

Overview

-
+
{{ totalItems() }}
-
Total Items
+
+ Total Items +
-
+
{{ inProgressCount() }}
-
Watching
+
+ Watching +
-
+
{{ completedCount() }}
-
Completed
+
+ Completed +
-
+
{{ droppedCount() }}
-
Dropped
+
+ Dropped +
-
-
{{ totalEpisodesWatched() }}
-
Episodes
+
+
+ {{ totalEpisodesWatched() }} +
+
+ Episodes +
-
-
{{ history().length }}
-
Watch Entries
+
+
+ {{ history().length }} +
+
+ Watch Entries +

Activity

-
+
@@ -60,37 +100,71 @@ import { Item } from '../../models/item.model';

Streaks

-
+
{{ currentStreak() }}
-
Current Streak (days)
+
+ Current Streak (days) +
-
+
{{ longestStreak() }}
-
Longest Streak (days)
+
+ Longest Streak (days) +
-
+
{{ totalDaysWatched() }}
-
Total Days Watched
+
+ Total Days Watched +
@if (mostWatchedItems().length > 0) {
-

Most Watched

-
+

+ Most Watched +

+
@for (item of mostWatchedItems(); track item.id; let i = $index) { -
- {{ i + 1 }} -
- +
+ {{ i + 1 }} +
+
{{ item.title }} - {{ item.type }} - {{ item.watchHistory.length }} - entries + {{ item.type }} + {{ item.watchHistory.length }} + entries
}
@@ -98,55 +172,97 @@ import { Item } from '../../models/item.model'; }
-

Breakdown

+

+ Breakdown +

-
-

Series

+
+

+ Series +

In Progress - {{ seriesByStatus('in-progress') }} + {{ + seriesByStatus('in-progress') + }}
Completed - {{ seriesByStatus('completed') }} + {{ + seriesByStatus('completed') + }}
Dropped - {{ seriesByStatus('dropped') }} + {{ + seriesByStatus('dropped') + }}
Not Started - {{ seriesByStatus('not-started') }} + {{ + seriesByStatus('not-started') + }}
-
- Avg. Episodes / Active - {{ avgEpisodesPerActiveSeries() }} +
+ Avg. Episodes / Active + {{ + avgEpisodesPerActiveSeries() + }}
-
-

Movies

+
+

+ Movies +

In Progress - {{ moviesByStatus('in-progress') }} + {{ + moviesByStatus('in-progress') + }}
Completed - {{ moviesByStatus('completed') }} + {{ + moviesByStatus('completed') + }}
Dropped - {{ moviesByStatus('dropped') }} + {{ + moviesByStatus('dropped') + }}
Not Started - {{ moviesByStatus('not-started') }} + {{ + moviesByStatus('not-started') + }}
-
- Avg. Time to Complete - {{ avgTimeToComplete() }} +
+ Avg. Time to Complete + {{ + avgTimeToComplete() + }}
@@ -154,24 +270,44 @@ import { Item } from '../../models/item.model';
-

Watch Patterns

+

+ Watch Patterns +

-
-

Most Active Day

+
+

+ Most Active Day +

{{ mostActiveDay() }}
-
{{ mostActiveDayCount() }} entries
+
+ {{ mostActiveDayCount() }} entries +
-
-

Most Active Time

-
{{ mostActiveTimeOfDay() }}
-
{{ mostActiveTimeOfDayCount() }} entries
+
+

+ Most Active Time +

+
+ {{ mostActiveTimeOfDay() }} +
+
+ {{ mostActiveTimeOfDayCount() }} entries +
}
- ` + `, }) export class StatsComponent { private watchListService = inject(WatchListService); @@ -181,9 +317,9 @@ export class StatsComponent { totalItems = computed(() => this.items().length); - inProgressCount = computed(() => this.items().filter(i => i.status === 'in-progress').length); - completedCount = computed(() => this.items().filter(i => i.status === 'completed').length); - droppedCount = computed(() => this.items().filter(i => i.status === 'dropped').length); + inProgressCount = computed(() => this.items().filter((i) => i.status === 'in-progress').length); + completedCount = computed(() => this.items().filter((i) => i.status === 'completed').length); + droppedCount = computed(() => this.items().filter((i) => i.status === 'dropped').length); totalEpisodesWatched = computed(() => { let count = 0; @@ -196,33 +332,36 @@ export class StatsComponent { }); seriesByStatus = (status: Item['status']): number => - this.items().filter(i => i.type === 'series' && i.status === status).length; + this.items().filter((i) => i.type === 'series' && i.status === status).length; moviesByStatus = (status: Item['status']): number => - this.items().filter(i => i.type === 'movie' && i.status === status).length; + this.items().filter((i) => i.type === 'movie' && i.status === status).length; mostWatchedItems = computed(() => { return [...this.items()] - .filter(i => i.watchHistory.length > 0) + .filter((i) => i.watchHistory.length > 0) .sort((a, b) => b.watchHistory.length - a.watchHistory.length) .slice(0, 10); }); avgEpisodesPerActiveSeries = computed(() => { - const activeSeries = this.items().filter(i => i.type === 'series' && i.status === 'in-progress'); + const activeSeries = this.items().filter( + (i) => i.type === 'series' && i.status === 'in-progress', + ); if (activeSeries.length === 0) return '0'; const totalEps = activeSeries.reduce((sum, s) => sum + s.watchHistory.length, 0); return (totalEps / activeSeries.length).toFixed(1); }); avgTimeToComplete = computed(() => { - const completed = this.items().filter(i => i.type === 'movie' && i.status === 'completed'); + const completed = this.items().filter((i) => i.type === 'movie' && i.status === 'completed'); if (completed.length === 0) return 'N/A'; const totalMs = completed.reduce((sum, item) => { const created = new Date(item.createdAt).getTime(); - const lastWatch = item.watchHistory.length > 0 - ? new Date(item.watchHistory[item.watchHistory.length - 1].date).getTime() - : created; + const lastWatch = + item.watchHistory.length > 0 + ? new Date(item.watchHistory[item.watchHistory.length - 1].date).getTime() + : created; return sum + (lastWatch - created); }, 0); const avgDays = totalMs / completed.length / (1000 * 60 * 60 * 24); @@ -277,12 +416,27 @@ export class StatsComponent { return streak; } - private static daysBetween(y1: number, m1: number, d1: number, y2: number, m2: number, d2: number): number { + private static daysBetween( + y1: number, + m1: number, + d1: number, + y2: number, + m2: number, + d2: number, + ): number { const toOrdinal = (y: number, m: number, d: number): number => { const a = Math.floor((14 - m) / 12); const y2 = y + 4800 - a; const m2 = m + 12 * a - 3; - return d + Math.floor((153 * m2 + 2) / 5) + 365 * y2 + Math.floor(y2 / 4) - Math.floor(y2 / 100) + Math.floor(y2 / 400) - 32045; + return ( + d + + Math.floor((153 * m2 + 2) / 5) + + 365 * y2 + + Math.floor(y2 / 4) - + Math.floor(y2 / 100) + + Math.floor(y2 / 400) - + 32045 + ); }; return toOrdinal(y2, m2, d2) - toOrdinal(y1, m1, d1); } @@ -331,7 +485,7 @@ export class StatsComponent { }); private timeOfDayCounts = computed(() => { - const periods = { 'Morning': 0, 'Afternoon': 0, 'Evening': 0, 'Night': 0 }; + const periods = { Morning: 0, Afternoon: 0, Evening: 0, Night: 0 }; for (const entry of this.history()) { const hour = new Date(entry.date).getHours(); if (hour >= 6 && hour < 12) periods['Morning']++; From 1bc09c93466e284126ac932b16f71768ace97b34 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 07:59:51 +0200 Subject: [PATCH 06/15] Show em dash placeholder for Most Active Day/Time when history is empty --- src/app/components/stats/stats.component.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index 1c363b3..35f567e 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -475,8 +475,9 @@ export class StatsComponent { mostActiveDay = computed(() => { const { counts, dayNames } = this.dayOfWeekCounts(); - const maxIdx = counts.indexOf(Math.max(...counts)); - return dayNames[maxIdx]; + const max = Math.max(...counts); + if (max === 0) return '—'; + return dayNames[counts.indexOf(max)]; }); mostActiveDayCount = computed(() => { @@ -498,7 +499,7 @@ export class StatsComponent { mostActiveTimeOfDay = computed(() => { const periods = this.timeOfDayCounts(); - let maxKey = 'Morning'; + let maxKey = '—'; let maxVal = 0; for (const [key, val] of Object.entries(periods)) { if (val > maxVal) { From 2ceab5390d04799830d60dd710c08ddeec65e876 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:00:49 +0200 Subject: [PATCH 07/15] Fix pluralization of average completion time --- src/app/components/stats/stats.component.ts | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index 35f567e..a8746d3 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -365,9 +365,10 @@ export class StatsComponent { return sum + (lastWatch - created); }, 0); const avgDays = totalMs / completed.length / (1000 * 60 * 60 * 24); - if (avgDays < 1) return '< 1 day'; - if (avgDays === 1) return '1 day'; - return `${Math.round(avgDays)} days`; + const rounded = Math.round(avgDays); + if (rounded <= 0) return '< 1 day'; + if (rounded === 1) return '1 day'; + return `${rounded} days`; }); private static toLocalDateKey(date: Date): string { From 0601c5a6812eb50e49702e4e440853ca58be077b Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:06:47 +0200 Subject: [PATCH 08/15] Allow current streak to continue from yesterday if today has no entry --- src/app/components/stats/stats.component.ts | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index a8746d3..f57f924 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -400,10 +400,16 @@ export class StatsComponent { if (dates.size === 0) return 0; const today = new Date(); - - let streak = 0; const current = new Date(today.getFullYear(), today.getMonth(), today.getDate()); + if (!dates.has(StatsComponent.toLocalDateKey(current))) { + current.setDate(current.getDate() - 1); + if (!dates.has(StatsComponent.toLocalDateKey(current))) { + return 0; + } + } + + let streak = 0; while (true) { const key = StatsComponent.toLocalDateKey(current); if (dates.has(key)) { From 46e53094a12833c7b77773073113d7fd38bdd769 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:21:35 +0200 Subject: [PATCH 09/15] Memoize seriesByStatus/moviesByStatus as computed signals --- src/app/components/stats/stats.component.ts | 39 ++++++++++++++------- 1 file changed, 26 insertions(+), 13 deletions(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index f57f924..f132366 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -2,7 +2,6 @@ import { Component, computed, inject } from '@angular/core'; import { RouterLink } from '@angular/router'; import { WatchListService } from '../../services/watch-list.service'; import { StatsHeatmapComponent } from './stats-heatmap/stats-heatmap.component'; -import { Item } from '../../models/item.model'; @Component({ selector: 'app-stats', @@ -188,25 +187,25 @@ import { Item } from '../../models/item.model';
In Progress {{ - seriesByStatus('in-progress') + seriesCountByStatus()['in-progress'] ?? 0 }}
Completed {{ - seriesByStatus('completed') + seriesCountByStatus()['completed'] ?? 0 }}
Dropped {{ - seriesByStatus('dropped') + seriesCountByStatus()['dropped'] ?? 0 }}
Not Started {{ - seriesByStatus('not-started') + seriesCountByStatus()['not-started'] ?? 0 }}
In Progress {{ - moviesByStatus('in-progress') + moviesCountByStatus()['in-progress'] ?? 0 }}
Completed {{ - moviesByStatus('completed') + moviesCountByStatus()['completed'] ?? 0 }}
Dropped {{ - moviesByStatus('dropped') + moviesCountByStatus()['dropped'] ?? 0 }}
Not Started {{ - moviesByStatus('not-started') + moviesCountByStatus()['not-started'] ?? 0 }}
- this.items().filter((i) => i.type === 'series' && i.status === status).length; + private seriesCountByStatus = computed(() => { + const counts: Record = {}; + for (const item of this.items()) { + if (item.type === 'series') { + counts[item.status] = (counts[item.status] ?? 0) + 1; + } + } + return counts; + }); - moviesByStatus = (status: Item['status']): number => - this.items().filter((i) => i.type === 'movie' && i.status === status).length; + private moviesCountByStatus = computed(() => { + const counts: Record = {}; + for (const item of this.items()) { + if (item.type === 'movie') { + counts[item.status] = (counts[item.status] ?? 0) + 1; + } + } + return counts; + }); mostWatchedItems = computed(() => { return [...this.items()] From 9c35bd835d8436e961e42a3728394c4d72412cc4 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:23:25 +0200 Subject: [PATCH 10/15] Fix build: remove private modifier and unnecessary nullish coalescing --- src/app/components/stats/stats.component.ts | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index f132366..e8f785d 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -187,25 +187,25 @@ import { StatsHeatmapComponent } from './stats-heatmap/stats-heatmap.component';
In Progress {{ - seriesCountByStatus()['in-progress'] ?? 0 + seriesCountByStatus()['in-progress'] }}
Completed {{ - seriesCountByStatus()['completed'] ?? 0 + seriesCountByStatus()['completed'] }}
Dropped {{ - seriesCountByStatus()['dropped'] ?? 0 + seriesCountByStatus()['dropped'] }}
Not Started {{ - seriesCountByStatus()['not-started'] ?? 0 + seriesCountByStatus()['not-started'] }}
In Progress {{ - moviesCountByStatus()['in-progress'] ?? 0 + moviesCountByStatus()['in-progress'] }}
Completed {{ - moviesCountByStatus()['completed'] ?? 0 + moviesCountByStatus()['completed'] }}
Dropped {{ - moviesCountByStatus()['dropped'] ?? 0 + moviesCountByStatus()['dropped'] }}
Not Started {{ - moviesCountByStatus()['not-started'] ?? 0 + moviesCountByStatus()['not-started'] }}
{ + seriesCountByStatus = computed(() => { const counts: Record = {}; for (const item of this.items()) { if (item.type === 'series') { @@ -340,7 +340,7 @@ export class StatsComponent { return counts; }); - private moviesCountByStatus = computed(() => { + moviesCountByStatus = computed(() => { const counts: Record = {}; for (const item of this.items()) { if (item.type === 'movie') { From fbce1abb6e68ff3c2250d8b90a64530a4902bdf6 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:30:06 +0200 Subject: [PATCH 11/15] Initialize all status keys to 0 in breakdown counts --- src/app/components/stats/stats.component.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index e8f785d..2ec5878 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -331,20 +331,20 @@ export class StatsComponent { }); seriesCountByStatus = computed(() => { - const counts: Record = {}; + const counts: Record = { 'not-started': 0, 'in-progress': 0, 'completed': 0, 'dropped': 0 }; for (const item of this.items()) { if (item.type === 'series') { - counts[item.status] = (counts[item.status] ?? 0) + 1; + counts[item.status]++; } } return counts; }); moviesCountByStatus = computed(() => { - const counts: Record = {}; + const counts: Record = { 'not-started': 0, 'in-progress': 0, 'completed': 0, 'dropped': 0 }; for (const item of this.items()) { if (item.type === 'movie') { - counts[item.status] = (counts[item.status] ?? 0) + 1; + counts[item.status]++; } } return counts; From 67bb0d05dd4bd80e4ce76131830ac127e75f4557 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:32:05 +0200 Subject: [PATCH 12/15] Run prettier on stats component --- src/app/components/stats/stats.component.ts | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/src/app/components/stats/stats.component.ts b/src/app/components/stats/stats.component.ts index 2ec5878..d7cbbcb 100644 --- a/src/app/components/stats/stats.component.ts +++ b/src/app/components/stats/stats.component.ts @@ -331,7 +331,12 @@ export class StatsComponent { }); seriesCountByStatus = computed(() => { - const counts: Record = { 'not-started': 0, 'in-progress': 0, 'completed': 0, 'dropped': 0 }; + const counts: Record = { + 'not-started': 0, + 'in-progress': 0, + completed: 0, + dropped: 0, + }; for (const item of this.items()) { if (item.type === 'series') { counts[item.status]++; @@ -341,7 +346,12 @@ export class StatsComponent { }); moviesCountByStatus = computed(() => { - const counts: Record = { 'not-started': 0, 'in-progress': 0, 'completed': 0, 'dropped': 0 }; + const counts: Record = { + 'not-started': 0, + 'in-progress': 0, + completed: 0, + dropped: 0, + }; for (const item of this.items()) { if (item.type === 'movie') { counts[item.status]++; From bbbe6c93c8ff1006115bfa046c9a11e13d7e8eb1 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:41:57 +0200 Subject: [PATCH 13/15] Align heatmap month labels above their starting week column --- .../stats/stats-heatmap/stats-heatmap.component.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts index 4c8497b..5f8b542 100644 --- a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts +++ b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts @@ -17,11 +17,11 @@ interface MonthLabel { template: `
-
+
@for (month of monthLabels(); track month.label + month.weekIndex) { {{ month.label }} From df9d9b402132ca0374a3806acec0d7225c4caa21 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:48:59 +0200 Subject: [PATCH 14/15] Account for day-label column offset in heatmap month label positioning --- .../components/stats/stats-heatmap/stats-heatmap.component.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts index 5f8b542..a64ad41 100644 --- a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts +++ b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts @@ -17,11 +17,11 @@ interface MonthLabel { template: `
-
+
@for (month of monthLabels(); track month.label + month.weekIndex) { {{ month.label }} From 2aa027ab002315e01839a1aec873b6cc46173981 Mon Sep 17 00:00:00 2001 From: Ma <101021254+CodeWithMa@users.noreply.github.com> Date: Sat, 18 Jul 2026 08:49:32 +0200 Subject: [PATCH 15/15] Remove unused data-tooltip attribute from heatmap cells --- .../components/stats/stats-heatmap/stats-heatmap.component.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts index a64ad41..af83de4 100644 --- a/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts +++ b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts @@ -46,7 +46,6 @@ interface MonthLabel {