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>
This commit is contained in:
2026-05-07 11:00:54 +00:00
committed by aetos
parent b1db439dda
commit 009ab50b47
45 changed files with 1600 additions and 1099 deletions

View File

@@ -0,0 +1,11 @@
export {
useReport
} from './useReport'
export type {
Transaction,
ReportData,
ReportPeriod,
} from './report.models'
export {
prepareReport
} from './report.utils'

View File

@@ -0,0 +1,90 @@
export interface Payor {
name: string;
}
export interface Payee {
name: string;
}
export interface Account {
name: string;
number: string;
}
export interface Tag {
name: string;
icon: string;
description: string;
}
export interface Transaction {
payor: Payor;
payee: Payee;
amount: number;
account: Account;
tags: Tag[];
occurred_at: Date;
}
// -----------------------------
// Metrics
// -----------------------------
export interface ReportMetric {
sum: number;
count: number;
average: number;
transactions?: Transaction[];
}
// -----------------------------
// Period
// -----------------------------
export interface ReportPeriod {
start: Date;
end: Date;
expenses: ReportMetric;
incomes: ReportMetric;
}
// -----------------------------
// Group (bucket)
// -----------------------------
export type GroupKey = {
payee?: string[];
tags?: string[];
flow?: string[];
};
export interface ReportBucket {
group_key: GroupKey;
periods: {
weekly?: ReportPeriod[];
monthly?: ReportPeriod[];
yearly?: ReportPeriod[];
fyly?: ReportPeriod[];
full?: ReportPeriod[];
};
}
// -----------------------------
// Final Report
// -----------------------------
export interface ReportData {
periods: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[];
rolling: boolean;
report_date?: string;
group_by: ("payee" | "tags")[];
ignore_self: boolean;
include_transactions: boolean;
buckets: ReportBucket[];
}

View File

@@ -0,0 +1,134 @@
import {
ReportData,
ReportPeriod
} from "./report.models";
/* ---------- ID BUILDING ---------- */
function formatDate(d: Date): string {
const y = d.getUTCFullYear();
const m = String(d.getUTCMonth() + 1).padStart(2, "0");
const day = String(d.getUTCDate()).padStart(2, "0");
return `${y}-${m}-${day}`;
}
function buildPeriodId(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
start: Date,
end: Date
): string {
const s = formatDate(start);
const e = formatDate(end);
switch (type) {
case "weekly":
return `W:${s}_${e}`;
case "monthly":
return `M:${s}_${e}`;
case "yearly":
return `Y:${s}_${e}`;
case "fyly":
return `FY:${s}_${e}`;
case "full":
return `FULL:${s}_${e}`;
default:
return `${s}_${e}`;
}
}
/* ---------- LABEL BUILDING ---------- */
const dayFmt = new Intl.DateTimeFormat("en-GB", {
day: "numeric",
month: "short",
timeZone: "UTC",
});
const monthDayFmt = new Intl.DateTimeFormat("en-GB", {
month: "short",
day: "numeric",
timeZone: "UTC",
});
const monthFmt = new Intl.DateTimeFormat("en-GB", {
month: "short",
timeZone: "UTC",
});
const yearFmt = new Intl.DateTimeFormat("en-GB", {
year: "numeric",
timeZone: "UTC",
});
function sameMonth(a: Date, b: Date) {
return (
a.getUTCFullYear() === b.getUTCFullYear() &&
a.getUTCMonth() === b.getUTCMonth()
);
}
function buildLabel(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
start: Date,
end: Date
): string {
switch (type) {
case "weekly":
if (sameMonth(start, end)) {
const sDay = start.getUTCDate();
const eDay = end.getUTCDate();
const m = monthFmt.format(start);
return `${sDay} ${m} - ${eDay} ${m}`;
}
return `${dayFmt.format(start)} - ${dayFmt.format(end)}`;
case "monthly":
return `${monthFmt.format(start)} ${yearFmt.format(start)}`;
case "yearly":
return yearFmt.format(start);
case "fyly": {
const startY = start.getUTCFullYear();
const endY = end.getUTCFullYear();
return `FY ${startY}${String(endY).slice(-2)}`;
}
default:
return `${monthDayFmt.format(start)} - ${monthDayFmt.format(end)}`;
}
}
/* ---------- MAIN ---------- */
function decoratePeriods(
type: "weekly" | "monthly" | "yearly" | "fyly" | "full",
periods: ReportPeriod[]
): (ReportPeriod & { id: string; label: string })[] {
return periods.map((p) => ({
...p,
id: buildPeriodId(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
label: buildLabel(type, new Date(p.start + "Z"), new Date(p.end + "Z")),
}));
}
export function prepareReport(reportData: ReportData): ReportData {
return {
...reportData,
buckets: reportData.buckets.map((bucket) => {
const newPeriods: typeof bucket.periods = {};
for (const type of reportData.periods) {
const arr = bucket.periods[type];
if (arr) {
newPeriods[type] = decoratePeriods(type, arr);
}
}
return {
...bucket,
periods: newPeriods,
};
}),
};
}

View File

@@ -0,0 +1,20 @@
import { useResourceByName } from "../../../react-openapi";
export interface ReportParams {
periods?: ("weekly" | "monthly" | "yearly" | "fyly" | "full")[];
rolling?: boolean;
report_date?: string;
group_by?: ("payee" | "tags")[];
ignore_self?: boolean;
include_transactions?: boolean;
}
export function useReport(params: ReportParams) {
const { useList } = useResourceByName("reports");
return useList({
...params,
periods: params.periods,
group_by: params.group_by,
});
}