diff --git a/apps/api/package.json b/apps/api/package.json index 7bdf10b..bc29a4a 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -18,6 +18,10 @@ "types": "./src/modules/platforms/model.ts", "default": "./src/modules/platforms/model.ts" }, + "./preferences": { + "types": "./src/modules/preferences/model.ts", + "default": "./src/modules/preferences/model.ts" + }, "./recommendations": { "types": "./src/modules/recommendations/model.ts", "default": "./src/modules/recommendations/model.ts" diff --git a/apps/api/src/modules/analytics/helpers/discovery-flow.ts b/apps/api/src/modules/analytics/helpers/discovery-flow.ts index dcaea56..30e19b8 100644 --- a/apps/api/src/modules/analytics/helpers/discovery-flow.ts +++ b/apps/api/src/modules/analytics/helpers/discovery-flow.ts @@ -78,7 +78,8 @@ export function attributeDiscovery( list.push(impression); byMedia.set(key, list); } - for (const list of byMedia.values()) list.sort((a, b) => a.shownAt.getTime() - b.shownAt.getTime()); + for (const list of byMedia.values()) + list.sort((a, b) => a.shownAt.getTime() - b.shownAt.getTime()); // Impressions always carry a concrete media type; an interaction logged without // one matches a title across both, oldest-first. diff --git a/apps/api/src/modules/analytics/helpers/overview.ts b/apps/api/src/modules/analytics/helpers/overview.ts index 059b202..0857d62 100644 --- a/apps/api/src/modules/analytics/helpers/overview.ts +++ b/apps/api/src/modules/analytics/helpers/overview.ts @@ -61,7 +61,9 @@ export function buildOverview( total_minutes: minutes, media_count: current.media, episode_count: current.episode, - average_rating: current.ratingCount ? storedToStars(current.ratingSum / current.ratingCount) : null, + average_rating: current.ratingCount + ? storedToStars(current.ratingSum / current.ratingCount) + : null, current_era: computeCurrentEra(currentEntries.filter((entry) => entry.kind === "media")), previous: { total_minutes: previousMinutes, @@ -72,7 +74,9 @@ export function buildOverview( minutes: minutes - previousMinutes, media_count: current.media - previous.media, minutes_pct: - previousMinutes > 0 ? Math.round(((minutes - previousMinutes) / previousMinutes) * 100) / 100 : null, + previousMinutes > 0 + ? Math.round(((minutes - previousMinutes) / previousMinutes) * 100) / 100 + : null, }, watchlist_backlog: watchlistBacklog, }; diff --git a/apps/api/src/modules/analytics/helpers/timeline.ts b/apps/api/src/modules/analytics/helpers/timeline.ts index a3e256d..2ee26c9 100644 --- a/apps/api/src/modules/analytics/helpers/timeline.ts +++ b/apps/api/src/modules/analytics/helpers/timeline.ts @@ -40,7 +40,9 @@ export function buildTimeline(entries: WatchEntry[], period: Period, timeZone: s const granularity = granularityFor(period.range); const keys = enumerateBuckets(period.from, period.to, granularity, timeZone); const keyOf = (entry: WatchEntry) => - granularity === "day" ? tzDayKey(entry.watchedAt, timeZone) : tzMonthKey(entry.watchedAt, timeZone); + granularity === "day" + ? tzDayKey(entry.watchedAt, timeZone) + : tzMonthKey(entry.watchedAt, timeZone); const grouped = new Map(); for (const key of keys) grouped.set(key, []); @@ -51,7 +53,9 @@ export function buildTimeline(entries: WatchEntry[], period: Period, timeZone: s const buckets: TimelineBucket[] = keys.map((key) => { const bucketEntries = grouped.get(key) ?? []; - const watchedTime = bucketEntries.length ? accumulateWatchedTime(bucketEntries) : emptyWatchedTime(); + const watchedTime = bucketEntries.length + ? accumulateWatchedTime(bucketEntries) + : emptyWatchedTime(); return { key, label: labelFor(key, period.range, granularity), diff --git a/apps/api/src/modules/analytics/model.ts b/apps/api/src/modules/analytics/model.ts index ce4dfe3..0ea5099 100644 --- a/apps/api/src/modules/analytics/model.ts +++ b/apps/api/src/modules/analytics/model.ts @@ -1,12 +1,7 @@ import { Elysia, t } from "elysia"; const mediaType = t.Union([t.Literal("movie"), t.Literal("tv")]); -const range = t.Union([ - t.Literal("week"), - t.Literal("month"), - t.Literal("year"), - t.Literal("all"), -]); +const range = t.Union([t.Literal("week"), t.Literal("month"), t.Literal("year"), t.Literal("all")]); const source = t.Union([ t.Literal("content"), t.Literal("collaborative"), diff --git a/apps/api/src/modules/analytics/queries/fetch-watch-entries.ts b/apps/api/src/modules/analytics/queries/fetch-watch-entries.ts index 2736bdc..3135e66 100644 --- a/apps/api/src/modules/analytics/queries/fetch-watch-entries.ts +++ b/apps/api/src/modules/analytics/queries/fetch-watch-entries.ts @@ -45,7 +45,9 @@ export async function fetchWatchEntries( movies, and(eq(reviews.tmdbId, movies.tmdbId), eq(reviews.mediaType, movies.mediaType)), ) - .where(and(eq(reviews.userId, userId), gte(reviews.watchedAt, from), lt(reviews.watchedAt, to))), + .where( + and(eq(reviews.userId, userId), gte(reviews.watchedAt, from), lt(reviews.watchedAt, to)), + ), db .select({ watchedAt: episodeReviews.watchedAt, diff --git a/apps/api/src/modules/analytics/queries/timeline-items.ts b/apps/api/src/modules/analytics/queries/timeline-items.ts index bea8bc8..4d24cc8 100644 --- a/apps/api/src/modules/analytics/queries/timeline-items.ts +++ b/apps/api/src/modules/analytics/queries/timeline-items.ts @@ -52,7 +52,9 @@ export async function getTimelineItems( movies, and(eq(reviews.tmdbId, movies.tmdbId), eq(reviews.mediaType, movies.mediaType)), ) - .where(and(eq(reviews.userId, userId), gte(reviews.watchedAt, from), lt(reviews.watchedAt, to))) + .where( + and(eq(reviews.userId, userId), gte(reviews.watchedAt, from), lt(reviews.watchedAt, to)), + ) .orderBy(desc(reviews.watchedAt)) .limit(MAX_ITEMS), db diff --git a/apps/api/src/modules/analytics/router.ts b/apps/api/src/modules/analytics/router.ts index 100a31a..e5bf815 100644 --- a/apps/api/src/modules/analytics/router.ts +++ b/apps/api/src/modules/analytics/router.ts @@ -38,15 +38,11 @@ export const analyticsController = new Elysia({ response: { 200: "analytics.Timeline" }, }, ) - .get( - "/timeline/items", - ({ user, query }) => getTimelineItems(user.id, query.from, query.to), - { - auth: true, - query: "analytics.TimelineItemsQuery", - response: { 200: "analytics.TimelineItems" }, - }, - ) + .get("/timeline/items", ({ user, query }) => getTimelineItems(user.id, query.from, query.to), { + auth: true, + query: "analytics.TimelineItemsQuery", + response: { 200: "analytics.TimelineItems" }, + }) .get( "/taste", ({ user, query }) => getTaste(user.id, query.range ?? DEFAULT_RANGE, query.timezone), diff --git a/apps/api/src/modules/analytics/tests/discovery-flow.test.ts b/apps/api/src/modules/analytics/tests/discovery-flow.test.ts index 677e8e6..7a513ac 100644 --- a/apps/api/src/modules/analytics/tests/discovery-flow.test.ts +++ b/apps/api/src/modules/analytics/tests/discovery-flow.test.ts @@ -12,7 +12,14 @@ const PERIOD: Period = { previous_to: "2026-06-01T00:00:00.000Z", }; -const noFlags = { clicked: false, addedToWatchlist: false, markedWatched: false, rated: false, shared: false, dismissed: false }; +const noFlags = { + clicked: false, + addedToWatchlist: false, + markedWatched: false, + rated: false, + shared: false, + dismissed: false, +}; type ImpressionOverrides = Partial> & { flags?: Partial; @@ -41,15 +48,42 @@ function interaction(o: Partial): DiscoveryInteraction { describe("attributeDiscovery", () => { test("credits outcomes to the most recent impression, dedupes flags, ignores stale/organic", () => { const impressions = [ - impression({ tmdbId: 1, source: "trending", shownAt: new Date("2026-06-05T10:00:00Z"), flags: { clicked: true } }), - impression({ tmdbId: 2, source: "availability", shownAt: new Date("2026-06-10T10:00:00Z"), flags: { dismissed: true } }), + impression({ + tmdbId: 1, + source: "trending", + shownAt: new Date("2026-06-05T10:00:00Z"), + flags: { clicked: true }, + }), + impression({ + tmdbId: 2, + source: "availability", + shownAt: new Date("2026-06-10T10:00:00Z"), + flags: { dismissed: true }, + }), // shown 17 days before the interaction → outside the 14-day window - impression({ tmdbId: 3, source: "content", shownAt: new Date("2026-05-20T10:00:00Z"), inRange: false }), + impression({ + tmdbId: 3, + source: "content", + shownAt: new Date("2026-05-20T10:00:00Z"), + inRange: false, + }), ]; const interactions = [ - interaction({ tmdbId: 1, type: "opened_detail", createdAt: new Date("2026-06-05T11:00:00Z") }), - interaction({ tmdbId: 1, type: "added_watchlist", createdAt: new Date("2026-06-06T09:00:00Z") }), - interaction({ tmdbId: 3, type: "opened_detail", createdAt: new Date("2026-06-06T09:00:00Z") }), + interaction({ + tmdbId: 1, + type: "opened_detail", + createdAt: new Date("2026-06-05T11:00:00Z"), + }), + interaction({ + tmdbId: 1, + type: "added_watchlist", + createdAt: new Date("2026-06-06T09:00:00Z"), + }), + interaction({ + tmdbId: 3, + type: "opened_detail", + createdAt: new Date("2026-06-06T09:00:00Z"), + }), interaction({ tmdbId: 99, type: "rated", createdAt: new Date("2026-06-07T09:00:00Z") }), ]; @@ -72,7 +106,13 @@ describe("attributeDiscovery", () => { test("interactions outside the period window are not counted", () => { const flow = attributeDiscovery( [impression({ tmdbId: 1, source: "trending", shownAt: new Date("2026-06-05T10:00:00Z") })], - [interaction({ tmdbId: 1, type: "opened_detail", createdAt: new Date("2026-07-05T10:00:00Z") })], + [ + interaction({ + tmdbId: 1, + type: "opened_detail", + createdAt: new Date("2026-07-05T10:00:00Z"), + }), + ], PERIOD, ); const trending = flow.by_source.find((row) => row.source === "trending"); diff --git a/apps/api/src/modules/analytics/tests/range.test.ts b/apps/api/src/modules/analytics/tests/range.test.ts index 8e4a5de..6d61b6d 100644 --- a/apps/api/src/modules/analytics/tests/range.test.ts +++ b/apps/api/src/modules/analytics/tests/range.test.ts @@ -24,9 +24,9 @@ describe("computeRange", () => { expect(period.to).toBe(now.toISOString()); expect(period.previous_to).toBe(period.from); // previous window is exactly 7 days earlier - expect(new Date(period.from).getTime() - new Date(period.previous_from as string).getTime()).toBe( - 7 * 24 * 60 * 60 * 1000, - ); + expect( + new Date(period.from).getTime() - new Date(period.previous_from as string).getTime(), + ).toBe(7 * 24 * 60 * 60 * 1000); expect(from.getTime()).toBeLessThanOrEqual(now.getTime()); }); @@ -61,22 +61,12 @@ describe("computeRange", () => { describe("enumerateBuckets", () => { test("day buckets are contiguous and inclusive of the end", () => { - const keys = enumerateBuckets( - "2026-06-01T00:00:00Z", - "2026-06-03T12:00:00Z", - "day", - "UTC", - ); + const keys = enumerateBuckets("2026-06-01T00:00:00Z", "2026-06-03T12:00:00Z", "day", "UTC"); expect(keys).toEqual(["2026-06-01", "2026-06-02", "2026-06-03"]); }); test("month buckets roll over the year boundary", () => { - const keys = enumerateBuckets( - "2025-11-15T00:00:00Z", - "2026-02-01T00:00:00Z", - "month", - "UTC", - ); + const keys = enumerateBuckets("2025-11-15T00:00:00Z", "2026-02-01T00:00:00Z", "month", "UTC"); expect(keys).toEqual(["2025-11", "2025-12", "2026-01", "2026-02"]); }); }); diff --git a/apps/api/src/modules/analytics/tests/timeline.test.ts b/apps/api/src/modules/analytics/tests/timeline.test.ts index 8256fcb..9ac7ae0 100644 --- a/apps/api/src/modules/analytics/tests/timeline.test.ts +++ b/apps/api/src/modules/analytics/tests/timeline.test.ts @@ -17,9 +17,25 @@ describe("buildTimeline", () => { test("buckets entries by day and fills empty days", () => { const timeline = buildTimeline( [ - entry({ mediaType: "movie", runtimeMinutes: 100, runtimeConfidence: "exact", watchedAt: new Date("2026-06-02T10:00:00Z") }), - entry({ kind: "episode", mediaType: "tv", runtimeMinutes: 50, runtimeConfidence: "exact", watchedAt: new Date("2026-06-02T20:00:00Z") }), - entry({ mediaType: "movie", runtimeMinutes: 90, runtimeConfidence: "exact", watchedAt: new Date("2026-06-05T10:00:00Z") }), + entry({ + mediaType: "movie", + runtimeMinutes: 100, + runtimeConfidence: "exact", + watchedAt: new Date("2026-06-02T10:00:00Z"), + }), + entry({ + kind: "episode", + mediaType: "tv", + runtimeMinutes: 50, + runtimeConfidence: "exact", + watchedAt: new Date("2026-06-02T20:00:00Z"), + }), + entry({ + mediaType: "movie", + runtimeMinutes: 90, + runtimeConfidence: "exact", + watchedAt: new Date("2026-06-05T10:00:00Z"), + }), ], WEEK, "UTC", diff --git a/apps/api/src/modules/analytics/tests/watched-time.test.ts b/apps/api/src/modules/analytics/tests/watched-time.test.ts index d1dc8a7..03e2470 100644 --- a/apps/api/src/modules/analytics/tests/watched-time.test.ts +++ b/apps/api/src/modules/analytics/tests/watched-time.test.ts @@ -11,7 +11,12 @@ describe("accumulateWatchedTime", () => { const time = accumulateWatchedTime([ entry({ mediaType: "movie", runtimeMinutes: 120, runtimeConfidence: "exact" }), entry({ mediaType: "movie", runtimeMinutes: null, runtimeConfidence: "unknown" }), - entry({ kind: "episode", mediaType: "tv", runtimeMinutes: 45, runtimeConfidence: "estimated" }), + entry({ + kind: "episode", + mediaType: "tv", + runtimeMinutes: 45, + runtimeConfidence: "estimated", + }), entry({ kind: "episode", mediaType: "tv", runtimeMinutes: 50, runtimeConfidence: "exact" }), // a series-level tv review: a log, not minutes — must not touch any bucket entry({ mediaType: "tv", kind: "media", runtimeMinutes: null, countsTowardTime: false }), @@ -22,9 +27,7 @@ describe("accumulateWatchedTime", () => { }); test("an exact entry with zero minutes counts as unknown, not exact", () => { - const time = accumulateWatchedTime([ - entry({ runtimeMinutes: 0, runtimeConfidence: "exact" }), - ]); + const time = accumulateWatchedTime([entry({ runtimeMinutes: 0, runtimeConfidence: "exact" })]); expect(time.exact_minutes).toBe(0); expect(time.unknown_count).toBe(1); }); @@ -55,16 +58,21 @@ describe("buildOverview", () => { const previous = [ entry({ mediaType: "movie", runtimeMinutes: 50, runtimeConfidence: "exact" }), ]; - const overview = buildOverview(current, previous, { - count: 3, - movie_count: 2, - tv_count: 1, - added_in_range: 1, - watched_in_range: 1, - per_week: 1, - weeks_to_clear: 3, - oldest_added_at: null, - }, period); + const overview = buildOverview( + current, + previous, + { + count: 3, + movie_count: 2, + tv_count: 1, + added_in_range: 1, + watched_in_range: 1, + per_week: 1, + weeks_to_clear: 3, + oldest_added_at: null, + }, + period, + ); expect(overview.total_minutes).toBe(150); expect(overview.media_count).toBe(1); diff --git a/apps/api/src/modules/analytics/tz.ts b/apps/api/src/modules/analytics/tz.ts index d0f6a63..7e281ef 100644 --- a/apps/api/src/modules/analytics/tz.ts +++ b/apps/api/src/modules/analytics/tz.ts @@ -64,12 +64,7 @@ function tzOffsetMs(instant: Date, timeZone: string): number { // The UTC instant for wall-clock midnight of a calendar day in the tz. Resolved in // two passes so DST transition days still land on the correct midnight. -export function zonedMidnight( - year: number, - month: number, - day: number, - timeZone: string, -): Date { +export function zonedMidnight(year: number, month: number, day: number, timeZone: string): Date { const guess = Date.UTC(year, month - 1, day, 0, 0, 0); const offset = tzOffsetMs(new Date(guess), timeZone); let utc = guess - offset; diff --git a/apps/api/src/modules/episode-reviews/model.ts b/apps/api/src/modules/episode-reviews/model.ts index 9474ae6..2063661 100644 --- a/apps/api/src/modules/episode-reviews/model.ts +++ b/apps/api/src/modules/episode-reviews/model.ts @@ -22,11 +22,7 @@ const episodeReview = t.Object({ title: t.Nullable(t.String()), comment: t.Nullable(t.String()), runtime_minutes: t.Nullable(t.Number()), - runtime_confidence: t.Union([ - t.Literal("exact"), - t.Literal("estimated"), - t.Literal("unknown"), - ]), + runtime_confidence: t.Union([t.Literal("exact"), t.Literal("estimated"), t.Literal("unknown")]), watched_at: t.String(), created_at: t.String(), updated_at: t.String(), diff --git a/apps/api/src/modules/import/queries/parse-csv.ts b/apps/api/src/modules/import/queries/parse-csv.ts index da6c227..2e78751 100644 --- a/apps/api/src/modules/import/queries/parse-csv.ts +++ b/apps/api/src/modules/import/queries/parse-csv.ts @@ -83,7 +83,10 @@ type ReviewSource = { priority: number; }; -function rowWatchedDate(record: CsvRecord, dateColumn: "Watched Date" | "Date"): string | undefined { +function rowWatchedDate( + record: CsvRecord, + dateColumn: "Watched Date" | "Date", +): string | undefined { if (dateColumn === "Watched Date") { return parseLetterboxdDate(record["Watched Date"]) ?? parseLetterboxdDate(record["Date"]); } @@ -161,8 +164,18 @@ export function parseLetterboxdFiles(files: Record): NormalizedR const records = (name: string) => (files[name] ? toRecords(files[name]) : []); return [ ...buildReviewRows([ - { records: records("diary.csv"), withComment: false, dateColumn: "Watched Date", priority: 4 }, - { records: records("reviews.csv"), withComment: true, dateColumn: "Watched Date", priority: 3 }, + { + records: records("diary.csv"), + withComment: false, + dateColumn: "Watched Date", + priority: 4, + }, + { + records: records("reviews.csv"), + withComment: true, + dateColumn: "Watched Date", + priority: 3, + }, { records: records("ratings.csv"), withComment: false, dateColumn: "Date", priority: 2 }, { records: records("watched.csv"), withComment: false, dateColumn: "Date", priority: 1 }, ]), diff --git a/apps/api/src/modules/preferences/index.ts b/apps/api/src/modules/preferences/index.ts new file mode 100644 index 0000000..3bc9595 --- /dev/null +++ b/apps/api/src/modules/preferences/index.ts @@ -0,0 +1,8 @@ +export { preferencesController } from "./router"; +export type { + PreferencesMeDto, + PreferencesInputDto, + SeedItemDto, + SwipeItemDto, + SwipeResultDto, +} from "./model"; diff --git a/apps/api/src/modules/preferences/model.ts b/apps/api/src/modules/preferences/model.ts new file mode 100644 index 0000000..5322a0e --- /dev/null +++ b/apps/api/src/modules/preferences/model.ts @@ -0,0 +1,59 @@ +import { Elysia, t } from "elysia"; +import type { Static } from "@sinclair/typebox"; + +const mediaType = t.Union([t.Literal("movie"), t.Literal("tv")]); + +const input = t.Object({ + favorite_genres: t.Array(t.Integer()), + disliked_genres: t.Array(t.Integer()), + moods: t.Array(t.String()), +}); + +// The stored shape is the input plus the server-managed timestamp. +const me = t.Composite([input, t.Object({ updated_at: t.Nullable(t.String()) })]); + +// Card payload for the onboarding swipe deck — a subset of the TMDB summary +// shape so the client can render it with its existing poster components. +const seedItem = t.Object({ + id: t.Number(), + media_type: mediaType, + title: t.Optional(t.String()), + original_title: t.Optional(t.String()), + overview: t.Optional(t.String()), + release_date: t.Optional(t.String()), + poster_path: t.Optional(t.Nullable(t.String())), + backdrop_path: t.Optional(t.Nullable(t.String())), + vote_average: t.Optional(t.Number()), + genre_ids: t.Optional(t.Array(t.Number())), +}); + +const seedList = t.Array(seedItem); + +const swipeItem = t.Object({ + tmdb_id: t.Number(), + media_type: mediaType, + choice: t.Union([t.Literal("like"), t.Literal("dislike")]), +}); + +const swipeBatch = t.Object({ + items: t.Array(swipeItem, { maxItems: 100 }), +}); + +const swipeResult = t.Object({ + liked: t.Number(), + disliked: t.Number(), +}); + +export const PreferencesModel = new Elysia({ name: "Preferences.Model" }).model({ + "preferences.Me": me, + "preferences.Input": input, + "preferences.SeedList": seedList, + "preferences.SwipeBatch": swipeBatch, + "preferences.SwipeResult": swipeResult, +}); + +export type PreferencesMeDto = Static; +export type PreferencesInputDto = Static; +export type SeedItemDto = Static; +export type SwipeItemDto = Static; +export type SwipeResultDto = Static; diff --git a/apps/api/src/modules/preferences/mutations/index.ts b/apps/api/src/modules/preferences/mutations/index.ts new file mode 100644 index 0000000..ae34c32 --- /dev/null +++ b/apps/api/src/modules/preferences/mutations/index.ts @@ -0,0 +1,2 @@ +export { setMyPreferences } from "./set-me"; +export { recordOnboardingSwipes } from "./record-swipes"; diff --git a/apps/api/src/modules/preferences/mutations/record-swipes.ts b/apps/api/src/modules/preferences/mutations/record-swipes.ts new file mode 100644 index 0000000..57ce8f4 --- /dev/null +++ b/apps/api/src/modules/preferences/mutations/record-swipes.ts @@ -0,0 +1,53 @@ +import { recordInteractions } from "../../events/mutations"; +import type { InteractionEventInput } from "../../events/shared"; +import { addLike } from "../../likes/mutations"; +import { dismiss } from "../../not-interested/mutations"; +import type { MediaType } from "../../tmdb"; + +export type OnboardingSwipe = { + tmdb_id: number; + media_type: MediaType; + choice: "like" | "dislike"; +}; + +// Collapses repeated swipes on the same title (last choice wins) so counts and +// interaction_events aren't double-written when the client sends duplicates. +function dedupeSwipes(items: OnboardingSwipe[]): OnboardingSwipe[] { + const byKey = new Map(); + for (const item of items) byKey.set(`${item.media_type}:${item.tmdb_id}`, item); + return [...byKey.values()]; +} + +// One write path for an onboarding swipe batch: likes → likes table, dislikes → +// not-interested (reason "onboarding"), and every choice → interaction_events +// (source "onboarding") so #12 can weight it. Skips never reach here. +// +// Per-item writes run concurrently and a single failed title (e.g. a TMDB +// lookup error) is skipped rather than aborting the batch — so one bad id can't +// leave likes/dismisses persisted with their interaction_events dropped. Each +// event is emitted only for a write that succeeded, keeping the two consistent. +export async function recordOnboardingSwipes(userId: string, items: OnboardingSwipe[]) { + const results = await Promise.all( + dedupeSwipes(items).map(async (item): Promise => { + const ref = { tmdb_id: item.tmdb_id, media_type: item.media_type }; + try { + if (item.choice === "like") { + await addLike(userId, { ...ref, kind: "like" }); + return { type: "liked", ...ref, metadata: { source: "onboarding" } }; + } + await dismiss(userId, { ...ref, reason: "onboarding" }); + return { type: "not_interested", ...ref, metadata: { source: "onboarding" } }; + } catch { + return null; + } + }), + ); + + const events = results.filter((event): event is InteractionEventInput => event !== null); + await recordInteractions(userId, events); + + return { + liked: events.filter((event) => event.type === "liked").length, + disliked: events.filter((event) => event.type === "not_interested").length, + }; +} diff --git a/apps/api/src/modules/preferences/mutations/set-me.ts b/apps/api/src/modules/preferences/mutations/set-me.ts new file mode 100644 index 0000000..31818f0 --- /dev/null +++ b/apps/api/src/modules/preferences/mutations/set-me.ts @@ -0,0 +1,29 @@ +import { db } from "@seen/db"; +import { userPreferences } from "@seen/db/schema"; + +import { type PreferencesInput, toPreferences, validatePreferences } from "../shared"; + +export async function setMyPreferences(userId: string, input: PreferencesInput) { + const clean = validatePreferences(input); + + const [row] = await db + .insert(userPreferences) + .values({ + userId, + favoriteGenres: clean.favorite_genres, + dislikedGenres: clean.disliked_genres, + moods: clean.moods, + }) + .onConflictDoUpdate({ + target: userPreferences.userId, + set: { + favoriteGenres: clean.favorite_genres, + dislikedGenres: clean.disliked_genres, + moods: clean.moods, + updatedAt: new Date(), + }, + }) + .returning(); + + return toPreferences(row); +} diff --git a/apps/api/src/modules/preferences/queries/get-me.ts b/apps/api/src/modules/preferences/queries/get-me.ts new file mode 100644 index 0000000..968e905 --- /dev/null +++ b/apps/api/src/modules/preferences/queries/get-me.ts @@ -0,0 +1,15 @@ +import { db } from "@seen/db"; +import { userPreferences } from "@seen/db/schema"; +import { eq } from "@seen/db/orm"; + +import { toPreferences } from "../shared"; + +export async function getMyPreferences(userId: string) { + const [row] = await db + .select() + .from(userPreferences) + .where(eq(userPreferences.userId, userId)) + .limit(1); + + return toPreferences(row); +} diff --git a/apps/api/src/modules/preferences/queries/index.ts b/apps/api/src/modules/preferences/queries/index.ts new file mode 100644 index 0000000..0baeb90 --- /dev/null +++ b/apps/api/src/modules/preferences/queries/index.ts @@ -0,0 +1,2 @@ +export { getMyPreferences } from "./get-me"; +export { getOnboardingSeed } from "./onboarding-seed"; diff --git a/apps/api/src/modules/preferences/queries/onboarding-seed.ts b/apps/api/src/modules/preferences/queries/onboarding-seed.ts new file mode 100644 index 0000000..87dc1b7 --- /dev/null +++ b/apps/api/src/modules/preferences/queries/onboarding-seed.ts @@ -0,0 +1,75 @@ +import { db } from "@seen/db"; +import { likes, notInterested, reviews, watchlist } from "@seen/db/schema"; +import { and, eq, inArray } from "@seen/db/orm"; + +import { getMediaDetail } from "../../tmdb"; +import type { MovieDetailDto } from "../../tmdb/model"; +import type { SeedItemDto } from "../model"; +import { SEED_TITLES } from "../seed"; + +// Titles the user has already acted on — we never ask "seen this?" about them. +async function getSeenKeys(userId: string, tmdbIds: number[]): Promise> { + if (tmdbIds.length === 0) return new Set(); + + const [reviewed, listed, liked, dismissed] = await Promise.all([ + db + .select({ tmdbId: reviews.tmdbId, mediaType: reviews.mediaType }) + .from(reviews) + .where(and(eq(reviews.userId, userId), inArray(reviews.tmdbId, tmdbIds))), + db + .select({ tmdbId: watchlist.tmdbId, mediaType: watchlist.mediaType }) + .from(watchlist) + .where(and(eq(watchlist.userId, userId), inArray(watchlist.tmdbId, tmdbIds))), + db + .select({ tmdbId: likes.tmdbId, mediaType: likes.mediaType }) + .from(likes) + .where(and(eq(likes.userId, userId), inArray(likes.tmdbId, tmdbIds))), + db + .select({ tmdbId: notInterested.tmdbId, mediaType: notInterested.mediaType }) + .from(notInterested) + .where(and(eq(notInterested.userId, userId), inArray(notInterested.tmdbId, tmdbIds))), + ]); + + const seen = new Set(); + for (const rows of [reviewed, listed, liked, dismissed]) { + for (const row of rows) seen.add(`${row.mediaType}:${row.tmdbId}`); + } + return seen; +} + +function toSeedItem(detail: MovieDetailDto): SeedItemDto { + return { + id: detail.id, + media_type: detail.media_type, + title: detail.title, + original_title: detail.original_title, + overview: detail.overview, + release_date: detail.release_date, + poster_path: detail.poster_path ?? null, + backdrop_path: detail.backdrop_path ?? null, + vote_average: detail.vote_average, + genre_ids: detail.genre_ids ?? detail.genres?.map((genre) => genre.id), + }; +} + +// Returns the curated seed (already-seen titles removed), resolved to cards via +// the TMDB cache, in the diverse round-robin order. The client slices ~8/~18. +export async function getOnboardingSeed(userId: string): Promise { + const seen = await getSeenKeys( + userId, + SEED_TITLES.map((entry) => entry.tmdbId), + ); + const remaining = SEED_TITLES.filter((entry) => !seen.has(`${entry.mediaType}:${entry.tmdbId}`)); + + const items = await Promise.all( + remaining.map(async (entry) => { + try { + return toSeedItem(await getMediaDetail(entry.mediaType, entry.tmdbId)); + } catch { + return null; + } + }), + ); + + return items.filter((item): item is SeedItemDto => item !== null); +} diff --git a/apps/api/src/modules/preferences/router.ts b/apps/api/src/modules/preferences/router.ts new file mode 100644 index 0000000..08837d6 --- /dev/null +++ b/apps/api/src/modules/preferences/router.ts @@ -0,0 +1,31 @@ +import { Elysia } from "elysia"; + +import { authGuard } from "../../auth-plugin"; +import { PreferencesModel } from "./model"; +import { recordOnboardingSwipes, setMyPreferences } from "./mutations"; +import { getMyPreferences, getOnboardingSeed } from "./queries"; + +export const preferencesController = new Elysia({ + name: "Preferences.Controller", + prefix: "/preferences", +}) + .use(authGuard) + .use(PreferencesModel) + .get("/me", ({ user }) => getMyPreferences(user.id), { + auth: true, + response: { 200: "preferences.Me" }, + }) + .put("/me", ({ user, body }) => setMyPreferences(user.id, body), { + auth: true, + body: "preferences.Input", + response: { 200: "preferences.Me" }, + }) + .get("/onboarding-seed", ({ user }) => getOnboardingSeed(user.id), { + auth: true, + response: { 200: "preferences.SeedList" }, + }) + .post("/onboarding-swipes", ({ user, body }) => recordOnboardingSwipes(user.id, body.items), { + auth: true, + body: "preferences.SwipeBatch", + response: { 200: "preferences.SwipeResult" }, + }); diff --git a/apps/api/src/modules/preferences/seed.ts b/apps/api/src/modules/preferences/seed.ts new file mode 100644 index 0000000..870f07c --- /dev/null +++ b/apps/api/src/modules/preferences/seed.ts @@ -0,0 +1,31 @@ +import type { MediaType } from "../tmdb"; + +export type SeedEntry = { + tmdbId: number; + mediaType: MediaType; +}; + +// Fixed, hand-curated diverse seed (#10 v1 — adaptive selection is out of scope). +// Ordered round-robin so any prefix (the importer-shortened ~8 or the full ~18) +// already spans many genres and decades. The genre/decade of each title is noted +// in the trailing comment so the diversity intent stays readable. +export const SEED_TITLES: SeedEntry[] = [ + { tmdbId: 278, mediaType: "movie" }, // The Shawshank Redemption — Drama, 1990s + { tmdbId: 76341, mediaType: "movie" }, // Mad Max: Fury Road — Action, 2010s + { tmdbId: 129, mediaType: "movie" }, // Spirited Away — Animation, 2000s + { tmdbId: 238, mediaType: "movie" }, // The Godfather — Crime, 1970s + { tmdbId: 603, mediaType: "movie" }, // The Matrix — Sci-Fi, 1990s + { tmdbId: 419430, mediaType: "movie" }, // Get Out — Horror, 2010s + { tmdbId: 1399, mediaType: "tv" }, // Game of Thrones — Fantasy, 2010s + { tmdbId: 289, mediaType: "movie" }, // Casablanca — Romance, 1940s + { tmdbId: 680, mediaType: "movie" }, // Pulp Fiction — Thriller, 1990s + { tmdbId: 105, mediaType: "movie" }, // Back to the Future — Adventure, 1980s + { tmdbId: 244786, mediaType: "movie" }, // Whiplash — Music, 2010s + { tmdbId: 1396, mediaType: "tv" }, // Breaking Bad — Crime, 2000s + { tmdbId: 62, mediaType: "movie" }, // 2001: A Space Odyssey — Sci-Fi, 1960s + { tmdbId: 539, mediaType: "movie" }, // Psycho — Horror, 1960s + { tmdbId: 120467, mediaType: "movie" }, // The Grand Budapest Hotel — Comedy, 2010s + { tmdbId: 120, mediaType: "movie" }, // LOTR: Fellowship of the Ring — Adventure, 2000s + { tmdbId: 510, mediaType: "movie" }, // One Flew Over the Cuckoo's Nest — Drama, 1970s + { tmdbId: 66732, mediaType: "tv" }, // Stranger Things — Sci-Fi, 2010s +]; diff --git a/apps/api/src/modules/preferences/shared.ts b/apps/api/src/modules/preferences/shared.ts new file mode 100644 index 0000000..23d6b76 --- /dev/null +++ b/apps/api/src/modules/preferences/shared.ts @@ -0,0 +1,61 @@ +import { isKnownGenreId, isKnownMood } from "@seen/shared"; +import type { userPreferences } from "@seen/db/schema"; + +import { HttpError } from "../../lib/http-error"; + +export type PreferencesInput = { + favorite_genres: number[]; + disliked_genres: number[]; + moods: string[]; +}; + +function unique(values: T[]): T[] { + return [...new Set(values)]; +} + +function sharedValues(left: T[], right: T[]): T[] { + const rightValues = new Set(right); + return left.filter((value) => rightValues.has(value)); +} + +function assertKnownGenreIds(ids: number[]) { + for (const id of ids) { + if (!isKnownGenreId(id)) throw new HttpError(400, `Unknown genre id: ${id}`); + } +} + +function assertKnownMoods(moods: string[]) { + for (const mood of moods) { + if (!isKnownMood(mood)) throw new HttpError(400, `Unknown mood: ${mood}`); + } +} + +export function toPreferences(row: typeof userPreferences.$inferSelect | undefined) { + if (!row) { + return { favorite_genres: [], disliked_genres: [], moods: [], updated_at: null }; + } + return { + favorite_genres: row.favoriteGenres, + disliked_genres: row.dislikedGenres, + moods: row.moods, + updated_at: row.updatedAt.toISOString(), + }; +} + +// Dedupes, then enforces: known genre ids, known moods, and that no genre is +// both a favorite and a dislike. Returns the cleaned input. +export function validatePreferences(input: PreferencesInput): PreferencesInput { + const favorite_genres = unique(input.favorite_genres); + const disliked_genres = unique(input.disliked_genres); + const moods = unique(input.moods); + + assertKnownGenreIds([...favorite_genres, ...disliked_genres]); + assertKnownMoods(moods); + + const overlap = sharedValues(favorite_genres, disliked_genres); + if (overlap.length > 0) { + throw new HttpError(400, `Genres cannot be both favorite and disliked: ${overlap.join(", ")}`); + } + + return { favorite_genres, disliked_genres, moods }; +} diff --git a/apps/api/src/modules/router.ts b/apps/api/src/modules/router.ts index 988793b..77a3152 100644 --- a/apps/api/src/modules/router.ts +++ b/apps/api/src/modules/router.ts @@ -8,6 +8,7 @@ import { importController } from "./import"; import { likesController } from "./likes"; import { notInterestedController } from "./not-interested"; import { platformsController } from "./platforms"; +import { preferencesController } from "./preferences"; import { profileController } from "./profiles"; import { recommendationsController } from "./recommendations"; import { reviewController } from "./reviews"; @@ -28,6 +29,7 @@ export const apiRouter = new Elysia({ name: "api.router" }) .use(importController) .use(accountController) .use(platformsController) + .use(preferencesController) .use(recommendationsController) .use(analyticsController) .use(whatsNewController); diff --git a/apps/mobile/eslint.config.js b/apps/mobile/eslint.config.js index ece9700..5e6a753 100644 --- a/apps/mobile/eslint.config.js +++ b/apps/mobile/eslint.config.js @@ -6,5 +6,8 @@ module.exports = defineConfig([ expoConfig, { ignores: ["dist/*"], + rules: { + "import/no-unresolved": ["error", { ignore: ["^expo-sharing$", "^react-native-view-shot$"] }], + }, }, ]); diff --git a/apps/mobile/src/app/(setup)/taste.tsx b/apps/mobile/src/app/(setup)/taste.tsx new file mode 100644 index 0000000..a7a9768 --- /dev/null +++ b/apps/mobile/src/app/(setup)/taste.tsx @@ -0,0 +1,5 @@ +import { TasteSwipe } from "@/components/screens/taste-swipe"; + +export default function SetupTasteRoute() { + return ; +} diff --git a/apps/mobile/src/app/(tabs)/profile/_layout.tsx b/apps/mobile/src/app/(tabs)/profile/_layout.tsx index 4d96a48..24da451 100644 --- a/apps/mobile/src/app/(tabs)/profile/_layout.tsx +++ b/apps/mobile/src/app/(tabs)/profile/_layout.tsx @@ -23,6 +23,14 @@ export default function ProfileLayout() { headerBackButtonDisplayMode: "minimal", }} /> + ; +} diff --git a/apps/mobile/src/app/_layout.tsx b/apps/mobile/src/app/_layout.tsx index 47548cc..bec50e7 100644 --- a/apps/mobile/src/app/_layout.tsx +++ b/apps/mobile/src/app/_layout.tsx @@ -33,6 +33,7 @@ function RootNavigator() { + diff --git a/apps/mobile/src/components/insights/bar-chart.tsx b/apps/mobile/src/components/insights/bar-chart.tsx index 9e6c1df..88921f3 100644 --- a/apps/mobile/src/components/insights/bar-chart.tsx +++ b/apps/mobile/src/components/insights/bar-chart.tsx @@ -38,10 +38,7 @@ export function BarChart({ describeValue, }: BarChartProps) { const theme = useTheme(); - const max = useMemo( - () => Math.max(1, ...bars.map((bar) => bar.exact + bar.estimated)), - [bars], - ); + const max = useMemo(() => Math.max(1, ...bars.map((bar) => bar.exact + bar.estimated)), [bars]); const labelStride = Math.ceil(bars.length / MAX_LABELS); return ( diff --git a/apps/mobile/src/components/insights/share/taste-card.tsx b/apps/mobile/src/components/insights/share/taste-card.tsx index 9049147..40fef63 100644 --- a/apps/mobile/src/components/insights/share/taste-card.tsx +++ b/apps/mobile/src/components/insights/share/taste-card.tsx @@ -21,7 +21,9 @@ export function TasteCard({ recap, accent }: { recap: ShareRecap; accent: string {genres.length > 0 ? ( {genres.map((genre) => ( - + {genre} ))} diff --git a/apps/mobile/src/components/insights/share/weekly-card.tsx b/apps/mobile/src/components/insights/share/weekly-card.tsx index c4fa30b..9524a74 100644 --- a/apps/mobile/src/components/insights/share/weekly-card.tsx +++ b/apps/mobile/src/components/insights/share/weekly-card.tsx @@ -21,7 +21,9 @@ export function WeeklyCard({ recap, accent }: { recap: ShareRecap; accent: strin {genres.length > 0 ? ( {genres.map((genre) => ( - + {genre} ))} diff --git a/apps/mobile/src/components/screens/insights/index.tsx b/apps/mobile/src/components/screens/insights/index.tsx index 7672706..4d96ee7 100644 --- a/apps/mobile/src/components/screens/insights/index.tsx +++ b/apps/mobile/src/components/screens/insights/index.tsx @@ -86,26 +86,28 @@ export function Insights() { styles.content, { paddingBottom: insets.bottom + BottomTabInset + SPACING.LG }, ]}> - + - {overview.isLoading ? ( - - - - ) : overview.error ? ( - {t("insights.loadError")} - ) : isEmpty ? ( - - ) : ( - <> - {ov ? : null} - {timeline.data ? : null} - {ov ? : null} - {taste.data ? : null} - {ov ? : null} - {discovery.data ? : null} - - )} + {overview.isLoading ? ( + + + + ) : overview.error ? ( + + {t("insights.loadError")} + + ) : isEmpty ? ( + + ) : ( + <> + {ov ? : null} + {timeline.data ? : null} + {ov ? : null} + {taste.data ? : null} + {ov ? : null} + {discovery.data ? : null} + + )} ); diff --git a/apps/mobile/src/components/screens/insights/taste-section.tsx b/apps/mobile/src/components/screens/insights/taste-section.tsx index 64d4d8d..69eb384 100644 --- a/apps/mobile/src/components/screens/insights/taste-section.tsx +++ b/apps/mobile/src/components/screens/insights/taste-section.tsx @@ -31,8 +31,7 @@ export function TasteSection({ taste }: { taste: Taste }) { const moviePct = taste.media_type_mix.movie + taste.media_type_mix.tv > 0 ? Math.round( - (taste.media_type_mix.movie / - (taste.media_type_mix.movie + taste.media_type_mix.tv)) * + (taste.media_type_mix.movie / (taste.media_type_mix.movie + taste.media_type_mix.tv)) * 100, ) : 0; diff --git a/apps/mobile/src/components/screens/insights/timeline-section.tsx b/apps/mobile/src/components/screens/insights/timeline-section.tsx index 522e230..0c64fbc 100644 --- a/apps/mobile/src/components/screens/insights/timeline-section.tsx +++ b/apps/mobile/src/components/screens/insights/timeline-section.tsx @@ -80,7 +80,10 @@ export function TimelineSection({ timeline }: { timeline: Timeline }) { ) : ( itemsQuery.data?.items.map((item) => ( - + )) )} @@ -104,7 +107,12 @@ function TimelineItemRow({ item }: { item: TimelineItem }) { return ( diff --git a/apps/mobile/src/components/screens/letterboxd-import/index.tsx b/apps/mobile/src/components/screens/letterboxd-import/index.tsx index 32f49b3..6733550 100644 --- a/apps/mobile/src/components/screens/letterboxd-import/index.tsx +++ b/apps/mobile/src/components/screens/letterboxd-import/index.tsx @@ -98,7 +98,7 @@ export function LetterboxdImport({ mode }: LetterboxdImportProps) { hapticTap(); if (mode === "onboarding") { if (!summary) markImportSkipped(); - router.replace("/platforms"); + router.replace("/taste"); } else { router.back(); } @@ -111,7 +111,11 @@ export function LetterboxdImport({ mode }: LetterboxdImportProps) { : null; return ( - +
{ - const next = new Set(prev); - if (next.has(providerId)) next.delete(providerId); - else next.add(providerId); - return next; - }); + setSelected((prev) => toggleInSet(prev, providerId)); } async function save({ skipped }: { skipped: boolean }) { diff --git a/apps/mobile/src/components/screens/profile/account-settings/index.tsx b/apps/mobile/src/components/screens/profile/account-settings/index.tsx index bb6528d..b5f4278 100644 --- a/apps/mobile/src/components/screens/profile/account-settings/index.tsx +++ b/apps/mobile/src/components/screens/profile/account-settings/index.tsx @@ -11,7 +11,7 @@ import { useSessions } from "@/hooks/account/use-sessions"; import { hapticError, hapticSuccess, hapticTap } from "@/lib/haptics"; import { signOut } from "@/services/account"; -import { AccountRow } from "./account-row"; +import { SettingsRow } from "../settings-row"; import { LinkedAccountsSection } from "./linked-accounts-section"; import { SessionsSection } from "./sessions-section"; @@ -43,6 +43,11 @@ export function AccountSettingsSheet() { router.push("/profile/platforms"); }, [router]); + const openTastePreferences = useCallback(() => { + hapticTap(); + router.push("/profile/taste-preferences"); + }, [router]); + const openWhatsNew = useCallback(() => { hapticTap(); router.push("/whats-new"); @@ -70,21 +75,30 @@ export function AccountSettingsSheet() { - +
- - - - + + +
@@ -92,7 +106,7 @@ export function AccountSettingsSheet() {
-
- ; @@ -49,7 +49,7 @@ export function LinkedAccountsSection({ linked }: { linked: LinkedAccounts }) { ) : linked.accounts.length > 0 ? ( linked.accounts.map((account) => ( - void; destructive?: boolean; } -// A settings row. The icon color comes from the parent Form's `tint` (the app -// accent) — a Form Button's local tint only colors the label, not the SF Symbol. -// So here the label is tinted to the primary text color while the Form keeps the -// accent on the icon; destructive rows stay fully red via `role`. -export function AccountRow({ icon, label, onPress, destructive }: AccountRowProps) { +// A native settings row. The icon color comes from the parent Form's `tint` +// while non-destructive labels use the primary text color. +export function SettingsRow({ icon, label, onPress, destructive }: SettingsRowProps) { const theme = useTheme(); if (destructive) { diff --git a/apps/mobile/src/components/screens/profile/taste-preferences/index.tsx b/apps/mobile/src/components/screens/profile/taste-preferences/index.tsx new file mode 100644 index 0000000..44f00ca --- /dev/null +++ b/apps/mobile/src/components/screens/profile/taste-preferences/index.tsx @@ -0,0 +1,84 @@ +import { Form, Host, Text as SwiftUIText } from "@expo/ui/swift-ui"; +import { tint } from "@expo/ui/swift-ui/modifiers"; +import { MOODS, MOVIE_GENRES_LIST } from "@seen/shared"; +import { Stack, useRouter } from "expo-router"; +import { useTranslation } from "react-i18next"; + +import { useMyPreferences } from "@/hooks/preferences/use-my-preferences"; +import { useSetPreferences } from "@/hooks/preferences/use-set-preferences"; +import { useAccentColor } from "@/hooks/use-accent-color"; +import { hapticTap } from "@/lib/haptics"; + +import { SelectableSection, type SelectableItem } from "./selectable-section"; +import { usePreferenceDraft } from "./use-preference-draft"; + +const GENRE_ITEMS: SelectableItem[] = MOVIE_GENRES_LIST.map((genre) => ({ + value: genre.id, + label: genre.name, +})); +const MOOD_ITEMS: SelectableItem[] = MOODS.map((mood) => ({ value: mood, label: mood })); + +export function TastePreferences() { + const { t } = useTranslation(); + const router = useRouter(); + const { accentHex } = useAccentColor(); + + const { data } = useMyPreferences(); + const setMutation = useSetPreferences(); + const draft = usePreferenceDraft(data); + + async function save() { + if (setMutation.isPending) return; + hapticTap(); + try { + await setMutation.mutateAsync(draft.input); + router.back(); + } catch { + // hapticError fires in the mutation; stay on screen so the user can retry. + } + } + + return ( + <> + + + {setMutation.isPending ? t("taste.saving") : t("taste.save")} + + + + + + + + + + {t("taste.settingsSubtitle")}} + /> + + + + ); +} diff --git a/apps/mobile/src/components/screens/profile/taste-preferences/selectable-section.tsx b/apps/mobile/src/components/screens/profile/taste-preferences/selectable-section.tsx new file mode 100644 index 0000000..a6bb0e2 --- /dev/null +++ b/apps/mobile/src/components/screens/profile/taste-preferences/selectable-section.tsx @@ -0,0 +1,47 @@ +import { Section } from "@expo/ui/swift-ui"; +import type { ReactElement } from "react"; + +import { hapticSelection } from "@/lib/haptics"; + +import { SettingsRow } from "../settings-row"; + +export type SelectableItem = { + value: T; + label: string; +}; + +type Props = { + title: string; + items: SelectableItem[]; + selected: Set; + onToggle: (value: T) => void; + footer?: ReactElement; +}; + +// A Form section of toggleable rows backed by a Set — the shared shape behind +// the favorite-genres, disliked-genres, and moods pickers. +export function SelectableSection({ + title, + items, + selected, + onToggle, + footer, +}: Props) { + function select(value: T) { + hapticSelection(); + onToggle(value); + } + + return ( +
+ {items.map((item) => ( + select(item.value)} + /> + ))} +
+ ); +} diff --git a/apps/mobile/src/components/screens/profile/taste-preferences/use-preference-draft.ts b/apps/mobile/src/components/screens/profile/taste-preferences/use-preference-draft.ts new file mode 100644 index 0000000..27b9974 --- /dev/null +++ b/apps/mobile/src/components/screens/profile/taste-preferences/use-preference-draft.ts @@ -0,0 +1,69 @@ +import { useMemo, useState } from "react"; + +import { removeFromSet, toggleInSet } from "@/lib/set"; +import type { Preferences, PreferencesInput } from "@/services/preferences"; + +type PreferenceDraft = { + favorite: Set; + disliked: Set; + moods: Set; +}; + +function toDraft(preferences?: Preferences | null): PreferenceDraft { + return { + favorite: new Set(preferences?.favorite_genres ?? []), + disliked: new Set(preferences?.disliked_genres ?? []), + moods: new Set(preferences?.moods ?? []), + }; +} + +export function usePreferenceDraft(preferences: Preferences | null) { + const [draft, setDraft] = useState(() => toDraft()); + const [applied, setApplied] = useState(null); + + // Seed local selections from the server once it loads (render-phase sync, the + // same pattern the platforms picker uses). + if (preferences && preferences !== applied) { + setApplied(preferences); + setDraft(toDraft(preferences)); + } + + const input = useMemo( + () => ({ + favorite_genres: [...draft.favorite], + disliked_genres: [...draft.disliked], + moods: [...draft.moods], + }), + [draft.favorite, draft.disliked, draft.moods], + ); + + function toggleFavorite(id: number) { + setDraft((prev) => ({ + ...prev, + favorite: toggleInSet(prev.favorite, id), + disliked: removeFromSet(prev.disliked, id), + })); + } + + function toggleDisliked(id: number) { + setDraft((prev) => ({ + ...prev, + disliked: toggleInSet(prev.disliked, id), + favorite: removeFromSet(prev.favorite, id), + })); + } + + function toggleMood(mood: string) { + setDraft((prev) => ({ ...prev, moods: toggleInSet(prev.moods, mood) })); + } + + return { + favorite: draft.favorite, + disliked: draft.disliked, + moods: draft.moods, + input, + toggleFavorite, + toggleDisliked, + toggleMood, + }; +} diff --git a/apps/mobile/src/components/screens/taste-swipe/index.tsx b/apps/mobile/src/components/screens/taste-swipe/index.tsx new file mode 100644 index 0000000..e5f3982 --- /dev/null +++ b/apps/mobile/src/components/screens/taste-swipe/index.tsx @@ -0,0 +1,114 @@ +import { useTranslation } from "react-i18next"; +import { ActivityIndicator, StyleSheet, View } from "react-native"; +import { useSafeAreaInsets } from "react-native-safe-area-context"; + +import { Button } from "@/components/ui/button/button"; +import { Text } from "@/components/ui/text"; +import { SPACING } from "@/constants/design-tokens"; +import { useTheme } from "@/hooks/use-theme"; + +import { SwipeDeck } from "./swipe-deck"; +import { useTasteSwipe } from "./use-taste-swipe"; + +export function TasteSwipe() { + const { t } = useTranslation(); + const theme = useTheme(); + const insets = useSafeAreaInsets(); + const swipe = useTasteSwipe(); + + const showContinue = !swipe.isLoading && (swipe.hasError || swipe.total === 0); + + return ( + + + + {t("taste.onboardingTitle")} + + + {t("taste.onboardingSubtitle")} + + + + + {swipe.isLoading ? ( + + + + ) : swipe.hasError ? ( + + + {t("taste.loadError")} + + + ) : swipe.total === 0 ? ( + + + {t("taste.empty")} + + + ) : swipe.currentCard ? ( + <> + + {t("taste.progress", { current: swipe.progress, total: swipe.total }).toUpperCase()} + + + + ) : ( + + + + {t("taste.finishing")} + + + )} + + + +