Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions apps/api/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
3 changes: 2 additions & 1 deletion apps/api/src/modules/analytics/helpers/discovery-flow.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/modules/analytics/helpers/overview.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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,
};
Expand Down
8 changes: 6 additions & 2 deletions apps/api/src/modules/analytics/helpers/timeline.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, WatchEntry[]>();
for (const key of keys) grouped.set(key, []);
Expand All @@ -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),
Expand Down
7 changes: 1 addition & 6 deletions apps/api/src/modules/analytics/model.ts
Original file line number Diff line number Diff line change
@@ -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"),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
4 changes: 3 additions & 1 deletion apps/api/src/modules/analytics/queries/timeline-items.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
14 changes: 5 additions & 9 deletions apps/api/src/modules/analytics/router.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand Down
56 changes: 48 additions & 8 deletions apps/api/src/modules/analytics/tests/discovery-flow.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Omit<DiscoveryImpression, "flags">> & {
flags?: Partial<DiscoveryImpression["flags"]>;
Expand Down Expand Up @@ -41,15 +48,42 @@ function interaction(o: Partial<DiscoveryInteraction>): 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") }),
];

Expand All @@ -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");
Expand Down
20 changes: 5 additions & 15 deletions apps/api/src/modules/analytics/tests/range.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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());
});

Expand Down Expand Up @@ -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"]);
});
});
22 changes: 19 additions & 3 deletions apps/api/src/modules/analytics/tests/timeline.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
36 changes: 22 additions & 14 deletions apps/api/src/modules/analytics/tests/watched-time.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand All @@ -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);
});
Expand Down Expand Up @@ -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);
Expand Down
7 changes: 1 addition & 6 deletions apps/api/src/modules/analytics/tz.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
6 changes: 1 addition & 5 deletions apps/api/src/modules/episode-reviews/model.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
19 changes: 16 additions & 3 deletions apps/api/src/modules/import/queries/parse-csv.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
}
Expand Down Expand Up @@ -161,8 +164,18 @@ export function parseLetterboxdFiles(files: Record<string, string>): 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 },
]),
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/modules/preferences/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
export { preferencesController } from "./router";
export type {
PreferencesMeDto,
PreferencesInputDto,
SeedItemDto,
SwipeItemDto,
SwipeResultDto,
} from "./model";
Loading
Loading