Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Non-RI experience #23

Merged
merged 3 commits into from
Sep 29, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
174 changes: 126 additions & 48 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 @@ -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);
Expand Down Expand Up @@ -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<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 +215,63 @@ 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(
const response = await fetchApi<APIResponse>(
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.
Copy link
Member Author

Choose a reason for hiding this comment

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

While writing this, I realized it may not be exactly what we want.

As implemented, if you have state="RI" and enter a zip code in any other state, you'll get the generic experience with IRA incentives only, because we have no state-level coverage in any other state. If we were to add coverage in MA, for example, and you entered a MA zip code, you'd get the error.

I think you could make an argument for this behavior: the core thing it's preventing is showing some other state energy office's logo and incentives on your website.

But there are some weird emergent properties:

  • As we add coverage to more states, state-locked embeds will lose functionality. E.g. if MA has no coverage, you can enter a MA zip in a RI-locked embed to see federal incentives. But when we add coverage to MA, entering a MA zip in a RI-locked embed will start causing an error.
  • Suppose we have coverage in MA but not in CT. If you have a RI-locked embed and enter a MA zip, it will say "that ZIP is not in RI", cool. But if you enter a CT zip, you won't get an error, even though that ZIP is not in RI.

If we want the behavior instead to be straightforwardly "you get an error if the ZIP you enter is in a state other than this.state, regardless of coverage", then the coverage field I added to the API is not quite what we want. We'd have to change it to always reflect the location you entered.

Thoughts?

Copy link
Member

Choose a reason for hiding this comment

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

Let's work through this from the desired UX?

  • On our site, I believe we don't ever want to limit the scope of the calculator to Rhode Island (or any other state). In that case, we may want to say on the page somewhere that we only have detailed incentives for X, Y, Z states so far, but that the tool works no matter what?
  • On RI OER's site, I believe they would want to limit the tool to RI zip codes? I think it's reasonable in that case that if you enter a non-RI zip code then it's a field level error in the form (requiring async validation) and it prevents you from submitting. I would prefer that to an error, but a generic error message would also be fine so long as it's recoverable with an RI zip code.

I think for RI OER that's the last thing you said: "you get an error if the ZIP you enter is in a state other than this.state, regardless of coverage"? Let's do that if state is specified, but otherwise have it return whatever we have?

I think this is probably worth workshopping with Spenser (fairly urgently) next week, so we can make sure we've got clearly designed and supported paths for each use-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;
},
});

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 +292,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