items-by-period #2

Merged
aetos merged 7 commits from items-by-period into main 2026-05-09 13:00:43 +00:00
11 changed files with 112 additions and 28 deletions
Showing only changes of commit 17b5a107fe - Show all commits

View File

@@ -1,6 +1,7 @@
import * as React from "react";
import {
ReportData
ReportData,
GroupKey,
} from "../../features/report";
export type DashboardMode = "expense" | "income";
@@ -11,6 +12,7 @@ export interface DashboardState {
mode: DashboardMode;
periodType: DashboardPeriodType;
selectedPeriodId: DashboardSelectedPeriodId;
selectedGroupKey: GroupKey | null;
comparison: boolean;
}

View File

@@ -7,6 +7,7 @@ export default function Dashboard(props: DashboardProps) {
mode: "expense",
periodType: "rolling",
selectedPeriodId: null,
selectedGroupKey: null,
comparison: false,
});
@@ -35,6 +36,10 @@ export default function Dashboard(props: DashboardProps) {
setState(prev => ({ ...prev, selectedPeriodId }));
};
const setSelectedGroupKey = (groupKey: typeof state.selectedGroupKey) => {
setState(prev => ({ ...prev, selectedGroupKey: groupKey }));
};
return (
<DashboardView
{...props}
@@ -44,6 +49,7 @@ export default function Dashboard(props: DashboardProps) {
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
/>
);
}

View File

@@ -8,6 +8,7 @@ import {
ToggleButtonGroup
} from "@mui/material";
import { useTheme, alpha } from "@mui/material/styles";
import { GroupKey } from "../../features/report";
import { DashboardProps, DashboardState } from "./Dashboard.models";
interface ViewProps extends DashboardProps {
@@ -16,6 +17,7 @@ interface ViewProps extends DashboardProps {
toggleMode: () => void;
togglePeriodType: () => void;
setSelectedPeriodId: (id: string | null) => void;
setSelectedGroupKey: (groupKey: GroupKey | null) => void;
toggleComparison: () => void;
}
@@ -28,10 +30,11 @@ export default function DashboardView({
togglePeriodType,
toggleComparison,
setSelectedPeriodId,
setSelectedGroupKey,
}: ViewProps) {
const theme = useTheme();
const themeMode = theme.palette.mode;
const { mode, periodType, comparison, selectedPeriodId } = state;
const { mode, periodType, comparison, selectedPeriodId, selectedGroupKey } = state;
// Resolve colors with fallbacks
const colors = React.useMemo(() => {
@@ -120,10 +123,12 @@ export default function DashboardView({
periodType={periodType}
comparison={comparison}
selectedPeriodId={selectedPeriodId}
selectedGroupKey={selectedGroupKey}
togglePeriodType={togglePeriodType}
toggleComparison={toggleComparison}
setSelectedPeriodId={setSelectedPeriodId}
setSelectedGroupKey={setSelectedGroupKey}
/>
</Grid>
);

View File

@@ -1,8 +1,9 @@
import { ReportData, Transaction } from "../../features/report";
import { ReportData, Transaction, GroupKey } from "../../features/report";
import {
mergeBucketPeriods,
periodIdToKey,
formatCurrency,
filterBuckets,
} from "../report.helpers";
import { LatestItem } from "./LatestItems.models";
@@ -11,11 +12,13 @@ import { LatestItem } from "./LatestItems.models";
function extractTransactions(
reportData: ReportData,
selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
mode: "expense" | "income"
): Transaction[] {
const buckets = filterBuckets(reportData.buckets, selectedGroupKey);
if (selectedPeriodId) {
const key = periodIdToKey(selectedPeriodId);
const periods = mergeBucketPeriods(reportData.buckets, key);
const periods = mergeBucketPeriods(buckets, key);
const selected = periods.find((p) => p.id === selectedPeriodId);
if (!selected) return [];
@@ -25,7 +28,7 @@ function extractTransactions(
: (selected.incomes.transactions || []);
}
const periods = mergeBucketPeriods(reportData.buckets, "full");
const periods = mergeBucketPeriods(buckets, "full");
if (!periods.length) return [];
@@ -41,9 +44,10 @@ function extractTransactions(
export function buildLatestItems(
reportData: ReportData,
selectedPeriodId: string | null,
selectedGroupKey: GroupKey | null,
mode: "expense" | "income"
): LatestItem[] {
const txns = extractTransactions(reportData, selectedPeriodId, mode);
const txns = extractTransactions(reportData, selectedPeriodId, selectedGroupKey, mode);
return txns
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))

View File

@@ -1,5 +1,5 @@
import * as React from "react";
import { ReportData } from "../../features/report";
import { ReportData, GroupKey } from "../../features/report";
import { buildLatestItems } from "./LatestItems.adapter";
import LatestItemsView from "./LatestItems.view";
@@ -7,6 +7,7 @@ type Props = {
reportData: ReportData;
mode: "expense" | "income";
selectedPeriodId: string | null;
selectedGroupKey?: GroupKey | null;
accentColor: string;
};
@@ -14,22 +15,23 @@ export default function LatestItems({
reportData,
mode,
selectedPeriodId,
selectedGroupKey = null,
accentColor,
}: Props) {
const [visibleCount, setVisibleCount] = React.useState(5);
const allItems = React.useMemo(() => {
return buildLatestItems(reportData, selectedPeriodId, mode);
}, [reportData, selectedPeriodId, mode]);
return buildLatestItems(reportData, selectedPeriodId, selectedGroupKey, mode);
}, [reportData, selectedPeriodId, selectedGroupKey, mode]);
const isPeriodSelected = Boolean(selectedPeriodId);
const hasSelection = Boolean(selectedPeriodId) || Boolean(selectedGroupKey);
const visibleItems = React.useMemo(() => {
if (!isPeriodSelected) return allItems.slice(0, 5);
if (!hasSelection) return allItems.slice(0, 5);
return allItems.slice(0, visibleCount);
}, [allItems, isPeriodSelected, visibleCount]);
}, [allItems, hasSelection, visibleCount]);
const canExpand = isPeriodSelected && visibleCount < allItems.length;
const canExpand = hasSelection && visibleCount < allItems.length;
return (
<LatestItemsView

View File

@@ -5,4 +5,6 @@ export interface ProgressCardProps {
totalAmount: number;
colorTheme?: "primary" | "secondary" | "error" | "info" | "success" | "warning";
compact?: boolean;
selected?: boolean;
onClick?: () => void;
}

View File

@@ -18,6 +18,8 @@ export default function ProgressCard(props: ProgressCardProps) {
formattedProgress={formattedProgress}
formattedTotal={formattedTotal}
compact={compact}
selected={props.selected}
onClick={props.onClick}
/>
);
}

View File

@@ -14,6 +14,8 @@ interface ViewProps extends ProgressCardProps {
percentage: number;
formattedProgress: string;
formattedTotal: string;
selected?: boolean;
onClick?: () => void;
}
export default function ProgressCardView({
@@ -23,6 +25,8 @@ export default function ProgressCardView({
formattedProgress,
formattedTotal,
compact = false,
selected,
onClick,
}: ViewProps) {
const theme = useTheme();
const isDark = theme.palette.mode === "dark";
@@ -30,10 +34,14 @@ export default function ProgressCardView({
return (
<Paper
elevation={compact ? 2 : 4}
onClick={onClick}
sx={{
width: "100%",
p: compact ? { xs: 2.5, md: 3 } : { xs: 3, md: 4 },
borderRadius: compact ? 3 : 4,
cursor: onClick ? "pointer" : "default",
transform: selected ? "scale(1.02)" : "scale(1)",
transition: "transform 0.2s ease, box-shadow 0.2s ease",
background: (theme) => {
const baseColor = theme.palette[colorTheme]?.main || theme.palette.primary.main;
const lightColor = theme.palette[colorTheme]?.light || theme.palette.primary.light;
@@ -48,13 +56,19 @@ export default function ProgressCardView({
justifyContent: "center",
position: "relative",
overflow: "hidden",
border: isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
boxShadow: (theme) =>
`0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
border: selected
? `2px solid #fff`
: isDark ? "1px solid rgba(255,255,255,0.1)" : "none",
boxShadow: (theme) => {
const baseShadow = `0 ${compact ? 6 : 12}px ${compact ? 12 : 24}px -10px ${
isDark
? "rgba(0,0,0,0.5)"
: theme.palette[colorTheme]?.main || theme.palette.primary.main
}`,
}`;
return selected
? `${baseShadow}, 0 0 0 2px ${theme.palette.background.paper}, 0 0 0 4px ${theme.palette[colorTheme]?.main || theme.palette.primary.main}`
: baseShadow;
},
}}
>
<Typography

View File

@@ -1,6 +1,6 @@
import * as React from "react";
import { Box } from "@mui/material";
import { ReportData } from "../../features/report";
import { ReportData, GroupKey } from "../../features/report";
import ProgressCard from "./ProgressCard";
import { extractTopTags } from "./TopTags.adapter";
@@ -8,6 +8,8 @@ type Props = {
reportData: ReportData;
mode: "expense" | "income";
selectedPeriodId?: string | null;
selectedGroupKey?: GroupKey | null;
setSelectedGroupKey?: (key: GroupKey | null) => void;
compact?: boolean;
};
@@ -15,6 +17,8 @@ export default function TopTags({
reportData,
mode,
selectedPeriodId,
selectedGroupKey,
setSelectedGroupKey,
compact = true,
}: Props) {
const { items, total } = React.useMemo(() => {
@@ -33,16 +37,25 @@ export default function TopTags({
gap: 2,
}}
>
{items.map((item) => (
<ProgressCard
key={item.tag}
header={item.tag}
progressAmount={item.amount}
totalAmount={total}
compact={compact}
colorTheme={mode === "expense" ? "error" : "success"}
/>
))}
{items.map((item) => {
const isSelected = selectedGroupKey?.tags?.includes(item.tag);
return (
<ProgressCard
key={item.tag}
header={item.tag}
progressAmount={item.amount}
totalAmount={total}
compact={compact}
colorTheme={mode === "expense" ? "error" : "success"}
selected={isSelected}
onClick={() => {
if (setSelectedGroupKey) {
setSelectedGroupKey(isSelected ? null : { tags: [item.tag] });
}
}}
/>
);
})}
</Box>
);
}

View File

@@ -1,6 +1,7 @@
import {
ReportPeriod,
ReportBucket,
GroupKey,
} from "../features/report";
// ─── Types ────────────────────────────────────────────────────
@@ -112,3 +113,35 @@ export const getPercentage = (progressAmount: number, totalAmount: number) => {
if (!totalAmount) return 0;
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
};
// ─── Group filtering ──────────────────────────────────────────
/**
* Check if a bucket's group_key matches the selected GroupKey.
* Every dimension present in `selected` must exist in the bucket
* and contain all the selected values.
*/
export function matchesGroupKey(
bucket: ReportBucket,
selected: GroupKey
): boolean {
for (const [dim, values] of Object.entries(selected)) {
const bucketValues = bucket.group_key[dim as keyof GroupKey];
if (!bucketValues) return false;
if (!(values as string[]).every((v) => bucketValues.includes(v)))
return false;
}
return true;
}
/**
* Return only buckets matching the selected group key,
* or all buckets if no selection.
*/
export function filterBuckets(
buckets: ReportBucket[],
selectedGroupKey: GroupKey | null
): ReportBucket[] {
if (!selectedGroupKey) return buckets;
return buckets.filter((b) => matchesGroupKey(b, selectedGroupKey));
}

View File

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