Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
98 changes: 98 additions & 0 deletions src/components/admin/AnalyticsControls/index.scss
Original file line number Diff line number Diff line change
@@ -0,0 +1,98 @@
.analytics-controls {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.85rem 1.25rem;

&__ranges,
&__comparison {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.2rem;
}

&__range,
&__comparison a {
padding: 0.48rem 0.68rem;
border-radius: var(--style-radius-s);
color: var(--theme-elevation-600);
text-decoration: none;
transition:
background-color 120ms ease,
color 120ms ease;

&:hover {
color: var(--theme-elevation-900);
background: var(--theme-elevation-100);
}

&[aria-current] {
color: var(--theme-elevation-950);
background: var(--theme-elevation-150);
font-weight: 600;
}
}

&__comparison {
margin-left: auto;

> span {
margin-right: 0.3rem;
color: var(--theme-elevation-450);
font-size: 0.82rem;
}
}

&__custom {
flex: 1 0 100%;
display: flex;
flex-wrap: wrap;
align-items: end;
gap: 0.75rem;
padding-top: 0.75rem;

label {
display: grid;
gap: 0.35rem;
color: var(--theme-elevation-550);
font-size: 0.78rem;
}

input {
min-height: 2.4rem;
padding: 0.45rem 0.65rem;
border: 1px solid var(--theme-elevation-200);
border-radius: var(--style-radius-s);
color: var(--theme-elevation-900);
background: var(--theme-input-bg);
font: inherit;
}

button {
min-height: 2.4rem;
padding: 0.45rem 0.9rem;
border: 0;
border-radius: var(--style-radius-s);
color: var(--theme-elevation-0);
background: var(--theme-elevation-900);
font: inherit;
font-weight: 600;
cursor: pointer;
}

p {
flex: 1 0 100%;
margin: 0;
color: var(--theme-error-500);
font-size: 0.8rem;
}
}

@media (max-width: 58rem) {
&__comparison {
flex-basis: 100%;
margin-left: 0;
}
}
}
139 changes: 139 additions & 0 deletions src/components/admin/AnalyticsControls/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
'use client'

import Link from 'next/link'
import { useRouter } from 'next/navigation'
import React from 'react'
import { useForm } from 'react-hook-form'

import type { AnalyticsComparison, AnalyticsRange } from '../Dashboard/analyticsRange'

import './index.scss'

type Props = {
adminPath: string
comparison: AnalyticsComparison
endDate: string
maxDate: string
range: AnalyticsRange
startDate: string
}

type CustomRangeFields = { from: string; to: string }

const calendarDays = (from: string, to: string) =>
Math.floor((Date.parse(`${to}T12:00:00Z`) - Date.parse(`${from}T12:00:00Z`)) / 86_400_000) + 1

const options: Array<{ label: string; value: AnalyticsRange }> = [
{ label: 'Today', value: 'today' },
{ label: '7 days', value: '7d' },
{ label: '30 days', value: '30d' },
{ label: '90 days', value: '90d' },
{ label: 'Year to date', value: 'ytd' },
{ label: 'Custom', value: 'custom' },
]

const hrefFor = (
adminPath: string,
range: AnalyticsRange,
comparison: AnalyticsComparison,
from?: string,
to?: string,
) => {
const query = new URLSearchParams({ range, compare: comparison })
if (range === 'custom' && from && to) {
query.set('from', from)
query.set('to', to)
}
return `${adminPath}?${query.toString()}`
}

export const AnalyticsControls: React.FC<Props> = ({
adminPath,
comparison,
endDate,
maxDate,
range,
startDate,
}) => {
const router = useRouter()
const {
formState: { errors },
handleSubmit,
register,
} = useForm<CustomRangeFields>({ defaultValues: { from: startDate, to: endDate } })

const applyCustom = handleSubmit(({ from, to }) => {
router.push(hrefFor(adminPath, 'custom', comparison, from, to))
})

return (
<div className="analytics-controls" aria-label="Analytics date controls">
<nav className="analytics-controls__ranges" aria-label="Date range">
{options.map((option) => (
<Link
aria-current={range === option.value ? 'page' : undefined}
className="analytics-controls__range"
href={hrefFor(adminPath, option.value, comparison, startDate, endDate)}
key={option.value}
>
{option.label}
</Link>
))}
</nav>

<div className="analytics-controls__comparison" aria-label="Comparison period">
<span>Compare</span>
<Link
aria-current={comparison === 'previous' ? 'true' : undefined}
href={hrefFor(adminPath, range, 'previous', startDate, endDate)}
>
Previous period
</Link>
<Link
aria-current={comparison === 'year' ? 'true' : undefined}
href={hrefFor(adminPath, range, 'year', startDate, endDate)}
>
Previous year
</Link>
</div>

{range === 'custom' && (
<form className="analytics-controls__custom" noValidate onSubmit={applyCustom}>
<label>
From
<input
type="date"
{...register('from', {
required: 'Choose a start date.',
validate: (value, values) =>
!values.to || value <= values.to || 'Start must be before end.',
})}
/>
</label>
<label>
To
<input
max={maxDate}
type="date"
{...register('to', {
required: 'Choose an end date.',
validate: (value, values) => {
if (values.from && value < values.from) return 'End must be after start.'
if (value > maxDate) return 'End cannot be in the future.'
if (values.from && calendarDays(values.from, value) > 366) {
return 'Choose a range of one year or less.'
}
return true
},
})}
/>
</label>
<button type="submit">Apply</button>
{(errors.from?.message || errors.to?.message) && (
<p role="alert">{errors.from?.message || errors.to?.message}</p>
)}
</form>
)}
</div>
)
}
147 changes: 147 additions & 0 deletions src/components/admin/Dashboard/analyticsRange.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,147 @@
import {
addCalendarDays,
dateTimeInZone,
type CalendarDate,
zonedMidnight,
} from '@/utilities/storeTime'

export const ANALYTICS_RANGES = ['today', '7d', '30d', '90d', 'ytd', 'custom'] as const
export type AnalyticsRange = (typeof ANALYTICS_RANGES)[number]

export const ANALYTICS_COMPARISONS = ['previous', 'year'] as const
export type AnalyticsComparison = (typeof ANALYTICS_COMPARISONS)[number]

const MAX_CUSTOM_DAYS = 366

const dateKey = (date: CalendarDate) =>
`${date.year}-${String(date.month).padStart(2, '0')}-${String(date.day).padStart(2, '0')}`

const parseDateKey = (value: string | undefined): CalendarDate | undefined => {
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value ?? '')
if (!match) return undefined

const date = { year: Number(match[1]), month: Number(match[2]), day: Number(match[3]) }
const rendered = new Date(Date.UTC(date.year, date.month - 1, date.day, 12))
if (
rendered.getUTCFullYear() !== date.year ||
rendered.getUTCMonth() + 1 !== date.month ||
rendered.getUTCDate() !== date.day
) {
return undefined
}
return date
}

const dayNumber = (date: CalendarDate) => Date.UTC(date.year, date.month - 1, date.day) / 86_400_000
const daysBetween = (start: CalendarDate, end: CalendarDate) => dayNumber(end) - dayNumber(start)

const daysInMonth = (year: number, month: number) => new Date(Date.UTC(year, month, 0)).getUTCDate()

const previousYear = (date: CalendarDate): CalendarDate => ({
year: date.year - 1,
month: date.month,
day: Math.min(date.day, daysInMonth(date.year - 1, date.month)),
})

const isRange = (value: string | undefined): value is AnalyticsRange =>
ANALYTICS_RANGES.includes(value as AnalyticsRange)

const isComparison = (value: string | undefined): value is AnalyticsComparison =>
ANALYTICS_COMPARISONS.includes(value as AnalyticsComparison)

export type AnalyticsWindow = {
comparison: AnalyticsComparison
comparisonLabel: string
days: number
end: Date
endDate: string
priorEnd: Date
priorStart: Date
range: AnalyticsRange
rangeLabel: string
start: Date
startDate: string
today: Date
todayDate: string
}

export const analyticsWindow = ({
comparison: comparisonInput,
from,
now,
range: rangeInput,
timeZone,
to,
}: {
comparison?: string
from?: string
now: Date
range?: string
timeZone: string
to?: string
}): AnalyticsWindow => {
const localNow = dateTimeInZone(now, timeZone)
const today = { year: localNow.year, month: localNow.month, day: localNow.day }
const comparison = isComparison(comparisonInput) ? comparisonInput : 'previous'
let range: AnalyticsRange = isRange(rangeInput) ? rangeInput : '30d'
let startDate: CalendarDate
let endDate = today

if (range === 'custom') {
const requestedStart = parseDateKey(from)
const requestedEnd = parseDateKey(to)
const requestedDays =
requestedStart && requestedEnd ? daysBetween(requestedStart, requestedEnd) + 1 : 0

if (
!requestedStart ||
!requestedEnd ||
requestedDays < 1 ||
requestedDays > MAX_CUSTOM_DAYS ||
daysBetween(today, requestedEnd) > 0
) {
range = '30d'
startDate = addCalendarDays(today, -29)
} else {
startDate = requestedStart
endDate = requestedEnd
}
} else if (range === 'ytd') {
startDate = { year: today.year, month: 1, day: 1 }
} else {
const days = range === 'today' ? 1 : Number.parseInt(range, 10)
startDate = addCalendarDays(today, -(days - 1))
}

const days = daysBetween(startDate, endDate) + 1
const start = zonedMidnight(startDate, timeZone)
const end = zonedMidnight(addCalendarDays(endDate, 1), timeZone)

const priorStartDate =
comparison === 'year' ? previousYear(startDate) : addCalendarDays(startDate, -days)
const priorEndDate = comparison === 'year' ? addCalendarDays(previousYear(endDate), 1) : startDate

const labels: Record<Exclude<AnalyticsRange, 'custom'>, string> = {
today: 'Today',
'7d': 'Last 7 days',
'30d': 'Last 30 days',
'90d': 'Last 90 days',
ytd: 'Year to date',
}

return {
comparison,
comparisonLabel: comparison === 'year' ? 'vs previous year' : 'vs previous period',
days,
end,
endDate: dateKey(endDate),
priorEnd: zonedMidnight(priorEndDate, timeZone),
priorStart: zonedMidnight(priorStartDate, timeZone),
range,
rangeLabel: range === 'custom' ? `${dateKey(startDate)} to ${dateKey(endDate)}` : labels[range],
start,
startDate: dateKey(startDate),
today: zonedMidnight(today, timeZone),
todayDate: dateKey(today),
}
}
Loading
Loading