diff --git a/.gitignore b/.gitignore index 3d841da..fe9e8da 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ node_modules _site *.local data +local diff --git a/src/components/daily-registrations-report.test.tsx b/src/components/daily-registrations-report.test.tsx new file mode 100644 index 0000000..b974e06 --- /dev/null +++ b/src/components/daily-registrations-report.test.tsx @@ -0,0 +1,39 @@ +import { expect } from "chai"; +import { yearMonthDayParse } from "../formats"; +import { ProcessedResult } from "../models/daily-registrations-report-data"; +import { tabulate } from "./daily-registrations-report"; + +describe("DailyRegistrationsReport", () => { + describe("#tabulate", () => { + it("renders a table", () => { + const results: ProcessedResult[] = [ + { + date: yearMonthDayParse("2020-01-01"), + totalUsers: 5, + fullyRegisteredUsers: 1, + totalUsersCumulative: 10, + fullyRegisteredUsersCumulative: 2, + }, + { + date: yearMonthDayParse("2020-01-02"), + totalUsers: 6, + fullyRegisteredUsers: 2, + totalUsersCumulative: 17, + fullyRegisteredUsersCumulative: 4, + }, + ]; + + const table = tabulate(results); + + expect(table).to.deep.equal({ + header: ["", "2020-01-01", "2020-01-02"], + body: [ + ["New Users", 5, 6], + ["New Fully Registered Users", 1, 2], + ["Cumulative Users", 10, 17], + ["Cumulative Fully Registered Users", 2, 4], + ], + }); + }); + }); +}); diff --git a/src/components/daily-registrations-report.tsx b/src/components/daily-registrations-report.tsx new file mode 100644 index 0000000..524e4c6 --- /dev/null +++ b/src/components/daily-registrations-report.tsx @@ -0,0 +1,125 @@ +import { VNode } from "preact"; +import { useQuery } from "preact-fetching"; +import { useRef, useState, useContext } from "preact/hooks"; +import * as Plot from "@observablehq/plot"; +import Markdown from "preact-markdown"; +import { ReportFilterContext } from "../contexts/report-filter-context"; +import useResizeListener from "../hooks/resize-listener"; +import { + DataType, + loadData, + ProcessedResult, + ProcessedRenderableData, + toRenderableData, +} from "../models/daily-registrations-report-data"; +import PlotComponent from "./plot"; +import { formatSIDropTrailingZeroes, formatWithCommas, yearMonthDayFormat } from "../formats"; +import Table, { TableData } from "./table"; +import Accordion from "./accordion"; + +function plot({ data, width }: { data: ProcessedRenderableData[]; width?: number }): HTMLElement { + return Plot.plot({ + color: { + legend: true, + type: "ordinal", + tickFormat: (type: DataType): string => { + switch (type) { + case DataType.TOTAL_USERS: + case DataType.TOTAL_USERS_CUMULATIVE: + return "Total Users"; + case DataType.FULLY_REGISTERED_USERS: + case DataType.FULLY_REGISTERED_USERS_CUMULATIVE: + return "Full Registered Users"; + default: + throw new Error(`Unknown type ${type}`); + } + }, + }, + marks: [ + Plot.ruleY([0]), + Plot.line(data, { + x: "date", + y: "value", + z: "type", + stroke: "type", + }), + ], + width, + y: { + tickFormat: formatSIDropTrailingZeroes, + }, + }); +} + +/** + * Assumes that results is pre-sorted, pre-filtered + */ +function tabulate(results: ProcessedResult[]): TableData { + return { + header: ["", ...results.map(({ date }) => yearMonthDayFormat(date))], + body: [ + ["New Users", ...results.map(({ totalUsers }) => totalUsers)], + [ + "New Fully Registered Users", + ...results.map(({ fullyRegisteredUsers }) => fullyRegisteredUsers), + ], + ["Cumulative Users", ...results.map(({ totalUsersCumulative }) => totalUsersCumulative)], + [ + "Cumulative Fully Registered Users", + ...results.map(({ fullyRegisteredUsersCumulative }) => fullyRegisteredUsersCumulative), + ], + ], + }; +} + +function DailyRegistrationsReport(): VNode { + const ref = useRef(null as HTMLDivElement | null); + const [width, setWidth] = useState(undefined as number | undefined); + useResizeListener(() => setWidth(ref.current?.offsetWidth)); + const { start, finish, env, cumulative } = useContext(ReportFilterContext); + + const { data } = useQuery(`daily-registrations-${finish.valueOf()}`, () => loadData(finish, env)); + + const filteredData = + data && + toRenderableData(data).filter(({ type }) => { + switch (type) { + case DataType.TOTAL_USERS: + case DataType.FULLY_REGISTERED_USERS: + return !cumulative; + case DataType.TOTAL_USERS_CUMULATIVE: + case DataType.FULLY_REGISTERED_USERS_CUMULATIVE: + return !!cumulative; + default: + throw new Error(`Unknown data type ${type}`); + } + }); + + const windowedData = data && data.filter(({ date }) => start <= date && date <= finish); + + return ( +
+ + + + {filteredData && ( + plot({ data: filteredData, width })} + inputs={[data, width, cumulative]} + /> + )} + {windowedData && } + + ); +} + +export default DailyRegistrationsReport; +export { tabulate }; diff --git a/src/components/report-filter-controls.tsx b/src/components/report-filter-controls.tsx index b6adfa5..ca3aa19 100644 --- a/src/components/report-filter-controls.tsx +++ b/src/components/report-filter-controls.tsx @@ -23,6 +23,7 @@ enum Control { AGENCY = "agency", BY_AGENCY = "by_agency", TIME_BUCKET = "time_bucket", + CUMULATIVE = "cumulative", } interface ReportFilterControlsProps { @@ -41,6 +42,7 @@ function ReportFilterControls({ controls }: ReportFilterControlsProps): VNode { byAgency, extra, timeBucket, + cumulative, setParameters, } = useContext(ReportFilterContext); const { agencies } = useContext(AgenciesContext); @@ -68,254 +70,285 @@ function ReportFilterControls({ controls }: ReportFilterControlsProps): VNode { } return ( - -
-
-
-
- Time Range -
-
- -
-
- -
-
-
-
- -
-
- -
-
-
- {controls?.includes(Control.TIME_BUCKET) && ( -
- Time Bucket -
- - -
-
- - -
-
- )} - {(controls?.includes(Control.AGENCY) || agency) && ( -
- - Agency - - -
- )} -
-
- {controls?.includes(Control.IAL) && ( -
- Identity -
- - -
-
- - -
-
- )} - {controls?.includes(Control.FUNNEL_MODE) && ( -
- Funnel Mode -
- - - - The funnel starts at the welcome step - -
-
+ +
+
+
+
+ Time Range +
+
+
-
- )} - {controls?.includes(Control.SCALE) && ( -
- Scale -
- - -
-
- - -
-
- )} - {controls?.includes(Control.BY_AGENCY) && ( -
- Break out by Agency -
- - -
-
+ +
+
+
-
- )} -
-
-
-
-
+
+
+ +
+
+ +
+
+
+ {controls?.includes(Control.TIME_BUCKET) && ( +
+ Time Bucket +
+ + +
+
+ + +
+
+ )} + {(controls?.includes(Control.AGENCY) || agency) && ( +
+ + Agency + + +
+ )} +
+
+ {controls?.includes(Control.IAL) && ( +
+ Identity +
+ + +
+
+ + +
+
+ )} + {controls?.includes(Control.FUNNEL_MODE) && ( +
+ Funnel Mode +
+ + + + The funnel starts at the welcome step + +
+
+ + + + The funnel starts at the image submit step + +
+
+ )} + {controls?.includes(Control.SCALE) && ( +
+ Scale +
+ + +
+
+ + +
+
+ )} + {controls?.includes(Control.BY_AGENCY) && ( +
+ Break out by Agency +
+ + +
+
+ + +
+
+ )} + {controls?.includes(Control.CUMULATIVE) && ( +
+ Cumulative +
+ + +
+
+ + +
+
+ )} +
+
+
+
+
- {env !== DEFAULT_ENV && } - {extra && } - +
+ {env !== DEFAULT_ENV && } + {extra && } + ); } diff --git a/src/contexts/report-filter-context.tsx b/src/contexts/report-filter-context.tsx index 1c990e6..764bb5b 100644 --- a/src/contexts/report-filter-context.tsx +++ b/src/contexts/report-filter-context.tsx @@ -39,6 +39,7 @@ interface ReportFilterContextValues { byAgency: boolean; extra: boolean; timeBucket?: TimeBucket + cumulative?: boolean setParameters: (params: Record) => void; } @@ -69,6 +70,7 @@ const ReportFilterContext = createContext({ byAgency: false, extra: false, timeBucket: DEFAULT_TIME_BUCKET, + cumulative: true, } as ReportFilterContextValues); type ReportFilterContextProviderProps = Omit; diff --git a/src/models/daily-registrations-report-data.test.ts b/src/models/daily-registrations-report-data.test.ts new file mode 100644 index 0000000..8581504 --- /dev/null +++ b/src/models/daily-registrations-report-data.test.ts @@ -0,0 +1,79 @@ +import { expect } from "chai"; +import fetchMock from "fetch-mock"; +import { yearMonthDayParse } from "../formats"; +import { DataType, loadData, toRenderableData } from "./daily-registrations-report-data"; + +describe("DailyRegistrationsReportData", () => { + describe("#loadData", () => { + it("loads data and processes it", () => { + const fetch = fetchMock + .sandbox() + .get("/local/daily-registrations-report/2021/2021-01-02.daily-registrations-report.json", { + finish: "2020-01-03", + results: [ + { date: "2020-01-01", total_users: 2, fully_registered_users: 1 }, + { date: "2020-01-02", total_users: 20, fully_registered_users: 10 }, + ], + }); + + return loadData(yearMonthDayParse("2021-01-02"), "local", fetch as typeof window.fetch).then( + (processed) => { + expect(processed).to.have.lengthOf(2); + expect(processed).to.deep.equal([ + { + date: yearMonthDayParse("2020-01-01"), + totalUsers: 2, + fullyRegisteredUsers: 1, + totalUsersCumulative: 2, + fullyRegisteredUsersCumulative: 1, + }, + { + date: yearMonthDayParse("2020-01-02"), + totalUsers: 20, + fullyRegisteredUsers: 10, + totalUsersCumulative: 22, + fullyRegisteredUsersCumulative: 11, + }, + ]); + } + ); + }); + }); + + describe("toRenderableData", () => { + it("breaks into elements with value and type", () => { + const renderable = toRenderableData([ + { + date: yearMonthDayParse("2020-01-01"), + totalUsers: 1, + fullyRegisteredUsers: 2, + totalUsersCumulative: 3, + fullyRegisteredUsersCumulative: 4, + }, + ]); + + expect(renderable).to.have.deep.members([ + { + date: yearMonthDayParse("2020-01-01"), + type: DataType.TOTAL_USERS, + value: 1, + }, + { + date: yearMonthDayParse("2020-01-01"), + type: DataType.FULLY_REGISTERED_USERS, + value: 2, + }, + { + date: yearMonthDayParse("2020-01-01"), + type: DataType.TOTAL_USERS_CUMULATIVE, + value: 3, + }, + { + date: yearMonthDayParse("2020-01-01"), + type: DataType.FULLY_REGISTERED_USERS_CUMULATIVE, + value: 4, + }, + ]); + }); + }); +}); diff --git a/src/models/daily-registrations-report-data.ts b/src/models/daily-registrations-report-data.ts new file mode 100644 index 0000000..ac7a642 --- /dev/null +++ b/src/models/daily-registrations-report-data.ts @@ -0,0 +1,91 @@ +import { ascending } from "d3-array"; +import { path as reportPath } from "./api-path"; + +interface Result { + /** + * ISO8601 date-only string (YYYY-MM-DD) + */ + date: string; + total_users: number; + fully_registered_users: number; +} + +interface ProcessedResult { + date: Date; + totalUsers: number; + totalUsersCumulative: number; + fullyRegisteredUsers: number; + fullyRegisteredUsersCumulative: number; +} + +enum DataType { + TOTAL_USERS, + TOTAL_USERS_CUMULATIVE, + FULLY_REGISTERED_USERS, + FULLY_REGISTERED_USERS_CUMULATIVE, +} + +interface ProcessedRenderableData { + date: Date; + value: number; + type: DataType; +} + +interface DailyRegistrationsReportData { + results: Result[]; + + /** + * ISO8601 string + */ + finish: string; +} + +function process({ results }: DailyRegistrationsReportData): ProcessedResult[] { + let totalUsersCumulative = 0; + let fullyRegisteredUsersCumulative = 0; + + return results + .sort(({ date: dateA }, { date: dateB }) => ascending(dateA, dateB)) + .map(({ date, total_users: totalUsers, fully_registered_users: fullyRegisteredUsers }) => { + totalUsersCumulative += totalUsers; + fullyRegisteredUsersCumulative += fullyRegisteredUsers; + + return { + date: new Date(date), + totalUsers, + totalUsersCumulative, + fullyRegisteredUsers, + fullyRegisteredUsersCumulative, + }; + }); +} + +function toRenderableData(results: ProcessedResult[]): ProcessedRenderableData[] { + return results.flatMap( + ({ + date, + totalUsers, + totalUsersCumulative, + fullyRegisteredUsers, + fullyRegisteredUsersCumulative, + }) => [ + { date, value: totalUsers, type: DataType.TOTAL_USERS }, + { date, value: totalUsersCumulative, type: DataType.TOTAL_USERS_CUMULATIVE }, + { date, value: fullyRegisteredUsers, type: DataType.FULLY_REGISTERED_USERS }, + { + date, + value: fullyRegisteredUsersCumulative, + type: DataType.FULLY_REGISTERED_USERS_CUMULATIVE, + }, + ] + ); +} + +function loadData(date: Date, env: string, fetch = window.fetch): Promise { + const path = reportPath({ reportName: "daily-registrations-report", date, env }); + return fetch(path) + .then((response) => (response.status === 200 ? response.json() : { results: [] })) + .then((report) => process(report)); +} + +export { ProcessedResult, ProcessedRenderableData, DataType, loadData, toRenderableData }; diff --git a/src/routes/all.ts b/src/routes/all.ts index 917e991..dcd9367 100644 --- a/src/routes/all.ts +++ b/src/routes/all.ts @@ -7,6 +7,7 @@ const ALL_ROUTES = { "/daily-auths-report/": "Daily Auths Report", "/daily-dropoffs-report/": "Daily Dropoffs Report", "/proofing-over-time/": "Proofing Over Time Report", + "/daily-registrations-report/": "Daily Registrations Report", }; export default ALL_ROUTES; diff --git a/src/routes/index.tsx b/src/routes/index.tsx index 3b17d9c..7a489f4 100644 --- a/src/routes/index.tsx +++ b/src/routes/index.tsx @@ -3,6 +3,7 @@ import { Control } from "../components/report-filter-controls"; import DailyAuthsReport from "../components/daily-auths-report"; import DailyDropffsReport from "../components/daily-dropoffs-report"; import ProofingOverTimeReport from "../components/proofing-over-time-report"; +import DailyRegistrationsReport from "../components/daily-registrations-report"; import { Router } from "../router"; import HomeRoute from "./home-route"; import createReportRoute, { ReportRoute } from "./report-route"; @@ -27,6 +28,9 @@ const reportRoutes: ReportRoutes = { defaultScale: Scale.PERCENT, }), "/": HomeRoute, + "/daily-registrations-report/": createReportRoute(DailyRegistrationsReport, { + controls: [Control.CUMULATIVE], + }), }; export function Routes(): VNode { diff --git a/src/routes/report-route.tsx b/src/routes/report-route.tsx index c544ef3..18df94a 100644 --- a/src/routes/report-route.tsx +++ b/src/routes/report-route.tsx @@ -44,6 +44,12 @@ interface ReportRouteProps { * Whether or not to show extra controls */ extra?: string; + + /** + * When "on" the report should show cumulative data + * When "off" it should show daily data + */ + cumulative?: "on" | "off"; } function createReportRoute( @@ -70,6 +76,7 @@ function createReportRoute( byAgency: byAgencyParam, extra: extraParam, timeBucket, + cumulative: cumulativeParam, }: ReportRouteProps): VNode => { const endOfPreviousWeek = utcDay.offset(utcWeek.floor(new Date()), -1); const startOfPreviousWeek = utcWeek.offset( @@ -85,6 +92,7 @@ function createReportRoute( const scale = scaleParam || defaultScale || DEFAULT_SCALE; const extra = extraParam === "true"; const byAgency = byAgencyParam ? byAgencyParam === "on" : extra; + const cumulative = cumulativeParam ? cumulativeParam === "on" : true; const reportControls = controls || []; if (extra) { @@ -108,6 +116,7 @@ function createReportRoute( byAgency={byAgency} extra={extra} timeBucket={timeBucket} + cumulative={cumulative} > diff --git a/typings/@observablehq/plot/index.d.ts b/typings/@observablehq/plot/index.d.ts index ea02529..9ea907f 100644 --- a/typings/@observablehq/plot/index.d.ts +++ b/typings/@observablehq/plot/index.d.ts @@ -5,6 +5,7 @@ declare module "@observablehq/plot" { export function binX(a: any, b: any): any; export function binY(a: any, b: any): any; export function ruleY(a: any, b?: any): any; + export function line(a: any, b: any): any; export function lineY(a: any, b: any): any; export function text(a: any, b?: any): any; }