, the event
- * should be discarded.
- *
- * Another case for ignoring a keybinding, is when the user is typing into a
- * form, and presses the keybinding. For example, if the keybinding is and
- * the user types , the event should also be discarded.
- *
- * @param {*} event - Captured HTML DOM event
- * @return {boolean} Whether the event should be ignored or not.
- *
- */
-export function shouldIgnoreKeypress(event) {
- const modifierPressed = event.ctrlKey || event.metaKey || event.altKey || event.keyCode == 229
- const isTyping = event.isComposing || event.target.tagName == "INPUT" || event.target.tagName == "TEXTAREA"
-
- return modifierPressed || isTyping
-}
-
-/**
- * Returns whether the given keybinding has been pressed and should be
- * processed. Events can be ignored based on `shouldIgnoreKeypress(event)`.
- *
- * @param {string} keybinding - The target key to checked, e.g. `"i"`.
- * @return {boolean} Whether the event should be processed or not.
- *
- */
-export function isKeyPressed(event, keybinding) {
- const keyPressed = event.key.toLowerCase() == keybinding.toLowerCase()
- return keyPressed && !shouldIgnoreKeypress(event)
-}
diff --git a/assets/js/dashboard/keybinding.tsx b/assets/js/dashboard/keybinding.tsx
new file mode 100644
index 000000000..d05a1f892
--- /dev/null
+++ b/assets/js/dashboard/keybinding.tsx
@@ -0,0 +1,109 @@
+/* @format */
+import React, { ReactNode, useCallback, useEffect } from 'react'
+import {
+ AppNavigationTarget,
+ useAppNavigate
+} from './navigation/use-app-navigate'
+
+/**
+ * Returns whether a keydown or keyup event should be ignored or not.
+ *
+ * Keybindings are ignored when a modifier key is pressed, for example, if the
+ * keybinding is , but the user pressed or , the event
+ * should be discarded.
+ *
+ * Another case for ignoring a keybinding, is when the user is typing into a
+ * form, and presses the keybinding. For example, if the keybinding is and
+ * the user types , the event should also be discarded.
+ *
+ * @param {*} event - Captured HTML DOM event
+ * @return {boolean} Whether the event should be ignored or not.
+ *
+ */
+export function shouldIgnoreKeypress(event: KeyboardEvent) {
+ const targetElement = event.target as Element | undefined
+ const modifierPressed =
+ event.ctrlKey || event.metaKey || event.altKey || event.keyCode == 229
+ const isTyping =
+ event.isComposing ||
+ targetElement?.tagName == 'INPUT' ||
+ targetElement?.tagName == 'TEXTAREA'
+
+ return modifierPressed || isTyping
+}
+
+/**
+ * Returns whether the given keybinding has been pressed and should be
+ * processed. Events can be ignored based on `shouldIgnoreKeypress(event)`.
+ *
+ * @param {string} keyboardKey - The target key to checked, e.g. `"i"`.
+ * @return {boolean} Whether the event should be processed or not.
+ *
+ */
+export function isKeyPressed(event: KeyboardEvent, keyboardKey: string) {
+ const keyPressed = event.key.toLowerCase() == keyboardKey.toLowerCase()
+ return keyPressed && !shouldIgnoreKeypress(event)
+}
+
+type KeyboardEventType = keyof Pick<
+ GlobalEventHandlersEventMap,
+ 'keyup' | 'keydown' | 'keypress'
+>
+
+export function Keybind({
+ keyboardKey,
+ type,
+ handler
+}: {
+ keyboardKey: string
+ type: KeyboardEventType
+ handler: () => void
+}) {
+ const wrappedHandler = useCallback(
+ (event: KeyboardEvent) => {
+ if (isKeyPressed(event, keyboardKey)) {
+ handler()
+ }
+ },
+ [keyboardKey, handler]
+ )
+
+ useEffect(() => {
+ const registerKeybind = () =>
+ document.addEventListener(type, wrappedHandler)
+
+ const deregisterKeybind = () =>
+ document.removeEventListener(type, wrappedHandler)
+
+ registerKeybind()
+
+ return deregisterKeybind
+ }, [type, wrappedHandler])
+
+ return null
+}
+
+export function NavigateKeybind({
+ keyboardKey,
+ type,
+ navigateProps
+}: {
+ keyboardKey: string
+ type: 'keyup' | 'keydown' | 'keypress'
+ navigateProps: AppNavigationTarget
+}) {
+ const navigate = useAppNavigate()
+ const handler = useCallback(() => {
+ navigate({ ...navigateProps })
+ }, [navigateProps, navigate])
+
+ return
+}
+
+export function KeybindHint({ children }: { children: ReactNode }) {
+ return (
+
+ {children}
+
+ )
+}
diff --git a/assets/js/dashboard/navigation/use-app-navigate.tsx b/assets/js/dashboard/navigation/use-app-navigate.tsx
index 88b152f25..b30d888ec 100644
--- a/assets/js/dashboard/navigation/use-app-navigate.tsx
+++ b/assets/js/dashboard/navigation/use-app-navigate.tsx
@@ -11,7 +11,7 @@ import {
} from 'react-router-dom'
import { parseSearch, stringifySearch } from '../util/url'
-type AppNavigationTarget = {
+export type AppNavigationTarget = {
/**
* path to target, for example `"/posts"` or `"/posts/:id"`
*/
diff --git a/assets/js/dashboard/query-context.js b/assets/js/dashboard/query-context.js
deleted file mode 100644
index 52c9c4507..000000000
--- a/assets/js/dashboard/query-context.js
+++ /dev/null
@@ -1,41 +0,0 @@
-import React, { createContext, useMemo, useEffect, useContext, useState, useCallback } from "react";
-import { parseQuery } from "./query";
-import { useLocation } from "react-router";
-import { useMountedEffect } from "./custom-hooks";
-import * as api from './api'
-import { useSiteContext } from "./site-context";
-import { parseSearch } from "./util/url";
-
-const queryContextDefaultValue = { query: {}, lastLoadTimestamp: new Date() }
-
-const QueryContext = createContext(queryContextDefaultValue)
-
-export const useQueryContext = () => { return useContext(QueryContext) }
-
-export default function QueryContextProvider({ children }) {
- const location = useLocation();
- const site = useSiteContext();
- const searchRecord = useMemo(() => parseSearch(location.search), [location.search]);
-
- const query = useMemo(() => {
- return parseQuery(searchRecord, site)
- }, [searchRecord, site])
-
- const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())
- const updateLastLoadTimestamp = useCallback(() => { setLastLoadTimestamp(new Date()) }, [setLastLoadTimestamp])
-
- useEffect(() => {
- document.addEventListener('tick', updateLastLoadTimestamp)
-
- return () => {
- document.removeEventListener('tick', updateLastLoadTimestamp)
- }
- }, [updateLastLoadTimestamp])
-
- useMountedEffect(() => {
- api.cancelAll()
- updateLastLoadTimestamp()
- }, [])
-
- return {children}
-};
diff --git a/assets/js/dashboard/query-context.tsx b/assets/js/dashboard/query-context.tsx
new file mode 100644
index 000000000..25056ebbf
--- /dev/null
+++ b/assets/js/dashboard/query-context.tsx
@@ -0,0 +1,151 @@
+/* @format */
+import React, {
+ createContext,
+ useMemo,
+ useEffect,
+ useContext,
+ useState,
+ useCallback,
+ ReactNode
+} from 'react'
+import { useLocation } from 'react-router'
+import { useMountedEffect } from './custom-hooks'
+import * as api from './api'
+import { useSiteContext } from './site-context'
+import { parseSearch } from './util/url'
+import dayjs from 'dayjs'
+import { nowForSite, yesterday } from './util/date'
+import {
+ getDashboardTimeSettings,
+ getSavedTimePreferencesFromStorage,
+ QueryPeriod,
+ useSaveTimePreferencesToStorage
+} from './query-time-periods'
+import { Filter, FilterClauseLabels, queryDefaultValue } from './query'
+
+const queryContextDefaultValue = {
+ query: queryDefaultValue,
+ otherSearch: {} as Record,
+ lastLoadTimestamp: new Date()
+}
+
+export type QueryContextValue = typeof queryContextDefaultValue
+
+const QueryContext = createContext(queryContextDefaultValue)
+
+export const useQueryContext = () => {
+ return useContext(QueryContext)
+}
+
+export default function QueryContextProvider({
+ children
+}: {
+ children: ReactNode
+}) {
+ const location = useLocation()
+ const site = useSiteContext()
+ const {
+ compare_from,
+ compare_to,
+ comparison,
+ date,
+ filters,
+ from,
+ labels,
+ match_day_of_week,
+ period,
+ to,
+ with_imported,
+ ...otherSearch
+ } = useMemo(() => parseSearch(location.search), [location.search])
+
+ const query = useMemo(() => {
+ const defaultValues = queryDefaultValue
+ const storedValues = getSavedTimePreferencesFromStorage({ site })
+
+ const timeQuery = getDashboardTimeSettings({
+ searchValues: { period, comparison, match_day_of_week },
+ storedValues,
+ defaultValues
+ })
+
+ return {
+ ...timeQuery,
+ compare_from:
+ typeof compare_from === 'string' && compare_from.length
+ ? dayjs.utc(compare_from)
+ : defaultValues.compare_from,
+ compare_to:
+ typeof compare_to === 'string' && compare_to.length
+ ? dayjs.utc(compare_to)
+ : defaultValues.compare_to,
+ date:
+ typeof date === 'string' && date.length
+ ? dayjs.utc(date)
+ : nowForSite(site),
+ from:
+ typeof from === 'string' && from.length
+ ? dayjs.utc(from)
+ : timeQuery.period === QueryPeriod.custom
+ ? yesterday(site)
+ : defaultValues.from,
+ to:
+ typeof to === 'string' && to.length
+ ? dayjs.utc(to)
+ : timeQuery.period === QueryPeriod.custom
+ ? nowForSite(site)
+ : defaultValues.to,
+ with_imported: [true, false].includes(with_imported as boolean)
+ ? (with_imported as boolean)
+ : defaultValues.with_imported,
+ filters: Array.isArray(filters)
+ ? (filters as Filter[])
+ : defaultValues.filters,
+ labels: (labels as FilterClauseLabels) || defaultValues.labels
+ }
+ }, [
+ compare_from,
+ compare_to,
+ comparison,
+ date,
+ filters,
+ from,
+ labels,
+ match_day_of_week,
+ period,
+ to,
+ with_imported,
+ site
+ ])
+
+ useSaveTimePreferencesToStorage({
+ site,
+ period,
+ comparison,
+ match_day_of_week
+ })
+
+ const [lastLoadTimestamp, setLastLoadTimestamp] = useState(new Date())
+ const updateLastLoadTimestamp = useCallback(() => {
+ setLastLoadTimestamp(new Date())
+ }, [setLastLoadTimestamp])
+
+ useEffect(() => {
+ document.addEventListener('tick', updateLastLoadTimestamp)
+
+ return () => {
+ document.removeEventListener('tick', updateLastLoadTimestamp)
+ }
+ }, [updateLastLoadTimestamp])
+
+ useMountedEffect(() => {
+ api.cancelAll()
+ updateLastLoadTimestamp()
+ }, [])
+
+ return (
+
+ {children}
+
+ )
+}
diff --git a/assets/js/dashboard/query-dates.test.tsx b/assets/js/dashboard/query-dates.test.tsx
index 76564cb34..c83d41e26 100644
--- a/assets/js/dashboard/query-dates.test.tsx
+++ b/assets/js/dashboard/query-dates.test.tsx
@@ -33,16 +33,12 @@ test('if no period is stored, loads with default value of "Last 30 days", all ex
['Month to Date', 'M'],
['Last Month', ''],
['Year to Date', 'Y'],
- ['Last 12 months', 'L'],
- ['All time', 'A']
+ ['Last 12 Months', 'L'],
+ ['All time', 'A'],
+ ['Custom Range', 'C'],
+ ['Compare', 'X']
].map((a) => a.join(''))
)
- expect(screen.getByText('Custom Range').textContent).toEqual(
- ['Custom Range', 'C'].join('')
- )
- expect(screen.getByText('Compare').textContent).toEqual(
- ['Compare', 'X'].join('')
- )
})
test('user can select a new period and its value is stored', async () => {
@@ -59,7 +55,7 @@ test('user can select a new period and its value is stored', async () => {
expect(localStorage.getItem(periodStorageKey)).toBe('all')
})
-test('stored period "all" is respected, and Compare option is not present for it in menu', async () => {
+test('period "all" is respected, and Compare option is not present for it in menu', async () => {
localStorage.setItem(periodStorageKey, 'all')
render( , {
diff --git a/assets/js/dashboard/query-time-periods.test.ts b/assets/js/dashboard/query-time-periods.test.ts
new file mode 100644
index 000000000..b2361092d
--- /dev/null
+++ b/assets/js/dashboard/query-time-periods.test.ts
@@ -0,0 +1,92 @@
+/** @format */
+
+import {
+ ComparisonMode,
+ getDashboardTimeSettings,
+ getPeriodStorageKey,
+ getStoredPeriod,
+ QueryPeriod
+} from './query-time-periods'
+
+describe(`${getStoredPeriod.name}`, () => {
+ const domain = 'any.site'
+ const key = getPeriodStorageKey(domain)
+
+ it('returns fallback value if invalid values stored', () => {
+ localStorage.setItem(key, 'any-invalid-value')
+ expect(getStoredPeriod(domain, null)).toEqual(null)
+ })
+
+ it('returns correct value if value stored', () => {
+ localStorage.setItem(key, QueryPeriod['7d'])
+ expect(getStoredPeriod(domain, null)).toEqual(QueryPeriod['7d'])
+ })
+})
+
+describe(`${getDashboardTimeSettings.name}`, () => {
+ const defaultValues = {
+ period: QueryPeriod['7d'],
+ comparison: null,
+ match_day_of_week: true
+ }
+ const emptySearchValues = {
+ period: undefined,
+ comparison: undefined,
+ match_day_of_week: undefined
+ }
+ const emptyStoredValues = {
+ period: null,
+ comparison: null,
+ match_day_of_week: null
+ }
+
+ it('returns defaults if nothing stored and no search', () => {
+ expect(
+ getDashboardTimeSettings({
+ searchValues: emptySearchValues,
+ storedValues: emptyStoredValues,
+ defaultValues
+ })
+ ).toEqual(defaultValues)
+ })
+
+ it('returns stored values if no search', () => {
+ expect(
+ getDashboardTimeSettings({
+ searchValues: emptySearchValues,
+ storedValues: {
+ period: QueryPeriod['12mo'],
+ comparison: ComparisonMode.year_over_year,
+ match_day_of_week: false
+ },
+ defaultValues
+ })
+ ).toEqual({
+ period: QueryPeriod['12mo'],
+ comparison: ComparisonMode.year_over_year,
+ match_day_of_week: false
+ })
+ })
+
+ it('uses values from search above all else, treats ComparisonMode.off as null', () => {
+ expect(
+ getDashboardTimeSettings({
+ searchValues: {
+ period: QueryPeriod['year'],
+ comparison: ComparisonMode.off,
+ match_day_of_week: true
+ },
+ storedValues: {
+ period: QueryPeriod['12mo'],
+ comparison: ComparisonMode.year_over_year,
+ match_day_of_week: false
+ },
+ defaultValues
+ })
+ ).toEqual({
+ period: QueryPeriod['year'],
+ comparison: null,
+ match_day_of_week: true
+ })
+ })
+})
diff --git a/assets/js/dashboard/query-time-periods.ts b/assets/js/dashboard/query-time-periods.ts
new file mode 100644
index 000000000..59f94ec8e
--- /dev/null
+++ b/assets/js/dashboard/query-time-periods.ts
@@ -0,0 +1,557 @@
+/* @format */
+import { useEffect } from 'react'
+import {
+ clearedComparisonSearch,
+ clearedDateSearch,
+ DashboardQuery
+} from './query'
+import { PlausibleSite, useSiteContext } from './site-context'
+import {
+ formatDateRange,
+ formatDay,
+ formatISO,
+ formatMonthYYYY,
+ formatYear,
+ isSameDate,
+ isSameMonth,
+ isThisMonth,
+ isThisYear,
+ isToday,
+ lastMonth,
+ nowForSite,
+ parseNaiveDate,
+ yesterday
+} from './util/date'
+import { AppNavigationTarget } from './navigation/use-app-navigate'
+import { getDomainScopedStorageKey, getItem, setItem } from './util/storage'
+import { useQueryContext } from './query-context'
+
+export enum QueryPeriod {
+ 'realtime' = 'realtime',
+ 'day' = 'day',
+ 'month' = 'month',
+ '7d' = '7d',
+ '30d' = '30d',
+ '6mo' = '6mo',
+ '12mo' = '12mo',
+ 'year' = 'year',
+ 'all' = 'all',
+ 'custom' = 'custom'
+}
+
+export enum ComparisonMode {
+ off = 'off',
+ previous_period = 'previous_period',
+ year_over_year = 'year_over_year',
+ custom = 'custom'
+}
+
+export const COMPARISON_MODES = {
+ [ComparisonMode.off]: 'Disable comparison',
+ [ComparisonMode.previous_period]: 'Previous period',
+ [ComparisonMode.year_over_year]: 'Year over year',
+ [ComparisonMode.custom]: 'Custom period'
+}
+
+export enum ComparisonMatchMode {
+ MatchExactDate = 0,
+ MatchDayOfWeek = 1
+}
+
+export const COMPARISON_MATCH_MODE_LABELS = {
+ [ComparisonMatchMode.MatchDayOfWeek]: 'Match day of week',
+ [ComparisonMatchMode.MatchExactDate]: 'Match exact date'
+}
+
+export const DEFAULT_COMPARISON_MODE = ComparisonMode.previous_period
+
+export const COMPARISON_DISABLED_PERIODS = [
+ QueryPeriod.realtime,
+ QueryPeriod.all
+]
+
+export const DEFAULT_COMPARISON_MATCH_MODE = ComparisonMatchMode.MatchDayOfWeek
+
+export function getPeriodStorageKey(domain: string): string {
+ return getDomainScopedStorageKey('period', domain)
+}
+
+export function isValidPeriod(period: unknown): period is QueryPeriod {
+ return Object.values(QueryPeriod).includes(period)
+}
+
+export function getStoredPeriod(
+ domain: string,
+ fallbackValue: QueryPeriod | null
+) {
+ const item = getItem(getPeriodStorageKey(domain))
+ return isValidPeriod(item) ? item : fallbackValue
+}
+
+function storePeriod(domain: string, value: QueryPeriod) {
+ return setItem(getPeriodStorageKey(domain), value)
+}
+
+export const isValidComparison = (
+ comparison: unknown
+): comparison is ComparisonMode =>
+ Object.values(ComparisonMode).includes(comparison)
+
+export const getMatchDayOfWeekStorageKey = (domain: string) =>
+ getDomainScopedStorageKey('comparison_match_day_of_week', domain)
+
+export const isValidMatchDayOfWeek = (
+ matchDayOfWeek: unknown
+): matchDayOfWeek is boolean =>
+ [true, false].includes(matchDayOfWeek as boolean)
+
+export const storeMatchDayOfWeek = (domain: string, matchDayOfWeek: boolean) =>
+ setItem(getMatchDayOfWeekStorageKey(domain), matchDayOfWeek.toString())
+
+export const getStoredMatchDayOfWeek = function (
+ domain: string,
+ fallbackValue: boolean | null
+) {
+ const storedValue = getItem(getMatchDayOfWeekStorageKey(domain))
+ if (storedValue === 'true') {
+ return true
+ }
+ if (storedValue === 'false') {
+ return false
+ }
+ return fallbackValue
+}
+
+export const getComparisonModeStorageKey = (domain: string) =>
+ getDomainScopedStorageKey('comparison_mode', domain)
+
+export const getStoredComparisonMode = function (
+ domain: string,
+ fallbackValue: ComparisonMode | null
+): ComparisonMode | null {
+ const storedValue = getItem(getComparisonModeStorageKey(domain))
+ if (Object.values(ComparisonMode).includes(storedValue)) {
+ return storedValue
+ }
+
+ return fallbackValue
+}
+
+export const storeComparisonMode = function (
+ domain: string,
+ mode: ComparisonMode
+) {
+ setItem(getComparisonModeStorageKey(domain), mode)
+}
+
+export const isComparisonEnabled = function (
+ mode?: ComparisonMode | null
+): mode is Exclude {
+ if (
+ [
+ ComparisonMode.custom,
+ ComparisonMode.previous_period,
+ ComparisonMode.year_over_year
+ ].includes(mode as ComparisonMode)
+ ) {
+ return true
+ }
+ return false
+}
+
+export const getSearchToToggleComparison = ({
+ site,
+ query
+}: {
+ site: PlausibleSite
+ query: DashboardQuery
+}): Required['search'] => {
+ return (search) => {
+ if (isComparisonEnabled(query.comparison)) {
+ return {
+ ...search,
+ ...clearedComparisonSearch,
+ comparison: ComparisonMode.off,
+ keybindHint: 'X'
+ }
+ }
+ const storedMode = getStoredComparisonMode(site.domain, null)
+ const newMode = isComparisonEnabled(storedMode)
+ ? storedMode
+ : DEFAULT_COMPARISON_MODE
+ return {
+ ...search,
+ ...clearedComparisonSearch,
+ comparison: newMode,
+ keybindHint: 'X'
+ }
+ }
+}
+
+export const getSearchToApplyCustomDates = ([selectionStart, selectionEnd]: [
+ Date,
+ Date
+]): AppNavigationTarget['search'] => {
+ const [from, to] = [
+ parseNaiveDate(selectionStart),
+ parseNaiveDate(selectionEnd)
+ ]
+ const singleDaySelected = from.isSame(to, 'day')
+
+ if (singleDaySelected) {
+ return (search) => ({
+ ...search,
+ ...clearedDateSearch,
+ period: QueryPeriod.day,
+ date: formatISO(from),
+ keybindHint: 'C'
+ })
+ }
+
+ return (search) => ({
+ ...search,
+ ...clearedDateSearch,
+ period: QueryPeriod.custom,
+ from: formatISO(from),
+ to: formatISO(to),
+ keybindHint: 'C'
+ })
+}
+
+export const getSearchToApplyCustomComparisonDates = ([
+ selectionStart,
+ selectionEnd
+]: [Date, Date]): AppNavigationTarget['search'] => {
+ const [from, to] = [
+ parseNaiveDate(selectionStart),
+ parseNaiveDate(selectionEnd)
+ ]
+
+ return (search) => ({
+ ...search,
+ comparison: ComparisonMode.custom,
+ compare_from: formatISO(from),
+ compare_to: formatISO(to),
+ keybindHint: null
+ })
+}
+
+export type LinkItem = [
+ string[],
+ {
+ search: AppNavigationTarget['search']
+ isActive: (options: {
+ site: PlausibleSite
+ query: DashboardQuery
+ }) => boolean
+ onClick?: () => void
+ }
+]
+
+export const getDatePeriodGroups = (
+ site: PlausibleSite
+): Array> => [
+ [
+ [
+ ['Today', 'D'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod.day,
+ date: formatISO(nowForSite(site)),
+ keybindHint: 'D'
+ }),
+ isActive: ({ query }) =>
+ query.period === QueryPeriod.day &&
+ isSameDate(query.date, nowForSite(site))
+ }
+ ],
+ [
+ ['Yesterday', 'E'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod.day,
+ date: formatISO(yesterday(site)),
+ keybindHint: 'E'
+ }),
+ isActive: ({ query }) =>
+ query.period === QueryPeriod.day &&
+ isSameDate(query.date, yesterday(site))
+ }
+ ],
+ [
+ ['Realtime', 'R'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod.realtime,
+ keybindHint: 'R'
+ }),
+ isActive: ({ query }) => query.period === QueryPeriod.realtime
+ }
+ ]
+ ],
+ [
+ [
+ ['Last 7 Days', 'W'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod['7d'],
+ keybindHint: 'W'
+ }),
+ isActive: ({ query }) => query.period === QueryPeriod['7d']
+ }
+ ],
+ [
+ ['Last 30 Days', 'T'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod['30d'],
+ keybindHint: 'T'
+ }),
+ isActive: ({ query }) => query.period === QueryPeriod['30d']
+ }
+ ]
+ ],
+ [
+ [
+ ['Month to Date', 'M'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod.month,
+ keybindHint: 'M'
+ }),
+ isActive: ({ query }) =>
+ query.period === QueryPeriod.month &&
+ isSameMonth(query.date, nowForSite(site))
+ }
+ ],
+ [
+ ['Last Month'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod.month,
+ date: formatISO(lastMonth(site)),
+ keybindHint: null
+ }),
+ isActive: ({ query }) =>
+ query.period === QueryPeriod.month &&
+ isSameMonth(query.date, lastMonth(site))
+ }
+ ]
+ ],
+ [
+ [
+ ['Year to Date', 'Y'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod.year,
+ keybindHint: 'Y'
+ }),
+ isActive: ({ query }) =>
+ query.period === QueryPeriod.year && isThisYear(site, query.date)
+ }
+ ],
+ [
+ ['Last 12 Months', 'L'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod['12mo'],
+ keybindHint: 'L'
+ }),
+ isActive: ({ query }) => query.period === QueryPeriod['12mo']
+ }
+ ]
+ ],
+ [
+ [
+ ['All time', 'A'],
+ {
+ search: (s) => ({
+ ...s,
+ ...clearedDateSearch,
+ period: QueryPeriod.all,
+ keybindHint: 'A'
+ }),
+ isActive: ({ query }) => query.period === QueryPeriod.all
+ }
+ ]
+ ]
+]
+
+export const last6MonthsLinkItem: LinkItem = [
+ ['Last 6 months', 'S'],
+ {
+ search: (s) => ({ ...s, period: QueryPeriod['6mo'], keybindHint: 'S' }),
+ isActive: ({ query }) => query.period === QueryPeriod['6mo']
+ }
+]
+
+export const getCompareLinkItem = ({
+ query,
+ site
+}: {
+ query: DashboardQuery
+ site: PlausibleSite
+}): LinkItem => [
+ [
+ isComparisonEnabled(query.comparison) ? 'Disable comparison' : 'Compare',
+ 'X'
+ ],
+ {
+ search: getSearchToToggleComparison({ site, query }),
+ isActive: () => false
+ }
+]
+
+export function useSaveTimePreferencesToStorage({
+ site,
+ period,
+ comparison,
+ match_day_of_week
+}: {
+ site: PlausibleSite
+ period: unknown
+ comparison: unknown
+ match_day_of_week: unknown
+}) {
+ useEffect(() => {
+ if (
+ isValidPeriod(period) &&
+ ![QueryPeriod.custom, QueryPeriod.realtime].includes(period)
+ ) {
+ storePeriod(site.domain, period)
+ }
+ if (isValidComparison(comparison) && comparison !== ComparisonMode.custom) {
+ storeComparisonMode(site.domain, comparison)
+ }
+ if (isValidMatchDayOfWeek(match_day_of_week)) {
+ storeMatchDayOfWeek(site.domain, match_day_of_week)
+ }
+ }, [period, comparison, match_day_of_week, site.domain])
+}
+
+export function getSavedTimePreferencesFromStorage({
+ site
+}: {
+ site: PlausibleSite
+}): {
+ period: null | QueryPeriod
+ comparison: null | ComparisonMode
+ match_day_of_week: boolean | null
+} {
+ const stored = {
+ period: getStoredPeriod(site.domain, null),
+ comparison: getStoredComparisonMode(site.domain, null),
+ match_day_of_week: getStoredMatchDayOfWeek(site.domain, true)
+ }
+ return stored
+}
+
+export function getDashboardTimeSettings({
+ searchValues,
+ storedValues,
+ defaultValues
+}: {
+ searchValues: Record<'period' | 'comparison' | 'match_day_of_week', unknown>
+ storedValues: ReturnType
+ defaultValues: Pick<
+ DashboardQuery,
+ 'period' | 'comparison' | 'match_day_of_week'
+ >
+}): Pick {
+ let period: QueryPeriod
+ if (isValidPeriod(searchValues.period)) {
+ period = searchValues.period
+ } else {
+ period = isValidPeriod(storedValues.period)
+ ? storedValues.period
+ : defaultValues.period
+ }
+
+ let comparison: ComparisonMode | null
+
+ if ([QueryPeriod.realtime, QueryPeriod.all].includes(period)) {
+ comparison = null
+ } else {
+ comparison = isValidComparison(searchValues.comparison)
+ ? searchValues.comparison
+ : storedValues.comparison
+
+ if (!isComparisonEnabled(comparison)) {
+ comparison = null
+ }
+ }
+
+ const match_day_of_week = isValidMatchDayOfWeek(
+ searchValues.match_day_of_week
+ )
+ ? (searchValues.match_day_of_week as boolean)
+ : isValidMatchDayOfWeek(storedValues.match_day_of_week)
+ ? (storedValues.match_day_of_week as boolean)
+ : defaultValues.match_day_of_week
+
+ return {
+ period,
+ comparison,
+ match_day_of_week
+ }
+}
+
+export function DisplaySelectedPeriod() {
+ const { query } = useQueryContext()
+ const site = useSiteContext()
+ if (query.period === 'day') {
+ if (isToday(site, query.date)) {
+ return 'Today'
+ }
+ return formatDay(query.date)
+ }
+ if (query.period === '7d') {
+ return 'Last 7 days'
+ }
+ if (query.period === '30d') {
+ return 'Last 30 days'
+ }
+ if (query.period === 'month') {
+ if (isThisMonth(site, query.date)) {
+ return 'Month to Date'
+ }
+ return formatMonthYYYY(query.date)
+ }
+ if (query.period === '6mo') {
+ return 'Last 6 months'
+ }
+ if (query.period === '12mo') {
+ return 'Last 12 months'
+ }
+ if (query.period === 'year') {
+ if (isThisYear(site, query.date)) {
+ return 'Year to Date'
+ }
+ return formatYear(query.date)
+ }
+ if (query.period === 'all') {
+ return 'All time'
+ }
+ if (query.period === 'custom') {
+ return formatDateRange(site, query.from, query.to)
+ }
+ return 'Realtime'
+}
diff --git a/assets/js/dashboard/query.js b/assets/js/dashboard/query.js
deleted file mode 100644
index 879630e91..000000000
--- a/assets/js/dashboard/query.js
+++ /dev/null
@@ -1,199 +0,0 @@
-import React, {useCallback} from 'react'
-import { parseSearch, stringifySearch } from './util/url'
-import { AppNavigationLink, useAppNavigate } from './navigation/use-app-navigate'
-import { nowForSite } from './util/date'
-import * as storage from './util/storage'
-import { COMPARISON_DISABLED_PERIODS, getStoredComparisonMode, isComparisonEnabled, getStoredMatchDayOfWeek } from './comparison-input'
-import { getFiltersByKeyPrefix, parseLegacyFilter, parseLegacyPropsFilter } from './util/filters'
-
-import dayjs from 'dayjs'
-import utc from 'dayjs/plugin/utc'
-import { useQueryContext } from './query-context'
-
-dayjs.extend(utc)
-
-const PERIODS = ['realtime', 'day', 'month', '7d', '30d', '6mo', '12mo', 'year', 'all', 'custom']
-
-export function parseQuery(searchRecord, site) {
- const getValue = (k) => searchRecord[k];
- let period = getValue('period')
- const periodKey = `period__${site.domain}`
-
- if (PERIODS.includes(period)) {
- if (period !== 'custom' && period !== 'realtime') {storage.setItem(periodKey, period)}
- } else if (storage.getItem(periodKey)) {
- period = storage.getItem(periodKey)
- } else {
- period = '30d'
- }
-
- let comparison = getValue('comparison') ?? getStoredComparisonMode(site.domain, null)
- if (COMPARISON_DISABLED_PERIODS.includes(period) || !isComparisonEnabled(comparison)) comparison = null
-
- let matchDayOfWeek = getValue('match_day_of_week') ?? getStoredMatchDayOfWeek(site.domain, true)
-
- return {
- period,
- comparison,
- compare_from: getValue('compare_from') ? dayjs.utc(getValue('compare_from')) : undefined,
- compare_to: getValue('compare_to') ? dayjs.utc(getValue('compare_to')) : undefined,
- date: getValue('date') ? dayjs.utc(getValue('date')) : nowForSite(site),
- from: getValue('from') ? dayjs.utc(getValue('from')) : undefined,
- to: getValue('to') ? dayjs.utc(getValue('to')) : undefined,
- match_day_of_week: matchDayOfWeek === true,
- with_imported: getValue('with_imported') ?? true,
- filters: getValue('filters') || [],
- labels: getValue('labels') || {}
- }
-}
-
-export function addFilter(query, filter) {
- return { ...query, filters: [...query.filters, filter] }
-}
-
-
-
-export function navigateToQuery(navigate, {period}, newPartialSearchRecord) {
- // if we update any data that we store in localstorage, make sure going back in history will
- // revert them
- if (newPartialSearchRecord.period && newPartialSearchRecord.period !== period) {
- navigate({ search: (search) => ({ ...search, period: period }), replace: true })
- }
-
- // then push the new query to the history
- navigate({ search: (search) => ({ ...search, ...newPartialSearchRecord }) })
-}
-
-const LEGACY_URL_PARAMETERS = {
- 'goal': null,
- 'source': null,
- 'utm_medium': null,
- 'utm_source': null,
- 'utm_campaign': null,
- 'utm_content': null,
- 'utm_term': null,
- 'referrer': null,
- 'screen': null,
- 'browser': null,
- 'browser_version': null,
- 'os': null,
- 'os_version': null,
- 'country': 'country_labels',
- 'region': 'region_labels',
- 'city': 'city_labels',
- 'page': null,
- 'hostname': null,
- 'entry_page': null,
- 'exit_page': null,
-}
-
-// Called once when dashboard is loaded load. Checks whether old filter style is used and if so,
-// updates the filters and updates location
-export function filtersBackwardsCompatibilityRedirect(windowLocation, windowHistory) {
- const searchRecord = parseSearch(windowLocation.search)
- const getValue = (k) => searchRecord[k];
-
- // New filters are used - no need to do anything
- if (getValue("filters")) {
- return
- }
-
- const changedSearchRecordEntries = [];
- let filters = []
- let labels = {}
-
- for (const [key, value] of Object.entries(searchRecord)) {
- if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
- const filter = parseLegacyFilter(key, value)
- filters.push(filter)
- const labelsKey = LEGACY_URL_PARAMETERS[key]
- if (labelsKey && getValue(labelsKey)) {
- const clauses = filter[2]
- const labelsValues = getValue(labelsKey).split('|').filter(label => !!label)
- const newLabels = Object.fromEntries(clauses.map((clause, index) => [clause, labelsValues[index]]))
-
- labels = Object.assign(labels, newLabels)
- }
- } else {
- changedSearchRecordEntries.push([key, value])
- }
- }
-
- if (getValue('props')) {
- filters.push(...parseLegacyPropsFilter(getValue('props')))
- }
-
- if (filters.length > 0) {
- changedSearchRecordEntries.push(['filters', filters], ['labels', labels])
- windowHistory.pushState({}, null, `${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}`)
- }
-}
-
-// Returns a boolean indicating whether the given query includes a
-// non-empty goal filterset containing a single, or multiple revenue
-// goals with the same currency. Used to decide whether to render
-// revenue metrics in a dashboard report or not.
-export function revenueAvailable(query, site) {
- const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
- const goalFilters = getFiltersByKeyPrefix(query, "goal")
-
- return goalFilters.some(([_op, _key, clauses]) => {
- return clauses.includes(rg.event_name)
- })
- })
-
- const singleCurrency = revenueGoalsInFilter.every((rg) => {
- return rg.currency === revenueGoalsInFilter[0].currency
- })
-
- return revenueGoalsInFilter.length > 0 && singleCurrency
-}
-
-export function QueryLink({ to, search, className, children, onClick }) {
- const navigate = useAppNavigate();
- const { query } = useQueryContext();
-
- const handleClick = useCallback((e) => {
- e.preventDefault()
- navigateToQuery(navigate, query, search)
- if (onClick) {
- onClick(e)
- }
- }, [navigate, onClick, query, search])
-
- return (
- ({...currentSearch, ...search})}
- className={className}
- onClick={handleClick}
- >
- {children}
-
- )
-}
-
-export function QueryButton({ search, disabled, className, children, onClick }) {
- const navigate = useAppNavigate();
- const { query } = useQueryContext();
-
- const handleClick = useCallback((e) => {
- e.preventDefault()
- navigateToQuery(navigate, query, search)
- if (onClick) {
- onClick(e)
- }
- }, [navigate, onClick, query, search])
-
- return (
-
- {children}
-
- )
-}
-
diff --git a/assets/js/dashboard/query.ts b/assets/js/dashboard/query.ts
new file mode 100644
index 000000000..5ca08d0fd
--- /dev/null
+++ b/assets/js/dashboard/query.ts
@@ -0,0 +1,272 @@
+/** @format */
+
+import { parseSearch, stringifySearch } from './util/url'
+import {
+ nowForSite,
+ formatISO,
+ shiftDays,
+ shiftMonths,
+ isBefore,
+ parseUTCDate,
+ isAfter
+} from './util/date'
+import {
+ getFiltersByKeyPrefix,
+ parseLegacyFilter,
+ parseLegacyPropsFilter
+} from './util/filters'
+import { PlausibleSite } from './site-context'
+import { ComparisonMode, QueryPeriod } from './query-time-periods'
+import { AppNavigationTarget } from './navigation/use-app-navigate'
+import { Dayjs } from 'dayjs'
+
+export type FilterClause = string | number
+
+export type FilterOperator = string
+
+export type FilterKey = string
+
+export type Filter = [FilterOperator, FilterKey, FilterClause[]]
+
+/**
+ * Dictionary that holds a human readable value for ID-based filter clauses.
+ * Needed to show the human readable value in the Filters configuration screens.
+ * Does not go through the backend.
+ * For example,
+ * for filters `[["is", "city", [2761369]], ["is", "country", ["AT"]]]`,
+ * labels would be `{"2761369": "Vienna", "AT": "Austria"}`
+ * */
+export type FilterClauseLabels = Record
+
+export const queryDefaultValue = {
+ period: '30d' as QueryPeriod,
+ comparison: null as ComparisonMode | null,
+ match_day_of_week: true,
+ date: null as Dayjs | null,
+ from: null as Dayjs | null,
+ to: null as Dayjs | null,
+ compare_from: null as Dayjs | null,
+ compare_to: null as Dayjs | null,
+ filters: [] as Filter[],
+ labels: {} as FilterClauseLabels,
+ with_imported: true
+}
+
+export type DashboardQuery = typeof queryDefaultValue
+
+export function addFilter(
+ query: DashboardQuery,
+ filter: Filter
+): DashboardQuery {
+ return { ...query, filters: [...query.filters, filter] }
+}
+
+const LEGACY_URL_PARAMETERS = {
+ goal: null,
+ source: null,
+ utm_medium: null,
+ utm_source: null,
+ utm_campaign: null,
+ utm_content: null,
+ utm_term: null,
+ referrer: null,
+ screen: null,
+ browser: null,
+ browser_version: null,
+ os: null,
+ os_version: null,
+ country: 'country_labels',
+ region: 'region_labels',
+ city: 'city_labels',
+ page: null,
+ hostname: null,
+ entry_page: null,
+ exit_page: null
+}
+
+// Called once when dashboard is loaded load. Checks whether old filter style is used and if so,
+// updates the filters and updates location
+export function filtersBackwardsCompatibilityRedirect(
+ windowLocation: Location,
+ windowHistory: History
+) {
+ const searchRecord = parseSearch(windowLocation.search)
+ const getValue = (k: string) => searchRecord[k]
+
+ // New filters are used - no need to do anything
+ if (getValue('filters')) {
+ return
+ }
+
+ const changedSearchRecordEntries = []
+ const filters: DashboardQuery['filters'] = []
+ let labels: DashboardQuery['labels'] = {}
+
+ for (const [key, value] of Object.entries(searchRecord)) {
+ if (LEGACY_URL_PARAMETERS.hasOwnProperty(key)) {
+ const filter = parseLegacyFilter(key, value) as Filter
+ filters.push(filter)
+ const labelsKey: string | null | undefined =
+ LEGACY_URL_PARAMETERS[key as keyof typeof LEGACY_URL_PARAMETERS]
+ if (labelsKey && getValue(labelsKey)) {
+ const clauses = filter[2]
+ const labelsValues = (getValue(labelsKey) as string)
+ .split('|')
+ .filter((label) => !!label)
+ const newLabels = Object.fromEntries(
+ clauses.map((clause, index) => [clause, labelsValues[index]])
+ )
+
+ labels = Object.assign(labels, newLabels)
+ }
+ } else {
+ changedSearchRecordEntries.push([key, value])
+ }
+ }
+
+ if (getValue('props')) {
+ filters.push(...(parseLegacyPropsFilter(getValue('props')) as Filter[]))
+ }
+
+ if (filters.length > 0) {
+ changedSearchRecordEntries.push(['filters', filters], ['labels', labels])
+ windowHistory.pushState(
+ {},
+ '',
+ `${windowLocation.pathname}${stringifySearch(Object.fromEntries(changedSearchRecordEntries))}`
+ )
+ }
+}
+
+// Returns a boolean indicating whether the given query includes a
+// non-empty goal filterset containing a single, or multiple revenue
+// goals with the same currency. Used to decide whether to render
+// revenue metrics in a dashboard report or not.
+export function revenueAvailable(query: DashboardQuery, site: PlausibleSite) {
+ const revenueGoalsInFilter = site.revenueGoals.filter((rg) => {
+ const goalFilters: Filter[] = getFiltersByKeyPrefix(query, 'goal')
+
+ return goalFilters.some(([_op, _key, clauses]) => {
+ return clauses.includes(rg.event_name)
+ })
+ })
+
+ const singleCurrency = revenueGoalsInFilter.every((rg) => {
+ return rg.currency === revenueGoalsInFilter[0].currency
+ })
+
+ return revenueGoalsInFilter.length > 0 && singleCurrency
+}
+
+export const clearedDateSearch = {
+ period: null,
+ from: null,
+ to: null,
+ date: null,
+ keybindHint: null
+}
+
+export const clearedComparisonSearch = {
+ comparison: null,
+ compare_from: null,
+ compare_to: null
+}
+
+export function isDateOnOrAfterStatsStartDate({
+ site,
+ date,
+ period
+}: {
+ site: PlausibleSite
+ date: string
+ period: QueryPeriod
+}) {
+ return !isBefore(parseUTCDate(date), parseUTCDate(site.statsBegin), period)
+}
+
+export function isDateBeforeOrOnCurrentDate({
+ site,
+ date,
+ period
+}: {
+ site: PlausibleSite
+ date: string
+ period: QueryPeriod
+}) {
+ const currentDate = nowForSite(site)
+ return !isAfter(parseUTCDate(date), currentDate, period)
+}
+
+export function getDateForShiftedPeriod({
+ site,
+ query,
+ direction
+}: {
+ site: PlausibleSite
+ direction: -1 | 1
+ query: DashboardQuery
+}) {
+ const isWithinRangeByDirection = {
+ '-1': isDateOnOrAfterStatsStartDate,
+ '1': isDateBeforeOrOnCurrentDate
+ }
+ const shiftByPeriod = {
+ [QueryPeriod.day]: { shift: shiftDays, amount: 1 },
+ [QueryPeriod.month]: { shift: shiftMonths, amount: 1 },
+ [QueryPeriod.year]: { shift: shiftMonths, amount: 12 }
+ } as const
+
+ const { shift, amount } =
+ shiftByPeriod[query.period as keyof typeof shiftByPeriod] ?? {}
+ if (shift) {
+ const date = shift(query.date, direction * amount)
+ if (
+ isWithinRangeByDirection[direction]({ site, date, period: query.period })
+ ) {
+ return date
+ }
+ }
+ return null
+}
+
+function setQueryPeriodAndDate({
+ period,
+ date = null,
+ keybindHint = null
+}: {
+ period: QueryPeriod
+ date?: null | string
+ keybindHint?: null | string
+}): AppNavigationTarget['search'] {
+ return function (search) {
+ return {
+ ...search,
+ ...clearedDateSearch,
+ period,
+ date,
+ keybindHint
+ }
+ }
+}
+
+export function shiftQueryPeriod({
+ site,
+ query,
+ direction,
+ keybindHint
+}: {
+ site: PlausibleSite
+ query: DashboardQuery
+ direction: -1 | 1
+ keybindHint?: null | string
+}): AppNavigationTarget['search'] {
+ const date = getDateForShiftedPeriod({ site, query, direction })
+ if (date !== null) {
+ return setQueryPeriodAndDate({
+ period: query.period,
+ date: formatISO(date),
+ keybindHint
+ })
+ }
+ return (search) => search
+}
diff --git a/assets/js/dashboard/router.tsx b/assets/js/dashboard/router.tsx
index 7fd0eafc8..c3cfdb5de 100644
--- a/assets/js/dashboard/router.tsx
+++ b/assets/js/dashboard/router.tsx
@@ -25,6 +25,7 @@ import PropsModal from './stats/modals/props'
import ConversionsModal from './stats/modals/conversions'
import FilterModal from './stats/modals/filter-modal'
import QueryContextProvider from './query-context'
+import { DashboardKeybinds } from './dashboard-keybinds'
const queryClient = new QueryClient({
defaultOptions: {
@@ -189,6 +190,7 @@ export function createAppRouter(site: PlausibleSite) {
...rootRoute,
errorElement: ,
children: [
+ { index: true, element: },
sourcesRoute,
utmMediumsRoute,
utmSourcesRoute,
diff --git a/assets/js/dashboard/stats/graph/interval-picker.js b/assets/js/dashboard/stats/graph/interval-picker.js
index c201cfbf1..bce3dfa3b 100644
--- a/assets/js/dashboard/stats/graph/interval-picker.js
+++ b/assets/js/dashboard/stats/graph/interval-picker.js
@@ -1,6 +1,6 @@
+import React, { Fragment, useCallback, useEffect, useRef } from 'react';
import { Menu, Transition } from '@headlessui/react';
import { ChevronDownIcon } from '@heroicons/react/20/solid';
-import React, { Fragment, useCallback, useEffect } from 'react';
import classNames from 'classnames';
import * as storage from '../../util/storage';
import { isKeyPressed } from '../../keybinding';
@@ -71,14 +71,13 @@ function storeInterval(period, domain, interval) {
storage.setItem(`interval__${period}__${domain}`, interval)
}
-function subscribeKeybinding(element) {
- // eslint-disable-next-line react-hooks/rules-of-hooks
+function useIKeybinding(ref) {
const handleKeyPress = useCallback((event) => {
- if (isKeyPressed(event, "i")) element.current?.click()
- // eslint-disable-next-line react-hooks/exhaustive-deps
- }, [])
+ if (isKeyPressed(event, "i")) {
+ ref.current?.click()
+ }
+ }, [ref])
- // eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => {
document.addEventListener('keydown', handleKeyPress)
return () => document.removeEventListener('keydown', handleKeyPress)
@@ -99,16 +98,16 @@ export const getCurrentInterval = function(site, query) {
}
export function IntervalPicker({ onIntervalUpdate }) {
+ const menuElement = useRef(null)
const {query} = useQueryContext();
const site = useSiteContext();
- if (query.period == 'realtime') return null
+ useIKeybinding(menuElement)
- // eslint-disable-next-line react-hooks/rules-of-hooks
- const menuElement = React.useRef(null)
+ if (query.period == 'realtime') return null
+
const options = validIntervals(site, query)
const currentInterval = getCurrentInterval(site, query)
- subscribeKeybinding(menuElement)
function updateInterval(interval) {
storeInterval(query.period, site.domain, interval)
diff --git a/assets/js/dashboard/stats/graph/line-graph.js b/assets/js/dashboard/stats/graph/line-graph.js
index f4f93dfc4..069423cd5 100644
--- a/assets/js/dashboard/stats/graph/line-graph.js
+++ b/assets/js/dashboard/stats/graph/line-graph.js
@@ -2,7 +2,6 @@ import React from 'react';
import { useAppNavigate } from '../../navigation/use-app-navigate';
import { useQueryContext } from '../../query-context';
import Chart from 'chart.js/auto';
-import { navigateToQuery } from '../../query'
import GraphTooltip from './graph-tooltip'
import { buildDataSet, METRIC_LABELS, METRIC_FORMATTER } from './graph-util'
import dateFormatter from './date-formatter';
@@ -226,9 +225,13 @@ class LineGraph extends React.Component {
const date = this.props.graphData.labels[element.index] || this.props.graphData.comparison_labels[element.index]
if (this.props.graphData.interval === 'month') {
- navigateToQuery(this.props.navigate, this.props.query, { period: 'month', date })
+ this.props.navigate({
+ search: ({ search }) => ({ ...search, period: 'month', date })
+ })
} else if (this.props.graphData.interval === 'day') {
- navigateToQuery(this.props.navigate, this.props.query, { period: 'day', date })
+ this.props.navigate({
+ search: ({ search }) => ({ ...search, period: 'day', date })
+ })
}
}
diff --git a/assets/js/dashboard/stats/graph/visitor-graph.js b/assets/js/dashboard/stats/graph/visitor-graph.js
index 5516d8f2e..b9950b429 100644
--- a/assets/js/dashboard/stats/graph/visitor-graph.js
+++ b/assets/js/dashboard/stats/graph/visitor-graph.js
@@ -10,7 +10,7 @@ import WithImportedSwitch from './with-imported-switch';
import SamplingNotice from './sampling-notice';
import FadeIn from '../../fade-in';
import * as url from '../../util/url';
-import { isComparisonEnabled } from '../../comparison-input';
+import { isComparisonEnabled } from '../../query-time-periods';
import LineGraphWithRouter from './line-graph';
import { useQueryContext } from '../../query-context';
import { useSiteContext } from '../../site-context';
diff --git a/assets/js/dashboard/stats/locations/map.tsx b/assets/js/dashboard/stats/locations/map.tsx
index 9cd017b8f..ba1df544c 100644
--- a/assets/js/dashboard/stats/locations/map.tsx
+++ b/assets/js/dashboard/stats/locations/map.tsx
@@ -3,7 +3,6 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import * as d3 from 'd3'
import classNames from 'classnames'
import * as api from '../../api'
-import { navigateToQuery } from '../../query'
import { replaceFilterByPrefix, cleanLabels } from '../../util/filters'
import { useAppNavigate } from '../../navigation/use-app-navigate'
import numberFormatter from '../../util/number-formatter'
@@ -109,7 +108,7 @@ const WorldMap = ({
[country.code]: country.name
})
onCountrySelect()
- navigateToQuery(navigate, query, { filters, labels })
+ navigate({ search: (search) => ({ ...search, filters, labels }) })
}
},
[navigate, query, dataByCountryCode, onCountrySelect]
diff --git a/assets/js/dashboard/stats/modals/modal.js b/assets/js/dashboard/stats/modals/modal.js
index 70b64fc3e..2b7114cb8 100644
--- a/assets/js/dashboard/stats/modals/modal.js
+++ b/assets/js/dashboard/stats/modals/modal.js
@@ -1,6 +1,6 @@
import React from "react";
import { createPortal } from "react-dom";
-import { shouldIgnoreKeypress } from '../../keybinding'
+import { NavigateKeybind } from '../../keybinding'
import { rootRoute } from "../../router";
import { useAppNavigate } from "../../navigation/use-app-navigate";
@@ -18,7 +18,6 @@ class Modal extends React.Component {
}
this.node = React.createRef()
this.handleClickOutside = this.handleClickOutside.bind(this)
- this.handleKeyup = this.handleKeyup.bind(this)
this.handleResize = this.handleResize.bind(this)
}
@@ -26,7 +25,6 @@ class Modal extends React.Component {
document.body.style.overflow = 'hidden';
document.body.style.height = '100vh';
document.addEventListener("mousedown", this.handleClickOutside);
- document.addEventListener("keyup", this.handleKeyup);
window.addEventListener('resize', this.handleResize, false);
this.handleResize();
}
@@ -35,7 +33,6 @@ class Modal extends React.Component {
document.body.style.overflow = null;
document.body.style.height = null;
document.removeEventListener("mousedown", this.handleClickOutside);
- document.removeEventListener("keyup", this.handleKeyup);
window.removeEventListener('resize', this.handleResize, false);
}
@@ -47,12 +44,6 @@ class Modal extends React.Component {
this.close()
}
- handleKeyup(e) {
- if (!shouldIgnoreKeypress(e) && e.code === 'Escape') {
- this.close()
- }
- }
-
handleResize() {
this.setState({ viewport: window.innerWidth });
}
@@ -85,18 +76,22 @@ class Modal extends React.Component {
render() {
return createPortal(
-
-
-
-
- {this.props.children}
+ <>
+
search }} />
+
+
+
+
+ {this.props.children}
+
- ,
+ >
+,
document.getElementById("modal_root"),
);
}
diff --git a/assets/js/dashboard/util/date.js b/assets/js/dashboard/util/date.js
index 9247a59b5..e0519406c 100644
--- a/assets/js/dashboard/util/date.js
+++ b/assets/js/dashboard/util/date.js
@@ -72,7 +72,7 @@ export function nowForSite(site) {
}
export function yesterday(site) {
- return nowForSite(site).subtract(1, 'day')
+ return shiftDays(nowForSite(site), -1)
}
export function lastMonth(site) {
diff --git a/assets/js/dashboard/util/use-on-click-outside.ts b/assets/js/dashboard/util/use-on-click-outside.ts
new file mode 100644
index 000000000..dd2cd02f6
--- /dev/null
+++ b/assets/js/dashboard/util/use-on-click-outside.ts
@@ -0,0 +1,40 @@
+/** @format */
+
+import { RefObject, useCallback, useEffect } from 'react'
+
+export function useOnClickOutside({
+ ref,
+ active,
+ handler
+}: {
+ ref: RefObject
+ active: boolean
+ handler: () => void
+}) {
+ const onClickOutsideClose = useCallback(
+ (e: MouseEvent) => {
+ const eventTarget = e.target as Element | null
+
+ if (ref.current && eventTarget && ref.current.contains(eventTarget)) {
+ return
+ }
+ handler()
+ },
+ [ref, handler]
+ )
+
+ useEffect(() => {
+ const register = () =>
+ document.addEventListener('mousedown', onClickOutsideClose)
+ const deregister = () =>
+ document.removeEventListener('mousedown', onClickOutsideClose)
+
+ if (active) {
+ register()
+ } else {
+ deregister()
+ }
+
+ return deregister
+ }, [active, onClickOutsideClose])
+}
diff --git a/assets/package-lock.json b/assets/package-lock.json
index 0e327b470..22eb422a0 100644
--- a/assets/package-lock.json
+++ b/assets/package-lock.json
@@ -51,6 +51,7 @@
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
+ "@types/react-flatpickr": "^3.8.11",
"@types/topojson-client": "^3.1.4",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",
@@ -2394,6 +2395,16 @@
"@types/react": "*"
}
},
+ "node_modules/@types/react-flatpickr": {
+ "version": "3.8.11",
+ "resolved": "https://registry.npmjs.org/@types/react-flatpickr/-/react-flatpickr-3.8.11.tgz",
+ "integrity": "sha512-wXGyGRpUjiGknioxWzWJdNvF2XxKw5lAI7H64Iv7w4iL+1iT7QvAzrigz5FkW4lTg9IJOww6t7g21FzsrmRV6A==",
+ "dev": true,
+ "dependencies": {
+ "@types/react": "*",
+ "flatpickr": "^4.0.6"
+ }
+ },
"node_modules/@types/stack-utils": {
"version": "2.0.3",
"resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz",
diff --git a/assets/package.json b/assets/package.json
index 558f793d5..f83ba88c2 100644
--- a/assets/package.json
+++ b/assets/package.json
@@ -54,6 +54,7 @@
"@types/jest": "^29.5.12",
"@types/react": "^18.3.3",
"@types/react-dom": "^18.3.0",
+ "@types/react-flatpickr": "^3.8.11",
"@types/topojson-client": "^3.1.4",
"@typescript-eslint/eslint-plugin": "^8.0.1",
"@typescript-eslint/parser": "^8.0.1",