Files
sports-empower-hub/app/citizen-frontend/src/utils/self-assessment.ts
T
2026-10-05 10:11:04 +08:00

178 lines
4.6 KiB
TypeScript

import type {
ChronicDisease,
ExerciseFrequency,
PainLevel,
RiskLevel,
SelfAssessmentRequest,
WantedImprovement,
} from "@/api/types";
export interface GuestSelfAssessmentDraft {
assessment: SelfAssessmentRequest;
provisionalRiskLevel: RiskLevel;
/** 同一份草稿重送正式自評時沿用同一個 key。 */
idempotencyKey?: string;
}
export const GUEST_SELF_ASSESSMENT_DRAFT_KEY = "guest-self-assessment-draft";
const EXERCISE_FREQUENCY_VALUES: ExerciseFrequency[] = [
"none",
"oneToTwo",
"threeOrMore",
];
const PAIN_LEVEL_VALUES: PainLevel[] = ["none", "mild", "moderate", "severe"];
const RISK_LEVEL_VALUES: RiskLevel[] = ["low", "followUp", "consultation"];
const WANTED_IMPROVEMENT_VALUES: WantedImprovement[] = [
"strength",
"balance",
"cardio",
"flexibility",
"agility",
"bodyComposition",
];
const CHRONIC_DISEASE_VALUES: ChronicDisease[] = [
"hypertension",
"diabetes",
"hyperlipidemia",
"heartDisease",
"osteoarthritis",
"osteoporosis",
];
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null;
}
function isOneOf<T extends string>(
values: readonly T[],
value: unknown,
): value is T {
return typeof value === "string" && values.includes(value as T);
}
function isStringArray<T extends string>(
values: readonly T[],
value: unknown,
): value is T[] {
return (
Array.isArray(value) &&
value.every((item) => isOneOf(values, item)) &&
new Set(value).size === value.length
);
}
function isValidAssessment(value: unknown): value is SelfAssessmentRequest {
if (!isRecord(value)) {
return false;
}
return (
isOneOf(EXERCISE_FREQUENCY_VALUES, value.exerciseFrequency) &&
isStringArray(WANTED_IMPROVEMENT_VALUES, value.wantedImprovements) &&
typeof value.hasFalls === "boolean" &&
isOneOf(PAIN_LEVEL_VALUES, value.painLevel) &&
isStringArray(CHRONIC_DISEASE_VALUES, value.chronicDiseases)
);
}
function isValidDraft(value: unknown): value is GuestSelfAssessmentDraft {
if (!isRecord(value)) {
return false;
}
return (
isValidAssessment(value.assessment) &&
isOneOf(RISK_LEVEL_VALUES, value.provisionalRiskLevel) &&
(value.idempotencyKey === undefined ||
typeof value.idempotencyKey === "string")
);
}
/** 依後端規則計算訪客階段的暫存結果;正式結果仍以後端回應為準。 */
export function calculateProvisionalRiskLevel(
assessment: SelfAssessmentRequest,
): RiskLevel {
if (
assessment.painLevel === "moderate" ||
assessment.painLevel === "severe" ||
assessment.chronicDiseases.length >= 2
) {
return "consultation";
}
if (assessment.exerciseFrequency === "none" || assessment.hasFalls === true) {
return "followUp";
}
return "low";
}
/** 僅在自評完成並確認送出後保存不含身分資料的訪客草稿。 */
export function saveGuestSelfAssessmentDraft(
draft: GuestSelfAssessmentDraft,
): void {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.setItem(
GUEST_SELF_ASSESSMENT_DRAFT_KEY,
JSON.stringify(draft),
);
} catch {
// 儲存空間不可用時不讓表單提交流程因瀏覽器安全設定而崩潰。
}
}
/** 讀取並驗證訪客草稿;格式不正確的資料視為不存在。 */
export function readGuestSelfAssessmentDraft(): GuestSelfAssessmentDraft | null {
if (typeof window === "undefined") {
return null;
}
try {
const rawDraft = window.sessionStorage.getItem(
GUEST_SELF_ASSESSMENT_DRAFT_KEY,
);
if (!rawDraft) {
return null;
}
const parsedDraft: unknown = JSON.parse(rawDraft);
if (!isValidDraft(parsedDraft)) {
return null;
}
const sanitizedDraft: GuestSelfAssessmentDraft = {
assessment: {
...parsedDraft.assessment,
wantedImprovements: [...parsedDraft.assessment.wantedImprovements],
chronicDiseases: [...parsedDraft.assessment.chronicDiseases],
},
provisionalRiskLevel: parsedDraft.provisionalRiskLevel,
...(parsedDraft.idempotencyKey
? { idempotencyKey: parsedDraft.idempotencyKey }
: {}),
};
saveGuestSelfAssessmentDraft(sanitizedDraft);
return sanitizedDraft;
} catch {
return null;
}
}
/** 正式保存成功後清除訪客草稿;登入取消或 API 失敗時不應呼叫。 */
export function clearGuestSelfAssessmentDraft(): void {
if (typeof window === "undefined") {
return;
}
try {
window.sessionStorage.removeItem(GUEST_SELF_ASSESSMENT_DRAFT_KEY);
} catch {
// sessionStorage 不可用時沒有可清除的資料,保持流程可繼續。
}
}