diff --git a/site/package.json b/site/package.json index 538e158218292..8d08e287ebf1a 100644 --- a/site/package.json +++ b/site/package.json @@ -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", diff --git a/site/pnpm-lock.yaml b/site/pnpm-lock.yaml index abb876c024c61..0746844223c01 100644 --- a/site/pnpm-lock.yaml +++ b/site/pnpm-lock.yaml @@ -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 diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx new file mode 100644 index 0000000000000..60774242dad33 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/DateRange.tsx @@ -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[] +} + +export type DateRangeValue = { + startDate: Date + endDate: Date +} + +type RangesState = NonNullable["ranges"]> + +export const DateRange = ({ + value, + onChange, +}: { + value: DateRangeValue + onChange: (value: DateRangeValue) => void +}) => { + const selectionStatusRef = useRef<"idle" | "selecting">("idle") + const anchorRef = useRef(null) + const [isOpen, setIsOpen] = useState(false) + const [ranges, setRanges] = useState([ + { + ...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 ( + <> + + + { + 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(), + }), + }, + ])} + /> + + + ) +} + +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, + }, + }, +})) diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx index 77bf66a0759e9..19de025f0648c 100644 --- a/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/TemplateInsightsPage.tsx @@ -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({ + 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() { {getTemplatePageTitle("Insights", template)} + } 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 ( - theme.spacing(3), - }} - > - - - - {shouldDisplayParameters && ( - + {dateRange} + theme.spacing(3), + }} + > + + + - )} - + {shouldDisplayParameters && ( + + )} + + ) } @@ -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" diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/utils.test.ts b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.test.ts new file mode 100644 index 0000000000000..e029f69fb9035 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.test.ts @@ -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") + }) +}) diff --git a/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts new file mode 100644 index 0000000000000..bfc751efe4c01 --- /dev/null +++ b/site/src/pages/TemplatePage/TemplateInsightsPage/utils.ts @@ -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") +}