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..af83de4 --- /dev/null +++ b/src/app/components/stats/stats-heatmap/stats-heatmap.component.ts @@ -0,0 +1,193 @@ +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 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 = StatsHeatmapComponent.toLocalDateKeyFromISO(entry.date); + 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 = StatsHeatmapComponent.toLocalDateKey(current); + 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 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) { + 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 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 new file mode 100644 index 0000000..d7cbbcb --- /dev/null +++ b/src/app/components/stats/stats.component.ts @@ -0,0 +1,547 @@ +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'; + +@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 + {{ + seriesCountByStatus()['in-progress'] + }} + + + Completed + {{ + seriesCountByStatus()['completed'] + }} + + + Dropped + {{ + seriesCountByStatus()['dropped'] + }} + + + Not Started + {{ + seriesCountByStatus()['not-started'] + }} + + + Avg. Episodes / Active + {{ + avgEpisodesPerActiveSeries() + }} + + + + + + Movies + + + + In Progress + {{ + moviesCountByStatus()['in-progress'] + }} + + + Completed + {{ + moviesCountByStatus()['completed'] + }} + + + Dropped + {{ + moviesCountByStatus()['dropped'] + }} + + + Not Started + {{ + moviesCountByStatus()['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; + }); + + seriesCountByStatus = computed(() => { + 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]++; + } + } + return counts; + }); + + moviesCountByStatus = computed(() => { + 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]++; + } + } + return counts; + }); + + 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.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; + return sum + (lastWatch - created); + }, 0); + const avgDays = totalMs / completed.length / (1000 * 60 * 60 * 24); + 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 { + 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(StatsComponent.toLocalDateKeyFromISO(entry.date)); + } + 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(); + 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)) { + streak++; + current.setDate(current.getDate() - 1); + } else { + break; + } + } + + 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; + + let longest = 1; + let current = 1; + + 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); + + if (StatsComponent.daysBetween(py, pm, pd, cy, cm, cd) === 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 max = Math.max(...counts); + if (max === 0) return '—'; + return dayNames[counts.indexOf(max)]; + }); + + 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 = '—'; + 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)); + }); +}
+ No data to show yet. +
+ Add items and start watching to see your stats! +