@lightdash/cli 3.163.1 → 3.165.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.
@@ -1,10 +1,11 @@
1
1
  import { ContentType, type ContentSlugRenameRequest } from '@lightdash/common';
2
+ type SlugUpdateResourceType = ContentType.CHART | ContentType.DASHBOARD | ContentType.DATA_APP;
2
3
  type SlugUpdateOptions = {
3
4
  verbose: boolean;
4
5
  project?: string;
5
6
  path?: string;
6
7
  dryRun: boolean;
7
- type: ContentType.CHART | ContentType.DASHBOARD;
8
+ type: ContentType.CHART | ContentType.DASHBOARD | 'app';
8
9
  from: string;
9
10
  to: string;
10
11
  };
@@ -17,6 +18,10 @@ type LocalSlugUpdatePlan = {
17
18
  source: string;
18
19
  target: string;
19
20
  }>;
21
+ directoryMoves: Array<{
22
+ source: string;
23
+ target: string;
24
+ }>;
20
25
  metadata: {
21
26
  root: string;
22
27
  resourceType: ContentType.CHART | ContentType.DASHBOARD;
@@ -28,11 +33,11 @@ export type LocalSlugUpdateFileChange = {
28
33
  source: string;
29
34
  target?: string;
30
35
  };
31
- export declare const planLocalSlugUpdate: (customPath: string | undefined, from: string, to: string, resourceType?: ContentType.CHART | ContentType.DASHBOARD) => Promise<LocalSlugUpdatePlan>;
36
+ export declare const planLocalSlugUpdate: (customPath: string | undefined, from: string, to: string, resourceType?: SlugUpdateResourceType) => Promise<LocalSlugUpdatePlan>;
32
37
  export declare const getLocalSlugUpdateFileChanges: (plan: LocalSlugUpdatePlan, customPath: string | undefined) => LocalSlugUpdateFileChange[];
33
38
  export declare const applyLocalSlugUpdate: (plan: LocalSlugUpdatePlan) => Promise<() => Promise<void>>;
34
39
  export declare const requestSlugUpdate: (projectUuid: string, request: ContentSlugRenameRequest) => Promise<void>;
35
- export declare const executeSlugUpdate: (projectUuid: string, customPath: string | undefined, from: string, to: string, resourceType?: ContentType.CHART | ContentType.DASHBOARD) => Promise<LocalSlugUpdatePlan>;
40
+ export declare const executeSlugUpdate: (projectUuid: string, customPath: string | undefined, from: string, to: string, resourceType?: SlugUpdateResourceType) => Promise<LocalSlugUpdatePlan>;
36
41
  export declare const slugUpdateHandler: (options: SlugUpdateOptions) => Promise<void>;
37
42
  export {};
38
43
  //# sourceMappingURL=slugUpdate.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"slugUpdate.d.ts","sourceRoot":"","sources":["../../src/handlers/slugUpdate.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EAKX,KAAK,wBAAwB,EAChC,MAAM,mBAAmB,CAAC;AAa3B,KAAK,iBAAiB,GAAG;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAChB,IAAI,EAAE,WAAW,CAAC,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC;IAChD,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,KAAK,mBAAmB,GAAG;IACvB,WAAW,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,SAAS,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IACrD,QAAQ,EACF;QACI,IAAI,EAAE,MAAM,CAAC;QACb,YAAY,EAAE,WAAW,CAAC,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC;QACxD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACjC,GACD,SAAS,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;CAC7B,CAAC;AAIF,MAAM,MAAM,yBAAyB,GAAG;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AAyFF,eAAO,MAAM,mBAAmB,eAChB,MAAM,GAAG,SAAS,QACxB,MAAM,MACR,MAAM,iBACI,WAAW,CAAC,KAAK,GAAG,WAAW,CAAC,SAAS,KACxD,OAAO,CAAC,mBAAmB,CA2L7B,CAAC;AAEF,eAAO,MAAM,6BAA6B,SAChC,mBAAmB,cACb,MAAM,GAAG,SAAS,KAC/B,yBAAyB,EAuB3B,CAAC;AAuDF,eAAO,MAAM,oBAAoB,SACvB,mBAAmB,KAC1B,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CAuD7B,CAAC;AAEF,eAAO,MAAM,iBAAiB,gBACb,MAAM,WACV,wBAAwB,KAClC,OAAO,CAAC,IAAI,CAmBd,CAAC;AAEF,eAAO,MAAM,iBAAiB,gBACb,MAAM,cACP,MAAM,GAAG,SAAS,QACxB,MAAM,MACR,MAAM,iBACI,WAAW,CAAC,KAAK,GAAG,WAAW,CAAC,SAAS,KACxD,OAAO,CAAC,mBAAmB,CAuB7B,CAAC;AAEF,eAAO,MAAM,iBAAiB,YACjB,iBAAiB,KAC3B,OAAO,CAAC,IAAI,CAuDd,CAAC"}
1
+ {"version":3,"file":"slugUpdate.d.ts","sourceRoot":"","sources":["../../src/handlers/slugUpdate.ts"],"names":[],"mappings":"AAAA,OAAO,EACH,WAAW,EAMX,KAAK,wBAAwB,EAChC,MAAM,mBAAmB,CAAC;AAa3B,KAAK,sBAAsB,GACrB,WAAW,CAAC,KAAK,GACjB,WAAW,CAAC,SAAS,GACrB,WAAW,CAAC,QAAQ,CAAC;AAE3B,KAAK,iBAAiB,GAAG;IACrB,OAAO,EAAE,OAAO,CAAC;IACjB,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,IAAI,CAAC,EAAE,MAAM,CAAC;IACd,MAAM,EAAE,OAAO,CAAC;IAEhB,IAAI,EAAE,WAAW,CAAC,KAAK,GAAG,WAAW,CAAC,SAAS,GAAG,KAAK,CAAC;IACxD,IAAI,EAAE,MAAM,CAAC;IACb,EAAE,EAAE,MAAM,CAAC;CACd,CAAC;AAEF,KAAK,mBAAmB,GAAG;IACvB,WAAW,EAAE,KAAK,CAAC;QAAE,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,SAAS,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAGrD,cAAc,EAAE,KAAK,CAAC;QAAE,MAAM,EAAE,MAAM,CAAC;QAAC,MAAM,EAAE,MAAM,CAAA;KAAE,CAAC,CAAC;IAC1D,QAAQ,EACF;QACI,IAAI,EAAE,MAAM,CAAC;QACb,YAAY,EAAE,WAAW,CAAC,KAAK,GAAG,WAAW,CAAC,SAAS,CAAC;QACxD,KAAK,EAAE,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;KACjC,GACD,SAAS,CAAC;IAChB,iBAAiB,EAAE,MAAM,CAAC;CAC7B,CAAC;AAIF,MAAM,MAAM,yBAAyB,GAAG;IACpC,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,CAAC,EAAE,MAAM,CAAC;CACnB,CAAC;AA+FF,eAAO,MAAM,mBAAmB,eAChB,MAAM,GAAG,SAAS,QACxB,MAAM,MACR,MAAM,iBACI,sBAAsB,KACrC,OAAO,CAAC,mBAAmB,CAwP7B,CAAC;AAEF,eAAO,MAAM,6BAA6B,SAChC,mBAAmB,cACb,MAAM,GAAG,SAAS,KAC/B,yBAAyB,EA2B3B,CAAC;AAuDF,eAAO,MAAM,oBAAoB,SACvB,mBAAmB,KAC1B,OAAO,CAAC,MAAM,OAAO,CAAC,IAAI,CAAC,CA8E7B,CAAC;AAEF,eAAO,MAAM,iBAAiB,gBACb,MAAM,WACV,wBAAwB,KAClC,OAAO,CAAC,IAAI,CAmBd,CAAC;AAEF,eAAO,MAAM,iBAAiB,gBACb,MAAM,cACP,MAAM,GAAG,SAAS,QACxB,MAAM,MACR,MAAM,iBACI,sBAAsB,KACrC,OAAO,CAAC,mBAAmB,CAuB7B,CAAC;AAEF,eAAO,MAAM,iBAAiB,YACjB,iBAAiB,KAC3B,OAAO,CAAC,IAAI,CA2Dd,CAAC"}
@@ -25,7 +25,11 @@ const getYamlFiles = async (root) => {
25
25
  (entry.name.endsWith('.yml') ||
26
26
  entry.name.endsWith('.yaml')) &&
27
27
  !entry.name.endsWith('.language.map.yml') &&
28
- !entry.name.endsWith('.language.map.yaml'))
28
+ !entry.name.endsWith('.language.map.yaml') &&
29
+ // App bundles can hold installed deps and lockfiles;
30
+ // neither carries renameable content references.
31
+ entry.name !== 'pnpm-lock.yaml' &&
32
+ !entry.parentPath.split(path.sep).includes('node_modules'))
29
33
  .map((entry) => path.join(entry.parentPath, entry.name));
30
34
  }
31
35
  catch (error) {
@@ -57,6 +61,7 @@ const assertMovesAreSafe = async (moves) => {
57
61
  }
58
62
  }
59
63
  };
64
+ const APP_MANIFEST_FILENAME = 'lightdash-app.yml';
60
65
  // Comment-only or scalar YAML (e.g. chart-type context files) parses to null or a primitive
61
66
  const isYamlMapping = (value) => typeof value === 'object' && value !== null && !Array.isArray(value);
62
67
  const readYamlDocuments = async (root) => {
@@ -78,18 +83,29 @@ const planLocalSlugUpdate = async (customPath, from, to, resourceType = common_1
78
83
  const root = (0, contentAsCodePaths_1.getDownloadFolder)(customPath);
79
84
  const fileUpdates = [];
80
85
  const fileMoves = [];
86
+ const directoryMoves = [];
81
87
  let referencesUpdated = 0;
82
88
  const languageMapKey = resourceType === common_1.ContentType.CHART ? 'chart' : 'dashboard';
83
89
  if (!from) {
84
90
  throw new common_1.ParameterError('The source slug must not be empty');
85
91
  }
86
- if (!to || to.length > common_1.MAX_CONTENT_SLUG_LENGTH || (0, common_1.generateSlug)(to) !== to) {
92
+ if (resourceType === common_1.ContentType.DATA_APP) {
93
+ // App slugs follow the server's own shape check, which allows more
94
+ // than generateSlug emits (e.g. consecutive hyphens).
95
+ if (!(0, common_1.isValidDataAppSlug)(to)) {
96
+ throw new common_1.ParameterError(`Invalid data app slug "${to}". Slugs must start with a lowercase letter or digit and contain only lowercase letters, digits, and hyphens, up to 255 characters.`);
97
+ }
98
+ }
99
+ else if (!to ||
100
+ to.length > common_1.MAX_CONTENT_SLUG_LENGTH ||
101
+ (0, common_1.generateSlug)(to) !== to) {
87
102
  throw new common_1.ParameterError(`The target slug must contain between 1 and ${common_1.MAX_CONTENT_SLUG_LENGTH} lowercase letters, numbers, or hyphen-separated words`);
88
103
  }
89
104
  if (from === to) {
90
105
  return {
91
106
  fileUpdates,
92
107
  fileMoves,
108
+ directoryMoves,
93
109
  metadata: undefined,
94
110
  referencesUpdated,
95
111
  };
@@ -102,6 +118,38 @@ const planLocalSlugUpdate = async (customPath, from, to, resourceType = common_1
102
118
  });
103
119
  for (const { filePath, document, parsed } of contentDocuments) {
104
120
  let changed = false;
121
+ if (resourceType === common_1.ContentType.DATA_APP) {
122
+ if (path.basename(filePath) === APP_MANIFEST_FILENAME &&
123
+ parsed.slug === from) {
124
+ document.set('slug', to);
125
+ referencesUpdated += 1;
126
+ changed = true;
127
+ // Bundles download to a folder named after the slug; keep the
128
+ // folder in step so the next download matches it.
129
+ const bundleDir = path.dirname(filePath);
130
+ if (path.basename(bundleDir) === from) {
131
+ directoryMoves.push({
132
+ source: bundleDir,
133
+ target: path.join(path.dirname(bundleDir), to),
134
+ });
135
+ }
136
+ }
137
+ if (Array.isArray(parsed.tiles)) {
138
+ for (const [index, tile] of parsed.tiles.entries()) {
139
+ if (tile.type === 'data_app' &&
140
+ tile.properties?.appSlug === from) {
141
+ document.setIn(['tiles', index, 'properties', 'appSlug'], to);
142
+ referencesUpdated += 1;
143
+ changed = true;
144
+ }
145
+ }
146
+ }
147
+ if (changed) {
148
+ fileUpdates.push({ filePath, content: document.toString() });
149
+ }
150
+ // eslint-disable-next-line no-continue
151
+ continue;
152
+ }
105
153
  const isChart = parsed.contentType === common_1.ContentType.CHART ||
106
154
  parsed.metricQuery !== undefined;
107
155
  const isDashboard = parsed.contentType === common_1.ContentType.DASHBOARD ||
@@ -185,24 +233,28 @@ const planLocalSlugUpdate = async (customPath, from, to, resourceType = common_1
185
233
  fileUpdates.push({ filePath, content: document.toString() });
186
234
  }
187
235
  }
188
- const metadata = await (0, metadataFile_1.readMetadataFile)(root);
189
236
  let updatedMetadata;
190
- const metadataSlugs = resourceType === common_1.ContentType.CHART
191
- ? metadata.charts
192
- : metadata.dashboards;
193
- if (from in metadataSlugs) {
194
- if (to !== from && to in metadataSlugs) {
195
- throw new common_1.ParameterError(`Cannot update local metadata because ${resourceType} slug "${to}" already exists`);
237
+ // The local metadata file only tracks charts and dashboards.
238
+ if (resourceType !== common_1.ContentType.DATA_APP) {
239
+ const metadata = await (0, metadataFile_1.readMetadataFile)(root);
240
+ const metadataSlugs = resourceType === common_1.ContentType.CHART
241
+ ? metadata.charts
242
+ : metadata.dashboards;
243
+ if (from in metadataSlugs) {
244
+ if (to !== from && to in metadataSlugs) {
245
+ throw new common_1.ParameterError(`Cannot update local metadata because ${resourceType} slug "${to}" already exists`);
246
+ }
247
+ const slugs = { ...metadataSlugs };
248
+ slugs[to] = slugs[from];
249
+ delete slugs[from];
250
+ updatedMetadata = { root, resourceType, slugs };
196
251
  }
197
- const slugs = { ...metadataSlugs };
198
- slugs[to] = slugs[from];
199
- delete slugs[from];
200
- updatedMetadata = { root, resourceType, slugs };
201
252
  }
202
- await assertMovesAreSafe(fileMoves);
253
+ await assertMovesAreSafe([...fileMoves, ...directoryMoves]);
203
254
  return {
204
255
  fileUpdates,
205
256
  fileMoves,
257
+ directoryMoves,
206
258
  metadata: updatedMetadata,
207
259
  referencesUpdated,
208
260
  };
@@ -217,6 +269,10 @@ const getLocalSlugUpdateFileChanges = (plan, customPath) => {
217
269
  source: relative(source),
218
270
  target: relative(target),
219
271
  })),
272
+ ...plan.directoryMoves.map(({ source, target }) => ({
273
+ source: `${relative(source)}${path.sep}`,
274
+ target: `${relative(target)}${path.sep}`,
275
+ })),
220
276
  ...plan.fileUpdates
221
277
  .filter(({ filePath }) => !movedSources.has(filePath))
222
278
  .map(({ filePath }) => ({ source: relative(filePath) })),
@@ -273,6 +329,14 @@ const applyLocalSlugUpdate = async (plan) => {
273
329
  ]);
274
330
  const snapshots = await captureFiles(affectedPaths);
275
331
  const stagedMoves = [];
332
+ const appliedDirectoryMoves = [];
333
+ const undoDirectoryMoves = async () => {
334
+ for (const move of [...appliedDirectoryMoves].reverse()) {
335
+ // eslint-disable-next-line no-await-in-loop
336
+ await fs_1.promises.rename(move.target, move.source);
337
+ }
338
+ appliedDirectoryMoves.length = 0;
339
+ };
276
340
  try {
277
341
  await Promise.all(plan.fileUpdates.map(({ filePath, content }) => fs_1.promises.writeFile(filePath, content)));
278
342
  for (const [index, move] of plan.fileMoves.entries()) {
@@ -285,6 +349,13 @@ const applyLocalSlugUpdate = async (plan) => {
285
349
  // eslint-disable-next-line no-await-in-loop
286
350
  await fs_1.promises.rename(stagedMoves[index], move.target);
287
351
  }
352
+ // Directory renames (data-app bundle folders) go last so every file
353
+ // write above targets the original paths.
354
+ for (const move of plan.directoryMoves) {
355
+ // eslint-disable-next-line no-await-in-loop
356
+ await fs_1.promises.rename(move.source, move.target);
357
+ appliedDirectoryMoves.push(move);
358
+ }
288
359
  if (plan.metadata && metadataPath) {
289
360
  const existing = await (0, metadataFile_1.readMetadataFile)(plan.metadata.root);
290
361
  await fs_1.promises.writeFile(metadataPath, JSON.stringify({
@@ -296,11 +367,17 @@ const applyLocalSlugUpdate = async (plan) => {
296
367
  }
297
368
  }
298
369
  catch (error) {
370
+ // Directory moves roll back first so restored files land inside
371
+ // folders at their original paths.
372
+ await undoDirectoryMoves();
299
373
  await Promise.all(stagedMoves.map((temporary) => fs_1.promises.rm(temporary, { force: true })));
300
374
  await restoreFiles(snapshots);
301
375
  throw error;
302
376
  }
303
- return () => restoreFiles(snapshots);
377
+ return async () => {
378
+ await undoDirectoryMoves();
379
+ await restoreFiles(snapshots);
380
+ };
304
381
  };
305
382
  exports.applyLocalSlugUpdate = applyLocalSlugUpdate;
306
383
  const requestSlugUpdate = async (projectUuid, request) => {
@@ -341,6 +418,8 @@ const slugUpdateHandler = async (options) => {
341
418
  (0, credentials_1.refuseAgentConnection)();
342
419
  globalState_1.default.setVerbose(options.verbose);
343
420
  await (0, apiClient_1.checkLightdashVersion)();
421
+ const resourceType = options.type === 'app' ? common_1.ContentType.DATA_APP : options.type;
422
+ const resourceLabel = options.type === 'app' ? 'data app' : options.type;
344
423
  const config = await (0, config_1.getConfig)();
345
424
  await (0, credentials_1.resolveCredential)(config);
346
425
  if (!config.context?.serverUrl)
@@ -350,17 +429,17 @@ const slugUpdateHandler = async (options) => {
350
429
  throw new common_1.ParameterError('No project selected. Run lightdash config set-project');
351
430
  }
352
431
  (0, selectProject_1.logSelectedProject)(selection, config, options.dryRun
353
- ? `Previewing ${options.type} slug update in`
354
- : `Updating ${options.type} slug in`);
432
+ ? `Previewing ${resourceLabel} slug update in`
433
+ : `Updating ${resourceLabel} slug in`);
355
434
  if (options.dryRun) {
356
- const localPlan = await (0, exports.planLocalSlugUpdate)(options.path, options.from, options.to, options.type);
357
- console.info(styles.success(`Would update ${options.type} slug "${options.from}" -> "${options.to}".`));
435
+ const localPlan = await (0, exports.planLocalSlugUpdate)(options.path, options.from, options.to, resourceType);
436
+ console.info(styles.success(`Would update ${resourceLabel} slug "${options.from}" -> "${options.to}".`));
358
437
  logLocalSlugUpdateFileChanges(localPlan, options.path, true);
359
438
  console.info(styles.secondary('No changes were made.'));
360
439
  return;
361
440
  }
362
- const localPlan = await (0, exports.executeSlugUpdate)(selection.projectUuid, options.path, options.from, options.to, options.type);
363
- console.info(styles.success(`Updated ${options.type} slug "${options.from}" -> "${options.to}".`));
441
+ const localPlan = await (0, exports.executeSlugUpdate)(selection.projectUuid, options.path, options.from, options.to, resourceType);
442
+ console.info(styles.success(`Updated ${resourceLabel} slug "${options.from}" -> "${options.to}".`));
364
443
  logLocalSlugUpdateFileChanges(localPlan, options.path, false);
365
444
  };
366
445
  exports.slugUpdateHandler = slugUpdateHandler;
package/dist/index.js CHANGED
@@ -711,9 +711,9 @@ commander_1.program
711
711
  .action(renameHandler_1.renameHandler);
712
712
  commander_1.program
713
713
  .command('slug-update')
714
- .description('Rename a chart or dashboard slug and update its local references')
714
+ .description('Rename a chart, dashboard, or data app slug and update its local references')
715
715
  .addOption(new commander_1.Option('--type <resource-type>', 'content type to rename')
716
- .choices(['chart', 'dashboard'])
716
+ .choices(['chart', 'dashboard', 'app'])
717
717
  .default('chart'))
718
718
  .option('--verbose', 'show verbose output', false)
719
719
  .option('--project <project uuid or slug>', 'specify a project by UUID or slug', parseProjectArgument, undefined)
@@ -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.165.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.165.0",
26
+ "@lightdash/warehouses": "3.165.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",