@pfm-platform/expenses-feature 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs ADDED
@@ -0,0 +1,282 @@
1
+ 'use strict';
2
+
3
+ var React = require('react');
4
+ var expensesDataAccess = require('@pfm-platform/expenses-data-access');
5
+ var dateFns = require('date-fns');
6
+ var jsxRuntime = require('react/jsx-runtime');
7
+
8
+ function _interopDefault (e) { return e && e.__esModule ? e : { default: e }; }
9
+
10
+ var React__default = /*#__PURE__*/_interopDefault(React);
11
+
12
+ // src/hooks/useExpenseSummary.ts
13
+ function useExpenseSummary({
14
+ userId,
15
+ filters
16
+ }) {
17
+ const { data: expenses } = expensesDataAccess.useExpenses({ userId, filters });
18
+ return React.useMemo(() => {
19
+ if (!expenses || expenses.length === 0) {
20
+ return {
21
+ totalAmount: 0,
22
+ count: 0,
23
+ hasExpenses: false,
24
+ averageAmount: 0
25
+ };
26
+ }
27
+ const totalAmount = expenses.reduce((sum, expense) => {
28
+ return sum + parseFloat(expense.amount);
29
+ }, 0);
30
+ const count = expenses.length;
31
+ return {
32
+ totalAmount,
33
+ count,
34
+ hasExpenses: true,
35
+ averageAmount: totalAmount / count
36
+ };
37
+ }, [expenses]);
38
+ }
39
+ function useExpensesByCategory({
40
+ userId,
41
+ filters,
42
+ sortBy = "amount",
43
+ sortOrder = "desc"
44
+ }) {
45
+ const { data: expenses } = expensesDataAccess.useExpenses({ userId, filters });
46
+ return React.useMemo(() => {
47
+ if (!expenses || expenses.length === 0) {
48
+ return [];
49
+ }
50
+ const total = expenses.reduce((sum, expense) => {
51
+ return sum + parseFloat(expense.amount);
52
+ }, 0);
53
+ const categories = expenses.map((expense) => {
54
+ const amount = parseFloat(expense.amount);
55
+ return {
56
+ tag: expense.tag,
57
+ amount,
58
+ percentage: total > 0 ? amount / total * 100 : 0
59
+ };
60
+ });
61
+ categories.sort((a, b) => {
62
+ if (sortBy === "amount") {
63
+ const diff = a.amount - b.amount;
64
+ return sortOrder === "asc" ? diff : -diff;
65
+ } else {
66
+ const comparison = a.tag.localeCompare(b.tag);
67
+ return sortOrder === "asc" ? comparison : -comparison;
68
+ }
69
+ });
70
+ return categories;
71
+ }, [expenses, sortBy, sortOrder]);
72
+ }
73
+ function useExpenseFilters(options = {}) {
74
+ return React.useMemo(() => {
75
+ const { begin_on, end_on, threshold } = options;
76
+ const hasDateFilter = !!(begin_on || end_on);
77
+ const hasThresholdFilter = !!(threshold && threshold > 0);
78
+ let dateRangeLabel;
79
+ if (begin_on || end_on) {
80
+ const formatDate = (dateStr) => {
81
+ const date = new Date(dateStr);
82
+ return date.toLocaleDateString("en-US", {
83
+ month: "short",
84
+ year: "numeric"
85
+ });
86
+ };
87
+ if (begin_on && end_on) {
88
+ dateRangeLabel = `${formatDate(begin_on)} - ${formatDate(end_on)}`;
89
+ } else if (begin_on) {
90
+ dateRangeLabel = `From ${formatDate(begin_on)}`;
91
+ } else if (end_on) {
92
+ dateRangeLabel = `Until ${formatDate(end_on)}`;
93
+ }
94
+ }
95
+ return {
96
+ hasDateFilter,
97
+ hasThresholdFilter,
98
+ hasActiveFilters: hasDateFilter || hasThresholdFilter,
99
+ dateRangeLabel
100
+ };
101
+ }, [options.begin_on, options.end_on, options.threshold]);
102
+ }
103
+ function validateExpenseFilters(options) {
104
+ const errors = [];
105
+ const { begin_on, end_on, threshold } = options;
106
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
107
+ if (begin_on && !dateRegex.test(begin_on)) {
108
+ errors.push("begin_on must be in YYYY-MM-DD format");
109
+ }
110
+ if (end_on && !dateRegex.test(end_on)) {
111
+ errors.push("end_on must be in YYYY-MM-DD format");
112
+ }
113
+ if (begin_on && end_on && dateRegex.test(begin_on) && dateRegex.test(end_on)) {
114
+ const beginDate = new Date(begin_on);
115
+ const endDate = new Date(end_on);
116
+ if (endDate < beginDate) {
117
+ errors.push("end_on must be after begin_on");
118
+ }
119
+ }
120
+ if (threshold !== void 0 && threshold < 0) {
121
+ errors.push("threshold must be non-negative");
122
+ }
123
+ return {
124
+ isValid: errors.length === 0,
125
+ errors
126
+ };
127
+ }
128
+ var ExpenseWheelContext = React.createContext(void 0);
129
+ function generateDateRanges() {
130
+ const now = /* @__PURE__ */ new Date();
131
+ const endDate = dateFns.endOfMonth(now);
132
+ return [
133
+ {
134
+ label: "1M",
135
+ longLabel: "1 Month",
136
+ range: [dateFns.startOfMonth(dateFns.subMonths(endDate, 0)), endDate]
137
+ },
138
+ {
139
+ label: "3M",
140
+ longLabel: "3 Months",
141
+ range: [dateFns.startOfMonth(dateFns.subMonths(endDate, 2)), endDate]
142
+ },
143
+ {
144
+ label: "6M",
145
+ longLabel: "6 Months",
146
+ range: [dateFns.startOfMonth(dateFns.subMonths(endDate, 5)), endDate]
147
+ },
148
+ {
149
+ label: "1Y",
150
+ longLabel: "1 Year",
151
+ range: [dateFns.startOfMonth(dateFns.subMonths(endDate, 11)), endDate]
152
+ },
153
+ {
154
+ label: "All",
155
+ longLabel: "All Time",
156
+ range: [dateFns.startOfYear(dateFns.subYears(endDate, 5)), endDate]
157
+ // 5 years back
158
+ }
159
+ ];
160
+ }
161
+ function formatDateForAPI(date) {
162
+ return date.toISOString().split("T")[0];
163
+ }
164
+ function ExpenseWheelProvider({
165
+ children,
166
+ userId,
167
+ initialDateRangeIndex = 0
168
+ }) {
169
+ const allDateRanges = React__default.default.useMemo(() => generateDateRanges(), []);
170
+ const [selectedDateRangeIndex, setSelectedDateRangeIndex] = React.useState(initialDateRangeIndex);
171
+ const [selectedTag, setSelectedTag] = React.useState(null);
172
+ const [settingsVisible, setSettingsVisible] = React.useState(false);
173
+ const [isBusy, setIsBusy] = React.useState(false);
174
+ const initialFilters = {
175
+ begin_on: formatDateForAPI(allDateRanges[initialDateRangeIndex].range[0]),
176
+ end_on: formatDateForAPI(allDateRanges[initialDateRangeIndex].range[1]),
177
+ accountIds: [],
178
+ excludedTags: []
179
+ };
180
+ const [filters, setFilters] = React.useState(initialFilters);
181
+ const [unsavedFilters, setUnsavedFilters] = React.useState(initialFilters);
182
+ const selectedDateRange = allDateRanges[selectedDateRangeIndex].range;
183
+ const selectDateRange = React.useCallback((index, force) => {
184
+ if (index < 0 || index >= allDateRanges.length) {
185
+ console.warn(`Invalid date range index: ${index}`);
186
+ return;
187
+ }
188
+ setSelectedDateRangeIndex(index);
189
+ const newRange = allDateRanges[index].range;
190
+ setFilters((prev) => ({
191
+ ...prev,
192
+ begin_on: formatDateForAPI(newRange[0]),
193
+ end_on: formatDateForAPI(newRange[1])
194
+ }));
195
+ setUnsavedFilters((prev) => ({
196
+ ...prev,
197
+ begin_on: formatDateForAPI(newRange[0]),
198
+ end_on: formatDateForAPI(newRange[1])
199
+ }));
200
+ }, [allDateRanges]);
201
+ const selectTag = React.useCallback((tag) => {
202
+ setSelectedTag((prev) => prev === tag ? null : tag);
203
+ }, []);
204
+ const showSettings = React.useCallback(() => {
205
+ setSettingsVisible(true);
206
+ setUnsavedFilters({ ...filters });
207
+ }, [filters]);
208
+ const hideSettings = React.useCallback(() => {
209
+ setSettingsVisible(false);
210
+ }, []);
211
+ const updateFilters = React.useCallback((partial) => {
212
+ setUnsavedFilters((prev) => ({ ...prev, ...partial }));
213
+ }, []);
214
+ const saveFilters = React.useCallback(() => {
215
+ setFilters({ ...unsavedFilters });
216
+ setSettingsVisible(false);
217
+ }, [unsavedFilters]);
218
+ const cancelFilters = React.useCallback(() => {
219
+ setUnsavedFilters({ ...filters });
220
+ setSettingsVisible(false);
221
+ }, [filters]);
222
+ const toggleAccount = React.useCallback((accountId) => {
223
+ setUnsavedFilters((prev) => {
224
+ const accountIds = prev.accountIds || [];
225
+ const exists = accountIds.includes(accountId);
226
+ return {
227
+ ...prev,
228
+ accountIds: exists ? accountIds.filter((id) => id !== accountId) : [...accountIds, accountId]
229
+ };
230
+ });
231
+ }, []);
232
+ const toggleExcludedTag = React.useCallback((tag) => {
233
+ setUnsavedFilters((prev) => {
234
+ const excludedTags = prev.excludedTags || [];
235
+ const exists = excludedTags.includes(tag);
236
+ return {
237
+ ...prev,
238
+ excludedTags: exists ? excludedTags.filter((t) => t !== tag) : [...excludedTags, tag]
239
+ };
240
+ });
241
+ }, []);
242
+ const setBusy = React.useCallback((busy) => {
243
+ setIsBusy(busy);
244
+ }, []);
245
+ const value = {
246
+ selectedDateRangeIndex,
247
+ selectedDateRange,
248
+ allDateRanges,
249
+ selectedTag,
250
+ settingsVisible,
251
+ filters,
252
+ unsavedFilters,
253
+ isBusy,
254
+ selectDateRange,
255
+ selectTag,
256
+ showSettings,
257
+ hideSettings,
258
+ updateFilters,
259
+ saveFilters,
260
+ cancelFilters,
261
+ toggleAccount,
262
+ toggleExcludedTag,
263
+ setBusy
264
+ };
265
+ return /* @__PURE__ */ jsxRuntime.jsx(ExpenseWheelContext.Provider, { value, children });
266
+ }
267
+ function useExpenseWheelContext() {
268
+ const context = React.useContext(ExpenseWheelContext);
269
+ if (!context) {
270
+ throw new Error("useExpenseWheelContext must be used within ExpenseWheelProvider");
271
+ }
272
+ return context;
273
+ }
274
+
275
+ exports.ExpenseWheelProvider = ExpenseWheelProvider;
276
+ exports.useExpenseFilters = useExpenseFilters;
277
+ exports.useExpenseSummary = useExpenseSummary;
278
+ exports.useExpenseWheelContext = useExpenseWheelContext;
279
+ exports.useExpensesByCategory = useExpensesByCategory;
280
+ exports.validateExpenseFilters = validateExpenseFilters;
281
+ //# sourceMappingURL=index.cjs.map
282
+ //# sourceMappingURL=index.cjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/useExpenseSummary.ts","../src/hooks/useExpensesByCategory.ts","../src/hooks/useExpenseFilters.ts","../src/context/ExpenseWheelContext.tsx"],"names":["useExpenses","useMemo","createContext","endOfMonth","startOfMonth","subMonths","startOfYear","subYears","React","useState","useCallback","jsx","useContext"],"mappings":"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","file":"index.cjs","sourcesContent":["import { useMemo } from 'react';\nimport { useExpenses } from '@pfm-platform/expenses-data-access';\n\nexport interface ExpenseSummary {\n totalAmount: number;\n count: number;\n hasExpenses: boolean;\n averageAmount: number;\n}\n\ninterface UseExpenseSummaryParams {\n userId: string;\n filters?: {\n begin_on?: string;\n end_on?: string;\n threshold?: number;\n };\n}\n\n/**\n * Calculate summary statistics for expenses\n *\n * Business logic:\n * - totalAmount: Sum of all expense amounts (parsed from strings)\n * - count: Total number of expense categories\n * - hasExpenses: Boolean flag for conditional rendering\n * - averageAmount: Average expense per category\n *\n * @param params - User ID and optional filters (date range, threshold)\n * @returns Summary statistics for expenses\n */\nexport function useExpenseSummary({\n userId,\n filters,\n}: UseExpenseSummaryParams): ExpenseSummary {\n const { data: expenses } = useExpenses({ userId, filters });\n\n return useMemo(() => {\n if (!expenses || expenses.length === 0) {\n return {\n totalAmount: 0,\n count: 0,\n hasExpenses: false,\n averageAmount: 0,\n };\n }\n\n // Parse string amounts and sum\n const totalAmount = expenses.reduce((sum, expense) => {\n return sum + parseFloat(expense.amount);\n }, 0);\n\n const count = expenses.length;\n\n return {\n totalAmount,\n count,\n hasExpenses: true,\n averageAmount: totalAmount / count,\n };\n }, [expenses]);\n}\n","import { useMemo } from 'react';\nimport { useExpenses } from '@pfm-platform/expenses-data-access';\nimport type { Expense } from '@pfm-platform/shared';\n\nexport interface ExpenseCategory {\n tag: string;\n amount: number;\n percentage: number;\n}\n\ninterface UseExpensesByCategoryParams {\n userId: string;\n filters?: {\n begin_on?: string;\n end_on?: string;\n threshold?: number;\n };\n sortBy?: 'amount' | 'tag';\n sortOrder?: 'asc' | 'desc';\n}\n\n/**\n * Group and sort expenses by category (tag)\n *\n * Business logic:\n * - Parse string amounts to numbers for calculations\n * - Calculate percentage of total for each category\n * - Sort by amount (default, descending) or tag name\n * - Return structured category objects with parsed amounts\n *\n * @param params - User ID, filters, and sorting options\n * @returns Array of expense categories with amounts and percentages\n */\nexport function useExpensesByCategory({\n userId,\n filters,\n sortBy = 'amount',\n sortOrder = 'desc',\n}: UseExpensesByCategoryParams): ExpenseCategory[] {\n const { data: expenses } = useExpenses({ userId, filters });\n\n return useMemo(() => {\n if (!expenses || expenses.length === 0) {\n return [];\n }\n\n // Calculate total for percentages\n const total = expenses.reduce((sum, expense) => {\n return sum + parseFloat(expense.amount);\n }, 0);\n\n // Transform expenses to categories with parsed amounts and percentages\n const categories: ExpenseCategory[] = expenses.map((expense) => {\n const amount = parseFloat(expense.amount);\n return {\n tag: expense.tag,\n amount,\n percentage: total > 0 ? (amount / total) * 100 : 0,\n };\n });\n\n // Sort categories\n categories.sort((a, b) => {\n if (sortBy === 'amount') {\n const diff = a.amount - b.amount;\n return sortOrder === 'asc' ? diff : -diff;\n } else {\n // Sort by tag name\n const comparison = a.tag.localeCompare(b.tag);\n return sortOrder === 'asc' ? comparison : -comparison;\n }\n });\n\n return categories;\n }, [expenses, sortBy, sortOrder]);\n}\n","import { useMemo } from 'react';\n\nexport interface ExpenseFilterState {\n hasDateFilter: boolean;\n hasThresholdFilter: boolean;\n hasActiveFilters: boolean;\n dateRangeLabel?: string;\n}\n\nexport interface ExpenseFilterOptions {\n begin_on?: string;\n end_on?: string;\n threshold?: number;\n}\n\n/**\n * Check which expense filters are active and provide helper methods\n *\n * Business logic:\n * - hasDateFilter: begin_on or end_on is set\n * - hasThresholdFilter: threshold is set and > 0\n * - hasActiveFilters: any filter is active\n * - dateRangeLabel: Human-readable date range (e.g., \"Jan 2024 - Mar 2024\")\n *\n * @param options - Filter options (begin_on, end_on, threshold)\n * @returns Object with boolean flags and helper properties\n *\n * @example\n * ```tsx\n * function ExpenseFilters() {\n * const [filters, setFilters] = useState({\n * begin_on: '2024-01-01',\n * end_on: '2024-03-31',\n * threshold: 100\n * });\n *\n * const filterState = useExpenseFilters(filters);\n *\n * return (\n * <div>\n * {filterState.hasActiveFilters && (\n * <p>Active filters: {filterState.dateRangeLabel}</p>\n * )}\n * {filterState.hasThresholdFilter && (\n * <p>Showing expenses above ${filters.threshold}</p>\n * )}\n * </div>\n * );\n * }\n * ```\n */\nexport function useExpenseFilters(\n options: ExpenseFilterOptions = {}\n): ExpenseFilterState {\n return useMemo(() => {\n const { begin_on, end_on, threshold } = options;\n\n const hasDateFilter = !!(begin_on || end_on);\n const hasThresholdFilter = !!(threshold && threshold > 0);\n\n // Generate human-readable date range label\n let dateRangeLabel: string | undefined;\n if (begin_on || end_on) {\n const formatDate = (dateStr: string) => {\n const date = new Date(dateStr);\n return date.toLocaleDateString('en-US', {\n month: 'short',\n year: 'numeric',\n });\n };\n\n if (begin_on && end_on) {\n dateRangeLabel = `${formatDate(begin_on)} - ${formatDate(end_on)}`;\n } else if (begin_on) {\n dateRangeLabel = `From ${formatDate(begin_on)}`;\n } else if (end_on) {\n dateRangeLabel = `Until ${formatDate(end_on)}`;\n }\n }\n\n return {\n hasDateFilter,\n hasThresholdFilter,\n hasActiveFilters: hasDateFilter || hasThresholdFilter,\n dateRangeLabel,\n };\n }, [options.begin_on, options.end_on, options.threshold]);\n}\n\n/**\n * Validate expense filter values\n *\n * Business logic:\n * - begin_on must be valid date format (YYYY-MM-DD)\n * - end_on must be valid date format (YYYY-MM-DD)\n * - end_on must be after begin_on if both provided\n * - threshold must be non-negative if provided\n *\n * @param options - Filter options to validate\n * @returns Validation result with errors array\n *\n * @example\n * ```tsx\n * function ExpenseFilterForm() {\n * const handleSubmit = (filters: ExpenseFilterOptions) => {\n * const validation = validateExpenseFilters(filters);\n * if (!validation.isValid) {\n * alert(validation.errors.join(', '));\n * return;\n * }\n * // Apply filters...\n * };\n * }\n * ```\n */\nexport function validateExpenseFilters(\n options: ExpenseFilterOptions\n): { isValid: boolean; errors: string[] } {\n const errors: string[] = [];\n const { begin_on, end_on, threshold } = options;\n\n // Validate date format (YYYY-MM-DD)\n const dateRegex = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n if (begin_on && !dateRegex.test(begin_on)) {\n errors.push('begin_on must be in YYYY-MM-DD format');\n }\n\n if (end_on && !dateRegex.test(end_on)) {\n errors.push('end_on must be in YYYY-MM-DD format');\n }\n\n // Validate date range logic\n if (begin_on && end_on && dateRegex.test(begin_on) && dateRegex.test(end_on)) {\n const beginDate = new Date(begin_on);\n const endDate = new Date(end_on);\n\n if (endDate < beginDate) {\n errors.push('end_on must be after begin_on');\n }\n }\n\n // Validate threshold\n if (threshold !== undefined && threshold < 0) {\n errors.push('threshold must be non-negative');\n }\n\n return {\n isValid: errors.length === 0,\n errors,\n };\n}\n","/**\n * ExpenseWheelContext\n *\n * Provides state management for the Expense Wheel and Analyzer views.\n * Manages date range selection, category selection, settings panel, and filters.\n *\n * Legacy Pattern: Replaces MobX spendingStore.Wheel observables with React Context\n */\n\nimport React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';\nimport { startOfMonth, endOfMonth, subMonths, subYears, startOfYear, parseISO, isValid } from 'date-fns';\n\n/**\n * Date range configuration matching legacy Wheel.allDateRanges\n */\nexport interface DateRangeConfig {\n label: string;\n longLabel: string;\n range: [Date, Date];\n}\n\n/**\n * Expense filter configuration\n */\nexport interface ExpenseFilters {\n begin_on?: string;\n end_on?: string;\n threshold?: number;\n accountIds?: string[];\n excludedTags?: string[];\n}\n\n/**\n * Context state interface\n */\nexport interface ExpenseWheelContextState {\n // Date Range State\n selectedDateRangeIndex: number;\n selectedDateRange: [Date, Date];\n allDateRanges: DateRangeConfig[];\n\n // Category Selection\n selectedTag: string | null;\n\n // Settings Panel\n settingsVisible: boolean;\n\n // Filters\n filters: ExpenseFilters;\n unsavedFilters: ExpenseFilters;\n\n // Loading State\n isBusy: boolean;\n\n // Actions\n selectDateRange: (index: number, force?: boolean) => void;\n selectTag: (tag: string | null) => void;\n showSettings: () => void;\n hideSettings: () => void;\n updateFilters: (filters: Partial<ExpenseFilters>) => void;\n saveFilters: () => void;\n cancelFilters: () => void;\n toggleAccount: (accountId: string) => void;\n toggleExcludedTag: (tag: string) => void;\n setBusy: (busy: boolean) => void;\n}\n\nconst ExpenseWheelContext = createContext<ExpenseWheelContextState | undefined>(undefined);\n\n/**\n * Generate predefined date ranges matching legacy implementation\n */\nfunction generateDateRanges(): DateRangeConfig[] {\n const now = new Date();\n const endDate = endOfMonth(now);\n\n return [\n {\n label: '1M',\n longLabel: '1 Month',\n range: [startOfMonth(subMonths(endDate, 0)), endDate],\n },\n {\n label: '3M',\n longLabel: '3 Months',\n range: [startOfMonth(subMonths(endDate, 2)), endDate],\n },\n {\n label: '6M',\n longLabel: '6 Months',\n range: [startOfMonth(subMonths(endDate, 5)), endDate],\n },\n {\n label: '1Y',\n longLabel: '1 Year',\n range: [startOfMonth(subMonths(endDate, 11)), endDate],\n },\n {\n label: 'All',\n longLabel: 'All Time',\n range: [startOfYear(subYears(endDate, 5)), endDate], // 5 years back\n },\n ];\n}\n\n/**\n * Format date for API (YYYY-MM-DD)\n */\nfunction formatDateForAPI(date: Date): string {\n return date.toISOString().split('T')[0];\n}\n\nexport interface ExpenseWheelProviderProps {\n children: ReactNode;\n userId: string;\n initialDateRangeIndex?: number;\n}\n\n/**\n * ExpenseWheelProvider Component\n *\n * Provides expense wheel state and actions via Context API\n */\nexport function ExpenseWheelProvider({\n children,\n userId,\n initialDateRangeIndex = 0,\n}: ExpenseWheelProviderProps) {\n const allDateRanges = React.useMemo(() => generateDateRanges(), []);\n\n const [selectedDateRangeIndex, setSelectedDateRangeIndex] = useState(initialDateRangeIndex);\n const [selectedTag, setSelectedTag] = useState<string | null>(null);\n const [settingsVisible, setSettingsVisible] = useState(false);\n const [isBusy, setIsBusy] = useState(false);\n\n // Initialize filters from selected date range\n const initialFilters: ExpenseFilters = {\n begin_on: formatDateForAPI(allDateRanges[initialDateRangeIndex].range[0]),\n end_on: formatDateForAPI(allDateRanges[initialDateRangeIndex].range[1]),\n accountIds: [],\n excludedTags: [],\n };\n\n const [filters, setFilters] = useState<ExpenseFilters>(initialFilters);\n const [unsavedFilters, setUnsavedFilters] = useState<ExpenseFilters>(initialFilters);\n\n const selectedDateRange = allDateRanges[selectedDateRangeIndex].range;\n\n /**\n * Select date range by index\n * Matches legacy: Wheel.selectDateRange(range, force)\n */\n const selectDateRange = useCallback((index: number, force?: boolean) => {\n if (index < 0 || index >= allDateRanges.length) {\n console.warn(`Invalid date range index: ${index}`);\n return;\n }\n\n setSelectedDateRangeIndex(index);\n const newRange = allDateRanges[index].range;\n\n // Update filters with new date range\n setFilters(prev => ({\n ...prev,\n begin_on: formatDateForAPI(newRange[0]),\n end_on: formatDateForAPI(newRange[1]),\n }));\n\n setUnsavedFilters(prev => ({\n ...prev,\n begin_on: formatDateForAPI(newRange[0]),\n end_on: formatDateForAPI(newRange[1]),\n }));\n }, [allDateRanges]);\n\n /**\n * Select category tag\n * Matches legacy: Wheel.selectTag(tag)\n */\n const selectTag = useCallback((tag: string | null) => {\n // Toggle behavior: if same tag clicked, deselect\n setSelectedTag(prev => (prev === tag ? null : tag));\n }, []);\n\n /**\n * Show settings panel\n * Matches legacy: Wheel.showSettings()\n */\n const showSettings = useCallback(() => {\n setSettingsVisible(true);\n // Copy current filters to unsaved (edit buffer)\n setUnsavedFilters({ ...filters });\n }, [filters]);\n\n /**\n * Hide settings panel\n * Matches legacy: Wheel.hideSettings()\n */\n const hideSettings = useCallback(() => {\n setSettingsVisible(false);\n }, []);\n\n /**\n * Update unsaved filters (while settings panel is open)\n */\n const updateFilters = useCallback((partial: Partial<ExpenseFilters>) => {\n setUnsavedFilters(prev => ({ ...prev, ...partial }));\n }, []);\n\n /**\n * Save unsaved filters and close settings\n * Matches legacy: Wheel.saveExcludedTags()\n */\n const saveFilters = useCallback(() => {\n setFilters({ ...unsavedFilters });\n setSettingsVisible(false);\n }, [unsavedFilters]);\n\n /**\n * Cancel unsaved changes and close settings\n */\n const cancelFilters = useCallback(() => {\n setUnsavedFilters({ ...filters });\n setSettingsVisible(false);\n }, [filters]);\n\n /**\n * Toggle account inclusion in filters\n * Matches legacy: Wheel.toggleAccount(account)\n */\n const toggleAccount = useCallback((accountId: string) => {\n setUnsavedFilters(prev => {\n const accountIds = prev.accountIds || [];\n const exists = accountIds.includes(accountId);\n\n return {\n ...prev,\n accountIds: exists\n ? accountIds.filter(id => id !== accountId)\n : [...accountIds, accountId],\n };\n });\n }, []);\n\n /**\n * Toggle tag exclusion\n */\n const toggleExcludedTag = useCallback((tag: string) => {\n setUnsavedFilters(prev => {\n const excludedTags = prev.excludedTags || [];\n const exists = excludedTags.includes(tag);\n\n return {\n ...prev,\n excludedTags: exists\n ? excludedTags.filter(t => t !== tag)\n : [...excludedTags, tag],\n };\n });\n }, []);\n\n /**\n * Set busy/loading state\n */\n const setBusy = useCallback((busy: boolean) => {\n setIsBusy(busy);\n }, []);\n\n const value: ExpenseWheelContextState = {\n selectedDateRangeIndex,\n selectedDateRange,\n allDateRanges,\n selectedTag,\n settingsVisible,\n filters,\n unsavedFilters,\n isBusy,\n selectDateRange,\n selectTag,\n showSettings,\n hideSettings,\n updateFilters,\n saveFilters,\n cancelFilters,\n toggleAccount,\n toggleExcludedTag,\n setBusy,\n };\n\n return (\n <ExpenseWheelContext.Provider value={value}>\n {children}\n </ExpenseWheelContext.Provider>\n );\n}\n\n/**\n * Hook to access ExpenseWheelContext\n * Throws error if used outside provider\n */\nexport function useExpenseWheelContext(): ExpenseWheelContextState {\n const context = useContext(ExpenseWheelContext);\n\n if (!context) {\n throw new Error('useExpenseWheelContext must be used within ExpenseWheelProvider');\n }\n\n return context;\n}\n"]}
@@ -0,0 +1,198 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+
4
+ interface ExpenseSummary {
5
+ totalAmount: number;
6
+ count: number;
7
+ hasExpenses: boolean;
8
+ averageAmount: number;
9
+ }
10
+ interface UseExpenseSummaryParams {
11
+ userId: string;
12
+ filters?: {
13
+ begin_on?: string;
14
+ end_on?: string;
15
+ threshold?: number;
16
+ };
17
+ }
18
+ /**
19
+ * Calculate summary statistics for expenses
20
+ *
21
+ * Business logic:
22
+ * - totalAmount: Sum of all expense amounts (parsed from strings)
23
+ * - count: Total number of expense categories
24
+ * - hasExpenses: Boolean flag for conditional rendering
25
+ * - averageAmount: Average expense per category
26
+ *
27
+ * @param params - User ID and optional filters (date range, threshold)
28
+ * @returns Summary statistics for expenses
29
+ */
30
+ declare function useExpenseSummary({ userId, filters, }: UseExpenseSummaryParams): ExpenseSummary;
31
+
32
+ interface ExpenseCategory {
33
+ tag: string;
34
+ amount: number;
35
+ percentage: number;
36
+ }
37
+ interface UseExpensesByCategoryParams {
38
+ userId: string;
39
+ filters?: {
40
+ begin_on?: string;
41
+ end_on?: string;
42
+ threshold?: number;
43
+ };
44
+ sortBy?: 'amount' | 'tag';
45
+ sortOrder?: 'asc' | 'desc';
46
+ }
47
+ /**
48
+ * Group and sort expenses by category (tag)
49
+ *
50
+ * Business logic:
51
+ * - Parse string amounts to numbers for calculations
52
+ * - Calculate percentage of total for each category
53
+ * - Sort by amount (default, descending) or tag name
54
+ * - Return structured category objects with parsed amounts
55
+ *
56
+ * @param params - User ID, filters, and sorting options
57
+ * @returns Array of expense categories with amounts and percentages
58
+ */
59
+ declare function useExpensesByCategory({ userId, filters, sortBy, sortOrder, }: UseExpensesByCategoryParams): ExpenseCategory[];
60
+
61
+ interface ExpenseFilterState {
62
+ hasDateFilter: boolean;
63
+ hasThresholdFilter: boolean;
64
+ hasActiveFilters: boolean;
65
+ dateRangeLabel?: string;
66
+ }
67
+ interface ExpenseFilterOptions {
68
+ begin_on?: string;
69
+ end_on?: string;
70
+ threshold?: number;
71
+ }
72
+ /**
73
+ * Check which expense filters are active and provide helper methods
74
+ *
75
+ * Business logic:
76
+ * - hasDateFilter: begin_on or end_on is set
77
+ * - hasThresholdFilter: threshold is set and > 0
78
+ * - hasActiveFilters: any filter is active
79
+ * - dateRangeLabel: Human-readable date range (e.g., "Jan 2024 - Mar 2024")
80
+ *
81
+ * @param options - Filter options (begin_on, end_on, threshold)
82
+ * @returns Object with boolean flags and helper properties
83
+ *
84
+ * @example
85
+ * ```tsx
86
+ * function ExpenseFilters() {
87
+ * const [filters, setFilters] = useState({
88
+ * begin_on: '2024-01-01',
89
+ * end_on: '2024-03-31',
90
+ * threshold: 100
91
+ * });
92
+ *
93
+ * const filterState = useExpenseFilters(filters);
94
+ *
95
+ * return (
96
+ * <div>
97
+ * {filterState.hasActiveFilters && (
98
+ * <p>Active filters: {filterState.dateRangeLabel}</p>
99
+ * )}
100
+ * {filterState.hasThresholdFilter && (
101
+ * <p>Showing expenses above ${filters.threshold}</p>
102
+ * )}
103
+ * </div>
104
+ * );
105
+ * }
106
+ * ```
107
+ */
108
+ declare function useExpenseFilters(options?: ExpenseFilterOptions): ExpenseFilterState;
109
+ /**
110
+ * Validate expense filter values
111
+ *
112
+ * Business logic:
113
+ * - begin_on must be valid date format (YYYY-MM-DD)
114
+ * - end_on must be valid date format (YYYY-MM-DD)
115
+ * - end_on must be after begin_on if both provided
116
+ * - threshold must be non-negative if provided
117
+ *
118
+ * @param options - Filter options to validate
119
+ * @returns Validation result with errors array
120
+ *
121
+ * @example
122
+ * ```tsx
123
+ * function ExpenseFilterForm() {
124
+ * const handleSubmit = (filters: ExpenseFilterOptions) => {
125
+ * const validation = validateExpenseFilters(filters);
126
+ * if (!validation.isValid) {
127
+ * alert(validation.errors.join(', '));
128
+ * return;
129
+ * }
130
+ * // Apply filters...
131
+ * };
132
+ * }
133
+ * ```
134
+ */
135
+ declare function validateExpenseFilters(options: ExpenseFilterOptions): {
136
+ isValid: boolean;
137
+ errors: string[];
138
+ };
139
+
140
+ /**
141
+ * Date range configuration matching legacy Wheel.allDateRanges
142
+ */
143
+ interface DateRangeConfig {
144
+ label: string;
145
+ longLabel: string;
146
+ range: [Date, Date];
147
+ }
148
+ /**
149
+ * Expense filter configuration
150
+ */
151
+ interface ExpenseFilters {
152
+ begin_on?: string;
153
+ end_on?: string;
154
+ threshold?: number;
155
+ accountIds?: string[];
156
+ excludedTags?: string[];
157
+ }
158
+ /**
159
+ * Context state interface
160
+ */
161
+ interface ExpenseWheelContextState {
162
+ selectedDateRangeIndex: number;
163
+ selectedDateRange: [Date, Date];
164
+ allDateRanges: DateRangeConfig[];
165
+ selectedTag: string | null;
166
+ settingsVisible: boolean;
167
+ filters: ExpenseFilters;
168
+ unsavedFilters: ExpenseFilters;
169
+ isBusy: boolean;
170
+ selectDateRange: (index: number, force?: boolean) => void;
171
+ selectTag: (tag: string | null) => void;
172
+ showSettings: () => void;
173
+ hideSettings: () => void;
174
+ updateFilters: (filters: Partial<ExpenseFilters>) => void;
175
+ saveFilters: () => void;
176
+ cancelFilters: () => void;
177
+ toggleAccount: (accountId: string) => void;
178
+ toggleExcludedTag: (tag: string) => void;
179
+ setBusy: (busy: boolean) => void;
180
+ }
181
+ interface ExpenseWheelProviderProps {
182
+ children: ReactNode;
183
+ userId: string;
184
+ initialDateRangeIndex?: number;
185
+ }
186
+ /**
187
+ * ExpenseWheelProvider Component
188
+ *
189
+ * Provides expense wheel state and actions via Context API
190
+ */
191
+ declare function ExpenseWheelProvider({ children, userId, initialDateRangeIndex, }: ExpenseWheelProviderProps): react_jsx_runtime.JSX.Element;
192
+ /**
193
+ * Hook to access ExpenseWheelContext
194
+ * Throws error if used outside provider
195
+ */
196
+ declare function useExpenseWheelContext(): ExpenseWheelContextState;
197
+
198
+ export { type DateRangeConfig, type ExpenseCategory, type ExpenseFilterOptions, type ExpenseFilterState, type ExpenseFilters, type ExpenseSummary, type ExpenseWheelContextState, ExpenseWheelProvider, type ExpenseWheelProviderProps, useExpenseFilters, useExpenseSummary, useExpenseWheelContext, useExpensesByCategory, validateExpenseFilters };
@@ -0,0 +1,198 @@
1
+ import * as react_jsx_runtime from 'react/jsx-runtime';
2
+ import { ReactNode } from 'react';
3
+
4
+ interface ExpenseSummary {
5
+ totalAmount: number;
6
+ count: number;
7
+ hasExpenses: boolean;
8
+ averageAmount: number;
9
+ }
10
+ interface UseExpenseSummaryParams {
11
+ userId: string;
12
+ filters?: {
13
+ begin_on?: string;
14
+ end_on?: string;
15
+ threshold?: number;
16
+ };
17
+ }
18
+ /**
19
+ * Calculate summary statistics for expenses
20
+ *
21
+ * Business logic:
22
+ * - totalAmount: Sum of all expense amounts (parsed from strings)
23
+ * - count: Total number of expense categories
24
+ * - hasExpenses: Boolean flag for conditional rendering
25
+ * - averageAmount: Average expense per category
26
+ *
27
+ * @param params - User ID and optional filters (date range, threshold)
28
+ * @returns Summary statistics for expenses
29
+ */
30
+ declare function useExpenseSummary({ userId, filters, }: UseExpenseSummaryParams): ExpenseSummary;
31
+
32
+ interface ExpenseCategory {
33
+ tag: string;
34
+ amount: number;
35
+ percentage: number;
36
+ }
37
+ interface UseExpensesByCategoryParams {
38
+ userId: string;
39
+ filters?: {
40
+ begin_on?: string;
41
+ end_on?: string;
42
+ threshold?: number;
43
+ };
44
+ sortBy?: 'amount' | 'tag';
45
+ sortOrder?: 'asc' | 'desc';
46
+ }
47
+ /**
48
+ * Group and sort expenses by category (tag)
49
+ *
50
+ * Business logic:
51
+ * - Parse string amounts to numbers for calculations
52
+ * - Calculate percentage of total for each category
53
+ * - Sort by amount (default, descending) or tag name
54
+ * - Return structured category objects with parsed amounts
55
+ *
56
+ * @param params - User ID, filters, and sorting options
57
+ * @returns Array of expense categories with amounts and percentages
58
+ */
59
+ declare function useExpensesByCategory({ userId, filters, sortBy, sortOrder, }: UseExpensesByCategoryParams): ExpenseCategory[];
60
+
61
+ interface ExpenseFilterState {
62
+ hasDateFilter: boolean;
63
+ hasThresholdFilter: boolean;
64
+ hasActiveFilters: boolean;
65
+ dateRangeLabel?: string;
66
+ }
67
+ interface ExpenseFilterOptions {
68
+ begin_on?: string;
69
+ end_on?: string;
70
+ threshold?: number;
71
+ }
72
+ /**
73
+ * Check which expense filters are active and provide helper methods
74
+ *
75
+ * Business logic:
76
+ * - hasDateFilter: begin_on or end_on is set
77
+ * - hasThresholdFilter: threshold is set and > 0
78
+ * - hasActiveFilters: any filter is active
79
+ * - dateRangeLabel: Human-readable date range (e.g., "Jan 2024 - Mar 2024")
80
+ *
81
+ * @param options - Filter options (begin_on, end_on, threshold)
82
+ * @returns Object with boolean flags and helper properties
83
+ *
84
+ * @example
85
+ * ```tsx
86
+ * function ExpenseFilters() {
87
+ * const [filters, setFilters] = useState({
88
+ * begin_on: '2024-01-01',
89
+ * end_on: '2024-03-31',
90
+ * threshold: 100
91
+ * });
92
+ *
93
+ * const filterState = useExpenseFilters(filters);
94
+ *
95
+ * return (
96
+ * <div>
97
+ * {filterState.hasActiveFilters && (
98
+ * <p>Active filters: {filterState.dateRangeLabel}</p>
99
+ * )}
100
+ * {filterState.hasThresholdFilter && (
101
+ * <p>Showing expenses above ${filters.threshold}</p>
102
+ * )}
103
+ * </div>
104
+ * );
105
+ * }
106
+ * ```
107
+ */
108
+ declare function useExpenseFilters(options?: ExpenseFilterOptions): ExpenseFilterState;
109
+ /**
110
+ * Validate expense filter values
111
+ *
112
+ * Business logic:
113
+ * - begin_on must be valid date format (YYYY-MM-DD)
114
+ * - end_on must be valid date format (YYYY-MM-DD)
115
+ * - end_on must be after begin_on if both provided
116
+ * - threshold must be non-negative if provided
117
+ *
118
+ * @param options - Filter options to validate
119
+ * @returns Validation result with errors array
120
+ *
121
+ * @example
122
+ * ```tsx
123
+ * function ExpenseFilterForm() {
124
+ * const handleSubmit = (filters: ExpenseFilterOptions) => {
125
+ * const validation = validateExpenseFilters(filters);
126
+ * if (!validation.isValid) {
127
+ * alert(validation.errors.join(', '));
128
+ * return;
129
+ * }
130
+ * // Apply filters...
131
+ * };
132
+ * }
133
+ * ```
134
+ */
135
+ declare function validateExpenseFilters(options: ExpenseFilterOptions): {
136
+ isValid: boolean;
137
+ errors: string[];
138
+ };
139
+
140
+ /**
141
+ * Date range configuration matching legacy Wheel.allDateRanges
142
+ */
143
+ interface DateRangeConfig {
144
+ label: string;
145
+ longLabel: string;
146
+ range: [Date, Date];
147
+ }
148
+ /**
149
+ * Expense filter configuration
150
+ */
151
+ interface ExpenseFilters {
152
+ begin_on?: string;
153
+ end_on?: string;
154
+ threshold?: number;
155
+ accountIds?: string[];
156
+ excludedTags?: string[];
157
+ }
158
+ /**
159
+ * Context state interface
160
+ */
161
+ interface ExpenseWheelContextState {
162
+ selectedDateRangeIndex: number;
163
+ selectedDateRange: [Date, Date];
164
+ allDateRanges: DateRangeConfig[];
165
+ selectedTag: string | null;
166
+ settingsVisible: boolean;
167
+ filters: ExpenseFilters;
168
+ unsavedFilters: ExpenseFilters;
169
+ isBusy: boolean;
170
+ selectDateRange: (index: number, force?: boolean) => void;
171
+ selectTag: (tag: string | null) => void;
172
+ showSettings: () => void;
173
+ hideSettings: () => void;
174
+ updateFilters: (filters: Partial<ExpenseFilters>) => void;
175
+ saveFilters: () => void;
176
+ cancelFilters: () => void;
177
+ toggleAccount: (accountId: string) => void;
178
+ toggleExcludedTag: (tag: string) => void;
179
+ setBusy: (busy: boolean) => void;
180
+ }
181
+ interface ExpenseWheelProviderProps {
182
+ children: ReactNode;
183
+ userId: string;
184
+ initialDateRangeIndex?: number;
185
+ }
186
+ /**
187
+ * ExpenseWheelProvider Component
188
+ *
189
+ * Provides expense wheel state and actions via Context API
190
+ */
191
+ declare function ExpenseWheelProvider({ children, userId, initialDateRangeIndex, }: ExpenseWheelProviderProps): react_jsx_runtime.JSX.Element;
192
+ /**
193
+ * Hook to access ExpenseWheelContext
194
+ * Throws error if used outside provider
195
+ */
196
+ declare function useExpenseWheelContext(): ExpenseWheelContextState;
197
+
198
+ export { type DateRangeConfig, type ExpenseCategory, type ExpenseFilterOptions, type ExpenseFilterState, type ExpenseFilters, type ExpenseSummary, type ExpenseWheelContextState, ExpenseWheelProvider, type ExpenseWheelProviderProps, useExpenseFilters, useExpenseSummary, useExpenseWheelContext, useExpensesByCategory, validateExpenseFilters };
package/dist/index.js ADDED
@@ -0,0 +1,271 @@
1
+ import React, { createContext, useMemo, useState, useCallback, useContext } from 'react';
2
+ import { useExpenses } from '@pfm-platform/expenses-data-access';
3
+ import { endOfMonth, startOfMonth, startOfYear, subMonths, subYears } from 'date-fns';
4
+ import { jsx } from 'react/jsx-runtime';
5
+
6
+ // src/hooks/useExpenseSummary.ts
7
+ function useExpenseSummary({
8
+ userId,
9
+ filters
10
+ }) {
11
+ const { data: expenses } = useExpenses({ userId, filters });
12
+ return useMemo(() => {
13
+ if (!expenses || expenses.length === 0) {
14
+ return {
15
+ totalAmount: 0,
16
+ count: 0,
17
+ hasExpenses: false,
18
+ averageAmount: 0
19
+ };
20
+ }
21
+ const totalAmount = expenses.reduce((sum, expense) => {
22
+ return sum + parseFloat(expense.amount);
23
+ }, 0);
24
+ const count = expenses.length;
25
+ return {
26
+ totalAmount,
27
+ count,
28
+ hasExpenses: true,
29
+ averageAmount: totalAmount / count
30
+ };
31
+ }, [expenses]);
32
+ }
33
+ function useExpensesByCategory({
34
+ userId,
35
+ filters,
36
+ sortBy = "amount",
37
+ sortOrder = "desc"
38
+ }) {
39
+ const { data: expenses } = useExpenses({ userId, filters });
40
+ return useMemo(() => {
41
+ if (!expenses || expenses.length === 0) {
42
+ return [];
43
+ }
44
+ const total = expenses.reduce((sum, expense) => {
45
+ return sum + parseFloat(expense.amount);
46
+ }, 0);
47
+ const categories = expenses.map((expense) => {
48
+ const amount = parseFloat(expense.amount);
49
+ return {
50
+ tag: expense.tag,
51
+ amount,
52
+ percentage: total > 0 ? amount / total * 100 : 0
53
+ };
54
+ });
55
+ categories.sort((a, b) => {
56
+ if (sortBy === "amount") {
57
+ const diff = a.amount - b.amount;
58
+ return sortOrder === "asc" ? diff : -diff;
59
+ } else {
60
+ const comparison = a.tag.localeCompare(b.tag);
61
+ return sortOrder === "asc" ? comparison : -comparison;
62
+ }
63
+ });
64
+ return categories;
65
+ }, [expenses, sortBy, sortOrder]);
66
+ }
67
+ function useExpenseFilters(options = {}) {
68
+ return useMemo(() => {
69
+ const { begin_on, end_on, threshold } = options;
70
+ const hasDateFilter = !!(begin_on || end_on);
71
+ const hasThresholdFilter = !!(threshold && threshold > 0);
72
+ let dateRangeLabel;
73
+ if (begin_on || end_on) {
74
+ const formatDate = (dateStr) => {
75
+ const date = new Date(dateStr);
76
+ return date.toLocaleDateString("en-US", {
77
+ month: "short",
78
+ year: "numeric"
79
+ });
80
+ };
81
+ if (begin_on && end_on) {
82
+ dateRangeLabel = `${formatDate(begin_on)} - ${formatDate(end_on)}`;
83
+ } else if (begin_on) {
84
+ dateRangeLabel = `From ${formatDate(begin_on)}`;
85
+ } else if (end_on) {
86
+ dateRangeLabel = `Until ${formatDate(end_on)}`;
87
+ }
88
+ }
89
+ return {
90
+ hasDateFilter,
91
+ hasThresholdFilter,
92
+ hasActiveFilters: hasDateFilter || hasThresholdFilter,
93
+ dateRangeLabel
94
+ };
95
+ }, [options.begin_on, options.end_on, options.threshold]);
96
+ }
97
+ function validateExpenseFilters(options) {
98
+ const errors = [];
99
+ const { begin_on, end_on, threshold } = options;
100
+ const dateRegex = /^\d{4}-\d{2}-\d{2}$/;
101
+ if (begin_on && !dateRegex.test(begin_on)) {
102
+ errors.push("begin_on must be in YYYY-MM-DD format");
103
+ }
104
+ if (end_on && !dateRegex.test(end_on)) {
105
+ errors.push("end_on must be in YYYY-MM-DD format");
106
+ }
107
+ if (begin_on && end_on && dateRegex.test(begin_on) && dateRegex.test(end_on)) {
108
+ const beginDate = new Date(begin_on);
109
+ const endDate = new Date(end_on);
110
+ if (endDate < beginDate) {
111
+ errors.push("end_on must be after begin_on");
112
+ }
113
+ }
114
+ if (threshold !== void 0 && threshold < 0) {
115
+ errors.push("threshold must be non-negative");
116
+ }
117
+ return {
118
+ isValid: errors.length === 0,
119
+ errors
120
+ };
121
+ }
122
+ var ExpenseWheelContext = createContext(void 0);
123
+ function generateDateRanges() {
124
+ const now = /* @__PURE__ */ new Date();
125
+ const endDate = endOfMonth(now);
126
+ return [
127
+ {
128
+ label: "1M",
129
+ longLabel: "1 Month",
130
+ range: [startOfMonth(subMonths(endDate, 0)), endDate]
131
+ },
132
+ {
133
+ label: "3M",
134
+ longLabel: "3 Months",
135
+ range: [startOfMonth(subMonths(endDate, 2)), endDate]
136
+ },
137
+ {
138
+ label: "6M",
139
+ longLabel: "6 Months",
140
+ range: [startOfMonth(subMonths(endDate, 5)), endDate]
141
+ },
142
+ {
143
+ label: "1Y",
144
+ longLabel: "1 Year",
145
+ range: [startOfMonth(subMonths(endDate, 11)), endDate]
146
+ },
147
+ {
148
+ label: "All",
149
+ longLabel: "All Time",
150
+ range: [startOfYear(subYears(endDate, 5)), endDate]
151
+ // 5 years back
152
+ }
153
+ ];
154
+ }
155
+ function formatDateForAPI(date) {
156
+ return date.toISOString().split("T")[0];
157
+ }
158
+ function ExpenseWheelProvider({
159
+ children,
160
+ userId,
161
+ initialDateRangeIndex = 0
162
+ }) {
163
+ const allDateRanges = React.useMemo(() => generateDateRanges(), []);
164
+ const [selectedDateRangeIndex, setSelectedDateRangeIndex] = useState(initialDateRangeIndex);
165
+ const [selectedTag, setSelectedTag] = useState(null);
166
+ const [settingsVisible, setSettingsVisible] = useState(false);
167
+ const [isBusy, setIsBusy] = useState(false);
168
+ const initialFilters = {
169
+ begin_on: formatDateForAPI(allDateRanges[initialDateRangeIndex].range[0]),
170
+ end_on: formatDateForAPI(allDateRanges[initialDateRangeIndex].range[1]),
171
+ accountIds: [],
172
+ excludedTags: []
173
+ };
174
+ const [filters, setFilters] = useState(initialFilters);
175
+ const [unsavedFilters, setUnsavedFilters] = useState(initialFilters);
176
+ const selectedDateRange = allDateRanges[selectedDateRangeIndex].range;
177
+ const selectDateRange = useCallback((index, force) => {
178
+ if (index < 0 || index >= allDateRanges.length) {
179
+ console.warn(`Invalid date range index: ${index}`);
180
+ return;
181
+ }
182
+ setSelectedDateRangeIndex(index);
183
+ const newRange = allDateRanges[index].range;
184
+ setFilters((prev) => ({
185
+ ...prev,
186
+ begin_on: formatDateForAPI(newRange[0]),
187
+ end_on: formatDateForAPI(newRange[1])
188
+ }));
189
+ setUnsavedFilters((prev) => ({
190
+ ...prev,
191
+ begin_on: formatDateForAPI(newRange[0]),
192
+ end_on: formatDateForAPI(newRange[1])
193
+ }));
194
+ }, [allDateRanges]);
195
+ const selectTag = useCallback((tag) => {
196
+ setSelectedTag((prev) => prev === tag ? null : tag);
197
+ }, []);
198
+ const showSettings = useCallback(() => {
199
+ setSettingsVisible(true);
200
+ setUnsavedFilters({ ...filters });
201
+ }, [filters]);
202
+ const hideSettings = useCallback(() => {
203
+ setSettingsVisible(false);
204
+ }, []);
205
+ const updateFilters = useCallback((partial) => {
206
+ setUnsavedFilters((prev) => ({ ...prev, ...partial }));
207
+ }, []);
208
+ const saveFilters = useCallback(() => {
209
+ setFilters({ ...unsavedFilters });
210
+ setSettingsVisible(false);
211
+ }, [unsavedFilters]);
212
+ const cancelFilters = useCallback(() => {
213
+ setUnsavedFilters({ ...filters });
214
+ setSettingsVisible(false);
215
+ }, [filters]);
216
+ const toggleAccount = useCallback((accountId) => {
217
+ setUnsavedFilters((prev) => {
218
+ const accountIds = prev.accountIds || [];
219
+ const exists = accountIds.includes(accountId);
220
+ return {
221
+ ...prev,
222
+ accountIds: exists ? accountIds.filter((id) => id !== accountId) : [...accountIds, accountId]
223
+ };
224
+ });
225
+ }, []);
226
+ const toggleExcludedTag = useCallback((tag) => {
227
+ setUnsavedFilters((prev) => {
228
+ const excludedTags = prev.excludedTags || [];
229
+ const exists = excludedTags.includes(tag);
230
+ return {
231
+ ...prev,
232
+ excludedTags: exists ? excludedTags.filter((t) => t !== tag) : [...excludedTags, tag]
233
+ };
234
+ });
235
+ }, []);
236
+ const setBusy = useCallback((busy) => {
237
+ setIsBusy(busy);
238
+ }, []);
239
+ const value = {
240
+ selectedDateRangeIndex,
241
+ selectedDateRange,
242
+ allDateRanges,
243
+ selectedTag,
244
+ settingsVisible,
245
+ filters,
246
+ unsavedFilters,
247
+ isBusy,
248
+ selectDateRange,
249
+ selectTag,
250
+ showSettings,
251
+ hideSettings,
252
+ updateFilters,
253
+ saveFilters,
254
+ cancelFilters,
255
+ toggleAccount,
256
+ toggleExcludedTag,
257
+ setBusy
258
+ };
259
+ return /* @__PURE__ */ jsx(ExpenseWheelContext.Provider, { value, children });
260
+ }
261
+ function useExpenseWheelContext() {
262
+ const context = useContext(ExpenseWheelContext);
263
+ if (!context) {
264
+ throw new Error("useExpenseWheelContext must be used within ExpenseWheelProvider");
265
+ }
266
+ return context;
267
+ }
268
+
269
+ export { ExpenseWheelProvider, useExpenseFilters, useExpenseSummary, useExpenseWheelContext, useExpensesByCategory, validateExpenseFilters };
270
+ //# sourceMappingURL=index.js.map
271
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/hooks/useExpenseSummary.ts","../src/hooks/useExpensesByCategory.ts","../src/hooks/useExpenseFilters.ts","../src/context/ExpenseWheelContext.tsx"],"names":["useExpenses","useMemo"],"mappings":";;;;;;AA+BO,SAAS,iBAAA,CAAkB;AAAA,EAChC,MAAA;AAAA,EACA;AACF,CAAA,EAA4C;AAC1C,EAAA,MAAM,EAAE,MAAM,QAAA,EAAS,GAAI,YAAY,EAAE,MAAA,EAAQ,SAAS,CAAA;AAE1D,EAAA,OAAO,QAAQ,MAAM;AACnB,IAAA,IAAI,CAAC,QAAA,IAAY,QAAA,CAAS,MAAA,KAAW,CAAA,EAAG;AACtC,MAAA,OAAO;AAAA,QACL,WAAA,EAAa,CAAA;AAAA,QACb,KAAA,EAAO,CAAA;AAAA,QACP,WAAA,EAAa,KAAA;AAAA,QACb,aAAA,EAAe;AAAA,OACjB;AAAA,IACF;AAGA,IAAA,MAAM,WAAA,GAAc,QAAA,CAAS,MAAA,CAAO,CAAC,KAAK,OAAA,KAAY;AACpD,MAAA,OAAO,GAAA,GAAM,UAAA,CAAW,OAAA,CAAQ,MAAM,CAAA;AAAA,IACxC,GAAG,CAAC,CAAA;AAEJ,IAAA,MAAM,QAAQ,QAAA,CAAS,MAAA;AAEvB,IAAA,OAAO;AAAA,MACL,WAAA;AAAA,MACA,KAAA;AAAA,MACA,WAAA,EAAa,IAAA;AAAA,MACb,eAAe,WAAA,GAAc;AAAA,KAC/B;AAAA,EACF,CAAA,EAAG,CAAC,QAAQ,CAAC,CAAA;AACf;AC5BO,SAAS,qBAAA,CAAsB;AAAA,EACpC,MAAA;AAAA,EACA,OAAA;AAAA,EACA,MAAA,GAAS,QAAA;AAAA,EACT,SAAA,GAAY;AACd,CAAA,EAAmD;AACjD,EAAA,MAAM,EAAE,MAAM,QAAA,EAAS,GAAIA,YAAY,EAAE,MAAA,EAAQ,SAAS,CAAA;AAE1D,EAAA,OAAOC,QAAQ,MAAM;AACnB,IAAA,IAAI,CAAC,QAAA,IAAY,QAAA,CAAS,MAAA,KAAW,CAAA,EAAG;AACtC,MAAA,OAAO,EAAC;AAAA,IACV;AAGA,IAAA,MAAM,KAAA,GAAQ,QAAA,CAAS,MAAA,CAAO,CAAC,KAAK,OAAA,KAAY;AAC9C,MAAA,OAAO,GAAA,GAAM,UAAA,CAAW,OAAA,CAAQ,MAAM,CAAA;AAAA,IACxC,GAAG,CAAC,CAAA;AAGJ,IAAA,MAAM,UAAA,GAAgC,QAAA,CAAS,GAAA,CAAI,CAAC,OAAA,KAAY;AAC9D,MAAA,MAAM,MAAA,GAAS,UAAA,CAAW,OAAA,CAAQ,MAAM,CAAA;AACxC,MAAA,OAAO;AAAA,QACL,KAAK,OAAA,CAAQ,GAAA;AAAA,QACb,MAAA;AAAA,QACA,UAAA,EAAY,KAAA,GAAQ,CAAA,GAAK,MAAA,GAAS,QAAS,GAAA,GAAM;AAAA,OACnD;AAAA,IACF,CAAC,CAAA;AAGD,IAAA,UAAA,CAAW,IAAA,CAAK,CAAC,CAAA,EAAG,CAAA,KAAM;AACxB,MAAA,IAAI,WAAW,QAAA,EAAU;AACvB,QAAA,MAAM,IAAA,GAAO,CAAA,CAAE,MAAA,GAAS,CAAA,CAAE,MAAA;AAC1B,QAAA,OAAO,SAAA,KAAc,KAAA,GAAQ,IAAA,GAAO,CAAC,IAAA;AAAA,MACvC,CAAA,MAAO;AAEL,QAAA,MAAM,UAAA,GAAa,CAAA,CAAE,GAAA,CAAI,aAAA,CAAc,EAAE,GAAG,CAAA;AAC5C,QAAA,OAAO,SAAA,KAAc,KAAA,GAAQ,UAAA,GAAa,CAAC,UAAA;AAAA,MAC7C;AAAA,IACF,CAAC,CAAA;AAED,IAAA,OAAO,UAAA;AAAA,EACT,CAAA,EAAG,CAAC,QAAA,EAAU,MAAA,EAAQ,SAAS,CAAC,CAAA;AAClC;ACxBO,SAAS,iBAAA,CACd,OAAA,GAAgC,EAAC,EACb;AACpB,EAAA,OAAOA,QAAQ,MAAM;AACnB,IAAA,MAAM,EAAE,QAAA,EAAU,MAAA,EAAQ,SAAA,EAAU,GAAI,OAAA;AAExC,IAAA,MAAM,aAAA,GAAgB,CAAC,EAAE,QAAA,IAAY,MAAA,CAAA;AACrC,IAAA,MAAM,kBAAA,GAAqB,CAAC,EAAE,SAAA,IAAa,SAAA,GAAY,CAAA,CAAA;AAGvD,IAAA,IAAI,cAAA;AACJ,IAAA,IAAI,YAAY,MAAA,EAAQ;AACtB,MAAA,MAAM,UAAA,GAAa,CAAC,OAAA,KAAoB;AACtC,QAAA,MAAM,IAAA,GAAO,IAAI,IAAA,CAAK,OAAO,CAAA;AAC7B,QAAA,OAAO,IAAA,CAAK,mBAAmB,OAAA,EAAS;AAAA,UACtC,KAAA,EAAO,OAAA;AAAA,UACP,IAAA,EAAM;AAAA,SACP,CAAA;AAAA,MACH,CAAA;AAEA,MAAA,IAAI,YAAY,MAAA,EAAQ;AACtB,QAAA,cAAA,GAAiB,GAAG,UAAA,CAAW,QAAQ,CAAC,CAAA,GAAA,EAAM,UAAA,CAAW,MAAM,CAAC,CAAA,CAAA;AAAA,MAClE,WAAW,QAAA,EAAU;AACnB,QAAA,cAAA,GAAiB,CAAA,KAAA,EAAQ,UAAA,CAAW,QAAQ,CAAC,CAAA,CAAA;AAAA,MAC/C,WAAW,MAAA,EAAQ;AACjB,QAAA,cAAA,GAAiB,CAAA,MAAA,EAAS,UAAA,CAAW,MAAM,CAAC,CAAA,CAAA;AAAA,MAC9C;AAAA,IACF;AAEA,IAAA,OAAO;AAAA,MACL,aAAA;AAAA,MACA,kBAAA;AAAA,MACA,kBAAkB,aAAA,IAAiB,kBAAA;AAAA,MACnC;AAAA,KACF;AAAA,EACF,CAAA,EAAG,CAAC,OAAA,CAAQ,QAAA,EAAU,QAAQ,MAAA,EAAQ,OAAA,CAAQ,SAAS,CAAC,CAAA;AAC1D;AA4BO,SAAS,uBACd,OAAA,EACwC;AACxC,EAAA,MAAM,SAAmB,EAAC;AAC1B,EAAA,MAAM,EAAE,QAAA,EAAU,MAAA,EAAQ,SAAA,EAAU,GAAI,OAAA;AAGxC,EAAA,MAAM,SAAA,GAAY,qBAAA;AAElB,EAAA,IAAI,QAAA,IAAY,CAAC,SAAA,CAAU,IAAA,CAAK,QAAQ,CAAA,EAAG;AACzC,IAAA,MAAA,CAAO,KAAK,uCAAuC,CAAA;AAAA,EACrD;AAEA,EAAA,IAAI,MAAA,IAAU,CAAC,SAAA,CAAU,IAAA,CAAK,MAAM,CAAA,EAAG;AACrC,IAAA,MAAA,CAAO,KAAK,qCAAqC,CAAA;AAAA,EACnD;AAGA,EAAA,IAAI,QAAA,IAAY,UAAU,SAAA,CAAU,IAAA,CAAK,QAAQ,CAAA,IAAK,SAAA,CAAU,IAAA,CAAK,MAAM,CAAA,EAAG;AAC5E,IAAA,MAAM,SAAA,GAAY,IAAI,IAAA,CAAK,QAAQ,CAAA;AACnC,IAAA,MAAM,OAAA,GAAU,IAAI,IAAA,CAAK,MAAM,CAAA;AAE/B,IAAA,IAAI,UAAU,SAAA,EAAW;AACvB,MAAA,MAAA,CAAO,KAAK,+BAA+B,CAAA;AAAA,IAC7C;AAAA,EACF;AAGA,EAAA,IAAI,SAAA,KAAc,MAAA,IAAa,SAAA,GAAY,CAAA,EAAG;AAC5C,IAAA,MAAA,CAAO,KAAK,gCAAgC,CAAA;AAAA,EAC9C;AAEA,EAAA,OAAO;AAAA,IACL,OAAA,EAAS,OAAO,MAAA,KAAW,CAAA;AAAA,IAC3B;AAAA,GACF;AACF;ACpFA,IAAM,mBAAA,GAAsB,cAAoD,MAAS,CAAA;AAKzF,SAAS,kBAAA,GAAwC;AAC/C,EAAA,MAAM,GAAA,uBAAU,IAAA,EAAK;AACrB,EAAA,MAAM,OAAA,GAAU,WAAW,GAAG,CAAA;AAE9B,EAAA,OAAO;AAAA,IACL;AAAA,MACE,KAAA,EAAO,IAAA;AAAA,MACP,SAAA,EAAW,SAAA;AAAA,MACX,KAAA,EAAO,CAAC,YAAA,CAAa,SAAA,CAAU,SAAS,CAAC,CAAC,GAAG,OAAO;AAAA,KACtD;AAAA,IACA;AAAA,MACE,KAAA,EAAO,IAAA;AAAA,MACP,SAAA,EAAW,UAAA;AAAA,MACX,KAAA,EAAO,CAAC,YAAA,CAAa,SAAA,CAAU,SAAS,CAAC,CAAC,GAAG,OAAO;AAAA,KACtD;AAAA,IACA;AAAA,MACE,KAAA,EAAO,IAAA;AAAA,MACP,SAAA,EAAW,UAAA;AAAA,MACX,KAAA,EAAO,CAAC,YAAA,CAAa,SAAA,CAAU,SAAS,CAAC,CAAC,GAAG,OAAO;AAAA,KACtD;AAAA,IACA;AAAA,MACE,KAAA,EAAO,IAAA;AAAA,MACP,SAAA,EAAW,QAAA;AAAA,MACX,KAAA,EAAO,CAAC,YAAA,CAAa,SAAA,CAAU,SAAS,EAAE,CAAC,GAAG,OAAO;AAAA,KACvD;AAAA,IACA;AAAA,MACE,KAAA,EAAO,KAAA;AAAA,MACP,SAAA,EAAW,UAAA;AAAA,MACX,KAAA,EAAO,CAAC,WAAA,CAAY,QAAA,CAAS,SAAS,CAAC,CAAC,GAAG,OAAO;AAAA;AAAA;AACpD,GACF;AACF;AAKA,SAAS,iBAAiB,IAAA,EAAoB;AAC5C,EAAA,OAAO,KAAK,WAAA,EAAY,CAAE,KAAA,CAAM,GAAG,EAAE,CAAC,CAAA;AACxC;AAaO,SAAS,oBAAA,CAAqB;AAAA,EACnC,QAAA;AAAA,EACA,MAAA;AAAA,EACA,qBAAA,GAAwB;AAC1B,CAAA,EAA8B;AAC5B,EAAA,MAAM,gBAAgB,KAAA,CAAM,OAAA,CAAQ,MAAM,kBAAA,EAAmB,EAAG,EAAE,CAAA;AAElE,EAAA,MAAM,CAAC,sBAAA,EAAwB,yBAAyB,CAAA,GAAI,SAAS,qBAAqB,CAAA;AAC1F,EAAA,MAAM,CAAC,WAAA,EAAa,cAAc,CAAA,GAAI,SAAwB,IAAI,CAAA;AAClE,EAAA,MAAM,CAAC,eAAA,EAAiB,kBAAkB,CAAA,GAAI,SAAS,KAAK,CAAA;AAC5D,EAAA,MAAM,CAAC,MAAA,EAAQ,SAAS,CAAA,GAAI,SAAS,KAAK,CAAA;AAG1C,EAAA,MAAM,cAAA,GAAiC;AAAA,IACrC,UAAU,gBAAA,CAAiB,aAAA,CAAc,qBAAqB,CAAA,CAAE,KAAA,CAAM,CAAC,CAAC,CAAA;AAAA,IACxE,QAAQ,gBAAA,CAAiB,aAAA,CAAc,qBAAqB,CAAA,CAAE,KAAA,CAAM,CAAC,CAAC,CAAA;AAAA,IACtE,YAAY,EAAC;AAAA,IACb,cAAc;AAAC,GACjB;AAEA,EAAA,MAAM,CAAC,OAAA,EAAS,UAAU,CAAA,GAAI,SAAyB,cAAc,CAAA;AACrE,EAAA,MAAM,CAAC,cAAA,EAAgB,iBAAiB,CAAA,GAAI,SAAyB,cAAc,CAAA;AAEnF,EAAA,MAAM,iBAAA,GAAoB,aAAA,CAAc,sBAAsB,CAAA,CAAE,KAAA;AAMhE,EAAA,MAAM,eAAA,GAAkB,WAAA,CAAY,CAAC,KAAA,EAAe,KAAA,KAAoB;AACtE,IAAA,IAAI,KAAA,GAAQ,CAAA,IAAK,KAAA,IAAS,aAAA,CAAc,MAAA,EAAQ;AAC9C,MAAA,OAAA,CAAQ,IAAA,CAAK,CAAA,0BAAA,EAA6B,KAAK,CAAA,CAAE,CAAA;AACjD,MAAA;AAAA,IACF;AAEA,IAAA,yBAAA,CAA0B,KAAK,CAAA;AAC/B,IAAA,MAAM,QAAA,GAAW,aAAA,CAAc,KAAK,CAAA,CAAE,KAAA;AAGtC,IAAA,UAAA,CAAW,CAAA,IAAA,MAAS;AAAA,MAClB,GAAG,IAAA;AAAA,MACH,QAAA,EAAU,gBAAA,CAAiB,QAAA,CAAS,CAAC,CAAC,CAAA;AAAA,MACtC,MAAA,EAAQ,gBAAA,CAAiB,QAAA,CAAS,CAAC,CAAC;AAAA,KACtC,CAAE,CAAA;AAEF,IAAA,iBAAA,CAAkB,CAAA,IAAA,MAAS;AAAA,MACzB,GAAG,IAAA;AAAA,MACH,QAAA,EAAU,gBAAA,CAAiB,QAAA,CAAS,CAAC,CAAC,CAAA;AAAA,MACtC,MAAA,EAAQ,gBAAA,CAAiB,QAAA,CAAS,CAAC,CAAC;AAAA,KACtC,CAAE,CAAA;AAAA,EACJ,CAAA,EAAG,CAAC,aAAa,CAAC,CAAA;AAMlB,EAAA,MAAM,SAAA,GAAY,WAAA,CAAY,CAAC,GAAA,KAAuB;AAEpD,IAAA,cAAA,CAAe,CAAA,IAAA,KAAS,IAAA,KAAS,GAAA,GAAM,IAAA,GAAO,GAAI,CAAA;AAAA,EACpD,CAAA,EAAG,EAAE,CAAA;AAML,EAAA,MAAM,YAAA,GAAe,YAAY,MAAM;AACrC,IAAA,kBAAA,CAAmB,IAAI,CAAA;AAEvB,IAAA,iBAAA,CAAkB,EAAE,GAAG,OAAA,EAAS,CAAA;AAAA,EAClC,CAAA,EAAG,CAAC,OAAO,CAAC,CAAA;AAMZ,EAAA,MAAM,YAAA,GAAe,YAAY,MAAM;AACrC,IAAA,kBAAA,CAAmB,KAAK,CAAA;AAAA,EAC1B,CAAA,EAAG,EAAE,CAAA;AAKL,EAAA,MAAM,aAAA,GAAgB,WAAA,CAAY,CAAC,OAAA,KAAqC;AACtE,IAAA,iBAAA,CAAkB,WAAS,EAAE,GAAG,IAAA,EAAM,GAAG,SAAQ,CAAE,CAAA;AAAA,EACrD,CAAA,EAAG,EAAE,CAAA;AAML,EAAA,MAAM,WAAA,GAAc,YAAY,MAAM;AACpC,IAAA,UAAA,CAAW,EAAE,GAAG,cAAA,EAAgB,CAAA;AAChC,IAAA,kBAAA,CAAmB,KAAK,CAAA;AAAA,EAC1B,CAAA,EAAG,CAAC,cAAc,CAAC,CAAA;AAKnB,EAAA,MAAM,aAAA,GAAgB,YAAY,MAAM;AACtC,IAAA,iBAAA,CAAkB,EAAE,GAAG,OAAA,EAAS,CAAA;AAChC,IAAA,kBAAA,CAAmB,KAAK,CAAA;AAAA,EAC1B,CAAA,EAAG,CAAC,OAAO,CAAC,CAAA;AAMZ,EAAA,MAAM,aAAA,GAAgB,WAAA,CAAY,CAAC,SAAA,KAAsB;AACvD,IAAA,iBAAA,CAAkB,CAAA,IAAA,KAAQ;AACxB,MAAA,MAAM,UAAA,GAAa,IAAA,CAAK,UAAA,IAAc,EAAC;AACvC,MAAA,MAAM,MAAA,GAAS,UAAA,CAAW,QAAA,CAAS,SAAS,CAAA;AAE5C,MAAA,OAAO;AAAA,QACL,GAAG,IAAA;AAAA,QACH,UAAA,EAAY,MAAA,GACR,UAAA,CAAW,MAAA,CAAO,CAAA,EAAA,KAAM,EAAA,KAAO,SAAS,CAAA,GACxC,CAAC,GAAG,UAAA,EAAY,SAAS;AAAA,OAC/B;AAAA,IACF,CAAC,CAAA;AAAA,EACH,CAAA,EAAG,EAAE,CAAA;AAKL,EAAA,MAAM,iBAAA,GAAoB,WAAA,CAAY,CAAC,GAAA,KAAgB;AACrD,IAAA,iBAAA,CAAkB,CAAA,IAAA,KAAQ;AACxB,MAAA,MAAM,YAAA,GAAe,IAAA,CAAK,YAAA,IAAgB,EAAC;AAC3C,MAAA,MAAM,MAAA,GAAS,YAAA,CAAa,QAAA,CAAS,GAAG,CAAA;AAExC,MAAA,OAAO;AAAA,QACL,GAAG,IAAA;AAAA,QACH,YAAA,EAAc,MAAA,GACV,YAAA,CAAa,MAAA,CAAO,CAAA,CAAA,KAAK,CAAA,KAAM,GAAG,CAAA,GAClC,CAAC,GAAG,YAAA,EAAc,GAAG;AAAA,OAC3B;AAAA,IACF,CAAC,CAAA;AAAA,EACH,CAAA,EAAG,EAAE,CAAA;AAKL,EAAA,MAAM,OAAA,GAAU,WAAA,CAAY,CAAC,IAAA,KAAkB;AAC7C,IAAA,SAAA,CAAU,IAAI,CAAA;AAAA,EAChB,CAAA,EAAG,EAAE,CAAA;AAEL,EAAA,MAAM,KAAA,GAAkC;AAAA,IACtC,sBAAA;AAAA,IACA,iBAAA;AAAA,IACA,aAAA;AAAA,IACA,WAAA;AAAA,IACA,eAAA;AAAA,IACA,OAAA;AAAA,IACA,cAAA;AAAA,IACA,MAAA;AAAA,IACA,eAAA;AAAA,IACA,SAAA;AAAA,IACA,YAAA;AAAA,IACA,YAAA;AAAA,IACA,aAAA;AAAA,IACA,WAAA;AAAA,IACA,aAAA;AAAA,IACA,aAAA;AAAA,IACA,iBAAA;AAAA,IACA;AAAA,GACF;AAEA,EAAA,uBACE,GAAA,CAAC,mBAAA,CAAoB,QAAA,EAApB,EAA6B,OAC3B,QAAA,EACH,CAAA;AAEJ;AAMO,SAAS,sBAAA,GAAmD;AACjE,EAAA,MAAM,OAAA,GAAU,WAAW,mBAAmB,CAAA;AAE9C,EAAA,IAAI,CAAC,OAAA,EAAS;AACZ,IAAA,MAAM,IAAI,MAAM,iEAAiE,CAAA;AAAA,EACnF;AAEA,EAAA,OAAO,OAAA;AACT","file":"index.js","sourcesContent":["import { useMemo } from 'react';\nimport { useExpenses } from '@pfm-platform/expenses-data-access';\n\nexport interface ExpenseSummary {\n totalAmount: number;\n count: number;\n hasExpenses: boolean;\n averageAmount: number;\n}\n\ninterface UseExpenseSummaryParams {\n userId: string;\n filters?: {\n begin_on?: string;\n end_on?: string;\n threshold?: number;\n };\n}\n\n/**\n * Calculate summary statistics for expenses\n *\n * Business logic:\n * - totalAmount: Sum of all expense amounts (parsed from strings)\n * - count: Total number of expense categories\n * - hasExpenses: Boolean flag for conditional rendering\n * - averageAmount: Average expense per category\n *\n * @param params - User ID and optional filters (date range, threshold)\n * @returns Summary statistics for expenses\n */\nexport function useExpenseSummary({\n userId,\n filters,\n}: UseExpenseSummaryParams): ExpenseSummary {\n const { data: expenses } = useExpenses({ userId, filters });\n\n return useMemo(() => {\n if (!expenses || expenses.length === 0) {\n return {\n totalAmount: 0,\n count: 0,\n hasExpenses: false,\n averageAmount: 0,\n };\n }\n\n // Parse string amounts and sum\n const totalAmount = expenses.reduce((sum, expense) => {\n return sum + parseFloat(expense.amount);\n }, 0);\n\n const count = expenses.length;\n\n return {\n totalAmount,\n count,\n hasExpenses: true,\n averageAmount: totalAmount / count,\n };\n }, [expenses]);\n}\n","import { useMemo } from 'react';\nimport { useExpenses } from '@pfm-platform/expenses-data-access';\nimport type { Expense } from '@pfm-platform/shared';\n\nexport interface ExpenseCategory {\n tag: string;\n amount: number;\n percentage: number;\n}\n\ninterface UseExpensesByCategoryParams {\n userId: string;\n filters?: {\n begin_on?: string;\n end_on?: string;\n threshold?: number;\n };\n sortBy?: 'amount' | 'tag';\n sortOrder?: 'asc' | 'desc';\n}\n\n/**\n * Group and sort expenses by category (tag)\n *\n * Business logic:\n * - Parse string amounts to numbers for calculations\n * - Calculate percentage of total for each category\n * - Sort by amount (default, descending) or tag name\n * - Return structured category objects with parsed amounts\n *\n * @param params - User ID, filters, and sorting options\n * @returns Array of expense categories with amounts and percentages\n */\nexport function useExpensesByCategory({\n userId,\n filters,\n sortBy = 'amount',\n sortOrder = 'desc',\n}: UseExpensesByCategoryParams): ExpenseCategory[] {\n const { data: expenses } = useExpenses({ userId, filters });\n\n return useMemo(() => {\n if (!expenses || expenses.length === 0) {\n return [];\n }\n\n // Calculate total for percentages\n const total = expenses.reduce((sum, expense) => {\n return sum + parseFloat(expense.amount);\n }, 0);\n\n // Transform expenses to categories with parsed amounts and percentages\n const categories: ExpenseCategory[] = expenses.map((expense) => {\n const amount = parseFloat(expense.amount);\n return {\n tag: expense.tag,\n amount,\n percentage: total > 0 ? (amount / total) * 100 : 0,\n };\n });\n\n // Sort categories\n categories.sort((a, b) => {\n if (sortBy === 'amount') {\n const diff = a.amount - b.amount;\n return sortOrder === 'asc' ? diff : -diff;\n } else {\n // Sort by tag name\n const comparison = a.tag.localeCompare(b.tag);\n return sortOrder === 'asc' ? comparison : -comparison;\n }\n });\n\n return categories;\n }, [expenses, sortBy, sortOrder]);\n}\n","import { useMemo } from 'react';\n\nexport interface ExpenseFilterState {\n hasDateFilter: boolean;\n hasThresholdFilter: boolean;\n hasActiveFilters: boolean;\n dateRangeLabel?: string;\n}\n\nexport interface ExpenseFilterOptions {\n begin_on?: string;\n end_on?: string;\n threshold?: number;\n}\n\n/**\n * Check which expense filters are active and provide helper methods\n *\n * Business logic:\n * - hasDateFilter: begin_on or end_on is set\n * - hasThresholdFilter: threshold is set and > 0\n * - hasActiveFilters: any filter is active\n * - dateRangeLabel: Human-readable date range (e.g., \"Jan 2024 - Mar 2024\")\n *\n * @param options - Filter options (begin_on, end_on, threshold)\n * @returns Object with boolean flags and helper properties\n *\n * @example\n * ```tsx\n * function ExpenseFilters() {\n * const [filters, setFilters] = useState({\n * begin_on: '2024-01-01',\n * end_on: '2024-03-31',\n * threshold: 100\n * });\n *\n * const filterState = useExpenseFilters(filters);\n *\n * return (\n * <div>\n * {filterState.hasActiveFilters && (\n * <p>Active filters: {filterState.dateRangeLabel}</p>\n * )}\n * {filterState.hasThresholdFilter && (\n * <p>Showing expenses above ${filters.threshold}</p>\n * )}\n * </div>\n * );\n * }\n * ```\n */\nexport function useExpenseFilters(\n options: ExpenseFilterOptions = {}\n): ExpenseFilterState {\n return useMemo(() => {\n const { begin_on, end_on, threshold } = options;\n\n const hasDateFilter = !!(begin_on || end_on);\n const hasThresholdFilter = !!(threshold && threshold > 0);\n\n // Generate human-readable date range label\n let dateRangeLabel: string | undefined;\n if (begin_on || end_on) {\n const formatDate = (dateStr: string) => {\n const date = new Date(dateStr);\n return date.toLocaleDateString('en-US', {\n month: 'short',\n year: 'numeric',\n });\n };\n\n if (begin_on && end_on) {\n dateRangeLabel = `${formatDate(begin_on)} - ${formatDate(end_on)}`;\n } else if (begin_on) {\n dateRangeLabel = `From ${formatDate(begin_on)}`;\n } else if (end_on) {\n dateRangeLabel = `Until ${formatDate(end_on)}`;\n }\n }\n\n return {\n hasDateFilter,\n hasThresholdFilter,\n hasActiveFilters: hasDateFilter || hasThresholdFilter,\n dateRangeLabel,\n };\n }, [options.begin_on, options.end_on, options.threshold]);\n}\n\n/**\n * Validate expense filter values\n *\n * Business logic:\n * - begin_on must be valid date format (YYYY-MM-DD)\n * - end_on must be valid date format (YYYY-MM-DD)\n * - end_on must be after begin_on if both provided\n * - threshold must be non-negative if provided\n *\n * @param options - Filter options to validate\n * @returns Validation result with errors array\n *\n * @example\n * ```tsx\n * function ExpenseFilterForm() {\n * const handleSubmit = (filters: ExpenseFilterOptions) => {\n * const validation = validateExpenseFilters(filters);\n * if (!validation.isValid) {\n * alert(validation.errors.join(', '));\n * return;\n * }\n * // Apply filters...\n * };\n * }\n * ```\n */\nexport function validateExpenseFilters(\n options: ExpenseFilterOptions\n): { isValid: boolean; errors: string[] } {\n const errors: string[] = [];\n const { begin_on, end_on, threshold } = options;\n\n // Validate date format (YYYY-MM-DD)\n const dateRegex = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n if (begin_on && !dateRegex.test(begin_on)) {\n errors.push('begin_on must be in YYYY-MM-DD format');\n }\n\n if (end_on && !dateRegex.test(end_on)) {\n errors.push('end_on must be in YYYY-MM-DD format');\n }\n\n // Validate date range logic\n if (begin_on && end_on && dateRegex.test(begin_on) && dateRegex.test(end_on)) {\n const beginDate = new Date(begin_on);\n const endDate = new Date(end_on);\n\n if (endDate < beginDate) {\n errors.push('end_on must be after begin_on');\n }\n }\n\n // Validate threshold\n if (threshold !== undefined && threshold < 0) {\n errors.push('threshold must be non-negative');\n }\n\n return {\n isValid: errors.length === 0,\n errors,\n };\n}\n","/**\n * ExpenseWheelContext\n *\n * Provides state management for the Expense Wheel and Analyzer views.\n * Manages date range selection, category selection, settings panel, and filters.\n *\n * Legacy Pattern: Replaces MobX spendingStore.Wheel observables with React Context\n */\n\nimport React, { createContext, useContext, useState, useCallback, ReactNode } from 'react';\nimport { startOfMonth, endOfMonth, subMonths, subYears, startOfYear, parseISO, isValid } from 'date-fns';\n\n/**\n * Date range configuration matching legacy Wheel.allDateRanges\n */\nexport interface DateRangeConfig {\n label: string;\n longLabel: string;\n range: [Date, Date];\n}\n\n/**\n * Expense filter configuration\n */\nexport interface ExpenseFilters {\n begin_on?: string;\n end_on?: string;\n threshold?: number;\n accountIds?: string[];\n excludedTags?: string[];\n}\n\n/**\n * Context state interface\n */\nexport interface ExpenseWheelContextState {\n // Date Range State\n selectedDateRangeIndex: number;\n selectedDateRange: [Date, Date];\n allDateRanges: DateRangeConfig[];\n\n // Category Selection\n selectedTag: string | null;\n\n // Settings Panel\n settingsVisible: boolean;\n\n // Filters\n filters: ExpenseFilters;\n unsavedFilters: ExpenseFilters;\n\n // Loading State\n isBusy: boolean;\n\n // Actions\n selectDateRange: (index: number, force?: boolean) => void;\n selectTag: (tag: string | null) => void;\n showSettings: () => void;\n hideSettings: () => void;\n updateFilters: (filters: Partial<ExpenseFilters>) => void;\n saveFilters: () => void;\n cancelFilters: () => void;\n toggleAccount: (accountId: string) => void;\n toggleExcludedTag: (tag: string) => void;\n setBusy: (busy: boolean) => void;\n}\n\nconst ExpenseWheelContext = createContext<ExpenseWheelContextState | undefined>(undefined);\n\n/**\n * Generate predefined date ranges matching legacy implementation\n */\nfunction generateDateRanges(): DateRangeConfig[] {\n const now = new Date();\n const endDate = endOfMonth(now);\n\n return [\n {\n label: '1M',\n longLabel: '1 Month',\n range: [startOfMonth(subMonths(endDate, 0)), endDate],\n },\n {\n label: '3M',\n longLabel: '3 Months',\n range: [startOfMonth(subMonths(endDate, 2)), endDate],\n },\n {\n label: '6M',\n longLabel: '6 Months',\n range: [startOfMonth(subMonths(endDate, 5)), endDate],\n },\n {\n label: '1Y',\n longLabel: '1 Year',\n range: [startOfMonth(subMonths(endDate, 11)), endDate],\n },\n {\n label: 'All',\n longLabel: 'All Time',\n range: [startOfYear(subYears(endDate, 5)), endDate], // 5 years back\n },\n ];\n}\n\n/**\n * Format date for API (YYYY-MM-DD)\n */\nfunction formatDateForAPI(date: Date): string {\n return date.toISOString().split('T')[0];\n}\n\nexport interface ExpenseWheelProviderProps {\n children: ReactNode;\n userId: string;\n initialDateRangeIndex?: number;\n}\n\n/**\n * ExpenseWheelProvider Component\n *\n * Provides expense wheel state and actions via Context API\n */\nexport function ExpenseWheelProvider({\n children,\n userId,\n initialDateRangeIndex = 0,\n}: ExpenseWheelProviderProps) {\n const allDateRanges = React.useMemo(() => generateDateRanges(), []);\n\n const [selectedDateRangeIndex, setSelectedDateRangeIndex] = useState(initialDateRangeIndex);\n const [selectedTag, setSelectedTag] = useState<string | null>(null);\n const [settingsVisible, setSettingsVisible] = useState(false);\n const [isBusy, setIsBusy] = useState(false);\n\n // Initialize filters from selected date range\n const initialFilters: ExpenseFilters = {\n begin_on: formatDateForAPI(allDateRanges[initialDateRangeIndex].range[0]),\n end_on: formatDateForAPI(allDateRanges[initialDateRangeIndex].range[1]),\n accountIds: [],\n excludedTags: [],\n };\n\n const [filters, setFilters] = useState<ExpenseFilters>(initialFilters);\n const [unsavedFilters, setUnsavedFilters] = useState<ExpenseFilters>(initialFilters);\n\n const selectedDateRange = allDateRanges[selectedDateRangeIndex].range;\n\n /**\n * Select date range by index\n * Matches legacy: Wheel.selectDateRange(range, force)\n */\n const selectDateRange = useCallback((index: number, force?: boolean) => {\n if (index < 0 || index >= allDateRanges.length) {\n console.warn(`Invalid date range index: ${index}`);\n return;\n }\n\n setSelectedDateRangeIndex(index);\n const newRange = allDateRanges[index].range;\n\n // Update filters with new date range\n setFilters(prev => ({\n ...prev,\n begin_on: formatDateForAPI(newRange[0]),\n end_on: formatDateForAPI(newRange[1]),\n }));\n\n setUnsavedFilters(prev => ({\n ...prev,\n begin_on: formatDateForAPI(newRange[0]),\n end_on: formatDateForAPI(newRange[1]),\n }));\n }, [allDateRanges]);\n\n /**\n * Select category tag\n * Matches legacy: Wheel.selectTag(tag)\n */\n const selectTag = useCallback((tag: string | null) => {\n // Toggle behavior: if same tag clicked, deselect\n setSelectedTag(prev => (prev === tag ? null : tag));\n }, []);\n\n /**\n * Show settings panel\n * Matches legacy: Wheel.showSettings()\n */\n const showSettings = useCallback(() => {\n setSettingsVisible(true);\n // Copy current filters to unsaved (edit buffer)\n setUnsavedFilters({ ...filters });\n }, [filters]);\n\n /**\n * Hide settings panel\n * Matches legacy: Wheel.hideSettings()\n */\n const hideSettings = useCallback(() => {\n setSettingsVisible(false);\n }, []);\n\n /**\n * Update unsaved filters (while settings panel is open)\n */\n const updateFilters = useCallback((partial: Partial<ExpenseFilters>) => {\n setUnsavedFilters(prev => ({ ...prev, ...partial }));\n }, []);\n\n /**\n * Save unsaved filters and close settings\n * Matches legacy: Wheel.saveExcludedTags()\n */\n const saveFilters = useCallback(() => {\n setFilters({ ...unsavedFilters });\n setSettingsVisible(false);\n }, [unsavedFilters]);\n\n /**\n * Cancel unsaved changes and close settings\n */\n const cancelFilters = useCallback(() => {\n setUnsavedFilters({ ...filters });\n setSettingsVisible(false);\n }, [filters]);\n\n /**\n * Toggle account inclusion in filters\n * Matches legacy: Wheel.toggleAccount(account)\n */\n const toggleAccount = useCallback((accountId: string) => {\n setUnsavedFilters(prev => {\n const accountIds = prev.accountIds || [];\n const exists = accountIds.includes(accountId);\n\n return {\n ...prev,\n accountIds: exists\n ? accountIds.filter(id => id !== accountId)\n : [...accountIds, accountId],\n };\n });\n }, []);\n\n /**\n * Toggle tag exclusion\n */\n const toggleExcludedTag = useCallback((tag: string) => {\n setUnsavedFilters(prev => {\n const excludedTags = prev.excludedTags || [];\n const exists = excludedTags.includes(tag);\n\n return {\n ...prev,\n excludedTags: exists\n ? excludedTags.filter(t => t !== tag)\n : [...excludedTags, tag],\n };\n });\n }, []);\n\n /**\n * Set busy/loading state\n */\n const setBusy = useCallback((busy: boolean) => {\n setIsBusy(busy);\n }, []);\n\n const value: ExpenseWheelContextState = {\n selectedDateRangeIndex,\n selectedDateRange,\n allDateRanges,\n selectedTag,\n settingsVisible,\n filters,\n unsavedFilters,\n isBusy,\n selectDateRange,\n selectTag,\n showSettings,\n hideSettings,\n updateFilters,\n saveFilters,\n cancelFilters,\n toggleAccount,\n toggleExcludedTag,\n setBusy,\n };\n\n return (\n <ExpenseWheelContext.Provider value={value}>\n {children}\n </ExpenseWheelContext.Provider>\n );\n}\n\n/**\n * Hook to access ExpenseWheelContext\n * Throws error if used outside provider\n */\nexport function useExpenseWheelContext(): ExpenseWheelContextState {\n const context = useContext(ExpenseWheelContext);\n\n if (!context) {\n throw new Error('useExpenseWheelContext must be used within ExpenseWheelProvider');\n }\n\n return context;\n}\n"]}
package/package.json ADDED
@@ -0,0 +1,58 @@
1
+ {
2
+ "name": "@pfm-platform/expenses-feature",
3
+ "version": "0.1.1",
4
+ "type": "module",
5
+ "main": "./dist/index.js",
6
+ "dependencies": {
7
+ "date-fns": "^4.1.0",
8
+ "react": "19.2.0",
9
+ "@pfm-platform/expenses-data-access": "0.1.1",
10
+ "@pfm-platform/shared": "0.0.1"
11
+ },
12
+ "devDependencies": {
13
+ "@tanstack/react-query": "5.90.9",
14
+ "@testing-library/react": "^16.3.0",
15
+ "@types/react": "19.2.5",
16
+ "@vitejs/plugin-react": "^5.1.1",
17
+ "@vitest/coverage-v8": "^4.0.9",
18
+ "vitest": "4.0.9"
19
+ },
20
+ "module": "./dist/index.js",
21
+ "types": "./dist/index.d.ts",
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js",
26
+ "require": "./dist/index.cjs"
27
+ }
28
+ },
29
+ "files": [
30
+ "dist",
31
+ "README.md"
32
+ ],
33
+ "description": "Personal Finance Management - EXPENSES feature layer",
34
+ "keywords": [
35
+ "pfm",
36
+ "finance",
37
+ "expenses",
38
+ "feature",
39
+ "react",
40
+ "typescript"
41
+ ],
42
+ "author": "Lenny Miller",
43
+ "license": "MIT",
44
+ "repository": {
45
+ "type": "git",
46
+ "url": "https://github.com/lennylmiller/pfm-research",
47
+ "directory": "packages/expenses/feature"
48
+ },
49
+ "bugs": "https://github.com/lennylmiller/pfm-research/issues",
50
+ "homepage": "https://github.com/lennylmiller/pfm-research#readme",
51
+ "scripts": {
52
+ "test": "vitest run",
53
+ "test:watch": "vitest",
54
+ "test:ui": "vitest --ui",
55
+ "test:coverage": "vitest run --coverage",
56
+ "build": "tsup src/index.ts --format cjs,esm --dts --clean"
57
+ }
58
+ }