@teincfood/core 0.10.12 → 0.10.14

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.
@@ -87,8 +87,14 @@ export declare const dashboardService: {
87
87
  fetchSellerDashboard: (_businessId: string) => Promise<unknown>;
88
88
  };
89
89
  export declare const analyticsService: {
90
- fetchSellerAnalytics: (_businessId: string, _period?: string) => Promise<unknown>;
91
- fetchRevenueSummary: (_businessId: string, _period?: string) => Promise<unknown>;
90
+ fetchSellerAnalytics: (_businessId: string, _period?: string, _range?: {
91
+ date_from?: string;
92
+ date_to?: string;
93
+ }) => Promise<unknown>;
94
+ fetchRevenueSummary: (_businessId: string, _period?: string, _range?: {
95
+ date_from?: string;
96
+ date_to?: string;
97
+ }) => Promise<unknown>;
92
98
  };
93
99
  export declare const placesService: {
94
100
  searchPlaces: (_query: string, _lat?: number, _lng?: number) => Promise<PlaceSuggestion[]>;
@@ -172,10 +172,10 @@ export const dashboardService = {
172
172
  };
173
173
  // analytics.service — seller analytics + revenue (hosts inject axios impls)
174
174
  export const analyticsService = {
175
- fetchSellerAnalytics: async (_businessId, _period) => {
175
+ fetchSellerAnalytics: async (_businessId, _period, _range) => {
176
176
  notImpl("analyticsService.fetchSellerAnalytics");
177
177
  },
178
- fetchRevenueSummary: async (_businessId, _period) => {
178
+ fetchRevenueSummary: async (_businessId, _period, _range) => {
179
179
  notImpl("analyticsService.fetchRevenueSummary");
180
180
  },
181
181
  };
@@ -3,13 +3,19 @@
3
3
  * Offline-first: local SQLite aggregates serve instantly; the network
4
4
  * refreshes silently when online.
5
5
  */
6
+ export type AnalyticsPeriod = "today" | "this_week" | "this_month" | "this_year" | "custom";
7
+ /** Inclusive UTC day bounds used by the custom range filter. */
8
+ export interface AnalyticsRange {
9
+ date_from?: string;
10
+ date_to?: string;
11
+ }
6
12
  export declare const SELLER_ANALYTICS_KEYS: {
7
13
  all: readonly ["seller-analytics"];
8
- analytics: (businessId?: string, period?: string) => readonly ["seller-analytics", string | undefined, string | undefined];
14
+ analytics: (businessId?: string, period?: AnalyticsPeriod, range?: AnalyticsRange) => readonly ["seller-analytics", string | undefined, AnalyticsPeriod | undefined, string | null, string | null];
9
15
  };
10
16
  export declare const REVENUE_KEYS: {
11
17
  all: readonly ["revenue-summary"];
12
- summary: (businessId?: string, period?: string) => readonly ["revenue-summary", string | undefined, string | undefined];
18
+ summary: (businessId?: string, period?: AnalyticsPeriod, range?: AnalyticsRange) => readonly ["revenue-summary", string | undefined, AnalyticsPeriod | undefined, string | null, string | null];
13
19
  };
14
20
  interface LocalOrderRow {
15
21
  status: string;
@@ -31,8 +37,21 @@ interface LocalOrderRow {
31
37
  interface AggregateOptions {
32
38
  forRevenueSummary: boolean;
33
39
  }
34
- export declare function aggregateLocal(orders: LocalOrderRow[], period: "today" | "this_week" | "this_month", opts: AggregateOptions): {
35
- period: "this_month" | "this_week" | "today";
40
+ /**
41
+ * Filter local rows to the same period boundaries as the backend
42
+ * (`period_bounds/2`):
43
+ * - today = current UTC day
44
+ * - this_week = Monday 00:00 UTC → now (backend uses ISO weekday)
45
+ * - this_month = 1st of the current UTC month → now
46
+ * - this_year = 1 January of the current UTC year → now
47
+ * - custom = inclusive `date_from` → `date_to` (YYYY-MM-DD)
48
+ *
49
+ * Exported so the period boundaries can be regression-tested with a fixed
50
+ * clock instead of depending on the day the suite runs.
51
+ */
52
+ export declare function filterByPeriod(orders: LocalOrderRow[], period: AnalyticsPeriod, now?: Date, range?: AnalyticsRange): LocalOrderRow[];
53
+ export declare function aggregateLocal(orders: LocalOrderRow[], period: AnalyticsPeriod, opts: AggregateOptions, range?: AnalyticsRange): {
54
+ period: AnalyticsPeriod;
36
55
  total_revenue_minor: number;
37
56
  total_orders: number;
38
57
  average_order_value_minor: number;
@@ -61,7 +80,7 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: "today"
61
80
  by_payment_method?: undefined;
62
81
  by_order_type?: undefined;
63
82
  by_day?: undefined;
64
- period: "this_month" | "this_week" | "today";
83
+ period: AnalyticsPeriod;
65
84
  total_revenue_minor: number;
66
85
  total_orders: number;
67
86
  average_order_value_minor: number;
@@ -82,16 +101,6 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: "today"
82
101
  }[];
83
102
  offline: boolean;
84
103
  };
85
- export declare function useSellerAnalyticsQuery(period?: "today" | "this_week" | "this_month", enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
86
- data?: {
87
- period?: string;
88
- revenue_by_day?: unknown[];
89
- };
90
- }, Error>;
91
- export declare function useRevenueSummaryQuery(period?: "today" | "this_week" | "this_month", enabled?: boolean): import("@tanstack/react-query").UseQueryResult<{
92
- data?: {
93
- period?: string;
94
- by_day?: unknown[];
95
- };
96
- }, Error>;
104
+ export declare function useSellerAnalyticsQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
105
+ export declare function useRevenueSummaryQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
97
106
  export {};
@@ -11,11 +11,23 @@ import { listLocalOrders } from "../domain/orders/service";
11
11
  import { useIsEffectivelyOnline } from "./use-infinite-list";
12
12
  export const SELLER_ANALYTICS_KEYS = {
13
13
  all: ["seller-analytics"],
14
- analytics: (businessId, period) => [...SELLER_ANALYTICS_KEYS.all, businessId, period],
14
+ analytics: (businessId, period, range) => [
15
+ ...SELLER_ANALYTICS_KEYS.all,
16
+ businessId,
17
+ period,
18
+ range?.date_from ?? null,
19
+ range?.date_to ?? null,
20
+ ],
15
21
  };
16
22
  export const REVENUE_KEYS = {
17
23
  all: ["revenue-summary"],
18
- summary: (businessId, period) => [...REVENUE_KEYS.all, businessId, period],
24
+ summary: (businessId, period, range) => [
25
+ ...REVENUE_KEYS.all,
26
+ businessId,
27
+ period,
28
+ range?.date_from ?? null,
29
+ range?.date_to ?? null,
30
+ ],
19
31
  };
20
32
  function useBusinessId() {
21
33
  const id = useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
@@ -23,35 +35,54 @@ function useBusinessId() {
23
35
  }
24
36
  const OFFLINE_FIRST = { retry: false, networkMode: "offlineFirst" };
25
37
  /**
26
- * Backend-shaped aggregates computed from local SQLite rows (offline).
27
- * Field names mirror orders.ex get_seller_analytics / get_revenue_summary
28
- * exactly so screens render identically online and offline.
38
+ * Filter local rows to the same period boundaries as the backend
39
+ * (`period_bounds/2`):
40
+ * - today = current UTC day
41
+ * - this_week = Monday 00:00 UTC → now (backend uses ISO weekday)
42
+ * - this_month = 1st of the current UTC month → now
43
+ * - this_year = 1 January of the current UTC year → now
44
+ * - custom = inclusive `date_from` → `date_to` (YYYY-MM-DD)
45
+ *
46
+ * Exported so the period boundaries can be regression-tested with a fixed
47
+ * clock instead of depending on the day the suite runs.
29
48
  */
30
- function filterByPeriod(orders, period) {
31
- const now = new Date();
49
+ export function filterByPeriod(orders, period, now = new Date(), range) {
32
50
  const todayStr = now.toISOString().slice(0, 10);
33
51
  if (period === "today") {
34
52
  return orders.filter((o) => (o.inserted_at ?? "").slice(0, 10) === todayStr);
35
53
  }
36
- if (period === "this_week") {
37
- const startOfWeek = new Date(now);
38
- startOfWeek.setDate(now.getDate() - now.getDay()); // Sunday
39
- const startStr = startOfWeek.toISOString().slice(0, 10);
54
+ if (period === "custom") {
55
+ const from = range?.date_from;
56
+ const to = range?.date_to;
57
+ if (!from || !to)
58
+ return [];
40
59
  return orders.filter((o) => {
41
60
  const d = (o.inserted_at ?? "").slice(0, 10);
42
- return d >= startStr && d <= todayStr;
61
+ return d >= from && d <= to;
43
62
  });
44
63
  }
45
- // this_month
46
- const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1);
47
- const startStr = startOfMonth.toISOString().slice(0, 10);
64
+ let startStr;
65
+ if (period === "this_week") {
66
+ // `getUTCDay()` is 0 for Sunday; shift so Monday is the first day.
67
+ const todayUtc = new Date(`${todayStr}T00:00:00.000Z`);
68
+ const daysSinceMonday = (todayUtc.getUTCDay() + 6) % 7;
69
+ const startOfWeek = new Date(todayUtc);
70
+ startOfWeek.setUTCDate(todayUtc.getUTCDate() - daysSinceMonday);
71
+ startStr = startOfWeek.toISOString().slice(0, 10);
72
+ }
73
+ else if (period === "this_year") {
74
+ startStr = `${todayStr.slice(0, 4)}-01-01`;
75
+ }
76
+ else {
77
+ startStr = `${todayStr.slice(0, 7)}-01`;
78
+ }
48
79
  return orders.filter((o) => {
49
80
  const d = (o.inserted_at ?? "").slice(0, 10);
50
81
  return d >= startStr && d <= todayStr;
51
82
  });
52
83
  }
53
- export function aggregateLocal(orders, period, opts) {
54
- const filtered = filterByPeriod(orders, period);
84
+ export function aggregateLocal(orders, period, opts, range) {
85
+ const filtered = filterByPeriod(orders, period, new Date(), range);
55
86
  // Backend parity (orders.ex get_business_analytics / get_revenue_summary):
56
87
  // revenue-eligible = all EXCEPT cancelled. Cancelled orders must never
57
88
  // inflate revenue, counts, breakdowns, or top items — online and offline.
@@ -59,7 +90,9 @@ export function aggregateLocal(orders, period, opts) {
59
90
  const total_revenue_minor = revenueOrders.reduce((s, o) => s + (o.total_minor ?? 0), 0);
60
91
  const total_orders = revenueOrders.length;
61
92
  // Backend uses div (truncation) — mirror, don't round.
62
- const average_order_value_minor = total_orders ? Math.floor(total_revenue_minor / total_orders) : 0;
93
+ const average_order_value_minor = total_orders
94
+ ? Math.floor(total_revenue_minor / total_orders)
95
+ : 0;
63
96
  const byMethod = new Map();
64
97
  const byType = new Map();
65
98
  const byDay = new Map();
@@ -89,7 +122,9 @@ export function aggregateLocal(orders, period, opts) {
89
122
  itemRev.set(name, (itemRev.get(name) ?? 0) + rev);
90
123
  }
91
124
  }
92
- const pct = (v) => (total_revenue_minor > 0 ? Math.round((v / total_revenue_minor) * 1000) / 10 : 0);
125
+ const pct = (v) => total_revenue_minor > 0
126
+ ? Math.round((v / total_revenue_minor) * 1000) / 10
127
+ : 0;
93
128
  // Backend orders_by_status counts over ALL period orders (not revenue-only).
94
129
  const orders_by_status = {
95
130
  delivered: filtered.filter((o) => o.status === "delivered").length,
@@ -103,7 +138,11 @@ export function aggregateLocal(orders, period, opts) {
103
138
  total_orders,
104
139
  average_order_value_minor,
105
140
  by_payment_method: [...byMethod.entries()]
106
- .map(([method, v]) => ({ method, ...v, percentage: pct(v.revenue_minor) }))
141
+ .map(([method, v]) => ({
142
+ method,
143
+ ...v,
144
+ percentage: pct(v.revenue_minor),
145
+ }))
107
146
  .sort((a, b) => b.revenue_minor - a.revenue_minor),
108
147
  by_order_type: [...byType.entries()]
109
148
  .map(([type, v]) => ({ type, ...v }))
@@ -121,7 +160,11 @@ export function aggregateLocal(orders, period, opts) {
121
160
  average_order_value_minor,
122
161
  total_refunds_minor: 0,
123
162
  top_items: [...itemQty.entries()]
124
- .map(([name, quantity]) => ({ name, quantity, revenue_minor: itemRev.get(name) ?? 0 }))
163
+ .map(([name, quantity]) => ({
164
+ name,
165
+ quantity,
166
+ revenue_minor: itemRev.get(name) ?? 0,
167
+ }))
125
168
  .sort((a, b) => b.revenue_minor - a.revenue_minor)
126
169
  .slice(0, 10),
127
170
  orders_by_status,
@@ -133,90 +176,78 @@ export function aggregateLocal(orders, period, opts) {
133
176
  }
134
177
  async function localOrders(businessId) {
135
178
  const { data } = await listLocalOrders(businessId, {}, 1, 500);
136
- return (data ?? []);
179
+ return data ?? [];
180
+ }
181
+ function hasCustomRange(period, range) {
182
+ return (period !== "custom" || Boolean(range?.date_from && range?.date_to));
137
183
  }
138
- export function useSellerAnalyticsQuery(period = "today", enabled = true) {
184
+ export function useSellerAnalyticsQuery(period = "today", enabled = true, range) {
139
185
  const businessId = useBusinessId();
140
186
  const online = useIsEffectivelyOnline();
187
+ const customReady = hasCustomRange(period, range);
141
188
  return useQuery({
142
- queryKey: SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period),
189
+ queryKey: SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period, range),
143
190
  queryFn: async () => {
144
191
  if (!online) {
145
192
  try {
146
- return { data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }) };
193
+ return {
194
+ data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
195
+ };
147
196
  }
148
197
  catch {
149
- return { data: aggregateLocal([], period, { forRevenueSummary: false }) };
198
+ return {
199
+ data: aggregateLocal([], period, { forRevenueSummary: false }, range),
200
+ };
150
201
  }
151
202
  }
152
203
  try {
153
- const backendRes = await analyticsService.fetchSellerAnalytics(businessId, period);
154
- // Safety net: if backend doesn't filter by period (returns all days regardless), filter locally
155
- const backendData = backendRes.data;
156
- const byDay = backendData?.revenue_by_day;
157
- const shouldFilterLocally = !byDay?.length ||
158
- (period === "today" && byDay.length > 1) ||
159
- (period === "this_week" && byDay.some(d => {
160
- const day = new Date(d.date ?? "");
161
- const now = new Date();
162
- const startOfWeek = new Date(now);
163
- startOfWeek.setDate(now.getDate() - now.getDay());
164
- return day < startOfWeek || day > now;
165
- }));
166
- if (shouldFilterLocally) {
167
- const localOrdersData = await localOrders(businessId);
168
- return { data: aggregateLocal(localOrdersData, period, { forRevenueSummary: false }) };
169
- }
170
- return backendRes;
204
+ // The backend applies the period filter itself. An empty
205
+ // `revenue_by_day` is a valid result (e.g. no orders today) and must
206
+ // not be replaced with local rows — doing that hid real backend data.
207
+ return await analyticsService.fetchSellerAnalytics(businessId, period, range);
171
208
  }
172
209
  catch {
173
- return { data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }) };
210
+ return {
211
+ data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
212
+ };
174
213
  }
175
214
  },
176
- enabled: enabled && Boolean(businessId),
215
+ enabled: enabled && Boolean(businessId) && customReady,
177
216
  staleTime: 30000,
178
217
  ...OFFLINE_FIRST,
179
218
  });
180
219
  }
181
- export function useRevenueSummaryQuery(period = "today", enabled = true) {
220
+ export function useRevenueSummaryQuery(period = "today", enabled = true, range) {
182
221
  const businessId = useBusinessId();
183
222
  const online = useIsEffectivelyOnline();
223
+ const customReady = hasCustomRange(period, range);
184
224
  return useQuery({
185
- queryKey: REVENUE_KEYS.summary(businessId ?? "", period),
225
+ queryKey: REVENUE_KEYS.summary(businessId ?? "", period, range),
186
226
  queryFn: async () => {
187
227
  if (!online) {
188
228
  try {
189
- return { data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }) };
229
+ return {
230
+ data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
231
+ };
190
232
  }
191
233
  catch {
192
- return { data: aggregateLocal([], period, { forRevenueSummary: true }) };
234
+ return {
235
+ data: aggregateLocal([], period, { forRevenueSummary: true }, range),
236
+ };
193
237
  }
194
238
  }
195
239
  try {
196
- const backendRes = await analyticsService.fetchRevenueSummary(businessId, period);
197
- // Safety net: if backend doesn't filter by period (returns all days regardless), filter locally
198
- const backendData = backendRes.data;
199
- const byDay = backendData?.by_day;
200
- const shouldFilterLocally = !byDay?.length ||
201
- (period === "today" && byDay.length > 1) ||
202
- (period === "this_week" && byDay.some(d => {
203
- const day = new Date(d.date ?? "");
204
- const now = new Date();
205
- const startOfWeek = new Date(now);
206
- startOfWeek.setDate(now.getDate() - now.getDay());
207
- return day < startOfWeek || day > now;
208
- }));
209
- if (shouldFilterLocally) {
210
- const localOrdersData = await localOrders(businessId);
211
- return { data: aggregateLocal(localOrdersData, period, { forRevenueSummary: true }) };
212
- }
213
- return backendRes;
240
+ // Same contract as analytics: trust the backend period filter and
241
+ // only fall back to SQLite when the request itself fails.
242
+ return await analyticsService.fetchRevenueSummary(businessId, period, range);
214
243
  }
215
244
  catch {
216
- return { data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }) };
245
+ return {
246
+ data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
247
+ };
217
248
  }
218
249
  },
219
- enabled: enabled && Boolean(businessId),
250
+ enabled: enabled && Boolean(businessId) && customReady,
220
251
  staleTime: 30000,
221
252
  ...OFFLINE_FIRST,
222
253
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.12",
3
+ "version": "0.10.14",
4
4
  "description": "TeincFood shared offline-first core — types, sync engine, local DB, reference data, and repositories for mobile + desktop",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",