diff --git a/apps/native/src/app/(tabs)/(home)/index.tsx b/apps/native/src/app/(tabs)/(home)/index.tsx index 999c68d..c9be98e 100644 --- a/apps/native/src/app/(tabs)/(home)/index.tsx +++ b/apps/native/src/app/(tabs)/(home)/index.tsx @@ -18,6 +18,7 @@ import { useCSSVariable } from "uniwind"; import { ContinueWatchingCard } from "@/components/dashboard/continue-watching-card"; import { HorizontalPosterRow } from "@/components/dashboard/horizontal-poster-row"; import { StatsCard } from "@/components/dashboard/stats-card"; +import { UpcomingSection } from "@/components/dashboard/upcoming-section"; import { EmptyState } from "@/components/ui/empty-state"; import { HorizontalListSeparator, @@ -179,8 +180,13 @@ export default function DashboardScreen() { )} + {/* Upcoming */} + + + + {/* Library */} - + @@ -200,7 +206,7 @@ export default function DashboardScreen() { {/* Recommendations */} {hasRecommendations && ( - + diff --git a/apps/native/src/app/(tabs)/(home)/upcoming.tsx b/apps/native/src/app/(tabs)/(home)/upcoming.tsx new file mode 100644 index 0000000..433fb7a --- /dev/null +++ b/apps/native/src/app/(tabs)/(home)/upcoming.tsx @@ -0,0 +1,111 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { IconCalendarEvent } from "@tabler/icons-react-native"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { Stack } from "expo-router"; +import { useCallback, useMemo } from "react"; +import { RefreshControl, SectionList, View } from "react-native"; +import Animated, { FadeInDown } from "react-native-reanimated"; +import { useCSSVariable, useResolveClassNames } from "uniwind"; + +import { UpcomingRow } from "@/components/dashboard/upcoming-row"; +import { ScaledIcon } from "@/components/ui/scaled-icon"; +import { Text } from "@/components/ui/text"; +import { orpc } from "@/lib/orpc"; +import { queryClient } from "@/lib/query-client"; +import { groupByDateBucket } from "@sofa/i18n/date-buckets"; + +const contentContainerStyle = { paddingBottom: 24 }; + +export default function UpcomingScreen() { + const { t } = useLingui(); + const headerTitleStyle = useResolveClassNames("font-display text-foreground text-xl"); + const tintColor = useCSSVariable("--color-primary") as string; + const backgroundColor = useCSSVariable("--color-background") as string; + const mutedColor = useCSSVariable("--color-muted-foreground") as string; + + const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage, isRefetching } = + useInfiniteQuery( + orpc.dashboard.upcoming.infiniteOptions({ + input: (pageParam: string | undefined) => ({ + days: 90, + limit: 20, + cursor: pageParam, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }), + ); + + const allItems = useMemo(() => data?.pages.flatMap((p) => p.items) ?? [], [data]); + const sections = useMemo( + () => + groupByDateBucket(allItems).map((b) => ({ + key: b.key, + title: b.label, + data: b.items, + })), + [allItems], + ); + + const onRefresh = useCallback(() => { + queryClient.invalidateQueries({ queryKey: orpc.dashboard.upcoming.key() }); + }, []); + + const onEndReached = useCallback(() => { + if (hasNextPage && !isFetchingNextPage) { + fetchNextPage(); + } + }, [hasNextPage, isFetchingNextPage, fetchNextPage]); + + return ( + <> + + }> + {t`Upcoming`} + + + {!isPending && allItems.length === 0 ? ( + + + + No upcoming episodes or releases in the next 90 days. + + + ) : ( + `${item.titleId}-${item.date}-${i}`} + renderItem={({ item }) => ( + + + + )} + renderSectionHeader={({ section: { title } }) => ( + + + {title} + + + )} + stickySectionHeadersEnabled + contentContainerStyle={contentContainerStyle} + contentInsetAdjustmentBehavior="automatic" + onEndReached={onEndReached} + onEndReachedThreshold={0.5} + refreshControl={} + /> + )} + + ); +} diff --git a/apps/native/src/components/dashboard/upcoming-row.tsx b/apps/native/src/components/dashboard/upcoming-row.tsx new file mode 100644 index 0000000..9758b88 --- /dev/null +++ b/apps/native/src/components/dashboard/upcoming-row.tsx @@ -0,0 +1,152 @@ +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { IconMovie } from "@tabler/icons-react-native"; +import { Link } from "expo-router"; +import { Pressable, View } from "react-native"; +import { useCSSVariable } from "uniwind"; + +import { Image } from "@/components/ui/image"; +import { ScaledIcon } from "@/components/ui/scaled-icon"; +import { Text } from "@/components/ui/text"; +import { titleActions } from "@/lib/title-actions"; +import type { UpcomingItem } from "@sofa/api/schemas"; + +const statusColors = { + in_watchlist: "--color-status-watchlist", + watching: "--color-status-watching", + caught_up: "--color-status-completed", + completed: "--color-status-completed", +} as const; + +function formatShortDate(dateStr: string): string { + const d = new Date(`${dateStr}T00:00:00Z`); + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + timeZone: "UTC", + }).format(d); +} + +export function UpcomingRow({ item }: { item: UpcomingItem }) { + const { t } = useLingui(); + const statusColor = useCSSVariable(statusColors[item.userStatus]) as string; + const mutedColor = useCSSVariable("--color-muted-foreground") as string; + + let subtitle: string; + if (item.titleType === "movie") { + subtitle = formatShortDate(item.date); + } else if (item.episodeCount > 1 && item.seasonNumber != null) { + const seasonNum = item.seasonNumber; + const epCount = item.episodeCount; + subtitle = t`S${seasonNum} \u00b7 ${plural(epCount, { one: "# episode", other: "# episodes" })}`; + } else { + const episodeLabel = + item.seasonNumber != null && item.episodeNumber != null + ? `S${item.seasonNumber} E${item.episodeNumber}` + : null; + subtitle = [episodeLabel, item.episodeName].filter(Boolean).join(" \u00b7 "); + } + + const rowContent = ( + + {/* Poster */} + + {item.posterPath ? ( + + ) : ( + + + + )} + + + {/* Content */} + + + {/* Status dot with halo */} + + + + + + {item.titleName} + + {item.isNewSeason && ( + + + {t`New Season`} + + + )} + + + {item.titleType === "movie" && ( + + )} + + {subtitle} + + + + + {/* Right column: date + provider logo */} + + {formatShortDate(item.date)} + {item.streamingProvider && + (item.streamingProvider.logoPath ? ( + + + + ) : ( + + {item.streamingProvider.providerName} + + ))} + + + ); + + return ( + + {rowContent} + + + {item.titleType === "movie" && ( + titleActions.markMovieWatched(item.titleId, item.titleName)} + /> + )} + {item.titleType === "tv" && item.episodeId && ( + titleActions.watchEpisode(item.episodeId!)} + /> + )} + titleActions.removeFromLibrary(item.titleId)} + /> + + + ); +} diff --git a/apps/native/src/components/dashboard/upcoming-section.tsx b/apps/native/src/components/dashboard/upcoming-section.tsx new file mode 100644 index 0000000..801aa39 --- /dev/null +++ b/apps/native/src/components/dashboard/upcoming-section.tsx @@ -0,0 +1,40 @@ +import { useLingui } from "@lingui/react/macro"; +import { IconCalendarEvent } from "@tabler/icons-react-native"; +import { useQuery } from "@tanstack/react-query"; +import { useRouter } from "expo-router"; +import { View } from "react-native"; + +import { SectionHeader } from "@/components/ui/section-header"; +import { orpc } from "@/lib/orpc"; + +import { UpcomingRow } from "./upcoming-row"; + +export function UpcomingSection() { + const { t } = useLingui(); + const { push } = useRouter(); + const { data, isPending } = useQuery( + orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }), + ); + + if (isPending) return null; + + const items = data?.items ?? []; + if (items.length === 0) return null; + + return ( + + + push("/upcoming")} + /> + + + {items.map((item, i) => ( + + ))} + + + ); +} diff --git a/apps/native/src/components/ui/section-header.tsx b/apps/native/src/components/ui/section-header.tsx index 8fe81c4..f232320 100644 --- a/apps/native/src/components/ui/section-header.tsx +++ b/apps/native/src/components/ui/section-header.tsx @@ -1,5 +1,6 @@ +import { useLingui } from "@lingui/react/macro"; import type { Icon } from "@tabler/icons-react-native"; -import { View } from "react-native"; +import { Pressable, View } from "react-native"; import { useCSSVariable } from "uniwind"; import { ScaledIcon } from "@/components/ui/scaled-icon"; @@ -9,16 +10,30 @@ interface SectionHeaderProps { title: string; icon?: Icon; iconColor?: string; + onSeeAll?: () => void; } -export function SectionHeader({ title, icon: IconComponent, iconColor }: SectionHeaderProps) { +export function SectionHeader({ + title, + icon: IconComponent, + iconColor, + onSeeAll, +}: SectionHeaderProps) { + const { t } = useLingui(); const primaryColor = useCSSVariable("--color-primary") as string; const resolvedColor = iconColor ?? primaryColor; return ( - - {IconComponent && } - {title} + + + {IconComponent && } + {title} + + {onSeeAll && ( + + {t`See all`} + + )} ); } diff --git a/apps/server/src/orpc/procedures/dashboard.ts b/apps/server/src/orpc/procedures/dashboard.ts index 1b0fae8..2cefe37 100644 --- a/apps/server/src/orpc/procedures/dashboard.ts +++ b/apps/server/src/orpc/procedures/dashboard.ts @@ -2,6 +2,7 @@ import { getContinueWatchingFeed, getLibraryFeed, getRecommendationsFeed, + getUpcomingFeed, getUserStats, getWatchCount, getWatchHistory, @@ -87,6 +88,27 @@ export const recommendations = os.dashboard.recommendations.use(authed).handler( return { items }; }); +export const upcoming = os.dashboard.upcoming.use(authed).handler(({ input, context }) => { + const result = getUpcomingFeed(context.user.id, { + days: input.days, + limit: input.limit, + cursor: input.cursor, + }); + return { + items: result.items.map((item) => ({ + ...item, + posterPath: tmdbImageUrl(item.posterPath, "posters"), + streamingProvider: item.streamingProvider + ? { + ...item.streamingProvider, + logoPath: tmdbImageUrl(item.streamingProvider.logoPath, "logos"), + } + : null, + })), + nextCursor: result.nextCursor, + }; +}); + export const watchHistory = os.dashboard.watchHistory.use(authed).handler(({ input, context }) => { const coreType = watchHistoryTypeMap[input.type]; const count = getWatchCount(context.user.id, coreType, input.period); diff --git a/apps/server/src/orpc/router.ts b/apps/server/src/orpc/router.ts index 84d3efc..b7bf9a8 100644 --- a/apps/server/src/orpc/router.ts +++ b/apps/server/src/orpc/router.ts @@ -40,6 +40,7 @@ export const implementedRouter = { dashboard: { stats: dashboard.stats, continueWatching: dashboard.continueWatching, + upcoming: dashboard.upcoming, library: dashboard.library, recommendations: dashboard.recommendations, watchHistory: dashboard.watchHistory, diff --git a/apps/web/src/components/dashboard/feed-section.tsx b/apps/web/src/components/dashboard/feed-section.tsx index 306ca84..5333a45 100644 --- a/apps/web/src/components/dashboard/feed-section.tsx +++ b/apps/web/src/components/dashboard/feed-section.tsx @@ -1,19 +1,30 @@ +import { Trans } from "@lingui/react/macro"; +import { Link } from "@tanstack/react-router"; import type { ReactNode } from "react"; export function FeedSection({ title, icon, children, + seeAllLink, }: { title: string; icon: ReactNode; children: ReactNode; + seeAllLink?: string; }) { return (
-
- {icon} -

{title}

+
+
+ {icon} +

{title}

+
+ {seeAllLink && ( + + See all + + )}
{children}
diff --git a/apps/web/src/components/dashboard/upcoming-item.tsx b/apps/web/src/components/dashboard/upcoming-item.tsx new file mode 100644 index 0000000..d510339 --- /dev/null +++ b/apps/web/src/components/dashboard/upcoming-item.tsx @@ -0,0 +1,133 @@ +import { plural } from "@lingui/core/macro"; +import { useLingui } from "@lingui/react/macro"; +import { IconMovie } from "@tabler/icons-react"; +import { Link } from "@tanstack/react-router"; + +import { Tooltip, TooltipContent, TooltipTrigger } from "@/components/ui/tooltip"; +import { thumbHashToUrl } from "@/lib/thumbhash"; +import type { UpcomingItem } from "@sofa/api/schemas"; + +const statusColorClass = { + in_watchlist: "bg-status-watchlist", + watching: "bg-status-watching", + caught_up: "bg-status-completed", + completed: "bg-status-completed", +} as const; + +const statusHaloClass = statusColorClass; + +function formatShortDate(dateStr: string): string { + const d = new Date(`${dateStr}T00:00:00Z`); + return new Intl.DateTimeFormat(undefined, { + month: "short", + day: "numeric", + timeZone: "UTC", + }).format(d); +} + +export function UpcomingRow({ item }: { item: UpcomingItem }) { + const { t } = useLingui(); + + const statusLabels = { + in_watchlist: t`On Watchlist`, + watching: t`Watching`, + caught_up: t`Caught Up`, + completed: t`Completed`, + } as const; + + let subtitle: string; + if (item.titleType === "movie") { + subtitle = formatShortDate(item.date); + } else if (item.episodeCount > 1 && item.seasonNumber != null) { + const seasonNum = item.seasonNumber; + const epCount = item.episodeCount; + subtitle = t`S${seasonNum} \u00b7 ${plural(epCount, { one: "# episode", other: "# episodes" })}`; + } else { + const episodeLabel = + item.seasonNumber != null && item.episodeNumber != null + ? `S${item.seasonNumber}E${item.episodeNumber}` + : null; + subtitle = [episodeLabel, item.episodeName].filter(Boolean).join(" \u00b7 "); + } + + return ( + + {/* Poster */} +
+ {item.posterPath ? ( + + ) : ( +
+ +
+ )} +
+ + {/* Content */} +
+
+ + + + + + {statusLabels[item.userStatus]} + + {item.titleName} + {item.isNewSeason && ( + + {t`New Season`} + + )} +
+
+ {item.titleType === "movie" && } + {subtitle} +
+
+ + {/* Right column: date + provider logo */} +
+ {formatShortDate(item.date)} + {item.streamingProvider && ( + + + {item.streamingProvider.logoPath ? ( + {item.streamingProvider.providerName} + ) : ( + + {item.streamingProvider.providerName} + + )} + + {item.streamingProvider.providerName} + + )} +
+ + ); +} diff --git a/apps/web/src/components/dashboard/upcoming-section.tsx b/apps/web/src/components/dashboard/upcoming-section.tsx new file mode 100644 index 0000000..ca3d6d8 --- /dev/null +++ b/apps/web/src/components/dashboard/upcoming-section.tsx @@ -0,0 +1,35 @@ +import { useLingui } from "@lingui/react/macro"; +import { IconCalendarEvent } from "@tabler/icons-react"; +import { useQuery } from "@tanstack/react-query"; + +import { orpc } from "@/lib/orpc/client"; + +import { FeedSection } from "./feed-section"; +import { UpcomingRow } from "./upcoming-item"; + +export function UpcomingSection() { + const { data, isPending } = useQuery( + orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }), + ); + + const { t } = useLingui(); + + if (isPending) return null; + + const items = data?.items ?? []; + if (items.length === 0) return null; + + return ( + } + seeAllLink="/upcoming" + > +
+ {items.map((item, i) => ( + + ))} +
+
+ ); +} diff --git a/apps/web/src/components/nav-bar.tsx b/apps/web/src/components/nav-bar.tsx index 21dd631..66a6de8 100644 --- a/apps/web/src/components/nav-bar.tsx +++ b/apps/web/src/components/nav-bar.tsx @@ -1,5 +1,12 @@ import { Trans, useLingui } from "@lingui/react/macro"; -import { IconCompass, IconHome, IconLogout, IconSearch, IconSettings } from "@tabler/icons-react"; +import { + IconCalendarEvent, + IconCompass, + IconHome, + IconLogout, + IconSearch, + IconSettings, +} from "@tabler/icons-react"; import { Link, useLocation, useNavigate } from "@tanstack/react-router"; import { useSetAtom } from "jotai"; import { motion } from "motion/react"; @@ -67,7 +74,6 @@ function useActiveIndicator( const instantRef = useRef(true); useLayoutEffect(() => { - instantRef.current = false; const update = () => { if (activeIndex === -1) { setValue(null); @@ -82,6 +88,8 @@ function useActiveIndicator( } }; update(); + // After the initial measurement, allow subsequent changes to animate + instantRef.current = false; const container = containerRef.current; if (!container) return; const observer = new ResizeObserver(() => { @@ -114,6 +122,7 @@ export function NavBar({ const navLinks = [ { href: "/dashboard", label: t`Home` }, { href: "/explore", label: t`Explore` }, + { href: "/upcoming", label: t`Upcoming` }, ] as const; const initial = userName?.charAt(0).toUpperCase() ?? "?"; @@ -275,6 +284,7 @@ export function MobileTabBar() { const mobileTabs = [ { href: "/dashboard", label: t`Home`, icon: IconHome }, { href: "/explore", label: t`Explore`, icon: IconCompass }, + { href: "/upcoming", label: t`Upcoming`, icon: IconCalendarEvent }, { href: "/settings", label: t`Settings`, icon: IconSettings }, ] as const; diff --git a/apps/web/src/components/people/person-detail-client.tsx b/apps/web/src/components/people/person-detail-client.tsx index f7b3e5b..ca2e739 100644 --- a/apps/web/src/components/people/person-detail-client.tsx +++ b/apps/web/src/components/people/person-detail-client.tsx @@ -10,7 +10,7 @@ import { PersonHero } from "./person-hero"; export function PersonDetailSkeleton() { return ( -
+
@@ -62,7 +62,7 @@ export function PersonDetailClient({ id }: { id: string }) { if (!person) return null; return ( -
+
diff --git a/apps/web/src/components/settings/settings-shell.tsx b/apps/web/src/components/settings/settings-shell.tsx index c862844..791740b 100644 --- a/apps/web/src/components/settings/settings-shell.tsx +++ b/apps/web/src/components/settings/settings-shell.tsx @@ -15,7 +15,7 @@ const sectionVariants = { export function SettingsShell({ children, footer }: { children: ReactNode; footer?: ReactNode }) { return ( AuthRoute, } as any) +const AppUpcomingRoute = AppUpcomingRouteImport.update({ + id: '/upcoming', + path: '/upcoming', + getParentRoute: () => AppRoute, +} as any) const AppSettingsRoute = AppSettingsRouteImport.update({ id: '/settings', path: '/settings', @@ -81,6 +87,7 @@ export interface FileRoutesByFullPath { '/dashboard': typeof AppDashboardRoute '/explore': typeof AppExploreRoute '/settings': typeof AppSettingsRoute + '/upcoming': typeof AppUpcomingRoute '/login': typeof AuthLoginRoute '/register': typeof AuthRegisterRoute '/people/$id': typeof AppPeopleIdRoute @@ -92,6 +99,7 @@ export interface FileRoutesByTo { '/dashboard': typeof AppDashboardRoute '/explore': typeof AppExploreRoute '/settings': typeof AppSettingsRoute + '/upcoming': typeof AppUpcomingRoute '/login': typeof AuthLoginRoute '/register': typeof AuthRegisterRoute '/people/$id': typeof AppPeopleIdRoute @@ -106,6 +114,7 @@ export interface FileRoutesById { '/_app/dashboard': typeof AppDashboardRoute '/_app/explore': typeof AppExploreRoute '/_app/settings': typeof AppSettingsRoute + '/_app/upcoming': typeof AppUpcomingRoute '/_auth/login': typeof AuthLoginRoute '/_auth/register': typeof AuthRegisterRoute '/_app/people/$id': typeof AppPeopleIdRoute @@ -119,6 +128,7 @@ export interface FileRouteTypes { | '/dashboard' | '/explore' | '/settings' + | '/upcoming' | '/login' | '/register' | '/people/$id' @@ -130,6 +140,7 @@ export interface FileRouteTypes { | '/dashboard' | '/explore' | '/settings' + | '/upcoming' | '/login' | '/register' | '/people/$id' @@ -143,6 +154,7 @@ export interface FileRouteTypes { | '/_app/dashboard' | '/_app/explore' | '/_app/settings' + | '/_app/upcoming' | '/_auth/login' | '/_auth/register' | '/_app/people/$id' @@ -200,6 +212,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof AuthLoginRouteImport parentRoute: typeof AuthRoute } + '/_app/upcoming': { + id: '/_app/upcoming' + path: '/upcoming' + fullPath: '/upcoming' + preLoaderRoute: typeof AppUpcomingRouteImport + parentRoute: typeof AppRoute + } '/_app/settings': { id: '/_app/settings' path: '/settings' @@ -242,6 +261,7 @@ interface AppRouteChildren { AppDashboardRoute: typeof AppDashboardRoute AppExploreRoute: typeof AppExploreRoute AppSettingsRoute: typeof AppSettingsRoute + AppUpcomingRoute: typeof AppUpcomingRoute AppPeopleIdRoute: typeof AppPeopleIdRoute AppTitlesIdRoute: typeof AppTitlesIdRoute } @@ -250,6 +270,7 @@ const AppRouteChildren: AppRouteChildren = { AppDashboardRoute: AppDashboardRoute, AppExploreRoute: AppExploreRoute, AppSettingsRoute: AppSettingsRoute, + AppUpcomingRoute: AppUpcomingRoute, AppPeopleIdRoute: AppPeopleIdRoute, AppTitlesIdRoute: AppTitlesIdRoute, } diff --git a/apps/web/src/routes/_app/dashboard.tsx b/apps/web/src/routes/_app/dashboard.tsx index 4ee3d68..d38e762 100644 --- a/apps/web/src/routes/_app/dashboard.tsx +++ b/apps/web/src/routes/_app/dashboard.tsx @@ -7,6 +7,7 @@ import { RecommendationsSection } from "@/components/dashboard/recommendations-s import { StatsSectionSkeleton } from "@/components/dashboard/stats-display"; import { StatsSection } from "@/components/dashboard/stats-section"; import { TitleGridSectionSkeleton } from "@/components/dashboard/title-grid"; +import { UpcomingSection } from "@/components/dashboard/upcoming-section"; import { WelcomeHeader } from "@/components/dashboard/welcome-header"; import { Skeleton } from "@/components/ui/skeleton"; import { orpc } from "@/lib/orpc/client"; @@ -19,6 +20,17 @@ export const Route = createFileRoute("/_app/dashboard")({ context.queryClient.ensureQueryData(orpc.dashboard.stats.queryOptions()), context.queryClient.ensureQueryData(orpc.dashboard.continueWatching.queryOptions()), context.queryClient.ensureQueryData(orpc.dashboard.recommendations.queryOptions()), + context.queryClient.ensureQueryData( + orpc.dashboard.upcoming.queryOptions({ input: { days: 7, limit: 5 } }), + ), + context.queryClient.ensureInfiniteQueryData( + orpc.dashboard.library.infiniteOptions({ + input: (pageParam: number) => ({ page: pageParam }), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, + }), + ), ]); }, pendingComponent: DashboardSkeleton, @@ -27,7 +39,7 @@ export const Route = createFileRoute("/_app/dashboard")({ function DashboardSkeleton() { return ( -
+
@@ -43,10 +55,11 @@ function DashboardSkeleton() { function DashboardPage() { const { session } = Route.useRouteContext(); return ( -
+
+
diff --git a/apps/web/src/routes/_app/explore.tsx b/apps/web/src/routes/_app/explore.tsx index 233b3cd..8e6becc 100644 --- a/apps/web/src/routes/_app/explore.tsx +++ b/apps/web/src/routes/_app/explore.tsx @@ -15,6 +15,14 @@ export const Route = createFileRoute("/_app/explore")({ head: () => ({ meta: [{ title: "Explore — Sofa" }] }), loader: async ({ context }) => { await Promise.all([ + context.queryClient.ensureInfiniteQueryData( + orpc.explore.trending.infiniteOptions({ + input: (pageParam: number) => ({ type: "all" as const, page: pageParam }), + initialPageParam: 1, + getNextPageParam: (lastPage) => + lastPage.page < lastPage.totalPages ? lastPage.page + 1 : undefined, + }), + ), context.queryClient.ensureQueryData( orpc.explore.popular.queryOptions({ input: { type: "movie" } }), ), @@ -35,7 +43,7 @@ export const Route = createFileRoute("/_app/explore")({ function ExploreSkeletons() { return ( -
+
{[1, 2, 3].map((i) => (
@@ -114,7 +122,7 @@ function ExplorePage() { ); return ( -
+
{hero && ( +
diff --git a/apps/web/src/routes/_app/titles.$id.tsx b/apps/web/src/routes/_app/titles.$id.tsx index 8192135..31a0fba 100644 --- a/apps/web/src/routes/_app/titles.$id.tsx +++ b/apps/web/src/routes/_app/titles.$id.tsx @@ -47,7 +47,7 @@ function TitleDetailPage() { const themeStyle = getThemeCssProperties(title.colorPalette); return ( -
+
} /> +
diff --git a/apps/web/src/routes/_app/upcoming.tsx b/apps/web/src/routes/_app/upcoming.tsx new file mode 100644 index 0000000..4900ebb --- /dev/null +++ b/apps/web/src/routes/_app/upcoming.tsx @@ -0,0 +1,122 @@ +import { Trans, useLingui } from "@lingui/react/macro"; +import { IconCalendarEvent } from "@tabler/icons-react"; +import { useInfiniteQuery } from "@tanstack/react-query"; +import { createFileRoute } from "@tanstack/react-router"; + +import { UpcomingRow } from "@/components/dashboard/upcoming-item"; +import { Skeleton } from "@/components/ui/skeleton"; +import { useInfiniteScroll } from "@/hooks/use-infinite-scroll"; +import { orpc } from "@/lib/orpc/client"; +import { groupByDateBucket } from "@sofa/i18n/date-buckets"; + +export const Route = createFileRoute("/_app/upcoming")({ + staleTime: 30_000, + head: () => ({ meta: [{ title: "Upcoming — Sofa" }] }), + loader: async ({ context }) => { + await context.queryClient.ensureInfiniteQueryData( + orpc.dashboard.upcoming.infiniteOptions({ + input: (pageParam: string | undefined) => ({ + days: 90, + limit: 20, + cursor: pageParam, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }), + ); + }, + pendingComponent: UpcomingSkeleton, + component: UpcomingPage, +}); + +function UpcomingSkeleton() { + return ( +
+
+ + +
+ {Array.from({ length: 5 }, (_, i) => ( +
+ +
+ + +
+
+ ))} +
+ ); +} + +function UpcomingPage() { + const { data, isPending, fetchNextPage, hasNextPage, isFetchingNextPage } = useInfiniteQuery( + orpc.dashboard.upcoming.infiniteOptions({ + input: (pageParam: string | undefined) => ({ + days: 90, + limit: 20, + cursor: pageParam, + }), + initialPageParam: undefined as string | undefined, + getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined, + }), + ); + + const sentinelRef = useInfiniteScroll({ + fetchNextPage, + hasNextPage, + isFetchingNextPage, + }); + + if (isPending) return ; + + const allItems = data?.pages.flatMap((p) => p.items) ?? []; + + if (allItems.length === 0) { + return ( +
+ +
+ +

+ No upcoming episodes or releases in the next 90 days. +

+
+
+ ); + } + + const buckets = groupByDateBucket(allItems); + + return ( +
+ + {buckets.map((bucket) => ( +
+

+ {bucket.label} +

+
+ {bucket.items.map((item, i) => ( + + ))} +
+
+ ))} +
+ {isFetchingNextPage && } +
+ ); +} + +function UpcomingHeader() { + const { t } = useLingui(); + return ( +
+

{t`Upcoming`}

+

+ Episodes and movies coming up in the next 90 days. +

+
+ ); +} diff --git a/docs/content/docs/api/dashboard/dashboard.upcoming.mdx b/docs/content/docs/api/dashboard/dashboard.upcoming.mdx new file mode 100644 index 0000000..ea24f44 --- /dev/null +++ b/docs/content/docs/api/dashboard/dashboard.upcoming.mdx @@ -0,0 +1,19 @@ +--- +title: Get upcoming episodes and movies +full: true +_openapi: + method: GET + toc: [] + structuredData: + headings: [] + contents: + - content: >- + Fetch upcoming episodes and movie releases for titles in the user's + library, sorted by date. Supports cursor-based pagination. +--- + +{/* This file was generated by Fumadocs. Do not edit this file directly. Any changes should be made by running the generation command again. */} + +Fetch upcoming episodes and movie releases for titles in the user's library, sorted by date. Supports cursor-based pagination. + + \ No newline at end of file diff --git a/docs/public/openapi.json b/docs/public/openapi.json index 208e15b..f4872fa 100644 --- a/docs/public/openapi.json +++ b/docs/public/openapi.json @@ -1787,8 +1787,7 @@ "anyOf": [ { "enum": [ - "in_progress", - "completed" + "watchlist" ], "type": "string" }, @@ -1796,13 +1795,13 @@ "type": "null" } ], - "description": "Tracking status: in_progress (currently watching), completed (finished), or null to remove from library" + "description": "Set to watchlist to add to library, or null to remove. Other transitions happen via watch endpoints." } }, "required": [ "status" ], - "description": "Update the user's tracking status for a title" + "description": "Add a title to the user's watchlist or remove from library" } } } @@ -1989,8 +1988,9 @@ "anyOf": [ { "enum": [ - "watchlist", - "in_progress", + "in_watchlist", + "watching", + "caught_up", "completed" ], "type": "string" @@ -1999,7 +1999,7 @@ "type": "null" } ], - "description": "User's tracking status, or null if not in library" + "description": "User's display status, or null if not in library" }, "rating": { "anyOf": [ @@ -2079,13 +2079,14 @@ }, "additionalProperties": { "enum": [ - "watchlist", - "in_progress", + "in_watchlist", + "watching", + "caught_up", "completed" ], "type": "string" }, - "description": "Map of title ID to the user's tracking status" + "description": "Map of title ID to the user's display status" } }, "required": [ @@ -2525,13 +2526,14 @@ }, "additionalProperties": { "enum": [ - "watchlist", - "in_progress", + "in_watchlist", + "watching", + "caught_up", "completed" ], "type": "string" }, - "description": "Map of title ID to the user's tracking status" + "description": "Map of title ID to the user's display status" }, "page": { "type": "number", @@ -2848,6 +2850,252 @@ ] } }, + "/dashboard/upcoming": { + "get": { + "operationId": "dashboard.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.", + "tags": [ + "Dashboard" + ], + "parameters": [ + { + "name": "days", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 90, + "default": 90, + "description": "How many days into the future to look" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "limit", + "in": "query", + "schema": { + "type": "integer", + "minimum": 1, + "maximum": 50, + "default": 20, + "description": "Maximum items per page" + }, + "allowEmptyValue": true, + "allowReserved": true + }, + { + "name": "cursor", + "in": "query", + "schema": { + "type": "string", + "description": "Pagination cursor" + }, + "allowEmptyValue": true, + "allowReserved": true + } + ], + "responses": { + "200": { + "description": "Upcoming items sorted by date with streaming info", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "items": { + "type": "array", + "items": { + "type": "object", + "properties": { + "episodeId": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Episode ID (TV only, null for movies and collapsed batches)" + }, + "titleId": { + "type": "string", + "description": "Internal title ID" + }, + "titleName": { + "type": "string", + "description": "Display title" + }, + "titleType": { + "enum": [ + "movie", + "tv" + ], + "type": "string", + "description": "Media type" + }, + "posterPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Poster image path" + }, + "posterThumbHash": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "ThumbHash blur placeholder" + }, + "seasonNumber": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Season number (TV only)" + }, + "episodeNumber": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "description": "Episode number (TV only)" + }, + "episodeName": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Episode title (TV only)" + }, + "episodeCount": { + "type": "number", + "description": "Number of episodes (1 for single, >1 for collapsed batch drops)" + }, + "date": { + "type": "string", + "description": "Air date or release date (YYYY-MM-DD)" + }, + "userStatus": { + "enum": [ + "in_watchlist", + "watching", + "caught_up", + "completed" + ], + "type": "string", + "description": "User's display status" + }, + "isNewSeason": { + "type": "boolean", + "description": "Whether this is a new season for a completed show" + }, + "streamingProvider": { + "anyOf": [ + { + "type": "object", + "properties": { + "providerId": { + "type": "number" + }, + "providerName": { + "type": "string" + }, + "logoPath": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + } + }, + "required": [ + "providerId", + "providerName", + "logoPath" + ] + }, + { + "type": "null" + } + ], + "description": "Primary streaming provider, or null" + } + }, + "required": [ + "episodeId", + "titleId", + "titleName", + "titleType", + "posterPath", + "posterThumbHash", + "seasonNumber", + "episodeNumber", + "episodeName", + "episodeCount", + "date", + "userStatus", + "isNewSeason", + "streamingProvider" + ], + "description": "An upcoming episode or movie release" + } + }, + "nextCursor": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Cursor for the next page, or null if no more items" + } + }, + "required": [ + "items", + "nextCursor" + ], + "description": "Upcoming episodes and movie releases for tracked titles" + } + } + } + } + }, + "security": [ + { + "session": [] + } + ] + } + }, "/dashboard/library": { "get": { "operationId": "dashboard.library", @@ -2976,8 +3224,9 @@ "anyOf": [ { "enum": [ - "watchlist", - "in_progress", + "in_watchlist", + "watching", + "caught_up", "completed" ], "type": "string" @@ -2986,7 +3235,7 @@ "type": "null" } ], - "description": "User's tracking status" + "description": "User's display status" } }, "required": [ @@ -3292,13 +3541,14 @@ }, "additionalProperties": { "enum": [ - "watchlist", - "in_progress", + "in_watchlist", + "watching", + "caught_up", "completed" ], "type": "string" }, - "description": "Map of title ID to the user's tracking status" + "description": "Map of title ID to the user's display status" }, "episodeProgress": { "type": "object", @@ -3488,13 +3738,14 @@ }, "additionalProperties": { "enum": [ - "watchlist", - "in_progress", + "in_watchlist", + "watching", + "caught_up", "completed" ], "type": "string" }, - "description": "Map of title ID to the user's tracking status" + "description": "Map of title ID to the user's display status" }, "episodeProgress": { "type": "object", @@ -4124,13 +4375,14 @@ }, "additionalProperties": { "enum": [ - "watchlist", - "in_progress", + "in_watchlist", + "watching", + "caught_up", "completed" ], "type": "string" }, - "description": "Map of title ID to the user's tracking status" + "description": "Map of title ID to the user's display status" }, "episodeProgress": { "type": "object", diff --git a/packages/api/src/contract.ts b/packages/api/src/contract.ts index a4667bb..c6ac4c4 100644 --- a/packages/api/src/contract.ts +++ b/packages/api/src/contract.ts @@ -57,6 +57,8 @@ import { UpdateRatingInput, UpdateScheduleInput, UpdateStatusInput, + UpcomingInput, + UpcomingOutput, UploadAvatarInput, UploadAvatarOutput, UserInfoOutput, @@ -290,6 +292,18 @@ export const contract = { successDescription: "Recommended titles", }) .output(DashboardRecommendationsOutput), + upcoming: oc + .route({ + method: "GET", + path: "/dashboard/upcoming", + tags: ["Dashboard"], + 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.", + successDescription: "Upcoming items sorted by date with streaming info", + }) + .input(UpcomingInput) + .output(UpcomingOutput), watchHistory: oc .route({ method: "GET", diff --git a/packages/api/src/schemas.ts b/packages/api/src/schemas.ts index 600ad31..da4e703 100644 --- a/packages/api/src/schemas.ts +++ b/packages/api/src/schemas.ts @@ -540,6 +540,63 @@ export const DashboardRecommendationsOutput = z description: "Personalized title recommendations for the dashboard", }); +// ─── Upcoming outputs ───────────────────────────────────────── + +export const UpcomingInput = z + .object({ + days: z + .number() + .int() + .min(1) + .max(90) + .default(90) + .describe("How many days into the future to look"), + limit: z.number().int().min(1).max(50).default(20).describe("Maximum items per page"), + cursor: z.string().optional().describe("Pagination cursor"), + }) + .meta({ description: "Filters for the upcoming feed" }); + +export const UpcomingItemSchema = z + .object({ + episodeId: z + .string() + .nullable() + .describe("Episode ID (TV only, null for movies and collapsed batches)"), + titleId: z.string().describe("Internal title ID"), + titleName: z.string().describe("Display title"), + titleType: z.enum(["movie", "tv"]).describe("Media type"), + posterPath: z.string().nullable().describe("Poster image path"), + posterThumbHash: z.string().nullable().describe("ThumbHash blur placeholder"), + seasonNumber: z.number().nullable().describe("Season number (TV only)"), + episodeNumber: z.number().nullable().describe("Episode number (TV only)"), + episodeName: z.string().nullable().describe("Episode title (TV only)"), + episodeCount: z + .number() + .describe("Number of episodes (1 for single, >1 for collapsed batch drops)"), + date: z.string().describe("Air date or release date (YYYY-MM-DD)"), + userStatus: displayStatusEnum.describe("User's display status"), + isNewSeason: z.boolean().describe("Whether this is a new season for a completed show"), + streamingProvider: z + .object({ + providerId: z.number(), + providerName: z.string(), + logoPath: z.string().nullable(), + }) + .nullable() + .describe("Primary streaming provider, or null"), + }) + .meta({ description: "An upcoming episode or movie release" }); + +export const UpcomingOutput = z + .object({ + items: z.array(UpcomingItemSchema), + nextCursor: z + .string() + .nullable() + .describe("Cursor for the next page, or null if no more items"), + }) + .meta({ description: "Upcoming episodes and movie releases for tracked titles" }); + // ─── Explore outputs ─────────────────────────────────────────── export const TrendingOutput = z @@ -1036,4 +1093,5 @@ export type PaginationInfo = z.infer; export type TimePeriod = z.infer["period"]; export type ImportJob = z.infer; export type NormalizedImport = z.infer; +export type UpcomingItem = z.infer; export type UpdateCheckResult = z.infer; diff --git a/packages/core/src/discovery.ts b/packages/core/src/discovery.ts index 7f55c49..befd318 100644 --- a/packages/core/src/discovery.ts +++ b/packages/core/src/discovery.ts @@ -1,5 +1,7 @@ +import type { DisplayStatus } from "@sofa/api/display-status"; import { getAllTrackedTitleIds, + getAvailabilityByTitleIds, getEngagedTitleIds, getEpisodesBySeasonIds, getEpisodeWatchCountSince, @@ -17,10 +19,18 @@ import { getTitleByIdOrNull, getTitlesByIds, getTvTitlesByIds, + getUpcomingEpisodes, + getUpcomingMovies, getUserStatusCounts, } from "@sofa/db/queries/discovery"; import { tmdbImageUrl } from "@sofa/tmdb/image"; +import { getDisplayStatusesByTitleIds } from "./tracking"; + +function formatLocalDate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + export type TimePeriod = "today" | "this_week" | "this_month" | "this_year"; export function periodStartTimestamp(period: TimePeriod): number { @@ -340,6 +350,217 @@ export function getRecommendationsFeed(userId: string) { return sorted.map((r) => recTitleMap.get(r.titleId)).filter(Boolean); } +// ─── Upcoming feed ────────────────────────────────────────────────── + +export interface UpcomingItem { + episodeId: string | null; + titleId: string; + titleName: string; + titleType: "movie" | "tv"; + posterPath: string | null; + posterThumbHash: string | null; + seasonNumber: number | null; + episodeNumber: number | null; + episodeName: string | null; + episodeCount: number; + date: string; + userStatus: DisplayStatus; + isNewSeason: boolean; + streamingProvider: { + providerId: number; + providerName: string; + logoPath: string | null; + } | null; +} + +export interface UpcomingFeedResult { + items: UpcomingItem[]; + nextCursor: string | null; +} + +export function getUpcomingFeed( + userId: string, + options: { days?: number; limit?: number; cursor?: string } = {}, +): UpcomingFeedResult { + const { days = 90, limit = 20, cursor } = options; + + const now = new Date(); + const today = formatLocalDate(now); + const horizon = new Date(); + horizon.setDate(horizon.getDate() + days); + const toDate = formatLocalDate(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. + let cursorDate: string | undefined; + let cursorName: string | undefined; + let cursorId: string | undefined; + if (cursor) { + try { + const parsed = JSON.parse(atob(cursor)); + cursorDate = parsed.d; + cursorName = parsed.n; + cursorId = parsed.i; + } catch { + // Invalid cursor — ignore + } + } + const fromDate = cursorDate ?? today; + const episodeRows = getUpcomingEpisodes(userId, fromDate, toDate); + const movieRows = getUpcomingMovies(userId, fromDate, toDate); + + // Merge into unified items + type RawItem = { date: string; titleId: string; titleName: string } & ( + | { type: "tv"; row: (typeof episodeRows)[number] } + | { type: "movie"; row: (typeof movieRows)[number] } + ); + + const merged: RawItem[] = [ + ...episodeRows.map((r) => ({ + date: r.airDate!, + titleId: r.titleId, + titleName: r.titleName, + type: "tv" as const, + row: r, + })), + ...movieRows.map((r) => ({ + date: r.releaseDate!, + titleId: r.titleId, + titleName: r.titleName, + type: "movie" as const, + row: r, + })), + ]; + + // Sort by date ASC, then title ASC, then titleId for deterministic tiebreak + merged.sort( + (a, b) => + a.date.localeCompare(b.date) || + a.titleName.localeCompare(b.titleName) || + a.titleId.localeCompare(b.titleId), + ); + + // Collapse batch drops: group 3+ episodes from the same title on the same date into one item + const collapsed: (RawItem & { episodeCount: number })[] = []; + let i = 0; + while (i < merged.length) { + const current = merged[i]!; + if (current.type === "tv") { + // Count consecutive episodes with the same (titleId, date) + let groupEnd = i + 1; + while ( + groupEnd < merged.length && + merged[groupEnd]!.type === "tv" && + merged[groupEnd]!.titleId === current.titleId && + merged[groupEnd]!.date === current.date + ) { + groupEnd++; + } + const groupSize = groupEnd - i; + if (groupSize >= 3) { + // Collapse: keep first item with episodeCount and clear episode name + collapsed.push({ ...current, episodeCount: groupSize }); + i = groupEnd; + } else { + // Keep individual items + for (let j = i; j < groupEnd; j++) { + collapsed.push({ ...merged[j]!, episodeCount: 1 }); + } + i = groupEnd; + } + } else { + collapsed.push({ ...current, episodeCount: 1 }); + i++; + } + } + + // Apply cursor: skip items at or before the cursor position. + // Cursor is base64-encoded JSON {d, n, i} matching the sort key (date, titleName, titleId). + let startIdx = 0; + if (cursorDate && cursorName && cursorId) { + startIdx = collapsed.findIndex( + (item) => + item.date > cursorDate || + (item.date === cursorDate && item.titleName > cursorName) || + (item.date === cursorDate && item.titleName === cursorName && item.titleId > cursorId), + ); + if (startIdx === -1) startIdx = collapsed.length; + } + + const pageItems = collapsed.slice(startIdx, startIdx + limit); + const hasMore = startIdx + limit < collapsed.length; + + // Compute cursor from the last item on this page + let nextCursor: string | null = null; + if (hasMore && pageItems.length > 0) { + const last = pageItems[pageItems.length - 1]!; + nextCursor = btoa(JSON.stringify({ d: last.date, n: last.titleName, i: last.titleId })); + } + + // Batch-fetch display statuses and streaming providers + const titleIds = [...new Set(pageItems.map((item) => item.titleId))]; + const displayStatuses = getDisplayStatusesByTitleIds(userId, titleIds); + const providerRows = getAvailabilityByTitleIds(titleIds); + const providerMap = new Map< + string, + { providerId: number; providerName: string; logoPath: string | null } + >(); + for (const p of providerRows) { + if (!providerMap.has(p.titleId)) { + providerMap.set(p.titleId, { + providerId: p.providerId, + providerName: p.providerName, + logoPath: p.logoPath, + }); + } + } + + const items: UpcomingItem[] = pageItems.map((item) => { + if (item.type === "tv") { + const r = item.row; + const isCollapsed = item.episodeCount > 1; + return { + episodeId: isCollapsed ? null : r.episodeId, + titleId: r.titleId, + titleName: r.titleName, + titleType: "tv", + posterPath: r.posterPath, + posterThumbHash: r.posterThumbHash, + seasonNumber: r.seasonNumber, + episodeNumber: r.episodeNumber, + episodeName: isCollapsed ? null : r.episodeName, + episodeCount: item.episodeCount, + date: r.airDate!, + userStatus: displayStatuses[r.titleId] ?? "watching", + isNewSeason: + (displayStatuses[r.titleId] === "caught_up" || + displayStatuses[r.titleId] === "completed") && + r.episodeNumber === 1, + streamingProvider: providerMap.get(r.titleId) ?? null, + }; + } + const r = item.row; + return { + episodeId: null, + titleId: r.titleId, + titleName: r.titleName, + titleType: "movie", + posterPath: r.posterPath, + posterThumbHash: r.posterThumbHash, + seasonNumber: null, + episodeNumber: null, + episodeName: null, + episodeCount: 1, + date: r.releaseDate!, + userStatus: displayStatuses[r.titleId] ?? "in_watchlist", + isNewSeason: false, + streamingProvider: providerMap.get(r.titleId) ?? null, + }; + }); + + return { items, nextCursor }; +} + export function getRecommendationsForTitle(titleId: string) { const title = getTitleByIdOrNull(titleId); if (!title) return []; diff --git a/packages/core/test/upcoming.test.ts b/packages/core/test/upcoming.test.ts new file mode 100644 index 0000000..619d6b7 --- /dev/null +++ b/packages/core/test/upcoming.test.ts @@ -0,0 +1,293 @@ +import { beforeEach, describe, expect, test } from "bun:test"; + +import { + clearAllTables, + insertAvailabilityOffer, + insertEpisodeWatch, + insertStatus, + insertTitle, + insertTvShow, + insertUser, +} from "@sofa/db/test-utils"; + +import { getUpcomingFeed } from "../src/discovery"; + +function daysFromNow(offset: number): string { + const d = new Date(); + d.setDate(d.getDate() + offset); + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +beforeEach(() => { + clearAllTables(); + insertUser(); +}); + +// ── Basic feed ────────────────────────────────────────────────────── + +describe("getUpcomingFeed", () => { + test("returns upcoming episodes for tracked shows", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 2, { + title: "Breaking Bad", + airDates: [tomorrow, tomorrow], + }); + insertStatus("user-1", "tv-1", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(2); + expect(result.items[0].titleName).toBe("Breaking Bad"); + expect(result.items[0].titleType).toBe("tv"); + expect(result.items[0].date).toBe(tomorrow); + }); + + test("returns upcoming movies for tracked titles", () => { + const nextWeek = daysFromNow(5); + insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "Dune 3", releaseDate: nextWeek }); + insertStatus("user-1", "m1", "watchlist"); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(1); + expect(result.items[0].titleName).toBe("Dune 3"); + expect(result.items[0].titleType).toBe("movie"); + expect(result.items[0].date).toBe(nextWeek); + }); + + test("includes items airing today", () => { + const today = daysFromNow(0); + insertTvShow("tv-1", 100, 1, 1, { airDates: [today] }); + insertStatus("user-1", "tv-1", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(1); + expect(result.items[0].date).toBe(today); + }); + + test("excludes items beyond the days window", () => { + const farFuture = daysFromNow(91); + insertTvShow("tv-1", 100, 1, 1, { airDates: [farFuture] }); + insertStatus("user-1", "tv-1", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 90 }); + expect(result.items).toHaveLength(0); + }); + + test("excludes titles not in user's library", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); + // No insertStatus — not tracked + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(0); + }); + + test("returns empty when no upcoming items", () => { + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(0); + expect(result.nextCursor).toBeNull(); + }); +}); + +// ── Sorting ───────────────────────────────────────────────────────── + +describe("sorting", () => { + test("sorts by date ascending, then title name", () => { + const day1 = daysFromNow(1); + const day2 = daysFromNow(2); + + insertTvShow("tv-z", 101, 1, 1, { title: "Zebra Show", airDates: [day1] }); + insertTvShow("tv-a", 102, 1, 1, { title: "Alpha Show", airDates: [day2] }); + insertTvShow("tv-b", 103, 1, 1, { title: "Alpha Show 2", airDates: [day1] }); + insertStatus("user-1", "tv-z", "in_progress"); + insertStatus("user-1", "tv-a", "in_progress"); + insertStatus("user-1", "tv-b", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items.map((i) => i.titleName)).toEqual([ + "Alpha Show 2", + "Zebra Show", + "Alpha Show", + ]); + }); + + test("merges movies and episodes in date order", () => { + const day1 = daysFromNow(1); + const day2 = daysFromNow(2); + + insertTitle({ id: "m1", tmdbId: 1, type: "movie", title: "A Movie", releaseDate: day2 }); + insertTvShow("tv-1", 100, 1, 1, { title: "A Show", airDates: [day1] }); + insertStatus("user-1", "m1", "watchlist"); + insertStatus("user-1", "tv-1", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items[0].titleType).toBe("tv"); + expect(result.items[1].titleType).toBe("movie"); + }); +}); + +// ── Batch collapse ────────────────────────────────────────────────── + +describe("batch collapse", () => { + test("collapses 3+ same-day episodes from the same title", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 4, { + title: "Batch Show", + airDates: [tomorrow, tomorrow, tomorrow, tomorrow], + }); + insertStatus("user-1", "tv-1", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(1); + expect(result.items[0].episodeCount).toBe(4); + expect(result.items[0].episodeName).toBeNull(); + }); + + test("does not collapse fewer than 3 same-day episodes", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 2, { + title: "Small Drop", + airDates: [tomorrow, tomorrow], + }); + insertStatus("user-1", "tv-1", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(2); + expect(result.items[0].episodeCount).toBe(1); + expect(result.items[0].episodeName).toBe("S1E1"); + }); + + test("does not collapse episodes on different dates", () => { + const day1 = daysFromNow(1); + const day2 = daysFromNow(2); + const day3 = daysFromNow(3); + insertTvShow("tv-1", 100, 1, 3, { + title: "Spread Show", + airDates: [day1, day2, day3], + }); + insertStatus("user-1", "tv-1", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(3); + }); +}); + +// ── Cursor pagination ─────────────────────────────────────────────── + +describe("cursor pagination", () => { + test("paginates with limit and returns nextCursor", () => { + const day1 = daysFromNow(1); + const day2 = daysFromNow(2); + const day3 = daysFromNow(3); + + insertTvShow("tv-a", 101, 1, 1, { title: "Show A", airDates: [day1] }); + insertTvShow("tv-b", 102, 1, 1, { title: "Show B", airDates: [day2] }); + insertTvShow("tv-c", 103, 1, 1, { title: "Show C", airDates: [day3] }); + insertStatus("user-1", "tv-a", "in_progress"); + insertStatus("user-1", "tv-b", "in_progress"); + insertStatus("user-1", "tv-c", "in_progress"); + + const page1 = getUpcomingFeed("user-1", { days: 7, limit: 2 }); + expect(page1.items).toHaveLength(2); + expect(page1.items[0].titleName).toBe("Show A"); + expect(page1.items[1].titleName).toBe("Show B"); + expect(page1.nextCursor).not.toBeNull(); + + const page2 = getUpcomingFeed("user-1", { days: 7, limit: 2, cursor: page1.nextCursor! }); + expect(page2.items).toHaveLength(1); + expect(page2.items[0].titleName).toBe("Show C"); + expect(page2.nextCursor).toBeNull(); + }); + + test("handles invalid cursor gracefully", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); + insertStatus("user-1", "tv-1", "in_progress"); + + const result = getUpcomingFeed("user-1", { days: 7, cursor: "not-valid-base64!" }); + expect(result.items).toHaveLength(1); + }); +}); + +// ── isNewSeason ───────────────────────────────────────────────────── + +describe("isNewSeason", () => { + test("marks episode 1 as new season for caught_up shows", () => { + const yesterday = daysFromNow(-1); + const tomorrow = daysFromNow(1); + // 2 seasons, 1 ep each; S1E1 aired yesterday, S2E1 airs tomorrow + const { episodeIds } = insertTvShow("tv-1", 100, 2, 1, { + title: "Returning Show", + airDates: [yesterday, tomorrow], + status: "Returning Series", + }); + insertStatus("user-1", "tv-1", "in_progress"); + // Watch S1E1 (the only aired episode) → caught_up display status + insertEpisodeWatch("user-1", episodeIds[0]); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(1); + expect(result.items[0].isNewSeason).toBe(true); + }); + + test("does not mark non-episode-1 as new season", () => { + const yesterday = daysFromNow(-1); + const tomorrow = daysFromNow(1); + // 1 season, 3 episodes — ep 1 aired yesterday, ep 2 and 3 air tomorrow + const { episodeIds } = insertTvShow("tv-1", 100, 1, 3, { + title: "Mid Show", + airDates: [yesterday, tomorrow, tomorrow], + status: "Returning Series", + }); + insertStatus("user-1", "tv-1", "in_progress"); + // Watch ep 1 (only aired episode) → caught_up + insertEpisodeWatch("user-1", episodeIds[0]); + + const result = getUpcomingFeed("user-1", { days: 7 }); + // episodes 2 and 3 air tomorrow (not collapsed since only 2) + expect(result.items.every((i) => i.isNewSeason === false)).toBe(true); + }); + + test("does not mark episode 1 as new season for watching shows", () => { + const yesterday = daysFromNow(-1); + const tomorrow = daysFromNow(1); + // 2 seasons, 1 ep each; S1E1 aired yesterday, S2E1 airs tomorrow + insertTvShow("tv-1", 100, 2, 1, { + airDates: [yesterday, tomorrow], + status: "Returning Series", + }); + insertStatus("user-1", "tv-1", "in_progress"); + // No episodes watched → display status is "watching", not "caught_up" + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items).toHaveLength(1); + expect(result.items[0].isNewSeason).toBe(false); + }); +}); + +// ── Streaming provider ────────────────────────────────────────────── + +describe("streaming provider", () => { + test("attaches flatrate streaming provider", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); + insertStatus("user-1", "tv-1", "in_progress"); + insertAvailabilityOffer("tv-1", { providerName: "Netflix", providerId: 8 }); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items[0].streamingProvider).toEqual({ + providerId: 8, + providerName: "Netflix", + logoPath: null, + }); + }); + + test("returns null when no flatrate provider exists", () => { + const tomorrow = daysFromNow(1); + insertTvShow("tv-1", 100, 1, 1, { airDates: [tomorrow] }); + insertStatus("user-1", "tv-1", "in_progress"); + insertAvailabilityOffer("tv-1", { offerType: "rent" }); + + const result = getUpcomingFeed("user-1", { days: 7 }); + expect(result.items[0].streamingProvider).toBeNull(); + }); +}); diff --git a/packages/db/src/queries/discovery.ts b/packages/db/src/queries/discovery.ts index 6721f4a..ff29054 100644 --- a/packages/db/src/queries/discovery.ts +++ b/packages/db/src/queries/discovery.ts @@ -1,4 +1,4 @@ -import { and, desc, eq, inArray, sql } from "drizzle-orm"; +import { and, asc, desc, eq, gte, inArray, lte, sql } from "drizzle-orm"; import { db } from "../client"; import { @@ -312,3 +312,76 @@ export function getRecommendationRowsForTitle(titleId: string) { export function getTitleByIdOrNull(titleId: string) { return db.select().from(titles).where(eq(titles.id, titleId)).get() ?? null; } + +// ─── Upcoming feed queries ────────────────────────────────────────── + +export function getUpcomingEpisodes(userId: string, fromDate: string, toDate: string) { + return db + .select({ + episodeId: episodes.id, + titleId: titles.id, + titleName: titles.title, + posterPath: titles.posterPath, + posterThumbHash: titles.posterThumbHash, + 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))) + .orderBy(asc(episodes.airDate), asc(titles.title)) + .all(); +} + +export function getUpcomingMovies(userId: string, fromDate: string, toDate: string) { + return db + .select({ + titleId: titles.id, + titleName: titles.title, + posterPath: titles.posterPath, + posterThumbHash: titles.posterThumbHash, + releaseDate: titles.releaseDate, + userStatus: userTitleStatus.status, + }) + .from(titles) + .innerJoin( + userTitleStatus, + and(eq(userTitleStatus.titleId, titles.id), eq(userTitleStatus.userId, userId)), + ) + .where( + and( + eq(titles.type, "movie"), + gte(titles.releaseDate, fromDate), + lte(titles.releaseDate, toDate), + ), + ) + .orderBy(asc(titles.releaseDate), asc(titles.title)) + .all(); +} + +export function getAvailabilityByTitleIds(titleIds: string[]) { + if (titleIds.length === 0) return []; + return db + .select({ + titleId: availabilityOffers.titleId, + providerId: availabilityOffers.providerId, + providerName: availabilityOffers.providerName, + logoPath: availabilityOffers.logoPath, + }) + .from(availabilityOffers) + .where( + and( + inArray(availabilityOffers.titleId, titleIds), + eq(availabilityOffers.offerType, "flatrate"), + ), + ) + .all(); +} diff --git a/packages/db/src/schema.ts b/packages/db/src/schema.ts index 7090790..6fb96db 100644 --- a/packages/db/src/schema.ts +++ b/packages/db/src/schema.ts @@ -160,6 +160,7 @@ export const episodes = sqliteTable( }, (table) => [ uniqueIndex("episodes_seasonId_episodeNumber").on(table.seasonId, table.episodeNumber), + index("episodes_airDate").on(table.airDate), ], ); diff --git a/packages/db/src/test-utils.ts b/packages/db/src/test-utils.ts index 1fde1b2..c7c90e5 100644 --- a/packages/db/src/test-utils.ts +++ b/packages/db/src/test-utils.ts @@ -63,6 +63,9 @@ export function insertTitle( tvdbId?: number; type?: "movie" | "tv"; title?: string; + releaseDate?: string; + posterPath?: string; + status?: string; } = {}, ) { const id = overrides.id ?? "title-1"; @@ -74,14 +77,30 @@ export function insertTitle( tvdbId: overrides.tvdbId, type: overrides.type ?? "movie", title: overrides.title ?? "Test Movie", + releaseDate: overrides.releaseDate, + posterPath: overrides.posterPath, + status: overrides.status, }) .run(); return id; } -export function insertTvShow(titleId = "tv-1", tmdbId = 99999, seasonCount = 1, epsPerSeason = 3) { - insertTitle({ id: titleId, tmdbId, type: "tv", title: "Test Show" }); +export function insertTvShow( + titleId = "tv-1", + tmdbId = 99999, + seasonCount = 1, + epsPerSeason = 3, + options: { title?: string; airDates?: string[]; status?: string } = {}, +) { + insertTitle({ + id: titleId, + tmdbId, + type: "tv", + title: options.title ?? "Test Show", + status: options.status, + }); const episodeIds: string[] = []; + let airDateIdx = 0; for (let s = 1; s <= seasonCount; s++) { const seasonId = `${titleId}-s${s}`; testDb.insert(seasons).values({ id: seasonId, titleId, seasonNumber: s }).run(); @@ -94,9 +113,11 @@ export function insertTvShow(titleId = "tv-1", tmdbId = 99999, seasonCount = 1, seasonId, episodeNumber: e, name: `S${s}E${e}`, + airDate: options.airDates?.[airDateIdx], }) .run(); episodeIds.push(epId); + airDateIdx++; } } return { titleId, episodeIds }; diff --git a/packages/i18n/package.json b/packages/i18n/package.json index 446fd63..81f7b56 100644 --- a/packages/i18n/package.json +++ b/packages/i18n/package.json @@ -7,6 +7,7 @@ ".": "./src/index.ts", "./locales": "./src/locales.ts", "./format": "./src/format.ts", + "./date-buckets": "./src/date-buckets.ts", "./test-utils": "./src/test-utils.tsx" }, "scripts": { diff --git a/packages/i18n/src/date-buckets.ts b/packages/i18n/src/date-buckets.ts new file mode 100644 index 0000000..4ea5da6 --- /dev/null +++ b/packages/i18n/src/date-buckets.ts @@ -0,0 +1,78 @@ +import { msg } from "@lingui/core/macro"; + +import { i18n } from "./index"; + +export type DateBucket = { + key: string; + label: string; + items: T[]; +}; + +export function formatLocalDate(d: Date): string { + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, "0")}-${String(d.getDate()).padStart(2, "0")}`; +} + +function getToday(): string { + return formatLocalDate(new Date()); +} + +function addDays(dateStr: string, days: number): string { + const d = new Date(`${dateStr}T00:00:00`); + d.setDate(d.getDate() + days); + return formatLocalDate(d); +} + +function getEndOfWeek(today: string): string { + return addDays(today, 6); +} + +function getMonthLabel(dateStr: string): string { + const d = new Date(`${dateStr}T00:00:00`); + return new Intl.DateTimeFormat(i18n.locale, { month: "long" }).format(d); +} + +type BucketKey = "today" | "tomorrow" | "this_week" | "next_week" | string; + +function getBucketKey(dateStr: string, today: string): BucketKey { + if (dateStr === today) return "today"; + const tomorrow = addDays(today, 1); + if (dateStr === tomorrow) return "tomorrow"; + const endOfWeek = getEndOfWeek(today); + if (dateStr <= endOfWeek) return "this_week"; + const endOfNextWeek = addDays(endOfWeek, 7); + if (dateStr <= endOfNextWeek) return "next_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 === "this_week") return i18n._(msg`This Week`); + if (key === "next_week") return i18n._(msg`Next Week`); + if (key.startsWith("month_")) { + return getMonthLabel(`${key.slice(6)}-01`); + } + return key; +} + +export function groupByDateBucket(items: T[]): DateBucket[] { + const today = getToday(); + const bucketMap = new Map(); + const bucketOrder: string[] = []; + + for (const item of items) { + const key = getBucketKey(item.date, today); + let bucket = bucketMap.get(key); + if (!bucket) { + bucket = { label: getBucketLabel(key), items: [] }; + bucketMap.set(key, bucket); + bucketOrder.push(key); + } + bucket.items.push(item); + } + + return bucketOrder.map((key) => { + const bucket = bucketMap.get(key)!; + return { key, label: bucket.label, items: bucket.items }; + }); +} diff --git a/packages/i18n/src/po/ar.po b/packages/i18n/src/po/ar.po index c07e897..0d8a278 100644 --- a/packages/i18n/src/po/ar.po +++ b/packages/i18n/src/po/ar.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "اللحاق" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "إغلاق" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "حلقات {period}" msgid "Episodes {select}" msgstr "حلقات {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "تحديد الكل كمشاهدة" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "تحديد كمشاهدة" @@ -1329,6 +1336,10 @@ msgstr "تحديد كمشاهدة" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "أبداً" msgid "Never run" msgstr "لم يُشغَّل قط" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "إنشاء الحسابات الجديدة معطّل حالياً." @@ -1460,6 +1475,11 @@ msgstr "كلمة المرور الجديدة" msgid "New password must be at least 8 characters" msgstr "يجب أن تتكون كلمة المرور الجديدة من 8 أحرف على الأقل" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "لم يتم العثور على نتائج." msgid "No titles found for this genre." msgstr "لم يتم العثور على عناوين لهذا النوع." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "غير مُهيَّأ" msgid "Not Found" msgstr "غير موجود" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "في قائمة المشاهدة" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "إزالة من المكتبة" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "إزالة من المكتبة" @@ -1938,6 +1965,16 @@ msgstr "راجع ما تم العثور عليه واختر ما تريد است msgid "Run now" msgstr "تشغيل الآن" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "السبت" @@ -2033,6 +2070,11 @@ msgstr "المواسم" msgid "Security" msgstr "الأمان" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "تطبيق تتبع الأفلام والمسلسلات ذاتي الاستضافة" @@ -2453,6 +2495,15 @@ msgstr "تم إلغاء مشاهدة م{seasonNum} ح{epNum}" msgid "Up next" msgstr "التالي" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "تمت مشاهدة م{seasonNum} ح{epNum}" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "مكتبتك فارغة" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "اسمك" - diff --git a/packages/i18n/src/po/de.po b/packages/i18n/src/po/de.po index ec389ae..c840354 100644 --- a/packages/i18n/src/po/de.po +++ b/packages/i18n/src/po/de.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "Aufholen" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "Schließen" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "" msgid "Episodes {select}" msgstr "Episoden {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "Alle als gesehen markieren" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "Als gesehen markieren" @@ -1329,6 +1336,10 @@ msgstr "Als gesehen markieren" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "Nie" msgid "Never run" msgstr "Noch nie ausgeführt" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "Das Erstellen neuer Konten ist derzeit deaktiviert." @@ -1460,6 +1475,11 @@ msgstr "Neues Passwort" msgid "New password must be at least 8 characters" msgstr "Das neue Passwort muss mindestens 8 Zeichen lang sein" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "Keine Ergebnisse gefunden." msgid "No titles found for this genre." msgstr "Keine Titel für dieses Genre gefunden." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "Nicht konfiguriert" msgid "Not Found" msgstr "Nicht gefunden" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "Auf der Merkliste" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "Aus Bibliothek entfernen" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "Aus Bibliothek entfernen" @@ -1938,6 +1965,16 @@ msgstr "Überprüfe die gefundenen Einträge und wähle aus, was importiert werd msgid "Run now" msgstr "Jetzt ausführen" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "Samstag" @@ -2033,6 +2070,11 @@ msgstr "Staffeln" msgid "Security" msgstr "Sicherheit" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "Selbstgehosteter Film- & Serien-Tracker" @@ -2453,6 +2495,15 @@ msgstr "S{seasonNum} E{epNum} als ungesehen markiert" msgid "Up next" msgstr "Als Nächstes" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "S{seasonNum} E{epNum} als gesehen markiert" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "Deine Bibliothek ist leer" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "Dein Name" - diff --git a/packages/i18n/src/po/en.po b/packages/i18n/src/po/en.po index 64196e0..2eb98fd 100644 --- a/packages/i18n/src/po/en.po +++ b/packages/i18n/src/po/en.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "Episodes {period}" msgid "Episodes {select}" msgstr "" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "Episodes and movies coming up in the next 90 days." + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "" @@ -1329,6 +1336,10 @@ msgstr "" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "Mark Episode Watched" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "" msgid "Never run" msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "New" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "" @@ -1460,6 +1475,11 @@ msgstr "" msgid "New password must be at least 8 characters" msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "New Season" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "" msgid "No titles found for this genre." msgstr "" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "No upcoming episodes or releases in the next 90 days." + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "" msgid "Not Found" msgstr "" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "" @@ -1938,6 +1965,16 @@ msgstr "" msgid "Run now" msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "S{0} · {1} {2, plural, one {episode} other {episodes}}" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "" @@ -2033,6 +2070,11 @@ msgstr "" msgid "Security" msgstr "" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "See all" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "" @@ -2453,6 +2495,15 @@ msgstr "" msgid "Up next" msgstr "" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "Upcoming" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" diff --git a/packages/i18n/src/po/es.po b/packages/i18n/src/po/es.po index ae307f8..cecab03 100644 --- a/packages/i18n/src/po/es.po +++ b/packages/i18n/src/po/es.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "Ponerse al día" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "Cerrar" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "" msgid "Episodes {select}" msgstr "Episodios {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "Marcar todos como vistos" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "Marcar como visto" @@ -1329,6 +1336,10 @@ msgstr "Marcar como visto" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "Nunca" msgid "Never run" msgstr "Nunca ejecutado" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "La creación de nuevas cuentas está desactivada actualmente." @@ -1460,6 +1475,11 @@ msgstr "Nueva contraseña" msgid "New password must be at least 8 characters" msgstr "La nueva contraseña debe tener al menos 8 caracteres" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "No se encontraron resultados." msgid "No titles found for this genre." msgstr "No se encontraron títulos para este género." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "No configurado" msgid "Not Found" msgstr "No encontrado" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "En la lista" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "Eliminar de la biblioteca" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "Eliminar de la biblioteca" @@ -1938,6 +1965,16 @@ msgstr "Revisa lo encontrado y elige qué importar." msgid "Run now" msgstr "Ejecutar ahora" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "Sábado" @@ -2033,6 +2070,11 @@ msgstr "Temporadas" msgid "Security" msgstr "Seguridad" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "Seguimiento de películas y series autoalojado" @@ -2453,6 +2495,15 @@ msgstr "No visto T{seasonNum} E{epNum}" msgid "Up next" msgstr "A continuación" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "Visto T{seasonNum} E{epNum}" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "Tu biblioteca está vacía" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "Tu nombre" - diff --git a/packages/i18n/src/po/fr.po b/packages/i18n/src/po/fr.po index edfd9d2..ccacaff 100644 --- a/packages/i18n/src/po/fr.po +++ b/packages/i18n/src/po/fr.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "Rattraper" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "Fermer" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "" msgid "Episodes {select}" msgstr "Épisodes {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "Tout marquer comme visionné" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "Marquer comme visionné" @@ -1329,6 +1336,10 @@ msgstr "Marquer comme visionné" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "Jamais" msgid "Never run" msgstr "Jamais exécuté" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "La création de nouveaux comptes est actuellement désactivée." @@ -1460,6 +1475,11 @@ msgstr "Nouveau mot de passe" msgid "New password must be at least 8 characters" msgstr "Le nouveau mot de passe doit comporter au moins 8 caractères" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "Aucun résultat trouvé." msgid "No titles found for this genre." msgstr "Aucun titre trouvé pour ce genre." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "Non configuré" msgid "Not Found" msgstr "Introuvable" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "Dans la liste de suivi" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "Supprimer de la bibliothèque" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "Supprimer de la bibliothèque" @@ -1938,6 +1965,16 @@ msgstr "Vérifiez ce qui a été trouvé et choisissez ce que vous souhaitez imp msgid "Run now" msgstr "Exécuter maintenant" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "Samedi" @@ -2033,6 +2070,11 @@ msgstr "Saisons" msgid "Security" msgstr "Sécurité" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "Suivi de films et séries auto-hébergé" @@ -2453,6 +2495,15 @@ msgstr "Démarqué S{seasonNum} E{epNum}" msgid "Up next" msgstr "Suivant" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "Visionné S{seasonNum} E{epNum}" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "Votre bibliothèque est vide" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "Votre nom" - diff --git a/packages/i18n/src/po/he.po b/packages/i18n/src/po/he.po index dc9d785..b925190 100644 --- a/packages/i18n/src/po/he.po +++ b/packages/i18n/src/po/he.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "השלם צפייה" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "סגור" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "פרקים {period}" msgid "Episodes {select}" msgstr "פרקים {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "סמן הכל כנצפה" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "סמן כנצפה" @@ -1329,6 +1336,10 @@ msgstr "סמן כנצפה" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "אף פעם" msgid "Never run" msgstr "מעולם לא הורץ" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "יצירת חשבון חדש כרגע מושבתת." @@ -1460,6 +1475,11 @@ msgstr "סיסמה חדשה" msgid "New password must be at least 8 characters" msgstr "הסיסמה החדשה חייבת להכיל לפחות 8 תווים" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "לא נמצאו תוצאות." msgid "No titles found for this genre." msgstr "לא נמצאו כותרים לז'אנר זה." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "לא מוגדר" msgid "Not Found" msgstr "לא נמצא" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "ברשימת הצפייה" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "הסר מהספרייה" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "הסר מהספרייה" @@ -1938,6 +1965,16 @@ msgstr "בדוק מה נמצא ובחר מה לייבא." msgid "Run now" msgstr "הפעל עכשיו" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "שבת" @@ -2033,6 +2070,11 @@ msgstr "עונות" msgid "Security" msgstr "אבטחה" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "מעקב סרטים וטלוויזיה לאירוח עצמי" @@ -2453,6 +2495,15 @@ msgstr "בוטלה צפייה ב-S{seasonNum} E{epNum}" msgid "Up next" msgstr "הבא בתור" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "נצפה S{seasonNum} E{epNum}" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "הספרייה שלך ריקה" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "שמך" - diff --git a/packages/i18n/src/po/it.po b/packages/i18n/src/po/it.po index 1633b99..56150cf 100644 --- a/packages/i18n/src/po/it.po +++ b/packages/i18n/src/po/it.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "Recupera episodi" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "Chiudi" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "" msgid "Episodes {select}" msgstr "Episodi {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "Segna tutti come visti" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "Segna come visto" @@ -1329,6 +1336,10 @@ msgstr "Segna come visto" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "Mai" msgid "Never run" msgstr "Mai eseguito" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "La creazione di nuovi account è attualmente disabilitata." @@ -1460,6 +1475,11 @@ msgstr "Nuova password" msgid "New password must be at least 8 characters" msgstr "La nuova password deve contenere almeno 8 caratteri" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "Nessun risultato trovato." msgid "No titles found for this genre." msgstr "Nessun titolo trovato per questo genere." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "Non configurato" msgid "Not Found" msgstr "Non trovato" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "In watchlist" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "Rimuovi dalla libreria" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "Rimuovi dalla libreria" @@ -1938,6 +1965,16 @@ msgstr "Rivedi i risultati trovati e scegli cosa importare." msgid "Run now" msgstr "Esegui ora" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "Sabato" @@ -2033,6 +2070,11 @@ msgstr "Stagioni" msgid "Security" msgstr "Sicurezza" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "Tracker film & TV self-hosted" @@ -2453,6 +2495,15 @@ msgstr "Non visto S{seasonNum} E{epNum}" msgid "Up next" msgstr "Prossimo" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "Visto S{seasonNum} E{epNum}" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "La tua libreria è vuota" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "Il tuo nome" - diff --git a/packages/i18n/src/po/ja.po b/packages/i18n/src/po/ja.po index d331cb9..adaa31b 100644 --- a/packages/i18n/src/po/ja.po +++ b/packages/i18n/src/po/ja.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "追いつく" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "閉じる" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "エピソード {period}" msgid "Episodes {select}" msgstr "エピソード {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "すべて視聴済みにする" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "視聴済みにする" @@ -1329,6 +1336,10 @@ msgstr "視聴済みにする" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "なし" msgid "Never run" msgstr "未実行" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "現在、新規アカウントの作成は無効になっています。" @@ -1460,6 +1475,11 @@ msgstr "新しいパスワード" msgid "New password must be at least 8 characters" msgstr "新しいパスワードは8文字以上である必要があります" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "結果が見つかりませんでした。" msgid "No titles found for this genre." msgstr "このジャンルの作品は見つかりませんでした。" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "未設定" msgid "Not Found" msgstr "見つかりません" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "ウォッチリスト内" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "ライブラリから削除" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "ライブラリから削除" @@ -1938,6 +1965,16 @@ msgstr "見つかったものを確認し、インポートする項目を選択 msgid "Run now" msgstr "今すぐ実行" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "土曜日" @@ -2033,6 +2070,11 @@ msgstr "シーズン" msgid "Security" msgstr "セキュリティ" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "セルフホスト型映画・TVトラッカー" @@ -2453,6 +2495,15 @@ msgstr "S{seasonNum} E{epNum}を未視聴にしました" msgid "Up next" msgstr "次のエピソード" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "S{seasonNum} E{epNum}を視聴済みにしました" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "ライブラリは空です" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "お名前" - diff --git a/packages/i18n/src/po/ko.po b/packages/i18n/src/po/ko.po index e1d1e73..4c202ed 100644 --- a/packages/i18n/src/po/ko.po +++ b/packages/i18n/src/po/ko.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "따라잡기" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "닫기" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "에피소드 {period}" msgid "Episodes {select}" msgstr "에피소드 {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "모두 시청 완료로 표시" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "시청 완료로 표시" @@ -1329,6 +1336,10 @@ msgstr "시청 완료로 표시" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "없음" msgid "Never run" msgstr "실행된 적 없음" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "현재 새 계정 생성이 비활성화되어 있습니다." @@ -1460,6 +1475,11 @@ msgstr "새 비밀번호" msgid "New password must be at least 8 characters" msgstr "새 비밀번호는 최소 8자 이상이어야 합니다" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "결과를 찾을 수 없습니다." msgid "No titles found for this genre." msgstr "이 장르에 해당하는 작품이 없습니다." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "구성되지 않음" msgid "Not Found" msgstr "찾을 수 없음" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "찜 목록에 있음" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "라이브러리에서 제거" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "라이브러리에서 제거" @@ -1938,6 +1965,16 @@ msgstr "찾은 항목을 검토하고 가져올 항목을 선택하세요." msgid "Run now" msgstr "지금 실행" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "토요일" @@ -2033,6 +2070,11 @@ msgstr "시즌" msgid "Security" msgstr "보안" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "자체 호스팅 영화 & TV 트래커" @@ -2453,6 +2495,15 @@ msgstr "S{seasonNum} E{epNum} 시청 취소됨" msgid "Up next" msgstr "다음 에피소드" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "S{seasonNum} E{epNum} 시청 완료" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "라이브러리가 비어 있습니다" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "이름" - diff --git a/packages/i18n/src/po/nl.po b/packages/i18n/src/po/nl.po index 15a7947..6bab205 100644 --- a/packages/i18n/src/po/nl.po +++ b/packages/i18n/src/po/nl.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "Inhalen" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "Sluiten" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "Afleveringen {period}" msgid "Episodes {select}" msgstr "Afleveringen {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "Alles als bekeken markeren" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "Markeren als bekeken" @@ -1329,6 +1336,10 @@ msgstr "Markeren als bekeken" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "Nooit" msgid "Never run" msgstr "Nooit uitgevoerd" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "Aanmaken van nieuwe accounts is momenteel uitgeschakeld." @@ -1460,6 +1475,11 @@ msgstr "Nieuw wachtwoord" msgid "New password must be at least 8 characters" msgstr "Nieuw wachtwoord moet minimaal 8 tekens bevatten" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "Geen resultaten gevonden." msgid "No titles found for this genre." msgstr "Geen titels gevonden voor dit genre." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "Niet geconfigureerd" msgid "Not Found" msgstr "Niet gevonden" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "Op kijklijst" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "Verwijderen uit bibliotheek" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "Verwijderen uit bibliotheek" @@ -1938,6 +1965,16 @@ msgstr "Bekijk wat er gevonden is en kies wat je wilt importeren." msgid "Run now" msgstr "Nu uitvoeren" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "Zaterdag" @@ -2033,6 +2070,11 @@ msgstr "Seizoenen" msgid "Security" msgstr "Beveiliging" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "Zelfgehoste film- & tv-tracker" @@ -2453,6 +2495,15 @@ msgstr "Niet bekeken S{seasonNum} E{epNum}" msgid "Up next" msgstr "Volgende" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "Bekeken S{seasonNum} E{epNum}" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "Je bibliotheek is leeg" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "Jouw naam" - diff --git a/packages/i18n/src/po/pt.po b/packages/i18n/src/po/pt.po index c967bbd..0e88e61 100644 --- a/packages/i18n/src/po/pt.po +++ b/packages/i18n/src/po/pt.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "Colocar em dia" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "Fechar" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "" msgid "Episodes {select}" msgstr "Episódios {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "Marcar Todos como Assistidos" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "Marcar como Assistido" @@ -1329,6 +1336,10 @@ msgstr "Marcar como Assistido" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "Nunca" msgid "Never run" msgstr "Nunca executado" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "A criação de novas contas está desativada no momento." @@ -1460,6 +1475,11 @@ msgstr "Nova senha" msgid "New password must be at least 8 characters" msgstr "A nova senha deve ter pelo menos 8 caracteres" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "Nenhum resultado encontrado." msgid "No titles found for this genre." msgstr "Nenhum título encontrado para este gênero." +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "Não configurado" msgid "Not Found" msgstr "Não Encontrado" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "Na Lista de Desejos" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "Remover da biblioteca" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "Remover da Biblioteca" @@ -1938,6 +1965,16 @@ msgstr "Revise o que foi encontrado e escolha o que importar." msgid "Run now" msgstr "Executar agora" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "Sábado" @@ -2033,6 +2070,11 @@ msgstr "Temporadas" msgid "Security" msgstr "Segurança" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "Rastreador de filmes e séries auto-hospedado" @@ -2453,6 +2495,15 @@ msgstr "Desmarcado T{seasonNum} E{epNum}" msgid "Up next" msgstr "A seguir" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "Assistido T{seasonNum} E{epNum}" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "Sua biblioteca está vazia" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "Seu nome" - diff --git a/packages/i18n/src/po/zh.po b/packages/i18n/src/po/zh.po index 2843b76..797ea5c 100644 --- a/packages/i18n/src/po/zh.po +++ b/packages/i18n/src/po/zh.po @@ -413,6 +413,7 @@ msgid "Catch up" msgstr "追上" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Caught Up" @@ -514,6 +515,7 @@ msgstr "关闭" #: apps/native/src/app/(tabs)/(home)/index.tsx #: apps/native/src/components/titles/status-action-button.tsx #: apps/web/src/components/dashboard/stats-display.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Completed" @@ -838,6 +840,10 @@ msgstr "剧集 {period}" msgid "Episodes {select}" msgstr "剧集 {select}" +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Episodes and movies coming up in the next 90 days." +msgstr "" + #: apps/native/src/app/(tabs)/(home)/index.tsx #~ msgid "Episodes this week" #~ msgstr "" @@ -1321,6 +1327,7 @@ msgstr "全部标记为已观看" #~ msgid "Mark as Completed" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Mark as Watched" msgstr "标记为已观看" @@ -1329,6 +1336,10 @@ msgstr "标记为已观看" #~ msgid "Mark as Watching" #~ msgstr "" +#: apps/native/src/components/dashboard/upcoming-row.tsx +msgid "Mark Episode Watched" +msgstr "" + #: apps/native/src/app/title/[id].tsx #: apps/web/src/components/titles/title-actions.tsx msgid "Mark Watched" @@ -1439,6 +1450,10 @@ msgstr "从未" msgid "Never run" msgstr "从未运行" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#~ msgid "New" +#~ msgstr "" + #: apps/native/src/app/(auth)/register.tsx msgid "New account creation is currently disabled." msgstr "当前已禁用新账户创建。" @@ -1460,6 +1475,11 @@ msgstr "新密码" msgid "New password must be at least 8 characters" msgstr "新密码至少需要 8 个字符" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "New Season" +msgstr "" + #: apps/web/src/components/people/filmography-grid.tsx #: apps/web/src/components/people/filmography-grid.tsx msgid "Newest" @@ -1501,6 +1521,11 @@ msgstr "未找到结果。" msgid "No titles found for this genre." msgstr "此类型下未找到任何内容。" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "No upcoming episodes or releases in the next 90 days." +msgstr "" + #: apps/native/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/integration-card.tsx #: apps/web/src/components/settings/system-health-section.tsx @@ -1511,6 +1536,7 @@ msgstr "未配置" msgid "Not Found" msgstr "未找到" +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx msgid "On Watchlist" msgstr "在待看列表中" @@ -1839,6 +1865,7 @@ msgid "Remove from library" msgstr "从媒体库中移除" #: apps/native/src/components/dashboard/continue-watching-card.tsx +#: apps/native/src/components/dashboard/upcoming-row.tsx #: apps/native/src/components/ui/poster-card.tsx msgid "Remove from Library" msgstr "从媒体库中移除" @@ -1938,6 +1965,16 @@ msgstr "查看已发现的内容,选择要导入的项目。" msgid "Run now" msgstr "立即运行" +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +#~ msgid "S{0} · {1} {2, plural, one {episode} other {episodes}}" +#~ msgstr "" + +#: apps/native/src/components/dashboard/upcoming-row.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx +msgid "S{seasonNum} · {epCount, plural, one {# episode} other {# episodes}}" +msgstr "" + #: apps/web/src/components/settings/backup-schedule-section.tsx msgid "Saturday" msgstr "星期六" @@ -2033,6 +2070,11 @@ msgstr "季" msgid "Security" msgstr "安全" +#: apps/native/src/components/ui/section-header.tsx +#: apps/web/src/components/dashboard/feed-section.tsx +msgid "See all" +msgstr "" + #: apps/web/src/components/landing-page.tsx msgid "Self-hosted movie & TV tracker" msgstr "自托管电影和电视剧追踪器" @@ -2453,6 +2495,15 @@ msgstr "第 {seasonNum} 季第 {epNum} 集已取消标记" msgid "Up next" msgstr "接下来" +#: apps/native/src/app/(tabs)/(home)/upcoming.tsx +#: apps/native/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/dashboard/upcoming-section.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/components/nav-bar.tsx +#: apps/web/src/routes/_app/upcoming.tsx +msgid "Upcoming" +msgstr "" + #: apps/native/src/app/(tabs)/(settings)/index.tsx #~ msgid "Update available: {0}" #~ msgstr "" @@ -2571,6 +2622,7 @@ msgid "Watched S{seasonNum} E{epNum}" msgstr "第 {seasonNum} 季第 {epNum} 集已标记为已看" #: apps/native/src/components/titles/status-action-button.tsx +#: apps/web/src/components/dashboard/upcoming-item.tsx #: apps/web/src/components/title-card.tsx #: apps/web/src/components/titles/status-button.tsx msgid "Watching" @@ -2645,4 +2697,3 @@ msgstr "您的媒体库为空" #: apps/native/src/app/(auth)/register.tsx msgid "Your name" msgstr "您的姓名" -