diff --git a/components/Appointment.tsx b/components/Appointment.tsx new file mode 100644 index 00000000..afb90cc2 --- /dev/null +++ b/components/Appointment.tsx @@ -0,0 +1,49 @@ +import { useTranslation } from 'next-i18next' + +import { TextLine } from './TextLine' +import { AppointmentContent } from '../types/common' +import { formatAppointmentDate } from '../utils/formatDate' +import { capitalizeFirstLetter } from '../utils/strings' +import { identifyI18nPeriod, samePeriod } from '../utils/timeSlot' + +export interface AppointmentProps extends AppointmentContent { + loading: boolean +} + +export const Appointment: React.FC = ({ loading = false, date, timeSlot }) => { + const { t, i18n } = useTranslation('common') + + let formattedAppointment = '' + + // Format the date portion. + formattedAppointment = capitalizeFirstLetter(formatAppointmentDate(date, i18n.language)) + + // Format the time portion. + if (timeSlot) { + const start = timeSlot.rangeStart + const end = timeSlot.rangeEnd + + // Appointment time slots are formatted using i18n's interpolation feature. + // See https://www.i18next.com/translation-function/interpolation + + // If the times are both am or both pm, the string should look something like: + // ", between 1–3 p.m. Pacific time" + if (samePeriod(start, end)) { + formattedAppointment += t('time.between-range', { range: `${start}–${end}`, ampm: t(identifyI18nPeriod(end)) }) + } + // If one time is am and one time is pm, the string should look something like: + // ", between 10 a.m. and 12 p.m. Pacific time" + else { + formattedAppointment += t('time.between-start-end', { + start: { time: start, ampm: t(identifyI18nPeriod(start)) }, + end: { time: end, ampm: t(identifyI18nPeriod(end)) }, + }) + } + } + + return ( +
+ +
+ ) +} diff --git a/components/ClaimStatus.tsx b/components/ClaimStatus.tsx index 6f547062..3af22779 100644 --- a/components/ClaimStatus.tsx +++ b/components/ClaimStatus.tsx @@ -1,8 +1,8 @@ import { useTranslation } from 'next-i18next' +import { ClaimSummary } from './ClaimSummary' import { NextSteps } from './NextSteps' import { TextLine } from './TextLine' -import { TransLine } from './TransLine' import { ClaimStatusContent } from '../types/common' export interface ClaimStatusProps extends ClaimStatusContent { @@ -26,18 +26,12 @@ export const ClaimStatus: React.FC = ({
-
- {summary.map((paragraph, index) => ( -
- -
- ))} -
+ = ({ + loading = false, + userArrivedFromUioMobile = false, + paragraphs, + appointment, +}) => { + let elements: JSX.Element[] = [] + + // Build generic paragraphs. + elements = paragraphs.map((paragraph, index) => ( +
+ +
+ )) + + // Insert appointment as second element. + // Currently only needed for Scenario 2. + if (appointment) { + const formattedAppointment = ( + + ) + // Splice it in as the second element. + elements.splice(1, 0, formattedAppointment) + } + + return
{elements}
+} diff --git a/public/locales/en/common.json b/public/locales/en/common.json index 37652f94..a580726a 100644 --- a/public/locales/en/common.json +++ b/public/locales/en/common.json @@ -40,6 +40,12 @@ "warning_plural": "To protect your information, you will be logged out in {{count}} minutes if you do not continue.", "button": "Return to UI Home" }, + "time": { + "between-range": ", between {{range}} {{ampm}} Pacific time", + "between-start-end": ", between {{start.time}} {{start.ampm}} and {{end.time}} {{end.ampm}} Pacific time", + "am": "a.m.", + "pm": "p.m." + }, "urls": { "edd": { "ui-certify": "https://edd.ca.gov/Unemployment/certify.htm", diff --git a/public/locales/es/common.json b/public/locales/es/common.json index 2ec509a8..46fa3452 100644 --- a/public/locales/es/common.json +++ b/public/locales/es/common.json @@ -40,6 +40,12 @@ "warning_plural": "Para proteger su información, se cerrará la sesión en {{count}} minutos si no continúa.", "button": "Volver a UI Casa" }, + "time": { + "between-range": ", entre {{range}} {{ampm}} hora del Pacífico", + "between-start-end": ", entre {{start.time}} {{start.ampm}} y {{end.time}} {{end.ampm}} hora del Pacífico", + "am": "a.m.", + "pm": "p.m." + }, "urls": { "edd": { "ui-certify": "https://edd.ca.gov/Unemployment/certify-espanol.htm", diff --git a/stories/Appointment.stories.tsx b/stories/Appointment.stories.tsx new file mode 100644 index 00000000..26c5dc9f --- /dev/null +++ b/stories/Appointment.stories.tsx @@ -0,0 +1,68 @@ +import { Story, Meta } from '@storybook/react' + +import { Appointment as AppointmentComponent, AppointmentProps } from '../components/Appointment' + +export default { + title: 'Component/Atoms/Appointment', + component: AppointmentComponent, + argTypes: { + date: { + description: 'Please ignore the time picker', + control: { + type: 'date', + }, + }, + start: { + name: 'start time', + table: { + type: { + summary: 'number', + }, + }, + control: { + type: 'number', + min: 1, + max: 12, + }, + }, + end: { + name: 'end time', + table: { + type: { + summary: 'number', + }, + }, + control: { + type: 'number', + min: 1, + max: 12, + }, + }, + timeSlot: { + table: { + disable: true, + }, + }, + }, +} as Meta + +interface StoryAppointmentProps extends AppointmentProps { + date: Date + start?: number + end?: number +} + +const Template: Story = ({ ...args }) => { + if (args.start && args.end) { + args.timeSlot = { + rangeStart: args.start, + rangeEnd: args.end, + } + } + return +} + +export const Appointment = Template.bind({}) +Appointment.args = { + date: new Date(), +} diff --git a/stories/ClaimStatus.stories.tsx b/stories/ClaimStatus.stories.tsx index ec6f7d20..d49dc4d4 100644 --- a/stories/ClaimStatus.stories.tsx +++ b/stories/ClaimStatus.stories.tsx @@ -12,7 +12,10 @@ const Template: Story = (args) => = (args) => + +export const ClaimSummary = Template.bind({}) +ClaimSummary.args = { + paragraphs: [ + { + i18nKey: 'claim-status:scenarios.scenario2.summary.0.text', + }, + { + i18nKey: 'claim-status:scenarios.scenario2.summary.1.text', + }, + ], + appointment: { + date: new Date(), + }, +} diff --git a/tests/components/Appointment.test.tsx b/tests/components/Appointment.test.tsx new file mode 100644 index 00000000..77c66b5d --- /dev/null +++ b/tests/components/Appointment.test.tsx @@ -0,0 +1,74 @@ +import renderer, { act } from 'react-test-renderer' + +import i18n from '../jest-i18n' +import { Appointment } from '../../components/Appointment' +import { TimeSlot } from '../../types/common' + +/** + * Helper functions. + */ + +function renderAppointmentComponent(timeSlot: TimeSlot | undefined): string { + // Set a random date in PT time. + const date = new Date('2021-05-05T00:00:00.000-0800') + return renderer.create().toJSON() +} + +/** + * Appointment snapshot tests. + */ + +// Each test case should be: +// [test description, timeSlot.rangeStart, timeSlot.rangeEnd] +const testCases = [ + ['with no time slot, then match the snapshot', null, null], + ['with a morning time slot, then match the snapshot', 8, 10], + ['with an afternoon time slot, then match the snapshot', 1, 3], + ['with a time slot that starts in the morning and ends in the afternoon, then match the snapshot', 8, 3], + ['with a time slot that has a nonsense time range, then match the snapshot', 3, 9], +] + +// Use describe.each() to DRY up the tests. +// See https://jestjs.io/docs/api#describeeachtablename-fn-timeout +describe.each(testCases)('If given an appointment', (description: string, start: number | null, end: number | null) => { + // Construct the timeslot argument. + let timeSlot: TimeSlot | undefined + if (start && end) { + timeSlot = { + rangeStart: start, + rangeEnd: end, + } + } + + // Run through the test cases first in English. + it(`${description}`, () => { + expect(renderAppointmentComponent(timeSlot)).toMatchSnapshot() + }) + + // Run through the test cases again in Spanish. + it(`${description}, in Spanish`, () => { + // Change the language to Spanish. + + // The call to changeLanguage() must be wrapped in act(), otherwise Jest/react + // complains. + // See https://reactjs.org/link/wrap-tests-with-act + // and https://reactjs.org/docs/test-renderer.html#testrendereract + + // Disable floating promises lint check. eslint really wants us to handle the Promise + // returned by changeLanguage(), but it doesn't appear necessary to this test. + // This can be revisited and refactored in the future if necessary. + /* eslint-disable @typescript-eslint/no-floating-promises */ + act(() => { + i18n.changeLanguage('es') + }) + + // Run the actual test. + expect(renderAppointmentComponent(timeSlot)).toMatchSnapshot() + + // Change the language back to English so the first it() renders correctly in English. + act(() => { + i18n.changeLanguage('en') + }) + /* eslint-enable @typescript-eslint/no-floating-promises */ + }) +}) diff --git a/tests/components/ClaimStatus.test.tsx b/tests/components/ClaimStatus.test.tsx index d4980b93..247924cc 100644 --- a/tests/components/ClaimStatus.test.tsx +++ b/tests/components/ClaimStatus.test.tsx @@ -1,8 +1,14 @@ +import MockDate from 'mockdate' import renderer from 'react-test-renderer' + import { ClaimStatus } from '../../components/ClaimStatus' -import getScenarioContent, { ScenarioType } from '../../utils/getScenarioContent' -import apiGatewayStub from '../../utils/apiGatewayStub' import { ClaimStatusContent } from '../../types/common' +import apiGatewayStub from '../../utils/apiGatewayStub' +import getScenarioContent, { ScenarioType } from '../../utils/getScenarioContent' + +/** + * Helper functions. + */ function renderClaimStatusComponent(statusContent: ClaimStatusContent, userArrivedFromUioMobile: boolean): string { return renderer @@ -28,6 +34,10 @@ function testClaimStatus( return renderClaimStatusComponent(scenarioContent.statusContent, userArrivedFromUioMobile) } +/** + * Scenario snapshot tests. + */ + describe('Scenario 1', () => { it('matches when there are weeks to certify, on desktop', () => { expect(testClaimStatus(ScenarioType.Scenario1, true)).toMatchSnapshot() @@ -44,6 +54,42 @@ describe('Scenario 1', () => { }) }) +describe('Scenario 2', () => { + beforeAll(() => { + MockDate.set('2021-05-05') + }) + + it('matches when there are weeks to certify, on desktop', () => { + expect(testClaimStatus(ScenarioType.Scenario2, true)).toMatchSnapshot() + }) + it('matches when there are weeks to certify, on mobile', () => { + expect(testClaimStatus(ScenarioType.Scenario2, true, true)).toMatchSnapshot() + }) + + it("matches when there aren't weeks to certify, on desktop", () => { + expect(testClaimStatus(ScenarioType.Scenario2, false)).toMatchSnapshot() + }) + it("matches when there aren't weeks to certify, on mobile", () => { + expect(testClaimStatus(ScenarioType.Scenario2, false, true)).toMatchSnapshot() + }) +}) + +describe('Scenario 3', () => { + it('matches when there are weeks to certify, on desktop', () => { + expect(testClaimStatus(ScenarioType.Scenario3, true)).toMatchSnapshot() + }) + it('matches when there are weeks to certify, on mobile', () => { + expect(testClaimStatus(ScenarioType.Scenario3, true, true)).toMatchSnapshot() + }) + + it("matches when there aren't weeks to certify, on desktop", () => { + expect(testClaimStatus(ScenarioType.Scenario3, false)).toMatchSnapshot() + }) + it("matches when there aren't weeks to certify, on mobile", () => { + expect(testClaimStatus(ScenarioType.Scenario3, false, true)).toMatchSnapshot() + }) +}) + describe('Scenario 4', () => { it('matches when there are weeks to certify, on desktop', () => { expect(testClaimStatus(ScenarioType.Scenario4, true)).toMatchSnapshot() diff --git a/tests/components/__snapshots__/Appointment.test.tsx.snap b/tests/components/__snapshots__/Appointment.test.tsx.snap new file mode 100644 index 00000000..966d2716 --- /dev/null +++ b/tests/components/__snapshots__/Appointment.test.tsx.snap @@ -0,0 +1,121 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`If given an appointment with a morning time slot, then match the snapshot 1`] = ` +
+ + Wednesday, May 5, 2021, between 8–10 a.m. Pacific time + +
+`; + +exports[`If given an appointment with a morning time slot, then match the snapshot, in Spanish 1`] = ` +
+ + Miércoles, mayo 5, 2021, entre 8–10 a.m. hora del Pacífico + +
+`; + +exports[`If given an appointment with a time slot that has a nonsense time range, then match the snapshot 1`] = ` +
+ + Wednesday, May 5, 2021, between 3 p.m. and 9 a.m. Pacific time + +
+`; + +exports[`If given an appointment with a time slot that has a nonsense time range, then match the snapshot, in Spanish 1`] = ` +
+ + Miércoles, mayo 5, 2021, entre 3 p.m. y 9 a.m. hora del Pacífico + +
+`; + +exports[`If given an appointment with a time slot that starts in the morning and ends in the afternoon, then match the snapshot 1`] = ` +
+ + Wednesday, May 5, 2021, between 8 a.m. and 3 p.m. Pacific time + +
+`; + +exports[`If given an appointment with a time slot that starts in the morning and ends in the afternoon, then match the snapshot, in Spanish 1`] = ` +
+ + Miércoles, mayo 5, 2021, entre 8 a.m. y 3 p.m. hora del Pacífico + +
+`; + +exports[`If given an appointment with an afternoon time slot, then match the snapshot 1`] = ` +
+ + Wednesday, May 5, 2021, between 1–3 p.m. Pacific time + +
+`; + +exports[`If given an appointment with an afternoon time slot, then match the snapshot, in Spanish 1`] = ` +
+ + Miércoles, mayo 5, 2021, entre 1–3 p.m. hora del Pacífico + +
+`; + +exports[`If given an appointment with no time slot, then match the snapshot 1`] = ` +
+ + Wednesday, May 5, 2021 + +
+`; + +exports[`If given an appointment with no time slot, then match the snapshot, in Spanish 1`] = ` +
+ + Miércoles, mayo 5, 2021 + +
+`; diff --git a/tests/components/__snapshots__/ClaimStatus.test.tsx.snap b/tests/components/__snapshots__/ClaimStatus.test.tsx.snap index 9eb29039..cf69c6c7 100644 --- a/tests/components/__snapshots__/ClaimStatus.test.tsx.snap +++ b/tests/components/__snapshots__/ClaimStatus.test.tsx.snap @@ -378,6 +378,978 @@ exports[`Scenario 1 matches when there aren't weeks to certify, on mobile 1`] = `; +exports[`Scenario 2 matches when there are weeks to certify, on desktop 1`] = ` +
+

+ Claim Status +

+
+

+ Pending Eligibility — Phone Interview Scheduled +

+
+
+
+ We identified a potential issue that could make you ineligible for benefits. You have a phone interview scheduled for: +
+
+ + Wednesday, May 12, 2021, between 1–3 p.m. Pacific time + +
+
+ + Important + + : If you miss your phone interview, a decision will be made based on the available facts, which could result in your unemployment benefits being delayed or denied. +
+
+
+

+ Your Next Steps +

+
+
    +
  • + Continue to + + certify for benefits + + while we determine your eligibility. +
  • +
  • + Confirm we have your current phone number. Go to the + + UI Online homepage + + , select + + Profile + + from the main menu, then select + + Contact Information + + . +
  • +
  • + Prepare for the interview. The + <em>Notification of Unemployment Insurance Benefits Eligibility Interview</em> + (DE 4800) includes the questions the interviewer is most likely to ask. +
  • +
  • + If you need to reschedule your interview, select + + Reschedule + + in the Appointments section on your + + UI Online homepage + + . You must reschedule at least one day before the interview. +
  • +
+
+
+
+

+ EDD Next Steps +

+
+
    +
  • + We will call you during your scheduled interview time. Your caller ID may show “St of CA EDD” or the UI Customer Service number 1-800-300-5616. +
      +
    • + If you do not receive a call from the EDD at your scheduled appointment time, we may have canceled your appointment because we confirmed your eligibility or resolved the issue before your interview. +
    • +
    • + If your appointment has been canceled, it will no longer display on your UI Online homepage. +
    • +
    +
  • +
+
+
+
+`; + +exports[`Scenario 2 matches when there are weeks to certify, on mobile 1`] = ` +
+

+ Claim Status +

+
+

+ Pending Eligibility — Phone Interview Scheduled +

+
+
+
+ We identified a potential issue that could make you ineligible for benefits. You have a phone interview scheduled for: +
+
+ + Wednesday, May 12, 2021, between 1–3 p.m. Pacific time + +
+
+ + Important + + : If you miss your phone interview, a decision will be made based on the available facts, which could result in your unemployment benefits being delayed or denied. +
+
+
+

+ Your Next Steps +

+
+
    +
  • + Continue to + + certify for benefits + + while we determine your eligibility. +
  • +
  • + Confirm we have your current phone number. Go to the + + UI Online homepage + + , select + + Profile + + from the main menu, then select + + Contact Information + + . +
  • +
  • + Prepare for the interview. The + <em>Notification of Unemployment Insurance Benefits Eligibility Interview</em> + (DE 4800) includes the questions the interviewer is most likely to ask. +
  • +
  • + If you need to reschedule your interview, select + + Reschedule + + in the Appointments section on your + + UI Online homepage + + . You must reschedule at least one day before the interview. +
  • +
+
+
+
+

+ EDD Next Steps +

+
+
    +
  • + We will call you during your scheduled interview time. Your caller ID may show “St of CA EDD” or the UI Customer Service number 1-800-300-5616. +
      +
    • + If you do not receive a call from the EDD at your scheduled appointment time, we may have canceled your appointment because we confirmed your eligibility or resolved the issue before your interview. +
    • +
    • + If your appointment has been canceled, it will no longer display on your UI Online homepage. +
    • +
    +
  • +
+
+
+
+`; + +exports[`Scenario 2 matches when there aren't weeks to certify, on desktop 1`] = ` +
+

+ Claim Status +

+
+

+ Pending Eligibility — Phone Interview Scheduled +

+
+
+
+ We identified a potential issue that could make you ineligible for benefits. You have a phone interview scheduled for: +
+
+ + Wednesday, May 12, 2021, between 1–3 p.m. Pacific time + +
+
+ + Important + + : If you miss your phone interview, a decision will be made based on the available facts, which could result in your unemployment benefits being delayed or denied. +
+
+
+

+ Your Next Steps +

+
+
    +
  • + Confirm we have your current phone number. Go to the + + UI Online homepage + + , select + + Profile + + from the main menu, then select + + Contact Information + + . +
  • +
  • + Prepare for the interview. The + <em>Notification of Unemployment Insurance Benefits Eligibility Interview</em> + (DE 4800) includes the questions the interviewer is most likely to ask. +
  • +
  • + If you need to reschedule your interview, select + + Reschedule + + in the Appointments section on your + + UI Online homepage + + . You must reschedule at least one day before the interview. +
  • +
+
+
+
+

+ EDD Next Steps +

+
+
    +
  • + We will call you during your scheduled interview time. Your caller ID may show “St of CA EDD” or the UI Customer Service number 1-800-300-5616. +
      +
    • + If you do not receive a call from the EDD at your scheduled appointment time, we may have canceled your appointment because we confirmed your eligibility or resolved the issue before your interview. +
    • +
    • + If your appointment has been canceled, it will no longer display on your UI Online homepage. +
    • +
    +
  • +
+
+
+
+`; + +exports[`Scenario 2 matches when there aren't weeks to certify, on mobile 1`] = ` +
+

+ Claim Status +

+
+

+ Pending Eligibility — Phone Interview Scheduled +

+
+
+
+ We identified a potential issue that could make you ineligible for benefits. You have a phone interview scheduled for: +
+
+ + Wednesday, May 12, 2021, between 1–3 p.m. Pacific time + +
+
+ + Important + + : If you miss your phone interview, a decision will be made based on the available facts, which could result in your unemployment benefits being delayed or denied. +
+
+
+

+ Your Next Steps +

+
+
    +
  • + Confirm we have your current phone number. Go to the + + UI Online homepage + + , select + + Profile + + from the main menu, then select + + Contact Information + + . +
  • +
  • + Prepare for the interview. The + <em>Notification of Unemployment Insurance Benefits Eligibility Interview</em> + (DE 4800) includes the questions the interviewer is most likely to ask. +
  • +
  • + If you need to reschedule your interview, select + + Reschedule + + in the Appointments section on your + + UI Online homepage + + . You must reschedule at least one day before the interview. +
  • +
+
+
+
+

+ EDD Next Steps +

+
+
    +
  • + We will call you during your scheduled interview time. Your caller ID may show “St of CA EDD” or the UI Customer Service number 1-800-300-5616. +
      +
    • + If you do not receive a call from the EDD at your scheduled appointment time, we may have canceled your appointment because we confirmed your eligibility or resolved the issue before your interview. +
    • +
    • + If your appointment has been canceled, it will no longer display on your UI Online homepage. +
    • +
    +
  • +
+
+
+
+`; + +exports[`Scenario 3 matches when there are weeks to certify, on desktop 1`] = ` +
+

+ Claim Status +

+
+

+ Pending Eligibility — Under Review +

+
+
+
+ Your phone interview time has passed and your eligibility for benefits is under review. +
+
+ + Important + + : If you missed your phone interview, a decision will be made based on the available facts, which could result in your unemployment benefits being delayed or denied. +
+
+
+

+ Your Next Steps +

+
+
    +
  • + Continue to + + certify for benefits + + while we determine your eligibility. +
  • +
  • + Allow up to 10 days for the EDD to make a decision. +
  • +
+
+
+
+

+ EDD Next Steps +

+
+
    +
  • + We will determine your eligibility. +
      +
    • + If you are found eligible and no other issues are identified, we will pay you for all pending weeks. +
    • +
    • + If you are found not eligible, we will mail you a + <em>Notice of Determination</em> + (DE 1080CZ) with the reasons you were denied benefits and an + <em>Appeal Form</em> + (DE 1000M). If you disagree with the decision, you have the right to appeal. +
    • +
    • + If you are paid benefits and are later found not eligible, we will also mail you a + <em>Notice of Overpayment</em> + (DE 1444) that explains why you were overpaid. +
    • +
    +
  • +
+
+
+
+`; + +exports[`Scenario 3 matches when there are weeks to certify, on mobile 1`] = ` +
+

+ Claim Status +

+
+

+ Pending Eligibility — Under Review +

+
+
+
+ Your phone interview time has passed and your eligibility for benefits is under review. +
+
+ + Important + + : If you missed your phone interview, a decision will be made based on the available facts, which could result in your unemployment benefits being delayed or denied. +
+
+
+

+ Your Next Steps +

+
+
    +
  • + Continue to + + certify for benefits + + while we determine your eligibility. +
  • +
  • + Allow up to 10 days for the EDD to make a decision. +
  • +
+
+
+
+

+ EDD Next Steps +

+
+
    +
  • + We will determine your eligibility. +
      +
    • + If you are found eligible and no other issues are identified, we will pay you for all pending weeks. +
    • +
    • + If you are found not eligible, we will mail you a + <em>Notice of Determination</em> + (DE 1080CZ) with the reasons you were denied benefits and an + <em>Appeal Form</em> + (DE 1000M). If you disagree with the decision, you have the right to appeal. +
    • +
    • + If you are paid benefits and are later found not eligible, we will also mail you a + <em>Notice of Overpayment</em> + (DE 1444) that explains why you were overpaid. +
    • +
    +
  • +
+
+
+
+`; + +exports[`Scenario 3 matches when there aren't weeks to certify, on desktop 1`] = ` +
+

+ Claim Status +

+
+

+ Pending Eligibility — Under Review +

+
+
+
+ Your phone interview time has passed and your eligibility for benefits is under review. +
+
+ + Important + + : If you missed your phone interview, a decision will be made based on the available facts, which could result in your unemployment benefits being delayed or denied. +
+
+
+

+ Your Next Steps +

+
+
    +
  • + Allow up to 10 days for the EDD to make a decision. +
  • +
+
+
+
+

+ EDD Next Steps +

+
+
    +
  • + We will determine your eligibility. +
      +
    • + If you are found eligible and no other issues are identified, we will pay you for all pending weeks. +
    • +
    • + If you are found not eligible, we will mail you a + <em>Notice of Determination</em> + (DE 1080CZ) with the reasons you were denied benefits and an + <em>Appeal Form</em> + (DE 1000M). If you disagree with the decision, you have the right to appeal. +
    • +
    • + If you are paid benefits and are later found not eligible, we will also mail you a + <em>Notice of Overpayment</em> + (DE 1444) that explains why you were overpaid. +
    • +
    +
  • +
+
+
+
+`; + +exports[`Scenario 3 matches when there aren't weeks to certify, on mobile 1`] = ` +
+

+ Claim Status +

+
+

+ Pending Eligibility — Under Review +

+
+
+
+ Your phone interview time has passed and your eligibility for benefits is under review. +
+
+ + Important + + : If you missed your phone interview, a decision will be made based on the available facts, which could result in your unemployment benefits being delayed or denied. +
+
+
+

+ Your Next Steps +

+
+
    +
  • + Allow up to 10 days for the EDD to make a decision. +
  • +
+
+
+
+

+ EDD Next Steps +

+
+
    +
  • + We will determine your eligibility. +
      +
    • + If you are found eligible and no other issues are identified, we will pay you for all pending weeks. +
    • +
    • + If you are found not eligible, we will mail you a + <em>Notice of Determination</em> + (DE 1080CZ) with the reasons you were denied benefits and an + <em>Appeal Form</em> + (DE 1000M). If you disagree with the decision, you have the right to appeal. +
    • +
    • + If you are paid benefits and are later found not eligible, we will also mail you a + <em>Notice of Overpayment</em> + (DE 1444) that explains why you were overpaid. +
    • +
    +
  • +
+
+
+
+`; + exports[`Scenario 4 matches when there are weeks to certify, on desktop 1`] = `
{ @@ -41,6 +42,17 @@ describe('Past dates: A date is', () => { }) }) +// Test formatAppointmentDate() +describe('Formatting appointments', () => { + it('displays the date in the expected format and timezone', () => { + // Create a date that is midnight UTC + const date = toDate('2021-01-01T00:00:00', { timeZone: 'Europe/London' }) + // Verify that it is formatted correctly for PT + const formattedDate = formatAppointmentDate(date) + expect(formattedDate).toBe('Thursday, December 31, 2020') + }) +}) + // Test formatDate() describe('Formatting dates', () => { it('displays the expected date string', () => { diff --git a/tests/utils/getClaimStatus.test.tsx b/tests/utils/getClaimStatus.test.tsx index d66c6958..c59dfa1e 100644 --- a/tests/utils/getClaimStatus.test.tsx +++ b/tests/utils/getClaimStatus.test.tsx @@ -1,4 +1,8 @@ -import { buildClaimStatusHeading } from '../../utils/getClaimStatus' +import MockDate from 'mockdate' + +import { getPendingDeterminationWithScheduleDate } from '../testHelpers' +import { formatFromApiGateway, parseApiGatewayDate } from '../../utils/formatDate' +import { buildAppointment, buildClaimStatusHeading } from '../../utils/getClaimStatus' import { ScenarioType } from '../../utils/getScenarioContent' import { getNumericEnumKeys } from '../../utils/numericEnum' @@ -12,3 +16,44 @@ describe('The Claim Status heading', () => { } }) }) + +// Test buildAppointment() +describe('An appointment is', () => { + beforeAll(() => { + MockDate.set('2020-01-05') + }) + + it('returned with a time slot if there is a time slot value', () => { + const pendingDetermination = getPendingDeterminationWithScheduleDate(0) + const expectedDate = parseApiGatewayDate(formatFromApiGateway(0)) + + pendingDetermination.timeSlotDesc = '10-12' + const expectedTimeSlot = { + rangeStart: 10, + rangeEnd: 12, + } + + const appointment = buildAppointment(ScenarioType.Scenario2, pendingDetermination) + expect(appointment.date).toStrictEqual(expectedDate) + expect(appointment.timeSlot).toStrictEqual(expectedTimeSlot) + }) + + it('returned with no time slot if there is no time slot value', () => { + const pendingDetermination = getPendingDeterminationWithScheduleDate(0) + const expectedDate = parseApiGatewayDate(formatFromApiGateway(0)) + const appointment = buildAppointment(ScenarioType.Scenario2, pendingDetermination) + expect(appointment.date).toStrictEqual(expectedDate) + expect(appointment.timeSlot).toBe(undefined) + }) + + it('not returned (null) if it is not scenario 2', () => { + const pendingDetermination = getPendingDeterminationWithScheduleDate(0) + const appointment = buildAppointment(ScenarioType.Scenario1, pendingDetermination) + expect(appointment).toBe(null) + }) + + it('not returned (null) if there is no pending determination object (undefined)', () => { + const appointment = buildAppointment(ScenarioType.Scenario2, undefined) + expect(appointment).toBe(null) + }) +}) diff --git a/tests/utils/getScenarioContent.test.tsx b/tests/utils/getScenarioContent.test.tsx index 0d6073a2..08b82545 100644 --- a/tests/utils/getScenarioContent.test.tsx +++ b/tests/utils/getScenarioContent.test.tsx @@ -1,5 +1,6 @@ import MockDate from 'mockdate' +import { getMockPendingDetermination, getPendingDeterminationWithScheduleDate } from '../testHelpers' import { PendingDetermination } from '../../types/common' import apiGatewayStub from '../../utils/apiGatewayStub' import { @@ -10,31 +11,6 @@ import { NonPendingDeterminationValues, ScenarioType, } from '../../utils/getScenarioContent' -import { formatFromApiGateway, getDateWithOffset } from '../../utils/formatDate' - -/** - * Test helpers to create shared mock data. - */ -function getMockPendingDetermination(): PendingDetermination { - const pendingDetermination: PendingDetermination = { - pendingDate: '', - scheduleDate: '', - timeSlotDesc: '', - requestDate: '', - determinationStatus: '', - willCallIndicator: false, - spokenLanguageCode: '', - spokenLanguageDesc: '', - } - return pendingDetermination -} - -function getPendingDeterminationWithScheduleDate(offset = 1): PendingDetermination { - const pendingDetermination = getMockPendingDetermination() - pendingDetermination.determinationStatus = 'Random string' // Can be anything other than one of NonPendingDeterminationValues - pendingDetermination.scheduleDate = formatFromApiGateway(getDateWithOffset(offset)) - return pendingDetermination -} /** * Setup before all tests. diff --git a/tests/utils/timeSlot.test.ts b/tests/utils/timeSlot.test.ts index 9d69b682..083a2513 100644 --- a/tests/utils/timeSlot.test.ts +++ b/tests/utils/timeSlot.test.ts @@ -1,4 +1,4 @@ -import { isFirstTimeSlotEarlier, parseTimeSlot } from '../../utils/timeSlot' +import { convertTo24H, isAm, isFirstTimeSlotEarlier, parseTimeSlot, samePeriod } from '../../utils/timeSlot' // Test parseTimeSlot() describe('A time slot string is', () => { @@ -17,6 +17,16 @@ describe('A time slot string is', () => { expect(badTimeSlot).toBe(null) }) + it('handled if it is a number less than 1', () => { + const badTimeSlot = parseTimeSlot('0-12') + expect(badTimeSlot).toBe(null) + }) + + it('handled if it is a greater than 12', () => { + const badTimeSlot = parseTimeSlot('3-16') + expect(badTimeSlot).toBe(null) + }) + it('handled if the separator is an ndash', () => { const multipleDigits = parseTimeSlot('10–12') expect(multipleDigits.rangeStart).toBe(10) @@ -66,3 +76,46 @@ describe('Comparing time slots results in', () => { expect(result).toBe(false) }) }) + +// Test samePeriod() +describe('Two times are', () => { + it('the same period if they are both AM', () => { + expect(samePeriod(8, 10)).toBe(true) + }) + + it('the same period if they are both PM', () => { + expect(samePeriod(1, 3)).toBe(true) + }) + + it('not the same period if one is AM and one is PM', () => { + expect(samePeriod(8, 3)).toBe(false) + }) +}) + +// Test isAm() +describe('A time is in the period', () => { + it('am if it is equal to or after 8 and before 12', () => { + expect(isAm(8)).toBe(true) + expect(isAm(11)).toBe(true) + }) + + it('pm if it is before 8 and equal to or after 12', () => { + expect(isAm(7)).toBe(false) + expect(isAm(12)).toBe(false) + expect(isAm(1)).toBe(false) + expect(isAm(5)).toBe(false) + }) +}) + +// Test convertTo24H() +describe('Converting a time to 24h time', () => { + it('does not happen for times between 8–12 (inclusive)', () => { + expect(convertTo24H(8)).toBe(8) + expect(convertTo24H(12)).toBe(12) + }) + + it('happens for times between 1–7 and 13 and up', () => { + expect(convertTo24H(1)).toBe(13) + expect(convertTo24H(7)).toBe(19) + }) +}) diff --git a/types/common.tsx b/types/common.tsx index 38e4561e..a3e584d9 100644 --- a/types/common.tsx +++ b/types/common.tsx @@ -53,9 +53,19 @@ export interface TimeSlot { rangeEnd: number } +export interface AppointmentContent { + date: Date + timeSlot?: TimeSlot +} + +export interface ClaimSummaryContent { + paragraphs: TransLineContent[] + appointment: null | AppointmentContent +} + export interface ClaimStatusContent { heading: I18nString - summary: TransLineContent[] + summary: ClaimSummaryContent yourNextSteps: Array eddNextSteps: Array } diff --git a/utils/apiGatewayStub.tsx b/utils/apiGatewayStub.tsx index 1dd668c4..30a7472b 100644 --- a/utils/apiGatewayStub.tsx +++ b/utils/apiGatewayStub.tsx @@ -6,7 +6,7 @@ import { Claim, PendingDetermination } from '../types/common' import { ScenarioType } from '../utils/getScenarioContent' -import { formatFromApiGateway, getDateWithOffset } from '../utils/formatDate' +import { formatFromApiGateway } from '../utils/formatDate' /** * Stub the API gateway response for a given scenario. @@ -52,14 +52,15 @@ export default function apiGatewayStub( case ScenarioType.Scenario2: pendingDetermination.determinationStatus = '' - pendingDetermination.scheduleDate = formatFromApiGateway(getDateWithOffset(7)) + pendingDetermination.scheduleDate = formatFromApiGateway(7) + pendingDetermination.timeSlotDesc = '1-3' claim.pendingDetermination = [pendingDetermination] claim.hasCertificationWeeksAvailable = hasCertificationWeeksAvailable break case ScenarioType.Scenario3: pendingDetermination.determinationStatus = '' - pendingDetermination.scheduleDate = formatFromApiGateway(getDateWithOffset(-7)) + pendingDetermination.scheduleDate = formatFromApiGateway(-7) claim.pendingDetermination = [pendingDetermination] claim.hasCertificationWeeksAvailable = hasCertificationWeeksAvailable break diff --git a/utils/formatDate.ts b/utils/formatDate.ts index 587953d8..ac125d39 100644 --- a/utils/formatDate.ts +++ b/utils/formatDate.ts @@ -12,8 +12,11 @@ * in Pacific Time if there is no timezone provided in the datetime string. */ -import { format, isValid } from 'date-fns' -import { toDate } from 'date-fns-tz' +import { isValid } from 'date-fns' +import { format, toDate, utcToZonedTime } from 'date-fns-tz' +import enUS from 'date-fns/locale/en-US' +import es from 'date-fns/locale/es' + import { ApiGatewayDateString } from '../types/common' const pacificTimeZone = 'America/Los_Angeles' @@ -44,19 +47,17 @@ export function parseApiGatewayDate(dateString: ApiGatewayDateString): Date { /** * Return a string that matches the API gateway format for datetimes. - */ -export function formatFromApiGateway(date: Date): string { - return format(date, apiGatewayFormat) -} - -/** + * * Create a Date object that is offset from today. + * + * Note: This returns a date at either midnight or 1am, depending on whether it is + * currently PST (-8) or PDT (-7). */ -export function getDateWithOffset(daysOffset = 1): Date { +export function formatFromApiGateway(daysOffset = 1): string { const today = new Date() today.setDate(today.getDate() + daysOffset) - today.setHours(0, 0, 0, 0) - return today + today.setUTCHours(8, 0, 0, 0) + return format(today, apiGatewayFormat) } /** @@ -104,6 +105,28 @@ export function isDatePast(date: Date): boolean { return date < today } +/** + * Convert date locale from string. + * + * Falls back to English if an unexpected value is given. + */ +export function convertStringToLocale(localeString: string): Locale { + return localeString === 'es' ? es : enUS +} + +/** + * Format appointment. + */ +export function formatAppointmentDate(date: Date, localeString: string): string { + const dateFormat = 'EEEE, LLLL d, yyyy' + const convertedDate = utcToZonedTime(date, pacificTimeZone) + const formattedDate = format(convertedDate, dateFormat, { + locale: convertStringToLocale(localeString), + timeZone: pacificTimeZone, + }) + return formattedDate +} + /** * Format dates for user-facing display. * diff --git a/utils/getClaimStatus.tsx b/utils/getClaimStatus.tsx index 817c73e1..b8b50469 100644 --- a/utils/getClaimStatus.tsx +++ b/utils/getClaimStatus.tsx @@ -2,9 +2,18 @@ * Utility file to get content for the Claim Status section. */ -import claimStatusJson from '../public/locales/en/claim-status.json' -import { ClaimStatusContent, I18nString, TextOptionalLink, TransLineContent } from '../types/common' +import { parseApiGatewayDate } from './formatDate' import { ScenarioType } from './getScenarioContent' +import { parseTimeSlot } from './timeSlot' +import claimStatusJson from '../public/locales/en/claim-status.json' +import { + AppointmentContent, + ClaimStatusContent, + I18nString, + PendingDetermination, + TextOptionalLink, + TransLineContent, +} from '../types/common' type StepType = 'your-next-steps' | 'edd-next-steps' @@ -58,10 +67,40 @@ function buildI18nKey(keys: string[]): I18nString { return 'claim-status:' + keys.join('.') + '.text' } +/** + * Construct Scenario 2 appointment date & time. + * + * Expects a valid pendingDetermination.scheduleDate. + * Will validate the pendingDetermination.timeSlotDesc. + */ +export function buildAppointment( + scenarioType: ScenarioType, + pendingDetermination: PendingDetermination | undefined, +): AppointmentContent | null { + // Return an appointment only if: + // - this is scenario 2 + // - AND there is a pendingDetermination object + if (scenarioType === ScenarioType.Scenario2 && pendingDetermination) { + const parsedDate = parseApiGatewayDate(pendingDetermination.scheduleDate) + const appointment: AppointmentContent = { + date: parsedDate, + } + + const timeSlot = parseTimeSlot(pendingDetermination.timeSlotDesc) + if (timeSlot) { + appointment.timeSlot = timeSlot + } + + return appointment + } else { + return null + } +} + /** * Get Claim Status summary. */ -export function buildClaimStatusSummary( +export function buildSummaryParagraphs( scenarioObject: ClaimStatusScenarioJson, scenarioString: string, ): TransLineContent[] { @@ -119,7 +158,11 @@ export function buildNextSteps( /** * Get combined Claim Status content. */ -export default function getClaimStatus(scenarioType: ScenarioType, continueCertifying: boolean): ClaimStatusContent { +export default function getClaimStatus( + scenarioType: ScenarioType, + continueCertifying: boolean, + pendingDetermination: PendingDetermination | undefined, +): ClaimStatusContent { // Explicitly cast the scenario string (e.g. scenario1, scenario2) into the union of literal types // expected by Typescript. scenarioString must be one of the key names in claimStatusJson.scenarios // or this won't compile. For a very good explanation of `keyof typeof` Typescript's and union of @@ -131,7 +174,10 @@ export default function getClaimStatus(scenarioType: ScenarioType, continueCerti return { heading: buildClaimStatusHeading(scenarioType), - summary: buildClaimStatusSummary(scenarioObject, scenarioString), + summary: { + paragraphs: buildSummaryParagraphs(scenarioObject, scenarioString), + appointment: buildAppointment(scenarioType, pendingDetermination), + }, yourNextSteps: buildNextSteps(scenarioObject, scenarioString, 'your-next-steps', continueCertifying), eddNextSteps: buildNextSteps(scenarioObject, scenarioString, 'edd-next-steps'), } diff --git a/utils/getScenarioContent.tsx b/utils/getScenarioContent.tsx index 080fa5f9..c2af17d7 100644 --- a/utils/getScenarioContent.tsx +++ b/utils/getScenarioContent.tsx @@ -212,7 +212,11 @@ export default function getScenarioContent(claimData: Claim): ScenarioContent { const scenarioType = scenarioTypeObject.scenarioType // Construct claim status content. - const statusContent = getClaimStatus(scenarioType, continueCertifying(scenarioType, claimData)) + const statusContent = getClaimStatus( + scenarioType, + continueCertifying(scenarioType, claimData), + scenarioTypeObject.pendingDetermination, + ) // Construct claim details content. if (!claimData.claimDetails) { diff --git a/utils/strings.ts b/utils/strings.ts new file mode 100644 index 00000000..dedfe1e5 --- /dev/null +++ b/utils/strings.ts @@ -0,0 +1,7 @@ +/** + * Utility file for helper functions related to strings. + */ + +export function capitalizeFirstLetter(string: string): string { + return string.charAt(0).toUpperCase() + string.slice(1) +} diff --git a/utils/timeSlot.ts b/utils/timeSlot.ts index 7fc271f0..a9a1986b 100644 --- a/utils/timeSlot.ts +++ b/utils/timeSlot.ts @@ -7,7 +7,16 @@ * - etc */ -import { TimeSlot } from '../types/common' +import { I18nString, TimeSlot } from '../types/common' + +/** + * Validate times. + * + * @TODO: Log if we receive a time that is outside this range. + */ +export function validTime(time: number): boolean { + return time >= 1 && time <= 12 +} /** * Parse a time slot from the API gateway. @@ -15,30 +24,64 @@ import { TimeSlot } from '../types/common' export function parseTimeSlot(timeSlot: string): TimeSlot | null { // Time slots are expected to be in the format 10-12, // where the dash can either be a hyphen (-) or an ndash (–) or an mdash (—). + let result: TimeSlot | null = null + const match = /(\d+)[-–—](\d+)/.exec(timeSlot) if (match) { - const formattedTimeSlot: TimeSlot = { - rangeStart: parseInt(match[1]), - rangeEnd: parseInt(match[2]), + const start = parseInt(match[1]) + const end = parseInt(match[2]) + + if (validTime(start) && validTime(end)) { + result = { + rangeStart: start, + rangeEnd: end, + } } - return formattedTimeSlot } // If the arg does not match the regex, return null. - else { - return null - } + return result } /** - * Convert 12 hour time into 24 hour time. + * Identify whether a time is AM or PM. * - * Assume that any time earlier than 8 is actually PM. + * AM = 8 (inclusive) up to 12 (not inclusive) */ -function convertTo24H(time: number): number { - if (time < 8) { - return time + 12 +export function isAm(time: number): boolean { + return time < 12 && time >= 8 +} + +/** + * Return the I18nString for AM/PM. + */ +export function identifyI18nPeriod(time: number): I18nString { + if (isAm(time)) { + return 'time.am' } else { + return 'time.pm' + } +} + +/** + * Identify whether two times are both am, pm, or different. + * + * Note: "period" is what Unicode calls AM/PM. + * See https://unicode.org/reports/tr35/tr35-6.html#Date_Format_Patterns + */ +export function samePeriod(first: number, second: number): boolean { + const bothAm = isAm(first) && isAm(second) + const bothPm = !isAm(first) && !isAm(second) + return bothAm || bothPm +} + +/** + * Convert 12 hour time into 24 hour time. + */ +export function convertTo24H(time: number): number { + if (isAm(time) || time === 12) { return time + } else { + return time + 12 } }