feat: time range selector & area chart improvements
- Add 7D/30D/3M toggle on Daily Activity and Comparison Between Villages charts - Switch LineChart to AreaChart with fillOpacity 0.4 for bold gradient fill - Fix broken tooltip on all charts with custom dark card content - Apply consistent chart style (tickLine, gridAxis, glow) to village detail page - api.ts: getDailyActivity and getComparisonActivity now accept range param
This commit is contained in:
@@ -1,7 +1,8 @@
|
|||||||
import { BarChart, LineChart } from '@mantine/charts'
|
import { AreaChart, BarChart } from '@mantine/charts'
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Box,
|
Box,
|
||||||
|
Button,
|
||||||
Group,
|
Group,
|
||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -11,14 +12,29 @@ import {
|
|||||||
} from '@mantine/core'
|
} from '@mantine/core'
|
||||||
import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb'
|
import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb'
|
||||||
|
|
||||||
|
type DailyRange = 7 | 30 | 90
|
||||||
|
|
||||||
interface ChartProps {
|
interface ChartProps {
|
||||||
data?: any[]
|
data?: any[]
|
||||||
isLoading?: boolean
|
isLoading?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
interface ActivityChartProps extends ChartProps {
|
||||||
|
range?: DailyRange
|
||||||
|
onRangeChange?: (range: DailyRange) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const RANGE_OPTIONS: { value: DailyRange; label: string }[] = [
|
||||||
|
{ value: 7, label: '7D' },
|
||||||
|
{ value: 30, label: '30D' },
|
||||||
|
{ value: 90, label: '3M' },
|
||||||
|
]
|
||||||
|
|
||||||
|
export function VillageActivityLineChart({ data = [], isLoading, range = 7, onRangeChange }: ActivityChartProps) {
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
|
|
||||||
|
const rangeLabel = range === 7 ? 'last 7 days' : range === 30 ? 'last 30 days' : 'last 3 months'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
||||||
<Stack gap="md" h="100%">
|
<Stack gap="md" h="100%">
|
||||||
@@ -29,21 +45,28 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={700} size="sm">DAILY ACTIVITY - ALL VILLAGES</Text>
|
<Text fw={700} size="sm">DAILY ACTIVITY - ALL VILLAGES</Text>
|
||||||
<Text size="xs" c="dimmed">Trend over the last 7 days</Text>
|
<Text size="xs" c="dimmed">Trend over the {rangeLabel}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
{
|
<Group gap={4}>
|
||||||
isLoading && (
|
{RANGE_OPTIONS.map((opt) => (
|
||||||
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
|
<Button
|
||||||
...
|
key={opt.value}
|
||||||
</Badge>
|
size="compact-xs"
|
||||||
)
|
variant={range === opt.value ? 'filled' : 'subtle'}
|
||||||
}
|
color="blue"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => onRangeChange?.(opt.value)}
|
||||||
|
loading={isLoading && range === opt.value}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box h={300} mt="lg">
|
<Box h={300} mt="lg">
|
||||||
<LineChart
|
<AreaChart
|
||||||
h={300}
|
h={300}
|
||||||
data={data}
|
data={data}
|
||||||
dataKey="date"
|
dataKey="date"
|
||||||
@@ -53,12 +76,33 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
|||||||
gridAxis="x"
|
gridAxis="x"
|
||||||
withTooltip
|
withTooltip
|
||||||
tooltipAnimationDuration={200}
|
tooltipAnimationDuration={200}
|
||||||
|
fillOpacity={0.4}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
allowEscapeViewBox: { x: true, y: false },
|
content: ({ active, payload, label }: any) => {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#1A1B1E',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #373A40',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#2563EB' }}>
|
||||||
|
Activity: <span style={{ fontWeight: 700 }}>{payload[0].value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
styles={{
|
styles={{
|
||||||
root: {
|
root: {
|
||||||
'.recharts-line-curve': {
|
'.recharts-area-curve': {
|
||||||
strokeWidth: 3,
|
strokeWidth: 3,
|
||||||
filter: 'drop-shadow(0 4px 8px rgba(37, 99, 235, 0.3))'
|
filter: 'drop-shadow(0 4px 8px rgba(37, 99, 235, 0.3))'
|
||||||
}
|
}
|
||||||
@@ -71,9 +115,11 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps) {
|
export function VillageComparisonBarChart({ data = [], isLoading, range = 7, onRangeChange }: ActivityChartProps) {
|
||||||
const theme = useMantineTheme()
|
const theme = useMantineTheme()
|
||||||
|
|
||||||
|
const rangeLabel = range === 7 ? 'last 7 days' : range === 30 ? 'last 30 days' : 'last 3 months'
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
||||||
<Stack gap="md" h="100%">
|
<Stack gap="md" h="100%">
|
||||||
@@ -84,9 +130,24 @@ export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps)
|
|||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Box>
|
<Box>
|
||||||
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
|
<Text fw={700} size="sm">USAGE COMPARISON BETWEEN VILLAGES</Text>
|
||||||
<Text size="xs" c="dimmed">Most active village deployments</Text>
|
<Text size="xs" c="dimmed">Most active village deployments — {rangeLabel}</Text>
|
||||||
</Box>
|
</Box>
|
||||||
</Group>
|
</Group>
|
||||||
|
<Group gap={4}>
|
||||||
|
{RANGE_OPTIONS.map((opt) => (
|
||||||
|
<Button
|
||||||
|
key={opt.value}
|
||||||
|
size="compact-xs"
|
||||||
|
variant={range === opt.value ? 'filled' : 'subtle'}
|
||||||
|
color="violet"
|
||||||
|
radius="md"
|
||||||
|
onClick={() => onRangeChange?.(opt.value)}
|
||||||
|
loading={isLoading && range === opt.value}
|
||||||
|
>
|
||||||
|
{opt.label}
|
||||||
|
</Button>
|
||||||
|
))}
|
||||||
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Box h={300} mt="lg">
|
<Box h={300} mt="lg">
|
||||||
|
|||||||
@@ -27,8 +27,8 @@ export const API_URLS = {
|
|||||||
return `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?${params}`
|
return `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?${params}`
|
||||||
},
|
},
|
||||||
getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`,
|
getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`,
|
||||||
getDailyActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity`,
|
getDailyActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity?range=${range}`,
|
||||||
getComparisonActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity`,
|
getComparisonActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity?range=${range}`,
|
||||||
postVersionUpdate: () => `${DESA_PLUS_PROXY}/api/monitoring/version-update`,
|
postVersionUpdate: () => `${DESA_PLUS_PROXY}/api/monitoring/version-update`,
|
||||||
createVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/create-villages`,
|
createVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/create-villages`,
|
||||||
createUser: () => `${DESA_PLUS_PROXY}/api/monitoring/create-user`,
|
createUser: () => `${DESA_PLUS_PROXY}/api/monitoring/create-user`,
|
||||||
|
|||||||
@@ -52,9 +52,12 @@ function AppOverviewPage() {
|
|||||||
const [maintenance, setMaintenance] = useState(false)
|
const [maintenance, setMaintenance] = useState(false)
|
||||||
const [isSaving, setIsSaving] = useState(false)
|
const [isSaving, setIsSaving] = useState(false)
|
||||||
|
|
||||||
|
const [dailyRange, setDailyRange] = useState<7 | 30 | 90>(7)
|
||||||
|
const [comparisonRange, setComparisonRange] = useState<7 | 30 | 90>(7)
|
||||||
|
|
||||||
const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
|
const { data: gridRes, isLoading: gridLoading, mutate: mutateGrid } = useSWR(isDesaPlus ? API_URLS.getGridOverview() : null, fetcher)
|
||||||
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
|
const { data: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity(dailyRange) : null, fetcher)
|
||||||
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : null, fetcher)
|
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity(comparisonRange) : null, fetcher)
|
||||||
|
|
||||||
const { data: appData, isLoading: appLoading } = useSWR(`/api/apps/${appId}`, fetcher)
|
const { data: appData, isLoading: appLoading } = useSWR(`/api/apps/${appId}`, fetcher)
|
||||||
|
|
||||||
@@ -253,8 +256,8 @@ function AppOverviewPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||||
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} />
|
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} range={dailyRange} onRangeChange={setDailyRange} />
|
||||||
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} />
|
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} range={comparisonRange} onRangeChange={setComparisonRange} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<ErrorDataTable ref={errorTableRef} appId={appId} />
|
<ErrorDataTable ref={errorTableRef} appId={appId} />
|
||||||
|
|||||||
@@ -123,16 +123,44 @@ function ActivityChart({ villageId }: { villageId: string }) {
|
|||||||
dataKey="label"
|
dataKey="label"
|
||||||
series={[{ name: 'activity', color: '#2563EB' }]}
|
series={[{ name: 'activity', color: '#2563EB' }]}
|
||||||
curveType="monotone"
|
curveType="monotone"
|
||||||
withTooltip={true}
|
withTooltip
|
||||||
withDots={true}
|
withDots
|
||||||
withPointLabels={false}
|
withPointLabels={false}
|
||||||
|
tickLine="none"
|
||||||
|
gridAxis="x"
|
||||||
|
fillOpacity={0.4}
|
||||||
tooltipAnimationDuration={150}
|
tooltipAnimationDuration={150}
|
||||||
tooltipProps={{
|
tooltipProps={{
|
||||||
allowEscapeViewBox: { x: true, y: false },
|
content: ({ active, payload, label }: any) => {
|
||||||
|
if (!active || !payload?.length) return null
|
||||||
|
return (
|
||||||
|
<div style={{
|
||||||
|
backgroundColor: '#1A1B1E',
|
||||||
|
padding: '8px 12px',
|
||||||
|
borderRadius: '6px',
|
||||||
|
border: '1px solid #373A40',
|
||||||
|
boxShadow: '0 4px 12px rgba(0,0,0,0.5)',
|
||||||
|
pointerEvents: 'none',
|
||||||
|
whiteSpace: 'nowrap',
|
||||||
|
}}>
|
||||||
|
<div style={{ fontSize: '12px', fontWeight: 600, color: '#C1C2C5', marginBottom: '4px' }}>
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: '11px', color: '#2563EB' }}>
|
||||||
|
Activity: <span style={{ fontWeight: 700 }}>{payload[0].value}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
activeDotProps={{
|
activeDotProps={{ r: 6, strokeWidth: 2 }}
|
||||||
r: 6,
|
styles={{
|
||||||
strokeWidth: 2,
|
root: {
|
||||||
|
'.recharts-area-curve': {
|
||||||
|
strokeWidth: 3,
|
||||||
|
filter: 'drop-shadow(0 4px 8px rgba(37, 99, 235, 0.3))',
|
||||||
|
},
|
||||||
|
},
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|||||||
Reference in New Issue
Block a user