Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 3 additions & 3 deletions api/prisma/schema/_main.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ model User {

role UserRole @default(STANDARD)

createdAt DateTime @default(now()) @db.Date
createdAt DateTime @default(now())

confirmEmailInfo ConfirmEmailInfo?

Expand Down Expand Up @@ -58,8 +58,8 @@ model Dataset {
id String @id @default(auto()) @map("_id") @db.ObjectId
name String
description String?
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @default(now()) @updatedAt @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
license String
managers User[] @relation(fields: [managerIds], references: [id])
managerIds String[] @db.ObjectId
Expand Down
4 changes: 2 additions & 2 deletions api/prisma/schema/projects.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ model Project {
name String
description String?
userIds String[]
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @default(now()) @updatedAt @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
expiry DateTime
// Each project dataset should contain filters on columns
datasets ProjectDataset[]
Expand Down
4 changes: 2 additions & 2 deletions api/prisma/schema/setup.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,8 @@ type UserVerificationStrategy {

model SetupConfig {
id String @id @default(auto()) @map("_id") @db.ObjectId
createdAt DateTime @default(now()) @db.Date
updatedAt DateTime @default(now()) @updatedAt @db.Date
createdAt DateTime @default(now())
updatedAt DateTime @default(now()) @updatedAt
isDemo Boolean
verificationStrategy UserVerificationStrategy
}
4 changes: 2 additions & 2 deletions api/src/auth/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export class AuthService {

// If there is an existing, non-expired code, use that since we record attempts for security
let confirmEmailInfo: ConfirmEmailInfo;
if (user.confirmEmailInfo && user.confirmEmailInfo.expiryAt > new Date(Date.now())) {
if (user.confirmEmailInfo && user.confirmEmailInfo.expiryAt > new Date()) {
confirmEmailInfo = user.confirmEmailInfo;
} else {
confirmEmailInfo = {
Expand Down Expand Up @@ -99,7 +99,7 @@ export class AuthService {
throw new ForbiddenException('Validation code is undefined. Please request a validation code.');
}

const isExpired = user.confirmEmailInfo.expiryAt < new Date(Date.now());
const isExpired = user.confirmEmailInfo.expiryAt < new Date();
if (isExpired) {
throw new ForbiddenException('Validation code is expired. Please request a new validation code.');
}
Expand Down
12 changes: 10 additions & 2 deletions api/src/columns/columns.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,14 @@ import type { Series } from 'nodejs-polars';

import { getColumnDataArray } from './column-data.utils';

/**
* Polars `pl.Date` series expose their values as integers counting days since
* the Unix epoch (1970-01-01). Multiplying by this constant converts a day
* count into the millisecond timestamp expected by `new Date(...)`. The
* resulting instant is interpreted in UTC.
*/
const MS_PER_DAY = 24 * 60 * 60 * 1000;

type ProjectColumnView = Pick<
NonNullable<Awaited<ReturnType<Model<'TabularColumn'>['findUnique']>>>,
| 'dataPermission'
Expand Down Expand Up @@ -589,8 +597,8 @@ export class ColumnsService {
return {
count: currSeries.len() - currSeries.nullCount(),
datetimeSummary: {
max: new Date(currSeries.max() * 24 * 3600 * 1000),
min: new Date(currSeries.min() * 24 * 3600 * 1000)
max: new Date(currSeries.max() * MS_PER_DAY),
min: new Date(currSeries.min() * MS_PER_DAY)
},
nullCount: currSeries.nullCount()
};
Expand Down
7 changes: 4 additions & 3 deletions api/src/datasets/datasets.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,8 @@ import {
$ProjectDataset,
$TabularColumnSummary,
$TabularDataDownloadFormat,
$TabularDatasetView
$TabularDatasetView,
formatISODateTime
} from '@databank/core';
import type { $DatasetCardProps, $DatasetInfo, $TabularDataset } from '@databank/core';
import type { Model } from '@douglasneuroinformatics/libnest';
Expand Down Expand Up @@ -776,8 +777,8 @@ export class DatasetsService {
datasetMetadata.nullable,
datasetMetadata.count,
datasetMetadata.nullCount,
datasetMetadata.datetimeSummary.max,
datasetMetadata.datetimeSummary.min,
formatISODateTime(datasetMetadata.datetimeSummary.max),
formatISODateTime(datasetMetadata.datetimeSummary.min),
'',
'',
'',
Expand Down
7 changes: 4 additions & 3 deletions api/src/tabular-data/tabular-data.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import {
$ProjectTabularDatasetView,
$TabularColumnSummary,
$TabularDatasetView,
$UpdatePrimaryKeys
$UpdatePrimaryKeys,
formatISODateTime
} from '@databank/core';
import type { Model } from '@douglasneuroinformatics/libnest';
import { InjectModel } from '@douglasneuroinformatics/libnest';
Expand Down Expand Up @@ -145,7 +146,7 @@ export class TabularDataService {
case 'DATETIME':
currColumnView.datetimeData.map((entry, i) => {
rows[i] ??= {};
rows[i][currColumnView.name] = entry.value!.toDateString()!;
rows[i][currColumnView.name] = formatISODateTime(entry.value!);
});
metaData[currColumnView.name] = {
count: currColumnView.summary.count,
Expand Down Expand Up @@ -298,7 +299,7 @@ export class TabularDataService {
if (columnIdsModifyData.has(col._id.$oid)) {
rows[i][col.name] = 'Hidden';
} else {
rows[i][col.name] = entry.value.$date ? new Date(entry.value.$date).toDateString() : null;
rows[i][col.name] = entry.value.$date ? formatISODateTime(new Date(entry.value.$date)) : null;
}
});

Expand Down
4 changes: 4 additions & 0 deletions core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,9 +10,13 @@
"scripts": {
"lint": "tsc && eslint --fix src"
},
"devDependencies": {
"@types/luxon": "catalog:"
},
"dependencies": {
"@douglasneuroinformatics/libjs": "catalog:",
"@douglasneuroinformatics/liblicense": "^1.0.0",
"luxon": "catalog:",
"type-fest": "catalog:",
"zod": "catalog:"
}
Expand Down
44 changes: 44 additions & 0 deletions core/src/dates.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
import { DateTime } from 'luxon';

/**
* Format a Date as an ISO8601 calendar date (`YYYY-MM-DD`).
*
* Used for user-facing display where time-of-day is not relevant. Forces UTC
* to avoid off-by-one drift when the stored instant falls near midnight in
* the server's local zone.
*/
export const formatISODate = (date: Date): string => {
const dt = DateTime.fromJSDate(date, { zone: 'utc' });
if (!dt.isValid) {
throw new TypeError(`Invalid Date: ${String(date)}`);
}
return dt.toISODate();
};

/**
* Format a Date as a full ISO8601 datetime with UTC offset (`YYYY-MM-DDTHH:mm:ss.sssZ`).
*
* Use this at storage/transport boundaries (API responses, CSV exports) so that
* the full precision of the instant is preserved across systems.
*/
export const formatISODateTime = (date: Date): string => {
const dt = DateTime.fromJSDate(date, { zone: 'utc' });
if (!dt.isValid) {
throw new TypeError(`Invalid Date: ${String(date)}`);
}
return dt.toISO();
};

/**
* Parse an ISO8601 string (date or datetime) into a Date.
*
* Throws if the input is not a valid ISO8601 string. Forces UTC so the returned
* Date represents the same instant regardless of the host's local timezone.
*/
export const parseISODate = (input: string): Date => {
const dt = DateTime.fromISO(input, { zone: 'utc' });
if (!dt.isValid) {
throw new TypeError(`Invalid ISO8601 date string: ${input}`);
}
return dt.toJSDate();
};
1 change: 1 addition & 0 deletions core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ export * from './auth';
export * from './columns';
export * from './dashboard';
export * from './datasets';
export * from './dates';
export * from './demo';
export * from './licenses';
export * from './projects';
Expand Down
6 changes: 3 additions & 3 deletions core/src/users.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,13 @@ const $CreateUser = z.object({
});

type User = {
confirmedAt: null | number | undefined;
creationTime?: number;
confirmedAt: Date | null | undefined;
creationTime?: Date;
email: string;
firstName: string;
lastName: string;
role: $UserRole;
verifiedAt: null | number | undefined;
verifiedAt: Date | null | undefined;
};

type $CreateUser = z.infer<typeof $CreateUser>;
Expand Down
19 changes: 19 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions pnpm-workspace.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ allowBuilds:
msgpackr-extract: true
catalog:
'@douglasneuroinformatics/libjs': ^3.1.0
'@types/luxon': ^3.7.0
luxon: ^3.7.2
type-fest: ^4.26.1
zod: '3.25.x'
minimumReleaseAge: 10080
Expand Down
7 changes: 4 additions & 3 deletions web/src/hooks/queries/useProjectQuery.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,18 @@
import { parseISODate } from '@databank/core';
import { queryOptions, useSuspenseQuery } from '@tanstack/react-query';
import axios from 'axios';
import { z } from 'zod/v4';

// TODO - this needs to be synced correctly with the backend
const $Project = z.object({
createdAt: z.coerce.date(),
createdAt: z.union([z.iso.datetime(), z.iso.date()]).transform(parseISODate),
datasets: z.array(z.any()),
description: z.string().nullish(),
expiry: z.coerce.date(),
expiry: z.union([z.iso.datetime(), z.iso.date()]).transform(parseISODate),
externalId: z.string().nullish(),
id: z.string(),
name: z.string(),
updatedAt: z.coerce.date(),
updatedAt: z.union([z.iso.datetime(), z.iso.date()]).transform(parseISODate),
userIds: z.array(z.string())
});

Expand Down
6 changes: 3 additions & 3 deletions web/src/routes/_public/datasets/$datasetId.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable perfectionist/sort-objects */

import { $DatasetViewPagination, licensesObjects } from '@databank/core';
import { $DatasetViewPagination, formatISODate, licensesObjects } from '@databank/core';
import { Badge, Card } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute } from '@tanstack/react-router';
Expand Down Expand Up @@ -164,14 +164,14 @@ const RouteComponent = () => {
<CalendarIcon className="size-3.5" />
{t({ en: 'Created', fr: 'Créé' })}
</dt>
<dd className="text-sm font-medium">{new Date(dataset.createdAt).toLocaleDateString()}</dd>
<dd className="text-sm font-medium">{formatISODate(new Date(dataset.createdAt))}</dd>
</div>
<div className="flex items-center justify-between">
<dt className="text-muted-foreground flex items-center gap-2 text-sm">
<ClockIcon className="size-3.5" />
{t({ en: 'Updated', fr: 'Mis à jour' })}
</dt>
<dd className="text-sm font-medium">{new Date(dataset.updatedAt).toLocaleDateString()}</dd>
<dd className="text-sm font-medium">{formatISODate(new Date(dataset.updatedAt))}</dd>
</div>
</dl>
</Card.Content>
Expand Down
4 changes: 2 additions & 2 deletions web/src/routes/_public/datasets/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { licensesObjects } from '@databank/core';
import { formatISODate, licensesObjects } from '@databank/core';
import type { $DatasetInfo } from '@databank/core';
import { Button, Card } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
Expand Down Expand Up @@ -36,7 +36,7 @@ const DatasetCard: React.FC<{ dataset: $DatasetInfo; index: number }> = ({ datas
{dataset.license}
</dd>
<dt className="font-medium">{t({ en: 'Created', fr: 'Créé' })}</dt>
<dd>{new Date(dataset.createdAt).toLocaleDateString()}</dd>
<dd>{formatISODate(new Date(dataset.createdAt))}</dd>
</dl>
</Card.Content>
<Card.Footer className="mt-auto pt-3">
Expand Down
6 changes: 3 additions & 3 deletions web/src/routes/portal/datasets/$datasetId/index.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
/* eslint-disable perfectionist/sort-objects */

import { $DatasetViewPagination, licensesObjects } from '@databank/core';
import { $DatasetViewPagination, formatISODate, licensesObjects } from '@databank/core';
import type { $DatasetViewPagination as DatasetViewPaginationType } from '@databank/core';
import { Button, Card, DropdownMenu } from '@douglasneuroinformatics/libui/components';
import { useDestructiveAction, useDownload, useTranslation } from '@douglasneuroinformatics/libui/hooks';
Expand Down Expand Up @@ -161,11 +161,11 @@ const RouteComponent = () => {
<dl className="grid gap-x-6 gap-y-4 sm:grid-cols-2 lg:grid-cols-4">
<div>
<dt className="text-muted-foreground text-xs font-medium uppercase tracking-wider">{t('createdAt')}</dt>
<dd className="mt-1.5 text-sm">{new Date(dataset.createdAt).toLocaleDateString()}</dd>
<dd className="mt-1.5 text-sm">{formatISODate(new Date(dataset.createdAt))}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs font-medium uppercase tracking-wider">{t('updatedAt')}</dt>
<dd className="mt-1.5 text-sm">{new Date(dataset.updatedAt).toLocaleDateString()}</dd>
<dd className="mt-1.5 text-sm">{formatISODate(new Date(dataset.updatedAt))}</dd>
</div>
<div>
<dt className="text-muted-foreground text-xs font-medium uppercase tracking-wider">
Expand Down
4 changes: 2 additions & 2 deletions web/src/routes/portal/datasets/index.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { $DatasetInfo } from '@databank/core';
import { $DatasetInfo, formatISODate } from '@databank/core';
import { Badge, Button, Card, DropdownMenu } from '@douglasneuroinformatics/libui/components';
import { useDestructiveAction, useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
Expand Down Expand Up @@ -44,7 +44,7 @@ const DatasetCard = ({ dataset, isManager }: { dataset: $DatasetInfo; isManager:
<dt className="font-medium">{t({ en: 'License', fr: 'Licence' })}</dt>
<dd className="truncate">{dataset.license}</dd>
<dt className="font-medium">{t({ en: 'Created', fr: 'Créé le' })}</dt>
<dd>{new Date(dataset.createdAt).toLocaleDateString()}</dd>
<dd>{formatISODate(new Date(dataset.createdAt))}</dd>
</dl>
</Card.Content>
<Card.Footer className="mt-auto flex items-center justify-between gap-2 pt-3">
Expand Down
4 changes: 2 additions & 2 deletions web/src/routes/portal/projects/$projectId/add-dataset.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { licensesObjects } from '@databank/core';
import { formatISODate, licensesObjects } from '@databank/core';
import { Button, Card } from '@douglasneuroinformatics/libui/components';
import { useTranslation } from '@douglasneuroinformatics/libui/hooks';
import { createFileRoute, useNavigate } from '@tanstack/react-router';
Expand Down Expand Up @@ -73,7 +73,7 @@ const RouteComponent = () => {
</p>
<p>
<span className="font-semibold tracking-tight">{t({ en: 'Created: ', fr: 'Créé : ' })}</span>
{new Date(dataset.createdAt).toLocaleDateString()}
{formatISODate(new Date(dataset.createdAt))}
</p>
</div>
</div>
Expand Down
Loading