Skip to content

Commit

Permalink
Non-RI experience (#23)
Browse files Browse the repository at this point in the history
## Description

This allows you to enter any ZIP the backend recognizes, and removes
the map+utility selector part if the ZIP is not in RI. It got a little
complicated because of the need to accommodate changing the utility
selector without losing the outline map; see the comment on the
`tempState` property for more detail on that. There are other options
for dealing with this quirk, but this felt like the straightest path.

One thing I wrestled with is that this would be easier if the
`/utilities` response included the state; that would mean the whole
`tempState` hack wouldn't be needed. But that felt too much like
allowing the needs of this specific frontend to dictate the design of
the backend.

### Followup

I'll implement the behavior of the `state` attribute on the calculator
element "locking" the calculator to that state in a followup PR.

## Test Plan

Calculate the RI zip 02814, which has multiple utility options. Make
sure the UI progresses straight from loading spinner to fully loaded
with utility selector + incentives. RI Energy should be auto-chosen.

Choose Pascoag in the utility selector; make sure the map+selector
remain visible while the new incentives load.

Calculate the zip 02859, which also has RIE and Pascoag. The
map+selector should disappear while loading, then reappear with
Pascoag still selected; incentives should include Pascoag ones.

Calculate the zip 02116, which is in MA. Only IRA incentives should
appear, with no map+selector.

Calculate a RI zip again; make sure the RI experience reappears.
  • Loading branch information
oyamauchi authored Sep 29, 2023
1 parent 488b31f commit 55a965d
Show file tree
Hide file tree
Showing 4 changed files with 122 additions and 62 deletions.
10 changes: 10 additions & 0 deletions src/api/calculator-types-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,12 @@ export interface Incentive {
eligible: boolean;
}

export type APIUtilitiesResponse = {
[utilityId: string]: {
name: string;
};
};

export interface APIResponse {
authorities: {
[authorityId: string]: {
Expand All @@ -63,6 +69,10 @@ export interface APIResponse {
};
};
};
coverage: {
state: string | null;
utility: string | null;
};
savings: {
tax_credit: number;
pos_rebate: number;
Expand Down
4 changes: 2 additions & 2 deletions src/api/fetch.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
* Fetches a response from the Incentives API. Handles turning an error response
* into an exception with a useful message.
*/
export async function fetchApi(
export async function fetchApi<R>(
apiKey: string,
apiHost: string,
path: string,
query: URLSearchParams,
) {
): Promise<R> {
const url = new URL(apiHost);
url.pathname = path;
url.search = query.toString();
Expand Down
1 change: 0 additions & 1 deletion src/rhode-island.html
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,6 @@
<h1>Rewiring America Incentives Calculator</h1>
<rewiring-america-state-calculator
api-key="{{ apiKey }}"
state="RI"
household-income="80000"
household-size="1"
tax-filing="single"
Expand Down
169 changes: 110 additions & 59 deletions src/state-calculator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { iconTabBarStyles } from './icon-tab-bar';
import '@shoelace-style/shoelace/dist/components/spinner/spinner';
import { STATES } from './states';
import { authorityLogosStyles } from './authority-logos';
import { APIResponse, APIUtilitiesResponse } from './api/calculator-types-v1';

const loadingTemplate = () => html`
<div class="card card-content">
Expand Down Expand Up @@ -70,17 +71,6 @@ export class RewiringAmericaStateCalculator extends LitElement {
@property({ type: String, attribute: 'api-host' })
apiHost: string = DEFAULT_CALCULATOR_API_HOST;

/**
* Property to customize the calculator for a particular state. Must be the
* two-letter code, uppercase (example: "NY").
*
* Currently the only customization is to display the name of the state.
* TODO: Have a nice error message if you enter a zip/address outside this
* state, if it's defined.
*/
@property({ type: String, attribute: 'state' })
state: string = '';

/* supported properties to allow pre-filling the form */

@property({ type: String, attribute: 'zip' })
Expand All @@ -107,6 +97,24 @@ export class RewiringAmericaStateCalculator extends LitElement {
@property({ type: String })
selectedOtherTab: Project = 'heat_pump_clothes_dryer';

/**
* This is a hack to deal with a quirk of the UI.
*
* Specifically:
*
* - Rendering the utility selector / map outline requires knowing what state
* the user is in, to know which outline to show.
* - That state is unknown until the /calculator response is available.
* - When the user changes the utility selector, the /calculator response is
* unavailable while it's loading. But we want to continue showing the
* utility selector / map outline.
*
* This property thus temporarily remembers the state from the last completed
* /calculator response, when the utility selector is changed. It's cleared
* when the /calculator response arrives.
*/
tempState: string | null = null;

submit(e: SubmitEvent) {
e.preventDefault();
const formData = new FormData(e.target as HTMLFormElement);
Expand Down Expand Up @@ -149,22 +157,33 @@ export class RewiringAmericaStateCalculator extends LitElement {
const query = new URLSearchParams({
'location[zip]': this.zip,
});
const utilityMap = await fetchApi(
this.apiKey,
this.apiHost,
'/api/v1/utilities',
query,
);

return Object.keys(utilityMap).map(id => ({
value: id,
label: utilityMap[id].name,
}));
try {
const utilityMap = await fetchApi<APIUtilitiesResponse>(
this.apiKey,
this.apiHost,
'/api/v1/utilities',
query,
);

return Object.keys(utilityMap).map(id => ({
value: id,
label: utilityMap[id].name,
}));
} catch (_) {
// Just use an empty utilities list if there's an error.
return [];
}
},
onComplete: options => {
// Preserve the previous utility selection if it's still available.
if (!options.map(o => o.value).includes(this.utility)) {
this.utility = options[0].value;
if (options.length === 0) {
this.utility = '';
} else {
// Preserve the previous utility selection if it's still available.
// Select the first option in the list otherwise.
if (!options.map(o => o.value).includes(this.utility)) {
this.utility = options[0].value;
}
}
this._task.run();
},
Expand All @@ -185,21 +204,47 @@ export class RewiringAmericaStateCalculator extends LitElement {
tax_filing: this.taxFiling,
household_size: this.householdSize,
});
query.append('authority_types', 'federal');
query.append('authority_types', 'state');
query.append('authority_types', 'utility');
query.set('utility', this.utility);
if (this.utility) {
query.set('utility', this.utility);
}

return await fetchApi(
return fetchApi<APIResponse>(
this.apiKey,
this.apiHost,
'/api/v1/calculator',
query,
);
},
onComplete: () => {
this.tempState = null;
},
});

override render() {
// If we have incentives loaded, use coverage.state from that to determine
// which state outline to show. Otherwise, look at the "tempState" override,
// which is set when the utility selector is changed.
const highlightedState =
this._task.status === TaskStatus.COMPLETE
? this._task.value?.coverage.state
: this.tempState;

// Show the following elements below the form:
//
// - The utility selector/map, if we know what state the user's ZIP is
// located in, we have an outline map of that state, and the options for
// utilities are finished loading.
//
// - The incentive results with a separator line above, if both the
// incentives and utilities are finished loading.
//
// - The loading spinner, if either utilities or incentives are still
// loading.

const showLoading =
this._utilitiesTask.status === TaskStatus.PENDING ||
this._task.status === TaskStatus.PENDING;

return html`
<div class="calculator">
<div class="card card-content">
Expand All @@ -220,37 +265,43 @@ export class RewiringAmericaStateCalculator extends LitElement {
'grid-3-2-1',
)}
</div>
${this._utilitiesTask.render({
pending: loadingTemplate,
complete: options =>
utilitySelectorTemplate(
STATES[this.state],
this.utility,
options,
newUtility => {
this.utility = newUtility;
this._task.run();
},
),
error: errorTemplate,
})}
${this._task.status !== TaskStatus.INITIAL &&
this._utilitiesTask.status === TaskStatus.COMPLETE
? html`<div class="separator"></div>`
${
// This is defensive against the possibility that the backend and
// frontend have support for different sets of states. (Backend support
// means knowing utilities and incentives for a state; frontend support
// means having an outline map and name for a state.)
this._utilitiesTask.status === TaskStatus.COMPLETE &&
this._utilitiesTask.value!.length > 0 &&
highlightedState &&
highlightedState in STATES
? utilitySelectorTemplate(
STATES[highlightedState],
this.utility,
this._utilitiesTask.value!,
newUtility => {
this.utility = newUtility;
this.tempState = highlightedState;
this._task.run();
},
)
: nothing
}
${this._utilitiesTask.status === TaskStatus.COMPLETE &&
this._task.status === TaskStatus.COMPLETE
? [
html`<div class="separator"></div>`,
stateIncentivesTemplate(
this._task.value!,
this.selectedProject,
this.selectedOtherTab,
newSelection => (this.selectedOtherTab = newSelection),
),
]
: nothing}
${showLoading ? loadingTemplate() : nothing}
${this._task.status === TaskStatus.ERROR && !showLoading
? errorTemplate(this._task.error)
: nothing}
${this._task.render({
pending: loadingTemplate,
complete: results =>
this._utilitiesTask.status !== TaskStatus.COMPLETE
? nothing
: stateIncentivesTemplate(
results,
this.selectedProject,
this.selectedOtherTab,
newSelection => (this.selectedOtherTab = newSelection),
),
error: errorTemplate,
})}
${CALCULATOR_FOOTER}
</div>
`;
Expand Down

1 comment on commit 55a965d

@vercel
Copy link

@vercel vercel bot commented on 55a965d Sep 29, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.