Compare commits

..

7 Commits

8 changed files with 371 additions and 278 deletions

View File

@@ -51,7 +51,7 @@ function Dashboard({ basePath }: { basePath: string }) {
transition: 'transform 0.2s', transition: 'transform 0.2s',
'&:hover': { transform: 'translateY(-4px)', boxShadow: 4 } '&:hover': { transform: 'translateY(-4px)', boxShadow: 4 }
}} }}
onClick={() => navigate(`${basePath}/${res.name}`)} onClick={() => navigate(`/admin/${res.name}`)}
> >
<Typography variant="h6" color="primary">{res.pluralLabel}</Typography> <Typography variant="h6" color="primary">{res.pluralLabel}</Typography>
<Typography variant="body2" color="text.secondary">Manage {res.pluralLabel.toLowerCase()}</Typography> <Typography variant="body2" color="text.secondary">Manage {res.pluralLabel.toLowerCase()}</Typography>
@@ -88,7 +88,7 @@ function AdminApp({ basePath }: { basePath: string }) {
<AdminLayout <AdminLayout
username={currentUser.username} username={currentUser.username}
onLogout={logout} onLogout={logout}
onSelectResource={(name) => navigate(`${basePath}/${name}`)} onSelectResource={(name) => navigate(`/admin/${name}`)}
resources={config?.resources || []} resources={config?.resources || []}
> >
<Routes> <Routes>

View File

@@ -104,12 +104,12 @@ export default function EnhancedTable({
<GridActionsCellItem <GridActionsCellItem
icon={<VisibilityIcon />} icon={<VisibilityIcon />}
label="View" label="View"
onClick={() => navigate(`/${config.name}/${params.id}`)} onClick={() => navigate(`/admin/${config.name}/${params.id}`)}
/>, />,
<GridActionsCellItem <GridActionsCellItem
icon={<EditIcon />} icon={<EditIcon />}
label="Edit" label="Edit"
onClick={() => navigate(`/${config.name}/edit/${params.id}`)} onClick={() => navigate(`/admin/${config.name}/edit/${params.id}`)}
/>, />,
<GridActionsCellItem <GridActionsCellItem
icon={<DeleteIcon />} icon={<DeleteIcon />}
@@ -222,8 +222,8 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
<MoreVertIcon fontSize="small" /> <MoreVertIcon fontSize="small" />
</IconButton> </IconButton>
<Menu anchorEl={anchorEl} open={open} onClose={handleClose}> <Menu anchorEl={anchorEl} open={open} onClose={handleClose}>
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/${id}`); }}>View</MenuItem> <MenuItem onClick={() => { handleClose(); navigate(`/admin/${config.name}/${id}`); }}>View</MenuItem>
<MenuItem onClick={() => { handleClose(); navigate(`/${config.name}/edit/${id}`); }}>Edit</MenuItem> <MenuItem onClick={() => { handleClose(); navigate(`/admin/${config.name}/edit/${id}`); }}>Edit</MenuItem>
<MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem> <MenuItem onClick={() => { handleClose(); onDelete(id); }} sx={{ color: 'error.main' }}>Delete</MenuItem>
</Menu> </Menu>
</Box> </Box>
@@ -242,7 +242,7 @@ function MobileCardRow({ row, config, onDelete, onNavigate, navigate }: any) {
</Box> </Box>
</CardContent> </CardContent>
<CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}> <CardActions sx={{ justifyContent: 'flex-end', px: 2, pb: 2 }}>
<Button size="small" onClick={() => navigate(`/${config.name}/${id}`)}>View Details</Button> <Button size="small" onClick={() => navigate(`/admin/${config.name}/${id}`)}>View Details</Button>
</CardActions> </CardActions>
</Card> </Card>
); );
@@ -359,7 +359,7 @@ function FieldRenderer({ params, field, fieldKey, config, onNavigate, navigate,
label={value} label={value}
size="small" size="small"
color="primary" color="primary"
onClick={(e) => { e.stopPropagation(); navigate(`/${config.name}/${params.row[config.primaryKey]}`); }} onClick={(e) => { e.stopPropagation(); navigate(`/admin/${config.name}/${params.row[config.primaryKey]}`); }}
sx={{ cursor: 'pointer', fontWeight: 'bold' }} sx={{ cursor: 'pointer', fontWeight: 'bold' }}
/> />
); );

View File

@@ -48,11 +48,11 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
const deleteMutation = useDelete(); const deleteMutation = useDelete();
const handleEdit = (item: any) => { const handleEdit = (item: any) => {
navigate(`/${config.name}/edit/${item[config.primaryKey]}`); navigate(`/admin/${config.name}/edit/${item[config.primaryKey]}`);
}; };
const handleCreate = () => { const handleCreate = () => {
navigate(`/${config.name}/create`); navigate(`/admin/${config.name}/create`);
}; };
const handleSave = async (formData: any) => { const handleSave = async (formData: any) => {
@@ -62,7 +62,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
} else { } else {
await createMutation.mutateAsync(formData); await createMutation.mutateAsync(formData);
} }
navigate(`/${config.name}`); navigate(`/admin/${config.name}`);
} catch (err) { } catch (err) {
console.error('Save failed:', err); console.error('Save failed:', err);
} }
@@ -90,7 +90,7 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
onEdit={handleEdit} onEdit={handleEdit}
onDelete={handleDelete} onDelete={handleDelete}
onCreate={handleCreate} onCreate={handleCreate}
onNavigateToResource={(res, id) => navigate(`/${res}/${id}`)} onNavigateToResource={(res, id) => navigate(`/admin/${res}/${id}`)}
/> />
) : ( ) : (
<Paper sx={{ p: 4 }}> <Paper sx={{ p: 4 }}>
@@ -98,10 +98,10 @@ export default function ResourceView({ config, onNavigateToResource }: ResourceV
config={config} config={config}
initialData={isCreate ? null : itemQuery.data} initialData={isCreate ? null : itemQuery.data}
onSave={handleSave} onSave={handleSave}
onCancel={() => navigate(`/${config.name}`)} onCancel={() => navigate(`/admin/${config.name}`)}
loading={createMutation.isPending || updateMutation.isPending} loading={createMutation.isPending || updateMutation.isPending}
readOnly={isView} readOnly={isView}
onEditClick={() => navigate(`/${config.name}/edit/${id}`)} onEditClick={() => navigate(`/admin/${config.name}/edit/${id}`)}
/> />
</Paper> </Paper>
)} )}

View File

@@ -11,12 +11,14 @@ import {
import LatestItemsList, { LatestItem } from "./components/LatestItemsList"; import LatestItemsList, { LatestItem } from "./components/LatestItemsList";
import HistoryChart from "./components/HistoryChart"; import HistoryChart from "./components/HistoryChart";
import {
AggregatedDashboardData,
} from "./components/HistoryChart";
import { import {
fetchLatestTransactions, fetchLatestTransactions,
fetchAggregatedExpenses, fetchAggregatedExpenses,
fetchAggregatedIncome, fetchAggregatedIncome,
AggregatedDashboardData
} from "./utils/dashboardLoader"; } from "./utils/dashboardLoader";
export default function Dashboard() { export default function Dashboard() {

View File

@@ -5,7 +5,7 @@ export interface ChartDataPoint {
id: string; id: string;
amount: number; amount: number;
compareAmount?: number; compareAmount?: number;
count?: number; compareLabel?: string;
highlighted?: boolean; highlighted?: boolean;
} }
@@ -31,6 +31,54 @@ export interface HistoryChartProps {
setComparison: (mode: boolean) => void; setComparison: (mode: boolean) => void;
} }
const formatDisplay = (
point: ChartDataPoint,
tab: string,
comparison: boolean
) => {
const base = point.amount;
const cmp = point.compareAmount ?? 0;
const formatShort = (val: number) => {
if (tab === "monthly") {
if (val >= 100000) return `${(val / 100000).toFixed(2)}L`;
}
if (tab === "weekly") {
if (val >= 1000) return `${(val / 1000).toFixed(1)}K`;
}
return val.toLocaleString("en-IN");
};
// Only hide diff when comparison OFF or compare is undefined
if (!comparison) {
return `${formatShort(base)}`;
}
const diff = base - cmp;
const sign = diff >= 0 ? "+" : "-";
const absDiff = Math.abs(diff);
return `${formatShort(base)} (${sign}${formatShort(absDiff)})`;
};
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 endParts = end.split(" ");
const endDay = endParts[0];
const month = endParts[1];
return `${startDay}${endDay} ${month}`;
}
}
return label;
};
export default function HistoryChart({ export default function HistoryChart({
header, header,
summary, summary,
@@ -44,9 +92,7 @@ export default function HistoryChart({
const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || ""); const [activeTab, setActiveTab] = React.useState<string>(tabs[0] || "");
const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => { const handleTabChange = (_: React.MouseEvent<HTMLElement>, newTab: string | null) => {
if (newTab !== null) { if (newTab !== null) setActiveTab(newTab);
setActiveTab(newTab);
}
}; };
const activeDataKey = activeTab.toLowerCase() as keyof ChartData; const activeDataKey = activeTab.toLowerCase() as keyof ChartData;
@@ -72,24 +118,6 @@ export default function HistoryChart({
) )
: 1; : 1;
const formatAmount = (amount: number) => {
const tab = activeTab.toLowerCase();
if (amount === 0) return "";
if (tab === "monthly") {
if (amount >= 100000) return `${(amount / 100000).toFixed(2)} L`;
return `${amount.toLocaleString("en-IN")}`;
}
if (tab === "weekly") {
if (amount >= 1000) return `${(amount / 1000).toFixed(1)} K`;
return `${amount.toLocaleString("en-IN")}`;
}
return `${amount.toLocaleString("en-IN")}`;
};
return ( return (
<Paper <Paper
sx={{ sx={{
@@ -111,38 +139,12 @@ export default function HistoryChart({
</Typography> </Typography>
)} )}
{/* Tabs */}
<ToggleButtonGroup <ToggleButtonGroup
value={activeTab} value={activeTab}
exclusive exclusive
onChange={handleTabChange} onChange={handleTabChange}
fullWidth fullWidth
sx={{ sx={{ mb: 4 }}
mb: 4,
bgcolor: (theme) =>
theme.palette.mode === "dark"
? "rgba(255,255,255,0.05)"
: "rgba(0,0,0,0.02)",
borderRadius: 8,
p: 0.5,
"& .MuiToggleButton-root": {
border: "none",
borderRadius: 8,
textTransform: "capitalize",
fontWeight: 600,
color: "text.secondary",
"&.Mui-selected": {
bgcolor: (theme) =>
theme.palette.mode === "dark"
? "primary.dark"
: "primary.light",
color: (theme) =>
theme.palette.mode === "dark"
? "primary.contrastText"
: "primary.main"
}
}
}}
> >
{tabs.map((tab) => ( {tabs.map((tab) => (
<ToggleButton key={tab} value={tab}> <ToggleButton key={tab} value={tab}>
@@ -151,7 +153,6 @@ export default function HistoryChart({
))} ))}
</ToggleButtonGroup> </ToggleButtonGroup>
{/* Period Toggle */}
<ToggleButtonGroup <ToggleButtonGroup
value={period} value={period}
exclusive exclusive
@@ -176,7 +177,6 @@ export default function HistoryChart({
<ToggleButton value="on">Compare</ToggleButton> <ToggleButton value="on">Compare</ToggleButton>
</ToggleButtonGroup> </ToggleButtonGroup>
{/* Chart */}
{currentData.length > 0 ? ( {currentData.length > 0 ? (
<Box sx={{ display: "flex", alignItems: "flex-end", height: 220, mt: 4 }}> <Box sx={{ display: "flex", alignItems: "flex-end", height: 220, mt: 4 }}>
{currentData.map((point) => { {currentData.map((point) => {
@@ -195,21 +195,15 @@ export default function HistoryChart({
height: "100%" height: "100%"
}} }}
> >
{/* Values */}
<Typography variant="caption" sx={{ mb: 1, fontSize: "0.65rem" }}>
{formatAmount(point.amount)}
</Typography>
{/* Bars */}
<Box <Box
sx={{ sx={{
display: "flex", display: "flex",
alignItems: "flex-end", alignItems: "flex-end",
gap: comparison ? 0.5 : 0, gap: comparison ? 0.5 : 0,
height: "100%" height: "100%",
position: "relative"
}} }}
> >
{/* Compare */}
{comparison && ( {comparison && (
<Box <Box
sx={{ sx={{
@@ -221,30 +215,68 @@ export default function HistoryChart({
/> />
)} )}
{/* Current */}
<Box <Box
sx={{ sx={{
width: 10, width: 10,
height: `${currentHeight}%`, height: `${currentHeight}%`,
bgcolor: point.highlighted ? "error.main" : "primary.main", bgcolor: point.highlighted ? "error.main" : "primary.main",
borderRadius: 2 borderRadius: 2,
position: "relative"
}} }}
/> >
<Typography
variant="caption"
sx={{
position: "absolute",
top: -18,
left: "50%",
transform: "translateX(-50%)",
fontSize: "0.65rem",
whiteSpace: "nowrap"
}}
>
{formatDisplay(point, activeTab.toLowerCase(), comparison)}
</Typography>
</Box>
</Box> </Box>
{/* Label */} <Box
<Typography
variant="caption"
color="text.secondary"
sx={{ sx={{
mt: 1, mt: 1,
fontSize: "0.7rem",
textAlign: "center", textAlign: "center",
whiteSpace: "pre-line" display: "flex",
flexDirection: "column",
alignItems: "center",
lineHeight: 1.1
}} }}
> >
{point.id} <Typography
</Typography> variant="caption"
sx={{
fontSize: "0.7rem",
opacity: 0.7,
color: "text.primary",
}}
>
{formatLabel(point.id, activeDataKey)}
</Typography>
<Typography
variant="caption"
sx={{
fontSize: "0.65rem",
color: "grey.400",
visibility:
comparison && point.compareLabel && activeDataKey !== "daily"
? "visible"
: "hidden"
}}
>
{point.compareLabel
? formatLabel(point.compareLabel, activeDataKey)
: "placeholder"}
</Typography>
</Box>
</Box> </Box>
); );
})} })}

View File

@@ -2,40 +2,21 @@ import { api } from "../../react-openapi";
import { LatestItem } from "../components/LatestItemsList"; import { LatestItem } from "../components/LatestItemsList";
import { ChartDataPoint } from "../components/HistoryChart"; import { ChartDataPoint } from "../components/HistoryChart";
import * as React from "react"; import * as React from "react";
import { format } from "./dateUtils";
import MonetizationOnIcon from "@mui/icons-material/MonetizationOn"; import MonetizationOnIcon from "@mui/icons-material/MonetizationOn";
// ---------------- ICON ---------------- import {
buildDailyBuckets,
buildWeeklyRolling,
buildWeeklyCalendar,
buildMonthlyRolling,
buildMonthlyCalendar
} from "./periodBuilders";
const DEFAULT_ICON = React.createElement(MonetizationOnIcon, { const DEFAULT_ICON = React.createElement(MonetizationOnIcon, {
sx: { color: "#388e3c" } sx: { color: "#388e3c" }
}); });
// ---------------- HELPERS ----------------
const format = (d: Date) =>
`${d.getDate()} ${d.toLocaleString("default", { month: "short" })}`;
const startOfDay = (d: Date) => {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
};
const endOfDay = (d: Date) => {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
};
const getStartOfWeek = (d: Date) => {
const date = new Date(d);
const day = date.getDay() || 7;
if (day !== 1) date.setDate(date.getDate() - (day - 1));
return startOfDay(date);
};
const shiftDate = (d: Date, days: number) =>
new Date(d.getTime() + days * 86400000);
// ---------------- LATEST ----------------
export async function fetchLatestTransactions( export async function fetchLatestTransactions(
type: "expense" | "income" type: "expense" | "income"
): Promise<LatestItem[]> { ): Promise<LatestItem[]> {
@@ -66,35 +47,17 @@ export async function fetchLatestTransactions(
iconBgColor: iconBgColor:
type === "expense" ? "#ffebee" : "#e8f5e9", type === "expense" ? "#ffebee" : "#e8f5e9",
title: exp.payee?.name || exp.payee || "Unknown Payee", title: exp.payee?.name || exp.payee || "Unknown Payee",
subtitle: exp.category?.name || exp.account?.name || "Transaction", subtitle:
exp.category?.name || exp.account?.name || "Transaction",
amount: `Rs ${Math.abs(exp.amount || 0)}`, amount: `Rs ${Math.abs(exp.amount || 0)}`,
timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago` timeAgo: diffDays === 0 ? "Today" : `${diffDays} days ago`
}; };
}); });
} }
// ---------------- TYPES ----------------
export interface ChartSeries {
rolling: ChartDataPoint[];
calendar: ChartDataPoint[];
}
export interface ChartData {
daily: ChartDataPoint[];
weekly: ChartSeries;
monthly: ChartSeries;
}
export interface AggregatedDashboardData {
chartData: ChartData;
totalAmount: number;
topPayees: Array<{ payeeName: string; amount: number }>;
}
// ---------------- AGGREGATION ----------------
export async function fetchAggregatedData( export async function fetchAggregatedData(
type: "expense" | "income" type: "expense" | "income"
): Promise<AggregatedDashboardData> { ) {
const res = await api.get("/expenses", { params: { limit: 0 } }); const res = await api.get("/expenses", { params: { limit: 0 } });
const all: any[] = res.data?.items || res.data || []; const all: any[] = res.data?.items || res.data || [];
@@ -108,126 +71,19 @@ export async function fetchAggregatedData(
const normalize = (amt: number) => Math.abs(amt); const normalize = (amt: number) => Math.abs(amt);
// ---------------- DAILY ---------------- const {
const dailyBuckets: Record<string, any> = { buckets: dailyBuckets,
Mon: { amount: 0, compare: 0 }, weekStart,
Tue: { amount: 0, compare: 0 }, weekEnd,
Wed: { amount: 0, compare: 0 }, prevWeekStart,
Thu: { amount: 0, compare: 0 }, prevWeekEnd
Fri: { amount: 0, compare: 0 }, } = buildDailyBuckets(now);
Sat: { amount: 0, compare: 0 },
Sun: { amount: 0, compare: 0 }
};
const weekStart = getStartOfWeek(now); const weeklyRolling = buildWeeklyRolling(now);
const weekEnd = endOfDay(new Date(weekStart.getTime() + 6 * 86400000)); const weeklyCalendar = buildWeeklyCalendar(now);
const prevWeekStart = shiftDate(weekStart, -7); const monthlyRolling = buildMonthlyRolling(now);
const prevWeekEnd = shiftDate(weekEnd, -7); const monthlyCalendar = buildMonthlyCalendar(now);
// ---------------- WEEKLY ----------------
const weeklyRolling: any[] = [];
const weeklyCalendar: any[] = [];
const currentWeekStart = getStartOfWeek(now);
// rolling (last 5 weeks)
for (let i = 4; i >= 0; i--) {
const start = new Date(currentWeekStart.getTime() - i * 7 * 86400000);
const end = endOfDay(new Date(start.getTime() + 6 * 86400000));
weeklyRolling.push({
label: `${format(start)} - ${format(end)}`,
start,
end,
amount: 0,
compare: 0,
prevStart: shiftDate(start, -7),
prevEnd: shiftDate(end, -7)
});
}
// calendar weeks
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const firstWeekStart = getStartOfWeek(startOfMonth);
const totalWeeks = Math.ceil(
(endOfMonth.getTime() - firstWeekStart.getTime()) / (7 * 86400000)
) + 1;
for (let i = 0; i < totalWeeks; i++) {
const start = new Date(firstWeekStart.getTime() + i * 7 * 86400000);
const end = endOfDay(new Date(start.getTime() + 6 * 86400000));
weeklyCalendar.push({
label: `${format(start)} - ${format(end)}`,
start,
end,
amount: 0,
compare: 0,
prevStart: shiftDate(start, -7),
prevEnd: shiftDate(end, -7)
});
}
// ---------------- MONTHLY ----------------
const monthlyRolling: any[] = [];
const monthlyCalendar: any[] = [];
// rolling (last 12 months)
for (let i = 11; i >= 0; i--) {
const d = new Date(now);
d.setMonth(d.getMonth() - i);
const start = new Date(d.getFullYear(), d.getMonth(), 1);
const end =
i === 0
? endOfDay(now)
: endOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0));
const prevStart = new Date(start);
prevStart.setMonth(prevStart.getMonth() - 1);
const prevEnd = new Date(end);
prevEnd.setMonth(prevEnd.getMonth() - 1);
monthlyRolling.push({
label: `${d.toLocaleString("default", { month: "short" })}-${String(
d.getFullYear()
).slice(2)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
// calendar (JanDec)
for (let i = 0; i < 12; i++) {
const start = new Date(now.getFullYear(), i, 1);
const end = endOfDay(new Date(now.getFullYear(), i + 1, 0));
const prevStart = new Date(start);
prevStart.setFullYear(prevStart.getFullYear() - 1);
const prevEnd = new Date(end);
prevEnd.setFullYear(prevEnd.getFullYear() - 1);
monthlyCalendar.push({
label: `${start.toLocaleString("default", { month: "short" })}-${String(
start.getFullYear()
).slice(2)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
// ---------------- LOOP ----------------
for (const item of all) { for (const item of all) {
const d = new Date( const d = new Date(
item.occurred_at || item.created_at || Date.now() item.occurred_at || item.created_at || Date.now()
@@ -237,56 +93,50 @@ export async function fetchAggregatedData(
if (!isValid(amtRaw)) continue; if (!isValid(amtRaw)) continue;
const amt = normalize(amtRaw); const amt = normalize(amtRaw);
totalAmount += amt; totalAmount += amt;
const payee = item.payee?.name || item.payee || "Unknown"; const payee = item.payee?.name || item.payee || "Unknown";
payeeMap[payee] = (payeeMap[payee] || 0) + amt; payeeMap[payee] = (payeeMap[payee] || 0) + amt;
// DAILY const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()];
if (d >= weekStart && d <= weekEnd) { if (d >= weekStart && d <= weekEnd) {
const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()];
if (dailyBuckets[day]) { if (dailyBuckets[day]) {
dailyBuckets[day].amount += amt; dailyBuckets[day].amount += amt;
} }
} }
if (d >= prevWeekStart && d <= prevWeekEnd) { if (d >= prevWeekStart && d <= prevWeekEnd) {
const day = ["Sun","Mon","Tue","Wed","Thu","Fri","Sat"][d.getDay()];
if (dailyBuckets[day]) { if (dailyBuckets[day]) {
dailyBuckets[day].compare += amt; dailyBuckets[day].compare += amt;
} }
} }
// WEEKLY const apply = (arr: any[]) => {
for (const b of weeklyRolling) { for (const b of arr) {
if (d >= b.start && d <= b.end) b.amount += amt; if (d >= b.start && d <= b.end) b.amount += amt;
if (d >= b.prevStart && d <= b.prevEnd) b.compare += amt; if (d >= b.prevStart && d <= b.prevEnd)
} b.compare += amt;
for (const b of weeklyCalendar) { }
if (d >= b.start && d <= b.end) b.amount += amt; };
if (d >= b.prevStart && d <= b.prevEnd) b.compare += amt;
}
// MONTHLY apply(weeklyRolling);
for (const b of monthlyRolling) { apply(weeklyCalendar);
if (d >= b.start && d <= b.end) b.amount += amt; apply(monthlyRolling);
if (d >= b.prevStart && d <= b.prevEnd) b.compare += amt; apply(monthlyCalendar);
}
for (const b of monthlyCalendar) {
if (d >= b.start && d <= b.end) b.amount += amt;
if (d >= b.prevStart && d <= b.prevEnd) b.compare += amt;
}
} }
const toPoints = (arr: any[]): ChartDataPoint[] => const toPoints = (arr: any[]): ChartDataPoint[] =>
arr.map((x) => ({ arr.map((x) => ({
id: x.label, id: x.label,
amount: x.amount, amount: x.amount,
compareAmount: x.compare compareAmount: x.compare,
compareLabel: x.prevStart && x.prevEnd
? `${format(x.prevStart)} - ${format(x.prevEnd)}`
: undefined
})); }));
const chartData: ChartData = { const chartData = {
daily: Object.entries(dailyBuckets).map(([k, v]: any) => ({ daily: Object.entries(dailyBuckets).map(([k, v]: any) => ({
id: k, id: k,
amount: v.amount, amount: v.amount,
@@ -302,7 +152,6 @@ export async function fetchAggregatedData(
} }
}; };
// highlight max (current only)
Object.values(chartData).forEach((group: any) => { Object.values(chartData).forEach((group: any) => {
const arr = Array.isArray(group) ? group : group.rolling; const arr = Array.isArray(group) ? group : group.rolling;
if (!arr?.length) return; if (!arr?.length) return;
@@ -322,7 +171,6 @@ export async function fetchAggregatedData(
return { chartData, totalAmount, topPayees }; return { chartData, totalAmount, topPayees };
} }
// ---------------- EXPORTS ----------------
export const fetchAggregatedExpenses = () => export const fetchAggregatedExpenses = () =>
fetchAggregatedData("expense"); fetchAggregatedData("expense");

33
src/utils/dateUtils.ts Normal file
View File

@@ -0,0 +1,33 @@
export const format = (d: Date) =>
`${d.getDate()} ${d.toLocaleString("default", { month: "short" })}`;
export const startOfDay = (d: Date) => {
const x = new Date(d);
x.setHours(0, 0, 0, 0);
return x;
};
export const endOfDay = (d: Date) => {
const x = new Date(d);
x.setHours(23, 59, 59, 999);
return x;
};
export const getStartOfWeek = (d: Date) => {
const date = new Date(d);
const day = date.getDay() || 7;
if (day !== 1) date.setDate(date.getDate() - (day - 1));
return startOfDay(date);
};
export const shiftDate = (d: Date, days: number) =>
new Date(d.getTime() + days * 86400000);
export const getWeekIndex = (date: Date) => {
const firstDay = new Date(date.getFullYear(), date.getMonth(), 1);
const firstWeekStart = getStartOfWeek(firstDay);
return Math.floor(
(startOfDay(date).getTime() - firstWeekStart.getTime()) /
(7 * 86400000)
);
};

178
src/utils/periodBuilders.ts Normal file
View File

@@ -0,0 +1,178 @@
import {
format,
endOfDay,
getStartOfWeek,
shiftDate,
getWeekIndex
} from "./dateUtils";
export const buildDailyBuckets = (now: Date) => {
const buckets: Record<string, any> = {
Mon: { amount: 0, compare: 0 },
Tue: { amount: 0, compare: 0 },
Wed: { amount: 0, compare: 0 },
Thu: { amount: 0, compare: 0 },
Fri: { amount: 0, compare: 0 },
Sat: { amount: 0, compare: 0 },
Sun: { amount: 0, compare: 0 }
};
const weekStart = getStartOfWeek(now);
const weekEnd = endOfDay(new Date(weekStart.getTime() + 6 * 86400000));
const prevWeekStart = shiftDate(weekStart, -7);
const prevWeekEnd = shiftDate(weekEnd, -7);
return { buckets, weekStart, weekEnd, prevWeekStart, prevWeekEnd };
};
const getPrevMonthWeek = (start: Date) => {
const prevMonthDate = new Date(start);
prevMonthDate.setMonth(prevMonthDate.getMonth() - 1);
const prevMonthFirst = new Date(
prevMonthDate.getFullYear(),
prevMonthDate.getMonth(),
1
);
const prevFirstWeekStart = getStartOfWeek(prevMonthFirst);
const weekIndex = getWeekIndex(start);
const prevStart = new Date(
prevFirstWeekStart.getTime() + weekIndex * 7 * 86400000
);
const prevEnd = endOfDay(new Date(prevStart.getTime() + 6 * 86400000));
return { prevStart, prevEnd };
};
export const buildWeeklyRolling = (now: Date) => {
const arr: any[] = [];
const currentWeekStart = getStartOfWeek(now);
for (let i = 4; i >= 0; i--) {
const start = new Date(
currentWeekStart.getTime() - i * 7 * 86400000
);
const end = endOfDay(new Date(start.getTime() + 6 * 86400000));
const { prevStart, prevEnd } = getPrevMonthWeek(start);
arr.push({
label: `${format(start)} - ${format(end)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
return arr;
};
export const buildWeeklyCalendar = (now: Date) => {
const arr: any[] = [];
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0);
const firstWeekStart = getStartOfWeek(startOfMonth);
const totalWeeks =
Math.ceil(
(endOfMonth.getTime() - firstWeekStart.getTime()) /
(7 * 86400000)
) + 1;
for (let i = 0; i < totalWeeks; i++) {
const start = new Date(
firstWeekStart.getTime() + i * 7 * 86400000
);
const end = endOfDay(new Date(start.getTime() + 6 * 86400000));
const { prevStart, prevEnd } = getPrevMonthWeek(start);
arr.push({
label: `${format(start)} - ${format(end)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
return arr;
};
export const buildMonthlyRolling = (now: Date) => {
const arr: any[] = [];
for (let i = 11; i >= 0; i--) {
const d = new Date(now);
d.setMonth(d.getMonth() - i);
const start = new Date(d.getFullYear(), d.getMonth(), 1);
const end =
i === 0
? endOfDay(now)
: endOfDay(new Date(d.getFullYear(), d.getMonth() + 1, 0));
const prevStart = new Date(start);
prevStart.setFullYear(prevStart.getFullYear() - 1);
let prevEnd = new Date(end);
prevEnd.setFullYear(prevEnd.getFullYear() - 1);
if (i === 0) {
prevEnd = new Date(prevStart);
prevEnd.setDate(now.getDate());
prevEnd = endOfDay(prevEnd);
}
arr.push({
label: `${d.toLocaleString("default", {
month: "short"
})}-${String(d.getFullYear()).slice(2)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
return arr;
};
export const buildMonthlyCalendar = (now: Date) => {
const arr: any[] = [];
for (let i = 0; i < 12; i++) {
const start = new Date(now.getFullYear(), i, 1);
const end = endOfDay(new Date(now.getFullYear(), i + 1, 0));
const prevStart = new Date(start);
prevStart.setFullYear(prevStart.getFullYear() - 1);
const prevEnd = new Date(end);
prevEnd.setFullYear(prevEnd.getFullYear() - 1);
arr.push({
label: `${start.toLocaleString("default", {
month: "short"
})}-${String(start.getFullYear()).slice(2)}`,
start,
end,
amount: 0,
compare: 0,
prevStart,
prevEnd
});
}
return arr;
};