@lightdash/cli 3.163.1 → 3.164.0

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.
@@ -608,6 +608,8 @@ function RevenueTable() {
608
608
 
609
609
  ### Formatting
610
610
 
611
+ **Trust Lightdash's values for date/time display — never re-convert them.** Date and timestamp values arrive as the correct wall-clock reading with a `Z` suffix (e.g. a March 1st date is `'2025-03-01T00:00:00Z'`). Rendering them through anything timezone-aware — `new Date(value).toLocaleDateString()`, `Intl.DateTimeFormat`, `date-fns-tz`, or manual offset math — shifts them into the viewer's local timezone, so a viewer behind UTC sees Feb 28 where Lightdash shows Mar 1. Always display dates via `format(row, fieldName)` or the `@/lib/format` helpers below (they render the server's wall-clock components as-is); when formatting manually, format the date's own Y/M/D components, never a locale/timezone conversion of them.
612
+
611
613
  Every Lightdash row carries two views of each value:
612
614
 
613
615
  - `row[fieldName]` — the **raw** value. Numbers are real numbers; **dates and timestamps are full ISO strings** (e.g. `'2025-03-01T00:00:00Z'`) regardless of the truncation grain. Pass these to charts as data, not as axis labels.
@@ -636,17 +638,15 @@ import { formatField, formatDate, formatNumber, getColumn } from '@/lib/format';
636
638
  - number — server-formatted (currency / % / suffix preserved via the SDK `format()` you pass in)
637
639
  - timestamp without grain — `Jun 16, 2025 14:00`
638
640
 
639
- **Override** by passing `opts.pattern` to `formatDate`, or by formatting yourself with `date-fns`/`Intl.NumberFormat`:
641
+ **Override** by passing `opts.pattern` to `formatDate` — this keeps the server's wall-clock reading while changing the pattern:
640
642
 
641
643
  ```tsx
642
- import { format as formatDateFns, parseISO } from 'date-fns';
643
-
644
644
  formatDate(row.order_date_month, getColumn(columns, 'order_date_month'), 'axis', { pattern: 'MMM yyyy' });
645
-
646
- // Or fully manual:
647
- formatDateFns(parseISO(row.order_date as string), 'EEEE, MMM d');
645
+ formatDate(row.order_date, undefined, 'cell', { pattern: 'EEEE, MMM d' });
648
646
  ```
649
647
 
648
+ Avoid `parseISO`/`new Date` + `format` from `date-fns` for display: the `Z` suffix makes them shift the value into the viewer's timezone, moving midnight dates to the previous day for viewers behind UTC.
649
+
650
650
  #### Chart axes
651
651
 
652
652
  **Every `<XAxis>` and `<YAxis>` in the app must have a `tickFormatter`.** No exceptions — including year axes that "look like they'd be fine" (`order_date_year` is still a full ISO timestamp at the data layer; Recharts will render `2025-01-01T00:00:00Z`, not `2025`).
@@ -1379,6 +1379,7 @@ The action-menu example above shows typical `drillDown()` usage. For the full AP
1379
1379
  | Skipping `tickFormatter` on a year axis because "year is just a number" | Year dimensions (`*_year`) are still full ISO timestamps at the data layer, so the axis renders `2025-01-01T00:00:00Z` | Apply the same `formatDate(...)` `tickFormatter` to year axes — `formatDate` will collapse to `2025` for year-grain columns |
1380
1380
  | Using `format(row, 'order_date')` for a date column in a table cell | Renders `2025-03-17` (zero-padded tabular) — readable but ugly in dashboards | `formatField(row, col, format, 'cell')` from `@/lib/format` — renders `Mar 17, 2025` while still routing currency/% metrics through the server format |
1381
1381
  | Treating `row.order_date_month` as a `Date` or short string | It's a full ISO timestamp string (`2025-03-01T00:00:00Z`) for every grain | Pass through `formatDate` / `formatField`, or `parseISO` it before doing date math |
1382
+ | Displaying dates via `toLocaleDateString()`, `Intl.DateTimeFormat`, or `parseISO` + `format` | The `Z` suffix makes these convert to the viewer's timezone — viewers behind UTC see the previous day (e.g. Feb 28 instead of Mar 1) | Render through `format(row, field)` or `@/lib/format` helpers; they display the server's wall-clock value as-is |
1382
1383
  | Wrong `.parameters()` key scope | Value silently ignored — query returns the default | Project-level → bare name (`{ region }`); model-level → `model.param` (`{ 'orders.region' }`), matching the `${lightdash.parameters.…}` reference |
1383
1384
  | Inventing a parameter not declared in the YAML | Backend ignores unknown params; nothing changes | Only pass parameters that exist in a model's `meta.parameters` or `lightdash.config.yml`. Use `.filters()` for row restriction instead |
1384
1385
  | Applying `.parameters()` at module scope for a UI-driven value | Value never updates when the control changes | Apply `.parameters()` in a `useMemo` keyed on the state value; keep the base query at module scope |
@@ -1,5 +1,5 @@
1
- import { format as formatDateFns, parseISO } from 'date-fns';
2
1
  import type { Column, FormatFunction, Row } from '@lightdash/query-sdk';
2
+ import { format as formatDateFns, parseISO } from 'date-fns';
3
3
 
4
4
  export type FormatVariant = 'cell' | 'axis';
5
5
 
@@ -21,6 +21,93 @@ function inferGranularity(name: string): DateGranularity | undefined {
21
21
  return undefined;
22
22
  }
23
23
 
24
+ // Lightdash returns dates/timestamps as the project's wall-clock reading with
25
+ // a `Z` suffix (e.g. a March 1st date arrives as '2025-03-01T00:00:00Z').
26
+ // Parsing that with `parseISO`/`new Date` and formatting locally shifts it
27
+ // into the viewer's timezone — a UTC-7 viewer would see "Feb 28". Render the
28
+ // wall-clock components exactly as the server sent them instead.
29
+ const ISO_WALL_CLOCK_RE =
30
+ /^(\d{4})-(\d{2})-(\d{2})(?:[T ](\d{2}):(\d{2})(?::(\d{2})(?:\.(\d{1,3}))?)?)?/;
31
+
32
+ /**
33
+ * A Date whose local-time getters return UTC components, so date-fns `format`
34
+ * renders the stored wall-clock reading in every viewer timezone. A plain
35
+ * `new Date(y, m, d, h, …)` is not enough: the constructor interprets the
36
+ * components in the viewer's timezone and normalizes local times that don't
37
+ * exist there (a DST spring-forward gap turns 02:30 into 03:30). date-fns
38
+ * clones inputs via their own constructor, so the subclass survives `format`.
39
+ * Same mechanism as @date-fns/utc's UTCDate, without the dependency.
40
+ */
41
+ class WallClockDate extends Date {
42
+ getFullYear() {
43
+ return this.getUTCFullYear();
44
+ }
45
+
46
+ getMonth() {
47
+ return this.getUTCMonth();
48
+ }
49
+
50
+ getDate() {
51
+ return this.getUTCDate();
52
+ }
53
+
54
+ getDay() {
55
+ return this.getUTCDay();
56
+ }
57
+
58
+ getHours() {
59
+ return this.getUTCHours();
60
+ }
61
+
62
+ getMinutes() {
63
+ return this.getUTCMinutes();
64
+ }
65
+
66
+ getSeconds() {
67
+ return this.getUTCSeconds();
68
+ }
69
+
70
+ getMilliseconds() {
71
+ return this.getUTCMilliseconds();
72
+ }
73
+
74
+ getTimezoneOffset() {
75
+ return 0;
76
+ }
77
+
78
+ // Setters must match the getters: date-fns week tokens (II, ww, RRRR)
79
+ // compute via startOfWeek, which mutates clones with setDate/setHours.
80
+ // Inheriting the local setters would mix UTC reads with local writes and
81
+ // land on the wrong ISO week in non-UTC viewer timezones.
82
+ setFullYear(...args: Parameters<Date['setFullYear']>) {
83
+ return this.setUTCFullYear(...args);
84
+ }
85
+
86
+ setMonth(...args: Parameters<Date['setMonth']>) {
87
+ return this.setUTCMonth(...args);
88
+ }
89
+
90
+ setDate(day: number) {
91
+ return this.setUTCDate(day);
92
+ }
93
+
94
+ setHours(...args: Parameters<Date['setHours']>) {
95
+ return this.setUTCHours(...args);
96
+ }
97
+
98
+ setMinutes(...args: Parameters<Date['setMinutes']>) {
99
+ return this.setUTCMinutes(...args);
100
+ }
101
+
102
+ setSeconds(...args: Parameters<Date['setSeconds']>) {
103
+ return this.setUTCSeconds(...args);
104
+ }
105
+
106
+ setMilliseconds(ms: number) {
107
+ return this.setUTCMilliseconds(ms);
108
+ }
109
+ }
110
+
24
111
  function toDate(value: unknown): Date | null {
25
112
  if (value === null || value === undefined || value === '') return null;
26
113
  if (value instanceof Date)
@@ -30,6 +117,23 @@ function toDate(value: unknown): Date | null {
30
117
  return Number.isNaN(d.getTime()) ? null : d;
31
118
  }
32
119
  if (typeof value === 'string') {
120
+ const wallClock = ISO_WALL_CLOCK_RE.exec(value);
121
+ if (wallClock) {
122
+ const [, year, month, day, hours, minutes, seconds, millis] =
123
+ wallClock;
124
+ const d = new WallClockDate(
125
+ Date.UTC(
126
+ Number(year),
127
+ Number(month) - 1,
128
+ Number(day),
129
+ Number(hours ?? 0),
130
+ Number(minutes ?? 0),
131
+ Number(seconds ?? 0),
132
+ Number(millis?.padEnd(3, '0') ?? 0),
133
+ ),
134
+ );
135
+ return Number.isNaN(d.getTime()) ? null : d;
136
+ }
33
137
  const d = parseISO(value);
34
138
  return Number.isNaN(d.getTime()) ? null : d;
35
139
  }
@@ -135,9 +239,6 @@ export function formatField(
135
239
  }
136
240
  }
137
241
 
138
- export function getColumn(
139
- columns: Column[],
140
- name: string,
141
- ): Column | undefined {
242
+ export function getColumn(columns: Column[], name: string): Column | undefined {
142
243
  return columns.find((c) => c.name === name);
143
244
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lightdash/cli",
3
- "version": "3.163.1",
3
+ "version": "3.164.0",
4
4
  "description": "Lightdash CLI tool",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -22,8 +22,8 @@
22
22
  "@actions/core": "1.11.1",
23
23
  "@aws-sdk/credential-providers": "3.995.0",
24
24
  "@casl/ability": "6.8.0",
25
- "@lightdash/common": "3.163.1",
26
- "@lightdash/warehouses": "3.163.1",
25
+ "@lightdash/common": "3.164.0",
26
+ "@lightdash/warehouses": "3.164.0",
27
27
  "@sidvind/better-ajv-errors": "7.0.0",
28
28
  "@types/columnify": "1.5.1",
29
29
  "ajv": "8.18.0",
@@ -61,6 +61,7 @@
61
61
  "@vercel/ncc": "0.38.4",
62
62
  "@yao-pkg/pkg": "6.24.0",
63
63
  "copyfiles": "2.4.1",
64
+ "date-fns": "3.6.0",
64
65
  "oxlint": "1.75.0",
65
66
  "oxlint-tsgolint": "7.0.2001",
66
67
  "tsx": "4.19.4",