star-chart-search-enhancer/src/shared/backend-metrics-client.ts

133 lines
3.6 KiB
TypeScript

import { DEFAULT_BACKEND_METRICS_BASE_URL } from "./backend-metrics-config";
export interface BackendMetricsRow {
a3IncreaseCount: string;
afterViewSearchCount: string;
afterViewSearchRate: string;
cpSearch: string;
cpa3: string;
newA3Rate: string;
starId: string;
}
interface FetchResponseLike {
json(): Promise<unknown>;
ok: boolean;
}
type FetchLike = (
input: string,
init?: RequestInit
) => Promise<FetchResponseLike>;
interface BackendMetricsClientOptions {
baseUrl?: string;
fetchImpl?: FetchLike;
getAccessToken: () => Promise<string>;
}
export function createBackendMetricsClient(options: BackendMetricsClientOptions) {
const baseUrl = options.baseUrl ?? DEFAULT_BACKEND_METRICS_BASE_URL;
const fetchImpl = options.fetchImpl ?? defaultFetch;
return {
async searchByStarIds(starIds: string[]): Promise<BackendMetricsRow[]> {
const response = await fetchImpl(buildBackendMetricsSearchUrl(baseUrl), {
body: JSON.stringify(buildBackendMetricsSearchRequestBody(starIds)),
headers: {
Authorization: `Bearer ${await options.getAccessToken()}`,
"Content-Type": "application/json"
},
method: "POST"
});
if (!response.ok) {
throw new Error("backend metrics request failed");
}
return mapBackendMetricsSearchResponse(await response.json());
}
};
}
export function buildBackendMetricsSearchUrl(baseUrl: string): string {
return new URL("/api/v1/history/talents/search", baseUrl).toString();
}
export function buildBackendMetricsSearchRequestBody(starIds: string[]) {
return {
page: 1,
size: Math.max(20, starIds.length),
type: "star_id",
values: starIds
};
}
export function mapBackendMetricsSearchResponse(payload: unknown): BackendMetricsRow[] {
const rows = readResponseRows(payload);
if (!rows) {
throw new Error("backend metrics response is invalid");
}
return rows.flatMap((row) => {
if (!isRecord(row) || typeof row.star_id !== "string") {
return [];
}
return [
{
a3IncreaseCount: formatDecimalValue(row.avg_a3_increase_cnt),
afterViewSearchCount: formatDecimalValue(row.avg_after_view_search_cnt),
afterViewSearchRate: formatRateValue(row.avg_after_view_search_rate),
cpSearch: formatDecimalValue(row.cp_search),
cpa3: formatDecimalValue(row.cpa3),
newA3Rate: formatRateValue(row.avg_new_a3_rate),
starId: row.star_id
}
];
});
}
function readResponseRows(payload: unknown): unknown[] | null {
if (!isRecord(payload) || payload.success !== true) {
return null;
}
const topLevelData = isRecord(payload.data) ? payload.data : null;
return Array.isArray(topLevelData?.data) ? topLevelData.data : null;
}
function formatRateValue(value: unknown): string {
const number = typeof value === "number" ? value : Number(value);
if (Number.isFinite(number)) {
const percentage = number * 100;
const formatted = new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: percentage % 1 === 0 ? 0 : 2
}).format(percentage);
return `${formatted}%`;
}
return "";
}
function formatDecimalValue(value: unknown): string {
const number = typeof value === "number" ? value : Number(value);
if (!Number.isFinite(number)) {
return "";
}
return new Intl.NumberFormat("en-US", {
maximumFractionDigits: 2,
minimumFractionDigits: 2
}).format(number);
}
async function defaultFetch(input: string, init?: RequestInit) {
return fetch(input, init);
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}