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 {
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Stack,
|
||||
@@ -11,14 +12,29 @@ import {
|
||||
} from '@mantine/core'
|
||||
import { TbArrowUpRight, TbChartBar, TbTimeline } from 'react-icons/tb'
|
||||
|
||||
type DailyRange = 7 | 30 | 90
|
||||
|
||||
interface ChartProps {
|
||||
data?: any[]
|
||||
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 rangeLabel = range === 7 ? 'last 7 days' : range === 30 ? 'last 30 days' : 'last 3 months'
|
||||
|
||||
return (
|
||||
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
||||
<Stack gap="md" h="100%">
|
||||
@@ -29,21 +45,28 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<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>
|
||||
</Group>
|
||||
{
|
||||
isLoading && (
|
||||
<Badge variant="light" color="blue" size="sm" rightSection={<TbArrowUpRight size={12} />}>
|
||||
...
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
|
||||
<Group gap={4}>
|
||||
{RANGE_OPTIONS.map((opt) => (
|
||||
<Button
|
||||
key={opt.value}
|
||||
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>
|
||||
|
||||
<Box h={300} mt="lg">
|
||||
<LineChart
|
||||
<AreaChart
|
||||
h={300}
|
||||
data={data}
|
||||
dataKey="date"
|
||||
@@ -53,12 +76,33 @@ export function VillageActivityLineChart({ data = [], isLoading }: ChartProps) {
|
||||
gridAxis="x"
|
||||
withTooltip
|
||||
tooltipAnimationDuration={200}
|
||||
fillOpacity={0.4}
|
||||
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={{
|
||||
root: {
|
||||
'.recharts-line-curve': {
|
||||
'.recharts-area-curve': {
|
||||
strokeWidth: 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 rangeLabel = range === 7 ? 'last 7 days' : range === 30 ? 'last 30 days' : 'last 3 months'
|
||||
|
||||
return (
|
||||
<Paper withBorder p="xl" radius="2xl" className="glass h-full">
|
||||
<Stack gap="md" h="100%">
|
||||
@@ -84,9 +130,24 @@ export function VillageComparisonBarChart({ data = [], isLoading }: ChartProps)
|
||||
</ThemeIcon>
|
||||
<Box>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
<Box h={300} mt="lg">
|
||||
|
||||
@@ -27,8 +27,8 @@ export const API_URLS = {
|
||||
return `${DESA_PLUS_PROXY}/api/monitoring/log-all-villages?${params}`
|
||||
},
|
||||
getGridOverview: () => `${DESA_PLUS_PROXY}/api/monitoring/grid-overview`,
|
||||
getDailyActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity`,
|
||||
getComparisonActivity: () => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity`,
|
||||
getDailyActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/daily-activity?range=${range}`,
|
||||
getComparisonActivity: (range: 7 | 30 | 90 = 7) => `${DESA_PLUS_PROXY}/api/monitoring/comparison-activity?range=${range}`,
|
||||
postVersionUpdate: () => `${DESA_PLUS_PROXY}/api/monitoring/version-update`,
|
||||
createVillages: () => `${DESA_PLUS_PROXY}/api/monitoring/create-villages`,
|
||||
createUser: () => `${DESA_PLUS_PROXY}/api/monitoring/create-user`,
|
||||
|
||||
@@ -52,9 +52,12 @@ function AppOverviewPage() {
|
||||
const [maintenance, setMaintenance] = 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: dailyRes, isLoading: dailyLoading, mutate: mutateDaily } = useSWR(isDesaPlus ? API_URLS.getDailyActivity() : null, fetcher)
|
||||
const { data: comparisonRes, isLoading: comparisonLoading, mutate: mutateComparison } = useSWR(isDesaPlus ? API_URLS.getComparisonActivity() : 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(comparisonRange) : null, fetcher)
|
||||
|
||||
const { data: appData, isLoading: appLoading } = useSWR(`/api/apps/${appId}`, fetcher)
|
||||
|
||||
@@ -253,8 +256,8 @@ function AppOverviewPage() {
|
||||
</Group>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, lg: 2 }} spacing="lg">
|
||||
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} />
|
||||
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} />
|
||||
<VillageActivityLineChart data={dailyData} isLoading={dailyLoading} range={dailyRange} onRangeChange={setDailyRange} />
|
||||
<VillageComparisonBarChart data={comparisonData} isLoading={comparisonLoading} range={comparisonRange} onRangeChange={setComparisonRange} />
|
||||
</SimpleGrid>
|
||||
|
||||
<ErrorDataTable ref={errorTableRef} appId={appId} />
|
||||
|
||||
@@ -123,16 +123,44 @@ function ActivityChart({ villageId }: { villageId: string }) {
|
||||
dataKey="label"
|
||||
series={[{ name: 'activity', color: '#2563EB' }]}
|
||||
curveType="monotone"
|
||||
withTooltip={true}
|
||||
withDots={true}
|
||||
withTooltip
|
||||
withDots
|
||||
withPointLabels={false}
|
||||
tickLine="none"
|
||||
gridAxis="x"
|
||||
fillOpacity={0.4}
|
||||
tooltipAnimationDuration={150}
|
||||
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={{
|
||||
r: 6,
|
||||
strokeWidth: 2,
|
||||
activeDotProps={{ r: 6, strokeWidth: 2 }}
|
||||
styles={{
|
||||
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