diff --git a/.github/workflows/package-candidate.yml b/.github/workflows/package-candidate.yml index d14d867..83e0627 100644 --- a/.github/workflows/package-candidate.yml +++ b/.github/workflows/package-candidate.yml @@ -82,11 +82,20 @@ jobs: run: | mkdir -p "$RUNNER_TEMP/npm-candidate" npm pack --ignore-scripts --pack-destination "$RUNNER_TEMP/npm-candidate" + npm pack --ignore-scripts --workspace @rdlabo/workers-timezone --pack-destination "$RUNNER_TEMP/npm-candidate" - name: Upload immutable package artifact uses: actions/upload-artifact@v7 with: name: ${{ steps.candidate.outputs.artifact }} - path: ${{ runner.temp }}/npm-candidate/*.tgz + path: ${{ runner.temp }}/npm-candidate/rdlabo-workers-hono-kit-*.tgz + if-no-files-found: error + retention-days: 7 + + - name: Upload timezone workspace artifact + uses: actions/upload-artifact@v7 + with: + name: ${{ steps.candidate.outputs.artifact }}-timezone + path: ${{ runner.temp }}/npm-candidate/rdlabo-workers-timezone-*.tgz if-no-files-found: error retention-days: 7 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 977cd4f..afe3e98 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -111,6 +111,10 @@ jobs: - name: Publish packages run: | + if node -e "process.exit(require('./packages/timezone/package.json').private ? 0 : 1)"; then + echo "::error::Release is disabled while @rdlabo/workers-timezone is private and unpublished" + exit 1 + fi VERSION="${{ steps.tag_version.outputs.version }}" IS_STABLE=$(echo "$VERSION" | grep -Eq '^[0-9]+\.[0-9]+\.[0-9]+$' && echo true || echo false) @@ -381,7 +385,11 @@ jobs: publish-candidate: needs: authorize-candidate - if: needs.authorize-candidate.outputs.allowed == 'true' + # Publishing remains explicitly disabled while the timezone workspace is private/unpublished. + # Candidate tarballs are still built and retained for review. + if: >- + needs.authorize-candidate.outputs.allowed == 'true' && + vars.WORKSPACE_NPM_PUBLISH_ENABLED == 'true' runs-on: ubuntu-latest concurrency: group: npm-${{ needs.authorize-candidate.outputs.dist-tag }}-${{ needs.authorize-candidate.outputs.head-sha }} @@ -492,6 +500,21 @@ jobs: expectedVersion !== process.env.VERSION ) { core.setFailed('Trusted package identity changed after authorization; rerun the release request.'); + return; + } + const { data: timezoneData } = await github.rest.repos.getContent({ + owner, + repo, + path: 'packages/timezone/package.json', + ref: defaultBranch, + }); + if (Array.isArray(timezoneData) || timezoneData.type !== 'file' || !timezoneData.content) { + core.setFailed('Trusted timezone package.json could not be read.'); + return; + } + const timezonePackage = JSON.parse(Buffer.from(timezoneData.content, 'base64').toString('utf8')); + if (timezonePackage.private !== false) { + core.setFailed('Candidate publishing is disabled while @rdlabo/workers-timezone is private.'); } - name: Setup Node.js diff --git a/.github/workflows/validation.yml b/.github/workflows/validation.yml index b932d0c..13a91c8 100644 --- a/.github/workflows/validation.yml +++ b/.github/workflows/validation.yml @@ -15,6 +15,25 @@ concurrency: cancel-in-progress: ${{ github.event_name == 'pull_request' }} jobs: + timezone: + runs-on: ubuntu-latest + strategy: + matrix: + node: [20, 22] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: ${{ matrix.node }} + cache: npm + - run: npm ci + - run: npm run format:check --workspace @rdlabo/workers-timezone + - run: npm run typecheck --workspace @rdlabo/workers-timezone + - run: npm run lint --workspace @rdlabo/workers-timezone + - run: npm test --workspace @rdlabo/workers-timezone + - run: npm run build --workspace @rdlabo/workers-timezone + - run: npm run test:package --workspace @rdlabo/workers-timezone + lint: runs-on: ubuntu-latest steps: @@ -24,7 +43,7 @@ jobs: node-version: 22 cache: npm cache-dependency-path: '**/package-lock.json' - - run: npm install + - run: npm ci - name: Typecheck run: npm run typecheck - name: Lint @@ -41,10 +60,12 @@ jobs: node-version: 22 cache: npm cache-dependency-path: '**/package-lock.json' - - run: npm install + - run: npm ci - name: Test run: npm run test env: CI: true - name: Build run: npm run build + - name: Package consumer smoke + run: npm run test:package diff --git a/AGENTS.md b/AGENTS.md index 982954f..7c69ec5 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -10,7 +10,10 @@ Shared infrastructure toolkit for Hono + Cloudflare Workers APIs. Published to n |---------|-------------|-------| | `.` | `@rdlabo/workers-hono-kit` | Web-standard only (middleware, HTTP helpers, Firebase, AWS, Stripe, AI Gateway, KV cache) | | `./db` | `@rdlabo/workers-hono-kit/db` | MySQL data layer (requires `mysql2` + `drizzle-orm` peers) | -| `./business-time` | `@rdlabo/workers-hono-kit/business-time` | JST 業務時刻(`toBusinessDateTime`, `normalizeBusinessDate`, `businessDateTimeInstant` 等) | +| `./business-time` | `@rdlabo/workers-hono-kit/business-time` | Deprecated compatibility re-export of `@rdlabo/workers-timezone` | + +The repository is an npm workspace. `packages/timezone` is the canonical implementation published +as `@rdlabo/workers-timezone`; the legacy `./business-time` subpath must remain a thin re-export. | `./offline` | `@rdlabo/workers-hono-kit/offline` | テーブル非依存のREST/DB method converter・replica wire・clock helpers | | `./testing` | `@rdlabo/workers-hono-kit/testing` | Test helpers (requires `mysql2` + `drizzle-orm` peers) | diff --git a/README.md b/README.md index ea9a577..4aded6b 100644 --- a/README.md +++ b/README.md @@ -27,13 +27,23 @@ Stripe is a direct dependency of the kit. The package is compiled ESM with decla | ---------------------------------------- | ------------------------------------------------------------------------------ | | `@rdlabo/workers-hono-kit` | HTTP, auth, errors, Firebase, AWS, AI, Stripe, KV, queues, realtime primitives | | `@rdlabo/workers-hono-kit/db` | Hyperdrive, MySQL, Drizzle, migrations, JST columns | -| `@rdlabo/workers-hono-kit/business-time` | JST business dates and date-times | +| `@rdlabo/workers-timezone` | IANA-timezone-aware dates and date-times (Asia/Tokyo by default) | +| `@rdlabo/workers-hono-kit/business-time` | Deprecated compatibility re-export of `@rdlabo/workers-timezone` | | `@rdlabo/workers-hono-kit/offline` | Offline replica wire, cursor, journal, and compatibility contracts | | `@rdlabo/workers-hono-kit/realtime` | Durable Object WebSocket and retry helpers | | `@rdlabo/workers-hono-kit/testing` | Test databases, auth helpers, fakes, and Stripe fixtures | Subpath imports keep optional database and testing dependencies out of the root runtime surface. +Set a deployment-wide timezone once when the Worker module starts. Helpers then use it whenever +their optional timezone argument is omitted: + +```ts +import { TIME_ZONES, initializeTimezone } from '@rdlabo/workers-timezone'; + +initializeTimezone({ timeZone: TIME_ZONES.NEW_YORK }); +``` + ## Documentation - [HTTP and Authentication](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/http-auth) @@ -43,23 +53,20 @@ Subpath imports keep optional database and testing dependencies out of the root - [API Reference](https://docs.rdlabo.dev/projects/workers-hono-kit/docs/api) + **Full documentation:** [https://docs.rdlabo.dev/projects/workers-hono-kit](https://docs.rdlabo.dev/projects/workers-hono-kit) ## Prerelease channels -An open, non-draft pull request can be published to the npm `beta` dist-tag after its `Validation` and `Package Candidate` workflows pass. A repository owner or maintainer must add a comment whose entire body is: +While `@rdlabo/workers-timezone` is private and unpublished, npm publication of both workspace +packages is disabled. Pull requests and merges still produce separate immutable candidate artifacts +for `@rdlabo/workers-hono-kit` and `@rdlabo/workers-timezone`, but `/beta`, automatic beta, `next`, +and stable publication are blocked. -```text -/beta -``` - -The request authorizes only the pull request head SHA that existed when the comment was added. The workflow revalidates the owner or maintainer permission and head SHA immediately before publishing. Any new commit requires CI to pass again and a fresh owner or maintainer `/beta` comment. Fork pull requests are supported. Pull requests that change a release-gating workflow cannot be beta-published until those workflow changes land on `main`. - -Beta versions use `-beta.pr.sha<12-character SHA>`. The candidate is built in a read-only workflow without npm publishing credentials. The privileged release workflow publishes only the validated immutable package artifact with lifecycle scripts disabled. A notification failure cannot invalidate a successful npm publish. - -When a pull request is merged into `main`, it is automatically published to `beta` only after the required CI and `Package Candidate` succeed for that exact merge commit. Direct pushes to `main` do not publish a candidate. - -Only `npm run release` creates a release tag. Stable `vX.Y.Z` tags publish to npm `latest`; revision/prerelease tags publish to `next`. Neither `beta` nor `next` publishing changes the npm `latest` dist-tag. +Publication may be enabled only after the timezone package becomes public, versions and dependency +ranges are synchronized, and release automation publishes timezone before hono-kit. Candidate +publication additionally requires the repository variable `WORKSPACE_NPM_PUBLISH_ENABLED=true` and +revalidates `packages/timezone/package.json` as non-private immediately before publishing. ## Maintainers @@ -68,4 +75,5 @@ Only `npm run release` creates a release tag. Stable `vX.Y.Z` tags publish to np ## License [MIT](./LICENSE) © rdlabo-dev + diff --git a/docs/api-business-time.md b/docs/api-business-time.md index a0fde39..acd83ad 100644 --- a/docs/api-business-time.md +++ b/docs/api-business-time.md @@ -1,33 +1,41 @@ -# API: `@rdlabo/workers-hono-kit/business-time` - -String-level JST business-time conversions (Workers UTC instant ↔ business calendar date / date-time), with **no `mysql2` / `drizzle-orm` dependency**. This is a different layer from the `./db` column helpers (which handle the MySQL wire format): the DB stays on JST, and the app handles JST explicitly through this module instead of relying implicitly on the connection `timezone`. - -| Export | Description | -| --- | --- | -| `today(ref?)` | The JST business calendar date (`YYYY-MM-DD`) of `ref` (defaults to now). | -| `toBusinessDate(instant)` | UTC instant → JST business calendar date (`YYYY-MM-DD`). | -| `normalizeBusinessDate(value)` | Normalize a `string` / `Date` / nullish to `YYYY-MM-DD`; a `YYYY-MM-DD` string passes through unchanged, nullish/empty/invalid → `null`. | -| `toBusinessDateTime(instant)` | UTC instant → JST business date-time (`YYYY-MM-DD HH:mm:ss`). | -| `parseBusinessDateTime(value)` | JST business date-time string → UTC instant (accepts a space or `T` separator). | -| `formatBusinessDateTime(instant, pattern?)` | Format an instant in the business TZ (Nest `helper.formatDate`-compatible tokens). | -| `startOfBusinessDay(date)` / `endOfBusinessDay(date)` | UTC instant of `00:00:00` / `23:59:59` on a JST business date. | -| `businessDateTimeInstant(date, time)` | JST business date + wall-clock time → UTC instant. | -| `addBusinessDays(date, days)` | Add calendar days to a JST business date. | -| `ageOnBusinessDate(birthDate, asOfDate?)` | Full years of age on a business date (`asOfDate` defaults to `today()`). | -| `DEFAULT_BUSINESS_DATETIME_PATTERN` | Default `formatBusinessDateTime` pattern (`YYYY-MM-DDThh:mm:ss`). | -| `BUSINESS_TIMEZONE` / `BusinessDate` / `BusinessDateTime` | JST timezone constant and the business-date / date-time string types. | +# API: `@rdlabo/workers-timezone` + +Timezone-aware calendar and wall-clock conversion for Cloudflare Workers, with no database or Node +runtime dependency. The uninitialized default remains `Asia/Tokyo` for compatibility. + +```ts +import { TIME_ZONES, initializeTimezone, toLocalDateTime } from '@rdlabo/workers-timezone'; + +initializeTimezone({ timeZone: TIME_ZONES.NEW_YORK }); +toLocalDateTime(new Date('2026-07-01T13:00:00Z')); // '2026-07-01 09:00:00' +``` + +Initialize once during module evaluation using deployment-wide static configuration. Do not mutate +the default per request, user, or tenant. Every conversion function accepts an explicit IANA +timezone override without changing the module-instance default. + +| Export | Description | +| ----------------------------------------------- | -------------------------------------------------------------------------------- | +| `initializeTimezone(config)` | Set the module-instance default once; repeated identical initialization is safe. | +| `getTimezoneConfig()` | Return the active configuration. | +| `toLocalDate(instant, timeZone?)` | Instant to local `YYYY-MM-DD`. | +| `toLocalDateTime(instant, timeZone?)` | Instant to local `YYYY-MM-DD HH:mm:ss`. | +| `localDateTimeToInstant(date, time, timeZone?)` | Local calendar date and wall clock to an instant. | +| `startOfDay` / `endOfDay` | First or final whole second of a local calendar day. | +| `addDays(date, days)` | Add calendar days without assuming a 24-hour day. | +| `TIME_ZONES` / `TimeZone` | Common typed constants and the open IANA timezone type. | + +IANA rules determine daylight-saving and historical offsets. A skipped local clock throws +`RangeError`; when a clock occurs twice during a DST overlap, the earlier instant is selected. + +## Legacy compatibility + +`@rdlabo/workers-hono-kit/business-time` is deprecated and re-exports the same module instance from +`@rdlabo/workers-timezone`. Existing names such as `today`, `normalizeBusinessDate`, +`toBusinessDateTime`, `businessDateTimeInstant`, `formatBusinessDateTime`, and +`ageOnBusinessDate` remain available during migration. ```ts -import { - toBusinessDate, - toBusinessDateTime, - formatBusinessDateTime, - addBusinessDays, -} from '@rdlabo/workers-hono-kit/business-time'; - -const now = new Date('2026-07-05T21:00:00Z'); -toBusinessDate(now); // '2026-07-06' (JST) -toBusinessDateTime(now); // '2026-07-06 06:00:00' -formatBusinessDateTime(now); // '2026-07-06T06:00:00' -addBusinessDays('2026-07-06', 3); // '2026-07-09' +// Deprecated; migrate the import path when practical. +import { toBusinessDateTime } from '@rdlabo/workers-hono-kit/business-time'; ``` diff --git a/package-lock.json b/package-lock.json index 8c33b92..72e3671 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,7 +8,11 @@ "name": "@rdlabo/workers-hono-kit", "version": "0.10.6", "license": "MIT", + "workspaces": [ + "packages/*" + ], "dependencies": { + "@rdlabo/workers-timezone": "0.1.0", "stripe": "^20.4.1" }, "bin": { @@ -1587,6 +1591,10 @@ "typescript": ">=4.8.4" } }, + "node_modules/@rdlabo/workers-timezone": { + "resolved": "packages/timezone", + "link": true + }, "node_modules/@rollup/rollup-android-arm-eabi": { "version": "4.62.2", "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.62.2.tgz", @@ -10629,6 +10637,22 @@ "dev": true, "license": "MIT", "peer": true + }, + "packages/timezone": { + "name": "@rdlabo/workers-timezone", + "version": "0.1.0", + "license": "MIT", + "devDependencies": { + "@hono/eslint-config": "^2.1.0", + "eslint": "^9.39.4", + "prettier": "^3.8.4", + "typescript": "~5.6.2", + "typescript-eslint": "^8.61.1", + "vitest": "^2.1.0" + }, + "engines": { + "node": ">=20.0.0" + } } } } diff --git a/package.json b/package.json index d27a63f..1159d08 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,9 @@ "access": "public" }, "type": "module", + "workspaces": [ + "packages/*" + ], "description": "Infrastructure toolkit for building APIs on Hono + Cloudflare Workers: Firebase ID-token verification (jose), AWS Secrets Manager (aws4fetch), ETag/validation/zod-coerce middleware, deadlock retry, and shared HTTP helpers.", "author": "Masahiko Sakakibara", "license": "MIT", @@ -86,18 +89,20 @@ } }, "scripts": { - "build": "node scripts/clean-dist.mjs && tsc -p tsconfig.build.json", + "build": "npm run build --workspace @rdlabo/workers-timezone && node scripts/clean-dist.mjs && tsc -p tsconfig.build.json", "prepare": "npm run build", - "typecheck": "tsc --noEmit", - "test": "vitest run", + "typecheck": "npm run typecheck --workspace @rdlabo/workers-timezone && tsc --noEmit", + "test": "npm test --workspace @rdlabo/workers-timezone && vitest run", "test:watch": "vitest", - "lint": "eslint \"src/**/*.ts\"", - "lint:fix": "eslint \"src/**/*.ts\" --fix", - "format": "prettier --write \"src/**/*.ts\"", - "format:check": "prettier --check \"src/**/*.ts\"", + "lint": "npm run lint --workspace @rdlabo/workers-timezone && eslint \"src/**/*.ts\"", + "lint:fix": "npm run lint:fix --workspace @rdlabo/workers-timezone && eslint \"src/**/*.ts\" --fix", + "test:package": "node scripts/workspace-package-smoke.mjs", + "format": "prettier --write \"src/**/*.ts\" \"packages/**/*.ts\" \"packages/**/*.mjs\" \"packages/**/*.md\"", + "format:check": "prettier --check \"src/**/*.ts\" \"packages/**/*.ts\" \"packages/**/*.mjs\" \"packages/**/*.md\"", "release": "np --no-tests --no-publish" }, "dependencies": { + "@rdlabo/workers-timezone": "0.1.0", "stripe": "^20.4.1" }, "peerDependencies": { diff --git a/packages/timezone/.gitignore b/packages/timezone/.gitignore new file mode 100644 index 0000000..1ab415f --- /dev/null +++ b/packages/timezone/.gitignore @@ -0,0 +1,3 @@ +node_modules/ +dist/ +*.tgz diff --git a/packages/timezone/LICENSE b/packages/timezone/LICENSE new file mode 100644 index 0000000..ef88587 --- /dev/null +++ b/packages/timezone/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 rdlabo-dev + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/timezone/README.md b/packages/timezone/README.md new file mode 100644 index 0000000..841ce6d --- /dev/null +++ b/packages/timezone/README.md @@ -0,0 +1,50 @@ +# @rdlabo/workers-timezone + +Timezone-aware calendar and wall-clock utilities for Cloudflare Workers. Workers execute with UTC +instants; this package lets an application select an IANA timezone once per isolate and handles DST +when converting between instants and local dates. + +> This package is under development and is not published to npm yet. + +## Usage + +```ts +import { TIME_ZONES, initializeTimezone, localDateTimeToInstant, toLocalDateTime } from '@rdlabo/workers-timezone'; + +initializeTimezone({ timeZone: TIME_ZONES.NEW_YORK }); + +toLocalDateTime(new Date('2026-07-01T13:00:00Z')); +// '2026-07-01 09:00:00' + +localDateTimeToInstant('2026-07-01', '09:00:00'); +// 2026-07-01T13:00:00.000Z +``` + +`initializeTimezone` is idempotent for the same timezone and rejects attempts to switch the same +module instance to another timezone. Call it during module evaluation with deployment-wide static +configuration—not with request-, tenant-, or user-specific data. Every conversion function also +accepts an explicit timezone override without changing the configured default. For compatibility +with `@rdlabo/workers-hono-kit/business-time`, the uninitialized default is `Asia/Tokyo`. + +At a DST overlap, conversion selects the earlier occurrence. Local wall clocks skipped by a DST +transition are rejected with `RangeError`. + +`TIME_ZONES` is a typed constant containing common choices for editor autocomplete. Any IANA ID +supported by the Workers `Intl` runtime can also be supplied as a string and is validated at runtime. + +The package also exports the existing `workers-hono-kit/business-time` function names as +compatibility aliases, including `toBusinessDateTime`, `businessDateTimeInstant`, `today`, +`normalizeBusinessDate`, `formatBusinessDateTime`, and `ageOnBusinessDate`. + +## Development + +```sh +npm install +npm run typecheck +npm test +npm run build +``` + +## License + +MIT diff --git a/packages/timezone/eslint.config.mjs b/packages/timezone/eslint.config.mjs new file mode 100644 index 0000000..b0fa555 --- /dev/null +++ b/packages/timezone/eslint.config.mjs @@ -0,0 +1,22 @@ +// @ts-check +import baseConfig from '@hono/eslint-config'; +import tseslint from 'typescript-eslint'; + +export default tseslint.config( + { + ignores: ['dist'], + }, + ...baseConfig, + { + languageOptions: { + parserOptions: { + projectService: true, + tsconfigRootDir: import.meta.dirname, + }, + }, + rules: { + '@typescript-eslint/prefer-nullish-coalescing': 'off', + 'import-x/extensions': ['error', 'ignorePackages', { ts: 'never', js: 'always' }], + }, + }, +); diff --git a/packages/timezone/package.json b/packages/timezone/package.json new file mode 100644 index 0000000..d86c2cd --- /dev/null +++ b/packages/timezone/package.json @@ -0,0 +1,45 @@ +{ + "name": "@rdlabo/workers-timezone", + "version": "0.1.0", + "private": true, + "type": "module", + "description": "Timezone-aware date and business-time utilities for Cloudflare Workers", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/rdlabo-dev/workers-hono-kit.git", + "directory": "packages/timezone" + }, + "files": [ + "dist" + ], + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:package": "node scripts/package-smoke.mjs", + "lint": "eslint \"src/**/*.ts\"", + "lint:fix": "eslint \"src/**/*.ts\" --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepack": "npm run build" + }, + "engines": { + "node": ">=20.0.0" + }, + "devDependencies": { + "@hono/eslint-config": "^2.1.0", + "eslint": "^9.39.4", + "prettier": "^3.8.4", + "typescript": "~5.6.2", + "typescript-eslint": "^8.61.1", + "vitest": "^2.1.0" + } +} diff --git a/packages/timezone/scripts/package-smoke.mjs b/packages/timezone/scripts/package-smoke.mjs new file mode 100644 index 0000000..a46a284 --- /dev/null +++ b/packages/timezone/scripts/package-smoke.mjs @@ -0,0 +1,60 @@ +import { execFileSync } from 'node:child_process'; +import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const root = resolve(fileURLToPath(new URL('..', import.meta.url))); +const temporaryDirectory = mkdtempSync(join(tmpdir(), 'workers-timezone-smoke-')); +const commandEnvironment = { + ...process.env, + npm_config_cache: join(temporaryDirectory, 'npm-cache'), +}; +const localTypeScript = join(root, 'node_modules', '.bin', 'tsc'); +const typeScript = existsSync(localTypeScript) + ? localTypeScript + : join(root, '..', '..', 'node_modules', '.bin', 'tsc'); + +try { + const packResult = JSON.parse( + execFileSync('npm', ['pack', '--json', '--pack-destination', temporaryDirectory], { + cwd: root, + encoding: 'utf8', + env: commandEnvironment, + }), + ); + const tarball = join(temporaryDirectory, packResult[0].filename); + writeFileSync(join(temporaryDirectory, 'package.json'), '{"private":true,"type":"module"}\n'); + execFileSync('npm', ['install', '--ignore-scripts', tarball], { + cwd: temporaryDirectory, + stdio: 'inherit', + env: commandEnvironment, + }); + writeFileSync( + join(temporaryDirectory, 'smoke.mjs'), + `import { TIME_ZONES, toLocalDateTime } from "@rdlabo/workers-timezone"; +if (toLocalDateTime(new Date("2026-01-01T00:00:00Z"), TIME_ZONES.TOKYO) !== "2026-01-01 09:00:00") process.exit(1); +`, + ); + writeFileSync( + join(temporaryDirectory, 'smoke.ts'), + `import { TIME_ZONES, type TimeZone } from "@rdlabo/workers-timezone"; +const timeZone: TimeZone = TIME_ZONES.LONDON; +void timeZone; +`, + ); + writeFileSync( + join(temporaryDirectory, 'tsconfig.json'), + '{"compilerOptions":{"strict":true,"module":"ESNext","moduleResolution":"Bundler","target":"ES2022","noEmit":true},"include":["smoke.ts"]}\n', + ); + execFileSync('node', ['smoke.mjs'], { + cwd: temporaryDirectory, + stdio: 'inherit', + }); + execFileSync(typeScript, ['-p', 'tsconfig.json'], { + cwd: temporaryDirectory, + stdio: 'inherit', + }); +} finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/packages/timezone/src/index.spec.ts b/packages/timezone/src/index.spec.ts new file mode 100644 index 0000000..4771c99 --- /dev/null +++ b/packages/timezone/src/index.spec.ts @@ -0,0 +1,110 @@ +import { describe, expect, it } from 'vitest'; +import { + TIME_ZONES, + addDays, + ageOnBusinessDate, + endOfDay, + formatBusinessDateTime, + getTimezoneConfig, + initializeTimezone, + localDateTimeToInstant, + normalizeBusinessDate, + parseBusinessDateTime, + startOfDay, + toLocalDate, + toLocalDateTime, +} from './index.js'; + +describe('instant to local time', () => { + it('supports IANA zones across the world', () => { + const instant = new Date('2026-01-01T00:30:00Z'); + expect(toLocalDateTime(instant, TIME_ZONES.LOS_ANGELES)).toBe('2025-12-31 16:30:00'); + expect(toLocalDateTime(instant, TIME_ZONES.LONDON)).toBe('2026-01-01 00:30:00'); + expect(toLocalDateTime(instant, TIME_ZONES.KOLKATA)).toBe('2026-01-01 06:00:00'); + expect(toLocalDate(instant, TIME_ZONES.TOKYO)).toBe('2026-01-01'); + }); + + it('keeps Asia/Tokyo as the migration-safe default', () => { + expect(getTimezoneConfig()).toEqual({ timeZone: 'Asia/Tokyo' }); + expect(toLocalDateTime(new Date('2026-01-01T00:30:00Z'))).toBe('2026-01-01 09:30:00'); + }); + + it('rejects invalid instants and timezone identifiers', () => { + expect(() => toLocalDateTime(new Date('invalid'))).toThrow('Invalid instant'); + expect(() => toLocalDateTime(new Date(), 'Mars/Olympus_Mons')).toThrow(RangeError); + }); +}); + +describe('local time to instant', () => { + it('applies summer and winter DST offsets', () => { + expect(localDateTimeToInstant('2026-07-01', '09:00', TIME_ZONES.NEW_YORK).toISOString()).toBe( + '2026-07-01T13:00:00.000Z', + ); + expect(localDateTimeToInstant('2026-01-01', '09:00', TIME_ZONES.NEW_YORK).toISOString()).toBe( + '2026-01-01T14:00:00.000Z', + ); + }); + + it('rejects skipped clocks and selects the earlier repeated clock', () => { + expect(() => localDateTimeToInstant('2026-03-08', '02:30', TIME_ZONES.NEW_YORK)).toThrow( + 'Local date-time does not exist', + ); + expect(localDateTimeToInstant('2026-11-01', '01:30', TIME_ZONES.NEW_YORK).toISOString()).toBe( + '2026-11-01T05:30:00.000Z', + ); + }); + + it('handles DST day boundaries and calendar arithmetic', () => { + const start = startOfDay('2026-03-08', TIME_ZONES.NEW_YORK); + const next = startOfDay('2026-03-09', TIME_ZONES.NEW_YORK); + expect(next.getTime() - start.getTime()).toBe(23 * 60 * 60 * 1000); + expect(endOfDay('2026-03-08', TIME_ZONES.NEW_YORK).toISOString()).toBe('2026-03-09T03:59:59.000Z'); + expect(addDays('2024-02-28', 1)).toBe('2024-02-29'); + }); + + it('supports non-hour offsets, half-hour DST, +14, and skipped dates', () => { + expect(toLocalDateTime(new Date('2026-01-01T00:00:00Z'), 'Asia/Kathmandu')).toBe('2026-01-01 05:45:00'); + expect(toLocalDateTime(new Date('2026-01-01T00:00:00Z'), 'Pacific/Kiritimati')).toBe('2026-01-01 14:00:00'); + expect(() => localDateTimeToInstant('2026-10-04', '02:15', 'Australia/Lord_Howe')).toThrow('does not exist'); + expect(() => startOfDay('2011-12-30', 'Pacific/Apia')).toThrow('does not exist'); + }); + + it('rejects malformed or impossible calendar values', () => { + expect(() => localDateTimeToInstant('2026/01/01', '09:00', 'UTC')).toThrow('Invalid BusinessDate'); + expect(() => localDateTimeToInstant('2026-02-30', '09:00', 'UTC')).toThrow('Invalid BusinessDate'); + expect(() => localDateTimeToInstant('2026-01-01', '25:00', 'UTC')).toThrow('does not exist'); + expect(() => addDays('2026-02-30', 0)).toThrow('Invalid BusinessDate'); + expect(() => addDays('2026-01-01', 1.5)).toThrow('Invalid day count'); + expect(() => addDays('2026-01-01', Number.NaN)).toThrow('Invalid day count'); + expect(addDays('0099-01-01', 1)).toBe('0099-01-02'); + expect(() => addDays('9999-12-31', 1)).toThrow('outside 0001-01-01..9999-12-31'); + expect(() => addDays('2026-01-01', Number.MAX_SAFE_INTEGER)).toThrow('outside 0001-01-01..9999-12-31'); + }); +}); + +describe('workers-hono-kit compatibility', () => { + it('preserves normalization, formatting, parsing, and age helpers', () => { + expect(normalizeBusinessDate('2026-02-30')).toBeNull(); + expect(normalizeBusinessDate('2026-07-05T20:00:00Z')).toBe('2026-07-06'); + const instant = new Date('2026-01-01T00:00:00.005Z'); + expect(formatBusinessDateTime(instant, 'YYYY-MM-DDThh:mm:ss.SSS')).toBe('2026-01-01T09:00:00.005'); + expect(parseBusinessDateTime('2026-01-01 09:00:00').toISOString()).toBe('2026-01-01T00:00:00.000Z'); + expect(ageOnBusinessDate('2000-06-16', '2026-06-15')).toBe(25); + }); +}); + +describe('initializeTimezone', () => { + it('configures the isolate once and permits only idempotent initialization', () => { + expect(initializeTimezone({ timeZone: TIME_ZONES.NEW_YORK })).toEqual({ + timeZone: TIME_ZONES.NEW_YORK, + }); + expect(toLocalDateTime(new Date('2026-07-01T13:00:00Z'))).toBe('2026-07-01 09:00:00'); + expect(toLocalDateTime(new Date('2026-07-01T13:00:00Z'), TIME_ZONES.LONDON)).toBe('2026-07-01 14:00:00'); + expect(initializeTimezone({ timeZone: TIME_ZONES.NEW_YORK })).toEqual({ + timeZone: TIME_ZONES.NEW_YORK, + }); + expect(() => initializeTimezone({ timeZone: TIME_ZONES.LONDON })).toThrow( + 'Timezone is already initialized with America/New_York', + ); + }); +}); diff --git a/packages/timezone/src/index.ts b/packages/timezone/src/index.ts new file mode 100644 index 0000000..67d73e6 --- /dev/null +++ b/packages/timezone/src/index.ts @@ -0,0 +1,290 @@ +import { BUSINESS_TIMEZONE, TIME_ZONES } from './types.js'; +import type { + BusinessDate, + BusinessDateTime, + BusinessTimeConfig, + BusinessTimeZone, + TimeZone, + TimezoneConfig, +} from './types.js'; + +export { + BUSINESS_TIMEZONE, + TIME_ZONES, + type BusinessDate, + type BusinessDateTime, + type BusinessTimeConfig, + type BusinessTimeZone, + type TimeZone, + type TimezoneConfig, +}; + +interface DateTimeFields { + year: number; + month: number; + day: number; + hour: number; + minute: number; + second: number; +} + +const formatters = new Map(); +let configuredTimeZone: TimeZone | undefined; + +function formatter(timeZone: TimeZone): Intl.DateTimeFormat { + let result = formatters.get(timeZone); + if (!result) { + result = new Intl.DateTimeFormat('en-US', { + timeZone, + calendar: 'gregory', + numberingSystem: 'latn', + hourCycle: 'h23', + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + second: '2-digit', + }); + formatters.set(timeZone, result); + } + return result; +} + +function activeTimeZone(timeZone?: TimeZone): TimeZone { + return timeZone ?? configuredTimeZone ?? TIME_ZONES.TOKYO; +} + +function utcMillis(fields: DateTimeFields): number { + const result = new Date(0); + result.setUTCFullYear(fields.year, fields.month - 1, fields.day); + result.setUTCHours(fields.hour, fields.minute, fields.second, 0); + return result.getTime(); +} + +function fieldsAt(instant: Date, timeZone: TimeZone): DateTimeFields { + if (Number.isNaN(instant.getTime())) { + throw new RangeError('Invalid instant'); + } + const parts = Object.fromEntries( + formatter(timeZone) + .formatToParts(instant) + .filter((part) => part.type !== 'literal') + .map((part) => [part.type, Number(part.value)]), + ); + return { + year: parts.year, + month: parts.month, + day: parts.day, + hour: parts.hour, + minute: parts.minute, + second: parts.second, + }; +} + +function sameFields(left: DateTimeFields, right: DateTimeFields): boolean { + return Object.keys(left).every((key) => left[key as keyof DateTimeFields] === right[key as keyof DateTimeFields]); +} + +function instantAt(fields: DateTimeFields, timeZone: TimeZone): Date { + const naive = utcMillis(fields); + const offsets = new Set(); + for (const hours of [-36, 0, 36]) { + const probe = new Date(naive + hours * 3_600_000); + const local = fieldsAt(probe, timeZone); + offsets.add(utcMillis(local) - probe.getTime()); + } + const match = [...offsets] + .map((offset) => new Date(naive - offset)) + .filter((candidate) => sameFields(fieldsAt(candidate, timeZone), fields)) + .sort((a, b) => a.getTime() - b.getTime()) + .at(0); + if (!match) { + throw new RangeError(`Local date-time does not exist in ${timeZone}`); + } + return match; +} + +const pad2 = (value: number): string => String(value).padStart(2, '0'); +const padYear = (value: number): string => String(value).padStart(4, '0'); + +function parseDate(date: BusinessDate): [number, number, number] { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); + if (!match) { + throw new RangeError(`Invalid BusinessDate: ${date}`); + } + const fields: DateTimeFields = { + year: Number(match[1]), + month: Number(match[2]), + day: Number(match[3]), + hour: 0, + minute: 0, + second: 0, + }; + if (!sameFields(fields, fieldsAt(new Date(utcMillis(fields)), TIME_ZONES.UTC))) { + throw new RangeError(`Invalid BusinessDate: ${date}`); + } + return [fields.year, fields.month, fields.day]; +} + +function parseTime(time: string): [number, number, number] { + const match = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time); + if (!match) { + throw new RangeError(`Invalid local time: ${time}`); + } + const [, hour, minute, seconds = '0'] = match; + return [Number(hour), Number(minute), Number(seconds)]; +} + +/** Set the default timezone once for the lifetime of this module instance. */ +export function initializeTimezone(config: TimezoneConfig): Readonly { + formatter(config.timeZone); + if (configuredTimeZone && configuredTimeZone !== config.timeZone) { + throw new Error(`Timezone is already initialized with ${configuredTimeZone}`); + } + configuredTimeZone = config.timeZone; + return Object.freeze({ timeZone: configuredTimeZone }); +} + +/** Return this module instance's active timezone configuration. */ +export function getTimezoneConfig(): Readonly { + return Object.freeze({ timeZone: activeTimeZone() }); +} + +/** Return an instant as a calendar date in the selected timezone. */ +export function toLocalDate(instant: Date, timeZone?: TimeZone): BusinessDate { + const fields = fieldsAt(instant, activeTimeZone(timeZone)); + return `${padYear(fields.year)}-${pad2(fields.month)}-${pad2(fields.day)}`; +} + +/** Return an instant as `YYYY-MM-DD HH:mm:ss` in the selected timezone. */ +export function toLocalDateTime(instant: Date, timeZone?: TimeZone): BusinessDateTime { + const fields = fieldsAt(instant, activeTimeZone(timeZone)); + return `${padYear(fields.year)}-${pad2(fields.month)}-${pad2(fields.day)} ${pad2(fields.hour)}:${pad2(fields.minute)}:${pad2(fields.second)}`; +} + +/** Resolve a timezone-local calendar date and wall-clock time to a UTC instant. */ +export function localDateTimeToInstant(date: BusinessDate, time: string, timeZone?: TimeZone): Date { + const [year, month, day] = parseDate(date); + const [hour, minute, second] = parseTime(time); + return instantAt({ year, month, day, hour, minute, second }, activeTimeZone(timeZone)); +} + +/** Return the instant at the start of a timezone-local calendar day. */ +export function startOfDay(date: BusinessDate, timeZone?: TimeZone): Date { + return localDateTimeToInstant(date, '00:00:00', timeZone); +} + +/** Return the instant at the final whole second of a timezone-local calendar day. */ +export function endOfDay(date: BusinessDate, timeZone?: TimeZone): Date { + return localDateTimeToInstant(date, '23:59:59', timeZone); +} + +/** Add calendar days without assuming that every local day contains 24 hours. */ +export function addDays(date: BusinessDate, days: number): BusinessDate { + if (!Number.isFinite(days) || !Number.isInteger(days)) { + throw new RangeError(`Invalid day count: ${days}`); + } + const [year, month, day] = parseDate(date); + const result = new Date(0); + result.setUTCFullYear(year, month - 1, day + days); + result.setUTCHours(0, 0, 0, 0); + const resultYear = result.getUTCFullYear(); + if (!Number.isFinite(result.getTime()) || resultYear < 1 || resultYear > 9999) { + throw new RangeError('Calendar result is outside 0001-01-01..9999-12-31'); + } + return `${padYear(resultYear)}-${pad2(result.getUTCMonth() + 1)}-${pad2(result.getUTCDate())}`; +} + +/** Compatibility alias for `toLocalDate`. */ +export const toBusinessDate = toLocalDate; +/** Compatibility alias for `toLocalDateTime`. */ +export const toBusinessDateTime = toLocalDateTime; +/** Compatibility alias for `localDateTimeToInstant`. */ +export const businessDateTimeInstant = localDateTimeToInstant; +/** Compatibility alias for `startOfDay`. */ +export const startOfBusinessDay = startOfDay; +/** Compatibility alias for `endOfDay`. */ +export const endOfBusinessDay = endOfDay; +/** Compatibility alias for `addDays`. */ +export const addBusinessDays = addDays; + +/** Return today's calendar date in the active or explicitly selected timezone. */ +export function today(reference: Date = new Date(), timeZone?: TimeZone): BusinessDate { + return toLocalDate(reference, timeZone); +} + +/** Normalize a date-only value or instant-like input to a timezone-local calendar date. */ +export function normalizeBusinessDate( + value: string | Date | null | undefined, + timeZone?: TimeZone, +): BusinessDate | null { + if (value == null) { + return null; + } + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : toLocalDate(value, timeZone); + } + const trimmed = value.trim(); + if (!trimmed) { + return null; + } + if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { + try { + parseDate(trimmed); + return trimmed; + } catch { + return null; + } + } + const instant = new Date(trimmed); + return Number.isNaN(instant.getTime()) ? null : toLocalDate(instant, timeZone); +} + +/** Default token pattern used by `formatBusinessDateTime`. */ +export const DEFAULT_BUSINESS_DATETIME_PATTERN = 'YYYY-MM-DDThh:mm:ss' as const; + +/** Format an instant with `YYYY`, `MM`, `DD`, `hh`, `mm`, `ss`, and `S` tokens. */ +export function formatBusinessDateTime( + instant: Date, + pattern: string = DEFAULT_BUSINESS_DATETIME_PATTERN, + timeZone?: TimeZone, +): string { + const [date, time] = toLocalDateTime(instant, timeZone).split(' '); + const [year, month, day] = date.split('-'); + const [hour, minute, second] = time.split(':'); + let output = pattern + .replace(/YYYY/g, year) + .replace(/MM/g, month) + .replace(/DD/g, day) + .replace(/hh/g, hour) + .replace(/mm/g, minute) + .replace(/ss/g, second); + const milliseconds = String(instant.getUTCMilliseconds()).padStart(3, '0'); + let index = 0; + output = output.replace(/S/g, () => milliseconds[index++] ?? ''); + return output; +} + +/** Parse a timezone-local `YYYY-MM-DD HH:mm:ss` value into a UTC instant. */ +export function parseBusinessDateTime(value: BusinessDateTime, timeZone?: TimeZone): Date { + const match = /^(\d{4}-\d{2}-\d{2})[ T](\d{2}:\d{2}:\d{2})$/.exec(value); + if (!match) { + throw new RangeError(`Invalid BusinessDateTime: ${value}`); + } + return localDateTimeToInstant(match[1], match[2], timeZone); +} + +/** Calculate completed years of age on a calendar date. */ +export function ageOnBusinessDate(birthDate: BusinessDate, asOfDate: BusinessDate = today()): number { + const [birthYear, birthMonth, birthDay] = parseDate(birthDate); + const [year, month, day] = parseDate(asOfDate); + return year - birthYear - (month < birthMonth || (month === birthMonth && day < birthDay) ? 1 : 0); +} + +/** Compatibility alias for `TIME_ZONES`. */ +export const BUSINESS_TIME_ZONES = TIME_ZONES; +/** Compatibility alias for `initializeTimezone`. */ +export const initializeBusinessTime = initializeTimezone; +/** Compatibility alias for `getTimezoneConfig`. */ +export const getBusinessTimeConfig = getTimezoneConfig; diff --git a/packages/timezone/src/types.ts b/packages/timezone/src/types.ts new file mode 100644 index 0000000..771efc0 --- /dev/null +++ b/packages/timezone/src/types.ts @@ -0,0 +1,48 @@ +/** Common IANA timezone identifiers with editor autocomplete. */ +export const TIME_ZONES = { + UTC: 'UTC', + TOKYO: 'Asia/Tokyo', + SEOUL: 'Asia/Seoul', + SHANGHAI: 'Asia/Shanghai', + HONG_KONG: 'Asia/Hong_Kong', + SINGAPORE: 'Asia/Singapore', + KOLKATA: 'Asia/Kolkata', + DUBAI: 'Asia/Dubai', + LONDON: 'Europe/London', + PARIS: 'Europe/Paris', + BERLIN: 'Europe/Berlin', + NEW_YORK: 'America/New_York', + CHICAGO: 'America/Chicago', + DENVER: 'America/Denver', + LOS_ANGELES: 'America/Los_Angeles', + SAO_PAULO: 'America/Sao_Paulo', + SYDNEY: 'Australia/Sydney', + AUCKLAND: 'Pacific/Auckland', +} as const; + +/** Legacy default timezone descriptor retained for workers-hono-kit compatibility. */ +export const BUSINESS_TIMEZONE = { + iana: TIME_ZONES.TOKYO, + offsetMinutes: 540, +} as const; + +/** An IANA timezone supported by the Workers `Intl` runtime. */ +export type TimeZone = (typeof TIME_ZONES)[keyof typeof TIME_ZONES] | (string & { readonly __timeZone?: never }); + +/** Isolate-wide default timezone configuration. */ +export interface TimezoneConfig { + /** IANA timezone identifier, for example `America/New_York`. */ + timeZone: TimeZone; +} + +/** Compatibility name for `TimeZone`. */ +export type BusinessTimeZone = TimeZone; + +/** Compatibility name for `TimezoneConfig`. */ +export type BusinessTimeConfig = TimezoneConfig; + +/** A calendar date in `YYYY-MM-DD` form. */ +export type BusinessDate = string; + +/** A timezone-local wall clock in MySQL-compatible `YYYY-MM-DD HH:mm:ss` form. */ +export type BusinessDateTime = string; diff --git a/packages/timezone/tsconfig.build.json b/packages/timezone/tsconfig.build.json new file mode 100644 index 0000000..51406ec --- /dev/null +++ b/packages/timezone/tsconfig.build.json @@ -0,0 +1,10 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "noEmit": false, + "declaration": true, + "outDir": "dist", + "rootDir": "src" + }, + "exclude": ["src/**/*.spec.ts"] +} diff --git a/packages/timezone/tsconfig.json b/packages/timezone/tsconfig.json new file mode 100644 index 0000000..ebe5a41 --- /dev/null +++ b/packages/timezone/tsconfig.json @@ -0,0 +1,15 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "Bundler", + "lib": ["ES2022", "ES2022.Intl"], + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "verbatimModuleSyntax": true, + "skipLibCheck": true, + "noEmit": true + }, + "include": ["src"] +} diff --git a/scripts/workspace-package-smoke.mjs b/scripts/workspace-package-smoke.mjs new file mode 100644 index 0000000..a6e2809 --- /dev/null +++ b/scripts/workspace-package-smoke.mjs @@ -0,0 +1,68 @@ +import { execFileSync } from 'node:child_process'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; + +const root = new URL('..', import.meta.url).pathname; +const temporaryDirectory = mkdtempSync(join(tmpdir(), 'workers-workspace-smoke-')); +const commandEnvironment = { + ...process.env, + npm_config_cache: join(temporaryDirectory, 'npm-cache'), +}; + +function pack(workspace) { + const args = ['pack', '--json', '--pack-destination', temporaryDirectory]; + if (workspace) { + args.push('--workspace', workspace); + } + const result = JSON.parse( + execFileSync('npm', args, { + cwd: root, + encoding: 'utf8', + env: commandEnvironment, + }), + ); + return join(temporaryDirectory, result[0].filename); +} + +try { + const timezoneTarball = pack('@rdlabo/workers-timezone'); + const kitTarball = pack(); + writeFileSync(join(temporaryDirectory, 'package.json'), '{"private":true,"type":"module"}\n'); + execFileSync('npm', ['install', '--ignore-scripts', timezoneTarball, kitTarball], { + cwd: temporaryDirectory, + stdio: 'inherit', + env: commandEnvironment, + }); + writeFileSync( + join(temporaryDirectory, 'smoke.mjs'), + `import { initializeTimezone, toLocalDateTime } from "@rdlabo/workers-timezone"; +import { getTimezoneConfig, toBusinessDateTime } from "@rdlabo/workers-hono-kit/business-time"; +initializeTimezone({ timeZone: "America/New_York" }); +const instant = new Date("2026-07-01T13:00:00Z"); +if (toLocalDateTime(instant) !== "2026-07-01 09:00:00") process.exit(1); +if (toBusinessDateTime(instant) !== "2026-07-01 09:00:00") process.exit(1); +if (getTimezoneConfig().timeZone !== "America/New_York") process.exit(1); +`, + ); + writeFileSync( + join(temporaryDirectory, 'smoke.ts'), + `import { type TimeZone } from "@rdlabo/workers-timezone"; +import { type BusinessTimeZone } from "@rdlabo/workers-hono-kit/business-time"; +const current: TimeZone = "Europe/London"; +const legacy: BusinessTimeZone = current; +void legacy; +`, + ); + writeFileSync( + join(temporaryDirectory, 'tsconfig.json'), + '{"compilerOptions":{"strict":true,"module":"ESNext","moduleResolution":"Bundler","target":"ES2022","noEmit":true,"skipLibCheck":true},"include":["smoke.ts"]}\n', + ); + execFileSync('node', ['smoke.mjs'], { cwd: temporaryDirectory, stdio: 'inherit' }); + execFileSync(join(root, 'node_modules', '.bin', 'tsc'), ['-p', 'tsconfig.json'], { + cwd: temporaryDirectory, + stdio: 'inherit', + }); +} finally { + rmSync(temporaryDirectory, { recursive: true, force: true }); +} diff --git a/src/business-time/business-time.spec.ts b/src/business-time/business-time.spec.ts index ea0021e..d30be2b 100644 --- a/src/business-time/business-time.spec.ts +++ b/src/business-time/business-time.spec.ts @@ -1,10 +1,14 @@ +import * as canonicalExports from '@rdlabo/workers-timezone'; import { describe, it, expect } from 'vitest'; +import * as legacyExports from './index.js'; import { addBusinessDays, ageOnBusinessDate, businessDateTimeInstant, endOfBusinessDay, formatBusinessDateTime, + getBusinessTimeConfig, + initializeBusinessTime, normalizeBusinessDate, parseBusinessDateTime, startOfBusinessDay, @@ -13,6 +17,17 @@ import { toBusinessDateTime, } from './index.js'; +describe('legacy export parity', () => { + it('canonical packageの全runtime exportを同一参照でre-exportする', () => { + expect(Object.keys(legacyExports).sort()).toEqual(Object.keys(canonicalExports).sort()); + for (const name of Object.keys(canonicalExports)) { + expect(legacyExports[name as keyof typeof legacyExports]).toBe( + canonicalExports[name as keyof typeof canonicalExports], + ); + } + }); +}); + describe('normalizeBusinessDate', () => { it('YYYY-MM-DD 文字列は Date 化せずそのまま返す', () => { expect(normalizeBusinessDate('1990-07-05')).toBe('1990-07-05'); @@ -99,3 +114,55 @@ describe('ageOnBusinessDate', () => { expect(ageOnBusinessDate('2000-01-01', today(new Date('2026-06-15T00:00:00Z')))).toBe(26); }); }); + +describe('IANA timezone support', () => { + it('同じinstantを利用者のタイムゾーンで変換する', () => { + const instant = new Date('2026-01-01T00:30:00Z'); + expect(toBusinessDateTime(instant, 'America/Los_Angeles')).toBe('2025-12-31 16:30:00'); + expect(toBusinessDateTime(instant, 'Europe/London')).toBe('2026-01-01 00:30:00'); + expect(toBusinessDateTime(instant, 'Asia/Kolkata')).toBe('2026-01-01 06:00:00'); + }); + + it('DSTの夏時間と冬時間をIANAルールで解決する', () => { + expect(businessDateTimeInstant('2026-07-01', '09:00:00', 'America/New_York').toISOString()).toBe( + '2026-07-01T13:00:00.000Z', + ); + expect(businessDateTimeInstant('2026-01-01', '09:00:00', 'America/New_York').toISOString()).toBe( + '2026-01-01T14:00:00.000Z', + ); + }); + + it('DST開始で存在しないwall clockは拒否する', () => { + expect(() => businessDateTimeInstant('2026-03-08', '02:30:00', 'America/New_York')).toThrow( + 'Local date-time does not exist', + ); + }); + + it('DST終了で重複するwall clockは早い方を選ぶ', () => { + expect(businessDateTimeInstant('2026-11-01', '01:30:00', 'America/New_York').toISOString()).toBe( + '2026-11-01T05:30:00.000Z', + ); + }); + + it('日境界は23時間または25時間になり得る', () => { + const start = startOfBusinessDay('2026-03-08', 'America/New_York'); + const next = startOfBusinessDay('2026-03-09', 'America/New_York'); + expect(next.getTime() - start.getTime()).toBe(23 * 60 * 60 * 1000); + }); + + it('不正なIANA timezoneを拒否する', () => { + expect(() => toBusinessDate(new Date(), 'Mars/Olympus_Mons')).toThrow(RangeError); + }); +}); + +describe('initializeBusinessTime', () => { + it('isolate全体のtimezoneを一度設定し、以後の省略時に利用する', () => { + expect(initializeBusinessTime({ timeZone: 'America/New_York' })).toEqual({ timeZone: 'America/New_York' }); + expect(getBusinessTimeConfig()).toEqual({ timeZone: 'America/New_York' }); + expect(toBusinessDateTime(new Date('2026-07-01T13:00:00Z'))).toBe('2026-07-01 09:00:00'); + expect(initializeBusinessTime({ timeZone: 'America/New_York' })).toEqual({ timeZone: 'America/New_York' }); + expect(() => initializeBusinessTime({ timeZone: 'Europe/London' })).toThrow( + 'Timezone is already initialized with America/New_York', + ); + }); +}); diff --git a/src/business-time/index.ts b/src/business-time/index.ts index 8ebf30e..86a6b3b 100644 --- a/src/business-time/index.ts +++ b/src/business-time/index.ts @@ -1,245 +1,10 @@ /** - * Explicit JST business-time API (Workers UTC instant ↔ business calendar date / date-time). + * @deprecated Import from `@rdlabo/workers-timezone` instead. * - * @remarks - * - The DB stays on JST. The app does not implicitly rely on the mysql2 `timezone` option; it goes - * through this module to handle JST. - * - Converting to the MySQL wire format is the responsibility of {@link ../db/jst.js | db/jst}. - * - Do not use a `Date`'s local getters (`getHours`, etc.) for business-time decisions. + * This compatibility subpath re-exports the canonical workspace implementation so existing + * consumers can migrate without a behavior change. * * @packageDocumentation */ -import { BUSINESS_TIMEZONE } from './types.js'; -import type { BusinessDate, BusinessDateTime } from './types.js'; - -export { BUSINESS_TIMEZONE, type BusinessDate, type BusinessDateTime }; - -const pad2 = (n: number): string => String(n).padStart(2, '0'); - -/** Shift an instant so it can be read as a business-TZ wall clock (extract fields with `getUTC*`). */ -function toWallClock(instant: Date): Date { - return new Date(instant.getTime() + BUSINESS_TIMEZONE.offsetMinutes * 60_000); -} - -function parseYmd(date: BusinessDate): [number, number, number] { - const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(date); - if (!m) { - throw new RangeError(`Invalid BusinessDate: ${date}`); - } - return [Number(m[1]), Number(m[2]), Number(m[3])]; -} - -function parseHms(time: string): [number, number, number] { - const m = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/.exec(time); - if (!m) { - throw new RangeError(`Invalid business time: ${time}`); - } - return [Number(m[1]), Number(m[2]), Number(m[3] || 0)]; -} - -/** - * The JST business calendar date of a reference instant. - * - * @param ref - the instant to read; defaults to now. - * @returns the business date as `YYYY-MM-DD`. - * @example - * today(new Date('2026-07-05T20:00:00Z')); // → '2026-07-06' (JST) - */ -export function today(ref: Date = new Date()): BusinessDate { - return toBusinessDate(ref); -} - -/** - * Convert a UTC instant to a JST business calendar date. - * - * @param instant - the UTC instant to convert. - * @returns the business date as `YYYY-MM-DD`. - */ -export function toBusinessDate(instant: Date): BusinessDate { - const wall = toWallClock(instant); - return `${wall.getUTCFullYear()}-${pad2(wall.getUTCMonth() + 1)}-${pad2(wall.getUTCDate())}`; -} - -/** - * Normalize a client / DB input to a JST business calendar date `YYYY-MM-DD`. - * - * - A string already in `YYYY-MM-DD` form is returned as-is, **without** constructing a `Date` - * (a birthday is a calendar day, not an instant). - * - ISO 8601 and similar values are converted to a JST calendar date via their instant. - * - Nullish / empty / invalid inputs yield `null`. - * - * @param value - the string, `Date`, or nullish value to normalize. - * @returns the business date as `YYYY-MM-DD`, or `null` when the input cannot be resolved. - * @example - * normalizeBusinessDate('1990-01-15'); // → '1990-01-15' (unchanged) - * normalizeBusinessDate('2026-07-05T20:00:00Z'); // → '2026-07-06' (JST) - * normalizeBusinessDate(''); // → null - */ -export function normalizeBusinessDate(value: string | Date | null | undefined): BusinessDate | null { - if (value == null) { - return null; - } - if (value instanceof Date) { - if (Number.isNaN(value.getTime())) { - return null; - } - return toBusinessDate(value); - } - const trimmed = value.trim(); - if (!trimmed) { - return null; - } - if (/^\d{4}-\d{2}-\d{2}$/.test(trimmed)) { - return trimmed; - } - const isoDatePrefix = /^(\d{4}-\d{2}-\d{2})/.exec(trimmed); - if (isoDatePrefix && !trimmed.includes('T') && !trimmed.includes(' ')) { - return isoDatePrefix[1]; - } - const ms = new Date(trimmed).getTime(); - if (Number.isNaN(ms)) { - return null; - } - return toBusinessDate(new Date(ms)); -} - -/** - * Convert a UTC instant to a JST business date-time (`YYYY-MM-DD HH:mm:ss`). - * - * @param instant - the UTC instant to convert. - * @returns the business date-time string. - * @example - * toBusinessDateTime(new Date('2026-07-05T21:00:00Z')); // → '2026-07-06 06:00:00' (JST) - */ -export function toBusinessDateTime(instant: Date): BusinessDateTime { - const wall = toWallClock(instant); - return `${wall.getUTCFullYear()}-${pad2(wall.getUTCMonth() + 1)}-${pad2(wall.getUTCDate())} ${pad2(wall.getUTCHours())}:${pad2(wall.getUTCMinutes())}:${pad2(wall.getUTCSeconds())}`; -} - -/** Default pattern for the Nest / foodlabel / winecode `helper.formatDate`. */ -export const DEFAULT_BUSINESS_DATETIME_PATTERN = 'YYYY-MM-DDThh:mm:ss' as const; - -/** - * Format an instant in the business TZ, compatible with the Nest `helper.formatDate`. - * - * Supported tokens: `YYYY` / `MM` / `DD` / `hh` / `mm` / `ss`, plus `S` for the source instant's - * milliseconds (matching the Nest reference implementation). - * - * @param instant - the UTC instant to format. - * @param pattern - the format pattern; defaults to {@link DEFAULT_BUSINESS_DATETIME_PATTERN}. - * @returns the formatted string. - * @example - * formatBusinessDateTime(new Date('2026-07-05T21:00:00Z')); // → '2026-07-06T06:00:00' (JST) - */ -export function formatBusinessDateTime(instant: Date, pattern: string = DEFAULT_BUSINESS_DATETIME_PATTERN): string { - const wall = toWallClock(instant); - let out = pattern; - out = out.replace(/YYYY/g, String(wall.getUTCFullYear())); - out = out.replace(/MM/g, pad2(wall.getUTCMonth() + 1)); - out = out.replace(/DD/g, pad2(wall.getUTCDate())); - out = out.replace(/hh/g, pad2(wall.getUTCHours())); - out = out.replace(/mm/g, pad2(wall.getUTCMinutes())); - out = out.replace(/ss/g, pad2(wall.getUTCSeconds())); - const matched = out.match(/S/g); - if (matched) { - const milliSeconds = String(instant.getMilliseconds()).padStart(3, '0'); - const length = matched.length; - for (let i = 0; i < length; i++) { - out = out.replace(/S/, milliSeconds.substring(i, i + 1)); - } - } - return out; -} - -/** - * Parse a JST business date-time string into a UTC instant. Accepts a space or `T` separator - * (`YYYY-MM-DD HH:mm:ss` or `YYYY-MM-DDTHH:mm:ss`). - * - * @param value - the business date-time string to parse. - * @returns the corresponding UTC instant. - * @throws RangeError when `value` is not a valid business date-time. - * @example - * parseBusinessDateTime('2026-07-06 06:00:00'); // → 2026-07-05T21:00:00Z - */ -export function parseBusinessDateTime(value: BusinessDateTime): Date { - const normalized = value.includes('T') ? value.replace('T', ' ') : value; - const m = /^(\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2}):(\d{2})$/.exec(normalized); - if (!m) { - throw new RangeError(`Invalid BusinessDateTime: ${value}`); - } - const [, y, mo, d, h, mi, s] = m; - const offsetHours = BUSINESS_TIMEZONE.offsetMinutes / 60; - return new Date(Date.UTC(Number(y), Number(mo) - 1, Number(d), Number(h) - offsetHours, Number(mi), Number(s), 0)); -} - -/** - * The UTC instant of `00:00:00` on a JST business calendar date. - * - * @param date - the business date as `YYYY-MM-DD`. - * @returns the UTC instant at the start of that business day. - */ -export function startOfBusinessDay(date: BusinessDate): Date { - return businessDateTimeInstant(date, '00:00:00'); -} - -/** - * The UTC instant of `23:59:59` on a JST business calendar date. - * - * @param date - the business date as `YYYY-MM-DD`. - * @returns the UTC instant at the end of that business day. - */ -export function endOfBusinessDay(date: BusinessDate): Date { - return businessDateTimeInstant(date, '23:59:59'); -} - -/** - * Convert a JST business calendar date + wall-clock time to a UTC instant. - * - * @param date - the business date as `YYYY-MM-DD`. - * @param time - the wall-clock time as `HH:mm:ss` (or `HH:mm`). - * @returns the corresponding UTC instant. - * @throws RangeError when `date` or `time` is malformed. - * @example - * businessDateTimeInstant('2026-07-06', '06:00:00'); // → 2026-07-05T21:00:00Z - */ -export function businessDateTimeInstant(date: BusinessDate, time: string): Date { - const [y, mo, d] = parseYmd(date); - const [h, mi, s] = parseHms(time); - const offsetHours = BUSINESS_TIMEZONE.offsetMinutes / 60; - return new Date(Date.UTC(y, mo - 1, d, h - offsetHours, mi, s, 0)); -} - -/** - * Add a number of calendar days to a JST business calendar date. - * - * @param date - the starting business date as `YYYY-MM-DD`. - * @param days - the number of calendar days to add (may be negative). - * @returns the resulting business date as `YYYY-MM-DD`. - * @example - * addBusinessDays('2026-07-06', 3); // → '2026-07-09' - */ -export function addBusinessDays(date: BusinessDate, days: number): BusinessDate { - const anchor = businessDateTimeInstant(date, '12:00:00'); - return toBusinessDate(new Date(anchor.getTime() + days * 24 * 60 * 60 * 1000)); -} - -/** - * The full years of age on a business calendar date (a birthday is a `BusinessDate`, not an instant). - * - * @param birthDate - the birth date as `YYYY-MM-DD`. - * @param asOfDate - the reference business date; defaults to {@link today}. - * @returns the age in completed years. - * @example - * ageOnBusinessDate('1990-07-10', '2026-07-06'); // → 35 - */ -export function ageOnBusinessDate(birthDate: BusinessDate, asOfDate?: BusinessDate): number { - const asOf = asOfDate ?? today(); - const [by, bm, bd] = parseYmd(birthDate); - const [ay, am, ad] = parseYmd(asOf); - let age = ay - by; - if (am < bm || (am === bm && ad < bd)) { - age--; - } - return age; -} +export * from '@rdlabo/workers-timezone'; diff --git a/src/business-time/types.ts b/src/business-time/types.ts deleted file mode 100644 index c89d443..0000000 --- a/src/business-time/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -/** A JST business calendar date `YYYY-MM-DD` (a calendar day, not an instant). */ -export type BusinessDate = string; - -/** A JST business date-time `YYYY-MM-DD HH:mm:ss` (a wall-clock value, MySQL `DATETIME`-compatible). */ -export type BusinessDateTime = string; - -/** JST business timezone constant. Workers run in UTC instants; business time is made explicit here. */ -export const BUSINESS_TIMEZONE = { - iana: 'Asia/Tokyo', - offsetMinutes: 540, -} as const;