-
Notifications
You must be signed in to change notification settings - Fork 33
Revamp/stats #499
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Revamp/stats #499
Changes from all commits
dc53321
c45448e
f35904c
3b6c4b5
8840d34
472fe92
2d25476
8e2cfa8
02d44de
e3321e7
fd6a691
8d90515
3328773
5b8cb37
ec66eee
cf62045
15bb0f5
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,11 +1,23 @@ | ||
| import { selectUserEpisodes } from '@/infra/db/repositories/user-episode' | ||
|
|
||
| export type GetUserEpisodesInput = { userId: string; tmdbId?: number } | ||
| export type GetUserEpisodesInput = { | ||
| userId: string | ||
| tmdbId?: number | ||
| startDate?: Date | ||
| endDate?: Date | ||
| } | ||
|
|
||
| export async function getUserEpisodesService({ | ||
| userId, | ||
| tmdbId, | ||
| startDate, | ||
| endDate, | ||
| }: GetUserEpisodesInput) { | ||
| const userEpisodes = await selectUserEpisodes({ userId, tmdbId }) | ||
| const userEpisodes = await selectUserEpisodes({ | ||
| userId, | ||
| tmdbId, | ||
| startDate, | ||
| endDate, | ||
| }) | ||
| return { userEpisodes } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| import type { FastifyRedis } from '@fastify/redis' | ||
| import type { Language } from '@plotwist_app/tmdb' | ||
| import { periodToDateRange } from '@/http/schemas/common' | ||
| import { getUserBestReviewsService } from './get-user-best-reviews' | ||
| import { getUserTotalHoursService } from './get-user-total-hours' | ||
| import { getUserWatchedGenresService } from './get-user-watched-genres' | ||
|
|
||
| type GetUserStatsTimelineInput = { | ||
| userId: string | ||
| redis: FastifyRedis | ||
| language: Language | ||
| cursor?: string | ||
| pageSize?: number | ||
| } | ||
|
|
||
| export async function getUserStatsTimelineService({ | ||
| userId, | ||
| redis, | ||
| language, | ||
| cursor, | ||
| pageSize = 3, | ||
| }: GetUserStatsTimelineInput) { | ||
| const maxScanMonths = 24 | ||
| const sections: Array<{ | ||
| yearMonth: string | ||
| totalHours: number | ||
| movieHours: number | ||
| seriesHours: number | ||
| topGenre: { name: string; posterPath: string | null } | null | ||
| topReview: { | ||
| title: string | ||
| posterPath: string | null | ||
| rating: number | ||
| } | null | ||
| }> = [] | ||
|
|
||
| let currentYM = cursor || getCurrentYearMonth() | ||
| let scanned = 0 | ||
|
|
||
| while (sections.length < pageSize && scanned < maxScanMonths) { | ||
| const period = currentYM | ||
| const dateRange = periodToDateRange(period) | ||
|
|
||
| const [hours, genres, reviews] = await Promise.all([ | ||
| getUserTotalHoursService(userId, redis, period, dateRange), | ||
| getUserWatchedGenresService({ | ||
| userId, | ||
| redis, | ||
| language, | ||
| dateRange, | ||
| period, | ||
| }), | ||
| getUserBestReviewsService({ | ||
| userId, | ||
| redis, | ||
| language, | ||
| limit: 1, | ||
| dateRange, | ||
| period, | ||
| }), | ||
| ]) | ||
|
|
||
| const hasData = | ||
| hours.totalHours > 0 || | ||
| genres.genres.length > 0 || | ||
| reviews.bestReviews.length > 0 | ||
|
|
||
| if (hasData) { | ||
| const topGenreData = genres.genres[0] | ||
| const topReviewData = reviews.bestReviews[0] as | ||
| | Record<string, unknown> | ||
| | undefined | ||
|
|
||
| sections.push({ | ||
| yearMonth: currentYM, | ||
| totalHours: hours.totalHours, | ||
| movieHours: hours.movieHours, | ||
| seriesHours: hours.seriesHours, | ||
| topGenre: topGenreData | ||
| ? { | ||
| name: topGenreData.name, | ||
| posterPath: topGenreData.posterPath, | ||
| } | ||
| : null, | ||
| topReview: topReviewData | ||
| ? { | ||
| title: topReviewData.title as string, | ||
| posterPath: (topReviewData.posterPath ?? null) as string | null, | ||
| rating: topReviewData.rating as number, | ||
| } | ||
| : null, | ||
| }) | ||
| } | ||
|
|
||
| currentYM = getPreviousYearMonth(currentYM) | ||
| scanned++ | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Timeline N+1 sequential calls creates severe latencyHigh Severity
|
||
|
|
||
| const hasMore = sections.length >= pageSize && scanned < maxScanMonths | ||
|
|
||
| return { | ||
| sections, | ||
| nextCursor: hasMore ? currentYM : null, | ||
| hasMore, | ||
| } | ||
| } | ||
|
|
||
| function getCurrentYearMonth(): string { | ||
| const now = new Date() | ||
| return `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}` | ||
| } | ||
|
|
||
| function getPreviousYearMonth(ym: string): string { | ||
| const [year, month] = ym.split('-').map(Number) | ||
| if (month === 1) { | ||
| return `${year - 1}-12` | ||
| } | ||
| return `${year}-${String(month - 1).padStart(2, '0')}` | ||
| } | ||


There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cache key collision between period-scoped and language-scoped stats
Medium Severity
getUserStatsCacheKeyappendslanguageandperiodas positional parts without labels, so a key withlanguage=undefined, period="en-US"would collide withlanguage="en-US", period="all". For example,watched-castuseslanguage=undefinedandperiod="en-US"(a YYYY-MM looking value aside), while other stat types uselanguage="en-US"andperiod="all". Sinceperiod="all"is filtered out, collisions between unlabeled positional segments are possible.