Skip to content
Merged
Show file tree
Hide file tree
Changes from 16 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
29 changes: 20 additions & 9 deletions apps/backend/src/db/repositories/reviews-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,18 +150,29 @@ export async function selectReviewsCount(userId?: string) {
.where(userId ? eq(schema.reviews.userId, userId) : undefined)
}

export async function selectBestReviews(userId: string, limit = 10) {
export async function selectBestReviews(
userId: string,
limit = 10,
startDate?: Date,
endDate?: Date
) {
const whereConditions = [
eq(schema.reviews.userId, userId),
sql`${schema.reviews.seasonNumber} IS NULL`,
sql`${schema.reviews.episodeNumber} IS NULL`,
]

if (startDate) {
whereConditions.push(gte(schema.reviews.createdAt, startDate))
}
if (endDate) {
whereConditions.push(lte(schema.reviews.createdAt, endDate))
}

return db
.select()
.from(schema.reviews)
.where(
and(
eq(schema.reviews.userId, userId),
eq(schema.reviews.rating, 5),
sql`${schema.reviews.seasonNumber} IS NULL`,
sql`${schema.reviews.episodeNumber} IS NULL`
)
)
.where(and(...whereConditions))
.orderBy(desc(schema.reviews.rating), desc(schema.reviews.createdAt))
.limit(limit)
}
Expand Down
42 changes: 32 additions & 10 deletions apps/backend/src/db/repositories/user-episode.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { and, count, desc, eq, inArray } from 'drizzle-orm'
import { and, count, desc, eq, gte, inArray, lte } from 'drizzle-orm'
import type { InsertUserEpisode } from '@/domain/entities/user-episode'
import type { GetUserEpisodesInput } from '@/domain/services/user-episodes/get-user-episodes'
import { db } from '..'
Expand All @@ -15,16 +15,25 @@ export async function insertUserEpisodes(values: InsertUserEpisode[]) {
export async function selectUserEpisodes({
userId,
tmdbId,
}: GetUserEpisodesInput) {
startDate,
endDate,
}: GetUserEpisodesInput & { startDate?: Date; endDate?: Date }) {
const whereConditions = [
eq(schema.userEpisodes.userId, userId),
tmdbId ? eq(schema.userEpisodes.tmdbId, tmdbId) : undefined,
].filter(Boolean)

if (startDate) {
whereConditions.push(gte(schema.userEpisodes.watchedAt, startDate))
}
if (endDate) {
whereConditions.push(lte(schema.userEpisodes.watchedAt, endDate))
}

return db
.select()
.from(schema.userEpisodes)
.where(
and(
eq(schema.userEpisodes.userId, userId),
tmdbId ? eq(schema.userEpisodes.tmdbId, tmdbId) : undefined
)
)
.where(and(...whereConditions))
.orderBy(schema.userEpisodes.episodeNumber)
}

Expand All @@ -34,11 +43,24 @@ export async function deleteUserEpisodes(ids: string[]) {
.where(inArray(schema.userEpisodes.id, ids))
}

export async function selectMostWatched(userId: string) {
export async function selectMostWatched(
userId: string,
startDate?: Date,
endDate?: Date
) {
const whereConditions = [eq(schema.userEpisodes.userId, userId)]

if (startDate) {
whereConditions.push(gte(schema.userEpisodes.watchedAt, startDate))
}
if (endDate) {
whereConditions.push(lte(schema.userEpisodes.watchedAt, endDate))
}

return db
.select({ count: count(), tmdbId: schema.userEpisodes.tmdbId })
.from(schema.userEpisodes)
.where(and(eq(schema.userEpisodes.userId, userId)))
.where(and(...whereConditions))
.groupBy(schema.userEpisodes.tmdbId)
.orderBy(desc(count()))
.limit(3)
Expand Down
26 changes: 24 additions & 2 deletions apps/backend/src/db/repositories/user-item-repository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
desc,
eq,
getTableColumns,
gte,
inArray,
isNull,
lte,
Expand Down Expand Up @@ -137,21 +138,36 @@ export async function selectUserItem({
.limit(1)
}

export async function selectUserItemStatus(userId: string) {
export async function selectUserItemStatus(
userId: string,
startDate?: Date,
endDate?: Date
) {
const whereConditions = [eq(schema.userItems.userId, userId)]

if (startDate) {
whereConditions.push(gte(schema.userItems.updatedAt, startDate))
}
if (endDate) {
whereConditions.push(lte(schema.userItems.updatedAt, endDate))
}

return db
.select({
status: schema.userItems.status,
count: sql`COUNT(*)::int`,
percentage: sql`(COUNT(*) * 100.0 / SUM(COUNT(*)) OVER ())::float`,
})
.from(schema.userItems)
.where(eq(schema.userItems.userId, userId))
.where(and(...whereConditions))
.groupBy(schema.userItems.status)
}

export async function selectAllUserItemsByStatus({
status,
userId,
startDate,
endDate,
}: SelectAllUserItems) {
const { id, tmdbId, mediaType, position, updatedAt } = getTableColumns(schema.userItems)

Expand All @@ -160,6 +176,12 @@ export async function selectAllUserItemsByStatus({
if (status !== 'ALL') {
whereConditions.push(eq(schema.userItems.status, status as UserItemStatus))
}
if (startDate) {
whereConditions.push(gte(schema.userItems.updatedAt, startDate))
}
if (endDate) {
whereConditions.push(lte(schema.userItems.updatedAt, endDate))
}
return db
.select({
id,
Expand Down
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 '@/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
Copy Markdown

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 '@/http/schemas/common'
import { selectBestReviews } from '@/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 '@/db/repositories/user-item-repository'

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 '@/http/schemas/common'
import { selectMostWatched } from '@/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
Loading