policyengine-household-wizard 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +116 -0
- package/dist/WizardReviewList-De9RTK_4.js +245 -0
- package/dist/WizardReviewList-tfP9LcqU.cjs +1 -0
- package/dist/index.cjs +1 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +35 -0
- package/dist/primitives/WizardNavigation.d.ts +19 -0
- package/dist/primitives/WizardNavigation.d.ts.map +1 -0
- package/dist/primitives/WizardOptionCard.d.ts +9 -0
- package/dist/primitives/WizardOptionCard.d.ts.map +1 -0
- package/dist/primitives/WizardProgress.d.ts +8 -0
- package/dist/primitives/WizardProgress.d.ts.map +1 -0
- package/dist/primitives/WizardReviewList.d.ts +14 -0
- package/dist/primitives/WizardReviewList.d.ts.map +1 -0
- package/dist/primitives/index.d.ts +11 -0
- package/dist/primitives/index.d.ts.map +1 -0
- package/dist/primitives/types.d.ts +30 -0
- package/dist/primitives/types.d.ts.map +1 -0
- package/dist/primitives/useWizardSteps.d.ts +3 -0
- package/dist/primitives/useWizardSteps.d.ts.map +1 -0
- package/dist/primitives.cjs +1 -0
- package/dist/primitives.d.ts +1 -0
- package/dist/primitives.js +8 -0
- package/dist/us-household/adapters/index.d.ts +2 -0
- package/dist/us-household/adapters/index.d.ts.map +1 -0
- package/dist/us-household/adapters/v1Payload.d.ts +68 -0
- package/dist/us-household/adapters/v1Payload.d.ts.map +1 -0
- package/dist/us-household/counties.d.ts +25 -0
- package/dist/us-household/counties.d.ts.map +1 -0
- package/dist/us-household/draft.d.ts +28 -0
- package/dist/us-household/draft.d.ts.map +1 -0
- package/dist/us-household/index.d.ts +9 -0
- package/dist/us-household/index.d.ts.map +1 -0
- package/dist/us-household/normalize.d.ts +11 -0
- package/dist/us-household/normalize.d.ts.map +1 -0
- package/dist/us-household/serialize.d.ts +4 -0
- package/dist/us-household/serialize.d.ts.map +1 -0
- package/dist/us-household/states.d.ts +15 -0
- package/dist/us-household/states.d.ts.map +1 -0
- package/dist/us-household/types.d.ts +80 -0
- package/dist/us-household/types.d.ts.map +1 -0
- package/dist/us-household/validate.d.ts +13 -0
- package/dist/us-household/validate.d.ts.map +1 -0
- package/dist/us-household-adapters.cjs +1 -0
- package/dist/us-household-adapters.d.ts +1 -0
- package/dist/us-household-adapters.js +92 -0
- package/dist/us-household.cjs +1 -0
- package/dist/us-household.d.ts +1 -0
- package/dist/us-household.js +556 -0
- package/package.json +76 -0
- package/src/index.ts +2 -0
- package/src/primitives/WizardNavigation.tsx +85 -0
- package/src/primitives/WizardOptionCard.tsx +55 -0
- package/src/primitives/WizardProgress.tsx +50 -0
- package/src/primitives/WizardReviewList.tsx +73 -0
- package/src/primitives/index.ts +15 -0
- package/src/primitives/types.ts +32 -0
- package/src/primitives/useWizardSteps.ts +150 -0
- package/src/styles.css +183 -0
- package/src/us-household/adapters/index.ts +15 -0
- package/src/us-household/adapters/v1Payload.ts +213 -0
- package/src/us-household/counties.ts +96 -0
- package/src/us-household/data/counties-by-state.json +12802 -0
- package/src/us-household/draft.ts +130 -0
- package/src/us-household/index.ts +59 -0
- package/src/us-household/normalize.ts +251 -0
- package/src/us-household/serialize.ts +153 -0
- package/src/us-household/states.ts +168 -0
- package/src/us-household/types.ts +82 -0
- package/src/us-household/validate.ts +129 -0
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { USHouseholdDraft, USPersonDraft, USPersonKind } from './types';
|
|
2
|
+
|
|
3
|
+
export const DEFAULT_HOUSEHOLD_YEAR = new Date().getUTCFullYear();
|
|
4
|
+
|
|
5
|
+
const ADULT_PREFIX = 'adult';
|
|
6
|
+
const DEPENDENT_PREFIX = 'dependent';
|
|
7
|
+
|
|
8
|
+
function nextPersonId(people: USPersonDraft[], kind: USPersonKind): string {
|
|
9
|
+
const prefix = kind === 'adult' ? ADULT_PREFIX : DEPENDENT_PREFIX;
|
|
10
|
+
const ids = new Set(people.map((person) => person.id));
|
|
11
|
+
let candidate = `${prefix}-1`;
|
|
12
|
+
let counter = 1;
|
|
13
|
+
while (ids.has(candidate)) {
|
|
14
|
+
counter += 1;
|
|
15
|
+
candidate = `${prefix}-${counter}`;
|
|
16
|
+
}
|
|
17
|
+
return candidate;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Returns a draft with no defaults for state, ages, or marital status. The
|
|
22
|
+
* wizard must explicitly capture each of these before the household is valid.
|
|
23
|
+
*
|
|
24
|
+
* `year` defaults to the current UTC year so calculations have a target period;
|
|
25
|
+
* apps that want a fixed year can pass one in.
|
|
26
|
+
*/
|
|
27
|
+
export function createBlankDraft(year: number = DEFAULT_HOUSEHOLD_YEAR): USHouseholdDraft {
|
|
28
|
+
return {
|
|
29
|
+
state: null,
|
|
30
|
+
county: null,
|
|
31
|
+
zip: null,
|
|
32
|
+
maritalStatus: null,
|
|
33
|
+
people: [],
|
|
34
|
+
year,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Returns a deep clone of an existing draft. Used by apps that want to keep an
|
|
40
|
+
* "edit baseline" separate from the in-progress draft.
|
|
41
|
+
*/
|
|
42
|
+
export function cloneDraft(draft: USHouseholdDraft): USHouseholdDraft {
|
|
43
|
+
return {
|
|
44
|
+
state: draft.state,
|
|
45
|
+
county: draft.county,
|
|
46
|
+
zip: draft.zip,
|
|
47
|
+
maritalStatus: draft.maritalStatus,
|
|
48
|
+
year: draft.year,
|
|
49
|
+
extras: draft.extras ? structuredClone(draft.extras) : undefined,
|
|
50
|
+
people: draft.people.map((person) => ({
|
|
51
|
+
...person,
|
|
52
|
+
extras: person.extras ? structuredClone(person.extras) : undefined,
|
|
53
|
+
})),
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function createPerson(
|
|
58
|
+
kind: USPersonKind,
|
|
59
|
+
existingPeople: USPersonDraft[] = [],
|
|
60
|
+
partial: Partial<USPersonDraft> = {},
|
|
61
|
+
): USPersonDraft {
|
|
62
|
+
return {
|
|
63
|
+
id: partial.id ?? nextPersonId(existingPeople, kind),
|
|
64
|
+
kind,
|
|
65
|
+
age: partial.age ?? null,
|
|
66
|
+
...partial,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function addPerson(
|
|
71
|
+
draft: USHouseholdDraft,
|
|
72
|
+
kind: USPersonKind,
|
|
73
|
+
partial: Partial<USPersonDraft> = {},
|
|
74
|
+
): USHouseholdDraft {
|
|
75
|
+
const newPerson = createPerson(kind, draft.people, partial);
|
|
76
|
+
return { ...draft, people: [...draft.people, newPerson] };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function removePerson(draft: USHouseholdDraft, personId: string): USHouseholdDraft {
|
|
80
|
+
return { ...draft, people: draft.people.filter((person) => person.id !== personId) };
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
export function updatePerson(
|
|
84
|
+
draft: USHouseholdDraft,
|
|
85
|
+
personId: string,
|
|
86
|
+
changes: Partial<USPersonDraft>,
|
|
87
|
+
): USHouseholdDraft {
|
|
88
|
+
return {
|
|
89
|
+
...draft,
|
|
90
|
+
people: draft.people.map((person) =>
|
|
91
|
+
person.id === personId ? { ...person, ...changes } : person,
|
|
92
|
+
),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export function getAdults(draft: USHouseholdDraft): USPersonDraft[] {
|
|
97
|
+
return draft.people.filter((person) => person.kind === 'adult');
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function getDependents(draft: USHouseholdDraft): USPersonDraft[] {
|
|
101
|
+
return draft.people.filter((person) => person.kind === 'dependent');
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Reconcile the people array with the chosen marital status. Switching to
|
|
106
|
+
* "married" with one adult adds a partner; switching to "single" with two or
|
|
107
|
+
* more adults removes the trailing adults beyond the first.
|
|
108
|
+
*/
|
|
109
|
+
export function applyMaritalStatusChange(
|
|
110
|
+
draft: USHouseholdDraft,
|
|
111
|
+
maritalStatus: USHouseholdDraft['maritalStatus'],
|
|
112
|
+
): USHouseholdDraft {
|
|
113
|
+
if (maritalStatus === draft.maritalStatus) {
|
|
114
|
+
return draft;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const adults = getAdults(draft);
|
|
118
|
+
let next: USHouseholdDraft = { ...draft, maritalStatus };
|
|
119
|
+
|
|
120
|
+
if (maritalStatus === 'married' && adults.length < 2) {
|
|
121
|
+
next = addPerson(next, 'adult');
|
|
122
|
+
} else if (maritalStatus === 'single' && adults.length > 1) {
|
|
123
|
+
const extras = adults.slice(1);
|
|
124
|
+
for (const extra of extras) {
|
|
125
|
+
next = removePerson(next, extra.id);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
return next;
|
|
130
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export type {
|
|
2
|
+
USHouseholdDraft,
|
|
3
|
+
USMaritalStatus,
|
|
4
|
+
USPersonDraft,
|
|
5
|
+
USPersonFlags,
|
|
6
|
+
USPersonIncomes,
|
|
7
|
+
USPersonKind,
|
|
8
|
+
ValidationIssue,
|
|
9
|
+
ValidationResult,
|
|
10
|
+
} from './types';
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
DEFAULT_HOUSEHOLD_YEAR,
|
|
14
|
+
createBlankDraft,
|
|
15
|
+
cloneDraft,
|
|
16
|
+
createPerson,
|
|
17
|
+
addPerson,
|
|
18
|
+
removePerson,
|
|
19
|
+
updatePerson,
|
|
20
|
+
getAdults,
|
|
21
|
+
getDependents,
|
|
22
|
+
applyMaritalStatusChange,
|
|
23
|
+
} from './draft';
|
|
24
|
+
|
|
25
|
+
export { normalizeLegacyDraft } from './normalize';
|
|
26
|
+
export { validate, isComplete, type ValidateOptions } from './validate';
|
|
27
|
+
export { serializeDraft, deserializeDraft } from './serialize';
|
|
28
|
+
|
|
29
|
+
export {
|
|
30
|
+
US_STATES,
|
|
31
|
+
isUSStateCode,
|
|
32
|
+
getStateName,
|
|
33
|
+
getStateFromZip,
|
|
34
|
+
type USState,
|
|
35
|
+
} from './states';
|
|
36
|
+
|
|
37
|
+
export {
|
|
38
|
+
getCountiesByState,
|
|
39
|
+
getCountyName,
|
|
40
|
+
isCountyCode,
|
|
41
|
+
resolveCountyCode,
|
|
42
|
+
type County,
|
|
43
|
+
} from './counties';
|
|
44
|
+
|
|
45
|
+
export {
|
|
46
|
+
toV1HouseholdPayload,
|
|
47
|
+
toV1HouseholdSituation,
|
|
48
|
+
type V1HouseholdEnvelope,
|
|
49
|
+
type V1HouseholdSituation,
|
|
50
|
+
type V1ValueMap,
|
|
51
|
+
type V1FieldValue,
|
|
52
|
+
type V1PersonRecord,
|
|
53
|
+
type V1GroupRecord,
|
|
54
|
+
type V1PersonCollection,
|
|
55
|
+
type V1GroupCollection,
|
|
56
|
+
type V1EntityRecord,
|
|
57
|
+
type V1EntityCollection,
|
|
58
|
+
type ToV1PayloadOptions,
|
|
59
|
+
} from './adapters/v1Payload';
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { resolveCountyCode } from './counties';
|
|
2
|
+
import { getStateFromZip, isUSStateCode } from './states';
|
|
3
|
+
import { DEFAULT_HOUSEHOLD_YEAR, createBlankDraft } from './draft';
|
|
4
|
+
import type {
|
|
5
|
+
USHouseholdDraft,
|
|
6
|
+
USMaritalStatus,
|
|
7
|
+
USPersonDraft,
|
|
8
|
+
USPersonKind,
|
|
9
|
+
} from './types';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Map a legacy filing status to marital status. This is intentionally
|
|
13
|
+
* one-directional: the shared draft only carries marital status because the
|
|
14
|
+
* issue calls out asking users marital status (not filing status) in UI.
|
|
15
|
+
*/
|
|
16
|
+
function filingStatusToMaritalStatus(value: unknown): USMaritalStatus | null {
|
|
17
|
+
if (typeof value !== 'string') {
|
|
18
|
+
return null;
|
|
19
|
+
}
|
|
20
|
+
const normalized = value.toLowerCase();
|
|
21
|
+
if (normalized.includes('married') && !normalized.includes('separately')) {
|
|
22
|
+
return 'married';
|
|
23
|
+
}
|
|
24
|
+
if (normalized === 'married_jointly' || normalized === 'married_separately') {
|
|
25
|
+
return 'married';
|
|
26
|
+
}
|
|
27
|
+
if (
|
|
28
|
+
normalized === 'mfj' ||
|
|
29
|
+
normalized === 'mfs' ||
|
|
30
|
+
normalized === 'joint' ||
|
|
31
|
+
normalized === 'separate'
|
|
32
|
+
) {
|
|
33
|
+
return 'married';
|
|
34
|
+
}
|
|
35
|
+
if (
|
|
36
|
+
normalized === 'single' ||
|
|
37
|
+
normalized === 'head_of_household' ||
|
|
38
|
+
normalized === 'hoh' ||
|
|
39
|
+
normalized === 'widowed' ||
|
|
40
|
+
normalized === 'qualifying_widower' ||
|
|
41
|
+
normalized === 'qualifying_widow_widower' ||
|
|
42
|
+
normalized === 'qualifying_surviving_spouse'
|
|
43
|
+
) {
|
|
44
|
+
return 'single';
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface LegacyPerson {
|
|
50
|
+
age?: number | string | null;
|
|
51
|
+
kind?: USPersonKind | 'child';
|
|
52
|
+
isDisabled?: boolean;
|
|
53
|
+
is_disabled?: boolean;
|
|
54
|
+
isBlind?: boolean;
|
|
55
|
+
is_blind?: boolean;
|
|
56
|
+
isFullTimeStudent?: boolean;
|
|
57
|
+
is_full_time_student?: boolean;
|
|
58
|
+
isPregnant?: boolean;
|
|
59
|
+
is_pregnant?: boolean;
|
|
60
|
+
needsCare?: boolean;
|
|
61
|
+
is_incapable_of_self_care?: boolean;
|
|
62
|
+
earned_income?: number;
|
|
63
|
+
employment_income?: number;
|
|
64
|
+
employmentIncome?: number;
|
|
65
|
+
self_employment_income_annual?: number;
|
|
66
|
+
ssi_amount?: number;
|
|
67
|
+
ssdi_amount?: number;
|
|
68
|
+
ssiAmount?: number;
|
|
69
|
+
ssdiAmount?: number;
|
|
70
|
+
social_security_annual?: number;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface LegacyDraftShape {
|
|
74
|
+
state?: string | null;
|
|
75
|
+
county?: string | null;
|
|
76
|
+
zip?: string | null;
|
|
77
|
+
zipCode?: string | null;
|
|
78
|
+
maritalStatus?: string | null;
|
|
79
|
+
marital_status?: string | null;
|
|
80
|
+
filingStatus?: string | null;
|
|
81
|
+
filing_status?: string | null;
|
|
82
|
+
people?: LegacyPerson[];
|
|
83
|
+
childAges?: Array<number | string | null>;
|
|
84
|
+
child_ages?: Array<number | string | null>;
|
|
85
|
+
age?: number | string | null;
|
|
86
|
+
partnerAge?: number | string | null;
|
|
87
|
+
spouseAge?: number | string | null;
|
|
88
|
+
year?: number;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function coerceAge(value: unknown): number | null {
|
|
92
|
+
if (value === null || value === undefined || value === '') {
|
|
93
|
+
return null;
|
|
94
|
+
}
|
|
95
|
+
const parsed = typeof value === 'number' ? value : parseInt(String(value), 10);
|
|
96
|
+
if (Number.isFinite(parsed)) {
|
|
97
|
+
return parsed;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function coerceNumber(value: unknown): number | undefined {
|
|
103
|
+
if (value === null || value === undefined || value === '') {
|
|
104
|
+
return undefined;
|
|
105
|
+
}
|
|
106
|
+
const parsed = typeof value === 'number' ? value : Number(value);
|
|
107
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function coerceBoolean(value: unknown): boolean | undefined {
|
|
111
|
+
if (value === undefined) {
|
|
112
|
+
return undefined;
|
|
113
|
+
}
|
|
114
|
+
return Boolean(value);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function normalizePerson(
|
|
118
|
+
legacy: LegacyPerson,
|
|
119
|
+
ordinal: number,
|
|
120
|
+
kind: USPersonKind,
|
|
121
|
+
): USPersonDraft {
|
|
122
|
+
const id =
|
|
123
|
+
kind === 'adult' ? `adult-${ordinal}` : `dependent-${ordinal}`;
|
|
124
|
+
return {
|
|
125
|
+
id,
|
|
126
|
+
kind,
|
|
127
|
+
age: coerceAge(legacy.age),
|
|
128
|
+
isDisabled: coerceBoolean(legacy.isDisabled ?? legacy.is_disabled),
|
|
129
|
+
isBlind: coerceBoolean(legacy.isBlind ?? legacy.is_blind),
|
|
130
|
+
isFullTimeStudent: coerceBoolean(
|
|
131
|
+
legacy.isFullTimeStudent ?? legacy.is_full_time_student,
|
|
132
|
+
),
|
|
133
|
+
isPregnant: coerceBoolean(legacy.isPregnant ?? legacy.is_pregnant),
|
|
134
|
+
needsCare: coerceBoolean(legacy.needsCare ?? legacy.is_incapable_of_self_care),
|
|
135
|
+
employmentIncome:
|
|
136
|
+
coerceNumber(legacy.employmentIncome) ??
|
|
137
|
+
coerceNumber(legacy.employment_income) ??
|
|
138
|
+
coerceNumber(legacy.earned_income),
|
|
139
|
+
selfEmploymentIncome: coerceNumber(legacy.self_employment_income_annual),
|
|
140
|
+
ssiAmount: coerceNumber(legacy.ssiAmount ?? legacy.ssi_amount),
|
|
141
|
+
ssdiAmount: coerceNumber(legacy.ssdiAmount ?? legacy.ssdi_amount),
|
|
142
|
+
socialSecurityIncome: coerceNumber(legacy.social_security_annual),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Best-effort normalization from legacy household shapes (cliff-watch's people
|
|
148
|
+
* array, Coverage Compass's flat fields, ad-hoc test fixtures) into the shared
|
|
149
|
+
* draft contract. Unknown fields are dropped silently; apps that need to keep
|
|
150
|
+
* extras should hand-edit the result.
|
|
151
|
+
*/
|
|
152
|
+
export function normalizeLegacyDraft(
|
|
153
|
+
raw: unknown,
|
|
154
|
+
options: { year?: number } = {},
|
|
155
|
+
): USHouseholdDraft {
|
|
156
|
+
if (!raw || typeof raw !== 'object') {
|
|
157
|
+
return createBlankDraft(options.year);
|
|
158
|
+
}
|
|
159
|
+
const legacy = raw as LegacyDraftShape;
|
|
160
|
+
const draft = createBlankDraft(options.year ?? legacy.year ?? DEFAULT_HOUSEHOLD_YEAR);
|
|
161
|
+
|
|
162
|
+
// Location.
|
|
163
|
+
if (typeof legacy.state === 'string' && isUSStateCode(legacy.state)) {
|
|
164
|
+
draft.state = legacy.state;
|
|
165
|
+
} else if (legacy.zipCode || legacy.zip) {
|
|
166
|
+
const derived = getStateFromZip(legacy.zip ?? legacy.zipCode ?? null);
|
|
167
|
+
if (derived) {
|
|
168
|
+
draft.state = derived;
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
draft.zip = legacy.zip ?? legacy.zipCode ?? null;
|
|
172
|
+
|
|
173
|
+
if (legacy.county) {
|
|
174
|
+
draft.county = resolveCountyCode(draft.state, legacy.county);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// Marital status — prefer explicit, fall back to filing status mapping.
|
|
178
|
+
const explicit =
|
|
179
|
+
legacy.maritalStatus ?? legacy.marital_status ?? null;
|
|
180
|
+
if (typeof explicit === 'string') {
|
|
181
|
+
const lower = explicit.toLowerCase();
|
|
182
|
+
if (lower === 'married' || lower === 'unmarried' || lower === 'single') {
|
|
183
|
+
draft.maritalStatus = lower === 'married' ? 'married' : 'single';
|
|
184
|
+
} else {
|
|
185
|
+
draft.maritalStatus = filingStatusToMaritalStatus(explicit);
|
|
186
|
+
}
|
|
187
|
+
} else {
|
|
188
|
+
const filing = legacy.filingStatus ?? legacy.filing_status;
|
|
189
|
+
draft.maritalStatus = filingStatusToMaritalStatus(filing);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// People — accept either a people array or coverage-compass-style flat fields.
|
|
193
|
+
if (Array.isArray(legacy.people) && legacy.people.length > 0) {
|
|
194
|
+
let adultCount = 0;
|
|
195
|
+
let dependentCount = 0;
|
|
196
|
+
draft.people = legacy.people.map((legacyPerson) => {
|
|
197
|
+
const kind = legacyPerson.kind === 'child' ? 'dependent' : legacyPerson.kind ?? 'adult';
|
|
198
|
+
if (kind === 'adult') {
|
|
199
|
+
adultCount += 1;
|
|
200
|
+
return normalizePerson(legacyPerson, adultCount, 'adult');
|
|
201
|
+
}
|
|
202
|
+
dependentCount += 1;
|
|
203
|
+
return normalizePerson(legacyPerson, dependentCount, 'dependent');
|
|
204
|
+
});
|
|
205
|
+
} else {
|
|
206
|
+
const adults: USPersonDraft[] = [];
|
|
207
|
+
if (legacy.age !== undefined && legacy.age !== null) {
|
|
208
|
+
adults.push({
|
|
209
|
+
id: 'adult-1',
|
|
210
|
+
kind: 'adult',
|
|
211
|
+
age: coerceAge(legacy.age),
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
const partnerAge = legacy.partnerAge ?? legacy.spouseAge;
|
|
215
|
+
if (draft.maritalStatus === 'married' && partnerAge !== undefined && partnerAge !== null) {
|
|
216
|
+
adults.push({
|
|
217
|
+
id: 'adult-2',
|
|
218
|
+
kind: 'adult',
|
|
219
|
+
age: coerceAge(partnerAge),
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
const dependents: USPersonDraft[] = [];
|
|
223
|
+
const childAges = legacy.childAges ?? legacy.child_ages;
|
|
224
|
+
if (Array.isArray(childAges)) {
|
|
225
|
+
childAges.forEach((childAge, index) => {
|
|
226
|
+
dependents.push({
|
|
227
|
+
id: `dependent-${index + 1}`,
|
|
228
|
+
kind: 'dependent',
|
|
229
|
+
age: coerceAge(childAge),
|
|
230
|
+
});
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
draft.people = [...adults, ...dependents];
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// If marital status is married but only one adult, add a partner with blank
|
|
237
|
+
// age so the wizard can prompt for it. If single but extra adults, trim.
|
|
238
|
+
const adults = draft.people.filter((person) => person.kind === 'adult');
|
|
239
|
+
if (draft.maritalStatus === 'married' && adults.length < 2) {
|
|
240
|
+
draft.people = [
|
|
241
|
+
...draft.people,
|
|
242
|
+
{ id: 'adult-2', kind: 'adult', age: null },
|
|
243
|
+
];
|
|
244
|
+
} else if (draft.maritalStatus === 'single' && adults.length > 1) {
|
|
245
|
+
const [keep] = adults;
|
|
246
|
+
const dependents = draft.people.filter((person) => person.kind === 'dependent');
|
|
247
|
+
draft.people = [keep, ...dependents];
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return draft;
|
|
251
|
+
}
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
import { createBlankDraft } from './draft';
|
|
2
|
+
import type { USHouseholdDraft, USPersonDraft } from './types';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Compact URL-safe serialization for the household draft. The format is
|
|
6
|
+
* stable: each app gets the same query-string shape, which lets us deep-link
|
|
7
|
+
* across apps that share the wizard.
|
|
8
|
+
*
|
|
9
|
+
* Format example: `state=CA&county=ALAMEDA_COUNTY_CA&marital=married&year=2026
|
|
10
|
+
* &p=adult:35:e50000,adult:33:e20000,dep:6`
|
|
11
|
+
*
|
|
12
|
+
* Fields are pipe-separated within each person; people are comma-separated.
|
|
13
|
+
* Income/flag keys are single letters to keep the URL short:
|
|
14
|
+
* - `e` = employmentIncome
|
|
15
|
+
* - `s` = ssiAmount
|
|
16
|
+
* - `d` = ssdiAmount
|
|
17
|
+
* - `D` = isDisabled
|
|
18
|
+
* - `B` = isBlind
|
|
19
|
+
* - `S` = isFullTimeStudent
|
|
20
|
+
* - `P` = isPregnant
|
|
21
|
+
* - `C` = needsCare
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const FLAG_KEYS: Array<[keyof USPersonDraft, string]> = [
|
|
25
|
+
['isDisabled', 'D'],
|
|
26
|
+
['isBlind', 'B'],
|
|
27
|
+
['isFullTimeStudent', 'S'],
|
|
28
|
+
['isPregnant', 'P'],
|
|
29
|
+
['needsCare', 'C'],
|
|
30
|
+
];
|
|
31
|
+
|
|
32
|
+
const INCOME_KEYS: Array<[keyof USPersonDraft, string]> = [
|
|
33
|
+
['employmentIncome', 'e'],
|
|
34
|
+
['ssiAmount', 's'],
|
|
35
|
+
['ssdiAmount', 'd'],
|
|
36
|
+
];
|
|
37
|
+
|
|
38
|
+
function serializePerson(person: USPersonDraft): string {
|
|
39
|
+
const segments: string[] = [
|
|
40
|
+
person.kind === 'adult' ? 'adult' : 'dep',
|
|
41
|
+
person.age === null || person.age === undefined ? '' : String(person.age),
|
|
42
|
+
];
|
|
43
|
+
for (const [key, letter] of INCOME_KEYS) {
|
|
44
|
+
const value = person[key] as number | undefined;
|
|
45
|
+
if (value !== undefined && value !== null && value !== 0) {
|
|
46
|
+
segments.push(`${letter}${value}`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
for (const [key, letter] of FLAG_KEYS) {
|
|
50
|
+
if (person[key]) {
|
|
51
|
+
segments.push(letter);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return segments.join(':');
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function parsePerson(raw: string): USPersonDraft | null {
|
|
58
|
+
const parts = raw.split(':');
|
|
59
|
+
if (parts.length === 0) {
|
|
60
|
+
return null;
|
|
61
|
+
}
|
|
62
|
+
const kindToken = parts[0];
|
|
63
|
+
const kind = kindToken === 'adult' ? 'adult' : kindToken === 'dep' ? 'dependent' : null;
|
|
64
|
+
if (!kind) {
|
|
65
|
+
return null;
|
|
66
|
+
}
|
|
67
|
+
const ageRaw = parts[1] ?? '';
|
|
68
|
+
const age = ageRaw === '' ? null : Number.parseInt(ageRaw, 10);
|
|
69
|
+
const person: USPersonDraft = {
|
|
70
|
+
id: `${kind === 'adult' ? 'adult' : 'dependent'}-?`,
|
|
71
|
+
kind,
|
|
72
|
+
age: Number.isFinite(age) ? (age as number) : null,
|
|
73
|
+
};
|
|
74
|
+
for (let i = 2; i < parts.length; i += 1) {
|
|
75
|
+
const token = parts[i];
|
|
76
|
+
if (token.length === 0) continue;
|
|
77
|
+
const letter = token[0];
|
|
78
|
+
const rest = token.slice(1);
|
|
79
|
+
const incomeMatch = INCOME_KEYS.find(([, l]) => l === letter);
|
|
80
|
+
if (incomeMatch) {
|
|
81
|
+
const value = Number.parseInt(rest, 10);
|
|
82
|
+
if (Number.isFinite(value)) {
|
|
83
|
+
(person as unknown as Record<string, unknown>)[incomeMatch[0]] = value;
|
|
84
|
+
}
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
const flagMatch = FLAG_KEYS.find(([, l]) => l === letter);
|
|
88
|
+
if (flagMatch) {
|
|
89
|
+
(person as unknown as Record<string, unknown>)[flagMatch[0]] = true;
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return person;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function serializeDraft(draft: USHouseholdDraft): string {
|
|
96
|
+
const params = new URLSearchParams();
|
|
97
|
+
if (draft.state) params.set('state', draft.state);
|
|
98
|
+
if (draft.county) params.set('county', draft.county);
|
|
99
|
+
if (draft.zip) params.set('zip', draft.zip);
|
|
100
|
+
if (draft.maritalStatus) params.set('marital', draft.maritalStatus);
|
|
101
|
+
params.set('year', String(draft.year));
|
|
102
|
+
if (draft.people.length > 0) {
|
|
103
|
+
params.set('p', draft.people.map(serializePerson).join(','));
|
|
104
|
+
}
|
|
105
|
+
return params.toString();
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function deserializeDraft(query: string | URLSearchParams): USHouseholdDraft {
|
|
109
|
+
const params = typeof query === 'string' ? new URLSearchParams(query) : query;
|
|
110
|
+
const draft = createBlankDraft();
|
|
111
|
+
|
|
112
|
+
const state = params.get('state');
|
|
113
|
+
if (state) draft.state = state;
|
|
114
|
+
|
|
115
|
+
const county = params.get('county');
|
|
116
|
+
if (county) draft.county = county;
|
|
117
|
+
|
|
118
|
+
const zip = params.get('zip');
|
|
119
|
+
if (zip) draft.zip = zip;
|
|
120
|
+
|
|
121
|
+
const marital = params.get('marital');
|
|
122
|
+
if (marital === 'married' || marital === 'single') {
|
|
123
|
+
draft.maritalStatus = marital;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const yearRaw = params.get('year');
|
|
127
|
+
if (yearRaw) {
|
|
128
|
+
const year = Number.parseInt(yearRaw, 10);
|
|
129
|
+
if (Number.isFinite(year)) {
|
|
130
|
+
draft.year = year;
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
const peopleRaw = params.get('p');
|
|
135
|
+
if (peopleRaw) {
|
|
136
|
+
let adultCount = 0;
|
|
137
|
+
let dependentCount = 0;
|
|
138
|
+
draft.people = peopleRaw
|
|
139
|
+
.split(',')
|
|
140
|
+
.map(parsePerson)
|
|
141
|
+
.filter((person): person is USPersonDraft => person !== null)
|
|
142
|
+
.map((person) => {
|
|
143
|
+
if (person.kind === 'adult') {
|
|
144
|
+
adultCount += 1;
|
|
145
|
+
return { ...person, id: `adult-${adultCount}` };
|
|
146
|
+
}
|
|
147
|
+
dependentCount += 1;
|
|
148
|
+
return { ...person, id: `dependent-${dependentCount}` };
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return draft;
|
|
153
|
+
}
|