Files
fristydev/lib/game/game-format.ts
T
fristy ed149ded52
CI / Code Health (pull_request) Successful in 24s
CI / Lint, Test & Build (pull_request) Successful in 59s
fix: prettier formatting and downgrade upload-artifact to v3 for Gitea
2026-09-12 00:47:38 +03:00

216 lines
6.4 KiB
TypeScript

import type { GamePricePoint, GameProfile, ItadReview } from "@/lib/game/game-profile-types";
import type { Dictionary } from "@/lib/i18n/get-dictionary";
import type { Locale } from "@/lib/i18n/locales";
export type ModalDict = Dictionary["discord"]["gameModal"];
export const STEAM_SCORE_KEYS: Partial<Record<string, keyof ModalDict["reviewScores"]>> = {
"Overwhelmingly Positive": "overwhelminglyPositive",
"Very Positive": "veryPositive",
Positive: "positive",
"Mostly Positive": "mostlyPositive",
Mixed: "mixed",
"Mostly Negative": "mostlyNegative",
Negative: "negative",
"Very Negative": "veryNegative",
"Overwhelmingly Negative": "overwhelminglyNegative",
};
export const LOCALE_TAGS: Record<Locale, string> = {
tr: "tr-TR",
en: "en-US",
de: "de-DE",
};
export const COUNTRIES: Record<Locale, string> = {
tr: "TR",
en: "US",
de: "DE",
};
export function localizeScore(label: string, dict: ModalDict): string {
const key = STEAM_SCORE_KEYS[label];
return key ? dict.reviewScores[key] : label;
}
export function formatRatingPercent(value: number, localeTag: string): string {
try {
return new Intl.NumberFormat(localeTag, {
style: "percent",
maximumFractionDigits: 2,
}).format(value);
} catch {
return `%${(value * 100).toFixed(2)}`;
}
}
export function formatPrice(point: GamePricePoint, localeTag: string): string {
try {
return new Intl.NumberFormat(localeTag, {
style: "currency",
currency: point.currency,
}).format(point.price);
} catch {
return `${point.price} ${point.currency}`;
}
}
export function formatReleaseDate(value: string, localeTag: string): string {
if (/^\d{4}-\d{2}-\d{2}$/.test(value)) {
const parsed = new Date(`${value}T00:00:00`);
if (!Number.isNaN(parsed.getTime())) {
return parsed.toLocaleDateString(localeTag, {
day: "numeric",
month: "long",
year: "numeric",
});
}
}
return value;
}
export function formatCount(
value: number | null | undefined,
localeTag: string,
unit: string,
): string {
if (value == null) {
return "—";
}
return `${value.toLocaleString(localeTag)} ${unit}`;
}
export function discountPercent(point: GamePricePoint): number | null {
if (point.regular > point.price && point.regular > 0) {
return Math.round((1 - point.price / point.regular) * 100);
}
return null;
}
export function steamdbRating(positive: number, total: number): number | null {
if (
!Number.isFinite(positive) ||
!Number.isFinite(total) ||
total <= 0 ||
positive < 0 ||
positive > total
) {
return null;
}
const score = positive / total;
return score - (score - 0.5) * 2 ** -Math.log10(total + 1);
}
export function toIsoDate(unixSeconds: number | undefined): string | null {
if (!unixSeconds) {
return null;
}
return new Date(unixSeconds * 1000).toISOString().slice(0, 10);
}
export interface GameView {
gameUrl: string;
name: string;
bestPrice: GamePricePoint | null;
historyLow: GamePricePoint | null;
isFree: boolean;
}
export function selectGameView(
profile: GameProfile | null,
fallbackName: string,
fallbackGameUrl: string,
): GameView {
const bestPrice = profile?.itad?.prices ?? null;
const historyLow = profile?.itad?.historyLow ?? null;
return {
gameUrl: profile?.gameUrl ?? fallbackGameUrl,
name: profile?.name || fallbackName,
bestPrice,
historyLow,
isFree: !!bestPrice && bestPrice.price === 0,
};
}
export interface ProfileSummary {
dbRating: number | null;
itadReviews: ItadReview[];
hasDetails: boolean;
hasReviews: boolean;
metascore: ItadReview | null;
opencritic: ItadReview | null;
}
export function describeProfile(profile: GameProfile | null): ProfileSummary {
const dbRating =
profile?.steamReviews?.positive != null && profile?.steamReviews?.total != null
? steamdbRating(profile.steamReviews.positive, profile.steamReviews.total)
: null;
const itadReviews = profile?.itad?.reviews ?? [];
const hasDetails = !!(
profile &&
(profile.developers.length > 0 ||
profile.publishers.length > 0 ||
profile.releaseDate ||
profile.platforms.length > 0 ||
profile.links.length > 0)
);
const metascore = itadReviews.find((r) => r.source === "Metascore") ?? null;
const opencritic = itadReviews.find((r) => r.source === "OpenCritic") ?? null;
const hasReviews = !!(profile?.steamReviews || metascore || opencritic);
return { dbRating, itadReviews, hasDetails, hasReviews, metascore, opencritic };
}
const DEFAULT_ELAPSED_UNITS = { hour: "h", minute: "m", lessThanMinute: "<1m" };
const ELAPSED_UNITS: Partial<
Record<string, { hour: string; minute: string; lessThanMinute: string }>
> = {
tr: { hour: "s", minute: "dk", lessThanMinute: "<1dk" },
de: { hour: " Std.", minute: " Min.", lessThanMinute: "<1 Min." },
en: DEFAULT_ELAPSED_UNITS,
};
export function formatElapsed(start: number, lang: string): string {
const seconds = Math.max(0, Math.floor((Date.now() - start) / 1000));
const hours = Math.floor(seconds / 3600);
const minutes = Math.floor((seconds % 3600) / 60);
const units = ELAPSED_UNITS[lang] ?? ELAPSED_UNITS.en ?? DEFAULT_ELAPSED_UNITS;
if (hours > 0) {
return `${hours}${units.hour} ${minutes}${units.minute}`;
}
if (minutes > 0) {
return `${minutes}${units.minute}`;
}
return units.lessThanMinute;
}
export function normalizeGameTitle(value: string): string {
return value
.toLowerCase()
.replace(/[®™©]/g, "")
.replace(/[^a-z0-9]+/g, " ")
.trim();
}
export interface GameLocale {
lang: Locale;
country: string;
localeTag: string;
steamLang: string;
}
const STEAM_LANG: Record<Locale, string> = {
tr: "turkish",
en: "english",
de: "german",
};
export function resolveGameLocale(lang: string): GameLocale {
const resolved = lang === "tr" || lang === "en" || lang === "de" ? lang : "en";
return {
lang: resolved,
country: COUNTRIES[resolved],
localeTag: LOCALE_TAGS[resolved],
steamLang: STEAM_LANG[resolved],
};
}