mirror of
https://github.com/jakejarvis/sofa.git
synced 2026-10-15 19:45:42 -04:00
feat(upcoming): add a "Recently aired" view of unwatched episodes
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
24 files changed
+671
-78
No files matched your search
@@ -54,6 +54,8 @@ export default function UpcomingScreen() {
|
||||
const backgroundColor = useCSSVariable("--color-background") as string;
|
||||
const mutedColor = useCSSVariable("--color-muted-foreground") as string;
|
||||
|
||||
const [view, setView] = useState<"upcoming" | "recent">("upcoming");
|
||||
const isRecent = view === "recent";
|
||||
const [mediaType, setMediaType] = useState<"all" | "movie" | "tv">("all");
|
||||
const [statusFilter, setStatusFilter] = useState<"all" | "watching" | "watchlist">("all");
|
||||
|
||||
@@ -64,8 +66,9 @@ export default function UpcomingScreen() {
|
||||
days: 90,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
mediaType: mediaType !== "all" ? mediaType : undefined,
|
||||
statusFilter: statusFilter !== "all" ? [statusFilter] : undefined,
|
||||
direction: view,
|
||||
mediaType: !isRecent && mediaType !== "all" ? mediaType : undefined,
|
||||
statusFilter: !isRecent && statusFilter !== "all" ? [statusFilter] : undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
@@ -75,12 +78,12 @@ export default function UpcomingScreen() {
|
||||
const allItems = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]);
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
groupByDateBucket(allItems).map((b) => ({
|
||||
groupByDateBucket(allItems, { past: view === "recent" }).map((b) => ({
|
||||
key: b.key,
|
||||
title: b.label,
|
||||
data: b.items,
|
||||
})),
|
||||
[allItems],
|
||||
[allItems, view],
|
||||
);
|
||||
|
||||
const refreshUpcoming = useCallback(
|
||||
@@ -102,31 +105,46 @@ export default function UpcomingScreen() {
|
||||
contentContainerStyle={{ paddingHorizontal: 16, gap: 6, paddingTop: 10, paddingBottom: 6 }}
|
||||
>
|
||||
<FilterChip
|
||||
label={t`All`}
|
||||
isSelected={mediaType === "all"}
|
||||
onPress={() => setMediaType("all")}
|
||||
label={t`Upcoming`}
|
||||
isSelected={view === "upcoming"}
|
||||
onPress={() => setView("upcoming")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`Movies`}
|
||||
isSelected={mediaType === "movie"}
|
||||
onPress={() => setMediaType("movie")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`TV`}
|
||||
isSelected={mediaType === "tv"}
|
||||
onPress={() => setMediaType("tv")}
|
||||
label={t`Recently aired`}
|
||||
isSelected={view === "recent"}
|
||||
onPress={() => setView("recent")}
|
||||
/>
|
||||
<View className="bg-border/30 mx-1 w-px self-stretch" />
|
||||
<FilterChip
|
||||
label={t`Watching`}
|
||||
isSelected={statusFilter === "watching"}
|
||||
onPress={() => setStatusFilter(statusFilter === "watching" ? "all" : "watching")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`Watchlist`}
|
||||
isSelected={statusFilter === "watchlist"}
|
||||
onPress={() => setStatusFilter(statusFilter === "watchlist" ? "all" : "watchlist")}
|
||||
/>
|
||||
{!isRecent && (
|
||||
<>
|
||||
<FilterChip
|
||||
label={t`All`}
|
||||
isSelected={mediaType === "all"}
|
||||
onPress={() => setMediaType("all")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`Movies`}
|
||||
isSelected={mediaType === "movie"}
|
||||
onPress={() => setMediaType("movie")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`TV`}
|
||||
isSelected={mediaType === "tv"}
|
||||
onPress={() => setMediaType("tv")}
|
||||
/>
|
||||
<View className="bg-border/30 mx-1 w-px self-stretch" />
|
||||
<FilterChip
|
||||
label={t`Watching`}
|
||||
isSelected={statusFilter === "watching"}
|
||||
onPress={() => setStatusFilter(statusFilter === "watching" ? "all" : "watching")}
|
||||
/>
|
||||
<FilterChip
|
||||
label={t`Watchlist`}
|
||||
isSelected={statusFilter === "watchlist"}
|
||||
onPress={() => setStatusFilter(statusFilter === "watchlist" ? "all" : "watchlist")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
|
||||
@@ -185,7 +203,11 @@ export default function UpcomingScreen() {
|
||||
>
|
||||
<ScaledIcon icon={IconCalendarEvent} size={48} color={`${mutedColor}66`} />
|
||||
<Text className="text-muted-foreground mt-4 text-center text-sm">
|
||||
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
||||
{isRecent ? (
|
||||
<Trans>You're all caught up on the last 90 days.</Trans>
|
||||
) : (
|
||||
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
||||
)}
|
||||
</Text>
|
||||
</Animated.View>
|
||||
)
|
||||
|
||||
@@ -84,6 +84,7 @@ export const upcoming = os.library.upcoming.use(authed).handler(({ input, contex
|
||||
cursor: input.cursor,
|
||||
mediaType: input.mediaType,
|
||||
statusFilter: input.statusFilter,
|
||||
direction: input.direction,
|
||||
});
|
||||
return {
|
||||
items: result.items.map((item) => ({
|
||||
|
||||
@@ -14,6 +14,7 @@ import { orpc } from "@/lib/orpc/client";
|
||||
import { groupByDateBucket } from "@sofa/i18n/date-buckets";
|
||||
|
||||
const upcomingSearchSchema = z.object({
|
||||
view: z.enum(["upcoming", "recent"]).optional().catch(undefined),
|
||||
type: z.enum(["all", "movie", "tv"]).optional().catch(undefined),
|
||||
status: z.enum(["all", "watching", "watchlist"]).optional().catch(undefined),
|
||||
});
|
||||
@@ -65,6 +66,8 @@ function UpcomingPage() {
|
||||
const search = Route.useSearch();
|
||||
const navigate = useNavigate({ from: Route.fullPath });
|
||||
|
||||
const view = search.view ?? "upcoming";
|
||||
const isRecent = view === "recent";
|
||||
const typeFilter = search.type ?? "all";
|
||||
const statusFilter = search.status ?? "all";
|
||||
|
||||
@@ -75,9 +78,12 @@ function UpcomingPage() {
|
||||
days: 90,
|
||||
limit: 20,
|
||||
cursor: pageParam,
|
||||
mediaType: typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
|
||||
direction: view,
|
||||
mediaType: !isRecent && typeFilter !== "all" ? (typeFilter as "movie" | "tv") : undefined,
|
||||
statusFilter:
|
||||
statusFilter !== "all" ? [statusFilter as "watching" | "watchlist"] : undefined,
|
||||
!isRecent && statusFilter !== "all"
|
||||
? [statusFilter as "watching" | "watchlist"]
|
||||
: undefined,
|
||||
}),
|
||||
initialPageParam: undefined as string | undefined,
|
||||
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
|
||||
@@ -91,6 +97,16 @@ function UpcomingPage() {
|
||||
isFetchNextPageError,
|
||||
});
|
||||
|
||||
function setView(value: "upcoming" | "recent") {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
...prev,
|
||||
view: value === "upcoming" ? undefined : value,
|
||||
}),
|
||||
replace: true,
|
||||
});
|
||||
}
|
||||
|
||||
function setTypeFilter(value: string) {
|
||||
void navigate({
|
||||
search: (prev) => ({
|
||||
@@ -112,33 +128,49 @@ function UpcomingPage() {
|
||||
}
|
||||
|
||||
const filterToggles = (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="space-y-3">
|
||||
<ToggleGroup
|
||||
value={[typeFilter]}
|
||||
value={[view]}
|
||||
onValueChange={(values) => {
|
||||
const next = values.find((v) => v !== typeFilter) ?? "all";
|
||||
setTypeFilter(next);
|
||||
const next = values.find((v) => v !== view);
|
||||
if (next === "upcoming" || next === "recent") setView(next);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="movie">{t`Movies`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tv">{t`TV Shows`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
value={[statusFilter]}
|
||||
onValueChange={(values) => {
|
||||
const next = values.find((v) => v !== statusFilter) ?? "all";
|
||||
setStatusFilter(next);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="watching">{t`Watching`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="watchlist">{t`Watchlist`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="upcoming">{t`Upcoming`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="recent">{t`Recently aired`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
{!isRecent && (
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<ToggleGroup
|
||||
value={[typeFilter]}
|
||||
onValueChange={(values) => {
|
||||
const next = values.find((v) => v !== typeFilter) ?? "all";
|
||||
setTypeFilter(next);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="movie">{t`Movies`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="tv">{t`TV Shows`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<ToggleGroup
|
||||
value={[statusFilter]}
|
||||
onValueChange={(values) => {
|
||||
const next = values.find((v) => v !== statusFilter) ?? "all";
|
||||
setStatusFilter(next);
|
||||
}}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
>
|
||||
<ToggleGroupItem value="all">{t`All`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="watching">{t`Watching`}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="watchlist">{t`Watchlist`}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -149,23 +181,27 @@ function UpcomingPage() {
|
||||
if (allItems.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<UpcomingHeader />
|
||||
<UpcomingHeader view={view} />
|
||||
{filterToggles}
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<IconCalendarEvent className="text-muted-foreground/40 size-12" />
|
||||
<p className="text-muted-foreground mt-4 text-sm">
|
||||
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
||||
{isRecent ? (
|
||||
<Trans>You're all caught up on the last 90 days.</Trans>
|
||||
) : (
|
||||
<Trans>No upcoming episodes or releases in the next 90 days.</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const buckets = groupByDateBucket(allItems);
|
||||
const buckets = groupByDateBucket(allItems, { past: isRecent });
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-6">
|
||||
<UpcomingHeader />
|
||||
<UpcomingHeader view={view} />
|
||||
{filterToggles}
|
||||
{buckets.map((bucket) => (
|
||||
<section key={bucket.key}>
|
||||
@@ -185,13 +221,19 @@ function UpcomingPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function UpcomingHeader() {
|
||||
function UpcomingHeader({ view }: { view: "upcoming" | "recent" }) {
|
||||
const { t } = useLingui();
|
||||
return (
|
||||
<div>
|
||||
<h1 className="font-display text-2xl tracking-tight">{t`Upcoming`}</h1>
|
||||
<h1 className="font-display text-2xl tracking-tight">
|
||||
{view === "recent" ? t`Recently aired` : t`Upcoming`}
|
||||
</h1>
|
||||
<p className="text-muted-foreground mt-1 text-sm">
|
||||
<Trans>Episodes and movies coming up in the next 90 days.</Trans>
|
||||
{view === "recent" ? (
|
||||
<Trans>Unwatched episodes from the last 90 days.</Trans>
|
||||
) : (
|
||||
<Trans>Episodes and movies coming up in the next 90 days.</Trans>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -10,8 +10,10 @@ _openapi:
|
||||
structuredData:
|
||||
headings: []
|
||||
contents:
|
||||
- content: Fetch upcoming episodes and movie releases for titles in the user's
|
||||
library, sorted by date. Supports cursor-based pagination.
|
||||
- content: 'Fetch upcoming episodes and movie releases for titles in the user''s
|
||||
library, sorted by date. Supports cursor-based pagination. Pass
|
||||
direction: "recent" for unwatched episodes that aired in the past
|
||||
days, newest first.'
|
||||
---
|
||||
|
||||
{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */}
|
||||
|
||||
@@ -2916,7 +2916,7 @@
|
||||
"get": {
|
||||
"operationId": "library.upcoming",
|
||||
"summary": "Get upcoming episodes and movies",
|
||||
"description": "Fetch upcoming episodes and movie releases for titles in the user's library, sorted by date. Supports cursor-based pagination.",
|
||||
"description": "Fetch upcoming episodes and movie releases for titles in the user's library, sorted by date. Supports cursor-based pagination. Pass direction: \"recent\" for unwatched episodes that aired in the past days, newest first.",
|
||||
"tags": [
|
||||
"Library"
|
||||
],
|
||||
@@ -2929,7 +2929,22 @@
|
||||
"minimum": 1,
|
||||
"maximum": 90,
|
||||
"default": 90,
|
||||
"description": "How many days into the future to look"
|
||||
"description": "How many days ahead (upcoming) or back (recent) to look"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
},
|
||||
{
|
||||
"name": "direction",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"enum": [
|
||||
"upcoming",
|
||||
"recent"
|
||||
],
|
||||
"type": "string",
|
||||
"default": "upcoming",
|
||||
"description": "\"upcoming\": the next `days` days. \"recent\": unwatched TV episodes that aired in the past `days` days (excluding today), newest first"
|
||||
},
|
||||
"allowEmptyValue": true,
|
||||
"allowReserved": true
|
||||
|
||||
@@ -238,7 +238,7 @@ export const contract = {
|
||||
tags: ["Library"],
|
||||
summary: "Get upcoming episodes and movies",
|
||||
description:
|
||||
"Fetch upcoming episodes and movie releases for titles in the user's library, sorted by date. Supports cursor-based pagination.",
|
||||
'Fetch upcoming episodes and movie releases for titles in the user\'s library, sorted by date. Supports cursor-based pagination. Pass direction: "recent" for unwatched episodes that aired in the past days, newest first.',
|
||||
successDescription: "Upcoming items sorted by date with streaming info",
|
||||
})
|
||||
.input(UpcomingInput)
|
||||
|
||||
@@ -663,7 +663,13 @@ export const UpcomingInput = z
|
||||
.min(1)
|
||||
.max(90)
|
||||
.default(90)
|
||||
.describe("How many days into the future to look"),
|
||||
.describe("How many days ahead (upcoming) or back (recent) to look"),
|
||||
direction: z
|
||||
.enum(["upcoming", "recent"])
|
||||
.default("upcoming")
|
||||
.describe(
|
||||
'"upcoming": the next `days` days. "recent": unwatched TV episodes that aired in the past `days` days (excluding today), newest first',
|
||||
),
|
||||
limit: z.number().int().min(1).max(50).default(20).describe("Maximum items per page"),
|
||||
cursor: z.string().optional().describe("Pagination cursor"),
|
||||
mediaType: z
|
||||
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
getTitleByIdOrNull,
|
||||
getTitlesByIds,
|
||||
getTvTitlesByIds,
|
||||
getRecentUnwatchedEpisodes,
|
||||
getUpcomingEpisodes,
|
||||
getUpcomingMovies,
|
||||
getUserStatusCounts,
|
||||
@@ -401,6 +402,17 @@ function compareUpcomingKeys(a: UpcomingCursorKey, b: UpcomingCursorKey): number
|
||||
);
|
||||
}
|
||||
|
||||
/** Same as compareUpcomingKeys, but dates descend (newest first). */
|
||||
function compareRecentKeys(a: UpcomingCursorKey, b: UpcomingCursorKey): number {
|
||||
return (
|
||||
b.d.localeCompare(a.d) ||
|
||||
a.n.localeCompare(b.n) ||
|
||||
a.i.localeCompare(b.i) ||
|
||||
a.s - b.s ||
|
||||
a.e - b.e
|
||||
);
|
||||
}
|
||||
|
||||
function encodeUpcomingCursor(key: UpcomingCursorKey): string {
|
||||
return Buffer.from(JSON.stringify(key), "utf8").toString("base64url");
|
||||
}
|
||||
@@ -435,27 +447,52 @@ export function getUpcomingFeed(
|
||||
cursor?: string;
|
||||
mediaType?: "movie" | "tv";
|
||||
statusFilter?: ("watching" | "watchlist")[];
|
||||
direction?: "upcoming" | "recent";
|
||||
} = {},
|
||||
): UpcomingFeedResult {
|
||||
const { days = 90, limit = 20, cursor, mediaType, statusFilter } = options;
|
||||
const {
|
||||
days = 90,
|
||||
limit = 20,
|
||||
cursor,
|
||||
mediaType,
|
||||
statusFilter,
|
||||
direction = "upcoming",
|
||||
} = options;
|
||||
const isRecent = direction === "recent";
|
||||
const compareKeys = isRecent ? compareRecentKeys : compareUpcomingKeys;
|
||||
|
||||
// Map display status filter to stored statuses for DB query
|
||||
const storedStatuses = statusFilter?.map((s) => (s === "watching" ? "in_progress" : s));
|
||||
|
||||
const now = new Date();
|
||||
const today = localDateString(now);
|
||||
const horizon = new Date();
|
||||
horizon.setDate(horizon.getDate() + days);
|
||||
const toDate = localDateString(horizon);
|
||||
|
||||
// Use the cursor date as the lower bound so later pages skip already-seen dates,
|
||||
// but don't apply a DB-level LIMIT so same-day items aren't truncated.
|
||||
const cursorKey = cursor ? decodeUpcomingCursor(cursor) : null;
|
||||
const fromDate = cursorKey?.d ?? today;
|
||||
const episodeRows =
|
||||
mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses);
|
||||
const movieRows =
|
||||
mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses);
|
||||
|
||||
let episodeRows: ReturnType<typeof getUpcomingEpisodes>;
|
||||
let movieRows: ReturnType<typeof getUpcomingMovies>;
|
||||
if (isRecent) {
|
||||
// Dates go down: the cursor date is the upper bound, `days` ago the lower bound.
|
||||
const yesterdayDate = new Date(now);
|
||||
yesterdayDate.setDate(yesterdayDate.getDate() - 1);
|
||||
const earliest = new Date(now);
|
||||
earliest.setDate(earliest.getDate() - days);
|
||||
const toDate = cursorKey?.d ?? localDateString(yesterdayDate);
|
||||
const fromDate = localDateString(earliest);
|
||||
episodeRows = mediaType === "movie" ? [] : getRecentUnwatchedEpisodes(userId, fromDate, toDate);
|
||||
movieRows = [];
|
||||
} else {
|
||||
const horizon = new Date();
|
||||
horizon.setDate(horizon.getDate() + days);
|
||||
const toDate = localDateString(horizon);
|
||||
|
||||
// Use the cursor date as the lower bound so later pages skip already-seen dates,
|
||||
// but don't apply a DB-level LIMIT so same-day items aren't truncated.
|
||||
const fromDate = cursorKey?.d ?? today;
|
||||
episodeRows =
|
||||
mediaType === "movie" ? [] : getUpcomingEpisodes(userId, fromDate, toDate, storedStatuses);
|
||||
movieRows =
|
||||
mediaType === "tv" ? [] : getUpcomingMovies(userId, fromDate, toDate, storedStatuses);
|
||||
}
|
||||
|
||||
// Merge into unified items
|
||||
type RawItem = {
|
||||
@@ -494,7 +531,7 @@ export function getUpcomingFeed(
|
||||
];
|
||||
|
||||
// Sort by (date, title, titleId, season, episode) ASC — the same key the cursor uses
|
||||
merged.sort((a, b) => compareUpcomingKeys(a.key, b.key));
|
||||
merged.sort((a, b) => compareKeys(a.key, b.key));
|
||||
|
||||
// Collapse batch drops: group 3+ episodes from the same title on the same date into one item
|
||||
const collapsed: (RawItem & { episodeCount: number })[] = [];
|
||||
@@ -534,7 +571,7 @@ export function getUpcomingFeed(
|
||||
// Cursor is base64url-encoded UTF-8 JSON {d, n, i, s, e} matching the sort key.
|
||||
let startIdx = 0;
|
||||
if (cursorKey) {
|
||||
startIdx = collapsed.findIndex((item) => compareUpcomingKeys(item.key, cursorKey) > 0);
|
||||
startIdx = collapsed.findIndex((item) => compareKeys(item.key, cursorKey) > 0);
|
||||
if (startIdx === -1) startIdx = collapsed.length;
|
||||
}
|
||||
|
||||
|
||||
@@ -388,3 +388,114 @@ describe("streaming provider", () => {
|
||||
expect(result.items[0].streamingProvider).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── Recent direction ────────────────────────────────────────────────
|
||||
|
||||
describe("recent direction", () => {
|
||||
test("returns recently aired episodes newest first", () => {
|
||||
insertTvShow("tv-1", 100, 1, 3, {
|
||||
title: "Show",
|
||||
airDates: [daysFromNow(-3), daysFromNow(-1), daysFromNow(-2)],
|
||||
});
|
||||
insertStatus("user-1", "tv-1", "in_progress");
|
||||
|
||||
const result = getUpcomingFeed("user-1", { direction: "recent" });
|
||||
expect(result.items.map((i) => i.date)).toEqual([
|
||||
daysFromNow(-1),
|
||||
daysFromNow(-2),
|
||||
daysFromNow(-3),
|
||||
]);
|
||||
expect(result.items.map((i) => i.episodeNumber)).toEqual([2, 3, 1]);
|
||||
});
|
||||
|
||||
test("excludes watched episodes", () => {
|
||||
const { episodeIds } = insertTvShow("tv-1", 100, 1, 2, {
|
||||
airDates: [daysFromNow(-1), daysFromNow(-2)],
|
||||
});
|
||||
insertStatus("user-1", "tv-1", "in_progress");
|
||||
insertEpisodeWatch("user-1", episodeIds[0]!);
|
||||
|
||||
const result = getUpcomingFeed("user-1", { direction: "recent" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.episodeNumber).toBe(2);
|
||||
});
|
||||
|
||||
test("excludes today's and future episodes", () => {
|
||||
insertTvShow("tv-1", 100, 1, 3, {
|
||||
airDates: [daysFromNow(0), daysFromNow(1), daysFromNow(-1)],
|
||||
});
|
||||
insertStatus("user-1", "tv-1", "in_progress");
|
||||
|
||||
const result = getUpcomingFeed("user-1", { direction: "recent" });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.date).toBe(daysFromNow(-1));
|
||||
});
|
||||
|
||||
test("excludes watchlist titles and includes completed titles", () => {
|
||||
insertTvShow("tv-w", 101, 1, 1, { title: "Watchlisted", airDates: [daysFromNow(-1)] });
|
||||
insertTvShow("tv-c", 102, 1, 1, { title: "Completed", airDates: [daysFromNow(-1)] });
|
||||
insertStatus("user-1", "tv-w", "watchlist");
|
||||
insertStatus("user-1", "tv-c", "completed");
|
||||
|
||||
const result = getUpcomingFeed("user-1", { direction: "recent" });
|
||||
expect(result.items.map((i) => i.titleName)).toEqual(["Completed"]);
|
||||
});
|
||||
|
||||
test("mediaType movie returns nothing and movies never appear", () => {
|
||||
insertTitle({ id: "m-1", tmdbId: 500, type: "movie", releaseDate: daysFromNow(-1) });
|
||||
insertStatus("user-1", "m-1", "completed");
|
||||
insertTvShow("tv-1", 100, 1, 1, { airDates: [daysFromNow(-1)] });
|
||||
insertStatus("user-1", "tv-1", "in_progress");
|
||||
|
||||
expect(getUpcomingFeed("user-1", { direction: "recent", mediaType: "movie" }).items).toEqual(
|
||||
[],
|
||||
);
|
||||
const all = getUpcomingFeed("user-1", { direction: "recent" });
|
||||
expect(all.items.every((i) => i.titleType === "tv")).toBe(true);
|
||||
expect(all.items).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("respects days", () => {
|
||||
insertTvShow("tv-1", 100, 1, 2, { airDates: [daysFromNow(-7), daysFromNow(-8)] });
|
||||
insertStatus("user-1", "tv-1", "in_progress");
|
||||
|
||||
const result = getUpcomingFeed("user-1", { direction: "recent", days: 7 });
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.items[0]!.date).toBe(daysFromNow(-7));
|
||||
});
|
||||
|
||||
test("paginates with a cursor in descending date order", () => {
|
||||
for (let n = 1; n <= 5; n++) {
|
||||
insertTvShow(`tv-${n}`, 100 + n, 1, 1, { title: `Show ${n}`, airDates: [daysFromNow(-n)] });
|
||||
insertStatus("user-1", `tv-${n}`, "in_progress");
|
||||
}
|
||||
|
||||
const page1 = getUpcomingFeed("user-1", { direction: "recent", limit: 2 });
|
||||
expect(page1.items.map((i) => i.titleName)).toEqual(["Show 1", "Show 2"]);
|
||||
expect(page1.nextCursor).not.toBeNull();
|
||||
const page2 = getUpcomingFeed("user-1", {
|
||||
direction: "recent",
|
||||
limit: 2,
|
||||
cursor: page1.nextCursor!,
|
||||
});
|
||||
expect(page2.items.map((i) => i.titleName)).toEqual(["Show 3", "Show 4"]);
|
||||
expect(page2.nextCursor).not.toBeNull();
|
||||
const page3 = getUpcomingFeed("user-1", {
|
||||
direction: "recent",
|
||||
limit: 2,
|
||||
cursor: page2.nextCursor!,
|
||||
});
|
||||
expect(page3.items.map((i) => i.titleName)).toEqual(["Show 5"]);
|
||||
expect(page3.nextCursor).toBeNull();
|
||||
});
|
||||
|
||||
test("default call still returns only today-and-later items", () => {
|
||||
insertTvShow("tv-1", 100, 1, 3, {
|
||||
airDates: [daysFromNow(-1), daysFromNow(0), daysFromNow(1)],
|
||||
});
|
||||
insertStatus("user-1", "tv-1", "in_progress");
|
||||
|
||||
const result = getUpcomingFeed("user-1", { days: 7 });
|
||||
expect(result.items.map((i) => i.date)).toEqual([daysFromNow(0), daysFromNow(1)]);
|
||||
});
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm";
|
||||
import { and, asc, desc, eq, gte, inArray, lte, notExists, sql } from "drizzle-orm";
|
||||
|
||||
import { localDateString } from "@sofa/config";
|
||||
|
||||
@@ -310,6 +310,52 @@ export function getUpcomingEpisodes(
|
||||
.all();
|
||||
}
|
||||
|
||||
/** Aired, unwatched episodes for in-progress/completed titles, newest first. */
|
||||
export function getRecentUnwatchedEpisodes(userId: string, fromDate: string, toDate: string) {
|
||||
return db
|
||||
.select({
|
||||
episodeId: episodes.id,
|
||||
titleId: titles.id,
|
||||
titleName: titles.title,
|
||||
posterPath: titles.posterPath,
|
||||
posterThumbHash: titles.posterThumbHash,
|
||||
backdropPath: titles.backdropPath,
|
||||
backdropThumbHash: titles.backdropThumbHash,
|
||||
seasonNumber: seasons.seasonNumber,
|
||||
episodeNumber: episodes.episodeNumber,
|
||||
episodeName: episodes.name,
|
||||
airDate: episodes.airDate,
|
||||
userStatus: userTitleStatus.status,
|
||||
})
|
||||
.from(episodes)
|
||||
.innerJoin(seasons, eq(episodes.seasonId, seasons.id))
|
||||
.innerJoin(titles, and(eq(seasons.titleId, titles.id), eq(titles.type, "tv")))
|
||||
.innerJoin(
|
||||
userTitleStatus,
|
||||
and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)),
|
||||
)
|
||||
.where(
|
||||
and(
|
||||
gte(episodes.airDate, fromDate),
|
||||
lte(episodes.airDate, toDate),
|
||||
inArray(userTitleStatus.status, ["in_progress", "completed"]),
|
||||
notExists(
|
||||
db
|
||||
.select({ x: sql`1` })
|
||||
.from(userEpisodeWatches)
|
||||
.where(
|
||||
and(
|
||||
eq(userEpisodeWatches.episodeId, episodes.id),
|
||||
eq(userEpisodeWatches.userId, userId),
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
.orderBy(desc(episodes.airDate), asc(titles.title))
|
||||
.all();
|
||||
}
|
||||
|
||||
export function getUpcomingMovies(
|
||||
userId: string,
|
||||
fromDate: string,
|
||||
|
||||
@@ -46,9 +46,18 @@ function getBucketKey(dateStr: string, today: string): BucketKey {
|
||||
return `month_${dateStr.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
function getPastBucketKey(dateStr: string, today: string): BucketKey {
|
||||
if (dateStr === today) return "today";
|
||||
if (dateStr === addDays(today, -1)) return "yesterday";
|
||||
if (dateStr >= addDays(today, -6)) return "earlier_this_week";
|
||||
return `month_${dateStr.slice(0, 7)}`;
|
||||
}
|
||||
|
||||
function getBucketLabel(key: BucketKey): string {
|
||||
if (key === "today") return i18n._(msg`Today`);
|
||||
if (key === "tomorrow") return i18n._(msg`Tomorrow`);
|
||||
if (key === "yesterday") return i18n._(msg`Yesterday`);
|
||||
if (key === "earlier_this_week") return i18n._(msg`Earlier this week`);
|
||||
if (key === "this_week") return i18n._(msg`This Week`);
|
||||
if (key === "next_week") return i18n._(msg`Next Week`);
|
||||
if (key.startsWith("month_")) {
|
||||
@@ -57,13 +66,17 @@ function getBucketLabel(key: BucketKey): string {
|
||||
return key;
|
||||
}
|
||||
|
||||
export function groupByDateBucket<T extends { date: string }>(items: T[]): DateBucket<T>[] {
|
||||
export function groupByDateBucket<T extends { date: string }>(
|
||||
items: T[],
|
||||
options?: { past?: boolean },
|
||||
): DateBucket<T>[] {
|
||||
const bucketKeyFor = options?.past ? getPastBucketKey : getBucketKey;
|
||||
const today = getToday();
|
||||
const bucketMap = new Map<string, { label: string; items: T[] }>();
|
||||
const bucketOrder: string[] = [];
|
||||
|
||||
for (const item of items) {
|
||||
const key = getBucketKey(item.date, today);
|
||||
const key = bucketKeyFor(item.date, today);
|
||||
let bucket = bucketMap.get(key);
|
||||
if (!bucket) {
|
||||
bucket = { label: getBucketLabel(key), items: [] };
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "تنزيل النسخة الاحتياطية"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "تنزيل مكتبتك وسجل المشاهدة والتقييمات بتنسيق JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "تعديل صورة الملف الشخصي"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "جاهز — لم يتم استلام أي شيء بعد"
|
||||
msgid "Recent Searches"
|
||||
msgstr "عمليات البحث الأخيرة"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "إلغاء مشاهدة الكل"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "تم إلغاء مشاهدة كل {seasonLabel}"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "تم إلغاء مشاهدة م{seasonNum} ح{epNum}"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "السنة"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "سيتم تسجيل خروجك لتغيير رابط الخادم."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "Backup herunterladen"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "Lade deine Bibliothek, den Wiedergabeverlauf und Bewertungen als JSON herunter"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "Profilfoto bearbeiten"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "Bereit - noch nichts empfangen"
|
||||
msgid "Recent Searches"
|
||||
msgstr "Letzte Suchen"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "Alle als ungesehen markieren"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "Alle von {seasonLabel} als ungesehen markiert"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "S{seasonNum} E{epNum} als ungesehen markiert"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "Jahr"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "Du wirst abgemeldet, um die Server-URL zu ändern."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr ""
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "Download your library, watch history, and ratings as JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr "Earlier this week"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "Edit profile photo"
|
||||
@@ -2589,6 +2593,11 @@ msgstr ""
|
||||
msgid "Recent Searches"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr "Recently aired"
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr ""
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "Unwatched all of {seasonLabel}"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr "Unwatched episodes from the last 90 days."
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr ""
|
||||
@@ -3765,10 +3778,19 @@ msgstr "Year"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr "Yesterday"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr "You're all caught up on the last 90 days."
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "Descargar copia de seguridad"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "Descarga tu biblioteca, historial de visualización y valoraciones como JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "Editar foto de perfil"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "Listo — nada recibido aún"
|
||||
msgid "Recent Searches"
|
||||
msgstr "Búsquedas recientes"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "Desmarcar todo"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "Desmarcado todo de {seasonLabel}"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "No visto T{seasonNum} E{epNum}"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "Año"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "Se cerrará tu sesión para cambiar la URL del servidor."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "Télécharger la sauvegarde"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "Téléchargez votre bibliothèque, votre historique de visionnage et vos notes en JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "Modifier la photo de profil"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "Prêt — rien reçu pour l'instant"
|
||||
msgid "Recent Searches"
|
||||
msgstr "Recherches récentes"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "Tout démarquer"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "Tout marqué comme non regardé pour {seasonLabel}"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "Démarqué S{seasonNum} E{epNum}"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "Année"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "Vous serez déconnecté pour modifier l'URL du serveur."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "הורד גיבוי"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "הורד את הספרייה, היסטוריית הצפייה והדירוגים שלך כ-JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "ערוך תמונת פרופיל"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "מוכן — טרם התקבל דבר"
|
||||
msgid "Recent Searches"
|
||||
msgstr "חיפושים אחרונים"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "הסר צפייה מהכל"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "בוטלה צפייה בכל {seasonLabel}"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "בוטלה צפייה ב-S{seasonNum} E{epNum}"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "שנה"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "תתנתק כדי לשנות את כתובת ה-URL של השרת."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "Scarica backup"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "Scarica la tua libreria, cronologia di visione e valutazioni in formato JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "Modifica foto profilo"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "Pronto — nessun evento ricevuto ancora"
|
||||
msgid "Recent Searches"
|
||||
msgstr "Ricerche recenti"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "Rimuovi visione dalla stagione"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "Rimosso 'visto' da {seasonLabel}"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "Non visto S{seasonNum} E{epNum}"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "Anno"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "Verrai disconnesso per cambiare l'URL del server."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "バックアップをダウンロード"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "ライブラリ、視聴履歴、評価をJSONとしてダウンロード"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "プロフィール写真を編集"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "準備完了 — まだ受信していません"
|
||||
msgid "Recent Searches"
|
||||
msgstr "最近の検索"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "すべて未視聴に戻す"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "{seasonLabel}のすべてを未視聴にしました"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "S{seasonNum} E{epNum}を未視聴にしました"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "年"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "サーバーURLを変更するためにサインアウトされます。"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "백업 다운로드"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "라이브러리, 시청 기록, 평점을 JSON으로 다운로드"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "프로필 사진 편집"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "준비됨 — 아직 수신된 이벤트 없음"
|
||||
msgid "Recent Searches"
|
||||
msgstr "최근 검색"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "모두 시청 취소"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "{seasonLabel} 전체 시청 취소"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "S{seasonNum} E{epNum} 시청 취소됨"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "연도"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "서버 URL을 변경하려면 로그아웃됩니다."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "Back-up downloaden"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "Download je bibliotheek, kijkgeschiedenis en beoordelingen als JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "Profielfoto bewerken"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "Gereed — nog niets ontvangen"
|
||||
msgid "Recent Searches"
|
||||
msgstr "Recente zoekopdrachten"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "Alles als onbekeken markeren"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "Alles van {seasonLabel} als niet bekeken gemarkeerd"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "Niet bekeken S{seasonNum} E{epNum}"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "Jaar"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "Je wordt afgemeld om de server-URL te wijzigen."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "Baixar backup"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "Descarregue a sua biblioteca, histórico de visualizações e avaliações como JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "Editar foto de perfil"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "Pronto — nada recebido ainda"
|
||||
msgid "Recent Searches"
|
||||
msgstr "Pesquisas Recentes"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "Desmarcar todos"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "Desmarcado tudo de {seasonLabel}"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "Desmarcado T{seasonNum} E{epNum}"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "Ano"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "Você será desconectado para alterar a URL do servidor."
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -1071,6 +1071,10 @@ msgstr "下载备份"
|
||||
msgid "Download your library, watch history, and ratings as JSON"
|
||||
msgstr "将媒体库、观看历史和评分下载为 JSON"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Earlier this week"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "Edit profile photo"
|
||||
msgstr "编辑头像"
|
||||
@@ -2589,6 +2593,11 @@ msgstr "就绪 — 尚未收到任何事件"
|
||||
msgid "Recent Searches"
|
||||
msgstr "最近搜索"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Recently aired"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/components/search/recently-viewed-list.ios.tsx
|
||||
#: apps/native/src/components/search/recently-viewed-list.tsx
|
||||
msgid "Recently Viewed"
|
||||
@@ -3517,6 +3526,10 @@ msgstr "标记全部为未看"
|
||||
msgid "Unwatched all of {seasonLabel}"
|
||||
msgstr "已将 {seasonLabel} 全部标记为未看"
|
||||
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "Unwatched episodes from the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/titles/use-title-actions.ts
|
||||
msgid "Unwatched S{seasonNum} E{epNum}"
|
||||
msgstr "第 {seasonNum} 季第 {epNum} 集已取消标记"
|
||||
@@ -3765,10 +3778,19 @@ msgstr "年份"
|
||||
#~ msgid "Year to"
|
||||
#~ msgstr "Year to"
|
||||
|
||||
#: packages/i18n/src/date-buckets.ts
|
||||
msgid "Yesterday"
|
||||
msgstr ""
|
||||
|
||||
#: apps/native/src/app/(tabs)/(settings)/index.tsx
|
||||
msgid "You'll be signed out to change the server URL."
|
||||
msgstr "更改服务器 URL 将使您退出登录。"
|
||||
|
||||
#: apps/native/src/app/(tabs)/(home)/upcoming.tsx
|
||||
#: apps/web/src/routes/_app/upcoming.tsx
|
||||
msgid "You're all caught up on the last 90 days."
|
||||
msgstr ""
|
||||
|
||||
#: apps/web/src/components/update-toast.tsx
|
||||
#~ msgid "You're running v{0}."
|
||||
#~ msgstr ""
|
||||
|
||||
@@ -154,3 +154,37 @@ describe("groupByDateBucket", () => {
|
||||
expect(result[1].label).toBeTruthy();
|
||||
});
|
||||
});
|
||||
|
||||
describe("groupByDateBucket past option", () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date("2024-06-12T10:00:00"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
test("yesterday goes to the 'yesterday' bucket", () => {
|
||||
const result = groupByDateBucket([{ date: "2024-06-11" }], { past: true });
|
||||
expect(result[0].key).toBe("yesterday");
|
||||
expect(result[0].label).toBe("Yesterday");
|
||||
});
|
||||
|
||||
test("3 days ago goes to 'earlier_this_week'", () => {
|
||||
const result = groupByDateBucket([{ date: "2024-06-09" }], { past: true });
|
||||
expect(result[0].key).toBe("earlier_this_week");
|
||||
expect(result[0].label).toBe("Earlier this week");
|
||||
});
|
||||
|
||||
test("20 days ago goes to a month bucket", () => {
|
||||
const result = groupByDateBucket([{ date: "2024-05-23" }], { past: true });
|
||||
expect(result[0].key).toBe("month_2024-05");
|
||||
expect(result[0].label).toBe("May");
|
||||
});
|
||||
|
||||
test("default grouping is unchanged for a future date", () => {
|
||||
const result = groupByDateBucket([{ date: "2024-06-13" }]);
|
||||
expect(result[0].key).toBe("tomorrow");
|
||||
});
|
||||
});
|
||||
Reference in new issue
Block a user