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
package/src/styles.css
ADDED
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared wizard primitive styles. Uses CSS variables from the PolicyEngine
|
|
3
|
+
* ui-kit theme where available, with safe fallbacks so the package also works
|
|
4
|
+
* in apps that haven't imported the ui-kit theme.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
.pe-wizard-progress {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
gap: 0.5rem;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
.pe-wizard-progress-topline {
|
|
14
|
+
display: flex;
|
|
15
|
+
justify-content: space-between;
|
|
16
|
+
align-items: baseline;
|
|
17
|
+
font-size: 0.875rem;
|
|
18
|
+
color: var(--muted-foreground, var(--color-gray-600, #555));
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
.pe-wizard-progress-track {
|
|
22
|
+
height: 0.375rem;
|
|
23
|
+
background: var(--muted, var(--color-gray-200, #e5e7eb));
|
|
24
|
+
border-radius: 999px;
|
|
25
|
+
overflow: hidden;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
.pe-wizard-progress-bar {
|
|
29
|
+
height: 100%;
|
|
30
|
+
background: var(--primary, var(--color-teal-600, #2c7a7b));
|
|
31
|
+
transition: width 0.25s ease-out;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
.pe-wizard-option-card {
|
|
35
|
+
display: flex;
|
|
36
|
+
flex-direction: column;
|
|
37
|
+
align-items: flex-start;
|
|
38
|
+
gap: 0.25rem;
|
|
39
|
+
text-align: left;
|
|
40
|
+
width: 100%;
|
|
41
|
+
padding: 1rem 1.25rem;
|
|
42
|
+
background: var(--background, white);
|
|
43
|
+
color: var(--foreground, inherit);
|
|
44
|
+
border: 1.5px solid var(--border, var(--color-gray-200, #e5e7eb));
|
|
45
|
+
border-radius: var(--radius, 0.75rem);
|
|
46
|
+
cursor: pointer;
|
|
47
|
+
transition: border-color 0.15s ease, background 0.15s ease;
|
|
48
|
+
font: inherit;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
.pe-wizard-option-card:hover:not(:disabled) {
|
|
52
|
+
border-color: var(--primary, var(--color-teal-500, #319795));
|
|
53
|
+
background: var(--accent, var(--color-teal-50, #e6fffa));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
.pe-wizard-option-card:focus-visible {
|
|
57
|
+
outline: 2px solid var(--ring, var(--color-teal-500, #319795));
|
|
58
|
+
outline-offset: 2px;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
.pe-wizard-option-card--selected {
|
|
62
|
+
border-color: var(--primary, var(--color-teal-600, #2c7a7b));
|
|
63
|
+
background: var(--accent, var(--color-teal-50, #e6fffa));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
.pe-wizard-option-card:disabled,
|
|
67
|
+
.pe-wizard-option-card[aria-disabled="true"] {
|
|
68
|
+
cursor: not-allowed;
|
|
69
|
+
opacity: 0.6;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
.pe-wizard-option-card-title {
|
|
73
|
+
font-weight: 600;
|
|
74
|
+
font-size: 0.95rem;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
.pe-wizard-option-card-description {
|
|
78
|
+
font-size: 0.85rem;
|
|
79
|
+
color: var(--muted-foreground, var(--color-gray-600, #555));
|
|
80
|
+
line-height: 1.4;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
.pe-wizard-nav {
|
|
84
|
+
display: flex;
|
|
85
|
+
flex-wrap: wrap;
|
|
86
|
+
gap: 0.75rem;
|
|
87
|
+
align-items: center;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
.pe-wizard-nav-leading {
|
|
91
|
+
margin-right: auto;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
.pe-wizard-nav-trailing {
|
|
95
|
+
margin-left: auto;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.pe-wizard-nav-back,
|
|
99
|
+
.pe-wizard-nav-primary {
|
|
100
|
+
font: inherit;
|
|
101
|
+
border-radius: var(--radius, 0.5rem);
|
|
102
|
+
padding: 0.625rem 1.25rem;
|
|
103
|
+
cursor: pointer;
|
|
104
|
+
border: 1.5px solid transparent;
|
|
105
|
+
transition: background 0.15s ease, border-color 0.15s ease, color 0.15s ease;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
.pe-wizard-nav-back {
|
|
109
|
+
background: transparent;
|
|
110
|
+
color: var(--foreground, inherit);
|
|
111
|
+
border-color: var(--border, var(--color-gray-300, #d1d5db));
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
.pe-wizard-nav-back:hover:not(:disabled) {
|
|
115
|
+
background: var(--muted, var(--color-gray-100, #f3f4f6));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
.pe-wizard-nav-back:focus-visible,
|
|
119
|
+
.pe-wizard-nav-primary:focus-visible {
|
|
120
|
+
outline: 2px solid var(--ring, var(--color-teal-500, #319795));
|
|
121
|
+
outline-offset: 2px;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
.pe-wizard-nav-primary {
|
|
125
|
+
background: var(--primary, var(--color-teal-600, #2c7a7b));
|
|
126
|
+
color: var(--primary-foreground, white);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
.pe-wizard-nav-primary:hover:not(:disabled) {
|
|
130
|
+
filter: brightness(1.05);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
.pe-wizard-nav-back:disabled,
|
|
134
|
+
.pe-wizard-nav-primary:disabled {
|
|
135
|
+
cursor: not-allowed;
|
|
136
|
+
opacity: 0.55;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.pe-wizard-review-list {
|
|
140
|
+
display: grid;
|
|
141
|
+
grid-template-columns: repeat(auto-fit, minmax(15rem, 1fr));
|
|
142
|
+
gap: 0.75rem;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.pe-wizard-review-item {
|
|
146
|
+
display: flex;
|
|
147
|
+
flex-direction: column;
|
|
148
|
+
align-items: flex-start;
|
|
149
|
+
gap: 0.25rem;
|
|
150
|
+
text-align: left;
|
|
151
|
+
padding: 0.75rem 1rem;
|
|
152
|
+
background: var(--background, white);
|
|
153
|
+
border: 1px solid var(--border, var(--color-gray-200, #e5e7eb));
|
|
154
|
+
border-radius: var(--radius, 0.5rem);
|
|
155
|
+
font: inherit;
|
|
156
|
+
cursor: pointer;
|
|
157
|
+
transition: border-color 0.15s ease;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
.pe-wizard-review-item[role="listitem"]:not(button) {
|
|
161
|
+
cursor: default;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
.pe-wizard-review-item:hover:not(:disabled):not([role="listitem"]:not(button)) {
|
|
165
|
+
border-color: var(--primary, var(--color-teal-500, #319795));
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
.pe-wizard-review-item-label {
|
|
169
|
+
font-size: 0.8rem;
|
|
170
|
+
color: var(--muted-foreground, var(--color-gray-600, #555));
|
|
171
|
+
text-transform: uppercase;
|
|
172
|
+
letter-spacing: 0.04em;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
.pe-wizard-review-item-value {
|
|
176
|
+
font-weight: 600;
|
|
177
|
+
font-size: 0.95rem;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
.pe-wizard-review-item--missing .pe-wizard-review-item-value {
|
|
181
|
+
color: var(--destructive, var(--color-red-600, #dc2626));
|
|
182
|
+
font-weight: 500;
|
|
183
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export {
|
|
2
|
+
toV1HouseholdPayload,
|
|
3
|
+
toV1HouseholdSituation,
|
|
4
|
+
type V1EntityCollection,
|
|
5
|
+
type V1EntityRecord,
|
|
6
|
+
type V1FieldValue,
|
|
7
|
+
type V1GroupCollection,
|
|
8
|
+
type V1GroupRecord,
|
|
9
|
+
type V1HouseholdEnvelope,
|
|
10
|
+
type V1HouseholdSituation,
|
|
11
|
+
type V1PersonCollection,
|
|
12
|
+
type V1PersonRecord,
|
|
13
|
+
type V1ValueMap,
|
|
14
|
+
type ToV1PayloadOptions,
|
|
15
|
+
} from './v1Payload';
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
USHouseholdDraft,
|
|
3
|
+
USPersonDraft,
|
|
4
|
+
USPersonFlags,
|
|
5
|
+
USPersonIncomes,
|
|
6
|
+
} from '../types';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The shape of a PolicyEngine US "situation" / household-creation payload at
|
|
10
|
+
* the wire level. Variables are year-keyed; person/group keys are arbitrary
|
|
11
|
+
* strings (callers pick them).
|
|
12
|
+
*/
|
|
13
|
+
export type V1ValueMap = Record<string, number | string | boolean | null>;
|
|
14
|
+
export type V1FieldValue = V1ValueMap | string[] | undefined;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* A person record carries only year-keyed variable maps. `members` belongs on
|
|
18
|
+
* group records, never on people.
|
|
19
|
+
*/
|
|
20
|
+
export type V1PersonRecord = Record<string, V1ValueMap | undefined>;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A group record always carries `members` plus zero-or-more year-keyed
|
|
24
|
+
* variable maps.
|
|
25
|
+
*/
|
|
26
|
+
export type V1GroupRecord = { members: string[] } & Record<string, V1FieldValue>;
|
|
27
|
+
|
|
28
|
+
export type V1EntityRecord = V1PersonRecord | V1GroupRecord;
|
|
29
|
+
export type V1PersonCollection = Record<string, V1PersonRecord>;
|
|
30
|
+
export type V1GroupCollection = Record<string, V1GroupRecord>;
|
|
31
|
+
/** @deprecated Prefer V1PersonCollection or V1GroupCollection. */
|
|
32
|
+
export type V1EntityCollection = Record<string, V1EntityRecord>;
|
|
33
|
+
|
|
34
|
+
export interface V1HouseholdSituation {
|
|
35
|
+
people: V1PersonCollection;
|
|
36
|
+
families?: V1GroupCollection;
|
|
37
|
+
marital_units?: V1GroupCollection;
|
|
38
|
+
tax_units?: V1GroupCollection;
|
|
39
|
+
spm_units?: V1GroupCollection;
|
|
40
|
+
households?: V1GroupCollection;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Matches policyengine-app-v2's `V1HouseholdCreateEnvelope` so `fromUSDraft`
|
|
45
|
+
* adapters can pass the envelope straight into `Household.fromV1CreationPayload`.
|
|
46
|
+
*/
|
|
47
|
+
export interface V1HouseholdEnvelope {
|
|
48
|
+
country_id: 'us';
|
|
49
|
+
label?: string | null;
|
|
50
|
+
data: V1HouseholdSituation;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export interface ToV1PayloadOptions {
|
|
54
|
+
/**
|
|
55
|
+
* Optional override for group keys. Defaults to short, lower-case keys
|
|
56
|
+
* (`"household"`, `"tax_unit"`, etc.). Pass `"verbose"` for `"your household"`
|
|
57
|
+
* style names compatible with app-v2's builder.
|
|
58
|
+
*/
|
|
59
|
+
groupKeyStyle?: 'short' | 'verbose';
|
|
60
|
+
/**
|
|
61
|
+
* If true, attaches member ids to the marital unit. Off by default because
|
|
62
|
+
* cliff-watch and other consumers send `marital_units: {}` and have it work.
|
|
63
|
+
*/
|
|
64
|
+
includeMaritalUnit?: boolean;
|
|
65
|
+
/**
|
|
66
|
+
* Optional label for the envelope. Surfaces as `label` on the V1
|
|
67
|
+
* creation/metadata response.
|
|
68
|
+
*/
|
|
69
|
+
label?: string | null;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const SHORT_KEYS = {
|
|
73
|
+
household: 'household',
|
|
74
|
+
family: 'family',
|
|
75
|
+
taxUnit: 'tax_unit',
|
|
76
|
+
spmUnit: 'spm_unit',
|
|
77
|
+
maritalUnit: 'marital_unit',
|
|
78
|
+
} as const;
|
|
79
|
+
|
|
80
|
+
const VERBOSE_KEYS = {
|
|
81
|
+
household: 'your household',
|
|
82
|
+
family: 'your family',
|
|
83
|
+
taxUnit: 'your tax unit',
|
|
84
|
+
spmUnit: 'your household', // app-v2 uses the same label for SPM unit
|
|
85
|
+
maritalUnit: 'your marital unit',
|
|
86
|
+
} as const;
|
|
87
|
+
|
|
88
|
+
const FLAG_TO_VARIABLE: Record<keyof USPersonFlags, string> = {
|
|
89
|
+
isDisabled: 'is_disabled',
|
|
90
|
+
isBlind: 'is_blind',
|
|
91
|
+
isFullTimeStudent: 'is_full_time_student',
|
|
92
|
+
isPregnant: 'is_pregnant',
|
|
93
|
+
needsCare: 'is_incapable_of_self_care',
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
const INCOME_TO_VARIABLE: Record<keyof USPersonIncomes, string> = {
|
|
97
|
+
employmentIncome: 'employment_income',
|
|
98
|
+
selfEmploymentIncome: 'self_employment_income',
|
|
99
|
+
socialSecurityIncome: 'social_security',
|
|
100
|
+
ssiAmount: 'ssi',
|
|
101
|
+
ssdiAmount: 'social_security_disability',
|
|
102
|
+
pensionIncome: 'taxable_pension_income',
|
|
103
|
+
dividendIncome: 'qualified_dividend_income',
|
|
104
|
+
taxableInterestIncome: 'taxable_interest_income',
|
|
105
|
+
rentalIncome: 'rental_income',
|
|
106
|
+
unemploymentCompensation: 'unemployment_compensation',
|
|
107
|
+
childSupportReceived: 'child_support_received',
|
|
108
|
+
miscellaneousIncome: 'miscellaneous_income',
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
function yearMap(year: string, value: number | string | boolean): V1ValueMap {
|
|
112
|
+
return { [year]: value };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function buildPersonVariables(person: USPersonDraft, year: string): V1PersonRecord {
|
|
116
|
+
const record: V1PersonRecord = {};
|
|
117
|
+
|
|
118
|
+
if (person.age !== null && person.age !== undefined) {
|
|
119
|
+
record.age = yearMap(year, person.age);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (person.kind === 'dependent') {
|
|
123
|
+
record.is_tax_unit_dependent = yearMap(year, true);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
for (const [draftKey, variable] of Object.entries(FLAG_TO_VARIABLE)) {
|
|
127
|
+
const value = (person as USPersonDraft)[draftKey as keyof USPersonFlags];
|
|
128
|
+
if (value !== undefined) {
|
|
129
|
+
record[variable] = yearMap(year, value);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
for (const [draftKey, variable] of Object.entries(INCOME_TO_VARIABLE)) {
|
|
134
|
+
const value = (person as USPersonDraft)[draftKey as keyof USPersonIncomes];
|
|
135
|
+
if (value !== undefined && value !== null) {
|
|
136
|
+
record[variable] = yearMap(year, value);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return record;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function toV1HouseholdPayload(
|
|
144
|
+
draft: USHouseholdDraft,
|
|
145
|
+
options: ToV1PayloadOptions = {},
|
|
146
|
+
): V1HouseholdEnvelope {
|
|
147
|
+
const { groupKeyStyle = 'short', includeMaritalUnit = false, label = null } = options;
|
|
148
|
+
const keys = groupKeyStyle === 'verbose' ? VERBOSE_KEYS : SHORT_KEYS;
|
|
149
|
+
const year = String(draft.year);
|
|
150
|
+
|
|
151
|
+
const memberIds = draft.people.map((person) => person.id);
|
|
152
|
+
|
|
153
|
+
const people: V1PersonCollection = {};
|
|
154
|
+
for (const person of draft.people) {
|
|
155
|
+
people[person.id] = buildPersonVariables(person, year);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const householdRecord: V1GroupRecord = {
|
|
159
|
+
members: [...memberIds],
|
|
160
|
+
};
|
|
161
|
+
if (draft.state) {
|
|
162
|
+
householdRecord.state_name = yearMap(year, draft.state);
|
|
163
|
+
}
|
|
164
|
+
if (draft.county) {
|
|
165
|
+
householdRecord.county = yearMap(year, draft.county);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const families: V1GroupCollection = {
|
|
169
|
+
[keys.family]: { members: [...memberIds] },
|
|
170
|
+
};
|
|
171
|
+
const taxUnits: V1GroupCollection = {
|
|
172
|
+
[keys.taxUnit]: { members: [...memberIds] },
|
|
173
|
+
};
|
|
174
|
+
const spmUnits: V1GroupCollection = {
|
|
175
|
+
[keys.spmUnit]: { members: [...memberIds] },
|
|
176
|
+
};
|
|
177
|
+
const households: V1GroupCollection = {
|
|
178
|
+
[keys.household]: householdRecord,
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
const maritalUnits: V1GroupCollection = {};
|
|
182
|
+
if (includeMaritalUnit) {
|
|
183
|
+
const adults = draft.people.filter((person) => person.kind === 'adult');
|
|
184
|
+
maritalUnits[keys.maritalUnit] = {
|
|
185
|
+
members: adults.slice(0, 2).map((person) => person.id),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
return {
|
|
190
|
+
country_id: 'us',
|
|
191
|
+
label,
|
|
192
|
+
data: {
|
|
193
|
+
people,
|
|
194
|
+
families,
|
|
195
|
+
marital_units: maritalUnits,
|
|
196
|
+
tax_units: taxUnits,
|
|
197
|
+
spm_units: spmUnits,
|
|
198
|
+
households,
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Convenience that returns just the inner `V1HouseholdSituation` — useful for
|
|
205
|
+
* callers that POST to `/calculate` or otherwise need the situation directly
|
|
206
|
+
* without the envelope wrapper.
|
|
207
|
+
*/
|
|
208
|
+
export function toV1HouseholdSituation(
|
|
209
|
+
draft: USHouseholdDraft,
|
|
210
|
+
options: ToV1PayloadOptions = {},
|
|
211
|
+
): V1HouseholdSituation {
|
|
212
|
+
return toV1HouseholdPayload(draft, options).data;
|
|
213
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
import countiesByState from './data/counties-by-state.json' with { type: 'json' };
|
|
2
|
+
|
|
3
|
+
export interface County {
|
|
4
|
+
/** PolicyEngine US county enum code (e.g. "ALAMEDA_COUNTY_CA"). */
|
|
5
|
+
code: string;
|
|
6
|
+
/** Display name without the trailing state segment (e.g. "Alameda County"). */
|
|
7
|
+
name: string;
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
const DATA = countiesByState as Record<string, County[]>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Counties grouped by two-letter state code, sorted by name. The data ships
|
|
14
|
+
* inline with the package so the wizard can render a county dropdown without
|
|
15
|
+
* any network call.
|
|
16
|
+
*
|
|
17
|
+
* To refresh from PolicyEngine US metadata, run `bun run regenerate-counties`.
|
|
18
|
+
*/
|
|
19
|
+
export function getCountiesByState(stateCode: string | null | undefined): County[] {
|
|
20
|
+
if (!stateCode) {
|
|
21
|
+
return [];
|
|
22
|
+
}
|
|
23
|
+
return DATA[stateCode] ?? [];
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const COUNTY_CODES: Set<string> = new Set(
|
|
27
|
+
Object.values(DATA)
|
|
28
|
+
.flat()
|
|
29
|
+
.map((county) => county.code),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
const COUNTY_NAMES_BY_STATE: Map<string, Map<string, string>> = new Map(
|
|
33
|
+
Object.entries(DATA).map(([stateCode, list]) => [
|
|
34
|
+
stateCode,
|
|
35
|
+
new Map(list.map((county) => [normalizeName(county.name), county.code])),
|
|
36
|
+
]),
|
|
37
|
+
);
|
|
38
|
+
|
|
39
|
+
function normalizeName(value: string): string {
|
|
40
|
+
return value
|
|
41
|
+
.normalize('NFKD')
|
|
42
|
+
.replace(/[̀-ͯ]/g, '')
|
|
43
|
+
.toLowerCase()
|
|
44
|
+
.replace(/\bcounty\b|\bparish\b|\bborough\b|\bcensus area\b|\bmunicipality\b/g, '')
|
|
45
|
+
.replace(/[^a-z0-9]+/g, ' ')
|
|
46
|
+
.trim();
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function isCountyCode(value: unknown): value is string {
|
|
50
|
+
return typeof value === 'string' && COUNTY_CODES.has(value);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function getCountyName(code: string | null | undefined): string | null {
|
|
54
|
+
if (!code) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
for (const list of Object.values(DATA)) {
|
|
58
|
+
for (const county of list) {
|
|
59
|
+
if (county.code === code) {
|
|
60
|
+
return county.name;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
return null;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Resolve a free-text county name (legacy input shape used by Coverage Compass
|
|
69
|
+
* and others) to a PolicyEngine county code. Matching is case-insensitive and
|
|
70
|
+
* ignores common suffixes like "County", "Parish", "Borough".
|
|
71
|
+
*/
|
|
72
|
+
export function resolveCountyCode(
|
|
73
|
+
stateCode: string | null | undefined,
|
|
74
|
+
countyInput: string | null | undefined,
|
|
75
|
+
): string | null {
|
|
76
|
+
if (!stateCode || !countyInput) {
|
|
77
|
+
return null;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// If the caller already passed a known county code, return it unchanged.
|
|
81
|
+
if (isCountyCode(countyInput)) {
|
|
82
|
+
return countyInput;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const lookup = COUNTY_NAMES_BY_STATE.get(stateCode);
|
|
86
|
+
if (!lookup) {
|
|
87
|
+
return null;
|
|
88
|
+
}
|
|
89
|
+
const key = normalizeName(countyInput);
|
|
90
|
+
if (!key) {
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
return lookup.get(key) ?? null;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export { DATA as _rawCountyData };
|