@openfn/language-varo 2.0.0 → 2.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/README.md CHANGED
@@ -239,9 +239,9 @@ openfn workflow.json -m --only convertToEms
239
239
 
240
240
  Some workflows required authorization to access the resources.
241
241
 
242
- ## Gmail token
242
+ ## Gmail access token
243
243
 
244
- Use Postman to retrieve an access token. This is a short-lived token will last 60 minutes and will have to be manually retrieved. See the documentation in the [Gmail adaptor readme](https://docs.openfn.org/adaptors/packages/gmail-readme#use-the-postman-application-to-query-the-oauth-enpoint-and-retrieve-an-access-token) for a guide on how to configure Postman to retrieve the access token.
244
+ An access token is required to access the Gmail API. This short-lived token will last 60 minutes and will have to be manually refreshed. See the documentation in the [Gmail adaptor readme](https://docs.openfn.org/adaptors/packages/gmail-readme#retrieve-an-access-token) for a guide on how to use Google Developers OAuth 2.0 Playground to retrieve and refresh the access token.
245
245
 
246
246
  ## OpenFn collections token
247
247
 
@@ -322,10 +322,94 @@ pnpm build
322
322
  pnpm run setup
323
323
  ```
324
324
 
325
- ### Switch to the working branch of the varo adaptor
325
+ ### Switch to a working branch of the varo adaptor
326
326
 
327
327
  ```
328
328
  cd openfn/adaptors/adaptors
329
- git checkout nhgh-varo
329
+ git checkout -b varo-enhancements
330
330
  ```
331
331
 
332
+ # FridgeTag `records` vs. `zReports`
333
+
334
+ ## Purpose
335
+
336
+ The FridgeTag parser (`parseFridgeTagToReport`) reads structured device output and emits two parallel data structures: `records` and `zReports`. This dual design allows strict compliance with EMS standards, while also preserving valuable out-of-spec information from the original FridgeTag device logs.
337
+
338
+ ## `records`: EMS-compliant daily extremes
339
+
340
+ FridgeTag source data provides daily minimum and maximum temperatures, including the exact timestamp each was observed. These data points are directly compatible with EMS requirements, which demand time-stamped records representing sensor readings.
341
+
342
+ For each day in the log, the parser generates:
343
+
344
+ - One EMS record for the minimum temperature, and
345
+ - One EMS record for the maximum temperature.
346
+
347
+ Each record includes:
348
+ - `ABST`: Absolute ISO-8601 timestamp (e.g., `"2024-10-08T08:15:00.000Z"`).
349
+ - `TVC`: Temperature value in °C (e.g., `18.5`).
350
+ - `ALRM`: Optional alarm flag (e.g., `"HEAT"` or `"FRZE"`), derived from alarm conditions.
351
+ - `zdescription`: A label for context (e.g., `"2024-10-08 Min T"`).
352
+
353
+ Example:
354
+ ```json
355
+ {
356
+ "ABST": "2024-10-08T08:15:00.000Z",
357
+ "TVC": 18.5,
358
+ "ALRM": null,
359
+ "zdescription": "2024-10-08 Min T"
360
+ }
361
+ ```
362
+
363
+ These entries fully conform to EMS specifications and can be directly integrated into compliant pipelines or summaries.
364
+
365
+ ## `zReports`: Out-of-spec aggregates & alarm summaries
366
+
367
+ In addition to min/max values, FridgeTag records include:
368
+
369
+ - A daily average temperature,
370
+ - Detailed alarm metadata: including duration of condition (`t Acc`), first alarm timestamp (`TS A`), and alarm count (`C A`).
371
+
372
+ This information is not compatible with EMS formats, which don't align with aggregated statistics and rich alarm metadata. However, this data remains operationally meaningful, especially for:
373
+
374
+ - 60-day summary reports,
375
+ - Country-level immunization program dashboards,
376
+ - Quick on-site reviews by technicians.
377
+
378
+ To retain this data without violating EMS constraints, the parser generates a `zReports` array. Each entry summarizes one day:
379
+
380
+ ```json
381
+ {
382
+ "date": "2024-10-08T00:00:00.000Z",
383
+ "duration": "1D",
384
+ "alarms": [
385
+ {
386
+ "condition": "HEAT",
387
+ "conditionMinutes": 840,
388
+ "alarmTime": "2024-10-08T00:00:00.000Z"
389
+ }
390
+ ],
391
+ "aggregates": [
392
+ {
393
+ "id": "TVC",
394
+ "min": 18.5,
395
+ "max": 21.2,
396
+ "average": 19.2
397
+ }
398
+ ]
399
+ }
400
+ ```
401
+
402
+ This structure is deliberately out-of-spec (hence the `z` prefix) and is ignored by EMS consumers. It is retained only for dashboards, exports, and enriched user-facing analytics.
403
+
404
+ ## Design philosophy
405
+
406
+ This split structure reflects a disciplined compromise between compliance and pragmatism:
407
+
408
+ - `records`: strictly EMS-compliant atomic data points.
409
+ - `zReports`: high-value extras that don't align with EMS, but still provide value.
410
+
411
+ This ensures that:
412
+
413
+ - Nothing is lost from the original FridgeTag data.
414
+ - Downstream EMS systems remain unaffected.
415
+ - Local insights and user-friendly summaries remain rich and actionable.
package/ast.json CHANGED
@@ -166,6 +166,124 @@
166
166
  ]
167
167
  },
168
168
  "valid": true
169
+ },
170
+ {
171
+ "name": "parseUtcForDataRange",
172
+ "params": [
173
+ "timeZone",
174
+ "startIso",
175
+ "endIso"
176
+ ],
177
+ "docs": {
178
+ "description": "Computes the UTC datetime range that corresponds to a given IANA timezone.",
179
+ "tags": [
180
+ {
181
+ "title": "public",
182
+ "description": null,
183
+ "type": null
184
+ },
185
+ {
186
+ "title": "function",
187
+ "description": null,
188
+ "name": null
189
+ },
190
+ {
191
+ "title": "param",
192
+ "description": "An IANA time zone identifier (e.g. \"America/Los_Angeles\").",
193
+ "type": {
194
+ "type": "NameExpression",
195
+ "name": "string"
196
+ },
197
+ "name": "timeZone"
198
+ },
199
+ {
200
+ "title": "param",
201
+ "description": "Starting date in ISO format.",
202
+ "type": {
203
+ "type": "NameExpression",
204
+ "name": "string"
205
+ },
206
+ "name": "startIso"
207
+ },
208
+ {
209
+ "title": "param",
210
+ "description": "Ending date range in ISO format.",
211
+ "type": {
212
+ "type": "NameExpression",
213
+ "name": "string"
214
+ },
215
+ "name": "endIso"
216
+ },
217
+ {
218
+ "title": "returns",
219
+ "description": null,
220
+ "type": {
221
+ "type": "NameExpression",
222
+ "name": "UtcRange"
223
+ }
224
+ }
225
+ ]
226
+ },
227
+ "valid": true
228
+ },
229
+ {
230
+ "name": "isKeyInRange",
231
+ "params": [
232
+ "key",
233
+ "start",
234
+ "end"
235
+ ],
236
+ "docs": {
237
+ "description": "Checks whether the timestamp embedded in a key falls within a UTC datetime range.",
238
+ "tags": [
239
+ {
240
+ "title": "public",
241
+ "description": null,
242
+ "type": null
243
+ },
244
+ {
245
+ "title": "function",
246
+ "description": null,
247
+ "name": null
248
+ },
249
+ {
250
+ "title": "param",
251
+ "description": "A string key containing a UTC timestamp in the format `YYYYMMDDTHHMMSS`, following a colon (e.g. \"prefix:20250624T101530\").",
252
+ "type": {
253
+ "type": "NameExpression",
254
+ "name": "string"
255
+ },
256
+ "name": "key"
257
+ },
258
+ {
259
+ "title": "param",
260
+ "description": "The inclusive lower bound of the UTC datetime range.",
261
+ "type": {
262
+ "type": "NameExpression",
263
+ "name": "Date"
264
+ },
265
+ "name": "start"
266
+ },
267
+ {
268
+ "title": "param",
269
+ "description": "The exclusive upper bound of the UTC datetime range.",
270
+ "type": {
271
+ "type": "NameExpression",
272
+ "name": "Date"
273
+ },
274
+ "name": "end"
275
+ },
276
+ {
277
+ "title": "returns",
278
+ "description": "True if the parsed UTC timestamp is within the range, false otherwise.",
279
+ "type": {
280
+ "type": "NameExpression",
281
+ "name": "boolean"
282
+ }
283
+ }
284
+ ]
285
+ },
286
+ "valid": true
169
287
  }
170
288
  ],
171
289
  "exports": [],
@@ -220,7 +338,7 @@
220
338
  "operation"
221
339
  ],
222
340
  "docs": {
223
- "description": "A custom operation that will only execute the function if the condition returns true",
341
+ "description": "Execute a function only when the condition returns true",
224
342
  "tags": [
225
343
  {
226
344
  "title": "public",
package/dist/index.cjs CHANGED
@@ -33,8 +33,10 @@ __export(src_exports, {
33
33
  fields: () => import_language_common2.fields,
34
34
  fn: () => import_language_common2.fn,
35
35
  fnIf: () => import_language_common2.fnIf,
36
+ isKeyInRange: () => isKeyInRange,
36
37
  lastReferenceValue: () => import_language_common2.lastReferenceValue,
37
38
  merge: () => import_language_common2.merge,
39
+ parseUtcForDataRange: () => parseUtcForDataRange,
38
40
  sourceValue: () => import_language_common2.sourceValue
39
41
  });
40
42
  module.exports = __toCommonJS(src_exports);
@@ -55,12 +57,15 @@ __export(Adaptor_exports, {
55
57
  fields: () => import_language_common2.fields,
56
58
  fn: () => import_language_common2.fn,
57
59
  fnIf: () => import_language_common2.fnIf,
60
+ isKeyInRange: () => isKeyInRange,
58
61
  lastReferenceValue: () => import_language_common2.lastReferenceValue,
59
62
  merge: () => import_language_common2.merge,
63
+ parseUtcForDataRange: () => parseUtcForDataRange,
60
64
  sourceValue: () => import_language_common2.sourceValue
61
65
  });
62
66
  var import_language_common = require("@openfn/language-common");
63
67
  var import_util = require("@openfn/language-common/util");
68
+ var import_luxon = require("luxon");
64
69
 
65
70
  // src/Utils.js
66
71
  function parseMetadata(message) {
@@ -79,7 +84,7 @@ function parseMetadata(message) {
79
84
  }
80
85
  function removeNullProps(obj) {
81
86
  for (const key in obj) {
82
- if (obj[key] == null) {
87
+ if (obj[key] === null) {
83
88
  delete obj[key];
84
89
  }
85
90
  }
@@ -126,7 +131,18 @@ function formatDeviceInfo(data) {
126
131
  output += formatType("Appliance", data.AMFR, data.AMOD, data.ASER);
127
132
  output += formatType("Logger", data.LMFR, data.LMOD, data.LSER);
128
133
  output += formatType("EMD", data.EMFR, data.EMOD, data.ESER);
129
- return output || "No valid data found";
134
+ return output || "Cannot determine device info; no valid data found.";
135
+ }
136
+ function abbreviatedIsoToDate(iso) {
137
+ const [year, month, day, hour, minute, second] = [
138
+ iso.slice(0, 4),
139
+ iso.slice(4, 6),
140
+ iso.slice(6, 8),
141
+ iso.slice(9, 11),
142
+ iso.slice(11, 13),
143
+ iso.slice(13, 15)
144
+ ];
145
+ return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
130
146
  }
131
147
 
132
148
  // src/StreamingUtils.js
@@ -202,7 +218,6 @@ function mergeRecords(records, groupKey) {
202
218
  }
203
219
  if (tambAlrm != null) {
204
220
  mergedRecord["zTambAlrm"] = tambAlrm;
205
- mergedRecord["ALRM"] = tambAlrm;
206
221
  }
207
222
  if (tvcAlrm != null) {
208
223
  mergedRecord["zTvcAlrm"] = tvcAlrm;
@@ -315,7 +330,7 @@ function promoteDeviceProperties(source, destination) {
315
330
  }
316
331
 
317
332
  // src/VaroEmsUtils.js
318
- function parseVaroEmsToReport(metadata, data, dataPath2) {
333
+ function parseVaroEmsToReport(metadata, data, rtcwMaps) {
319
334
  const report = {
320
335
  CID: null,
321
336
  LAT: metadata.location.used.latitude,
@@ -339,10 +354,14 @@ function parseVaroEmsToReport(metadata, data, dataPath2) {
339
354
  LSV: data.LSV,
340
355
  records: []
341
356
  };
342
- const deviceDate = parseRelativeDateFromUsbPluggedInfo(dataPath2);
357
+ removeNullProps(report);
343
358
  for (const item of data.records) {
344
- const durations = [item.RELT];
345
- const absoluteDate = parseAdjustedDate(deviceDate, durations);
359
+ const deviceDate = rtcwMaps.get(item.RTCW);
360
+ if (!deviceDate) {
361
+ report.zHasUnreconciledRtcw = true;
362
+ continue;
363
+ }
364
+ const absoluteDate = applyDurationToDate(deviceDate, item.RELT);
346
365
  const abst = parseIsoToAbbreviatedIso(absoluteDate);
347
366
  const record = {
348
367
  ABST: abst,
@@ -359,67 +378,82 @@ function parseVaroEmsToReport(metadata, data, dataPath2) {
359
378
  TAMB: item.TAMB,
360
379
  TVC: item.TVC
361
380
  };
381
+ removeNullProps(record);
362
382
  report.records.push(record);
363
383
  }
364
384
  return report;
365
385
  }
366
- function parseRelativeDateFromUsbPluggedInfo(path) {
367
- const regex = /_CURRENT_DATA_(?<duration>\w+?)_(?<date>\w+?)\.json$/;
368
- const match = path.match(regex);
369
- if (!match) {
370
- throw new Error(`Path format is incorrect: ${path}`);
386
+ function applyDurationToDate(incomingDate, duration, subtract = false) {
387
+ const date = normalizeIncomingDate(incomingDate);
388
+ const parsedDuration = parseDuration(duration, subtract);
389
+ if (!parsedDuration)
390
+ return date;
391
+ date.setUTCDate(date.getUTCDate() + parsedDuration.days);
392
+ date.setUTCHours(date.getUTCHours() + parsedDuration.hours);
393
+ date.setUTCMinutes(date.getUTCMinutes() + parsedDuration.minutes);
394
+ date.setUTCSeconds(date.getUTCSeconds() + parsedDuration.seconds);
395
+ return date;
396
+ }
397
+ function parseDuration(duration, subtract) {
398
+ const regex = /^P(?:(?<days>\d+)D)?(?:T(?:(?<hours>\d+)H)?(?:(?<minutes>\d+)M)?(?:(?<seconds>\d+)S)?)?$/;
399
+ const match = duration.match(regex);
400
+ if (!match)
401
+ throw new Error(`Invalid duration format: ${duration}`);
402
+ const m = subtract ? -1 : 1;
403
+ const days = +(match.groups.days || 0) * m;
404
+ const hours = +(match.groups.hours || 0) * m;
405
+ const minutes = +(match.groups.minutes || 0) * m;
406
+ const seconds = +(match.groups.seconds || 0) * m;
407
+ return days || hours || minutes || seconds ? { days, hours, minutes, seconds } : null;
408
+ }
409
+ function normalizeIncomingDate(incomingDate) {
410
+ let date = incomingDate;
411
+ if (typeof date === "string" && /^\d{8}T\d{6}Z$/.test(date)) {
412
+ date = parseAbbreviatedIsoToIso(date);
371
413
  }
372
- const usbPluggedInfo = {
373
- date: match.groups.date,
374
- duration: match.groups.duration
375
- };
376
- const isoDate = parseAbbreviatedIsoToIso(usbPluggedInfo.date);
377
- return parseAdjustedDate(isoDate, [usbPluggedInfo.duration], true);
378
- }
379
- function parseAdjustedDate(incomingDate, durations, subtract = false) {
380
- function parseDuration(duration) {
381
- const regex = /^P((?<days>\d+)D)?(T((?<hours>\d+)H)?((?<minutes>\d+)M)?((?<seconds>\d+)S)?)?$/;
382
- const match = duration.match(regex);
383
- if (!match) {
384
- throw new Error(`Invalid duration format: ${duration}`);
414
+ return new Date(date);
415
+ }
416
+ function parseAbbreviatedIsoToIso(abbrIso) {
417
+ const m = abbrIso.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
418
+ if (!m)
419
+ throw new Error(`Invalid abbreviated ISO date format: ${abbrIso}`);
420
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
421
+ }
422
+ function parseIsoToAbbreviatedIso(iso) {
423
+ return iso.toISOString().replace(/[\-\:]/g, "").replace(".000Z", "Z");
424
+ }
425
+ function buildDeviceRtcwDateMaps(contents) {
426
+ const dataContents = contents.filter((c) => c.data);
427
+ const deviceRtcwDateMaps = /* @__PURE__ */ new Map();
428
+ for (const content of dataContents) {
429
+ const { deviceId, deviceDate, finalRtcw } = extractDeviceData(content);
430
+ let rtcwDateMap = deviceRtcwDateMaps.get(deviceId);
431
+ if (!rtcwDateMap) {
432
+ rtcwDateMap = /* @__PURE__ */ new Map();
433
+ deviceRtcwDateMaps.set(deviceId, rtcwDateMap);
385
434
  }
386
- const multiplier = subtract ? -1 : 1;
387
- const days = parseInt(match.groups.days || 0, 10) * multiplier;
388
- const hours = parseInt(match.groups.hours || 0, 10) * multiplier;
389
- const minutes = parseInt(match.groups.minutes || 0, 10) * multiplier;
390
- const seconds = parseInt(match.groups.seconds || 0, 10) * multiplier;
391
- if (days === 0 && hours === 0 && minutes === 0 && seconds === 0) {
392
- return null;
435
+ const existingDate = rtcwDateMap.get(finalRtcw);
436
+ if (!existingDate || deviceDate < existingDate) {
437
+ rtcwDateMap.set(finalRtcw, deviceDate);
393
438
  }
394
- return { days, hours, minutes, seconds };
395
439
  }
396
- function applyDuration(date, duration) {
397
- date.setDate(date.getDate() + duration.days);
398
- date.setHours(date.getHours() + duration.hours);
399
- date.setMinutes(date.getMinutes() + duration.minutes);
400
- date.setSeconds(date.getSeconds() + duration.seconds);
401
- }
402
- const adjustedDate = new Date(incomingDate);
403
- for (const duration of durations) {
404
- if (!duration)
405
- continue;
406
- const parsedDuration = parseDuration(duration);
407
- if (!parsedDuration)
408
- continue;
409
- applyDuration(adjustedDate, parsedDuration);
410
- }
411
- return adjustedDate;
440
+ return deviceRtcwDateMaps;
412
441
  }
413
- function parseAbbreviatedIsoToIso(abbreviatedIso) {
414
- const regex = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
415
- const match = abbreviatedIso.match(regex);
416
- if (!match) {
417
- throw new Error(`Invalid abbreviated ISO date format: ${abbreviatedIso}`);
442
+ function extractDeviceData(content) {
443
+ const regex = /^([a-zA-Z0-9]+)_(?:.+)_([A-Z0-9]{4,15})_([0-9]{8}T[0-9]{6}Z)\.json$/;
444
+ const [, deviceId, deviceRelt, abbrUsbDate] = content.data.filename.match(regex);
445
+ const deviceDate = applyDurationToDate(abbrUsbDate, deviceRelt, true);
446
+ if (typeof content.data.content === "string") {
447
+ try {
448
+ content.data.content = JSON.parse(content.data.content);
449
+ } catch (e) {
450
+ console.error("Invalid JSON string in content.data.content:", e);
451
+ }
418
452
  }
419
- return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`;
420
- }
421
- function parseIsoToAbbreviatedIso(iso) {
422
- return iso.toISOString().replace(/[\-\:]/g, "").replace(".000Z", "Z");
453
+ content.zDeviceId = deviceId;
454
+ const data = content.data.content;
455
+ const finalRtcw = data.records[data.records.length - 1].RTCW;
456
+ return { deviceId, deviceDate, finalRtcw };
423
457
  }
424
458
 
425
459
  // src/FridgeTagUtils.js
@@ -479,7 +513,7 @@ function parseFridgeTagToReport(metadata, nodes) {
479
513
  ABST: dateTime,
480
514
  TVC: temp,
481
515
  ALRM: alarm,
482
- zdescription: date + " " + tempField
516
+ zDescription: date + " " + tempField
483
517
  });
484
518
  function parseAlarm(alarmNode, description) {
485
519
  if (alarmNode["t Acc"] === "0")
@@ -577,38 +611,46 @@ function parseFridgeTag(text) {
577
611
  var import_language_common2 = require("@openfn/language-common");
578
612
  function convertToEms(messageContents) {
579
613
  return async (state) => {
580
- var _a, _b;
581
614
  const [resolvedMessageContents] = (0, import_util.expandReferences)(state, messageContents);
582
615
  const reports = [];
583
- console.log("Incoming message contents", resolvedMessageContents.length);
584
- for (const content of resolvedMessageContents) {
585
- if ((_a = content.fridgeTag) == null ? void 0 : _a.content) {
586
- const metadata = parseMetadata(content);
587
- if (!metadata)
588
- continue;
589
- const fridgeTagNodes = parseFridgeTag(content.fridgeTag.content);
590
- const result = parseFridgeTagToReport(metadata, fridgeTagNodes);
591
- reports.push(result);
592
- continue;
593
- }
594
- if ((_b = content.data) == null ? void 0 : _b.content) {
595
- const metadata = parseMetadata(content);
596
- if (!metadata)
597
- continue;
598
- const data = JSON.parse(content.data.content);
599
- const dataPath2 = content.data.filename;
600
- const result = parseVaroEmsToReport(metadata, data, dataPath2);
601
- reports.push(result);
602
- continue;
603
- }
616
+ console.info("Incoming message contents", resolvedMessageContents.length);
617
+ processFridgeTagContents(resolvedMessageContents, reports);
618
+ processDataContents(resolvedMessageContents, reports);
619
+ for (const content of resolvedMessageContents.filter((c) => !c.zProcessed)) {
604
620
  console.error(
605
621
  `Insufficient content found for MessageID: ${content.messageId}`
606
622
  );
607
623
  }
608
- console.log("Converted message contents", reports.length);
624
+ console.info("Converted message contents", reports.length);
609
625
  return { ...(0, import_language_common.composeNextState)(state, reports) };
610
626
  };
611
627
  }
628
+ function processFridgeTagContents(contents, reports) {
629
+ const fridgeTagContents = contents.filter((c) => c.fridgeTag);
630
+ for (const content of fridgeTagContents) {
631
+ const metadata = parseMetadata(content);
632
+ if (!metadata)
633
+ continue;
634
+ const fridgeTagNodes = parseFridgeTag(content.fridgeTag.content);
635
+ const result = parseFridgeTagToReport(metadata, fridgeTagNodes);
636
+ reports.push(result);
637
+ content.zProcessed = true;
638
+ }
639
+ }
640
+ function processDataContents(contents, reports) {
641
+ const dataContents = contents.filter((c) => c.data);
642
+ const deviceRtcwDateMaps = buildDeviceRtcwDateMaps(dataContents);
643
+ for (const content of dataContents) {
644
+ const metadata = parseMetadata(content);
645
+ if (!metadata)
646
+ continue;
647
+ const data = content.data.content;
648
+ const rtcwMaps = deviceRtcwDateMaps.get(content.zDeviceId);
649
+ const result = parseVaroEmsToReport(metadata, data, rtcwMaps);
650
+ reports.push(result);
651
+ content.zProcessed = true;
652
+ }
653
+ }
612
654
  function convertItemsToReports(items, reportType = "unknown") {
613
655
  return async (state) => {
614
656
  const [resolvedRecords, resolvedReportType] = (0, import_util.expandReferences)(
@@ -636,7 +678,7 @@ function convertReportsToMessageContents(reports, reportType = "unknown") {
636
678
  reportType
637
679
  );
638
680
  const messageContents = [];
639
- for (const report of resolvedReports) {
681
+ for (const report of resolvedReports ?? []) {
640
682
  report["zReportType"] = resolvedReportType;
641
683
  report["zGeneratedTimestamp"] = new Date().toISOString();
642
684
  const serialNumber = report["ESER"] || report["LSER"] || report["ASER"];
@@ -653,6 +695,45 @@ function convertReportsToMessageContents(reports, reportType = "unknown") {
653
695
  return { ...(0, import_language_common.composeNextState)(state, messageContents) };
654
696
  };
655
697
  }
698
+ function parseUtcForDataRange(timeZone, startIso, endIso) {
699
+ const localNow = import_luxon.DateTime.now().setZone(timeZone);
700
+ const wallClock = new Date(
701
+ localNow.year,
702
+ localNow.month - 1,
703
+ localNow.day,
704
+ localNow.hour,
705
+ localNow.minute,
706
+ localNow.second,
707
+ localNow.millisecond
708
+ );
709
+ const startLocal = import_luxon.DateTime.fromISO(startIso, { zone: timeZone });
710
+ const endLocal = import_luxon.DateTime.fromISO(endIso, { zone: timeZone });
711
+ const startUtc = startLocal.toUTC().toJSDate();
712
+ const endUtc = endLocal.toUTC().toJSDate();
713
+ const daysDiff = Math.floor(endLocal.diff(startLocal, "days").days);
714
+ const collectionKeyPattern = "yyyyLLdd";
715
+ const collectionKeys = [];
716
+ for (let i = 0; i <= daysDiff; i++) {
717
+ const currentDay = startLocal.plus({ days: i });
718
+ collectionKeys.push(`*${currentDay.toFormat(collectionKeyPattern)}*`);
719
+ }
720
+ return {
721
+ wallClock,
722
+ startUtc,
723
+ endUtc,
724
+ collectionKeys
725
+ };
726
+ }
727
+ function isKeyInRange(key, start, end) {
728
+ console.error(
729
+ "i changed this implementation and need to verify the scenarios."
730
+ );
731
+ const iso = key == null ? void 0 : key.split(":")[1];
732
+ if (!iso || iso.length < 15)
733
+ return false;
734
+ const date = abbreviatedIsoToDate(iso);
735
+ return date >= start && date < end;
736
+ }
656
737
 
657
738
  // src/index.js
658
739
  var src_default = Adaptor_exports;
@@ -671,7 +752,9 @@ var src_default = Adaptor_exports;
671
752
  fields,
672
753
  fn,
673
754
  fnIf,
755
+ isKeyInRange,
674
756
  lastReferenceValue,
675
757
  merge,
758
+ parseUtcForDataRange,
676
759
  sourceValue
677
760
  });
package/dist/index.js CHANGED
@@ -20,12 +20,15 @@ __export(Adaptor_exports, {
20
20
  fields: () => fields,
21
21
  fn: () => fn,
22
22
  fnIf: () => fnIf,
23
+ isKeyInRange: () => isKeyInRange,
23
24
  lastReferenceValue: () => lastReferenceValue,
24
25
  merge: () => merge,
26
+ parseUtcForDataRange: () => parseUtcForDataRange,
25
27
  sourceValue: () => sourceValue
26
28
  });
27
29
  import { composeNextState } from "@openfn/language-common";
28
30
  import { expandReferences } from "@openfn/language-common/util";
31
+ import { DateTime } from "luxon";
29
32
 
30
33
  // src/Utils.js
31
34
  function parseMetadata(message) {
@@ -44,7 +47,7 @@ function parseMetadata(message) {
44
47
  }
45
48
  function removeNullProps(obj) {
46
49
  for (const key in obj) {
47
- if (obj[key] == null) {
50
+ if (obj[key] === null) {
48
51
  delete obj[key];
49
52
  }
50
53
  }
@@ -91,7 +94,18 @@ function formatDeviceInfo(data) {
91
94
  output += formatType("Appliance", data.AMFR, data.AMOD, data.ASER);
92
95
  output += formatType("Logger", data.LMFR, data.LMOD, data.LSER);
93
96
  output += formatType("EMD", data.EMFR, data.EMOD, data.ESER);
94
- return output || "No valid data found";
97
+ return output || "Cannot determine device info; no valid data found.";
98
+ }
99
+ function abbreviatedIsoToDate(iso) {
100
+ const [year, month, day, hour, minute, second] = [
101
+ iso.slice(0, 4),
102
+ iso.slice(4, 6),
103
+ iso.slice(6, 8),
104
+ iso.slice(9, 11),
105
+ iso.slice(11, 13),
106
+ iso.slice(13, 15)
107
+ ];
108
+ return new Date(`${year}-${month}-${day}T${hour}:${minute}:${second}Z`);
95
109
  }
96
110
 
97
111
  // src/StreamingUtils.js
@@ -167,7 +181,6 @@ function mergeRecords(records, groupKey) {
167
181
  }
168
182
  if (tambAlrm != null) {
169
183
  mergedRecord["zTambAlrm"] = tambAlrm;
170
- mergedRecord["ALRM"] = tambAlrm;
171
184
  }
172
185
  if (tvcAlrm != null) {
173
186
  mergedRecord["zTvcAlrm"] = tvcAlrm;
@@ -280,7 +293,7 @@ function promoteDeviceProperties(source, destination) {
280
293
  }
281
294
 
282
295
  // src/VaroEmsUtils.js
283
- function parseVaroEmsToReport(metadata, data, dataPath2) {
296
+ function parseVaroEmsToReport(metadata, data, rtcwMaps) {
284
297
  const report = {
285
298
  CID: null,
286
299
  LAT: metadata.location.used.latitude,
@@ -304,10 +317,14 @@ function parseVaroEmsToReport(metadata, data, dataPath2) {
304
317
  LSV: data.LSV,
305
318
  records: []
306
319
  };
307
- const deviceDate = parseRelativeDateFromUsbPluggedInfo(dataPath2);
320
+ removeNullProps(report);
308
321
  for (const item of data.records) {
309
- const durations = [item.RELT];
310
- const absoluteDate = parseAdjustedDate(deviceDate, durations);
322
+ const deviceDate = rtcwMaps.get(item.RTCW);
323
+ if (!deviceDate) {
324
+ report.zHasUnreconciledRtcw = true;
325
+ continue;
326
+ }
327
+ const absoluteDate = applyDurationToDate(deviceDate, item.RELT);
311
328
  const abst = parseIsoToAbbreviatedIso(absoluteDate);
312
329
  const record = {
313
330
  ABST: abst,
@@ -324,67 +341,82 @@ function parseVaroEmsToReport(metadata, data, dataPath2) {
324
341
  TAMB: item.TAMB,
325
342
  TVC: item.TVC
326
343
  };
344
+ removeNullProps(record);
327
345
  report.records.push(record);
328
346
  }
329
347
  return report;
330
348
  }
331
- function parseRelativeDateFromUsbPluggedInfo(path) {
332
- const regex = /_CURRENT_DATA_(?<duration>\w+?)_(?<date>\w+?)\.json$/;
333
- const match = path.match(regex);
334
- if (!match) {
335
- throw new Error(`Path format is incorrect: ${path}`);
349
+ function applyDurationToDate(incomingDate, duration, subtract = false) {
350
+ const date = normalizeIncomingDate(incomingDate);
351
+ const parsedDuration = parseDuration(duration, subtract);
352
+ if (!parsedDuration)
353
+ return date;
354
+ date.setUTCDate(date.getUTCDate() + parsedDuration.days);
355
+ date.setUTCHours(date.getUTCHours() + parsedDuration.hours);
356
+ date.setUTCMinutes(date.getUTCMinutes() + parsedDuration.minutes);
357
+ date.setUTCSeconds(date.getUTCSeconds() + parsedDuration.seconds);
358
+ return date;
359
+ }
360
+ function parseDuration(duration, subtract) {
361
+ const regex = /^P(?:(?<days>\d+)D)?(?:T(?:(?<hours>\d+)H)?(?:(?<minutes>\d+)M)?(?:(?<seconds>\d+)S)?)?$/;
362
+ const match = duration.match(regex);
363
+ if (!match)
364
+ throw new Error(`Invalid duration format: ${duration}`);
365
+ const m = subtract ? -1 : 1;
366
+ const days = +(match.groups.days || 0) * m;
367
+ const hours = +(match.groups.hours || 0) * m;
368
+ const minutes = +(match.groups.minutes || 0) * m;
369
+ const seconds = +(match.groups.seconds || 0) * m;
370
+ return days || hours || minutes || seconds ? { days, hours, minutes, seconds } : null;
371
+ }
372
+ function normalizeIncomingDate(incomingDate) {
373
+ let date = incomingDate;
374
+ if (typeof date === "string" && /^\d{8}T\d{6}Z$/.test(date)) {
375
+ date = parseAbbreviatedIsoToIso(date);
336
376
  }
337
- const usbPluggedInfo = {
338
- date: match.groups.date,
339
- duration: match.groups.duration
340
- };
341
- const isoDate = parseAbbreviatedIsoToIso(usbPluggedInfo.date);
342
- return parseAdjustedDate(isoDate, [usbPluggedInfo.duration], true);
343
- }
344
- function parseAdjustedDate(incomingDate, durations, subtract = false) {
345
- function parseDuration(duration) {
346
- const regex = /^P((?<days>\d+)D)?(T((?<hours>\d+)H)?((?<minutes>\d+)M)?((?<seconds>\d+)S)?)?$/;
347
- const match = duration.match(regex);
348
- if (!match) {
349
- throw new Error(`Invalid duration format: ${duration}`);
377
+ return new Date(date);
378
+ }
379
+ function parseAbbreviatedIsoToIso(abbrIso) {
380
+ const m = abbrIso.match(/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/);
381
+ if (!m)
382
+ throw new Error(`Invalid abbreviated ISO date format: ${abbrIso}`);
383
+ return `${m[1]}-${m[2]}-${m[3]}T${m[4]}:${m[5]}:${m[6]}Z`;
384
+ }
385
+ function parseIsoToAbbreviatedIso(iso) {
386
+ return iso.toISOString().replace(/[\-\:]/g, "").replace(".000Z", "Z");
387
+ }
388
+ function buildDeviceRtcwDateMaps(contents) {
389
+ const dataContents = contents.filter((c) => c.data);
390
+ const deviceRtcwDateMaps = /* @__PURE__ */ new Map();
391
+ for (const content of dataContents) {
392
+ const { deviceId, deviceDate, finalRtcw } = extractDeviceData(content);
393
+ let rtcwDateMap = deviceRtcwDateMaps.get(deviceId);
394
+ if (!rtcwDateMap) {
395
+ rtcwDateMap = /* @__PURE__ */ new Map();
396
+ deviceRtcwDateMaps.set(deviceId, rtcwDateMap);
350
397
  }
351
- const multiplier = subtract ? -1 : 1;
352
- const days = parseInt(match.groups.days || 0, 10) * multiplier;
353
- const hours = parseInt(match.groups.hours || 0, 10) * multiplier;
354
- const minutes = parseInt(match.groups.minutes || 0, 10) * multiplier;
355
- const seconds = parseInt(match.groups.seconds || 0, 10) * multiplier;
356
- if (days === 0 && hours === 0 && minutes === 0 && seconds === 0) {
357
- return null;
398
+ const existingDate = rtcwDateMap.get(finalRtcw);
399
+ if (!existingDate || deviceDate < existingDate) {
400
+ rtcwDateMap.set(finalRtcw, deviceDate);
358
401
  }
359
- return { days, hours, minutes, seconds };
360
402
  }
361
- function applyDuration(date, duration) {
362
- date.setDate(date.getDate() + duration.days);
363
- date.setHours(date.getHours() + duration.hours);
364
- date.setMinutes(date.getMinutes() + duration.minutes);
365
- date.setSeconds(date.getSeconds() + duration.seconds);
366
- }
367
- const adjustedDate = new Date(incomingDate);
368
- for (const duration of durations) {
369
- if (!duration)
370
- continue;
371
- const parsedDuration = parseDuration(duration);
372
- if (!parsedDuration)
373
- continue;
374
- applyDuration(adjustedDate, parsedDuration);
375
- }
376
- return adjustedDate;
403
+ return deviceRtcwDateMaps;
377
404
  }
378
- function parseAbbreviatedIsoToIso(abbreviatedIso) {
379
- const regex = /^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z$/;
380
- const match = abbreviatedIso.match(regex);
381
- if (!match) {
382
- throw new Error(`Invalid abbreviated ISO date format: ${abbreviatedIso}`);
405
+ function extractDeviceData(content) {
406
+ const regex = /^([a-zA-Z0-9]+)_(?:.+)_([A-Z0-9]{4,15})_([0-9]{8}T[0-9]{6}Z)\.json$/;
407
+ const [, deviceId, deviceRelt, abbrUsbDate] = content.data.filename.match(regex);
408
+ const deviceDate = applyDurationToDate(abbrUsbDate, deviceRelt, true);
409
+ if (typeof content.data.content === "string") {
410
+ try {
411
+ content.data.content = JSON.parse(content.data.content);
412
+ } catch (e) {
413
+ console.error("Invalid JSON string in content.data.content:", e);
414
+ }
383
415
  }
384
- return `${match[1]}-${match[2]}-${match[3]}T${match[4]}:${match[5]}:${match[6]}Z`;
385
- }
386
- function parseIsoToAbbreviatedIso(iso) {
387
- return iso.toISOString().replace(/[\-\:]/g, "").replace(".000Z", "Z");
416
+ content.zDeviceId = deviceId;
417
+ const data = content.data.content;
418
+ const finalRtcw = data.records[data.records.length - 1].RTCW;
419
+ return { deviceId, deviceDate, finalRtcw };
388
420
  }
389
421
 
390
422
  // src/FridgeTagUtils.js
@@ -444,7 +476,7 @@ function parseFridgeTagToReport(metadata, nodes) {
444
476
  ABST: dateTime,
445
477
  TVC: temp,
446
478
  ALRM: alarm,
447
- zdescription: date + " " + tempField
479
+ zDescription: date + " " + tempField
448
480
  });
449
481
  function parseAlarm(alarmNode, description) {
450
482
  if (alarmNode["t Acc"] === "0")
@@ -556,38 +588,46 @@ import {
556
588
  } from "@openfn/language-common";
557
589
  function convertToEms(messageContents) {
558
590
  return async (state) => {
559
- var _a, _b;
560
591
  const [resolvedMessageContents] = expandReferences(state, messageContents);
561
592
  const reports = [];
562
- console.log("Incoming message contents", resolvedMessageContents.length);
563
- for (const content of resolvedMessageContents) {
564
- if ((_a = content.fridgeTag) == null ? void 0 : _a.content) {
565
- const metadata = parseMetadata(content);
566
- if (!metadata)
567
- continue;
568
- const fridgeTagNodes = parseFridgeTag(content.fridgeTag.content);
569
- const result = parseFridgeTagToReport(metadata, fridgeTagNodes);
570
- reports.push(result);
571
- continue;
572
- }
573
- if ((_b = content.data) == null ? void 0 : _b.content) {
574
- const metadata = parseMetadata(content);
575
- if (!metadata)
576
- continue;
577
- const data = JSON.parse(content.data.content);
578
- const dataPath2 = content.data.filename;
579
- const result = parseVaroEmsToReport(metadata, data, dataPath2);
580
- reports.push(result);
581
- continue;
582
- }
593
+ console.info("Incoming message contents", resolvedMessageContents.length);
594
+ processFridgeTagContents(resolvedMessageContents, reports);
595
+ processDataContents(resolvedMessageContents, reports);
596
+ for (const content of resolvedMessageContents.filter((c) => !c.zProcessed)) {
583
597
  console.error(
584
598
  `Insufficient content found for MessageID: ${content.messageId}`
585
599
  );
586
600
  }
587
- console.log("Converted message contents", reports.length);
601
+ console.info("Converted message contents", reports.length);
588
602
  return { ...composeNextState(state, reports) };
589
603
  };
590
604
  }
605
+ function processFridgeTagContents(contents, reports) {
606
+ const fridgeTagContents = contents.filter((c) => c.fridgeTag);
607
+ for (const content of fridgeTagContents) {
608
+ const metadata = parseMetadata(content);
609
+ if (!metadata)
610
+ continue;
611
+ const fridgeTagNodes = parseFridgeTag(content.fridgeTag.content);
612
+ const result = parseFridgeTagToReport(metadata, fridgeTagNodes);
613
+ reports.push(result);
614
+ content.zProcessed = true;
615
+ }
616
+ }
617
+ function processDataContents(contents, reports) {
618
+ const dataContents = contents.filter((c) => c.data);
619
+ const deviceRtcwDateMaps = buildDeviceRtcwDateMaps(dataContents);
620
+ for (const content of dataContents) {
621
+ const metadata = parseMetadata(content);
622
+ if (!metadata)
623
+ continue;
624
+ const data = content.data.content;
625
+ const rtcwMaps = deviceRtcwDateMaps.get(content.zDeviceId);
626
+ const result = parseVaroEmsToReport(metadata, data, rtcwMaps);
627
+ reports.push(result);
628
+ content.zProcessed = true;
629
+ }
630
+ }
591
631
  function convertItemsToReports(items, reportType = "unknown") {
592
632
  return async (state) => {
593
633
  const [resolvedRecords, resolvedReportType] = expandReferences(
@@ -615,7 +655,7 @@ function convertReportsToMessageContents(reports, reportType = "unknown") {
615
655
  reportType
616
656
  );
617
657
  const messageContents = [];
618
- for (const report of resolvedReports) {
658
+ for (const report of resolvedReports ?? []) {
619
659
  report["zReportType"] = resolvedReportType;
620
660
  report["zGeneratedTimestamp"] = new Date().toISOString();
621
661
  const serialNumber = report["ESER"] || report["LSER"] || report["ASER"];
@@ -632,6 +672,45 @@ function convertReportsToMessageContents(reports, reportType = "unknown") {
632
672
  return { ...composeNextState(state, messageContents) };
633
673
  };
634
674
  }
675
+ function parseUtcForDataRange(timeZone, startIso, endIso) {
676
+ const localNow = DateTime.now().setZone(timeZone);
677
+ const wallClock = new Date(
678
+ localNow.year,
679
+ localNow.month - 1,
680
+ localNow.day,
681
+ localNow.hour,
682
+ localNow.minute,
683
+ localNow.second,
684
+ localNow.millisecond
685
+ );
686
+ const startLocal = DateTime.fromISO(startIso, { zone: timeZone });
687
+ const endLocal = DateTime.fromISO(endIso, { zone: timeZone });
688
+ const startUtc = startLocal.toUTC().toJSDate();
689
+ const endUtc = endLocal.toUTC().toJSDate();
690
+ const daysDiff = Math.floor(endLocal.diff(startLocal, "days").days);
691
+ const collectionKeyPattern = "yyyyLLdd";
692
+ const collectionKeys = [];
693
+ for (let i = 0; i <= daysDiff; i++) {
694
+ const currentDay = startLocal.plus({ days: i });
695
+ collectionKeys.push(`*${currentDay.toFormat(collectionKeyPattern)}*`);
696
+ }
697
+ return {
698
+ wallClock,
699
+ startUtc,
700
+ endUtc,
701
+ collectionKeys
702
+ };
703
+ }
704
+ function isKeyInRange(key, start, end) {
705
+ console.error(
706
+ "i changed this implementation and need to verify the scenarios."
707
+ );
708
+ const iso = key == null ? void 0 : key.split(":")[1];
709
+ if (!iso || iso.length < 15)
710
+ return false;
711
+ const date = abbreviatedIsoToDate(iso);
712
+ return date >= start && date < end;
713
+ }
635
714
 
636
715
  // src/index.js
637
716
  var src_default = Adaptor_exports;
@@ -650,7 +729,9 @@ export {
650
729
  fields,
651
730
  fn,
652
731
  fnIf,
732
+ isKeyInRange,
653
733
  lastReferenceValue,
654
734
  merge,
735
+ parseUtcForDataRange,
655
736
  sourceValue
656
737
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@openfn/language-varo",
3
3
  "label": "Varo",
4
- "version": "2.0.0",
4
+ "version": "2.1.1",
5
5
  "description": "OpenFn varo adaptor for cold chain information",
6
6
  "type": "module",
7
7
  "exports": {
@@ -21,7 +21,8 @@
21
21
  "configuration-schema.json"
22
22
  ],
23
23
  "dependencies": {
24
- "@openfn/language-common": "3.0.2"
24
+ "luxon": "^3.6.1",
25
+ "@openfn/language-common": "3.0.3"
25
26
  },
26
27
  "devDependencies": {
27
28
  "assertion-error": "2.0.0",
@@ -57,4 +57,50 @@ export function convertItemsToReports(items: any[], reportType?: string): Operat
57
57
  * convertReportsToMessageContents(emsReports, "ems");
58
58
  */
59
59
  export function convertReportsToMessageContents(reports: any, reportType?: string): Function;
60
+ /**
61
+ * @typedef {Object} UtcRange
62
+ * @property {Date} wallClock - The current local datetime as it appears on the wall in the specified timezone.
63
+ * @property {Date} startUtc - UTC start date range (inclusive).
64
+ * @property {Date} endUtc - UTC end of date range (exclusive).
65
+ * @property {Array} collectionKeys - Array of wildcard patterns to match UTC dates which correspond with date range (e.g. "*20250624*").
66
+ */
67
+ /**
68
+ * Computes the UTC datetime range that corresponds to a given IANA timezone.
69
+ * @public
70
+ * @function
71
+ * @param {string} timeZone - An IANA time zone identifier (e.g. "America/Los_Angeles").
72
+ * @param {string} startIso - Starting date in ISO format.
73
+ * @param {string} endIso - Ending date range in ISO format.
74
+ * @returns {UtcRange}
75
+ */
76
+ export function parseUtcForDataRange(timeZone: string, startIso: string, endIso: string): UtcRange;
77
+ /**
78
+ * Checks whether the timestamp embedded in a key falls within a UTC datetime range.
79
+ *
80
+ * @public
81
+ * @function
82
+ * @param {string} key - A string key containing a UTC timestamp in the format `YYYYMMDDTHHMMSS`, following a colon (e.g. "prefix:20250624T101530").
83
+ * @param {Date} start - The inclusive lower bound of the UTC datetime range.
84
+ * @param {Date} end - The exclusive upper bound of the UTC datetime range.
85
+ * @returns {boolean} True if the parsed UTC timestamp is within the range, false otherwise.
86
+ */
87
+ export function isKeyInRange(key: string, start: Date, end: Date): boolean;
88
+ export type UtcRange = {
89
+ /**
90
+ * - The current local datetime as it appears on the wall in the specified timezone.
91
+ */
92
+ wallClock: Date;
93
+ /**
94
+ * - UTC start date range (inclusive).
95
+ */
96
+ startUtc: Date;
97
+ /**
98
+ * - UTC end of date range (exclusive).
99
+ */
100
+ endUtc: Date;
101
+ /**
102
+ * - Array of wildcard patterns to match UTC dates which correspond with date range (e.g. "*20250624*").
103
+ */
104
+ collectionKeys: any[];
105
+ };
60
106
  export { alterState, combine, cursor, dataPath, dataValue, each, field, fields, fn, fnIf, lastReferenceValue, merge, sourceValue } from "@openfn/language-common";
package/types/Utils.d.ts CHANGED
@@ -2,3 +2,4 @@ export function parseMetadata(message: any): any;
2
2
  export function removeNullProps(obj: any): any;
3
3
  export function deepEqual(a: any, b: any): boolean;
4
4
  export function formatDeviceInfo(data: any): string;
5
+ export function abbreviatedIsoToDate(iso: any): Date;
@@ -1,4 +1,4 @@
1
- export function parseVaroEmsToReport(metadata: any, data: any, dataPath: any): {
1
+ export function parseVaroEmsToReport(metadata: any, data: any, rtcwMaps: any): {
2
2
  CID: any;
3
3
  LAT: any;
4
4
  LNG: any;
@@ -21,3 +21,10 @@ export function parseVaroEmsToReport(metadata: any, data: any, dataPath: any): {
21
21
  LSV: any;
22
22
  records: any[];
23
23
  };
24
+ export function applyDurationToDate(incomingDate: any, duration: any, subtract?: boolean): Date;
25
+ export function buildDeviceRtcwDateMaps(contents: any): Map<any, any>;
26
+ export function extractDeviceData(content: any): {
27
+ deviceId: any;
28
+ deviceDate: Date;
29
+ finalRtcw: any;
30
+ };