From d5a64f7565909d142f1ea490e3effc4405c7d673 Mon Sep 17 00:00:00 2001 From: Owen Yamauchi Date: Wed, 27 Sep 2023 19:13:23 -0400 Subject: [PATCH 1/3] Non-RI experience ## 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. --- src/api/calculator-types-v1.ts | 10 +++ src/api/fetch.ts | 4 +- src/rhode-island.html | 1 - src/state-calculator.ts | 157 +++++++++++++++++++++++---------- 4 files changed, 121 insertions(+), 51 deletions(-) diff --git a/src/api/calculator-types-v1.ts b/src/api/calculator-types-v1.ts index 5d3a232..472dd75 100644 --- a/src/api/calculator-types-v1.ts +++ b/src/api/calculator-types-v1.ts @@ -52,6 +52,12 @@ export interface Incentive { eligible: boolean; } +export type APIUtilitiesResponse = { + [utilityId: string]: { + name: string; + }; +}; + export interface APIResponse { authorities: { [authorityId: string]: { @@ -63,6 +69,10 @@ export interface APIResponse { }; }; }; + coverage: { + state: string | null; + utility: string | null; + }; savings: { tax_credit: number; pos_rebate: number; diff --git a/src/api/fetch.ts b/src/api/fetch.ts index 215ba98..ecc52df 100644 --- a/src/api/fetch.ts +++ b/src/api/fetch.ts @@ -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( apiKey: string, apiHost: string, path: string, query: URLSearchParams, -) { +): Promise { const url = new URL(apiHost); url.pathname = path; url.search = query.toString(); diff --git a/src/rhode-island.html b/src/rhode-island.html index cecdd4d..4de20a1 100644 --- a/src/rhode-island.html +++ b/src/rhode-island.html @@ -64,7 +64,6 @@

Rewiring America Incentives Calculator

html`
@@ -107,6 +108,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); @@ -149,22 +168,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( + 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(); }, @@ -185,21 +215,43 @@ 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 await fetchApi( 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. + return html`
@@ -220,37 +272,46 @@ export class RewiringAmericaStateCalculator extends LitElement { 'grid-3-2-1', )}
- ${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`
` + ${ + // 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`
`, + stateIncentivesTemplate( + this._task.value!, + this.selectedProject, + this.selectedOtherTab, + newSelection => (this.selectedOtherTab = newSelection), + ), + ] + : nothing} + ${this._utilitiesTask.status === TaskStatus.PENDING || + this._task.status === TaskStatus.PENDING + ? loadingTemplate() + : nothing} + ${this._task.status === TaskStatus.ERROR + ? 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}
`; From dbe35ec178c3204adbd9fef4cbe8ac38b99fc6c4 Mon Sep 17 00:00:00 2001 From: Owen Yamauchi Date: Thu, 28 Sep 2023 11:06:24 -0400 Subject: [PATCH 2/3] Actually just add the state locking feature now --- src/rhode-island.html | 1 + src/state-calculator.ts | 29 +++++++++++++++++++++++------ 2 files changed, 24 insertions(+), 6 deletions(-) diff --git a/src/rhode-island.html b/src/rhode-island.html index 4de20a1..cecdd4d 100644 --- a/src/rhode-island.html +++ b/src/rhode-island.html @@ -64,6 +64,7 @@

Rewiring America Incentives Calculator

( + const response = await fetchApi( this.apiKey, this.apiHost, '/api/v1/calculator', query, ); + + // If the "state" attribute is set, enforce that some other state's + // incentives aren't shown. But if coverage.state is null, we won't show + // the error: we'll only be showing federal incentives in that case. + if ( + this.state && + response.coverage.state && + response.coverage.state !== this.state + ) { + // Throw to put the task into the ERROR state for rendering. + throw new Error( + `That ZIP code is not in ${STATES[this.state]?.name ?? this.state}.`, + ); + } + + return response; }, onComplete: () => { this.tempState = null; @@ -252,6 +268,10 @@ export class RewiringAmericaStateCalculator extends LitElement { // - 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`
@@ -305,11 +325,8 @@ export class RewiringAmericaStateCalculator extends LitElement { ), ] : nothing} - ${this._utilitiesTask.status === TaskStatus.PENDING || - this._task.status === TaskStatus.PENDING - ? loadingTemplate() - : nothing} - ${this._task.status === TaskStatus.ERROR + ${showLoading ? loadingTemplate() : nothing} + ${this._task.status === TaskStatus.ERROR && !showLoading ? errorTemplate(this._task.error) : nothing} ${CALCULATOR_FOOTER} From 4a4b94a10da213835a3cfa07920a58ccf6bb1fb1 Mon Sep 17 00:00:00 2001 From: Owen Yamauchi Date: Fri, 29 Sep 2023 15:16:23 -0400 Subject: [PATCH 3/3] Remove state attribute for now --- src/rhode-island.html | 1 - src/state-calculator.ts | 29 +---------------------------- 2 files changed, 1 insertion(+), 29 deletions(-) diff --git a/src/rhode-island.html b/src/rhode-island.html index cecdd4d..4de20a1 100644 --- a/src/rhode-island.html +++ b/src/rhode-island.html @@ -64,7 +64,6 @@

Rewiring America Incentives Calculator

( + return fetchApi( this.apiKey, this.apiHost, '/api/v1/calculator', query, ); - - // If the "state" attribute is set, enforce that some other state's - // incentives aren't shown. But if coverage.state is null, we won't show - // the error: we'll only be showing federal incentives in that case. - if ( - this.state && - response.coverage.state && - response.coverage.state !== this.state - ) { - // Throw to put the task into the ERROR state for rendering. - throw new Error( - `That ZIP code is not in ${STATES[this.state]?.name ?? this.state}.`, - ); - } - - return response; }, onComplete: () => { this.tempState = null;