diff --git a/apps/backend/src/domain/entities/user-item.ts b/apps/backend/src/domain/entities/user-item.ts index 2c7c21c2a..be83e1375 100644 --- a/apps/backend/src/domain/entities/user-item.ts +++ b/apps/backend/src/domain/entities/user-item.ts @@ -23,4 +23,6 @@ export type SelectUserItems = { export type SelectAllUserItems = { status?: UserItemStatus | 'ALL' userId: string + startDate?: Date + endDate?: Date } diff --git a/apps/backend/src/domain/services/user-episodes/get-user-episodes.ts b/apps/backend/src/domain/services/user-episodes/get-user-episodes.ts index 74963e7b2..073218a2a 100644 --- a/apps/backend/src/domain/services/user-episodes/get-user-episodes.ts +++ b/apps/backend/src/domain/services/user-episodes/get-user-episodes.ts @@ -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 } } diff --git a/apps/backend/src/domain/services/user-stats/cache-utils.ts b/apps/backend/src/domain/services/user-stats/cache-utils.ts index 5b46a25b1..0f0f18d35 100644 --- a/apps/backend/src/domain/services/user-stats/cache-utils.ts +++ b/apps/backend/src/domain/services/user-stats/cache-utils.ts @@ -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 @@ -20,12 +19,17 @@ export async function getCachedStats( } 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 } @@ -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(':') } diff --git a/apps/backend/src/domain/services/user-stats/get-user-best-reviews.ts b/apps/backend/src/domain/services/user-stats/get-user-best-reviews.ts index a846feae2..7ce03dc16 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-best-reviews.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-best-reviews.ts @@ -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' @@ -10,6 +11,8 @@ type GetUserBestReviewsServiceInput = { redis: FastifyRedis language: Language limit?: number + dateRange?: { startDate: Date | undefined; endDate: Date | undefined } + period?: StatsPeriod } export async function getUserBestReviewsService({ @@ -17,10 +20,14 @@ export async function getUserBestReviewsService({ 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, diff --git a/apps/backend/src/domain/services/user-stats/get-user-items-status.ts b/apps/backend/src/domain/services/user-stats/get-user-items-status.ts index 6e46a316f..d2973f4ff 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-items-status.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-items-status.ts @@ -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, diff --git a/apps/backend/src/domain/services/user-stats/get-user-most-watched-series.ts b/apps/backend/src/domain/services/user-stats/get-user-most-watched-series.ts index 405092821..0dd4b7771 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-most-watched-series.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-most-watched-series.ts @@ -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' @@ -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, diff --git a/apps/backend/src/domain/services/user-stats/get-user-stats-timeline.ts b/apps/backend/src/domain/services/user-stats/get-user-stats-timeline.ts new file mode 100644 index 000000000..9e0df4edd --- /dev/null +++ b/apps/backend/src/domain/services/user-stats/get-user-stats-timeline.ts @@ -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 + | 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++ + } + + 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')}` +} diff --git a/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts b/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts index a11aadad5..8ca5a824c 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts @@ -1,21 +1,42 @@ import type { FastifyRedis } from '@fastify/redis' +import type { StatsPeriod } from '@/http/schemas/common' +import { db } from '@/db' +import { sql } from 'drizzle-orm' import { getTMDBMovieService } from '../tmdb/get-tmdb-movie' import { getUserEpisodesService } from '../user-episodes/get-user-episodes' import { getAllUserItemsService } from '../user-items/get-all-user-items' import { processInBatches } from './batch-utils' import { getCachedStats, getUserStatsCacheKey } from './cache-utils' +type DateRange = { startDate: Date | undefined; endDate: Date | undefined } + export async function getUserTotalHoursService( userId: string, - redis: FastifyRedis + redis: FastifyRedis, + period: StatsPeriod = 'all', + dateRange: DateRange = { startDate: undefined, endDate: undefined } ) { - const cacheKey = getUserStatsCacheKey(userId, 'total-hours-v2') + const cacheKey = getUserStatsCacheKey( + userId, + 'total-hours-v6', + undefined, + period + ) return getCachedStats(redis, cacheKey, async () => { - const watchedItems = await getAllUserItemsService({ - userId, - status: 'WATCHED', - }) + const [watchedItems, watchedEpisodes] = await Promise.all([ + getAllUserItemsService({ + userId, + status: 'WATCHED', + startDate: dateRange.startDate, + endDate: dateRange.endDate, + }), + getUserEpisodesService({ + userId, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + }), + ]) const movieRuntimesWithDates = await getMovieRuntimesWithDates( watchedItems, @@ -25,7 +46,6 @@ export async function getUserTotalHoursService( movieRuntimesWithDates.map(m => m.runtime) ) - const watchedEpisodes = await getUserEpisodesService({ userId }) const episodeTotalHours = sumRuntimes( watchedEpisodes.userEpisodes.map(ep => ep.runtime) ) @@ -33,15 +53,36 @@ export async function getUserTotalHoursService( const totalHours = movieTotalHours + episodeTotalHours const monthlyHours = computeMonthlyHours( + movieRuntimesWithDates, + watchedEpisodes.userEpisodes, + period + ) + + const { peakTimeSlot, hourlyDistribution } = computePeakAndHourly( movieRuntimesWithDates, watchedEpisodes.userEpisodes ) + const dailyActivity = computeAllDailyActivity( + movieRuntimesWithDates, + watchedEpisodes.userEpisodes, + period + ) + + const percentileRank = + period === 'all' + ? await computeUserPercentile(userId) + : null + return { totalHours, movieHours: movieTotalHours, seriesHours: episodeTotalHours, monthlyHours, + peakTimeSlot, + hourlyDistribution, + dailyActivity, + percentileRank, } }) } @@ -66,15 +107,29 @@ async function getMovieRuntimesWithDates( function computeMonthlyHours( movieData: { runtime: number; date: Date | null }[], - episodes: { runtime: number; watchedAt: Date | null }[] + episodes: { runtime: number; watchedAt: Date | null }[], + period: StatsPeriod = 'all' ) { - const monthMap = new Map() + if (period === 'month' || period === 'last_month') { + return computeDailyBreakdown(movieData, episodes, period) + } + const monthCount = period === 'year' ? 12 : 12 + const monthMap = new Map() const now = new Date() - for (let i = 5; i >= 0; i--) { - const d = new Date(now.getFullYear(), now.getMonth() - i, 1) - const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` - monthMap.set(key, 0) + + if (period === 'year') { + for (let i = 0; i < 12; i++) { + const d = new Date(now.getFullYear(), i, 1) + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + monthMap.set(key, 0) + } + } else { + for (let i = monthCount - 1; i >= 0; i--) { + const d = new Date(now.getFullYear(), now.getMonth() - i, 1) + const key = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}` + monthMap.set(key, 0) + } } for (const movie of movieData) { @@ -99,6 +154,195 @@ function computeMonthlyHours( })) } +function computeDailyBreakdown( + movieData: { runtime: number; date: Date | null }[], + episodes: { runtime: number; watchedAt: Date | null }[], + period: 'month' | 'last_month' +) { + const now = new Date() + let year: number + let month: number + + if (period === 'month') { + year = now.getFullYear() + month = now.getMonth() + } else { + const lastMonth = new Date(now.getFullYear(), now.getMonth() - 1, 1) + year = lastMonth.getFullYear() + month = lastMonth.getMonth() + } + + const daysInMonth = new Date(year, month + 1, 0).getDate() + const dayMap = new Map() + + for (let d = 1; d <= daysInMonth; d++) { + const key = `${year}-${String(month + 1).padStart(2, '0')}-${String(d).padStart(2, '0')}` + dayMap.set(key, 0) + } + + for (const movie of movieData) { + if (!movie.date) continue + const key = `${movie.date.getFullYear()}-${String(movie.date.getMonth() + 1).padStart(2, '0')}-${String(movie.date.getDate()).padStart(2, '0')}` + if (dayMap.has(key)) { + dayMap.set(key, (dayMap.get(key) || 0) + movie.runtime / 60) + } + } + + for (const ep of episodes) { + if (!ep.watchedAt) continue + const key = `${ep.watchedAt.getFullYear()}-${String(ep.watchedAt.getMonth() + 1).padStart(2, '0')}-${String(ep.watchedAt.getDate()).padStart(2, '0')}` + if (dayMap.has(key)) { + dayMap.set(key, (dayMap.get(key) || 0) + ep.runtime / 60) + } + } + + return Array.from(dayMap.entries()).map(([month, hours]) => ({ + month, + hours: Math.round(hours * 10) / 10, + })) +} + +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 dayMap = new Map() + const cursor = new Date(startDate) + while (cursor <= endDate) { + const key = `${cursor.getFullYear()}-${String(cursor.getMonth() + 1).padStart(2, '0')}-${String(cursor.getDate()).padStart(2, '0')}` + dayMap.set(key, 0) + cursor.setDate(cursor.getDate() + 1) + } + + for (const movie of movieData) { + if (!movie.date) continue + const key = `${movie.date.getFullYear()}-${String(movie.date.getMonth() + 1).padStart(2, '0')}-${String(movie.date.getDate()).padStart(2, '0')}` + if (dayMap.has(key)) { + dayMap.set(key, (dayMap.get(key) || 0) + movie.runtime / 60) + } + } + + for (const ep of episodes) { + if (!ep.watchedAt) continue + const key = `${ep.watchedAt.getFullYear()}-${String(ep.watchedAt.getMonth() + 1).padStart(2, '0')}-${String(ep.watchedAt.getDate()).padStart(2, '0')}` + if (dayMap.has(key)) { + dayMap.set(key, (dayMap.get(key) || 0) + ep.runtime / 60) + } + } + + return Array.from(dayMap.entries()).map(([day, hours]) => ({ + day, + hours: Math.round(hours * 10) / 10, + })) +} + +function computePeakAndHourly( + movieData: { runtime: number; date: Date | null }[], + episodes: { runtime: number; watchedAt: Date | null }[] +): { + peakTimeSlot: { slot: string; hour: number; count: number } | null + hourlyDistribution: { hour: number; count: number }[] +} { + const hourCounts = new Array(24).fill(0) + + for (const movie of movieData) { + if (!movie.date) continue + hourCounts[movie.date.getHours()]++ + } + + for (const ep of episodes) { + if (!ep.watchedAt) continue + hourCounts[ep.watchedAt.getHours()]++ + } + + const hourlyDistribution = hourCounts.map((count: number, hour: number) => ({ + hour, + count, + })) + + const totalEntries = hourCounts.reduce((a: number, b: number) => a + b, 0) + if (totalEntries === 0) + return { peakTimeSlot: null, hourlyDistribution } + + const slots = [ + { slot: 'night', range: [0, 1, 2, 3, 4, 5], count: 0 }, + { slot: 'morning', range: [6, 7, 8, 9, 10, 11], count: 0 }, + { slot: 'afternoon', range: [12, 13, 14, 15, 16, 17], count: 0 }, + { slot: 'evening', range: [18, 19, 20, 21, 22, 23], count: 0 }, + ] + + for (const s of slots) { + s.count = s.range.reduce((sum, h) => sum + hourCounts[h], 0) + } + + const peak = slots.reduce( + (max, s) => (s.count > max.count ? s : max), + slots[0] + ) + const peakHour = peak.range.reduce( + (maxH, h) => (hourCounts[h] > hourCounts[maxH] ? h : maxH), + peak.range[0] + ) + + return { + peakTimeSlot: { slot: peak.slot, hour: peakHour, count: peak.count }, + hourlyDistribution, + } +} + function sumRuntimes(runtimes: number[]): number { return runtimes.reduce((acc, curr) => acc + curr, 0) / 60 } + +async function computeUserPercentile(userId: string): Promise { + const rows = await db.execute(sql` + WITH user_counts AS ( + SELECT user_id, COUNT(*)::int AS item_count + FROM user_items + WHERE status = 'WATCHED' + GROUP BY user_id + ) + SELECT + (SELECT item_count FROM user_counts WHERE user_id = ${userId}) AS user_count, + COUNT(*)::int AS total_users, + (SELECT COUNT(*)::int FROM user_counts + WHERE item_count < (SELECT item_count FROM user_counts WHERE user_id = ${userId}) + ) AS users_below + FROM user_counts + `) + + const row = rows[0] as + | { user_count: number | null; total_users: number; users_below: number } + | undefined + + if (!row?.user_count || row.total_users <= 1) return null + + const percentile = Math.round((row.users_below / row.total_users) * 100) + + return Math.max(1, percentile) +} diff --git a/apps/backend/src/domain/services/user-stats/get-user-watched-cast.ts b/apps/backend/src/domain/services/user-stats/get-user-watched-cast.ts index 6d431c9d7..b1f2cfc6a 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-watched-cast.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-watched-cast.ts @@ -1,4 +1,5 @@ import type { FastifyRedis } from '@fastify/redis' +import type { StatsPeriod } from '@/infra/http/schemas/common' import { selectAllUserItemsByStatus } from '@/infra/db/repositories/user-item-repository' import { getTMDBCredits } from '../tmdb/get-tmdb-credits' import { processInBatches } from './batch-utils' @@ -7,18 +8,24 @@ import { getCachedStats, getUserStatsCacheKey } from './cache-utils' type GetUserWatchedCastServiceInput = { userId: string redis: FastifyRedis + dateRange?: { startDate: Date | undefined; endDate: Date | undefined } + period?: StatsPeriod } export async function getUserWatchedCastService({ userId, redis, + dateRange, + period = 'all', }: GetUserWatchedCastServiceInput) { - const cacheKey = getUserStatsCacheKey(userId, 'watched-cast') + const cacheKey = getUserStatsCacheKey(userId, 'watched-cast', undefined, period) return getCachedStats(redis, cacheKey, async () => { const watchedItems = await selectAllUserItemsByStatus({ status: 'WATCHED', userId, + startDate: dateRange?.startDate, + endDate: dateRange?.endDate, }) const watchedItemsCast = await processInBatches( @@ -35,7 +42,6 @@ export async function getUserWatchedCastService({ const filteredCast = flattedCast.filter( actor => actor.known_for_department === 'Acting' && - // Filter characters that are voiced ['(', ')'].every(char => !actor.character.includes(char)) ) diff --git a/apps/backend/src/domain/services/user-stats/get-user-watched-countries.ts b/apps/backend/src/domain/services/user-stats/get-user-watched-countries.ts index ce862212c..8414dff82 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-watched-countries.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-watched-countries.ts @@ -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 { selectAllUserItemsByStatus } from '@/infra/db/repositories/user-item-repository' import { getTMDBMovieService } from '../tmdb/get-tmdb-movie' import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series' @@ -10,19 +11,25 @@ type GetUserWatchedCountriesServiceInput = { userId: string redis: FastifyRedis language: Language + dateRange?: { startDate: Date | undefined; endDate: Date | undefined } + period?: StatsPeriod } export async function getUserWatchedCountriesService({ userId, redis, language, + dateRange, + period = 'all', }: GetUserWatchedCountriesServiceInput) { - const cacheKey = getUserStatsCacheKey(userId, 'watched-countries', language) + const cacheKey = getUserStatsCacheKey(userId, 'watched-countries', language, period) return getCachedStats(redis, cacheKey, async () => { const watchedItems = await selectAllUserItemsByStatus({ status: 'WATCHED', userId, + startDate: dateRange?.startDate, + endDate: dateRange?.endDate, }) const countryCount = new Map() diff --git a/apps/backend/src/domain/services/user-stats/get-user-watched-genres.ts b/apps/backend/src/domain/services/user-stats/get-user-watched-genres.ts index f81266efe..007c0dd5c 100644 --- a/apps/backend/src/domain/services/user-stats/get-user-watched-genres.ts +++ b/apps/backend/src/domain/services/user-stats/get-user-watched-genres.ts @@ -1,6 +1,8 @@ import type { FastifyRedis } from '@fastify/redis' import type { Language } from '@plotwist_app/tmdb' +import { selectUserEpisodes } from '@/infra/db/repositories/user-episode' 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' @@ -10,24 +12,70 @@ type GetUserWatchedGenresServiceInput = { userId: string redis: FastifyRedis language: Language + dateRange?: { startDate: Date | undefined; endDate: Date | undefined } + period?: StatsPeriod } export async function getUserWatchedGenresService({ userId, redis, language, + dateRange, + period = 'all', }: GetUserWatchedGenresServiceInput) { - const cacheKey = getUserStatsCacheKey(userId, 'watched-genres', language) + const cacheKey = getUserStatsCacheKey( + userId, + 'watched-genres-v5', + language, + period + ) return getCachedStats(redis, cacheKey, async () => { - const watchedItems = await selectAllUserItemsByStatus({ - userId, - status: 'WATCHED', - }) + const hasDateRange = dateRange?.startDate || dateRange?.endDate + + const [watchedItems, episodesInRange] = await Promise.all([ + selectAllUserItemsByStatus({ + userId, + status: 'WATCHED', + startDate: dateRange?.startDate, + endDate: dateRange?.endDate, + }), + hasDateRange + ? selectUserEpisodes({ + userId, + startDate: dateRange?.startDate, + endDate: dateRange?.endDate, + }) + : Promise.resolve([]), + ]) + + if (hasDateRange && episodesInRange.length > 0) { + const seenTmdbIds = new Set(watchedItems.map(item => item.tmdbId)) + const seriesTmdbIds = new Set(episodesInRange.map(ep => ep.tmdbId)) + for (const tmdbId of seriesTmdbIds) { + if (!seenTmdbIds.has(tmdbId)) { + seenTmdbIds.add(tmdbId) + watchedItems.push({ + id: `ep-${tmdbId}`, + tmdbId, + mediaType: 'TV_SHOW' as const, + position: null as unknown as number, + updatedAt: new Date(), + }) + } + } + } + + type GenreItem = { + tmdbId: number + mediaType: string + posterPath: string | null + } const genreCount = new Map() + const genreItems = new Map() await processInBatches(watchedItems, async item => { - const { genres } = + const { genres, posterPath } = item.mediaType === 'MOVIE' ? await getTMDBMovieService(redis, { tmdbId: item.tmdbId, @@ -41,19 +89,37 @@ export async function getUserWatchedGenresService({ }) if (genres) { + const mediaType = item.mediaType === 'MOVIE' ? 'movie' : 'tv' for (const genre of genres) { const currentCount = genreCount.get(genre.name) || 0 genreCount.set(genre.name, currentCount + 1) + + const items = genreItems.get(genre.name) || [] + if (!items.some(i => i.tmdbId === item.tmdbId)) { + items.push({ + tmdbId: item.tmdbId, + mediaType, + posterPath: posterPath ?? null, + }) + genreItems.set(genre.name, items) + } } } }) + const totalItems = watchedItems.length const genres = Array.from(genreCount) - .map(([name, count]) => ({ - name, - count, - percentage: (count / watchedItems.length) * 100, - })) + .map(([name, count]) => { + const items = genreItems.get(name) || [] + return { + name, + count, + percentage: totalItems > 0 ? (count / totalItems) * 100 : 0, + posterPath: items[0]?.posterPath ?? null, + posterPaths: items.map(i => i.posterPath).filter(Boolean) as string[], + items, + } + }) .sort((a, b) => b.count - a.count) return { genres } diff --git a/apps/backend/src/infra/db/repositories/reviews-repository.ts b/apps/backend/src/infra/db/repositories/reviews-repository.ts index dc57bc6ba..75e8154bd 100644 --- a/apps/backend/src/infra/db/repositories/reviews-repository.ts +++ b/apps/backend/src/infra/db/repositories/reviews-repository.ts @@ -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) } diff --git a/apps/backend/src/infra/db/repositories/user-episode.ts b/apps/backend/src/infra/db/repositories/user-episode.ts index e8b22b0c2..732167c57 100644 --- a/apps/backend/src/infra/db/repositories/user-episode.ts +++ b/apps/backend/src/infra/db/repositories/user-episode.ts @@ -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 '..' @@ -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) } @@ -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) diff --git a/apps/backend/src/infra/db/repositories/user-item-repository.ts b/apps/backend/src/infra/db/repositories/user-item-repository.ts index d7c65cba2..ab1192a64 100644 --- a/apps/backend/src/infra/db/repositories/user-item-repository.ts +++ b/apps/backend/src/infra/db/repositories/user-item-repository.ts @@ -5,6 +5,7 @@ import { desc, eq, getTableColumns, + gte, inArray, isNull, lte, @@ -137,7 +138,20 @@ 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, @@ -145,13 +159,15 @@ export async function selectUserItemStatus(userId: string) { 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 @@ -162,6 +178,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, diff --git a/apps/backend/src/infra/http/controllers/review-controller.ts b/apps/backend/src/infra/http/controllers/review-controller.ts index 4222bdd18..d9d11fd96 100644 --- a/apps/backend/src/infra/http/controllers/review-controller.ts +++ b/apps/backend/src/infra/http/controllers/review-controller.ts @@ -9,6 +9,7 @@ import { updateReviewService } from '@/domain/services/reviews/update-review' import { getTMDBDataService } from '@/domain/services/tmdb/get-tmdb-data' import { createUserActivity } from '@/domain/services/user-activities/create-user-activity' import { deleteUserActivityByEntityService } from '@/domain/services/user-activities/delete-user-activity' +import { invalidateUserStatsCache } from '@/domain/services/user-stats/cache-utils' import { createReviewBodySchema, getReviewQuerySchema, @@ -19,7 +20,8 @@ import { export async function createReviewController( request: FastifyRequest, - reply: FastifyReply + reply: FastifyReply, + redis: FastifyRedis ) { const body = createReviewBodySchema.parse(request.body) @@ -45,6 +47,8 @@ export async function createReviewController( }, }) + await invalidateUserStatsCache(redis, request.user.id) + return reply.status(201).send({ review: result.review }) } @@ -78,7 +82,8 @@ export async function getReviewsController( export async function deleteReviewController( request: FastifyRequest, - reply: FastifyReply + reply: FastifyReply, + redis: FastifyRedis ) { const { id } = reviewParamsSchema.parse(request.params) const result = await deleteReviewService(id) @@ -94,18 +99,23 @@ export async function deleteReviewController( userId: result.userId, }) + await invalidateUserStatsCache(redis, result.userId) + return reply.status(204).send() } export async function updateReviewController( request: FastifyRequest, - reply: FastifyReply + reply: FastifyReply, + redis: FastifyRedis ) { const { id } = reviewParamsSchema.parse(request.params) const body = updateReviewBodySchema.parse(request.body) const result = await updateReviewService({ ...body, id }) + await invalidateUserStatsCache(redis, request.user.id) + return reply.status(200).send(result.review) } diff --git a/apps/backend/src/infra/http/controllers/user-episodes-controller.ts b/apps/backend/src/infra/http/controllers/user-episodes-controller.ts index d97a31fda..2c3788594 100644 --- a/apps/backend/src/infra/http/controllers/user-episodes-controller.ts +++ b/apps/backend/src/infra/http/controllers/user-episodes-controller.ts @@ -1,9 +1,11 @@ +import type { FastifyRedis } from '@fastify/redis' import type { FastifyReply, FastifyRequest } from 'fastify' import { DomainError } from '@/domain/errors/domain-error' import { createUserActivity } from '@/domain/services/user-activities/create-user-activity' import { createUserEpisodesService } from '@/domain/services/user-episodes/create-user-episodes' import { deleteUserEpisodesService } from '@/domain/services/user-episodes/delete-user-episodes' import { getUserEpisodesService } from '@/domain/services/user-episodes/get-user-episodes' +import { invalidateUserStatsCache } from '@/domain/services/user-stats/cache-utils' import { createUserEpisodesBodySchema, deleteUserEpisodesBodySchema, @@ -12,7 +14,8 @@ import { export async function createUserEpisodesController( request: FastifyRequest, - reply: FastifyReply + reply: FastifyReply, + redis: FastifyRedis ) { const body = createUserEpisodesBodySchema.parse(request.body) @@ -30,6 +33,8 @@ export async function createUserEpisodesController( metadata: body, }) + await invalidateUserStatsCache(redis, request.user.id) + return reply.status(201).send(result.userEpisodes) } @@ -49,10 +54,13 @@ export async function getUserEpisodesController( export async function deleteUserEpisodesController( request: FastifyRequest, - reply: FastifyReply + reply: FastifyReply, + redis: FastifyRedis ) { const { ids } = deleteUserEpisodesBodySchema.parse(request.body) await deleteUserEpisodesService(ids) + await invalidateUserStatsCache(redis, request.user.id) + return reply.status(204).send() } diff --git a/apps/backend/src/infra/http/controllers/user-stats.ts b/apps/backend/src/infra/http/controllers/user-stats.ts index 313d34840..a615d1fb1 100644 --- a/apps/backend/src/infra/http/controllers/user-stats.ts +++ b/apps/backend/src/infra/http/controllers/user-stats.ts @@ -9,9 +9,13 @@ import { getUserTotalHoursService } from '@/domain/services/user-stats/get-user- import { getUserWatchedCastService } from '@/domain/services/user-stats/get-user-watched-cast' import { getUserWatchedCountriesService } from '@/domain/services/user-stats/get-user-watched-countries' import { getUserWatchedGenresService } from '@/domain/services/user-stats/get-user-watched-genres' +import { getUserStatsTimelineService } from '@/domain/services/user-stats/get-user-stats-timeline' import { - languageQuerySchema, - languageWithLimitQuerySchema, + languageWithLimitAndPeriodQuerySchema, + languageWithPeriodQuerySchema, + periodQuerySchema, + periodToDateRange, + timelineQuerySchema, } from '../schemas/common' import { getUserDefaultSchema } from '../schemas/user-stats' @@ -32,7 +36,9 @@ export async function getUserTotalHoursController( redis: FastifyRedis ) { const { id } = getUserDefaultSchema.parse(request.params) - const result = await getUserTotalHoursService(id, redis) + const { period } = periodQuerySchema.parse(request.query) + const dateRange = periodToDateRange(period) + const result = await getUserTotalHoursService(id, redis, period, dateRange) return reply.status(200).send(result) } @@ -52,13 +58,18 @@ export async function getUserMostWatchedSeriesController( reply: FastifyReply, redis: FastifyRedis ) { - const { language } = languageQuerySchema.parse(request.query) + const { language, period } = languageWithPeriodQuerySchema.parse( + request.query + ) const { id } = getUserDefaultSchema.parse(request.params) + const dateRange = periodToDateRange(period) const result = await getUserMostWatchedSeriesService({ userId: id, redis, language, + dateRange, + period, }) return reply.status(200).send(result) @@ -69,13 +80,18 @@ export async function getUserWatchedGenresController( reply: FastifyReply, redis: FastifyRedis ) { - const { language } = languageQuerySchema.parse(request.query) + const { language, period } = languageWithPeriodQuerySchema.parse( + request.query + ) const { id } = getUserDefaultSchema.parse(request.params) + const dateRange = periodToDateRange(period) const result = await getUserWatchedGenresService({ userId: id, redis, language, + dateRange, + period, }) return reply.status(200).send(result) @@ -87,7 +103,14 @@ export async function getUserWatchedCastController( redis: FastifyRedis ) { const { id } = getUserDefaultSchema.parse(request.params) - const result = await getUserWatchedCastService({ userId: id, redis }) + const { period } = periodQuerySchema.parse(request.query) + const dateRange = periodToDateRange(period) + const result = await getUserWatchedCastService({ + userId: id, + redis, + dateRange, + period, + }) return reply.status(200).send(result) } @@ -98,12 +121,17 @@ export async function getUserWatchedCountriesController( redis: FastifyRedis ) { const { id } = getUserDefaultSchema.parse(request.params) - const { language } = languageQuerySchema.parse(request.query) + const { language, period } = languageWithPeriodQuerySchema.parse( + request.query + ) + const dateRange = periodToDateRange(period) const result = await getUserWatchedCountriesService({ userId: id, redis, language, + dateRange, + period, }) return reply.status(200).send(result) @@ -115,13 +143,38 @@ export async function getUserBestReviewsController( redis: FastifyRedis ) { const { id } = getUserDefaultSchema.parse(request.params) - const { language, limit } = languageWithLimitQuerySchema.parse(request.query) + const { language, limit, period } = + languageWithLimitAndPeriodQuerySchema.parse(request.query) + const dateRange = periodToDateRange(period) const result = await getUserBestReviewsService({ userId: id, redis, language, limit, + dateRange, + period, + }) + + return reply.status(200).send(result) +} + +export async function getUserStatsTimelineController( + request: FastifyRequest, + reply: FastifyReply, + redis: FastifyRedis +) { + const { id } = getUserDefaultSchema.parse(request.params) + const { language, cursor, pageSize } = timelineQuerySchema.parse( + request.query + ) + + const result = await getUserStatsTimelineService({ + userId: id, + redis, + language, + cursor, + pageSize, }) return reply.status(200).send(result) @@ -133,9 +186,12 @@ export async function getUserItemsStatusController( _redis: FastifyRedis ) { const { id } = getUserDefaultSchema.parse(request.params) + const { period } = periodQuerySchema.parse(request.query) + const dateRange = periodToDateRange(period) const result = await getUserItemsStatusService({ userId: id, + dateRange, }) return reply.status(200).send(result) diff --git a/apps/backend/src/infra/http/routes/reviews.ts b/apps/backend/src/infra/http/routes/reviews.ts index f839b1351..77f030fee 100644 --- a/apps/backend/src/infra/http/routes/reviews.ts +++ b/apps/backend/src/infra/http/routes/reviews.ts @@ -43,7 +43,8 @@ export async function reviewsRoute(app: FastifyInstance) { }, ], }, - handler: createReviewController, + handler: (request, reply) => + createReviewController(request, reply, app.redis), }) ) @@ -76,7 +77,8 @@ export async function reviewsRoute(app: FastifyInstance) { tags: [reviewsTag], params: reviewParamsSchema, }, - handler: deleteReviewController, + handler: (request, reply) => + deleteReviewController(request, reply, app.redis), }) ) @@ -91,7 +93,8 @@ export async function reviewsRoute(app: FastifyInstance) { body: updateReviewBodySchema, response: updateReviewResponse, }, - handler: updateReviewController, + handler: (request, reply) => + updateReviewController(request, reply, app.redis), }) ) diff --git a/apps/backend/src/infra/http/routes/user-episodes.ts b/apps/backend/src/infra/http/routes/user-episodes.ts index 901f8df1e..cce406c6b 100644 --- a/apps/backend/src/infra/http/routes/user-episodes.ts +++ b/apps/backend/src/infra/http/routes/user-episodes.ts @@ -35,7 +35,8 @@ export async function userEpisodesRoutes(app: FastifyInstance) { }, ], }, - handler: createUserEpisodesController, + handler: (request, reply) => + createUserEpisodesController(request, reply, app.redis), }) ) @@ -75,7 +76,8 @@ export async function userEpisodesRoutes(app: FastifyInstance) { }, ], }, - handler: deleteUserEpisodesController, + handler: (request, reply) => + deleteUserEpisodesController(request, reply, app.redis), }) ) } diff --git a/apps/backend/src/infra/http/routes/user-stats.ts b/apps/backend/src/infra/http/routes/user-stats.ts index 71afdf0f8..69c6482ec 100644 --- a/apps/backend/src/infra/http/routes/user-stats.ts +++ b/apps/backend/src/infra/http/routes/user-stats.ts @@ -7,14 +7,17 @@ import { getUserMostWatchedSeriesController, getUserReviewsCountController, getUserStatsController, + getUserStatsTimelineController, getUserTotalHoursController, getUserWatchedCastController, getUserWatchedCountriesController, getUserWatchedGenresController, } from '../controllers/user-stats' import { - languageQuerySchema, - languageWithLimitQuerySchema, + languageWithLimitAndPeriodQuerySchema, + languageWithPeriodQuerySchema, + periodQuerySchema, + timelineQuerySchema, } from '../schemas/common' import { getUserBestReviewsResponseSchema, @@ -23,6 +26,7 @@ import { getUserMostWatchedSeriesResponseSchema, getUserReviewsCountResponseSchema, getUserStatsResponseSchema, + getUserStatsTimelineResponseSchema, getUserTotalHoursResponseSchema, getUserWatchedCastResponseSchema, getUserWatchedCountriesResponseSchema, @@ -46,6 +50,22 @@ export async function userStatsRoutes(app: FastifyInstance) { }) ) + app.after(() => + app.withTypeProvider().route({ + method: 'GET', + url: '/user/:id/stats-timeline', + schema: { + description: 'Get user stats timeline (paginated monthly sections)', + params: getUserDefaultSchema, + query: timelineQuerySchema, + response: getUserStatsTimelineResponseSchema, + tags: USER_STATS_TAG, + }, + handler: (request, reply) => + getUserStatsTimelineController(request, reply, app.redis), + }) + ) + app.after(() => app.withTypeProvider().route({ method: 'GET', @@ -53,6 +73,7 @@ export async function userStatsRoutes(app: FastifyInstance) { schema: { description: 'Get user total hours', params: getUserDefaultSchema, + query: periodQuerySchema, response: getUserTotalHoursResponseSchema, tags: USER_STATS_TAG, }, @@ -82,7 +103,7 @@ export async function userStatsRoutes(app: FastifyInstance) { schema: { description: 'Get user most watched series', params: getUserDefaultSchema, - query: languageQuerySchema, + query: languageWithPeriodQuerySchema, response: getUserMostWatchedSeriesResponseSchema, tags: USER_STATS_TAG, }, @@ -98,7 +119,7 @@ export async function userStatsRoutes(app: FastifyInstance) { schema: { description: 'Get user watched genres', params: getUserDefaultSchema, - query: languageQuerySchema, + query: languageWithPeriodQuerySchema, response: getUserWatchedGenresResponseSchema, tags: USER_STATS_TAG, }, @@ -114,7 +135,7 @@ export async function userStatsRoutes(app: FastifyInstance) { schema: { description: 'Get user watched cast', params: getUserDefaultSchema, - query: languageQuerySchema, + query: periodQuerySchema, response: getUserWatchedCastResponseSchema, tags: USER_STATS_TAG, }, @@ -130,7 +151,7 @@ export async function userStatsRoutes(app: FastifyInstance) { schema: { description: 'Get user watched countries', params: getUserDefaultSchema, - query: languageQuerySchema, + query: languageWithPeriodQuerySchema, response: getUserWatchedCountriesResponseSchema, tags: USER_STATS_TAG, }, @@ -146,7 +167,7 @@ export async function userStatsRoutes(app: FastifyInstance) { schema: { description: 'Get user best reviews', params: getUserDefaultSchema, - query: languageWithLimitQuerySchema, + query: languageWithLimitAndPeriodQuerySchema, response: getUserBestReviewsResponseSchema, tags: USER_STATS_TAG, }, @@ -162,6 +183,7 @@ export async function userStatsRoutes(app: FastifyInstance) { schema: { description: 'Get user items status', params: getUserDefaultSchema, + query: periodQuerySchema, response: getUserItemsStatusResponseSchema, tags: USER_STATS_TAG, }, diff --git a/apps/backend/src/infra/http/schemas/common.ts b/apps/backend/src/infra/http/schemas/common.ts index 3864db8ba..fa6b9920a 100644 --- a/apps/backend/src/infra/http/schemas/common.ts +++ b/apps/backend/src/infra/http/schemas/common.ts @@ -19,3 +19,75 @@ export const languageWithLimitQuerySchema = z.object({ .default('en-US'), limit: z.coerce.number().int().min(1).max(100).optional().default(10), }) + +const yearMonthRegex = /^\d{4}-(0[1-9]|1[0-2])$/ + +export const periodQuerySchema = z.object({ + period: z + .union([ + z.enum(['month', 'last_month', 'year', 'all']), + z.string().regex(yearMonthRegex), + ]) + .optional() + .default('all'), +}) + +export const languageWithPeriodQuerySchema = + languageQuerySchema.merge(periodQuerySchema) + +export const languageWithLimitAndPeriodQuerySchema = + languageWithLimitQuerySchema.merge(periodQuerySchema) + +export const timelineQuerySchema = z.object({ + language: z + .enum(['en-US', 'es-ES', 'fr-FR', 'de-DE', 'it-IT', 'pt-BR', 'ja-JP']) + .optional() + .default('en-US'), + cursor: z + .string() + .regex(yearMonthRegex) + .optional(), + pageSize: z.coerce.number().int().min(1).max(10).optional().default(3), +}) + +export type StatsPeriod = z.infer['period'] + +export function periodToDateRange(period: StatsPeriod): { + startDate: Date | undefined + endDate: Date | undefined +} { + if (typeof period === 'string' && yearMonthRegex.test(period)) { + const [year, month] = period.split('-').map(Number) + const startDate = new Date(year, month - 1, 1) + const endDate = new Date(year, month, 0, 23, 59, 59, 999) + return { startDate, endDate } + } + + const now = new Date() + switch (period) { + case 'month': { + const startDate = new Date(now.getFullYear(), now.getMonth(), 1) + return { startDate, endDate: undefined } + } + case 'last_month': { + const startDate = new Date(now.getFullYear(), now.getMonth() - 1, 1) + const endDate = new Date( + now.getFullYear(), + now.getMonth(), + 0, + 23, + 59, + 59, + 999 + ) + return { startDate, endDate } + } + case 'year': { + const startDate = new Date(now.getFullYear(), 0, 1) + return { startDate, endDate: undefined } + } + case 'all': + default: + return { startDate: undefined, endDate: undefined } + } +} diff --git a/apps/backend/src/infra/http/schemas/user-stats.ts b/apps/backend/src/infra/http/schemas/user-stats.ts index 934fafa07..5c7049046 100644 --- a/apps/backend/src/infra/http/schemas/user-stats.ts +++ b/apps/backend/src/infra/http/schemas/user-stats.ts @@ -26,6 +26,31 @@ export const getUserTotalHoursResponseSchema = { hours: z.number(), }) ), + peakTimeSlot: z + .object({ + slot: z.string(), + hour: z.number(), + count: z.number(), + }) + .nullable() + .optional(), + hourlyDistribution: z + .array( + z.object({ + hour: z.number(), + count: z.number(), + }) + ) + .optional(), + dailyActivity: z + .array( + z.object({ + day: z.string(), + hours: z.number(), + }) + ) + .optional(), + percentileRank: z.number().nullable().optional(), }), } @@ -56,6 +81,17 @@ export const getUserWatchedGenresResponseSchema = { name: z.string(), count: z.number(), percentage: z.number(), + posterPath: z.string().nullable(), + posterPaths: z.array(z.string()).optional(), + items: z + .array( + z.object({ + tmdbId: z.number(), + mediaType: z.string(), + posterPath: z.string().nullable(), + }) + ) + .optional(), }) ), }), @@ -110,3 +146,31 @@ export const getUserItemsStatusResponseSchema = { ), }), } + +export const getUserStatsTimelineResponseSchema = { + 200: z.object({ + sections: z.array( + z.object({ + yearMonth: z.string(), + totalHours: z.number(), + movieHours: z.number(), + seriesHours: z.number(), + topGenre: z + .object({ + name: z.string(), + posterPath: z.string().nullable(), + }) + .nullable(), + topReview: z + .object({ + title: z.string(), + posterPath: z.string().nullable(), + rating: z.number(), + }) + .nullable(), + }) + ), + nextCursor: z.string().nullable(), + hasMore: z.boolean(), + }), +} diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index 07e662448..186b00adb 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -275,6 +275,8 @@ enum L10n { stats: "Stats", series: "Series", timeWatched: "Time Watched", + youSpentWatching: "You spent %@ hours watching", + distribution: "Distribution", hour: "hour", hours: "hours", days: "days", @@ -283,18 +285,76 @@ enum L10n { couldNotLoadStats: "Could not load stats", tryAgain: "Try again", favoriteGenres: "Favorite Genres", + favoriteGenre: "Favorite Genre", + nTitles: "%d titles", content: "Content", genres: "Genres", - collectionStatus: "Collection Status", itemsInCollection: "items in collection", bestReviews: "Best Reviews", + bestReview: "Best Review", daysOfContent: "days of content", othersGenres: "Others", - mostWatchedCast: "Most Watched Cast", watchedCountries: "Watched Countries", - mostWatchedSeries: "Most Watched Series", - inTitles: "in %d titles", - episodesWatched: "ep. watched", + perDay: "per day", + yourTasteDNA: "Your Taste DNA", + activity: "Activity", + less: "Less", + more: "More", + peakTime: "Peak time", + peakTimeMorning: "Mornings", + peakTimeAfternoon: "Afternoons", + peakTimeEvening: "Evenings", + peakTimeNight: "Nights", + evolution: "Evolution", + shareMyStats: "Share", + unlockFullProfile: "Unlock your full profile", + startTrackingStats: "Start tracking to see your stats", + trackMoreInsights: "Track more to unlock insights", + monthlyTrend: "Monthly Trend", + dnaPace: "Pace", + dnaTone: "Tone", + dnaFormat: "Format", + dnaOrigin: "Origin", + dnaSlowTense: "Slow and tension-building", + dnaFastIntense: "Fast-paced and intense", + dnaVariedRhythm: "Varied rhythm", + dnaDarkMoody: "Dark and moody", + dnaLightUpbeat: "Light and upbeat", + dnaEclectic: "Eclectic", + dnaBingeWatcher: "Series binge-watcher", + dnaFilmLover: "Film enthusiast", + dnaBalancedViewer: "Balanced viewer", + dnaGlobalExplorer: "Global explorer", + dnaMainstream: "Mainstream viewer", + dnaAsianCinema: "Asian cinema fan", + topTitlesAre: "Because %d%% of your top titles are %@", + ofYourTimeIs: "%d%% of your time", + myMonthInReview: "My month in review", + yourPeakIs: "Your peak is %@", + moreVsLastMonth: "%@%d%% vs last month", + consumptionUp: "Your watching has increased recently", + consumptionDown: "Your watching has slowed down recently", + moreSeriesThanMovies: "You're watching more series than movies", + moreMoviesThanSeries: "You're watching more movies than series", + proUnlockDescription: "Complete taste DNA, yearly activity, trends, and premium share cards", + // Period Selector + thisMonth: "This Month", + lastMonth: "Last Month", + thisYear: "This Year", + allTime: "Lifetime", + hoursThisMonth: "hours this month", + hoursLastMonth: "hours last month", + hoursThisYear: "hours this year", + vsLastMonth: "%@ vs last month", + vsLastYear: "%@ vs last year", + perDayThisMonth: "/day this month", + perDayThisYear: "/day this year", + noActivityThisPeriod: "No activity in this period", + minutes: "minutes", + timeline: "Timeline", + vsLastMonthShort: "vs last month", + comparisonPrevMonth: "%@ hours in %@", + topPercentile: "More than %d%% of users", // Home Engagement forYou: "For you", basedOnYourTaste: "Because you like %@", @@ -585,6 +645,8 @@ enum L10n { stats: "Estatísticas", series: "Séries", timeWatched: "Tempo Assistido", + youSpentWatching: "Você passou %@ horas assistindo", + distribution: "Distribuição", hour: "hora", hours: "horas", days: "dias", @@ -593,18 +655,76 @@ enum L10n { couldNotLoadStats: "Não foi possível carregar estatísticas", tryAgain: "Tentar novamente", favoriteGenres: "Gêneros Favoritos", + favoriteGenre: "Gênero Favorito", + nTitles: "%d títulos", content: "Conteúdo", genres: "Gêneros", - collectionStatus: "Status da Coleção", itemsInCollection: "itens na coleção", bestReviews: "Melhores Reviews", + bestReview: "Melhor Review", daysOfContent: "dias de conteúdo", othersGenres: "Outros", - mostWatchedCast: "Elenco Mais Assistido", watchedCountries: "Países Assistidos", - mostWatchedSeries: "Séries Mais Assistidas", - inTitles: "em %d títulos", - episodesWatched: "ep. assistidos", + perDay: "por dia", + yourTasteDNA: "Seu DNA de Gosto", + activity: "Atividade", + less: "Menos", + more: "Mais", + peakTime: "Horário de pico", + peakTimeMorning: "Manhãs", + peakTimeAfternoon: "Tardes", + peakTimeEvening: "Noites", + peakTimeNight: "Madrugadas", + evolution: "Evolução", + shareMyStats: "Compartilhar", + unlockFullProfile: "Desbloquear perfil completo", + startTrackingStats: "Comece a registrar para ver suas estatísticas", + trackMoreInsights: "Registre mais para desbloquear insights", + monthlyTrend: "Tendência Mensal", + dnaPace: "Ritmo", + dnaTone: "Tom", + dnaFormat: "Formato", + dnaOrigin: "Origem", + dnaSlowTense: "Lento e cheio de tensão", + dnaFastIntense: "Acelerado e intenso", + dnaVariedRhythm: "Ritmo variado", + dnaDarkMoody: "Sombrio e melancólico", + dnaLightUpbeat: "Leve e alegre", + dnaEclectic: "Eclético", + dnaBingeWatcher: "Maratonista de séries", + dnaFilmLover: "Entusiasta de filmes", + dnaBalancedViewer: "Espectador balanceado", + dnaGlobalExplorer: "Explorador global", + dnaMainstream: "Espectador mainstream", + dnaAsianCinema: "Fã de cinema asiático", + topTitlesAre: "Porque %d%% dos seus top títulos são %@", + ofYourTimeIs: "%d%% do seu tempo", + myMonthInReview: "Meu mês em review", + yourPeakIs: "Seu pico é %@", + moreVsLastMonth: "%@%d%% vs mês passado", + consumptionUp: "Você está assistindo mais recentemente", + consumptionDown: "Você está assistindo menos recentemente", + moreSeriesThanMovies: "Você está assistindo mais séries do que filmes", + moreMoviesThanSeries: "Você está assistindo mais filmes do que séries", + proUnlockDescription: "DNA de gosto completo, atividade anual, tendências e cards premium", + // Period Selector + thisMonth: "Este Mês", + lastMonth: "Mês Passado", + thisYear: "Este Ano", + allTime: "Geral", + hoursThisMonth: "horas este mês", + hoursLastMonth: "horas mês passado", + hoursThisYear: "horas este ano", + vsLastMonth: "%@ vs mês passado", + vsLastYear: "%@ vs ano passado", + perDayThisMonth: "/dia este mês", + perDayThisYear: "/dia este ano", + noActivityThisPeriod: "Sem atividade neste período", + minutes: "minutos", + timeline: "Timeline", + vsLastMonthShort: "vs mês passado", + comparisonPrevMonth: "%@ horas em %@", + topPercentile: "Mais que %d%% dos usuários", // Home Engagement forYou: "Para você", basedOnYourTaste: "Porque você gosta de %@", @@ -895,6 +1015,8 @@ enum L10n { stats: "Estadísticas", series: "Series", timeWatched: "Tiempo Visto", + youSpentWatching: "Pasaste %@ horas viendo", + distribution: "Distribución", hour: "hora", hours: "horas", days: "días", @@ -903,18 +1025,76 @@ enum L10n { couldNotLoadStats: "No se pudieron cargar las estadísticas", tryAgain: "Intentar de nuevo", favoriteGenres: "Géneros Favoritos", + favoriteGenre: "Género Favorito", + nTitles: "%d títulos", content: "Contenido", genres: "Géneros", - collectionStatus: "Estado de la Colección", itemsInCollection: "ítems en la colección", bestReviews: "Mejores Reseñas", + bestReview: "Mejor Reseña", daysOfContent: "días de contenido", othersGenres: "Otros", - mostWatchedCast: "Elenco Más Visto", watchedCountries: "Países Vistos", - mostWatchedSeries: "Series Más Vistas", - inTitles: "en %d títulos", - episodesWatched: "ep. vistos", + perDay: "al día", + yourTasteDNA: "Tu ADN de Gustos", + activity: "Actividad", + less: "Menos", + more: "Más", + peakTime: "Hora pico", + peakTimeMorning: "Mañanas", + peakTimeAfternoon: "Tardes", + peakTimeEvening: "Noches", + peakTimeNight: "Madrugadas", + evolution: "Evolución", + shareMyStats: "Compartir", + unlockFullProfile: "Desbloquear perfil completo", + startTrackingStats: "Empieza a registrar para ver tus estadísticas", + trackMoreInsights: "Registra más para desbloquear datos", + monthlyTrend: "Tendencia Mensual", + dnaPace: "Ritmo", + dnaTone: "Tono", + dnaFormat: "Formato", + dnaOrigin: "Origen", + dnaSlowTense: "Lento y lleno de tensión", + dnaFastIntense: "Rápido e intenso", + dnaVariedRhythm: "Ritmo variado", + dnaDarkMoody: "Oscuro y melancólico", + dnaLightUpbeat: "Ligero y alegre", + dnaEclectic: "Ecléctico", + dnaBingeWatcher: "Maratonista de series", + dnaFilmLover: "Entusiasta del cine", + dnaBalancedViewer: "Espectador equilibrado", + dnaGlobalExplorer: "Explorador global", + dnaMainstream: "Espectador mainstream", + dnaAsianCinema: "Fan del cine asiático", + topTitlesAre: "Porque %d%% de tus títulos top son %@", + ofYourTimeIs: "%d%% de tu tiempo", + myMonthInReview: "Mi mes en revisión", + yourPeakIs: "Tu pico es %@", + moreVsLastMonth: "%@%d%% vs mes pasado", + consumptionUp: "Estás viendo más últimamente", + consumptionDown: "Estás viendo menos últimamente", + moreSeriesThanMovies: "Estás viendo más series que películas", + moreMoviesThanSeries: "Estás viendo más películas que series", + proUnlockDescription: "ADN de gustos completo, actividad anual, tendencias y cards premium", + // Period Selector + thisMonth: "Este Mes", + lastMonth: "Mes Pasado", + thisYear: "Este Año", + allTime: "General", + hoursThisMonth: "horas este mes", + hoursLastMonth: "horas mes pasado", + hoursThisYear: "horas este año", + vsLastMonth: "%@ vs mes pasado", + vsLastYear: "%@ vs año pasado", + perDayThisMonth: "/día este mes", + perDayThisYear: "/día este año", + noActivityThisPeriod: "Sin actividad en este período", + minutes: "minutos", + timeline: "Timeline", + vsLastMonthShort: "vs mes pasado", + comparisonPrevMonth: "%@ horas en %@", + topPercentile: "Más que el %d%% de usuarios", // Home Engagement forYou: "Para ti", basedOnYourTaste: "Porque te gusta %@", @@ -1205,6 +1385,8 @@ enum L10n { stats: "Statistiques", series: "Séries", timeWatched: "Temps Regardé", + youSpentWatching: "Vous avez passé %@ heures à regarder", + distribution: "Répartition", hour: "heure", hours: "heures", days: "jours", @@ -1213,18 +1395,76 @@ enum L10n { couldNotLoadStats: "Impossible de charger les statistiques", tryAgain: "Réessayer", favoriteGenres: "Genres Préférés", + favoriteGenre: "Genre Préféré", + nTitles: "%d titres", content: "Contenu", genres: "Genres", - collectionStatus: "Statut de la Collection", itemsInCollection: "éléments dans la collection", bestReviews: "Meilleures Critiques", + bestReview: "Meilleure Critique", daysOfContent: "jours de contenu", othersGenres: "Autres", - mostWatchedCast: "Casting Le Plus Vu", watchedCountries: "Pays Regardés", - mostWatchedSeries: "Séries Les Plus Vues", - inTitles: "dans %d titres", - episodesWatched: "ép. regardés", + perDay: "par jour", + yourTasteDNA: "Votre ADN de Goût", + activity: "Activité", + less: "Moins", + more: "Plus", + peakTime: "Heure de pointe", + peakTimeMorning: "Matins", + peakTimeAfternoon: "Après-midis", + peakTimeEvening: "Soirées", + peakTimeNight: "Nuits", + evolution: "Évolution", + shareMyStats: "Partager", + unlockFullProfile: "Débloquer votre profil complet", + startTrackingStats: "Commencez à suivre pour voir vos statistiques", + trackMoreInsights: "Suivez plus pour débloquer des insights", + monthlyTrend: "Tendance Mensuelle", + dnaPace: "Rythme", + dnaTone: "Ton", + dnaFormat: "Format", + dnaOrigin: "Origine", + dnaSlowTense: "Lent et plein de tension", + dnaFastIntense: "Rapide et intense", + dnaVariedRhythm: "Rythme varié", + dnaDarkMoody: "Sombre et mélancolique", + dnaLightUpbeat: "Léger et joyeux", + dnaEclectic: "Éclectique", + dnaBingeWatcher: "Binge-watcher de séries", + dnaFilmLover: "Passionné de cinéma", + dnaBalancedViewer: "Spectateur équilibré", + dnaGlobalExplorer: "Explorateur mondial", + dnaMainstream: "Spectateur mainstream", + dnaAsianCinema: "Fan de cinéma asiatique", + topTitlesAre: "Parce que %d%% de vos titres sont %@", + ofYourTimeIs: "%d%% de votre temps", + myMonthInReview: "Mon mois en revue", + yourPeakIs: "Votre pic est %@", + moreVsLastMonth: "%@%d%% vs mois dernier", + consumptionUp: "Vous regardez plus récemment", + consumptionDown: "Vous regardez moins récemment", + moreSeriesThanMovies: "Vous regardez plus de séries que de films", + moreMoviesThanSeries: "Vous regardez plus de films que de séries", + proUnlockDescription: "ADN de goût complet, activité annuelle, tendances et cards premium", + // Period Selector + thisMonth: "Ce Mois", + lastMonth: "Mois Dernier", + thisYear: "Cette Année", + allTime: "Général", + hoursThisMonth: "heures ce mois", + hoursLastMonth: "heures mois dernier", + hoursThisYear: "heures cette année", + vsLastMonth: "%@ vs mois dernier", + vsLastYear: "%@ vs année dernière", + perDayThisMonth: "/jour ce mois", + perDayThisYear: "/jour cette année", + noActivityThisPeriod: "Aucune activité pour cette période", + minutes: "minutes", + timeline: "Timeline", + vsLastMonthShort: "vs mois dernier", + comparisonPrevMonth: "%@ heures en %@", + topPercentile: "Plus que %d%% des utilisateurs", // Home Engagement forYou: "Pour vous", basedOnYourTaste: "Parce que vous aimez %@", @@ -1515,6 +1755,8 @@ enum L10n { stats: "Statistiken", series: "Serien", timeWatched: "Gesehene Zeit", + youSpentWatching: "Du hast %@ Stunden geschaut", + distribution: "Verteilung", hour: "Stunde", hours: "Stunden", days: "Tage", @@ -1523,18 +1765,76 @@ enum L10n { couldNotLoadStats: "Statistiken konnten nicht geladen werden", tryAgain: "Erneut versuchen", favoriteGenres: "Lieblingsgenres", + favoriteGenre: "Lieblingsgenre", + nTitles: "%d Titel", content: "Inhalte", genres: "Genres", - collectionStatus: "Sammlungsstatus", itemsInCollection: "Elemente in der Sammlung", bestReviews: "Beste Bewertungen", + bestReview: "Beste Bewertung", daysOfContent: "Tage an Inhalten", othersGenres: "Andere", - mostWatchedCast: "Meistgesehene Besetzung", watchedCountries: "Gesehene Länder", - mostWatchedSeries: "Meistgesehene Serien", - inTitles: "in %d Titeln", - episodesWatched: "Ep. gesehen", + perDay: "pro Tag", + yourTasteDNA: "Dein Geschmacks-DNA", + activity: "Aktivität", + less: "Weniger", + more: "Mehr", + peakTime: "Spitzenzeit", + peakTimeMorning: "Morgens", + peakTimeAfternoon: "Nachmittags", + peakTimeEvening: "Abends", + peakTimeNight: "Nachts", + evolution: "Entwicklung", + shareMyStats: "Teilen", + unlockFullProfile: "Vollständiges Profil freischalten", + startTrackingStats: "Beginne mit dem Tracking, um deine Statistiken zu sehen", + trackMoreInsights: "Tracke mehr, um Einblicke freizuschalten", + monthlyTrend: "Monatlicher Trend", + dnaPace: "Tempo", + dnaTone: "Stimmung", + dnaFormat: "Format", + dnaOrigin: "Herkunft", + dnaSlowTense: "Langsam und spannungsgeladen", + dnaFastIntense: "Schnell und intensiv", + dnaVariedRhythm: "Abwechslungsreiches Tempo", + dnaDarkMoody: "Düster und stimmungsvoll", + dnaLightUpbeat: "Leicht und fröhlich", + dnaEclectic: "Eklektisch", + dnaBingeWatcher: "Serien-Binge-Watcher", + dnaFilmLover: "Filmbegeistert", + dnaBalancedViewer: "Ausgewogener Zuschauer", + dnaGlobalExplorer: "Globaler Entdecker", + dnaMainstream: "Mainstream-Zuschauer", + dnaAsianCinema: "Asiatischer Kino-Fan", + topTitlesAre: "Weil %d%% deiner Top-Titel %@ sind", + ofYourTimeIs: "%d%% deiner Zeit", + myMonthInReview: "Mein Monat im Rückblick", + yourPeakIs: "Dein Höhepunkt ist %@", + moreVsLastMonth: "%@%d%% vs letzten Monat", + consumptionUp: "Du schaust in letzter Zeit mehr", + consumptionDown: "Du schaust in letzter Zeit weniger", + moreSeriesThanMovies: "Du schaust mehr Serien als Filme", + moreMoviesThanSeries: "Du schaust mehr Filme als Serien", + proUnlockDescription: "Komplettes Geschmacks-DNA, Jahresaktivität, Trends und Premium-Karten", + // Period Selector + thisMonth: "Dieser Monat", + lastMonth: "Letzter Monat", + thisYear: "Dieses Jahr", + allTime: "Gesamt", + hoursThisMonth: "Stunden diesen Monat", + hoursLastMonth: "Stunden letzten Monat", + hoursThisYear: "Stunden dieses Jahr", + vsLastMonth: "%@ vs letzten Monat", + vsLastYear: "%@ vs letztes Jahr", + perDayThisMonth: "/Tag diesen Monat", + perDayThisYear: "/Tag dieses Jahr", + noActivityThisPeriod: "Keine Aktivität in diesem Zeitraum", + minutes: "Minuten", + timeline: "Timeline", + vsLastMonthShort: "vs letzten Monat", + comparisonPrevMonth: "%@ Stunden im %@", + topPercentile: "Mehr als %d%% der Nutzer", // Home Engagement forYou: "Für dich", basedOnYourTaste: "Weil du %@ magst", @@ -1825,6 +2125,8 @@ enum L10n { stats: "Statistiche", series: "Serie", timeWatched: "Tempo Guardato", + youSpentWatching: "Hai passato %@ ore guardando", + distribution: "Distribuzione", hour: "ora", hours: "ore", days: "giorni", @@ -1833,18 +2135,76 @@ enum L10n { couldNotLoadStats: "Impossibile caricare le statistiche", tryAgain: "Riprova", favoriteGenres: "Generi Preferiti", + favoriteGenre: "Genere Preferito", + nTitles: "%d titoli", content: "Contenuti", genres: "Generi", - collectionStatus: "Stato della Collezione", itemsInCollection: "elementi nella collezione", bestReviews: "Migliori Recensioni", + bestReview: "Migliore Recensione", daysOfContent: "giorni di contenuti", othersGenres: "Altri", - mostWatchedCast: "Cast Più Visto", watchedCountries: "Paesi Guardati", - mostWatchedSeries: "Serie Più Viste", - inTitles: "in %d titoli", - episodesWatched: "ep. guardati", + perDay: "al giorno", + yourTasteDNA: "Il Tuo DNA di Gusto", + activity: "Attività", + less: "Meno", + more: "Di più", + peakTime: "Ora di punta", + peakTimeMorning: "Mattine", + peakTimeAfternoon: "Pomeriggi", + peakTimeEvening: "Sere", + peakTimeNight: "Notti", + evolution: "Evoluzione", + shareMyStats: "Condividi", + unlockFullProfile: "Sblocca il profilo completo", + startTrackingStats: "Inizia a tracciare per vedere le tue statistiche", + trackMoreInsights: "Traccia di più per sbloccare approfondimenti", + monthlyTrend: "Tendenza Mensile", + dnaPace: "Ritmo", + dnaTone: "Tono", + dnaFormat: "Formato", + dnaOrigin: "Origine", + dnaSlowTense: "Lento e pieno di tensione", + dnaFastIntense: "Veloce e intenso", + dnaVariedRhythm: "Ritmo vario", + dnaDarkMoody: "Cupo e malinconico", + dnaLightUpbeat: "Leggero e allegro", + dnaEclectic: "Eclettico", + dnaBingeWatcher: "Maratoneta di serie", + dnaFilmLover: "Appassionato di cinema", + dnaBalancedViewer: "Spettatore equilibrato", + dnaGlobalExplorer: "Esploratore globale", + dnaMainstream: "Spettatore mainstream", + dnaAsianCinema: "Fan del cinema asiatico", + topTitlesAre: "Perché %d%% dei tuoi titoli top sono %@", + ofYourTimeIs: "%d%% del tuo tempo", + myMonthInReview: "Il mio mese in rassegna", + yourPeakIs: "Il tuo picco è %@", + moreVsLastMonth: "%@%d%% vs mese scorso", + consumptionUp: "Stai guardando di più ultimamente", + consumptionDown: "Stai guardando di meno ultimamente", + moreSeriesThanMovies: "Stai guardando più serie che film", + moreMoviesThanSeries: "Stai guardando più film che serie", + proUnlockDescription: "DNA di gusto completo, attività annuale, tendenze e card premium", + // Period Selector + thisMonth: "Questo Mese", + lastMonth: "Mese Scorso", + thisYear: "Quest'Anno", + allTime: "Generale", + hoursThisMonth: "ore questo mese", + hoursLastMonth: "ore mese scorso", + hoursThisYear: "ore quest'anno", + vsLastMonth: "%@ vs mese scorso", + vsLastYear: "%@ vs anno scorso", + perDayThisMonth: "/giorno questo mese", + perDayThisYear: "/giorno quest'anno", + noActivityThisPeriod: "Nessuna attività in questo periodo", + minutes: "minuti", + timeline: "Timeline", + vsLastMonthShort: "vs mese scorso", + comparisonPrevMonth: "%@ ore a %@", + topPercentile: "Più del %d%% degli utenti", // Home Engagement forYou: "Per te", basedOnYourTaste: "Perché ti piace %@", @@ -2134,6 +2494,8 @@ enum L10n { stats: "統計", series: "シリーズ", timeWatched: "視聴時間", + youSpentWatching: "%@時間視聴しました", + distribution: "内訳", hour: "時間", hours: "時間", days: "日", @@ -2142,18 +2504,76 @@ enum L10n { couldNotLoadStats: "統計を読み込めませんでした", tryAgain: "再試行", favoriteGenres: "お気に入りジャンル", + favoriteGenre: "お気に入りジャンル", + nTitles: "%d作品", content: "コンテンツ", genres: "ジャンル", - collectionStatus: "コレクションステータス", itemsInCollection: "アイテム", bestReviews: "ベストレビュー", + bestReview: "ベストレビュー", daysOfContent: "日分のコンテンツ", othersGenres: "その他", - mostWatchedCast: "よく見た俳優", watchedCountries: "視聴した国", - mostWatchedSeries: "よく見たシリーズ", - inTitles: "%d作品に出演", - episodesWatched: "話視聴済み", + perDay: "1日あたり", + yourTasteDNA: "あなたの好みDNA", + activity: "アクティビティ", + less: "少ない", + more: "多い", + peakTime: "ピーク時間", + peakTimeMorning: "朝", + peakTimeAfternoon: "午後", + peakTimeEvening: "夕方", + peakTimeNight: "夜", + evolution: "推移", + shareMyStats: "共有", + unlockFullProfile: "フルプロフィールをアンロック", + startTrackingStats: "トラッキングを始めて統計を見よう", + trackMoreInsights: "もっとトラッキングしてインサイトを解除", + monthlyTrend: "月間トレンド", + dnaPace: "テンポ", + dnaTone: "トーン", + dnaFormat: "フォーマット", + dnaOrigin: "出身", + dnaSlowTense: "スローで緊張感がある", + dnaFastIntense: "テンポが速く激しい", + dnaVariedRhythm: "多彩なリズム", + dnaDarkMoody: "ダークでムーディー", + dnaLightUpbeat: "明るく陽気", + dnaEclectic: "エクレクティック", + dnaBingeWatcher: "シリーズ一気見派", + dnaFilmLover: "映画愛好家", + dnaBalancedViewer: "バランス型視聴者", + dnaGlobalExplorer: "グローバル探検家", + dnaMainstream: "メインストリーム視聴者", + dnaAsianCinema: "アジア映画ファン", + topTitlesAre: "トップタイトルの%d%%が%@だから", + ofYourTimeIs: "視聴時間の%d%%", + myMonthInReview: "今月の振り返り", + yourPeakIs: "ピークは%@", + moreVsLastMonth: "先月比%@%d%%", + consumptionUp: "最近の視聴が増えています", + consumptionDown: "最近の視聴が減っています", + moreSeriesThanMovies: "映画よりシリーズを多く見ています", + moreMoviesThanSeries: "シリーズより映画を多く見ています", + proUnlockDescription: "完全な好みDNA、年間アクティビティ、トレンド、プレミアムカード", + // Period Selector + thisMonth: "今月", + lastMonth: "先月", + thisYear: "今年", + allTime: "全期間", + hoursThisMonth: "時間(今月)", + hoursLastMonth: "時間(先月)", + hoursThisYear: "時間(今年)", + vsLastMonth: "%@ 先月比", + vsLastYear: "%@ 昨年比", + perDayThisMonth: "/日(今月)", + perDayThisYear: "/日(今年)", + noActivityThisPeriod: "この期間にアクティビティはありません", + minutes: "分", + timeline: "タイムライン", + vsLastMonthShort: "vs 先月", + comparisonPrevMonth: "%@の%@時間", + topPercentile: "ユーザーの%d%%以上", // Home Engagement forYou: "あなたへ", basedOnYourTaste: "%@が好きだから", @@ -2455,6 +2875,8 @@ struct Strings { let stats: String let series: String let timeWatched: String + let youSpentWatching: String + let distribution: String let hour: String let hours: String let days: String @@ -2463,18 +2885,76 @@ struct Strings { let couldNotLoadStats: String let tryAgain: String let favoriteGenres: String + let favoriteGenre: String + let nTitles: String let content: String let genres: String - let collectionStatus: String let itemsInCollection: String let bestReviews: String + let bestReview: String let daysOfContent: String let othersGenres: String - let mostWatchedCast: String let watchedCountries: String - let mostWatchedSeries: String - let inTitles: String - let episodesWatched: String + let perDay: String + let yourTasteDNA: String + let activity: String + let less: String + let more: String + let peakTime: String + let peakTimeMorning: String + let peakTimeAfternoon: String + let peakTimeEvening: String + let peakTimeNight: String + let evolution: String + let shareMyStats: String + let unlockFullProfile: String + let startTrackingStats: String + let trackMoreInsights: String + let monthlyTrend: String + let dnaPace: String + let dnaTone: String + let dnaFormat: String + let dnaOrigin: String + let dnaSlowTense: String + let dnaFastIntense: String + let dnaVariedRhythm: String + let dnaDarkMoody: String + let dnaLightUpbeat: String + let dnaEclectic: String + let dnaBingeWatcher: String + let dnaFilmLover: String + let dnaBalancedViewer: String + let dnaGlobalExplorer: String + let dnaMainstream: String + let dnaAsianCinema: String + let topTitlesAre: String + let ofYourTimeIs: String + let myMonthInReview: String + let yourPeakIs: String + let moreVsLastMonth: String + let consumptionUp: String + let consumptionDown: String + let moreSeriesThanMovies: String + let moreMoviesThanSeries: String + let proUnlockDescription: String + // Period Selector + let thisMonth: String + let lastMonth: String + let thisYear: String + let allTime: String + let hoursThisMonth: String + let hoursLastMonth: String + let hoursThisYear: String + let vsLastMonth: String + let vsLastYear: String + let perDayThisMonth: String + let perDayThisYear: String + let noActivityThisPeriod: String + let minutes: String + let timeline: String + let vsLastMonthShort: String + let comparisonPrevMonth: String + let topPercentile: String // Home Engagement let forYou: String let basedOnYourTaste: String diff --git a/apps/ios/Plotwist/Plotwist/Services/ProfilePrefetchService.swift b/apps/ios/Plotwist/Plotwist/Services/ProfilePrefetchService.swift index 017f3b32c..67352badc 100644 --- a/apps/ios/Plotwist/Plotwist/Services/ProfilePrefetchService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/ProfilePrefetchService.swift @@ -112,32 +112,33 @@ final class ProfilePrefetchService { } catch {} } - // Prefetch stats (total hours, genres, status distribution, best reviews) + // Prefetch stats for current month (timeline default view) group.addTask { let statsCache = ProfileStatsCache.shared - guard statsCache.get(userId: userId) == nil else { return } + let currentMonth = MonthSection.currentYearMonth() + guard statsCache.get(userId: userId, period: currentMonth) == nil else { return } let language = Language.current.rawValue do { - async let hoursTask = UserStatsService.shared.getTotalHours(userId: userId) + async let hoursTask = UserStatsService.shared.getTotalHours(userId: userId, period: currentMonth) async let genresTask = UserStatsService.shared.getWatchedGenres( userId: userId, - language: language + language: language, + period: currentMonth ) - async let statusTask = UserStatsService.shared.getItemsStatus(userId: userId) async let reviewsTask = UserStatsService.shared.getBestReviews( userId: userId, - language: language + language: language, + period: currentMonth ) - - let (hoursResponse, genres, status, reviews) = try await ( - hoursTask, genresTask, statusTask, reviewsTask + let (hoursResponse, genres, reviews) = try await ( + hoursTask, genresTask, reviewsTask ) statsCache.set( userId: userId, + period: currentMonth, totalHours: hoursResponse.totalHours, watchedGenres: genres, - itemsStatus: status, bestReviews: reviews ) } catch {} diff --git a/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift index a8d8ca078..e3dd00ce3 100644 --- a/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift @@ -9,6 +9,15 @@ class UserStatsService { static let shared = UserStatsService() private init() {} + private func buildURL(base: String, queryItems: [URLQueryItem]) -> URL? { + var components = URLComponents(string: base) + let filtered = queryItems.filter { $0.value != nil } + if !filtered.isEmpty { + components?.queryItems = filtered + } + return components?.url + } + // MARK: - Get User Stats (followers, following, watched counts) func getUserStats(userId: String) async throws -> UserStats { guard let url = URL(string: "\(API.baseURL)/user/\(userId)/stats") else { @@ -32,8 +41,11 @@ class UserStatsService { } // MARK: - Get Total Hours Watched - func getTotalHours(userId: String) async throws -> TotalHoursResponse { - guard let url = URL(string: "\(API.baseURL)/user/\(userId)/total-hours") else { + func getTotalHours(userId: String, period: String = "all") async throws -> TotalHoursResponse { + guard let url = buildURL( + base: "\(API.baseURL)/user/\(userId)/total-hours", + queryItems: [URLQueryItem(name: "period", value: period)] + ) else { throw UserStatsError.invalidURL } @@ -54,8 +66,14 @@ class UserStatsService { } // MARK: - Get Watched Genres - func getWatchedGenres(userId: String, language: String = "en-US") async throws -> [WatchedGenre] { - guard let url = URL(string: "\(API.baseURL)/user/\(userId)/watched-genres?language=\(language)") else { + func getWatchedGenres(userId: String, language: String = "en-US", period: String = "all") async throws -> [WatchedGenre] { + guard let url = buildURL( + base: "\(API.baseURL)/user/\(userId)/watched-genres", + queryItems: [ + URLQueryItem(name: "language", value: language), + URLQueryItem(name: "period", value: period), + ] + ) else { throw UserStatsError.invalidURL } @@ -75,8 +93,11 @@ class UserStatsService { } // MARK: - Get Items Status Distribution - func getItemsStatus(userId: String) async throws -> [ItemStatusStat] { - guard let url = URL(string: "\(API.baseURL)/user/\(userId)/items-status") else { + func getItemsStatus(userId: String, period: String = "all") async throws -> [ItemStatusStat] { + guard let url = buildURL( + base: "\(API.baseURL)/user/\(userId)/items-status", + queryItems: [URLQueryItem(name: "period", value: period)] + ) else { throw UserStatsError.invalidURL } @@ -94,9 +115,17 @@ class UserStatsService { let result = try decoder.decode(ItemsStatusResponse.self, from: data) return result.userItems } + // MARK: - Get Best Reviews - func getBestReviews(userId: String, language: String = "en-US", limit: Int = 50) async throws -> [BestReview] { - guard let url = URL(string: "\(API.baseURL)/user/\(userId)/best-reviews?language=\(language)&limit=\(limit)") else { + func getBestReviews(userId: String, language: String = "en-US", limit: Int = 50, period: String = "all") async throws -> [BestReview] { + guard let url = buildURL( + base: "\(API.baseURL)/user/\(userId)/best-reviews", + queryItems: [ + URLQueryItem(name: "language", value: language), + URLQueryItem(name: "limit", value: "\(limit)"), + URLQueryItem(name: "period", value: period), + ] + ) else { throw UserStatsError.invalidURL } @@ -115,9 +144,45 @@ class UserStatsService { return result.bestReviews } + // MARK: - Get Stats Timeline (Paginated) + func getStatsTimeline(userId: String, language: String = "en-US", cursor: String? = nil, pageSize: Int = 3) async throws -> StatsTimelineResponse { + var queryItems = [ + URLQueryItem(name: "language", value: language), + URLQueryItem(name: "pageSize", value: "\(pageSize)"), + ] + if let cursor { + queryItems.append(URLQueryItem(name: "cursor", value: cursor)) + } + + guard let url = buildURL( + base: "\(API.baseURL)/user/\(userId)/stats-timeline", + queryItems: queryItems + ) else { + throw UserStatsError.invalidURL + } + + var request = URLRequest(url: url) + request.setValue("application/json", forHTTPHeaderField: "Accept") + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let http = response as? HTTPURLResponse, http.statusCode == 200 else { + let code = (response as? HTTPURLResponse)?.statusCode ?? -1 + AnalyticsService.trackAPIError(endpoint: "/user/stats-timeline", statusCode: code) + throw UserStatsError.invalidResponse + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + return try decoder.decode(StatsTimelineResponse.self, from: data) + } + // MARK: - Get Watched Cast - func getWatchedCast(userId: String) async throws -> [WatchedCastMember] { - guard let url = URL(string: "\(API.baseURL)/user/\(userId)/watched-cast") else { + func getWatchedCast(userId: String, period: String = "all") async throws -> [WatchedCastMember] { + guard let url = buildURL( + base: "\(API.baseURL)/user/\(userId)/watched-cast", + queryItems: [URLQueryItem(name: "period", value: period)] + ) else { throw UserStatsError.invalidURL } @@ -139,8 +204,14 @@ class UserStatsService { } // MARK: - Get Watched Countries - func getWatchedCountries(userId: String, language: String = "en-US") async throws -> [WatchedCountry] { - guard let url = URL(string: "\(API.baseURL)/user/\(userId)/watched-countries?language=\(language)") else { + func getWatchedCountries(userId: String, language: String = "en-US", period: String = "all") async throws -> [WatchedCountry] { + guard let url = buildURL( + base: "\(API.baseURL)/user/\(userId)/watched-countries", + queryItems: [ + URLQueryItem(name: "language", value: language), + URLQueryItem(name: "period", value: period), + ] + ) else { throw UserStatsError.invalidURL } @@ -162,8 +233,14 @@ class UserStatsService { } // MARK: - Get Most Watched Series - func getMostWatchedSeries(userId: String, language: String = "en-US") async throws -> [MostWatchedSeries] { - guard let url = URL(string: "\(API.baseURL)/user/\(userId)/most-watched-series?language=\(language)") else { + func getMostWatchedSeries(userId: String, language: String = "en-US", period: String = "all") async throws -> [MostWatchedSeries] { + guard let url = buildURL( + base: "\(API.baseURL)/user/\(userId)/most-watched-series", + queryItems: [ + URLQueryItem(name: "language", value: language), + URLQueryItem(name: "period", value: period), + ] + ) else { throw UserStatsError.invalidURL } @@ -195,19 +272,76 @@ struct MonthlyHoursEntry: Codable, Identifiable { var id: String { month } } +struct PeakTimeSlot: Codable { + let slot: String + let hour: Int + let count: Int +} + +struct DailyActivityEntry: Codable, Identifiable { + let day: String + let hours: Double + + var id: String { day } +} + +struct HourlyEntry: Codable, Identifiable { + let hour: Int + let count: Int + + var id: Int { hour } +} + struct TotalHoursResponse: Codable { let totalHours: Double let movieHours: Double let seriesHours: Double let monthlyHours: [MonthlyHoursEntry] + let peakTimeSlot: PeakTimeSlot? + let hourlyDistribution: [HourlyEntry]? + let dailyActivity: [DailyActivityEntry]? + let percentileRank: Int? +} + +struct GenreItem: Codable, Identifiable, Hashable { + let tmdbId: Int + let mediaType: String + let posterPath: String? + + var id: Int { tmdbId } + + var posterURL: URL? { + guard let posterPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/w342\(posterPath)") + } } struct WatchedGenre: Codable, Identifiable { let name: String let count: Int let percentage: Double + let posterPath: String? + let posterPaths: [String]? + let items: [GenreItem]? var id: String { name } + + var posterURL: URL? { + guard let posterPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/w342\(posterPath)") + } + + var posterURLs: [URL] { + if let items, !items.isEmpty { + return items.compactMap { $0.posterURL } + } + return (posterPaths ?? (posterPath.map { [$0] } ?? [])) + .compactMap { URL(string: "https://image.tmdb.org/t/p/w342\($0)") } + } + + var genreItems: [GenreItem] { + items ?? [] + } } struct WatchedGenresResponse: Codable { @@ -304,6 +438,44 @@ struct MostWatchedSeriesResponse: Codable { let mostWatchedSeries: [MostWatchedSeries] } +// MARK: - Stats Timeline Models + +struct TimelineGenreSummary: Codable { + let name: String + let posterPath: String? + + var posterURL: URL? { + guard let posterPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/w342\(posterPath)") + } +} + +struct TimelineReviewSummary: Codable { + let title: String + let posterPath: String? + let rating: Double + + var posterURL: URL? { + guard let posterPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/w342\(posterPath)") + } +} + +struct TimelineSection: Codable { + let yearMonth: String + let totalHours: Double + let movieHours: Double + let seriesHours: Double + let topGenre: TimelineGenreSummary? + let topReview: TimelineReviewSummary? +} + +struct StatsTimelineResponse: Codable { + let sections: [TimelineSection] + let nextCursor: String? + let hasMore: Bool +} + enum UserStatsError: LocalizedError { case invalidURL case invalidResponse diff --git a/apps/ios/Plotwist/Plotwist/Theme/Colors.swift b/apps/ios/Plotwist/Plotwist/Theme/Colors.swift index 0d18c5f3d..6cc692bd8 100644 --- a/apps/ios/Plotwist/Plotwist/Theme/Colors.swift +++ b/apps/ios/Plotwist/Plotwist/Theme/Colors.swift @@ -111,6 +111,15 @@ extension Color { static let appDestructive = Color(hue: 0, saturation: 0.842, brightness: 0.602) + static var statsCardBackground: Color { + Color( + UIColor { + $0.userInterfaceStyle == .dark + ? UIColor(hue: 240 / 360, saturation: 0.037, brightness: 0.159, alpha: 0.4) + : UIColor(red: 246 / 255, green: 246 / 255, blue: 249 / 255, alpha: 1) + }) + } + // Star rating yellow - bright gold that works in both light and dark modes static let appStarYellow = Color(hex: "FBBF24") } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift new file mode 100644 index 000000000..808e8729b --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift @@ -0,0 +1,299 @@ +// +// PeriodGenresView.swift +// Plotwist + +import SwiftUI + +struct PeriodGenresView: View { + @Environment(\.dismiss) private var dismiss + + @State var genres: [WatchedGenre] + let periodLabel: String + let strings: Strings + var userId: String? = nil + var period: String? = nil + + @State private var scrollOffset: CGFloat = 0 + @State private var initialScrollOffset: CGFloat? = nil + private let scrollThreshold: CGFloat = 10 + @State private var isLoading = false + + private var isScrolled: Bool { + guard let initial = initialScrollOffset else { return false } + return scrollOffset < initial - scrollThreshold + } + + init(genres: [WatchedGenre], periodLabel: String, strings: Strings, userId: String? = nil, period: String? = nil) { + _genres = State(initialValue: genres) + self.periodLabel = periodLabel + self.strings = strings + self.userId = userId + self.period = period + } + + var body: some View { + VStack(spacing: 0) { + detailHeaderView(title: strings.favoriteGenres, isScrolled: isScrolled) { dismiss() } + + if isLoading && genres.isEmpty { + Spacer() + ProgressView() + Spacer() + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(periodLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + Text(strings.favoriteGenres) + .font(.system(size: 34, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + .padding(.bottom, 20) + + LazyVStack(spacing: 0) { + ForEach(Array(genres.enumerated()), id: \.element.id) { index, genre in + genreRow(genre: genre, rank: index + 1) + + if index < genres.count - 1 { + Divider() + .padding(.leading, 40) + } + } + } + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 24) + .background(scrollOffsetReader) + } + } + } + .background(Color.appBackgroundAdaptive.ignoresSafeArea()) + .navigationBarHidden(true) + .task { await loadGenresIfNeeded() } + } + + private var scrollOffsetReader: some View { + GeometryReader { geo -> Color in + DispatchQueue.main.async { + let offset = geo.frame(in: .global).minY + if initialScrollOffset == nil { + initialScrollOffset = offset + } + scrollOffset = offset + } + return Color.clear + } + } + + private func genreRow(genre: WatchedGenre, rank: Int) -> some View { + HStack(spacing: 14) { + Text("\(rank)") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + .frame(width: 22, alignment: .leading) + + VStack(alignment: .leading, spacing: 4) { + Text(genre.name) + .font(.system(size: rank <= 3 ? 17 : 15, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + .lineLimit(1) + + Text(genre.percentage < 1 + ? String(format: "%.1f%%", genre.percentage) + : String(format: "%.0f%%", genre.percentage)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + } + + Spacer() + + PosterDeckView(items: genre.genreItems, urls: genre.posterURLs, rank: rank, genreName: genre.name, itemCount: genre.count, strings: strings) + } + .padding(.vertical, 12) + } + + private func loadGenresIfNeeded() async { + guard let userId, let period, genres.count <= 1, period != "all" else { return } + isLoading = true + if let loaded = try? await UserStatsService.shared.getWatchedGenres( + userId: userId, language: Language.current.rawValue, period: period + ) { + genres = loaded + } + isLoading = false + } +} + +// MARK: - Poster Deck View + +struct PosterDeckView: View { + let items: [GenreItem] + let urls: [URL] + let rank: Int + let genreName: String + let itemCount: Int + let strings: Strings + + @State private var showSheet = false + @State private var selectedItem: GenreItem? + + var body: some View { + let posterWidth: CGFloat = rank == 1 ? 115 : rank == 2 ? 100 : rank == 3 ? 90 : 78 + let posterHeight = posterWidth * 1.5 + let cr: CGFloat = rank <= 2 ? 10 : 8 + let positions: [(x: CGFloat, rotation: Double)] = [ + (0, 0), + (14, 5), + (28, 10), + ] + let displayURLs = Array(urls.prefix(3)) + let count = displayURLs.count + let deckWidth: CGFloat = posterWidth + (count > 1 ? positions[count - 1].x : 0) + + Group { + if items.count == 1, let item = items.first { + NavigationLink { + MediaDetailView(mediaId: item.tmdbId, mediaType: item.mediaType) + } label: { + deckContent( + urls: displayURLs, + posterWidth: posterWidth, + posterHeight: posterHeight, + cr: cr, + positions: positions, + deckWidth: deckWidth + ) + } + .buttonStyle(.plain) + } else { + deckContent( + urls: displayURLs, + posterWidth: posterWidth, + posterHeight: posterHeight, + cr: cr, + positions: positions, + deckWidth: deckWidth + ) + .contentShape(Rectangle()) + .onTapGesture { + if items.count > 1 { showSheet = true } + } + .sheet(isPresented: $showSheet) { + GenreItemsSheet(items: items, genreName: genreName, itemCount: itemCount, strings: strings) { item in + showSheet = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { + selectedItem = item + } + } + .presentationDetents([.medium, .large]) + .presentationBackground { + Color.appSheetBackgroundAdaptive.ignoresSafeArea() + } + .presentationDragIndicator(.visible) + } + .navigationDestination(item: $selectedItem) { item in + MediaDetailView(mediaId: item.tmdbId, mediaType: item.mediaType) + } + } + } + } + + private func deckContent( + urls: [URL], + posterWidth: CGFloat, + posterHeight: CGFloat, + cr: CGFloat, + positions: [(x: CGFloat, rotation: Double)], + deckWidth: CGFloat + ) -> some View { + ZStack(alignment: .leading) { + ForEach(Array(urls.enumerated().reversed()), id: \.element) { index, url in + CachedAsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: cr) + .fill(Color.appBorderAdaptive.opacity(0.3)) + } + .frame(width: posterWidth, height: posterHeight) + .clipShape(RoundedRectangle(cornerRadius: cr)) + .shadow(color: .black.opacity(0.15), radius: 3, x: 1, y: 1) + .offset(x: positions[index].x) + .rotationEffect(.degrees(positions[index].rotation), anchor: .bottom) + } + } + .frame(width: deckWidth + 20, height: posterHeight + 10, alignment: .leading) + } +} + +// MARK: - Genre Items Sheet + +struct GenreItemsSheet: View { + let items: [GenreItem] + let genreName: String + let itemCount: Int + let strings: Strings + let onSelectItem: (GenreItem) -> Void + + private let columns = [ + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + GridItem(.flexible(), spacing: 10), + ] + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 16) { + VStack(spacing: 4) { + Text(genreName) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + + Text(String(format: strings.nTitles, itemCount)) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + } + .frame(maxWidth: .infinity) + + LazyVGrid(columns: columns, spacing: 10) { + ForEach(items) { item in + Button { + onSelectItem(item) + } label: { + Group { + if let url = item.posterURL { + CachedAsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(2/3, contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: 10) + .fill(Color.appBorderAdaptive.opacity(0.3)) + .aspectRatio(2/3, contentMode: .fill) + } + } else { + RoundedRectangle(cornerRadius: 10) + .fill(Color.appBorderAdaptive.opacity(0.3)) + .aspectRatio(2/3, contentMode: .fill) + .overlay { + Image(systemName: item.mediaType == "movie" ? "film" : "tv") + .font(.system(size: 20)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + } + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + .buttonStyle(.plain) + } + } + .padding(.horizontal, 16) + } + .padding(.top, 20) + .padding(.bottom, 16) + } + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift new file mode 100644 index 000000000..729612dc7 --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift @@ -0,0 +1,95 @@ +// +// PeriodReviewsView.swift +// Plotwist + +import SwiftUI + +struct PeriodReviewsView: View { + @Environment(\.dismiss) private var dismiss + + @State var reviews: [BestReview] + let periodLabel: String + let strings: Strings + var userId: String? = nil + var period: String? = nil + + @State private var scrollOffset: CGFloat = 0 + @State private var initialScrollOffset: CGFloat? = nil + private let scrollThreshold: CGFloat = 10 + @State private var isLoading = false + + private var isScrolled: Bool { + guard let initial = initialScrollOffset else { return false } + return scrollOffset < initial - scrollThreshold + } + + init(reviews: [BestReview], periodLabel: String, strings: Strings, userId: String? = nil, period: String? = nil) { + _reviews = State(initialValue: reviews) + self.periodLabel = periodLabel + self.strings = strings + self.userId = userId + self.period = period + } + + var body: some View { + VStack(spacing: 0) { + detailHeaderView(title: strings.bestReviews, isScrolled: isScrolled) { dismiss() } + + if isLoading && reviews.isEmpty { + Spacer() + ProgressView() + Spacer() + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + Text(periodLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + Text(strings.bestReviews) + .font(.system(size: 34, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + .padding(.bottom, 16) + + LazyVStack(spacing: 16) { + ForEach(Array(reviews.enumerated()), id: \.element.id) { index, review in + BestReviewRow(review: review, rank: index + 1) + } + } + } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 24) + .background(scrollOffsetReader) + } + } + } + .background(Color.appBackgroundAdaptive.ignoresSafeArea()) + .navigationBarHidden(true) + .task { await loadReviewsIfNeeded() } + } + + private var scrollOffsetReader: some View { + GeometryReader { geo -> Color in + DispatchQueue.main.async { + let offset = geo.frame(in: .global).minY + if initialScrollOffset == nil { + initialScrollOffset = offset + } + scrollOffset = offset + } + return Color.clear + } + } + + private func loadReviewsIfNeeded() async { + guard let userId, let period, reviews.isEmpty, period != "all" else { return } + isLoading = true + if let loaded = try? await UserStatsService.shared.getBestReviews( + userId: userId, language: Language.current.rawValue, period: period + ) { + reviews = loaded + } + isLoading = false + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsCache.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsCache.swift index 395bf74b9..7ea0ecbad 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsCache.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsCache.swift @@ -14,26 +14,41 @@ class ProfileStatsCache { private struct CachedStats { let totalHours: Double + let movieHours: Double + let seriesHours: Double + let monthlyHours: [MonthlyHoursEntry] let watchedGenres: [WatchedGenre] - let itemsStatus: [ItemStatusStat] let bestReviews: [BestReview] let timestamp: Date } + + private func cacheKey(userId: String, period: String) -> String { + "\(userId)_\(period)" + } - func get(userId: String) -> (totalHours: Double, watchedGenres: [WatchedGenre], itemsStatus: [ItemStatusStat], bestReviews: [BestReview])? { - guard let cached = cache[userId], + func get(userId: String, period: String = "all") -> (totalHours: Double, movieHours: Double, seriesHours: Double, monthlyHours: [MonthlyHoursEntry], watchedGenres: [WatchedGenre], bestReviews: [BestReview])? { + let key = cacheKey(userId: userId, period: period) + guard let cached = cache[key], Date().timeIntervalSince(cached.timestamp) < cacheDuration else { return nil } - return (cached.totalHours, cached.watchedGenres, cached.itemsStatus, cached.bestReviews) + return (cached.totalHours, cached.movieHours, cached.seriesHours, cached.monthlyHours, cached.watchedGenres, cached.bestReviews) } - func set(userId: String, totalHours: Double, watchedGenres: [WatchedGenre], itemsStatus: [ItemStatusStat], bestReviews: [BestReview]) { - cache[userId] = CachedStats(totalHours: totalHours, watchedGenres: watchedGenres, itemsStatus: itemsStatus, bestReviews: bestReviews, timestamp: Date()) + func set(userId: String, period: String = "all", totalHours: Double, movieHours: Double = 0, seriesHours: Double = 0, monthlyHours: [MonthlyHoursEntry] = [], watchedGenres: [WatchedGenre], bestReviews: [BestReview]) { + let key = cacheKey(userId: userId, period: period) + cache[key] = CachedStats(totalHours: totalHours, movieHours: movieHours, seriesHours: seriesHours, monthlyHours: monthlyHours, watchedGenres: watchedGenres, bestReviews: bestReviews, timestamp: Date()) } - func invalidate(userId: String) { - cache.removeValue(forKey: userId) + func invalidate(userId: String, period: String? = nil) { + if let period { + cache.removeValue(forKey: cacheKey(userId: userId, period: period)) + } else { + let keysToRemove = cache.keys.filter { $0.hasPrefix("\(userId)_") } + for key in keysToRemove { + cache.removeValue(forKey: key) + } + } } func invalidateAll() { diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsDNA.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsDNA.swift new file mode 100644 index 000000000..1bd146596 --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsDNA.swift @@ -0,0 +1,302 @@ +// +// ProfileStatsDNA.swift +// Plotwist +// + +import Foundation + +struct TasteDNA { + let traits: [TasteTrait] + + static func compute( + genres: [WatchedGenre], + countries: [WatchedCountry], + movieHours: Double, + seriesHours: Double, + totalHours: Double + ) -> TasteDNA { + var traits: [TasteTrait] = [] + + if !genres.isEmpty { + traits.append(computePace(genres: genres)) + traits.append(computeTone(genres: genres)) + } + + if totalHours > 0 { + traits.append(computeFormat( + movieHours: movieHours, + seriesHours: seriesHours, + totalHours: totalHours + )) + } + + if !countries.isEmpty { + traits.append(computeOrigin(countries: countries)) + } + + return TasteDNA(traits: traits) + } + + // MARK: - Genre Classification Sets + + private static let slowGenres: Set = [ + "drama", "mystery", "documentary", "history", "war", "war & politics", + "romance", "family", "biography" + ] + + private static let fastGenres: Set = [ + "action", "action & adventure", "thriller", "horror", "adventure", + "science fiction", "sci-fi & fantasy", "crime" + ] + + private static let darkGenres: Set = [ + "horror", "thriller", "crime", "war", "war & politics", "mystery", "drama" + ] + + private static let lightGenres: Set = [ + "comedy", "animation", "family", "romance", "music", "kids" + ] + + private static let asianCountryTokens: Set = [ + "south korea", "korea", "japan", "china", "taiwan", "hong kong", + "thailand", "india", "indonesia", "philippines", + "coreia do sul", "coréia do sul", "japão", "tailândia", "índia", + "corea del sur", "japón", + "corée du sud", "japon", "chine", "thaïlande", "inde", + "südkorea", "thailand", "indien", + "corea del sud", "giappone", "cina", + "韓国", "日本", "中国" + ] + + private static let westernTokens: Set = [ + "united states of america", "united states", "united kingdom", + "united kingdom of great britain and northern ireland", + "estados unidos da américa", "estados unidos", "reino unido", + "estados unidos de américa", + "états-unis", "états-unis d'amérique", "royaume-uni", + "vereinigte staaten", "vereinigte staaten von amerika", + "großbritannien", "vereinigtes königreich", + "stati uniti", "stati uniti d'america", "regno unito", + "アメリカ合衆国", "イギリス" + ] + + // MARK: - Trait Computation + + private static func computePace(genres: [WatchedGenre]) -> TasteTrait { + let topGenres = Array(genres.prefix(5)) + let totalWeight = topGenres.reduce(0.0) { $0 + $1.percentage } + + var slowWeight = 0.0 + var fastWeight = 0.0 + var slowEvidence: [String] = [] + var fastEvidence: [String] = [] + + for genre in topGenres { + let lower = genre.name.lowercased() + if slowGenres.contains(lower) { + slowWeight += genre.percentage + slowEvidence.append(genre.name) + } + if fastGenres.contains(lower) { + fastWeight += genre.percentage + fastEvidence.append(genre.name) + } + } + + let value: TasteTraitValue + let evidence: [String] + let confidence: Double + + if fastWeight > slowWeight * 1.3 { + value = .fastIntense + evidence = fastEvidence + confidence = min(fastWeight / max(totalWeight, 1), 1) + } else if slowWeight > fastWeight * 1.3 { + value = .slowTense + evidence = slowEvidence + confidence = min(slowWeight / max(totalWeight, 1), 1) + } else { + value = .variedRhythm + evidence = Array(Set(slowEvidence + fastEvidence)) + confidence = 0.5 + } + + return TasteTrait( + type: .pace, + value: value, + confidence: confidence, + evidenceGenres: evidence, + evidencePercent: Int(max(fastWeight, slowWeight)), + icon: "waveform.path" + ) + } + + private static func computeTone(genres: [WatchedGenre]) -> TasteTrait { + let topGenres = Array(genres.prefix(5)) + let totalWeight = topGenres.reduce(0.0) { $0 + $1.percentage } + + var darkWeight = 0.0 + var lightWeight = 0.0 + var darkEvidence: [String] = [] + var lightEvidence: [String] = [] + + for genre in topGenres { + let lower = genre.name.lowercased() + if darkGenres.contains(lower) { + darkWeight += genre.percentage + darkEvidence.append(genre.name) + } + if lightGenres.contains(lower) { + lightWeight += genre.percentage + lightEvidence.append(genre.name) + } + } + + let value: TasteTraitValue + let evidence: [String] + let confidence: Double + + if darkWeight > lightWeight * 1.3 { + value = .darkMoody + evidence = darkEvidence + confidence = min(darkWeight / max(totalWeight, 1), 1) + } else if lightWeight > darkWeight * 1.3 { + value = .lightUpbeat + evidence = lightEvidence + confidence = min(lightWeight / max(totalWeight, 1), 1) + } else { + value = .eclectic + evidence = Array(Set(darkEvidence + lightEvidence)) + confidence = 0.5 + } + + return TasteTrait( + type: .tone, + value: value, + confidence: confidence, + evidenceGenres: evidence, + evidencePercent: Int(max(darkWeight, lightWeight)), + icon: "theatermasks" + ) + } + + private static func computeFormat( + movieHours: Double, + seriesHours: Double, + totalHours: Double + ) -> TasteTrait { + let movieRatio = movieHours / max(totalHours, 1) + let seriesRatio = seriesHours / max(totalHours, 1) + + let value: TasteTraitValue + let confidence: Double + + if seriesRatio > 0.65 { + value = .bingeWatcher + confidence = seriesRatio + } else if movieRatio > 0.65 { + value = .filmLover + confidence = movieRatio + } else { + value = .balancedViewer + confidence = 0.5 + } + + return TasteTrait( + type: .format, + value: value, + confidence: confidence, + evidenceGenres: [], + evidencePercent: Int(max(movieRatio, seriesRatio) * 100), + icon: "play.rectangle.on.rectangle" + ) + } + + private static func computeOrigin(countries: [WatchedCountry]) -> TasteTrait { + let top3 = Array(countries.prefix(3)) + + var westernWeight = 0.0 + var asianWeight = 0.0 + + for country in top3 { + let lower = country.name.lowercased() + if westernTokens.contains(lower) { + westernWeight += country.percentage + } + if asianCountryTokens.contains(lower) { + asianWeight += country.percentage + } + } + + let value: TasteTraitValue + let confidence: Double + + if asianWeight > 25 { + value = .asianCinema + confidence = min(asianWeight / 100, 1) + } else if westernWeight > 70 || countries.count <= 3 { + value = .mainstream + confidence = min(westernWeight / 100, 1) + } else { + value = .globalExplorer + confidence = min(Double(countries.count) / 10.0, 1) + } + + return TasteTrait( + type: .origin, + value: value, + confidence: confidence, + evidenceGenres: top3.map(\.name), + evidencePercent: countries.count, + icon: "globe" + ) + } +} + +// MARK: - Trait Models + +struct TasteTrait { + let type: TasteTraitType + let value: TasteTraitValue + let confidence: Double + let evidenceGenres: [String] + let evidencePercent: Int + let icon: String +} + +enum TasteTraitType { + case pace, tone, format, origin + + func localizedName(_ strings: Strings) -> String { + switch self { + case .pace: return strings.dnaPace + case .tone: return strings.dnaTone + case .format: return strings.dnaFormat + case .origin: return strings.dnaOrigin + } + } +} + +enum TasteTraitValue { + case slowTense, fastIntense, variedRhythm + case darkMoody, lightUpbeat, eclectic + case bingeWatcher, filmLover, balancedViewer + case globalExplorer, mainstream, asianCinema + + func localizedDescription(_ strings: Strings) -> String { + switch self { + case .slowTense: return strings.dnaSlowTense + case .fastIntense: return strings.dnaFastIntense + case .variedRhythm: return strings.dnaVariedRhythm + case .darkMoody: return strings.dnaDarkMoody + case .lightUpbeat: return strings.dnaLightUpbeat + case .eclectic: return strings.dnaEclectic + case .bingeWatcher: return strings.dnaBingeWatcher + case .filmLover: return strings.dnaFilmLover + case .balancedViewer: return strings.dnaBalancedViewer + case .globalExplorer: return strings.dnaGlobalExplorer + case .mainstream: return strings.dnaMainstream + case .asianCinema: return strings.dnaAsianCinema + } + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift new file mode 100644 index 000000000..be53d04a7 --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift @@ -0,0 +1,317 @@ +// +// ProfileStatsHelpers.swift +// Plotwist +// + +import SwiftUI + +// MARK: - Free Utility Functions (shared across views) + +private let decimalFormatter: NumberFormatter = { + let f = NumberFormatter() + f.numberStyle = .decimal + return f +}() + +func formatTotalMinutes(_ hours: Double) -> String { + let totalMinutes = Int(hours * 60) + return decimalFormatter.string(from: NSNumber(value: totalMinutes)) ?? "\(totalMinutes)" +} + +func formatTotalHours(_ hours: Double) -> String { + let rounded = Int(hours) + return decimalFormatter.string(from: NSNumber(value: rounded)) ?? "\(rounded)" +} + +func formatHoursMinutes(_ hours: Double) -> String { + let totalMinutes = Int(hours * 60) + let h = totalMinutes / 60 + let m = totalMinutes % 60 + if h == 0 { return "\(m)m" } + if m == 0 { return "\(h)h" } + return "\(h)h \(m)m" +} + +func computeGridSteps(maxValue: Double) -> [Double] { + guard maxValue > 0 else { return [1] } + let nice = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000.0] + let target = maxValue / 3.0 + let step = nice.first(where: { $0 >= target }) ?? maxValue + var steps: [Double] = [] + var v = step + while v <= maxValue + step * 0.1 { + steps.append(v) + v += step + } + if steps.isEmpty { steps = [maxValue] } + return steps +} + +func formatAxisLabel(_ value: Double) -> String { + if value >= 1000 { return String(format: "%.0fk", value / 1000) } + if value == floor(value) { return String(format: "%.0f", value) } + return String(format: "%.1f", value) +} + +private let ymParseFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM" + return f +}() + +private let shortMonthFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM" + return f +}() + +private let fullMonthFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMMM" + return f +}() + +func shortMonthLabel(_ month: String) -> String { + guard let date = ymParseFormatter.date(from: month) else { return month } + return shortMonthFormatter.string(from: date).prefix(3).lowercased() +} + +func fullMonthLabel(_ month: String) -> String { + guard let date = ymParseFormatter.date(from: month) else { return month } + return fullMonthFormatter.string(from: date) +} + +// MARK: - ProfileStatsView Helpers +extension ProfileStatsView { + func formatHours(_ hours: Double) -> String { + if hours >= 1000 { + return String(format: "%.1fk", hours / 1000) + } + return String(format: "%.0f", hours) + } + + func formatDays(_ hours: Double) -> String { + let days = hours / 24 + return String(format: "%.0f", days) + } + + // MARK: - Country Helpers + + static var tmdbNameOverrides: [String: String] { + [ + "united states of america": "US", + "united states": "US", + "united kingdom of great britain and northern ireland": "GB", + "korea": "KR", + "republic of korea": "KR", + "south korea": "KR", + "czech republic": "CZ", + "russian federation": "RU", + "taiwan, province of china": "TW", + "hong kong sar china": "HK", + "iran, islamic republic of": "IR", + "viet nam": "VN", + "estados unidos da américa": "US", + "estados unidos": "US", + "reino unido": "GB", + "coreia do sul": "KR", + "coréia do sul": "KR", + "república tcheca": "CZ", + "república checa": "CZ", + "estados unidos de américa": "US", + "corea del sur": "KR", + "états-unis": "US", + "états-unis d'amérique": "US", + "corée du sud": "KR", + "royaume-uni": "GB", + "vereinigte staaten von amerika": "US", + "vereinigte staaten": "US", + "südkorea": "KR", + "großbritannien": "GB", + "vereinigtes königreich": "GB", + "tschechien": "CZ", + "stati uniti d'america": "US", + "stati uniti": "US", + "regno unito": "GB", + "アメリカ合衆国": "US", + "韓国": "KR", + "イギリス": "GB", + ] + } + + func isoCodeForCountry(_ name: String) -> String? { + if let code = Self.tmdbNameOverrides[name.lowercased()] { + return code + } + + let languageId = Language.current.rawValue.replacingOccurrences(of: "-", with: "_") + let locale = Locale(identifier: languageId) + + for code in Locale.Region.isoRegions.map(\.identifier) { + guard code.count == 2 else { continue } + if let localizedName = locale.localizedString(forRegionCode: code), + localizedName.caseInsensitiveCompare(name) == .orderedSame { + return code + } + } + + let enLocale = Locale(identifier: "en_US") + for code in Locale.Region.isoRegions.map(\.identifier) { + guard code.count == 2 else { continue } + if let localizedName = enLocale.localizedString(forRegionCode: code), + localizedName.caseInsensitiveCompare(name) == .orderedSame { + return code + } + } + + for code in Locale.Region.isoRegions.map(\.identifier) { + guard code.count == 2 else { continue } + if let localizedName = enLocale.localizedString(forRegionCode: code), + name.localizedCaseInsensitiveContains(localizedName) || localizedName.localizedCaseInsensitiveContains(name) { + return code + } + } + + return nil + } + + func flagForCountry(_ name: String) -> String { + guard let code = isoCodeForCountry(name) else { return "🌍" } + return emojiFlag(code) + } + + func emojiFlag(_ countryCode: String) -> String { + let base: UInt32 = 127397 + var flag = "" + for scalar in countryCode.uppercased().unicodeScalars { + if let s = UnicodeScalar(base + scalar.value) { + flag.unicodeScalars.append(s) + } + } + return flag + } +} + +// MARK: - Stats Genre Chip +struct StatsGenreChip: View { + let genre: WatchedGenre + let isFirst: Bool + + var body: some View { + HStack(spacing: 8) { + Text(genre.name) + .font(.system(size: 14, weight: .medium)) + Text("\(genre.count)") + .font(.system(size: 12)) + .opacity(isFirst ? 0.6 : 1) + } + .foregroundColor(isFirst ? .appBackgroundAdaptive : .appForegroundAdaptive) + .padding(.horizontal, 16) + .padding(.vertical, 10) + .background(isFirst ? Color.appForegroundAdaptive : Color.clear) + .overlay( + RoundedRectangle(cornerRadius: 20) + .strokeBorder(Color.appBorderAdaptive, lineWidth: isFirst ? 0 : 1) + ) + .clipShape(RoundedRectangle(cornerRadius: 20)) + } +} + +// MARK: - Best Review Row +struct BestReviewRow: View { + let review: BestReview + let rank: Int + + private var posterWidth: CGFloat { + (UIScreen.main.bounds.width - 48 - 24) / 3 + } + + private var posterHeight: CGFloat { + posterWidth * 1.5 + } + + private static let isoFormatterFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() + + private static let isoFormatter: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime] + return f + }() + + private static let dateOutputFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "dd/MM/yyyy" + return f + }() + + private var formattedDate: String { + if let date = Self.isoFormatterFractional.date(from: review.createdAt) { + return Self.dateOutputFormatter.string(from: date) + } + if let date = Self.isoFormatter.date(from: review.createdAt) { + return Self.dateOutputFormatter.string(from: date) + } + return review.createdAt + } + + var body: some View { + HStack(alignment: .center, spacing: 12) { + CachedAsyncImage(url: review.posterURL) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster) + .fill(Color.appBorderAdaptive) + } + .frame(width: posterWidth, height: posterHeight) + .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster)) + .posterBorder() + .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) + + VStack(alignment: .leading, spacing: 8) { + Text(review.title) + .font(.headline) + .foregroundColor(.appForegroundAdaptive) + .lineLimit(2) + + HStack(spacing: 8) { + StarRatingView(rating: .constant(review.rating), size: 14, interactive: false) + + Circle() + .fill(Color.appMutedForegroundAdaptive.opacity(0.5)) + .frame(width: 4, height: 4) + + Text(formattedDate) + .font(.subheadline) + .foregroundColor(.appMutedForegroundAdaptive) + } + + if !review.review.isEmpty { + Text(review.review) + .font(.subheadline) + .foregroundColor(.appMutedForegroundAdaptive) + .lineLimit(3) + .multilineTextAlignment(.leading) + .blur(radius: review.hasSpoilers ? 4 : 0) + .overlay( + review.hasSpoilers + ? Text(L10n.current.containSpoilers) + .font(.caption.weight(.medium)) + .foregroundColor(.appMutedForegroundAdaptive) + .padding(.horizontal, 6) + .padding(.vertical, 3) + .background(Color.appInputFilled) + .cornerRadius(4) + : nil + ) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift new file mode 100644 index 000000000..41081396b --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -0,0 +1,326 @@ +// +// ProfileStatsSections.swift +// Plotwist + +import SwiftUI + +// MARK: - Month Section Content View (Equatable to prevent cascading re-renders) + +struct MonthSectionContentView: View, Equatable { + let section: MonthSection + let userId: String + let strings: Strings + let period: String + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.section == rhs.section + } + + var body: some View { + VStack(spacing: 16) { + timeWatchedCard + topGenreAndReviewRow + } + } + + private var topGenreAndReviewRow: some View { + let needsFade = section.watchedGenres.count > 5 + + return HStack(alignment: .top, spacing: 12) { + Color.statsCardBackground + .clipShape(RoundedRectangle(cornerRadius: 22)) + .overlay(alignment: .top) { + topGenreCard + .mask( + VStack(spacing: 0) { + Color.black + if needsFade { + LinearGradient( + colors: [Color.black, Color.black.opacity(0)], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 60) + } + } + ) + } + .clipped() + .clipShape(RoundedRectangle(cornerRadius: 22)) + topReviewCard + } + } +} + +// MARK: - Month Section Header View (Equatable to prevent cascading re-renders) + +struct MonthSectionHeaderView: View, Equatable { + let section: MonthSection + let isOwnProfile: Bool + let onShare: () -> Void + + nonisolated static func == (lhs: Self, rhs: Self) -> Bool { + lhs.section == rhs.section && lhs.isOwnProfile == rhs.isOwnProfile + } + + var body: some View { + HStack { + Text(section.displayName) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + + Spacer() + + if isOwnProfile { + Button(action: onShare) { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + } + .padding(.horizontal, 24) + .padding(.vertical, 10) + .background( + GeometryReader { geo in + Color.appBackgroundAdaptive + .overlay(alignment: .bottom) { + Rectangle() + .fill(Color.appBorderAdaptive) + .frame(height: 1) + .opacity(geo.frame(in: .named("statsTimeline")).minY < 2 ? 1 : 0) + } + } + ) + } +} + +// MARK: - Time Watched Card + +extension MonthSectionContentView { + private var timeWatchedCard: some View { + NavigationLink { + TimeWatchedDetailView( + totalHours: section.totalHours, + movieHours: section.movieHours, + seriesHours: section.seriesHours, + monthlyHours: section.monthlyHours, + comparisonHours: section.comparisonHours, + peakTimeSlot: section.peakTimeSlot, + hourlyDistribution: section.hourlyDistribution, + dailyActivity: section.dailyActivity, + percentileRank: section.percentileRank, + periodLabel: period == "all" ? strings.allTime : section.displayName, + strings: strings, + userId: userId, + period: section.yearMonth + ) + } label: { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + Text(strings.timeWatched) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + } + .padding(.bottom, 20) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(formatTotalHours(section.totalHours)) + .font(.system(size: 34, weight: .bold, design: .rounded)) + .foregroundColor(.appForegroundAdaptive) + .contentTransition(.numericText(countsDown: false)) + + Text(strings.hours) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + } + + if period != "all", let comparison = section.comparisonHours { + comparisonBadgeView(totalHours: section.totalHours, comparison: comparison) + .padding(.top, 6) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.statsCardBackground) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + .buttonStyle(.plain) + } + + @ViewBuilder + func comparisonBadgeView(totalHours: Double, comparison: Double) -> some View { + let delta = totalHours - comparison + let sign = delta >= 0 ? "+" : "" + let label = "\(sign)\(formatHoursMinutes(abs(delta))) \(strings.vsLastMonthShort)" + + HStack(spacing: 4) { + Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 10, weight: .bold)) + Text(label) + .font(.system(size: 12, weight: .semibold)) + } + .foregroundColor(delta >= 0 ? Color(hex: "10B981") : Color(hex: "EF4444")) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background((delta >= 0 ? Color(hex: "10B981") : Color(hex: "EF4444")).opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 10)) + } + + private static let ymFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM" + return f + }() + + func computeDailyAverage() -> Double { + guard section.totalHours > 0 else { return 0 } + let cal = Calendar.current + if period == "all" { + if let firstMonth = section.monthlyHours.first?.month, + let startDate = Self.ymFormatter.date(from: firstMonth) { + let days = max(cal.dateComponents([.day], from: startDate, to: Date()).day ?? 30, 1) + return section.totalHours / Double(days) + } + return section.totalHours / 30 + } + if let date = Self.ymFormatter.date(from: period) { + let now = Date() + if cal.isDate(date, equalTo: now, toGranularity: .month) { + return section.totalHours / Double(max(cal.component(.day, from: now), 1)) + } else { + return section.totalHours / Double(cal.range(of: .day, in: .month, for: date)?.count ?? 30) + } + } + return section.totalHours / 30 + } +} + +// MARK: - Top Genre Card + +extension MonthSectionContentView { + private var topGenreCard: some View { + let hasGenres = section.hasGenreData + + let genres = section.watchedGenres + let maxCount = genres.first?.count ?? 1 + + return NavigationLink { + PeriodGenresView( + genres: genres, + periodLabel: section.yearMonth == "all" ? strings.allTime : section.displayName, + strings: strings, + userId: userId, + period: section.yearMonth + ) + } label: { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + Text(strings.favoriteGenre) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + Spacer() + if hasGenres { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + .padding(.bottom, 16) + + if !genres.isEmpty { + VStack(spacing: 16) { + ForEach(Array(genres.enumerated()), id: \.element.id) { index, genre in + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(genre.name) + .font(.system(size: index == 0 ? 15 : 13, weight: index == 0 ? .bold : .semibold)) + .foregroundColor(.appForegroundAdaptive) + .lineLimit(1) + Spacer() + Text("\(genre.count)") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + } + + GeometryReader { geo in + let fraction = maxCount > 0 ? CGFloat(genre.count) / CGFloat(maxCount) : 0 + RoundedRectangle(cornerRadius: 3) + .fill(Color.appForegroundAdaptive.opacity(index == 0 ? 0.25 : 0.12)) + .frame(width: geo.size.width * fraction) + } + .frame(height: 4) + } + } + } + } else { + Text("–") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + } + .buttonStyle(.plain) + .disabled(!hasGenres) + } +} + +// MARK: - Top Review Card + +extension MonthSectionContentView { + private var topReviewCard: some View { + let hasReviews = section.hasReviewData + + return NavigationLink { + PeriodReviewsView( + reviews: section.bestReviews, + periodLabel: section.yearMonth == "all" ? strings.allTime : section.displayName, + strings: strings, + userId: userId, + period: section.yearMonth + ) + } label: { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + Text(strings.bestReview) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + Spacer() + if hasReviews { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + .padding(.bottom, 20) + + if let title = section.topReviewTitle { + Text(title) + .font(.system(size: 16, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + .lineLimit(2) + .minimumScaleFactor(0.85) + .padding(.bottom, 10) + + statsPoster(url: section.topReviewPosterURL, rating: section.topReviewRating) + } else { + Text("–") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + .background(Color.statsCardBackground) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + .buttonStyle(.plain) + .disabled(!hasReviews) + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index 2cd52e001..777390d28 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -5,53 +5,180 @@ import SwiftUI +// MARK: - Month Section Model + +struct MonthSection: Identifiable, Equatable { + let yearMonth: String + var totalHours: Double = 0 + var movieHours: Double = 0 + var seriesHours: Double = 0 + var monthlyHours: [MonthlyHoursEntry] = [] + var watchedGenres: [WatchedGenre] = [] + var bestReviews: [BestReview] = [] + var topGenre: TimelineGenreSummary? + var topReview: TimelineReviewSummary? + var comparisonHours: Double? + var peakTimeSlot: PeakTimeSlot? + var hourlyDistribution: [HourlyEntry] = [] + var dailyActivity: [DailyActivityEntry] = [] + var percentileRank: Int? + var isLoaded: Bool = false + + var id: String { yearMonth } + + // Resolved accessors: prefer summary fields, fall back to full arrays (all-time mode) + var topGenreName: String? { topGenre?.name ?? watchedGenres.first?.name } + var topGenrePosterURL: URL? { topGenre?.posterURL ?? watchedGenres.first?.posterURL } + var hasGenreData: Bool { topGenre != nil || !watchedGenres.isEmpty } + + var topReviewTitle: String? { topReview?.title ?? bestReviews.first?.title } + var topReviewPosterURL: URL? { topReview?.posterURL ?? bestReviews.first?.posterURL } + var topReviewRating: Double? { topReview?.rating ?? bestReviews.first?.rating } + var hasReviewData: Bool { topReview != nil || !bestReviews.isEmpty } + + private static let parseFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM" + return f + }() + + private static var appLocale: Locale { + let langId = Language.current.rawValue.replacingOccurrences(of: "-", with: "_") + return Locale(identifier: langId) + } + + private static let displayFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMMM yyyy" + return f + }() + + private static let shortFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "MMM" + return f + }() + + var displayName: String { + let locale = Self.appLocale + Self.parseFormatter.locale = locale + guard let date = Self.parseFormatter.date(from: yearMonth) else { return yearMonth } + Self.displayFormatter.locale = locale + let result = Self.displayFormatter.string(from: date) + return result.prefix(1).uppercased() + result.dropFirst() + } + + var shortLabel: String { + let locale = Self.appLocale + Self.parseFormatter.locale = locale + guard let date = Self.parseFormatter.date(from: yearMonth) else { return yearMonth } + Self.shortFormatter.locale = locale + return Self.shortFormatter.string(from: date).prefix(3).uppercased() + } + + var hasMinimumData: Bool { + totalHours > 0 || hasGenreData || hasReviewData + } + + static func == (lhs: MonthSection, rhs: MonthSection) -> Bool { + lhs.yearMonth == rhs.yearMonth && + lhs.isLoaded == rhs.isLoaded && + lhs.totalHours == rhs.totalHours && + lhs.topGenre?.name == rhs.topGenre?.name && + lhs.topReview?.title == rhs.topReview?.title && + lhs.watchedGenres.count == rhs.watchedGenres.count && + lhs.bestReviews.count == rhs.bestReviews.count && + lhs.comparisonHours == rhs.comparisonHours && + lhs.peakTimeSlot?.slot == rhs.peakTimeSlot?.slot && + lhs.hourlyDistribution.count == rhs.hourlyDistribution.count + } + + static func currentYearMonth() -> String { + parseFormatter.string(from: Date()) + } + + static func previousYearMonth(from ym: String) -> String { + guard let date = parseFormatter.date(from: ym), + let prev = Calendar.current.date(byAdding: .month, value: -1, to: date) else { + return ym + } + return parseFormatter.string(from: prev) + } +} + +// MARK: - ProfileStatsView + struct ProfileStatsView: View { let userId: String - @State private var strings = L10n.current - @State private var isLoading: Bool - @State private var totalHours: Double - @State private var watchedGenres: [WatchedGenre] - @State private var itemsStatus: [ItemStatusStat] - @State private var bestReviews: [BestReview] - @State private var error: String? - @State private var showAllGenres = false - @State private var showAllReviews = false - @State private var countStartTime: Date? - @State private var animationTrigger = false - - private let countDuration: Double = 1.8 - private let cache = ProfileStatsCache.shared - - init(userId: String) { + let isPro: Bool + let isOwnProfile: Bool + + @State var strings = L10n.current + @State var selectedPeriod: String = "all" + @State private var availableMonths: [String] = [] + + @State private var loadedSections: [String: MonthSection] = [:] + @State private var loadingPeriods: Set = [] + + let cache = ProfileStatsCache.shared + + init(userId: String, isPro: Bool = false, isOwnProfile: Bool = true) { self.userId = userId - let cache = ProfileStatsCache.shared - if let cached = cache.get(userId: userId) { - _isLoading = State(initialValue: false) - _totalHours = State(initialValue: cached.totalHours) - _watchedGenres = State(initialValue: cached.watchedGenres) - _itemsStatus = State(initialValue: cached.itemsStatus) - _bestReviews = State(initialValue: cached.bestReviews) - } else { - _isLoading = State(initialValue: true) - _totalHours = State(initialValue: 0) - _watchedGenres = State(initialValue: []) - _itemsStatus = State(initialValue: []) - _bestReviews = State(initialValue: []) - } + self.isPro = isPro + self.isOwnProfile = isOwnProfile + } + + private var currentSection: MonthSection? { + loadedSections[selectedPeriod] + } + + private var isAllTime: Bool { + selectedPeriod == "all" } var body: some View { VStack(spacing: 0) { - if isLoading { - loadingView - } else if let error { - errorView(error) - } else { - statsContent + periodHeader + .padding(.horizontal, 24) + .padding(.vertical, 12) + .transaction { $0.animation = nil } + + ScrollView { + VStack(spacing: 16) { + if let section = currentSection { + if section.isLoaded && !section.hasMinimumData { + emptyStateView(isAllTime: isAllTime) + } else if section.isLoaded { + MonthSectionContentView( + section: section, + userId: userId, + strings: strings, + period: selectedPeriod + ) + .equatable() + .transition(.opacity.animation(.easeIn(duration: 0.25))) + } else { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + } + } else if loadingPeriods.contains(selectedPeriod) { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + } + } + .padding(.horizontal, 24) + .padding(.top, 8) + .padding(.bottom, 24) } } .task { - await loadStats() + buildAvailableMonths() + await loadPeriod(selectedPeriod) + } + .onChange(of: selectedPeriod) { _, newPeriod in + Task { await loadPeriod(newPeriod) } } .onAppear { AnalyticsService.shared.track(.statsView) @@ -59,550 +186,282 @@ struct ProfileStatsView: View { .onReceive(NotificationCenter.default.publisher(for: .languageChanged)) { _ in strings = L10n.current } - } - - // MARK: - Loading View - private var loadingView: some View { - VStack(spacing: 32) { - VStack(spacing: 8) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.appBorderAdaptive) - .frame(width: 120, height: 12) - RoundedRectangle(cornerRadius: 8) - .fill(Color.appBorderAdaptive) - .frame(width: 180, height: 72) - RoundedRectangle(cornerRadius: 4) - .fill(Color.appBorderAdaptive) - .frame(width: 100, height: 14) - } - .padding(.vertical, 32) - - Divider() - - VStack(alignment: .leading, spacing: 16) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.appBorderAdaptive) - .frame(width: 100, height: 12) - HStack(spacing: 8) { - ForEach(0..<4, id: \.self) { _ in - RoundedRectangle(cornerRadius: 20) - .fill(Color.appBorderAdaptive) - .frame(width: 80, height: 36) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - Divider() - - VStack(alignment: .leading, spacing: 16) { - RoundedRectangle(cornerRadius: 4) - .fill(Color.appBorderAdaptive) - .frame(width: 120, height: 12) - RoundedRectangle(cornerRadius: 4) - .fill(Color.appBorderAdaptive) - .frame(height: 8) - } - .frame(maxWidth: .infinity, alignment: .leading) + .refreshable { + cache.invalidate(userId: userId, period: selectedPeriod) + loadedSections.removeValue(forKey: selectedPeriod) + await loadPeriod(selectedPeriod) } - .padding(.horizontal, 24) - .padding(.top, 16) } - // MARK: - Error View - private func errorView(_ message: String) -> some View { - VStack(spacing: 12) { - Image(systemName: "chart.bar.xaxis") - .font(.system(size: 32)) - .foregroundColor(.appMutedForegroundAdaptive) + // MARK: - Period Header - Text(strings.couldNotLoadStats) - .font(.subheadline) - .foregroundColor(.appMutedForegroundAdaptive) + private var periodHeader: some View { + HStack { + Menu { + Button { + selectedPeriod = "all" + } label: { + HStack { + Text(strings.allTime) + if selectedPeriod == "all" { Image(systemName: "checkmark") } + } + } - Button(strings.tryAgain) { - Task { await loadStats() } - } - .font(.footnote.weight(.medium)) - .foregroundColor(.appForegroundAdaptive) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 48) - } + Divider() - // MARK: - Stats Content - private var statsContent: some View { - VStack(spacing: 32) { - heroStatsSection - - Divider() - - if !watchedGenres.isEmpty { - genresChipsSection - } - - Divider() - - if !itemsStatus.isEmpty { - statusBarSection - } - - Divider() - - if !bestReviews.isEmpty { - bestReviewsSection - } - } - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 24) - } - - // MARK: - Hero Stats Section - private var heroStatsSection: some View { - TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in - let currentHours = interpolatedHours(at: timeline.date) - - VStack(spacing: 8) { - Text(strings.timeWatched.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) - .foregroundColor(.appMutedForegroundAdaptive) - - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(formatHours(currentHours)) - .font(.system(size: 72, weight: .medium)) - .tracking(-2) - .foregroundColor(.appForegroundAdaptive) - .contentTransition(.numericText(countsDown: false)) - .animation(.snappy(duration: 0.2), value: formatHours(currentHours)) - - Text(strings.hours.lowercased()) - .font(.system(size: 18)) - .foregroundColor(.appMutedForegroundAdaptive) - } - - let daysText = "\(formatDays(currentHours)) \(strings.daysOfContent)" - Text(daysText) - .font(.system(size: 14)) - .foregroundColor(.appMutedForegroundAdaptive) - .contentTransition(.numericText(countsDown: false)) - .animation(.snappy(duration: 0.2), value: daysText) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 24) - } - } - - // MARK: - Genres Chips Section - private var genresChipsSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text(strings.favoriteGenres.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) - .foregroundColor(.appMutedForegroundAdaptive) - - FlowLayout(spacing: 8) { - ForEach(Array(watchedGenres.prefix(5).enumerated()), id: \.element.id) { index, genre in - StatsGenreChip(genre: genre, isFirst: index == 0) - } - - if watchedGenres.count > 5 { + ForEach(availableMonths.filter { $0 != "all" }, id: \.self) { period in Button { - showAllGenres = true + selectedPeriod = period } label: { - HStack(spacing: 6) { - Text(strings.othersGenres) - .font(.system(size: 14, weight: .medium)) - Image(systemName: "chevron.right") - .font(.system(size: 10, weight: .semibold)) + HStack { + Text(periodDisplayLabel(for: period)) + if period == selectedPeriod { Image(systemName: "checkmark") } } + } label: { + HStack(spacing: 4) { + Text(periodDisplayLabel(for: selectedPeriod)) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.appForegroundAdaptive) + + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) .foregroundColor(.appMutedForegroundAdaptive) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .overlay( - RoundedRectangle(cornerRadius: 20) - .strokeBorder(Color.appBorderAdaptive.opacity(0.5), lineWidth: 1, antialiased: true) - ) - .clipShape(RoundedRectangle(cornerRadius: 20)) - } } } - } - .frame(maxWidth: .infinity, alignment: .leading) - .sheet(isPresented: $showAllGenres) { - allGenresSheet - } - } - - // MARK: - All Genres Sheet - private var allGenresSheet: some View { - FloatingSheetContainer { - VStack(spacing: 0) { - HStack { - Text(strings.favoriteGenres.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) + .id(selectedPeriod) + + Spacer() + + if isOwnProfile, let section = currentSection, section.isLoaded { + Button { shareMonthStats(section) } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) - Spacer() - Button { - showAllGenres = false - } label: { - Image(systemName: "xmark") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - } - } - .padding(.horizontal, 24) - .padding(.top, 24) - .padding(.bottom, 16) - - ScrollView { - VStack(spacing: 0) { - ForEach(watchedGenres) { genre in - HStack { - Text(genre.name) - .font(.system(size: 16)) - .foregroundColor(.appForegroundAdaptive) - Spacer() - HStack(spacing: 8) { - Text("\(genre.count)") - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - Text(String(format: "%.0f%%", genre.percentage)) - .font(.system(size: 12)) - .foregroundColor(.appMutedForegroundAdaptive) - } - } - .padding(.horizontal, 24) - .padding(.vertical, 14) - - Divider() - .padding(.horizontal, 24) - } - } } } } - .floatingSheetDynamicPresentation() } - - // MARK: - Status Bar Section - private var statusBarSection: some View { - VStack(alignment: .leading, spacing: 16) { - Text(strings.collectionStatus.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) - .foregroundColor(.appMutedForegroundAdaptive) - - GeometryReader { geo in - HStack(spacing: 0) { - ForEach(Array(itemsStatus.enumerated()), id: \.element.id) { index, item in - let statusInfo = getStatusInfo(item.status) - Rectangle() - .fill(statusInfo.color) - .frame(width: geo.size.width * CGFloat(item.percentage / 100)) - .clipShape( - UnevenRoundedRectangle( - topLeadingRadius: index == 0 ? 4 : 0, - bottomLeadingRadius: index == 0 ? 4 : 0, - bottomTrailingRadius: index == itemsStatus.count - 1 ? 4 : 0, - topTrailingRadius: index == itemsStatus.count - 1 ? 4 : 0 - ) - ) - } - } - } - .frame(height: 8) - .background(Color.appInputFilled) - .clipShape(RoundedRectangle(cornerRadius: 4)) - - FlowLayout(spacing: 16) { - ForEach(itemsStatus) { item in - let statusInfo = getStatusInfo(item.status) - HStack(spacing: 6) { - Circle() - .fill(statusInfo.color) - .frame(width: 8, height: 8) - Text(statusInfo.name) - .font(.system(size: 12)) - .foregroundColor(.appMutedForegroundAdaptive) - Text("\(item.count)") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - } - } + + // MARK: - Helpers + + private func periodDisplayLabel(for period: String) -> String { + if period == "all" { return strings.allTime } + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM" + let locale = Locale(identifier: Language.current.rawValue.replacingOccurrences(of: "-", with: "_")) + fmt.locale = locale + guard let date = fmt.date(from: period) else { return period } + let display = DateFormatter() + display.dateFormat = "MMMM yyyy" + display.locale = locale + let result = display.string(from: date) + return result.prefix(1).uppercased() + result.dropFirst() + } + + private func buildAvailableMonths() { + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM" + var months: [String] = [] + let now = Date() + for i in 0..<12 { + if let date = Calendar.current.date(byAdding: .month, value: -i, to: now) { + months.append(fmt.string(from: date)) } - .frame(maxWidth: .infinity, alignment: .leading) } - .frame(maxWidth: .infinity, alignment: .leading) + months.append("all") + availableMonths = months + } + + // MARK: - Skeleton + + func skeletonRect(height: CGFloat) -> some View { + RoundedRectangle(cornerRadius: 22) + .fill(Color.appBorderAdaptive.opacity(0.5)) + .frame(height: height) + .modifier(ShimmerEffect()) } - - // MARK: - Best Reviews Section - private var bestReviewsSection: some View { - VStack(alignment: .leading, spacing: 24) { - Text(strings.bestReviews.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) + + // MARK: - Empty State + + func emptyStateView(isAllTime: Bool) -> some View { + VStack(spacing: 20) { + Spacer().frame(height: 40) + + Image(systemName: "chart.bar.doc.horizontal") + .font(.system(size: 48)) .foregroundColor(.appMutedForegroundAdaptive) - - VStack(alignment: .leading, spacing: 24) { - ForEach(Array(bestReviews.prefix(3).enumerated()), id: \.element.id) { index, review in - BestReviewRow(review: review, rank: index + 1) - } - } - .frame(maxWidth: .infinity, alignment: .leading) - - if bestReviews.count > 3 { - Button { - showAllReviews = true - } label: { - Text(strings.seeAll) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - .frame(maxWidth: .infinity) - .padding(.vertical, 12) - .overlay( - RoundedRectangle(cornerRadius: 12) - .strokeBorder(Color.appBorderAdaptive.opacity(0.5), lineWidth: 1) - ) - } + + VStack(spacing: 8) { + Text(strings.stats) + .font(.title3.bold()) + .foregroundColor(.appForegroundAdaptive) + + Text(isAllTime ? strings.startTrackingStats : strings.noActivityThisPeriod) + .font(.subheadline) + .foregroundColor(.appMutedForegroundAdaptive) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) } + + Spacer().frame(height: 40) } - .frame(maxWidth: .infinity, alignment: .leading) - .fullScreenCover(isPresented: $showAllReviews) { - allReviewsSheet - } + .frame(maxWidth: .infinity) } - - // MARK: - All Reviews Sheet - private var allReviewsSheet: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 24) { - ForEach(Array(bestReviews.enumerated()), id: \.element.id) { index, review in - BestReviewRow(review: review, rank: index + 1) - } - } - .padding(.horizontal, 24) - .padding(.top, 8) - .padding(.bottom, 24) - } - .background(Color.appBackgroundAdaptive) - .navigationTitle(strings.bestReviews) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showAllReviews = false - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 20)) - .foregroundStyle(.gray, Color(.systemGray5)) - } - } + + // MARK: - Share + + func shareMonthStats(_ section: MonthSection) { + Task { + let genreImage = await downloadImage(url: section.topGenrePosterURL) + let reviewImage = await downloadImage(url: section.topReviewPosterURL) + + let cardImage = renderShareCard( + section: section, + genrePoster: genreImage, + reviewPoster: reviewImage + ) + + let activityVC = UIActivityViewController(activityItems: [cardImage], applicationActivities: nil) + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = scene.windows.first?.rootViewController { + root.present(activityVC, animated: true) } } } - - // MARK: - Helpers - private func interpolatedHours(at date: Date) -> Double { - guard let start = countStartTime else { return 0 } - let elapsed = date.timeIntervalSince(start) - let progress = min(elapsed / countDuration, 1.0) - let eased = 1 - pow(2, -10 * progress) - return totalHours * eased - } - - private func formatHours(_ hours: Double) -> String { - if totalHours >= 1000 { - return String(format: "%.1fk", hours / 1000) + + func downloadImage(url: URL?) async -> UIImage? { + guard let url else { return nil } + do { + let (data, _) = try await URLSession.shared.data(from: url) + return UIImage(data: data) + } catch { + return nil } - return String(format: "%.0f", hours) } - private func formatDays(_ hours: Double) -> String { - let days = hours / 24 - return String(format: "%.0f", days) - } - - private func getStatusInfo(_ status: String) -> (color: Color, name: String) { - switch status { - case "WATCHED": - return (Color(hex: "10B981"), strings.watched) - case "WATCHING": - return (Color(hex: "3B82F6"), strings.watching) - case "WATCHLIST": - return (Color(hex: "F59E0B"), strings.watchlist) - case "DROPPED": - return (Color(hex: "EF4444"), strings.dropped) - default: - return (Color(hex: "71717A"), status) + @MainActor + func renderShareCard(section: MonthSection, genrePoster: UIImage?, reviewPoster: UIImage?) -> UIImage { + let cardView = StatsShareCardView( + section: section, + strings: strings, + genrePosterImage: genrePoster, + reviewPosterImage: reviewPoster + ) + let controller = UIHostingController(rootView: cardView) + let size = CGSize(width: 1080 / 3, height: 1920 / 3) + controller.view.bounds = CGRect(origin: .zero, size: size) + controller.view.backgroundColor = .clear + + let window = UIWindow(frame: CGRect(origin: .zero, size: size)) + window.rootViewController = controller + window.makeKeyAndVisible() + controller.view.setNeedsLayout() + controller.view.layoutIfNeeded() + + let scale: CGFloat = 3.0 + let renderer = UIGraphicsImageRenderer( + size: size, + format: { + let f = UIGraphicsImageRendererFormat() + f.scale = scale + return f + }() + ) + let image = renderer.image { _ in + controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) } + + window.isHidden = true + return image } - // MARK: - Load Stats - private func loadStats() async { - if !isLoading && totalHours > 0 { - if countStartTime == nil { - countStartTime = .now - withAnimation(.linear(duration: countDuration)) { - animationTrigger.toggle() - } + // MARK: - Load Period Data + + @MainActor + func loadPeriod(_ period: String) async { + guard loadedSections[period] == nil, !loadingPeriods.contains(period) else { return } + loadingPeriods.insert(period) + defer { loadingPeriods.remove(period) } + + if let cached = cache.get(userId: userId, period: period) { + var section = MonthSection( + yearMonth: period, + totalHours: cached.totalHours, + movieHours: cached.movieHours, + seriesHours: cached.seriesHours, + monthlyHours: cached.monthlyHours, + watchedGenres: cached.watchedGenres, + bestReviews: cached.bestReviews, + isLoaded: true + ) + if period != "all" { + let prev = MonthSection.previousYearMonth(from: period) + let prevHours = try? await UserStatsService.shared.getTotalHours(userId: userId, period: prev) + section.comparisonHours = prevHours?.totalHours + } + withAnimation(.easeIn(duration: 0.25)) { + loadedSections[period] = section } return } - - isLoading = true - error = nil - do { - async let hoursTask = UserStatsService.shared.getTotalHours(userId: userId) - async let genresTask = UserStatsService.shared.getWatchedGenres( - userId: userId, - language: Language.current.rawValue - ) - async let statusTask = UserStatsService.shared.getItemsStatus(userId: userId) - async let reviewsTask = UserStatsService.shared.getBestReviews( - userId: userId, - language: Language.current.rawValue - ) + let language = Language.current.rawValue - let (hoursResponse, genres, status, reviews) = try await (hoursTask, genresTask, statusTask, reviewsTask) - - totalHours = hoursResponse.totalHours - watchedGenres = genres - itemsStatus = status - bestReviews = reviews - isLoading = false - - cache.set(userId: userId, totalHours: hoursResponse.totalHours, watchedGenres: genres, itemsStatus: status, bestReviews: reviews) - - countStartTime = .now - withAnimation(.linear(duration: countDuration)) { - animationTrigger.toggle() - } - } catch { - self.error = error.localizedDescription - isLoading = false + async let hoursResult = try? UserStatsService.shared.getTotalHours(userId: userId, period: period) + async let genresResult = try? UserStatsService.shared.getWatchedGenres(userId: userId, language: language, period: period) + async let reviewsResult = try? UserStatsService.shared.getBestReviews(userId: userId, language: language, period: period) + + let (hours, genres, reviews) = await (hoursResult, genresResult, reviewsResult) + + var section = MonthSection(yearMonth: period) + + if let hours { + section.totalHours = hours.totalHours + section.movieHours = hours.movieHours + section.seriesHours = hours.seriesHours + section.monthlyHours = hours.monthlyHours + section.peakTimeSlot = hours.peakTimeSlot + section.hourlyDistribution = hours.hourlyDistribution ?? [] + section.dailyActivity = hours.dailyActivity ?? [] + section.percentileRank = hours.percentileRank } - } -} + if let genres { section.watchedGenres = genres } + if let reviews { section.bestReviews = reviews } + section.isLoaded = true -// MARK: - Stats Genre Chip -private struct StatsGenreChip: View { - let genre: WatchedGenre - let isFirst: Bool - - var body: some View { - HStack(spacing: 8) { - Text(genre.name) - .font(.system(size: 14, weight: .medium)) - Text("\(genre.count)") - .font(.system(size: 12)) - .opacity(isFirst ? 0.6 : 1) + if period != "all" { + let prev = MonthSection.previousYearMonth(from: period) + let prevHours = try? await UserStatsService.shared.getTotalHours(userId: userId, period: prev) + section.comparisonHours = prevHours?.totalHours + } + + withAnimation(.easeIn(duration: 0.25)) { + loadedSections[period] = section } - .foregroundColor(isFirst ? .appBackgroundAdaptive : .appForegroundAdaptive) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(isFirst ? Color.appForegroundAdaptive : Color.clear) - .overlay( - RoundedRectangle(cornerRadius: 20) - .strokeBorder(Color.appBorderAdaptive, lineWidth: isFirst ? 0 : 1) + + cache.set( + userId: userId, + period: period, + totalHours: section.totalHours, + movieHours: section.movieHours, + seriesHours: section.seriesHours, + monthlyHours: section.monthlyHours, + watchedGenres: section.watchedGenres, + bestReviews: section.bestReviews ) - .clipShape(RoundedRectangle(cornerRadius: 20)) } } -// MARK: - Best Review Row -private struct BestReviewRow: View { - let review: BestReview - let rank: Int - - private var posterWidth: CGFloat { - (UIScreen.main.bounds.width - 48 - 24) / 3 - } - - private var posterHeight: CGFloat { - posterWidth * 1.5 - } - - private var formattedDate: String { - let inputFormatter = ISO8601DateFormatter() - inputFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - - guard let date = inputFormatter.date(from: review.createdAt) else { - inputFormatter.formatOptions = [.withInternetDateTime] - guard let date = inputFormatter.date(from: review.createdAt) else { - return review.createdAt - } - return formatDate(date) - } - return formatDate(date) - } - - private func formatDate(_ date: Date) -> String { - let outputFormatter = DateFormatter() - outputFormatter.dateFormat = "dd/MM/yyyy" - return outputFormatter.string(from: date) - } - - var body: some View { - HStack(alignment: .center, spacing: 12) { - CachedAsyncImage(url: review.posterURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster) - .fill(Color.appBorderAdaptive) - } - .frame(width: posterWidth, height: posterHeight) - .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster)) - .posterBorder() - .shadow(color: Color.black.opacity(0.1), radius: 2, x: 0, y: 1) - - VStack(alignment: .leading, spacing: 8) { - Text(review.title) - .font(.headline) - .foregroundColor(.appForegroundAdaptive) - .lineLimit(2) - - HStack(spacing: 8) { - StarRatingView(rating: .constant(review.rating), size: 14, interactive: false) - - Circle() - .fill(Color.appMutedForegroundAdaptive.opacity(0.5)) - .frame(width: 4, height: 4) - - Text(formattedDate) - .font(.subheadline) - .foregroundColor(.appMutedForegroundAdaptive) - } - - if !review.review.isEmpty { - Text(review.review) - .font(.subheadline) - .foregroundColor(.appMutedForegroundAdaptive) - .lineLimit(3) - .multilineTextAlignment(.leading) - .blur(radius: review.hasSpoilers ? 4 : 0) - .overlay( - review.hasSpoilers - ? Text(L10n.current.containSpoilers) - .font(.caption.weight(.medium)) - .foregroundColor(.appMutedForegroundAdaptive) - .padding(.horizontal, 6) - .padding(.vertical, 3) - .background(Color.appInputFilled) - .cornerRadius(4) - : nil - ) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) +// MARK: - Share Sheet + +struct ActivityViewController: UIViewControllerRepresentable { + let activityItems: [Any] + + func makeUIViewController(context: Context) -> UIActivityViewController { + UIActivityViewController(activityItems: activityItems, applicationActivities: nil) } + + func updateUIViewController(_ uiViewController: UIActivityViewController, context: Context) {} } #Preview { diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileTabView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileTabView.swift index fb6a067f9..01a72a694 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileTabView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileTabView.swift @@ -317,7 +317,7 @@ struct ProfileTabView: View { ProfileReviewsListView(userId: userId) .padding(.bottom, 24) case .stats: - ProfileStatsView(userId: userId) + ProfileStatsView(userId: userId, isPro: user?.isPro ?? false, isOwnProfile: true) .padding(.bottom, 24) } } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift b/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift new file mode 100644 index 000000000..a08369290 --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift @@ -0,0 +1,267 @@ +// +// StatsSharedComponents.swift +// Plotwist + +import SwiftUI + +// MARK: - Shared Detail Header + +@ViewBuilder +func detailHeaderView(title: String, isScrolled: Bool, onBack: @escaping () -> Void) -> some View { + let _ = print("[detailHeader] title=\(title) isScrolled=\(isScrolled)") + ZStack { + if isScrolled { + Text(title) + .font(.headline) + .foregroundColor(.appForegroundAdaptive) + .transition(.opacity.combined(with: .move(edge: .bottom))) + } + + HStack { + Button(action: onBack) { + Image(systemName: "chevron.left") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.appForegroundAdaptive) + .frame(width: 36, height: 36) + .background(Color.appInputFilled) + .clipShape(Circle()) + } + + Spacer() + } + } + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color.appBackgroundAdaptive) + .overlay( + Rectangle() + .fill(Color.appBorderAdaptive) + .frame(height: 1) + .opacity(isScrolled ? 1 : 0), + alignment: .bottom + ) + .animation(.easeInOut(duration: 0.2), value: isScrolled) +} + +// MARK: - Stats Poster (70% width, standard corner radius) + +@ViewBuilder +func statsPoster(url: URL?, rating: Double? = nil) -> some View { + let cr = DesignTokens.CornerRadius.poster + let screenWidth = UIScreen.main.bounds.width + let cardContentWidth = (screenWidth - 60) / 2 - 32 + let posterWidth = cardContentWidth * 0.7 + let posterHeight = posterWidth * 1.5 + + CachedAsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: cr) + .fill(Color.appBorderAdaptive.opacity(0.3)) + } + .frame(width: posterWidth, height: posterHeight) + .clipShape(RoundedRectangle(cornerRadius: cr)) + .overlay(alignment: .bottomTrailing) { + if let rating { + HStack(spacing: 2) { + Image(systemName: "star.fill") + .font(.system(size: 9)) + .foregroundColor(Color(hex: "F59E0B")) + Text(String(format: "%.1f", rating)) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(.white) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(.ultraThinMaterial) + .clipShape(RoundedRectangle(cornerRadius: cr * 0.5)) + .padding(6) + } + } + .posterBorder() +} + +// MARK: - Shimmer Effect + +struct ShimmerEffect: ViewModifier { + @State private var phase: CGFloat = 0 + + func body(content: Content) -> some View { + content + .opacity(0.4 + 0.3 * Foundation.sin(Double(phase))) + .onAppear { + withAnimation(.easeInOut(duration: 1.2).repeatForever(autoreverses: true)) { + phase = .pi + } + } + } +} + +// MARK: - Stats Share Card (Stories 9:16) + +struct StatsShareCardView: View { + let section: MonthSection + let strings: Strings + let genrePosterImage: UIImage? + let reviewPosterImage: UIImage? + + private let cardWidth: CGFloat = 1080 / 3 + private let cardHeight: CGFloat = 1920 / 3 + + private let accentBlue = Color(hex: "60A5FA") + private let accentPurple = Color(hex: "A78BFA") + + var body: some View { + ZStack { + LinearGradient( + stops: [ + .init(color: Color(hex: "000000"), location: 0), + .init(color: Color(hex: "050505"), location: 0.4), + .init(color: Color(hex: "0C0C0C"), location: 0.7), + .init(color: Color(hex: "111111"), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) + + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 16) { + Text(section.displayName.uppercased()) + .font(.system(size: 11, weight: .bold)) + .tracking(2.5) + .foregroundColor(accentBlue) + + Text(strings.myMonthInReview) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(formatTotalHours(section.totalHours)) + .font(.system(size: 52, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + + Text(strings.hours) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + } + + if section.movieHours > 0 || section.seriesHours > 0 { + HStack(spacing: 14) { + HStack(spacing: 5) { + Circle().fill(accentBlue).frame(width: 6, height: 6) + Text("\(strings.movies) \(formatHoursMinutes(section.movieHours))") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + } + HStack(spacing: 5) { + Circle().fill(accentPurple).frame(width: 6, height: 6) + Text("\(strings.series) \(formatHoursMinutes(section.seriesHours))") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + } + } + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 48) + + Spacer().frame(height: 24) + + HStack(alignment: .top, spacing: 12) { + if let genreName = section.topGenreName { + VStack(alignment: .leading, spacing: 8) { + shareCardPoster(image: genrePosterImage) + Text(strings.favoriteGenre.uppercased()) + .font(.system(size: 9, weight: .bold)) + .tracking(1) + .foregroundColor(.white.opacity(0.35)) + Text(genreName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + } + + if let reviewTitle = section.topReviewTitle { + VStack(alignment: .leading, spacing: 8) { + shareCardPoster(image: reviewPosterImage, rating: section.topReviewRating) + Text(strings.bestReview.uppercased()) + .font(.system(size: 9, weight: .bold)) + .tracking(1) + .foregroundColor(.white.opacity(0.35)) + Text(reviewTitle) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + } + .frame(maxWidth: .infinity) + } + } + .padding(.horizontal, 24) + + Spacer() + + HStack { + Image("PlotistLogo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 22) + .opacity(0.35) + + Spacer() + + Text("plotwist.app") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.2)) + } + .padding(.horizontal, 24) + .padding(.bottom, 32) + } + } + .frame(width: cardWidth, height: cardHeight) + } + + @ViewBuilder + func shareCardPoster(image: UIImage?, rating: Double? = nil) -> some View { + let cr: CGFloat = 12 + + if let uiImage = image { + Image(uiImage: uiImage) + .resizable() + .aspectRatio(2 / 3, contentMode: .fit) + .clipShape(RoundedRectangle(cornerRadius: cr)) + .overlay(alignment: .bottomTrailing) { + ratingBadge(rating: rating, cornerRadius: cr) + } + } else { + RoundedRectangle(cornerRadius: cr) + .fill(Color.white.opacity(0.06)) + .aspectRatio(2 / 3, contentMode: .fit) + } + } + + @ViewBuilder + func ratingBadge(rating: Double?, cornerRadius cr: CGFloat) -> some View { + if let rating { + HStack(spacing: 3) { + Image(systemName: "star.fill") + .font(.system(size: 8)) + .foregroundColor(Color(hex: "F59E0B")) + Text(String(format: "%.1f", rating)) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundColor(.white) + } + .padding(.horizontal, 6) + .padding(.vertical, 4) + .background(Color.black.opacity(0.6)) + .clipShape(RoundedRectangle(cornerRadius: cr * 0.5)) + .padding(6) + } + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift new file mode 100644 index 000000000..02ad8e810 --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift @@ -0,0 +1,643 @@ +// +// TimeWatchedDetailView.swift +// Plotwist + +import SwiftUI + +struct TimeWatchedDetailView: View { + @Environment(\.dismiss) private var dismiss + + let totalHours: Double + let movieHours: Double + let seriesHours: Double + @State var monthlyHours: [MonthlyHoursEntry] + let comparisonHours: Double? + @State var peakTimeSlot: PeakTimeSlot? + @State var hourlyDistribution: [HourlyEntry] + @State var dailyActivity: [DailyActivityEntry] + let percentileRank: Int? + let periodLabel: String + let strings: Strings + var userId: String? = nil + var period: String? = nil + + @State private var scrollOffset: CGFloat = 0 + @State private var initialScrollOffset: CGFloat? = nil + private let scrollThreshold: CGFloat = 10 + + private var isScrolled: Bool { + guard let initial = initialScrollOffset else { return false } + return scrollOffset < initial - scrollThreshold + } + + init(totalHours: Double, movieHours: Double, seriesHours: Double, monthlyHours: [MonthlyHoursEntry], + comparisonHours: Double? = nil, + peakTimeSlot: PeakTimeSlot? = nil, + hourlyDistribution: [HourlyEntry] = [], + dailyActivity: [DailyActivityEntry] = [], + percentileRank: Int? = nil, + periodLabel: String, strings: Strings, + userId: String? = nil, period: String? = nil) { + self.totalHours = totalHours + self.movieHours = movieHours + self.seriesHours = seriesHours + _monthlyHours = State(initialValue: monthlyHours) + self.comparisonHours = comparisonHours + _peakTimeSlot = State(initialValue: peakTimeSlot) + _hourlyDistribution = State(initialValue: hourlyDistribution) + _dailyActivity = State(initialValue: dailyActivity) + self.percentileRank = percentileRank + self.periodLabel = periodLabel + self.strings = strings + self.userId = userId + self.period = period + } + + var body: some View { + VStack(spacing: 0) { + detailHeaderView(title: strings.timeWatched, isScrolled: isScrolled) { dismiss() } + + ScrollView { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 24) { + VStack(alignment: .leading, spacing: 8) { + Text(periodLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + Text(String(format: strings.youSpentWatching, formatTotalHours(totalHours))) + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + + if period == "all", let pct = percentileRank, pct > 0 { + HStack(spacing: 6) { + Image(systemName: "flame.fill") + .font(.system(size: 12)) + Text(String(format: strings.topPercentile, pct)) + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(Color(hex: "F59E0B")) + } else { + comparisonLine + } + } + + if movieHours > 0 || seriesHours > 0 { + Divider() + + VStack(alignment: .leading, spacing: 12) { + Text(strings.distribution) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + movieSeriesSplitBar + } + } + + if !dailyActivity.isEmpty { + Divider() + } + } + .padding(.horizontal, 24) + .padding(.top, 16) + + if !dailyActivity.isEmpty { + activityHeatmap + .padding(.horizontal, 24) + .padding(.top, 8) + } + + if !hourlyDistribution.isEmpty { + VStack(alignment: .leading, spacing: 24) { + Divider() + hourlyDistributionChart + } + .padding(.horizontal, 24) + .padding(.top, 16) + } + + Spacer().frame(height: 24) + } + .background(scrollOffsetReader) + } + } + .background(Color.appBackgroundAdaptive.ignoresSafeArea()) + .navigationBarHidden(true) + .task(id: "\(period ?? "")-\(dailyActivity.count)-\(hourlyDistribution.count)") { + await loadDetailDataIfNeeded() + } + } + + private var scrollOffsetReader: some View { + GeometryReader { geo -> Color in + DispatchQueue.main.async { + let offset = geo.frame(in: .global).minY + if initialScrollOffset == nil { + initialScrollOffset = offset + print("[TimeWatchedDetail] initial offset set: \(offset)") + } + scrollOffset = offset + let scrolled = isScrolled + print("[TimeWatchedDetail] offset=\(offset) initial=\(String(describing: initialScrollOffset)) isScrolled=\(scrolled)") + } + return Color.clear + } + } + + private func loadDetailDataIfNeeded() async { + guard let userId, let period, period != "all" else { return } + let needsFetch = dailyActivity.isEmpty || hourlyDistribution.isEmpty || peakTimeSlot == nil + guard needsFetch else { return } + if let result = try? await UserStatsService.shared.getTotalHours(userId: userId, period: period) { + if monthlyHours.isEmpty { monthlyHours = result.monthlyHours } + if dailyActivity.isEmpty { dailyActivity = result.dailyActivity ?? [] } + if hourlyDistribution.isEmpty { hourlyDistribution = result.hourlyDistribution ?? [] } + if peakTimeSlot == nil { peakTimeSlot = result.peakTimeSlot } + } + } + + // MARK: - Comparison Line + + @ViewBuilder + var comparisonLine: some View { + if let comparison = comparisonHours, comparison > 0 { + let delta = totalHours - comparison + let pctChange = abs(delta) / comparison * 100 + let isUp = delta >= 0 + let sign = isUp ? "+" : "-" + let color: Color = isUp ? Color(hex: "10B981") : Color(hex: "EF4444") + + HStack(spacing: 6) { + Image(systemName: isUp ? "arrow.up.right" : "arrow.down.right") + .font(.system(size: 11, weight: .bold)) + + Text("\(sign)\(formatHoursMinutes(abs(delta))) vs \(strings.vsLastMonthShort.replacingOccurrences(of: "vs ", with: "")) (\(String(format: "%.0f%%", pctChange)))") + .font(.system(size: 14, weight: .medium)) + } + .foregroundColor(color) + } + } + + private static func previousMonthName(period: String?) -> String { + guard let period else { return "" } + let lang = Language.current.rawValue + let locale = Locale(identifier: lang.replacingOccurrences(of: "-", with: "_")) + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM" + fmt.locale = locale + guard let date = fmt.date(from: period), + let prev = Calendar.current.date(byAdding: .month, value: -1, to: date) else { + return "" + } + let display = DateFormatter() + display.dateFormat = "MMMM" + display.locale = locale + let monthName = display.string(from: prev) + return monthName.prefix(1).uppercased() + monthName.dropFirst() + } + + // MARK: - Hourly Distribution Chart + + var hourlyDistributionChart: some View { + let maxCount = max(hourlyDistribution.map(\.count).max() ?? 1, 1) + let peakHourEntry = hourlyDistribution.max(by: { $0.count < $1.count }) + let peakDayName = Self.peakDayOfWeek(from: dailyActivity) + + return VStack(alignment: .leading, spacing: 16) { + Text(strings.peakTime) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + if let peak = peakHourEntry, peak.count > 0 { + peakWithBars(peak: peak, peakDay: peakDayName, maxCount: maxCount) + } + } + } + + @ViewBuilder + private func peakWithBars(peak: HourlyEntry, peakDay: String?, maxCount: Int) -> some View { + let slotLabel = Self.slotLabel(for: peak.hour, strings: strings) + let hourLabel = String(format: "%02d:00", peak.hour) + let barHeight: CGFloat = 60 + + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 2) { + if let day = peakDay { + Text("\(day) à \(slotLabel.lowercased())") + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + } else { + Text(slotLabel) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + } + + Text(hourLabel) + .font(.system(size: 14, weight: .medium, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + } + + VStack(spacing: 4) { + ZStack(alignment: .bottom) { + VStack(spacing: 0) { + ForEach(0..<3, id: \.self) { _ in + Divider().opacity(0.2) + Spacer() + } + Divider().opacity(0.2) + } + .frame(height: barHeight) + + HStack(alignment: .bottom, spacing: 3) { + ForEach(hourlyDistribution.sorted(by: { $0.hour < $1.hour }), id: \.hour) { entry in + let ratio = CGFloat(entry.count) / CGFloat(maxCount) + let isPeak = entry.hour == peak.hour + + RoundedRectangle(cornerRadius: 3) + .fill(isPeak + ? Self.heatmapHigh + : ratio > 0 ? Self.heatmapColor(hours: ratio, max: 1.0) : Self.heatmapEmpty) + .frame(maxWidth: .infinity, minHeight: 2) + .frame(height: max(barHeight * ratio, 2)) + } + } + } + .frame(height: barHeight) + + HStack { + Text("0h") + Spacer() + Text("6h") + Spacer() + Text("12h") + Spacer() + Text("18h") + Spacer() + Text("23h") + } + .font(.system(size: 9, weight: .medium, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + .padding(16) + .background(Color.appForegroundAdaptive.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + private static func peakDayOfWeek(from activity: [DailyActivityEntry]) -> String? { + guard !activity.isEmpty else { return nil } + + var dayCounts = [Int: Double]() + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + + for entry in activity { + guard let date = fmt.date(from: entry.day) else { continue } + let weekday = Calendar.current.component(.weekday, from: date) + dayCounts[weekday, default: 0] += entry.hours + } + + guard let peakWeekday = dayCounts.max(by: { $0.value < $1.value })?.key else { return nil } + + let lang = Language.current.rawValue + let locale = Locale(identifier: lang.replacingOccurrences(of: "-", with: "_")) + let symbols = DateFormatter() + symbols.locale = locale + guard let weekdaySymbols = symbols.standaloneWeekdaySymbols else { return nil } + let name = weekdaySymbols[peakWeekday - 1] + return name.prefix(1).uppercased() + name.dropFirst() + } + + private static func slotLabel(for hour: Int, strings: Strings) -> String { + switch hour { + case 6...11: return strings.peakTimeMorning + case 12...17: return strings.peakTimeAfternoon + case 18...23: return strings.peakTimeEvening + default: return strings.peakTimeNight + } + } + + private static func slotIcon(for hour: Int) -> String { + switch hour { + case 6...11: return "sunrise.fill" + case 12...17: return "sun.max.fill" + case 18...23: return "sunset.fill" + default: return "moon.stars.fill" + } + } + + // MARK: - Movie/Series Split Bar + + private static let movieColor = Color(hex: "60A5FA") + private static let seriesColor = Color(hex: "A78BFA") + + var movieSeriesSplitBar: some View { + let moviePct = totalHours > 0 ? movieHours / totalHours : 0 + let seriesPct = totalHours > 0 ? seriesHours / totalHours : 0 + + return VStack(spacing: 16) { + GeometryReader { geo in + HStack(spacing: 2) { + if moviePct > 0 { + RoundedRectangle(cornerRadius: 4) + .fill(Self.movieColor) + .frame(width: geo.size.width * moviePct) + } + if seriesPct > 0 { + RoundedRectangle(cornerRadius: 4) + .fill(Self.seriesColor) + .frame(width: geo.size.width * seriesPct) + } + } + } + .frame(height: 8) + + HStack(spacing: 0) { + HStack(spacing: 6) { + Circle() + .fill(Self.movieColor) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(strings.movies) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.appForegroundAdaptive) + Text("\(formatHoursMinutes(movieHours)) · \(String(format: "%.0f%%", moviePct * 100))") + .font(.system(size: 12)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + + Spacer() + + HStack(spacing: 6) { + Circle() + .fill(Self.seriesColor) + .frame(width: 8, height: 8) + VStack(alignment: .leading, spacing: 1) { + Text(strings.series) + .font(.system(size: 13, weight: .semibold)) + .foregroundColor(.appForegroundAdaptive) + Text("\(formatHoursMinutes(seriesHours)) · \(String(format: "%.0f%%", seriesPct * 100))") + .font(.system(size: 12)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + } + } + } + + // MARK: - Activity Heatmap + + private static let cellSize: CGFloat = 13 + private static let cellSpacing: CGFloat = 3 + + private var isSingleMonthPeriod: Bool { + guard let period else { return false } + let pattern = /^\d{4}-\d{2}$/ + return period.wholeMatch(of: pattern) != nil + } + + var activityHeatmap: some View { + let maxHours = max(dailyActivity.map(\.hours).max() ?? 1, 1) + let weeks = Self.buildWeekColumns(from: dailyActivity) + let monthLabels = Self.extractMonthLabels(from: weeks) + + return VStack(alignment: .leading, spacing: 12) { + Text(strings.activity) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + if isSingleMonthPeriod { + calendarHeatmap(weeks: weeks, maxHours: maxHours) + } else { + githubHeatmap(weeks: weeks, monthLabels: monthLabels, maxHours: maxHours) + .padding(.horizontal, -24) + } + + heatmapLegend + .padding(.top, 4) + } + } + + // MARK: Calendar heatmap (single month) + + private func calendarHeatmap(weeks: [[HeatmapCell]], maxHours: Double) -> some View { + let dayLabels = Self.weekdayLabels() + let calSpacing: CGFloat = 6 + + let columns = Array(repeating: GridItem(.flexible(), spacing: calSpacing), count: 7) + + return LazyVGrid(columns: columns, spacing: calSpacing) { + ForEach(0..<7, id: \.self) { col in + Text(dayLabels[col]) + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + .frame(height: 16) + } + + ForEach(Array(weeks.enumerated()), id: \.offset) { _, week in + ForEach(week, id: \.id) { cell in + if cell.isPadding { + Color.clear + .aspectRatio(1, contentMode: .fit) + } else { + RoundedRectangle(cornerRadius: 8) + .fill(Self.heatmapColor(hours: cell.hours, max: maxHours)) + .aspectRatio(1, contentMode: .fit) + .overlay( + Text("\(cell.dayNumber)") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(cell.hours > 0 ? .white : .appMutedForegroundAdaptive) + ) + } + } + } + } + } + + // MARK: GitHub-style heatmap (multi-month) + + private func githubHeatmap(weeks: [[HeatmapCell]], monthLabels: [MonthLabel], maxHours: Double) -> some View { + let dayLabels = Self.weekdayLabels() + + return ScrollView(.horizontal, showsIndicators: false) { + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 0) { + Spacer().frame(width: 28) + ForEach(monthLabels, id: \.offset) { label in + Text(label.name) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + .frame(width: CGFloat(label.span) * (Self.cellSize + Self.cellSpacing), alignment: .leading) + } + } + + HStack(alignment: .top, spacing: 0) { + VStack(alignment: .trailing, spacing: 0) { + ForEach(0..<7, id: \.self) { row in + if row == 1 || row == 3 || row == 5 { + Text(dayLabels[row]) + .font(.system(size: 9, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + .frame(width: 24, height: Self.cellSize) + } else { + Color.clear + .frame(width: 24, height: Self.cellSize) + } + if row < 6 { + Spacer().frame(height: Self.cellSpacing) + } + } + } + .padding(.trailing, 4) + + HStack(alignment: .top, spacing: Self.cellSpacing) { + ForEach(Array(weeks.enumerated()), id: \.offset) { _, week in + VStack(spacing: Self.cellSpacing) { + ForEach(week, id: \.id) { cell in + RoundedRectangle(cornerRadius: 2) + .fill(Self.heatmapColor(hours: cell.hours, max: maxHours)) + .frame(width: Self.cellSize, height: Self.cellSize) + } + } + } + } + } + } + .padding(.horizontal, 24) + } + } + + private var heatmapLegend: some View { + HStack(spacing: 4) { + Text(strings.less) + .font(.system(size: 10)) + .foregroundColor(.appMutedForegroundAdaptive) + + ForEach([0.0, 0.25, 0.5, 0.75, 1.0], id: \.self) { level in + RoundedRectangle(cornerRadius: 2) + .fill(level > 0 + ? Self.heatmapColor(hours: level, max: 1.0) + : Self.heatmapEmpty) + .frame(width: 12, height: 12) + } + + Text(strings.more) + .font(.system(size: 10)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + + // MARK: - Heatmap Helpers + + static let heatmapLow = Color(hex: "9BE9A8") + static let heatmapHigh = Color(hex: "216E39") + static let heatmapEmpty = Color.appForegroundAdaptive.opacity(0.06) + + static func heatmapColor(hours: Double, max: Double) -> Color { + if hours <= 0 { return heatmapEmpty } + let t = Swift.max(hours / max, 0.15) + return Color( + red: lerp(0.608, 0.129, t), + green: lerp(0.914, 0.431, t), + blue: lerp(0.659, 0.224, t) + ) + } + + private static func lerp(_ a: Double, _ b: Double, _ t: Double) -> Double { + a + (b - a) * t + } + + struct HeatmapCell: Identifiable { + let id: String + let hours: Double + let weekday: Int + let month: Int + let dayNumber: Int + var isPadding: Bool { month == 0 } + } + + struct MonthLabel { + let name: String + let span: Int + let offset: Int + } + + static func buildWeekColumns(from entries: [DailyActivityEntry]) -> [[HeatmapCell]] { + guard !entries.isEmpty else { return [] } + let cal = Calendar.current + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" + + var weeks: [[HeatmapCell]] = [] + var currentWeek: [HeatmapCell] = [] + + guard let firstDate = fmt.date(from: entries.first!.day) else { return [] } + let firstWeekday = cal.component(.weekday, from: firstDate) + if firstWeekday > 1 { + for wd in 1.. [MonthLabel] { + guard !weeks.isEmpty else { return [] } + let locale = Locale(identifier: Language.current.rawValue.replacingOccurrences(of: "-", with: "_")) + let fmt = DateFormatter() + fmt.locale = locale + let monthNames = fmt.shortMonthSymbols ?? [] + + var result: [MonthLabel] = [] + var prevMonth = 0 + + for (i, week) in weeks.enumerated() { + let realCells = week.filter { $0.month > 0 } + guard let first = realCells.first else { continue } + let month = first.month + + if month != prevMonth { + result.append(MonthLabel( + name: month <= monthNames.count ? monthNames[month - 1].lowercased() : "", + span: 1, + offset: i + )) + prevMonth = month + } else if !result.isEmpty { + let idx = result.count - 1 + result[idx] = MonthLabel(name: result[idx].name, span: result[idx].span + 1, offset: result[idx].offset) + } + } + return result + } + + static func weekdayLabels() -> [String] { + let locale = Locale(identifier: Language.current.rawValue.replacingOccurrences(of: "-", with: "_")) + let fmt = DateFormatter() + fmt.locale = locale + let symbols = fmt.veryShortWeekdaySymbols ?? ["S", "M", "T", "W", "T", "F", "S"] + return symbols + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Profile/UserProfileView.swift b/apps/ios/Plotwist/Plotwist/Views/Profile/UserProfileView.swift index b2c6de6cf..f65241efa 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Profile/UserProfileView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Profile/UserProfileView.swift @@ -214,7 +214,7 @@ struct UserProfileView: View { ProfileReviewsListView(userId: userId) .padding(.bottom, 24) case .stats: - ProfileStatsView(userId: userId) + ProfileStatsView(userId: userId, isPro: true, isOwnProfile: false) .padding(.bottom, 24) } } diff --git a/apps/ios/Plotwist/Plotwist/Views/Reviews/ReviewSectionView.swift b/apps/ios/Plotwist/Plotwist/Views/Reviews/ReviewSectionView.swift index 85c567397..d0073d2e9 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Reviews/ReviewSectionView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Reviews/ReviewSectionView.swift @@ -146,6 +146,7 @@ struct ReviewSectionView: View { } .buttonStyle(.plain) .padding(.horizontal, 24) + .padding(.top, 16) } } } diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index b14d65e2d..e057daae2 100644 --- a/apps/web/next.config.mjs +++ b/apps/web/next.config.mjs @@ -10,7 +10,6 @@ const nextConfig = { hostname: 'image.tmdb.org', }, ], - unoptimized: true, }, pageExtensions: ['mdx', 'ts', 'tsx'], transpilePackages: ['@plotwist/ui'], diff --git a/apps/web/src/app/[lang]/animes/page.tsx b/apps/web/src/app/[lang]/animes/page.tsx index 8171fc2dd..8b3b941fb 100644 --- a/apps/web/src/app/[lang]/animes/page.tsx +++ b/apps/web/src/app/[lang]/animes/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { AnimeList } from '@/components/animes-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -23,6 +24,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(lang, '/animes'), } } diff --git a/apps/web/src/app/[lang]/docs/roadmap/page.tsx b/apps/web/src/app/[lang]/docs/roadmap/page.tsx index 3c77a31f0..51ffd8ba5 100644 --- a/apps/web/src/app/[lang]/docs/roadmap/page.tsx +++ b/apps/web/src/app/[lang]/docs/roadmap/page.tsx @@ -1,3 +1,13 @@ -export default function Page() { - return
oi
+import type { Metadata } from 'next' + +export const metadata: Metadata = { + robots: { index: false, follow: false }, +} + +export default function RoadmapPage() { + return ( +
+

Coming soon.

+
+ ) } diff --git a/apps/web/src/app/[lang]/doramas/page.tsx b/apps/web/src/app/[lang]/doramas/page.tsx index ccfeab8f2..fd329b1d0 100644 --- a/apps/web/src/app/[lang]/doramas/page.tsx +++ b/apps/web/src/app/[lang]/doramas/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { DoramaList } from '@/components/dorama-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -23,6 +24,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(lang, '/doramas'), } } diff --git a/apps/web/src/app/[lang]/home/page.tsx b/apps/web/src/app/[lang]/home/page.tsx index 5ccc86598..f862e3e39 100644 --- a/apps/web/src/app/[lang]/home/page.tsx +++ b/apps/web/src/app/[lang]/home/page.tsx @@ -8,6 +8,7 @@ import { PosterCard } from '@/components/poster-card' import { tmdb } from '@/services/tmdb' import { asLanguage, type PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { tmdbImage } from '@/utils/tmdb/image' import { Container } from '../_components/container' import { NetworkActivity } from './_components/network-activity' @@ -32,6 +33,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/home'), } } diff --git a/apps/web/src/app/[lang]/layout.tsx b/apps/web/src/app/[lang]/layout.tsx index e11c973f1..ecaea8379 100644 --- a/apps/web/src/app/[lang]/layout.tsx +++ b/apps/web/src/app/[lang]/layout.tsx @@ -3,6 +3,7 @@ import type { GetUserPreferences200 } from '@/api/endpoints.schemas' import { getUserPreferences } from '@/api/users' import { Footer } from '@/components/footer' import { Header } from '@/components/header' +import { HtmlLangSetter } from '@/components/html-lang-setter' import { ProBadge } from '@/components/pro-badge' import { SonnerProvider, ThemeProvider } from '@/components/providers' import { LanguageContextProvider } from '@/context/language' @@ -50,6 +51,7 @@ export default async function RootLayout({ enableSystem disableTransitionOnChange > + diff --git a/apps/web/src/app/[lang]/lists/page.tsx b/apps/web/src/app/[lang]/lists/page.tsx index 2d4115424..39aa4ea3d 100644 --- a/apps/web/src/app/[lang]/lists/page.tsx +++ b/apps/web/src/app/[lang]/lists/page.tsx @@ -2,6 +2,7 @@ import { Separator } from '@plotwist/ui/components/ui/separator' import type { Metadata } from 'next' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../_components/container' import { LatestLists } from './_components/latest-lists' import { Lists } from './_components/lists' @@ -25,6 +26,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/lists'), } } diff --git a/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx index 41415a8c4..951b97112 100644 --- a/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx +++ b/apps/web/src/app/[lang]/movies/[id]/_components/movie-details.tsx @@ -1,6 +1,7 @@ import { Suspense } from 'react' import { Banner } from '@/components/banner' +import { BreadcrumbJsonLd, MovieJsonLd } from '@/components/structured-data' import { tmdb } from '@/services/tmdb' import type { Language } from '@/types/languages' import { tmdbImage } from '@/utils/tmdb/image' @@ -18,6 +19,27 @@ export const MovieDetails = async ({ id, language }: MovieDetailsProps) => { return (
+ +
diff --git a/apps/web/src/app/[lang]/movies/discover/page.tsx b/apps/web/src/app/[lang]/movies/discover/page.tsx index 3e5c257e8..61c532e74 100644 --- a/apps/web/src/app/[lang]/movies/discover/page.tsx +++ b/apps/web/src/app/[lang]/movies/discover/page.tsx @@ -3,6 +3,7 @@ import { MovieList } from '@/components/movie-list' import { MoviesListFilters } from '@/components/movies-list-filters' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -25,6 +26,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/movies/discover'), } } diff --git a/apps/web/src/app/[lang]/movies/now-playing/page.tsx b/apps/web/src/app/[lang]/movies/now-playing/page.tsx index 86f5d3fe9..513bfd67c 100644 --- a/apps/web/src/app/[lang]/movies/now-playing/page.tsx +++ b/apps/web/src/app/[lang]/movies/now-playing/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { MovieList } from '@/components/movie-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -24,6 +25,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/movies/now-playing'), } } diff --git a/apps/web/src/app/[lang]/movies/popular/page.tsx b/apps/web/src/app/[lang]/movies/popular/page.tsx index d0d67e2d7..38fe75135 100644 --- a/apps/web/src/app/[lang]/movies/popular/page.tsx +++ b/apps/web/src/app/[lang]/movies/popular/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { MovieList } from '@/components/movie-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -24,6 +25,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/movies/popular'), } } diff --git a/apps/web/src/app/[lang]/movies/top-rated/page.tsx b/apps/web/src/app/[lang]/movies/top-rated/page.tsx index e81c14063..d2e8f549d 100644 --- a/apps/web/src/app/[lang]/movies/top-rated/page.tsx +++ b/apps/web/src/app/[lang]/movies/top-rated/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { MovieList } from '@/components/movie-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -24,6 +25,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/movies/top-rated'), } } diff --git a/apps/web/src/app/[lang]/movies/upcoming/page.tsx b/apps/web/src/app/[lang]/movies/upcoming/page.tsx index 0b3aa52d8..8199ca751 100644 --- a/apps/web/src/app/[lang]/movies/upcoming/page.tsx +++ b/apps/web/src/app/[lang]/movies/upcoming/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { MovieList } from '@/components/movie-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -24,6 +25,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/movies/upcoming'), } } diff --git a/apps/web/src/app/[lang]/page.tsx b/apps/web/src/app/[lang]/page.tsx index 27afd1dc2..ed116aa35 100644 --- a/apps/web/src/app/[lang]/page.tsx +++ b/apps/web/src/app/[lang]/page.tsx @@ -4,6 +4,7 @@ import { Pattern } from '@/components/pattern' import { Pricing } from '@/components/pricing' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { APP_URL } from '../../../constants' import { SUPPORTED_LANGUAGES } from '../../../languages' import { AppDownload } from './_components/app-download' @@ -16,17 +17,6 @@ export async function generateMetadata(props: PageProps): Promise { const dictionary = await getDictionary(lang) const image = `${APP_URL}/images/landing-page.jpg` - const canonicalUrl = `${APP_URL}/${lang}` - - const languageAlternates = SUPPORTED_LANGUAGES.reduce( - (acc, lang) => { - if (lang.enabled) { - acc[lang.hreflang] = `${APP_URL}/${lang.value}` - } - return acc - }, - {} as Record - ) const title = `${dictionary.perfect_place_for_watching} ${dictionary.everything}` const fullTitle = `${title} • Plotwist` @@ -57,10 +47,7 @@ export async function generateMetadata(props: PageProps): Promise { description, card: 'summary_large_image', }, - alternates: { - canonical: canonicalUrl, - languages: languageAlternates, - }, + alternates: buildLanguageAlternates(lang, '/'), } } diff --git a/apps/web/src/app/[lang]/pricing/page.tsx b/apps/web/src/app/[lang]/pricing/page.tsx index c2d89929b..57e6ae4be 100644 --- a/apps/web/src/app/[lang]/pricing/page.tsx +++ b/apps/web/src/app/[lang]/pricing/page.tsx @@ -3,6 +3,7 @@ import { Pattern } from '@/components/pattern' import { Pricing } from '@/components/pricing' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' export async function generateMetadata(props: PageProps): Promise { const params = await props.params @@ -25,6 +26,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(lang, '/pricing'), } } diff --git a/apps/web/src/app/[lang]/privacy/page.tsx b/apps/web/src/app/[lang]/privacy/page.tsx index 8953b8fdb..0024c04cc 100644 --- a/apps/web/src/app/[lang]/privacy/page.tsx +++ b/apps/web/src/app/[lang]/privacy/page.tsx @@ -1,7 +1,15 @@ -'use client' - +import type { Metadata } from 'next' import { notFound } from 'next/navigation' import type { PageProps } from '@/types/languages' +import { buildLanguageAlternates } from '@/utils/seo' + +export async function generateMetadata(props: PageProps): Promise { + const { lang } = await props.params + return { + title: 'Privacy Policy • Plotwist', + alternates: buildLanguageAlternates(lang, '/privacy'), + } +} export default async function PrivacyPage(props: PageProps) { const params = await props.params diff --git a/apps/web/src/app/[lang]/sitemap.xml/route.ts b/apps/web/src/app/[lang]/sitemap.xml/route.ts index 0a06b0ed2..ae7fc530d 100644 --- a/apps/web/src/app/[lang]/sitemap.xml/route.ts +++ b/apps/web/src/app/[lang]/sitemap.xml/route.ts @@ -7,17 +7,21 @@ const APP_ROUTES = [ '/', '/home', '/lists', - '/sign-in', - '/sign-up', '/movies/discover', '/movies/now-playing', '/movies/popular', '/movies/top-rated', + '/movies/upcoming', '/tv-series/airing-today', '/tv-series/discover', '/tv-series/on-the-air', '/tv-series/popular', '/tv-series/top-rated', + '/animes', + '/doramas', + '/pricing', + '/privacy', + '/docs', ] export async function GET(request: Request) { diff --git a/apps/web/src/app/[lang]/tv-series/[id]/_components/tv-serie-details.tsx b/apps/web/src/app/[lang]/tv-series/[id]/_components/tv-serie-details.tsx index 6df974b83..59943e8aa 100644 --- a/apps/web/src/app/[lang]/tv-series/[id]/_components/tv-serie-details.tsx +++ b/apps/web/src/app/[lang]/tv-series/[id]/_components/tv-serie-details.tsx @@ -1,5 +1,6 @@ import { Suspense } from 'react' import { Banner } from '@/components/banner' +import { BreadcrumbJsonLd, TvSeriesJsonLd } from '@/components/structured-data' import { tmdb } from '@/services/tmdb' import type { Language } from '@/types/languages' @@ -17,6 +18,25 @@ export const TvSerieDetails = async ({ id, language }: TvSerieDetailsProps) => { return (
+ +
diff --git a/apps/web/src/app/[lang]/tv-series/airing-today/page.tsx b/apps/web/src/app/[lang]/tv-series/airing-today/page.tsx index 534019811..42ef3ab6b 100644 --- a/apps/web/src/app/[lang]/tv-series/airing-today/page.tsx +++ b/apps/web/src/app/[lang]/tv-series/airing-today/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { TvSeriesList } from '@/components/tv-series-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -24,6 +25,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/tv-series/airing-today'), } } diff --git a/apps/web/src/app/[lang]/tv-series/discover/page.tsx b/apps/web/src/app/[lang]/tv-series/discover/page.tsx index 3465279ab..1c228c05c 100644 --- a/apps/web/src/app/[lang]/tv-series/discover/page.tsx +++ b/apps/web/src/app/[lang]/tv-series/discover/page.tsx @@ -3,6 +3,7 @@ import { TvSeriesList } from '@/components/tv-series-list' import { TvSeriesListFilters } from '@/components/tv-series-list-filters' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -25,6 +26,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/tv-series/discover'), } } diff --git a/apps/web/src/app/[lang]/tv-series/on-the-air/page.tsx b/apps/web/src/app/[lang]/tv-series/on-the-air/page.tsx index 99d7a7562..bc5c1fe49 100644 --- a/apps/web/src/app/[lang]/tv-series/on-the-air/page.tsx +++ b/apps/web/src/app/[lang]/tv-series/on-the-air/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { TvSeriesList } from '@/components/tv-series-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -24,6 +25,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/tv-series/on-the-air'), } } diff --git a/apps/web/src/app/[lang]/tv-series/popular/page.tsx b/apps/web/src/app/[lang]/tv-series/popular/page.tsx index 8b3493c92..f53d9f0a7 100644 --- a/apps/web/src/app/[lang]/tv-series/popular/page.tsx +++ b/apps/web/src/app/[lang]/tv-series/popular/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { TvSeriesList } from '@/components/tv-series-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -24,6 +25,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/tv-series/popular'), } } diff --git a/apps/web/src/app/[lang]/tv-series/top-rated/page.tsx b/apps/web/src/app/[lang]/tv-series/top-rated/page.tsx index 182b10476..a05e88816 100644 --- a/apps/web/src/app/[lang]/tv-series/top-rated/page.tsx +++ b/apps/web/src/app/[lang]/tv-series/top-rated/page.tsx @@ -2,6 +2,7 @@ import type { Metadata } from 'next' import { TvSeriesList } from '@/components/tv-series-list' import type { PageProps } from '@/types/languages' import { getDictionary } from '@/utils/dictionaries' +import { buildLanguageAlternates } from '@/utils/seo' import { Container } from '../../_components/container' export async function generateMetadata(props: PageProps): Promise { @@ -24,6 +25,7 @@ export async function generateMetadata(props: PageProps): Promise { title: `${title} • Plotwist`, description, }, + alternates: buildLanguageAlternates(params.lang, '/tv-series/top-rated'), } } diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx index 9c1cd84bf..149da46dd 100644 --- a/apps/web/src/app/layout.tsx +++ b/apps/web/src/app/layout.tsx @@ -4,6 +4,10 @@ import type { Metadata, Viewport } from 'next' import { Space_Grotesk as SpaceGrotesk } from 'next/font/google' import { ViewTransitions } from 'next-view-transitions' import { GTag } from '@/components/gtag' +import { + OrganizationJsonLd, + WebsiteJsonLd, +} from '@/components/structured-data' const spaceGrotesk = SpaceGrotesk({ subsets: ['latin'], preload: true }) @@ -15,9 +19,6 @@ export const metadata: Metadata = { export const viewport: Viewport = { colorScheme: 'dark', themeColor: '#09090b', - initialScale: 1, - maximumScale: 1, - userScalable: false, } export default async function RootLayout(props: { children: React.ReactNode }) { @@ -40,6 +41,8 @@ export default async function RootLayout(props: { children: React.ReactNode }) { /> + + {children} diff --git a/apps/web/src/components/html-lang-setter.tsx b/apps/web/src/components/html-lang-setter.tsx new file mode 100644 index 000000000..a3a036fb1 --- /dev/null +++ b/apps/web/src/components/html-lang-setter.tsx @@ -0,0 +1,10 @@ +'use client' + +import { useEffect } from 'react' + +export function HtmlLangSetter({ lang }: { lang: string }) { + useEffect(() => { + document.documentElement.lang = lang + }, [lang]) + return null +} diff --git a/apps/web/src/components/structured-data.tsx b/apps/web/src/components/structured-data.tsx new file mode 100644 index 000000000..731665bfe --- /dev/null +++ b/apps/web/src/components/structured-data.tsx @@ -0,0 +1,147 @@ +type JsonLdProps = { + data: Record +} + +export function JsonLd({ data }: JsonLdProps) { + return ( +