From dc53321f76f9b7f7aacaa797dc7019eeeaf92623 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 13:11:16 -0300 Subject: [PATCH 01/16] refactor: redesign Stats page with Spotify-inspired card layout - Simplify Stats page to card-based UI with period filtering (month/last_month/year/all) - Replace expandable sections with NavigationLink cards to detail pages - Add custom profile-style headers (circular back button, scroll-aware title) to all detail views - Fix intermittent data: genres now include series with episodes watched in period - Fix stale cache: invalidate stats on episode create/delete and review create/update/delete - Reduce cache TTL to 10min for period-scoped stats (month/last_month) - Add posterPath to genre response schema so Fastify doesn't strip it - Add statsCardBackground color with proper light/dark mode contrast - Shared statsPoster helper: 70% width, standard 16px corner radius, optional rating badge - Add singular localization strings (favoriteGenre, bestReview) across 7 languages Co-authored-by: Cursor --- .../src/db/repositories/reviews-repository.ts | 29 +- .../src/db/repositories/user-episode.ts | 42 +- .../db/repositories/user-item-repository.ts | 26 +- apps/backend/src/domain/entities/user-item.ts | 2 + .../domain/services/user-stats/cache-utils.ts | 28 +- .../user-stats/get-user-best-reviews.ts | 13 +- .../user-stats/get-user-items-status.ts | 8 +- .../get-user-most-watched-series.ts | 10 +- .../user-stats/get-user-total-hours.ts | 107 +- .../user-stats/get-user-watched-cast.ts | 10 +- .../user-stats/get-user-watched-countries.ts | 9 +- .../user-stats/get-user-watched-genres.ts | 47 +- .../src/http/controllers/review-controller.ts | 16 +- .../controllers/user-episodes-controller.ts | 12 +- .../src/http/controllers/user-stats.ts | 49 +- apps/backend/src/http/routes/reviews.ts | 9 +- apps/backend/src/http/routes/user-episodes.ts | 6 +- apps/backend/src/http/routes/user-stats.ts | 17 +- apps/backend/src/http/schemas/common.ts | 41 + apps/backend/src/http/schemas/user-stats.ts | 1 + .../Plotwist/Localization/Strings.swift | 448 +++++++- .../Services/ProfilePrefetchService.swift | 24 +- .../Plotwist/Services/UserStatsService.swift | 78 +- apps/ios/Plotwist/Plotwist/Theme/Colors.swift | 9 + .../Plotwist/Plotwist/Utils/Constants.swift | 2 +- .../Views/Home/ProfileStatsCache.swift | 31 +- .../Plotwist/Views/Home/ProfileStatsDNA.swift | 302 ++++++ .../Views/Home/ProfileStatsHelpers.swift | 224 ++-- .../Views/Home/ProfileStatsSections.swift | 990 ++++++++++-------- .../Views/Home/ProfileStatsView.swift | 491 +++++---- .../Plotwist/Views/Home/ProfileTabView.swift | 2 +- .../Views/Profile/UserProfileView.swift | 2 +- 32 files changed, 2166 insertions(+), 919 deletions(-) create mode 100644 apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsDNA.swift diff --git a/apps/backend/src/db/repositories/reviews-repository.ts b/apps/backend/src/db/repositories/reviews-repository.ts index cca8e6c7..be74a2f9 100644 --- a/apps/backend/src/db/repositories/reviews-repository.ts +++ b/apps/backend/src/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/db/repositories/user-episode.ts b/apps/backend/src/db/repositories/user-episode.ts index e8b22b0c..732167c5 100644 --- a/apps/backend/src/db/repositories/user-episode.ts +++ b/apps/backend/src/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/db/repositories/user-item-repository.ts b/apps/backend/src/db/repositories/user-item-repository.ts index 67a397a1..0573e92c 100644 --- a/apps/backend/src/db/repositories/user-item-repository.ts +++ b/apps/backend/src/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) @@ -160,6 +176,12 @@ export async function selectAllUserItemsByStatus({ if (status !== 'ALL') { whereConditions.push(eq(schema.userItems.status, status as UserItemStatus)) } + if (startDate) { + whereConditions.push(gte(schema.userItems.updatedAt, startDate)) + } + if (endDate) { + whereConditions.push(lte(schema.userItems.updatedAt, endDate)) + } return db .select({ id, diff --git a/apps/backend/src/domain/entities/user-item.ts b/apps/backend/src/domain/entities/user-item.ts index 19069a60..b45dc01b 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-stats/cache-utils.ts b/apps/backend/src/domain/services/user-stats/cache-utils.ts index 5b46a25b..58526ff7 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,14 @@ export async function getCachedStats( } const result = await computeFn() - await redis.set( - cacheKey, - JSON.stringify(result), - 'EX', - STATS_CACHE_TTL_SECONDS - ) + + const isPeriodScoped = + cacheKey.endsWith(':month') || cacheKey.endsWith(':last_month') + 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 +53,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 6257f574..f0120a93 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 '@/http/schemas/common' import { selectBestReviews } from '@/db/repositories/reviews-repository' import { getTMDBMovieService } from '../tmdb/get-tmdb-movie' import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series' @@ -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 32cf6936..932d39d9 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 '@/db/repositories/user-item-repository' type GetUserItemsStatusServiceInput = { userId: string + dateRange?: { startDate: Date | undefined; endDate: Date | undefined } } export async function getUserItemsStatusService({ userId, + dateRange, }: GetUserItemsStatusServiceInput) { - const userItems = await selectUserItemStatus(userId) + const userItems = await selectUserItemStatus( + userId, + dateRange?.startDate, + dateRange?.endDate + ) return { userItems, 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 2fefc20e..7e220c84 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 '@/http/schemas/common' import { selectMostWatched } from '@/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-total-hours.ts b/apps/backend/src/domain/services/user-stats/get-user-total-hours.ts index a11aadad..8d5f0a19 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,20 +1,27 @@ import type { FastifyRedis } from '@fastify/redis' +import type { StatsPeriod } from '@/http/schemas/common' 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-v2', undefined, period) return getCachedStats(redis, cacheKey, async () => { const watchedItems = await getAllUserItemsService({ userId, status: 'WATCHED', + startDate: dateRange.startDate, + endDate: dateRange.endDate, }) const movieRuntimesWithDates = await getMovieRuntimesWithDates( @@ -26,15 +33,20 @@ export async function getUserTotalHoursService( ) const watchedEpisodes = await getUserEpisodesService({ userId }) + const filteredEpisodes = filterEpisodesByDateRange( + watchedEpisodes.userEpisodes, + dateRange + ) const episodeTotalHours = sumRuntimes( - watchedEpisodes.userEpisodes.map(ep => ep.runtime) + filteredEpisodes.map(ep => ep.runtime) ) const totalHours = movieTotalHours + episodeTotalHours const monthlyHours = computeMonthlyHours( movieRuntimesWithDates, - watchedEpisodes.userEpisodes + filteredEpisodes, + period ) return { @@ -46,6 +58,19 @@ export async function getUserTotalHoursService( }) } +function filterEpisodesByDateRange( + episodes: { runtime: number; watchedAt: Date | null }[], + dateRange: DateRange +) { + if (!dateRange.startDate && !dateRange.endDate) return episodes + return episodes.filter(ep => { + if (!ep.watchedAt) return false + if (dateRange.startDate && ep.watchedAt < dateRange.startDate) return false + if (dateRange.endDate && ep.watchedAt > dateRange.endDate) return false + return true + }) +} + async function getMovieRuntimesWithDates( watchedItems: Awaited>, redis: FastifyRedis @@ -66,15 +91,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 +138,54 @@ 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 sumRuntimes(runtimes: number[]): number { return runtimes.reduce((acc, curr) => acc + curr, 0) / 60 } 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 a532447c..94dbe56b 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 '@/http/schemas/common' import { selectAllUserItemsByStatus } from '@/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 4b84a77f..e8625e97 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 '@/http/schemas/common' import { selectAllUserItemsByStatus } from '@/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 9723aef6..1b892759 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 type { StatsPeriod } from '@/http/schemas/common' import { selectAllUserItemsByStatus } from '@/db/repositories/user-item-repository' +import { selectUserEpisodes } from '@/db/repositories/user-episode' import { getTMDBMovieService } from '../tmdb/get-tmdb-movie' import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series' import { processInBatches } from './batch-utils' @@ -10,24 +12,58 @@ 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-v2', language, period) return getCachedStats(redis, cacheKey, async () => { const watchedItems = await selectAllUserItemsByStatus({ userId, status: 'WATCHED', + startDate: dateRange?.startDate, + endDate: dateRange?.endDate, }) + + const seenTmdbIds = new Set(watchedItems.map(item => item.tmdbId)) + + // Also include series that had episodes watched in the date range, + // even if the series user_item.updatedAt is outside the range + if (dateRange?.startDate || dateRange?.endDate) { + const episodesInRange = await selectUserEpisodes({ + userId, + startDate: dateRange.startDate, + endDate: dateRange.endDate, + }) + + 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(), + }) + } + } + } + const genreCount = new Map() + const genrePoster = new Map() await processInBatches(watchedItems, async item => { - const { genres } = + const { genres, posterPath } = item.mediaType === 'MOVIE' ? await getTMDBMovieService(redis, { tmdbId: item.tmdbId, @@ -44,15 +80,20 @@ export async function getUserWatchedGenresService({ for (const genre of genres) { const currentCount = genreCount.get(genre.name) || 0 genreCount.set(genre.name, currentCount + 1) + if (posterPath && !genrePoster.has(genre.name)) { + genrePoster.set(genre.name, posterPath) + } } } }) + const totalItems = watchedItems.length const genres = Array.from(genreCount) .map(([name, count]) => ({ name, count, - percentage: (count / watchedItems.length) * 100, + percentage: totalItems > 0 ? (count / totalItems) * 100 : 0, + posterPath: genrePoster.get(name) ?? null, })) .sort((a, b) => b.count - a.count) diff --git a/apps/backend/src/http/controllers/review-controller.ts b/apps/backend/src/http/controllers/review-controller.ts index 4222bdd1..d9d11fd9 100644 --- a/apps/backend/src/http/controllers/review-controller.ts +++ b/apps/backend/src/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/http/controllers/user-episodes-controller.ts b/apps/backend/src/http/controllers/user-episodes-controller.ts index d97a31fd..2c378859 100644 --- a/apps/backend/src/http/controllers/user-episodes-controller.ts +++ b/apps/backend/src/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/http/controllers/user-stats.ts b/apps/backend/src/http/controllers/user-stats.ts index 313d3484..bda15600 100644 --- a/apps/backend/src/http/controllers/user-stats.ts +++ b/apps/backend/src/http/controllers/user-stats.ts @@ -10,8 +10,10 @@ import { getUserWatchedCastService } from '@/domain/services/user-stats/get-user import { getUserWatchedCountriesService } from '@/domain/services/user-stats/get-user-watched-countries' import { getUserWatchedGenresService } from '@/domain/services/user-stats/get-user-watched-genres' import { - languageQuerySchema, - languageWithLimitQuerySchema, + languageWithLimitAndPeriodQuerySchema, + languageWithPeriodQuerySchema, + periodQuerySchema, + periodToDateRange, } from '../schemas/common' import { getUserDefaultSchema } from '../schemas/user-stats' @@ -32,7 +34,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 +56,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 +78,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 +101,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 +119,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 +141,17 @@ 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) @@ -133,9 +163,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/http/routes/reviews.ts b/apps/backend/src/http/routes/reviews.ts index f839b135..77f030fe 100644 --- a/apps/backend/src/http/routes/reviews.ts +++ b/apps/backend/src/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/http/routes/user-episodes.ts b/apps/backend/src/http/routes/user-episodes.ts index 6f066598..51ae215a 100644 --- a/apps/backend/src/http/routes/user-episodes.ts +++ b/apps/backend/src/http/routes/user-episodes.ts @@ -34,7 +34,8 @@ export async function userEpisodesRoutes(app: FastifyInstance) { }, ], }, - handler: createUserEpisodesController, + handler: (request, reply) => + createUserEpisodesController(request, reply, app.redis), }) ) @@ -74,7 +75,8 @@ export async function userEpisodesRoutes(app: FastifyInstance) { }, ], }, - handler: deleteUserEpisodesController, + handler: (request, reply) => + deleteUserEpisodesController(request, reply, app.redis), }) ) } diff --git a/apps/backend/src/http/routes/user-stats.ts b/apps/backend/src/http/routes/user-stats.ts index 14c34060..50d3377a 100644 --- a/apps/backend/src/http/routes/user-stats.ts +++ b/apps/backend/src/http/routes/user-stats.ts @@ -12,8 +12,9 @@ import { getUserWatchedGenresController, } from '../controllers/user-stats' import { - languageQuerySchema, - languageWithLimitQuerySchema, + languageWithLimitAndPeriodQuerySchema, + languageWithPeriodQuerySchema, + periodQuerySchema, } from '../schemas/common' import { getUserBestReviewsResponseSchema, @@ -52,6 +53,7 @@ export async function userStatsRoutes(app: FastifyInstance) { schema: { description: 'Get user total hours', params: getUserDefaultSchema, + query: periodQuerySchema, response: getUserTotalHoursResponseSchema, tags: USER_STATS_TAG, }, @@ -81,7 +83,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, }, @@ -97,7 +99,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, }, @@ -113,7 +115,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, }, @@ -129,7 +131,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, }, @@ -145,7 +147,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, }, @@ -161,6 +163,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/http/schemas/common.ts b/apps/backend/src/http/schemas/common.ts index 3864db8b..c4b869ec 100644 --- a/apps/backend/src/http/schemas/common.ts +++ b/apps/backend/src/http/schemas/common.ts @@ -19,3 +19,44 @@ export const languageWithLimitQuerySchema = z.object({ .default('en-US'), limit: z.coerce.number().int().min(1).max(100).optional().default(10), }) + +export const periodQuerySchema = z.object({ + period: z + .enum(['month', 'last_month', 'year', 'all']) + .optional() + .default('all'), +}) + +export const languageWithPeriodQuerySchema = languageQuerySchema.merge( + periodQuerySchema +) + +export const languageWithLimitAndPeriodQuerySchema = + languageWithLimitQuerySchema.merge(periodQuerySchema) + +export type StatsPeriod = z.infer['period'] + +export function periodToDateRange(period: StatsPeriod): { + startDate: Date | undefined + endDate: Date | undefined +} { + 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/http/schemas/user-stats.ts b/apps/backend/src/http/schemas/user-stats.ts index 8201837e..1d80b54d 100644 --- a/apps/backend/src/http/schemas/user-stats.ts +++ b/apps/backend/src/http/schemas/user-stats.ts @@ -56,6 +56,7 @@ export const getUserWatchedGenresResponseSchema = { name: z.string(), count: z.number(), percentage: z.number(), + posterPath: z.string().nullable(), }) ), }), diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index 684edec0..69e3f3cb 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -283,18 +283,64 @@ enum L10n { couldNotLoadStats: "Could not load stats", tryAgain: "Try again", favoriteGenres: "Favorite Genres", + favoriteGenre: "Favorite Genre", 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", + 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: "All Time", + 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", // Home Engagement forYou: "For you", basedOnYourTaste: "Because you like %@", @@ -588,18 +634,64 @@ enum L10n { couldNotLoadStats: "Não foi possível carregar estatísticas", tryAgain: "Tentar novamente", favoriteGenres: "Gêneros Favoritos", + favoriteGenre: "Gênero Favorito", 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", + 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: "Tudo", + 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", // Home Engagement forYou: "Para você", basedOnYourTaste: "Porque você gosta de %@", @@ -893,18 +985,64 @@ enum L10n { couldNotLoadStats: "No se pudieron cargar las estadísticas", tryAgain: "Intentar de nuevo", favoriteGenres: "Géneros Favoritos", + favoriteGenre: "Género Favorito", 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", + 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: "Todo", + 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", // Home Engagement forYou: "Para ti", basedOnYourTaste: "Porque te gusta %@", @@ -1198,18 +1336,64 @@ enum L10n { couldNotLoadStats: "Impossible de charger les statistiques", tryAgain: "Réessayer", favoriteGenres: "Genres Préférés", + favoriteGenre: "Genre Préféré", 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é", + 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: "Tout", + 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", // Home Engagement forYou: "Pour vous", basedOnYourTaste: "Parce que vous aimez %@", @@ -1503,18 +1687,64 @@ enum L10n { couldNotLoadStats: "Statistiken konnten nicht geladen werden", tryAgain: "Erneut versuchen", favoriteGenres: "Lieblingsgenres", + favoriteGenre: "Lieblingsgenre", 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", + 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", // Home Engagement forYou: "Für dich", basedOnYourTaste: "Weil du %@ magst", @@ -1808,18 +2038,64 @@ enum L10n { couldNotLoadStats: "Impossibile caricare le statistiche", tryAgain: "Riprova", favoriteGenres: "Generi Preferiti", + favoriteGenre: "Genere Preferito", 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à", + 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: "Tutto", + 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", // Home Engagement forYou: "Per te", basedOnYourTaste: "Perché ti piace %@", @@ -2112,18 +2388,64 @@ enum L10n { couldNotLoadStats: "統計を読み込めませんでした", tryAgain: "再試行", favoriteGenres: "お気に入りジャンル", + favoriteGenre: "お気に入りジャンル", content: "コンテンツ", genres: "ジャンル", - collectionStatus: "コレクションステータス", itemsInCollection: "アイテム", bestReviews: "ベストレビュー", + bestReview: "ベストレビュー", daysOfContent: "日分のコンテンツ", othersGenres: "その他", - mostWatchedCast: "よく見た俳優", watchedCountries: "視聴した国", - mostWatchedSeries: "よく見たシリーズ", - inTitles: "%d作品に出演", - episodesWatched: "話視聴済み", + perDay: "1日あたり", + yourTasteDNA: "あなたの好みDNA", + activity: "アクティビティ", + 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: "分", // Home Engagement forYou: "あなたへ", basedOnYourTaste: "%@が好きだから", @@ -2428,18 +2750,64 @@ struct Strings { let couldNotLoadStats: String let tryAgain: String let favoriteGenres: String + let favoriteGenre: 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 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 // 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 b135d3f0..1484110b 100644 --- a/apps/ios/Plotwist/Plotwist/Services/ProfilePrefetchService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/ProfilePrefetchService.swift @@ -123,27 +123,13 @@ final class ProfilePrefetchService { userId: userId, language: language ) - async let statusTask = UserStatsService.shared.getItemsStatus(userId: userId) async let reviewsTask = UserStatsService.shared.getBestReviews( userId: userId, language: language ) - async let castTask = UserStatsService.shared.getWatchedCast(userId: userId) - async let countriesTask = UserStatsService.shared.getWatchedCountries( - userId: userId, - language: language - ) - async let seriesTask = UserStatsService.shared.getMostWatchedSeries( - userId: userId, - language: language - ) - - let (hoursResponse, genres, status, reviews) = try await ( - hoursTask, genresTask, statusTask, reviewsTask + let (hoursResponse, genres, reviews) = try await ( + hoursTask, genresTask, reviewsTask ) - let cast = (try? await castTask) ?? [] - let countries = (try? await countriesTask) ?? [] - let series = (try? await seriesTask) ?? [] statsCache.set( userId: userId, @@ -152,11 +138,7 @@ final class ProfilePrefetchService { seriesHours: hoursResponse.seriesHours, monthlyHours: hoursResponse.monthlyHours, watchedGenres: genres, - itemsStatus: status, - bestReviews: reviews, - watchedCast: cast, - watchedCountries: countries, - mostWatchedSeries: series + bestReviews: reviews ) } catch {} } diff --git a/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift index a8d8ca07..def755bf 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 } @@ -116,8 +145,11 @@ class UserStatsService { } // 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 +171,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 +200,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 } @@ -206,8 +250,14 @@ struct WatchedGenre: Codable, Identifiable { let name: String let count: Int let percentage: Double + let posterPath: String? var id: String { name } + + var posterURL: URL? { + guard let posterPath else { return nil } + return URL(string: "https://image.tmdb.org/t/p/w342\(posterPath)") + } } struct WatchedGenresResponse: Codable { diff --git a/apps/ios/Plotwist/Plotwist/Theme/Colors.swift b/apps/ios/Plotwist/Plotwist/Theme/Colors.swift index 0d18c5f3..c9312c2c 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: 242 / 255, green: 242 / 255, blue: 247 / 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/Utils/Constants.swift b/apps/ios/Plotwist/Plotwist/Utils/Constants.swift index 0688640d..36ba6df2 100644 --- a/apps/ios/Plotwist/Plotwist/Utils/Constants.swift +++ b/apps/ios/Plotwist/Plotwist/Utils/Constants.swift @@ -6,5 +6,5 @@ import Foundation enum API { - static let baseURL = Env.apiBaseURL + static let baseURL = "http://localhost:3333" } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsCache.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsCache.swift index f6d15607..7ea0ecba 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsCache.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsCache.swift @@ -18,28 +18,37 @@ class ProfileStatsCache { let seriesHours: Double let monthlyHours: [MonthlyHoursEntry] let watchedGenres: [WatchedGenre] - let itemsStatus: [ItemStatusStat] let bestReviews: [BestReview] - let watchedCast: [WatchedCastMember] - let watchedCountries: [WatchedCountry] - let mostWatchedSeries: [MostWatchedSeries] let timestamp: Date } + + private func cacheKey(userId: String, period: String) -> String { + "\(userId)_\(period)" + } - func get(userId: String) -> (totalHours: Double, movieHours: Double, seriesHours: Double, monthlyHours: [MonthlyHoursEntry], watchedGenres: [WatchedGenre], itemsStatus: [ItemStatusStat], bestReviews: [BestReview], watchedCast: [WatchedCastMember], watchedCountries: [WatchedCountry], mostWatchedSeries: [MostWatchedSeries])? { - 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.movieHours, cached.seriesHours, cached.monthlyHours, cached.watchedGenres, cached.itemsStatus, cached.bestReviews, cached.watchedCast, cached.watchedCountries, cached.mostWatchedSeries) + return (cached.totalHours, cached.movieHours, cached.seriesHours, cached.monthlyHours, cached.watchedGenres, cached.bestReviews) } - func set(userId: String, totalHours: Double, movieHours: Double = 0, seriesHours: Double = 0, monthlyHours: [MonthlyHoursEntry] = [], watchedGenres: [WatchedGenre], itemsStatus: [ItemStatusStat], bestReviews: [BestReview], watchedCast: [WatchedCastMember] = [], watchedCountries: [WatchedCountry] = [], mostWatchedSeries: [MostWatchedSeries] = []) { - cache[userId] = CachedStats(totalHours: totalHours, movieHours: movieHours, seriesHours: seriesHours, monthlyHours: monthlyHours, watchedGenres: watchedGenres, itemsStatus: itemsStatus, bestReviews: bestReviews, watchedCast: watchedCast, watchedCountries: watchedCountries, mostWatchedSeries: mostWatchedSeries, 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 00000000..1bd14659 --- /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 index d78ef50b..c437ead0 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift @@ -5,7 +5,62 @@ import SwiftUI -// MARK: - Helpers +// MARK: - Free Utility Functions (shared across views) + +func formatTotalMinutes(_ hours: Double) -> String { + let totalMinutes = Int(hours * 60) + let formatter = NumberFormatter() + formatter.numberStyle = .decimal + return formatter.string(from: NSNumber(value: totalMinutes)) ?? "\(totalMinutes)" +} + +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) +} + +func shortMonthLabel(_ month: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + guard let date = formatter.date(from: month) else { return month } + formatter.dateFormat = "MMM" + return formatter.string(from: date).prefix(3).lowercased() +} + +func fullMonthLabel(_ month: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + guard let date = formatter.date(from: month) else { return month } + formatter.dateFormat = "MMMM" + return formatter.string(from: date) +} + +// MARK: - ProfileStatsView Helpers extension ProfileStatsView { func interpolatedHours(at date: Date) -> Double { guard let start = countStartTime else { return 0 } @@ -14,77 +69,58 @@ extension ProfileStatsView { let eased = 1 - pow(2, -10 * progress) return totalHours * eased } - + func formatHours(_ hours: Double) -> String { if totalHours >= 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) } - - 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) + + // MARK: - Trend Insight Computation + + func computeTrendInsight() -> String? { + guard monthlyHours.count >= 3 else { return nil } + + let recent = Array(monthlyHours.suffix(3)) + let earlier = Array(monthlyHours.dropLast(3).suffix(3)) + + guard !recent.isEmpty else { return nil } + + let recentAvg = recent.map(\.hours).reduce(0, +) / Double(recent.count) + let earlierAvg = earlier.isEmpty ? recentAvg : earlier.map(\.hours).reduce(0, +) / Double(earlier.count) + + if !earlier.isEmpty { + let change = earlierAvg > 0 ? ((recentAvg - earlierAvg) / earlierAvg) * 100 : 0 + if change > 20 { + return strings.consumptionUp + } else if change < -20 { + return strings.consumptionDown + } } - } - - 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 totalHours > 0 { + let seriesRatio = seriesHours / totalHours + let movieRatio = movieHours / totalHours + if seriesRatio > 0.6 { + return strings.moreSeriesThanMovies + } else if movieRatio > 0.6 { + return strings.moreMoviesThanSeries + } } - 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) - } - - 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 shortMonthLabel(_ month: String) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - guard let date = formatter.date(from: month) else { return month } - formatter.dateFormat = "MMM" - return formatter.string(from: date).prefix(3).lowercased() + + return nil } - + // MARK: - Country Helpers - + static var tmdbNameOverrides: [String: String] { [ - // English "united states of america": "US", "united states": "US", "united kingdom of great britain and northern ireland": "GB", @@ -97,7 +133,6 @@ extension ProfileStatsView { "hong kong sar china": "HK", "iran, islamic republic of": "IR", "viet nam": "VN", - // Portuguese "estados unidos da américa": "US", "estados unidos": "US", "reino unido": "GB", @@ -105,40 +140,35 @@ extension ProfileStatsView { "coréia do sul": "KR", "república tcheca": "CZ", "república checa": "CZ", - // Spanish "estados unidos de américa": "US", "corea del sur": "KR", - // French "états-unis": "US", "états-unis d'amérique": "US", "corée du sud": "KR", "royaume-uni": "GB", - // German "vereinigte staaten von amerika": "US", "vereinigte staaten": "US", "südkorea": "KR", "großbritannien": "GB", "vereinigtes königreich": "GB", "tschechien": "CZ", - // Italian "stati uniti d'america": "US", "stati uniti": "US", "regno unito": "GB", - // Japanese "アメリカ合衆国": "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), @@ -146,7 +176,7 @@ extension ProfileStatsView { return code } } - + let enLocale = Locale(identifier: "en_US") for code in Locale.Region.isoRegions.map(\.identifier) { guard code.count == 2 else { continue } @@ -155,7 +185,7 @@ extension ProfileStatsView { return code } } - + for code in Locale.Region.isoRegions.map(\.identifier) { guard code.count == 2 else { continue } if let localizedName = enLocale.localizedString(forRegionCode: code), @@ -163,15 +193,15 @@ extension ProfileStatsView { 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 = "" @@ -188,7 +218,7 @@ extension ProfileStatsView { struct StatsGenreChip: View { let genre: WatchedGenre let isFirst: Bool - + var body: some View { HStack(spacing: 8) { Text(genre.name) @@ -213,19 +243,19 @@ struct StatsGenreChip: View { 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 { @@ -235,13 +265,13 @@ struct BestReviewRow: View { } 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 @@ -256,25 +286,25 @@ struct BestReviewRow: View { .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) @@ -299,33 +329,3 @@ struct BestReviewRow: View { .frame(maxWidth: .infinity, alignment: .leading) } } - -// MARK: - Pie Slice Shape -struct PieSliceShape: Shape { - let startAngle: Angle - let endAngle: Angle - - func path(in rect: CGRect) -> Path { - let center = CGPoint(x: rect.midX, y: rect.midY) - let radius = min(rect.width, rect.height) / 2 - - var path = Path() - path.addArc(center: center, radius: radius, startAngle: startAngle, endAngle: endAngle, clockwise: false) - return path - } -} - -// 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 - } - } - } -} diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index fa176b0c..e18b6444 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -5,95 +5,359 @@ import SwiftUI -// MARK: - Time Watched Section +// MARK: - Time Watched Card + extension ProfileStatsView { - var heroStatsSection: some View { - TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in - let currentHours = interpolatedHours(at: timeline.date) - - VStack(alignment: .leading, spacing: 20) { - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(formatHours(currentHours)) - .font(.system(size: 44, weight: .bold, design: .rounded)) + var timeWatchedCard: some View { + NavigationLink { + TimeWatchedDetailView( + totalHours: totalHours, + movieHours: movieHours, + seriesHours: seriesHours, + monthlyHours: monthlyHours, + dailyAverage: dailyAverage, + dailyAverageLabel: dailyAverageLabel, + comparisonHours: comparisonHours, + selectedPeriod: selectedPeriod, + strings: strings + ) + } label: { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + Text(strings.timeWatched.uppercased()) + .font(.system(size: 11, weight: .medium)) + .tracking(1.5) + .foregroundColor(.appMutedForegroundAdaptive) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + } + .padding(.bottom, 14) + + HStack(alignment: .firstTextBaseline, spacing: 6) { + TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in + let currentHours = interpolatedHours(at: timeline.date) + Text(formatTotalMinutes(currentHours)) + .font(.system(size: 48, weight: .bold, design: .rounded)) .foregroundColor(.appForegroundAdaptive) .contentTransition(.numericText(countsDown: false)) - .animation(.snappy(duration: 0.2), value: formatHours(currentHours)) - - Text(strings.hours.lowercased()) - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) } - - let daysText = "\(formatDays(currentHours)) \(strings.daysOfContent)" - Text(daysText) - .font(.system(size: 14)) + + Text(strings.minutes) + .font(.system(size: 16, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) - .contentTransition(.numericText(countsDown: false)) - .animation(.snappy(duration: 0.2), value: daysText) } - - if !monthlyHours.isEmpty { - monthlyBarChart + + HStack(spacing: 8) { + comparisonBadge } - - if movieHours > 0 || seriesHours > 0 { - Divider() - - HStack(spacing: 24) { - HStack(spacing: 8) { - RoundedRectangle(cornerRadius: 2) - .fill(Color(hex: "3B82F6")) - .frame(width: 12, height: 12) - VStack(alignment: .leading, spacing: 1) { - Text(strings.movies) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - Text(formatHoursMinutes(movieHours)) - .font(.system(size: 12)) - .foregroundColor(.appMutedForegroundAdaptive) - } + .padding(.top, 6) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.statsCardBackground) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + .buttonStyle(.plain) + } + + @ViewBuilder + var comparisonBadge: some View { + if let comparison = comparisonHours, selectedPeriod == .month || selectedPeriod == .year { + let delta = totalHours - comparison + let sign = delta >= 0 ? "+" : "" + let label = selectedPeriod == .month + ? String(format: strings.vsLastMonth, "\(sign)\(formatHoursMinutes(abs(delta)))") + : String(format: strings.vsLastYear, "\(sign)\(formatHoursMinutes(abs(delta)))") + + 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)) + } + } +} + +// MARK: - Top Genre Card (half-width, navigates to detail) + +extension ProfileStatsView { + var topGenreCard: some View { + let topGenre = watchedGenres.first + + return NavigationLink { + PeriodGenresView(genres: watchedGenres, period: selectedPeriod, strings: strings) + } label: { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + Text(strings.favoriteGenre.uppercased()) + .font(.system(size: 11, weight: .medium)) + .tracking(1.5) + .foregroundColor(.appMutedForegroundAdaptive) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + } + .padding(.bottom, 14) + + if let genre = topGenre { + Text(genre.name) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + .lineLimit(1) + .padding(.bottom, 10) + + if let posterURL = genre.posterURL { + statsPoster(url: posterURL) + } + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.statsCardBackground) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + .buttonStyle(.plain) + } +} + +// MARK: - Top Review Card (half-width, navigates to detail) + +extension ProfileStatsView { + var topReviewCard: some View { + let topReview = bestReviews.first + + return NavigationLink { + PeriodReviewsView(reviews: bestReviews, period: selectedPeriod, strings: strings) + } label: { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .top) { + Text(strings.bestReview.uppercased()) + .font(.system(size: 11, weight: .medium)) + .tracking(1.5) + .foregroundColor(.appMutedForegroundAdaptive) + Spacer() + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + } + .padding(.bottom, 14) + + if let review = topReview { + Text(review.title) + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + .lineLimit(1) + .padding(.bottom, 10) + + statsPoster(url: review.posterURL, rating: review.rating) + } + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background(Color.statsCardBackground) + .clipShape(RoundedRectangle(cornerRadius: 22)) + } + .buttonStyle(.plain) + } +} + + +// MARK: - Time Watched Detail View + +struct TimeWatchedDetailView: View { + @Environment(\.dismiss) private var dismiss + + let totalHours: Double + let movieHours: Double + let seriesHours: Double + let monthlyHours: [MonthlyHoursEntry] + let dailyAverage: Double + let dailyAverageLabel: String + let comparisonHours: Double? + let selectedPeriod: StatsPeriod + let strings: Strings + + @State private var scrollOffset: CGFloat = 0 + @State private var initialScrollOffset: CGFloat? + private let scrollThreshold: CGFloat = 40 + + private var isScrolled: Bool { + guard let initial = initialScrollOffset else { return false } + return scrollOffset < initial - scrollThreshold + } + + var body: some View { + VStack(spacing: 0) { + detailHeaderView(title: strings.timeWatched, isScrolled: isScrolled) { dismiss() } + + ScrollView { + VStack(alignment: .leading, spacing: 24) { + scrollOffsetReader + .frame(height: 0) + + Text(strings.timeWatched) + .font(.system(size: 34, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(formatTotalMinutes(totalHours)) + .font(.system(size: 56, weight: .bold, design: .rounded)) + .foregroundColor(.appForegroundAdaptive) + + Text(strings.minutes) + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) } - + + comparisonBadge + .padding(.top, 4) + } + + if dailyAverage > 0 { HStack(spacing: 8) { - RoundedRectangle(cornerRadius: 2) - .fill(Color(hex: "10B981")) - .frame(width: 12, height: 12) - VStack(alignment: .leading, spacing: 1) { - Text(strings.series) - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - Text(formatHoursMinutes(seriesHours)) - .font(.system(size: 12)) - .foregroundColor(.appMutedForegroundAdaptive) - } + Image(systemName: "clock.fill") + .font(.system(size: 14)) + .foregroundColor(Color(hex: "3B82F6")) + Text("\(formatHoursMinutes(dailyAverage)) \(dailyAverageLabel)") + .font(.system(size: 16, weight: .semibold)) + .foregroundColor(.appForegroundAdaptive) } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color(hex: "3B82F6").opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + } + + if movieHours > 0 || seriesHours > 0 { + VStack(alignment: .leading, spacing: 12) { + breakdownRow( + color: Color(hex: "3B82F6"), + label: strings.movies, + minutes: formatTotalMinutes(movieHours), + percentage: totalHours > 0 ? movieHours / totalHours * 100 : 0 + ) + breakdownRow( + color: Color(hex: "10B981"), + label: strings.series, + minutes: formatTotalMinutes(seriesHours), + percentage: totalHours > 0 ? seriesHours / totalHours * 100 : 0 + ) + } + .padding(16) + .background(Color.appInputFilled.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + if monthlyHours.count >= 2 { + VStack(alignment: .leading, spacing: 12) { + Text(strings.activity.uppercased()) + .font(.system(size: 11, weight: .medium)) + .tracking(1.5) + .foregroundColor(.appMutedForegroundAdaptive) + + chartView + } + .padding(16) + .background(Color.appInputFilled.opacity(0.4)) + .clipShape(RoundedRectangle(cornerRadius: 16)) } } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 24) } - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 4) } + .navigationBarHidden(true) } - - var monthlyBarChart: some View { + + 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 + } + } + + @ViewBuilder + var comparisonBadge: some View { + if let comparison = comparisonHours, selectedPeriod == .month || selectedPeriod == .year { + let delta = totalHours - comparison + let sign = delta >= 0 ? "+" : "" + let label = selectedPeriod == .month + ? String(format: strings.vsLastMonth, "\(sign)\(formatHoursMinutes(abs(delta)))") + : String(format: strings.vsLastYear, "\(sign)\(formatHoursMinutes(abs(delta)))") + + 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)) + } + } + + func breakdownRow(color: Color, label: String, minutes: String, percentage: Double) -> some View { + HStack(spacing: 12) { + RoundedRectangle(cornerRadius: 3) + .fill(color) + .frame(width: 4, height: 36) + + VStack(alignment: .leading, spacing: 2) { + Text(label) + .font(.system(size: 14, weight: .semibold)) + .foregroundColor(.appForegroundAdaptive) + Text("\(minutes) min") + .font(.system(size: 13)) + .foregroundColor(.appMutedForegroundAdaptive) + } + + Spacer() + + Text(String(format: "%.0f%%", percentage)) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + + var chartView: some View { let maxHours = max(monthlyHours.map(\.hours).max() ?? 1, 1) - let avgHours = { + let avgHours: Double = { let nonZero = monthlyHours.filter { $0.hours > 0 } guard !nonZero.isEmpty else { return 0.0 } return nonZero.map(\.hours).reduce(0, +) / Double(nonZero.count) }() - let chartHeight: CGFloat = 120 + let chartHeight: CGFloat = 140 let gridSteps = computeGridSteps(maxValue: maxHours) let ceilMax = gridSteps.last ?? maxHours - + let isDaily = monthlyHours.first?.month.split(separator: "-").count == 3 + return VStack(spacing: 0) { HStack(alignment: .bottom, spacing: 0) { - HStack(alignment: .bottom, spacing: 6) { + HStack(alignment: .bottom, spacing: isDaily ? 2 : 6) { ForEach(monthlyHours) { entry in let barHeight = entry.hours > 0 ? CGFloat(entry.hours / ceilMax) * chartHeight : 0 - - RoundedRectangle(cornerRadius: 3) + + RoundedRectangle(cornerRadius: isDaily ? 2 : 3) .fill( entry.hours > 0 ? LinearGradient( @@ -107,23 +371,11 @@ extension ProfileStatsView { endPoint: .top ) ) - .frame(height: max(barHeight, 3)) + .frame(height: max(barHeight, 2)) .frame(maxWidth: .infinity) } } .frame(height: chartHeight) - .overlay( - VStack(spacing: 0) { - ForEach(Array(gridSteps.reversed().enumerated()), id: \.offset) { idx, step in - if idx > 0 { Spacer() } - Rectangle() - .fill(Color.appBorderAdaptive.opacity(0.3)) - .frame(height: 0.5) - } - Spacer().frame(height: 0) - } - .frame(height: chartHeight) - ) .overlay( avgHours > 0 ? AnyView( @@ -133,421 +385,287 @@ extension ProfileStatsView { path.move(to: CGPoint(x: 0, y: y)) path.addLine(to: CGPoint(x: geo.size.width, y: y)) } - .stroke(style: StrokeStyle(lineWidth: 1.5, dash: [5, 4])) - .foregroundColor(Color(hex: "F59E0B").opacity(0.7)) + .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 3])) + .foregroundColor(Color(hex: "F59E0B").opacity(0.6)) } ) : AnyView(EmptyView()) ) - + VStack(alignment: .trailing) { ForEach(Array(gridSteps.reversed().enumerated()), id: \.offset) { idx, step in if idx > 0 { Spacer() } Text(formatAxisLabel(step)) - .font(.system(size: 9, weight: .medium, design: .rounded)) + .font(.system(size: 8, weight: .medium, design: .rounded)) .foregroundColor(.appMutedForegroundAdaptive) } - Text("0") - .font(.system(size: 9, weight: .medium, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive) } - .frame(width: 28, height: chartHeight) - .padding(.leading, 6) - } - - HStack(spacing: 6) { - ForEach(monthlyHours) { entry in - Text(shortMonthLabel(entry.month)) - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - .frame(maxWidth: .infinity) - } - Spacer().frame(width: 34) - } - .padding(.top, 6) - - if avgHours > 0 { - HStack(spacing: 4) { - Circle() - .fill(Color(hex: "F59E0B").opacity(0.7)) - .frame(width: 6, height: 6) - Text("avg \(formatAxisLabel(avgHours))h") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(Color(hex: "F59E0B")) - } - .frame(maxWidth: .infinity, alignment: .trailing) - .padding(.top, 4) + .frame(width: 24, height: chartHeight) + .padding(.leading, 4) } - } - } -} -// MARK: - Genres Section -extension ProfileStatsView { - var genresListSection: some View { - let maxCount = watchedGenres.map(\.count).max() ?? 1 - let displayGenres = showAllGenres ? watchedGenres : Array(watchedGenres.prefix(5)) - - return VStack(spacing: 0) { - ForEach(Array(displayGenres.enumerated()), id: \.element.id) { index, genre in - VStack(spacing: 0) { - HStack(spacing: 0) { - Text(genre.name) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - .lineLimit(1) - - Spacer(minLength: 12) - - Text(String(format: "%.0f%%", genre.percentage)) - .font(.system(size: 12, weight: .semibold, design: .rounded)) + if !isDaily { + HStack(spacing: 6) { + ForEach(monthlyHours) { entry in + Text(shortMonthLabel(entry.month)) + .font(.system(size: 9, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) - .frame(width: 36, alignment: .trailing) - } - .padding(.top, index == 0 ? 0 : 12) - .padding(.bottom, 12) - - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 2.5) - .fill(Color.appBorderAdaptive.opacity(0.4)) - .frame(height: 4) - RoundedRectangle(cornerRadius: 2.5) - .fill(Color.appForegroundAdaptive) - .frame(width: geo.size.width * CGFloat(genre.count) / CGFloat(max(maxCount, 1)), height: 4) - } - } - .frame(height: 4) - - if index < displayGenres.count - 1 { - Divider() - .padding(.top, 12) - } - } - } - - if watchedGenres.count > 5 { - Button { - withAnimation(.easeInOut(duration: 0.3)) { - showAllGenres.toggle() - } - } label: { - HStack(spacing: 4) { - Text(showAllGenres ? strings.showLess : strings.showMore) - .font(.system(size: 13, weight: .medium)) - Image(systemName: showAllGenres ? "chevron.up" : "chevron.down") - .font(.system(size: 10, weight: .semibold)) + .frame(maxWidth: .infinity) } - .foregroundColor(.appMutedForegroundAdaptive) - .padding(.top, 14) + Spacer().frame(width: 28) } - .frame(maxWidth: .infinity, alignment: .center) + .padding(.top, 4) } } - .frame(maxWidth: .infinity, alignment: .leading) - .clipped() } } -// MARK: - Collection Status Section -extension ProfileStatsView { - var statusBarSection: some View { - let totalCount = itemsStatus.reduce(0) { $0 + $1.count } - - return HStack(alignment: .center, spacing: 20) { - ZStack { - pieSlices - - VStack(spacing: 2) { - Text("\(totalCount)") - .font(.system(size: 22, weight: .bold, design: .rounded)) +// MARK: - All Genres Detail View + +struct PeriodGenresView: View { + @Environment(\.dismiss) private var dismiss + + let genres: [WatchedGenre] + let period: StatsPeriod + let strings: Strings + + @State private var scrollOffset: CGFloat = 0 + @State private var initialScrollOffset: CGFloat? + private let scrollThreshold: CGFloat = 40 + + private var isScrolled: Bool { + guard let initial = initialScrollOffset else { return false } + return scrollOffset < initial - scrollThreshold + } + + var body: some View { + VStack(spacing: 0) { + detailHeaderView(title: strings.favoriteGenres, isScrolled: isScrolled) { dismiss() } + + ScrollView { + let maxCount = genres.map(\.count).max() ?? 1 + + VStack(alignment: .leading, spacing: 0) { + scrollOffsetReader + .frame(height: 0) + + Text(strings.favoriteGenres) + .font(.system(size: 34, weight: .bold)) .foregroundColor(.appForegroundAdaptive) - Text("total") - .font(.system(size: 10, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - } - } - .frame(width: 120, height: 120) - - VStack(alignment: .leading, spacing: 10) { - ForEach(itemsStatus) { item in - let statusInfo = getStatusInfo(item.status) - HStack(spacing: 8) { - Circle() - .fill(statusInfo.color) - .frame(width: 8, height: 8) - Text(statusInfo.name) - .font(.system(size: 13)) - .foregroundColor(.appForegroundAdaptive) - Spacer() - Text("\(item.count)") - .font(.system(size: 13, weight: .semibold)) - .foregroundColor(.appForegroundAdaptive) + .padding(.bottom, 16) + + LazyVStack(spacing: 0) { + ForEach(Array(genres.enumerated()), id: \.element.id) { index, genre in + VStack(spacing: 0) { + HStack { + Text("\(index + 1)") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + .frame(width: 24, alignment: .leading) + + Text(genre.name) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.appForegroundAdaptive) + + Spacer() + + Text(String(format: "%.0f%%", genre.percentage)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + } + .padding(.vertical, 14) + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.appBorderAdaptive.opacity(0.3)) + .frame(height: 5) + RoundedRectangle(cornerRadius: 3) + .fill(Color(hex: "3B82F6")) + .frame(width: geo.size.width * CGFloat(genre.count) / CGFloat(max(maxCount, 1)), height: 5) + } + } + .frame(height: 5) + + if index < genres.count - 1 { + Divider().padding(.top, 12) + } + } + } } } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 24) } } - .frame(maxWidth: .infinity, alignment: .center) - } - - var pieSliceData: [(id: String, color: Color, startAngle: Angle, endAngle: Angle)] { - let gap: Angle = .degrees(3) - var current: Angle = .degrees(-90) - var slices: [(id: String, color: Color, startAngle: Angle, endAngle: Angle)] = [] - - for item in itemsStatus { - let statusInfo = getStatusInfo(item.status) - let sweep = Angle.degrees(360 * item.percentage / 100) - let effectiveSweep = max(sweep - gap, .degrees(0.5)) - let sliceStart = current + gap / 2 - slices.append((id: item.id, color: statusInfo.color, startAngle: sliceStart, endAngle: sliceStart + effectiveSweep)) - current = current + sweep - } - return slices + .navigationBarHidden(true) } - - var pieSlices: some View { - let lineWidth: CGFloat = 16 - - return ZStack { - ForEach(pieSliceData, id: \.id) { slice in - PieSliceShape(startAngle: slice.startAngle, endAngle: slice.endAngle) - .stroke(slice.color, style: StrokeStyle(lineWidth: lineWidth, lineCap: .round)) + + 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 } - .padding(lineWidth / 2) } } -// MARK: - Watched Cast Section -extension ProfileStatsView { - var watchedCastSection: some View { +// MARK: - All Reviews Detail View + +struct PeriodReviewsView: View { + @Environment(\.dismiss) private var dismiss + + let reviews: [BestReview] + let period: StatsPeriod + let strings: Strings + + @State private var scrollOffset: CGFloat = 0 + @State private var initialScrollOffset: CGFloat? + private let scrollThreshold: CGFloat = 40 + + private var isScrolled: Bool { + guard let initial = initialScrollOffset else { return false } + return scrollOffset < initial - scrollThreshold + } + + var body: some View { VStack(spacing: 0) { - ForEach(Array(watchedCast.enumerated()), id: \.element.id) { index, actor in - VStack(spacing: 0) { - HStack(spacing: 10) { - CachedAsyncImage(url: actor.profileURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - Circle() - .fill(Color.appInputFilled) - .overlay( - Image(systemName: "person.fill") - .font(.system(size: 14)) - .foregroundColor(.appMutedForegroundAdaptive) - ) - } - .frame(width: 40, height: 40) - .clipShape(Circle()) - - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(actor.name) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - .lineLimit(1) - - Spacer() - - Text(String(format: strings.inTitles, actor.count)) - .font(.system(size: 12)) - .foregroundColor(.appMutedForegroundAdaptive) - } - - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.appBorderAdaptive.opacity(0.5)) - .frame(height: 4) - - RoundedRectangle(cornerRadius: 3) - .fill(Color.appForegroundAdaptive) - .frame(width: geo.size.width * CGFloat(actor.percentage / 100), height: 4) - } - } - .frame(height: 4) + detailHeaderView(title: strings.bestReviews, isScrolled: isScrolled) { dismiss() } + + ScrollView { + VStack(alignment: .leading, spacing: 0) { + scrollOffsetReader + .frame(height: 0) + + 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(.top, index == 0 ? 0 : 12) - .padding(.bottom, 12) - - if index < watchedCast.count - 1 { - Divider() - .padding(.leading, 50) - } } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 24) } } - .frame(maxWidth: .infinity, alignment: .leading) + .navigationBarHidden(true) } -} -// MARK: - Watched Countries Section -extension ProfileStatsView { - var watchedCountriesSection: some View { - let displayCountries = showAllCountries ? watchedCountries : Array(watchedCountries.prefix(5)) - - return VStack(spacing: 0) { - ForEach(Array(displayCountries.enumerated()), id: \.element.id) { index, country in - VStack(spacing: 0) { - HStack(spacing: 10) { - Text(flagForCountry(country.name)) - .font(.system(size: 24)) - .frame(width: 32, height: 32) - - VStack(alignment: .leading, spacing: 6) { - HStack { - Text(country.name) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - .lineLimit(1) - - Spacer() - - Text(String(format: "%.0f%%", country.percentage)) - .font(.system(size: 12)) - .foregroundColor(.appMutedForegroundAdaptive) - } - - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.appBorderAdaptive.opacity(0.5)) - .frame(height: 4) - - RoundedRectangle(cornerRadius: 3) - .fill(Color.appForegroundAdaptive) - .frame(width: geo.size.width * CGFloat(country.percentage / 100), height: 4) - } - } - .frame(height: 4) - } - } - .padding(.top, index == 0 ? 0 : 12) - .padding(.bottom, 12) - - if index < displayCountries.count - 1 { - Divider() - .padding(.leading, 42) - } + 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 } - - if watchedCountries.count > 5 { - Button { - withAnimation(.easeInOut(duration: 0.3)) { - showAllCountries.toggle() - } - } label: { - HStack(spacing: 4) { - Text(showAllCountries ? strings.showLess : strings.showMore) - .font(.system(size: 13, weight: .medium)) - Image(systemName: showAllCountries ? "chevron.up" : "chevron.down") - .font(.system(size: 10, weight: .semibold)) - } - .foregroundColor(.appMutedForegroundAdaptive) - .padding(.top, 12) - } - .frame(maxWidth: .infinity, alignment: .center) - } + return Color.clear } - .frame(maxWidth: .infinity, alignment: .leading) - .clipped() } } -// MARK: - Most Watched Series Section -extension ProfileStatsView { - var mostWatchedSeriesSection: some View { - HStack(alignment: .top, spacing: 12) { - ForEach(mostWatchedSeries, id: \.id) { series in - VStack(spacing: 6) { - CachedAsyncImage(url: series.posterURL) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster) - .fill(Color.appBorderAdaptive) - } - .frame(maxWidth: .infinity) - .aspectRatio(2/3, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: DesignTokens.CornerRadius.poster)) - .posterBorder() - .posterShadow() - - Text("\(series.episodes) \(strings.episodesWatched)") - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) - } +// MARK: - Shared Detail Header + +@ViewBuilder +func detailHeaderView(title: String, isScrolled: Bool, onBack: @escaping () -> Void) -> some View { + 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() } - .frame(maxWidth: .infinity, alignment: .leading) } + .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: - Best Reviews Section -extension ProfileStatsView { - var bestReviewsSection: some View { - VStack(alignment: .leading, spacing: 24) { - 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) - ) +// MARK: - Stats Poster (70% width, standard corner radius) + +@ViewBuilder +func statsPoster(url: URL?, rating: Double? = nil) -> some View { + let cr = DesignTokens.CornerRadius.poster + + GeometryReader { geo in + let posterWidth = geo.size.width * 0.7 + + CachedAsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(contentMode: .fill) + } placeholder: { + RoundedRectangle(cornerRadius: cr) + .fill(Color.appBorderAdaptive.opacity(0.3)) + } + .frame(width: posterWidth, height: posterWidth * 3 / 2) + .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: 6)) + .padding(5) } } - .frame(maxWidth: .infinity, alignment: .leading) - .fullScreenCover(isPresented: $showAllReviews) { - allReviewsSheet - } + .posterBorder() } - - 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)) - } + .aspectRatio(1.0 / 1.05, contentMode: .fit) +} + +// 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 } } - } } } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index 39e459df..2dfe6924 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -5,142 +5,131 @@ import SwiftUI +enum StatsPeriod: String, CaseIterable { + case month + case lastMonth = "last_month" + case year + case all + + var displayName: (Strings) -> String { + switch self { + case .month: return { $0.thisMonth } + case .lastMonth: return { $0.lastMonth } + case .year: return { $0.thisYear } + case .all: return { $0.allTime } + } + } +} + struct ProfileStatsView: View { let userId: String + let isPro: Bool + let isOwnProfile: Bool + @State var strings = L10n.current - @State var totalHours: Double - @State var movieHours: Double - @State var seriesHours: Double - @State var monthlyHours: [MonthlyHoursEntry] - @State var watchedGenres: [WatchedGenre] - @State var itemsStatus: [ItemStatusStat] - @State var bestReviews: [BestReview] - @State var watchedCast: [WatchedCastMember] - @State var watchedCountries: [WatchedCountry] - @State var mostWatchedSeries: [MostWatchedSeries] - + @State var selectedPeriod: StatsPeriod = .month + + @State var totalHours: Double = 0 + @State var movieHours: Double = 0 + @State var seriesHours: Double = 0 + @State var monthlyHours: [MonthlyHoursEntry] = [] + @State var watchedGenres: [WatchedGenre] = [] + @State var bestReviews: [BestReview] = [] + + @State var comparisonHours: Double? + @State var loadedHours = false @State var loadedGenres = false - @State var loadedStatus = false @State var loadedReviews = false - @State var loadedCast = false - @State var loadedCountries = false - @State var loadedSeries = false @State var hasStartedLoading = false @State var error: String? - - @State var showAllGenres = false - @State var showAllCountries = false - @State var showAllReviews = false + @State var countStartTime: Date? @State var animationTrigger = false - + @State private var smartDefaultApplied = false + let countDuration: Double = 1.8 let cache = ProfileStatsCache.shared - - init(userId: String) { - self.userId = userId - let cache = ProfileStatsCache.shared - if let cached = cache.get(userId: userId) { - _totalHours = State(initialValue: cached.totalHours) - _movieHours = State(initialValue: cached.movieHours) - _seriesHours = State(initialValue: cached.seriesHours) - _monthlyHours = State(initialValue: cached.monthlyHours) - _watchedGenres = State(initialValue: cached.watchedGenres) - _itemsStatus = State(initialValue: cached.itemsStatus) - _bestReviews = State(initialValue: cached.bestReviews) - _watchedCast = State(initialValue: cached.watchedCast) - _watchedCountries = State(initialValue: cached.watchedCountries) - _mostWatchedSeries = State(initialValue: cached.mostWatchedSeries) - _loadedHours = State(initialValue: true) - _loadedGenres = State(initialValue: true) - _loadedStatus = State(initialValue: true) - _loadedReviews = State(initialValue: true) - _loadedCast = State(initialValue: true) - _loadedCountries = State(initialValue: true) - _loadedSeries = State(initialValue: true) - _hasStartedLoading = State(initialValue: true) - } else { - _totalHours = State(initialValue: 0) - _movieHours = State(initialValue: 0) - _seriesHours = State(initialValue: 0) - _monthlyHours = State(initialValue: []) - _watchedGenres = State(initialValue: []) - _itemsStatus = State(initialValue: []) - _bestReviews = State(initialValue: []) - _watchedCast = State(initialValue: []) - _watchedCountries = State(initialValue: []) - _mostWatchedSeries = State(initialValue: []) + + var hasMinimumData: Bool { + totalHours > 0 || !watchedGenres.isEmpty + } + + var allSectionsLoaded: Bool { + loadedHours && loadedGenres && loadedReviews + } + + var dailyAverage: Double { + guard totalHours > 0 else { return 0 } + switch selectedPeriod { + case .month: + let day = max(Calendar.current.component(.day, from: Date()), 1) + return totalHours / Double(day) + case .lastMonth: + let lastMonth = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() + let range = Calendar.current.range(of: .day, in: .month, for: lastMonth) + return totalHours / Double(range?.count ?? 30) + case .year: + let dayOfYear = max(Calendar.current.ordinality(of: .day, in: .year, for: Date()) ?? 1, 1) + return totalHours / Double(dayOfYear) + case .all: + if let firstMonth = monthlyHours.first?.month { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + if let startDate = formatter.date(from: firstMonth) { + let days = max(Calendar.current.dateComponents([.day], from: startDate, to: Date()).day ?? 30, 1) + return totalHours / Double(days) + } + } + return totalHours / 30 } } + var dailyAverageLabel: String { + switch selectedPeriod { + case .month, .lastMonth: return strings.perDayThisMonth + case .year: return strings.perDayThisYear + case .all: return strings.perDay + } + } + + init(userId: String, isPro: Bool = false, isOwnProfile: Bool = true) { + self.userId = userId + self.isPro = isPro + self.isOwnProfile = isOwnProfile + } + var body: some View { ScrollView { - VStack(spacing: 24) { - statsSection(loaded: loadedHours) { - statsCard(label: strings.timeWatched) { heroStatsSection } - } skeleton: { - statsCard { - VStack(spacing: 8) { - RoundedRectangle(cornerRadius: 8) - .fill(Color.appBorderAdaptive.opacity(0.5)) - .frame(width: 180, height: 48) - RoundedRectangle(cornerRadius: 4) - .fill(Color.appBorderAdaptive.opacity(0.5)) - .frame(width: 100, height: 14) - RoundedRectangle(cornerRadius: 4) - .fill(Color.appBorderAdaptive.opacity(0.5)) - .frame(maxWidth: .infinity) - .frame(height: 100) - } - .frame(maxWidth: .infinity) - .padding(.vertical, 12) + VStack(spacing: 16) { + periodSelector + + if allSectionsLoaded && !hasMinimumData { + emptyStateView + } else { + // 1. Time Watched + sectionWrapper(loaded: loadedHours) { + timeWatchedCard } - } - - statsSection(loaded: loadedGenres, hasData: !watchedGenres.isEmpty) { - statsCard(label: strings.favoriteGenres) { genresListSection } - } skeleton: { - sectionSkeleton(lines: 4) - } - - statsSection(loaded: loadedStatus, hasData: !itemsStatus.isEmpty) { - statsCard(label: strings.collectionStatus) { statusBarSection } - } skeleton: { - sectionSkeleton(lines: 3) - } - - statsSection(loaded: loadedCast, hasData: !watchedCast.isEmpty) { - statsCard(label: strings.mostWatchedCast) { watchedCastSection } - } skeleton: { - sectionSkeleton(lines: 4) - } - - statsSection(loaded: loadedSeries, hasData: !mostWatchedSeries.isEmpty) { - statsCard(label: strings.mostWatchedSeries) { mostWatchedSeriesSection } - } skeleton: { - sectionSkeleton(lines: 2) - } - - statsSection(loaded: loadedCountries, hasData: !watchedCountries.isEmpty) { - statsCard(label: strings.watchedCountries) { watchedCountriesSection } - } skeleton: { - sectionSkeleton(lines: 4) - } - - statsSection(loaded: loadedReviews, hasData: !bestReviews.isEmpty) { - statsCard(label: strings.bestReviews) { bestReviewsSection } - } skeleton: { - sectionSkeleton(lines: 3) + + // 2. Top Genre | Top Review (side by side) + highlightCards } } .padding(.horizontal, 24) .padding(.top, 16) .padding(.bottom, 24) } + .refreshable { + cache.invalidate(userId: userId, period: selectedPeriod.rawValue) + await loadStats() + } .task { await loadStats() } + .onChange(of: selectedPeriod) { _, _ in + resetAndReload() + } .onAppear { AnalyticsService.shared.track(.statsView) } @@ -148,14 +137,48 @@ struct ProfileStatsView: View { strings = L10n.current } } - + + // MARK: - Highlight Cards (Side by Side) + + @ViewBuilder + var highlightCards: some View { + let bothLoaded = loadedGenres && loadedReviews + let hasGenre = loadedGenres && !watchedGenres.isEmpty + let hasReview = loadedReviews && !bestReviews.isEmpty + let anyLoading = hasStartedLoading && !bothLoaded + + if anyLoading || hasGenre || hasReview { + HStack(alignment: .top, spacing: 12) { + Group { + if hasGenre { + topGenreCard + .transition(.opacity.animation(.easeIn(duration: 0.25))) + } else if !loadedGenres && hasStartedLoading { + skeletonRect(height: 200) + } + } + .frame(maxWidth: .infinity) + + Group { + if hasReview { + topReviewCard + .transition(.opacity.animation(.easeIn(duration: 0.25))) + } else if !loadedReviews && hasStartedLoading { + skeletonRect(height: 200) + } + } + .frame(maxWidth: .infinity) + } + } + } + // MARK: - Section Wrapper + @ViewBuilder - func statsSection( + func sectionWrapper( loaded: Bool, hasData: Bool = true, - @ViewBuilder content: () -> Content, - @ViewBuilder skeleton: () -> Skeleton + @ViewBuilder content: () -> Content ) -> some View { if loaded { if hasData { @@ -163,57 +186,92 @@ struct ProfileStatsView: View { .transition(.opacity.animation(.easeIn(duration: 0.25))) } } else if hasStartedLoading { - skeleton() - .modifier(ShimmerEffect()) + skeletonRect(height: 80) } } - - func sectionSkeleton(lines: Int) -> some View { - statsCard { - VStack(alignment: .leading, spacing: 14) { - ForEach(0.. some View { + RoundedRectangle(cornerRadius: 22) + .fill(Color.appBorderAdaptive.opacity(0.15)) + .frame(height: height) + .modifier(ShimmerEffect()) + } + + // MARK: - Period Selector + + var periodSelector: some View { + Picker("", selection: $selectedPeriod) { + ForEach(StatsPeriod.allCases, id: \.self) { period in + Text(period.displayName(strings)) + .tag(period) } } + .pickerStyle(.segmented) } - - // MARK: - Stats Card Container - func statsCard(label: String? = nil, @ViewBuilder content: () -> Content) -> some View { - VStack(alignment: .leading, spacing: 8) { - if let label { - Text(label.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) + + // MARK: - Empty State + + var emptyStateView: some View { + VStack(spacing: 20) { + Spacer().frame(height: 40) + + Image(systemName: "chart.bar.doc.horizontal") + .font(.system(size: 48)) + .foregroundColor(.appMutedForegroundAdaptive) + + VStack(spacing: 8) { + Text(strings.stats) + .font(.title3.bold()) + .foregroundColor(.appForegroundAdaptive) + + Text(selectedPeriod == .all ? strings.startTrackingStats : strings.noActivityThisPeriod) + .font(.subheadline) .foregroundColor(.appMutedForegroundAdaptive) - .padding(.horizontal, 16) + .multilineTextAlignment(.center) + .padding(.horizontal, 32) } - - content() - .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) - .background(Color.appInputFilled.opacity(0.4)) - .clipShape(RoundedRectangle(cornerRadius: 22)) + + Spacer().frame(height: 40) } + .frame(maxWidth: .infinity) + } + + // MARK: - Reset & Reload + + func resetAndReload() { + loadedHours = false + loadedGenres = false + loadedReviews = false + countStartTime = nil + comparisonHours = nil + + totalHours = 0 + movieHours = 0 + seriesHours = 0 + monthlyHours = [] + watchedGenres = [] + bestReviews = [] + + Task { await loadStats() } } - + // MARK: - Load Stats + func loadStats() async { - if loadedHours && totalHours > 0 { - if countStartTime == nil { + let period = selectedPeriod.rawValue + + if let cached = cache.get(userId: userId, period: period) { + totalHours = cached.totalHours + movieHours = cached.movieHours + seriesHours = cached.seriesHours + monthlyHours = cached.monthlyHours + watchedGenres = cached.watchedGenres + bestReviews = cached.bestReviews + loadedHours = true + loadedGenres = true + loadedReviews = true + hasStartedLoading = true + if totalHours > 0 { countStartTime = .now withAnimation(.linear(duration: countDuration)) { animationTrigger.toggle() @@ -221,15 +279,15 @@ struct ProfileStatsView: View { } return } - + hasStartedLoading = true error = nil let language = Language.current.rawValue - + await withTaskGroup(of: Void.self) { group in group.addTask { @MainActor in do { - let response = try await UserStatsService.shared.getTotalHours(userId: userId) + let response = try await UserStatsService.shared.getTotalHours(userId: userId, period: period) withAnimation(.easeIn(duration: 0.25)) { totalHours = response.totalHours movieHours = response.movieHours @@ -241,14 +299,21 @@ struct ProfileStatsView: View { withAnimation(.linear(duration: countDuration)) { animationTrigger.toggle() } + + await loadComparisonHours(period: period) + + if !smartDefaultApplied { + smartDefaultApplied = true + applySmartDefault(currentTotalHours: response.totalHours) + } } catch { withAnimation { loadedHours = true } } } - + group.addTask { @MainActor in do { - let genres = try await UserStatsService.shared.getWatchedGenres(userId: userId, language: language) + let genres = try await UserStatsService.shared.getWatchedGenres(userId: userId, language: language, period: period) withAnimation(.easeIn(duration: 0.25)) { watchedGenres = genres loadedGenres = true @@ -257,22 +322,10 @@ struct ProfileStatsView: View { withAnimation { loadedGenres = true } } } - - group.addTask { @MainActor in - do { - let status = try await UserStatsService.shared.getItemsStatus(userId: userId) - withAnimation(.easeIn(duration: 0.25)) { - itemsStatus = status - loadedStatus = true - } - } catch { - withAnimation { loadedStatus = true } - } - } - + group.addTask { @MainActor in do { - let reviews = try await UserStatsService.shared.getBestReviews(userId: userId, language: language) + let reviews = try await UserStatsService.shared.getBestReviews(userId: userId, language: language, period: period) withAnimation(.easeIn(duration: 0.25)) { bestReviews = reviews loadedReviews = true @@ -281,46 +334,80 @@ struct ProfileStatsView: View { withAnimation { loadedReviews = true } } } - - group.addTask { @MainActor in - let cast = (try? await UserStatsService.shared.getWatchedCast(userId: userId)) ?? [] - withAnimation(.easeIn(duration: 0.25)) { - watchedCast = cast - loadedCast = true - } - } - - group.addTask { @MainActor in - let countries = (try? await UserStatsService.shared.getWatchedCountries(userId: userId, language: language)) ?? [] - withAnimation(.easeIn(duration: 0.25)) { - watchedCountries = countries - loadedCountries = true - } - } - - group.addTask { @MainActor in - let series = (try? await UserStatsService.shared.getMostWatchedSeries(userId: userId, language: language)) ?? [] - withAnimation(.easeIn(duration: 0.25)) { - mostWatchedSeries = series - loadedSeries = true - } - } + } - + cache.set( userId: userId, + period: period, totalHours: totalHours, movieHours: movieHours, seriesHours: seriesHours, monthlyHours: monthlyHours, watchedGenres: watchedGenres, - itemsStatus: itemsStatus, - bestReviews: bestReviews, - watchedCast: watchedCast, - watchedCountries: watchedCountries, - mostWatchedSeries: mostWatchedSeries + bestReviews: bestReviews ) } + + // MARK: - Comparison Hours + + @MainActor + func loadComparisonHours(period: String) async { + let comparisonPeriod: String? + switch period { + case "month": comparisonPeriod = "last_month" + default: comparisonPeriod = nil + } + + guard let compPeriod = comparisonPeriod else { + comparisonHours = nil + return + } + + do { + let response = try await UserStatsService.shared.getTotalHours(userId: userId, period: compPeriod) + comparisonHours = response.totalHours + } catch { + comparisonHours = nil + } + } + + // MARK: - Smart Default + + @MainActor + func applySmartDefault(currentTotalHours: Double) { + if selectedPeriod != .month { return } + + if currentTotalHours == 0 { + Task { + let allTimeResponse = try? await UserStatsService.shared.getTotalHours(userId: userId, period: "all") + let allTimeHours = allTimeResponse?.totalHours ?? 0 + + if allTimeHours == 0 { + selectedPeriod = .all + } else { + let lastMonthResponse = try? await UserStatsService.shared.getTotalHours(userId: userId, period: "last_month") + if (lastMonthResponse?.totalHours ?? 0) > 0 { + selectedPeriod = .lastMonth + } else { + selectedPeriod = .all + } + } + } + } + } +} + +// 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 fb6a067f..01a72a69 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/Profile/UserProfileView.swift b/apps/ios/Plotwist/Plotwist/Views/Profile/UserProfileView.swift index b2c6de6c..f65241ef 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) } } From c45448edc7f8a6bff8907835a1265262529b1641 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 14:07:13 -0300 Subject: [PATCH 02/16] feat: enhance stats functionality and UI in ProfileStatsView - Introduced a new MonthSection model to manage monthly statistics, improving data organization and display. - Updated ProfileStatsView to support a timeline mode, allowing users to view stats by month with enhanced visual elements. - Added new localization strings for timeline and comparison metrics, improving user experience across multiple languages. - Refactored various components to streamline data handling and improve code readability, including the removal of unused methods and properties. These changes aim to provide users with a more engaging and informative stats experience, aligning with modern UI practices. --- apps/backend/src/http/schemas/common.ts | 29 +- .../Plotwist/Localization/Strings.swift | 16 + .../Services/ProfilePrefetchService.swift | 14 +- .../Views/Home/ProfileStatsHelpers.swift | 45 +- .../Views/Home/ProfileStatsSections.swift | 272 ++++-- .../Views/Home/ProfileStatsView.swift | 782 ++++++++++++------ 6 files changed, 801 insertions(+), 357 deletions(-) diff --git a/apps/backend/src/http/schemas/common.ts b/apps/backend/src/http/schemas/common.ts index c4b869ec..6941f102 100644 --- a/apps/backend/src/http/schemas/common.ts +++ b/apps/backend/src/http/schemas/common.ts @@ -20,16 +20,20 @@ export const languageWithLimitQuerySchema = z.object({ 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 - .enum(['month', 'last_month', 'year', 'all']) + .union([ + z.enum(['month', 'last_month', 'year', 'all']), + z.string().regex(yearMonthRegex), + ]) .optional() .default('all'), }) -export const languageWithPeriodQuerySchema = languageQuerySchema.merge( - periodQuerySchema -) +export const languageWithPeriodQuerySchema = + languageQuerySchema.merge(periodQuerySchema) export const languageWithLimitAndPeriodQuerySchema = languageWithLimitQuerySchema.merge(periodQuerySchema) @@ -40,6 +44,13 @@ 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': { @@ -48,7 +59,15 @@ export function periodToDateRange(period: StatsPeriod): { } 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) + const endDate = new Date( + now.getFullYear(), + now.getMonth(), + 0, + 23, + 59, + 59, + 999 + ) return { startDate, endDate } } case 'year': { diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index 69e3f3cb..a4ef9caf 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -341,6 +341,8 @@ enum L10n { perDayThisYear: "/day this year", noActivityThisPeriod: "No activity in this period", minutes: "minutes", + timeline: "Timeline", + vsLastMonthShort: "vs last month", // Home Engagement forYou: "For you", basedOnYourTaste: "Because you like %@", @@ -692,6 +694,8 @@ enum L10n { perDayThisYear: "/dia este ano", noActivityThisPeriod: "Sem atividade neste período", minutes: "minutos", + timeline: "Timeline", + vsLastMonthShort: "vs mês passado", // Home Engagement forYou: "Para você", basedOnYourTaste: "Porque você gosta de %@", @@ -1043,6 +1047,8 @@ enum L10n { perDayThisYear: "/día este año", noActivityThisPeriod: "Sin actividad en este período", minutes: "minutos", + timeline: "Timeline", + vsLastMonthShort: "vs mes pasado", // Home Engagement forYou: "Para ti", basedOnYourTaste: "Porque te gusta %@", @@ -1394,6 +1400,8 @@ enum L10n { perDayThisYear: "/jour cette année", noActivityThisPeriod: "Aucune activité pour cette période", minutes: "minutes", + timeline: "Timeline", + vsLastMonthShort: "vs mois dernier", // Home Engagement forYou: "Pour vous", basedOnYourTaste: "Parce que vous aimez %@", @@ -1745,6 +1753,8 @@ enum L10n { perDayThisYear: "/Tag dieses Jahr", noActivityThisPeriod: "Keine Aktivität in diesem Zeitraum", minutes: "Minuten", + timeline: "Timeline", + vsLastMonthShort: "vs letzten Monat", // Home Engagement forYou: "Für dich", basedOnYourTaste: "Weil du %@ magst", @@ -2096,6 +2106,8 @@ enum L10n { perDayThisYear: "/giorno quest'anno", noActivityThisPeriod: "Nessuna attività in questo periodo", minutes: "minuti", + timeline: "Timeline", + vsLastMonthShort: "vs mese scorso", // Home Engagement forYou: "Per te", basedOnYourTaste: "Perché ti piace %@", @@ -2446,6 +2458,8 @@ enum L10n { perDayThisYear: "/日(今年)", noActivityThisPeriod: "この期間にアクティビティはありません", minutes: "分", + timeline: "タイムライン", + vsLastMonthShort: "vs 先月", // Home Engagement forYou: "あなたへ", basedOnYourTaste: "%@が好きだから", @@ -2808,6 +2822,8 @@ struct Strings { let perDayThisYear: String let noActivityThisPeriod: String let minutes: String + let timeline: String + let vsLastMonthShort: 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 1484110b..a0a4f5b7 100644 --- a/apps/ios/Plotwist/Plotwist/Services/ProfilePrefetchService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/ProfilePrefetchService.swift @@ -112,20 +112,23 @@ final class ProfilePrefetchService { } catch {} } - // Prefetch stats (total hours, genres, status distribution, best reviews, cast, countries, series) + // 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 reviewsTask = UserStatsService.shared.getBestReviews( userId: userId, - language: language + language: language, + period: currentMonth ) let (hoursResponse, genres, reviews) = try await ( hoursTask, genresTask, reviewsTask @@ -133,6 +136,7 @@ final class ProfilePrefetchService { statsCache.set( userId: userId, + period: currentMonth, totalHours: hoursResponse.totalHours, movieHours: hoursResponse.movieHours, seriesHours: hoursResponse.seriesHours, diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift index c437ead0..e60a6992 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift @@ -62,16 +62,8 @@ func fullMonthLabel(_ month: String) -> String { // MARK: - ProfileStatsView Helpers extension ProfileStatsView { - 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 - } - func formatHours(_ hours: Double) -> String { - if totalHours >= 1000 { + if hours >= 1000 { return String(format: "%.1fk", hours / 1000) } return String(format: "%.0f", hours) @@ -82,41 +74,6 @@ extension ProfileStatsView { return String(format: "%.0f", days) } - // MARK: - Trend Insight Computation - - func computeTrendInsight() -> String? { - guard monthlyHours.count >= 3 else { return nil } - - let recent = Array(monthlyHours.suffix(3)) - let earlier = Array(monthlyHours.dropLast(3).suffix(3)) - - guard !recent.isEmpty else { return nil } - - let recentAvg = recent.map(\.hours).reduce(0, +) / Double(recent.count) - let earlierAvg = earlier.isEmpty ? recentAvg : earlier.map(\.hours).reduce(0, +) / Double(earlier.count) - - if !earlier.isEmpty { - let change = earlierAvg > 0 ? ((recentAvg - earlierAvg) / earlierAvg) * 100 : 0 - if change > 20 { - return strings.consumptionUp - } else if change < -20 { - return strings.consumptionDown - } - } - - if totalHours > 0 { - let seriesRatio = seriesHours / totalHours - let movieRatio = movieHours / totalHours - if seriesRatio > 0.6 { - return strings.moreSeriesThanMovies - } else if movieRatio > 0.6 { - return strings.moreMoviesThanSeries - } - } - - return nil - } - // MARK: - Country Helpers static var tmdbNameOverrides: [String: String] { diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index e18b6444..f4f85123 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -8,17 +8,18 @@ import SwiftUI // MARK: - Time Watched Card extension ProfileStatsView { - var timeWatchedCard: some View { + func timeWatchedCard(for section: MonthSection, period: String) -> some View { NavigationLink { TimeWatchedDetailView( - totalHours: totalHours, - movieHours: movieHours, - seriesHours: seriesHours, - monthlyHours: monthlyHours, - dailyAverage: dailyAverage, - dailyAverageLabel: dailyAverageLabel, - comparisonHours: comparisonHours, - selectedPeriod: selectedPeriod, + totalHours: section.totalHours, + movieHours: section.movieHours, + seriesHours: section.seriesHours, + monthlyHours: section.monthlyHours, + dailyAverage: computeDailyAverage(section: section, period: period), + dailyAverageLabel: strings.perDayThisMonth, + comparisonHours: section.comparisonHours, + periodLabel: period == "all" ? strings.allTime : section.displayName, + showComparison: period != "all", strings: strings ) } label: { @@ -36,23 +37,20 @@ extension ProfileStatsView { .padding(.bottom, 14) HStack(alignment: .firstTextBaseline, spacing: 6) { - TimelineView(.animation(minimumInterval: 1.0 / 60.0)) { timeline in - let currentHours = interpolatedHours(at: timeline.date) - Text(formatTotalMinutes(currentHours)) - .font(.system(size: 48, weight: .bold, design: .rounded)) - .foregroundColor(.appForegroundAdaptive) - .contentTransition(.numericText(countsDown: false)) - } + Text(formatTotalMinutes(section.totalHours)) + .font(.system(size: 48, weight: .bold, design: .rounded)) + .foregroundColor(.appForegroundAdaptive) + .contentTransition(.numericText(countsDown: false)) Text(strings.minutes) .font(.system(size: 16, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) } - HStack(spacing: 8) { - comparisonBadge + if period != "all", let comparison = section.comparisonHours { + comparisonBadgeView(totalHours: section.totalHours, comparison: comparison) + .padding(.top, 6) } - .padding(.top, 6) } .padding(16) .frame(maxWidth: .infinity, alignment: .leading) @@ -63,37 +61,65 @@ extension ProfileStatsView { } @ViewBuilder - var comparisonBadge: some View { - if let comparison = comparisonHours, selectedPeriod == .month || selectedPeriod == .year { - let delta = totalHours - comparison - let sign = delta >= 0 ? "+" : "" - let label = selectedPeriod == .month - ? String(format: strings.vsLastMonth, "\(sign)\(formatHoursMinutes(abs(delta)))") - : String(format: strings.vsLastYear, "\(sign)\(formatHoursMinutes(abs(delta)))") + 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)) + } - 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)) + func computeDailyAverage(section: MonthSection, period: String) -> Double { + guard section.totalHours > 0 else { return 0 } + if period == "all" { + if let firstMonth = section.monthlyHours.first?.month { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + if let startDate = formatter.date(from: firstMonth) { + let days = max(Calendar.current.dateComponents([.day], from: startDate, to: Date()).day ?? 30, 1) + return section.totalHours / Double(days) + } + } + return section.totalHours / 30 + } + + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + if let date = formatter.date(from: period) { + let now = Date() + let cal = Calendar.current + let sameMonth = cal.isDate(date, equalTo: now, toGranularity: .month) + if sameMonth { + let day = max(cal.component(.day, from: now), 1) + return section.totalHours / Double(day) + } else { + let range = cal.range(of: .day, in: .month, for: date) + return section.totalHours / Double(range?.count ?? 30) } - .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)) } + + return section.totalHours / 30 } } -// MARK: - Top Genre Card (half-width, navigates to detail) +// MARK: - Top Genre Card extension ProfileStatsView { - var topGenreCard: some View { - let topGenre = watchedGenres.first + func topGenreCard(for section: MonthSection) -> some View { + let topGenre = section.watchedGenres.first return NavigationLink { - PeriodGenresView(genres: watchedGenres, period: selectedPeriod, strings: strings) + PeriodGenresView(genres: section.watchedGenres, periodLabel: section.yearMonth == "all" ? strings.allTime : section.displayName, strings: strings) } label: { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { @@ -129,14 +155,14 @@ extension ProfileStatsView { } } -// MARK: - Top Review Card (half-width, navigates to detail) +// MARK: - Top Review Card extension ProfileStatsView { - var topReviewCard: some View { - let topReview = bestReviews.first + func topReviewCard(for section: MonthSection) -> some View { + let topReview = section.bestReviews.first return NavigationLink { - PeriodReviewsView(reviews: bestReviews, period: selectedPeriod, strings: strings) + PeriodReviewsView(reviews: section.bestReviews, periodLabel: section.yearMonth == "all" ? strings.allTime : section.displayName, strings: strings) } label: { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { @@ -183,7 +209,8 @@ struct TimeWatchedDetailView: View { let dailyAverage: Double let dailyAverageLabel: String let comparisonHours: Double? - let selectedPeriod: StatsPeriod + let periodLabel: String + let showComparison: Bool let strings: Strings @State private var scrollOffset: CGFloat = 0 @@ -295,12 +322,10 @@ struct TimeWatchedDetailView: View { @ViewBuilder var comparisonBadge: some View { - if let comparison = comparisonHours, selectedPeriod == .month || selectedPeriod == .year { + if showComparison, let comparison = comparisonHours { let delta = totalHours - comparison let sign = delta >= 0 ? "+" : "" - let label = selectedPeriod == .month - ? String(format: strings.vsLastMonth, "\(sign)\(formatHoursMinutes(abs(delta)))") - : String(format: strings.vsLastYear, "\(sign)\(formatHoursMinutes(abs(delta)))") + let label = String(format: strings.vsLastMonth, "\(sign)\(formatHoursMinutes(abs(delta)))") HStack(spacing: 4) { Image(systemName: delta >= 0 ? "arrow.up.right" : "arrow.down.right") @@ -425,7 +450,7 @@ struct PeriodGenresView: View { @Environment(\.dismiss) private var dismiss let genres: [WatchedGenre] - let period: StatsPeriod + let periodLabel: String let strings: Strings @State private var scrollOffset: CGFloat = 0 @@ -521,7 +546,7 @@ struct PeriodReviewsView: View { @Environment(\.dismiss) private var dismiss let reviews: [BestReview] - let period: StatsPeriod + let periodLabel: String let strings: Strings @State private var scrollOffset: CGFloat = 0 @@ -645,8 +670,8 @@ func statsPoster(url: URL?, rating: Double? = nil) -> some View { .padding(.horizontal, 6) .padding(.vertical, 4) .background(.ultraThinMaterial) - .clipShape(RoundedRectangle(cornerRadius: 6)) - .padding(5) + .clipShape(RoundedRectangle(cornerRadius: cr * 0.5)) + .padding(6) } } .posterBorder() @@ -669,3 +694,144 @@ struct ShimmerEffect: ViewModifier { } } } + +// MARK: - Stats Share Card + +struct StatsShareCardView: View { + let section: MonthSection + let strings: Strings + + private let gradientStart = Color(hex: "0F172A") + private let gradientEnd = Color(hex: "1E293B") + private let accentBlue = Color(hex: "3B82F6") + private let accentGreen = Color(hex: "10B981") + + var body: some View { + VStack(spacing: 0) { + VStack(alignment: .leading, spacing: 24) { + // Header + HStack { + VStack(alignment: .leading, spacing: 4) { + Text(section.displayName.uppercased()) + .font(.system(size: 13, weight: .bold)) + .tracking(2) + .foregroundColor(accentBlue) + + Text(strings.myMonthInReview) + .font(.system(size: 22, weight: .bold)) + .foregroundColor(.white) + } + + Spacer() + } + + // Big number + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(formatTotalMinutes(section.totalHours)) + .font(.system(size: 64, weight: .heavy, design: .rounded)) + .foregroundColor(.white) + + Text(strings.minutes) + .font(.system(size: 18, weight: .medium)) + .foregroundColor(.white.opacity(0.6)) + } + + if section.movieHours > 0 || section.seriesHours > 0 { + HStack(spacing: 16) { + HStack(spacing: 6) { + Circle() + .fill(accentBlue) + .frame(width: 8, height: 8) + Text("\(strings.movies) \(formatTotalMinutes(section.movieHours))m") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + + HStack(spacing: 6) { + Circle() + .fill(accentGreen) + .frame(width: 8, height: 8) + Text("\(strings.series) \(formatTotalMinutes(section.seriesHours))m") + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + } + } + } + } + + Divider() + .background(Color.white.opacity(0.1)) + + // Stats grid + HStack(spacing: 0) { + if let genre = section.watchedGenres.first { + VStack(alignment: .leading, spacing: 6) { + Text(strings.favoriteGenre.uppercased()) + .font(.system(size: 10, weight: .bold)) + .tracking(1.2) + .foregroundColor(.white.opacity(0.4)) + + Text(genre.name) + .font(.system(size: 17, weight: .bold)) + .foregroundColor(.white) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + if let review = section.bestReviews.first { + VStack(alignment: .leading, spacing: 6) { + Text(strings.bestReview.uppercased()) + .font(.system(size: 10, weight: .bold)) + .tracking(1.2) + .foregroundColor(.white.opacity(0.4)) + + HStack(spacing: 4) { + Image(systemName: "star.fill") + .font(.system(size: 12)) + .foregroundColor(Color(hex: "F59E0B")) + Text(String(format: "%.1f", review.rating)) + .font(.system(size: 17, weight: .bold)) + .foregroundColor(.white) + } + + Text(review.title) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.white.opacity(0.7)) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + .padding(28) + + Spacer() + + // Footer branding + HStack { + Text("plotwist") + .font(.system(size: 15, weight: .bold, design: .rounded)) + .foregroundColor(.white.opacity(0.3)) + + Spacer() + + Text("plotwist.app") + .font(.system(size: 12, weight: .medium)) + .foregroundColor(.white.opacity(0.25)) + } + .padding(.horizontal, 28) + .padding(.bottom, 20) + } + .frame(width: 390, height: 520) + .background( + LinearGradient( + colors: [gradientStart, gradientEnd], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + ) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } +} diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index 2dfe6924..fc004656 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -5,115 +5,232 @@ import SwiftUI -enum StatsPeriod: String, CaseIterable { - case month - case lastMonth = "last_month" - case year - case all +// 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 comparisonHours: Double? + var isLoaded: Bool = false + + var id: String { yearMonth } + + var displayName: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + guard let date = formatter.date(from: yearMonth) else { return yearMonth } + formatter.dateFormat = "MMMM yyyy" + let result = formatter.string(from: date) + return result.prefix(1).uppercased() + result.dropFirst() + } + + var shortLabel: String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + guard let date = formatter.date(from: yearMonth) else { return yearMonth } + formatter.dateFormat = "MMM" + return formatter.string(from: date).prefix(3).uppercased() + } + + var hasMinimumData: Bool { + totalHours > 0 || !watchedGenres.isEmpty || !bestReviews.isEmpty + } + + static func == (lhs: MonthSection, rhs: MonthSection) -> Bool { + lhs.yearMonth == rhs.yearMonth + } + + static func currentYearMonth() -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + return formatter.string(from: Date()) + } + + static func previousYearMonth(from ym: String) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "yyyy-MM" + guard let date = formatter.date(from: ym), + let prev = Calendar.current.date(byAdding: .month, value: -1, to: date) else { + return ym + } + return formatter.string(from: prev) + } +} + +// MARK: - Stats Mode + +enum StatsMode: String, CaseIterable { + case timeline + case allTime var displayName: (Strings) -> String { switch self { - case .month: return { $0.thisMonth } - case .lastMonth: return { $0.lastMonth } - case .year: return { $0.thisYear } - case .all: return { $0.allTime } + case .timeline: return { $0.timeline } + case .allTime: return { $0.allTime } } } } +// MARK: - Visible Section Preference + +struct VisibleSectionPreferenceKey: PreferenceKey { + static var defaultValue: String? = nil + static func reduce(value: inout String?, nextValue: () -> String?) { + value = value ?? nextValue() + } +} + +// MARK: - ProfileStatsView + struct ProfileStatsView: View { let userId: String let isPro: Bool let isOwnProfile: Bool @State var strings = L10n.current - @State var selectedPeriod: StatsPeriod = .month - - @State var totalHours: Double = 0 - @State var movieHours: Double = 0 - @State var seriesHours: Double = 0 - @State var monthlyHours: [MonthlyHoursEntry] = [] - @State var watchedGenres: [WatchedGenre] = [] - @State var bestReviews: [BestReview] = [] + @State var mode: StatsMode = .timeline - @State var comparisonHours: Double? + // Timeline state + @State var monthSections: [MonthSection] = [] + @State var isLoadingMore = false + @State var visibleYearMonth: String? + @State var isDraggingScrubber = false + @State var scrubberDragMonth: String? - @State var loadedHours = false - @State var loadedGenres = false - @State var loadedReviews = false - @State var hasStartedLoading = false - @State var error: String? + // All-time state + @State var allTimeSection = MonthSection(yearMonth: "all") @State var countStartTime: Date? @State var animationTrigger = false - @State private var smartDefaultApplied = false + @State var hasStartedLoading = false + @State var error: String? let countDuration: Double = 1.8 let cache = ProfileStatsCache.shared - var hasMinimumData: Bool { - totalHours > 0 || !watchedGenres.isEmpty - } - - var allSectionsLoaded: Bool { - loadedHours && loadedGenres && loadedReviews + init(userId: String, isPro: Bool = false, isOwnProfile: Bool = true) { + self.userId = userId + self.isPro = isPro + self.isOwnProfile = isOwnProfile } - var dailyAverage: Double { - guard totalHours > 0 else { return 0 } - switch selectedPeriod { - case .month: - let day = max(Calendar.current.component(.day, from: Date()), 1) - return totalHours / Double(day) - case .lastMonth: - let lastMonth = Calendar.current.date(byAdding: .month, value: -1, to: Date()) ?? Date() - let range = Calendar.current.range(of: .day, in: .month, for: lastMonth) - return totalHours / Double(range?.count ?? 30) - case .year: - let dayOfYear = max(Calendar.current.ordinality(of: .day, in: .year, for: Date()) ?? 1, 1) - return totalHours / Double(dayOfYear) - case .all: - if let firstMonth = monthlyHours.first?.month { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - if let startDate = formatter.date(from: firstMonth) { - let days = max(Calendar.current.dateComponents([.day], from: startDate, to: Date()).day ?? 30, 1) - return totalHours / Double(days) + var body: some View { + VStack(spacing: 0) { + modeSelector + .padding(.horizontal, 24) + .padding(.top, 8) + .padding(.bottom, 8) + + if mode == .timeline { + timelineBody + } else { + allTimeBody + } + } + .task { + if mode == .timeline { + await initTimeline() + } else { + await loadAllTime() + } + } + .onChange(of: mode) { _, newMode in + Task { + if newMode == .timeline { + if monthSections.isEmpty { + await initTimeline() + } + } else { + if !allTimeSection.isLoaded { + await loadAllTime() + } } } - return totalHours / 30 + } + .onAppear { + AnalyticsService.shared.track(.statsView) + } + .onReceive(NotificationCenter.default.publisher(for: .languageChanged)) { _ in + strings = L10n.current } } - var dailyAverageLabel: String { - switch selectedPeriod { - case .month, .lastMonth: return strings.perDayThisMonth - case .year: return strings.perDayThisYear - case .all: return strings.perDay + // MARK: - Mode Selector + + var modeSelector: some View { + Picker("", selection: $mode) { + ForEach(StatsMode.allCases, id: \.self) { m in + Text(m.displayName(strings)) + .tag(m) + } } + .pickerStyle(.segmented) } - init(userId: String, isPro: Bool = false, isOwnProfile: Bool = true) { - self.userId = userId - self.isPro = isPro - self.isOwnProfile = isOwnProfile + // MARK: - Timeline Body + + var timelineBody: some View { + ScrollViewReader { proxy in + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(Array(monthSections.enumerated()), id: \.element.id) { index, section in + Section { + monthContentView(section) + .padding(.horizontal, 24) + .padding(.bottom, 48) + } header: { + monthHeaderView(section) + } + .id(section.yearMonth) + .onAppear { + visibleYearMonth = section.yearMonth + loadMoreIfNeeded(index: index) + } + } + + if isLoadingMore { + VStack(spacing: 16) { + skeletonRect(height: 120) + HStack(spacing: 12) { + skeletonRect(height: 200) + skeletonRect(height: 200) + } + } + .padding(.horizontal, 24) + .padding(.bottom, 48) + } + } + .padding(.top, 8) + } + .refreshable { + for section in monthSections { + cache.invalidate(userId: userId, period: section.yearMonth) + } + monthSections = [] + await initTimeline() + } + .overlay(alignment: .trailing) { + if monthSections.count > 2 { + monthScrubber(proxy: proxy) + } + } + } } - var body: some View { + // MARK: - All-Time Body + + var allTimeBody: some View { ScrollView { VStack(spacing: 16) { - periodSelector - - if allSectionsLoaded && !hasMinimumData { - emptyStateView + if allTimeSection.isLoaded && !allTimeSection.hasMinimumData { + emptyStateView(isAllTime: true) } else { - // 1. Time Watched - sectionWrapper(loaded: loadedHours) { - timeWatchedCard - } - - // 2. Top Genre | Top Review (side by side) - highlightCards + allTimeSectionContent } } .padding(.horizontal, 24) @@ -121,75 +238,186 @@ struct ProfileStatsView: View { .padding(.bottom, 24) } .refreshable { - cache.invalidate(userId: userId, period: selectedPeriod.rawValue) - await loadStats() - } - .task { - await loadStats() + cache.invalidate(userId: userId, period: "all") + allTimeSection = MonthSection(yearMonth: "all") + await loadAllTime() } - .onChange(of: selectedPeriod) { _, _ in - resetAndReload() - } - .onAppear { - AnalyticsService.shared.track(.statsView) + } + + @ViewBuilder + var allTimeSectionContent: some View { + if allTimeSection.isLoaded { + timeWatchedCard(for: allTimeSection, period: "all") + .transition(.opacity.animation(.easeIn(duration: 0.25))) + + highlightCards(for: allTimeSection) + } else if hasStartedLoading { + skeletonRect(height: 120) + HStack(spacing: 12) { + skeletonRect(height: 200) + skeletonRect(height: 200) + } } - .onReceive(NotificationCenter.default.publisher(for: .languageChanged)) { _ in - strings = L10n.current + } + + // MARK: - Month Content View + + @ViewBuilder + func monthContentView(_ section: MonthSection) -> some View { + if section.isLoaded && !section.hasMinimumData { + emptyStateView(isAllTime: false) + } else if section.isLoaded { + VStack(spacing: 16) { + timeWatchedCard(for: section, period: section.yearMonth) + .transition(.opacity.animation(.easeIn(duration: 0.25))) + + highlightCards(for: section) + } + } else { + VStack(spacing: 16) { + skeletonRect(height: 120) + HStack(spacing: 12) { + skeletonRect(height: 200) + skeletonRect(height: 200) + } + } } } // MARK: - Highlight Cards (Side by Side) @ViewBuilder - var highlightCards: some View { - let bothLoaded = loadedGenres && loadedReviews - let hasGenre = loadedGenres && !watchedGenres.isEmpty - let hasReview = loadedReviews && !bestReviews.isEmpty - let anyLoading = hasStartedLoading && !bothLoaded + func highlightCards(for section: MonthSection) -> some View { + let hasGenre = !section.watchedGenres.isEmpty + let hasReview = !section.bestReviews.isEmpty - if anyLoading || hasGenre || hasReview { + if hasGenre || hasReview { HStack(alignment: .top, spacing: 12) { - Group { - if hasGenre { - topGenreCard - .transition(.opacity.animation(.easeIn(duration: 0.25))) - } else if !loadedGenres && hasStartedLoading { - skeletonRect(height: 200) - } + if hasGenre { + topGenreCard(for: section) + .transition(.opacity.animation(.easeIn(duration: 0.25))) + } else { + Color.clear } - .frame(maxWidth: .infinity) - - Group { - if hasReview { - topReviewCard - .transition(.opacity.animation(.easeIn(duration: 0.25))) - } else if !loadedReviews && hasStartedLoading { - skeletonRect(height: 200) - } + + if hasReview { + topReviewCard(for: section) + .transition(.opacity.animation(.easeIn(duration: 0.25))) + } else { + Color.clear } - .frame(maxWidth: .infinity) } } } - // MARK: - Section Wrapper + // MARK: - Month Header - @ViewBuilder - func sectionWrapper( - loaded: Bool, - hasData: Bool = true, - @ViewBuilder content: () -> Content - ) -> some View { - if loaded { - if hasData { - content() - .transition(.opacity.animation(.easeIn(duration: 0.25))) + func monthHeaderView(_ section: MonthSection) -> some View { + HStack { + Text(section.displayName) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + + Spacer() + + Button { + shareMonthStats(section) + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + .frame(width: 32, height: 32) + .background(Color.appInputFilled) + .clipShape(Circle()) + } + } + .padding(.horizontal, 24) + .padding(.vertical, 10) + .background(Color.appBackgroundAdaptive) + } + + // MARK: - Month Scrubber (floating handle) + + func monthScrubber(proxy: ScrollViewProxy) -> some View { + let activeYM = scrubberDragMonth ?? visibleYearMonth ?? monthSections.first?.yearMonth ?? "" + let activeIndex = monthSections.firstIndex(where: { $0.yearMonth == activeYM }) ?? 0 + + return GeometryReader { geo in + let trackHeight = geo.size.height + let count = max(monthSections.count, 1) + let segmentHeight = trackHeight / CGFloat(count) + let handleY = CGFloat(activeIndex) * segmentHeight + segmentHeight / 2 + + HStack(spacing: 0) { + Spacer() + + ZStack(alignment: .topTrailing) { + // Floating month label (appears while dragging) + if isDraggingScrubber, let dragYM = scrubberDragMonth, + let section = monthSections.first(where: { $0.yearMonth == dragYM }) { + HStack(spacing: 6) { + Text(section.displayName) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) + } + .padding(.horizontal, 14) + .padding(.vertical, 10) + .background(Color.appForegroundAdaptive) + .clipShape(Capsule()) + .offset(y: handleY - 18) + .padding(.trailing, 48) + .transition(.opacity.combined(with: .scale(scale: 0.8))) + .animation(.easeOut(duration: 0.15), value: dragYM) + } + + // Handle + VStack(spacing: 1) { + Image(systemName: "chevron.up") + .font(.system(size: 8, weight: .bold)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundColor(isDraggingScrubber ? .white : .appMutedForegroundAdaptive) + .frame(width: 28, height: 36) + .background(isDraggingScrubber ? Color.appForegroundAdaptive : Color.statsCardBackground) + .clipShape(Capsule()) + .shadow(color: Color.black.opacity(0.15), radius: 4, x: 0, y: 2) + .offset(y: handleY - 18) + .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.8), value: activeIndex) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + if !isDraggingScrubber { + isDraggingScrubber = true + } + let y = min(max(value.location.y + handleY - 18, 0), trackHeight) + let idx = max(0, min(Int(y / segmentHeight), count - 1)) + let targetYM = monthSections[idx].yearMonth + if scrubberDragMonth != targetYM { + scrubberDragMonth = targetYM + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + proxy.scrollTo(targetYM, anchor: .top) + } + } + .onEnded { _ in + withAnimation(.easeOut(duration: 0.25)) { + isDraggingScrubber = false + } + scrubberDragMonth = nil + } + ) + } + .frame(width: 28) } - } else if hasStartedLoading { - skeletonRect(height: 80) } + .padding(.vertical, 80) + .padding(.trailing, 6) + .allowsHitTesting(true) } + // MARK: - Skeleton + func skeletonRect(height: CGFloat) -> some View { RoundedRectangle(cornerRadius: 22) .fill(Color.appBorderAdaptive.opacity(0.15)) @@ -197,21 +425,9 @@ struct ProfileStatsView: View { .modifier(ShimmerEffect()) } - // MARK: - Period Selector - - var periodSelector: some View { - Picker("", selection: $selectedPeriod) { - ForEach(StatsPeriod.allCases, id: \.self) { period in - Text(period.displayName(strings)) - .tag(period) - } - } - .pickerStyle(.segmented) - } - // MARK: - Empty State - var emptyStateView: some View { + func emptyStateView(isAllTime: Bool) -> some View { VStack(spacing: 20) { Spacer().frame(height: 40) @@ -224,7 +440,7 @@ struct ProfileStatsView: View { .font(.title3.bold()) .foregroundColor(.appForegroundAdaptive) - Text(selectedPeriod == .all ? strings.startTrackingStats : strings.noActivityThisPeriod) + Text(isAllTime ? strings.startTrackingStats : strings.noActivityThisPeriod) .font(.subheadline) .foregroundColor(.appMutedForegroundAdaptive) .multilineTextAlignment(.center) @@ -236,165 +452,231 @@ struct ProfileStatsView: View { .frame(maxWidth: .infinity) } - // MARK: - Reset & Reload + // MARK: - Share - func resetAndReload() { - loadedHours = false - loadedGenres = false - loadedReviews = false - countStartTime = nil - comparisonHours = nil - - totalHours = 0 - movieHours = 0 - seriesHours = 0 - monthlyHours = [] - watchedGenres = [] - bestReviews = [] + func shareMonthStats(_ section: MonthSection) { + guard section.isLoaded else { return } + let image = renderShareCard(section: section) + let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil) + if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, + let root = scene.windows.first?.rootViewController { + root.present(activityVC, animated: true) + } + } - Task { await loadStats() } + @MainActor + func renderShareCard(section: MonthSection) -> UIImage { + let cardView = StatsShareCardView(section: section, strings: strings) + let controller = UIHostingController(rootView: cardView) + let size = CGSize(width: 390, height: 520) + controller.view.bounds = CGRect(origin: .zero, size: size) + controller.view.backgroundColor = .clear + controller.view.layoutIfNeeded() + + let renderer = UIGraphicsImageRenderer(size: size) + return renderer.image { _ in + controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) + } } - // MARK: - Load Stats - - func loadStats() async { - let period = selectedPeriod.rawValue - - if let cached = cache.get(userId: userId, period: period) { - totalHours = cached.totalHours - movieHours = cached.movieHours - seriesHours = cached.seriesHours - monthlyHours = cached.monthlyHours - watchedGenres = cached.watchedGenres - bestReviews = cached.bestReviews - loadedHours = true - loadedGenres = true - loadedReviews = true - hasStartedLoading = true - if totalHours > 0 { - countStartTime = .now - withAnimation(.linear(duration: countDuration)) { - animationTrigger.toggle() + // MARK: - Load More + + func loadMoreIfNeeded(index: Int) { + guard !isLoadingMore else { return } + let threshold = monthSections.count - 2 + if index >= threshold { + isLoadingMore = true + Task { + guard let last = monthSections.last else { return } + let prevYM = MonthSection.previousYearMonth(from: last.yearMonth) + if !monthSections.contains(where: { $0.yearMonth == prevYM }) { + let newSection = MonthSection(yearMonth: prevYM) + monthSections.append(newSection) + await loadMonthData(yearMonth: prevYM) } + isLoadingMore = false } - return } + } + // MARK: - Init Timeline + + func initTimeline() async { hasStartedLoading = true - error = nil + let current = MonthSection.currentYearMonth() + let prev = MonthSection.previousYearMonth(from: current) + + monthSections = [ + MonthSection(yearMonth: current), + MonthSection(yearMonth: prev), + ] + + await withTaskGroup(of: Void.self) { group in + group.addTask { await loadMonthData(yearMonth: current) } + group.addTask { await loadMonthData(yearMonth: prev) } + } + } + + // MARK: - Load Month Data + + @MainActor + func loadMonthData(yearMonth: String) async { + if let cached = cache.get(userId: userId, period: yearMonth) { + updateSection(yearMonth: yearMonth, cached: cached) + return + } + let language = Language.current.rawValue await withTaskGroup(of: Void.self) { group in group.addTask { @MainActor in do { - let response = try await UserStatsService.shared.getTotalHours(userId: userId, period: period) - withAnimation(.easeIn(duration: 0.25)) { - totalHours = response.totalHours - movieHours = response.movieHours - seriesHours = response.seriesHours - monthlyHours = response.monthlyHours - loadedHours = true - } - countStartTime = .now - withAnimation(.linear(duration: countDuration)) { - animationTrigger.toggle() + let response = try await UserStatsService.shared.getTotalHours(userId: userId, period: yearMonth) + if let idx = monthSections.firstIndex(where: { $0.yearMonth == yearMonth }) { + withAnimation(.easeIn(duration: 0.25)) { + monthSections[idx].totalHours = response.totalHours + monthSections[idx].movieHours = response.movieHours + monthSections[idx].seriesHours = response.seriesHours + monthSections[idx].monthlyHours = response.monthlyHours + } } - await loadComparisonHours(period: period) - - if !smartDefaultApplied { - smartDefaultApplied = true - applySmartDefault(currentTotalHours: response.totalHours) + let prevYM = MonthSection.previousYearMonth(from: yearMonth) + if let prevResponse = try? await UserStatsService.shared.getTotalHours(userId: userId, period: prevYM) { + if let idx = monthSections.firstIndex(where: { $0.yearMonth == yearMonth }) { + monthSections[idx].comparisonHours = prevResponse.totalHours + } } - } catch { - withAnimation { loadedHours = true } - } + } catch {} } group.addTask { @MainActor in do { - let genres = try await UserStatsService.shared.getWatchedGenres(userId: userId, language: language, period: period) - withAnimation(.easeIn(duration: 0.25)) { - watchedGenres = genres - loadedGenres = true + let genres = try await UserStatsService.shared.getWatchedGenres(userId: userId, language: language, period: yearMonth) + if let idx = monthSections.firstIndex(where: { $0.yearMonth == yearMonth }) { + withAnimation(.easeIn(duration: 0.25)) { + monthSections[idx].watchedGenres = genres + } } - } catch { - withAnimation { loadedGenres = true } - } + } catch {} } group.addTask { @MainActor in do { - let reviews = try await UserStatsService.shared.getBestReviews(userId: userId, language: language, period: period) - withAnimation(.easeIn(duration: 0.25)) { - bestReviews = reviews - loadedReviews = true + let reviews = try await UserStatsService.shared.getBestReviews(userId: userId, language: language, period: yearMonth) + if let idx = monthSections.firstIndex(where: { $0.yearMonth == yearMonth }) { + withAnimation(.easeIn(duration: 0.25)) { + monthSections[idx].bestReviews = reviews + } } - } catch { - withAnimation { loadedReviews = true } - } + } catch {} } - } - cache.set( - userId: userId, - period: period, - totalHours: totalHours, - movieHours: movieHours, - seriesHours: seriesHours, - monthlyHours: monthlyHours, - watchedGenres: watchedGenres, - bestReviews: bestReviews - ) + if let idx = monthSections.firstIndex(where: { $0.yearMonth == yearMonth }) { + withAnimation(.easeIn(duration: 0.25)) { + monthSections[idx].isLoaded = true + } + + let s = monthSections[idx] + cache.set( + userId: userId, + period: yearMonth, + totalHours: s.totalHours, + movieHours: s.movieHours, + seriesHours: s.seriesHours, + monthlyHours: s.monthlyHours, + watchedGenres: s.watchedGenres, + bestReviews: s.bestReviews + ) + } } - // MARK: - Comparison Hours + // MARK: - Load All-Time - @MainActor - func loadComparisonHours(period: String) async { - let comparisonPeriod: String? - switch period { - case "month": comparisonPeriod = "last_month" - default: comparisonPeriod = nil - } + func loadAllTime() async { + hasStartedLoading = true - guard let compPeriod = comparisonPeriod else { - comparisonHours = nil + if let cached = cache.get(userId: userId, period: "all") { + allTimeSection = MonthSection( + yearMonth: "all", + totalHours: cached.totalHours, + movieHours: cached.movieHours, + seriesHours: cached.seriesHours, + monthlyHours: cached.monthlyHours, + watchedGenres: cached.watchedGenres, + bestReviews: cached.bestReviews, + isLoaded: true + ) return } - do { - let response = try await UserStatsService.shared.getTotalHours(userId: userId, period: compPeriod) - comparisonHours = response.totalHours - } catch { - comparisonHours = nil - } - } - - // MARK: - Smart Default + let language = Language.current.rawValue - @MainActor - func applySmartDefault(currentTotalHours: Double) { - if selectedPeriod != .month { return } + await withTaskGroup(of: Void.self) { group in + group.addTask { @MainActor in + do { + let response = try await UserStatsService.shared.getTotalHours(userId: userId, period: "all") + withAnimation(.easeIn(duration: 0.25)) { + allTimeSection.totalHours = response.totalHours + allTimeSection.movieHours = response.movieHours + allTimeSection.seriesHours = response.seriesHours + allTimeSection.monthlyHours = response.monthlyHours + } + } catch {} + } - if currentTotalHours == 0 { - Task { - let allTimeResponse = try? await UserStatsService.shared.getTotalHours(userId: userId, period: "all") - let allTimeHours = allTimeResponse?.totalHours ?? 0 + group.addTask { @MainActor in + do { + let genres = try await UserStatsService.shared.getWatchedGenres(userId: userId, language: language, period: "all") + withAnimation(.easeIn(duration: 0.25)) { + allTimeSection.watchedGenres = genres + } + } catch {} + } - if allTimeHours == 0 { - selectedPeriod = .all - } else { - let lastMonthResponse = try? await UserStatsService.shared.getTotalHours(userId: userId, period: "last_month") - if (lastMonthResponse?.totalHours ?? 0) > 0 { - selectedPeriod = .lastMonth - } else { - selectedPeriod = .all + group.addTask { @MainActor in + do { + let reviews = try await UserStatsService.shared.getBestReviews(userId: userId, language: language, period: "all") + withAnimation(.easeIn(duration: 0.25)) { + allTimeSection.bestReviews = reviews } - } + } catch {} } } + + withAnimation(.easeIn(duration: 0.25)) { + allTimeSection.isLoaded = true + } + + cache.set( + userId: userId, + period: "all", + totalHours: allTimeSection.totalHours, + movieHours: allTimeSection.movieHours, + seriesHours: allTimeSection.seriesHours, + monthlyHours: allTimeSection.monthlyHours, + watchedGenres: allTimeSection.watchedGenres, + bestReviews: allTimeSection.bestReviews + ) + } + + // MARK: - Update Section from Cache + + @MainActor + func updateSection( + yearMonth: String, + cached: (totalHours: Double, movieHours: Double, seriesHours: Double, monthlyHours: [MonthlyHoursEntry], watchedGenres: [WatchedGenre], bestReviews: [BestReview]) + ) { + if let idx = monthSections.firstIndex(where: { $0.yearMonth == yearMonth }) { + monthSections[idx].totalHours = cached.totalHours + monthSections[idx].movieHours = cached.movieHours + monthSections[idx].seriesHours = cached.seriesHours + monthSections[idx].monthlyHours = cached.monthlyHours + monthSections[idx].watchedGenres = cached.watchedGenres + monthSections[idx].bestReviews = cached.bestReviews + monthSections[idx].isLoaded = true + } } } From f35904c138d879f04b5f6283ca72cfe8646141cb Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 14:11:19 -0300 Subject: [PATCH 03/16] refactor: update StatsShareCardView layout and rendering for improved UI - Enhanced the layout of StatsShareCardView to utilize a new card size and updated gradient background for better visual appeal. - Adjusted text sizes and spacing for improved readability and consistency across different screen sizes. - Refactored the rendering logic in ProfileStatsView to accommodate the new card dimensions, ensuring high-quality image generation for sharing. - Improved accessibility by refining color contrasts and text legibility. These changes aim to create a more engaging and visually appealing user experience in the stats section. --- .../Views/Home/ProfileStatsSections.swift | 217 ++++++++++-------- .../Views/Home/ProfileStatsView.swift | 12 +- 2 files changed, 132 insertions(+), 97 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index f4f85123..77681654 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -695,143 +695,170 @@ struct ShimmerEffect: ViewModifier { } } -// MARK: - Stats Share Card +// MARK: - Stats Share Card (Stories 9:16) struct StatsShareCardView: View { let section: MonthSection let strings: Strings - private let gradientStart = Color(hex: "0F172A") - private let gradientEnd = Color(hex: "1E293B") + private let cardWidth: CGFloat = 1080 / 3 + private let cardHeight: CGFloat = 1920 / 3 + private let accentBlue = Color(hex: "3B82F6") private let accentGreen = Color(hex: "10B981") var body: some View { - VStack(spacing: 0) { - VStack(alignment: .leading, spacing: 24) { - // Header - HStack { - VStack(alignment: .leading, spacing: 4) { - Text(section.displayName.uppercased()) - .font(.system(size: 13, weight: .bold)) - .tracking(2) - .foregroundColor(accentBlue) - - Text(strings.myMonthInReview) - .font(.system(size: 22, weight: .bold)) - .foregroundColor(.white) - } + ZStack { + // Background gradient + LinearGradient( + stops: [ + .init(color: Color(hex: "000000"), location: 0), + .init(color: Color(hex: "0A0A0A"), location: 0.3), + .init(color: Color(hex: "141414"), location: 0.6), + .init(color: Color(hex: "1A1A1A"), location: 1), + ], + startPoint: .top, + endPoint: .bottom + ) - Spacer() - } + VStack(spacing: 0) { + // Top section: month + time + VStack(alignment: .leading, spacing: 16) { + Text(section.displayName.uppercased()) + .font(.system(size: 11, weight: .bold)) + .tracking(2.5) + .foregroundColor(accentBlue) - // Big number - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(formatTotalMinutes(section.totalHours)) - .font(.system(size: 64, weight: .heavy, design: .rounded)) - .foregroundColor(.white) + Text(strings.myMonthInReview) + .font(.system(size: 20, weight: .bold)) + .foregroundColor(.white) - Text(strings.minutes) - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.white.opacity(0.6)) - } + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 6) { + Text(formatTotalMinutes(section.totalHours)) + .font(.system(size: 52, weight: .heavy, design: .rounded)) + .foregroundColor(.white) - if section.movieHours > 0 || section.seriesHours > 0 { - HStack(spacing: 16) { - HStack(spacing: 6) { - Circle() - .fill(accentBlue) - .frame(width: 8, height: 8) - Text("\(strings.movies) \(formatTotalMinutes(section.movieHours))m") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white.opacity(0.7)) - } + Text(strings.minutes) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + } - HStack(spacing: 6) { - Circle() - .fill(accentGreen) - .frame(width: 8, height: 8) - Text("\(strings.series) \(formatTotalMinutes(section.seriesHours))m") - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white.opacity(0.7)) + if section.movieHours > 0 || section.seriesHours > 0 { + HStack(spacing: 14) { + HStack(spacing: 5) { + Circle().fill(accentBlue).frame(width: 6, height: 6) + Text("\(strings.movies) \(formatTotalMinutes(section.movieHours))m") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + } + HStack(spacing: 5) { + Circle().fill(accentGreen).frame(width: 6, height: 6) + Text("\(strings.series) \(formatTotalMinutes(section.seriesHours))m") + .font(.system(size: 11, weight: .medium)) + .foregroundColor(.white.opacity(0.5)) + } } } } } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 24) + .padding(.top, 48) - Divider() - .background(Color.white.opacity(0.1)) + Spacer().frame(height: 24) - // Stats grid - HStack(spacing: 0) { + // Posters section + HStack(alignment: .top, spacing: 12) { + // Genre poster if let genre = section.watchedGenres.first { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { + sharePoster(url: genre.posterURL) + Text(strings.favoriteGenre.uppercased()) - .font(.system(size: 10, weight: .bold)) - .tracking(1.2) - .foregroundColor(.white.opacity(0.4)) + .font(.system(size: 9, weight: .bold)) + .tracking(1) + .foregroundColor(.white.opacity(0.35)) Text(genre.name) - .font(.system(size: 17, weight: .bold)) + .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) .lineLimit(1) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity) } + // Review poster if let review = section.bestReviews.first { - VStack(alignment: .leading, spacing: 6) { + VStack(alignment: .leading, spacing: 8) { + sharePoster(url: review.posterURL, rating: review.rating) + Text(strings.bestReview.uppercased()) - .font(.system(size: 10, weight: .bold)) - .tracking(1.2) - .foregroundColor(.white.opacity(0.4)) - - HStack(spacing: 4) { - Image(systemName: "star.fill") - .font(.system(size: 12)) - .foregroundColor(Color(hex: "F59E0B")) - Text(String(format: "%.1f", review.rating)) - .font(.system(size: 17, weight: .bold)) - .foregroundColor(.white) - } + .font(.system(size: 9, weight: .bold)) + .tracking(1) + .foregroundColor(.white.opacity(0.35)) Text(review.title) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.white.opacity(0.7)) + .font(.system(size: 14, weight: .bold)) + .foregroundColor(.white) .lineLimit(1) } - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity) } } - } - .padding(28) + .padding(.horizontal, 24) - Spacer() + Spacer() - // Footer branding - HStack { - Text("plotwist") - .font(.system(size: 15, weight: .bold, design: .rounded)) - .foregroundColor(.white.opacity(0.3)) + // Footer + HStack { + Text("plotwist") + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundColor(.white.opacity(0.25)) - Spacer() + Spacer() - Text("plotwist.app") - .font(.system(size: 12, weight: .medium)) - .foregroundColor(.white.opacity(0.25)) + 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 sharePoster(url: URL?, rating: Double? = nil) -> some View { + let cr: CGFloat = 12 + + CachedAsyncImage(url: url) { image in + image + .resizable() + .aspectRatio(2 / 3, contentMode: .fit) + } placeholder: { + RoundedRectangle(cornerRadius: cr) + .fill(Color.white.opacity(0.08)) + .aspectRatio(2 / 3, contentMode: .fit) + } + .clipShape(RoundedRectangle(cornerRadius: cr)) + .overlay(alignment: .bottomTrailing) { + 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(.ultraThinMaterial.opacity(0.9)) + .clipShape(RoundedRectangle(cornerRadius: cr * 0.5)) + .padding(6) } - .padding(.horizontal, 28) - .padding(.bottom, 20) } - .frame(width: 390, height: 520) - .background( - LinearGradient( - colors: [gradientStart, gradientEnd], - startPoint: .topLeading, - endPoint: .bottomTrailing - ) - ) - .clipShape(RoundedRectangle(cornerRadius: 24)) } } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index fc004656..cc5fc12e 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -468,12 +468,20 @@ struct ProfileStatsView: View { func renderShareCard(section: MonthSection) -> UIImage { let cardView = StatsShareCardView(section: section, strings: strings) let controller = UIHostingController(rootView: cardView) - let size = CGSize(width: 390, height: 520) + let size = CGSize(width: 1080 / 3, height: 1920 / 3) controller.view.bounds = CGRect(origin: .zero, size: size) controller.view.backgroundColor = .clear controller.view.layoutIfNeeded() - let renderer = UIGraphicsImageRenderer(size: size) + let scale: CGFloat = 3.0 + let renderer = UIGraphicsImageRenderer( + size: size, + format: { + let f = UIGraphicsImageRendererFormat() + f.scale = scale + return f + }() + ) return renderer.image { _ in controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) } From 3b6c4b5a2b595b2bf38e00629d4e8c6e435b63be Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 14:18:20 -0300 Subject: [PATCH 04/16] feat: improve stats timeline UX and share card - Fix floating scrubber: use absolute drag offset from start index instead of cumulative reset, ensuring reliable month navigation - Localize month names in scrubber and share card using app language - Pre-download poster images before rendering share card snapshot - Use PlotistLogo asset in share card footer instead of text label - Darken share card gradient background for better contrast - Stories format (9:16) share card with genre and review posters - Lighten statsCardBackground in light theme for subtler contrast - Keep cards at 50% width when only one of genre/review exists Co-authored-by: Cursor --- apps/ios/Plotwist/Plotwist/Theme/Colors.swift | 2 +- .../Views/Home/ProfileStatsSections.swift | 79 ++++---- .../Views/Home/ProfileStatsView.swift | 174 ++++++++++-------- 3 files changed, 138 insertions(+), 117 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Theme/Colors.swift b/apps/ios/Plotwist/Plotwist/Theme/Colors.swift index c9312c2c..6cc692bd 100644 --- a/apps/ios/Plotwist/Plotwist/Theme/Colors.swift +++ b/apps/ios/Plotwist/Plotwist/Theme/Colors.swift @@ -116,7 +116,7 @@ extension Color { UIColor { $0.userInterfaceStyle == .dark ? UIColor(hue: 240 / 360, saturation: 0.037, brightness: 0.159, alpha: 0.4) - : UIColor(red: 242 / 255, green: 242 / 255, blue: 247 / 255, alpha: 1) + : UIColor(red: 246 / 255, green: 246 / 255, blue: 249 / 255, alpha: 1) }) } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index 77681654..002bacc0 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -700,6 +700,8 @@ struct ShimmerEffect: ViewModifier { 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 @@ -709,20 +711,18 @@ struct StatsShareCardView: View { var body: some View { ZStack { - // Background gradient LinearGradient( stops: [ .init(color: Color(hex: "000000"), location: 0), - .init(color: Color(hex: "0A0A0A"), location: 0.3), - .init(color: Color(hex: "141414"), location: 0.6), - .init(color: Color(hex: "1A1A1A"), location: 1), + .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) { - // Top section: month + time VStack(alignment: .leading, spacing: 16) { Text(section.displayName.uppercased()) .font(.system(size: 11, weight: .bold)) @@ -768,18 +768,15 @@ struct StatsShareCardView: View { Spacer().frame(height: 24) - // Posters section + // Posters HStack(alignment: .top, spacing: 12) { - // Genre poster if let genre = section.watchedGenres.first { VStack(alignment: .leading, spacing: 8) { - sharePoster(url: genre.posterURL) - + shareCardPoster(image: genrePosterImage) Text(strings.favoriteGenre.uppercased()) .font(.system(size: 9, weight: .bold)) .tracking(1) .foregroundColor(.white.opacity(0.35)) - Text(genre.name) .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) @@ -788,16 +785,13 @@ struct StatsShareCardView: View { .frame(maxWidth: .infinity) } - // Review poster if let review = section.bestReviews.first { VStack(alignment: .leading, spacing: 8) { - sharePoster(url: review.posterURL, rating: review.rating) - + shareCardPoster(image: reviewPosterImage, rating: review.rating) Text(strings.bestReview.uppercased()) .font(.system(size: 9, weight: .bold)) .tracking(1) .foregroundColor(.white.opacity(0.35)) - Text(review.title) .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) @@ -810,11 +804,13 @@ struct StatsShareCardView: View { Spacer() - // Footer + // Footer with logo HStack { - Text("plotwist") - .font(.system(size: 14, weight: .bold, design: .rounded)) - .foregroundColor(.white.opacity(0.25)) + Image("PlotistLogo") + .resizable() + .aspectRatio(contentMode: .fit) + .frame(height: 22) + .opacity(0.35) Spacer() @@ -830,35 +826,40 @@ struct StatsShareCardView: View { } @ViewBuilder - func sharePoster(url: URL?, rating: Double? = nil) -> some View { + func shareCardPoster(image: UIImage?, rating: Double? = nil) -> some View { let cr: CGFloat = 12 - CachedAsyncImage(url: url) { image in - image + if let uiImage = image { + Image(uiImage: uiImage) .resizable() .aspectRatio(2 / 3, contentMode: .fit) - } placeholder: { + .clipShape(RoundedRectangle(cornerRadius: cr)) + .overlay(alignment: .bottomTrailing) { + ratingBadge(rating: rating, cornerRadius: cr) + } + } else { RoundedRectangle(cornerRadius: cr) - .fill(Color.white.opacity(0.08)) + .fill(Color.white.opacity(0.06)) .aspectRatio(2 / 3, contentMode: .fit) } - .clipShape(RoundedRectangle(cornerRadius: cr)) - .overlay(alignment: .bottomTrailing) { - 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(.ultraThinMaterial.opacity(0.9)) - .clipShape(RoundedRectangle(cornerRadius: cr * 0.5)) - .padding(6) + } + + @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/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index cc5fc12e..5b951b13 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -20,8 +20,14 @@ struct MonthSection: Identifiable, Equatable { var id: String { yearMonth } + private static var appLocale: Locale { + let langId = Language.current.rawValue.replacingOccurrences(of: "-", with: "_") + return Locale(identifier: langId) + } + var displayName: String { let formatter = DateFormatter() + formatter.locale = Self.appLocale formatter.dateFormat = "yyyy-MM" guard let date = formatter.date(from: yearMonth) else { return yearMonth } formatter.dateFormat = "MMMM yyyy" @@ -31,6 +37,7 @@ struct MonthSection: Identifiable, Equatable { var shortLabel: String { let formatter = DateFormatter() + formatter.locale = Self.appLocale formatter.dateFormat = "yyyy-MM" guard let date = formatter.date(from: yearMonth) else { return yearMonth } formatter.dateFormat = "MMM" @@ -101,6 +108,7 @@ struct ProfileStatsView: View { @State var visibleYearMonth: String? @State var isDraggingScrubber = false @State var scrubberDragMonth: String? + @State var scrubberStartIndex: Int = 0 // All-time state @State var allTimeSection = MonthSection(yearMonth: "all") @@ -217,6 +225,8 @@ struct ProfileStatsView: View { .overlay(alignment: .trailing) { if monthSections.count > 2 { monthScrubber(proxy: proxy) + .frame(maxHeight: .infinity, alignment: .center) + .padding(.trailing, 4) } } } @@ -339,81 +349,66 @@ struct ProfileStatsView: View { // MARK: - Month Scrubber (floating handle) func monthScrubber(proxy: ScrollViewProxy) -> some View { - let activeYM = scrubberDragMonth ?? visibleYearMonth ?? monthSections.first?.yearMonth ?? "" - let activeIndex = monthSections.firstIndex(where: { $0.yearMonth == activeYM }) ?? 0 - - return GeometryReader { geo in - let trackHeight = geo.size.height - let count = max(monthSections.count, 1) - let segmentHeight = trackHeight / CGFloat(count) - let handleY = CGFloat(activeIndex) * segmentHeight + segmentHeight / 2 - - HStack(spacing: 0) { - Spacer() - - ZStack(alignment: .topTrailing) { - // Floating month label (appears while dragging) - if isDraggingScrubber, let dragYM = scrubberDragMonth, - let section = monthSections.first(where: { $0.yearMonth == dragYM }) { - HStack(spacing: 6) { - Text(section.displayName) - .font(.system(size: 14, weight: .bold)) - .foregroundColor(.white) + let displayYM = scrubberDragMonth ?? visibleYearMonth ?? monthSections.first?.yearMonth ?? "" + let displaySection = monthSections.first(where: { $0.yearMonth == displayYM }) + + return HStack(spacing: 8) { + // Month label (appears while dragging) + if isDraggingScrubber, let section = displaySection { + Text(section.shortLabel) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundColor(.white) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(Color.appForegroundAdaptive) + .clipShape(Capsule()) + .transition(.opacity.combined(with: .move(edge: .trailing))) + } + + // Handle pill + VStack(spacing: 1) { + Image(systemName: "chevron.up") + .font(.system(size: 8, weight: .bold)) + Image(systemName: "chevron.down") + .font(.system(size: 8, weight: .bold)) + } + .foregroundColor(isDraggingScrubber ? .white : .appMutedForegroundAdaptive) + .frame(width: 24, height: 32) + .background(isDraggingScrubber ? Color.appForegroundAdaptive : Color.statsCardBackground) + .clipShape(Capsule()) + .shadow(color: Color.black.opacity(0.12), radius: 3, x: 0, y: 1) + } + .gesture( + DragGesture(minimumDistance: 2) + .onChanged { value in + if !isDraggingScrubber { + withAnimation(.easeOut(duration: 0.15)) { + isDraggingScrubber = true } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(Color.appForegroundAdaptive) - .clipShape(Capsule()) - .offset(y: handleY - 18) - .padding(.trailing, 48) - .transition(.opacity.combined(with: .scale(scale: 0.8))) - .animation(.easeOut(duration: 0.15), value: dragYM) + scrubberStartIndex = monthSections.firstIndex(where: { $0.yearMonth == (visibleYearMonth ?? "") }) ?? 0 + scrubberDragMonth = visibleYearMonth } - // Handle - VStack(spacing: 1) { - Image(systemName: "chevron.up") - .font(.system(size: 8, weight: .bold)) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) + let threshold: CGFloat = 50 + let steps = Int(value.translation.height / threshold) + let targetIdx = max(0, min(scrubberStartIndex + steps, monthSections.count - 1)) + let targetYM = monthSections[targetIdx].yearMonth + + if scrubberDragMonth != targetYM { + scrubberDragMonth = targetYM + let generator = UIImpactFeedbackGenerator(style: .light) + generator.impactOccurred() + proxy.scrollTo(targetYM, anchor: .top) } - .foregroundColor(isDraggingScrubber ? .white : .appMutedForegroundAdaptive) - .frame(width: 28, height: 36) - .background(isDraggingScrubber ? Color.appForegroundAdaptive : Color.statsCardBackground) - .clipShape(Capsule()) - .shadow(color: Color.black.opacity(0.15), radius: 4, x: 0, y: 2) - .offset(y: handleY - 18) - .animation(.interactiveSpring(response: 0.25, dampingFraction: 0.8), value: activeIndex) - .gesture( - DragGesture(minimumDistance: 0) - .onChanged { value in - if !isDraggingScrubber { - isDraggingScrubber = true - } - let y = min(max(value.location.y + handleY - 18, 0), trackHeight) - let idx = max(0, min(Int(y / segmentHeight), count - 1)) - let targetYM = monthSections[idx].yearMonth - if scrubberDragMonth != targetYM { - scrubberDragMonth = targetYM - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() - proxy.scrollTo(targetYM, anchor: .top) - } - } - .onEnded { _ in - withAnimation(.easeOut(duration: 0.25)) { - isDraggingScrubber = false - } - scrubberDragMonth = nil - } - ) } - .frame(width: 28) - } - } - .padding(.vertical, 80) - .padding(.trailing, 6) - .allowsHitTesting(true) + .onEnded { _ in + withAnimation(.easeOut(duration: 0.2)) { + isDraggingScrubber = false + } + scrubberDragMonth = nil + } + ) + .animation(.easeOut(duration: 0.15), value: isDraggingScrubber) } // MARK: - Skeleton @@ -456,17 +451,42 @@ struct ProfileStatsView: View { func shareMonthStats(_ section: MonthSection) { guard section.isLoaded else { return } - let image = renderShareCard(section: section) - let activityVC = UIActivityViewController(activityItems: [image], applicationActivities: nil) - if let scene = UIApplication.shared.connectedScenes.first as? UIWindowScene, - let root = scene.windows.first?.rootViewController { - root.present(activityVC, animated: true) + Task { + let genreImage = await downloadImage(url: section.watchedGenres.first?.posterURL) + let reviewImage = await downloadImage(url: section.bestReviews.first?.posterURL) + + 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) + } + } + } + + 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 } } @MainActor - func renderShareCard(section: MonthSection) -> UIImage { - let cardView = StatsShareCardView(section: section, strings: strings) + 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) From 8840d34f386e09cc90434e40142c7aa8998b46a0 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 14:50:18 -0300 Subject: [PATCH 05/16] feat: enhance ProfileStatsView and related components for improved UX - Added checks for empty genre lists to conditionally display UI elements in topGenreCard. - Updated layout and styling in ProfileStatsView to enhance readability and visual appeal. - Introduced new text elements for period labels in various views for better context. - Refactored monthContentView and highlightCards functions to streamline logic and improve performance. - Adjusted padding and frame settings for better alignment and responsiveness. These changes aim to create a more engaging and user-friendly experience in the stats section. --- .../Views/Home/ProfileStatsSections.swift | 28 ++- .../Views/Home/ProfileStatsView.swift | 227 ++++++++---------- .../Views/Reviews/ReviewSectionView.swift | 1 + 3 files changed, 119 insertions(+), 137 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index 002bacc0..cd243a65 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -117,6 +117,7 @@ extension ProfileStatsView { extension ProfileStatsView { func topGenreCard(for section: MonthSection) -> some View { let topGenre = section.watchedGenres.first + let hasGenres = !section.watchedGenres.isEmpty return NavigationLink { PeriodGenresView(genres: section.watchedGenres, periodLabel: section.yearMonth == "all" ? strings.allTime : section.displayName, strings: strings) @@ -128,9 +129,11 @@ extension ProfileStatsView { .tracking(1.5) .foregroundColor(.appMutedForegroundAdaptive) Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.appMutedForegroundAdaptive) + if hasGenres { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + } } .padding(.bottom, 14) @@ -144,14 +147,19 @@ extension ProfileStatsView { if let posterURL = genre.posterURL { statsPoster(url: posterURL) } + } else { + Text("–") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.appMutedForegroundAdaptive) } } .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color.statsCardBackground) .clipShape(RoundedRectangle(cornerRadius: 22)) } .buttonStyle(.plain) + .disabled(!hasGenres) } } @@ -231,6 +239,10 @@ struct TimeWatchedDetailView: View { scrollOffsetReader .frame(height: 0) + Text(periodLabel) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + Text(strings.timeWatched) .font(.system(size: 34, weight: .bold)) .foregroundColor(.appForegroundAdaptive) @@ -473,6 +485,10 @@ struct PeriodGenresView: View { scrollOffsetReader .frame(height: 0) + Text(periodLabel) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + Text(strings.favoriteGenres) .font(.system(size: 34, weight: .bold)) .foregroundColor(.appForegroundAdaptive) @@ -567,6 +583,10 @@ struct PeriodReviewsView: View { scrollOffsetReader .frame(height: 0) + Text(periodLabel) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + Text(strings.bestReviews) .font(.system(size: 34, weight: .bold)) .foregroundColor(.appForegroundAdaptive) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index 5b951b13..b59d629a 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -105,10 +105,7 @@ struct ProfileStatsView: View { // Timeline state @State var monthSections: [MonthSection] = [] @State var isLoadingMore = false - @State var visibleYearMonth: String? - @State var isDraggingScrubber = false - @State var scrubberDragMonth: String? - @State var scrubberStartIndex: Int = 0 + // All-time state @State var allTimeSection = MonthSection(yearMonth: "all") @@ -183,10 +180,10 @@ struct ProfileStatsView: View { // MARK: - Timeline Body var timelineBody: some View { - ScrollViewReader { proxy in - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - ForEach(Array(monthSections.enumerated()), id: \.element.id) { index, section in + ScrollView { + LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { + ForEach(Array(monthSections.enumerated()), id: \.element.id) { index, section in + if !section.isLoaded || section.hasMinimumData { Section { monthContentView(section) .padding(.horizontal, 24) @@ -194,41 +191,32 @@ struct ProfileStatsView: View { } header: { monthHeaderView(section) } - .id(section.yearMonth) .onAppear { - visibleYearMonth = section.yearMonth loadMoreIfNeeded(index: index) } } + } - if isLoadingMore { - VStack(spacing: 16) { - skeletonRect(height: 120) - HStack(spacing: 12) { - skeletonRect(height: 200) - skeletonRect(height: 200) - } + if isLoadingMore { + VStack(spacing: 16) { + skeletonRect(height: 120) + HStack(spacing: 12) { + skeletonRect(height: 200) + skeletonRect(height: 200) } - .padding(.horizontal, 24) - .padding(.bottom, 48) } + .padding(.horizontal, 24) + .padding(.bottom, 48) } - .padding(.top, 8) - } - .refreshable { - for section in monthSections { - cache.invalidate(userId: userId, period: section.yearMonth) - } - monthSections = [] - await initTimeline() } - .overlay(alignment: .trailing) { - if monthSections.count > 2 { - monthScrubber(proxy: proxy) - .frame(maxHeight: .infinity, alignment: .center) - .padding(.trailing, 4) - } + .padding(.top, 8) + } + .refreshable { + for section in monthSections { + cache.invalidate(userId: userId, period: section.yearMonth) } + monthSections = [] + await initTimeline() } } @@ -274,9 +262,7 @@ struct ProfileStatsView: View { @ViewBuilder func monthContentView(_ section: MonthSection) -> some View { - if section.isLoaded && !section.hasMinimumData { - emptyStateView(isAllTime: false) - } else if section.isLoaded { + if section.isLoaded { VStack(spacing: 16) { timeWatchedCard(for: section, period: section.yearMonth) .transition(.opacity.animation(.easeIn(duration: 0.25))) @@ -298,24 +284,17 @@ struct ProfileStatsView: View { @ViewBuilder func highlightCards(for section: MonthSection) -> some View { - let hasGenre = !section.watchedGenres.isEmpty let hasReview = !section.bestReviews.isEmpty - if hasGenre || hasReview { - HStack(alignment: .top, spacing: 12) { - if hasGenre { - topGenreCard(for: section) - .transition(.opacity.animation(.easeIn(duration: 0.25))) - } else { - Color.clear - } + HStack(alignment: .top, spacing: 12) { + topGenreCard(for: section) + .transition(.opacity.animation(.easeIn(duration: 0.25))) - if hasReview { - topReviewCard(for: section) - .transition(.opacity.animation(.easeIn(duration: 0.25))) - } else { - Color.clear - } + if hasReview { + topReviewCard(for: section) + .transition(.opacity.animation(.easeIn(duration: 0.25))) + } else { + Color.clear } } } @@ -334,11 +313,8 @@ struct ProfileStatsView: View { shareMonthStats(section) } label: { Image(systemName: "square.and.arrow.up") - .font(.system(size: 14, weight: .medium)) + .font(.system(size: 16, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) - .frame(width: 32, height: 32) - .background(Color.appInputFilled) - .clipShape(Circle()) } } .padding(.horizontal, 24) @@ -346,76 +322,11 @@ struct ProfileStatsView: View { .background(Color.appBackgroundAdaptive) } - // MARK: - Month Scrubber (floating handle) - - func monthScrubber(proxy: ScrollViewProxy) -> some View { - let displayYM = scrubberDragMonth ?? visibleYearMonth ?? monthSections.first?.yearMonth ?? "" - let displaySection = monthSections.first(where: { $0.yearMonth == displayYM }) - - return HStack(spacing: 8) { - // Month label (appears while dragging) - if isDraggingScrubber, let section = displaySection { - Text(section.shortLabel) - .font(.system(size: 12, weight: .bold, design: .rounded)) - .foregroundColor(.white) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(Color.appForegroundAdaptive) - .clipShape(Capsule()) - .transition(.opacity.combined(with: .move(edge: .trailing))) - } - - // Handle pill - VStack(spacing: 1) { - Image(systemName: "chevron.up") - .font(.system(size: 8, weight: .bold)) - Image(systemName: "chevron.down") - .font(.system(size: 8, weight: .bold)) - } - .foregroundColor(isDraggingScrubber ? .white : .appMutedForegroundAdaptive) - .frame(width: 24, height: 32) - .background(isDraggingScrubber ? Color.appForegroundAdaptive : Color.statsCardBackground) - .clipShape(Capsule()) - .shadow(color: Color.black.opacity(0.12), radius: 3, x: 0, y: 1) - } - .gesture( - DragGesture(minimumDistance: 2) - .onChanged { value in - if !isDraggingScrubber { - withAnimation(.easeOut(duration: 0.15)) { - isDraggingScrubber = true - } - scrubberStartIndex = monthSections.firstIndex(where: { $0.yearMonth == (visibleYearMonth ?? "") }) ?? 0 - scrubberDragMonth = visibleYearMonth - } - - let threshold: CGFloat = 50 - let steps = Int(value.translation.height / threshold) - let targetIdx = max(0, min(scrubberStartIndex + steps, monthSections.count - 1)) - let targetYM = monthSections[targetIdx].yearMonth - - if scrubberDragMonth != targetYM { - scrubberDragMonth = targetYM - let generator = UIImpactFeedbackGenerator(style: .light) - generator.impactOccurred() - proxy.scrollTo(targetYM, anchor: .top) - } - } - .onEnded { _ in - withAnimation(.easeOut(duration: 0.2)) { - isDraggingScrubber = false - } - scrubberDragMonth = nil - } - ) - .animation(.easeOut(duration: 0.15), value: isDraggingScrubber) - } - // MARK: - Skeleton func skeletonRect(height: CGFloat) -> some View { RoundedRectangle(cornerRadius: 22) - .fill(Color.appBorderAdaptive.opacity(0.15)) + .fill(Color.appBorderAdaptive.opacity(0.5)) .frame(height: height) .modifier(ShimmerEffect()) } @@ -509,22 +420,53 @@ struct ProfileStatsView: View { // MARK: - Load More + private let batchSize = 3 + func loadMoreIfNeeded(index: Int) { guard !isLoadingMore else { return } - let threshold = monthSections.count - 2 - if index >= threshold { + if index >= monthSections.count - 3 { isLoadingMore = true Task { - guard let last = monthSections.last else { return } - let prevYM = MonthSection.previousYearMonth(from: last.yearMonth) - if !monthSections.contains(where: { $0.yearMonth == prevYM }) { - let newSection = MonthSection(yearMonth: prevYM) - monthSections.append(newSection) - await loadMonthData(yearMonth: prevYM) - } + await loadNextBatch() isLoadingMore = false + + let lastVisibleIndex = monthSections.enumerated() + .filter { !$0.element.isLoaded || $0.element.hasMinimumData } + .last?.offset ?? 0 + loadMoreIfNeeded(index: lastVisibleIndex) + } + } + } + + private func loadNextBatch() async { + guard let last = monthSections.last else { return } + + var newMonths: [String] = [] + var ym = last.yearMonth + for _ in 0.. Date: Wed, 18 Feb 2026 15:03:45 -0300 Subject: [PATCH 06/16] feat: conditionally display share button in ProfileStatsView for own profiles - Added a check to only show the share button when viewing one's own profile, enhancing user experience and preventing unnecessary options for other profiles. - Maintained existing styling and functionality for the share button to ensure consistency across the UI. These changes aim to streamline the user interface and improve the relevance of available actions in the ProfileStatsView. --- .../Plotwist/Views/Home/ProfileStatsView.swift | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index b59d629a..6e8f9ee2 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -309,12 +309,14 @@ struct ProfileStatsView: View { Spacer() - Button { - shareMonthStats(section) - } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) + if isOwnProfile { + Button { + shareMonthStats(section) + } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + } } } .padding(.horizontal, 24) From 2d254765c17d3c4f4e9d0e5d836fd9e9bfdc5141 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 16:52:46 -0300 Subject: [PATCH 07/16] feat: implement user stats timeline service and UI components - Introduced a new service to fetch user stats timeline, allowing for paginated monthly sections of viewing data. - Updated ProfileStatsView and related components to display timeline data, enhancing user engagement with visual summaries of total hours, top genres, and reviews. - Added new query schema and response structure for timeline data, improving data handling and user experience. - Refactored existing components for better organization and readability, ensuring a cohesive integration of the new timeline feature. These changes aim to provide users with a more comprehensive and engaging overview of their viewing habits over time. --- .../user-episodes/get-user-episodes.ts | 16 +- .../domain/services/user-stats/cache-utils.ts | 5 +- .../user-stats/get-user-stats-timeline.ts | 119 +++++ .../user-stats/get-user-total-hours.ts | 48 +- .../user-stats/get-user-watched-genres.ts | 43 +- .../src/http/controllers/user-stats.ts | 23 + apps/backend/src/http/routes/user-stats.ts | 19 + apps/backend/src/http/schemas/common.ts | 12 + apps/backend/src/http/schemas/user-stats.ts | 28 + .../Plotwist/Services/UserStatsService.swift | 71 +++ .../Views/Home/ProfileStatsHelpers.swift | 82 ++- .../Views/Home/ProfileStatsSections.swift | 503 +++++++++++------- .../Views/Home/ProfileStatsView.swift | 497 ++++++----------- 13 files changed, 861 insertions(+), 605 deletions(-) create mode 100644 apps/backend/src/domain/services/user-stats/get-user-stats-timeline.ts 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 b8fcadca..64f969e5 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 '@/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 58526ff7..0f0f18d3 100644 --- a/apps/backend/src/domain/services/user-stats/cache-utils.ts +++ b/apps/backend/src/domain/services/user-stats/cache-utils.ts @@ -20,8 +20,11 @@ export async function getCachedStats( const result = await computeFn() + const yearMonthPattern = /:\d{4}-\d{2}$/ const isPeriodScoped = - cacheKey.endsWith(':month') || cacheKey.endsWith(':last_month') + cacheKey.endsWith(':month') || + cacheKey.endsWith(':last_month') || + yearMonthPattern.test(cacheKey) const ttl = isPeriodScoped ? STATS_CACHE_SHORT_TTL_SECONDS : STATS_CACHE_TTL_SECONDS 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 00000000..9e0df4ed --- /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 8d5f0a19..60611a5e 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 @@ -14,15 +14,27 @@ export async function getUserTotalHoursService( period: StatsPeriod = 'all', dateRange: DateRange = { startDate: undefined, endDate: undefined } ) { - const cacheKey = getUserStatsCacheKey(userId, 'total-hours-v2', undefined, period) + const cacheKey = getUserStatsCacheKey( + userId, + 'total-hours-v2', + undefined, + period + ) return getCachedStats(redis, cacheKey, async () => { - const watchedItems = await getAllUserItemsService({ - userId, - status: 'WATCHED', - startDate: dateRange.startDate, - endDate: dateRange.endDate, - }) + 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, @@ -32,20 +44,15 @@ export async function getUserTotalHoursService( movieRuntimesWithDates.map(m => m.runtime) ) - const watchedEpisodes = await getUserEpisodesService({ userId }) - const filteredEpisodes = filterEpisodesByDateRange( - watchedEpisodes.userEpisodes, - dateRange - ) const episodeTotalHours = sumRuntimes( - filteredEpisodes.map(ep => ep.runtime) + watchedEpisodes.userEpisodes.map(ep => ep.runtime) ) const totalHours = movieTotalHours + episodeTotalHours const monthlyHours = computeMonthlyHours( movieRuntimesWithDates, - filteredEpisodes, + watchedEpisodes.userEpisodes, period ) @@ -58,19 +65,6 @@ export async function getUserTotalHoursService( }) } -function filterEpisodesByDateRange( - episodes: { runtime: number; watchedAt: Date | null }[], - dateRange: DateRange -) { - if (!dateRange.startDate && !dateRange.endDate) return episodes - return episodes.filter(ep => { - if (!ep.watchedAt) return false - if (dateRange.startDate && ep.watchedAt < dateRange.startDate) return false - if (dateRange.endDate && ep.watchedAt > dateRange.endDate) return false - return true - }) -} - async function getMovieRuntimesWithDates( watchedItems: Awaited>, redis: FastifyRedis 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 1b892759..4eabc285 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,8 +1,8 @@ import type { FastifyRedis } from '@fastify/redis' import type { Language } from '@plotwist_app/tmdb' -import type { StatsPeriod } from '@/http/schemas/common' -import { selectAllUserItemsByStatus } from '@/db/repositories/user-item-repository' import { selectUserEpisodes } from '@/db/repositories/user-episode' +import { selectAllUserItemsByStatus } from '@/db/repositories/user-item-repository' +import type { StatsPeriod } from '@/http/schemas/common' import { getTMDBMovieService } from '../tmdb/get-tmdb-movie' import { getTMDBTvSeriesService } from '../tmdb/get-tmdb-tv-series' import { processInBatches } from './batch-utils' @@ -23,27 +23,34 @@ export async function getUserWatchedGenresService({ dateRange, period = 'all', }: GetUserWatchedGenresServiceInput) { - const cacheKey = getUserStatsCacheKey(userId, 'watched-genres-v2', language, period) + const cacheKey = getUserStatsCacheKey( + userId, + 'watched-genres-v2', + language, + period + ) return getCachedStats(redis, cacheKey, async () => { - const watchedItems = await selectAllUserItemsByStatus({ - userId, - status: 'WATCHED', - startDate: dateRange?.startDate, - endDate: dateRange?.endDate, - }) - - const seenTmdbIds = new Set(watchedItems.map(item => item.tmdbId)) + const hasDateRange = dateRange?.startDate || dateRange?.endDate - // Also include series that had episodes watched in the date range, - // even if the series user_item.updatedAt is outside the range - if (dateRange?.startDate || dateRange?.endDate) { - const episodesInRange = await selectUserEpisodes({ + const [watchedItems, episodesInRange] = await Promise.all([ + selectAllUserItemsByStatus({ userId, - startDate: dateRange.startDate, - endDate: dateRange.endDate, - }) + 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)) { diff --git a/apps/backend/src/http/controllers/user-stats.ts b/apps/backend/src/http/controllers/user-stats.ts index bda15600..a615d1fb 100644 --- a/apps/backend/src/http/controllers/user-stats.ts +++ b/apps/backend/src/http/controllers/user-stats.ts @@ -9,11 +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 { languageWithLimitAndPeriodQuerySchema, languageWithPeriodQuerySchema, periodQuerySchema, periodToDateRange, + timelineQuerySchema, } from '../schemas/common' import { getUserDefaultSchema } from '../schemas/user-stats' @@ -157,6 +159,27 @@ export async function getUserBestReviewsController( 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) +} + export async function getUserItemsStatusController( request: FastifyRequest, reply: FastifyReply, diff --git a/apps/backend/src/http/routes/user-stats.ts b/apps/backend/src/http/routes/user-stats.ts index 50d3377a..65197866 100644 --- a/apps/backend/src/http/routes/user-stats.ts +++ b/apps/backend/src/http/routes/user-stats.ts @@ -6,6 +6,7 @@ import { getUserMostWatchedSeriesController, getUserReviewsCountController, getUserStatsController, + getUserStatsTimelineController, getUserTotalHoursController, getUserWatchedCastController, getUserWatchedCountriesController, @@ -15,6 +16,7 @@ import { languageWithLimitAndPeriodQuerySchema, languageWithPeriodQuerySchema, periodQuerySchema, + timelineQuerySchema, } from '../schemas/common' import { getUserBestReviewsResponseSchema, @@ -23,6 +25,7 @@ import { getUserMostWatchedSeriesResponseSchema, getUserReviewsCountResponseSchema, getUserStatsResponseSchema, + getUserStatsTimelineResponseSchema, getUserTotalHoursResponseSchema, getUserWatchedCastResponseSchema, getUserWatchedCountriesResponseSchema, @@ -46,6 +49,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', diff --git a/apps/backend/src/http/schemas/common.ts b/apps/backend/src/http/schemas/common.ts index 6941f102..fa6b9920 100644 --- a/apps/backend/src/http/schemas/common.ts +++ b/apps/backend/src/http/schemas/common.ts @@ -38,6 +38,18 @@ export const languageWithPeriodQuerySchema = 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): { diff --git a/apps/backend/src/http/schemas/user-stats.ts b/apps/backend/src/http/schemas/user-stats.ts index 1d80b54d..de6990fd 100644 --- a/apps/backend/src/http/schemas/user-stats.ts +++ b/apps/backend/src/http/schemas/user-stats.ts @@ -111,3 +111,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/Services/UserStatsService.swift b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift index def755bf..bdb3ca2c 100644 --- a/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift @@ -144,6 +144,39 @@ 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, period: String = "all") async throws -> [WatchedCastMember] { guard let url = buildURL( @@ -354,6 +387,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/Views/Home/ProfileStatsHelpers.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift index e60a6992..df2a69ec 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift @@ -7,11 +7,15 @@ 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) - let formatter = NumberFormatter() - formatter.numberStyle = .decimal - return formatter.string(from: NSNumber(value: totalMinutes)) ?? "\(totalMinutes)" + return decimalFormatter.string(from: NSNumber(value: totalMinutes)) ?? "\(totalMinutes)" } func formatHoursMinutes(_ hours: Double) -> String { @@ -44,20 +48,32 @@ func formatAxisLabel(_ value: Double) -> String { 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 { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - guard let date = formatter.date(from: month) else { return month } - formatter.dateFormat = "MMM" - return formatter.string(from: date).prefix(3).lowercased() + guard let date = ymParseFormatter.date(from: month) else { return month } + return shortMonthFormatter.string(from: date).prefix(3).lowercased() } func fullMonthLabel(_ month: String) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - guard let date = formatter.date(from: month) else { return month } - formatter.dateFormat = "MMMM" - return formatter.string(from: date) + guard let date = ymParseFormatter.date(from: month) else { return month } + return fullMonthFormatter.string(from: date) } // MARK: - ProfileStatsView Helpers @@ -209,24 +225,32 @@ struct BestReviewRow: View { posterWidth * 1.5 } - private var formattedDate: String { - let inputFormatter = ISO8601DateFormatter() - inputFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + private static let isoFormatterFractional: ISO8601DateFormatter = { + let f = ISO8601DateFormatter() + f.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + return f + }() - 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 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 func formatDate(_ date: Date) -> String { - let outputFormatter = DateFormatter() - outputFormatter.dateFormat = "dd/MM/yyyy" - return outputFormatter.string(from: date) + 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 { diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index cd243a65..e1460d5f 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -1,26 +1,83 @@ // // 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 + HStack(alignment: .top, spacing: 12) { + topGenreCard + 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: 20, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + + 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(Color.appBackgroundAdaptive) + } +} + // MARK: - Time Watched Card -extension ProfileStatsView { - func timeWatchedCard(for section: MonthSection, period: String) -> some View { +extension MonthSectionContentView { + private var timeWatchedCard: some View { NavigationLink { TimeWatchedDetailView( totalHours: section.totalHours, movieHours: section.movieHours, seriesHours: section.seriesHours, monthlyHours: section.monthlyHours, - dailyAverage: computeDailyAverage(section: section, period: period), + dailyAverage: computeDailyAverage(), dailyAverageLabel: strings.perDayThisMonth, comparisonHours: section.comparisonHours, periodLabel: period == "all" ? strings.allTime : section.displayName, showComparison: period != "all", - strings: strings + strings: strings, + userId: userId, + period: section.yearMonth ) } label: { VStack(alignment: .leading, spacing: 0) { @@ -79,13 +136,17 @@ extension ProfileStatsView { .clipShape(RoundedRectangle(cornerRadius: 10)) } - func computeDailyAverage(section: MonthSection, period: String) -> Double { + private static let ymFormatter: DateFormatter = { + let f = DateFormatter() + f.dateFormat = "yyyy-MM" + return f + }() + + func computeDailyAverage() -> Double { guard section.totalHours > 0 else { return 0 } if period == "all" { if let firstMonth = section.monthlyHours.first?.month { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - if let startDate = formatter.date(from: firstMonth) { + if let startDate = Self.ymFormatter.date(from: firstMonth) { let days = max(Calendar.current.dateComponents([.day], from: startDate, to: Date()).day ?? 30, 1) return section.totalHours / Double(days) } @@ -93,9 +154,7 @@ extension ProfileStatsView { return section.totalHours / 30 } - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - if let date = formatter.date(from: period) { + if let date = Self.ymFormatter.date(from: period) { let now = Date() let cal = Calendar.current let sameMonth = cal.isDate(date, equalTo: now, toGranularity: .month) @@ -114,13 +173,18 @@ extension ProfileStatsView { // MARK: - Top Genre Card -extension ProfileStatsView { - func topGenreCard(for section: MonthSection) -> some View { - let topGenre = section.watchedGenres.first - let hasGenres = !section.watchedGenres.isEmpty +extension MonthSectionContentView { + private var topGenreCard: some View { + let hasGenres = section.hasGenreData return NavigationLink { - PeriodGenresView(genres: section.watchedGenres, periodLabel: section.yearMonth == "all" ? strings.allTime : section.displayName, strings: strings) + PeriodGenresView( + genres: section.watchedGenres, + periodLabel: section.yearMonth == "all" ? strings.allTime : section.displayName, + strings: strings, + userId: userId, + period: section.yearMonth + ) } label: { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { @@ -137,14 +201,14 @@ extension ProfileStatsView { } .padding(.bottom, 14) - if let genre = topGenre { - Text(genre.name) + if let name = section.topGenreName { + Text(name) .font(.system(size: 18, weight: .bold)) .foregroundColor(.appForegroundAdaptive) .lineLimit(1) .padding(.bottom, 10) - if let posterURL = genre.posterURL { + if let posterURL = section.topGenrePosterURL { statsPoster(url: posterURL) } } else { @@ -165,12 +229,18 @@ extension ProfileStatsView { // MARK: - Top Review Card -extension ProfileStatsView { - func topReviewCard(for section: MonthSection) -> some View { - let topReview = section.bestReviews.first +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) + 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) { @@ -179,28 +249,35 @@ extension ProfileStatsView { .tracking(1.5) .foregroundColor(.appMutedForegroundAdaptive) Spacer() - Image(systemName: "chevron.right") - .font(.system(size: 12, weight: .semibold)) - .foregroundColor(.appMutedForegroundAdaptive) + if hasReviews { + Image(systemName: "chevron.right") + .font(.system(size: 12, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) + } } .padding(.bottom, 14) - if let review = topReview { - Text(review.title) + if let title = section.topReviewTitle { + Text(title) .font(.system(size: 18, weight: .bold)) .foregroundColor(.appForegroundAdaptive) .lineLimit(1) .padding(.bottom, 10) - statsPoster(url: review.posterURL, rating: review.rating) + statsPoster(url: section.topReviewPosterURL, rating: section.topReviewRating) + } else { + Text("–") + .font(.system(size: 18, weight: .bold)) + .foregroundColor(.appMutedForegroundAdaptive) } } .padding(16) - .frame(maxWidth: .infinity, alignment: .leading) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) .background(Color.statsCardBackground) .clipShape(RoundedRectangle(cornerRadius: 22)) } .buttonStyle(.plain) + .disabled(!hasReviews) } } @@ -213,21 +290,34 @@ struct TimeWatchedDetailView: View { let totalHours: Double let movieHours: Double let seriesHours: Double - let monthlyHours: [MonthlyHoursEntry] + @State var monthlyHours: [MonthlyHoursEntry] let dailyAverage: Double let dailyAverageLabel: String let comparisonHours: Double? let periodLabel: String let showComparison: Bool let strings: Strings - - @State private var scrollOffset: CGFloat = 0 - @State private var initialScrollOffset: CGFloat? - private let scrollThreshold: CGFloat = 40 - - private var isScrolled: Bool { - guard let initial = initialScrollOffset else { return false } - return scrollOffset < initial - scrollThreshold + var userId: String? = nil + var period: String? = nil + + @State private var isScrolled = false + + init(totalHours: Double, movieHours: Double, seriesHours: Double, monthlyHours: [MonthlyHoursEntry], + dailyAverage: Double, dailyAverageLabel: String, comparisonHours: Double?, + periodLabel: String, showComparison: Bool, strings: Strings, + userId: String? = nil, period: String? = nil) { + self.totalHours = totalHours + self.movieHours = movieHours + self.seriesHours = seriesHours + _monthlyHours = State(initialValue: monthlyHours) + self.dailyAverage = dailyAverage + self.dailyAverageLabel = dailyAverageLabel + self.comparisonHours = comparisonHours + self.periodLabel = periodLabel + self.showComparison = showComparison + self.strings = strings + self.userId = userId + self.period = period } var body: some View { @@ -236,8 +326,10 @@ struct TimeWatchedDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 24) { - scrollOffsetReader - .frame(height: 0) + GeometryReader { geo in + Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) + } + .frame(height: 0) Text(periodLabel) .font(.system(size: 14, weight: .medium)) @@ -315,20 +407,20 @@ struct TimeWatchedDetailView: View { .padding(.top, 16) .padding(.bottom, 24) } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + isScrolled = value < -40 + } } + .background(Color.appBackgroundAdaptive.ignoresSafeArea()) .navigationBarHidden(true) + .task { await loadMonthlyHoursIfNeeded() } } - 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 loadMonthlyHoursIfNeeded() async { + guard let userId, let period, monthlyHours.isEmpty, period != "all" else { return } + if let result = try? await UserStatsService.shared.getTotalHours(userId: userId, period: period) { + monthlyHours = result.monthlyHours } } @@ -461,98 +553,114 @@ struct TimeWatchedDetailView: View { struct PeriodGenresView: View { @Environment(\.dismiss) private var dismiss - let genres: [WatchedGenre] + @State var genres: [WatchedGenre] let periodLabel: String let strings: Strings - - @State private var scrollOffset: CGFloat = 0 - @State private var initialScrollOffset: CGFloat? - private let scrollThreshold: CGFloat = 40 - - private var isScrolled: Bool { - guard let initial = initialScrollOffset else { return false } - return scrollOffset < initial - scrollThreshold + var userId: String? = nil + var period: String? = nil + + @State private var isScrolled = false + @State private var isLoading = false + + 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() } - ScrollView { - let maxCount = genres.map(\.count).max() ?? 1 + if isLoading && genres.isEmpty { + Spacer() + ProgressView() + Spacer() + } else { + ScrollView { + let maxCount = genres.map(\.count).max() ?? 1 - VStack(alignment: .leading, spacing: 0) { - scrollOffsetReader + VStack(alignment: .leading, spacing: 0) { + GeometryReader { geo in + Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) + } .frame(height: 0) - Text(periodLabel) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) + Text(periodLabel) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) - Text(strings.favoriteGenres) - .font(.system(size: 34, weight: .bold)) - .foregroundColor(.appForegroundAdaptive) - .padding(.bottom, 16) - - LazyVStack(spacing: 0) { - ForEach(Array(genres.enumerated()), id: \.element.id) { index, genre in - VStack(spacing: 0) { - HStack { - Text("\(index + 1)") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive) - .frame(width: 24, alignment: .leading) - - Text(genre.name) - .font(.system(size: 15, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - - Spacer() - - Text(String(format: "%.0f%%", genre.percentage)) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive) - } - .padding(.vertical, 14) - - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.appBorderAdaptive.opacity(0.3)) - .frame(height: 5) - RoundedRectangle(cornerRadius: 3) - .fill(Color(hex: "3B82F6")) - .frame(width: geo.size.width * CGFloat(genre.count) / CGFloat(max(maxCount, 1)), height: 5) + Text(strings.favoriteGenres) + .font(.system(size: 34, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) + .padding(.bottom, 16) + + LazyVStack(spacing: 0) { + ForEach(Array(genres.enumerated()), id: \.element.id) { index, genre in + VStack(spacing: 0) { + HStack { + Text("\(index + 1)") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + .frame(width: 24, alignment: .leading) + + Text(genre.name) + .font(.system(size: 15, weight: .medium)) + .foregroundColor(.appForegroundAdaptive) + + Spacer() + + Text(String(format: "%.0f%%", genre.percentage)) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) } - } - .frame(height: 5) + .padding(.vertical, 14) + + GeometryReader { geo in + ZStack(alignment: .leading) { + RoundedRectangle(cornerRadius: 3) + .fill(Color.appBorderAdaptive.opacity(0.3)) + .frame(height: 5) + RoundedRectangle(cornerRadius: 3) + .fill(Color(hex: "3B82F6")) + .frame(width: geo.size.width * CGFloat(genre.count) / CGFloat(max(maxCount, 1)), height: 5) + } + } + .frame(height: 5) - if index < genres.count - 1 { - Divider().padding(.top, 12) + if index < genres.count - 1 { + Divider().padding(.top, 12) + } } } } } + .padding(.horizontal, 24) + .padding(.top, 16) + .padding(.bottom, 24) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + isScrolled = value < -40 } - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 24) } } + .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 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 } } @@ -561,62 +669,78 @@ struct PeriodGenresView: View { struct PeriodReviewsView: View { @Environment(\.dismiss) private var dismiss - let reviews: [BestReview] + @State var reviews: [BestReview] let periodLabel: String let strings: Strings - - @State private var scrollOffset: CGFloat = 0 - @State private var initialScrollOffset: CGFloat? - private let scrollThreshold: CGFloat = 40 - - private var isScrolled: Bool { - guard let initial = initialScrollOffset else { return false } - return scrollOffset < initial - scrollThreshold + var userId: String? = nil + var period: String? = nil + + @State private var isScrolled = false + @State private var isLoading = false + + 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() } - ScrollView { - VStack(alignment: .leading, spacing: 0) { - scrollOffsetReader + if isLoading && reviews.isEmpty { + Spacer() + ProgressView() + Spacer() + } else { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + GeometryReader { geo in + Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) + } .frame(height: 0) - Text(periodLabel) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) + Text(periodLabel) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) - Text(strings.bestReviews) - .font(.system(size: 34, weight: .bold)) - .foregroundColor(.appForegroundAdaptive) - .padding(.bottom, 16) + 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) + 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) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + isScrolled = value < -40 } - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 24) } } + .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 } } @@ -663,40 +787,39 @@ func detailHeaderView(title: String, isScrolled: Bool, onBack: @escaping () -> V @ViewBuilder func statsPoster(url: URL?, rating: Double? = nil) -> some View { let cr = DesignTokens.CornerRadius.poster - - GeometryReader { geo in - let posterWidth = geo.size.width * 0.7 - - CachedAsyncImage(url: url) { image in - image - .resizable() - .aspectRatio(contentMode: .fill) - } placeholder: { - RoundedRectangle(cornerRadius: cr) - .fill(Color.appBorderAdaptive.opacity(0.3)) - } - .frame(width: posterWidth, height: posterWidth * 3 / 2) - .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) + 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() } - .aspectRatio(1.0 / 1.05, contentMode: .fit) + .posterBorder() } // MARK: - Shimmer Effect @@ -790,14 +913,14 @@ struct StatsShareCardView: View { // Posters HStack(alignment: .top, spacing: 12) { - if let genre = section.watchedGenres.first { + 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(genre.name) + Text(genreName) .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) .lineLimit(1) @@ -805,14 +928,14 @@ struct StatsShareCardView: View { .frame(maxWidth: .infinity) } - if let review = section.bestReviews.first { + if let reviewTitle = section.topReviewTitle { VStack(alignment: .leading, spacing: 8) { - shareCardPoster(image: reviewPosterImage, rating: review.rating) + shareCardPoster(image: reviewPosterImage, rating: section.topReviewRating) Text(strings.bestReview.uppercased()) .font(.system(size: 9, weight: .bold)) .tracking(1) .foregroundColor(.white.opacity(0.35)) - Text(review.title) + Text(reviewTitle) .font(.system(size: 14, weight: .bold)) .foregroundColor(.white) .lineLimit(1) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index 6e8f9ee2..0a7c68f8 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -15,57 +15,88 @@ struct MonthSection: Identifiable, Equatable { var monthlyHours: [MonthlyHoursEntry] = [] var watchedGenres: [WatchedGenre] = [] var bestReviews: [BestReview] = [] + var topGenre: TimelineGenreSummary? + var topReview: TimelineReviewSummary? var comparisonHours: Double? 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 formatter = DateFormatter() - formatter.locale = Self.appLocale - formatter.dateFormat = "yyyy-MM" - guard let date = formatter.date(from: yearMonth) else { return yearMonth } - formatter.dateFormat = "MMMM yyyy" - let result = formatter.string(from: date) + 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 formatter = DateFormatter() - formatter.locale = Self.appLocale - formatter.dateFormat = "yyyy-MM" - guard let date = formatter.date(from: yearMonth) else { return yearMonth } - formatter.dateFormat = "MMM" - return formatter.string(from: date).prefix(3).uppercased() + 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 || !watchedGenres.isEmpty || !bestReviews.isEmpty + totalHours > 0 || hasGenreData || hasReviewData } static func == (lhs: MonthSection, rhs: MonthSection) -> Bool { - lhs.yearMonth == rhs.yearMonth + 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 } static func currentYearMonth() -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - return formatter.string(from: Date()) + parseFormatter.string(from: Date()) } static func previousYearMonth(from ym: String) -> String { - let formatter = DateFormatter() - formatter.dateFormat = "yyyy-MM" - guard let date = formatter.date(from: ym), + guard let date = parseFormatter.date(from: ym), let prev = Calendar.current.date(byAdding: .month, value: -1, to: date) else { return ym } - return formatter.string(from: prev) + return parseFormatter.string(from: prev) } } @@ -83,15 +114,6 @@ enum StatsMode: String, CaseIterable { } } -// MARK: - Visible Section Preference - -struct VisibleSectionPreferenceKey: PreferenceKey { - static var defaultValue: String? = nil - static func reduce(value: inout String?, nextValue: () -> String?) { - value = value ?? nextValue() - } -} - // MARK: - ProfileStatsView struct ProfileStatsView: View { @@ -105,17 +127,14 @@ struct ProfileStatsView: View { // Timeline state @State var monthSections: [MonthSection] = [] @State var isLoadingMore = false - + @State var nextCursor: String? = nil + @State var hasMore = true + @State private var timelineLoadId = UUID() // All-time state @State var allTimeSection = MonthSection(yearMonth: "all") - - @State var countStartTime: Date? - @State var animationTrigger = false @State var hasStartedLoading = false - @State var error: String? - let countDuration: Double = 1.8 let cache = ProfileStatsCache.shared init(userId: String, isPro: Bool = false, isOwnProfile: Bool = true) { @@ -139,7 +158,7 @@ struct ProfileStatsView: View { } .task { if mode == .timeline { - await initTimeline() + await loadTimeline() } else { await loadAllTime() } @@ -148,7 +167,7 @@ struct ProfileStatsView: View { Task { if newMode == .timeline { if monthSections.isEmpty { - await initTimeline() + await loadTimeline() } } else { if !allTimeSection.isLoaded { @@ -182,41 +201,44 @@ struct ProfileStatsView: View { var timelineBody: some View { ScrollView { LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - ForEach(Array(monthSections.enumerated()), id: \.element.id) { index, section in - if !section.isLoaded || section.hasMinimumData { - Section { - monthContentView(section) - .padding(.horizontal, 24) - .padding(.bottom, 48) - } header: { - monthHeaderView(section) - } - .onAppear { - loadMoreIfNeeded(index: index) - } + ForEach(monthSections) { section in + Section { + MonthSectionContentView( + section: section, + userId: userId, + strings: strings, + period: section.yearMonth + ) + .equatable() + .padding(.horizontal, 24) + .padding(.bottom, 48) + } header: { + MonthSectionHeaderView( + section: section, + isOwnProfile: isOwnProfile, + onShare: { shareMonthStats(section) } + ) + .equatable() } } - if isLoadingMore { - VStack(spacing: 16) { - skeletonRect(height: 120) - HStack(spacing: 12) { - skeletonRect(height: 200) - skeletonRect(height: 200) - } - } - .padding(.horizontal, 24) - .padding(.bottom, 48) + if hasMore { + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + .task { await loadTimeline() } + .id("load-more-\(monthSections.count)") } } .padding(.top, 8) } .refreshable { - for section in monthSections { - cache.invalidate(userId: userId, period: section.yearMonth) - } monthSections = [] - await initTimeline() + nextCursor = nil + hasMore = true + isLoadingMore = false + timelineLoadId = UUID() + await loadTimeline() } } @@ -245,85 +267,21 @@ struct ProfileStatsView: View { @ViewBuilder var allTimeSectionContent: some View { if allTimeSection.isLoaded { - timeWatchedCard(for: allTimeSection, period: "all") - .transition(.opacity.animation(.easeIn(duration: 0.25))) - - highlightCards(for: allTimeSection) + MonthSectionContentView( + section: allTimeSection, + userId: userId, + strings: strings, + period: "all" + ) + .equatable() + .transition(.opacity.animation(.easeIn(duration: 0.25))) } else if hasStartedLoading { - skeletonRect(height: 120) - HStack(spacing: 12) { - skeletonRect(height: 200) - skeletonRect(height: 200) - } - } - } - - // MARK: - Month Content View - - @ViewBuilder - func monthContentView(_ section: MonthSection) -> some View { - if section.isLoaded { - VStack(spacing: 16) { - timeWatchedCard(for: section, period: section.yearMonth) - .transition(.opacity.animation(.easeIn(duration: 0.25))) - - highlightCards(for: section) - } - } else { - VStack(spacing: 16) { - skeletonRect(height: 120) - HStack(spacing: 12) { - skeletonRect(height: 200) - skeletonRect(height: 200) - } - } - } - } - - // MARK: - Highlight Cards (Side by Side) - - @ViewBuilder - func highlightCards(for section: MonthSection) -> some View { - let hasReview = !section.bestReviews.isEmpty - - HStack(alignment: .top, spacing: 12) { - topGenreCard(for: section) - .transition(.opacity.animation(.easeIn(duration: 0.25))) - - if hasReview { - topReviewCard(for: section) - .transition(.opacity.animation(.easeIn(duration: 0.25))) - } else { - Color.clear - } + ProgressView() + .frame(maxWidth: .infinity) + .padding(.vertical, 48) } } - // MARK: - Month Header - - func monthHeaderView(_ section: MonthSection) -> some View { - HStack { - Text(section.displayName) - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.appForegroundAdaptive) - - Spacer() - - if isOwnProfile { - Button { - shareMonthStats(section) - } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - } - } - } - .padding(.horizontal, 24) - .padding(.vertical, 10) - .background(Color.appBackgroundAdaptive) - } - // MARK: - Skeleton func skeletonRect(height: CGFloat) -> some View { @@ -363,10 +321,9 @@ struct ProfileStatsView: View { // MARK: - Share func shareMonthStats(_ section: MonthSection) { - guard section.isLoaded else { return } Task { - let genreImage = await downloadImage(url: section.watchedGenres.first?.posterURL) - let reviewImage = await downloadImage(url: section.bestReviews.first?.posterURL) + let genreImage = await downloadImage(url: section.topGenrePosterURL) + let reviewImage = await downloadImage(url: section.topReviewPosterURL) let cardImage = renderShareCard( section: section, @@ -415,176 +372,72 @@ struct ProfileStatsView: View { return f }() ) - return renderer.image { _ in - controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) - } - } - - // MARK: - Load More - - private let batchSize = 3 - - func loadMoreIfNeeded(index: Int) { - guard !isLoadingMore else { return } - if index >= monthSections.count - 3 { - isLoadingMore = true - Task { - await loadNextBatch() - isLoadingMore = false - - let lastVisibleIndex = monthSections.enumerated() - .filter { !$0.element.isLoaded || $0.element.hasMinimumData } - .last?.offset ?? 0 - loadMoreIfNeeded(index: lastVisibleIndex) - } - } - } - - private func loadNextBatch() async { - guard let last = monthSections.last else { return } - - var newMonths: [String] = [] - var ym = last.yearMonth - for _ in 0.. Date: Wed, 18 Feb 2026 17:02:51 -0300 Subject: [PATCH 08/16] feat: enhance user stats services and introduce stats timeline feature - Added new `getUserStatsTimelineService` to retrieve user statistics in a paginated monthly format, providing insights into total hours watched, top genres, and reviews. - Updated `ProfileStatsView` to support timeline mode, displaying monthly sections with improved UI components for better user engagement. - Introduced new query schema for timeline requests and corresponding response schema to structure the data effectively. - Refactored existing services to integrate episode data into total hours calculations, ensuring accurate statistics across different viewing periods. These changes aim to enrich the user experience by offering a more detailed and organized view of user statistics over time. --- .../Plotwist/Views/Home/ProfileStatsSections.swift | 12 +++++++++++- .../Plotwist/Views/Home/ProfileStatsView.swift | 13 +++++++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index e1460d5f..d9081d4c 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -56,7 +56,17 @@ struct MonthSectionHeaderView: View, Equatable { } .padding(.horizontal, 24) .padding(.vertical, 10) - .background(Color.appBackgroundAdaptive) + .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) + } + } + ) } } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index 0a7c68f8..53cd2ec4 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -232,6 +232,7 @@ struct ProfileStatsView: View { } .padding(.top, 8) } + .coordinateSpace(name: "statsTimeline") .refreshable { monthSections = [] nextCursor = nil @@ -361,6 +362,11 @@ struct ProfileStatsView: View { 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 @@ -372,9 +378,12 @@ struct ProfileStatsView: View { return f }() ) - return renderer.image { ctx in - controller.view.layer.render(in: ctx.cgContext) + let image = renderer.image { _ in + controller.view.drawHierarchy(in: controller.view.bounds, afterScreenUpdates: true) } + + window.isHidden = true + return image } // MARK: - Load Timeline (Paginated) From 02d44de911c04bc74bc69ba28b99931d9ea62f99 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 17:48:44 -0300 Subject: [PATCH 09/16] feat: update user stats service to enhance genre data handling - Changed cache key for user watched genres from 'watched-genres-v2' to 'watched-genres-v5' for improved versioning. - Introduced a new `GenreItem` type to encapsulate genre-related data, including `tmdbId`, `mediaType`, and `posterPath`. - Refactored genre data structures to include `posterPaths` and `items`, allowing for more detailed genre information in responses. - Updated response schema to accommodate new fields, enhancing the data returned for user watched genres. These changes aim to provide a richer and more organized representation of user genre statistics, improving overall user experience. --- .../user-stats/get-user-watched-genres.ts | 38 ++- apps/backend/src/http/schemas/user-stats.ts | 10 + .../Plotwist/Localization/Strings.swift | 8 + .../Plotwist/Services/UserStatsService.swift | 27 ++ .../Views/Home/ProfileStatsSections.swift | 265 ++++++++++++++---- 5 files changed, 291 insertions(+), 57 deletions(-) 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 4eabc285..c4dffd86 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 @@ -25,7 +25,7 @@ export async function getUserWatchedGenresService({ }: GetUserWatchedGenresServiceInput) { const cacheKey = getUserStatsCacheKey( userId, - 'watched-genres-v2', + 'watched-genres-v5', language, period ) @@ -66,8 +66,13 @@ export async function getUserWatchedGenresService({ } } + type GenreItem = { + tmdbId: number + mediaType: string + posterPath: string | null + } const genreCount = new Map() - const genrePoster = new Map() + const genreItems = new Map() await processInBatches(watchedItems, async item => { const { genres, posterPath } = @@ -84,11 +89,19 @@ 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) - if (posterPath && !genrePoster.has(genre.name)) { - genrePoster.set(genre.name, posterPath) + + 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) } } } @@ -96,12 +109,17 @@ export async function getUserWatchedGenresService({ const totalItems = watchedItems.length const genres = Array.from(genreCount) - .map(([name, count]) => ({ - name, - count, - percentage: totalItems > 0 ? (count / totalItems) * 100 : 0, - posterPath: genrePoster.get(name) ?? null, - })) + .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/http/schemas/user-stats.ts b/apps/backend/src/http/schemas/user-stats.ts index de6990fd..282b0614 100644 --- a/apps/backend/src/http/schemas/user-stats.ts +++ b/apps/backend/src/http/schemas/user-stats.ts @@ -57,6 +57,16 @@ export const getUserWatchedGenresResponseSchema = { 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(), }) ), }), diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index a4ef9caf..4fc308f0 100644 --- a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift +++ b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift @@ -284,6 +284,7 @@ enum L10n { tryAgain: "Try again", favoriteGenres: "Favorite Genres", favoriteGenre: "Favorite Genre", + nTitles: "%d titles", content: "Content", genres: "Genres", itemsInCollection: "items in collection", @@ -637,6 +638,7 @@ enum L10n { tryAgain: "Tentar novamente", favoriteGenres: "Gêneros Favoritos", favoriteGenre: "Gênero Favorito", + nTitles: "%d títulos", content: "Conteúdo", genres: "Gêneros", itemsInCollection: "itens na coleção", @@ -990,6 +992,7 @@ enum L10n { tryAgain: "Intentar de nuevo", favoriteGenres: "Géneros Favoritos", favoriteGenre: "Género Favorito", + nTitles: "%d títulos", content: "Contenido", genres: "Géneros", itemsInCollection: "ítems en la colección", @@ -1343,6 +1346,7 @@ enum L10n { tryAgain: "Réessayer", favoriteGenres: "Genres Préférés", favoriteGenre: "Genre Préféré", + nTitles: "%d titres", content: "Contenu", genres: "Genres", itemsInCollection: "éléments dans la collection", @@ -1696,6 +1700,7 @@ enum L10n { tryAgain: "Erneut versuchen", favoriteGenres: "Lieblingsgenres", favoriteGenre: "Lieblingsgenre", + nTitles: "%d Titel", content: "Inhalte", genres: "Genres", itemsInCollection: "Elemente in der Sammlung", @@ -2049,6 +2054,7 @@ enum L10n { tryAgain: "Riprova", favoriteGenres: "Generi Preferiti", favoriteGenre: "Genere Preferito", + nTitles: "%d titoli", content: "Contenuti", genres: "Generi", itemsInCollection: "elementi nella collezione", @@ -2401,6 +2407,7 @@ enum L10n { tryAgain: "再試行", favoriteGenres: "お気に入りジャンル", favoriteGenre: "お気に入りジャンル", + nTitles: "%d作品", content: "コンテンツ", genres: "ジャンル", itemsInCollection: "アイテム", @@ -2765,6 +2772,7 @@ struct Strings { let tryAgain: String let favoriteGenres: String let favoriteGenre: String + let nTitles: String let content: String let genres: String let itemsInCollection: String diff --git a/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift index bdb3ca2c..b24796e6 100644 --- a/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift @@ -279,11 +279,26 @@ struct TotalHoursResponse: Codable { let monthlyHours: [MonthlyHoursEntry] } +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 } @@ -291,6 +306,18 @@ struct WatchedGenre: Codable, Identifiable { 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 { diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index d9081d4c..4559ca73 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -101,7 +101,7 @@ extension MonthSectionContentView { .font(.system(size: 12, weight: .semibold)) .foregroundColor(.appMutedForegroundAdaptive) } - .padding(.bottom, 14) + .padding(.bottom, 20) HStack(alignment: .firstTextBaseline, spacing: 6) { Text(formatTotalMinutes(section.totalHours)) @@ -209,7 +209,7 @@ extension MonthSectionContentView { .foregroundColor(.appMutedForegroundAdaptive) } } - .padding(.bottom, 14) + .padding(.bottom, 20) if let name = section.topGenreName { Text(name) @@ -265,7 +265,7 @@ extension MonthSectionContentView { .foregroundColor(.appMutedForegroundAdaptive) } } - .padding(.bottom, 14) + .padding(.bottom, 20) if let title = section.topReviewTitle { Text(title) @@ -341,8 +341,9 @@ struct TimeWatchedDetailView: View { } .frame(height: 0) - Text(periodLabel) - .font(.system(size: 14, weight: .medium)) + Text(periodLabel.uppercased()) + .font(.system(size: 11, weight: .medium)) + .tracking(0.5) .foregroundColor(.appMutedForegroundAdaptive) Text(strings.timeWatched) @@ -590,59 +591,29 @@ struct PeriodGenresView: View { Spacer() } else { ScrollView { - let maxCount = genres.map(\.count).max() ?? 1 - VStack(alignment: .leading, spacing: 0) { GeometryReader { geo in Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) } .frame(height: 0) - Text(periodLabel) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) + Text(periodLabel.uppercased()) + .font(.system(size: 11, weight: .medium)) + .tracking(0.5) + .foregroundColor(.appMutedForegroundAdaptive) - Text(strings.favoriteGenres) + Text(strings.favoriteGenres) .font(.system(size: 34, weight: .bold)) .foregroundColor(.appForegroundAdaptive) - .padding(.bottom, 16) + .padding(.bottom, 20) LazyVStack(spacing: 0) { ForEach(Array(genres.enumerated()), id: \.element.id) { index, genre in - VStack(spacing: 0) { - HStack { - Text("\(index + 1)") - .font(.system(size: 13, weight: .bold, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive) - .frame(width: 24, alignment: .leading) - - Text(genre.name) - .font(.system(size: 15, weight: .medium)) - .foregroundColor(.appForegroundAdaptive) - - Spacer() - - Text(String(format: "%.0f%%", genre.percentage)) - .font(.system(size: 14, weight: .semibold, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive) - } - .padding(.vertical, 14) - - GeometryReader { geo in - ZStack(alignment: .leading) { - RoundedRectangle(cornerRadius: 3) - .fill(Color.appBorderAdaptive.opacity(0.3)) - .frame(height: 5) - RoundedRectangle(cornerRadius: 3) - .fill(Color(hex: "3B82F6")) - .frame(width: geo.size.width * CGFloat(genre.count) / CGFloat(max(maxCount, 1)), height: 5) - } - } - .frame(height: 5) + genreRow(genre: genre, rank: index + 1) - if index < genres.count - 1 { - Divider().padding(.top, 12) - } + if index < genres.count - 1 { + Divider() + .padding(.leading, 40) } } } @@ -662,6 +633,33 @@ struct PeriodGenresView: View { .task { await loadGenresIfNeeded() } } + 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 @@ -674,6 +672,178 @@ struct PeriodGenresView: View { } } +// MARK: - Poster Deck View + +private 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 + +private 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) + } + } +} + // MARK: - All Reviews Detail View struct PeriodReviewsView: View { @@ -712,8 +882,9 @@ struct PeriodReviewsView: View { } .frame(height: 0) - Text(periodLabel) - .font(.system(size: 14, weight: .medium)) + Text(periodLabel.uppercased()) + .font(.system(size: 11, weight: .medium)) + .tracking(0.5) .foregroundColor(.appMutedForegroundAdaptive) Text(strings.bestReviews) From e3321e7e8c176f6798f08a78fe8fa19ef0d774c9 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 22:09:34 -0300 Subject: [PATCH 10/16] feat: enhance user total hours service with additional metrics and improved response structure - Updated the cache key for user total hours from 'total-hours-v2' to 'total-hours-v6' for better versioning. - Introduced new metrics including peak time slot, hourly distribution, daily activity, and user percentile rank to provide a more comprehensive view of user engagement. - Enhanced the response schema to include these new metrics, improving the data returned for user total hours. - Added new utility functions to compute daily activity and peak viewing times, enriching the analytics capabilities of the service. These changes aim to deliver a more detailed and insightful user statistics experience, enhancing overall engagement and understanding of viewing habits. --- .../user-stats/get-user-total-hours.ts | 165 +++- apps/backend/src/http/schemas/user-stats.ts | 25 + .../Plotwist/Localization/Strings.swift | 98 ++- .../Plotwist/Services/UserStatsService.swift | 24 + .../Views/Home/ProfileStatsHelpers.swift | 5 + .../Views/Home/ProfileStatsSections.swift | 742 +++++++++++++----- .../Views/Home/ProfileStatsView.swift | 45 +- 7 files changed, 894 insertions(+), 210 deletions(-) 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 60611a5e..8ca5a824 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,5 +1,7 @@ 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' @@ -16,7 +18,7 @@ export async function getUserTotalHoursService( ) { const cacheKey = getUserStatsCacheKey( userId, - 'total-hours-v2', + 'total-hours-v6', undefined, period ) @@ -56,11 +58,31 @@ export async function getUserTotalHoursService( 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, } }) } @@ -180,6 +202,147 @@ function computeDailyBreakdown( })) } +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/http/schemas/user-stats.ts b/apps/backend/src/http/schemas/user-stats.ts index 282b0614..63de3f65 100644 --- a/apps/backend/src/http/schemas/user-stats.ts +++ b/apps/backend/src/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(), }), } diff --git a/apps/ios/Plotwist/Plotwist/Localization/Strings.swift b/apps/ios/Plotwist/Plotwist/Localization/Strings.swift index 4fc308f0..84e341db 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", @@ -296,6 +298,13 @@ enum L10n { 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", @@ -332,7 +341,7 @@ enum L10n { thisMonth: "This Month", lastMonth: "Last Month", thisYear: "This Year", - allTime: "All Time", + allTime: "Lifetime", hoursThisMonth: "hours this month", hoursLastMonth: "hours last month", hoursThisYear: "hours this year", @@ -344,6 +353,8 @@ enum L10n { 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 %@", @@ -629,6 +640,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", @@ -650,6 +663,13 @@ enum L10n { 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", @@ -686,7 +706,7 @@ enum L10n { thisMonth: "Este Mês", lastMonth: "Mês Passado", thisYear: "Este Ano", - allTime: "Tudo", + allTime: "Geral", hoursThisMonth: "horas este mês", hoursLastMonth: "horas mês passado", hoursThisYear: "horas este ano", @@ -698,6 +718,8 @@ enum L10n { 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 %@", @@ -983,6 +1005,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", @@ -1004,6 +1028,13 @@ enum L10n { 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", @@ -1040,7 +1071,7 @@ enum L10n { thisMonth: "Este Mes", lastMonth: "Mes Pasado", thisYear: "Este Año", - allTime: "Todo", + allTime: "General", hoursThisMonth: "horas este mes", hoursLastMonth: "horas mes pasado", hoursThisYear: "horas este año", @@ -1052,6 +1083,8 @@ enum L10n { 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 %@", @@ -1337,6 +1370,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", @@ -1358,6 +1393,13 @@ enum L10n { 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", @@ -1394,7 +1436,7 @@ enum L10n { thisMonth: "Ce Mois", lastMonth: "Mois Dernier", thisYear: "Cette Année", - allTime: "Tout", + allTime: "Général", hoursThisMonth: "heures ce mois", hoursLastMonth: "heures mois dernier", hoursThisYear: "heures cette année", @@ -1406,6 +1448,8 @@ enum L10n { 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 %@", @@ -1691,6 +1735,8 @@ enum L10n { stats: "Statistiken", series: "Serien", timeWatched: "Gesehene Zeit", + youSpentWatching: "Du hast %@ Stunden geschaut", + distribution: "Verteilung", hour: "Stunde", hours: "Stunden", days: "Tage", @@ -1712,6 +1758,13 @@ enum L10n { 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", @@ -1760,6 +1813,8 @@ enum L10n { 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", @@ -2045,6 +2100,8 @@ enum L10n { stats: "Statistiche", series: "Serie", timeWatched: "Tempo Guardato", + youSpentWatching: "Hai passato %@ ore guardando", + distribution: "Distribuzione", hour: "ora", hours: "ore", days: "giorni", @@ -2066,6 +2123,13 @@ enum L10n { 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", @@ -2102,7 +2166,7 @@ enum L10n { thisMonth: "Questo Mese", lastMonth: "Mese Scorso", thisYear: "Quest'Anno", - allTime: "Tutto", + allTime: "Generale", hoursThisMonth: "ore questo mese", hoursLastMonth: "ore mese scorso", hoursThisYear: "ore quest'anno", @@ -2114,6 +2178,8 @@ enum L10n { 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 %@", @@ -2398,6 +2464,8 @@ enum L10n { stats: "統計", series: "シリーズ", timeWatched: "視聴時間", + youSpentWatching: "%@時間視聴しました", + distribution: "内訳", hour: "時間", hours: "時間", days: "日", @@ -2419,6 +2487,13 @@ enum L10n { perDay: "1日あたり", yourTasteDNA: "あなたの好みDNA", activity: "アクティビティ", + less: "少ない", + more: "多い", + peakTime: "ピーク時間", + peakTimeMorning: "朝", + peakTimeAfternoon: "午後", + peakTimeEvening: "夕方", + peakTimeNight: "夜", evolution: "推移", shareMyStats: "共有", unlockFullProfile: "フルプロフィールをアンロック", @@ -2467,6 +2542,8 @@ enum L10n { minutes: "分", timeline: "タイムライン", vsLastMonthShort: "vs 先月", + comparisonPrevMonth: "%@の%@時間", + topPercentile: "ユーザーの%d%%以上", // Home Engagement forYou: "あなたへ", basedOnYourTaste: "%@が好きだから", @@ -2763,6 +2840,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 @@ -2784,6 +2863,13 @@ struct Strings { 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 @@ -2832,6 +2918,8 @@ struct Strings { 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/UserStatsService.swift b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift index b24796e6..e3dd00ce 100644 --- a/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift +++ b/apps/ios/Plotwist/Plotwist/Services/UserStatsService.swift @@ -272,11 +272,35 @@ 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 { diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift index df2a69ec..be53d04a 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsHelpers.swift @@ -18,6 +18,11 @@ func formatTotalMinutes(_ hours: Double) -> String { 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 diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index 4559ca73..1167d8bc 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -41,8 +41,8 @@ struct MonthSectionHeaderView: View, Equatable { var body: some View { HStack { Text(section.displayName) - .font(.system(size: 20, weight: .bold)) - .foregroundColor(.appForegroundAdaptive) + .font(.system(size: 15, weight: .semibold)) + .foregroundColor(.appMutedForegroundAdaptive) Spacer() @@ -80,11 +80,12 @@ extension MonthSectionContentView { movieHours: section.movieHours, seriesHours: section.seriesHours, monthlyHours: section.monthlyHours, - dailyAverage: computeDailyAverage(), - dailyAverageLabel: strings.perDayThisMonth, comparisonHours: section.comparisonHours, + peakTimeSlot: section.peakTimeSlot, + hourlyDistribution: section.hourlyDistribution, + dailyActivity: section.dailyActivity, + percentileRank: section.percentileRank, periodLabel: period == "all" ? strings.allTime : section.displayName, - showComparison: period != "all", strings: strings, userId: userId, period: section.yearMonth @@ -92,9 +93,8 @@ extension MonthSectionContentView { } label: { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { - Text(strings.timeWatched.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) + Text(strings.timeWatched) + .font(.system(size: 13, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) Spacer() Image(systemName: "chevron.right") @@ -104,13 +104,13 @@ extension MonthSectionContentView { .padding(.bottom, 20) HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(formatTotalMinutes(section.totalHours)) - .font(.system(size: 48, weight: .bold, design: .rounded)) + Text(formatTotalHours(section.totalHours)) + .font(.system(size: 34, weight: .bold, design: .rounded)) .foregroundColor(.appForegroundAdaptive) .contentTransition(.numericText(countsDown: false)) - Text(strings.minutes) - .font(.system(size: 16, weight: .medium)) + Text(strings.hours) + .font(.system(size: 14, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) } @@ -154,29 +154,23 @@ extension MonthSectionContentView { func computeDailyAverage() -> Double { guard section.totalHours > 0 else { return 0 } + let cal = Calendar.current if period == "all" { - if let firstMonth = section.monthlyHours.first?.month { - if let startDate = Self.ymFormatter.date(from: firstMonth) { - let days = max(Calendar.current.dateComponents([.day], from: startDate, to: Date()).day ?? 30, 1) - return section.totalHours / Double(days) - } + 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() - let cal = Calendar.current - let sameMonth = cal.isDate(date, equalTo: now, toGranularity: .month) - if sameMonth { - let day = max(cal.component(.day, from: now), 1) - return section.totalHours / Double(day) + if cal.isDate(date, equalTo: now, toGranularity: .month) { + return section.totalHours / Double(max(cal.component(.day, from: now), 1)) } else { - let range = cal.range(of: .day, in: .month, for: date) - return section.totalHours / Double(range?.count ?? 30) + return section.totalHours / Double(cal.range(of: .day, in: .month, for: date)?.count ?? 30) } } - return section.totalHours / 30 } } @@ -198,9 +192,8 @@ extension MonthSectionContentView { } label: { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { - Text(strings.favoriteGenre.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) + Text(strings.favoriteGenre) + .font(.system(size: 13, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) Spacer() if hasGenres { @@ -254,9 +247,8 @@ extension MonthSectionContentView { } label: { VStack(alignment: .leading, spacing: 0) { HStack(alignment: .top) { - Text(strings.bestReview.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) + Text(strings.bestReview) + .font(.system(size: 13, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) Spacer() if hasReviews { @@ -301,11 +293,12 @@ struct TimeWatchedDetailView: View { let movieHours: Double let seriesHours: Double @State var monthlyHours: [MonthlyHoursEntry] - let dailyAverage: Double - let dailyAverageLabel: String let comparisonHours: Double? + @State var peakTimeSlot: PeakTimeSlot? + @State var hourlyDistribution: [HourlyEntry] + @State var dailyActivity: [DailyActivityEntry] + let percentileRank: Int? let periodLabel: String - let showComparison: Bool let strings: Strings var userId: String? = nil var period: String? = nil @@ -313,18 +306,23 @@ struct TimeWatchedDetailView: View { @State private var isScrolled = false init(totalHours: Double, movieHours: Double, seriesHours: Double, monthlyHours: [MonthlyHoursEntry], - dailyAverage: Double, dailyAverageLabel: String, comparisonHours: Double?, - periodLabel: String, showComparison: Bool, strings: Strings, + 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.dailyAverage = dailyAverage - self.dailyAverageLabel = dailyAverageLabel self.comparisonHours = comparisonHours + _peakTimeSlot = State(initialValue: peakTimeSlot) + _hourlyDistribution = State(initialValue: hourlyDistribution) + _dailyActivity = State(initialValue: dailyActivity) + self.percentileRank = percentileRank self.periodLabel = periodLabel - self.showComparison = showComparison self.strings = strings self.userId = userId self.period = period @@ -341,82 +339,63 @@ struct TimeWatchedDetailView: View { } .frame(height: 0) - Text(periodLabel.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(0.5) - .foregroundColor(.appMutedForegroundAdaptive) - - Text(strings.timeWatched) - .font(.system(size: 34, weight: .bold)) - .foregroundColor(.appForegroundAdaptive) - - VStack(alignment: .leading, spacing: 4) { - HStack(alignment: .firstTextBaseline, spacing: 8) { - Text(formatTotalMinutes(totalHours)) - .font(.system(size: 56, weight: .bold, design: .rounded)) - .foregroundColor(.appForegroundAdaptive) - - Text(strings.minutes) - .font(.system(size: 18, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - } + VStack(alignment: .leading, spacing: 8) { + Text(periodLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) - comparisonBadge - .padding(.top, 4) - } + Text(String(format: strings.youSpentWatching, formatTotalHours(totalHours))) + .font(.system(size: 28, weight: .bold)) + .foregroundColor(.appForegroundAdaptive) - if dailyAverage > 0 { - HStack(spacing: 8) { - Image(systemName: "clock.fill") - .font(.system(size: 14)) - .foregroundColor(Color(hex: "3B82F6")) - Text("\(formatHoursMinutes(dailyAverage)) \(dailyAverageLabel)") - .font(.system(size: 16, weight: .semibold)) - .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 } - .padding(.horizontal, 14) - .padding(.vertical, 10) - .background(Color(hex: "3B82F6").opacity(0.08)) - .clipShape(RoundedRectangle(cornerRadius: 12)) } if movieHours > 0 || seriesHours > 0 { - VStack(alignment: .leading, spacing: 12) { - breakdownRow( - color: Color(hex: "3B82F6"), - label: strings.movies, - minutes: formatTotalMinutes(movieHours), - percentage: totalHours > 0 ? movieHours / totalHours * 100 : 0 - ) - breakdownRow( - color: Color(hex: "10B981"), - label: strings.series, - minutes: formatTotalMinutes(seriesHours), - percentage: totalHours > 0 ? seriesHours / totalHours * 100 : 0 - ) - } - .padding(16) - .background(Color.appInputFilled.opacity(0.4)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - } + Divider() - if monthlyHours.count >= 2 { VStack(alignment: .leading, spacing: 12) { - Text(strings.activity.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(1.5) + Text(strings.distribution) + .font(.system(size: 13, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) - chartView + movieSeriesSplitBar } - .padding(16) - .background(Color.appInputFilled.opacity(0.4)) - .clipShape(RoundedRectangle(cornerRadius: 16)) + } + + if !dailyActivity.isEmpty { + Divider() } } .padding(.horizontal, 24) .padding(.top, 16) - .padding(.bottom, 24) + + 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) } .coordinateSpace(name: "scroll") .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in @@ -425,138 +404,497 @@ struct TimeWatchedDetailView: View { } .background(Color.appBackgroundAdaptive.ignoresSafeArea()) .navigationBarHidden(true) - .task { await loadMonthlyHoursIfNeeded() } + .task { await loadDetailDataIfNeeded() } } - private func loadMonthlyHoursIfNeeded() async { - guard let userId, let period, monthlyHours.isEmpty, period != "all" else { return } + private func loadDetailDataIfNeeded() async { + guard let userId, let period, period != "all" else { return } + let needsLoad = monthlyHours.isEmpty || dailyActivity.isEmpty || hourlyDistribution.isEmpty + guard needsLoad else { return } if let result = try? await UserStatsService.shared.getTotalHours(userId: userId, period: period) { - monthlyHours = result.monthlyHours + 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 comparisonBadge: some View { - if showComparison, let comparison = comparisonHours { + var comparisonLine: some View { + if let comparison = comparisonHours, comparison > 0 { let delta = totalHours - comparison - let sign = delta >= 0 ? "+" : "" - let label = String(format: strings.vsLastMonth, "\(sign)\(formatHoursMinutes(abs(delta)))") + 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: 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)) + 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(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)) + .foregroundColor(color) } } - func breakdownRow(color: Color, label: String, minutes: String, percentage: Double) -> some View { - HStack(spacing: 12) { - RoundedRectangle(cornerRadius: 3) - .fill(color) - .frame(width: 4, height: 36) + 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: - Peak Time + + // 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 }) + + return VStack(alignment: .leading, spacing: 16) { + Text(strings.peakTime) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + if let peak = peakHourEntry, peak.count > 0 { + peakSummaryRow(peak: peak) + } + + radialClockChart(maxCount: maxCount, peakHour: peakHourEntry?.hour) + } + } + + @ViewBuilder + private func peakSummaryRow(peak: HourlyEntry) -> some View { + let slotLabel = Self.slotLabel(for: peak.hour, strings: strings) + let icon = Self.slotIcon(for: peak.hour) + let hourLabel = String(format: "%02d:00 – %02d:00", peak.hour, (peak.hour + 1) % 24) + + HStack(spacing: 14) { + ZStack { + Circle() + .fill(Color.appForegroundAdaptive.opacity(0.08)) + .frame(width: 44, height: 44) + Image(systemName: icon) + .font(.system(size: 18)) + .foregroundColor(.appForegroundAdaptive) + } VStack(alignment: .leading, spacing: 2) { - Text(label) - .font(.system(size: 14, weight: .semibold)) + Text(slotLabel) + .font(.system(size: 16, weight: .semibold)) .foregroundColor(.appForegroundAdaptive) - Text("\(minutes) min") + Text(hourLabel) .font(.system(size: 13)) .foregroundColor(.appMutedForegroundAdaptive) } Spacer() - Text(String(format: "%.0f%%", percentage)) - .font(.system(size: 16, weight: .bold, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive) + Text("\(peak.count)") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundColor(.appForegroundAdaptive) } + .padding(16) + .background(Color.appForegroundAdaptive.opacity(0.04)) + .clipShape(RoundedRectangle(cornerRadius: 16)) } - var chartView: some View { - let maxHours = max(monthlyHours.map(\.hours).max() ?? 1, 1) - let avgHours: Double = { - let nonZero = monthlyHours.filter { $0.hours > 0 } - guard !nonZero.isEmpty else { return 0.0 } - return nonZero.map(\.hours).reduce(0, +) / Double(nonZero.count) - }() - let chartHeight: CGFloat = 140 - let gridSteps = computeGridSteps(maxValue: maxHours) - let ceilMax = gridSteps.last ?? maxHours - let isDaily = monthlyHours.first?.month.split(separator: "-").count == 3 - - return VStack(spacing: 0) { - HStack(alignment: .bottom, spacing: 0) { - HStack(alignment: .bottom, spacing: isDaily ? 2 : 6) { - ForEach(monthlyHours) { entry in - let barHeight = entry.hours > 0 ? CGFloat(entry.hours / ceilMax) * chartHeight : 0 - - RoundedRectangle(cornerRadius: isDaily ? 2 : 3) - .fill( - entry.hours > 0 - ? LinearGradient( - colors: [Color(hex: "3B82F6"), Color(hex: "10B981")], - startPoint: .bottom, - endPoint: .top - ) - : LinearGradient( - colors: [Color.appBorderAdaptive.opacity(0.2)], - startPoint: .bottom, - endPoint: .top - ) - ) - .frame(height: max(barHeight, 2)) - .frame(maxWidth: .infinity) + private func radialClockChart(maxCount: Int, peakHour: Int?) -> some View { + let size: CGFloat = 280 + let ringWidth: CGFloat = 24 + let outerRadius = size / 2 + let innerRadius = outerRadius - ringWidth - 40 + + return ZStack { + ForEach(0..<24, id: \.self) { hour in + let entry = hourlyDistribution.first(where: { $0.hour == hour }) + let count = entry?.count ?? 0 + let ratio = Double(count) / Double(maxCount) + let isPeak = hour == peakHour && count > 0 + + let startAngle = Angle.degrees(Double(hour) * 15 - 90) + let endAngle = Angle.degrees(Double(hour + 1) * 15 - 90) + + Path { path in + let barInner = innerRadius + 4 + let barOuter = barInner + (outerRadius - barInner - 2) * CGFloat(max(ratio, 0.04)) + path.addArc(center: CGPoint(x: size / 2, y: size / 2), + radius: barOuter, + startAngle: startAngle + .degrees(0.8), + endAngle: endAngle - .degrees(0.8), + clockwise: false) + path.addArc(center: CGPoint(x: size / 2, y: size / 2), + radius: barInner, + startAngle: endAngle - .degrees(0.8), + endAngle: startAngle + .degrees(0.8), + clockwise: true) + path.closeSubpath() + } + .fill(isPeak + ? Color.appForegroundAdaptive + : Color.appForegroundAdaptive.opacity(ratio > 0 ? 0.12 + ratio * 0.4 : 0.06)) + } + + ForEach([0, 6, 12, 18], id: \.self) { hour in + let angle = Angle.degrees(Double(hour) * 15 - 90) + let labelRadius = innerRadius - 8 + let x = size / 2 + labelRadius * CGFloat(cos(angle.radians)) + let y = size / 2 + labelRadius * CGFloat(sin(angle.radians)) + let label = String(format: "%02d", hour) + + Text(label) + .font(.system(size: 10, weight: .semibold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + .position(x: x, y: y) + } + + VStack(spacing: 2) { + Image(systemName: "clock") + .font(.system(size: 16, weight: .light)) + .foregroundColor(.appMutedForegroundAdaptive) + Text("24h") + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + .frame(width: size, height: size) + .frame(maxWidth: .infinity) + } + + 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 + + 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(Color.appForegroundAdaptive.opacity(0.8)) + .frame(width: geo.size.width * moviePct) + } + if seriesPct > 0 { + RoundedRectangle(cornerRadius: 4) + .fill(Color.appForegroundAdaptive.opacity(0.25)) + .frame(width: geo.size.width * seriesPct) } } - .frame(height: chartHeight) - .overlay( - avgHours > 0 ? - AnyView( - GeometryReader { geo in - let y = geo.size.height - CGFloat(avgHours / ceilMax) * geo.size.height - Path { path in - path.move(to: CGPoint(x: 0, y: y)) - path.addLine(to: CGPoint(x: geo.size.width, y: y)) - } - .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 3])) - .foregroundColor(Color(hex: "F59E0B").opacity(0.6)) - } - ) : AnyView(EmptyView()) - ) + } + .frame(height: 8) - VStack(alignment: .trailing) { - ForEach(Array(gridSteps.reversed().enumerated()), id: \.offset) { idx, step in - if idx > 0 { Spacer() } - Text(formatAxisLabel(step)) - .font(.system(size: 8, weight: .medium, design: .rounded)) + HStack(spacing: 0) { + HStack(spacing: 6) { + Circle() + .fill(Color.appForegroundAdaptive.opacity(0.8)) + .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) } } - .frame(width: 24, height: chartHeight) - .padding(.leading, 4) - } - if !isDaily { + Spacer() + HStack(spacing: 6) { - ForEach(monthlyHours) { entry in - Text(shortMonthLabel(entry.month)) - .font(.system(size: 9, weight: .medium)) + Circle() + .fill(Color.appForegroundAdaptive.opacity(0.25)) + .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 (GitHub-style) + + private static let cellSize: CGFloat = 13 + private static let cellSpacing: CGFloat = 3 + + 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) + let isSingleMonth = monthLabels.count <= 1 + + return VStack(alignment: .leading, spacing: 6) { + Text(strings.activity) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + if isSingleMonth { + calendarHeatmap(weeks: weeks, maxHours: maxHours) + } else { + githubHeatmap(weeks: weeks, monthLabels: monthLabels, maxHours: maxHours) + .padding(.horizontal, -24) + } + + heatmapLegend + } + } + + // MARK: Calendar heatmap (single month) + + private func calendarHeatmap(weeks: [[HeatmapCell]], maxHours: Double) -> some View { + let dayLabels = Self.weekdayLabels() + let calSpacing: CGFloat = 6 + + return GeometryReader { geo in + let cellSize = (geo.size.width - calSpacing * 6) / 7 + + VStack(spacing: calSpacing) { + HStack(spacing: calSpacing) { + ForEach(0..<7, id: \.self) { col in + Text(dayLabels[col]) + .font(.system(size: 11, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) - .frame(maxWidth: .infinity) + .frame(width: cellSize, height: 20) + } + } + + ForEach(Array(weeks.enumerated()), id: \.offset) { _, week in + HStack(spacing: calSpacing) { + ForEach(week, id: \.id) { cell in + if cell.isPadding { + Color.clear + .frame(width: cellSize, height: cellSize) + } else { + Text("\(cell.dayNumber)") + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundColor(cell.hours > 0 ? .white : .appMutedForegroundAdaptive) + .frame(width: cellSize, height: cellSize) + .background(Self.heatmapColor(hours: cell.hours, max: maxHours)) + .clipShape(RoundedRectangle(cornerRadius: 8)) + } + } } + } + } + } + .aspectRatio(7.0 / CGFloat(weeks.count + 1), contentMode: .fit) + } + + // 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(.top, 4) } + .padding(.horizontal, 24) } } + + private var heatmapLegend: some View { + HStack(spacing: 4) { + Text(strings.less) + .font(.system(size: 10)) + .foregroundColor(.appMutedForegroundAdaptive) + + ForEach([0.0, 0.2, 0.4, 0.6, 0.8], id: \.self) { level in + RoundedRectangle(cornerRadius: 2) + .fill(level > 0 + ? Color.appForegroundAdaptive.opacity(level * 0.65 + 0.1) + : Color.appForegroundAdaptive.opacity(0.06)) + .frame(width: 12, height: 12) + } + + Text(strings.more) + .font(.system(size: 10)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + + private static func heatmapColor(hours: Double, max: Double) -> Color { + if hours <= 0 { + return Color.appForegroundAdaptive.opacity(0.06) + } + let intensity = Swift.max(hours / max, 0.12) + return Color.appForegroundAdaptive.opacity(intensity * 0.65 + 0.1) + } + + private struct HeatmapCell: Identifiable { + let id: String + let hours: Double + let weekday: Int + let month: Int + let dayNumber: Int + var isPadding: Bool { month == 0 } + } + + private struct MonthLabel { + let name: String + let span: Int + let offset: Int + } + + private 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) + // Pad the first week with empty cells (Sunday = 1) + 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 + } + + private 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 + } } // MARK: - All Genres Detail View @@ -597,9 +935,8 @@ struct PeriodGenresView: View { } .frame(height: 0) - Text(periodLabel.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(0.5) + Text(periodLabel) + .font(.system(size: 13, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) Text(strings.favoriteGenres) @@ -882,9 +1219,8 @@ struct PeriodReviewsView: View { } .frame(height: 0) - Text(periodLabel.uppercased()) - .font(.system(size: 11, weight: .medium)) - .tracking(0.5) + Text(periodLabel) + .font(.system(size: 13, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) Text(strings.bestReviews) @@ -1059,11 +1395,11 @@ struct StatsShareCardView: View { VStack(alignment: .leading, spacing: 4) { HStack(alignment: .firstTextBaseline, spacing: 6) { - Text(formatTotalMinutes(section.totalHours)) + Text(formatTotalHours(section.totalHours)) .font(.system(size: 52, weight: .heavy, design: .rounded)) .foregroundColor(.white) - Text(strings.minutes) + Text(strings.hours) .font(.system(size: 15, weight: .medium)) .foregroundColor(.white.opacity(0.5)) } @@ -1072,13 +1408,13 @@ struct StatsShareCardView: View { HStack(spacing: 14) { HStack(spacing: 5) { Circle().fill(accentBlue).frame(width: 6, height: 6) - Text("\(strings.movies) \(formatTotalMinutes(section.movieHours))m") + Text("\(strings.movies) \(formatHoursMinutes(section.movieHours))") .font(.system(size: 11, weight: .medium)) .foregroundColor(.white.opacity(0.5)) } HStack(spacing: 5) { Circle().fill(accentGreen).frame(width: 6, height: 6) - Text("\(strings.series) \(formatTotalMinutes(section.seriesHours))m") + Text("\(strings.series) \(formatHoursMinutes(section.seriesHours))") .font(.system(size: 11, weight: .medium)) .foregroundColor(.white.opacity(0.5)) } diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index 53cd2ec4..b9a9b321 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -18,6 +18,10 @@ struct MonthSection: Identifiable, Equatable { 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 } @@ -84,7 +88,9 @@ struct MonthSection: Identifiable, Equatable { lhs.topReview?.title == rhs.topReview?.title && lhs.watchedGenres.count == rhs.watchedGenres.count && lhs.bestReviews.count == rhs.bestReviews.count && - lhs.comparisonHours == rhs.comparisonHours + lhs.comparisonHours == rhs.comparisonHours && + lhs.peakTimeSlot?.slot == rhs.peakTimeSlot?.slot && + lhs.hourlyDistribution.count == rhs.hourlyDistribution.count } static func currentYearMonth() -> String { @@ -265,9 +271,42 @@ struct ProfileStatsView: View { } } + private var allTimeDateRange: String? { + guard let first = allTimeSection.monthlyHours.first?.month else { return nil } + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM" + let locale = Locale(identifier: Language.current.rawValue.replacingOccurrences(of: "-", with: "_")) + fmt.locale = locale + guard let startDate = fmt.date(from: first) else { return nil } + let display = DateFormatter() + display.dateFormat = "MMM yyyy" + display.locale = locale + let start = display.string(from: startDate) + let end = display.string(from: Date()) + return "\(start.prefix(1).uppercased())\(start.dropFirst()) – \(end.prefix(1).uppercased())\(end.dropFirst())" + } + @ViewBuilder var allTimeSectionContent: some View { if allTimeSection.isLoaded { + HStack { + if let range = allTimeDateRange { + Text(range) + .font(.system(size: 14, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + } + + Spacer() + + if isOwnProfile { + Button { shareMonthStats(allTimeSection) } label: { + Image(systemName: "square.and.arrow.up") + .font(.system(size: 16, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + } + } + } + MonthSectionContentView( section: allTimeSection, userId: userId, @@ -478,6 +517,10 @@ struct ProfileStatsView: View { allTimeSection.movieHours = hours.movieHours allTimeSection.seriesHours = hours.seriesHours allTimeSection.monthlyHours = hours.monthlyHours + allTimeSection.peakTimeSlot = hours.peakTimeSlot + allTimeSection.hourlyDistribution = hours.hourlyDistribution ?? [] + allTimeSection.dailyActivity = hours.dailyActivity ?? [] + allTimeSection.percentileRank = hours.percentileRank } if let genres { allTimeSection.watchedGenres = genres From fd6a6919abea3612ce42ca845c97c758d0249413 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 22:29:40 -0300 Subject: [PATCH 11/16] feat: enhance TimeWatchedDetailView with improved data loading and visualization - Updated the task identifier for loading detail data to include dynamic parameters, ensuring accurate data fetching based on user activity. - Refined the logic for determining when to fetch data, focusing on daily activity and hourly distribution metrics. - Introduced a new method to calculate the peak day of the week from daily activity, enhancing the analytics capabilities. - Replaced the peak summary row with a more detailed peak visualization that includes bars representing hourly distribution, improving user engagement and understanding of viewing patterns. These changes aim to provide a more comprehensive and visually appealing representation of user viewing habits in the TimeWatchedDetailView. --- .../Views/Home/ProfileStatsSections.swift | 263 +++++++++--------- 1 file changed, 137 insertions(+), 126 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index 1167d8bc..7c54680e 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -404,13 +404,15 @@ struct TimeWatchedDetailView: View { } .background(Color.appBackgroundAdaptive.ignoresSafeArea()) .navigationBarHidden(true) - .task { await loadDetailDataIfNeeded() } + .task(id: "\(period ?? "")-\(dailyActivity.count)-\(hourlyDistribution.count)") { + await loadDetailDataIfNeeded() + } } private func loadDetailDataIfNeeded() async { guard let userId, let period, period != "all" else { return } - let needsLoad = monthlyHours.isEmpty || dailyActivity.isEmpty || hourlyDistribution.isEmpty - guard needsLoad 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 ?? [] } @@ -466,6 +468,7 @@ struct TimeWatchedDetailView: View { 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) @@ -473,109 +476,103 @@ struct TimeWatchedDetailView: View { .foregroundColor(.appMutedForegroundAdaptive) if let peak = peakHourEntry, peak.count > 0 { - peakSummaryRow(peak: peak) + peakWithBars(peak: peak, peakDay: peakDayName, maxCount: maxCount) } - - radialClockChart(maxCount: maxCount, peakHour: peakHourEntry?.hour) } } @ViewBuilder - private func peakSummaryRow(peak: HourlyEntry) -> some View { + private func peakWithBars(peak: HourlyEntry, peakDay: String?, maxCount: Int) -> some View { let slotLabel = Self.slotLabel(for: peak.hour, strings: strings) - let icon = Self.slotIcon(for: peak.hour) - let hourLabel = String(format: "%02d:00 – %02d:00", peak.hour, (peak.hour + 1) % 24) - - HStack(spacing: 14) { - ZStack { - Circle() - .fill(Color.appForegroundAdaptive.opacity(0.08)) - .frame(width: 44, height: 44) - Image(systemName: icon) - .font(.system(size: 18)) - .foregroundColor(.appForegroundAdaptive) - } + let hourLabel = String(format: "%02d:00", peak.hour) + let barHeight: CGFloat = 60 + VStack(alignment: .leading, spacing: 14) { VStack(alignment: .leading, spacing: 2) { - Text(slotLabel) - .font(.system(size: 16, weight: .semibold)) - .foregroundColor(.appForegroundAdaptive) + 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: 13)) + .font(.system(size: 14, weight: .medium, design: .rounded)) .foregroundColor(.appMutedForegroundAdaptive) } - Spacer() + 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) - Text("\(peak.count)") - .font(.system(size: 28, weight: .bold, design: .rounded)) - .foregroundColor(.appForegroundAdaptive) + 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 func radialClockChart(maxCount: Int, peakHour: Int?) -> some View { - let size: CGFloat = 280 - let ringWidth: CGFloat = 24 - let outerRadius = size / 2 - let innerRadius = outerRadius - ringWidth - 40 - - return ZStack { - ForEach(0..<24, id: \.self) { hour in - let entry = hourlyDistribution.first(where: { $0.hour == hour }) - let count = entry?.count ?? 0 - let ratio = Double(count) / Double(maxCount) - let isPeak = hour == peakHour && count > 0 - - let startAngle = Angle.degrees(Double(hour) * 15 - 90) - let endAngle = Angle.degrees(Double(hour + 1) * 15 - 90) - - Path { path in - let barInner = innerRadius + 4 - let barOuter = barInner + (outerRadius - barInner - 2) * CGFloat(max(ratio, 0.04)) - path.addArc(center: CGPoint(x: size / 2, y: size / 2), - radius: barOuter, - startAngle: startAngle + .degrees(0.8), - endAngle: endAngle - .degrees(0.8), - clockwise: false) - path.addArc(center: CGPoint(x: size / 2, y: size / 2), - radius: barInner, - startAngle: endAngle - .degrees(0.8), - endAngle: startAngle + .degrees(0.8), - clockwise: true) - path.closeSubpath() - } - .fill(isPeak - ? Color.appForegroundAdaptive - : Color.appForegroundAdaptive.opacity(ratio > 0 ? 0.12 + ratio * 0.4 : 0.06)) - } - - ForEach([0, 6, 12, 18], id: \.self) { hour in - let angle = Angle.degrees(Double(hour) * 15 - 90) - let labelRadius = innerRadius - 8 - let x = size / 2 + labelRadius * CGFloat(cos(angle.radians)) - let y = size / 2 + labelRadius * CGFloat(sin(angle.radians)) - let label = String(format: "%02d", hour) + private static func peakDayOfWeek(from activity: [DailyActivityEntry]) -> String? { + guard !activity.isEmpty else { return nil } - Text(label) - .font(.system(size: 10, weight: .semibold, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive) - .position(x: x, y: y) - } + var dayCounts = [Int: Double]() + let fmt = DateFormatter() + fmt.dateFormat = "yyyy-MM-dd" - VStack(spacing: 2) { - Image(systemName: "clock") - .font(.system(size: 16, weight: .light)) - .foregroundColor(.appMutedForegroundAdaptive) - Text("24h") - .font(.system(size: 13, weight: .semibold, design: .rounded)) - .foregroundColor(.appMutedForegroundAdaptive) - } + 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 } - .frame(width: size, height: size) - .frame(maxWidth: .infinity) + + 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 { @@ -598,6 +595,9 @@ struct TimeWatchedDetailView: View { // 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 @@ -607,12 +607,12 @@ struct TimeWatchedDetailView: View { HStack(spacing: 2) { if moviePct > 0 { RoundedRectangle(cornerRadius: 4) - .fill(Color.appForegroundAdaptive.opacity(0.8)) + .fill(Self.movieColor) .frame(width: geo.size.width * moviePct) } if seriesPct > 0 { RoundedRectangle(cornerRadius: 4) - .fill(Color.appForegroundAdaptive.opacity(0.25)) + .fill(Self.seriesColor) .frame(width: geo.size.width * seriesPct) } } @@ -622,7 +622,7 @@ struct TimeWatchedDetailView: View { HStack(spacing: 0) { HStack(spacing: 6) { Circle() - .fill(Color.appForegroundAdaptive.opacity(0.8)) + .fill(Self.movieColor) .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 1) { Text(strings.movies) @@ -638,7 +638,7 @@ struct TimeWatchedDetailView: View { HStack(spacing: 6) { Circle() - .fill(Color.appForegroundAdaptive.opacity(0.25)) + .fill(Self.seriesColor) .frame(width: 8, height: 8) VStack(alignment: .leading, spacing: 1) { Text(strings.series) @@ -658,18 +658,23 @@ struct TimeWatchedDetailView: View { 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) - let isSingleMonth = monthLabels.count <= 1 - return VStack(alignment: .leading, spacing: 6) { + return VStack(alignment: .leading, spacing: 12) { Text(strings.activity) .font(.system(size: 13, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) - if isSingleMonth { + if isSingleMonthPeriod { calendarHeatmap(weeks: weeks, maxHours: maxHours) } else { githubHeatmap(weeks: weeks, monthLabels: monthLabels, maxHours: maxHours) @@ -677,6 +682,7 @@ struct TimeWatchedDetailView: View { } heatmapLegend + .padding(.top, 4) } } @@ -686,39 +692,34 @@ struct TimeWatchedDetailView: View { let dayLabels = Self.weekdayLabels() let calSpacing: CGFloat = 6 - return GeometryReader { geo in - let cellSize = (geo.size.width - calSpacing * 6) / 7 + let columns = Array(repeating: GridItem(.flexible(), spacing: calSpacing), count: 7) - VStack(spacing: calSpacing) { - HStack(spacing: calSpacing) { - ForEach(0..<7, id: \.self) { col in - Text(dayLabels[col]) - .font(.system(size: 11, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - .frame(width: cellSize, height: 20) - } - } + 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 - HStack(spacing: calSpacing) { - ForEach(week, id: \.id) { cell in - if cell.isPadding { - Color.clear - .frame(width: cellSize, height: cellSize) - } else { + 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) - .frame(width: cellSize, height: cellSize) - .background(Self.heatmapColor(hours: cell.hours, max: maxHours)) - .clipShape(RoundedRectangle(cornerRadius: 8)) - } - } + ) } } } } - .aspectRatio(7.0 / CGFloat(weeks.count + 1), contentMode: .fit) } // MARK: GitHub-style heatmap (multi-month) @@ -780,11 +781,11 @@ struct TimeWatchedDetailView: View { .font(.system(size: 10)) .foregroundColor(.appMutedForegroundAdaptive) - ForEach([0.0, 0.2, 0.4, 0.6, 0.8], id: \.self) { level in + ForEach([0.0, 0.25, 0.5, 0.75, 1.0], id: \.self) { level in RoundedRectangle(cornerRadius: 2) .fill(level > 0 - ? Color.appForegroundAdaptive.opacity(level * 0.65 + 0.1) - : Color.appForegroundAdaptive.opacity(0.06)) + ? Self.heatmapColor(hours: level, max: 1.0) + : Self.heatmapEmpty) .frame(width: 12, height: 12) } @@ -794,12 +795,22 @@ struct TimeWatchedDetailView: View { } } + private static let heatmapLow = Color(hex: "9BE9A8") + private static let heatmapHigh = Color(hex: "216E39") + private static let heatmapEmpty = Color.appForegroundAdaptive.opacity(0.06) + private static func heatmapColor(hours: Double, max: Double) -> Color { - if hours <= 0 { - return Color.appForegroundAdaptive.opacity(0.06) - } - let intensity = Swift.max(hours / max, 0.12) - return Color.appForegroundAdaptive.opacity(intensity * 0.65 + 0.1) + 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 } private struct HeatmapCell: Identifiable { @@ -1366,8 +1377,8 @@ struct StatsShareCardView: View { private let cardWidth: CGFloat = 1080 / 3 private let cardHeight: CGFloat = 1920 / 3 - private let accentBlue = Color(hex: "3B82F6") - private let accentGreen = Color(hex: "10B981") + private let accentBlue = Color(hex: "60A5FA") + private let accentPurple = Color(hex: "A78BFA") var body: some View { ZStack { @@ -1413,7 +1424,7 @@ struct StatsShareCardView: View { .foregroundColor(.white.opacity(0.5)) } HStack(spacing: 5) { - Circle().fill(accentGreen).frame(width: 6, height: 6) + 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)) From 8d905154a6c78131beb5eaf5e2644bc23df1bd70 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 22:56:24 -0300 Subject: [PATCH 12/16] feat: replace timeline/general tabs with month dropdown selector Replace the segmented picker (timeline vs general) with a native Menu dropdown that lets users select a specific month or "Geral" (lifetime). Defaults to the current month. Each period loads data on-demand and caches it for instant switching. Co-authored-by: Cursor --- .../Views/Home/ProfileStatsView.swift | 404 +++++++----------- 1 file changed, 156 insertions(+), 248 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index b9a9b321..1b74dc1c 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -106,20 +106,6 @@ struct MonthSection: Identifiable, Equatable { } } -// MARK: - Stats Mode - -enum StatsMode: String, CaseIterable { - case timeline - case allTime - - var displayName: (Strings) -> String { - switch self { - case .timeline: return { $0.timeline } - case .allTime: return { $0.allTime } - } - } -} - // MARK: - ProfileStatsView struct ProfileStatsView: View { @@ -128,18 +114,11 @@ struct ProfileStatsView: View { let isOwnProfile: Bool @State var strings = L10n.current - @State var mode: StatsMode = .timeline - - // Timeline state - @State var monthSections: [MonthSection] = [] - @State var isLoadingMore = false - @State var nextCursor: String? = nil - @State var hasMore = true - @State private var timelineLoadId = UUID() + @State var selectedPeriod: String = MonthSection.currentYearMonth() + @State private var availableMonths: [String] = [] - // All-time state - @State var allTimeSection = MonthSection(yearMonth: "all") - @State var hasStartedLoading = false + @State private var loadedSections: [String: MonthSection] = [:] + @State private var loadingPeriods: Set = [] let cache = ProfileStatsCache.shared @@ -149,38 +128,56 @@ struct ProfileStatsView: View { self.isOwnProfile = isOwnProfile } + private var currentSection: MonthSection? { + loadedSections[selectedPeriod] + } + + private var isAllTime: Bool { + selectedPeriod == "all" + } + var body: some View { VStack(spacing: 0) { - modeSelector + periodHeader + .padding(.horizontal, 24) + .padding(.vertical, 12) + + 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, 8) - - if mode == .timeline { - timelineBody - } else { - allTimeBody + .padding(.bottom, 24) } } .task { - if mode == .timeline { - await loadTimeline() - } else { - await loadAllTime() - } + buildAvailableMonths() + await loadPeriod(selectedPeriod) } - .onChange(of: mode) { _, newMode in - Task { - if newMode == .timeline { - if monthSections.isEmpty { - await loadTimeline() - } - } else { - if !allTimeSection.isLoaded { - await loadAllTime() - } - } - } + .onChange(of: selectedPeriod) { _, newPeriod in + Task { await loadPeriod(newPeriod) } } .onAppear { AnalyticsService.shared.track(.statsView) @@ -188,138 +185,91 @@ struct ProfileStatsView: View { .onReceive(NotificationCenter.default.publisher(for: .languageChanged)) { _ in strings = L10n.current } - } - - // MARK: - Mode Selector - - var modeSelector: some View { - Picker("", selection: $mode) { - ForEach(StatsMode.allCases, id: \.self) { m in - Text(m.displayName(strings)) - .tag(m) - } + .refreshable { + cache.invalidate(userId: userId, period: selectedPeriod) + loadedSections.removeValue(forKey: selectedPeriod) + await loadPeriod(selectedPeriod) } - .pickerStyle(.segmented) } - // MARK: - Timeline Body - - var timelineBody: some View { - ScrollView { - LazyVStack(spacing: 0, pinnedViews: [.sectionHeaders]) { - ForEach(monthSections) { section in - Section { - MonthSectionContentView( - section: section, - userId: userId, - strings: strings, - period: section.yearMonth - ) - .equatable() - .padding(.horizontal, 24) - .padding(.bottom, 48) - } header: { - MonthSectionHeaderView( - section: section, - isOwnProfile: isOwnProfile, - onShare: { shareMonthStats(section) } - ) - .equatable() + // MARK: - Period Header + + private var periodHeader: some View { + HStack { + Menu { + Button { + withAnimation(.easeInOut(duration: 0.2)) { selectedPeriod = "all" } + } label: { + HStack { + Text(strings.allTime) + if selectedPeriod == "all" { Image(systemName: "checkmark") } } } - if hasMore { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.vertical, 48) - .task { await loadTimeline() } - .id("load-more-\(monthSections.count)") + Divider() + + ForEach(availableMonths.filter { $0 != "all" }, id: \.self) { period in + Button { + withAnimation(.easeInOut(duration: 0.2)) { selectedPeriod = period } + } label: { + 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(.top, 8) - } - .coordinateSpace(name: "statsTimeline") - .refreshable { - monthSections = [] - nextCursor = nil - hasMore = true - isLoadingMore = false - timelineLoadId = UUID() - await loadTimeline() - } - } - // MARK: - All-Time Body + Spacer() - var allTimeBody: some View { - ScrollView { - VStack(spacing: 16) { - if allTimeSection.isLoaded && !allTimeSection.hasMinimumData { - emptyStateView(isAllTime: true) - } else { - allTimeSectionContent + 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) } } - .padding(.horizontal, 24) - .padding(.top, 16) - .padding(.bottom, 24) - } - .refreshable { - cache.invalidate(userId: userId, period: "all") - allTimeSection = MonthSection(yearMonth: "all") - await loadAllTime() } } - private var allTimeDateRange: String? { - guard let first = allTimeSection.monthlyHours.first?.month else { return nil } + // 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 startDate = fmt.date(from: first) else { return nil } + guard let date = fmt.date(from: period) else { return period } let display = DateFormatter() - display.dateFormat = "MMM yyyy" + display.dateFormat = "MMMM yyyy" display.locale = locale - let start = display.string(from: startDate) - let end = display.string(from: Date()) - return "\(start.prefix(1).uppercased())\(start.dropFirst()) – \(end.prefix(1).uppercased())\(end.dropFirst())" + let result = display.string(from: date) + return result.prefix(1).uppercased() + result.dropFirst() } - @ViewBuilder - var allTimeSectionContent: some View { - if allTimeSection.isLoaded { - HStack { - if let range = allTimeDateRange { - Text(range) - .font(.system(size: 14, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - } - - Spacer() - - if isOwnProfile { - Button { shareMonthStats(allTimeSection) } label: { - Image(systemName: "square.and.arrow.up") - .font(.system(size: 16, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - } - } + 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)) } - - MonthSectionContentView( - section: allTimeSection, - userId: userId, - strings: strings, - period: "all" - ) - .equatable() - .transition(.opacity.animation(.easeIn(duration: 0.25))) - } else if hasStartedLoading { - ProgressView() - .frame(maxWidth: .infinity) - .padding(.vertical, 48) } + months.append("all") + availableMonths = months } // MARK: - Skeleton @@ -425,73 +375,17 @@ struct ProfileStatsView: View { return image } - // MARK: - Load Timeline (Paginated) - - @MainActor - func loadTimeline() async { - guard !isLoadingMore, hasMore else { return } - let currentLoadId = timelineLoadId - isLoadingMore = true - defer { isLoadingMore = false } - - do { - let language = Language.current.rawValue - - let response = try await UserStatsService.shared.getStatsTimeline( - userId: userId, - language: language, - cursor: nextCursor, - pageSize: 3 - ) - - guard currentLoadId == timelineLoadId else { return } - - var newSections: [MonthSection] = response.sections.map { section in - MonthSection( - yearMonth: section.yearMonth, - totalHours: section.totalHours, - movieHours: section.movieHours, - seriesHours: section.seriesHours, - topGenre: section.topGenre, - topReview: section.topReview, - isLoaded: true - ) - } - - for i in 0.. Date: Wed, 18 Feb 2026 23:35:39 -0300 Subject: [PATCH 13/16] feat: add PeriodGenresView and PeriodReviewsView components for enhanced user experience - Introduced PeriodGenresView to display user favorite genres with loading state and genre details. - Added PeriodReviewsView to showcase best reviews for a selected period, including loading state and review details. - Implemented shared components for consistent UI, including detail headers and genre item displays. - Enhanced ProfileStatsSections to integrate new views, improving overall user engagement and data presentation. These additions aim to provide users with a richer and more interactive experience when exploring their viewing habits and preferences. --- .../Views/Home/PeriodGenresView.swift | 287 ++++ .../Views/Home/PeriodReviewsView.swift | 83 + .../Views/Home/ProfileStatsSections.swift | 1334 +---------------- .../Views/Home/StatsSharedComponents.swift | 266 ++++ .../Views/Home/TimeWatchedDetailView.swift | 626 ++++++++ 5 files changed, 1324 insertions(+), 1272 deletions(-) create mode 100644 apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift create mode 100644 apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift create mode 100644 apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift create mode 100644 apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift 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 00000000..712b0fc2 --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift @@ -0,0 +1,287 @@ +// +// 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 isScrolled = false + @State private var isLoading = false + + 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) { + GeometryReader { geo in + Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) + } + .frame(height: 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) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + isScrolled = value < -10 + } + } + } + .background(Color.appBackgroundAdaptive.ignoresSafeArea()) + .navigationBarHidden(true) + .task { await loadGenresIfNeeded() } + } + + 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 00000000..3fd9e93d --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift @@ -0,0 +1,83 @@ +// +// 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 isScrolled = false + @State private var isLoading = false + + 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) { + GeometryReader { geo in + Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) + } + .frame(height: 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) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + isScrolled = value < -10 + } + } + } + .background(Color.appBackgroundAdaptive.ignoresSafeArea()) + .navigationBarHidden(true) + .task { await loadReviewsIfNeeded() } + } + + 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/ProfileStatsSections.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift index 7c54680e..41081396 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsSections.swift @@ -19,10 +19,35 @@ struct MonthSectionContentView: View, Equatable { var body: some View { VStack(spacing: 16) { timeWatchedCard - HStack(alignment: .top, spacing: 12) { - topGenreCard - topReviewCard - } + 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 } } } @@ -181,9 +206,12 @@ 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: section.watchedGenres, + genres: genres, periodLabel: section.yearMonth == "all" ? strings.allTime : section.displayName, strings: strings, userId: userId, @@ -202,17 +230,32 @@ extension MonthSectionContentView { .foregroundColor(.appMutedForegroundAdaptive) } } - .padding(.bottom, 20) - - if let name = section.topGenreName { - Text(name) - .font(.system(size: 18, weight: .bold)) - .foregroundColor(.appForegroundAdaptive) - .lineLimit(1) - .padding(.bottom, 10) + .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) + } - if let posterURL = section.topGenrePosterURL { - statsPoster(url: posterURL) + 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("–") @@ -222,8 +265,6 @@ extension MonthSectionContentView { } .padding(16) .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background(Color.statsCardBackground) - .clipShape(RoundedRectangle(cornerRadius: 22)) } .buttonStyle(.plain) .disabled(!hasGenres) @@ -261,9 +302,10 @@ extension MonthSectionContentView { if let title = section.topReviewTitle { Text(title) - .font(.system(size: 18, weight: .bold)) + .font(.system(size: 16, weight: .bold)) .foregroundColor(.appForegroundAdaptive) - .lineLimit(1) + .lineLimit(2) + .minimumScaleFactor(0.85) .padding(.bottom, 10) statsPoster(url: section.topReviewPosterURL, rating: section.topReviewRating) @@ -274,7 +316,7 @@ extension MonthSectionContentView { } } .padding(16) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .frame(maxWidth: .infinity, alignment: .topLeading) .background(Color.statsCardBackground) .clipShape(RoundedRectangle(cornerRadius: 22)) } @@ -282,1255 +324,3 @@ extension MonthSectionContentView { .disabled(!hasReviews) } } - - -// MARK: - Time Watched Detail View - -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 isScrolled = false - - 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(alignment: .leading, spacing: 24) { - GeometryReader { geo in - Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) - } - .frame(height: 0) - - 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) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in - isScrolled = value < -40 - } - } - .background(Color.appBackgroundAdaptive.ignoresSafeArea()) - .navigationBarHidden(true) - .task(id: "\(period ?? "")-\(dailyActivity.count)-\(hourlyDistribution.count)") { - await loadDetailDataIfNeeded() - } - } - - 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: - Peak Time - - // 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 (GitHub-style) - - 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) - } - } - - private static let heatmapLow = Color(hex: "9BE9A8") - private static let heatmapHigh = Color(hex: "216E39") - private static let heatmapEmpty = Color.appForegroundAdaptive.opacity(0.06) - - private 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 - } - - private struct HeatmapCell: Identifiable { - let id: String - let hours: Double - let weekday: Int - let month: Int - let dayNumber: Int - var isPadding: Bool { month == 0 } - } - - private struct MonthLabel { - let name: String - let span: Int - let offset: Int - } - - private 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) - // Pad the first week with empty cells (Sunday = 1) - 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 - } - - private 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 - } -} - -// MARK: - All Genres Detail View - -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 isScrolled = false - @State private var isLoading = false - - 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) { - GeometryReader { geo in - Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) - } - .frame(height: 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) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in - isScrolled = value < -40 - } - } - } - .background(Color.appBackgroundAdaptive.ignoresSafeArea()) - .navigationBarHidden(true) - .task { await loadGenresIfNeeded() } - } - - 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 - -private 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 - -private 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) - } - } -} - -// MARK: - All Reviews Detail View - -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 isScrolled = false - @State private var isLoading = false - - 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) { - GeometryReader { geo in - Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) - } - .frame(height: 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) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in - isScrolled = value < -40 - } - } - } - .background(Color.appBackgroundAdaptive.ignoresSafeArea()) - .navigationBarHidden(true) - .task { await loadReviewsIfNeeded() } - } - - 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 - } -} - -// MARK: - Shared Detail Header - -@ViewBuilder -func detailHeaderView(title: String, isScrolled: Bool, onBack: @escaping () -> Void) -> some View { - 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) - - // Posters - 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() - - // Footer with logo - 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/StatsSharedComponents.swift b/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift new file mode 100644 index 00000000..4f72204e --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift @@ -0,0 +1,266 @@ +// +// StatsSharedComponents.swift +// Plotwist + +import SwiftUI + +// MARK: - Shared Detail Header + +@ViewBuilder +func detailHeaderView(title: String, isScrolled: Bool, onBack: @escaping () -> Void) -> some View { + 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 00000000..043fd0e3 --- /dev/null +++ b/apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift @@ -0,0 +1,626 @@ +// +// 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 isScrolled = false + + 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(alignment: .leading, spacing: 24) { + GeometryReader { geo in + Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) + } + .frame(height: 0) + + 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) + } + .coordinateSpace(name: "scroll") + .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in + isScrolled = value < -10 + } + } + .background(Color.appBackgroundAdaptive.ignoresSafeArea()) + .navigationBarHidden(true) + .task(id: "\(period ?? "")-\(dailyActivity.count)-\(hourlyDistribution.count)") { + await loadDetailDataIfNeeded() + } + } + + 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 + } +} From 5b8cb37b959af3f5b9449d199f1e509db79911f9 Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Wed, 18 Feb 2026 23:47:38 -0300 Subject: [PATCH 14/16] feat: refactor scrolling behavior in PeriodGenresView, PeriodReviewsView, and TimeWatchedDetailView - Replaced the previous scrolling detection mechanism with a more efficient approach using scroll offset tracking. - Updated PeriodGenresView and PeriodReviewsView to utilize a new scroll offset reader for improved user experience. - Enhanced TimeWatchedDetailView with similar scroll offset functionality, ensuring consistent behavior across views. - Adjusted ProfileStatsView to set the default selected period to "all" and removed unnecessary animations for smoother transitions. These changes aim to enhance the responsiveness and interactivity of the user interface when navigating through different views. --- .../Views/Home/PeriodGenresView.swift | 40 ++++-- .../Views/Home/PeriodReviewsView.swift | 32 +++-- .../Views/Home/ProfileStatsView.swift | 8 +- .../Views/Home/StatsSharedComponents.swift | 1 + .../Views/Home/TimeWatchedDetailView.swift | 121 ++++++++++-------- 5 files changed, 123 insertions(+), 79 deletions(-) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift index 712b0fc2..808e8729 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodGenresView.swift @@ -13,9 +13,16 @@ struct PeriodGenresView: View { var userId: String? = nil var period: String? = nil - @State private var isScrolled = false + @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 @@ -35,16 +42,11 @@ struct PeriodGenresView: View { } else { ScrollView { VStack(alignment: .leading, spacing: 0) { - GeometryReader { geo in - Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) - } - .frame(height: 0) + Text(periodLabel) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) - Text(periodLabel) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) - - Text(strings.favoriteGenres) + Text(strings.favoriteGenres) .font(.system(size: 34, weight: .bold)) .foregroundColor(.appForegroundAdaptive) .padding(.bottom, 20) @@ -63,10 +65,7 @@ struct PeriodGenresView: View { .padding(.horizontal, 24) .padding(.top, 16) .padding(.bottom, 24) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in - isScrolled = value < -10 + .background(scrollOffsetReader) } } } @@ -75,6 +74,19 @@ struct PeriodGenresView: View { .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)") diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift index 3fd9e93d..729612dc 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/PeriodReviewsView.swift @@ -13,9 +13,16 @@ struct PeriodReviewsView: View { var userId: String? = nil var period: String? = nil - @State private var isScrolled = false + @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 @@ -35,11 +42,6 @@ struct PeriodReviewsView: View { } else { ScrollView { VStack(alignment: .leading, spacing: 0) { - GeometryReader { geo in - Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) - } - .frame(height: 0) - Text(periodLabel) .font(.system(size: 13, weight: .medium)) .foregroundColor(.appMutedForegroundAdaptive) @@ -58,10 +60,7 @@ struct PeriodReviewsView: View { .padding(.horizontal, 24) .padding(.top, 16) .padding(.bottom, 24) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in - isScrolled = value < -10 + .background(scrollOffsetReader) } } } @@ -70,6 +69,19 @@ struct PeriodReviewsView: View { .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 diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift index 1b74dc1c..edc67f47 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/ProfileStatsView.swift @@ -114,7 +114,7 @@ struct ProfileStatsView: View { let isOwnProfile: Bool @State var strings = L10n.current - @State var selectedPeriod: String = MonthSection.currentYearMonth() + @State var selectedPeriod: String = "all" @State private var availableMonths: [String] = [] @State private var loadedSections: [String: MonthSection] = [:] @@ -141,6 +141,7 @@ struct ProfileStatsView: View { periodHeader .padding(.horizontal, 24) .padding(.vertical, 12) + .transaction { $0.animation = nil } ScrollView { VStack(spacing: 16) { @@ -198,7 +199,7 @@ struct ProfileStatsView: View { HStack { Menu { Button { - withAnimation(.easeInOut(duration: 0.2)) { selectedPeriod = "all" } + selectedPeriod = "all" } label: { HStack { Text(strings.allTime) @@ -210,7 +211,7 @@ struct ProfileStatsView: View { ForEach(availableMonths.filter { $0 != "all" }, id: \.self) { period in Button { - withAnimation(.easeInOut(duration: 0.2)) { selectedPeriod = period } + selectedPeriod = period } label: { HStack { Text(periodDisplayLabel(for: period)) @@ -229,6 +230,7 @@ struct ProfileStatsView: View { .foregroundColor(.appMutedForegroundAdaptive) } } + .id(selectedPeriod) Spacer() diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift b/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift index 4f72204e..a0836929 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/StatsSharedComponents.swift @@ -8,6 +8,7 @@ import SwiftUI @ViewBuilder func detailHeaderView(title: String, isScrolled: Bool, onBack: @escaping () -> Void) -> some View { + let _ = print("[detailHeader] title=\(title) isScrolled=\(isScrolled)") ZStack { if isScrolled { Text(title) diff --git a/apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift b/apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift index 043fd0e3..02ad8e81 100644 --- a/apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift +++ b/apps/ios/Plotwist/Plotwist/Views/Home/TimeWatchedDetailView.swift @@ -21,7 +21,14 @@ struct TimeWatchedDetailView: View { var userId: String? = nil var period: String? = nil - @State private var isScrolled = false + @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, @@ -51,73 +58,67 @@ struct TimeWatchedDetailView: View { detailHeaderView(title: strings.timeWatched, isScrolled: isScrolled) { dismiss() } ScrollView { - VStack(alignment: .leading, spacing: 24) { - GeometryReader { geo in - Color.clear.preference(key: ScrollOffsetPreferenceKey.self, value: geo.frame(in: .named("scroll")).minY) - } - .frame(height: 0) - - VStack(alignment: .leading, spacing: 8) { - Text(periodLabel) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) + 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) + 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)) + 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 } - .foregroundColor(Color(hex: "F59E0B")) - } else { - comparisonLine } - } - if movieHours > 0 || seriesHours > 0 { - Divider() + if movieHours > 0 || seriesHours > 0 { + Divider() - VStack(alignment: .leading, spacing: 12) { - Text(strings.distribution) - .font(.system(size: 13, weight: .medium)) - .foregroundColor(.appMutedForegroundAdaptive) + VStack(alignment: .leading, spacing: 12) { + Text(strings.distribution) + .font(.system(size: 13, weight: .medium)) + .foregroundColor(.appMutedForegroundAdaptive) + + movieSeriesSplitBar + } + } - movieSeriesSplitBar + if !dailyActivity.isEmpty { + Divider() } } + .padding(.horizontal, 24) + .padding(.top, 16) if !dailyActivity.isEmpty { - Divider() + activityHeatmap + .padding(.horizontal, 24) + .padding(.top, 8) } - } - .padding(.horizontal, 24) - .padding(.top, 16) - if !dailyActivity.isEmpty { - activityHeatmap + if !hourlyDistribution.isEmpty { + VStack(alignment: .leading, spacing: 24) { + Divider() + hourlyDistributionChart + } .padding(.horizontal, 24) - .padding(.top, 8) - } - - if !hourlyDistribution.isEmpty { - VStack(alignment: .leading, spacing: 24) { - Divider() - hourlyDistributionChart + .padding(.top, 16) } - .padding(.horizontal, 24) - .padding(.top, 16) - } - Spacer().frame(height: 24) - } - .coordinateSpace(name: "scroll") - .onPreferenceChange(ScrollOffsetPreferenceKey.self) { value in - isScrolled = value < -10 + Spacer().frame(height: 24) + } + .background(scrollOffsetReader) } } .background(Color.appBackgroundAdaptive.ignoresSafeArea()) @@ -127,6 +128,22 @@ struct TimeWatchedDetailView: View { } } + 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 From ec66eee1fc5ee566537a5571eb3400025ac391fc Mon Sep 17 00:00:00 2001 From: henrique <7henrique18@gmail.com> Date: Fri, 27 Feb 2026 16:51:17 -0300 Subject: [PATCH 15/16] feat: enhance SEO and structured data integration across the application - Removed unoptimized image loading in the Next.js configuration for improved performance. - Added structured data components including WebsiteJsonLd, OrganizationJsonLd, MovieJsonLd, and BreadcrumbJsonLd to enhance SEO. - Implemented HtmlLangSetter component to dynamically set the document language based on user preferences. - Updated metadata generation in various pages to include language alternates for better search engine indexing. - Refactored existing pages to utilize the new buildLanguageAlternates utility for consistent SEO practices. These changes aim to improve the application's visibility and indexing in search engines, enhancing overall user experience. --- apps/web/next.config.mjs | 1 - apps/web/src/app/[lang]/animes/page.tsx | 2 + apps/web/src/app/[lang]/docs/roadmap/page.tsx | 14 +- apps/web/src/app/[lang]/doramas/page.tsx | 2 + apps/web/src/app/[lang]/home/page.tsx | 2 + apps/web/src/app/[lang]/layout.tsx | 2 + apps/web/src/app/[lang]/lists/page.tsx | 2 + .../movies/[id]/_components/movie-details.tsx | 22 +++ .../src/app/[lang]/movies/discover/page.tsx | 2 + .../app/[lang]/movies/now-playing/page.tsx | 2 + .../src/app/[lang]/movies/popular/page.tsx | 2 + .../src/app/[lang]/movies/top-rated/page.tsx | 2 + .../src/app/[lang]/movies/upcoming/page.tsx | 2 + apps/web/src/app/[lang]/page.tsx | 18 +-- apps/web/src/app/[lang]/pricing/page.tsx | 2 + apps/web/src/app/[lang]/privacy/page.tsx | 12 +- apps/web/src/app/[lang]/sitemap.xml/route.ts | 8 +- .../[id]/_components/tv-serie-details.tsx | 20 +++ .../[lang]/tv-series/airing-today/page.tsx | 2 + .../app/[lang]/tv-series/discover/page.tsx | 2 + .../app/[lang]/tv-series/on-the-air/page.tsx | 2 + .../src/app/[lang]/tv-series/popular/page.tsx | 2 + .../app/[lang]/tv-series/top-rated/page.tsx | 2 + apps/web/src/app/layout.tsx | 9 +- apps/web/src/components/html-lang-setter.tsx | 10 ++ apps/web/src/components/structured-data.tsx | 147 ++++++++++++++++++ apps/web/src/utils/seo.ts | 29 ++++ 27 files changed, 296 insertions(+), 26 deletions(-) create mode 100644 apps/web/src/components/html-lang-setter.tsx create mode 100644 apps/web/src/components/structured-data.tsx create mode 100644 apps/web/src/utils/seo.ts diff --git a/apps/web/next.config.mjs b/apps/web/next.config.mjs index b14d65e2..e057daae 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 8171fc2d..8b3b941f 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 3c77a31f..51ffd8ba 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 ccfeab8f..fd329b1d 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 5ccc8659..f862e3e3 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 e11c973f..ecaea837 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 2d411542..39aa4ea3 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 41415a8c..951b9711 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 3e5c257e..61c532e7 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 86f5d3fe..513bfd67 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 d0d67e2d..38fe7513 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 e81c1406..d2e8f549 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 0b3aa52d..8199ca75 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 8f949095..46a6d4c0 100644 --- a/apps/web/src/app/[lang]/page.tsx +++ b/apps/web/src/app/[lang]/page.tsx @@ -4,8 +4,8 @@ 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 { Hero } from './_components/hero' import { Images } from './_components/images' @@ -14,17 +14,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` @@ -55,10 +44,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 c2d89929..57e6ae4b 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 8953b8fd..0024c04c 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 0a06b0ed..ae7fc530 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 6df974b8..59943e8a 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 53401981..42ef3ab6 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 3465279a..1c228c05 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 99d7a756..bc5c1fe4 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 8b3493c9..f53d9f0a 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 182b1047..a05e8881 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 9c1cd84b..149da46d 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 00000000..a3a036fb --- /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 00000000..731665bf --- /dev/null +++ b/apps/web/src/components/structured-data.tsx @@ -0,0 +1,147 @@ +type JsonLdProps = { + data: Record +} + +export function JsonLd({ data }: JsonLdProps) { + return ( +