Files
Jyotisha/frontend/src/lib/chart-view-service.ts
T
jesse-ux 5a8328e591
Independent Staging Quality Gate / validate (push) Successful in 27m22s
Independent Staging Quality Gate / publish (push) Canceled after 47s
fix(web): paint the chart page shell before engine calls
BUG-715/716/717: typed engine failures with logs and split copy, natal-only open, on-demand tabs, 10s timeout, in-memory cache, no speed-promise eyebrow.
2026-09-15 22:51:15 +08:00

172 lines
5.1 KiB
TypeScript

import "server-only";
import { consumeRequestRateLimit } from "./request-rate-limit.ts";
import {
ACCOUNT_BIRTH_SELECT,
globalBirthProfileFromAccountRow,
} from "./server-owned-birth-profile.ts";
import { createServerSupabaseClient } from "./supabase/server.ts";
import { resolveAyanamsa } from "./ayanamsa.ts";
import { assembleChartView, type ChartViewEnginePost } from "./chart-view-load.ts";
import type { ChartViewProfileInput } from "./chart-view-mapper.ts";
import type { ChartViewResponse } from "./chart-view-contract.ts";
import {
CHART_VIEW_ENGINE_TIMEOUT_MS,
chartViewEngineCacheKey,
engineCallFromHttp,
engineCallFromThrown,
readChartViewEngineCache,
writeChartViewEngineCache,
type ChartViewLayer,
type EngineCallResult,
} from "./chart-view-engine.ts";
const jyotishApiBase = process.env.JYOTISH_API_BASE ?? "http://127.0.0.1:5200";
function text(value: unknown): string | undefined {
if (typeof value !== "string") return undefined;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : undefined;
}
function finite(value: unknown): number | undefined {
if (typeof value === "number" && Number.isFinite(value)) return value;
if (typeof value === "string" && value.trim()) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : undefined;
}
return undefined;
}
async function postEngine(path: string, body: Record<string, unknown>): Promise<EngineCallResult> {
const started = Date.now();
try {
const response = await fetch(`${jyotishApiBase}${path}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
cache: "no-store",
signal: AbortSignal.timeout(CHART_VIEW_ENGINE_TIMEOUT_MS),
});
let payload: unknown;
try {
payload = await response.json();
} catch (error) {
const errorName = error instanceof Error ? error.name : "Error";
if (response.ok) {
return {
status: "bad_payload",
path,
elapsedMs: Date.now() - started,
httpStatus: response.status,
errorName,
};
}
return engineCallFromHttp({
path,
elapsedMs: Date.now() - started,
httpStatus: response.status,
payload: null,
});
}
return engineCallFromHttp({
path,
elapsedMs: Date.now() - started,
httpStatus: response.status,
payload,
});
} catch (error) {
return engineCallFromThrown(path, Date.now() - started, error);
}
}
function cachedPostEngine(userId: string, profile: ChartViewProfileInput): ChartViewEnginePost {
const ayanamsa = resolveAyanamsa({ ayanamsa: profile.ayanamsa });
const nodeMode = "mean";
return async (path, body) => {
const key = chartViewEngineCacheKey({
userId,
date: profile.date,
time: profile.time,
latitude: profile.latitude,
longitude: profile.longitude,
timezoneOffset: profile.timezoneOffset,
ayanamsa,
nodeMode,
path,
});
const hit = readChartViewEngineCache(key);
if (hit) return { status: "ok", payload: hit };
const result = await postEngine(path, body);
if (result.status === "ok") writeChartViewEngineCache(key, result.payload);
return result;
};
}
function profileFromRow(row: unknown): ChartViewProfileInput | null {
const profile = globalBirthProfileFromAccountRow(row);
const record = row && typeof row === "object" && !Array.isArray(row)
? row as Record<string, unknown>
: {};
if (!profile.date || !profile.time) return null;
const latitude = finite(profile.latitude);
const longitude = finite(profile.longitude);
const timezoneOffset = finite(profile.timezoneOffset);
if (latitude === undefined || longitude === undefined || timezoneOffset === undefined) return null;
return {
name: profile.name,
date: profile.date,
time: profile.time,
placeLabel: text(record.birth_place_label),
latitude,
longitude,
timezoneOffset,
timezoneId: profile.timezoneId,
ayanamsa: typeof profile.ayanamsa === "string" ? profile.ayanamsa : undefined,
birthTimeStatus: profile.birthTimeStatus,
};
}
export async function loadChartView(input: {
now?: Date;
layers?: readonly ChartViewLayer[];
} = {}): Promise<{
httpStatus: number;
body: ChartViewResponse;
}> {
const now = input.now ?? new Date();
const supabase = await createServerSupabaseClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return assembleChartView({
userId: null,
profile: null,
postEngine,
asOf: now.toISOString().slice(0, 10),
layers: input.layers,
});
}
const limited = consumeRequestRateLimit({
key: `chartView:${user.id}`,
limit: 20,
windowMs: 60_000,
});
const { data: row } = await supabase
.from("profiles")
.select(ACCOUNT_BIRTH_SELECT)
.eq("id", user.id)
.maybeSingle();
const profile = profileFromRow(row);
return assembleChartView({
userId: user.id,
profile,
postEngine: profile ? cachedPostEngine(user.id, profile) : postEngine,
asOf: now.toISOString().slice(0, 10),
rateLimited: !limited.ok,
layers: input.layers,
});
}