Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
5 changes: 3 additions & 2 deletions apps/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,11 +39,14 @@
"@fastify/swagger-ui": "^5.2.4",
"@kubiks/otel-drizzle": "^2.1.0",
"@opentelemetry/api": "^1.9.0",
"@opentelemetry/api-logs": "^0.211.0",
"@opentelemetry/exporter-logs-otlp-proto": "^0.211.0",
"@opentelemetry/exporter-metrics-otlp-proto": "^0.211.0",
"@opentelemetry/exporter-trace-otlp-proto": "^0.211.0",
"@opentelemetry/host-metrics": "^0.38.2",
"@opentelemetry/instrumentation-http": "^0.212.0",
"@opentelemetry/resources": "^2.5.0",
"@opentelemetry/sdk-logs": "^0.211.0",
"@opentelemetry/sdk-metrics": "^2.5.0",
"@opentelemetry/sdk-node": "^0.211.0",
"@opentelemetry/semantic-conventions": "^1.39.0",
Expand Down Expand Up @@ -73,8 +76,6 @@
"jwks-rsa": "^3.1.0",
"node-cron": "^4.2.1",
"openai": "^6.15.0",
"pino": "^10.1.0",
"pino-pretty": "^13.1.3",
"postgres": "^3.4.7",
"puppeteer": "^24.34.0",
"react": "^19.2.3",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,20 @@ import { makeUser } from '@/test/factories/make-user'
import { makeUserImport } from '@/test/factories/make-user-import'
import { getDetailedUserImportById } from './get-detailed-user-import-by-id'

function sortImportResult<
T extends { series?: { id: string }[]; movies?: { id: string }[] },
>(r: T): T {
return {
...r,
...(r.series && {
series: [...r.series].sort((a, b) => a.id.localeCompare(b.id)),
}),
...(r.movies && {
movies: [...r.movies].sort((a, b) => a.id.localeCompare(b.id)),
}),
}
}

describe('get user import', () => {
it('should be able to get user import by id', async () => {
const { id: userId } = await makeUser({})
Expand All @@ -17,7 +31,7 @@ describe('get user import', () => {

const sut = await getDetailedUserImportById(result.id)

expect(sut).toEqual(result)
expect(sortImportResult(sut)).toEqual(sortImportResult(result))
})

it('should be able to get user import by id when movies are empty', async () => {
Expand All @@ -33,7 +47,7 @@ describe('get user import', () => {

const sut = await getDetailedUserImportById(result.id)

expect(sut).toEqual(result)
expect(sortImportResult(sut)).toEqual(sortImportResult(result))
})

it('should be able to get user import by id when series are empty', async () => {
Expand All @@ -46,6 +60,6 @@ describe('get user import', () => {

const sut = await getDetailedUserImportById(result.id)

expect(sut).toEqual(result)
expect(sortImportResult(sut)).toEqual(sortImportResult(result))
})
})
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 type { StatsPeriod } from '@/infra/http/schemas/common'
import { getTMDBMovieService } from '../tmdb/get-tmdb-movie'
import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series'
import { processInBatches } from './batch-utils'
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
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 type { StatsPeriod } from '@/infra/http/schemas/common'
import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series'
import { processInBatches } from './batch-utils'

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -131,12 +131,89 @@ describe('get user total hours count', () => {
movieHours: INCEPTION.runtime,
seriesHours: CHERNOBYL.runtime,
})
expect(sut.monthlyHours).toHaveLength(6)
expect(sut.monthlyHours).toHaveLength(12)
expect(
sut.monthlyHours.every(
(m: { month: string; hours: number }) =>
typeof m.month === 'string' && typeof m.hours === 'number'
)
).toBe(true)
expect(Array.isArray(sut.dailyActivity)).toBe(true)
expect(
sut.dailyActivity.every(
(d: { day: string; hours: number }) =>
typeof d.day === 'string' && typeof d.hours === 'number'
)
).toBe(true)
expect(sut.dailyActivity.length).toBeGreaterThan(0)
})

it('should return empty dailyActivity for period "all" when user has no watched items', async () => {
const user = await makeUser()

const sut = await getUserTotalHoursService(user.id, redisClient, 'all')

expect(sut.dailyActivity).toEqual([])
})

it('should return dailyActivity array for period "month"', async () => {
const user = await makeUser()

await makeUserItem({
userId: user.id,
tmdbId: INCEPTION.tmdbId,
mediaType: INCEPTION.mediaType,
status: 'WATCHED',
})

const sut = await getUserTotalHoursService(user.id, redisClient, 'month')

expect(Array.isArray(sut.dailyActivity)).toBe(true)
expect(
sut.dailyActivity.every(
(d: { day: string; hours: number }) =>
typeof d.day === 'string' && typeof d.hours === 'number'
)
).toBe(true)
expect(sut.movieHours).toBe(INCEPTION.runtime)
})

it('should return dailyActivity array for period "year"', async () => {
const user = await makeUser()

await makeUserItem({
userId: user.id,
tmdbId: INCEPTION.tmdbId,
mediaType: INCEPTION.mediaType,
status: 'WATCHED',
})

const sut = await getUserTotalHoursService(user.id, redisClient, 'year')

expect(Array.isArray(sut.dailyActivity)).toBe(true)
expect(
sut.dailyActivity.every(
(d: { day: string; hours: number }) =>
typeof d.day === 'string' && typeof d.hours === 'number'
)
).toBe(true)
})

it('should return dailyActivity array for period "last_month"', async () => {
const user = await makeUser()

const sut = await getUserTotalHoursService(
user.id,
redisClient,
'last_month'
)

expect(Array.isArray(sut.dailyActivity)).toBe(true)
expect(
sut.dailyActivity.every(
(d: { day: string; hours: number }) =>
typeof d.day === 'string' && typeof d.hours === 'number'
)
).toBe(true)
})
})
65 changes: 39 additions & 26 deletions apps/backend/src/domain/services/user-stats/get-user-total-hours.ts
Original file line number Diff line number Diff line change
Expand Up @@ -200,39 +200,52 @@ function computeDailyBreakdown(
}))
}

function getDateRangeForPeriod(
period: StatsPeriod,
now: Date,
movieData: { date: Date | null }[],
episodes: { watchedAt: Date | null }[]
): { startDate: Date; endDate: Date } | null {
if (period === 'month') {
return {
startDate: new Date(now.getFullYear(), now.getMonth(), 1),
endDate: now,
}
}
if (period === 'last_month') {
return {
startDate: new Date(now.getFullYear(), now.getMonth() - 1, 1),
endDate: new Date(now.getFullYear(), now.getMonth(), 0),
}
}
if (period === 'year') {
return {
startDate: new Date(now.getFullYear(), 0, 1),
endDate: now,
}
}
const allTimestamps = [
...movieData.flatMap(m => (m.date ? [m.date.getTime()] : [])),
...episodes.flatMap(e => (e.watchedAt ? [e.watchedAt.getTime()] : [])),
]
if (allTimestamps.length === 0) return null
const start = new Date(Math.min(...allTimestamps))
return {
startDate: new Date(start.getFullYear(), start.getMonth(), start.getDate()),
endDate: now,
}
}

function computeAllDailyActivity(
movieData: { runtime: number; date: Date | null }[],
episodes: { runtime: number; watchedAt: Date | null }[],
period: StatsPeriod
) {
const now = new Date()
let startDate: Date
let endDate: Date

if (period === 'month') {
startDate = new Date(now.getFullYear(), now.getMonth(), 1)
endDate = now
} else if (period === 'last_month') {
startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1)
endDate = new Date(now.getFullYear(), now.getMonth(), 0)
} else if (period === 'year') {
startDate = new Date(now.getFullYear(), 0, 1)
endDate = now
} else {
const allDates = [
...movieData.filter(m => m.date).map(m => m.date!.getTime()),
...episodes.filter(e => e.watchedAt).map(e => e.watchedAt!.getTime()),
]
if (allDates.length === 0) return []
startDate = new Date(Math.min(...allDates))
startDate = new Date(
startDate.getFullYear(),
startDate.getMonth(),
startDate.getDate()
)
endDate = now
}
const range = getDateRangeForPeriod(period, now, movieData, episodes)
if (!range) return []

const { startDate, endDate } = range
const dayMap = new Map<string, number>()
const cursor = new Date(startDate)
while (cursor <= endDate) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import type { FastifyRedis } from '@fastify/redis'
import type { StatsPeriod } from '@/infra/http/schemas/common'
import { selectAllUserItemsByStatus } from '@/infra/db/repositories/user-item-repository'
import type { StatsPeriod } from '@/infra/http/schemas/common'
import { getTMDBCredits } from '../tmdb/get-tmdb-credits'
import { processInBatches } from './batch-utils'
import { getCachedStats, getUserStatsCacheKey } from './cache-utils'
Expand All @@ -18,7 +18,12 @@ export async function getUserWatchedCastService({
dateRange,
period = 'all',
}: GetUserWatchedCastServiceInput) {
const cacheKey = getUserStatsCacheKey(userId, 'watched-cast', undefined, period)
const cacheKey = getUserStatsCacheKey(
userId,
'watched-cast',
undefined,
period
)

return getCachedStats(redis, cacheKey, async () => {
const watchedItems = await selectAllUserItemsByStatus({
Expand Down
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import type { FastifyRedis } from '@fastify/redis'
import type { Language } from '@plotwist_app/tmdb'
import type { StatsPeriod } from '@/infra/http/schemas/common'
import { selectAllUserItemsByStatus } from '@/infra/db/repositories/user-item-repository'
import type { StatsPeriod } from '@/infra/http/schemas/common'
import { getTMDBMovieService } from '../tmdb/get-tmdb-movie'
import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series'
import { processInBatches } from './batch-utils'
Expand All @@ -22,7 +22,12 @@ export async function getUserWatchedCountriesService({
dateRange,
period = 'all',
}: GetUserWatchedCountriesServiceInput) {
const cacheKey = getUserStatsCacheKey(userId, 'watched-countries', language, period)
const cacheKey = getUserStatsCacheKey(
userId,
'watched-countries',
language,
period
)

return getCachedStats(redis, cacheKey, async () => {
const watchedItems = await selectAllUserItemsByStatus({
Expand Down
Loading