@teincfood/core 0.10.13 → 0.10.15

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,24 +37,21 @@ interface LocalOrderRow {
31
37
  interface AggregateOptions {
32
38
  forRevenueSummary: boolean;
33
39
  }
34
- /**
35
- * Backend-shaped aggregates computed from local SQLite rows (offline).
36
- * Field names mirror orders.ex get_seller_analytics / get_revenue_summary
37
- * exactly so screens render identically online and offline.
38
- */
39
40
  /**
40
41
  * Filter local rows to the same period boundaries as the backend
41
- * (`period_start_datetime/1`):
42
+ * (`period_bounds/2`):
42
43
  * - today = current UTC day
43
44
  * - this_week = Monday 00:00 UTC → now (backend uses ISO weekday)
44
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)
45
48
  *
46
49
  * Exported so the period boundaries can be regression-tested with a fixed
47
50
  * clock instead of depending on the day the suite runs.
48
51
  */
49
- export declare function filterByPeriod(orders: LocalOrderRow[], period: "today" | "this_week" | "this_month", now?: Date): LocalOrderRow[];
50
- export declare function aggregateLocal(orders: LocalOrderRow[], period: "today" | "this_week" | "this_month", opts: AggregateOptions): {
51
- period: "this_month" | "this_week" | "today";
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;
52
55
  total_revenue_minor: number;
53
56
  total_orders: number;
54
57
  average_order_value_minor: number;
@@ -77,7 +80,7 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: "today"
77
80
  by_payment_method?: undefined;
78
81
  by_order_type?: undefined;
79
82
  by_day?: undefined;
80
- period: "this_month" | "this_week" | "today";
83
+ period: AnalyticsPeriod;
81
84
  total_revenue_minor: number;
82
85
  total_orders: number;
83
86
  average_order_value_minor: number;
@@ -98,6 +101,14 @@ export declare function aggregateLocal(orders: LocalOrderRow[], period: "today"
98
101
  }[];
99
102
  offline: boolean;
100
103
  };
101
- export declare function useSellerAnalyticsQuery(period?: "today" | "this_week" | "this_month", enabled?: boolean): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
102
- export declare function useRevenueSummaryQuery(period?: "today" | "this_week" | "this_month", enabled?: boolean): import("@tanstack/react-query").UseQueryResult<unknown, Error>;
104
+ interface AnalyticsPayload {
105
+ total_orders?: number;
106
+ [key: string]: unknown;
107
+ }
108
+ interface AnalyticsResponse {
109
+ data?: AnalyticsPayload;
110
+ [key: string]: unknown;
111
+ }
112
+ export declare function useSellerAnalyticsQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<AnalyticsResponse, Error>;
113
+ export declare function useRevenueSummaryQuery(period?: AnalyticsPeriod, enabled?: boolean, range?: AnalyticsRange): import("@tanstack/react-query").UseQueryResult<AnalyticsResponse, Error>;
103
114
  export {};
@@ -3,45 +3,66 @@
3
3
  * Offline-first: local SQLite aggregates serve instantly; the network
4
4
  * refreshes silently when online.
5
5
  */
6
- import { useQuery } from "@tanstack/react-query";
7
- import { useSyncExternalStore } from "react";
6
+ import { useQuery, useQueryClient } from "@tanstack/react-query";
7
+ import { useEffect, useSyncExternalStore } from "react";
8
8
  import { getActiveBusiness, subscribeBusiness } from "../adapters/business";
9
9
  import { analyticsService } from "../adapters/services";
10
10
  import { listLocalOrders } from "../domain/orders/service";
11
+ import { pullRemoteOrders } from "../sync/sync-jobs.service";
12
+ import { syncLogger } from "../utils/sync-logger";
11
13
  import { useIsEffectivelyOnline } from "./use-infinite-list";
12
14
  export const SELLER_ANALYTICS_KEYS = {
13
15
  all: ["seller-analytics"],
14
- analytics: (businessId, period) => [...SELLER_ANALYTICS_KEYS.all, businessId, period],
16
+ analytics: (businessId, period, range) => [
17
+ ...SELLER_ANALYTICS_KEYS.all,
18
+ businessId,
19
+ period,
20
+ range?.date_from ?? null,
21
+ range?.date_to ?? null,
22
+ ],
15
23
  };
16
24
  export const REVENUE_KEYS = {
17
25
  all: ["revenue-summary"],
18
- summary: (businessId, period) => [...REVENUE_KEYS.all, businessId, period],
26
+ summary: (businessId, period, range) => [
27
+ ...REVENUE_KEYS.all,
28
+ businessId,
29
+ period,
30
+ range?.date_from ?? null,
31
+ range?.date_to ?? null,
32
+ ],
19
33
  };
20
34
  function useBusinessId() {
21
35
  const id = useSyncExternalStore(subscribeBusiness, () => getActiveBusiness()?.id ?? null, () => null);
22
36
  return id ?? undefined;
23
37
  }
24
38
  const OFFLINE_FIRST = { retry: false, networkMode: "offlineFirst" };
25
- /**
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.
29
- */
30
39
  /**
31
40
  * Filter local rows to the same period boundaries as the backend
32
- * (`period_start_datetime/1`):
41
+ * (`period_bounds/2`):
33
42
  * - today = current UTC day
34
43
  * - this_week = Monday 00:00 UTC → now (backend uses ISO weekday)
35
44
  * - this_month = 1st of the current UTC month → now
45
+ * - this_year = 1 January of the current UTC year → now
46
+ * - custom = inclusive `date_from` → `date_to` (YYYY-MM-DD)
36
47
  *
37
48
  * Exported so the period boundaries can be regression-tested with a fixed
38
49
  * clock instead of depending on the day the suite runs.
39
50
  */
40
- export function filterByPeriod(orders, period, now = new Date()) {
51
+ export function filterByPeriod(orders, period, now = new Date(), range) {
41
52
  const todayStr = now.toISOString().slice(0, 10);
42
53
  if (period === "today") {
43
54
  return orders.filter((o) => (o.inserted_at ?? "").slice(0, 10) === todayStr);
44
55
  }
56
+ if (period === "custom") {
57
+ const from = range?.date_from;
58
+ const to = range?.date_to;
59
+ if (!from || !to)
60
+ return [];
61
+ return orders.filter((o) => {
62
+ const d = (o.inserted_at ?? "").slice(0, 10);
63
+ return d >= from && d <= to;
64
+ });
65
+ }
45
66
  let startStr;
46
67
  if (period === "this_week") {
47
68
  // `getUTCDay()` is 0 for Sunday; shift so Monday is the first day.
@@ -51,6 +72,9 @@ export function filterByPeriod(orders, period, now = new Date()) {
51
72
  startOfWeek.setUTCDate(todayUtc.getUTCDate() - daysSinceMonday);
52
73
  startStr = startOfWeek.toISOString().slice(0, 10);
53
74
  }
75
+ else if (period === "this_year") {
76
+ startStr = `${todayStr.slice(0, 4)}-01-01`;
77
+ }
54
78
  else {
55
79
  startStr = `${todayStr.slice(0, 7)}-01`;
56
80
  }
@@ -59,8 +83,8 @@ export function filterByPeriod(orders, period, now = new Date()) {
59
83
  return d >= startStr && d <= todayStr;
60
84
  });
61
85
  }
62
- export function aggregateLocal(orders, period, opts) {
63
- const filtered = filterByPeriod(orders, period);
86
+ export function aggregateLocal(orders, period, opts, range) {
87
+ const filtered = filterByPeriod(orders, period, new Date(), range);
64
88
  // Backend parity (orders.ex get_business_analytics / get_revenue_summary):
65
89
  // revenue-eligible = all EXCEPT cancelled. Cancelled orders must never
66
90
  // inflate revenue, counts, breakdowns, or top items — online and offline.
@@ -68,7 +92,9 @@ export function aggregateLocal(orders, period, opts) {
68
92
  const total_revenue_minor = revenueOrders.reduce((s, o) => s + (o.total_minor ?? 0), 0);
69
93
  const total_orders = revenueOrders.length;
70
94
  // Backend uses div (truncation) — mirror, don't round.
71
- const average_order_value_minor = total_orders ? Math.floor(total_revenue_minor / total_orders) : 0;
95
+ const average_order_value_minor = total_orders
96
+ ? Math.floor(total_revenue_minor / total_orders)
97
+ : 0;
72
98
  const byMethod = new Map();
73
99
  const byType = new Map();
74
100
  const byDay = new Map();
@@ -98,7 +124,9 @@ export function aggregateLocal(orders, period, opts) {
98
124
  itemRev.set(name, (itemRev.get(name) ?? 0) + rev);
99
125
  }
100
126
  }
101
- const pct = (v) => (total_revenue_minor > 0 ? Math.round((v / total_revenue_minor) * 1000) / 10 : 0);
127
+ const pct = (v) => total_revenue_minor > 0
128
+ ? Math.round((v / total_revenue_minor) * 1000) / 10
129
+ : 0;
102
130
  // Backend orders_by_status counts over ALL period orders (not revenue-only).
103
131
  const orders_by_status = {
104
132
  delivered: filtered.filter((o) => o.status === "delivered").length,
@@ -112,7 +140,11 @@ export function aggregateLocal(orders, period, opts) {
112
140
  total_orders,
113
141
  average_order_value_minor,
114
142
  by_payment_method: [...byMethod.entries()]
115
- .map(([method, v]) => ({ method, ...v, percentage: pct(v.revenue_minor) }))
143
+ .map(([method, v]) => ({
144
+ method,
145
+ ...v,
146
+ percentage: pct(v.revenue_minor),
147
+ }))
116
148
  .sort((a, b) => b.revenue_minor - a.revenue_minor),
117
149
  by_order_type: [...byType.entries()]
118
150
  .map(([type, v]) => ({ type, ...v }))
@@ -130,7 +162,11 @@ export function aggregateLocal(orders, period, opts) {
130
162
  average_order_value_minor,
131
163
  total_refunds_minor: 0,
132
164
  top_items: [...itemQty.entries()]
133
- .map(([name, quantity]) => ({ name, quantity, revenue_minor: itemRev.get(name) ?? 0 }))
165
+ .map(([name, quantity]) => ({
166
+ name,
167
+ quantity,
168
+ revenue_minor: itemRev.get(name) ?? 0,
169
+ }))
134
170
  .sort((a, b) => b.revenue_minor - a.revenue_minor)
135
171
  .slice(0, 10),
136
172
  orders_by_status,
@@ -142,69 +178,145 @@ export function aggregateLocal(orders, period, opts) {
142
178
  }
143
179
  async function localOrders(businessId) {
144
180
  const { data } = await listLocalOrders(businessId, {}, 1, 500);
145
- return (data ?? []);
181
+ return data ?? [];
182
+ }
183
+ function hasCustomRange(period, range) {
184
+ return (period !== "custom" || Boolean(range?.date_from && range?.date_to));
146
185
  }
147
- export function useSellerAnalyticsQuery(period = "today", enabled = true) {
186
+ const ORDER_PULL_COOLDOWN_MS = 15000;
187
+ const lastOrderPullAt = new Map();
188
+ /**
189
+ * Refresh the local order read model in the background. Analytics renders
190
+ * from SQLite immediately; this pull is what makes a freshly opened screen
191
+ * converge with the server without blocking the first paint.
192
+ */
193
+ async function syncOrdersForAnalytics(businessId, queryClient) {
194
+ const now = Date.now();
195
+ const last = lastOrderPullAt.get(businessId) ?? 0;
196
+ if (now - last < ORDER_PULL_COOLDOWN_MS)
197
+ return;
198
+ lastOrderPullAt.set(businessId, now);
199
+ try {
200
+ await pullRemoteOrders(businessId);
201
+ await Promise.all([
202
+ queryClient.invalidateQueries({ queryKey: SELLER_ANALYTICS_KEYS.all }),
203
+ queryClient.invalidateQueries({ queryKey: REVENUE_KEYS.all }),
204
+ ]);
205
+ }
206
+ catch (error) {
207
+ syncLogger.debug("Analytics", "background order sync failed", {
208
+ businessId,
209
+ err: String(error),
210
+ });
211
+ }
212
+ }
213
+ /**
214
+ * Keep local data when it knows about more orders than the server snapshot
215
+ * (e.g. an offline order still in the outbox). Otherwise the server snapshot
216
+ * wins because it may include older history the device has never pulled.
217
+ */
218
+ function preferRemote(current, remote) {
219
+ const localOrders = current?.data?.total_orders ?? 0;
220
+ const remoteOrders = remote.data?.total_orders ?? 0;
221
+ return remoteOrders >= localOrders ? remote : (current ?? remote);
222
+ }
223
+ async function buildLocalAnalytics(businessId, period, range) {
224
+ try {
225
+ return {
226
+ data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }, range),
227
+ };
228
+ }
229
+ catch {
230
+ return {
231
+ data: aggregateLocal([], period, { forRevenueSummary: false }, range),
232
+ };
233
+ }
234
+ }
235
+ async function buildLocalRevenue(businessId, period, range) {
236
+ try {
237
+ return {
238
+ data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }, range),
239
+ };
240
+ }
241
+ catch {
242
+ return {
243
+ data: aggregateLocal([], period, { forRevenueSummary: true }, range),
244
+ };
245
+ }
246
+ }
247
+ export function useSellerAnalyticsQuery(period = "today", enabled = true, range) {
148
248
  const businessId = useBusinessId();
149
249
  const online = useIsEffectivelyOnline();
250
+ const queryClient = useQueryClient();
251
+ const customReady = hasCustomRange(period, range);
252
+ const queryKey = SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period, range);
253
+ const active = enabled && Boolean(businessId) && customReady;
254
+ // Local-first: read SQLite on every query run. The network is a background
255
+ // refresh only, so opening the screen never waits on the server.
256
+ useEffect(() => {
257
+ if (!active || !online || !businessId)
258
+ return;
259
+ void syncOrdersForAnalytics(businessId, queryClient);
260
+ }, [active, online, businessId, queryClient]);
150
261
  return useQuery({
151
- queryKey: SELLER_ANALYTICS_KEYS.analytics(businessId ?? "", period),
262
+ queryKey,
152
263
  queryFn: async () => {
153
- if (!online) {
154
- try {
155
- return { data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: false }) };
156
- }
157
- catch {
158
- return { data: aggregateLocal([], period, { forRevenueSummary: false }) };
159
- }
160
- }
161
- try {
162
- // The backend applies the period filter itself. An empty
163
- // `revenue_by_day` is a valid result (e.g. no orders today) and must
164
- // not be replaced with local rows — doing that hid real backend data.
165
- return await analyticsService.fetchSellerAnalytics(businessId, period);
166
- }
167
- catch {
168
- return {
169
- data: aggregateLocal(await localOrders(businessId), period, {
170
- forRevenueSummary: false,
171
- }),
172
- };
264
+ const local = await buildLocalAnalytics(businessId, period, range);
265
+ if (online && businessId) {
266
+ // Do not await. Once the server snapshot arrives, merge it into the
267
+ // cache only when it is at least as complete as the local model.
268
+ void analyticsService
269
+ .fetchSellerAnalytics(businessId, period, range)
270
+ .then((remote) => {
271
+ const remoteResponse = remote;
272
+ const current = queryClient.getQueryData(queryKey);
273
+ queryClient.setQueryData(queryKey, preferRemote(current, remoteResponse));
274
+ })
275
+ .catch((error) => {
276
+ syncLogger.debug("Analytics", "background analytics refresh failed", {
277
+ businessId,
278
+ err: String(error),
279
+ });
280
+ });
173
281
  }
282
+ return local;
174
283
  },
175
- enabled: enabled && Boolean(businessId),
284
+ enabled: active,
176
285
  staleTime: 30000,
177
286
  ...OFFLINE_FIRST,
178
287
  });
179
288
  }
180
- export function useRevenueSummaryQuery(period = "today", enabled = true) {
289
+ export function useRevenueSummaryQuery(period = "today", enabled = true, range) {
181
290
  const businessId = useBusinessId();
182
291
  const online = useIsEffectivelyOnline();
292
+ const queryClient = useQueryClient();
293
+ const customReady = hasCustomRange(period, range);
294
+ const queryKey = REVENUE_KEYS.summary(businessId ?? "", period, range);
295
+ const active = enabled && Boolean(businessId) && customReady;
296
+ useEffect(() => {
297
+ if (!active || !online || !businessId)
298
+ return;
299
+ void syncOrdersForAnalytics(businessId, queryClient);
300
+ }, [active, online, businessId, queryClient]);
183
301
  return useQuery({
184
- queryKey: REVENUE_KEYS.summary(businessId ?? "", period),
302
+ queryKey,
185
303
  queryFn: async () => {
186
- if (!online) {
187
- try {
188
- return { data: aggregateLocal(await localOrders(businessId), period, { forRevenueSummary: true }) };
189
- }
190
- catch {
191
- return { data: aggregateLocal([], period, { forRevenueSummary: true }) };
192
- }
193
- }
194
- try {
195
- // Same contract as analytics: trust the backend period filter and
196
- // only fall back to SQLite when the request itself fails.
197
- return await analyticsService.fetchRevenueSummary(businessId, period);
198
- }
199
- catch {
200
- return {
201
- data: aggregateLocal(await localOrders(businessId), period, {
202
- forRevenueSummary: true,
203
- }),
204
- };
304
+ const local = await buildLocalRevenue(businessId, period, range);
305
+ if (online && businessId) {
306
+ void analyticsService
307
+ .fetchRevenueSummary(businessId, period, range)
308
+ .then((remote) => {
309
+ const remoteResponse = remote;
310
+ const current = queryClient.getQueryData(queryKey);
311
+ queryClient.setQueryData(queryKey, preferRemote(current, remoteResponse));
312
+ })
313
+ .catch((error) => {
314
+ syncLogger.debug("Analytics", "background revenue refresh failed", { businessId, err: String(error) });
315
+ });
205
316
  }
317
+ return local;
206
318
  },
207
- enabled: enabled && Boolean(businessId),
319
+ enabled: active,
208
320
  staleTime: 30000,
209
321
  ...OFFLINE_FIRST,
210
322
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@teincfood/core",
3
- "version": "0.10.13",
3
+ "version": "0.10.15",
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",