Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
dc53321
refactor: redesign Stats page with Spotify-inspired card layout
lui7henrique Feb 18, 2026
c45448e
feat: enhance stats functionality and UI in ProfileStatsView
lui7henrique Feb 18, 2026
f35904c
refactor: update StatsShareCardView layout and rendering for improved UI
lui7henrique Feb 18, 2026
3b6c4b5
feat: improve stats timeline UX and share card
lui7henrique Feb 18, 2026
8840d34
feat: enhance ProfileStatsView and related components for improved UX
lui7henrique Feb 18, 2026
472fe92
feat: conditionally display share button in ProfileStatsView for own …
lui7henrique Feb 18, 2026
2d25476
feat: implement user stats timeline service and UI components
lui7henrique Feb 18, 2026
8e2cfa8
feat: enhance user stats services and introduce stats timeline feature
lui7henrique Feb 18, 2026
02d44de
feat: update user stats service to enhance genre data handling
lui7henrique Feb 18, 2026
e3321e7
feat: enhance user total hours service with additional metrics and im…
lui7henrique Feb 19, 2026
fd6a691
feat: enhance TimeWatchedDetailView with improved data loading and vi…
lui7henrique Feb 19, 2026
8d90515
feat: replace timeline/general tabs with month dropdown selector
lui7henrique Feb 19, 2026
3328773
feat: add PeriodGenresView and PeriodReviewsView components for enhan…
lui7henrique Feb 19, 2026
5b8cb37
feat: refactor scrolling behavior in PeriodGenresView, PeriodReviewsV…
lui7henrique Feb 19, 2026
ec66eee
feat: enhance SEO and structured data integration across the application
lui7henrique Feb 27, 2026
cf62045
fix: update import statement and enhance CSS for better mobile input …
lui7henrique Feb 27, 2026
15bb0f5
Merge branch 'main' of https://github.com/plotwist-app/plotwist into …
lui7henrique Mar 1, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions apps/backend/src/domain/entities/user-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ export type SelectUserItems = {
export type SelectAllUserItems = {
status?: UserItemStatus | 'ALL'
userId: string
startDate?: Date
endDate?: Date
}
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 }
}
31 changes: 18 additions & 13 deletions apps/backend/src/domain/services/user-stats/cache-utils.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,7 @@
import type { FastifyRedis } from '@fastify/redis'

// Cache duration for computed statistics (1 hour)
// Short TTL ensures data freshness while still providing significant performance benefit
const STATS_CACHE_TTL_SECONDS = 60 * 60
const STATS_CACHE_SHORT_TTL_SECONDS = 10 * 60

/**
* Helper to cache computed statistics results
Expand All @@ -20,12 +19,17 @@ export async function getCachedStats<T>(
}

const result = await computeFn()
await redis.set(
cacheKey,
JSON.stringify(result),
'EX',
STATS_CACHE_TTL_SECONDS
)

const yearMonthPattern = /:\d{4}-\d{2}$/
const isPeriodScoped =
cacheKey.endsWith(':month') ||
cacheKey.endsWith(':last_month') ||
yearMonthPattern.test(cacheKey)
const ttl = isPeriodScoped
? STATS_CACHE_SHORT_TTL_SECONDS
: STATS_CACHE_TTL_SECONDS

await redis.set(cacheKey, JSON.stringify(result), 'EX', ttl)

return result
}
Expand All @@ -52,10 +56,11 @@ export async function invalidateUserStatsCache(
export function getUserStatsCacheKey(
userId: string,
statType: string,
language?: string
language?: string,
period?: string
): string {
if (language) {
return `user-stats:${userId}:${statType}:${language}`
}
return `user-stats:${userId}:${statType}`
const parts = ['user-stats', userId, statType]
if (language) parts.push(language)
if (period && period !== 'all') parts.push(period)
return parts.join(':')
Copy link

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

getUserStatsCacheKey appends language and period as positional parts without labels, so a key with language=undefined, period="en-US" would collide with language="en-US", period="all". For example, watched-cast uses language=undefined and period="en-US" (a YYYY-MM looking value aside), while other stat types use language="en-US" and period="all". Since period="all" is filtered out, collisions between unlabeled positional segments are possible.

Fix in Cursor Fix in Web

}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FastifyRedis } from '@fastify/redis'
import type { Language } from '@plotwist_app/tmdb'
import type { StatsPeriod } from '@/infra/http/schemas/common'
import { selectBestReviews } from '@/infra/db/repositories/reviews-repository'
import { getTMDBMovieService } from '../tmdb/get-tmdb-movie'
import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series'
Expand All @@ -10,17 +11,23 @@ type GetUserBestReviewsServiceInput = {
redis: FastifyRedis
language: Language
limit?: number
dateRange?: { startDate: Date | undefined; endDate: Date | undefined }
period?: StatsPeriod
}

export async function getUserBestReviewsService({
userId,
language,
redis,
limit,
dateRange,
}: GetUserBestReviewsServiceInput) {
// Note: Not using cache here because createdAt Date serialization
// causes issues when retrieved from Redis (Date becomes string)
const bestReviews = await selectBestReviews(userId, limit)
const bestReviews = await selectBestReviews(
userId,
limit,
dateRange?.startDate,
dateRange?.endDate
)

const formattedBestReviews = await processInBatches(
bestReviews,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,18 @@ import { selectUserItemStatus } from '@/infra/db/repositories/user-item-reposito

type GetUserItemsStatusServiceInput = {
userId: string
dateRange?: { startDate: Date | undefined; endDate: Date | undefined }
}

export async function getUserItemsStatusService({
userId,
dateRange,
}: GetUserItemsStatusServiceInput) {
const userItems = await selectUserItemStatus(userId)
const userItems = await selectUserItemStatus(
userId,
dateRange?.startDate,
dateRange?.endDate
)

return {
userItems,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { FastifyRedis } from '@fastify/redis'
import type { Language } from '@plotwist_app/tmdb'
import type { StatsPeriod } from '@/infra/http/schemas/common'
import { selectMostWatched } from '@/infra/db/repositories/user-episode'
import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series'
import { processInBatches } from './batch-utils'
Expand All @@ -8,14 +9,21 @@ type GetUserMostWatchedSeriesServiceInput = {
userId: string
redis: FastifyRedis
language: Language
dateRange?: { startDate: Date | undefined; endDate: Date | undefined }
period?: StatsPeriod
}

export async function getUserMostWatchedSeriesService({
userId,
language,
redis,
dateRange,
}: GetUserMostWatchedSeriesServiceInput) {
const mostWatchedSeries = await selectMostWatched(userId)
const mostWatchedSeries = await selectMostWatched(
userId,
dateRange?.startDate,
dateRange?.endDate
)

const formattedMostWatchedSeries = await processInBatches(
mostWatchedSeries,
Expand Down
119 changes: 119 additions & 0 deletions apps/backend/src/domain/services/user-stats/get-user-stats-timeline.ts
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++
}
Copy link

Choose a reason for hiding this comment

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

Timeline N+1 sequential calls creates severe latency

High Severity

getUserStatsTimelineService calls three heavy services (getUserTotalHoursService, getUserWatchedGenresService, getUserBestReviewsService) inside a while loop that can iterate up to 24 times. Each service itself does DB queries and external TMDB API calls in batches. On a cold cache, a single timeline request could trigger dozens of DB queries and hundreds of TMDB calls sequentially, leading to extreme response times.

Fix in Cursor Fix in Web


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')}`
}
Loading
Loading