@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 +282 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +198 -0
- package/dist/index.d.ts +198 -0
- package/dist/index.js +271 -0
- package/dist/index.js.map +1 -0
- package/package.json +58 -0
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"]}
|
package/dist/index.d.cts
ADDED
|
@@ -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.d.ts
ADDED
|
@@ -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":"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","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
|
+
}
|