@go-avro/avro-js 0.0.44 → 0.0.46
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/client/QueryClient.d.ts +6 -11
- package/dist/client/QueryClient.js +149 -48
- package/dist/client/hooks/events.js +3 -5
- package/dist/types/api/Job.d.ts +15 -0
- package/dist/types/api/Job.js +21 -0
- package/dist/types/api/Route.d.ts +13 -0
- package/dist/types/api/Route.js +32 -0
- package/dist/types/api/RouteJob.d.ts +7 -0
- package/dist/types/api/RouteJob.js +11 -0
- package/dist/types/api/Task.js +9 -6
- package/package.json +1 -1
|
@@ -517,6 +517,11 @@ declare module '../client/QueryClient' {
|
|
|
517
517
|
}>>;
|
|
518
518
|
}
|
|
519
519
|
}
|
|
520
|
+
export type ListQueryMatch = 'match' | 'nomatch' | 'unknown';
|
|
521
|
+
export declare function matchesEventsListQuery(queryKey: readonly unknown[], item: any): ListQueryMatch;
|
|
522
|
+
export declare function matchesBillsListQuery(queryKey: readonly unknown[], item: any): ListQueryMatch;
|
|
523
|
+
export declare function matchesJobsListQuery(queryKey: readonly unknown[], item: any): ListQueryMatch;
|
|
524
|
+
export declare function matchesRoutesListQuery(queryKey: readonly unknown[], _item: any): ListQueryMatch;
|
|
520
525
|
export declare class AvroQueryClient {
|
|
521
526
|
protected config: Required<AvroQueryClientConfig>;
|
|
522
527
|
readonly socket: Socket;
|
|
@@ -618,23 +623,13 @@ export declare class AvroQueryClient {
|
|
|
618
623
|
getAuthState(): AuthState;
|
|
619
624
|
getAuthStateAsync(): Promise<AuthState>;
|
|
620
625
|
getQueryClient(): QueryClient;
|
|
621
|
-
/**
|
|
622
|
-
* Fetch an entity from the API, optionally construct it, and surgically
|
|
623
|
-
* update all matching React-Query caches (individual + list + infinite).
|
|
624
|
-
*
|
|
625
|
-
* Shared by socket handlers and mutation `onSuccess` callbacks so the
|
|
626
|
-
* sender gets an immediate cache sync and everyone else gets the socket
|
|
627
|
-
* update — both use the identical code path.
|
|
628
|
-
*
|
|
629
|
-
* @returns The fetched (and optionally constructed) item, or `undefined`
|
|
630
|
-
* for deletes / entities without a fetchPath.
|
|
631
|
-
*/
|
|
632
626
|
_syncEntity(queryClient: QueryClient, params: {
|
|
633
627
|
action: 'create' | 'update' | 'delete';
|
|
634
628
|
entityKey: string;
|
|
635
629
|
id: string;
|
|
636
630
|
fetchPath?: string;
|
|
637
631
|
construct?: (raw: any) => any;
|
|
632
|
+
matchesQuery?: (queryKey: readonly unknown[], item: any) => ListQueryMatch;
|
|
638
633
|
}): Promise<any>;
|
|
639
634
|
useLogout(): ReturnType<typeof useMutation<void, StandardError, CancelToken | undefined>>;
|
|
640
635
|
fetchJobs(body?: {
|
|
@@ -7,6 +7,91 @@ import { StandardError } from '../types/error';
|
|
|
7
7
|
function isBulkEvent(c) {
|
|
8
8
|
return 'invalidateKeys' in c;
|
|
9
9
|
}
|
|
10
|
+
export function matchesEventsListQuery(queryKey, item) {
|
|
11
|
+
if (queryKey.length < 11)
|
|
12
|
+
return 'match';
|
|
13
|
+
if (!item)
|
|
14
|
+
return 'match';
|
|
15
|
+
const knownIds = queryKey[3];
|
|
16
|
+
const unknownIds = queryKey[4];
|
|
17
|
+
const query = queryKey[5];
|
|
18
|
+
const includeUnbilled = queryKey[6];
|
|
19
|
+
const includeBilled = queryKey[7];
|
|
20
|
+
const includePaid = queryKey[8];
|
|
21
|
+
const filterJobId = queryKey[9];
|
|
22
|
+
if (knownIds && knownIds.length > 0 && !knownIds.includes(item.id))
|
|
23
|
+
return 'nomatch';
|
|
24
|
+
if (unknownIds && unknownIds.length > 0 && unknownIds.includes(item.id))
|
|
25
|
+
return 'nomatch';
|
|
26
|
+
if (filterJobId && item.job_id !== filterJobId)
|
|
27
|
+
return 'nomatch';
|
|
28
|
+
const status = item.status;
|
|
29
|
+
if (status !== undefined) {
|
|
30
|
+
const isPaid = status === 'PAID' || status === 'PREPAID' || status === 'EXTERNALLY_PAID';
|
|
31
|
+
const isBilled = status === 'BILLED' || status === 'EXTERNALLY_BILLED';
|
|
32
|
+
const isUnbilled = !isPaid && !isBilled;
|
|
33
|
+
if (includePaid === false && isPaid)
|
|
34
|
+
return 'nomatch';
|
|
35
|
+
if (includeBilled === false && isBilled)
|
|
36
|
+
return 'nomatch';
|
|
37
|
+
if (includeUnbilled === false && isUnbilled)
|
|
38
|
+
return 'nomatch';
|
|
39
|
+
}
|
|
40
|
+
if (query && query.length > 0)
|
|
41
|
+
return 'unknown';
|
|
42
|
+
return 'match';
|
|
43
|
+
}
|
|
44
|
+
export function matchesBillsListQuery(queryKey, item) {
|
|
45
|
+
if (queryKey.length < 6)
|
|
46
|
+
return 'match';
|
|
47
|
+
if (!item)
|
|
48
|
+
return 'match';
|
|
49
|
+
const query = queryKey[2];
|
|
50
|
+
const knownIds = queryKey[3];
|
|
51
|
+
const unknownIds = queryKey[4];
|
|
52
|
+
const paidFilter = queryKey[5];
|
|
53
|
+
if (knownIds && knownIds.length > 0 && !knownIds.includes(item.id))
|
|
54
|
+
return 'nomatch';
|
|
55
|
+
if (unknownIds && unknownIds.length > 0 && unknownIds.includes(item.id))
|
|
56
|
+
return 'nomatch';
|
|
57
|
+
const status = item.status;
|
|
58
|
+
if (paidFilter !== undefined && status !== undefined) {
|
|
59
|
+
const isPaid = status === 'PAID' || status === 'MANUALLY_PAID';
|
|
60
|
+
if (paidFilter === true && !isPaid)
|
|
61
|
+
return 'nomatch';
|
|
62
|
+
if (paidFilter === false && isPaid)
|
|
63
|
+
return 'nomatch';
|
|
64
|
+
}
|
|
65
|
+
if (query && query.length > 0)
|
|
66
|
+
return 'unknown';
|
|
67
|
+
return 'match';
|
|
68
|
+
}
|
|
69
|
+
export function matchesJobsListQuery(queryKey, item) {
|
|
70
|
+
if (queryKey[0] !== 'infinite')
|
|
71
|
+
return 'match';
|
|
72
|
+
if (queryKey.length < 6)
|
|
73
|
+
return 'match';
|
|
74
|
+
if (!item)
|
|
75
|
+
return 'match';
|
|
76
|
+
const query = queryKey[4];
|
|
77
|
+
const routeId = queryKey[5];
|
|
78
|
+
if (routeId && routeId.length > 0) {
|
|
79
|
+
const routes = item.routes ?? [];
|
|
80
|
+
if (!routes.some((r) => r?.route_id === routeId))
|
|
81
|
+
return 'nomatch';
|
|
82
|
+
}
|
|
83
|
+
if (query && query.length > 0)
|
|
84
|
+
return 'unknown';
|
|
85
|
+
return 'match';
|
|
86
|
+
}
|
|
87
|
+
export function matchesRoutesListQuery(queryKey, _item) {
|
|
88
|
+
if (queryKey.length < 5)
|
|
89
|
+
return 'match';
|
|
90
|
+
const query = queryKey[3];
|
|
91
|
+
if (query && query.length > 0)
|
|
92
|
+
return 'unknown';
|
|
93
|
+
return 'match';
|
|
94
|
+
}
|
|
10
95
|
/**
|
|
11
96
|
* Maps socket event names to cache-update strategies.
|
|
12
97
|
*
|
|
@@ -38,12 +123,14 @@ const SOCKET_EVENT_CONFIG = {
|
|
|
38
123
|
action: 'create',
|
|
39
124
|
fetchPath: (id) => `/job/${id}`,
|
|
40
125
|
construct: (d) => new Job(d),
|
|
126
|
+
matchesQuery: matchesJobsListQuery,
|
|
41
127
|
},
|
|
42
128
|
update_job: {
|
|
43
129
|
entityKey: 'jobs',
|
|
44
130
|
action: 'update',
|
|
45
131
|
fetchPath: (id) => `/job/${id}`,
|
|
46
132
|
construct: (d) => new Job(d),
|
|
133
|
+
matchesQuery: matchesJobsListQuery,
|
|
47
134
|
},
|
|
48
135
|
delete_job: { entityKey: 'jobs', action: 'delete', fetchPath: null },
|
|
49
136
|
update_jobs: { invalidateKeys: [['jobs'], ['infinite', 'jobs']] },
|
|
@@ -54,12 +141,14 @@ const SOCKET_EVENT_CONFIG = {
|
|
|
54
141
|
action: 'create',
|
|
55
142
|
fetchPath: (id) => `/route/${id}`,
|
|
56
143
|
construct: (d) => new Route(d),
|
|
144
|
+
matchesQuery: matchesRoutesListQuery,
|
|
57
145
|
},
|
|
58
146
|
update_route: {
|
|
59
147
|
entityKey: 'routes',
|
|
60
148
|
action: 'update',
|
|
61
149
|
fetchPath: (id) => `/route/${id}`,
|
|
62
150
|
construct: (d) => new Route(d),
|
|
151
|
+
matchesQuery: matchesRoutesListQuery,
|
|
63
152
|
},
|
|
64
153
|
delete_route: { entityKey: 'routes', action: 'delete', fetchPath: null },
|
|
65
154
|
// ── Events (also refetch parent job — overdueness, last_event, etc.) ──
|
|
@@ -68,12 +157,14 @@ const SOCKET_EVENT_CONFIG = {
|
|
|
68
157
|
action: 'create',
|
|
69
158
|
fetchPath: (id) => `/event/${id}`,
|
|
70
159
|
construct: (d) => new _Event(d),
|
|
160
|
+
matchesQuery: matchesEventsListQuery,
|
|
71
161
|
relatedRefetch: [
|
|
72
162
|
{
|
|
73
163
|
entityKey: 'jobs',
|
|
74
164
|
idField: 'job_id',
|
|
75
165
|
fetchPath: (id) => `/job/${id}`,
|
|
76
166
|
construct: (d) => new Job(d),
|
|
167
|
+
matchesQuery: matchesJobsListQuery,
|
|
77
168
|
},
|
|
78
169
|
],
|
|
79
170
|
},
|
|
@@ -82,12 +173,14 @@ const SOCKET_EVENT_CONFIG = {
|
|
|
82
173
|
action: 'update',
|
|
83
174
|
fetchPath: (id) => `/event/${id}`,
|
|
84
175
|
construct: (d) => new _Event(d),
|
|
176
|
+
matchesQuery: matchesEventsListQuery,
|
|
85
177
|
relatedRefetch: [
|
|
86
178
|
{
|
|
87
179
|
entityKey: 'jobs',
|
|
88
180
|
idField: 'job_id',
|
|
89
181
|
fetchPath: (id) => `/job/${id}`,
|
|
90
182
|
construct: (d) => new Job(d),
|
|
183
|
+
matchesQuery: matchesJobsListQuery,
|
|
91
184
|
},
|
|
92
185
|
],
|
|
93
186
|
},
|
|
@@ -101,6 +194,7 @@ const SOCKET_EVENT_CONFIG = {
|
|
|
101
194
|
idField: 'job_id',
|
|
102
195
|
fetchPath: (id) => `/job/${id}`,
|
|
103
196
|
construct: (d) => new Job(d),
|
|
197
|
+
matchesQuery: matchesJobsListQuery,
|
|
104
198
|
},
|
|
105
199
|
],
|
|
106
200
|
},
|
|
@@ -115,6 +209,7 @@ const SOCKET_EVENT_CONFIG = {
|
|
|
115
209
|
entityKey: 'bills',
|
|
116
210
|
action: 'create',
|
|
117
211
|
fetchPath: (id) => `/bill/${id}`,
|
|
212
|
+
matchesQuery: matchesBillsListQuery,
|
|
118
213
|
},
|
|
119
214
|
delete_bill: { entityKey: 'bills', action: 'delete', fetchPath: null },
|
|
120
215
|
update_bills: { invalidateKeys: [['bills']] },
|
|
@@ -348,10 +443,10 @@ export class AvroQueryClient {
|
|
|
348
443
|
id,
|
|
349
444
|
fetchPath: fetchPath ? fetchPath(id) : undefined,
|
|
350
445
|
construct: config.construct,
|
|
446
|
+
matchesQuery: config.matchesQuery,
|
|
351
447
|
});
|
|
352
448
|
// Invalidate any additional keys (e.g. companies → /company/list)
|
|
353
449
|
alsoInvalidate?.forEach((k) => queryClient.invalidateQueries({ queryKey: k }));
|
|
354
|
-
// Refetch & patch related entities (e.g. event → parent job)
|
|
355
450
|
if (relatedRefetch) {
|
|
356
451
|
for (const related of relatedRefetch) {
|
|
357
452
|
const relatedId = data?.[related.idField] ?? fetchedItem?.[related.idField];
|
|
@@ -363,6 +458,7 @@ export class AvroQueryClient {
|
|
|
363
458
|
id: relatedId,
|
|
364
459
|
fetchPath: related.fetchPath(relatedId),
|
|
365
460
|
construct: related.construct,
|
|
461
|
+
matchesQuery: related.matchesQuery,
|
|
366
462
|
});
|
|
367
463
|
}
|
|
368
464
|
}
|
|
@@ -615,22 +711,10 @@ export class AvroQueryClient {
|
|
|
615
711
|
getQueryClient() {
|
|
616
712
|
return useQueryClient();
|
|
617
713
|
}
|
|
618
|
-
/**
|
|
619
|
-
* Fetch an entity from the API, optionally construct it, and surgically
|
|
620
|
-
* update all matching React-Query caches (individual + list + infinite).
|
|
621
|
-
*
|
|
622
|
-
* Shared by socket handlers and mutation `onSuccess` callbacks so the
|
|
623
|
-
* sender gets an immediate cache sync and everyone else gets the socket
|
|
624
|
-
* update — both use the identical code path.
|
|
625
|
-
*
|
|
626
|
-
* @returns The fetched (and optionally constructed) item, or `undefined`
|
|
627
|
-
* for deletes / entities without a fetchPath.
|
|
628
|
-
*/
|
|
629
714
|
async _syncEntity(queryClient, params) {
|
|
630
|
-
const { action, entityKey, id, fetchPath, construct } = params;
|
|
715
|
+
const { action, entityKey, id, fetchPath, construct, matchesQuery } = params;
|
|
631
716
|
const predicate = (q) => matchesEntityKey(q, entityKey);
|
|
632
717
|
const invalidate = () => queryClient.invalidateQueries({ predicate });
|
|
633
|
-
// ─── DELETE ─────────────────────────────────────────
|
|
634
718
|
if (action === 'delete') {
|
|
635
719
|
queryClient.removeQueries({ queryKey: [entityKey, id], exact: true });
|
|
636
720
|
queryClient.setQueriesData({ predicate }, (old) => {
|
|
@@ -649,7 +733,6 @@ export class AvroQueryClient {
|
|
|
649
733
|
});
|
|
650
734
|
return undefined;
|
|
651
735
|
}
|
|
652
|
-
// ─── CREATE / UPDATE ────────────────────────────────
|
|
653
736
|
if (!fetchPath) {
|
|
654
737
|
invalidate();
|
|
655
738
|
return undefined;
|
|
@@ -662,42 +745,60 @@ export class AvroQueryClient {
|
|
|
662
745
|
});
|
|
663
746
|
const item = construct ? construct(raw) : raw;
|
|
664
747
|
queryClient.setQueryData([entityKey, id], item);
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
748
|
+
const matchingQueries = queryClient.getQueryCache().findAll({ predicate });
|
|
749
|
+
for (const query of matchingQueries) {
|
|
750
|
+
const queryKey = query.queryKey;
|
|
751
|
+
if (queryKey.length === 2 && queryKey[0] === entityKey)
|
|
752
|
+
continue;
|
|
753
|
+
const verdict = matchesQuery
|
|
754
|
+
? matchesQuery(queryKey, item)
|
|
755
|
+
: 'match';
|
|
756
|
+
if (verdict === 'unknown') {
|
|
757
|
+
queryClient.invalidateQueries({ queryKey, exact: true });
|
|
758
|
+
continue;
|
|
759
|
+
}
|
|
760
|
+
if (action === 'create') {
|
|
761
|
+
if (verdict === 'nomatch')
|
|
762
|
+
continue;
|
|
763
|
+
queryClient.setQueryData(queryKey, (old) => {
|
|
764
|
+
if (!old)
|
|
671
765
|
return old;
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
766
|
+
if (old.pages && Array.isArray(old.pages)) {
|
|
767
|
+
if (old.pages.some((p) => p.some((x) => x?.id === id)))
|
|
768
|
+
return old;
|
|
769
|
+
return {
|
|
770
|
+
...old,
|
|
771
|
+
pages: [[item, ...(old.pages[0] || [])], ...old.pages.slice(1)],
|
|
772
|
+
};
|
|
773
|
+
}
|
|
774
|
+
if (Array.isArray(old)) {
|
|
775
|
+
if (old.some((x) => x?.id === id))
|
|
776
|
+
return old;
|
|
777
|
+
return [...old, item];
|
|
778
|
+
}
|
|
779
|
+
return old;
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
else {
|
|
783
|
+
queryClient.setQueryData(queryKey, (old) => {
|
|
784
|
+
if (!old)
|
|
679
785
|
return old;
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
786
|
+
if (old.pages && Array.isArray(old.pages)) {
|
|
787
|
+
return {
|
|
788
|
+
...old,
|
|
789
|
+
pages: old.pages.map((page) => verdict === 'match'
|
|
790
|
+
? page.map((x) => (x?.id === id ? item : x))
|
|
791
|
+
: page.filter((x) => x?.id !== id)),
|
|
792
|
+
};
|
|
793
|
+
}
|
|
794
|
+
if (Array.isArray(old)) {
|
|
795
|
+
return verdict === 'match'
|
|
796
|
+
? old.map((x) => (x?.id === id ? item : x))
|
|
797
|
+
: old.filter((x) => x?.id !== id);
|
|
798
|
+
}
|
|
689
799
|
return old;
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
...old,
|
|
693
|
-
pages: old.pages.map((page) => page.map((x) => (x?.id === id ? item : x))),
|
|
694
|
-
};
|
|
695
|
-
}
|
|
696
|
-
if (Array.isArray(old)) {
|
|
697
|
-
return old.map((x) => (x?.id === id ? item : x));
|
|
698
|
-
}
|
|
699
|
-
return old;
|
|
700
|
-
});
|
|
800
|
+
});
|
|
801
|
+
}
|
|
701
802
|
}
|
|
702
803
|
return item;
|
|
703
804
|
}
|
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
import { useInfiniteQuery, useQuery, useMutation } from '@tanstack/react-query';
|
|
2
|
-
import { AvroQueryClient } from '../../client/QueryClient';
|
|
2
|
+
import { AvroQueryClient, matchesEventsListQuery } from '../../client/QueryClient';
|
|
3
3
|
import { _Event, Job, LineItemStatus } from '../../types/api';
|
|
4
|
-
/** Predicate that matches all 'jobs' queries (list and individual, but NOT 'infinite'). */
|
|
5
4
|
const isJobsQuery = (q) => q.queryKey[0] === 'jobs';
|
|
6
|
-
/** Predicate that matches all 'events' queries (list and individual). */
|
|
7
5
|
const isEventsQuery = (q) => q.queryKey[0] === 'events';
|
|
6
|
+
const isEventsQueryFor = (item) => (q) => q.queryKey[0] === 'events' && matchesEventsListQuery(q.queryKey, item) === 'match';
|
|
8
7
|
AvroQueryClient.prototype.useGetEvents = function (body) {
|
|
9
8
|
const queryClient = this.getQueryClient();
|
|
10
9
|
const result = useInfiniteQuery({
|
|
@@ -102,8 +101,7 @@ AvroQueryClient.prototype.useCreateEvent = function () {
|
|
|
102
101
|
return old;
|
|
103
102
|
});
|
|
104
103
|
}
|
|
105
|
-
|
|
106
|
-
queryClient.setQueriesData({ predicate: isEventsQuery, type: 'active' }, (oldData) => {
|
|
104
|
+
queryClient.setQueriesData({ predicate: isEventsQueryFor(optimisticEvent), type: 'active' }, (oldData) => {
|
|
107
105
|
if (!oldData)
|
|
108
106
|
return oldData;
|
|
109
107
|
if (oldData.pages) {
|
package/dist/types/api/Job.d.ts
CHANGED
|
@@ -35,6 +35,21 @@ export declare class Job {
|
|
|
35
35
|
getOverdueLabel: () => string;
|
|
36
36
|
getStatus(): "PENDING_CUSTOMER" | "PENDING_COMPANY" | "ACTIVE" | "ARCHIVED" | "DRAFT" | "PENDING_PAYMENT" | "PENDING_ACTIVATION";
|
|
37
37
|
getStatusLabel(): "Active" | "Draft" | "Archived" | "Pending Activation" | "Customer Approval" | "Company Approval" | "Awaiting Payment";
|
|
38
|
+
/**
|
|
39
|
+
* Returns the sum of `service` (in seconds) for the tasks attached to a
|
|
40
|
+
* given routeJob. Use this to compute predicted completion time:
|
|
41
|
+
* `routeJob.getPredictedArrival() + job.getServiceDuration(routeJob)`.
|
|
42
|
+
*/
|
|
43
|
+
getServiceDuration: (routeJob: RouteJob) => number;
|
|
44
|
+
/**
|
|
45
|
+
* Returns the most relevant event for the job in its current state:
|
|
46
|
+
* the active `current_event` if one exists, otherwise the
|
|
47
|
+
* `last_completed_event`. Falls back to `null`.
|
|
48
|
+
*
|
|
49
|
+
* Most callers historically used `last_completed_event ?? current_event`
|
|
50
|
+
* which inverted the precedence — prefer the live event over a stale one.
|
|
51
|
+
*/
|
|
52
|
+
getRouteEvent: () => _Event | null;
|
|
38
53
|
portionDone: (route: Route) => number;
|
|
39
54
|
isDone: (route: Route) => boolean;
|
|
40
55
|
}
|
package/dist/types/api/Job.js
CHANGED
|
@@ -21,6 +21,27 @@ export class Job {
|
|
|
21
21
|
}, activeTasks[0]);
|
|
22
22
|
return mostOverdueTask.getOverdueLabel?.() ?? 'N/A';
|
|
23
23
|
};
|
|
24
|
+
/**
|
|
25
|
+
* Returns the sum of `service` (in seconds) for the tasks attached to a
|
|
26
|
+
* given routeJob. Use this to compute predicted completion time:
|
|
27
|
+
* `routeJob.getPredictedArrival() + job.getServiceDuration(routeJob)`.
|
|
28
|
+
*/
|
|
29
|
+
this.getServiceDuration = (routeJob) => {
|
|
30
|
+
return routeJob.tasks
|
|
31
|
+
.map((tid) => this.tasks.find((t) => t.id === tid)?.service ?? 0)
|
|
32
|
+
.reduce((a, b) => a + b, 0);
|
|
33
|
+
};
|
|
34
|
+
/**
|
|
35
|
+
* Returns the most relevant event for the job in its current state:
|
|
36
|
+
* the active `current_event` if one exists, otherwise the
|
|
37
|
+
* `last_completed_event`. Falls back to `null`.
|
|
38
|
+
*
|
|
39
|
+
* Most callers historically used `last_completed_event ?? current_event`
|
|
40
|
+
* which inverted the precedence — prefer the live event over a stale one.
|
|
41
|
+
*/
|
|
42
|
+
this.getRouteEvent = () => {
|
|
43
|
+
return this.current_event ?? this.last_completed_event ?? null;
|
|
44
|
+
};
|
|
24
45
|
this.portionDone = (route) => {
|
|
25
46
|
if (!this.tasks || this.tasks.length === 0) {
|
|
26
47
|
return 0;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { RouteJob } from '../../types/api/RouteJob';
|
|
2
|
+
import type { Job } from '../../types/api/Job';
|
|
2
3
|
export declare const FrequencyType: {
|
|
3
4
|
readonly ONCE: "ONCE";
|
|
4
5
|
readonly DAILY: "DAILY";
|
|
@@ -33,4 +34,16 @@ export declare class Route {
|
|
|
33
34
|
constructor(init?: Partial<Route>);
|
|
34
35
|
getNextOccurrences: (count?: number) => (Date | null)[];
|
|
35
36
|
getNextOccurrence: (after?: Date) => Date | null;
|
|
37
|
+
/**
|
|
38
|
+
* Computes how far ahead/behind schedule the route is, in seconds, based on
|
|
39
|
+
* stops that have already been completed. Positive = behind schedule,
|
|
40
|
+
* negative = ahead of schedule.
|
|
41
|
+
*
|
|
42
|
+
* The offset is taken from the most recently completed stop (largest
|
|
43
|
+
* `time_ended`), not the last stop in route order. This is what consumers
|
|
44
|
+
* want: "given the latest completed work, how off-schedule are we?".
|
|
45
|
+
*
|
|
46
|
+
* Returns `null` if no stop in this route has a usable completion event.
|
|
47
|
+
*/
|
|
48
|
+
getScheduleOffset: (jobs: Job[]) => number | null;
|
|
36
49
|
}
|
package/dist/types/api/Route.js
CHANGED
|
@@ -43,6 +43,38 @@ export class Route {
|
|
|
43
43
|
}
|
|
44
44
|
return next_occurrence;
|
|
45
45
|
};
|
|
46
|
+
/**
|
|
47
|
+
* Computes how far ahead/behind schedule the route is, in seconds, based on
|
|
48
|
+
* stops that have already been completed. Positive = behind schedule,
|
|
49
|
+
* negative = ahead of schedule.
|
|
50
|
+
*
|
|
51
|
+
* The offset is taken from the most recently completed stop (largest
|
|
52
|
+
* `time_ended`), not the last stop in route order. This is what consumers
|
|
53
|
+
* want: "given the latest completed work, how off-schedule are we?".
|
|
54
|
+
*
|
|
55
|
+
* Returns `null` if no stop in this route has a usable completion event.
|
|
56
|
+
*/
|
|
57
|
+
this.getScheduleOffset = (jobs) => {
|
|
58
|
+
let latestEnd = -Infinity;
|
|
59
|
+
let offset = null;
|
|
60
|
+
for (const rj of this.jobs) {
|
|
61
|
+
const job = jobs.find((j) => j.id === rj.job_id);
|
|
62
|
+
if (!job || !job.isDone(this))
|
|
63
|
+
continue;
|
|
64
|
+
// Offset semantics specifically want a *completed* event, not an
|
|
65
|
+
// in-progress one. Use last_completed_event directly rather than
|
|
66
|
+
// getRouteEvent() (which prefers current_event for display).
|
|
67
|
+
const actualEnd = job.last_completed_event?.time_ended;
|
|
68
|
+
if (typeof actualEnd !== 'number' || actualEnd <= 0)
|
|
69
|
+
continue;
|
|
70
|
+
if (actualEnd <= latestEnd)
|
|
71
|
+
continue;
|
|
72
|
+
const predictedCompletion = rj.getPredictedArrival() + job.getServiceDuration(rj);
|
|
73
|
+
latestEnd = actualEnd;
|
|
74
|
+
offset = actualEnd - predictedCompletion;
|
|
75
|
+
}
|
|
76
|
+
return offset;
|
|
77
|
+
};
|
|
46
78
|
Object.assign(this, init);
|
|
47
79
|
if (init?.jobs) {
|
|
48
80
|
this.jobs = init.jobs.map((j) => new RouteJob(j));
|
|
@@ -12,4 +12,11 @@ declare module '../../types/api/RouteJob' {
|
|
|
12
12
|
}
|
|
13
13
|
export declare class RouteJob {
|
|
14
14
|
constructor(init?: Partial<RouteJob>);
|
|
15
|
+
/**
|
|
16
|
+
* Returns the arrival time the UI should show for this stop:
|
|
17
|
+
* `scheduled_arrival_time` if set (>= 0), otherwise the optimizer's
|
|
18
|
+
* `estimated_arrival_time`. The sentinel value -1 means "no manual
|
|
19
|
+
* schedule, fall back to the optimizer estimate".
|
|
20
|
+
*/
|
|
21
|
+
getPredictedArrival: () => number;
|
|
15
22
|
}
|
|
@@ -1,5 +1,16 @@
|
|
|
1
1
|
export class RouteJob {
|
|
2
2
|
constructor(init) {
|
|
3
|
+
/**
|
|
4
|
+
* Returns the arrival time the UI should show for this stop:
|
|
5
|
+
* `scheduled_arrival_time` if set (>= 0), otherwise the optimizer's
|
|
6
|
+
* `estimated_arrival_time`. The sentinel value -1 means "no manual
|
|
7
|
+
* schedule, fall back to the optimizer estimate".
|
|
8
|
+
*/
|
|
9
|
+
this.getPredictedArrival = () => {
|
|
10
|
+
return this.scheduled_arrival_time === -1
|
|
11
|
+
? this.estimated_arrival_time
|
|
12
|
+
: this.scheduled_arrival_time;
|
|
13
|
+
};
|
|
3
14
|
Object.assign(this, init);
|
|
4
15
|
}
|
|
5
16
|
}
|
package/dist/types/api/Task.js
CHANGED
|
@@ -102,12 +102,15 @@ export class Task {
|
|
|
102
102
|
if (!this.status || this.status !== TaskStatus.ACTIVE) {
|
|
103
103
|
return true;
|
|
104
104
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
105
|
+
// Cutoff: midnight (local time) of the day route.start_time falls on.
|
|
106
|
+
// Counts work completed at any point during the route's day (including
|
|
107
|
+
// an early-start autostart that finishes a few minutes before
|
|
108
|
+
// route.start_time) while still rejecting stale events from previous
|
|
109
|
+
// occurrences. Applies to both recurring and one-shot tasks.
|
|
110
|
+
const routeDay = new Date(route.start_time * 1000);
|
|
111
|
+
routeDay.setHours(0, 0, 0, 0);
|
|
112
|
+
const cutoff = Math.floor(routeDay.getTime() / 1000);
|
|
113
|
+
return (this.last_completed_event?.time_ended ?? 0) >= cutoff;
|
|
111
114
|
};
|
|
112
115
|
Object.assign(this, init);
|
|
113
116
|
if (init?.prepayments) {
|