Skip to content
Merged
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
11 changes: 11 additions & 0 deletions .changeset/first-release.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
"@cosyte/dates": minor
---

First release of `@cosyte/dates`: validate, render and convert the date parts the `@cosyte/*` parsers return, without adding precision a value did not state or guessing a time zone.

What you can depend on from this release: `validateParts`, `isValidParts` and `assertValidParts`, which report every fault in a value and name the rule each one broke; `precisionOf`; `toISO`, which renders ISO 8601 at the value's own precision; `toTemporal`, which returns the narrowest Temporal type that carries everything the value states; `toZonedDateTime` and `toInstant`, which need a time of day and a zone and never fall back to the host zone or to UTC; the re-exported `Temporal`; the shape of `DateParts`; the issue codes; and the four error classes, `DatePartsError`, `MissingZoneError`, `PrecisionError` and `AmbiguousLocalTimeError`.

What the version number promises: while it is below 1.0.0, a breaking change to any of the above ships in a new minor version, never a patch, and its entry here says what broke and what to do instead. A fix that changes nothing else a consumer relies on ships as a patch.

Not covered yet: parsing vendor date text (the parsers do that), two-digit years (never interpreted), arithmetic, comparison or formatting helpers (convert with `toTemporal()` and use Temporal for those), locale-aware output, years outside 1000 to 9999, calendars other than the proleptic Gregorian, and leap seconds.
2 changes: 1 addition & 1 deletion .github/CI.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ no gate: it blocks the very pull requests that would fix it.

| workflow | trigger | what it is |
|---|---|---|
| `.github/workflows/ci.yml` | push to `main`, pull request to `main` | The gate. One job, `verify`: install from the frozen lockfile, build, the typed-exports check (`pnpm run attw`), typecheck, lint, format check, the test suite, the suite again under a second host time zone, and the em-dash scan. |
| `.github/workflows/ci.yml` | push to `main`, pull request to `main` | The gate. One job, `verify`: install from the frozen lockfile, build, the typed-exports check (`pnpm run attw`), the runnable examples (`pnpm run examples`), typecheck, lint, format check, the test suite, the suite again under a second host time zone, and the em-dash scan. |
| `.github/workflows/no-emdash.yml` | push to `main`, pull request to `main` (including `edited`) | Repo-local gate. Scans every tracked file, plus the pull request's own title, body and commit messages, for U+2014. |
| `.github/workflows/scorecard.yml` | push to `main`, weekly cron | Thin caller of `cosyte/.github/.github/workflows/scorecard.yml@main`. Supply-chain analysis, SARIF into the Security tab. |
| `.github/workflows/codeql.yml` | push to `main`, pull request to `main`, weekly cron | Thin caller of the org CodeQL reusable at a published reference. |
Expand Down
6 changes: 6 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ jobs:
- name: Types resolve (attw)
run: pnpm run attw

# The programs under examples/ import the package by its name, which resolves through the
# package's own `exports` to the build above, and each one checks its own output. They run
# as a step of this job rather than a job of their own, to keep the single status above.
- name: Examples
run: pnpm run examples

- name: Typecheck
run: pnpm run typecheck

Expand Down
13 changes: 9 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,9 @@ nobody has their date library chosen for them.

**0.1.0. The public API is settled and safe to depend on.** The exported
functions, the shape of `DateParts`, the issue codes and the error classes are
what this package intends to keep, and a breaking change to any of them will
come with a major version.
what this package intends to keep. While the version is below 1.0.0, a breaking
change to any of them ships in a new minor version, never a patch, and the
changelog says what broke and what to do instead.

Not covered at 0.1.0, and named so the boundary is not a surprise:

Expand Down Expand Up @@ -111,6 +112,10 @@ That output is identical under every host `TZ`, and
`test/readme.test.ts` runs this exact block against the built package and
compares it character for character.

More runnable programs, one for each job this package does, are in
[`examples/`](https://github.com/cosyte/dates/tree/main/examples), and the full
guide is at [docs.cosyte.com/dates](https://docs.cosyte.com/dates).

## PHI and safety

A date of birth is PHI, and a wrong conversion is the quiet kind of defect: it
Expand Down Expand Up @@ -188,8 +193,8 @@ identifier or a numeric offset is required).
Issues and pull requests are welcome at
[github.com/cosyte/dates](https://github.com/cosyte/dates). A contribution has
to clear the same gate CI runs on every push: `pnpm run build`, `pnpm run
typecheck`, `pnpm run lint`, `pnpm run test`, and
`bash scripts/check-no-emdash.sh`. New behaviour needs a test, and anything
attw`, `pnpm run typecheck`, `pnpm run lint`, `pnpm run test`, `pnpm run
examples`, and `bash scripts/check-no-emdash.sh`. New behaviour needs a test, and anything
touching conversion needs one that pins the exact output rather than a shape.

## License
Expand Down
11 changes: 10 additions & 1 deletion eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,16 @@ import tseslint from 'typescript-eslint';

export default tseslint.config(
{
ignores: ['dist/**', 'node_modules/**', 'coverage/**', '.tmp/**'],
// `.cosyte-release-tooling/` is a checkout of cosyte/.github that the shared release workflow
// makes inside this working tree before it runs `pnpm lint`. It is not this repository's code.
ignores: [
'dist/**',
'node_modules/**',
'coverage/**',
'.tmp/**',
'dist-artifacts/**',
'.cosyte-release-tooling/**',
],
},
js.configs.recommended,
...tseslint.configs.recommended,
Expand Down
22 changes: 22 additions & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
# Examples

Three small programs, one for each job the [README](../README.md) describes. Each imports
`@cosyte/dates` by its package name, which Node resolves through this package's own `exports` map to
the built `dist/`, prints what it did, checks its own output, and exits non-zero on a mismatch. Every
value in them is synthetic.

```bash
pnpm install
pnpm build
pnpm examples # run all three
pnpm tsx examples/validate-parts.ts # or one
```

| Example | What it shows | Calls |
| ---------------------------------------------------- | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------- |
| [`validate-parts.ts`](./validate-parts.ts) | Every fault in a value reported at once; a date that does not exist refused, never rolled over. | `validateParts`, `isValidParts`, `assertValidParts` |
| [`render-at-precision.ts`](./render-at-precision.ts) | A year stays a year: ISO 8601 and Temporal types at the precision the value arrived with. | `toISO`, `precisionOf`, `toTemporal` |
| [`instants-and-zones.ts`](./instants-and-zones.ts) | An instant only when the zone is known; a daylight-saving repeat refused until the offset is stated. | `toInstant`, `toZonedDateTime` |

`pnpm examples` runs `scripts/run-examples.ts`, which fails if any example exits non-zero or stops
before printing its final `<name>: ok` line. CI runs it on every pull request, after the build.
61 changes: 61 additions & 0 deletions examples/instants-and-zones.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
/**
* Turn a timestamp into an absolute instant only when its zone is known. A value that states its
* offset keeps it; a value that does not needs the caller to say which zone applies; and a local
* time a daylight-saving transition skips or repeats is refused rather than resolved by a guess.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/instants-and-zones.ts
*
* Every value here is synthetic. The output does not depend on the host time zone. The example
* checks its own output and exits non-zero on a mismatch.
*/

import assert from 'node:assert/strict';

import {
AmbiguousLocalTimeError,
MissingZoneError,
toInstant,
toZonedDateTime,
} from '@cosyte/dates';

// The sender stated +05:30, so that offset is used as sent.
const collected = { year: 1988, month: 5, day: 7, hour: 13, minute: 45, offsetMinutes: 330 };
const instant = toInstant(collected).toString();
console.log('stated offset:', instant);
assert.equal(instant, '1988-05-07T08:15:00Z');

// No offset on the wire: nothing guesses one, not the host zone and not UTC.
const local = { year: 2026, month: 3, day: 9, hour: 8, minute: 5 };
try {
toInstant(local);
assert.fail('toInstant guessed a zone for a value with no offset');
} catch (error) {
assert.ok(error instanceof MissingZoneError);
console.log('no zone given:', error.name);
}

// The caller knows the sender's zone and says so.
const inNewYork = toInstant(local, { timeZone: 'America/New_York' }).toString();
const zoned = toZonedDateTime(local, { timeZone: 'America/New_York' }).toString();
console.log('in America/New_York:', inNewYork, '|', zoned);
assert.equal(inNewYork, '2026-03-09T12:05:00Z');
assert.equal(zoned, '2026-03-09T08:05:00-04:00[America/New_York]');

// 01:30 happens twice on 1 November 2026 in New York. Refused, not picked.
const repeated = { year: 2026, month: 11, day: 1, hour: 1, minute: 30 };
try {
toInstant(repeated, { timeZone: 'America/New_York' });
assert.fail('toInstant picked one side of a daylight-saving transition');
} catch (error) {
assert.ok(error instanceof AmbiguousLocalTimeError);
console.log('repeated local time:', error.name);
}

// Saying which of the two offsets was meant settles it.
const resolved = toInstant(repeated, { offsetMinutes: -240 }).toString();
console.log('with offset -04:00:', resolved);
assert.equal(resolved, '2026-11-01T05:30:00Z');

console.log('instants-and-zones: ok');
79 changes: 79 additions & 0 deletions examples/render-at-precision.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
/**
* Render and convert a value at the precision it arrived with. A year stays a year: nothing adds a
* month, a day or a midnight the value did not state.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/render-at-precision.ts
*
* Every value here is synthetic. The example checks its own output and exits non-zero on a
* mismatch.
*/

import assert from 'node:assert/strict';

import { PrecisionError, Temporal, precisionOf, toISO, toTemporal } from '@cosyte/dates';
import type { DateParts } from '@cosyte/dates';

const rows: ReadonlyArray<{ value: DateParts; iso: string; precision: string }> = [
{ value: { year: 1988 }, iso: '1988', precision: 'year' },
{ value: { year: 1988, month: 5 }, iso: '1988-05', precision: 'month' },
{ value: { year: 1988, month: 5, day: 7 }, iso: '1988-05-07', precision: 'day' },
{
value: { year: 1988, month: 5, day: 7, hour: 13, minute: 45 },
iso: '1988-05-07T13:45',
precision: 'minute',
},
{
value: {
year: 1988,
month: 5,
day: 7,
hour: 13,
minute: 45,
second: 6,
fraction: 0.25,
offsetMinutes: 330,
},
iso: '1988-05-07T13:45:06.25+05:30',
precision: 'fraction',
},
];

for (const row of rows) {
const iso = toISO(row.value);
const precision = precisionOf(row.value);
console.log(iso.padEnd(30), precision);
assert.equal(iso, row.iso);
assert.equal(precision, row.precision);
}

// toTemporal returns the narrowest Temporal type that carries everything the value states.
const month = toTemporal({ year: 1988, month: 5 });
const day = toTemporal({ year: 1988, month: 5, day: 7 });
const local = toTemporal({ year: 1988, month: 5, day: 7, hour: 13, minute: 45 });
const zoned = toTemporal({
year: 1988,
month: 5,
day: 7,
hour: 13,
minute: 45,
offsetMinutes: 330,
});
console.log([month, day, local, zoned].map((value) => value.constructor.name).join(' '));
assert.ok(month instanceof Temporal.PlainYearMonth);
assert.ok(day instanceof Temporal.PlainDate);
assert.ok(local instanceof Temporal.PlainDateTime);
assert.ok(zoned instanceof Temporal.ZonedDateTime);

// Temporal has no year-only type, so a year is refused rather than given an invented month.
try {
toTemporal({ year: 1988 });
assert.fail('toTemporal invented a month for a year-only value');
} catch (error) {
assert.ok(error instanceof PrecisionError);
console.log('year-only toTemporal refused:', error.name, `(precision ${error.precision})`);
assert.equal(error.precision, 'year');
}

console.log('render-at-precision: ok');
44 changes: 44 additions & 0 deletions examples/validate-parts.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/**
* Validate a date-parts value before trusting it. Every fault is reported, each naming the
* component and the rule it broke, and a value that passes comes back with its precision.
*
* Run from the repository root after `pnpm build`:
*
* pnpm tsx examples/validate-parts.ts
*
* Every value here is synthetic. The example checks its own output and exits non-zero on a
* mismatch.
*/

import assert from 'node:assert/strict';

import { assertValidParts, DatePartsError, isValidParts, validateParts } from '@cosyte/dates';

// A leap day that exists: valid, at day precision.
const admitted = validateParts({ year: 2024, month: 2, day: 29 });
assert.ok(admitted.valid);
console.log('2024-02-29 valid:', admitted.valid, '| precision:', admitted.precision);
assert.equal(admitted.precision, 'day');

// Three faults in one value, all reported rather than only the first.
const broken = validateParts({ year: 88, month: 13, day: 40 });
assert.ok(!broken.valid);
const faults = broken.issues.map((issue) => `${issue.component}:${issue.code}`);
console.log('faults:', faults.join(', '));
assert.deepEqual(faults, ['year:year-not-four-digits', 'month:out-of-range', 'day:out-of-range']);

// A day that does not exist is refused, never rolled over into March.
const input: unknown = { year: 2025, month: 2, day: 29 };
console.log('2025-02-29 valid:', isValidParts(input));
assert.equal(isValidParts(input), false);

try {
assertValidParts(input);
assert.fail('assertValidParts accepted a date that does not exist');
} catch (error) {
assert.ok(error instanceof DatePartsError);
console.log('refused:', error.issues[0]?.code);
assert.equal(error.issues[0]?.code, 'no-such-calendar-date');
}

console.log('validate-parts: ok');
15 changes: 10 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
{
"name": "@cosyte/dates",
"version": "0.1.0",
"version": "0.0.0",
"description": "Date and time utilities for the @cosyte/* healthcare parsers: precision-preserving conversion and validation that never guesses a timezone.",
"license": "MIT",
"author": "Cosyte",
"type": "module",
"sideEffects": false,
"homepage": "https://github.com/cosyte/dates#readme",
"homepage": "https://docs.cosyte.com/dates",
"repository": {
"type": "git",
"url": "git+https://github.com/cosyte/dates.git"
Expand All @@ -19,10 +19,13 @@
"datetime",
"healthcare",
"hl7",
"fhir",
"temporal",
"iso-8601",
"precision",
"timezone",
"validation"
"validation",
"typescript"
],
"exports": {
".": {
Expand Down Expand Up @@ -51,10 +54,11 @@
"changeset": "changeset",
"typecheck": "tsc --noEmit",
"lint": "eslint . --max-warnings=0",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.mjs\" \"docs-content/**/*.{md,json}\" \"*.{ts,json,md,yml}\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.mjs\" \"docs-content/**/*.{md,json}\" \"*.{ts,json,md,yml}\"",
"format": "prettier --write \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.{ts,mjs}\" \"examples/**/*.{ts,md}\" \"docs-content/**/*.{md,json}\" \"*.{ts,json,md,yml}\"",
"format:check": "prettier --check \"src/**/*.ts\" \"test/**/*.ts\" \"scripts/**/*.{ts,mjs}\" \"examples/**/*.{ts,md}\" \"docs-content/**/*.{md,json}\" \"*.{ts,json,md,yml}\"",
"test": "vitest run",
"test:tz": "TZ=UTC vitest run && TZ=Pacific/Kiritimati vitest run",
"examples": "tsx scripts/run-examples.ts",
"clean": "rm -rf dist coverage .tmp dist-artifacts",
"attw": "node scripts/attw.mjs --profile esm-only",
"pack:docs": "bash scripts/build-docs-artifacts.sh",
Expand All @@ -76,6 +80,7 @@
"eslint": "10.9.1",
"prettier": "3.9.6",
"tsup": "8.5.1",
"tsx": "4.21.0",
"typescript": "5.9.3",
"typescript-eslint": "8.68.0",
"vitest": "4.1.11"
Expand Down
Loading
Loading