A personal media tracker built on the AT Protocol
opnshelf.xyz
1import { type ClassValue, clsx } from "clsx";
2import { twMerge } from "tailwind-merge";
3
4export function cn(...inputs: ClassValue[]) {
5 return twMerge(clsx(inputs));
6}
7
8export function createTitleSlug(title: string): string {
9 return title
10 .replace(/[^a-zA-Z0-9\s-]/g, "")
11 .trim()
12 .replace(/\s+/g, "-");
13}
14
15export function formatRuntime(minutes: number, useHours: boolean): string {
16 if (!useHours) return `${minutes} min`;
17 const hours = Math.floor(minutes / 60);
18 const mins = minutes % 60;
19 if (mins === 0) return `${hours} hours`;
20 return `${hours} hours ${mins} minutes`;
21}
22
23export function getTmdbPosterUrl(
24 path: string | null | undefined,
25 size: "w342" | "w500" | "w780" = "w342",
26): string | null {
27 if (!path) return null;
28 return `https://image.tmdb.org/t/p/${size}${path}`;
29}
30
31export function getTmdbBackdropUrl(
32 path: string | null | undefined,
33): string | null {
34 if (!path) return null;
35 return `https://image.tmdb.org/t/p/w1280${path}`;
36}
37
38export function getTmdbProfileUrl(
39 path: string | null | undefined,
40): string | null {
41 if (!path) return null;
42 return `https://image.tmdb.org/t/p/w185${path}`;
43}
44
45export function buildScopedShowMediaId(
46 showId: string,
47 seasonNumber?: number,
48 episodeNumber?: number,
49): string {
50 if (typeof seasonNumber === "number" && Number.isFinite(seasonNumber)) {
51 if (typeof episodeNumber === "number" && Number.isFinite(episodeNumber)) {
52 return `${showId}:season:${seasonNumber}:episode:${episodeNumber}`;
53 }
54 return `${showId}:season:${seasonNumber}`;
55 }
56 return showId;
57}
58
59export function parseScopedShowMediaId(mediaId: string): {
60 showId: string;
61 seasonNumber?: number;
62 episodeNumber?: number;
63} {
64 const episodeMatch = mediaId.match(/^([^:]+):season:(\d+):episode:(\d+)$/);
65 if (episodeMatch) {
66 return {
67 showId: episodeMatch[1],
68 seasonNumber: Number(episodeMatch[2]),
69 episodeNumber: Number(episodeMatch[3]),
70 };
71 }
72
73 const seasonMatch = mediaId.match(/^([^:]+):season:(\d+)$/);
74 if (seasonMatch) {
75 return {
76 showId: seasonMatch[1],
77 seasonNumber: Number(seasonMatch[2]),
78 };
79 }
80
81 return { showId: mediaId };
82}
83
84export interface DateFormatOptions {
85 timezone: string;
86 is24Hour: boolean;
87 includeTime?: boolean;
88}
89
90export function getDayKeyInTimezone(
91 dateString: string | Date,
92 timezone: string,
93): string {
94 const date =
95 typeof dateString === "string" ? new Date(dateString) : dateString;
96
97 try {
98 const formatter = new Intl.DateTimeFormat("en-US", {
99 timeZone: timezone,
100 year: "numeric",
101 month: "2-digit",
102 day: "2-digit",
103 });
104 const parts = formatter.formatToParts(date);
105 const year = parts.find((part) => part.type === "year")?.value ?? "0000";
106 const month = parts.find((part) => part.type === "month")?.value ?? "01";
107 const day = parts.find((part) => part.type === "day")?.value ?? "01";
108 return `${year}-${month.padStart(2, "0")}-${day.padStart(2, "0")}`;
109 } catch {
110 return `${date.getUTCFullYear()}-${String(date.getUTCMonth() + 1).padStart(2, "0")}-${String(date.getUTCDate()).padStart(2, "0")}`;
111 }
112}
113
114export function getShelfDayLabel(dayKey: string, timezone: string): string {
115 const now = new Date();
116 const todayKey = getDayKeyInTimezone(now, timezone);
117 const yesterdayKey = getDayKeyInTimezone(
118 new Date(now.getTime() - 24 * 60 * 60 * 1000),
119 timezone,
120 );
121
122 if (dayKey === todayKey) return "Today";
123 if (dayKey === yesterdayKey) return "Yesterday";
124
125 const [year, month, day] = dayKey.split("-").map(Number);
126 const safeDate = new Date(Date.UTC(year, month - 1, day, 12, 0, 0));
127 const currentYear = Number(todayKey.split("-")[0] ?? now.getUTCFullYear());
128
129 return safeDate.toLocaleDateString("en-US", {
130 weekday: "long",
131 month: "long",
132 day: "numeric",
133 ...(year !== currentYear ? { year: "numeric" } : {}),
134 });
135}
136
137export function formatDateWithTimezone(
138 dateString: string | Date,
139 options: DateFormatOptions,
140): string {
141 const { timezone, is24Hour, includeTime = true } = options;
142 const date =
143 typeof dateString === "string" ? new Date(dateString) : dateString;
144
145 try {
146 return date.toLocaleString("en-US", {
147 year: "numeric",
148 month: "short",
149 day: "numeric",
150 ...(includeTime && {
151 hour: "2-digit",
152 minute: "2-digit",
153 hour12: !is24Hour,
154 }),
155 timeZone: timezone,
156 });
157 } catch {
158 return date.toLocaleString("en-US", {
159 year: "numeric",
160 month: "short",
161 day: "numeric",
162 ...(includeTime && {
163 hour: "2-digit",
164 minute: "2-digit",
165 hour12: !is24Hour,
166 }),
167 });
168 }
169}
170
171export function formatDateOnly(
172 dateString: string | Date,
173 timezone = "UTC",
174): string {
175 const date =
176 typeof dateString === "string" ? new Date(dateString) : dateString;
177 try {
178 return date.toLocaleDateString("en-US", {
179 year: "numeric",
180 month: "long",
181 day: "numeric",
182 timeZone: timezone,
183 });
184 } catch {
185 return date.toLocaleDateString("en-US", {
186 year: "numeric",
187 month: "long",
188 day: "numeric",
189 });
190 }
191}