Skip to content

Commit

Permalink
Use start dates from API for "expected in" message (#151)
Browse files Browse the repository at this point in the history
## Description

Having finally gotten our dates story straight on the API side, we can
now just use them here in the frontend.

The old frontend just gets years from v0, so it displays those as-is
if they're in the future. The new frontend determines whether the
pseudo-ISO-8601 date is in the future, and displays the year from it
if so. (In the interest of time, I didn't want to go further in this
PR by rendering "2024H2" as "late 2024" or whatever, though we can
easily go that route later.)

I also added Jest setup so I could write a unit test for the date
logic. I don't know how much other unit testing we'll need in this
codebase, but now the infrastructure is there for when we want it.

## Test Plan

New unit test for the date parsing. (Useful, because JS's Date class
is a nightmare to work with!)

Set my local API instance as the API host (`api-host` attribute on the
calculator component). Look at the old frontend and make sure all the
IRA rebates show up with "2025". Look at the new frontend with a
low-ish income, and make sure the IRA rebates show up with "Expected
in 2025", and they are shown after all other incentives in their item
category.
  • Loading branch information
oyamauchi authored Mar 26, 2024
1 parent d24fe5c commit ba30bd0
Show file tree
Hide file tree
Showing 11 changed files with 1,627 additions and 57 deletions.
1 change: 1 addition & 0 deletions .github/workflows/node.js.yml
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ jobs:
cache: 'yarn'
- run: yarn --frozen-lockfile
- run: yarn lint
- run: yarn test
- name: Make sure strings are up to date
run: |
yarn strings:extract
Expand Down
13 changes: 13 additions & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
/**
* For a detailed explanation regarding each configuration property, visit:
* https://jestjs.io/docs/configuration
*/

import type { Config } from 'jest';

const config: Config = {
// A preset that is used as a base for Jest's configuration
preset: 'ts-jest',
};

export default config;
5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"lint": "tsc --noEmit && prettier --check . && eslint .",
"prepare": "husky install",
"strings:extract": "lit-localize extract",
"strings:build": "lit-localize build && rexreplace '@lit/localize' '../str' src/i18n/strings/*.ts && prettier --write src/i18n"
"strings:build": "lit-localize build && rexreplace '@lit/localize' '../str' src/i18n/strings/*.ts && prettier --write src/i18n",
"test": "jest"
},
"browserslist": {
"production": [
Expand Down Expand Up @@ -55,6 +56,7 @@
"cypress-axe": "^1.5.0",
"eslint": "^8.45.0",
"husky": "^8.0.3",
"jest": "^29.7.0",
"lint-staged": "^14.0.1",
"parcel": "^2.10.2",
"postcss": "^8.4.21",
Expand All @@ -65,6 +67,7 @@
"process": "^0.11.10",
"rexreplace": "^7.1.3",
"tailwindcss": "^3.3.6",
"ts-jest": "^29.1.2",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
},
Expand Down
4 changes: 2 additions & 2 deletions src/api/calculator-types-v1.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,8 @@ export interface Incentive {
more_info_url?: string;
item: Item;
amount: Amount;
start_date?: number | string;
end_date?: number | string;
start_date?: string;
end_date?: string;
short_description?: string;

eligible: boolean;
Expand Down
42 changes: 42 additions & 0 deletions src/api/dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* Returns whether the given API date is unambiguously in the future, relative
* to "now". (start_date and end_date in the API can represent a range of dates
* rather than just a single one.)
*
* The [now] param will be interpreted in local time.
*/
export function isInFuture(apiDate: string, now: Date): boolean {
// Construct the timestamp at UTC midnight on the earliest possible day that
// the API date refers to.
let earliestPossibleInstant: number;
const match = apiDate.match(/^(\d{4})(Q|H)(\d)$/);
if (match) {
// Quarter or half
const parsedYear = parseInt(match[1]);
const halfOrQuarterNumber = parseInt(match[3]);
// Date.getMonth is 0-based, so the first month of Q1 is 0, first month of
// Q2 is 3, and so on.
const firstPossibleMonth =
match[2] === 'Q'
? (halfOrQuarterNumber - 1) * 3
: (halfOrQuarterNumber - 1) * 6;
earliestPossibleInstant = Date.UTC(parsedYear, firstPossibleMonth, 1);
} else {
// It's year, year-month, or year-month-day.
const parts = apiDate.split('-');
const parsedYear = parseInt(parts[0]);
const parsedMonth = parts[1] ? parseInt(parts[1]) - 1 : 0; // 0-based month
const parsedDay = parts[2] ? parseInt(parts[2]) : 1;
earliestPossibleInstant = Date.UTC(parsedYear, parsedMonth, parsedDay);
}

// Construct the timestamp at UTC midnight, with the Y/M/D of now, as
// interpreted in local time. This avoids timezone issues by only comparing
// timestamps with the same time and timezone component.
const utcNow = Date.UTC(now.getFullYear(), now.getMonth(), now.getDate());
return utcNow < earliestPossibleInstant;
}

export function getYear(apiDate: string): number {
return parseInt(apiDate.slice(0, 4));
}
2 changes: 1 addition & 1 deletion src/i18n/strings/es.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,11 @@ export const templates = {
s8194d17164cbd6de: `Florida`,
s81aa671e64f2010e: `Dakota del Norte`,
s82397872ac9bddcf: `Tamaño del hogar`,
s863623721dcb0072: str`Esperado en ${0}`,
s8b29a87eb1bdd138: `Otros incentivos disponibles para usted`,
s8e35f6b4e6e0adb9: `Pensilvania`,
s8e7e52ad112342ab: `un cargador de vehículos eléctricos`,
s8fd029fdcc452602: `Ingresa su código postal para seleccionar una empresa de servicios eléctricos.`,
s8fd8a424edc336b4: `Esperado en 2024`,
s912b944fa287f7d0: `Nuevo Hampshire`,
s9afee25dcf31efc1: `un vehículo eléctrico nuevo`,
s9b0d347a81e8f0a3: `Oklahoma`,
Expand Down
23 changes: 5 additions & 18 deletions src/incentive-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,6 @@ import {
AmountType,
ICalculatedIncentiveResults,
IIncentiveRecord,
IncentiveType,
} from './calculator-types';
import { CalculatorTableIcon } from './icons';
import { tableStyles } from './styles';
Expand Down Expand Up @@ -84,21 +83,11 @@ function formatAmount(amount: number, amount_type: AmountType) {
}
}

function formatStartDate(start_date: number, type: IncentiveType) {
if (type === 'pos_rebate') {
// we hard-code 2024 for rebates because their availability is not yet certain
// FIXME: we should model the uncertainty explicitly rather than leaving it to frontend code
return '2024';
} else if (type === 'tax_credit') {
// for tax credits, the year is safe to use as data:
const thisYear = new Date().getFullYear();
if (start_date <= thisYear) {
return <em>Available Now!</em>;
} else {
return start_date.toString();
}
function formatStartDate(start_date: number) {
const thisYear = new Date().getFullYear();
if (start_date <= thisYear) {
return <em>Available Now!</em>;
} else {
// while we technically don't expect another IncentiveType, fall back to date here if needed:
return start_date.toString();
}
}
Expand All @@ -120,9 +109,7 @@ const renderDetailRow = (key: number, incentive: IIncentiveRecord) => (
<td className="cell--right">
{formatAmount(incentive.amount, incentive.amount_type)}
</td>
<td className="cell--right">
{formatStartDate(incentive.start_date, incentive.type)}
</td>
<td className="cell--right">{formatStartDate(incentive.start_date)}</td>
<td className="cell--right hide-on-mobile">
<a
className="more-info-button"
Expand Down
29 changes: 16 additions & 13 deletions src/state-incentive-details.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
Incentive,
ItemType,
} from './api/calculator-types-v1';
import { getYear, isInFuture } from './api/dates';
import { PrimaryButton, TextButton } from './buttons';
import { Card } from './card';
import { TextInput } from './components/text-input';
Expand Down Expand Up @@ -145,11 +146,10 @@ const formatIncentiveType = (incentive: Incentive, msg: MsgFn) =>
? msg('Performance rebate')
: msg('Incentive');

/** We're special-casing these to hardcode an availability start date */
const isIRARebate = (incentive: Incentive) =>
incentive.authority_type === 'federal' &&
(incentive.payment_methods[0] === 'pos_rebate' ||
incentive.payment_methods[0] === 'performance_rebate');
const getStartYearIfInFuture = (incentive: Incentive) =>
incentive.start_date && isInFuture(incentive.start_date, new Date())
? getYear(incentive.start_date)
: null;

const Chip: FC<PropsWithChildren<{ isWarning?: boolean }>> = ({
isWarning,
Expand Down Expand Up @@ -220,6 +220,7 @@ const IncentiveCard: FC<{ incentive: Incentive }> = ({ incentive }) => {
</>,
]
: [incentive.item.url, msg('Learn more')];
const futureStartYear = getStartYearIfInFuture(incentive);
return (
<Card>
<div className="flex flex-col gap-4 h-full">
Expand All @@ -234,12 +235,11 @@ const IncentiveCard: FC<{ incentive: Incentive }> = ({ incentive }) => {
<div className="text-grey-400 leading-normal">
{incentive.short_description}
</div>
{
/** TODO get real dates in the data! */
isIRARebate(incentive) && (
<Chip isWarning={true}>{msg('Expected in 2024')}</Chip>
)
}
{futureStartYear && (
<Chip isWarning={true}>
{msg(str`Expected in ${futureStartYear}`)}
</Chip>
)}
<LinkButton href={buttonUrl}>{buttonContent}</LinkButton>
</div>
</Card>
Expand Down Expand Up @@ -337,8 +337,11 @@ const renderNoResults = (emailSubmitter: ((email: string) => void) | null) => {
const renderCardCollection = (incentives: Incentive[]) => (
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 items-start">
{incentives
// Put IRA rebates after everything else
.sort((a, b) => +isIRARebate(a) - +isIRARebate(b))
// Sort incentives that haven't started yet at the end
.sort(
(a, b) =>
(getStartYearIfInFuture(a) ?? 0) - (getStartYearIfInFuture(b) ?? 0),
)
.map((incentive, index) => (
<IncentiveCard key={index} incentive={incentive} />
))}
Expand Down
99 changes: 99 additions & 0 deletions test/api/dates.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
import { describe, expect, test } from '@jest/globals';
import { isInFuture } from '../../src/api/dates';

/** Turn an ISO 8601 date string into a Date in the local timezone. */
function localDate(dateStr: string): Date {
// If you just pass a date to the Date constructor, it will get a time of
// midnight UTC, but isInFuture looks at it in local time. So construct a
// Date of the current instant and replace its year/month/day components, so
// that isInFuture will see the same year/month/day we see here.
const date = new Date();
const [year, month, day] = dateStr.split('-');
date.setFullYear(parseInt(year));
date.setMonth(parseInt(month) - 1); // Date's months are 0-based
date.setDate(parseInt(day));
return date;
}

describe('quarters', () => {
test.each([
{ date: '2024Q4', now: '2024-09-30' },
{ date: '2025Q1', now: '2024-12-31' },
])('$date should be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(true);
});
test.each([
{ date: '2023Q4', now: '2024-01-01' },
{ date: '2024Q4', now: '2024-10-01' },
{ date: '2024Q1', now: '2024-10-01' },
])('$date should not be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(false);
});
});

describe('halves', () => {
test.each([
{ date: '2024H2', now: '2024-01-30' },
{ date: '2024H2', now: '2024-06-30' },
{ date: '2025H1', now: '2024-06-30' },
])('$date should be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(true);
});
test.each([
{ date: '2024H2', now: '2024-07-01' },
{ date: '2024H2', now: '2024-12-31' },
{ date: '2024H1', now: '2024-06-30' },
])('$date should not be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(false);
});
});

describe('years', () => {
test.each([
{ date: '2025', now: '2024-01-01' },
{ date: '2025', now: '2024-12-31' },
])('$date should be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(true);
});
test.each([
{ date: '2023', now: '2024-01-01' },
{ date: '2024', now: '2024-01-01' },
{ date: '2024', now: '2024-12-31' },
])('$date should not be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(false);
});
});

describe('year-months', () => {
test.each([
{ date: '2024-12', now: '2024-06-30' },
{ date: '2024-12', now: '2024-11-30' },
{ date: '2025-01', now: '2024-12-31' },
])('$date should be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(true);
});
test.each([
{ date: '2023-12', now: '2024-01-01' },
{ date: '2024-01', now: '2024-01-01' },
{ date: '2024-01', now: '2024-12-31' },
])('$date should not be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(false);
});
});

describe('year-month-days', () => {
test.each([
{ date: '2025-01-01', now: '2024-12-31' },
{ date: '2024-01-02', now: '2024-01-01' },
{ date: '2024-02-02', now: '2024-01-02' },
])('$date should be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(true);
});
test.each([
{ date: '2023-12-31', now: '2024-01-01' },
{ date: '2024-01-02', now: '2024-01-02' },
{ date: '2024-01-01', now: '2024-02-02' },
])('$date should not be in future on $now', ({ date, now }) => {
expect(isInFuture(date, localDate(now))).toBe(false);
});
});
8 changes: 4 additions & 4 deletions translations/es.xlf
Original file line number Diff line number Diff line change
Expand Up @@ -300,10 +300,6 @@
<source>Back to calculator</source>
<target>Volver a la calculadora</target>
</trans-unit>
<trans-unit id="s8fd8a424edc336b4">
<source>Expected in 2024</source>
<target>Esperado en 2024</target>
</trans-unit>
<trans-unit id="s255857544a9d5ec0">
<source>Reset</source>
<target>Reiniciar</target>
Expand Down Expand Up @@ -580,6 +576,10 @@
<note from="lit-localize">followed by authority logos</note>
<target>Presentado en colaboración con</target>
</trans-unit>
<trans-unit id="s863623721dcb0072">
<source>Expected in <x id="0" equiv-text="${futureStartYear}"/></source>
<target>Esperado en <x id="0" equiv-text="${futureStartYear}"/></target>
</trans-unit>
</body>
</file>
</xliff>
Loading

0 comments on commit ba30bd0

Please sign in to comment.