major refactor of the dashboard and react-openapi integration #1

Merged
aetos merged 44 commits from period-selection into main 2026-05-07 11:00:54 +00:00
5 changed files with 189 additions and 134 deletions
Showing only changes of commit df5cf9fbb6 - Show all commits

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,179 @@
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"> = {
Weekly: "weekly",
Monthly: "monthly",
Yearly: "yearly",
FYLY: "fyly"
};
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"
): 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 buildChartData(
reportData: HistoryChartProps["reportData"],
key: "weekly" | "monthly" | "yearly" | "fyly",
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 = points.map((p, i) => ({
...p,
compare:
i > 0
? {
id: points[i - 1].id,
label: points[i - 1].label,
amount: points[i - 1].amount
}
: undefined
}));
}
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
};
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 +183,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,
@@ -85,10 +83,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 +103,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 +114,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 +122,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 +133,66 @@ 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",
height: "100%",
cursor: "pointer" 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

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