211 lines
7.3 KiB
TypeScript
211 lines
7.3 KiB
TypeScript
import { useCallback, useEffect, useMemo, useState } from "react";
|
|
import type { AppSnapshot } from "../../../types/domain";
|
|
import type { StoredScanResultEntry } from "../../../types/storage";
|
|
import {
|
|
BUILD_FIT_PROFILE_CORPUS_VERSION,
|
|
curatedBuildFitProfiles,
|
|
} from "../../../data/buildFitProfiles";
|
|
import {
|
|
BUILD_FIT_COMBINATION_ASSESSMENT_VERSION,
|
|
BUILD_FIT_CONTRACT_VERSION,
|
|
BUILD_FIT_EVIDENCE_RANKING_VERSION,
|
|
type BuildFitEvidenceRankingResultV1,
|
|
} from "../../../types/buildFit";
|
|
import {
|
|
buildFitInputFromScanResult,
|
|
rankBuildFitEvidence,
|
|
} from "../../../lib/buildFitContract";
|
|
import {
|
|
aggregateContextKey,
|
|
buildExplicitAggregateContexts,
|
|
type BuildFitExplicitContextDraft,
|
|
} from "../../../lib/buildFitExplicitContext";
|
|
import { createRendererRepositories } from "../../../infrastructure/repositories/rendererBridgeRepositories";
|
|
import type { AppLocale } from "../../../i18n";
|
|
import { getBuildCopy } from "../buildsCopy";
|
|
|
|
const MAX_NATIVE_BUILD_FIT_RESULTS = 2400;
|
|
|
|
interface UseBuildsViewModelInput {
|
|
snapshot: AppSnapshot;
|
|
locale: AppLocale;
|
|
}
|
|
|
|
export interface BuildsViewModel {
|
|
artifactCount: number;
|
|
hasScanData: boolean;
|
|
foundationLabel: string;
|
|
foundationDetail: string;
|
|
contractLabel: string;
|
|
contractDetail: string;
|
|
profileLabel: string;
|
|
profileDetail: string;
|
|
nativeRunDir: string;
|
|
nativeResultCount: number;
|
|
isLoadingBuildFit: boolean;
|
|
buildFitLoadError: string;
|
|
ranking: BuildFitEvidenceRankingResultV1;
|
|
aggregateContextFields: BuildFitAggregateContextField[];
|
|
setAggregateContextDraft: (
|
|
profileId: string,
|
|
targetKey: string,
|
|
update: Partial<BuildFitExplicitContextDraft>,
|
|
) => void;
|
|
refreshBuildFit: () => Promise<void>;
|
|
}
|
|
|
|
export interface BuildFitAggregateContextField {
|
|
profileId: string;
|
|
characterName: string;
|
|
targetLabel: string;
|
|
targetKey: string;
|
|
stat: string;
|
|
unit: "flat" | "percent";
|
|
minimum?: number;
|
|
maximum?: number;
|
|
reason: string;
|
|
value: string;
|
|
confirmed: boolean;
|
|
error: string;
|
|
}
|
|
|
|
export function useBuildsViewModel({ snapshot, locale }: UseBuildsViewModelInput): BuildsViewModel {
|
|
const copy = getBuildCopy(locale);
|
|
const repositories = useMemo(() => createRendererRepositories(), []);
|
|
const [nativeResults, setNativeResults] = useState<StoredScanResultEntry[]>([]);
|
|
const [nativeRunDir, setNativeRunDir] = useState("");
|
|
const [nativeResultCount, setNativeResultCount] = useState(0);
|
|
const [isLoadingBuildFit, setIsLoadingBuildFit] = useState(Boolean(repositories));
|
|
const [buildFitLoadError, setBuildFitLoadError] = useState("");
|
|
const [aggregateContextDrafts, setAggregateContextDrafts] = useState<Record<string, Record<string, BuildFitExplicitContextDraft>>>({});
|
|
|
|
const refreshBuildFit = useCallback(async () => {
|
|
if (!repositories?.automation) {
|
|
setNativeResults([]);
|
|
setNativeRunDir("");
|
|
setNativeResultCount(0);
|
|
setBuildFitLoadError(copy.noBridge);
|
|
setIsLoadingBuildFit(false);
|
|
return;
|
|
}
|
|
|
|
setIsLoadingBuildFit(true);
|
|
setBuildFitLoadError("");
|
|
try {
|
|
const result = await repositories.automation.nativeScannerLoadResults({
|
|
limit: MAX_NATIVE_BUILD_FIT_RESULTS,
|
|
});
|
|
if (!result.ok) {
|
|
setNativeResults([]);
|
|
setNativeRunDir("");
|
|
setNativeResultCount(0);
|
|
setBuildFitLoadError(result.error ?? copy.noCompleteRun);
|
|
return;
|
|
}
|
|
setNativeResults(result.results);
|
|
setNativeRunDir(result.runDir);
|
|
setNativeResultCount(result.total);
|
|
} catch (error) {
|
|
setNativeResults([]);
|
|
setNativeRunDir("");
|
|
setNativeResultCount(0);
|
|
setBuildFitLoadError(error instanceof Error ? error.message : copy.loadFailed);
|
|
} finally {
|
|
setIsLoadingBuildFit(false);
|
|
}
|
|
}, [copy.loadFailed, copy.noBridge, copy.noCompleteRun, repositories]);
|
|
|
|
useEffect(() => {
|
|
void refreshBuildFit();
|
|
}, [refreshBuildFit]);
|
|
|
|
const aggregateContextResults = useMemo(() => Object.fromEntries(
|
|
curatedBuildFitProfiles.map((profile) => [
|
|
profile.id,
|
|
buildExplicitAggregateContexts(profile, aggregateContextDrafts[profile.id] ?? {}),
|
|
]),
|
|
) as Record<string, ReturnType<typeof buildExplicitAggregateContexts>>, [aggregateContextDrafts]);
|
|
const aggregateContextByProfile = useMemo(() => Object.fromEntries(
|
|
Object.entries(aggregateContextResults)
|
|
.filter(([, result]) => result.contexts.length > 0)
|
|
.map(([profileId, result]) => [profileId, result.contexts]),
|
|
), [aggregateContextResults]);
|
|
const aggregateContextFields = useMemo(() => curatedBuildFitProfiles.flatMap((profile) => profile.aggregateTargets.map((target) => {
|
|
const targetKey = aggregateContextKey(target);
|
|
const draft = aggregateContextDrafts[profile.id]?.[targetKey] ?? { value: "", confirmed: false };
|
|
return {
|
|
profileId: profile.id,
|
|
characterName: profile.character.name,
|
|
targetLabel: profile.target.label,
|
|
targetKey,
|
|
stat: target.stat,
|
|
unit: target.unit,
|
|
...(target.minimum === undefined ? {} : { minimum: target.minimum }),
|
|
...(target.maximum === undefined ? {} : { maximum: target.maximum }),
|
|
reason: target.reason,
|
|
value: draft.value,
|
|
confirmed: draft.confirmed,
|
|
error: aggregateContextResults[profile.id]?.issues[targetKey] ?? "",
|
|
};
|
|
})), [aggregateContextDrafts, aggregateContextResults]);
|
|
const setAggregateContextDraft = useCallback((
|
|
profileId: string,
|
|
targetKey: string,
|
|
update: Partial<BuildFitExplicitContextDraft>,
|
|
) => {
|
|
setAggregateContextDrafts((current) => ({
|
|
...current,
|
|
[profileId]: {
|
|
...current[profileId],
|
|
[targetKey]: {
|
|
value: current[profileId]?.[targetKey]?.value ?? "",
|
|
confirmed: current[profileId]?.[targetKey]?.confirmed ?? false,
|
|
...update,
|
|
},
|
|
},
|
|
}));
|
|
}, []);
|
|
|
|
const ranking = useMemo(() => rankBuildFitEvidence({
|
|
version: BUILD_FIT_EVIDENCE_RANKING_VERSION,
|
|
profiles: curatedBuildFitProfiles,
|
|
artifacts: nativeResults
|
|
.map((result) => buildFitInputFromScanResult(result))
|
|
.filter((result): result is NonNullable<typeof result> => result !== null),
|
|
aggregateContextByProfile,
|
|
}), [aggregateContextByProfile, nativeResults]);
|
|
const artifactCount = Math.max(snapshot.artifacts.length, nativeResultCount);
|
|
const hasScanData = artifactCount > 0;
|
|
const profileCount = curatedBuildFitProfiles.length;
|
|
const suggestionCount = ranking.suggestions.length;
|
|
|
|
return {
|
|
artifactCount,
|
|
hasScanData,
|
|
foundationLabel: hasScanData ? copy.foundationReady : copy.foundationEmpty,
|
|
foundationDetail: hasScanData
|
|
? copy.foundationReadyDetail(nativeResultCount || artifactCount)
|
|
: copy.foundationEmptyDetail,
|
|
contractLabel: copy.contractLabel,
|
|
contractDetail: copy.contractDetail(
|
|
BUILD_FIT_CONTRACT_VERSION,
|
|
BUILD_FIT_COMBINATION_ASSESSMENT_VERSION,
|
|
BUILD_FIT_EVIDENCE_RANKING_VERSION,
|
|
),
|
|
profileLabel: suggestionCount > 0
|
|
? copy.profilesReady(suggestionCount)
|
|
: copy.profilesChecked(profileCount),
|
|
profileDetail: suggestionCount > 0
|
|
? copy.profilesReadyDetail(BUILD_FIT_PROFILE_CORPUS_VERSION)
|
|
: copy.profilesBlockedDetail(BUILD_FIT_PROFILE_CORPUS_VERSION),
|
|
nativeRunDir,
|
|
nativeResultCount,
|
|
isLoadingBuildFit,
|
|
buildFitLoadError,
|
|
ranking,
|
|
aggregateContextFields,
|
|
setAggregateContextDraft,
|
|
refreshBuildFit,
|
|
};
|
|
}
|