diff --git a/src/components/HistoryChart/HistoryChart.adapter.ts b/src/components/HistoryChart/HistoryChart.adapter.ts new file mode 100644 index 0000000..8f17dca --- /dev/null +++ b/src/components/HistoryChart/HistoryChart.adapter.ts @@ -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 = { + 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; +} diff --git a/src/components/LatestItems/LatestItems.adapter.ts b/src/components/LatestItems/LatestItems.adapter.ts new file mode 100644 index 0000000..df36795 --- /dev/null +++ b/src/components/LatestItems/LatestItems.adapter.ts @@ -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"), + })); +} diff --git a/src/components/ProgressCard/TopTags.adapter.ts b/src/components/ProgressCard/TopTags.adapter.ts new file mode 100644 index 0000000..ea0eb63 --- /dev/null +++ b/src/components/ProgressCard/TopTags.adapter.ts @@ -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(); + + 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 }; +} diff --git a/src/components/report.helpers.ts b/src/components/report.helpers.ts new file mode 100644 index 0000000..eb003fd --- /dev/null +++ b/src/components/report.helpers.ts @@ -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 = { + 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(); + + 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)); +};