feat(site): add date range picker for the template insights (#8976)

This commit is contained in:
Bruno Quaresma
2023-08-08 13:43:21 -03:00
committed by GitHub
parent f4122fa9f5
commit 76b1594670
6 changed files with 392 additions and 38 deletions
+2
View File
@@ -48,6 +48,7 @@
"@types/color-convert": "2.0.0",
"@types/lodash": "4.14.196",
"@types/react-color": "3.0.6",
"@types/react-date-range": "1.4.4",
"@types/semver": "7.5.0",
"@vitejs/plugin-react": "4.0.1",
"@xstate/inspect": "0.8.0",
@@ -77,6 +78,7 @@
"react-chartjs-2": "5.2.0",
"react-color": "2.19.3",
"react-confetti": "6.1.0",
"react-date-range": "1.4.0",
"react-dom": "18.2.0",
"react-headless-tabs": "6.0.3",
"react-helmet-async": "1.3.0",
+44
View File
@@ -60,6 +60,9 @@ dependencies:
'@types/react-color':
specifier: 3.0.6
version: 3.0.6
'@types/react-date-range':
specifier: 1.4.4
version: 1.4.4
'@types/semver':
specifier: 7.5.0
version: 7.5.0
@@ -147,6 +150,9 @@ dependencies:
react-confetti:
specifier: 6.1.0
version: 6.1.0(react@18.2.0)
react-date-range:
specifier: 1.4.0
version: 1.4.0(date-fns@2.30.0)(react@18.2.0)
react-dom:
specifier: 18.2.0
version: 18.2.0(react@18.2.0)
@@ -4952,6 +4958,13 @@ packages:
'@types/reactcss': 1.2.6
dev: false
/@types/react-date-range@1.4.4:
resolution: {integrity: sha512-9Y9NyNgaCsEVN/+O4HKuxzPbVjRVBGdOKRxMDcsTRWVG62lpYgnxefNckTXDWup8FvczoqPW0+ESZR6R1yymDg==}
dependencies:
'@types/react': 18.2.6
date-fns: 2.30.0
dev: false
/@types/react-dom@18.2.4:
resolution: {integrity: sha512-G2mHoTMTL4yoydITgOGwWdWMVd8sNgyEP85xVmMKAPUBwQWm9wBPQUmvbeF4V3WBY1P7mmL4BkjQ0SqUpf1snw==}
dependencies:
@@ -6226,6 +6239,10 @@ packages:
resolution: {integrity: sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ==}
dev: true
/classnames@2.3.2:
resolution: {integrity: sha512-CSbhY4cFEJRe6/GQzIk5qXZ4Jeg5pcsP7b5peFSDpffpe1cqjASH/n9UTjBwOp6XpMSTwQ8Za2K5V02ueA7Tmw==}
dev: false
/clean-regexp@1.0.0:
resolution: {integrity: sha512-GfisEZEJvzKrmGWkvfhgzcz/BllN1USeqD2V6tg14OAOgaCD2Z/PUEuxnAZ/nPvmaHRG7a8y77p1T/IRQ4D1Hw==}
engines: {node: '>=4'}
@@ -11179,6 +11196,20 @@ packages:
tween-functions: 1.2.0
dev: false
/react-date-range@1.4.0(date-fns@2.30.0)(react@18.2.0):
resolution: {integrity: sha512-+9t0HyClbCqw1IhYbpWecjsiaftCeRN5cdhsi9v06YdimwyMR2yYHWcgVn3URwtN/txhqKpEZB6UX1fHpvK76w==}
peerDependencies:
date-fns: 2.0.0-alpha.7 || >=2.0.0
react: ^0.14 || ^15.0.0-rc || >=15.0
dependencies:
classnames: 2.3.2
date-fns: 2.30.0
prop-types: 15.8.1
react: 18.2.0
react-list: 0.8.17(react@18.2.0)
shallow-equal: 1.2.1
dev: false
/react-docgen-typescript@2.2.2(typescript@5.1.6):
resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==}
peerDependencies:
@@ -11313,6 +11344,15 @@ packages:
/react-is@18.2.0:
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
/react-list@0.8.17(react@18.2.0):
resolution: {integrity: sha512-pgmzGi0G5uGrdHzMhgO7KR1wx5ZXVvI3SsJUmkblSAKtewIhMwbQiMuQiTE83ozo04BQJbe0r3WIWzSO0dR1xg==}
peerDependencies:
react: 0.14 || 15 - 18
dependencies:
prop-types: 15.8.1
react: 18.2.0
dev: false
/react-markdown@8.0.3(@types/react@18.2.6)(react@18.2.0):
resolution: {integrity: sha512-We36SfqaKoVNpN1QqsZwWSv/OZt5J15LNgTLWynwAN5b265hrQrsjMtlRNwUvS+YyR3yDM8HpTNc4pK9H/Gc0A==}
peerDependencies:
@@ -12001,6 +12041,10 @@ packages:
kind-of: 6.0.3
dev: true
/shallow-equal@1.2.1:
resolution: {integrity: sha512-S4vJDjHHMBaiZuT9NPb616CSmLf618jawtv3sufLl6ivK8WocjAo58cXwbRV1cgqxH0Qbv+iUt6m05eqEa2IRA==}
dev: false
/shallowequal@1.1.0:
resolution: {integrity: sha512-y0m1JoUZSlPAjXVtPPW70aZWfIL/dSP7AFkRnniLCrK/8MDKog3TySTBmckD+RObVxH0v4Tox67+F14PdED2oQ==}
dev: false
@@ -0,0 +1,234 @@
import Box from "@mui/material/Box"
import { styled } from "@mui/material/styles"
import { ComponentProps, useRef, useState } from "react"
import "react-date-range/dist/styles.css"
import "react-date-range/dist/theme/default.css"
import Button from "@mui/material/Button"
import ArrowRightAltOutlined from "@mui/icons-material/ArrowRightAltOutlined"
import Popover from "@mui/material/Popover"
import { DateRangePicker, createStaticRanges } from "react-date-range"
import { format, subDays } from "date-fns"
// The type definition from @types is wrong
declare module "react-date-range" {
export function createStaticRanges(
ranges: Omit<StaticRange, "isSelected">[],
): StaticRange[]
}
export type DateRangeValue = {
startDate: Date
endDate: Date
}
type RangesState = NonNullable<ComponentProps<typeof DateRangePicker>["ranges"]>
export const DateRange = ({
value,
onChange,
}: {
value: DateRangeValue
onChange: (value: DateRangeValue) => void
}) => {
const selectionStatusRef = useRef<"idle" | "selecting">("idle")
const anchorRef = useRef<HTMLButtonElement>(null)
const [isOpen, setIsOpen] = useState(false)
const [ranges, setRanges] = useState<RangesState>([
{
...value,
key: "selection",
},
])
const currentRange = {
startDate: ranges[0].startDate as Date,
endDate: ranges[0].endDate as Date,
}
const handleClose = () => {
onChange({
startDate: currentRange.startDate,
endDate: currentRange.endDate,
})
setIsOpen(false)
}
return (
<>
<Button ref={anchorRef} onClick={() => setIsOpen(true)}>
<span>{format(currentRange.startDate, "MMM d, Y")}</span>
<ArrowRightAltOutlined sx={{ width: 16, height: 16, mx: 1 }} />
<span>{format(currentRange.endDate, "MMM d, Y")}</span>
</Button>
<Popover
anchorEl={anchorRef.current}
open={isOpen}
onClose={handleClose}
anchorOrigin={{
vertical: "bottom",
horizontal: "left",
}}
sx={{
"& .MuiPaper-root": {
marginTop: 1,
},
}}
>
<DateRangePickerWrapper
component={DateRangePicker}
onChange={(item) => {
const range = item.selection
setRanges([range])
// When it is the first selection, we don't want to close the popover
// We have to do that ourselves because the library doesn't provide a way to do it
if (selectionStatusRef.current === "idle") {
selectionStatusRef.current = "selecting"
return
}
selectionStatusRef.current = "idle"
const startDate = range.startDate as Date
const endDate = range.endDate as Date
onChange({
startDate,
endDate,
})
setIsOpen(false)
}}
moveRangeOnFirstSelection={false}
months={2}
ranges={ranges}
maxDate={new Date()}
direction="horizontal"
staticRanges={createStaticRanges([
{
label: "Today",
range: () => ({
startDate: new Date(),
endDate: new Date(),
}),
},
{
label: "Yesterday",
range: () => ({
startDate: subDays(new Date(), 1),
endDate: subDays(new Date(), 1),
}),
},
{
label: "Last 7 days",
range: () => ({
startDate: subDays(new Date(), 6),
endDate: new Date(),
}),
},
{
label: "Last 14 days",
range: () => ({
startDate: subDays(new Date(), 13),
endDate: new Date(),
}),
},
{
label: "Last 30 days",
range: () => ({
startDate: subDays(new Date(), 29),
endDate: new Date(),
}),
},
])}
/>
</Popover>
</>
)
}
const DateRangePickerWrapper: typeof Box = styled(Box)(({ theme }) => ({
"& .rdrDefinedRangesWrapper": {
background: theme.palette.background.paper,
borderColor: theme.palette.divider,
},
"& .rdrStaticRange": {
background: theme.palette.background.paper,
border: 0,
fontSize: 14,
color: theme.palette.text.secondary,
"&:hover .rdrStaticRangeLabel": {
background: theme.palette.background.paperLight,
color: theme.palette.text.primary,
},
"&.rdrStaticRangeSelected": {
color: `${theme.palette.text.primary} !important`,
},
},
"& .rdrInputRanges": {
display: "none",
},
"& .rdrDateDisplayWrapper": {
backgroundColor: theme.palette.background.paper,
},
"& .rdrCalendarWrapper": {
backgroundColor: theme.palette.background.paperLight,
},
"& .rdrDateDisplayItem": {
background: "transparent",
borderColor: theme.palette.divider,
"& input": {
color: theme.palette.text.secondary,
},
"&.rdrDateDisplayItemActive": {
borderColor: theme.palette.text.primary,
backgroundColor: theme.palette.background.paperLight,
"& input": {
color: theme.palette.text.primary,
},
},
},
"& .rdrMonthPicker select, & .rdrYearPicker select": {
color: theme.palette.text.primary,
appearance: "auto",
background: "transparent",
},
"& .rdrMonthName, & .rdrWeekDay": {
color: theme.palette.text.secondary,
},
"& .rdrDayPassive .rdrDayNumber span": {
color: theme.palette.text.disabled,
},
"& .rdrDayNumber span": {
color: theme.palette.text.primary,
},
"& .rdrDayToday .rdrDayNumber span": {
fontWeight: 900,
"&:after": {
display: "none",
},
},
"& .rdrInRange, & .rdrEndEdge, & .rdrStartEdge": {
color: theme.palette.primary.main,
},
"& .rdrDayDisabled": {
backgroundColor: "transparent",
"& .rdrDayNumber span": {
color: theme.palette.text.disabled,
},
},
}))
@@ -25,28 +25,40 @@ import {
TemplateParameterValue,
UserLatencyInsightsResponse,
} from "api/typesGenerated"
import { ComponentProps } from "react"
import { subDays, addHours, startOfHour } from "date-fns"
import { ComponentProps, ReactNode, useState } from "react"
import { subDays, isToday } from "date-fns"
import "react-date-range/dist/styles.css"
import "react-date-range/dist/theme/default.css"
import { DateRange, DateRangeValue } from "./DateRange"
import { useDashboard } from "components/Dashboard/DashboardProvider"
import OpenInNewOutlined from "@mui/icons-material/OpenInNewOutlined"
import Link from "@mui/material/Link"
import CheckCircleOutlined from "@mui/icons-material/CheckCircleOutlined"
import CancelOutlined from "@mui/icons-material/CancelOutlined"
import { getDateRangeFilter } from "./utils"
export default function TemplateInsightsPage() {
const now = new Date()
const [dateRangeValue, setDateRangeValue] = useState<DateRangeValue>({
startDate: subDays(now, 6),
endDate: now,
})
const { template } = useTemplateLayoutContext()
const insightsFilter = {
template_ids: template.id,
start_time: toStartTimeFilter(subDays(now, 7)),
end_time: startOfHour(addHours(now, 1)).toISOString(),
...getDateRangeFilter({
startDate: dateRangeValue.startDate,
endDate: dateRangeValue.endDate,
now,
isToday,
}),
}
const { data: templateInsights } = useQuery({
queryKey: ["templates", template.id, "usage"],
queryKey: ["templates", template.id, "usage", insightsFilter],
queryFn: () => getInsightsTemplate(insightsFilter),
})
const { data: userLatency } = useQuery({
queryKey: ["templates", template.id, "user-latency"],
queryKey: ["templates", template.id, "user-latency", insightsFilter],
queryFn: () => getInsightsUserLatency(insightsFilter),
})
const dashboard = useDashboard()
@@ -60,6 +72,9 @@ export default function TemplateInsightsPage() {
<title>{getTemplatePageTitle("Insights", template)}</title>
</Helmet>
<TemplateInsightsPageView
dateRange={
<DateRange value={dateRangeValue} onChange={setDateRangeValue} />
}
templateInsights={templateInsights}
userLatency={userLatency}
shouldDisplayParameters={shouldDisplayParameters}
@@ -72,36 +87,41 @@ export const TemplateInsightsPageView = ({
templateInsights,
userLatency,
shouldDisplayParameters,
dateRange,
}: {
templateInsights: TemplateInsightsResponse | undefined
userLatency: UserLatencyInsightsResponse | undefined
shouldDisplayParameters: boolean
dateRange: ReactNode
}) => {
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gridTemplateRows: "440px auto",
gap: (theme) => theme.spacing(3),
}}
>
<DailyUsersPanel
sx={{ gridColumn: "span 2" }}
data={templateInsights?.interval_reports}
/>
<UserLatencyPanel data={userLatency} />
<TemplateUsagePanel
sx={{ gridColumn: "span 3" }}
data={templateInsights?.report.apps_usage}
/>
{shouldDisplayParameters && (
<TemplateParametersUsagePanel
sx={{ gridColumn: "span 3" }}
data={templateInsights?.report.parameters_usage}
<>
<Box sx={{ mb: 4 }}>{dateRange}</Box>
<Box
sx={{
display: "grid",
gridTemplateColumns: "repeat(3, minmax(0, 1fr))",
gridTemplateRows: "440px auto",
gap: (theme) => theme.spacing(3),
}}
>
<DailyUsersPanel
sx={{ gridColumn: "span 2" }}
data={templateInsights?.interval_reports}
/>
)}
</Box>
<UserLatencyPanel data={userLatency} />
<TemplateUsagePanel
sx={{ gridColumn: "span 3" }}
data={templateInsights?.report.apps_usage}
/>
{shouldDisplayParameters && (
<TemplateParametersUsagePanel
sx={{ gridColumn: "span 3" }}
data={templateInsights?.report.parameters_usage}
/>
)}
</Box>
</>
)
}
@@ -551,20 +571,12 @@ function mapToDAUsResponse(
entries: data.map((d) => {
return {
amount: d.active_users,
date: d.end_time,
date: d.start_time,
}
}),
}
}
function toStartTimeFilter(date: Date) {
date.setHours(0, 0, 0, 0)
const year = date.getUTCFullYear()
const month = String(date.getUTCMonth() + 1).padStart(2, "0")
const day = String(date.getUTCDate()).padStart(2, "0")
return `${year}-${month}-${day}T00:00:00Z`
}
function formatTime(seconds: number): string {
if (seconds < 60) {
return seconds + " seconds"
@@ -0,0 +1,36 @@
import { getDateRangeFilter } from "./utils"
describe("getDateRangeFilter", () => {
it("returns the start time at the start of the day", () => {
const date = new Date("2020-01-01T12:00:00.000Z")
const { start_time } = getDateRangeFilter({
startDate: date,
endDate: date,
now: date,
isToday: () => false,
})
expect(start_time).toEqual("2020-01-01T00:00:00+00:00")
})
it("returns the end time at the start of the next day", () => {
const date = new Date("2020-01-01T12:00:00.000Z")
const { end_time } = getDateRangeFilter({
startDate: date,
endDate: date,
now: date,
isToday: () => false,
})
expect(end_time).toEqual("2020-01-02T00:00:00+00:00")
})
it("returns the end time at the start of the next hour if the end date is today", () => {
const date = new Date("2020-01-01T12:00:00.000Z")
const { end_time } = getDateRangeFilter({
startDate: date,
endDate: date,
now: date,
isToday: () => true,
})
expect(end_time).toEqual("2020-01-01T13:00:00+00:00")
})
})
@@ -0,0 +1,26 @@
import { addDays, addHours, format, startOfDay, startOfHour } from "date-fns"
export function getDateRangeFilter({
startDate,
endDate,
now,
isToday,
}: {
startDate: Date
endDate: Date
now: Date
isToday: (date: Date) => boolean
}) {
return {
start_time: toISOLocal(startOfDay(startDate)),
end_time: toISOLocal(
isToday(endDate)
? startOfHour(addHours(now, 1))
: startOfDay(addDays(endDate, 1)),
),
}
}
function toISOLocal(d: Date) {
return format(d, "yyyy-MM-dd'T'HH:mm:ssxxx")
}