Compare commits

..

2 Commits

Author SHA1 Message Date
f213a9455b Fix pagination bounds in HistoryChart and add responsive grid to TopTags 2026-05-07 16:42:52 +05:30
009ab50b47 major refactor of the dashboard and react-openapi integration (#1)
## Summary

This MR introduces a major refactor of the dashboard and react-openapi integration, focusing on configurability, separation of concerns, and improved extensibility.

---

## Key Changes

### 1. OpenAPI / Admin Refactor

* Extracted `ConfigContext` into a dedicated provider.
* Introduced `AppProvider` to encapsulate:

  * Config loading
  * API client initialization
  * React Query setup
* Removed internal `QueryClientProvider` from `Admin` for better composability.
* `Admin` now supports both:

  * Standalone usage
  * Nested usage inside an existing provider

### 2. Resource System Improvements

* Added `hidden` flag to `ResourceConfig` and overrides.
* Admin UI now filters out hidden resources.
* Added `useResourceByName` helper for dynamic resource access.
* Improved `useResource`:

  * Handles undefined config safely
  * Adds guards for missing endpoints
  * Disables queries when endpoint is absent

### 3. API Client Enhancements

* Added custom `paramsSerializer`:

  * Serializes arrays without `[]`
  * Ensures backend-compatible query formats

### 4. Dashboard Architecture Overhaul

* Replaced hardcoded dashboard with **config-driven system**:

  * Introduced `ConfigurableDashboard`
  * Dashboard sections defined via config
* New state model:

  * `mode` (expense/income)
  * `periodType` (rolling/calendar)
  * `comparison`
  * `selectedPeriodId`

### 5. Component Refactor (View / Logic Split)

* Split major components into:

  * `.tsx` (logic/controller)
  * `.view.tsx` (presentation)
  * `.models.ts` (types)
* Applied to:

  * Dashboard
  * HistoryChart
  * ProgressCard
  * LatestItems

### 6. HistoryChart Redesign

* Fully rebuilt using report-driven data
* Supports:

  * Weekly / Monthly / Yearly / FY / Full views
  * Rolling vs Calendar periods
  * Comparison mode (auto-aligned offsets)
* Introduced:

  * Bucket merging logic
  * Dynamic comparison attachment

### 7. Reporting Integration

* Dashboard now powered by:

  * `useReport`
  * `prepareReport`
* Removes need for multiple manual API calls

### 8. UI / UX Improvements

* Theme-aware color system
* Dynamic accent colors per mode
* Cleaner layout using section-based rendering
* Improved selection and interaction in charts

### 9. Cleanup & Removals

* Removed legacy components:

  * Old `HistoryChart`
  * Old `ProgressCard`
* Simplified Header layout spacing

---

## Behavior Changes

* Hidden resources are no longer visible in Admin UI.
* Dashboard is now entirely configuration-driven.
* API query params for arrays no longer use `[]`.
* Resource hooks no longer crash on missing config.

---

## Risks / Considerations

* Dashboard depends on correct configuration structure.
* Hidden flag may unintentionally hide resources if misconfigured.
* Query param serialization change must align with backend expectations.

---

## Follow-ups

* Add typing improvements to remove `@ts-ignore` in `GenericForm`.
* Extend dashboard config with more reusable section presets.
* Add tests for report aggregation and comparison logic.

---

Reviewed-on: #1
Co-authored-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
Co-committed-by: Vishesh 'ironeagle' Bangotra <aetoskia@gmail.com>
2026-05-07 11:00:54 +00:00
13 changed files with 368 additions and 156 deletions

View File

@@ -7,6 +7,28 @@ import { createApiClient } from "../../react-auth";
let _api: AxiosInstance | null = null; let _api: AxiosInstance | null = null;
let _auth: AxiosInstance | null = null; let _auth: AxiosInstance | null = null;
function withParamsSerializer(instance: AxiosInstance): AxiosInstance {
instance.defaults.paramsSerializer = {
serialize: (params) => {
const searchParams = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (Array.isArray(value)) {
value.forEach((v) => {
searchParams.append(key, String(v)); // NO []
});
} else if (value !== undefined && value !== null) {
searchParams.append(key, String(value));
}
});
return searchParams.toString();
},
};
return instance;
}
export const api = { export const api = {
get: (...args: Parameters<AxiosInstance["get"]>) => { get: (...args: Parameters<AxiosInstance["get"]>) => {
if (!_api) throw new Error("API client not initialized"); if (!_api) throw new Error("API client not initialized");
@@ -38,6 +60,6 @@ export const auth = {
}; };
export function initializeApiClients(baseUrl: string, authBaseUrl: string) { export function initializeApiClients(baseUrl: string, authBaseUrl: string) {
_api = createApiClient(baseUrl); _api = withParamsSerializer(createApiClient(baseUrl));
_auth = createApiClient(authBaseUrl); _auth = withParamsSerializer(createApiClient(authBaseUrl));
} }

View File

@@ -15,9 +15,10 @@ import {
export default function Dashboard() { export default function Dashboard() {
const report = useReport({ const report = useReport({
periods: ["weekly", "monthly", "full"],
rolling: true, rolling: true,
include_transactions: true, include_transactions: true,
group_by: ["payee"], group_by: ["tags"],
}) })
const isLoading = report.isLoading; const isLoading = report.isLoading;

View File

@@ -19,7 +19,6 @@ export interface DashboardSection {
title?: string; title?: string;
summary?: string; summary?: string;
component: React.ComponentType<any>; component: React.ComponentType<any>;
dataKey: string;
settings?: Record<string, any>; settings?: Record<string, any>;
isList?: boolean; isList?: boolean;
style?: { style?: {

View File

@@ -109,7 +109,7 @@ export default function DashboardView({
{...section.settings} {...section.settings}
header={section.title} header={section.title}
summary={section.summary} summary={section.summary}
data={data} reportData={data}
title={section.title} title={section.title}
accentColor={colors.primary} accentColor={colors.primary}
colorScheme={colors} colorScheme={colors}

View File

@@ -3,9 +3,11 @@ import {
DashboardPeriodType, DashboardPeriodType,
DashboardSelectedPeriodId DashboardSelectedPeriodId
} from "../Dashboard"; } from "../Dashboard";
import { ReportData } from "../../features/report";
export interface _ChartDataPoint { export interface _ChartDataPoint {
id: string; id: string;
label: string;
amount: number; amount: number;
highlighted?: boolean; highlighted?: boolean;
} }
@@ -14,26 +16,19 @@ export interface ChartDataPoint extends _ChartDataPoint {
compare?: _ChartDataPoint; compare?: _ChartDataPoint;
} }
export interface ChartData {
weekly?: Record<string, ChartDataPoint[]>;
monthly?: Record<string, ChartDataPoint[]>;
// yearly?: Record<string, ChartDataPoint[]>;
// fyly?: Record<string, ChartDataPoint[]>;
// full?: Record<string, ChartDataPoint[]>;
}
export interface HistoryChartProps { export interface HistoryChartProps {
header: string; header: string;
summary?: string; summary?: string;
tabs: string[]; tabs: string[];
data: ChartData;
reportData: ReportData;
colorScheme: { colorScheme: {
primary: string; primary: string;
light: string; light: string;
text: string; text: string;
}; };
// State management
mode: DashboardMode; mode: DashboardMode;
periodType: DashboardPeriodType; periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId; selectedPeriodId: DashboardSelectedPeriodId;

View File

@@ -1,45 +1,200 @@
import * as React from "react"; import * as React from "react";
import { ChartDataPoint, HistoryChartProps, ChartData } from "./HistoryChart.models"; import { HistoryChartProps, ChartDataPoint } from "./HistoryChart.models";
import HistoryChartView from "./HistoryChart.view"; import HistoryChartView from "./HistoryChart.view";
import { ReportPeriod } from "../../features/report";
type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
const TAB_TO_KEY: Record<string, "weekly" | "monthly" | "yearly" | "fyly" | "full"> = {
Weekly: "weekly",
Monthly: "monthly",
Yearly: "yearly",
'Financial Year': "fyly",
'All Time': "full"
};
function getAmount(p: ReportPeriod, mode: "expense" | "income") {
return mode === "expense" ? p.expenses.sum : p.incomes.sum;
}
function mergeMetric(a: any, b: any) {
const sum = a.sum + b.sum;
const count = a.count + b.count;
return {
...a,
sum,
count,
average: count > 0 ? sum / count : 0,
transactions: a.transactions || b.transactions
? [
...(a.transactions || []),
...(b.transactions || [])
]
: undefined
};
}
function mergeBuckets(
buckets: any[],
key: "weekly" | "monthly" | "yearly" | "fyly" | "full"
): DecoratedPeriod[] {
const map = new Map<string, DecoratedPeriod>();
for (const bucket of buckets) {
const periods = (bucket.periods[key] || []) as DecoratedPeriod[];
for (const p of periods) {
const existing = map.get(p.id);
if (!existing) {
map.set(p.id, {
...p,
expenses: { ...p.expenses },
incomes: { ...p.incomes }
});
} else {
map.set(p.id, {
...existing,
expenses: mergeMetric(existing.expenses, p.expenses),
incomes: mergeMetric(existing.incomes, p.incomes)
});
}
}
}
return Array.from(map.values()).sort(
(a, b) => new Date(a.start).getTime() - new Date(b.start).getTime()
);
}
function attachComparison(
points: ChartDataPoint[],
key: "weekly" | "monthly" | "yearly" | "fyly" | "full"
): ChartDataPoint[] {
const getCompareIndex = (i: number) => {
if (key === "weekly") return i - 4;
if (key === "monthly") return i - 12;
if (key === "yearly") return i - 1;
if (key === "fyly") return i - 1;
return -1;
};
return points.map((p, i) => {
const ci = getCompareIndex(i);
return {
...p,
compare:
ci >= 0 && points[ci]
? {
id: points[ci].id,
label: points[ci].label,
amount: points[ci].amount
}
: undefined
};
});
}
function buildChartData(
reportData: HistoryChartProps["reportData"],
key: "weekly" | "monthly" | "yearly" | "fyly" | "full",
mode: "expense" | "income",
comparison: boolean
): ChartDataPoint[] {
const merged = mergeBuckets(reportData.buckets, key);
console.log("Merged periods:", merged);
let points: ChartDataPoint[] = merged.map((p) => ({
id: p.id,
label: p.label,
amount: getAmount(p, mode)
}));
if (comparison) {
points = attachComparison(points, key);
}
return points;
}
export default function HistoryChart(props: HistoryChartProps) { export default function HistoryChart(props: HistoryChartProps) {
const { tabs, data, mode, periodType, comparison } = props; const {
tabs,
reportData,
mode,
periodType,
comparison,
selectedPeriodId,
setSelectedPeriodId
} = props;
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || ""); const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
const [startIndex, setStartIndex] = React.useState(0); const [startIndex, setStartIndex] = React.useState(0);
const activeDataKey = activeTab.toLowerCase() as keyof ChartData; const activeDataKey = TAB_TO_KEY[activeTab];
let rawData: ChartDataPoint[] = []; const currentData = React.useMemo(() => {
return buildChartData(reportData, activeDataKey, mode, comparison);
const section = data[activeDataKey]; }, [reportData, activeDataKey, mode, comparison]);
rawData = section?.[periodType] || [];
const currentData = rawData;
const maxAmount = const maxAmount =
currentData.length > 0 currentData.length > 0
? Math.max( ? Math.max(
...currentData.flatMap((d) => ...currentData.flatMap((d) =>
comparison ? [d.amount, d.compare?.amount ?? 0] : [d.amount] comparison
? [d.amount, ...(d.compare ? [d.compare.amount] : [])]
: [d.amount]
), ),
1 1
) )
: 1; : 1;
const visibleCountMap = { daily: 7, weekly: 6, monthly: 4 }; const visibleCountMap = {
// const visibleCountMap = { daily: 7, weekly: 6, monthly: 4, yearly: 4, fyly: 4, full: 4 }; weekly: 6,
const visibleCount = visibleCountMap[activeDataKey]; monthly: 4,
yearly: 4,
fyly: 4,
full: 4,
};
const visibleCount = visibleCountMap[activeDataKey] ?? 4;
const total = currentData.length; const total = currentData.length;
const clampedStartIndex = Math.min(startIndex, Math.max(total - visibleCount, 0)); const clampedStartIndex = Math.min(
startIndex,
Math.max(total - visibleCount, 0)
);
React.useEffect(() => {
if (startIndex !== clampedStartIndex) {
setStartIndex(clampedStartIndex);
}
}, [startIndex, clampedStartIndex]);
const visibleData = currentData.slice( const visibleData = currentData.slice(
clampedStartIndex, clampedStartIndex,
clampedStartIndex + visibleCount clampedStartIndex + visibleCount
); );
React.useEffect(() => {
setSelectedPeriodId(null);
}, [activeTab, periodType]);
React.useEffect(() => {
if (
selectedPeriodId &&
!visibleData.some((p) => p.id === selectedPeriodId)
) {
setSelectedPeriodId(null);
}
}, [visibleData, selectedPeriodId]);
return ( return (
<HistoryChartView <HistoryChartView
{...props} {...props}
@@ -49,7 +204,7 @@ export default function HistoryChart(props: HistoryChartProps) {
visibleData={visibleData} visibleData={visibleData}
maxAmount={maxAmount} maxAmount={maxAmount}
visibleCount={visibleCount} visibleCount={visibleCount}
startIndex={startIndex} startIndex={clampedStartIndex}
setStartIndex={setStartIndex} setStartIndex={setStartIndex}
activeDataKey={activeDataKey} activeDataKey={activeDataKey}
/> />

View File

@@ -25,19 +25,3 @@ export const formatDisplay = (
return `${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`; return `${formatShort(base)} (${sign}${formatShort(Math.abs(diff))})`;
}; };
export const formatLabel = (label: string, type: string) => {
if (type === "monthly") return label;
if (type === "weekly") {
const parts = label.split(" - ");
if (parts.length === 2) {
const [start, end] = parts;
const startDay = start.split(" ")[0];
const [endDay, month] = end.split(" ");
return `${startDay}${endDay} ${month}`;
}
}
return label;
};

View File

@@ -14,7 +14,7 @@ import {
ChartDataPoint, ChartDataPoint,
HistoryChartProps, HistoryChartProps,
} from "./HistoryChart.models"; } from "./HistoryChart.models";
import { formatDisplay, formatLabel } from "./HistoryChart.utils"; import { formatDisplay } from "./HistoryChart.utils";
interface ViewProps extends HistoryChartProps { interface ViewProps extends HistoryChartProps {
activeTab: string; activeTab: string;
@@ -35,7 +35,6 @@ export default function HistoryChartView(props: ViewProps) {
tabs, tabs,
colorScheme, colorScheme,
// State management
mode, mode,
periodType, periodType,
selectedPeriodId, selectedPeriodId,
@@ -45,7 +44,6 @@ export default function HistoryChartView(props: ViewProps) {
setSelectedPeriodId, setSelectedPeriodId,
toggleComparison, toggleComparison,
// HistoryChart state management
activeTab, activeTab,
setActiveTab, setActiveTab,
currentData, currentData,
@@ -60,19 +58,28 @@ export default function HistoryChartView(props: ViewProps) {
const theme = useTheme(); const theme = useTheme();
const isDark = theme.palette.mode === "dark"; const isDark = theme.palette.mode === "dark";
const total = currentData.length;
const maxStartIndex = Math.max(total - visibleCount, 0);
const clampedStartIndex = Math.min(startIndex, maxStartIndex);
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => { const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
if (newTab !== null) setActiveTab(newTab); if (newTab !== null) setActiveTab(newTab);
}; };
const canGoLeft = startIndex > 0; const canGoLeft = clampedStartIndex > 0;
const canGoRight = startIndex + visibleCount < currentData.length; const canGoRight = clampedStartIndex < maxStartIndex;
const handlePrev = () => { const handlePrev = () => {
if (canGoLeft) setStartIndex((prev) => prev - visibleCount); if (!canGoLeft) return;
setStartIndex((prev) => Math.max(prev - visibleCount, 0));
}; };
const handleNext = () => { const handleNext = () => {
if (canGoRight) setStartIndex((prev) => prev + visibleCount); if (!canGoRight) return;
setStartIndex((prev) => {
const next = prev + visibleCount;
return Math.min(next, maxStartIndex);
});
}; };
return ( return (
@@ -85,10 +92,9 @@ export default function HistoryChartView(props: ViewProps) {
border: "1px solid", border: "1px solid",
borderColor: "divider", borderColor: "divider",
bgcolor: isDark ? "background.paper" : colorScheme.light, bgcolor: isDark ? "background.paper" : colorScheme.light,
transition: 'background-color 0.3s ease, border-color 0.3s ease'
}} }}
> >
<Typography variant="h6" fontWeight={700} gutterBottom sx={{ color: isDark ? 'text.primary' : colorScheme.text }}> <Typography variant="h6" fontWeight={700} gutterBottom>
{header} {header}
</Typography> </Typography>
@@ -106,12 +112,10 @@ export default function HistoryChartView(props: ViewProps) {
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
<Box sx={{ display: "flex", alignItems: "center", justifyContent: "space-between", mb: 3 }}> <Box sx={{ display: "flex", justifyContent: "space-between", mb: 3 }}>
<ToggleButtonGroup value={periodType} exclusive onChange={togglePeriodType} size="small"> <ToggleButtonGroup value={periodType} exclusive onChange={togglePeriodType} size="small">
<ToggleButton value="rolling">Rolling</ToggleButton> <ToggleButton value="rolling">Rolling</ToggleButton>
<ToggleButton value="calendar" disabled={activeDataKey === "daily"}> <ToggleButton value="calendar">Calendar</ToggleButton>
Calendar
</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
<ToggleButton <ToggleButton
@@ -119,22 +123,6 @@ export default function HistoryChartView(props: ViewProps) {
selected={comparison} selected={comparison}
onChange={toggleComparison} onChange={toggleComparison}
size="small" size="small"
sx={{
textTransform: "none",
borderRadius: 2,
px: 2,
color: "text.secondary",
border: "1px solid",
borderColor: "divider",
"&.Mui-selected": {
color: "white",
bgcolor: "success.main",
borderColor: "success.main"
},
"&.Mui-selected:hover": {
bgcolor: "success.dark"
}
}}
> >
Compare Compare
</ToggleButton> </ToggleButton>
@@ -143,19 +131,7 @@ export default function HistoryChartView(props: ViewProps) {
{currentData.length > 0 ? ( {currentData.length > 0 ? (
<Box sx={{ position: "relative", mt: 4 }}> <Box sx={{ position: "relative", mt: 4 }}>
{canGoLeft && ( {canGoLeft && (
<IconButton <IconButton onClick={handlePrev} size="small" sx={{ position: "absolute", left: 0, top: "50%" }}>
onClick={handlePrev}
size="small"
sx={{
position: "absolute",
left: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
bgcolor: "background.paper",
boxShadow: 1
}}
>
<ChevronLeftIcon fontSize="small" /> <ChevronLeftIcon fontSize="small" />
</IconButton> </IconButton>
)} )}
@@ -166,92 +142,67 @@ export default function HistoryChartView(props: ViewProps) {
const compareHeight = comparison const compareHeight = comparison
? ((point.compare?.amount ?? 0) / maxAmount) * 100 ? ((point.compare?.amount ?? 0) / maxAmount) * 100
: 0; : 0;
const labelHeight = Math.max(currentHeight, compareHeight);
const isSelected = selectedPeriodId === point.id; const isSelected = selectedPeriodId === point.id;
const display = formatDisplay(point, activeTab.toLowerCase(), comparison); const display = formatDisplay(point, activeDataKey, comparison);
return ( return (
<Box <Box
key={point.id} key={point.id}
onClick={() => setSelectedPeriodId(isSelected ? null : point.id)} onClick={() =>
setSelectedPeriodId(isSelected ? null : point.id)
}
sx={{ sx={{
flex: 1, flex: 1,
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
justifyContent: "flex-end", cursor: "pointer",
height: "100%", height: "100%"
cursor: "pointer"
}} }}
> >
<Box sx={{ display: "flex", alignItems: "flex-end", gap: comparison ? 1 : 0.5, height: "100%", position: "relative" }}> <Box sx={{ display: "flex", alignItems: "flex-end", gap: 1, height: "100%" }}>
<Typography
variant="caption"
sx={{
position: "absolute",
bottom: `${labelHeight}%`,
left: "50%",
transform: "translate(-50%, -6px)",
fontSize: "0.65rem",
whiteSpace: "nowrap",
pointerEvents: "none",
color: 'text.secondary',
fontWeight: 600
}}
>
{isSelected ? `SELECTED: ${display}` : display}
</Typography>
{comparison && ( {comparison && (
<Box sx={{ width: 8, height: `${compareHeight}%`, bgcolor: isDark ? alpha(colorScheme.primary, 0.3) : alpha(colorScheme.primary, 0.4), borderRadius: '4px 4px 0 0' }} /> <Box
sx={{
width: 8,
height: `${compareHeight}%`,
bgcolor: alpha(colorScheme.primary, 0.4),
borderRadius: "4px 4px 0 0"
}}
/>
)} )}
<Box <Box
sx={{ sx={{
width: comparison ? 10 : 16, width: 12,
height: `${currentHeight}%`, height: `${currentHeight}%`,
bgcolor: point.highlighted ? colorScheme.primary : isDark ? alpha(colorScheme.primary, 0.8) : alpha(colorScheme.primary, 0.9), bgcolor: isSelected ? "warning.main" : colorScheme.primary,
borderRadius: '4px 4px 0 0', borderRadius: "4px 4px 0 0"
boxShadow: point.highlighted ? `0 0 10px ${alpha(colorScheme.primary, 0.5)}` : 'none'
}} }}
/> />
</Box> </Box>
<Box sx={{ mt: 1.5, textAlign: "center", display: "flex", flexDirection: "column", alignItems: "center", lineHeight: 1.1 }}> <Typography variant="caption">
<Typography variant="caption" sx={{ fontSize: "0.7rem", opacity: 0.8, color: 'text.primary', fontWeight: 500 }}> {point.label}
{formatLabel(point.id, activeDataKey)} </Typography>
</Typography>
<Typography {comparison && point.compare && (
variant="caption" <Typography variant="caption" color="text.secondary">
sx={{ {point.compare.label}
fontSize: "0.65rem",
color: "text.disabled",
visibility: comparison && point.compare && activeDataKey !== "daily" ? "visible" : "hidden"
}}
>
{point.compare ? formatLabel(point.compare.id, activeDataKey) : "placeholder"}
</Typography> </Typography>
</Box> )}
<Typography variant="caption">
{display}
</Typography>
</Box> </Box>
); );
})} })}
</Box> </Box>
{canGoRight && ( {canGoRight && (
<IconButton <IconButton onClick={handleNext} size="small" sx={{ position: "absolute", right: 0, top: "50%" }}>
onClick={handleNext}
size="small"
sx={{
position: "absolute",
right: 0,
top: "50%",
transform: "translateY(-50%)",
zIndex: 2,
bgcolor: "background.paper",
boxShadow: 1
}}
>
<ChevronRightIcon fontSize="small" /> <ChevronRightIcon fontSize="small" />
</IconButton> </IconButton>
)} )}

View File

@@ -0,0 +1,109 @@
import * as React from "react";
import { Box } from "@mui/material";
import { ReportData, ReportPeriod } from "../../features/report";
import ProgressCard from "./ProgressCard";
type Props = {
reportData: ReportData;
mode: "expense" | "income";
selectedPeriodId?: string | null;
compact?: boolean;
};
type DecoratedPeriod = ReportPeriod & {
id: string;
label: string;
};
function getAmount(p: ReportPeriod, mode: "expense" | "income") {
return mode === "expense" ? p.expenses.sum : p.incomes.sum;
}
function findPeriod(
periods: DecoratedPeriod[],
selectedPeriodId?: string | null
) {
if (!periods.length) return null;
if (selectedPeriodId) {
const match = periods.find((p) => p.id === selectedPeriodId);
if (match) return match;
}
// fallback → latest
return periods.reduce((latest, p) =>
new Date(p.start).getTime() > new Date(latest.start).getTime()
? p
: latest
);
}
export default function TopTags({
reportData,
mode,
selectedPeriodId,
compact = true
}: Props) {
const { items, total } = React.useMemo(() => {
const tagMap = new Map<string, number>();
for (const bucket of reportData.buckets) {
const tags = bucket.group_key.tags;
if (!tags || tags.length === 0) continue;
// Prefer FULL if available
const fullPeriods = (bucket.periods.full || []) as DecoratedPeriod[];
const periodsToUse =
selectedPeriodId
? Object.values(bucket.periods).flat() as DecoratedPeriod[]
: fullPeriods;
const period = findPeriod(periodsToUse, selectedPeriodId);
if (!period) continue;
const amount = getAmount(period, mode);
for (const tag of tags) {
tagMap.set(tag, (tagMap.get(tag) || 0) + amount);
}
}
const arr = Array.from(tagMap.entries()).map(([tag, amount]) => ({
tag,
amount
}));
arr.sort((a, b) => b.amount - a.amount);
const top = arr.slice(0, 4);
const total = top.reduce((sum, t) => sum + t.amount, 0);
return { items: top, total };
}, [reportData, mode, selectedPeriodId]);
return (
<Box
sx={{
display: "grid",
gridTemplateColumns: {
xs: "1fr",
sm: "repeat(2, 1fr)",
md: "repeat(4, 1fr)"
},
gap: 2
}}
>
{items.map((item) => (
<ProgressCard
key={item.tag}
header={item.tag}
progressAmount={item.amount}
totalAmount={total}
compact={compact}
colorTheme={mode === "expense" ? "error" : "success"}
/>
))}
</Box>
);
}

View File

@@ -1,7 +1,7 @@
import HistoryChart from "./components/HistoryChart"; import HistoryChart from "./components/HistoryChart";
import ProgressCard from "./components/ProgressCard";
import LatestItems from "./components/LatestItems"; import LatestItems from "./components/LatestItems";
import { DashboardConfig } from "./components/Dashboard"; import { DashboardConfig } from "./components/Dashboard";
import TopTags from "./components/ProgressCard/TopTags";
export const configuration: DashboardConfig = { export const configuration: DashboardConfig = {
sections: [ sections: [
@@ -10,7 +10,6 @@ export const configuration: DashboardConfig = {
title: "Breakdown", title: "Breakdown",
summary: "Interactive chronological tracking", summary: "Interactive chronological tracking",
component: HistoryChart, component: HistoryChart,
dataKey: "chartData",
settings: { settings: {
tabs: ["Weekly", "Monthly"], tabs: ["Weekly", "Monthly"],
// tabs: ["Weekly", "Monthly", "Yearly", "Financial Year", "All Time"], // tabs: ["Weekly", "Monthly", "Yearly", "Financial Year", "All Time"],
@@ -22,9 +21,7 @@ export const configuration: DashboardConfig = {
{ {
id: "top-payees", id: "top-payees",
title: 'Top Payees', title: 'Top Payees',
component: ProgressCard, component: TopTags,
dataKey: "topPayees",
isList: true,
settings: { settings: {
compact: true, compact: true,
}, },

View File

@@ -4,6 +4,7 @@ export {
export type { export type {
Transaction, Transaction,
ReportData, ReportData,
ReportPeriod,
} from './report.models' } from './report.models'
export { export {
prepareReport prepareReport

View File

@@ -67,6 +67,7 @@ export interface ReportBucket {
monthly?: ReportPeriod[]; monthly?: ReportPeriod[];
yearly?: ReportPeriod[]; yearly?: ReportPeriod[];
fyly?: ReportPeriod[]; fyly?: ReportPeriod[];
full?: ReportPeriod[];
}; };
} }
@@ -75,7 +76,7 @@ export interface ReportBucket {
// ----------------------------- // -----------------------------
export interface ReportData { export interface ReportData {
periods: ("weekly" | "monthly" | "yearly" | "fyly")[]; periods: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[];
rolling: boolean; rolling: boolean;
report_date?: string; report_date?: string;

View File

@@ -83,10 +83,7 @@ function buildLabel(
return `${dayFmt.format(start)} - ${dayFmt.format(end)}`; return `${dayFmt.format(start)} - ${dayFmt.format(end)}`;
case "monthly": case "monthly":
if (sameMonth(start, end)) { return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
}
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
case "yearly": case "yearly":
return yearFmt.format(start); return yearFmt.format(start);
@@ -110,8 +107,8 @@ function decoratePeriods(
): (ReportPeriod & { id: string; label: string })[] { ): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({ return periods.map((p) => ({
...p, ...p,
id: buildPeriodId(type, new Date(p.start), new Date(p.end)), id: buildPeriodId(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
label: buildLabel(type, new Date(p.start), new Date(p.end)), label: buildLabel(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
})); }));
} }