items-by-period #2
75
src/components/HistoryChart/HistoryChart.adapter.ts
Normal file
75
src/components/HistoryChart/HistoryChart.adapter.ts
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
import { ReportData } from "../../features/report";
|
||||||
|
import {
|
||||||
|
mergeBucketPeriods,
|
||||||
|
getAmount,
|
||||||
|
PeriodKey,
|
||||||
|
} from "../report.helpers";
|
||||||
|
import { ChartDataPoint } from "./HistoryChart.models";
|
||||||
|
|
||||||
|
// ─── Tab → PeriodKey ─────────────────────────────────────────
|
||||||
|
|
||||||
|
const TAB_TO_KEY: Record<string, PeriodKey> = {
|
||||||
|
Weekly: "weekly",
|
||||||
|
Monthly: "monthly",
|
||||||
|
Yearly: "yearly",
|
||||||
|
"Financial Year": "fyly",
|
||||||
|
"All Time": "full",
|
||||||
|
};
|
||||||
|
|
||||||
|
export function tabToKey(tab: string): PeriodKey {
|
||||||
|
return TAB_TO_KEY[tab] ?? "full";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Comparison ──────────────────────────────────────────────
|
||||||
|
|
||||||
|
function attachComparison(
|
||||||
|
points: ChartDataPoint[],
|
||||||
|
key: PeriodKey
|
||||||
|
): 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,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main adapter ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildChartData(
|
||||||
|
reportData: ReportData,
|
||||||
|
key: PeriodKey,
|
||||||
|
mode: "expense" | "income",
|
||||||
|
comparison: boolean
|
||||||
|
): ChartDataPoint[] {
|
||||||
|
const merged = mergeBucketPeriods(reportData.buckets, key);
|
||||||
|
|
||||||
|
let points: ChartDataPoint[] = merged.map((p) => ({
|
||||||
|
id: p.id,
|
||||||
|
label: p.label,
|
||||||
|
amount: getAmount(p, mode),
|
||||||
|
}));
|
||||||
|
|
||||||
|
if (comparison) {
|
||||||
|
points = attachComparison(points, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
return points;
|
||||||
|
}
|
||||||
62
src/components/LatestItems/LatestItems.adapter.ts
Normal file
62
src/components/LatestItems/LatestItems.adapter.ts
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import { ReportData, Transaction } from "../../features/report";
|
||||||
|
import {
|
||||||
|
mergeBucketPeriods,
|
||||||
|
periodIdToKey,
|
||||||
|
formatCurrency,
|
||||||
|
} from "../report.helpers";
|
||||||
|
import { LatestItem } from "./LatestItems.models";
|
||||||
|
|
||||||
|
// ─── Transaction extraction ─────────────────────────────────
|
||||||
|
|
||||||
|
function extractTransactions(
|
||||||
|
reportData: ReportData,
|
||||||
|
selectedPeriodId: string | null,
|
||||||
|
mode: "expense" | "income"
|
||||||
|
): Transaction[] {
|
||||||
|
if (selectedPeriodId) {
|
||||||
|
const key = periodIdToKey(selectedPeriodId);
|
||||||
|
const periods = mergeBucketPeriods(reportData.buckets, key);
|
||||||
|
const selected = periods.find((p) => p.id === selectedPeriodId);
|
||||||
|
|
||||||
|
if (!selected) return [];
|
||||||
|
|
||||||
|
return mode === "expense"
|
||||||
|
? (selected.expenses.transactions || [])
|
||||||
|
: (selected.incomes.transactions || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
const periods = mergeBucketPeriods(reportData.buckets, "full");
|
||||||
|
|
||||||
|
if (!periods.length) return [];
|
||||||
|
|
||||||
|
const full = periods[0];
|
||||||
|
|
||||||
|
return mode === "expense"
|
||||||
|
? (full.expenses.transactions || [])
|
||||||
|
: (full.incomes.transactions || []);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main adapter ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export function buildLatestItems(
|
||||||
|
reportData: ReportData,
|
||||||
|
selectedPeriodId: string | null,
|
||||||
|
mode: "expense" | "income"
|
||||||
|
): LatestItem[] {
|
||||||
|
const txns = extractTransactions(reportData, selectedPeriodId, mode);
|
||||||
|
|
||||||
|
return txns
|
||||||
|
.filter((t) => (mode === "expense" ? t.amount < 0 : t.amount >= 0))
|
||||||
|
.sort(
|
||||||
|
(a, b) =>
|
||||||
|
new Date(b.occurred_at).getTime() -
|
||||||
|
new Date(a.occurred_at).getTime()
|
||||||
|
)
|
||||||
|
.map((t, index) => ({
|
||||||
|
id: index + 1,
|
||||||
|
title: t.payee.name,
|
||||||
|
subtitle: t.tags.map((tag) => tag.name).join(", "),
|
||||||
|
amount: formatCurrency(t.amount),
|
||||||
|
timeAgo: new Date(t.occurred_at).toLocaleDateString("en-IN"),
|
||||||
|
}));
|
||||||
|
}
|
||||||
74
src/components/ProgressCard/TopTags.adapter.ts
Normal file
74
src/components/ProgressCard/TopTags.adapter.ts
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { ReportData } from "../../features/report";
|
||||||
|
import {
|
||||||
|
getAmount,
|
||||||
|
DecoratedPeriod,
|
||||||
|
} from "../report.helpers";
|
||||||
|
|
||||||
|
// ─── Helpers ─────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Main adapter ────────────────────────────────────────────
|
||||||
|
|
||||||
|
export interface TagItem {
|
||||||
|
tag: string;
|
||||||
|
amount: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function extractTopTags(
|
||||||
|
reportData: ReportData,
|
||||||
|
mode: "expense" | "income",
|
||||||
|
selectedPeriodId?: string | null
|
||||||
|
): { items: TagItem[]; total: number } {
|
||||||
|
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 };
|
||||||
|
}
|
||||||
114
src/components/report.helpers.ts
Normal file
114
src/components/report.helpers.ts
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import {
|
||||||
|
ReportPeriod,
|
||||||
|
ReportBucket,
|
||||||
|
} from "../features/report";
|
||||||
|
|
||||||
|
// ─── Types ────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
export type PeriodKey = "weekly" | "monthly" | "yearly" | "fyly" | "full";
|
||||||
|
|
||||||
|
export type DecoratedPeriod = ReportPeriod & {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ─── Period helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
const PREFIX_TO_KEY: Record<string, PeriodKey> = {
|
||||||
|
W: "weekly",
|
||||||
|
M: "monthly",
|
||||||
|
Y: "yearly",
|
||||||
|
FY: "fyly",
|
||||||
|
FULL: "full",
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the period key from a decorated-period id.
|
||||||
|
* E.g. `"W:2026-04-28_2026-05-04"` → `"weekly"`
|
||||||
|
*/
|
||||||
|
export function periodIdToKey(periodId: string): PeriodKey {
|
||||||
|
const prefix = periodId.split(":")[0];
|
||||||
|
return PREFIX_TO_KEY[prefix] ?? "full";
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Metric helpers ───────────────────────────────────────────
|
||||||
|
|
||||||
|
export function getAmount(
|
||||||
|
period: ReportPeriod,
|
||||||
|
mode: "expense" | "income"
|
||||||
|
): number {
|
||||||
|
return mode === "expense" ? period.expenses.sum : period.incomes.sum;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mergeMetric(a: ReportPeriod["expenses"], b: ReportPeriod["expenses"]) {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Merge periods with the same id across all buckets, summing
|
||||||
|
* their metrics and concatenating transactions.
|
||||||
|
*
|
||||||
|
* Returns sorted by start date ascending.
|
||||||
|
*/
|
||||||
|
export function mergeBucketPeriods(
|
||||||
|
buckets: ReportBucket[],
|
||||||
|
key: PeriodKey
|
||||||
|
): 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()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ─── Formatting ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
export const formatCurrency = (val: number) => {
|
||||||
|
const absVal = Math.abs(val);
|
||||||
|
if (absVal >= 100000) {
|
||||||
|
return `₹ ${(val / 100000).toFixed(2)}L`;
|
||||||
|
}
|
||||||
|
if (absVal >= 1000) {
|
||||||
|
return `₹ ${(val / 1000).toFixed(2)}k`;
|
||||||
|
}
|
||||||
|
return `₹ ${val.toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getPercentage = (progressAmount: number, totalAmount: number) => {
|
||||||
|
if (!totalAmount) return 0;
|
||||||
|
return Math.min(100, Math.max(0, (progressAmount / totalAmount) * 100));
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user