stack/packages/shared/src/utils/dates.tsx
BilalG1 c14a9dd3d0
feat(hexclave): PR 5 — internal symbol/path/package renames + brand strings (#1547)
## Stack Auth → Hexclave rename — PR 5 (internal symbols, paths,
packages, brand strings)

PR 5 finishes the **internal / non-wire** half of the Stack→Hexclave
rename. It only touches things where nothing outside the repo depends on
the exact name: internal symbols, file/dir names, the
`@stackframe/template` package, and residual brand strings. Plan +
progress are in `HEXCLAVE-RENAME-PR5-PLAN.md`.

Every step was verified green (`pnpm typecheck` + `pnpm lint`, 28/28)
and committed as its own checkpoint, then a fan-out of review agents
audited all commits and the findings were fixed.

### What changed
- **Internal symbols** (`@hexclave/shared`, `packages/template`, apps):
`stack*`/`Stack*` → `hexclave*`/`Hexclave*` — incl.
`stackGlobalsSymbol`, the `_Stack*AppImpl` classes,
`stackAppInternalsSymbol`, `StackContext`, `getStackStripe`, etc. The
`stack*App` local-variable convention
(`stackServerApp`/`stackClientApp`/…) was renamed across 175
source/example/doc files.
- **File renames**: `hexclave-handler/provider/context.tsx`,
`backend/hexclave.tsx`, `internal-tool/hexclave.ts`,
`hexclave-app-internals.ts`.
- **Directory renames**: `lib/hexclave-app`, `hexclave-companion`,
`[...hexclave]` route segment, `skills/hexclave`,
`dashboard/src/hexclave`, and the package dirs
**`packages/{next,shared,ui,sc,cli}`** (dropping the `stack-` prefix to
match the `@hexclave/*` npm names).
- **Packages**: `@stackframe/template` → `@hexclave/template`; **deleted
`packages/init-stack`** (onboarding lives in `@hexclave/cli init`; the
published npm package is untouched).
- **Brand strings**: reworded `Stack Auth`/`Stack dashboard` prose in
code + docs-mintlify, renamed `hexclave-app.mdx`/`use-hexclave-app.mdx`
with redirects, regenerated OpenAPI, updated coupled e2e assertions;
`doctor`/`init` now prefer `hexclave.config.ts`.

### Intentionally kept (verified, not oversights)
Wire/compat identifiers (`x-stack-*` headers, `stack-*` cookies,
`STACK_*` env names, `*.stack-auth.com`, `stackauth_`, `ask_stack_auth`,
query params), public `Stack*` SDK aliases, crypto/JWT/vault
domain-separation tags, `*-brand-sentinel`s, the
`Symbol.for("StackAuth--…")` string, `_stack_sync_metadata`, Postgres
`stackframe` / docker image names, the `stack-auth-logo*.svg` (used by
the rebrand modal), and `migration.mdx` / "formerly known as Stack Auth"
notes. False positives (Phosphor `StackIcon`/`StackSimple`, `TanStack`,
`OrbStack`, `stackable`/`Stacked` charts) left alone.

### Review pass
Six review agents audited all commits. Found + fixed one real bug — a
build script (`bundle-type-definitions.ts`) hardcoded the old
`lib/stack-app` glob path (not an import, so typecheck/lint were blind),
silently emptying the dashboard AI type bundle — plus stale comments, a
dead CI env var, and stale `.gitignore`/`.dockerignore` entries.
Cross-cutting audit confirmed **zero wire-compat identifiers were
accidentally renamed**.

### ⚠️ Verification note
`typecheck` + `lint` are fully green locally. The **e2e suite was not
run** (needs a live backend+DB), so the brand-string assertion +
OpenAPI-regen changes are verified by grep/codegen only — please let CI
exercise e2e to confirm.

### Base-branch note
This branch was forked from the local-only `cl/friendly-lewin-72293f`
(not on origin, no separate PR), so this PR against `dev` also carries
that branch's ~11 preceding Hexclave-rename commits (config-file rename,
env-var dual-read, AI setup-prompt rebrand). If those should land
separately, re-parent before merge.

<!-- This is an auto-generated description by cubic. -->
---
## Summary by cubic
Finishes the internal Stack Auth → Hexclave rename and cleans up
remaining stragglers, including dev-tool and prompt copy. All changes
are internal-only; public/wire APIs remain unchanged. Re-merged `dev`
and resolved the payments create-purchase-url conflict.

- **Refactors**
- Internal symbols: stack*/Stack* → hexclave*/Hexclave* (e.g.,
`getHexclaveServerApp` via `@/hexclave`, `getHexclaveStripe`,
`hexclaveAppInternalsSymbol`, `hexclaveSchemaInfo`, Prisma
`__hexclave_*`, `data-hexclave-handler-page`, Stripe mock
`hexclavePortPrefix`).
- Files/dirs: moved to `lib/hexclave-app`; handler route
`[...hexclave]`; backend entry `src/hexclave.tsx`; dashboard internals
`hexclave-app-internals`; companion `hexclave-companion`; dropped
`stack-` prefix across package dirs
(`packages/{shared,ui,sc,cli,next}`); workflows/emulator paths now
`packages/cli`; Quetzal codegen env at `packages/next/.env.local`.
- Packages/docs: `@stackframe/template` → `@hexclave/template`; removed
`packages/init-stack`; regenerated OpenAPI and updated docs
slugs/redirects for hexclave-app/use-hexclave-app.
- Brand strings/prompts: reworded remaining “Stack” dashboard strings to
Hexclave; updated dev-tool copy and prompts; `doctor/init` now prefer
`hexclave.config.ts`. Kept all wire-compat identifiers and public
aliases (`x-stack-*`, `stack-*` cookies, `STACK_*` env,
`*.stack-auth.com`, `Stack*` SDK names).
- Rebased/merged onto latest `dev`: retained `@hexclave/template`, kept
`src` in published files, refreshed setup-prompt imports and docs JSON,
adopted 1.0.5 version bumps, and re-merged `dev` again (resolved
`create-purchase-url` with `getHexclaveStripe`).

- **Bug Fixes**
- Restored dashboard AI type bundle by pointing the glob to
`packages/template/src/lib/hexclave-app`.
- Addressed rename leftovers: updated lingering `@/stack` imports and
CSS selector, fixed schema/meta and port-prefix expansions, and aligned
emulator commands to `packages/cli`.
- CI/build: removed a dead env var and stale ignore entries; fixed
Docker by renaming `STACK_SKIP_TEMPLATE_GENERATION` →
`HEXCLAVE_SKIP_TEMPLATE_GENERATION`.

<sup>Written for commit 3c1af3bff3.
Summary will update on new commits.</sup>

<a
href="https://cubic.dev/pr/hexclave/hexclave/pull/1547?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>

<!-- End of auto-generated description by cubic. -->
2026-06-03 18:57:09 -07:00

247 lines
7.6 KiB
TypeScript

import { intervalSchema } from "../schema-fields";
import { HexclaveAssertionError } from "./errors";
import { remainder } from "./math";
export function isWeekend(date: Date): boolean {
return date.getDay() === 0 || date.getDay() === 6;
}
import.meta.vitest?.test("isWeekend", ({ expect }) => {
// Sunday (day 0)
expect(isWeekend(new Date(2023, 0, 1))).toBe(true);
// Saturday (day 6)
expect(isWeekend(new Date(2023, 0, 7))).toBe(true);
// Monday (day 1)
expect(isWeekend(new Date(2023, 0, 2))).toBe(false);
// Friday (day 5)
expect(isWeekend(new Date(2023, 0, 6))).toBe(false);
});
const agoUnits = [
[60, 'second'],
[60, 'minute'],
[24, 'hour'],
[7, 'day'],
[5, 'week'],
] as const;
export function fromNow(date: Date): string {
return fromNowDetailed(date).result;
}
import.meta.vitest?.test("fromNow", ({ expect }) => {
// Set a fixed date for testing
const fixedDate = new Date("2023-01-15T12:00:00.000Z");
// Use Vitest's fake timers
import.meta.vitest?.vi.useFakeTimers();
import.meta.vitest?.vi.setSystemTime(fixedDate);
// Test past times
expect(fromNow(new Date("2023-01-15T11:59:50.000Z"))).toBe("just now");
expect(fromNow(new Date("2023-01-15T11:59:00.000Z"))).toBe("1 minute ago");
expect(fromNow(new Date("2023-01-15T11:00:00.000Z"))).toBe("1 hour ago");
expect(fromNow(new Date("2023-01-14T12:00:00.000Z"))).toBe("1 day ago");
expect(fromNow(new Date("2023-01-08T12:00:00.000Z"))).toBe("1 week ago");
// Test future times
expect(fromNow(new Date("2023-01-15T12:00:10.000Z"))).toBe("just now");
expect(fromNow(new Date("2023-01-15T12:01:00.000Z"))).toBe("in 1 minute");
expect(fromNow(new Date("2023-01-15T13:00:00.000Z"))).toBe("in 1 hour");
expect(fromNow(new Date("2023-01-16T12:00:00.000Z"))).toBe("in 1 day");
expect(fromNow(new Date("2023-01-22T12:00:00.000Z"))).toBe("in 1 week");
// Test very old dates (should use date format)
expect(fromNow(new Date("2022-01-15T12:00:00.000Z"))).toMatch(/Jan 15, 2022/);
// Restore real timers
import.meta.vitest?.vi.useRealTimers();
});
export function fromNowDetailed(date: Date): {
result: string,
/**
* May be Infinity if the result will never change.
*/
secondsUntilChange: number,
} {
if (!(date instanceof Date)) {
throw new Error(`fromNow only accepts Date objects (received: ${date})`);
}
const now = new Date();
const elapsed = now.getTime() - date.getTime();
let remainingInUnit = Math.abs(elapsed) / 1000;
if (remainingInUnit < 15) {
return {
result: 'just now',
secondsUntilChange: 15 - remainingInUnit,
};
}
let unitInSeconds = 1;
for (const [nextUnitSize, unitName] of agoUnits) {
const rounded = Math.round(remainingInUnit);
if (rounded < nextUnitSize) {
if (elapsed < 0) {
return {
result: `in ${rounded} ${unitName}${rounded === 1 ? '' : 's'}`,
secondsUntilChange: remainder((remainingInUnit - rounded + 0.5) * unitInSeconds, unitInSeconds),
};
} else {
return {
result: `${rounded} ${unitName}${rounded === 1 ? '' : 's'} ago`,
secondsUntilChange: remainder((rounded - remainingInUnit - 0.5) * unitInSeconds, unitInSeconds),
};
}
}
unitInSeconds *= nextUnitSize;
remainingInUnit /= nextUnitSize;
}
return {
result: date.toLocaleDateString('en-US', { year: 'numeric', month: 'short', day: 'numeric' }),
secondsUntilChange: Infinity,
};
}
/**
* Returns a string representation of the given date in the format expected by the `datetime-local` input type.
*/
export function getInputDatetimeLocalString(date: Date): string {
date = new Date(date);
date.setMinutes(date.getMinutes() - date.getTimezoneOffset());
return date.toISOString().slice(0, 19);
}
import.meta.vitest?.test("getInputDatetimeLocalString", ({ expect }) => {
// Use Vitest's fake timers to ensure consistent timezone behavior
import.meta.vitest?.vi.useFakeTimers();
// Test with a specific date
const mockDate = new Date("2023-01-15T12:30:45.000Z");
const result = getInputDatetimeLocalString(mockDate);
// The result should be in the format YYYY-MM-DDTHH:MM:SS
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
// Test with different dates
const dates = [
new Date("2023-01-01T00:00:00.000Z"),
new Date("2023-06-15T23:59:59.000Z"),
new Date("2023-12-31T12:34:56.000Z"),
];
for (const date of dates) {
const result = getInputDatetimeLocalString(date);
expect(result).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}$/);
}
// Restore real timers
import.meta.vitest?.vi.useRealTimers();
});
export type Interval = [number, 'millisecond' | 'second' | 'minute' | 'hour' | 'day' | 'week' | 'month' | 'year'];
export type DayInterval = [number, 'day' | 'week' | 'month' | 'year'];
function applyInterval(date: Date, times: number, interval: Interval): Date {
if (!intervalSchema.isValidSync(interval)) {
throw new HexclaveAssertionError(`Invalid interval`, { interval });
}
const [amount, unit] = interval;
switch (unit) {
case 'millisecond': {
date.setMilliseconds(date.getMilliseconds() + amount * times);
break;
}
case 'second': {
date.setSeconds(date.getSeconds() + amount * times);
break;
}
case 'minute': {
date.setMinutes(date.getMinutes() + amount * times);
break;
}
case 'hour': {
date.setHours(date.getHours() + amount * times);
break;
}
case 'day': {
date.setDate(date.getDate() + amount * times);
break;
}
case 'week': {
date.setDate(date.getDate() + amount * times * 7);
break;
}
case 'month': {
date.setMonth(date.getMonth() + amount * times);
break;
}
case 'year': {
date.setFullYear(date.getFullYear() + amount * times);
break;
}
default: {
throw new HexclaveAssertionError(`Invalid interval despite schema validation`, { interval });
}
}
return date;
}
export function subtractInterval(date: Date, interval: Interval): Date {
return applyInterval(date, -1, interval);
}
export function addInterval(date: Date, interval: Interval): Date {
return applyInterval(date, 1, interval);
}
export const FAR_FUTURE_DATE = new Date(8640000000000000); // 13 Sep 275760 00:00:00 UTC
function getMsPerDayIntervalUnit(unit: 'day' | 'week'): number {
if (unit === 'day') {
return 24 * 60 * 60 * 1000;
}
return 7 * 24 * 60 * 60 * 1000;
}
export function getIntervalsElapsed(anchor: Date, to: Date, repeat: DayInterval): number {
const [amount, unit] = repeat;
if (to <= anchor) return 0;
if (unit === 'day' || unit === 'week') {
const msPerUnit = getMsPerDayIntervalUnit(unit);
const diffMs = to.getTime() - anchor.getTime();
return Math.floor(diffMs / (msPerUnit * amount));
}
if (["month", "year"].includes(unit)) {
let count = 0;
let current = new Date(anchor);
for (; ;) {
const next = addInterval(new Date(current), [amount, unit]);
if (next > to) break;
current = next;
count += 1;
}
return count;
}
return 0;
}
import.meta.vitest?.test("getIntervalsElapsed", ({ expect }) => {
const anchor = new Date('2025-01-01T00:00:00.000Z');
const to = new Date('2025-01-15T00:00:00.000Z');
expect(getIntervalsElapsed(anchor, to, [1, 'week'])).toBe(2);
expect(getIntervalsElapsed(anchor, to, [3, 'day'])).toBe(4);
const mAnchor = new Date('2023-01-31T00:00:00.000Z');
const mTo = new Date('2023-03-01T00:00:00.000Z');
expect(getIntervalsElapsed(mAnchor, mTo, [1, 'month'])).toBe(0);
const yAnchor = new Date('2020-01-01T00:00:00.000Z');
const yTo = new Date('2022-06-01T00:00:00.000Z');
expect(getIntervalsElapsed(yAnchor, yTo, [1, 'year'])).toBe(2);
});