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.
Files changed (72) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +116 -0
  3. package/dist/WizardReviewList-De9RTK_4.js +245 -0
  4. package/dist/WizardReviewList-tfP9LcqU.cjs +1 -0
  5. package/dist/index.cjs +1 -0
  6. package/dist/index.d.ts +3 -0
  7. package/dist/index.d.ts.map +1 -0
  8. package/dist/index.js +35 -0
  9. package/dist/primitives/WizardNavigation.d.ts +19 -0
  10. package/dist/primitives/WizardNavigation.d.ts.map +1 -0
  11. package/dist/primitives/WizardOptionCard.d.ts +9 -0
  12. package/dist/primitives/WizardOptionCard.d.ts.map +1 -0
  13. package/dist/primitives/WizardProgress.d.ts +8 -0
  14. package/dist/primitives/WizardProgress.d.ts.map +1 -0
  15. package/dist/primitives/WizardReviewList.d.ts +14 -0
  16. package/dist/primitives/WizardReviewList.d.ts.map +1 -0
  17. package/dist/primitives/index.d.ts +11 -0
  18. package/dist/primitives/index.d.ts.map +1 -0
  19. package/dist/primitives/types.d.ts +30 -0
  20. package/dist/primitives/types.d.ts.map +1 -0
  21. package/dist/primitives/useWizardSteps.d.ts +3 -0
  22. package/dist/primitives/useWizardSteps.d.ts.map +1 -0
  23. package/dist/primitives.cjs +1 -0
  24. package/dist/primitives.d.ts +1 -0
  25. package/dist/primitives.js +8 -0
  26. package/dist/us-household/adapters/index.d.ts +2 -0
  27. package/dist/us-household/adapters/index.d.ts.map +1 -0
  28. package/dist/us-household/adapters/v1Payload.d.ts +68 -0
  29. package/dist/us-household/adapters/v1Payload.d.ts.map +1 -0
  30. package/dist/us-household/counties.d.ts +25 -0
  31. package/dist/us-household/counties.d.ts.map +1 -0
  32. package/dist/us-household/draft.d.ts +28 -0
  33. package/dist/us-household/draft.d.ts.map +1 -0
  34. package/dist/us-household/index.d.ts +9 -0
  35. package/dist/us-household/index.d.ts.map +1 -0
  36. package/dist/us-household/normalize.d.ts +11 -0
  37. package/dist/us-household/normalize.d.ts.map +1 -0
  38. package/dist/us-household/serialize.d.ts +4 -0
  39. package/dist/us-household/serialize.d.ts.map +1 -0
  40. package/dist/us-household/states.d.ts +15 -0
  41. package/dist/us-household/states.d.ts.map +1 -0
  42. package/dist/us-household/types.d.ts +80 -0
  43. package/dist/us-household/types.d.ts.map +1 -0
  44. package/dist/us-household/validate.d.ts +13 -0
  45. package/dist/us-household/validate.d.ts.map +1 -0
  46. package/dist/us-household-adapters.cjs +1 -0
  47. package/dist/us-household-adapters.d.ts +1 -0
  48. package/dist/us-household-adapters.js +92 -0
  49. package/dist/us-household.cjs +1 -0
  50. package/dist/us-household.d.ts +1 -0
  51. package/dist/us-household.js +556 -0
  52. package/package.json +76 -0
  53. package/src/index.ts +2 -0
  54. package/src/primitives/WizardNavigation.tsx +85 -0
  55. package/src/primitives/WizardOptionCard.tsx +55 -0
  56. package/src/primitives/WizardProgress.tsx +50 -0
  57. package/src/primitives/WizardReviewList.tsx +73 -0
  58. package/src/primitives/index.ts +15 -0
  59. package/src/primitives/types.ts +32 -0
  60. package/src/primitives/useWizardSteps.ts +150 -0
  61. package/src/styles.css +183 -0
  62. package/src/us-household/adapters/index.ts +15 -0
  63. package/src/us-household/adapters/v1Payload.ts +213 -0
  64. package/src/us-household/counties.ts +96 -0
  65. package/src/us-household/data/counties-by-state.json +12802 -0
  66. package/src/us-household/draft.ts +130 -0
  67. package/src/us-household/index.ts +59 -0
  68. package/src/us-household/normalize.ts +251 -0
  69. package/src/us-household/serialize.ts +153 -0
  70. package/src/us-household/states.ts +168 -0
  71. package/src/us-household/types.ts +82 -0
  72. 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
+ }