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
7 changed files with 168 additions and 34 deletions

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

@@ -8,11 +8,12 @@ type DecoratedPeriod = ReportPeriod & {
label: string; label: string;
}; };
const TAB_TO_KEY: Record<string, "weekly" | "monthly" | "yearly" | "fyly"> = { const TAB_TO_KEY: Record<string, "weekly" | "monthly" | "yearly" | "fyly" | "full"> = {
Weekly: "weekly", Weekly: "weekly",
Monthly: "monthly", Monthly: "monthly",
Yearly: "yearly", Yearly: "yearly",
FYLY: "fyly" 'Financial Year': "fyly",
'All Time': "full"
}; };
function getAmount(p: ReportPeriod, mode: "expense" | "income") { function getAmount(p: ReportPeriod, mode: "expense" | "income") {
@@ -39,7 +40,7 @@ function mergeMetric(a: any, b: any) {
function mergeBuckets( function mergeBuckets(
buckets: any[], buckets: any[],
key: "weekly" | "monthly" | "yearly" | "fyly" key: "weekly" | "monthly" | "yearly" | "fyly" | "full"
): DecoratedPeriod[] { ): DecoratedPeriod[] {
const map = new Map<string, DecoratedPeriod>(); const map = new Map<string, DecoratedPeriod>();
@@ -70,9 +71,38 @@ function mergeBuckets(
); );
} }
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( function buildChartData(
reportData: HistoryChartProps["reportData"], reportData: HistoryChartProps["reportData"],
key: "weekly" | "monthly" | "yearly" | "fyly", key: "weekly" | "monthly" | "yearly" | "fyly" | "full",
mode: "expense" | "income", mode: "expense" | "income",
comparison: boolean comparison: boolean
): ChartDataPoint[] { ): ChartDataPoint[] {
@@ -86,17 +116,7 @@ function buildChartData(
})); }));
if (comparison) { if (comparison) {
points = points.map((p, i) => ({ points = attachComparison(points, key);
...p,
compare:
i > 0
? {
id: points[i - 1].id,
label: points[i - 1].label,
amount: points[i - 1].amount
}
: undefined
}));
} }
return points; return points;
@@ -138,7 +158,8 @@ export default function HistoryChart(props: HistoryChartProps) {
weekly: 6, weekly: 6,
monthly: 4, monthly: 4,
yearly: 4, yearly: 4,
fyly: 4 fyly: 4,
full: 4,
}; };
const visibleCount = visibleCountMap[activeDataKey] ?? 4; const visibleCount = visibleCountMap[activeDataKey] ?? 4;

View File

@@ -58,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 (
@@ -148,7 +157,8 @@ export default function HistoryChartView(props: ViewProps) {
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
alignItems: "center", alignItems: "center",
cursor: "pointer" cursor: "pointer",
height: "100%"
}} }}
> >
<Box sx={{ display: "flex", alignItems: "flex-end", gap: 1, height: "100%" }}> <Box sx={{ display: "flex", alignItems: "flex-end", gap: 1, height: "100%" }}>

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

@@ -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")),
})); }));
} }