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:
2026-05-22 14:16:31 +08:00
parent 91dead0082
commit 0846ac924c
4 changed files with 120 additions and 28 deletions

View File

@@ -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">

View File

@@ -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`,

View File

@@ -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} />

View File

@@ -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))',
},
},
}} }}
/> />
)} )}