From f1e07e1067b36b5c8b3183b67a5d44a617d5cd09 Mon Sep 17 00:00:00 2001 From: Peter Hewat Date: Tue, 13 Oct 2026 21:52:15 +0200 Subject: [PATCH 01/10] Add the Time app: Convert, Find a time and Duration Starting point taken from the test_2 run, moved onto current main (typecheck with bun check, lockfile refreshed). Co-Authored-By: Claude Opus 5.5 --- AGENTS.md | 4 +- README.md | 2 +- apps/json/index.html | 4 +- apps/json/src/styles.css | 31 +- apps/jwt/index.html | 3 + apps/jwt/src/main.ts | 19 +- apps/jwt/src/styles.css | 5 + apps/time/index.html | 576 +++++++++++++++ apps/time/package.json | 16 + apps/time/public/art.svg | 1 + apps/time/public/icon.svg | 1 + apps/time/src/app.ts | 28 + apps/time/src/catalog.test.ts | 6 + apps/time/src/convert.ts | 390 ++++++++++ apps/time/src/dom.ts | 43 ++ apps/time/src/duration-view.ts | 253 +++++++ apps/time/src/lib/availability.test.ts | 290 ++++++++ apps/time/src/lib/availability.ts | 291 ++++++++ apps/time/src/lib/civil.ts | 144 ++++ apps/time/src/lib/duration.ts | 115 +++ apps/time/src/lib/format.ts | 146 ++++ apps/time/src/lib/parse.ts | 162 +++++ apps/time/src/lib/plan.ts | 131 ++++ apps/time/src/lib/time.test.ts | 278 +++++++ apps/time/src/lib/zone.ts | 167 +++++ apps/time/src/main.ts | 109 +++ apps/time/src/meet.ts | 954 +++++++++++++++++++++++++ apps/time/src/prefs.ts | 50 ++ apps/time/src/resolution.ts | 83 +++ apps/time/src/styles.css | 603 ++++++++++++++++ apps/time/src/zone-field.ts | 79 ++ apps/time/tsconfig.json | 7 + apps/time/vite.config.ts | 4 + bun.lock | 11 + docs/README.md | 4 +- docs/apps/jwt.md | 2 +- docs/apps/time.md | 170 ++--- packages/catalog/src/index.ts | 13 + packages/ui/header.css | 32 + tests/browser/developer-tools.spec.ts | 10 +- tests/browser/time.spec.ts | 260 +++++++ 41 files changed, 5369 insertions(+), 128 deletions(-) create mode 100644 apps/time/index.html create mode 100644 apps/time/package.json create mode 100644 apps/time/public/art.svg create mode 100644 apps/time/public/icon.svg create mode 100644 apps/time/src/app.ts create mode 100644 apps/time/src/catalog.test.ts create mode 100644 apps/time/src/convert.ts create mode 100644 apps/time/src/dom.ts create mode 100644 apps/time/src/duration-view.ts create mode 100644 apps/time/src/lib/availability.test.ts create mode 100644 apps/time/src/lib/availability.ts create mode 100644 apps/time/src/lib/civil.ts create mode 100644 apps/time/src/lib/duration.ts create mode 100644 apps/time/src/lib/format.ts create mode 100644 apps/time/src/lib/parse.ts create mode 100644 apps/time/src/lib/plan.ts create mode 100644 apps/time/src/lib/time.test.ts create mode 100644 apps/time/src/lib/zone.ts create mode 100644 apps/time/src/main.ts create mode 100644 apps/time/src/meet.ts create mode 100644 apps/time/src/prefs.ts create mode 100644 apps/time/src/resolution.ts create mode 100644 apps/time/src/styles.css create mode 100644 apps/time/src/zone-field.ts create mode 100644 apps/time/tsconfig.json create mode 100644 apps/time/vite.config.ts create mode 100644 tests/browser/time.spec.ts diff --git a/AGENTS.md b/AGENTS.md index d99a6ce..d836c4c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,7 +7,7 @@ - No `git add` / `commit` / `push` unless the user asks - Branch or PR work follows [Git](#git) - Run the [format gate](#format-gate) after editing, and the [verify gate](#verify-gate) before finishing -- **Saved formats:** the SVG and JSON apps are released. JSON keeps only settings (`tools.json.prefs` in localStorage, read field by field with a default for anything missing or unknown) and a per-tab draft; a change to its settings must still read the old ones. A change to the SVG app's IndexedDB documents (`ProjectFile`) or exported SVG keeps existing files opening: bump `PROJECT_VERSION` (`types.ts`) and add the step up from the previous version in `readProject` (`project-file.ts`); the database's own `DB_VERSION` (`storage.ts`) is for its stores. Exported SVG carries no marker, so an SVG change must still import files written before it. An app that is not released yet has no migration — when its stored format changes, fail closed and tell the person to clear that app's storage. +- **Saved formats:** the SVG, JSON and Time apps are released. JSON keeps only settings (`tools.json.prefs` in localStorage, read field by field with a default for anything missing or unknown) and a per-tab draft; a change to its settings must still read the old ones. A change to the SVG app's IndexedDB documents (`ProjectFile`) or exported SVG keeps existing files opening: bump `PROJECT_VERSION` (`types.ts`) and add the step up from the previous version in `readProject` (`project-file.ts`); the database's own `DB_VERSION` (`storage.ts`) is for its stores. Exported SVG carries no marker, so an SVG change must still import files written before it. Time keeps its settings (`tools.time.prefs`) and saved people (`tools.time.plan`, the same format as its share links) in localStorage, both read field by field with defaults; a change that cannot be read that way bumps `PLAN_VERSION` (`plan.ts`) and reads the previous version. An app that is not released yet has no migration — when its stored format changes, fail closed and tell the person to clear that app's storage. ## Clean changes @@ -31,7 +31,7 @@ - **App wiring:** an app's `vite.config.ts` is `defineConfig(toolsApp(""))` from `@tools/ui/vite`, used to build it; the site's one dev server serves it (see [Running it](#running-it)). It sets the base path and output folder, and injects ``, description, icon and manifest from the catalog — so an app's `index.html` must not set those itself (the build fails if it does). Each app keeps its own `public/icon.svg`. The page links its stylesheet in `index.html` (`<link rel="stylesheet" href="/src/styles.css">`, which `@import`s `@tools/ui/base.css`) rather than importing CSS from the script: a linked stylesheet holds the first paint, so the page never shows unstyled while the script loads under the dev server. - **Header:** every app's page has one `<header data-tools-header>` holding only its own controls. The build writes its start from the catalog — "‹ Tools", the app's name and, unless it is stable, its status badge — styled by `@tools/ui/header.css` (base.css imports it). Put the far-end controls (theme switch, Help) in `.ui-header-end`. The build fails if the marker is missing or the page writes its own home link or name. - **Light / dark:** every page follows the browser until someone presses a theme switch; from then on the choice (`tools.theme` in localStorage, shared by the whole site) is light or dark, never "system" again. The Vite plugin inlines a script that applies it before first paint. Colours are CSS custom properties with a dark base and a light override keyed on `prefers-color-scheme` and `<html data-theme>` (see `packages/ui/tokens.css`); an app adds a button and calls `bindThemeToggle(button)` from `@tools/ui`, and repaints anything drawn outside CSS (a canvas) on `THEME_EVENT`. -- **Shared UI:** icons, side panels, menus, colour pickers and SVG-to-PNG export come from `@tools/ui`, never drawn again in an app. An empty `<svg data-ui-icon="help">` (or sprite `<symbol data-ui-icon>`) is filled at build time from `icons.ts`; code uses `iconSvg`. Help and other side panels are `.ui-dock` with `bindDock` (open state kept per tab, placed under the header, full width on a phone); menus are `.ui-menu` popovers with `bindMenu` (styles in `packages/ui/panels.css`); colours are a `.color-swatch` with `bindColorSwatch`, which opens the one colour picker (hue, opacity, hex; colours as `#rrggbb` or `#rrggbbaa`, helpers in `color.ts`); a PNG of an SVG is `renderPng`. A setting that is on or off is a `.ui-switch` button (`role="switch"`, `bindSwitch`, `isOn` / `setOn`), never a checkbox; work that waits for typing to pause is `debounce`; a tab's draft is `readDraft` / `writeDraft` (versioned, kept per tab, `restoreFields` / `fieldValues` for form fields). In the text tools (`tool.css`), a card is `.ui-card` with its heading row `.ui-row`, a count or error beside the heading is `.ui-note`, and an editor holding data kept exactly is `.ui-editor.ui-data`. One app opens another on the same input by writing that app's draft for the tab and following a plain link (Codec ⇄ Digests). +- **Shared UI:** icons, side panels, menus, colour pickers and SVG-to-PNG export come from `@tools/ui`, never drawn again in an app. An empty `<svg data-ui-icon="help">` (or sprite `<symbol data-ui-icon>`) is filled at build time from `icons.ts`; code uses `iconSvg`. Help and other side panels are `.ui-dock` with `bindDock` (open state kept per tab, placed under the header, full width on a phone); menus are `.ui-menu` popovers with `bindMenu` (styles in `packages/ui/panels.css`); colours are a `.color-swatch` with `bindColorSwatch`, which opens the one colour picker (hue, opacity, hex; colours as `#rrggbb` or `#rrggbbaa`, helpers in `color.ts`); a PNG of an SVG is `renderPng`. A setting that is on or off is a `.ui-switch` button (`role="switch"`, `bindSwitch`, `isOn` / `setOn`), never a checkbox; work that waits for typing to pause is `debounce`; a tab's draft is `readDraft` / `writeDraft` (versioned, kept per tab, `restoreFields` / `fieldValues` for form fields). In the text tools (`tool.css`), a card is `.ui-card` with its heading row `.ui-row`, a count or error beside the heading is `.ui-note`, and an editor holding data kept exactly is `.ui-editor.ui-data`. One app opens another on the same input by writing that app's draft for the tab and following a plain link (Codec ⇄ Digests, JWT → Time). - **Settings:** preferences for the whole app go in a Settings (cog) menu in the header; what belongs to the document being edited stays with the document (the SVG app's size, grid and background, in its Document panel). - **Phone header:** the header collapses in levels, each where the one before stops fitting (measure what the header needs; do not guess). First "‹ Tools Name" reduces to "‹", at the app's `compactHeader` width in the catalog (the build writes that rule; 720px by default); an app whose name in full needs the room may drop "Tools" alone before that, at its `compactHome`. Then the app's own steps (JSON: the views become a dropdown at 645px, then the phone layout at 475px; SVG: the phone layout at 590px). The phone layout is one row that never wraps. What does not fit folds into a "⋯" menu, Help stays last in the bar, and the theme switch stays in the bar at every width. Buttons keep their size and spacing at every width (the header's measures in `header.css`: `--ui-control`, `--ui-group-gap`, `--ui-header-gap`); a level only moves, hides or folds things. Layout and button sizes follow the width alone, never the pointer (`pointer: coarse`), so a touch screen gets the same layout as a mouse at the same width; only what happens on a canvas (hit targets, drag thresholds) and which help is shown adapt to touch. The header stays a solid row at the top in every app; an app whose work is a canvas may float its drawing tools in a bar over the bottom of it (the SVG app). Buttons that belong together are joined into one control with `.ui-seg` (`header.css`). - **Install and offline:** the site installs as one app — the index, with every tool inside its scope, so moving between tools never leaves the installed window. Every page links the one manifest at the site root (emitted by the index build; each listed app is a shortcut in it). Pages call `registerServiceWorker()` from `@tools/ui`, which registers the one worker at the site root; `scripts/build-site.ts` writes it last (`packages/ui/src/site-worker.ts` + `packages/ui/sw.js`) with a version hash and every file of every app to precache, so one visit makes all tools work offline and every deploy replaces the previous cache. Nothing is registered under the dev server. diff --git a/README.md b/README.md index c56bce5..f7f30b3 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ The scaffold includes the shared theme toggle and Help dock. Put help content in using the catalog name. `bindToolHelp(slug)` wires both header buttons and remembers the Help dock's open state for the tab. -JWT, Codec, Codes and Digests keep versioned session drafts with `readDraft` / `writeDraft` +JWT, Codec, Codes, Digests and Time keep versioned session drafts with `readDraft` / `writeDraft` from `@tools/ui`. These restore the current tab on reload, including entered keys, but a fresh tab starts with defaults. Drafts are limited to 2 MB; an incompatible format asks the user to clear that app's session storage. Native file selections must be made again diff --git a/apps/json/index.html b/apps/json/index.html index ee4b6eb..e9cc87f 100644 --- a/apps/json/index.html +++ b/apps/json/index.html @@ -31,8 +31,8 @@ </button> </div> <!-- The same choice on a phone, where five buttons would take a row of their own. --> - <div class="view-picker"> - <select class="view-select" id="view-select" aria-label="Show as"> + <div class="ui-view-picker"> + <select class="ui-view-select" id="view-select" aria-label="Show as"> <option value="json">JSON</option> <option value="yaml">YAML</option> <option value="csv">CSV</option> diff --git a/apps/json/src/styles.css b/apps/json/src/styles.css index 6325975..d709f42 100644 --- a/apps/json/src/styles.css +++ b/apps/json/src/styles.css @@ -53,35 +53,6 @@ body { /* Buttons joined into one control (JSON | YAML | …, indent, path style) are @tools/ui's .ui-seg. */ -/* The views as one dropdown, for a phone (see the media query below); drawn as a header button - with its own chevron, since the native arrow differs on every platform. */ -.view-picker { - position: relative; - display: none; -} -.view-picker > svg { - position: absolute; - right: 6px; - top: 50%; - width: 18px; - height: 18px; - transform: translateY(-50%); - color: var(--text); - pointer-events: none; -} -.view-select { - min-height: var(--ui-control); - padding: 0 28px 0 10px; - border: 1px solid var(--accent); - border-radius: var(--radius); - background: var(--accent-dim); - color: var(--text); - font: inherit; - font-size: var(--ui-control-font); - appearance: none; - cursor: pointer; -} - /* Choices that read as one scale (2, 4, Tab) take the same width, that of the widest. */ .ui-seg.equal { display: inline-grid; @@ -351,7 +322,7 @@ body:not([data-view="json"]) .text-area { display: none; } - .view-picker { + .json-header .ui-view-picker { display: block; } } diff --git a/apps/jwt/index.html b/apps/jwt/index.html index 9de6599..d410894 100644 --- a/apps/jwt/index.html +++ b/apps/jwt/index.html @@ -63,6 +63,9 @@ <h2 id="header-title" class="jwt-title-header">Header</h2> <section class="ui-card" aria-labelledby="payload-title"> <div class="ui-row"> <h2 id="payload-title" class="jwt-title-payload">Payload</h2> + <a class="ui-btn ui-btn--small jwt-link" id="to-time" href="../time/" hidden + ><svg data-ui-icon="calendar"></svg>Time</a + > <button type="button" class="ui-btn ui-icon-btn" diff --git a/apps/jwt/src/main.ts b/apps/jwt/src/main.ts index c5cd6cc..5d0ee43 100644 --- a/apps/jwt/src/main.ts +++ b/apps/jwt/src/main.ts @@ -185,6 +185,8 @@ function decorateToken(inspection: Inspection): void { if (!text.trim()) { state("token-state", ""); state("time-state", ""); + timeClaims = []; + byId("to-time").hidden = true; note("token-status", "Paste a token, or type a payload to start a new one."); return; } @@ -201,9 +203,16 @@ function decorateToken(inspection: Inspection): void { note("token-status", problem, Boolean(problem)); showTimes(inspection); } +/** The payload's time claims as Time reads them, one a line: `exp: 1760264100`. */ +let timeClaims: string[] = []; /** Whether the token is current: not yet valid, expired, or when it expires. */ function showTimes(inspection: Inspection): void { - const times = inspection.payload.object ? claimTimes(inspection.payload.object) : []; + const object = inspection.payload.object; + const times = object ? claimTimes(object) : []; + timeClaims = times + .filter((time) => Number.isFinite(object![time.claim])) + .map((time) => `${time.claim}: ${object![time.claim] as number}`); + byId("to-time").hidden = !timeClaims.length; const exp = times.find((time) => time.claim === "exp"), nbf = times.find((time) => time.claim === "nbf"); if (nbf?.problem) state("time-state", nbf.description, "warn"); @@ -661,6 +670,14 @@ for (const [id, text] of [ if (!(await copyText(text(), byId(id)))) note("token-status", "Clipboard unavailable. Select and copy the text.", true); }); +/** Time opens on the payload's time claims, one a line, in every zone. */ +const toTime = byId<HTMLAnchorElement>("to-time"); +toTime.addEventListener("click", (event) => { + event.preventDefault(); + // Time's own draft for this tab, so it opens on these claims. + writeDraft("time", 1, { view: "convert", input: timeClaims.join("\n"), unit: "s" }); + location.href = toTime.href; +}); /** "Expires in 2 min" and the notes' "expired 3 min ago" keep up with the clock. */ function refreshTimes(): void { if (document.hidden || !token.text.trim()) return; diff --git a/apps/jwt/src/styles.css b/apps/jwt/src/styles.css index 2fa92dd..592c043 100644 --- a/apps/jwt/src/styles.css +++ b/apps/jwt/src/styles.css @@ -75,6 +75,11 @@ .jwt-payload { color: var(--jwt-payload); } +/* An icon and a word, like the buttons beside it in height. */ +.jwt-link svg { + width: 14px; + height: 14px; +} .jwt-title-payload { color: var(--jwt-payload-title); } diff --git a/apps/time/index.html b/apps/time/index.html new file mode 100644 index 0000000..8b51622 --- /dev/null +++ b/apps/time/index.html @@ -0,0 +1,576 @@ +<!doctype html> +<html lang="en"> + <head> + <meta charset="UTF-8" /> + <meta name="viewport" content="width=device-width, initial-scale=1.0" /> + <link rel="stylesheet" href="/src/styles.css" /> + </head> + <body> + <!-- "‹ Tools" and the name are written in at build time, from the catalog. --> + <header class="ui-header time-header" data-tools-header> + <div class="ui-seg time-views" role="group" aria-label="View"> + <button type="button" class="ui-btn" data-view="convert" aria-pressed="true">Convert</button + ><button type="button" class="ui-btn" data-view="meet" aria-pressed="false"> + Find a time</button + ><button type="button" class="ui-btn" data-view="duration" aria-pressed="false"> + Duration + </button> + </div> + <!-- The same choice on a phone, where three buttons would not fit beside the others. --> + <div class="ui-view-picker"> + <select class="ui-view-select" id="view-select" aria-label="View"> + <option value="convert">Convert</option> + <option value="meet">Find a time</option> + <option value="duration">Duration</option> + </select> + <svg data-ui-icon="chevron-down" aria-hidden="true"></svg> + </div> + <button + type="button" + class="ui-btn ui-icon-btn" + id="settings-btn" + title="Settings" + aria-label="Settings" + > + <svg data-ui-icon="settings" aria-hidden="true"></svg> + </button> + <div class="ui-menu time-settings" id="settings" aria-label="Settings"> + <div class="ui-menu-label">Clock</div> + <div class="ui-menu-row"> + <span>Hours</span> + <div class="ui-seg" role="group" aria-label="Clock"> + <button type="button" class="ui-btn ui-btn--small" data-clock="24" aria-pressed="true"> + 24-hour</button + ><button + type="button" + class="ui-btn ui-btn--small" + data-clock="12" + aria-pressed="false" + > + 12-hour + </button> + </div> + </div> + </div> + <div class="ui-header-end"> + <button type="button" class="ui-btn ui-icon-btn ui-theme-toggle" id="theme-toggle"></button + ><button + type="button" + class="ui-btn ui-icon-btn" + id="help-toggle" + aria-label="Help" + aria-controls="help" + > + <svg data-ui-icon="help"></svg> + </button> + </div> + </header> + + <main class="ui-workbench time"> + <p class="ui-message" id="draft-status" role="status" hidden></p> + <datalist id="zone-list"></datalist> + + <!-- Convert: one value, or one a line. --> + <div class="time-view" id="view-convert"> + <section class="ui-card" aria-labelledby="input-title"> + <div class="ui-row"> + <h2 id="input-title"><label for="input">Time</label></h2> + <span class="ui-note" id="input-note" role="status"></span> + <button type="button" class="ui-btn ui-btn--small" id="now">Now</button> + <button type="button" class="ui-btn ui-icon-btn" id="input-clear" aria-label="Clear"> + <svg data-ui-icon="trash"></svg> + </button> + </div> + <textarea + class="ui-input time-input" + id="input" + rows="1" + autocomplete="off" + spellcheck="false" + placeholder="1760264100, 2026-10-12T14:30 or Mon, 12 Oct 2026 14:30:00 GMT; one a line to convert many" + ></textarea> + <div class="time-options"> + <div class="time-option"> + <span id="unit-label">Unix unit</span> + <div class="ui-seg" role="group" aria-labelledby="unit-label"> + <button + type="button" + class="ui-btn ui-btn--small" + data-unit="auto" + aria-pressed="true" + > + Auto</button + ><button + type="button" + class="ui-btn ui-btn--small" + data-unit="s" + aria-pressed="false" + > + Seconds</button + ><button + type="button" + class="ui-btn ui-btn--small" + data-unit="ms" + aria-pressed="false" + > + Milliseconds + </button> + </div> + </div> + <div class="time-option"> + <label for="input-zone">Without an offset, read in</label> + <input class="ui-input time-zone" id="input-zone" list="zone-list" /> + </div> + <div class="time-option"> + <span id="pick-label">Or pick</span> + <input + class="ui-input" + type="date" + id="pick-date" + aria-label="Date to convert" + aria-describedby="pick-label" + /> + <input + class="ui-input" + type="time" + id="pick-time" + step="1" + aria-label="Time to convert" + aria-describedby="pick-label" + /> + </div> + </div> + <div class="time-choice" id="choice" hidden> + <p id="choice-text"></p> + <div class="ui-seg" role="group" aria-label="Which time" id="choice-buttons"></div> + </div> + </section> + + <section class="ui-card" id="results-card" aria-labelledby="results-title"> + <div class="ui-row"> + <h2 id="results-title">Results</h2> + <span class="ui-note" id="relative" role="status"></span> + </div> + <div class="time-results" id="results"></div> + <div class="time-pin"> + <label for="pin-zone">Pin a zone</label> + <input + class="ui-input time-zone" + id="pin-zone" + list="zone-list" + placeholder="e.g. Asia/Tokyo or tokyo" + /> + <button type="button" class="ui-btn ui-btn--small" id="pin"> + <svg data-ui-icon="plus"></svg>Pin + </button> + </div> + </section> + + <section class="ui-card" id="batch-card" aria-labelledby="batch-title" hidden> + <div class="ui-row"> + <h2 id="batch-title">Results</h2> + <span class="ui-note" id="batch-note" role="status"></span> + <button type="button" class="ui-btn ui-btn--small" id="batch-copy"> + <svg data-ui-icon="copy"></svg>Copy table + </button> + </div> + <div class="time-table-wrap"> + <table class="time-table" id="batch"></table> + </div> + </section> + </div> + + <!-- Find a time: people, the search, what fits, and a day's timeline. --> + <div class="time-view meet" id="view-meet" hidden> + <div class="meet-side"> + <section class="ui-card" aria-labelledby="people-title"> + <div class="ui-row"> + <h2 id="people-title">People</h2> + <span class="ui-note" id="people-note" role="status"></span> + <button type="button" class="ui-btn ui-btn--small" id="share"> + <svg data-ui-icon="link"></svg>Share link + </button> + <button type="button" class="ui-btn ui-btn--small" id="add-person"> + <svg data-ui-icon="plus"></svg>Person + </button> + </div> + <div class="time-snapshot" id="snapshot" role="region" aria-label="Shared link" hidden> + <p id="snapshot-text"></p> + <div class="time-actions"> + <button type="button" class="ui-btn ui-btn--small" id="snapshot-replace"> + Replace mine + </button> + <button type="button" class="ui-btn ui-btn--small" id="snapshot-add"> + Add to mine + </button> + <button type="button" class="ui-btn ui-btn--small" id="snapshot-dismiss"> + Dismiss + </button> + </div> + </div> + <div class="meet-people" id="people"></div> + </section> + </div> + <div class="meet-main"> + <section class="ui-card" aria-labelledby="search-title"> + <div class="ui-row"> + <h2 id="search-title">Search</h2> + <span class="ui-note" id="search-note" role="status"></span> + </div> + <div class="time-options"> + <div class="time-option"> + <label for="call-length">Call</label> + <select class="ui-input" id="call-length"> + <option value="15">15 min</option> + <option value="30">30 min</option> + <option value="45">45 min</option> + <option value="60" selected>1 h</option> + <option value="90">1 h 30 min</option> + <option value="120">2 h</option> + <option value="180">3 h</option> + </select> + </div> + <div class="time-option"> + <label for="range-from">From</label> + <input class="ui-input" type="date" id="range-from" /> + <label for="range-to">to</label> + <input class="ui-input" type="date" id="range-to" /> + </div> + <div class="time-option"> + <label for="meet-zone">Show times in</label> + <input class="ui-input time-zone" id="meet-zone" list="zone-list" /> + </div> + </div> + </section> + + <section class="ui-card" aria-labelledby="suggestions-title"> + <div class="ui-row"> + <h2 id="suggestions-title">Suggestions</h2> + <span class="ui-note" id="suggestions-note" role="status"></span> + </div> + <ol class="time-suggestions" id="suggestions"></ol> + <div class="time-diagnosis" id="diagnosis" hidden></div> + <button type="button" class="ui-btn ui-btn--small" id="more" hidden>Show more</button> + </section> + + <section class="ui-card" aria-labelledby="timeline-title"> + <div class="ui-row"> + <h2 id="timeline-title">Timeline</h2> + <span class="ui-note" id="timeline-day"></span> + <div class="ui-seg" role="group" aria-label="Day"> + <button + type="button" + class="ui-btn ui-icon-btn" + id="day-prev" + aria-label="Previous day" + > + <svg data-ui-icon="chevron-right" class="time-flip"></svg></button + ><button + type="button" + class="ui-btn ui-icon-btn" + id="day-next" + aria-label="Next day" + > + <svg data-ui-icon="chevron-right"></svg> + </button> + </div> + </div> + <div class="time-timeline" id="timeline"></div> + <div class="time-legend" aria-hidden="true"> + <span class="level-preferred">Preferred</span> + <span class="level-acceptable">Acceptable</span> + <span class="level-unavailable">Unavailable</span> + </div> + <div class="time-picked" id="picked"> + <div class="time-option"> + <label for="picked-time">Call at</label> + <input class="ui-input" type="time" id="picked-time" step="900" /> + <span class="ui-note" id="picked-zone"></span> + <button type="button" class="ui-btn ui-btn--small" id="picked-copy" disabled> + <svg data-ui-icon="copy"></svg>Copy summary + </button> + </div> + <p class="time-picked-text" id="picked-text" role="status"></p> + <pre class="time-summary" id="picked-summary"></pre> + </div> + </section> + </div> + </div> + + <!-- Duration: between two dates, or from or to now. --> + <div class="time-view" id="view-duration" hidden> + <section class="ui-card" aria-labelledby="measure-title"> + <div class="ui-row"> + <h2 id="measure-title">Measure</h2> + <div class="ui-seg" role="group" aria-label="Preset"> + <button + type="button" + class="ui-btn ui-btn--small" + data-preset="age" + aria-pressed="true" + > + Age</button + ><button + type="button" + class="ui-btn ui-btn--small" + data-preset="since" + aria-pressed="false" + > + Since</button + ><button + type="button" + class="ui-btn ui-btn--small" + data-preset="until" + aria-pressed="false" + > + Until</button + ><button + type="button" + class="ui-btn ui-btn--small" + data-preset="between" + aria-pressed="false" + > + Between + </button> + </div> + </div> + <div class="dur-ends"> + <fieldset class="dur-end"> + <legend id="start-legend">Born</legend> + <div class="time-option"> + <input + class="ui-input" + type="date" + id="start-date" + aria-labelledby="start-legend start-date-label" + /> + <span id="start-date-label" hidden>date</span> + <input + class="ui-input" + type="time" + id="start-time" + step="1" + aria-labelledby="start-legend start-time-label" + /> + <span id="start-time-label" hidden>time (optional)</span> + <label class="time-switch" + ><button + type="button" + class="ui-switch" + id="start-now" + role="switch" + aria-checked="false" + ></button> + Now</label + > + </div> + </fieldset> + <fieldset class="dur-end"> + <legend id="end-legend">Until</legend> + <div class="time-option"> + <input + class="ui-input" + type="date" + id="end-date" + aria-labelledby="end-legend end-date-label" + /> + <span id="end-date-label" hidden>date</span> + <input + class="ui-input" + type="time" + id="end-time" + step="1" + aria-labelledby="end-legend end-time-label" + /> + <span id="end-time-label" hidden>time (optional)</span> + <label class="time-switch" + ><button + type="button" + class="ui-switch" + id="end-now" + role="switch" + aria-checked="true" + ></button> + Now</label + > + </div> + </fieldset> + </div> + <div class="time-options"> + <div class="time-option"> + <label for="dur-zone">Dates and times in</label> + <input class="ui-input time-zone" id="dur-zone" list="zone-list" /> + </div> + </div> + <p class="time-assume" id="dur-assume" role="status"></p> + <div class="time-choice" id="start-choice" hidden> + <p id="start-choice-text"></p> + <div + class="ui-seg" + role="group" + aria-label="Which first time" + id="start-choice-buttons" + ></div> + </div> + <div class="time-choice" id="end-choice" hidden> + <p id="end-choice-text"></p> + <div + class="ui-seg" + role="group" + aria-label="Which second time" + id="end-choice-buttons" + ></div> + </div> + </section> + + <section class="ui-card" aria-labelledby="span-title"> + <div class="ui-row"> + <h2 id="span-title">Calendar</h2> + <span class="ui-note" id="span-note" role="status"></span> + <button + type="button" + class="ui-btn ui-icon-btn" + id="span-copy" + aria-label="Copy calendar duration" + disabled + > + <svg data-ui-icon="copy"></svg> + </button> + </div> + <p class="dur-span" id="span"></p> + <p class="dur-extra" id="span-extra"></p> + </section> + + <section class="ui-card" aria-labelledby="totals-title"> + <div class="ui-row"> + <h2 id="totals-title">Elapsed</h2> + <span class="ui-note">A day is 24 hours.</span> + <div class="ui-seg" role="group" aria-label="Rounding"> + <button + type="button" + class="ui-btn ui-btn--small" + data-rounding="down" + aria-pressed="true" + > + Completed</button + ><button + type="button" + class="ui-btn ui-btn--small" + data-rounding="nearest" + aria-pressed="false" + > + Nearest + </button> + </div> + </div> + <div class="time-results" id="totals"></div> + <label class="time-switch dur-live" + ><button + type="button" + class="ui-switch" + id="live" + role="switch" + aria-checked="true" + ></button> + Count live while one end is now</label + > + </section> + </div> + </main> + + <aside class="ui-dock help" id="help" aria-label="Help" hidden> + <div class="ui-dock-head"> + <h2 class="ui-dock-title">Help</h2> + </div> + <div class="ui-dock-body ui-help"> + <div data-tools-about></div> + <ul> + <li> + Time zones are the browser's copy of the IANA database, named like Europe/Paris: an + offset depends on the date, because of daylight-saving time. Type a zone's name or just + its city. "Now" is this device's clock, as exact as its setting. + </li> + <li> + What you type stays in this tab; reloading restores it. People and pinned zones are kept + in this browser. Light / dark applies to every tool. + </li> + </ul> + <h3>Convert</h3> + <ul> + <li> + Type a Unix timestamp, an ISO 8601 date (2026-10-12T14:30, with or without an offset), + or an HTTP or email date. A number from 100,000,000,000 up is read as milliseconds, + below that as seconds; Seconds or Milliseconds overrides it. + </li> + <li> + A date or time without an offset is read in the zone you choose, and a date alone at + 00:00 there; the result says so. Where clocks go back, a time happens twice: choose + which. Where they go forward, it never happens: it is read as the time on the clock + after the change, and the result says so. + </li> + <li> + Results show Unix seconds and milliseconds, UTC, this device's zone, the zone it was + read in and your pinned zones, each with its offset at that date, plus an HTTP date and + how long ago or ahead it is. Each has a copy button. + </li> + <li> + Several lines convert together into a table. A line may start with a label, as in + <code>exp: 1760264100</code> or a JSON line <code>"exp": 1760264100,</code>. A time that + happens twice is not guessed in a table: give it an offset. + </li> + </ul> + <h3>Find a time</h3> + <ul> + <li> + Each person has a zone and windows on their own clock: preferred, acceptable or + unavailable. Outside every window is unavailable. Where windows overlap, unavailable + wins over preferred, and preferred over acceptable. A window ending at or before its + start runs past midnight. + </li> + <li> + Weekly windows apply on the days ticked. A date exception replaces the windows that + start on that date; with no windows, the person is unavailable that day. + </li> + <li> + The whole call must fit. Times that are preferred by everyone come first. Compromises + then rank by the most minutes any one person spends outside their preferred hours, then + by everyone's total, then the earliest. A run of starts that cost the same is one + suggestion. + </li> + <li> + Unavailable time is never used unless that person's "Allow unavailable time" is on; + those times rank after every other. When nothing fits, the search says whose windows + stand in the way. + </li> + <li> + The timeline shows one day in the zone you choose, with each person's own hours above + their row. Click it, or choose a suggestion, to place the call; Copy summary gives + everyone's own date and time. + </li> + <li> + Share link copies a link holding the people and their windows. It is a snapshot of what + was entered, not live answers. Opening one asks before replacing or adding to yours. + </li> + </ul> + <h3>Duration</h3> + <ul> + <li> + Age, Since, Until and Between set which end is now; either can be switched. A time is + optional: without one, 00:00 in the chosen zone is assumed, and the result says so. + </li> + <li> + Calendar counts whole years, months and days on the wall clock, so a day of 23 or 25 + hours at a clock change is still one day. A month too short for the day ends at its last + day: 29 February birthdays count on 28 February in other years. + </li> + <li> + Elapsed counts exact time, a day being 24 hours. Completed counts only whole units; + Nearest rounds to the nearest whole unit. It counts live, every second, while one end is + now. + </li> + </ul> + </div> + </aside> + <script type="module" src="/src/main.ts"></script> + </body> +</html> diff --git a/apps/time/package.json b/apps/time/package.json new file mode 100644 index 0000000..48fd449 --- /dev/null +++ b/apps/time/package.json @@ -0,0 +1,16 @@ +{ + "name": "@tools/time", + "version": "0.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "vite build", + "typecheck": "bun check", + "test": "bun test" + }, + "devDependencies": { + "@tools/bytes": "workspace:*", + "@tools/catalog": "workspace:*", + "@tools/ui": "workspace:*" + } +} diff --git a/apps/time/public/art.svg b/apps/time/public/art.svg new file mode 100644 index 0000000..8130cdb --- /dev/null +++ b/apps/time/public/art.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320"><defs><linearGradient id="backdrop" x2="0" y2="1"><stop stop-color="#5b8def" stop-opacity=".16"/><stop offset="1" stop-color="#5b8def" stop-opacity=".04"/></linearGradient><pattern id="off" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><path d="M0 0v6" stroke="#8a93a8" stroke-opacity=".35" stroke-width="2"/></pattern><clipPath id="bounds"><rect x="16" y="16" width="288" height="288" rx="9"/></clipPath></defs><rect width="320" height="320" fill="url(#backdrop)"/><g font-family="ui-monospace, Cascadia Code, Menlo, Consolas, monospace" clip-path="url(#bounds)"><rect x="16" y="16" width="288" height="118" rx="9" fill="#8a93a8" fill-opacity=".10" stroke="#8a93a8" stroke-opacity=".35"/><text x="28" y="39" fill="#4f86e8" font-size="12">Unix seconds</text><path d="M16 49h288" stroke="#8a93a8" stroke-opacity=".3"/><text x="28" y="76" fill="#c48a1c" font-size="16">1760264100</text><text x="28" y="100" fill="#3a9d62" font-size="12">2025-10-12T10:15:00Z</text><text x="28" y="120" fill="#8a90a0" font-size="11">12:15 Paris · 06:15 New York</text><rect x="16" y="146" width="288" height="158" rx="9" fill="#8a93a8" fill-opacity=".10" stroke="#8a93a8" stroke-opacity=".35"/><text x="28" y="169" fill="#a066bc" font-size="12">Find a time</text><path d="M16 179h288" stroke="#8a93a8" stroke-opacity=".3"/><g font-size="10" fill="#8a90a0"><text x="28" y="204">Paris</text><text x="28" y="234">New York</text><text x="28" y="264">Tokyo</text></g><g transform="translate(96 194)"><rect width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="58" width="74" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="132" width="50" height="14" fill="#5fbf7f" fill-opacity=".7"/><rect y="30" width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="66" y="30" width="56" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="122" y="30" width="74" height="14" fill="#5fbf7f" fill-opacity=".7"/><rect y="60" width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="0" y="60" width="40" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="138" y="60" width="58" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="140" y="-5" width="24" height="84" rx="3" fill="#5b8def" fill-opacity=".18" stroke="#5b8def" stroke-width="2"/></g><text x="28" y="290" fill="#3a9d62" font-size="11">Mon 20:00 · 14:00 · Tue 03:00</text></g></svg> diff --git a/apps/time/public/icon.svg b/apps/time/public/icon.svg new file mode 100644 index 0000000..10569e4 --- /dev/null +++ b/apps/time/public/icon.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="#5b8def" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0zM12 7v5l3 2"/></svg> diff --git a/apps/time/src/app.ts b/apps/time/src/app.ts new file mode 100644 index 0000000..91dfb4e --- /dev/null +++ b/apps/time/src/app.ts @@ -0,0 +1,28 @@ +/** What every view is given by main.ts: the settings, the tab's draft, and a way to report. */ + +import type { Prefs } from "./prefs.js"; + +export interface App { + readonly prefs: Prefs; + /** This view's part of the tab's draft, as last saved. */ + readonly saved: Record<string, unknown>; + /** Keeps the tab's draft, once edits pause. */ + save(): void; + /** Keeps the settings for the whole app. */ + savePrefs(): void; + /** The clipboard refused: say so where it was asked. */ + clipboardFailed(where: HTMLElement): void; + /** Opens a view, as its header button does. */ + show(view: "convert" | "meet" | "duration"): void; + /** Called when the clock setting changes, to draw times again. */ + onPrefs(listener: () => void): void; +} + +/** A view: its draft fields, and what to do as it is shown and hidden. */ +export interface View { + values(): Record<string, unknown>; + shown(visible: boolean): void; +} + +export const text = (value: unknown, fallback = ""): string => + typeof value === "string" ? value : fallback; diff --git a/apps/time/src/catalog.test.ts b/apps/time/src/catalog.test.ts new file mode 100644 index 0000000..71886b0 --- /dev/null +++ b/apps/time/src/catalog.test.ts @@ -0,0 +1,6 @@ +import { expect, test } from "bun:test"; +import { findApp } from "@tools/catalog"; + +test("is in the catalog", () => { + expect(findApp("time")).toBeDefined(); +}); diff --git a/apps/time/src/convert.ts b/apps/time/src/convert.ts new file mode 100644 index 0000000..90b17d7 --- /dev/null +++ b/apps/time/src/convert.ts @@ -0,0 +1,390 @@ +/** Convert: a timestamp or date to every form and zone; several lines to a table. */ + +import { byId, copyText, iconSvg, showMessage } from "@tools/ui"; +import { formatClock, formatDate, parseClock, parseDate } from "./lib/civil.js"; +import type { WallTime } from "./lib/civil.js"; +import { + httpDate, + isoString, + readableDate, + relativeTime, + utcOffset, + zoneAbbreviation, +} from "./lib/format.js"; +import { parseTime } from "./lib/parse.js"; +import type { Parsed, Unit } from "./lib/parse.js"; +import { localZone, offsetAt, resolveWall, UTC, wallAt } from "./lib/zone.js"; +import { MAX_PINNED, readable } from "./prefs.js"; +import { text } from "./app.js"; +import type { App, View } from "./app.js"; +import { copyButton, h, press } from "./dom.js"; +import { resolveShown } from "./resolution.js"; +import type { Choice } from "./resolution.js"; +import { bindZoneField } from "./zone-field.js"; + +type UnitChoice = Unit | "auto"; + +/** A line may start with a label: `exp: 1760264100`, or a JSON member `"exp": 1760264100,`. */ +const LABELLED = /^\s*"?([A-Za-z_][\w.-]*)"?\s*[:=]\s*"?(.+?)"?\s*,?\s*$/; +function splitLabel(line: string): { label?: string; value: string } { + const match = LABELLED.exec(line); + return match ? { label: match[1], value: match[2] } : { value: line.trim() }; +} + +/** Unix seconds, with milliseconds as a fraction when there are any. */ +function unixSeconds(ms: number): string { + const sign = ms < 0 ? "-" : ""; + const abs = Math.abs(ms); + const whole = Math.floor(abs / 1000); + const fraction = abs % 1000; + return sign + whole + (fraction ? "." + String(fraction).padStart(3, "0") : ""); +} + +const wallInput = (wall: WallTime) => + `${formatDate(wall)}T${formatClock(wall.hour * 60 + wall.minute)}:${String(wall.second).padStart(2, "0")}`; + +export function convertView(app: App): View { + const input = byId<HTMLTextAreaElement>("input"); + const note = byId("input-note"); + const unitButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-unit]")]; + const pickDate = byId<HTMLInputElement>("pick-date"); + const pickTime = byId<HTMLInputElement>("pick-time"); + const results = byId("results"); + const relative = byId("relative"); + const choiceUi = { + box: byId("choice"), + text: byId("choice-text"), + buttons: byId("choice-buttons"), + }; + + const saved = app.saved; + input.value = text(saved.input, String(Math.floor(Date.now() / 1000))); + let unit: UnitChoice = saved.unit === "s" || saved.unit === "ms" ? saved.unit : "auto"; + let choice: Choice | undefined = + saved.choice === "earlier" || saved.choice === "later" ? saved.choice : undefined; + /** The instant on show, for the relative time that ticks. */ + let shown: number | undefined; + let visible = false; + + const zoneField = bindZoneField(byId("input-zone"), text(saved.inputZone) || localZone(), () => { + choice = undefined; + update(); + }); + const pinField = bindZoneField(byId("pin-zone"), "", () => {}); + byId<HTMLInputElement>("pin-zone").value = ""; + + function fitRows(): void { + input.rows = Math.min(10, Math.max(1, input.value.split("\n").length)); + } + + /** One zone's row: its name and offset, the ISO date, and the date as the reader writes it. */ + function zoneRow(label: string, zone: string, ms: number, pinned = false): HTMLElement { + const offset = utcOffset(offsetAt(ms, zone)); + const abbr = zoneAbbreviation(ms, zone); + const iso = isoString(ms, zone); + const actions = h("div", { class: "time-result-actions" }); + if (pinned) { + const unpin = h( + "button", + { type: "button", class: "ui-btn ui-icon-btn", "aria-label": `Unpin ${zone}` }, + iconSvg("close") + ); + unpin.addEventListener("click", () => { + app.prefs.pinned = app.prefs.pinned.filter((z) => z !== zone); + app.savePrefs(); + update(); + }); + actions.append(unpin); + } + actions.append( + copyButton( + `Copy ${label}`, + () => iso, + () => app.clipboardFailed(note) + ) + ); + return h( + "div", + { class: "time-result" }, + h( + "div", + { class: "time-result-head" }, + h("span", { class: "time-result-label" }, label), + h( + "span", + { class: "time-result-meta" }, + [zone === label ? "" : zone, offset, abbr].filter(Boolean).join(" · ") + ) + ), + h("code", { class: "time-result-value" }, iso), + h("span", { class: "time-result-readable" }, readableDate(ms, zone, readable(app.prefs))), + actions + ); + } + + function plainRow(label: string, value: string, meta = ""): HTMLElement { + return h( + "div", + { class: "time-result" }, + h( + "div", + { class: "time-result-head" }, + h("span", { class: "time-result-label" }, label), + meta && h("span", { class: "time-result-meta" }, meta) + ), + h("code", { class: "time-result-value" }, value), + h( + "div", + { class: "time-result-actions" }, + copyButton( + `Copy ${label}`, + () => value, + () => app.clipboardFailed(note) + ) + ) + ); + } + + function showResults(ms: number, readIn: string | undefined): void { + shown = ms; + const here = localZone(); + const rows: HTMLElement[] = [ + plainRow("Unix seconds", unixSeconds(ms)), + plainRow("Unix milliseconds", String(ms)), + zoneRow(UTC, UTC, ms), + zoneRow("This device", here, ms), + ]; + if (readIn && readIn !== here && readIn !== UTC) rows.push(zoneRow("Read in", readIn, ms)); + for (const zone of app.prefs.pinned) + if (zone !== here && zone !== readIn && zone !== UTC) + rows.push(zoneRow(zone, zone, ms, true)); + rows.push(plainRow("HTTP date", httpDate(ms), "RFC 9110")); + results.replaceChildren(...rows); + byId("results-card").removeAttribute("data-stale"); + tick(); + } + + function clearResults(): void { + shown = undefined; + showMessage(relative, ""); + byId("results-card").toggleAttribute("data-stale", results.childElementCount > 0); + } + + function tick(): void { + if (shown !== undefined) showMessage(relative, relativeTime(shown, Date.now())); + } + + const requested = () => (unit === "auto" ? undefined : unit); + + /** What a parsed value means, and what to say about how it was read. */ + function describe(parsed: Parsed): { + ms?: number; + note: string; + readIn?: string; + wall?: WallTime; + } { + switch (parsed.kind) { + case "error": + return { note: parsed.message }; + case "unix": + return { + ms: parsed.ms, + note: `Unix ${parsed.unit === "s" ? "seconds" : "milliseconds"}${parsed.detected ? ", by its size" : ""}.`, + }; + case "instant": + return { + ms: parsed.ms, + note: + `Offset ${utcOffset(parsed.offset)} given.` + + (parsed.dropped ? " Digits past milliseconds dropped." : ""), + readIn: parsed.zone, + }; + case "wall": { + const zone = parsed.zone ?? zoneField.zone; + const resolved = resolveShown( + parsed.wall, + zone, + readable(app.prefs), + choiceUi, + choice, + (next) => { + choice = next; + update(); + } + ); + const assumed = parsed.dateOnly ? `00:00 in ${zone} assumed.` : `Read in ${zone}.`; + return { + ms: resolved.ms, + note: [assumed, resolved.note, parsed.dropped ? "Digits past milliseconds dropped." : ""] + .filter(Boolean) + .join(" "), + readIn: zone, + wall: parsed.wall, + }; + } + } + } + + function single(line: string): void { + byId("results-card").hidden = false; + byId("batch-card").hidden = true; + const { label, value } = splitLabel(line); + if (!value) { + choiceUi.box.hidden = true; + showMessage(note, "Type a timestamp or a date."); + clearResults(); + return; + } + const parsed = parseTime(value, requested()); + if (parsed.kind !== "wall") choiceUi.box.hidden = true; + const found = describe(parsed); + showMessage(note, (label ? label + ": " : "") + found.note, parsed.kind === "error"); + if (found.ms === undefined) return clearResults(); + showResults(found.ms, found.readIn); + // The pickers follow what was typed, in the zone things are read in. + const wall = found.wall ?? wallAt(found.ms, zoneField.zone); + if (wall.year >= 1 && wall.year <= 9999) { + pickDate.value = formatDate(wall); + pickTime.value = wallInput(wall).slice(11); + } + } + + function batch(lines: string[]): void { + byId("results-card").hidden = true; + byId("batch-card").hidden = false; + choiceUi.box.hidden = true; + shown = undefined; + const zone = zoneField.zone; + const rows = lines.map((line) => { + const { label, value } = splitLabel(line); + const parsed = parseTime(value, requested()); + let ms: number | undefined; + let remark = ""; + if (parsed.kind === "error") remark = parsed.message; + else if (parsed.kind === "wall") { + const where = parsed.zone ?? zone; + const found = resolveWall(parsed.wall, where); + if (found.kind === "ambiguous") remark = `Happens twice in ${where}: give it an offset.`; + else { + ms = found.ms; + remark = + found.kind === "gap" + ? `Does not exist in ${where}: read after the change.` + : `Read in ${where}.`; + } + } else ms = parsed.ms; + if (parsed.kind === "unix") remark = parsed.unit === "s" ? "Seconds." : "Milliseconds."; + return { label: label ?? "", value, ms, remark, error: ms === undefined }; + }); + const labelled = rows.some((r) => r.label); + const head = [...(labelled ? ["Label"] : []), "Input", "Unix seconds", "UTC", zone, "Note"]; + const cells = rows.map((r) => [ + ...(labelled ? [r.label] : []), + r.value, + r.ms === undefined ? "" : unixSeconds(r.ms), + r.ms === undefined ? "" : isoString(r.ms, UTC), + r.ms === undefined ? "" : isoString(r.ms, zone), + r.remark, + ]); + const table = byId<HTMLTableElement>("batch"); + table.replaceChildren( + h("thead", {}, h("tr", {}, ...head.map((c) => h("th", { scope: "col" }, c)))), + h( + "tbody", + {}, + ...cells.map((row, i) => + h( + "tr", + { "data-error": rows[i].error ? "true" : undefined }, + ...row.map((c, j) => + h("td", { class: j < head.length - 1 ? "time-mono" : undefined }, c) + ) + ) + ) + ) + ); + const failed = rows.filter((r) => r.error).length; + showMessage(note, `${rows.length} lines.`); + showMessage(byId("batch-note"), failed ? `${failed} could not be read.` : "", failed > 0); + batchText = [head, ...cells].map((row) => row.join("\t")).join("\n"); + } + let batchText = ""; + + function update(): void { + fitRows(); + press(unitButtons, "unit", unit); + const lines = input.value.split("\n").filter((line) => line.trim()); + if (lines.length > 1) batch(lines); + else single(lines[0] ?? ""); + app.save(); + } + + input.addEventListener("input", () => { + choice = undefined; + update(); + }); + for (const button of unitButtons) + button.addEventListener("click", () => { + unit = button.dataset.unit as UnitChoice; + update(); + }); + const fromPicker = () => { + const date = parseDate(pickDate.value); + if (!date) return; + const clock = pickTime.value ? parseClock(pickTime.value) : 0; + if (clock === undefined) return; + const seconds = /:(\d{2}):(\d{2})/.exec(pickTime.value)?.[2] ?? "00"; + input.value = `${formatDate(date)}T${formatClock(clock)}:${seconds}`; + choice = undefined; + update(); + }; + pickDate.addEventListener("change", fromPicker); + pickTime.addEventListener("change", fromPicker); + byId("now").addEventListener("click", () => { + input.value = String(Math.floor(Date.now() / 1000)); + choice = undefined; + update(); + }); + byId("input-clear").addEventListener("click", () => { + input.value = ""; + update(); + input.focus(); + }); + byId("batch-copy").addEventListener("click", async (event) => { + const button = event.currentTarget as HTMLElement; + if (!(await copyText(batchText, button))) app.clipboardFailed(byId("batch-note")); + }); + const pin = () => { + const zone = pinField.zone; + const typed = byId<HTMLInputElement>("pin-zone"); + if (!zone || typed.getAttribute("aria-invalid") === "true") return; + if (!app.prefs.pinned.includes(zone)) { + app.prefs.pinned = [...app.prefs.pinned, zone].slice(-MAX_PINNED); + app.savePrefs(); + } + typed.value = ""; + pinField.set(""); + update(); + }; + byId("pin").addEventListener("click", pin); + byId("pin-zone").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + // The field reads what was typed as it is left; Enter commits it first. + byId("pin-zone").dispatchEvent(new Event("change")); + pin(); + } + }); + app.onPrefs(update); + setInterval(() => { + if (visible) tick(); + }, 15_000); + + update(); + return { + values: () => ({ input: input.value, unit, choice, inputZone: zoneField.zone }), + shown(on) { + visible = on; + if (on) tick(); + }, + }; +} diff --git a/apps/time/src/dom.ts b/apps/time/src/dom.ts new file mode 100644 index 0000000..0518647 --- /dev/null +++ b/apps/time/src/dom.ts @@ -0,0 +1,43 @@ +/** Building the parts of the page drawn from data: results, people, suggestions, the timeline. */ + +import { copyText, iconSvg } from "@tools/ui"; + +type Child = Node | string | false | undefined | null; + +/** An element with attributes (`class`, `aria-*`, `data-*`, …) and children. */ +export function h<K extends keyof HTMLElementTagNameMap>( + tag: K, + attributes: Record<string, string | boolean | undefined> = {}, + ...children: Child[] +): HTMLElementTagNameMap[K] { + const element = document.createElement(tag); + for (const [name, value] of Object.entries(attributes)) { + if (value === undefined || value === false) continue; + element.setAttribute(name, value === true ? "" : value); + } + for (const child of children) if (child) element.append(child); + return element; +} + +/** A copy button for a value, flashing when copied; `onFail` when the clipboard refuses. */ +export function copyButton( + label: string, + value: () => string, + onFail: () => void +): HTMLButtonElement { + const button = h( + "button", + { type: "button", class: "ui-btn ui-icon-btn", "aria-label": label }, + iconSvg("copy") + ); + button.addEventListener("click", async () => { + if (!(await copyText(value(), button))) onFail(); + }); + return button; +} + +/** Pressed state for a set of buttons that pick one value. */ +export function press(buttons: Iterable<HTMLElement>, attribute: string, value: string): void { + for (const button of buttons) + button.setAttribute("aria-pressed", String(button.dataset[attribute] === value)); +} diff --git a/apps/time/src/duration-view.ts b/apps/time/src/duration-view.ts new file mode 100644 index 0000000..6d4e107 --- /dev/null +++ b/apps/time/src/duration-view.ts @@ -0,0 +1,253 @@ +/** Duration: calendar age and elapsed time between two dates, either of which can be now. */ + +import { bindSwitch, byId, copyText, isOn, setOn, showMessage } from "@tools/ui"; +import { dayNumber, parseClock, parseDate } from "./lib/civil.js"; +import type { WallTime } from "./lib/civil.js"; +import { calendarSpan, describeSpan, nextAnniversary, totalIn } from "./lib/duration.js"; +import type { Rounding, TotalUnit } from "./lib/duration.js"; +import { readableDate, relativeTime } from "./lib/format.js"; +import { localZone, wallAt } from "./lib/zone.js"; +import { readable } from "./prefs.js"; +import { text } from "./app.js"; +import type { App, View } from "./app.js"; +import { copyButton, h, press } from "./dom.js"; +import { resolveShown } from "./resolution.js"; +import type { Choice } from "./resolution.js"; +import { bindZoneField } from "./zone-field.js"; + +type Preset = "age" | "since" | "until" | "between"; +type End = "start" | "end"; + +const LEGENDS: Record<Preset, [string, string]> = { + age: ["Born", "Age on"], + since: ["Since", "Until"], + until: ["From", "Until"], + between: ["From", "To"], +}; +const NOW: Record<Preset, [boolean, boolean]> = { + age: [false, true], + since: [false, true], + until: [true, false], + between: [false, false], +}; +const UNITS: [TotalUnit, string][] = [ + ["weeks", "Weeks"], + ["days", "Days"], + ["hours", "Hours"], + ["minutes", "Minutes"], + ["seconds", "Seconds"], +]; + +export function durationView(app: App): View { + const saved = app.saved; + const presetButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-preset]")]; + const roundingButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-rounding]")]; + const field = (end: End, part: "date" | "time") => byId<HTMLInputElement>(`${end}-${part}`); + const now = (end: End) => byId(`${end}-now`); + const assume = byId("dur-assume"); + const choiceUi = (end: End) => ({ + box: byId(`${end}-choice`), + text: byId(`${end}-choice-text`), + buttons: byId(`${end}-choice-buttons`), + }); + const live = byId("live"); + + let preset: Preset = + (["age", "since", "until", "between"] as const).find((p) => p === saved.preset) ?? "age"; + let rounding: Rounding = saved.rounding === "nearest" ? "nearest" : "down"; + const choices: Partial<Record<End, Choice>> = {}; + let visible = false; + for (const end of ["start", "end"] as const) { + field(end, "date").value = text(saved[`${end}Date`]); + field(end, "time").value = text(saved[`${end}Time`]); + const on = saved[`${end}Now`]; + setOn(now(end), typeof on === "boolean" ? on : NOW[preset][end === "start" ? 0 : 1]); + } + if (typeof saved.live === "boolean") setOn(live, saved.live); + const zoneField = bindZoneField(byId("dur-zone"), text(saved.durZone) || localZone(), () => + update() + ); + + /** One end as an instant, or what is missing; `timed` when it has a time of day. */ + /** `timed` is undefined for now, which has every time of day. */ + function instant(end: End, zone: string): { ms?: number; timed?: boolean; note: string } { + choiceUi(end).box.hidden = true; + if (isOn(now(end))) return { ms: Date.now(), timed: undefined, note: "" }; + const date = parseDate(field(end, "date").value); + if (!date) + return { timed: false, note: `Choose the ${end === "start" ? "first" : "second"} date.` }; + const clock = field(end, "time").value; + const minutes = clock ? parseClock(clock) : 0; + const second = Number(/:\d{2}:(\d{2})/.exec(clock)?.[1] ?? 0); + const wall: WallTime = { + ...date, + hour: Math.floor((minutes ?? 0) / 60), + minute: (minutes ?? 0) % 60, + second, + ms: 0, + }; + const resolved = resolveShown( + wall, + zone, + readable(app.prefs), + choiceUi(end), + choices[end], + (choice) => { + choices[end] = choice; + update(); + } + ); + const legend = byId(`${end}-legend`).textContent; + const note = clock ? resolved.note : `${legend}: 00:00 assumed. ${resolved.note}`.trim(); + return { ms: resolved.ms, timed: Boolean(clock), note }; + } + + function show(): void { + const zone = zoneField.zone; + const [startLegend, endLegend] = LEGENDS[preset]; + byId("start-legend").textContent = startLegend; + byId("end-legend").textContent = endLegend; + for (const end of ["start", "end"] as const) { + field(end, "date").disabled = isOn(now(end)); + field(end, "time").disabled = isOn(now(end)); + } + press(presetButtons, "preset", preset); + press(roundingButtons, "rounding", rounding); + const start = instant("start", zone); + const end = instant("end", zone); + showMessage(assume, [start.note, end.note].filter(Boolean).join(" ")); + const span = byId("span"); + const extra = byId("span-extra"); + const totals = byId("totals"); + const copy = byId<HTMLButtonElement>("span-copy"); + if (start.ms === undefined || end.ms === undefined) { + span.textContent = "—"; + extra.textContent = ""; + showMessage(byId("span-note"), ""); + totals.hidden = true; + copy.disabled = true; + return; + } + const calendar = calendarSpan(start.ms, end.ms, zone); + const words = describeSpan(calendar, start.timed !== false && end.timed !== false); + span.textContent = words; + copy.disabled = false; + showMessage(byId("span-note"), calendar.negative ? "The second date is before the first." : ""); + extra.textContent = extraLine(start.ms, end.ms, zone); + const elapsed = end.ms - start.ms; + totals.hidden = false; + for (const [unit] of UNITS) { + values[unit] = totalIn(elapsed, unit, rounding); + cells[unit].textContent = numbers.format(values[unit]); + } + } + + /** The age to come, or the date being counted to or from. */ + function extraLine(start: number, end: number, zone: string): string { + const options = readable(app.prefs); + if (preset === "age" && !isOn(now("start"))) { + const birth = parseDate(field("start", "date").value)!; + const today = wallAt(isOn(now("end")) ? Date.now() : end, zone); + const next = nextAnniversary(birth, today); + const days = dayNumber(next.date) - dayNumber(today); + if (next.years === 0) return ""; + const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`; + return `Turns ${next.years} ${when}.`; + } + const other = isOn(now("start")) ? end : start; + if (isOn(now("start")) || isOn(now("end"))) + return `${readableDate(other, zone, options)} · ${relativeTime(other, Date.now())}`; + return ""; + } + + /** The totals' rows, made once: a live count rewrites only their numbers, so focus stays. */ + const numbers = new Intl.NumberFormat(); + const values = {} as Record<TotalUnit, number>; + const cells = {} as Record<TotalUnit, HTMLElement>; + byId("totals").replaceChildren( + ...UNITS.map(([unit, label]) => { + cells[unit] = h("code", { class: "time-result-value" }); + return h( + "div", + { class: "time-result time-total" }, + h("div", { class: "time-result-head" }, h("span", { class: "time-result-label" }, label)), + cells[unit], + h( + "div", + { class: "time-result-actions" }, + copyButton( + `Copy ${label.toLowerCase()}`, + () => String(values[unit]), + () => app.clipboardFailed(byId("span-note")) + ) + ) + ); + }) + ); + + let timer: ReturnType<typeof setInterval> | undefined; + function schedule(): void { + const ticking = visible && isOn(live) && (isOn(now("start")) || isOn(now("end"))); + if (ticking && !timer) timer = setInterval(show, 1000); + if (!ticking && timer) { + clearInterval(timer); + timer = undefined; + } + } + + function update(): void { + show(); + schedule(); + app.save(); + } + + for (const button of presetButtons) + button.addEventListener("click", () => { + preset = button.dataset.preset as Preset; + setOn(now("start"), NOW[preset][0]); + setOn(now("end"), NOW[preset][1]); + update(); + const first = (["start", "end"] as const).find((end) => !isOn(now(end))); + if (first) field(first, "date").focus(); + }); + for (const button of roundingButtons) + button.addEventListener("click", () => { + rounding = button.dataset.rounding as Rounding; + update(); + }); + for (const end of ["start", "end"] as const) { + for (const part of ["date", "time"] as const) + field(end, part).addEventListener("input", () => { + delete choices[end]; + update(); + }); + bindSwitch(now(end), () => update()); + } + bindSwitch(live, () => update()); + byId("span-copy").addEventListener("click", async () => { + if (!(await copyText(byId("span").textContent ?? "", byId("span-copy")))) + app.clipboardFailed(byId("span-note")); + }); + app.onPrefs(show); + + show(); + return { + values: () => ({ + preset, + rounding, + live: isOn(live), + durZone: zoneField.zone, + startDate: field("start", "date").value, + startTime: field("start", "time").value, + startNow: isOn(now("start")), + endDate: field("end", "date").value, + endTime: field("end", "time").value, + endNow: isOn(now("end")), + }), + shown(on) { + visible = on; + if (on) show(); + schedule(); + }, + }; +} diff --git a/apps/time/src/lib/availability.test.ts b/apps/time/src/lib/availability.test.ts new file mode 100644 index 0000000..3d222a5 --- /dev/null +++ b/apps/time/src/lib/availability.test.ts @@ -0,0 +1,290 @@ +import { describe, expect, test } from "bun:test"; +import { + combine, + costOf, + diagnose, + explain, + findTimes, + personSegments, + summary, +} from "./availability.js"; +import type { Person, Search } from "./availability.js"; +import { readPlan, sharePlan, writePlan } from "./plan.js"; + +const utc = (iso: string) => Date.parse(iso); +const hours = (h: number, m = 0) => h * 60 + m; + +function person( + id: string, + zone: string, + windows: Person["windows"], + extra: Partial<Person> = {} +): Person { + return { id, name: id, zone, windows, exceptions: [], relaxed: false, ...extra }; +} +const EVERY_DAY = [1, 2, 3, 4, 5, 6, 7]; + +describe("segments", () => { + test("a window is read on the person's own clock, and outside windows is unavailable", () => { + const paris = person("Ana", "Europe/Paris", [ + { days: EVERY_DAY, start: hours(18), end: hours(22), level: "preferred" }, + ]); + const from = utc("2026-10-12T00:00:00Z"); + expect(personSegments(paris, from, from + 86_400_000)).toEqual([ + { start: from, end: utc("2026-10-12T16:00:00Z"), level: "unavailable" }, + { start: utc("2026-10-12T16:00:00Z"), end: utc("2026-10-12T20:00:00Z"), level: "preferred" }, + { start: utc("2026-10-12T20:00:00Z"), end: from + 86_400_000, level: "unavailable" }, + ]); + }); + test("an evening window follows daylight-saving time rather than a fixed offset", () => { + const paris = person("Ana", "Europe/Paris", [ + { days: EVERY_DAY, start: hours(18), end: hours(19), level: "preferred" }, + ]); + const before = personSegments(paris, utc("2026-10-24T00:00:00Z"), utc("2026-10-25T00:00:00Z")); + const after = personSegments(paris, utc("2026-10-26T00:00:00Z"), utc("2026-10-27T00:00:00Z")); + expect(before.find((s) => s.level === "preferred")!.start).toBe(utc("2026-10-24T16:00:00Z")); + expect(after.find((s) => s.level === "preferred")!.start).toBe(utc("2026-10-26T17:00:00Z")); + }); + test("windows past midnight run into the next day; unavailable wins where windows overlap", () => { + const owl = person("Owl", "UTC", [ + { days: [5], start: hours(22), end: hours(2), level: "preferred" }, + { days: [5], start: hours(20), end: hours(23, 30), level: "acceptable" }, + { days: [5], start: hours(23), end: hours(23, 30), level: "unavailable" }, + ]); + // Friday 16 October 2026. + const from = utc("2026-10-16T20:00:00Z"); + expect(personSegments(owl, from, utc("2026-10-17T03:00:00Z"))).toEqual([ + { start: from, end: utc("2026-10-16T22:00:00Z"), level: "acceptable" }, + { start: utc("2026-10-16T22:00:00Z"), end: utc("2026-10-16T23:00:00Z"), level: "preferred" }, + { + start: utc("2026-10-16T23:00:00Z"), + end: utc("2026-10-16T23:30:00Z"), + level: "unavailable", + }, + { start: utc("2026-10-16T23:30:00Z"), end: utc("2026-10-17T02:00:00Z"), level: "preferred" }, + { + start: utc("2026-10-17T02:00:00Z"), + end: utc("2026-10-17T03:00:00Z"), + level: "unavailable", + }, + ]); + }); + test("a date exception replaces the windows that start on that date", () => { + const busy = person( + "Bo", + "UTC", + [{ days: EVERY_DAY, start: hours(9), end: hours(17), level: "preferred" }], + { + exceptions: [{ date: "2026-10-13", windows: [] }], + } + ); + const segments = personSegments(busy, utc("2026-10-13T00:00:00Z"), utc("2026-10-14T00:00:00Z")); + expect(segments).toEqual([ + { + start: utc("2026-10-13T00:00:00Z"), + end: utc("2026-10-14T00:00:00Z"), + level: "unavailable", + }, + ]); + }); + test("a window that ends where it starts is the whole day", () => { + const always = person("Al", "UTC", [ + { days: EVERY_DAY, start: 0, end: 0, level: "acceptable" }, + ]); + expect( + personSegments(always, utc("2026-10-13T00:00:00Z"), utc("2026-10-15T00:00:00Z")) + ).toEqual([ + { start: utc("2026-10-13T00:00:00Z"), end: utc("2026-10-15T00:00:00Z"), level: "acceptable" }, + ]); + }); +}); + +describe("finding a time", () => { + // Ana in Paris prefers 18:00 to 21:00, accepts from 08:00 to 22:00. Ben in New York prefers + // 12:00 to 15:00, accepts from 08:00 to 20:00. 12 October 2026: Paris is UTC+2, New York UTC-4. + const ana = person("Ana", "Europe/Paris", [ + { days: EVERY_DAY, start: hours(8), end: hours(22), level: "acceptable" }, + { days: EVERY_DAY, start: hours(18), end: hours(21), level: "preferred" }, + ]); + const ben = person("Ben", "America/New_York", [ + { days: EVERY_DAY, start: hours(8), end: hours(20), level: "acceptable" }, + { days: EVERY_DAY, start: hours(12), end: hours(15), level: "preferred" }, + ]); + const day: Search = { + from: utc("2026-10-12T00:00:00Z"), + to: utc("2026-10-13T00:00:00Z"), + duration: 60, + step: 15, + }; + + test("fully preferred overlaps come first, as one suggestion for a run of starts", () => { + const found = findTimes([ana, ben], day); + // Both preferred: 16:00–19:00 UTC overlaps 16:00–19:00 UTC: starts from 16:00 to 18:00. + expect(found[0]).toEqual({ + start: utc("2026-10-12T16:00:00Z"), + last: utc("2026-10-12T18:00:00Z"), + costs: [ + { acceptable: 0, unavailable: 0 }, + { acceptable: 0, unavailable: 0 }, + ], + }); + expect(explain([ana, ben], found[0].costs)).toBe("Everyone: preferred hours."); + }); + + test("compromises minimise the worst person's minutes before the total", () => { + const tight = { + ...ana, + windows: [ + ...ana.windows.slice(0, 1), + { days: EVERY_DAY, start: hours(21), end: hours(22), level: "preferred" as const }, + ], + }; + // Ana prefers 19:00–20:00 UTC; Ben prefers 16:00–19:00 UTC; both accept 12:00–20:00 UTC. + const found = findTimes([tight, ben], day); + const first = found[0]; + const worst = Math.max(...first.costs.map((c) => c.acceptable)); + for (const other of found) + expect(Math.max(...other.costs.map((c) => c.acceptable))).toBeGreaterThanOrEqual(worst); + // A call from 18:30 costs each 30 minutes: better than 0 for one and 60 for the other. + expect(first.start).toBe(utc("2026-10-12T18:30:00Z")); + expect(explain([tight, ben], first.costs)).toBe( + "Ana: 30 min outside preferred hours; Ben: 30 min outside preferred hours." + ); + }); + + test("the whole call must fit, not only its start", () => { + const found = findTimes([ana, ben], { ...day, duration: 120 }); + for (const s of found) + expect(s.last + 120 * 60_000).toBeLessThanOrEqual(utc("2026-10-13T00:00:00Z")); + // Ben stops accepting at 00:00 UTC (20:00 New York): the last start is 22:00 UTC. + expect(Math.max(...found.map((s) => s.last))).toBe(utc("2026-10-12T18:00:00Z")); + }); + + test("unavailable time is excluded unless relaxed, and relaxed times rank last", () => { + const early = person("Cy", "Asia/Tokyo", [ + { days: EVERY_DAY, start: hours(7), end: hours(9), level: "acceptable" }, + ]); + // Cy accepts 22:00–00:00 UTC; Ana stops at 20:00 UTC. + expect(findTimes([ana, early], day)).toEqual([]); + const diagnosis = diagnose([ana, early], day); + expect(diagnosis.alone).toEqual([]); + expect(diagnosis.blocking.map((b) => b.id).sort()).toEqual(["Ana", "Cy"]); + const relaxed = findTimes([{ ...ana, relaxed: true }, early], day); + expect(relaxed.length).toBeGreaterThan(0); + expect(relaxed[0].costs[0].unavailable).toBeGreaterThan(0); + expect(explain([ana, early], relaxed[0].costs)).toContain("Ana: 1 h in unavailable time"); + }); + + test("someone with no window long enough is named", () => { + const brief = person("Di", "UTC", [ + { days: EVERY_DAY, start: hours(10), end: hours(10, 30), level: "preferred" }, + ]); + expect(diagnose([ana, brief], day).alone).toEqual(["Di"]); + }); + + test("costs count minutes at each level", () => { + const segments = personSegments(ana, day.from, day.to); + expect(costOf(segments, utc("2026-10-12T15:30:00Z"), utc("2026-10-12T16:30:00Z"))).toEqual({ + acceptable: 30, + unavailable: 0, + }); + expect(costOf(segments, utc("2026-10-12T19:30:00Z"), utc("2026-10-12T20:30:00Z"))).toEqual({ + acceptable: 30, + unavailable: 30, + }); + }); + + test("everyone's levels combine to the worst at each moment", () => { + const both = combine( + [personSegments(ana, day.from, day.to), personSegments(ben, day.from, day.to)], + day.from, + day.to + ); + expect(both.find((s) => s.level === "preferred")).toEqual({ + start: utc("2026-10-12T16:00:00Z"), + end: utc("2026-10-12T19:00:00Z"), + level: "preferred", + }); + }); + + test("a summary gives each person their own date and time", () => { + const tokyo = person("Kai", "Asia/Tokyo", []); + expect( + summary([ana, ben, tokyo], utc("2026-10-12T18:00:00Z"), 60, { + locale: "en-GB", + hour12: false, + }) + ).toBe( + [ + "Call, 1 h", + "Ana (Europe/Paris): Mon 12 Oct, 20:00–21:00", + "Ben (America/New_York): Mon 12 Oct, 14:00–15:00", + "Kai (Asia/Tokyo): Tue 13 Oct, 03:00–04:00", + ].join("\n") + ); + }); +}); + +describe("saved plans", () => { + test("read field by field: what is valid is kept, the rest defaulted or dropped", () => { + const plan = readPlan({ + version: 1, + duration: "long", + people: [ + { + id: "a", + name: "Ana", + zone: "Europe/Paris", + relaxed: true, + windows: [ + { days: [1, 1, 9, 2], start: 60, end: 120, level: "preferred" }, + { days: [], start: 60, end: 120, level: "preferred" }, + { days: [3], start: 60, end: 2000 }, + { days: [3], start: 60, end: 90, level: "mystery" }, + ], + exceptions: [ + { date: "2026-10-13", windows: [{ start: 0, end: 60, level: "acceptable" }, "x"] }, + { date: "2026-10-13", windows: [] }, + { date: "not a date", windows: [] }, + ], + }, + { id: "a", name: 7, zone: "Asia/Tokyo" }, + { name: "No zone" }, + "nobody", + ], + }); + expect(plan!.duration).toBe(60); + expect(plan!.people).toHaveLength(2); + const [ana, second] = plan!.people; + expect(ana).toEqual({ + id: "a", + name: "Ana", + zone: "Europe/Paris", + relaxed: true, + windows: [ + { days: [1, 2], start: 60, end: 120, level: "preferred" }, + { days: [3], start: 60, end: 90, level: "acceptable" }, + ], + exceptions: [{ date: "2026-10-13", windows: [{ start: 0, end: 60, level: "acceptable" }] }], + }); + expect(second.id).not.toBe("a"); + expect(second).toMatchObject({ + name: "", + zone: "Asia/Tokyo", + windows: [], + exceptions: [], + relaxed: false, + }); + expect(readPlan("nothing")).toBeUndefined(); + }); + test("what is written reads back the same; a shared plan leaves out the ids", () => { + const plan = readPlan({ + people: [{ id: "x", name: "Ana", zone: "UTC", windows: [], exceptions: [] }], + duration: 30, + })!; + expect(readPlan(JSON.parse(JSON.stringify(writePlan(plan))))).toEqual(plan); + const shared = sharePlan(plan); + expect(JSON.stringify(shared)).not.toContain('"id"'); + expect(readPlan(shared)!.people[0]).toMatchObject({ name: "Ana", zone: "UTC" }); + }); +}); diff --git a/apps/time/src/lib/availability.ts b/apps/time/src/lib/availability.ts new file mode 100644 index 0000000..08261fb --- /dev/null +++ b/apps/time/src/lib/availability.ts @@ -0,0 +1,291 @@ +/** + * Find a time: people's availability, each in their own zone, compared at the same instants. + * + * The model: a person's windows say when they are **preferred** or **acceptable**, or mark time + * **unavailable** inside them; any time outside every window is unavailable. Where windows + * overlap, unavailable wins over preferred, and preferred over acceptable. Weekly windows apply on + * the weekdays they name; a date exception replaces the windows that start on that date. A + * window whose end is not after its start runs past midnight into the next day, and one that + * ends where it starts lasts the whole day. + * + * Windows are read on each person's wall clock, date by date, so an evening window follows that + * person's daylight-saving changes rather than a frozen UTC offset. + */ + +import { atMinutes, civilFromDays, dayNumber, formatDate, MINUTE, weekday } from "./civil.js"; +import { clockTime, formatMinutes, shortDate } from "./format.js"; +import type { ReadableOptions } from "./format.js"; +import { instantOf, wallAt } from "./zone.js"; + +export type Level = "preferred" | "acceptable" | "unavailable"; +export const LEVELS: readonly Level[] = ["preferred", "acceptable", "unavailable"]; + +/** Minutes after midnight on the person's own clock. */ +export interface TimeWindow { + start: number; + end: number; + level: Level; +} + +export interface WeeklyWindow extends TimeWindow { + /** ISO weekdays: 1 is Monday, 7 is Sunday. */ + days: number[]; +} + +export interface DateException { + /** `YYYY-MM-DD`. */ + date: string; + /** None: unavailable all day. */ + windows: TimeWindow[]; +} + +export interface Person { + id: string; + name: string; + zone: string; + windows: WeeklyWindow[]; + exceptions: DateException[]; + /** Their unavailable time may be used, ranked after every time that does not need it. */ + relaxed: boolean; +} + +/** A stretch of real time at one level. */ +export interface Segment { + start: number; + end: number; + level: Level; +} + +const RANK: Record<Level, number> = { acceptable: 1, preferred: 2, unavailable: 3 }; + +/** A window's length in minutes: past midnight when its end is not after its start. */ +export function windowLength(window: TimeWindow): number { + return window.end > window.start ? window.end - window.start : window.end + 1440 - window.start; +} + +/** The windows that start on a date, from its exception or else the weekly pattern. */ +export function windowsOn(person: Person, date: string, isoWeekday: number): TimeWindow[] { + const exception = person.exceptions.find((x) => x.date === date); + return exception ? exception.windows : person.windows.filter((w) => w.days.includes(isoWeekday)); +} + +/** + * A person's levels from `from` to `to`, as segments that cover it all, in order, with + * neighbours always at different levels. + */ +export function personSegments(person: Person, from: number, to: number): Segment[] { + const raw: Segment[] = []; + // A window from the evening before can still be running at `from`. + const first = dayNumber(wallAt(from, person.zone)) - 1; + const last = dayNumber(wallAt(to, person.zone)); + for (let day = first; day <= last; day++) { + const date = civilFromDays(day); + for (const window of windowsOn(person, formatDate(date), weekday(date))) { + const start = instantOf(atMinutes(date, window.start), person.zone, "earlier"); + const end = instantOf( + atMinutes(date, window.start + windowLength(window)), + person.zone, + "later" + ); + if (end > from && start < to && end > start) raw.push({ start, end, level: window.level }); + } + } + const edges = [...new Set([from, to, ...raw.flatMap((s) => [s.start, s.end])])] + .filter((t) => t >= from && t <= to) + .sort((a, b) => a - b); + const segments: Segment[] = []; + for (let i = 0; i < edges.length - 1; i++) { + const [start, end] = [edges[i], edges[i + 1]]; + let level: Level | undefined; + for (const s of raw) + if (s.start <= start && s.end >= end && (!level || RANK[s.level] > RANK[level])) + level = s.level; + level ??= "unavailable"; + const previous = segments[segments.length - 1]; + if (previous?.level === level) previous.end = end; + else segments.push({ start, end, level }); + } + return segments; +} + +/** Minutes of a call outside a person's preferred time. */ +export interface Cost { + acceptable: number; + unavailable: number; +} + +/** How a call from `start` to `end` sits with one person's segments. */ +export function costOf(segments: Segment[], start: number, end: number): Cost { + const cost: Cost = { acceptable: 0, unavailable: 0 }; + // The first segment that ends after the start. + let low = 0; + let high = segments.length; + while (low < high) { + const mid = (low + high) >> 1; + if (segments[mid].end <= start) low = mid + 1; + else high = mid; + } + let covered = 0; + for (let i = low; i < segments.length && segments[i].start < end; i++) { + const s = segments[i]; + const overlap = Math.min(end, s.end) - Math.max(start, s.start); + covered += overlap; + if (s.level !== "preferred") cost[s.level] += overlap / MINUTE; + } + // Beyond the segments is beyond the search: nobody said they were free then. + cost.unavailable += Math.max(0, end - start - covered) / MINUTE; + return cost; +} + +export interface Search { + /** The first start considered. */ + from: number; + /** Starts are before this; the call may run past it. */ + to: number; + /** Call length in minutes. */ + duration: number; + /** Minutes between the starts considered. */ + step: number; +} + +/** One time to suggest: every start from `start` to `last` costs everyone the same. */ +export interface Suggestion { + start: number; + last: number; + costs: Cost[]; +} + +const totalOf = (c: Cost) => c.acceptable + c.unavailable; + +/** + * The order of suggestions. Times that need nobody's unavailable time come first. Then the least + * unavailable time any one person gives up, and in total; then the most minutes any one person + * spends outside their preferred hours, so no one bears a large compromise to spare the group a + * small one; then the group's total; then the earliest. + */ +export function rankKey(suggestion: Suggestion): number[] { + const { costs } = suggestion; + const unavailable = costs.map((c) => c.unavailable); + const outside = costs.map(totalOf); + const sum = (values: number[]) => values.reduce((a, b) => a + b, 0); + return [ + Math.max(...unavailable) > 0 ? 1 : 0, + Math.max(...unavailable), + sum(unavailable), + Math.max(...outside), + sum(outside), + suggestion.start, + ]; +} + +function compareKeys(a: number[], b: number[]): number { + for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return a[i] - b[i]; + return 0; +} + +const sameCosts = (a: Cost[], b: Cost[]) => + a.every((c, i) => c.acceptable === b[i].acceptable && c.unavailable === b[i].unavailable); + +/** Every time the whole call fits everyone, best first. */ +export function findTimes(people: Person[], search: Search, segments?: Segment[][]): Suggestion[] { + if (!people.length) return []; + const length = search.duration * MINUTE; + const step = search.step * MINUTE; + const all = segments ?? people.map((p) => personSegments(p, search.from, search.to + length)); + const found: Suggestion[] = []; + let open: Suggestion | undefined; + for (let start = search.from; start < search.to; start += step) { + const costs = all.map((s) => costOf(s, start, start + length)); + const fits = costs.every((c, i) => people[i].relaxed || c.unavailable === 0); + if (!fits) { + open = undefined; + continue; + } + if (open && sameCosts(open.costs, costs)) open.last = start; + else found.push((open = { start, last: start, costs })); + } + return found.sort((a, b) => compareKeys(rankKey(a), rankKey(b))); +} + +export interface Diagnosis { + /** People with no time of their own long enough for the call. */ + alone: string[]; + /** People without whom the others would find times, and how many. */ + blocking: { id: string; count: number }[]; +} + +/** Why nothing fits: whose windows stand in the way. */ +export function diagnose(people: Person[], search: Search): Diagnosis { + const alone = people.filter((p) => !p.relaxed && !findTimes([p], search).length).map((p) => p.id); + const blocking: Diagnosis["blocking"] = []; + if (people.length > 1) + for (const person of people) { + if (alone.includes(person.id) && alone.length > 1) continue; + const count = findTimes( + people.filter((p) => p !== person), + search + ).length; + if (count) blocking.push({ id: person.id, count }); + } + return { alone, blocking }; +} + +/** "Alice: 30 min outside preferred hours; everyone else: preferred." */ +export function explain(people: Person[], costs: Cost[]): string { + const parts: string[] = []; + costs.forEach((cost, i) => { + const notes: string[] = []; + if (cost.acceptable) notes.push(`${formatMinutes(cost.acceptable)} outside preferred hours`); + if (cost.unavailable) notes.push(`${formatMinutes(cost.unavailable)} in unavailable time`); + if (notes.length) parts.push(`${people[i].name || "Unnamed"}: ${notes.join(", ")}`); + }); + if (!parts.length) return people.length === 1 ? "Preferred hours." : "Everyone: preferred hours."; + if (parts.length < costs.length) parts.push("everyone else: preferred"); + return parts.join("; ") + "."; +} + +/** + * The call as text to paste into a message: each person's own date and time. + * "Alice (Europe/Paris): Sat 14 Nov, 20:00–21:00". + */ +export function summary( + people: Person[], + start: number, + duration: number, + options: ReadableOptions = {} +): string { + const end = start + duration * MINUTE; + const lines = people.map((person) => { + const startDay = shortDate(start, person.zone, options.locale); + const endDay = shortDate(end, person.zone, options.locale); + const range = + `${clockTime(start, person.zone, options)}–${clockTime(end, person.zone, options)}` + + (endDay === startDay ? "" : ` (${endDay})`); + return `${person.name || "Unnamed"} (${person.zone}): ${startDay}, ${range}`; + }); + return [`Call, ${formatMinutes(duration)}`, ...lines].join("\n"); +} + +/** The levels at once: unavailable where anyone is, else acceptable where anyone is. */ +export function combine(segments: Segment[][], from: number, to: number): Segment[] { + const edges = [...new Set([from, to, ...segments.flat().flatMap((s) => [s.start, s.end])])] + .filter((t) => t >= from && t <= to) + .sort((a, b) => a - b); + const result: Segment[] = []; + for (let i = 0; i < edges.length - 1; i++) { + const [start, end] = [edges[i], edges[i + 1]]; + let level: Level = "preferred"; + for (const person of segments) { + const s = person.find((x) => x.start <= start && x.end >= end); + const here = s?.level ?? "unavailable"; + if (here === "unavailable" || (here === "acceptable" && level === "preferred")) level = here; + } + const previous = result[result.length - 1]; + if (previous?.level === level) previous.end = end; + else result.push({ start, end, level }); + } + return result; +} + +/** Days in a search, as a guard against a range nobody meant. */ +export const MAX_SEARCH_DAYS = 31; diff --git a/apps/time/src/lib/civil.ts b/apps/time/src/lib/civil.ts new file mode 100644 index 0000000..24c16b1 --- /dev/null +++ b/apps/time/src/lib/civil.ts @@ -0,0 +1,144 @@ +/** + * Calendar arithmetic on plain dates and wall-clock times: the proleptic Gregorian calendar, with + * no time zone. Day numbers count from 1970-01-01, so a wall time maps to the milliseconds it + * would be in UTC; `zone.ts` turns that into a real instant for a named zone. + */ + +export interface PlainDate { + year: number; + /** 1 to 12. */ + month: number; + day: number; +} + +export interface WallTime extends PlainDate { + hour: number; + minute: number; + second: number; + ms: number; +} + +export const MINUTE = 60_000; +export const HOUR = 3_600_000; +export const DAY = 86_400_000; + +/** The day number of a date: 0 is 1970-01-01. Exact for any year (H. Hinnant's algorithm). */ +export function daysFromCivil(year: number, month: number, day: number): number { + const y = month <= 2 ? year - 1 : year; + const era = Math.floor(y / 400); + const yoe = y - era * 400; + const doy = Math.floor((153 * (month + (month > 2 ? -3 : 9)) + 2) / 5) + day - 1; + const doe = yoe * 365 + Math.floor(yoe / 4) - Math.floor(yoe / 100) + doy; + return era * 146097 + doe - 719468; +} + +/** The date of a day number. */ +export function civilFromDays(days: number): PlainDate { + const z = days + 719468; + const era = Math.floor(z / 146097); + const doe = z - era * 146097; + const yoe = Math.floor( + (doe - Math.floor(doe / 1460) + Math.floor(doe / 36524) - Math.floor(doe / 146096)) / 365 + ); + const doy = doe - (365 * yoe + Math.floor(yoe / 4) - Math.floor(yoe / 100)); + const mp = Math.floor((5 * doy + 2) / 153); + const day = doy - Math.floor((153 * mp + 2) / 5) + 1; + const month = mp < 10 ? mp + 3 : mp - 9; + return { year: yoe + era * 400 + (month <= 2 ? 1 : 0), month, day }; +} + +export const isLeapYear = (year: number): boolean => + (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0; + +export function daysInMonth(year: number, month: number): number { + return month === 2 ? (isLeapYear(year) ? 29 : 28) : [4, 6, 9, 11].includes(month) ? 30 : 31; +} + +export function isValidDate(year: number, month: number, day: number): boolean { + return ( + Number.isInteger(year) && + month >= 1 && + month <= 12 && + day >= 1 && + day <= daysInMonth(year, month) + ); +} + +export const dayNumber = (date: PlainDate): number => + daysFromCivil(date.year, date.month, date.day); + +/** 1 is Monday and 7 is Sunday, as in ISO 8601. */ +export function weekday(date: PlainDate): number { + return ((((dayNumber(date) + 3) % 7) + 7) % 7) + 1; +} + +export function addDays(date: PlainDate, days: number): PlainDate { + return civilFromDays(dayNumber(date) + days); +} + +/** + * The same day `months` later; a day the month does not have becomes its last day, so 31 January + * plus one month is 28 or 29 February. + */ +export function addMonths(date: PlainDate, months: number): PlainDate { + const index = date.year * 12 + (date.month - 1) + months; + const year = Math.floor(index / 12); + const month = index - year * 12 + 1; + return { year, month, day: Math.min(date.day, daysInMonth(year, month)) }; +} + +/** The wall time read as if it were UTC, in milliseconds since 1970. */ +export function wallMs(wall: WallTime): number { + return ( + dayNumber(wall) * DAY + wall.hour * HOUR + wall.minute * MINUTE + wall.second * 1000 + wall.ms + ); +} + +export function wallFromMs(ms: number): WallTime { + const days = Math.floor(ms / DAY); + let rest = ms - days * DAY; + const hour = Math.floor(rest / HOUR); + rest -= hour * HOUR; + const minute = Math.floor(rest / MINUTE); + rest -= minute * MINUTE; + const second = Math.floor(rest / 1000); + return { ...civilFromDays(days), hour, minute, second, ms: rest - second * 1000 }; +} + +/** A date at a time of day, given in minutes from midnight (1440 is the next midnight). */ +export function atMinutes(date: PlainDate, minutes: number): WallTime { + return wallFromMs(dayNumber(date) * DAY + minutes * MINUTE); +} + +const pad = (value: number, width = 2) => String(value).padStart(width, "0"); + +/** Years outside 0000 to 9999 take a sign and six digits, as ISO 8601 extends them. */ +export function formatYear(year: number): string { + return year >= 0 && year <= 9999 ? pad(year, 4) : (year < 0 ? "-" : "+") + pad(Math.abs(year), 6); +} + +/** `YYYY-MM-DD`. */ +export function formatDate(date: PlainDate): string { + return `${formatYear(date.year)}-${pad(date.month)}-${pad(date.day)}`; +} + +/** A date as an `<input type="date">` holds it, `YYYY-MM-DD`; undefined when it is not one. */ +export function parseDate(value: string): PlainDate | undefined { + const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); + if (!match) return undefined; + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; + return isValidDate(year, month, day) ? { year, month, day } : undefined; +} + +/** `HH:MM` from minutes after midnight. */ +export function formatClock(minutes: number): string { + return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; +} + +/** Minutes after midnight from `HH:MM` (or `HH:MM:SS`, seconds dropped); undefined otherwise. */ +export function parseClock(value: string): number | undefined { + const match = /^(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/.exec(value.trim()); + if (!match) return undefined; + const [hour, minute] = [Number(match[1]), Number(match[2])]; + return hour < 24 && minute < 60 ? hour * 60 + minute : undefined; +} diff --git a/apps/time/src/lib/duration.ts b/apps/time/src/lib/duration.ts new file mode 100644 index 0000000..109c102 --- /dev/null +++ b/apps/time/src/lib/duration.ts @@ -0,0 +1,115 @@ +/** + * Two measurements of the time between two instants. Elapsed time is exact: a day is 24 hours. + * Calendar time counts as a calendar does, in years, months and days on the wall clock of one + * zone, so a day that daylight-saving time made 23 or 25 hours long is still one day. + */ + +import { addDays, addMonths, dayNumber, DAY, HOUR, MINUTE } from "./civil.js"; +import type { PlainDate, WallTime } from "./civil.js"; +import { instantOf, wallAt } from "./zone.js"; + +export interface CalendarSpan { + /** The end is before the start; the parts are counted from the end to the start. */ + negative: boolean; + years: number; + months: number; + days: number; + hours: number; + minutes: number; + seconds: number; + ms: number; +} + +const timeOfDay = (wall: WallTime) => + wall.hour * HOUR + wall.minute * MINUTE + wall.second * 1000 + wall.ms; + +/** + * Whole months, then whole days, then the time left, from the earlier instant to the later, on + * the wall clock of `zone`. A month too short for the day ends at its last day: one month after + * 31 January is 28 (or 29) February, and someone born on 29 February is a year older on + * 28 February in other years. + */ +export function calendarSpan(start: number, end: number, zone: string): CalendarSpan { + const negative = end < start; + const [from, to] = negative ? [end, start] : [start, end]; + const a = wallAt(from, zone); + const b = wallAt(to, zone); + const clock = timeOfDay(a); + const beyond = (date: PlainDate) => + dayNumber(date) * DAY + clock > dayNumber(b) * DAY + timeOfDay(b); + + let months = (b.year - a.year) * 12 + (b.month - a.month); + if (months > 0 && beyond(addMonths(a, months))) months--; + const anchor = addMonths(a, months); + let days = dayNumber(b) - dayNumber(anchor); + if (days > 0 && beyond(addDays(anchor, days))) days--; + const at = (count: number) => { + const date = addDays(anchor, count); + return instantOf({ ...date, hour: a.hour, minute: a.minute, second: a.second, ms: a.ms }, zone); + }; + let mid = at(days); + // A clock change can put the day's time past the end: then that day is not complete. + while (days > 0 && mid > to) mid = at(--days); + const rest = Math.max(0, to - mid); + return { + negative, + years: Math.floor(months / 12), + months: months % 12, + days, + hours: Math.floor(rest / HOUR), + minutes: Math.floor((rest % HOUR) / MINUTE), + seconds: Math.floor((rest % MINUTE) / 1000), + ms: rest % 1000, + }; +} + +const plural = (count: number, word: string) => `${count} ${word}${count === 1 ? "" : "s"}`; + +/** "34 years, 2 months, 5 days", with the time of day when `withTime`. */ +export function describeSpan(span: CalendarSpan, withTime: boolean): string { + const parts: string[] = []; + if (span.years) parts.push(plural(span.years, "year")); + if (span.months) parts.push(plural(span.months, "month")); + if (span.days || (!withTime && !parts.length)) parts.push(plural(span.days, "day")); + if (withTime) { + if (span.hours) parts.push(plural(span.hours, "hour")); + if (span.minutes) parts.push(plural(span.minutes, "minute")); + if (span.seconds || !parts.length) parts.push(plural(span.seconds, "second")); + } + return parts.join(", "); +} + +export type Rounding = "down" | "nearest"; + +export const UNIT_MS = { + weeks: 7 * DAY, + days: DAY, + hours: HOUR, + minutes: MINUTE, + seconds: 1000, +} as const; +export type TotalUnit = keyof typeof UNIT_MS; + +/** + * Elapsed time in one unit. Down counts only units completed (toward zero, so a negative span + * rounds the same way); nearest rounds half away from zero. + */ +export function totalIn(ms: number, unit: TotalUnit, rounding: Rounding): number { + const value = Math.abs(ms) / UNIT_MS[unit]; + const rounded = rounding === "down" ? Math.floor(value) : Math.round(value); + return ms < 0 && rounded ? -rounded : rounded; +} + +/** + * The next time a date comes round, on or after `today` (today counts), and how many years + * that will be. + */ +export function nextAnniversary( + date: PlainDate, + today: PlainDate +): { date: PlainDate; years: number } { + let years = Math.max(0, today.year - date.year); + let next = addMonths(date, years * 12); + if (dayNumber(next) < dayNumber(today)) next = addMonths(date, ++years * 12); + return { date: next, years }; +} diff --git a/apps/time/src/lib/format.ts b/apps/time/src/lib/format.ts new file mode 100644 index 0000000..e5af307 --- /dev/null +++ b/apps/time/src/lib/format.ts @@ -0,0 +1,146 @@ +/** Instants written out: ISO 8601, HTTP dates, offsets, a reader's own format, and "in 3 hours". */ + +import { formatDate, formatYear, HOUR, MINUTE } from "./civil.js"; +import { offsetAt, UTC, wallAt } from "./zone.js"; + +const pad = (value: number, width = 2) => String(value).padStart(width, "0"); + +/** `+05:30`, `-03:00`, or with seconds for the historical offsets that had them. */ +export function formatOffset(offset: number): string { + const sign = offset < 0 ? "-" : "+"; + const total = Math.abs(Math.round(offset / 1000)); + const hours = Math.floor(total / 3600); + const minutes = Math.floor((total % 3600) / 60); + const seconds = total % 60; + return `${sign}${pad(hours)}:${pad(minutes)}${seconds ? ":" + pad(seconds) : ""}`; +} + +/** `UTC+05:30`, or `UTC` for none. */ +export function utcOffset(offset: number): string { + return offset === 0 ? "UTC" : "UTC" + formatOffset(offset); +} + +/** + * ISO 8601 / RFC 3339 in a zone, with its offset at that instant (`Z` in UTC). Milliseconds are + * written only when there are some. + */ +export function isoString(ms: number, zone: string): string { + const wall = wallAt(ms, zone); + const offset = offsetAt(ms, zone); + const fraction = wall.ms ? "." + pad(wall.ms, 3) : ""; + return ( + `${formatDate(wall)}T${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)}${fraction}` + + (zone === UTC ? "Z" : formatOffset(offset)) + ); +} + +const DAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; +const MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"]; + +/** The date HTTP headers carry (RFC 9110 IMF-fixdate), always in GMT. */ +export function httpDate(ms: number): string { + const wall = wallAt(ms, UTC); + const day = DAYS[new Date(ms).getUTCDay()]; + return ( + `${day}, ${pad(wall.day)} ${MONTHS[wall.month - 1]} ${formatYear(wall.year)} ` + + `${pad(wall.hour)}:${pad(wall.minute)}:${pad(wall.second)} GMT` + ); +} + +export interface ReadableOptions { + hour12?: boolean; + /** The reader's language; the browser's by default. */ + locale?: string; +} + +const readers = new Map<string, Intl.DateTimeFormat>(); +function reader(key: string, make: () => Intl.DateTimeFormat): Intl.DateTimeFormat { + let found = readers.get(key); + if (!found) readers.set(key, (found = make())); + return found; +} + +/** The date and time as the reader's language writes them, e.g. "Monday 12 October 2026 at 10:15:00". */ +export function readableDate(ms: number, zone: string, options: ReadableOptions = {}): string { + const { locale, hour12 } = options; + return reader( + `full|${locale}|${zone}|${hour12}`, + () => + new Intl.DateTimeFormat(locale, { + timeZone: zone, + dateStyle: "full", + timeStyle: "medium", + hour12, + }) + ).format(ms); +} + +/** A short weekday and date, e.g. "Sat 14 Nov". */ +export function shortDate(ms: number, zone: string, locale?: string): string { + return reader( + `short|${locale}|${zone}`, + () => + new Intl.DateTimeFormat(locale, { + timeZone: zone, + weekday: "short", + day: "numeric", + month: "short", + }) + ).format(ms); +} + +/** A clock time, e.g. "20:00" or "8:00 PM". */ +export function clockTime(ms: number, zone: string, options: ReadableOptions = {}): string { + const { locale, hour12 } = options; + return reader( + `clock|${locale}|${zone}|${hour12}`, + () => + new Intl.DateTimeFormat(locale, { + timeZone: zone, + hour: hour12 === false ? "2-digit" : "numeric", + minute: "2-digit", + hour12, + }) + ).format(ms); +} + +/** + * The zone's abbreviation at an instant ("CEST", "EST") when the language has one; undefined + * where it only has an offset ("GMT+9"), which is shown separately anyway. + */ +export function zoneAbbreviation(ms: number, zone: string, locale?: string): string | undefined { + const part = reader( + `abbr|${locale}|${zone}`, + () => new Intl.DateTimeFormat(locale, { timeZone: zone, timeZoneName: "short" }) + ) + .formatToParts(ms) + .find((p) => p.type === "timeZoneName")?.value; + return part && /^[A-Za-z]{2,5}$/.test(part) && !/^(GMT|UTC)$/.test(part) ? part : undefined; +} + +const UNITS: [Intl.RelativeTimeFormatUnit, number][] = [ + ["year", 365.2425 * 24 * HOUR], + ["month", (365.2425 / 12) * 24 * HOUR], + ["week", 7 * 24 * HOUR], + ["day", 24 * HOUR], + ["hour", HOUR], + ["minute", MINUTE], + ["second", 1000], +]; + +/** "in 3 hours", "2 days ago": approximate, a supplement to the exact time. */ +export function relativeTime(ms: number, now: number, locale?: string): string { + const difference = ms - now; + const format = new Intl.RelativeTimeFormat(locale, { numeric: "auto" }); + for (const [unit, size] of UNITS) + if (Math.abs(difference) >= size || unit === "second") + return format.format(Math.trunc(difference / size), unit); + return ""; +} + +/** "45 min", "1 h 30 min", "2 h". */ +export function formatMinutes(minutes: number): string { + const h = Math.floor(minutes / 60); + const m = Math.round(minutes - h * 60); + return h ? (m ? `${h} h ${m} min` : `${h} h`) : `${m} min`; +} diff --git a/apps/time/src/lib/parse.ts b/apps/time/src/lib/parse.ts new file mode 100644 index 0000000..3cd9028 --- /dev/null +++ b/apps/time/src/lib/parse.ts @@ -0,0 +1,162 @@ +/** + * What a person typed into Convert: a Unix timestamp, an ISO 8601 / RFC 3339 date, or an HTTP / + * email date. A date without an offset is a wall time that still needs a zone, which is the + * caller's to supply; nothing here guesses one. + */ + +import { isValidDate, wallMs } from "./civil.js"; +import type { WallTime } from "./civil.js"; +import { findZone } from "./zone.js"; + +export type Unit = "s" | "ms"; + +/** Instants a Date can hold: ±100,000,000 days around 1970. */ +export const MAX_MS = 8.64e15; + +export type Parsed = + /** A Unix timestamp, in the unit given or, failing that, the one its size suggests. */ + | { kind: "unix"; ms: number; unit: Unit; detected: boolean } + /** A date and time with an offset: one instant. */ + | { kind: "instant"; ms: number; offset: number; zone?: string; dropped?: boolean } + /** A date, or a date and time, with no offset: a wall time in a zone still to be chosen. */ + | { kind: "wall"; wall: WallTime; dateOnly: boolean; zone?: string; dropped?: boolean } + | { kind: "error"; message: string }; + +/** + * A number this size or more is taken for milliseconds: in seconds it would be past the year + * 5000, while in milliseconds it is after March 1973. + */ +const MS_FROM = 1e11; + +export function detectUnit(value: number): Unit { + return Math.abs(value) >= MS_FROM ? "ms" : "s"; +} + +const ISO = + /^([+-]\d{6}|\d{4})-(\d{2})-(\d{2})(?:[Tt ](\d{2}):(\d{2})(?::(\d{2})(?:[.,](\d+))?)?)?\s*(Z|z|[+-]\d{2}(?::?\d{2})?)?(?:\[!?([^\]=]+)\])?$/; +const ISO_BASIC = + /^(\d{4})(\d{2})(\d{2})[Tt](\d{2})(\d{2})(?:(\d{2})(?:[.,](\d+))?)?(Z|z|[+-]\d{2}(?:\d{2})?)?$/; +const MONTHS = ["jan", "feb", "mar", "apr", "may", "jun", "jul", "aug", "sep", "oct", "nov", "dec"]; +const RFC_2822 = + /^(?:(?:mon|tue|wed|thu|fri|sat|sun)[a-z]*,?\s+)?(\d{1,2})\s+([a-z]{3})[a-z]*\s+(\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(gmt|ut|utc|z|[+-]\d{4}|[ecmp][sd]t)?$/i; +const NAMED_OFFSETS: Record<string, number> = { + est: -5, + edt: -4, + cst: -6, + cdt: -5, + mst: -7, + mdt: -6, + pst: -8, + pdt: -7, +}; + +function offsetMs(text: string): number { + if (/^z$/i.test(text)) return 0; + const digits = text.slice(1).replace(":", ""); + const hours = Number(digits.slice(0, 2)); + const minutes = Number(digits.slice(2, 4) || "0"); + if (hours > 23 || minutes > 59) return NaN; + return (text[0] === "-" ? -1 : 1) * (hours * 60 + minutes) * 60_000; +} + +/** Fractions of a second past milliseconds are dropped, and the caller is told. */ +function fractionMs(text: string | undefined): { ms: number; dropped: boolean } { + if (!text) return { ms: 0, dropped: false }; + return { ms: Number(text.slice(0, 3).padEnd(3, "0")), dropped: /[1-9]/.test(text.slice(3)) }; +} + +function wallOf( + year: number, + month: number, + day: number, + hour: number, + minute: number, + second: number, + ms: number +): WallTime | string { + if (!isValidDate(year, month, day)) return "That date does not exist."; + if (hour > 23 || minute > 59 || second > 59) + return "That time of day does not exist (00:00 to 23:59:59)."; + return { year, month, day, hour, minute, second, ms }; +} + +function fromWall( + wall: WallTime | string, + dateOnly: boolean, + offset: string | undefined, + zoneName: string | undefined, + dropped: boolean +): Parsed { + if (typeof wall === "string") return { kind: "error", message: wall }; + let zone: string | undefined; + if (zoneName) { + zone = findZone(zoneName); + if (!zone) return { kind: "error", message: `Unknown time zone "${zoneName}".` }; + } + if (offset !== undefined) { + if (dateOnly) return { kind: "error", message: "An offset needs a time of day." }; + const shift = offsetMs(offset); + if (Number.isNaN(shift)) return { kind: "error", message: `Not an offset: "${offset}".` }; + const ms = wallMs(wall) - shift; + if (Math.abs(ms) > MAX_MS) return { kind: "error", message: "Out of range for a date." }; + return { kind: "instant", ms, offset: shift, zone, dropped }; + } + if (Math.abs(wallMs(wall)) > MAX_MS) + return { kind: "error", message: "Out of range for a date." }; + return { kind: "wall", wall, dateOnly, zone, dropped }; +} + +/** Reads one value. `unit` overrides the unit a number's size suggests. */ +export function parseTime(input: string, unit?: Unit): Parsed { + const text = input.trim(); + if (!text) return { kind: "error", message: "Empty." }; + + if (/^[+-]?\d+(\.\d+)?$/.test(text)) { + const value = Number(text); + const chosen = unit ?? detectUnit(value); + const ms = chosen === "s" ? value * 1000 : value; + if (Math.abs(ms) > MAX_MS) return { kind: "error", message: "Out of range for a date." }; + // Below a millisecond is beyond what a Date holds. + return { kind: "unix", ms: Math.trunc(ms), unit: chosen, detected: unit === undefined }; + } + + const iso = ISO.exec(text) ?? ISO_BASIC.exec(text); + if (iso) { + const [, y, mo, d, h, mi, s, fraction, offset, zone] = iso; + const { ms, dropped } = fractionMs(fraction); + const wall = wallOf( + Number(y), + Number(mo), + Number(d), + Number(h ?? 0), + Number(mi ?? 0), + Number(s ?? 0), + ms + ); + return fromWall(wall, h === undefined, offset, zone, dropped); + } + + const rfc = RFC_2822.exec(text); + if (rfc) { + const [, d, mon, y, h, mi, s, zone] = rfc; + const month = MONTHS.indexOf(mon.toLowerCase()) + 1; + if (!month) return { kind: "error", message: `Unknown month "${mon}".` }; + const wall = wallOf(Number(y), month, Number(d), Number(h), Number(mi), Number(s ?? 0), 0); + const name = zone?.toLowerCase(); + const offset = + name === undefined + ? undefined + : name in NAMED_OFFSETS + ? (NAMED_OFFSETS[name] < 0 ? "-" : "+") + + String(Math.abs(NAMED_OFFSETS[name])).padStart(2, "0") + : /^[+-]/.test(name) + ? name + : "Z"; + return fromWall(wall, false, offset, undefined, false); + } + + return { + kind: "error", + message: "Not a Unix timestamp, an ISO 8601 date (2026-10-12T14:30) or an HTTP date.", + }; +} diff --git a/apps/time/src/lib/plan.ts b/apps/time/src/lib/plan.ts new file mode 100644 index 0000000..bfee7f3 --- /dev/null +++ b/apps/time/src/lib/plan.ts @@ -0,0 +1,131 @@ +/** + * The people and patterns Find a time keeps, as stored in the browser and carried in a share + * link. This is a saved format: it is read field by field, keeping what is valid and giving a + * default for anything missing or unknown, so what someone saved still opens after a change. + * `version` is for a later change that cannot be read that way. + */ + +import { parseDate } from "./civil.js"; +import { LEVELS } from "./availability.js"; +import type { DateException, Level, Person, TimeWindow, WeeklyWindow } from "./availability.js"; + +export const PLAN_VERSION = 1; + +export interface Plan { + people: Person[]; + /** Call length in minutes. */ + duration: number; +} + +export const DEFAULT_DURATION = 60; +const MAX_PEOPLE = 20; +const MAX_WINDOWS = 30; +const MAX_NAME = 60; + +const isRecord = (value: unknown): value is Record<string, unknown> => + typeof value === "object" && value !== null && !Array.isArray(value); + +const isMinute = (value: unknown, max: number): value is number => + Number.isInteger(value) && (value as number) >= 0 && (value as number) <= max; + +let counter = 0; +/** A person's id, unique within a plan: only the page uses it, to keep track of who is who. */ +export function newId(): string { + return `p${Date.now().toString(36)}${(counter++).toString(36)}${Math.random().toString(36).slice(2, 6)}`; +} + +function readWindow(raw: unknown): TimeWindow | undefined { + if (!isRecord(raw) || !isMinute(raw.start, 1439) || !isMinute(raw.end, 1439)) return undefined; + const level = LEVELS.includes(raw.level as Level) ? (raw.level as Level) : "acceptable"; + return { start: raw.start, end: raw.end, level }; +} + +function readWeekly(raw: unknown): WeeklyWindow | undefined { + const window = readWindow(raw); + if (!window || !isRecord(raw) || !Array.isArray(raw.days)) return undefined; + const days = [...new Set(raw.days.filter((d): d is number => isMinute(d, 7) && d >= 1))].sort(); + return days.length ? { ...window, days } : undefined; +} + +function readException(raw: unknown): DateException | undefined { + if (!isRecord(raw) || typeof raw.date !== "string" || !parseDate(raw.date)) return undefined; + const windows = Array.isArray(raw.windows) + ? raw.windows.map(readWindow).filter((w) => w !== undefined) + : []; + return { date: raw.date, windows: windows.slice(0, MAX_WINDOWS) }; +} + +function readPerson(raw: unknown, ids: Set<string>): Person | undefined { + if (!isRecord(raw) || typeof raw.zone !== "string" || !raw.zone) return undefined; + let id = typeof raw.id === "string" && raw.id && !ids.has(raw.id) ? raw.id : newId(); + while (ids.has(id)) id = newId(); + ids.add(id); + const list = <T>(value: unknown, read: (item: unknown) => T | undefined): T[] => + Array.isArray(value) + ? value + .map(read) + .filter((x): x is T => x !== undefined) + .slice(0, MAX_WINDOWS) + : []; + const exceptions = list(raw.exceptions, readException); + return { + id, + name: typeof raw.name === "string" ? raw.name.slice(0, MAX_NAME) : "", + zone: raw.zone, + windows: list(raw.windows, readWeekly), + // One exception a date: the first one kept. + exceptions: exceptions.filter((x, i) => exceptions.findIndex((y) => y.date === x.date) === i), + relaxed: raw.relaxed === true, + }; +} + +/** A plan from whatever was stored or shared; undefined when it is not one at all. */ +export function readPlan(raw: unknown): Plan | undefined { + if (!isRecord(raw) || !Array.isArray(raw.people)) return undefined; + const ids = new Set<string>(); + const people = raw.people + .slice(0, MAX_PEOPLE) + .map((p) => readPerson(p, ids)) + .filter((p) => p !== undefined); + const duration = + Number.isInteger(raw.duration) && + (raw.duration as number) >= 5 && + (raw.duration as number) <= 24 * 60 + ? (raw.duration as number) + : DEFAULT_DURATION; + return { people, duration }; +} + +/** The plan as stored and shared. */ +export function writePlan(plan: Plan): Record<string, unknown> { + return { version: PLAN_VERSION, people: plan.people, duration: plan.duration }; +} + +/** The share link's form: no ids, which belong to the page that made them. */ +export function sharePlan(plan: Plan): Record<string, unknown> { + return { + version: PLAN_VERSION, + people: plan.people.map(({ id: _, ...person }) => person), + duration: plan.duration, + }; +} + +export const ALL_DAYS = [1, 2, 3, 4, 5, 6, 7]; +export const WEEKDAYS = [1, 2, 3, 4, 5]; +export const WEEKEND = [6, 7]; + +/** A new person: acceptable from 08:00 to 22:00 every day, preferred on weekday evenings. */ +export function newPerson(name: string, zone: string): Person { + return { + id: newId(), + name, + zone, + windows: [ + { days: ALL_DAYS, start: 8 * 60, end: 22 * 60, level: "acceptable" }, + { days: WEEKDAYS, start: 18 * 60, end: 21 * 60, level: "preferred" }, + { days: WEEKEND, start: 10 * 60, end: 18 * 60, level: "preferred" }, + ], + exceptions: [], + relaxed: false, + }; +} diff --git a/apps/time/src/lib/time.test.ts b/apps/time/src/lib/time.test.ts new file mode 100644 index 0000000..34b7abf --- /dev/null +++ b/apps/time/src/lib/time.test.ts @@ -0,0 +1,278 @@ +import { describe, expect, test } from "bun:test"; +import { + addMonths, + civilFromDays, + daysFromCivil, + parseClock, + weekday, + wallFromMs, + wallMs, +} from "./civil.js"; +import { calendarSpan, describeSpan, nextAnniversary, totalIn } from "./duration.js"; +import { formatOffset, httpDate, isoString, relativeTime } from "./format.js"; +import { detectUnit, parseTime } from "./parse.js"; +import { canonicalZone, findZone, offsetAt, resolveWall, wallAt } from "./zone.js"; + +const wall = (iso: string) => { + const p = parseTime(iso); + if (p.kind !== "wall") throw new Error("not a wall time: " + iso); + return p.wall; +}; +const utc = (iso: string) => Date.parse(iso); + +describe("civil dates", () => { + test("day numbers round-trip across eras and leap years", () => { + for (const days of [-1_000_000, -719468, -1, 0, 1, 11016, 20000, 2_932_896]) + expect( + daysFromCivil(...(Object.values(civilFromDays(days)) as [number, number, number])) + ).toBe(days); + expect(daysFromCivil(1970, 1, 1)).toBe(0); + expect(daysFromCivil(2000, 3, 1)).toBe(11017); + }); + test("wall times read as UTC milliseconds", () => { + const ms = utc("2026-10-12T10:15:30.250Z"); + expect(wallMs(wallFromMs(ms))).toBe(ms); + expect(wallFromMs(-1)).toMatchObject({ year: 1969, month: 12, day: 31, hour: 23, ms: 999 }); + }); + test("weekdays are ISO, Monday first", () => { + expect(weekday({ year: 2026, month: 10, day: 12 })).toBe(1); + expect(weekday({ year: 2026, month: 10, day: 18 })).toBe(7); + }); + test("months too short end at their last day", () => { + expect(addMonths({ year: 2026, month: 1, day: 31 }, 1)).toEqual({ + year: 2026, + month: 2, + day: 28, + }); + expect(addMonths({ year: 2024, month: 1, day: 31 }, 1)).toEqual({ + year: 2024, + month: 2, + day: 29, + }); + expect(addMonths({ year: 2026, month: 3, day: 15 }, -3)).toEqual({ + year: 2025, + month: 12, + day: 15, + }); + }); + test("clock times", () => { + expect(parseClock("09:30")).toBe(570); + expect(parseClock("23:59:59")).toBe(1439); + expect(parseClock("24:00")).toBeUndefined(); + }); +}); + +describe("zones", () => { + test("names are found however they are typed", () => { + expect(findZone("europe/paris")).toBe("Europe/Paris"); + expect(findZone("New York")).toBe("America/New_York"); + expect(findZone("tokyo")).toBe("Asia/Tokyo"); + expect(findZone("utc")).toBe("UTC"); + expect(findZone("Atlantis/Lost")).toBeUndefined(); + expect(canonicalZone("+05:30")).toBeUndefined(); + }); + test("offsets follow daylight-saving time and fractional hours", () => { + expect(offsetAt(utc("2026-01-15T12:00:00Z"), "Europe/Paris")).toBe(3_600_000); + expect(offsetAt(utc("2026-07-15T12:00:00Z"), "Europe/Paris")).toBe(7_200_000); + expect(offsetAt(utc("2026-07-15T12:00:00Z"), "Asia/Kolkata")).toBe(5.5 * 3_600_000); + expect(offsetAt(utc("2026-07-15T12:00:00Z"), "Asia/Kathmandu")).toBe(5.75 * 3_600_000); + expect(offsetAt(utc("2026-07-15T12:00:00Z"), "America/St_Johns")).toBe(-2.5 * 3_600_000); + }); + test("a wall time that happens twice is ambiguous", () => { + // Paris turns its clocks back from 03:00 CEST to 02:00 CET on 25 October 2026. + const found = resolveWall(wall("2026-10-25T02:30"), "Europe/Paris"); + expect(found).toEqual({ + kind: "ambiguous", + earlier: utc("2026-10-25T00:30:00Z"), + later: utc("2026-10-25T01:30:00Z"), + }); + }); + test("a wall time that never happens is a gap, read after the change", () => { + // Paris moves from 02:00 CET to 03:00 CEST on 29 March 2026. + const found = resolveWall(wall("2026-03-29T02:30"), "Europe/Paris"); + expect(found).toEqual({ + kind: "gap", + ms: utc("2026-03-29T01:30:00Z"), + before: 3_600_000, + after: 7_200_000, + }); + expect(wallAt(utc("2026-03-29T01:30:00Z"), "Europe/Paris")).toMatchObject({ + hour: 3, + minute: 30, + }); + }); + test("ordinary times are exact, in any zone", () => { + expect(resolveWall(wall("2026-10-12T14:30"), "America/New_York")).toEqual({ + kind: "exact", + ms: utc("2026-10-12T18:30:00Z"), + }); + expect(resolveWall(wall("2026-10-12T14:30"), "UTC")).toEqual({ + kind: "exact", + ms: utc("2026-10-12T14:30:00Z"), + }); + }); + test("years before 1 are counted with year 0", () => { + expect(wallAt(utc("0000-06-01T00:00:00Z"), "UTC").year).toBe(0); + expect(wallAt(Date.UTC(-1, 5, 1) + 0, "Europe/London").year).toBe(-1); + }); +}); + +describe("parsing", () => { + test("numbers are Unix time, in the unit their size suggests unless one is given", () => { + expect(parseTime("1760264100")).toEqual({ + kind: "unix", + ms: 1760264100000, + unit: "s", + detected: true, + }); + expect(parseTime("1760264100000")).toEqual({ + kind: "unix", + ms: 1760264100000, + unit: "ms", + detected: true, + }); + expect(parseTime("1760264100000000", "s")).toEqual({ + kind: "error", + message: "Out of range for a date.", + }); + expect(parseTime("1760264100", "ms")).toEqual({ + kind: "unix", + ms: 1760264100, + unit: "ms", + detected: false, + }); + expect(parseTime("-1.5")).toMatchObject({ kind: "unix", ms: -1500 }); + expect(detectUnit(99_999_999_999)).toBe("s"); + }); + test("ISO 8601 with an offset is one instant", () => { + expect(parseTime("2026-10-12T10:15:00Z")).toMatchObject({ + kind: "instant", + ms: utc("2026-10-12T10:15:00Z"), + offset: 0, + }); + expect(parseTime("2026-10-12T10:15:00.5+05:30")).toMatchObject({ + kind: "instant", + ms: utc("2026-10-12T04:45:00.500Z"), + }); + expect(parseTime("20261012T101500-0400")).toMatchObject({ ms: utc("2026-10-12T14:15:00Z") }); + expect(parseTime("2026-10-12 10:15+02:00[Europe/Paris]")).toMatchObject({ + kind: "instant", + zone: "Europe/Paris", + }); + }); + test("ISO 8601 without an offset is a wall time, a date alone at midnight", () => { + expect(parseTime("2026-10-12T10:15")).toMatchObject({ + kind: "wall", + dateOnly: false, + wall: { hour: 10, minute: 15 }, + }); + expect(parseTime("2026-10-12")).toMatchObject({ + kind: "wall", + dateOnly: true, + wall: { hour: 0 }, + }); + expect(parseTime("2026-10-12T10:15[asia/tokyo]")).toMatchObject({ + kind: "wall", + zone: "Asia/Tokyo", + }); + }); + test("HTTP and email dates", () => { + expect(parseTime("Mon, 12 Oct 2026 10:15:00 GMT")).toMatchObject({ + kind: "instant", + ms: utc("2026-10-12T10:15:00Z"), + }); + expect(parseTime("12 Oct 2026 10:15 -0700")).toMatchObject({ ms: utc("2026-10-12T17:15:00Z") }); + expect(parseTime("Mon, 12 Oct 2026 10:15:00 EDT")).toMatchObject({ + ms: utc("2026-10-12T14:15:00Z"), + }); + }); + test("refuses what is not a date, and says why", () => { + expect(parseTime("2026-02-30")).toEqual({ + kind: "error", + message: "That date does not exist.", + }); + expect(parseTime("2026-10-12T25:00").kind).toBe("error"); + expect(parseTime("2026-10-12Z").kind).toBe("error"); + expect(parseTime("2026-10-12T10:00+25:00").kind).toBe("error"); + expect(parseTime("2026-10-12T10:00[Nowhere/Here]")).toEqual({ + kind: "error", + message: 'Unknown time zone "Nowhere/Here".', + }); + expect(parseTime("yesterday").kind).toBe("error"); + }); + test("precision past milliseconds is dropped, and said", () => { + expect(parseTime("2026-10-12T10:15:00.123456Z")).toMatchObject({ + dropped: true, + ms: utc("2026-10-12T10:15:00.123Z"), + }); + expect(parseTime("2026-10-12T10:15:00.123000Z")).toMatchObject({ dropped: false }); + }); +}); + +describe("formatting", () => { + test("ISO 8601 in a zone carries the offset at that instant", () => { + const ms = utc("2026-10-12T08:15:00Z"); + expect(isoString(ms, "UTC")).toBe("2026-10-12T08:15:00Z"); + expect(isoString(ms, "Europe/Paris")).toBe("2026-10-12T10:15:00+02:00"); + expect(isoString(ms + 5, "Asia/Kolkata")).toBe("2026-10-12T13:45:00.005+05:30"); + expect(isoString(utc("+012026-01-01T00:00:00Z"), "UTC")).toBe("+012026-01-01T00:00:00Z"); + }); + test("offsets, HTTP dates and relative times", () => { + expect(formatOffset(-9_000_000)).toBe("-02:30"); + expect(formatOffset(561_000)).toBe("+00:09:21"); + expect(httpDate(utc("2026-10-12T08:15:00Z"))).toBe("Mon, 12 Oct 2026 08:15:00 GMT"); + const now = utc("2026-10-12T08:00:00Z"); + expect(relativeTime(now + 3 * 3_600_000, now, "en")).toBe("in 3 hours"); + expect(relativeTime(now - 2 * 86_400_000, now, "en")).toBe("2 days ago"); + expect(relativeTime(now, now, "en")).toBe("now"); + }); +}); + +describe("durations", () => { + test("calendar age counts whole years, months and days on the wall clock", () => { + const span = calendarSpan(utc("1990-05-20T00:00:00Z"), utc("2026-10-12T15:30:00Z"), "UTC"); + expect(span).toMatchObject({ + negative: false, + years: 36, + months: 4, + days: 22, + hours: 15, + minutes: 30, + }); + expect(describeSpan(span, false)).toBe("36 years, 4 months, 22 days"); + }); + test("a 29 February birthday counts on 28 February in other years", () => { + const span = calendarSpan(utc("2000-02-29T00:00:00Z"), utc("2001-02-28T00:00:00Z"), "UTC"); + expect(span).toMatchObject({ years: 1, months: 0, days: 0 }); + expect( + nextAnniversary({ year: 2000, month: 2, day: 29 }, { year: 2026, month: 3, day: 1 }) + ).toEqual({ + date: { year: 2027, month: 2, day: 28 }, + years: 27, + }); + expect( + nextAnniversary({ year: 2000, month: 5, day: 20 }, { year: 2026, month: 5, day: 20 }) + ).toEqual({ + date: { year: 2026, month: 5, day: 20 }, + years: 26, + }); + }); + test("a month from the 31st ends at the next month's last day", () => { + const span = calendarSpan(utc("2026-01-31T00:00:00Z"), utc("2026-03-01T00:00:00Z"), "UTC"); + expect(span).toMatchObject({ months: 1, days: 1 }); + }); + test("a day across a clock change is one calendar day but 23 elapsed hours", () => { + const start = utc("2026-03-28T11:00:00Z"); // noon in Paris, CET + const end = utc("2026-03-29T10:00:00Z"); // noon in Paris, CEST + expect(calendarSpan(start, end, "Europe/Paris")).toMatchObject({ days: 1, hours: 0 }); + expect(totalIn(end - start, "hours", "down")).toBe(23); + expect(totalIn(end - start, "days", "down")).toBe(0); + expect(totalIn(end - start, "days", "nearest")).toBe(1); + }); + test("an end before the start counts backwards", () => { + const span = calendarSpan(utc("2026-10-12T00:00:00Z"), utc("2026-10-10T12:00:00Z"), "UTC"); + expect(span).toMatchObject({ negative: true, days: 1, hours: 12 }); + expect(totalIn(-90 * 60_000, "hours", "down")).toBe(-1); + expect(totalIn(-90 * 60_000, "hours", "nearest")).toBe(-2); + }); +}); diff --git a/apps/time/src/lib/zone.ts b/apps/time/src/lib/zone.ts new file mode 100644 index 0000000..b8c1985 --- /dev/null +++ b/apps/time/src/lib/zone.ts @@ -0,0 +1,167 @@ +/** + * Named time zones, through the browser's own copy of the IANA database (`Intl`). An offset is + * never a fixed property of a zone: it depends on the instant, because of daylight-saving time + * and the zone's history, so every question here is asked at an instant. + */ + +import { DAY, wallFromMs, wallMs } from "./civil.js"; +import type { WallTime } from "./civil.js"; + +export const UTC = "UTC"; + +const formatters = new Map<string, Intl.DateTimeFormat>(); +function formatter(zone: string): Intl.DateTimeFormat { + let found = formatters.get(zone); + if (!found) { + found = new Intl.DateTimeFormat("en-US", { + timeZone: zone, + hourCycle: "h23", + era: "short", + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }); + formatters.set(zone, found); + } + return found; +} + +/** The zone's own name for itself (`utc` → `UTC`), or undefined when there is no such zone. */ +export function canonicalZone(name: string): string | undefined { + const value = name.trim(); + if (!value || !/^[A-Za-z0-9_+\-/]+$/.test(value)) return undefined; + try { + const resolved = new Intl.DateTimeFormat("en-US", { timeZone: value }).resolvedOptions() + .timeZone; + // Fixed offsets ("+05:30") are not named zones: they have no rules to follow. + return /^[+-]/.test(resolved) ? undefined : resolved === "Etc/UTC" ? UTC : resolved; + } catch { + return undefined; + } +} + +/** The browser's zone: the device's setting. */ +export function localZone(): string { + return canonicalZone(Intl.DateTimeFormat().resolvedOptions().timeZone) ?? UTC; +} + +const REGIONS = [ + "America", + "Europe", + "Asia", + "Africa", + "Australia", + "Pacific", + "Atlantic", + "Indian", + "Antarctica", +]; + +let names: string[] | undefined; +/** Every zone this browser knows, UTC first. */ +export function zoneNames(): string[] { + if (!names) { + let all: string[] = []; + try { + all = Intl.supportedValuesOf("timeZone"); + } catch { + /* an older browser: zones can still be typed */ + } + names = [UTC, ...all.filter((zone) => zone !== UTC && zone !== "Etc/UTC")]; + } + return names; +} + +/** + * Finds a zone from what someone typed: its name in any case, with spaces for underscores, or + * just its city when only one zone has it (`tokyo` → `Asia/Tokyo`). + */ +export function findZone(typed: string): string | undefined { + const value = typed.trim().replace(/\s+/g, "_"); + if (!value) return undefined; + const lower = value.toLowerCase(); + if (lower === "utc" || lower === "gmt" || lower === "z") return UTC; + const all = zoneNames(); + const exact = all.find((zone) => zone.toLowerCase() === lower); + if (exact) return exact; + const cities = all.filter((zone) => zone.toLowerCase().split("/").pop() === lower); + if (cities.length === 1) return cities[0]; + // A city the browser lists under an older name: Chromium has Asia/Calcutta, not Asia/Kolkata. + if (!value.includes("/")) { + const city = lower.replace( + /(^|[_-])([a-z])/g, + (_, gap: string, letter: string) => gap + letter.toUpperCase() + ); + for (const region of REGIONS) { + const found = canonicalZone(`${region}/${city}`); + if (found) return found; + } + } + return canonicalZone(value); +} + +/** The wall-clock time in a zone at an instant. */ +export function wallAt(ms: number, zone: string): WallTime { + if (zone === UTC) return wallFromMs(ms); + const fields: Record<string, string> = {}; + for (const part of formatter(zone).formatToParts(ms)) fields[part.type] = part.value; + const year = Number(fields.year); + return { + // The years before 1 are counted backwards with an era: 1 BC is year 0. + year: /^B/.test(fields.era ?? "") ? 1 - year : year, + month: Number(fields.month), + day: Number(fields.day), + hour: Number(fields.hour), + minute: Number(fields.minute), + second: Number(fields.second), + ms: ((ms % 1000) + 1000) % 1000, + }; +} + +/** How far the zone's clocks are ahead of UTC at an instant, in milliseconds. */ +export function offsetAt(ms: number, zone: string): number { + return zone === UTC ? 0 : wallMs(wallAt(ms, zone)) - ms; +} + +/** + * A wall time read in a zone. Where the clocks go back, the time happens twice (`ambiguous`); + * where they go forward, it never happens (`gap`), and `ms` is the moment the clock shows that + * time after moving on (02:30 in a one-hour gap is 03:30), as calendars resolve it. + */ +export type Resolution = + | { kind: "exact"; ms: number } + | { kind: "ambiguous"; earlier: number; later: number } + | { kind: "gap"; ms: number; before: number; after: number }; + +export function resolveWall(wall: WallTime, zone: string): Resolution { + const local = wallMs(wall); + if (zone === UTC) return { kind: "exact", ms: local }; + // Each offset the zone has around this time is a candidate; those that read back as this + // wall time are its instants. + const before = offsetAt(local - DAY, zone); + const after = offsetAt(local + DAY, zone); + const offsets = new Set([before, offsetAt(local, zone), after]); + const hits = [...offsets] + .map((offset) => local - offset) + .filter((ms) => offsetAt(ms, zone) === local - ms) + .sort((a, b) => a - b); + if (hits.length === 1) return { kind: "exact", ms: hits[0] }; + if (hits.length > 1) return { kind: "ambiguous", earlier: hits[0], later: hits[hits.length - 1] }; + return { kind: "gap", ms: local - before, before, after }; +} + +/** + * One instant for a wall time, where none has to be chosen by a person: the earlier of two, or + * the time after a gap. + */ +export function instantOf( + wall: WallTime, + zone: string, + prefer: "earlier" | "later" = "earlier" +): number { + const found = resolveWall(wall, zone); + return found.kind === "ambiguous" ? found[prefer] : found.ms; +} diff --git a/apps/time/src/main.ts b/apps/time/src/main.ts new file mode 100644 index 0000000..27f1f0f --- /dev/null +++ b/apps/time/src/main.ts @@ -0,0 +1,109 @@ +import { + bindMenu, + bindToolHelp, + byId, + debounce, + readDraft, + registerServiceWorker, + showMessage, + writeDraft, +} from "@tools/ui"; +import type { App, View } from "./app.js"; +import { convertView } from "./convert.js"; +import { durationView } from "./duration-view.js"; +import { press } from "./dom.js"; +import { meetView, SHARE_PREFIX } from "./meet.js"; +import { readPrefs, writePrefs } from "./prefs.js"; + +bindToolHelp("time"); +registerServiceWorker(); + +type ViewName = "convert" | "meet" | "duration"; +const NAMES: readonly ViewName[] = ["convert", "meet", "duration"]; + +/** The tab's draft: every view's fields, and which view is open. Other apps may write it too. */ +const DRAFT = 1; +const draft = readDraft("time", DRAFT); +const prefs = readPrefs(); +const listeners: (() => void)[] = []; +/** Filled once every view is made; a save asked for while they are being made waits for it. */ +const views = {} as Record<ViewName, View>; +// A share link opens on the people it holds. +let current: ViewName = location.hash.startsWith(SHARE_PREFIX) + ? "meet" + : (NAMES.find((name) => name === draft.value.view) ?? "convert"); + +function save(): void { + // An incompatible draft stays as it was: this tab works without saving over it. + if (draft.error || !views.duration) return; + const value = { + view: current, + ...views.convert.values(), + ...views.meet.values(), + ...views.duration.values(), + }; + if (!writeDraft("time", DRAFT, value)) { + const status = byId("draft-status"); + status.hidden = false; + showMessage(status, "Draft could not be remembered (storage unavailable or over 2 MB).", true); + } +} +const saveSoon = debounce(save, 300); + +const app: App = { + prefs, + saved: draft.error ? {} : draft.value, + save: saveSoon, + savePrefs: () => writePrefs(prefs), + clipboardFailed(where) { + showMessage(where, "Clipboard unavailable: select the text and copy it.", true); + }, + show(view) { + // Asked while the views are still being made (a share link): they open on it. + if (views.duration) show(view); + else current = view; + }, + onPrefs(listener) { + listeners.push(listener); + }, +}; + +Object.assign(views, { + convert: convertView(app), + meet: meetView(app), + duration: durationView(app), +}); + +const viewButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-view]")]; +const viewSelect = byId<HTMLSelectElement>("view-select"); +function show(name: ViewName): void { + current = name; + press(viewButtons, "view", name); + viewSelect.value = name; + for (const view of NAMES) { + byId(`view-${view}`).hidden = view !== name; + views[view].shown(view === name); + } + saveSoon(); +} +for (const button of viewButtons) + button.addEventListener("click", () => show(button.dataset.view as ViewName)); +viewSelect.addEventListener("change", () => show(viewSelect.value as ViewName)); + +const clockButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-clock]")]; +press(clockButtons, "clock", prefs.clock); +bindMenu(byId("settings-btn"), byId("settings"), { align: "end" }); +for (const button of clockButtons) + button.addEventListener("click", () => { + prefs.clock = button.dataset.clock === "12" ? "12" : "24"; + press(clockButtons, "clock", prefs.clock); + writePrefs(prefs); + for (const listener of listeners) listener(); + }); + +if (draft.error) { + const status = byId("draft-status"); + status.hidden = false; + showMessage(status, draft.error, true); +} +show(current); diff --git a/apps/time/src/meet.ts b/apps/time/src/meet.ts new file mode 100644 index 0000000..9f18766 --- /dev/null +++ b/apps/time/src/meet.ts @@ -0,0 +1,954 @@ +/** Find a time: people and their windows, the search, what fits, a day's timeline, share links. */ + +import { fromBase64, toBase64, utf8, textFromBytes } from "@tools/bytes"; +import { + byId, + copyText, + debounce, + iconSvg, + readStored, + setOn, + showMessage, + writeStored, +} from "@tools/ui"; +import { + combine, + costOf, + diagnose, + explain, + findTimes, + LEVELS, + personSegments, + rankKey, + summary, + MAX_SEARCH_DAYS, +} from "./lib/availability.js"; +import type { + Level, + Person, + Search, + Segment, + Suggestion, + TimeWindow, + WeeklyWindow, +} from "./lib/availability.js"; +import { + addDays, + atMinutes, + dayNumber, + DAY, + formatClock, + formatDate, + MINUTE, + parseClock, + parseDate, +} from "./lib/civil.js"; +import type { PlainDate } from "./lib/civil.js"; +import { clockTime, formatMinutes, shortDate, utcOffset } from "./lib/format.js"; +import { DEFAULT_DURATION, newPerson, readPlan, sharePlan, writePlan } from "./lib/plan.js"; +import type { Plan } from "./lib/plan.js"; +import { canonicalZone, instantOf, localZone, offsetAt, wallAt } from "./lib/zone.js"; +import { readable } from "./prefs.js"; +import { text } from "./app.js"; +import type { App, View } from "./app.js"; +import { h } from "./dom.js"; +import { bindZoneField } from "./zone-field.js"; + +const PLAN_KEY = "tools.time.plan"; +/** Starts are this far apart. Every zone in use today is a whole number of quarter hours from UTC. */ +const STEP = 15; +const PAGE = 10; +const DAY_NAMES = ["Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday"]; +const LEVEL_NAMES: Record<Level, string> = { + preferred: "Preferred", + acceptable: "Acceptable", + unavailable: "Unavailable", +}; +export const SHARE_PREFIX = "#plan="; + +/** First visit: three people in three zones, so every part of the view has something to show. */ +function examplePlan(): Plan { + const here = localZone(); + return { + people: [ + newPerson("You", here), + newPerson("Sam", here === "America/New_York" ? "America/Los_Angeles" : "America/New_York"), + newPerson("Kai", here === "Asia/Tokyo" ? "Australia/Sydney" : "Asia/Tokyo"), + ], + duration: DEFAULT_DURATION, + }; +} + +/** + * A plan in a link: JSON, compressed where the browser can, in Base64url. `z` marks compressed + * and `j` plain, so a link works in a browser either way. + */ +export async function encodeShare(plan: Plan): Promise<string> { + const json = utf8(JSON.stringify(sharePlan(plan))); + if (typeof CompressionStream === "undefined") return "j" + toBase64(json, true); + const stream = new Blob([json]).stream().pipeThrough(new CompressionStream("deflate-raw")); + return "z" + toBase64(new Uint8Array(await new Response(stream).arrayBuffer()), true); +} + +export async function decodeShare(value: string): Promise<Plan | undefined> { + try { + let bytes: Uint8Array<ArrayBuffer> = fromBase64(value.slice(1), true); + if (value[0] === "z") { + const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw")); + bytes = new Uint8Array(await new Response(stream).arrayBuffer()); + } else if (value[0] !== "j") return undefined; + return readPlan(JSON.parse(textFromBytes(bytes))); + } catch { + return undefined; + } +} + +export function meetView(app: App): View { + const saved = app.saved; + const stored = readPlan(readStored(PLAN_KEY)); + let plan: Plan = stored ?? examplePlan(); + let pending: Plan | undefined; + /** People whose card is folded. */ + const folded = new Set<string>( + Array.isArray(saved.folded) + ? saved.folded.filter((x): x is string => typeof x === "string") + : // The example opens on its first person; the others are folded to their name and zone. + stored + ? [] + : plan.people.slice(1).map((p) => p.id) + ); + let shownCount = PAGE; + let suggestions: Suggestion[] = []; + let selected: number | undefined = + typeof saved.selected === "number" ? saved.selected : undefined; + let day: PlainDate | undefined = parseDate(text(saved.day)); + let visible = false; + + const peopleBox = byId("people"); + const length = byId<HTMLSelectElement>("call-length"); + const rangeFrom = byId<HTMLInputElement>("range-from"); + const rangeTo = byId<HTMLInputElement>("range-to"); + const zoneField = bindZoneField(byId("meet-zone"), text(saved.meetZone) || localZone(), () => { + day = undefined; + research(); + }); + const zone = () => zoneField.zone; + + const today = wallAt(Date.now(), zone()); + rangeFrom.value = text(saved.rangeFrom) || formatDate(today); + rangeTo.value = text(saved.rangeTo) || formatDate(addDays(today, 6)); + + function savePlan(): void { + writeStored(PLAN_KEY, writePlan(plan)); + } + + function setLength(minutes: number): void { + if (![...length.options].some((o) => o.value === String(minutes))) + length.append(h("option", { value: String(minutes) }, formatMinutes(minutes))); + length.value = String(minutes); + } + setLength(plan.duration); + + // ---------- People ---------- + + const levelSelect = (level: Level) => + h( + "select", + { class: "ui-input", "data-field": "level", "aria-label": "Level" }, + ...LEVELS.map((l) => h("option", { value: l, selected: l === level }, LEVEL_NAMES[l])) + ); + + function windowRow( + window: TimeWindow | WeeklyWindow, + index: number, + exception?: number + ): HTMLElement { + const days = + "days" in window + ? h( + "div", + { class: "ui-seg time-days", role: "group", "aria-label": "Days" }, + ...DAY_NAMES.map((name, i) => + h( + "button", + { + type: "button", + class: "ui-btn ui-btn--small", + "data-day": String(i + 1), + "aria-pressed": String(window.days.includes(i + 1)), + "aria-label": name, + title: name, + }, + name.slice(0, 2) + ) + ) + ) + : undefined; + const clock = (field: "start" | "end", label: string) => + h("input", { + class: "ui-input", + type: "time", + step: "60", + "data-field": field, + "aria-label": label, + value: formatClock(window[field]), + }); + return h( + "div", + { + class: "time-window", + "data-window": String(index), + "data-exception": exception === undefined ? undefined : String(exception), + "data-level": window.level, + }, + days, + h( + "div", + { class: "time-window-hours" }, + clock("start", "From"), + h("span", { "aria-hidden": "true" }, "–"), + clock("end", "Until"), + levelSelect(window.level), + h( + "button", + { + type: "button", + class: "ui-btn ui-icon-btn", + "data-action": "remove-window", + "aria-label": "Remove window", + }, + iconSvg("close") + ) + ) + ); + } + + function personCard(person: Person): HTMLElement { + const valid = canonicalZone(person.zone) !== undefined; + const meta = valid + ? `${person.zone} · ${utcOffset(offsetAt(Date.now(), person.zone))}` + : `${person.zone} · unknown zone`; + const zoneInput = h("input", { + class: "ui-input time-zone", + list: "zone-list", + "aria-label": "Time zone", + }); + const relaxed = h("button", { + type: "button", + class: "ui-switch", + role: "switch", + "aria-checked": String(person.relaxed), + "data-field": "relaxed", + }); + const card = h( + "details", + { class: "time-person", "data-person": person.id, open: !folded.has(person.id) }, + h( + "summary", + {}, + h("span", { class: "time-person-name" }, person.name || "Unnamed"), + h("span", { class: "time-person-meta" }, meta), + person.relaxed && h("span", { class: "time-badge" }, "Relaxed") + ), + h( + "div", + { class: "time-person-body" }, + h( + "div", + { class: "time-person-fields" }, + h( + "label", + {}, + h("span", {}, "Name"), + h("input", { + class: "ui-input", + "data-field": "name", + value: person.name, + maxlength: "60", + autocomplete: "off", + }) + ), + h("label", {}, h("span", {}, "Time zone"), zoneInput) + ), + h("h3", {}, "Every week"), + h("div", { class: "time-windows" }, ...person.windows.map((w, i) => windowRow(w, i))), + h( + "button", + { type: "button", class: "ui-btn ui-btn--small", "data-action": "add-window" }, + iconSvg("plus"), + "Window" + ), + h("h3", {}, "Dates that differ"), + ...person.exceptions.map((x, i) => + h( + "div", + { class: "time-exception", "data-exception": String(i) }, + h( + "div", + { class: "time-exception-head" }, + h("input", { + class: "ui-input", + type: "date", + "data-field": "date", + value: x.date, + "aria-label": "Date", + }), + h( + "span", + { class: "ui-note" }, + x.windows.length ? "Replaces that day's weekly windows." : "Unavailable all day." + ), + h( + "button", + { + type: "button", + class: "ui-btn ui-icon-btn", + "data-action": "remove-exception", + "aria-label": "Remove date", + }, + iconSvg("trash") + ) + ), + ...x.windows.map((w, j) => windowRow(w, j, i)), + h( + "button", + { + type: "button", + class: "ui-btn ui-btn--small", + "data-action": "add-exception-window", + }, + iconSvg("plus"), + "Window" + ) + ) + ), + h( + "button", + { type: "button", class: "ui-btn ui-btn--small", "data-action": "add-exception" }, + iconSvg("plus"), + "Date" + ), + h( + "div", + { class: "time-person-foot" }, + h("label", { class: "time-switch" }, relaxed, "Allow unavailable time"), + h( + "button", + { type: "button", class: "ui-btn ui-btn--small", "data-action": "remove-person" }, + iconSvg("trash"), + "Remove" + ) + ) + ) + ); + bindZoneField(zoneInput, person.zone, (next) => { + person.zone = next; + changed(); + refreshSummary(person); + }); + if (!valid) zoneInput.setAttribute("aria-invalid", "true"); + return card; + } + + function refreshSummary(person: Person): void { + const card = peopleBox.querySelector<HTMLDetailsElement>(`[data-person="${person.id}"]`); + if (!card) return; + const old = card.querySelector("summary")!; + const fresh = personCard(person).querySelector("summary")!; + old.replaceChildren(...fresh.childNodes); + } + + function renderPeople(): void { + peopleBox.replaceChildren(...plan.people.map(personCard)); + if (!plan.people.length) + peopleBox.append(h("p", { class: "time-empty" }, "No one yet. Add a person to compare.")); + showMessage( + byId("people-note"), + plan.people.length + ? `${plan.people.length} ${plan.people.length === 1 ? "person" : "people"}` + : "" + ); + } + + /** The person, window and exception an event happened in. */ + function locate(target: HTMLElement) { + const card = target.closest<HTMLElement>("[data-person]"); + const person = plan.people.find((p) => p.id === card?.dataset.person); + const row = target.closest<HTMLElement>("[data-window]"); + const exceptionBox = target.closest<HTMLElement>(".time-exception"); + const exception = exceptionBox + ? person?.exceptions[Number(exceptionBox.dataset.exception)] + : undefined; + const index = row ? Number(row.dataset.window) : -1; + const window = row + ? exception + ? exception.windows[index] + : person?.windows[index] + : undefined; + return { person, exception, window, index, row }; + } + + peopleBox.addEventListener("input", (event) => { + const target = event.target as HTMLInputElement | HTMLSelectElement; + const { person, exception, window, row } = locate(target); + if (!person) return; + switch (target.dataset.field) { + case "name": + person.name = target.value.slice(0, 60); + refreshSummary(person); + break; + case "start": + case "end": { + const minutes = parseClock(target.value); + if (window && minutes !== undefined) window[target.dataset.field] = minutes; + break; + } + case "level": + if (window) window.level = target.value as Level; + row?.setAttribute("data-level", target.value); + break; + case "date": + if ( + exception && + parseDate(target.value) && + !person.exceptions.some((x) => x !== exception && x.date === target.value) + ) + exception.date = target.value; + break; + default: + return; + } + changed(); + }); + + peopleBox.addEventListener("click", (event) => { + const target = (event.target as HTMLElement).closest<HTMLElement>("button"); + if (!target) return; + const { person, exception, window, index } = locate(target); + if (!person) return; + if (target.dataset.day && window && !exception) { + const weekly = window as WeeklyWindow; + const d = Number(target.dataset.day); + const days = weekly.days.includes(d) + ? weekly.days.filter((x) => x !== d) + : [...weekly.days, d].sort(); + // A weekly window keeps at least one day: remove the window to have none. + if (!days.length) return; + weekly.days = days; + target.setAttribute("aria-pressed", String(days.includes(d))); + return changed(); + } + if (target.dataset.field === "relaxed") { + person.relaxed = !person.relaxed; + setOn(target, person.relaxed); + refreshSummary(person); + return changed(); + } + switch (target.dataset.action) { + case "add-window": + person.windows.push({ + days: [1, 2, 3, 4, 5], + start: 9 * 60, + end: 17 * 60, + level: "acceptable", + }); + break; + case "remove-window": + if (exception) exception.windows.splice(index, 1); + else person.windows.splice(index, 1); + break; + case "add-exception": { + let date = parseDate(rangeFrom.value) ?? wallAt(Date.now(), person.zone); + while (person.exceptions.some((x) => x.date === formatDate(date))) date = addDays(date, 1); + person.exceptions.push({ date: formatDate(date), windows: [] }); + break; + } + case "remove-exception": + if (exception) person.exceptions.splice(person.exceptions.indexOf(exception), 1); + break; + case "add-exception-window": + exception?.windows.push({ start: 9 * 60, end: 17 * 60, level: "acceptable" }); + break; + case "remove-person": + plan.people.splice(plan.people.indexOf(person), 1); + folded.delete(person.id); + break; + default: + return; + } + // Structure changed: draw the people again, keeping the focus near where it was. + const id = person.id; + renderPeople(); + const card = peopleBox.querySelector<HTMLElement>(`[data-person="${id}"]`); + ( + card?.querySelector<HTMLElement>(`[data-action="${target.dataset.action}"]`) ?? + byId("add-person") + ).focus(); + changed(); + }); + + peopleBox.addEventListener( + "toggle", + (event) => { + const card = event.target as HTMLDetailsElement; + if (!card.dataset?.person) return; + if (card.open) folded.delete(card.dataset.person); + else folded.add(card.dataset.person); + app.save(); + }, + true + ); + + byId("add-person").addEventListener("click", () => { + const person = newPerson(`Person ${plan.people.length + 1}`, zone()); + plan.people.push(person); + renderPeople(); + peopleBox + .querySelector<HTMLInputElement>(`[data-person="${person.id}"] [data-field="name"]`) + ?.select(); + changed(); + }); + + // ---------- Search ---------- + + function range(): { search?: Search; problem?: string } { + const from = parseDate(rangeFrom.value); + const to = parseDate(rangeTo.value); + if (!from || !to) return { problem: "Choose the dates to search." }; + const days = dayNumber(to) - dayNumber(from) + 1; + if (days < 1) return { problem: "The last date is before the first." }; + if (days > MAX_SEARCH_DAYS) + return { problem: `Search up to ${MAX_SEARCH_DAYS} days at a time.` }; + return { + search: { + from: instantOf(atMinutes(from, 0), zone()), + to: instantOf(atMinutes(addDays(to, 1), 0), zone()), + duration: plan.duration, + step: STEP, + }, + }; + } + + const usable = () => plan.people.filter((p) => canonicalZone(p.zone) !== undefined); + + function tier(s: Suggestion): "preferred" | "compromise" | "relaxed" { + const key = rankKey(s); + return key[0] ? "relaxed" : key[3] ? "compromise" : "preferred"; + } + + function renderSuggestions(): void { + const list = byId("suggestions"); + const options = readable(app.prefs); + const people = usable(); + list.replaceChildren( + ...suggestions.slice(0, shownCount).map((s) => { + const end = s.start + plan.duration * MINUTE; + const startDay = shortDate(s.start, zone()); + const endDay = shortDate(end, zone()); + const when = `${startDay}, ${clockTime(s.start, zone(), options)}–${clockTime(end, zone(), options)}${endDay === startDay ? "" : ` (${endDay})`}`; + const button = h( + "button", + { + type: "button", + class: "time-suggestion", + "data-tier": tier(s), + "aria-pressed": String( + selected !== undefined && selected >= s.start && selected <= s.last + ), + }, + h("span", { class: "time-suggestion-when" }, when), + s.last > s.start && + h( + "span", + { class: "time-suggestion-range" }, + `or start any time until ${clockTime(s.last, zone(), options)}` + ), + h("span", { class: "time-suggestion-why" }, explain(people, s.costs)) + ); + button.addEventListener("click", () => pick(s.start)); + return h("li", {}, button); + }) + ); + byId("more").hidden = suggestions.length <= shownCount; + } + + function renderDiagnosis(search: Search): void { + const box = byId("diagnosis"); + const people = usable(); + if (suggestions.length || people.length < 1) { + box.hidden = true; + return; + } + const found = diagnose(people, search); + const name = (id: string) => people.find((p) => p.id === id)?.name || "Unnamed"; + const lines: HTMLElement[] = [ + h("p", {}, "No time in this range fits everyone's windows for the whole call."), + ]; + for (const id of found.alone) + lines.push( + h( + "p", + {}, + `${name(id)} has no ${formatMinutes(plan.duration)} stretch of preferred or acceptable time in this range.` + ) + ); + for (const { id, count } of found.blocking) { + const relax = h( + "button", + { type: "button", class: "ui-btn ui-btn--small" }, + `Allow ${name(id)}'s unavailable time` + ); + relax.addEventListener("click", () => { + const person = plan.people.find((p) => p.id === id); + if (!person) return; + person.relaxed = true; + renderPeople(); + changed(); + }); + lines.push( + h( + "p", + {}, + `Without ${name(id)}, ${count} ${count === 1 ? "time fits" : "times fit"}. `, + relax + ) + ); + } + if (!found.alone.length && !found.blocking.length && people.length > 1) + lines.push( + h( + "p", + {}, + "No one person stands in the way: several people's windows would have to change, or search more dates." + ) + ); + box.replaceChildren(...lines); + box.hidden = false; + } + + function research(): void { + const note = byId("search-note"); + const { search, problem } = range(); + const people = usable(); + const broken = plan.people.filter((p) => !people.includes(p)).map((p) => p.name || "Unnamed"); + showMessage( + note, + problem ?? (broken.length ? `Unknown time zone for ${broken.join(", ")}: left out.` : ""), + Boolean(problem || broken.length) + ); + if (!search || !people.length) { + suggestions = []; + byId("diagnosis").hidden = true; + showMessage(byId("suggestions-note"), people.length ? "" : "Add people to compare."); + } else { + suggestions = findTimes(people, search); + const best = suggestions.filter((s) => tier(s) === "preferred").length; + showMessage( + byId("suggestions-note"), + suggestions.length + ? `${suggestions.length} ${suggestions.length === 1 ? "time" : "times"}${best ? `, ${best} preferred by everyone` : ""}.` + : "None fits." + ); + renderDiagnosis(search); + if (!day) day = wallAt(suggestions[0]?.start ?? search.from, zone()); + } + shownCount = PAGE; + renderSuggestions(); + renderTimeline(); + app.save(); + } + const researchSoon = debounce(research, 150); + + function changed(): void { + savePlan(); + researchSoon(); + } + + // ---------- Timeline ---------- + + function hourLabel(ms: number, zoneName: string): string { + const wall = wallAt(ms, zoneName); + if (app.prefs.clock === "12") { + const hour = wall.hour % 12 || 12; + return `${hour}${wall.minute ? ":" + String(wall.minute).padStart(2, "0") : ""}${wall.hour < 12 ? "a" : "p"}`; + } + return wall.minute ? formatClock(wall.hour * 60 + wall.minute) : String(wall.hour); + } + + function track( + segments: Segment[], + from: number, + to: number, + hoursZone: string, + label: string + ): HTMLElement { + const span = to - from; + const pct = (t: number) => `${(((t - from) / span) * 100).toFixed(3)}%`; + const hours = h("div", { class: "tl-hours", "aria-hidden": "true" }); + let lastDate = ""; + for (let t = from, i = 0; t < to; t += 60 * MINUTE, i++) { + const date = formatDate(wallAt(t, hoursZone)); + const newDay = date !== lastDate && i > 0; + lastDate = date; + hours.append( + h( + "span", + { + class: `tl-hour${i % 3 ? " tl-minor" : ""}${newDay ? " tl-newday" : ""}`, + style: `left:${pct(t)}`, + }, + newDay ? shortDate(t, hoursZone).split(/[ ,]/)[0] : hourLabel(t, hoursZone) + ) + ); + } + const bar = h("div", { class: "tl-bar", role: "img", "aria-label": label }); + for (const s of segments) + if (s.end > from && s.start < to) + bar.append( + h("span", { + class: `tl-seg level-${s.level}`, + style: `left:${pct(Math.max(s.start, from))};width:${(((Math.min(s.end, to) - Math.max(s.start, from)) / span) * 100).toFixed(3)}%`, + }) + ); + if (selected !== undefined) { + const end = selected + plan.duration * MINUTE; + if (end > from && selected < to) + bar.append( + h("span", { + class: "tl-call", + style: `left:${pct(Math.max(selected, from))};width:${(((Math.min(end, to) - Math.max(selected, from)) / span) * 100).toFixed(3)}%`, + }) + ); + } + const now = Date.now(); + if (now >= from && now < to) + bar.append(h("span", { class: "tl-now", style: `left:${pct(now)}` })); + return h("div", { class: "tl-track" }, hours, bar); + } + + /** Words for a row's levels, for those who cannot see the colours. */ + function describeRow(name: string, segments: Segment[], zoneName: string): string { + const options = readable(app.prefs); + const parts = segments + .filter((s) => s.level !== "unavailable") + .map( + (s) => + `${LEVEL_NAMES[s.level].toLowerCase()} ${clockTime(s.start, zoneName, options)}–${clockTime(s.end, zoneName, options)}` + ); + return `${name}: ${parts.length ? parts.join(", ") : "unavailable all day"}`; + } + + function dayBounds(): [number, number] { + const date = day ?? wallAt(Date.now(), zone()); + return [ + instantOf(atMinutes(date, 0), zone()), + instantOf(atMinutes(addDays(date, 1), 0), zone()), + ]; + } + + function renderTimeline(): void { + const [from, to] = dayBounds(); + const people = usable(); + byId("timeline-day").textContent = `${shortDate(from, zone())} · ${zone()}`; + const segments = people.map((p) => personSegments(p, from, to)); + const rows: HTMLElement[] = []; + if (people.length > 1) { + const all = combine(segments, from, to); + rows.push( + h( + "div", + { class: "tl-row tl-all" }, + h("div", { class: "tl-name" }, h("span", {}, "Everyone"), h("small", {}, zone())), + track(all, from, to, zone(), describeRow("Everyone", all, zone())) + ) + ); + } + people.forEach((person, i) => + rows.push( + h( + "div", + { class: "tl-row" }, + h( + "div", + { class: "tl-name" }, + h("span", {}, person.name || "Unnamed"), + h("small", {}, person.zone) + ), + track( + segments[i], + from, + to, + person.zone, + describeRow(person.name || "Unnamed", segments[i], person.zone) + ) + ) + ) + ); + byId("timeline").replaceChildren(...rows); + renderPicked(); + } + + function renderPicked(): void { + const people = usable(); + const [from] = dayBounds(); + const input = byId<HTMLInputElement>("picked-time"); + const copy = byId<HTMLButtonElement>("picked-copy"); + byId("picked-zone").textContent = `${shortDate(from, zone())}, ${zone()}`; + if (selected === undefined || !people.length) { + input.value = ""; + copy.disabled = true; + byId("picked-text").textContent = people.length + ? "Choose a suggestion, or click the timeline, to place the call." + : ""; + byId("picked-summary").textContent = ""; + return; + } + const wall = wallAt(selected, zone()); + input.value = formatClock(wall.hour * 60 + wall.minute); + const end = selected + plan.duration * MINUTE; + const costs = people.map((p) => + costOf(personSegments(p, selected! - DAY, end + DAY), selected!, end) + ); + const blocked = people + .filter((p, i) => !p.relaxed && costs[i].unavailable > 0) + .map((p) => p.name || "Unnamed"); + showMessage( + byId("picked-text"), + (blocked.length + ? `Does not fit: ${blocked.join(", ")} ${blocked.length === 1 ? "is" : "are"} unavailable for part of it. ` + : "") + explain(people, costs), + blocked.length > 0 + ); + byId("picked-summary").textContent = summary( + people, + selected, + plan.duration, + readable(app.prefs) + ); + copy.disabled = false; + } + + function pick(start: number): void { + selected = start; + day = wallAt(start, zone()); + renderSuggestions(); + renderTimeline(); + app.save(); + } + + byId("timeline").addEventListener("click", (event) => { + const bar = (event.target as HTMLElement).closest(".tl-track"); + if (!bar) return; + const box = bar.getBoundingClientRect(); + const [from, to] = dayBounds(); + const t = from + ((event.clientX - box.left) / box.width) * (to - from); + // Snapped to a quarter hour, the call starting where it was clicked. + pick(from + Math.max(0, Math.floor((t - from) / (STEP * MINUTE))) * STEP * MINUTE); + }); + byId("picked-time").addEventListener("change", () => { + const minutes = parseClock(byId<HTMLInputElement>("picked-time").value); + if (minutes === undefined) return; + pick(instantOf(atMinutes(day ?? wallAt(Date.now(), zone()), minutes), zone())); + }); + byId("picked-copy").addEventListener("click", async () => { + if (!(await copyText(byId("picked-summary").textContent ?? "", byId("picked-copy")))) + app.clipboardFailed(byId("picked-text")); + }); + const moveDay = (by: number) => { + day = addDays(day ?? wallAt(Date.now(), zone()), by); + renderTimeline(); + app.save(); + }; + byId("day-prev").addEventListener("click", () => moveDay(-1)); + byId("day-next").addEventListener("click", () => moveDay(1)); + byId("more").addEventListener("click", () => { + shownCount += PAGE; + renderSuggestions(); + }); + + length.addEventListener("change", () => { + plan.duration = Number(length.value); + changed(); + }); + for (const input of [rangeFrom, rangeTo]) + input.addEventListener("change", () => { + day = undefined; + research(); + }); + + // ---------- Sharing ---------- + + byId("share").addEventListener("click", async () => { + const note = byId("people-note"); + if (!plan.people.length) return showMessage(note, "Add people to share.", true); + const url = location.href.split("#")[0] + SHARE_PREFIX + (await encodeShare(plan)); + if (await copyText(url, byId("share"))) + showMessage(note, "Link copied: a snapshot of these people and windows."); + else app.clipboardFailed(note); + }); + + function showSnapshot(): void { + const box = byId("snapshot"); + box.hidden = !pending; + if (!pending) return; + const names = pending.people.map((p) => p.name || "Unnamed").join(", "); + byId("snapshot-text").textContent = + `This link holds ${pending.people.length} ${pending.people.length === 1 ? "person" : "people"} (${names}) ` + + `and a ${formatMinutes(pending.duration)} call: what its sender entered, not live answers.`; + } + const take = (replace: boolean) => { + if (!pending) return; + plan = replace ? pending : { ...plan, people: [...plan.people, ...pending.people] }; + if (replace) setLength(plan.duration); + pending = undefined; + showSnapshot(); + selected = undefined; + day = undefined; + renderPeople(); + savePlan(); + research(); + }; + byId("snapshot-replace").addEventListener("click", () => take(true)); + byId("snapshot-add").addEventListener("click", () => take(false)); + byId("snapshot-dismiss").addEventListener("click", () => { + pending = undefined; + showSnapshot(); + }); + /** A share link, opened in a new tab or followed from this one. */ + function readShared(): void { + if (!location.hash.startsWith(SHARE_PREFIX)) return; + const value = location.hash.slice(SHARE_PREFIX.length); + app.show("meet"); + // The link has been read: a reload must not ask again. + history.replaceState(null, "", location.pathname + location.search); + void decodeShare(value).then((shared) => { + if (shared?.people.length) pending = shared; + else showMessage(byId("people-note"), "That link holds no people that can be read.", true); + showSnapshot(); + }); + } + readShared(); + window.addEventListener("hashchange", readShared); + + app.onPrefs(() => { + renderSuggestions(); + renderTimeline(); + }); + + renderPeople(); + research(); + return { + values: () => ({ + meetZone: zone(), + rangeFrom: rangeFrom.value, + rangeTo: rangeTo.value, + selected, + day: day ? formatDate(day) : "", + folded: [...folded], + }), + shown(on) { + visible = on; + if (visible) renderTimeline(); + }, + }; +} diff --git a/apps/time/src/prefs.ts b/apps/time/src/prefs.ts new file mode 100644 index 0000000..d50f0c2 --- /dev/null +++ b/apps/time/src/prefs.ts @@ -0,0 +1,50 @@ +/** + * What Time keeps for the whole app, in this browser: the clock (24- or 12-hour) and the pinned + * zones. A saved format: read field by field, anything missing or unknown given its default. + */ + +import { readStored, writeStored } from "@tools/ui"; +import { canonicalZone } from "./lib/zone.js"; +import type { ReadableOptions } from "./lib/format.js"; + +const KEY = "tools.time.prefs"; + +export interface Prefs { + clock: "24" | "12"; + pinned: string[]; +} + +export const MAX_PINNED = 12; +const DEFAULT_PINNED = ["America/New_York", "Europe/London", "Asia/Tokyo"]; + +/** The reader's own habit, until they choose. */ +function localeClock(): Prefs["clock"] { + const cycle = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).resolvedOptions().hourCycle; + return cycle === "h11" || cycle === "h12" ? "12" : "24"; +} + +export function readPrefs(): Prefs { + const raw = readStored(KEY); + const stored = + typeof raw === "object" && raw !== null && !Array.isArray(raw) + ? (raw as Record<string, unknown>) + : {}; + const clock = stored.clock === "12" || stored.clock === "24" ? stored.clock : localeClock(); + const pinned = Array.isArray(stored.pinned) + ? [ + ...new Set( + stored.pinned + .filter((z): z is string => typeof z === "string") + .map((z) => canonicalZone(z)) + .filter((z): z is string => z !== undefined) + ), + ].slice(0, MAX_PINNED) + : DEFAULT_PINNED; + return { clock, pinned }; +} + +export function writePrefs(prefs: Prefs): void { + writeStored(KEY, prefs); +} + +export const readable = (prefs: Prefs): ReadableOptions => ({ hour12: prefs.clock === "12" }); diff --git a/apps/time/src/resolution.ts b/apps/time/src/resolution.ts new file mode 100644 index 0000000..a98f7c1 --- /dev/null +++ b/apps/time/src/resolution.ts @@ -0,0 +1,83 @@ +/** + * A wall time read in a zone, explained: where clocks go forward it never happens, and the time + * after the change is used and said; where they go back it happens twice, and the person + * chooses which before anything is shown. Convert and Duration both ask this. + */ + +import { formatDate, formatClock } from "./lib/civil.js"; +import type { WallTime } from "./lib/civil.js"; +import { clockTime, formatMinutes, utcOffset } from "./lib/format.js"; +import type { ReadableOptions } from "./lib/format.js"; +import { offsetAt, resolveWall } from "./lib/zone.js"; +import { h } from "./dom.js"; + +export type Choice = "earlier" | "later"; + +export interface ChoiceBox { + box: HTMLElement; + text: HTMLElement; + buttons: HTMLElement; +} + +export interface Resolved { + /** Undefined while a choice is needed. */ + ms?: number; + /** What was assumed or changed, to say beside the result. */ + note: string; +} + +const wallClock = (wall: WallTime) => + formatClock(wall.hour * 60 + wall.minute) + + (wall.second ? ":" + String(wall.second).padStart(2, "0") : ""); + +export function resolveShown( + wall: WallTime, + zone: string, + options: ReadableOptions, + ui: ChoiceBox, + chosen: Choice | undefined, + onChoose: (choice: Choice) => void +): Resolved { + const found = resolveWall(wall, zone); + const when = `${wallClock(wall)} on ${formatDate(wall)} in ${zone}`; + ui.box.hidden = found.kind !== "ambiguous"; + if (found.kind === "exact") return { ms: found.ms, note: "" }; + if (found.kind === "gap") { + const shift = formatMinutes(Math.round((found.after - found.before) / 60_000)); + return { + ms: found.ms, + note: + `${when} does not exist: the clocks go forward ${shift} there. ` + + `Read as ${clockTime(found.ms, zone, options)} (${utcOffset(offsetAt(found.ms, zone))}), the time after the change.`, + }; + } + const shift = formatMinutes(Math.round((found.later - found.earlier) / 60_000)); + // Drawn again only when it changes, so a live count does not take the focus from its buttons. + const key = `${when}|${chosen}|${found.earlier}`; + if (ui.box.dataset.key !== key) { + ui.box.dataset.key = key; + ui.text.textContent = `${when} happens twice: the clocks go back ${shift}. Which one?`; + ui.buttons.replaceChildren( + ...(["earlier", "later"] as const).map((choice) => { + const ms = found[choice]; + const button = h( + "button", + { + type: "button", + class: "ui-btn ui-btn--small", + "aria-pressed": String(chosen === choice), + }, + `${choice === "earlier" ? "First" : "Second"}, ${utcOffset(offsetAt(ms, zone))}` + ); + button.addEventListener("click", () => onChoose(choice)); + return button; + }) + ); + } + return chosen + ? { + ms: found[chosen], + note: `The ${chosen === "earlier" ? "first" : "second"} ${wallClock(wall)}, ${utcOffset(offsetAt(found[chosen], zone))}.`, + } + : { note: "Choose which of the two times." }; +} diff --git a/apps/time/src/styles.css b/apps/time/src/styles.css new file mode 100644 index 0000000..6986b62 --- /dev/null +++ b/apps/time/src/styles.css @@ -0,0 +1,603 @@ +@import "@tools/ui/base.css"; +@import "@tools/ui/tool.css"; + +/* The levels of availability, in the timeline, the windows and the suggestions alike. */ +:root { + --level-preferred: var(--ok); + --level-acceptable: var(--warn); + --level-unavailable: var(--danger); +} + +.time { + max-width: 1280px; +} +.time .ui-row h2 { + flex: none; + margin-right: 0; +} +.time h3 { + margin: 14px 0 6px; + color: var(--muted); + font-size: 12px; + font-weight: 600; +} +.time-flip { + transform: rotate(180deg); +} +.time .ui-btn svg { + width: 14px; + height: 14px; +} +.time .ui-icon-btn > svg { + width: var(--ui-glyph); + height: var(--ui-glyph); +} +.time [aria-invalid="true"] { + border-color: var(--danger); +} +.time input[type="date"], +.time input[type="time"] { + min-height: 32px; + padding: 4px 8px; +} + +/* Rows of labelled controls under a card's heading, wrapping as the card narrows. */ +.time-options { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 8px 18px; + margin-top: 10px; +} +.time-option { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + color: var(--muted); + font-size: 13px; +} +.time-option .ui-input { + color: var(--text); +} +.time-zone { + width: 15em; +} +.time-switch { + display: inline-flex; + align-items: center; + gap: 8px; + color: var(--text); + font-size: 13px; +} + +/* ---------- Convert ---------- */ + +.time .time-input { + min-height: 0; + resize: vertical; + font-size: 15px; +} +/* A time that happens twice: the question, then the two answers. */ +.time-choice { + margin-top: 10px; + padding: 8px 10px; + border: 1px solid var(--warn); + border-radius: var(--radius); + background: color-mix(in srgb, var(--warn) 10%, transparent); +} +.time-choice p { + margin: 0 0 8px; + font-size: 13px; +} + +.time-results { + display: grid; + gap: 6px; +} +.time-result { + display: grid; + grid-template-columns: minmax(0, 1fr) auto; + grid-template-areas: + "head actions" + "value actions" + "readable actions"; + column-gap: 8px; + padding: 6px 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); +} +.time-result-head { + grid-area: head; + display: flex; + flex-wrap: wrap; + gap: 2px 10px; + color: var(--muted); + font-size: 12px; +} +.time-result-label { + color: var(--text); + font-weight: 600; +} +.time-result-value { + grid-area: value; + font: 14px/1.5 var(--mono); + overflow-wrap: anywhere; +} +.time-result-readable { + grid-area: readable; + color: var(--muted); + font-size: 12px; +} +.time-result-actions { + grid-area: actions; + display: flex; + align-items: center; + gap: var(--ui-group-gap); +} +/* Their last values while the input cannot be read, dimmed. */ +[data-stale] .time-results { + opacity: 0.45; +} +.time-pin { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-top: 10px; + color: var(--muted); + font-size: 13px; +} +.time-pin .ui-input { + color: var(--text); +} + +.time-table-wrap { + overflow-x: auto; +} +.time-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; +} +.time-table th, +.time-table td { + padding: 4px 8px; + border-bottom: 1px solid var(--border); + text-align: left; + white-space: nowrap; +} +.time-table th { + color: var(--muted); + font-weight: 600; +} +.time-mono { + font-family: var(--mono); +} +.time-table tr[data-error] td:last-child { + color: var(--danger); +} + +/* ---------- Find a time ---------- */ + +.meet { + display: grid; + gap: 10px; +} +.meet .ui-card + .ui-card { + margin-top: 10px; +} +@media (width >= 1000px) { + .meet:not([hidden]) { + grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); + align-items: start; + } +} +.time-snapshot { + margin-bottom: 8px; + padding: 8px 10px; + border: 1px solid var(--accent); + border-radius: var(--radius); + background: color-mix(in srgb, var(--accent) 10%, transparent); + font-size: 13px; +} +.time-snapshot p { + margin: 0 0 8px; +} +.time-actions { + display: flex; + flex-wrap: wrap; + gap: 6px; +} +.time-empty { + color: var(--muted); + font-size: 13px; +} + +.time-person { + margin-top: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); +} +.time-person summary { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: 4px 10px; + padding: 8px 10px; + cursor: pointer; +} +.time-person summary::marker { + color: var(--muted); +} +.time-person-name { + font-weight: 600; +} +.time-person-meta { + color: var(--muted); + font-size: 12px; +} +.time-badge { + padding: 0 7px; + border: 1px solid color-mix(in srgb, var(--warn) 50%, transparent); + border-radius: 999px; + color: var(--warn); + font-size: 11px; +} +.time-person-body { + padding: 0 10px 10px; +} +.time-person-fields { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); + gap: 8px; +} +.time-person-fields label { + display: grid; + gap: 2px; + color: var(--muted); + font-size: 12px; +} +.time-person-fields .ui-input { + width: 100%; + color: var(--text); +} +.time-window { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 6px; + margin-bottom: 6px; + padding: 6px; + border: 1px solid var(--border); + border-left: 4px solid var(--level-acceptable); + border-radius: var(--radius); + background: var(--panel); +} +.time-window[data-level="preferred"] { + border-left-color: var(--level-preferred); +} +.time-window[data-level="unavailable"] { + border-left-color: var(--level-unavailable); +} +.time-window-hours > .ui-icon-btn { + margin-left: auto; +} +.time-days .ui-btn { + min-width: 30px; + padding: 0 4px; +} +.time-window-hours { + display: flex; + flex: 1 1 auto; + flex-wrap: wrap; + align-items: center; + gap: 6px; +} +.time-window-hours select { + min-height: 32px; + padding: 4px 6px; +} +.time-exception { + margin-bottom: 6px; + padding: 6px; + border: 1px dashed var(--border); + border-radius: var(--radius); +} +.time-exception-head { + display: flex; + align-items: center; + gap: 6px; + margin-bottom: 6px; +} +.time-person-foot { + display: flex; + flex-wrap: wrap; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-top: 14px; +} + +.time-suggestions { + display: grid; + gap: 6px; + margin: 0; + padding: 0; + list-style: none; +} +.time-suggestion { + display: grid; + gap: 2px; + width: 100%; + padding: 8px 10px; + border: 1px solid var(--border); + border-left: 4px solid var(--level-acceptable); + border-radius: var(--radius); + background: var(--bg); + color: var(--text); + font: inherit; + text-align: left; + cursor: pointer; +} +.time-suggestion[data-tier="preferred"] { + border-left-color: var(--level-preferred); +} +.time-suggestion[data-tier="relaxed"] { + border-left-color: var(--level-unavailable); +} +.time-suggestion:hover, +.time-suggestion:focus-visible { + border-color: var(--accent); + outline: none; +} +.time-suggestion[aria-pressed="true"] { + border-color: var(--accent); + background: color-mix(in srgb, var(--accent) 14%, var(--bg)); +} +.time-suggestion-when { + font-size: 14px; + font-weight: 600; +} +.time-suggestion-range, +.time-suggestion-why { + color: var(--muted); + font-size: 12px; +} +#more { + margin-top: 8px; +} +.time-diagnosis { + font-size: 13px; +} +.time-diagnosis p { + margin: 4px 0; +} + +/* The timeline: one row a person, every row the same instants from left to right. */ +.time-timeline { + display: grid; + gap: 6px; +} +.tl-row { + display: grid; + grid-template-columns: 120px minmax(0, 1fr); + align-items: end; + gap: 8px; +} +.tl-name { + display: flex; + flex-direction: column; + min-width: 0; + font-size: 12px; + line-height: 1.25; +} +.tl-name > * { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.tl-name small { + color: var(--muted); + font-size: 11px; +} +.tl-track { + cursor: crosshair; +} +.tl-hours { + position: relative; + height: 16px; + color: var(--muted); + font-size: 10px; + line-height: 16px; + font-variant-numeric: tabular-nums; +} +.tl-hour { + position: absolute; + top: 0; + padding-left: 2px; + border-left: 1px solid var(--border); + white-space: nowrap; +} +.tl-newday { + color: var(--text); + font-weight: 600; +} +.tl-bar { + position: relative; + height: 22px; + overflow: hidden; + border: 1px solid var(--border); + border-radius: 4px; + /* Unavailable: hatched, so it reads without colour too. */ + background: repeating-linear-gradient( + 135deg, + transparent 0 4px, + color-mix(in srgb, var(--muted) 18%, transparent) 4px 6px + ); +} +.tl-all .tl-bar { + height: 28px; +} +.tl-seg, +.tl-call, +.tl-now { + position: absolute; + top: 0; + bottom: 0; +} +.tl-seg.level-preferred { + background: color-mix(in srgb, var(--level-preferred) 60%, transparent); +} +.tl-seg.level-acceptable { + background: color-mix(in srgb, var(--level-acceptable) 45%, transparent); +} +.tl-call { + border: 2px solid var(--accent); + border-radius: 3px; + background: color-mix(in srgb, var(--accent) 22%, transparent); +} +.tl-now { + width: 2px; + background: var(--danger); +} +.time-legend { + display: flex; + flex-wrap: wrap; + gap: 4px 14px; + margin-top: 8px; + color: var(--muted); + font-size: 12px; +} +.time-legend span::before { + content: ""; + display: inline-block; + width: 10px; + height: 10px; + margin-right: 5px; + border: 1px solid var(--border); + border-radius: 2px; + vertical-align: -1px; +} +.time-legend .level-preferred::before { + background: color-mix(in srgb, var(--level-preferred) 60%, transparent); +} +.time-legend .level-acceptable::before { + background: color-mix(in srgb, var(--level-acceptable) 45%, transparent); +} +.time-legend .level-unavailable::before { + background: repeating-linear-gradient( + 135deg, + transparent 0 2px, + color-mix(in srgb, var(--muted) 40%, transparent) 2px 3px + ); +} +.time-picked { + margin-top: 10px; + padding-top: 10px; + border-top: 1px solid var(--border); +} +.time-picked .ui-note { + flex: none; +} +.time-picked-text { + margin: 8px 0 0; + font-size: 13px; +} +.time-picked-text[data-error="true"] { + color: var(--danger); +} +.time-summary { + margin: 8px 0 0; + padding: 8px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--bg); + font: 12px/1.5 var(--mono); + white-space: pre-wrap; +} +.time-summary:empty { + display: none; +} + +@media (width < 700px) { + .tl-minor { + display: none; + } + .tl-row { + grid-template-columns: minmax(0, 1fr); + gap: 2px; + } + .tl-name { + flex-direction: row; + gap: 8px; + } +} + +/* ---------- Duration ---------- */ + +.dur-ends { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); + gap: 10px; +} +.dur-end { + min-width: 0; + margin: 0; + padding: 6px 10px 10px; + border: 1px solid var(--border); + border-radius: var(--radius); +} +.dur-end legend { + padding: 0 4px; + color: var(--muted); + font-size: 12px; +} +.time-assume { + min-height: 1.5em; + margin: 8px 0 0; + color: var(--muted); + font-size: 12px; +} +.dur-span { + margin: 2px 0 4px; + font-size: 22px; + font-weight: 600; + line-height: 1.3; +} +.dur-extra { + margin: 0; + color: var(--muted); + font-size: 13px; +} +.dur-extra:empty { + display: none; +} +.dur-live { + margin-top: 10px; +} + +/* ---------- Header ---------- */ + +/* When the three views no longer fit beside "‹" and the settings, they become a dropdown. */ +@media (width < 406px) { + .time-header .time-views { + display: none; + } + .time-header .ui-view-picker { + display: block; + } +} + +/* On a phone, a result's buttons move up beside its label, so the value has the whole width. */ +@media (width < 520px) { + .time-result { + grid-template-areas: + "head actions" + "value value" + "readable readable"; + align-items: center; + } +} diff --git a/apps/time/src/zone-field.ts b/apps/time/src/zone-field.ts new file mode 100644 index 0000000..27e2b97 --- /dev/null +++ b/apps/time/src/zone-field.ts @@ -0,0 +1,79 @@ +/** + * A time-zone field: a text input over the list of zones (a `<datalist>`), shared by every view. + * It takes a zone's name in any case or its city alone, writes back the zone's own name, and + * marks itself invalid, keeping the last good zone, while what is typed is not a zone. + */ + +import { byId } from "@tools/ui"; +import { formatOffset } from "./lib/format.js"; +import { findZone, offsetAt, zoneNames } from "./lib/zone.js"; + +export interface ZoneField { + /** The last valid zone. */ + readonly zone: string; + set(zone: string): void; +} + +let listed = false; +/** Fills the shared list once, the first time a zone field is used: each zone with its offset now. */ +function fillList(): void { + if (listed) return; + listed = true; + const now = Date.now(); + const list = byId<HTMLDataListElement>("zone-list"); + for (const zone of zoneNames()) { + const option = document.createElement("option"); + option.value = zone; + option.label = "UTC" + formatOffset(offsetAt(now, zone)); + list.append(option); + } +} + +export function bindZoneField( + input: HTMLInputElement, + initial: string, + onChange: (zone: string) => void +): ZoneField { + let zone = initial; + input.value = zone; + input.autocomplete = "off"; + input.spellcheck = false; + input.addEventListener("focus", fillList, { once: true }); + const read = (commit: boolean) => { + const found = findZone(input.value); + input.setAttribute("aria-invalid", String(!found && input.value.trim() !== "")); + if (!found) { + if (commit && !input.value.trim()) { + // Emptied and left: back to the zone in use. + input.value = zone; + input.setAttribute("aria-invalid", "false"); + } + return; + } + if (commit) input.value = found; + if (found !== zone) { + zone = found; + onChange(zone); + } + }; + // While typing, only a full name counts, so "Europe/Par" does not jump to a city's zone. + input.addEventListener("input", () => { + if (zoneNames().includes(input.value.trim())) read(false); + else + input.setAttribute( + "aria-invalid", + String(!findZone(input.value) && input.value.trim() !== "") + ); + }); + input.addEventListener("change", () => read(true)); + return { + get zone() { + return zone; + }, + set(next: string) { + zone = next; + input.value = next; + input.setAttribute("aria-invalid", "false"); + }, + }; +} diff --git a/apps/time/tsconfig.json b/apps/time/tsconfig.json new file mode 100644 index 0000000..a133a4a --- /dev/null +++ b/apps/time/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../packages/tsconfig/app.json", + "compilerOptions": { + "types": ["vite/client", "bun"] + }, + "include": ["src/**/*.ts", "vite.config.ts"] +} diff --git a/apps/time/vite.config.ts b/apps/time/vite.config.ts new file mode 100644 index 0000000..9860744 --- /dev/null +++ b/apps/time/vite.config.ts @@ -0,0 +1,4 @@ +import { defineConfig } from "vite"; +import { toolsApp } from "@tools/ui/vite"; + +export default defineConfig(toolsApp("time")); diff --git a/bun.lock b/bun.lock index ce8ba9a..5fcde7c 100644 --- a/bun.lock +++ b/bun.lock @@ -88,6 +88,15 @@ "@tools/ui": "workspace:*", }, }, + "apps/time": { + "name": "@tools/time", + "version": "0.0.0", + "devDependencies": { + "@tools/bytes": "workspace:*", + "@tools/catalog": "workspace:*", + "@tools/ui": "workspace:*", + }, + }, "packages/bytes": { "name": "@tools/bytes", "version": "0.0.0", @@ -256,6 +265,8 @@ "@tools/svg": ["@tools/svg@workspace:apps/svg"], + "@tools/time": ["@tools/time@workspace:apps/time"], + "@tools/tsconfig": ["@tools/tsconfig@workspace:packages/tsconfig"], "@tools/ui": ["@tools/ui@workspace:packages/ui"], diff --git a/docs/README.md b/docs/README.md index 18185c3..b4fce9a 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,8 +18,8 @@ under way; **Planned** is a later proposal. Future rows are ordered by build pri | [Codes](apps/codes.md) | Stable | QR codes for links, Wi-Fi, contacts, events and more, with your colours, shapes, logo and frame; plus Code 128, EAN-13 and UPC-A barcodes. | | [Codec](apps/codec.md) | Stable | Convert UTF-8 text, Base64, Base64url, URL components and hex, with every byte visible. | | [Digests](apps/digests.md) | Stable | Hash text and files with every SHA at once, check an expected digest, and compute HMAC. | -| [Time](apps/time.md) | Next | Convert timestamps, compare availability and measure durations. | -| [Diff](apps/diff.md) | Planned | Compare two texts or JSON documents, with clear changes and navigation. | +| [Time](apps/time.md) | Stable | Convert Unix timestamps and ISO dates across time zones, find a call time for people in several zones, and measure durations and ages. | +| [Diff](apps/diff.md) | Next | Compare two texts or JSON documents, with clear changes and navigation. | | [Certs](apps/certs.md) | Planned | Inspect X.509 certificates, chains and related data. | | [Notes](apps/notes.md) | Planned | Write Markdown and organise a small local collection of notes. | | [Magnet](apps/magnet.md) | Planned | Inspect torrent metadata and convert infohashes and magnet links. | diff --git a/docs/apps/jwt.md b/docs/apps/jwt.md index 9215ff8..05325b7 100644 --- a/docs/apps/jwt.md +++ b/docs/apps/jwt.md @@ -9,7 +9,7 @@ Editing the encoded token updates the header and payload. Editing the header or the token and signs it again when a key that can sign is present. The algorithm is the header's `alg`; choosing another rewrites the header and signs again. Known header parameters and claims are explained at the end of their line, with `exp`, `nbf` and `iat` as local dates. Those notes are not -part of the JSON. A token pasted with a `Bearer` prefix, or wrapped over several lines, is made +part of the JSON; Time opens on those claims to see them in every zone. A token pasted with a `Bearer` prefix, or wrapped over several lines, is made compact as it is pasted. HS256, HS384 and HS512 use a shared secret, as UTF-8 text, Base64url, Base64, hex or an oct JWK. diff --git a/docs/apps/time.md b/docs/apps/time.md index 04d4d4d..bde9c30 100644 --- a/docs/apps/time.md +++ b/docs/apps/time.md @@ -1,96 +1,98 @@ # Time -Not built yet. Status and build order are on the [roadmap](../README.md). +Convert Unix timestamps and ISO dates across time zones, find a call time for people in several +zones, and measure durations and ages. -Purpose: convert times, compare availability and measure durations. Three views share -the same date controls and time-zone picker: **Convert**, **Find a time**, and **Duration**. +Three views share the same time-zone field and date controls: **Convert**, **Find a time** and +**Duration**. Zones are named IANA zones (`Europe/Paris`), read from the browser's own copy of +the [time-zone database](https://www.iana.org/time-zones) through `Intl`, so every offset is the +one at that date, fractional hours and daylight-saving time included. A zone can be typed by +name in any case or by its city alone. "Now" is the device clock, as exact as its setting; it is +not an independently synchronised clock like [Time.is](https://time.is/). -## Convert +What is typed stays in the tab's draft. People, windows and pinned zones, and the 24- or +12-hour clock (Settings), are kept in the browser. The calendar is the proleptic Gregorian one, +with year 0 for 1 BC. -Initial scope: +## Convert -- Unix timestamps in seconds or milliseconds, with explicit units. Detection may suggest - a unit, but people can see and override it. -- ISO 8601 input/output, UTC, the browser's local zone and a few pinned named zones. -- Conversion in both directions, with copy buttons and a visible UTC offset for the date. -- Relative display such as "in 3 hours" as a supplement to the exact result. +The input takes a Unix timestamp, an ISO 8601 / RFC 3339 date (extended or basic, with an +optional `[Zone/Name]` suffix), or an HTTP or email date (RFC 9110, RFC 2822). A number from +100,000,000,000 up is read as milliseconds and below that as seconds; the note says which, and +Seconds or Milliseconds overrides it. Digits past milliseconds are dropped, and the note says so. -Use named zones such as `Europe/Paris`, not a fixed list of 24 offsets. Zones can have -fractional-hour offsets, and their offsets depend on the date and daylight-saving rules. -The [IANA time-zone database](https://www.iana.org/time-zones) describes these rules. +A date or time without an offset is read in the chosen zone, and a date alone at 00:00 there; the +note states the assumption. Where clocks go back, a time happens twice and nothing is shown until +the person chooses the first or second; where they go forward, it never happens, and it is read +as the time on the clock after the change (02:30 in a one-hour gap is 03:30), with the reason. +A date and time picker writes the input, and follows it. -A local time can occur twice or never occur when the clocks change. Explain that result -and require a choice for an ambiguous time rather than silently picking an instant. -Date-only and date-time inputs need visible assumptions about their zone and time. -Any "now" display uses the device clock; it should not promise an independently -synchronised exact clock like [Time.is](https://time.is/). +Results give Unix seconds and milliseconds, UTC, the device's zone, the zone the input was read in +and each pinned zone (New York, London and Tokyo until changed), as ISO 8601 with the offset and +abbreviation at that instant and as the reader's language writes it, plus an HTTP date and a +relative time ("in 3 hours") that keeps up with the clock. Each has a copy button. -Small extension: batch timestamp conversion. A handoff from JWT's time claims would -also fit the existing pattern for opening another app on the same input. +Several lines convert together into a table (label, input, Unix seconds, UTC, the chosen zone, a +note), copied as tab-separated text. A line may start with a label, `exp: 1760264100`, or be a +JSON member, `"exp": 1760264100,`. A time that happens twice is not guessed in a table: the line +asks for an offset. JWT's payload card opens Time on the token's `exp`, `iat` and `nbf`. ## Find a time -Use case: one person enters their own and their friends' availability to find a convenient -call time across several zones, or the least inconvenient compromise when preferences -do not overlap. It works for two people or a group without accounts. - -Inputs: - -- A name and named time zone for each person. -- Multiple availability windows in each person's local time, including windows that - cross midnight. -- Three levels: **preferred**, **acceptable**, and **unavailable**. Define the availability - model so those levels are unambiguous; outside the entered windows is unavailable. -- A call duration and a date range to search. -- Reusable weekly patterns, with exceptions for individual dates. - -Resolve weekly windows for each actual date before comparing them. A recurring local -evening window should follow that person's daylight-saving changes, not a frozen UTC offset. -The call's entire duration must fit, not just its start. Show the calendar date as well -as the clock time for each person when a candidate crosses a date boundary. - -Results: - -- A timeline with one row per person, aligned to the same instants, showing local times, - preferences and shared availability. -- Ranked suggestions, with fully preferred overlaps first and acceptable compromises next. -- A plain explanation of each compromise, for example "Alice: 30 minutes past preferred - hours; everyone else: preferred." - -For compromise ranking, minimise the worst inconvenience for any one person first, then -use total inconvenience to break ties. This avoids making one person bear a large penalty -just to improve the group's total. The exact scoring rule remains to be defined; it should -be deterministic and understandable, and account for the whole call duration. - -Unavailable periods remain excluded unless the person explicitly relaxes them. If no -candidate fits everyone's acceptable windows, show that result and identify which windows -prevent a match; offer relaxation as a deliberate action. Do not quietly schedule a call -during someone's excluded sleep or work hours. - -Save people and patterns locally. Useful later additions are a shareable snapshot and a -copyable summary such as "Saturday: Paris 20:00 / New York 14:00". A snapshot represents -entered information, not live answers from friends; choose its sharing format separately. - -Out of scope: accounts, collecting live availability from participants, calendar integrations, -invitations and an ongoing scheduling service. - -## Duration and age - -Make "How old am I?" a preset of a general duration calculator: - -- A start date/time and optional end date/time, defaulting to now. -- Calendar age in years, months and days, or a total expressed in **days only**, **hours - only**, **minutes only**, or **seconds only**. -- The same controls for "how long since we met?" or "how long until my holiday?" -- An optional live counter when the end is now. - -Calendar age and elapsed duration are separate measurements. An elapsed day is 24 hours; -calendar days can span daylight-saving changes. Define and expose rounding for totals. -A birth date alone does not establish an exact age in seconds: allow a birth time and -zone, or visibly state the assumed time and zone. Resolve ambiguous inputs as in Convert. - -## Decisions before implementation - -- First-release scope for conversion, duration and basic availability overlap. -- Compromise scoring, duration rounding and calendar-age conventions. -- Portable file, URL snapshot or copied text for sharing availability. +Each person has a name, a zone, and windows on their own wall clock: **preferred**, +**acceptable** or **unavailable**. Time outside every window is unavailable. Where windows +overlap, unavailable wins over preferred, and preferred over acceptable. A window whose end is not +after its start runs past midnight; one that ends where it starts is the whole day. Weekly windows +apply on the weekdays ticked; a date exception replaces the windows that start on that date, and +with none the person is unavailable that day. A new person is acceptable from 08:00 to 22:00 and +prefers weekday evenings and weekend days; the first visit opens on three example people. + +The search covers up to 31 days, from midnight to midnight in the zone the results are shown in, +for a call of 15 minutes to 3 hours. Windows are resolved date by date in each person's zone, so a +recurring evening follows that person's daylight-saving changes. Starts are a quarter hour apart, +and the whole call must fit. A run of starts that cost everyone the same is one suggestion ("or +start any time until 20:00"). + +Ranking: a person's cost is the minutes of the call outside their preferred windows. Times that +need nobody's unavailable time come first; then the fewest unavailable minutes any one person +gives up, and in total; then the most minutes any one person spends outside preferred hours, so +nobody bears a large compromise to spare the group a small one; then the group's total; then the +earliest. Each suggestion says why: "Sam: 1 h outside preferred hours; everyone else: preferred." + +Unavailable time is used only for someone whose "Allow unavailable time" is on. When nothing fits, +the search names anyone with no stretch long enough of their own, and each person without whom the +others would find times, with a button to allow that person's unavailable time. + +The timeline shows one day in the chosen zone: a row for everyone combined and one a person, all +aligned to the same instants, each with that person's own hours and date changes above it. +Preferred and acceptable are coloured, unavailable is hatched, and each row also reads as text +for a screen reader. Choosing a suggestion, clicking the timeline or typing a time places the call; +it then says how it sits with everyone and gives a summary to copy, with each person's own date +and time. + +Share link copies a link holding the people, their windows and the call length: JSON, compressed +with `deflate-raw` where the browser can, in Base64url after `#plan=`. Nothing is sent to a +server. Opening one asks whether to replace the saved people, add to them, or dismiss it, and says +it is a snapshot of what was entered, not live answers. The saved people (`tools.time.plan`) and +the share link are one format, read field by field, and versioned for a change that cannot be. + +## Duration + +Age, Since, Until and Between set which end is now; either can be switched. A time is optional: +without one, 00:00 in the chosen zone is assumed, and the note says so. Ambiguous and skipped +times are resolved as in Convert. + +Calendar counts whole years, months and days on the wall clock, then the time left when every +dated end has a time. A day of 23 or 25 hours at a clock change is still one day. A month too +short for the day ends at its last day: one month after 31 January is 28 or 29 February, and a +29 February birthday counts on 28 February in other years. Age adds when the next birthday is. + +Elapsed gives weeks, days, hours, minutes and seconds of exact time, a day being 24 hours. +Completed counts only whole units, toward zero for a negative span; Nearest rounds to the nearest +whole unit, half away from zero. While one end is now, both count live, every second. + +## Later + +- Batch conversion of a pasted file or CSV column. +- A shareable snapshot as a file, for availability too large for a link. +- Find a time across more than 31 days, or with a start step other than a quarter hour. diff --git a/packages/catalog/src/index.ts b/packages/catalog/src/index.ts index d4452ad..747c5be 100644 --- a/packages/catalog/src/index.ts +++ b/packages/catalog/src/index.ts @@ -134,6 +134,19 @@ export const APPS: readonly ToolsApp[] = [ status: "stable", listed: true, }, + { + slug: "time", + name: "Time", + blurb: + "Convert Unix timestamps and ISO dates across time zones, find a call time for people in several zones, and measure durations and ages.", + icon: "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0zM12 7v5l3 2", + tags: ["time", "timestamp", "timezone", "iso-8601", "meeting", "duration", "age"], + art: true, + compactHeader: 500, + jsBudget: { entryGzip: 24, totalGzip: 24 }, + status: "stable", + listed: true, + }, ]; export function findApp(slug: string): ToolsApp | undefined { diff --git a/packages/ui/header.css b/packages/ui/header.css index 58a8e8a..a61c4ec 100644 --- a/packages/ui/header.css +++ b/packages/ui/header.css @@ -173,3 +173,35 @@ /* On a narrow screen "‹ Tools Name" reduces to "‹", its words kept for screen readers. The build writes that rule into each page, at the width the catalog gives the app (`compactHeader`, `compactHeaderTag` in src/vite.ts). */ + +/* An app's views as one dropdown, for when its .ui-seg of view buttons no longer fits: hidden + until the app's own media query shows it in place of the buttons. Drawn as a pressed header + button with its own chevron, since the native arrow differs on every platform. */ +.ui-view-picker { + position: relative; + display: none; +} + +.ui-view-picker > svg { + position: absolute; + right: 6px; + top: 50%; + width: 18px; + height: 18px; + transform: translateY(-50%); + color: var(--text); + pointer-events: none; +} + +.ui-view-select { + min-height: var(--ui-control); + padding: 0 28px 0 10px; + border: 1px solid var(--accent); + border-radius: var(--radius, 6px); + background: var(--accent-dim); + color: var(--text); + font: inherit; + font-size: var(--ui-control-font); + appearance: none; + cursor: pointer; +} diff --git a/tests/browser/developer-tools.spec.ts b/tests/browser/developer-tools.spec.ts index fe60c13..55a1e58 100644 --- a/tests/browser/developer-tools.spec.ts +++ b/tests/browser/developer-tools.spec.ts @@ -841,7 +841,7 @@ for (const [slug, field, value, result] of [ }); } -for (const slug of ["jwt", "codec", "codes", "digests"]) { +for (const slug of ["jwt", "codec", "codes", "digests", "time"]) { test(`${slug} fits narrow headers, shares theme and has accessible controls`, async ({ page, }) => { @@ -934,7 +934,7 @@ for (const slug of ["jwt", "codec", "codes", "digests"]) { }); } -test("catalog links, install shortcuts and offline cache contain all four tools", async ({ +test("catalog links, install shortcuts and offline cache contain every developer tool", async ({ page, context, }) => { @@ -942,10 +942,10 @@ test("catalog links, install shortcuts and offline cache contain all four tools" await page.setViewportSize({ width: 1440, height: 1000 }); expect((await page.locator(".grid").boundingBox())!.width).toBeGreaterThan(1300); await page.screenshot({ path: "test-results/home-desktop.png", fullPage: true }); - for (const slug of ["jwt", "codec", "codes", "digests"]) + for (const slug of ["jwt", "codec", "codes", "digests", "time"]) await expect(page.locator(`a[href="/Tools/${slug}/"]`)).toBeVisible(); const manifest = await (await page.request.get("/Tools/manifest.webmanifest")).json(); - for (const slug of ["jwt", "codec", "codes", "digests"]) + for (const slug of ["jwt", "codec", "codes", "digests", "time"]) expect( manifest.shortcuts.some((shortcut: { url: string }) => shortcut.url === `${slug}/`) ).toBe(true); @@ -960,7 +960,7 @@ test("catalog links, install shortcuts and offline cache contain all four tools" }); }); await context.setOffline(true); - for (const slug of ["jwt", "codec", "codes", "digests"]) { + for (const slug of ["jwt", "codec", "codes", "digests", "time"]) { await page.goto(`/Tools/${slug}/`); await expect(page.locator(".ui-app-name")).toHaveText(findApp(slug)!.name); await expect(page.locator("main")).toBeVisible(); diff --git a/tests/browser/time.spec.ts b/tests/browser/time.spec.ts new file mode 100644 index 0000000..4acb7e1 --- /dev/null +++ b/tests/browser/time.spec.ts @@ -0,0 +1,260 @@ +import { expect, test } from "@playwright/test"; +import type { Page } from "@playwright/test"; +import AxeBuilder from "@axe-core/playwright"; + +// Every expectation below is in Paris time and British English, whatever the machine running it. +test.use({ timezoneId: "Europe/Paris", locale: "en-GB" }); + +const EVERY_DAY = [1, 2, 3, 4, 5, 6, 7]; +/** Ana in Paris prefers 18:00–21:00; Ben in New York prefers 12:00–15:00: 18:00–21:00 in Paris. */ +const PEOPLE = [ + { + id: "a", + name: "Ana", + zone: "Europe/Paris", + windows: [ + { days: EVERY_DAY, start: 480, end: 1320, level: "acceptable" }, + { days: EVERY_DAY, start: 1080, end: 1260, level: "preferred" }, + ], + exceptions: [], + }, + { + id: "b", + name: "Ben", + zone: "America/New_York", + windows: [ + { days: EVERY_DAY, start: 480, end: 1200, level: "acceptable" }, + { days: EVERY_DAY, start: 720, end: 900, level: "preferred" }, + ], + exceptions: [], + }, +]; + +async function open(page: Page, people: unknown[], draft: Record<string, unknown> = {}) { + await page.goto("/Tools/time/"); + await page.evaluate( + ([people, draft]) => { + localStorage.setItem("tools.time.plan", JSON.stringify({ version: 1, duration: 60, people })); + sessionStorage.setItem("tools.time.draft", JSON.stringify({ version: 1, value: draft })); + }, + [people, draft] as const + ); + await page.reload(); +} + +const row = (page: Page, label: string) => + page + .locator(".time-result") + .filter({ has: page.locator(".time-result-label", { hasText: label }) }); + +test("Time converts both ways, asks which of a doubled time and explains a skipped one", async ({ + page, +}) => { + await page.goto("/Tools/time/"); + const input = page.locator("#input"); + await input.fill("1760264100"); + await expect(page.locator("#input-note")).toHaveText("Unix seconds, by its size."); + await expect(row(page, "UTC").locator("code")).toHaveText("2025-10-12T10:15:00Z"); + await expect(row(page, "This device").locator("code")).toHaveText("2025-10-12T12:15:00+02:00"); + await expect(row(page, "Unix milliseconds").locator("code")).toHaveText("1760264100000"); + await expect(row(page, "HTTP date").locator("code")).toHaveText("Sun, 12 Oct 2025 10:15:00 GMT"); + await page.locator('[data-unit="ms"]').click(); + await expect(page.locator("#input-note")).toHaveText("Unix milliseconds."); + await expect(row(page, "UTC").locator("code")).toHaveText("1970-01-21T08:57:44.100Z"); + await page.locator('[data-unit="auto"]').click(); + + // Paris turns its clocks back on 25 October 2026: 02:30 happens twice. + await input.fill("2026-10-25T02:30"); + await expect(page.locator("#choice-text")).toContainText("happens twice"); + await expect(page.locator("#input-note")).toContainText("Choose which of the two times."); + await page.getByRole("button", { name: "Second, UTC+01:00" }).click(); + await expect(row(page, "UTC").locator("code")).toHaveText("2026-10-25T01:30:00Z"); + await expect(page.getByRole("button", { name: "Second, UTC+01:00" })).toHaveAttribute( + "aria-pressed", + "true" + ); + + // And forward on 29 March: 02:30 never happens, and is read as the time after the change. + await input.fill("2026-03-29T02:30"); + await expect(page.locator("#choice")).toBeHidden(); + await expect(page.locator("#input-note")).toContainText("does not exist"); + await expect(row(page, "This device").locator("code")).toHaveText("2026-03-29T03:30:00+02:00"); + + // A date alone is midnight in the zone it is read in, which can be changed. + await input.fill("2026-10-12"); + await page.locator("#input-zone").fill("tokyo"); + await page.locator("#input-zone").press("Tab"); + await expect(page.locator("#input-zone")).toHaveValue("Asia/Tokyo"); + await expect(page.locator("#input-note")).toHaveText("00:00 in Asia/Tokyo assumed."); + await expect(row(page, "UTC").locator("code")).toHaveText("2026-10-11T15:00:00Z"); + await expect(row(page, "Read in").locator("code")).toHaveText("2026-10-12T00:00:00+09:00"); + + // A zone pinned stays for every result after. + await page.locator("#pin-zone").fill("adelaide"); + await page.locator("#pin-zone").press("Enter"); + await expect(row(page, "Australia/Adelaide").locator("code")).toHaveText( + "2026-10-12T01:30:00+10:30" + ); + await page.reload(); + await expect(row(page, "Australia/Adelaide")).toBeVisible(); + + // Several lines are a table; labels and JSON members are read, and a bad line says why. + await input.fill('exp: 1760264100\n"iat": 1760260500,\nsoon'); + await expect(page.locator("#results-card")).toBeHidden(); + await expect(page.locator("#batch tbody tr")).toHaveCount(3); + await expect(page.locator("#batch thead")).toContainText("Label"); + await expect(page.locator("#batch tbody tr").nth(1)).toContainText("2025-10-12T09:15:00Z"); + await expect(page.locator("#batch tbody tr").nth(2)).toHaveAttribute("data-error", "true"); + await expect(page.locator("#batch-note")).toHaveText("1 could not be read."); +}); + +test("Time finds a call time, explains compromises, relaxes on request and shares a snapshot", async ({ + page, + context, +}) => { + const day = { + view: "meet", + rangeFrom: "2026-10-12", + rangeTo: "2026-10-12", + meetZone: "Europe/Paris", + }; + await open(page, PEOPLE, day); + const first = page.locator(".time-suggestion").first(); + await expect(first).toContainText("Mon 12 Oct, 18:00–19:00"); + await expect(first).toContainText("or start any time until 20:00"); + await expect(first).toContainText("Everyone: preferred hours."); + await expect(first).toHaveAttribute("data-tier", "preferred"); + await first.click(); + await expect(page.locator("#picked-summary")).toHaveText( + [ + "Call, 1 h", + "Ana (Europe/Paris): Mon 12 Oct, 18:00–19:00", + "Ben (America/New_York): Mon 12 Oct, 12:00–13:00", + ].join("\n") + ); + await expect(page.locator(".tl-row")).toHaveCount(3); + await expect(page.locator(".tl-call")).toHaveCount(3); + // Placing the call by its time says how it sits with everyone. + await page.locator("#picked-time").fill("22:00"); + await page.locator("#picked-time").dispatchEvent("change"); + await expect(page.locator("#picked-text")).toContainText("Does not fit: Ana is unavailable"); + + // Cy in Tokyo accepts 07:00–09:00, 00:00–02:00 in Paris, when Ana is unavailable. + const cy = { + id: "c", + name: "Cy", + zone: "Asia/Tokyo", + windows: [{ days: EVERY_DAY, start: 420, end: 540, level: "acceptable" }], + exceptions: [], + }; + await open(page, [...PEOPLE, cy], day); + await expect(page.locator("#suggestions-note")).toHaveText("None fits."); + await expect(page.locator("#diagnosis")).toContainText("Without Cy"); + await expect(page.locator("#diagnosis")).toContainText("Without Ana"); + await page.getByRole("button", { name: "Allow Ana's unavailable time" }).click(); + await expect(page.locator(".time-suggestion").first()).toHaveAttribute("data-tier", "relaxed"); + await expect(page.locator(".time-suggestion").first()).toContainText( + "Ana: 1 h in unavailable time" + ); + await expect(page.locator('[data-person="a"] .time-badge')).toHaveText("Relaxed"); + + // Share link: the snapshot opens elsewhere and asks before it replaces anything. + await page.evaluate(() => { + Object.defineProperty(navigator, "clipboard", { + value: { + writeText: async (text: string) => void ((window as { copied?: string }).copied = text), + }, + }); + }); + await page.locator("#share").click(); + await expect(page.locator("#people-note")).toContainText("Link copied"); + const link = await page.evaluate(() => (window as { copied?: string }).copied!); + expect(link).toMatch(/\/time\/#plan=[zj][A-Za-z0-9_-]+$/); + const other = await context.newPage(); + await other.goto("/Tools/time/"); + await other.evaluate(() => localStorage.removeItem("tools.time.plan")); + await other.goto(link); + await expect(other.locator("#view-meet")).toBeVisible(); + await expect(other.locator("#snapshot-text")).toContainText("3 people (Ana, Ben, Cy)"); + await expect(other.locator("#snapshot-text")).toContainText("not live answers"); + expect(other.url()).not.toContain("#plan="); + await other.locator("#snapshot-replace").click(); + await expect(other.locator(".time-person")).toHaveCount(3); + await expect(other.locator(".time-person summary").first()).toContainText("Relaxed"); +}); + +test("Time measures ages and spans on the calendar and in elapsed units", async ({ page }) => { + await page.goto("/Tools/time/"); + await page.locator('[data-view="duration"]').click(); + await page.locator('[data-preset="between"]').click(); + await page.locator("#start-date").fill("2000-02-29"); + await page.locator("#end-date").fill("2001-02-28"); + await expect(page.locator("#span")).toHaveText("1 year"); + await expect(page.locator(".time-total").filter({ hasText: "Days" }).locator("code")).toHaveText( + "365" + ); + await expect(page.locator("#dur-assume")).toContainText("From: 00:00 assumed."); + // A day across the clock change is a calendar day, but 23 hours elapsed. + await page.locator("#start-date").fill("2026-03-28"); + await page.locator("#start-time").fill("12:00"); + await page.locator("#end-date").fill("2026-03-29"); + await page.locator("#end-time").fill("12:00"); + await expect(page.locator("#span")).toHaveText("1 day"); + await expect(page.locator(".time-total").filter({ hasText: "Hours" }).locator("code")).toHaveText( + "23" + ); + await expect(page.locator(".time-total").filter({ hasText: "Days" }).locator("code")).toHaveText( + "0" + ); + await page.locator('[data-rounding="nearest"]').click(); + await expect(page.locator(".time-total").filter({ hasText: "Days" }).locator("code")).toHaveText( + "1" + ); + + await page.locator('[data-preset="age"]').click(); + await page.locator("#start-date").fill("1990-05-20"); + await page.locator("#start-time").fill(""); + await expect(page.locator("#span")).toHaveText(/^\d+ years, /); + await expect(page.locator("#span")).not.toContainText("hour"); + await expect(page.locator("#span-extra")).toContainText(/Turns \d+/); + await expect(page.locator("#end-date")).toBeDisabled(); +}); + +test("JWT opens Time on the token's time claims", async ({ page }) => { + await page.goto("/Tools/jwt/"); + const part = (value: object) => Buffer.from(JSON.stringify(value)).toString("base64url"); + const token = + part({ alg: "HS256", typ: "JWT" }) + + "." + + part({ sub: "x", iat: 1760260500, exp: 1760264100 }) + + ".c2ln"; + await page.getByRole("textbox", { name: "JSON Web Token", exact: true }).fill(token); + await page.locator("#to-time").click(); + await expect(page).toHaveURL(/\/time\/$/); + await expect(page.locator("#batch tbody tr")).toHaveCount(2); + await expect(page.locator("#batch tbody tr").first()).toContainText("exp"); + await expect(page.locator("#batch tbody tr").first()).toContainText("2025-10-12T10:15:00Z"); +}); + +test("Time's views have no automated WCAG A/AA violations in either theme", async ({ page }) => { + await open(page, PEOPLE, { rangeFrom: "2026-10-12", rangeTo: "2026-10-13" }); + for (const view of ["convert", "meet", "duration"]) { + await page.locator(`[data-view="${view}"]`).click(); + for (const theme of ["dark", "light"]) { + await page.evaluate((value) => { + document.documentElement.dataset.theme = value; + }, theme); + const scan = await new AxeBuilder({ page }) + .withTags(["wcag2a", "wcag2aa", "wcag21a", "wcag21aa"]) + .analyze(); + expect( + scan.violations.map(({ id, nodes }) => ({ id, targets: nodes.map((n) => n.target) })), + `${view} in ${theme}` + ).toEqual([]); + } + } + await page.setViewportSize({ width: 320, height: 800 }); + await page.locator("#view-select").selectOption("meet"); + await expect(page.locator("#view-meet")).toBeVisible(); + expect(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth)).toBe(true); +}); From bb6cc0286225e17b79d7dced49b58497481a8e4a Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Tue, 13 Oct 2026 21:58:09 +0200 Subject: [PATCH 02/10] Fix Find a time on clock-change nights, and refuse plans from a newer Time - A window is now read one offset at a time (offsetSpans in zone.ts). In an hour the clocks repeat, a window is there twice with the gap between left out, rather than one stretch from its first start to its last end, which let a call run through time nobody marked. In a skipped hour, only the part that does not exist is dropped, not the whole window. - The whole call ends within the dates searched. - No suggestions while someone's zone is unknown: times found without them read as times that suit everyone. - A saved plan or share link with a higher version than this page knows is not read as this one; saved people are left untouched and the page says so. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/time/index.html | 9 ++--- apps/time/src/lib/availability.test.ts | 50 +++++++++++++++++++++++++- apps/time/src/lib/availability.ts | 40 ++++++++++++++------- apps/time/src/lib/plan.ts | 12 +++++-- apps/time/src/lib/time.test.ts | 18 +++++++++- apps/time/src/lib/zone.ts | 38 +++++++++++++++++++- apps/time/src/meet.ts | 44 ++++++++++++++++++----- docs/apps/time.md | 12 +++++-- 8 files changed, 189 insertions(+), 34 deletions(-) diff --git a/apps/time/index.html b/apps/time/index.html index 8b51622..8f43bee 100644 --- a/apps/time/index.html +++ b/apps/time/index.html @@ -532,10 +532,11 @@ <h3>Find a time</h3> start on that date; with no windows, the person is unavailable that day. </li> <li> - The whole call must fit. Times that are preferred by everyone come first. Compromises - then rank by the most minutes any one person spends outside their preferred hours, then - by everyone's total, then the earliest. A run of starts that cost the same is one - suggestion. + The whole call must fit, within the dates searched. On a night the clocks change, a + window in a repeated hour counts both times, and a skipped hour does not exist. Times + that are preferred by everyone come first. Compromises then rank by the most minutes any + one person spends outside their preferred hours, then by everyone's total, then the + earliest. A run of starts that cost the same is one suggestion. </li> <li> Unavailable time is never used unless that person's "Allow unavailable time" is on; diff --git a/apps/time/src/lib/availability.test.ts b/apps/time/src/lib/availability.test.ts index 3d222a5..2ee9522 100644 --- a/apps/time/src/lib/availability.test.ts +++ b/apps/time/src/lib/availability.test.ts @@ -9,7 +9,7 @@ import { summary, } from "./availability.js"; import type { Person, Search } from "./availability.js"; -import { readPlan, sharePlan, writePlan } from "./plan.js"; +import { isNewerPlan, PLAN_VERSION, readPlan, sharePlan, writePlan } from "./plan.js"; const utc = (iso: string) => Date.parse(iso); const hours = (h: number, m = 0) => h * 60 + m; @@ -45,6 +45,37 @@ describe("segments", () => { expect(before.find((s) => s.level === "preferred")!.start).toBe(utc("2026-10-24T16:00:00Z")); expect(after.find((s) => s.level === "preferred")!.start).toBe(utc("2026-10-26T17:00:00Z")); }); + test("a window in an hour the clocks repeat is there twice, not once from first to last", () => { + // Paris goes back from 03:00 to 02:00 on 25 October 2026: 02:30 is 00:30Z, then 01:30Z. + const paris = person("Ana", "Europe/Paris", [ + { days: EVERY_DAY, start: hours(2, 30), end: hours(2, 45), level: "preferred" }, + ]); + const from = utc("2026-10-25T00:00:00Z"); + const preferred = personSegments(paris, from, utc("2026-10-25T03:00:00Z")).filter( + (s) => s.level === "preferred" + ); + expect(preferred).toEqual([ + { start: utc("2026-10-25T00:30:00Z"), end: utc("2026-10-25T00:45:00Z"), level: "preferred" }, + { start: utc("2026-10-25T01:30:00Z"), end: utc("2026-10-25T01:45:00Z"), level: "preferred" }, + ]); + const anyone = person("Al", "UTC", [{ days: EVERY_DAY, start: 0, end: 0, level: "preferred" }]); + const search = { from, to: utc("2026-10-25T03:00:00Z"), duration: 30, step: 15 }; + expect(findTimes([paris, anyone], search)).toEqual([]); + }); + test("the part of a window in an hour the clocks skip is left out, and the rest kept", () => { + // Paris goes forward from 02:00 to 03:00 on 29 March 2026: 03:00 is 01:00Z. + const paris = person("Ana", "Europe/Paris", [ + { days: EVERY_DAY, start: hours(2, 30), end: hours(3, 30), level: "preferred" }, + ]); + const preferred = personSegments( + paris, + utc("2026-03-29T00:00:00Z"), + utc("2026-03-29T03:00:00Z") + ).filter((s) => s.level === "preferred"); + expect(preferred).toEqual([ + { start: utc("2026-03-29T01:00:00Z"), end: utc("2026-03-29T01:30:00Z"), level: "preferred" }, + ]); + }); test("windows past midnight run into the next day; unavailable wins where windows overlap", () => { const owl = person("Owl", "UTC", [ { days: [5], start: hours(22), end: hours(2), level: "preferred" }, @@ -131,6 +162,17 @@ describe("finding a time", () => { expect(explain([ana, ben], found[0].costs)).toBe("Everyone: preferred hours."); }); + test("the whole call ends within the search", () => { + const late = person("Lee", "UTC", [ + { days: EVERY_DAY, start: hours(22), end: hours(2), level: "preferred" }, + ]); + const found = findTimes([late], day); + expect(found.map((s) => [s.start, s.last])).toEqual([ + [utc("2026-10-12T00:00:00Z"), utc("2026-10-12T01:00:00Z")], + [utc("2026-10-12T22:00:00Z"), utc("2026-10-12T23:00:00Z")], + ]); + }); + test("compromises minimise the worst person's minutes before the total", () => { const tight = { ...ana, @@ -287,4 +329,10 @@ describe("saved plans", () => { expect(JSON.stringify(shared)).not.toContain('"id"'); expect(readPlan(shared)!.people[0]).toMatchObject({ name: "Ana", zone: "UTC" }); }); + test("a plan from a later version is not read as this one", () => { + const later = { version: PLAN_VERSION + 1, people: [{ name: "Ana", zone: "UTC" }] }; + expect(isNewerPlan(later)).toBe(true); + expect(readPlan(later)).toBeUndefined(); + expect(isNewerPlan(writePlan({ people: [], duration: 30 }))).toBe(false); + }); }); diff --git a/apps/time/src/lib/availability.ts b/apps/time/src/lib/availability.ts index 08261fb..83e5a19 100644 --- a/apps/time/src/lib/availability.ts +++ b/apps/time/src/lib/availability.ts @@ -9,13 +9,23 @@ * ends where it starts lasts the whole day. * * Windows are read on each person's wall clock, date by date, so an evening window follows that - * person's daylight-saving changes rather than a frozen UTC offset. + * person's daylight-saving changes rather than a frozen UTC offset. A window covers the real time + * when that clock reads inside it: both passes of an hour the clocks repeat, and nothing of an + * hour they skip. */ -import { atMinutes, civilFromDays, dayNumber, formatDate, MINUTE, weekday } from "./civil.js"; +import { + atMinutes, + civilFromDays, + dayNumber, + formatDate, + MINUTE, + wallMs, + weekday, +} from "./civil.js"; import { clockTime, formatMinutes, shortDate } from "./format.js"; import type { ReadableOptions } from "./format.js"; -import { instantOf, wallAt } from "./zone.js"; +import { offsetSpans, wallAt } from "./zone.js"; export type Level = "preferred" | "acceptable" | "unavailable"; export const LEVELS: readonly Level[] = ["preferred", "acceptable", "unavailable"]; @@ -75,19 +85,23 @@ export function windowsOn(person: Person, date: string, isoWeekday: number): Tim */ export function personSegments(person: Person, from: number, to: number): Segment[] { const raw: Segment[] = []; + // A window covers the instants whose wall clock reads inside it, taken one offset at a time: + // where the clocks go back, a window in the repeated hour is there twice, with the time between + // left out; where they go forward, the part of a window in the skipped hour does not exist. + const spans = offsetSpans(from, to, person.zone); // A window from the evening before can still be running at `from`. const first = dayNumber(wallAt(from, person.zone)) - 1; const last = dayNumber(wallAt(to, person.zone)); for (let day = first; day <= last; day++) { const date = civilFromDays(day); for (const window of windowsOn(person, formatDate(date), weekday(date))) { - const start = instantOf(atMinutes(date, window.start), person.zone, "earlier"); - const end = instantOf( - atMinutes(date, window.start + windowLength(window)), - person.zone, - "later" - ); - if (end > from && start < to && end > start) raw.push({ start, end, level: window.level }); + const wallStart = wallMs(atMinutes(date, window.start)); + const wallEnd = wallStart + windowLength(window) * MINUTE; + for (const span of spans) { + const start = Math.max(span.start, wallStart - span.offset); + const end = Math.min(span.end, wallEnd - span.offset); + if (end > start) raw.push({ start, end, level: window.level }); + } } } const edges = [...new Set([from, to, ...raw.flatMap((s) => [s.start, s.end])])] @@ -140,7 +154,7 @@ export function costOf(segments: Segment[], start: number, end: number): Cost { export interface Search { /** The first start considered. */ from: number; - /** Starts are before this; the call may run past it. */ + /** The end of the search: the whole call ends by then. */ to: number; /** Call length in minutes. */ duration: number; @@ -191,10 +205,10 @@ export function findTimes(people: Person[], search: Search, segments?: Segment[] if (!people.length) return []; const length = search.duration * MINUTE; const step = search.step * MINUTE; - const all = segments ?? people.map((p) => personSegments(p, search.from, search.to + length)); + const all = segments ?? people.map((p) => personSegments(p, search.from, search.to)); const found: Suggestion[] = []; let open: Suggestion | undefined; - for (let start = search.from; start < search.to; start += step) { + for (let start = search.from; start + length <= search.to; start += step) { const costs = all.map((s) => costOf(s, start, start + length)); const fits = costs.every((c, i) => people[i].relaxed || c.unavailable === 0); if (!fits) { diff --git a/apps/time/src/lib/plan.ts b/apps/time/src/lib/plan.ts index bfee7f3..503c609 100644 --- a/apps/time/src/lib/plan.ts +++ b/apps/time/src/lib/plan.ts @@ -2,7 +2,8 @@ * The people and patterns Find a time keeps, as stored in the browser and carried in a share * link. This is a saved format: it is read field by field, keeping what is valid and giving a * default for anything missing or unknown, so what someone saved still opens after a change. - * `version` is for a later change that cannot be read that way. + * `version` is for a later change that cannot be read that way. A plan with a higher version than + * this page knows was written by a later Time: it is not read, and the page says so. */ import { parseDate } from "./civil.js"; @@ -79,9 +80,14 @@ function readPerson(raw: unknown, ids: Set<string>): Person | undefined { }; } -/** A plan from whatever was stored or shared; undefined when it is not one at all. */ +/** Whether a later Time wrote this, in a form this one does not know. */ +export function isNewerPlan(raw: unknown): boolean { + return isRecord(raw) && typeof raw.version === "number" && raw.version > PLAN_VERSION; +} + +/** A plan from whatever was stored or shared; undefined when it is not one this page can read. */ export function readPlan(raw: unknown): Plan | undefined { - if (!isRecord(raw) || !Array.isArray(raw.people)) return undefined; + if (!isRecord(raw) || !Array.isArray(raw.people) || isNewerPlan(raw)) return undefined; const ids = new Set<string>(); const people = raw.people .slice(0, MAX_PEOPLE) diff --git a/apps/time/src/lib/time.test.ts b/apps/time/src/lib/time.test.ts index 34b7abf..4aac1f4 100644 --- a/apps/time/src/lib/time.test.ts +++ b/apps/time/src/lib/time.test.ts @@ -1,6 +1,8 @@ import { describe, expect, test } from "bun:test"; import { addMonths, + DAY, + HOUR, civilFromDays, daysFromCivil, parseClock, @@ -11,7 +13,7 @@ import { import { calendarSpan, describeSpan, nextAnniversary, totalIn } from "./duration.js"; import { formatOffset, httpDate, isoString, relativeTime } from "./format.js"; import { detectUnit, parseTime } from "./parse.js"; -import { canonicalZone, findZone, offsetAt, resolveWall, wallAt } from "./zone.js"; +import { canonicalZone, findZone, offsetAt, offsetSpans, resolveWall, wallAt } from "./zone.js"; const wall = (iso: string) => { const p = parseTime(iso); @@ -111,6 +113,20 @@ describe("zones", () => { ms: utc("2026-10-12T14:30:00Z"), }); }); + test("a stretch of time is cut where the offset changes, to the millisecond", () => { + // Lord Howe Island moves its clocks by half an hour. + expect( + offsetSpans(utc("2026-04-04T00:00:00Z"), utc("2026-04-05T00:00:00Z"), "Australia/Lord_Howe") + ).toEqual([ + { start: utc("2026-04-04T00:00:00Z"), end: utc("2026-04-04T15:00:00Z"), offset: 11 * HOUR }, + { + start: utc("2026-04-04T15:00:00Z"), + end: utc("2026-04-05T00:00:00Z"), + offset: 10.5 * HOUR, + }, + ]); + expect(offsetSpans(0, DAY, "UTC")).toEqual([{ start: 0, end: DAY, offset: 0 }]); + }); test("years before 1 are counted with year 0", () => { expect(wallAt(utc("0000-06-01T00:00:00Z"), "UTC").year).toBe(0); expect(wallAt(Date.UTC(-1, 5, 1) + 0, "Europe/London").year).toBe(-1); diff --git a/apps/time/src/lib/zone.ts b/apps/time/src/lib/zone.ts index b8c1985..b690a31 100644 --- a/apps/time/src/lib/zone.ts +++ b/apps/time/src/lib/zone.ts @@ -4,7 +4,7 @@ * and the zone's history, so every question here is asked at an instant. */ -import { DAY, wallFromMs, wallMs } from "./civil.js"; +import { DAY, HOUR, wallFromMs, wallMs } from "./civil.js"; import type { WallTime } from "./civil.js"; export const UTC = "UTC"; @@ -126,6 +126,42 @@ export function offsetAt(ms: number, zone: string): number { return zone === UTC ? 0 : wallMs(wallAt(ms, zone)) - ms; } +/** A stretch of real time over which a zone's clocks keep one offset. */ +export interface OffsetSpan { + start: number; + end: number; + offset: number; +} + +/** + * `from` to `to` cut where the zone's offset changes, in order. Zones change offset a few times + * a year at most, so an hourly look finds every change, and a halving search finds its + * millisecond. + */ +export function offsetSpans(from: number, to: number, zone: string): OffsetSpan[] { + const spans: OffsetSpan[] = []; + let start = from; + let offset = offsetAt(from, zone); + let cursor = from; + while (zone !== UTC && cursor < to) { + const next = Math.min(cursor + HOUR, to); + if (offsetAt(next, zone) === offset) { + cursor = next; + continue; + } + let [low, high] = [cursor, next]; + while (high - low > 1) { + const mid = Math.floor((low + high) / 2); + if (offsetAt(mid, zone) === offset) low = mid; + else high = mid; + } + spans.push({ start, end: high, offset }); + [start, cursor, offset] = [high, high, offsetAt(high, zone)]; + } + spans.push({ start, end: to, offset }); + return spans.filter((span) => span.end > span.start); +} + /** * A wall time read in a zone. Where the clocks go back, the time happens twice (`ambiguous`); * where they go forward, it never happens (`gap`), and `ms` is the moment the clock shows that diff --git a/apps/time/src/meet.ts b/apps/time/src/meet.ts index 9f18766..45f7373 100644 --- a/apps/time/src/meet.ts +++ b/apps/time/src/meet.ts @@ -45,7 +45,14 @@ import { } from "./lib/civil.js"; import type { PlainDate } from "./lib/civil.js"; import { clockTime, formatMinutes, shortDate, utcOffset } from "./lib/format.js"; -import { DEFAULT_DURATION, newPerson, readPlan, sharePlan, writePlan } from "./lib/plan.js"; +import { + DEFAULT_DURATION, + isNewerPlan, + newPerson, + readPlan, + sharePlan, + writePlan, +} from "./lib/plan.js"; import type { Plan } from "./lib/plan.js"; import { canonicalZone, instantOf, localZone, offsetAt, wallAt } from "./lib/zone.js"; import { readable } from "./prefs.js"; @@ -90,14 +97,16 @@ export async function encodeShare(plan: Plan): Promise<string> { return "z" + toBase64(new Uint8Array(await new Response(stream).arrayBuffer()), true); } -export async function decodeShare(value: string): Promise<Plan | undefined> { +/** The plan in a link; "newer" when a later Time made it, undefined when it cannot be read. */ +export async function decodeShare(value: string): Promise<Plan | "newer" | undefined> { try { let bytes: Uint8Array<ArrayBuffer> = fromBase64(value.slice(1), true); if (value[0] === "z") { const stream = new Blob([bytes]).stream().pipeThrough(new DecompressionStream("deflate-raw")); bytes = new Uint8Array(await new Response(stream).arrayBuffer()); } else if (value[0] !== "j") return undefined; - return readPlan(JSON.parse(textFromBytes(bytes))); + const raw: unknown = JSON.parse(textFromBytes(bytes)); + return isNewerPlan(raw) ? "newer" : readPlan(raw); } catch { return undefined; } @@ -105,7 +114,10 @@ export async function decodeShare(value: string): Promise<Plan | undefined> { export function meetView(app: App): View { const saved = app.saved; - const stored = readPlan(readStored(PLAN_KEY)); + const raw = readStored(PLAN_KEY); + // People a later Time saved stay as they are: this page neither reads nor replaces them. + const newer = isNewerPlan(raw); + const stored = readPlan(raw); let plan: Plan = stored ?? examplePlan(); let pending: Plan | undefined; /** People whose card is folded. */ @@ -139,8 +151,14 @@ export function meetView(app: App): View { rangeTo.value = text(saved.rangeTo) || formatDate(addDays(today, 6)); function savePlan(): void { - writeStored(PLAN_KEY, writePlan(plan)); + if (!newer) writeStored(PLAN_KEY, writePlan(plan)); } + if (newer) + showMessage( + byId("people-note"), + "These people were saved by a newer version of Time. Reload the page to see them; changes made here are not kept.", + true + ); function setLength(minutes: number): void { if (![...length.options].some((o) => o.value === String(minutes))) @@ -633,10 +651,14 @@ export function meetView(app: App): View { const broken = plan.people.filter((p) => !people.includes(p)).map((p) => p.name || "Unnamed"); showMessage( note, - problem ?? (broken.length ? `Unknown time zone for ${broken.join(", ")}: left out.` : ""), + problem ?? + (broken.length + ? `Unknown time zone for ${broken.join(", ")}: choose one to see times for everyone.` + : ""), Boolean(problem || broken.length) ); - if (!search || !people.length) { + // Times found without someone would read as times that suit everyone. + if (!search || !people.length || broken.length) { suggestions = []; byId("diagnosis").hidden = true; showMessage(byId("suggestions-note"), people.length ? "" : "Add people to compare."); @@ -922,7 +944,13 @@ export function meetView(app: App): View { // The link has been read: a reload must not ask again. history.replaceState(null, "", location.pathname + location.search); void decodeShare(value).then((shared) => { - if (shared?.people.length) pending = shared; + if (shared === "newer") + showMessage( + byId("people-note"), + "That link was made by a newer version of Time. Reload the page, then open the link again.", + true + ); + else if (shared?.people.length) pending = shared; else showMessage(byId("people-note"), "That link holds no people that can be read.", true); showSnapshot(); }); diff --git a/docs/apps/time.md b/docs/apps/time.md index bde9c30..30337f6 100644 --- a/docs/apps/time.md +++ b/docs/apps/time.md @@ -49,9 +49,13 @@ prefers weekday evenings and weekend days; the first visit opens on three exampl The search covers up to 31 days, from midnight to midnight in the zone the results are shown in, for a call of 15 minutes to 3 hours. Windows are resolved date by date in each person's zone, so a -recurring evening follows that person's daylight-saving changes. Starts are a quarter hour apart, -and the whole call must fit. A run of starts that cost everyone the same is one suggestion ("or -start any time until 20:00"). +recurring evening follows that person's daylight-saving changes. A window covers the real time when +that person's clock reads inside it: where the clocks go back, a window in the repeated hour is +there twice and the time between is not; where they go forward, the part in the skipped hour does +not exist. Starts are a quarter hour apart, and the whole call must fit, both in everyone's windows +and before the end of the last day. A run of starts that cost everyone the same is one suggestion +("or start any time until 20:00"). While anyone's zone is unknown, no times are suggested: they +would read as times that suit everyone. Ranking: a person's cost is the minutes of the call outside their preferred windows. Times that need nobody's unavailable time come first; then the fewest unavailable minutes any one person @@ -75,6 +79,8 @@ with `deflate-raw` where the browser can, in Base64url after `#plan=`. Nothing i server. Opening one asks whether to replace the saved people, add to them, or dismiss it, and says it is a snapshot of what was entered, not live answers. The saved people (`tools.time.plan`) and the share link are one format, read field by field, and versioned for a change that cannot be. +A plan or link from a later version of Time is not read as this one: the page says so, and leaves +the saved people as they are. ## Duration From fb09c97de36f4cc395984fcd97897e412dc8d7da Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Tue, 13 Oct 2026 22:00:31 +0200 Subject: [PATCH 03/10] Keep every millisecond of a Unix time, and show everyone's time on each suggestion - Unix input is read from its digits: 1.001 seconds was 1000 ms after floating-point multiplication. Digits past milliseconds are now noted for Unix times too, as they already were for ISO dates. - Each Find a time suggestion lists everyone's own clock at the start, with their date where it differs from the one shown. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/time/src/convert.ts | 4 +++- apps/time/src/lib/parse.ts | 27 +++++++++++++++++++-------- apps/time/src/lib/time.test.ts | 14 ++++++++++++++ apps/time/src/meet.ts | 14 ++++++++++++++ apps/time/src/styles.css | 3 +++ docs/apps/time.md | 3 ++- 6 files changed, 55 insertions(+), 10 deletions(-) diff --git a/apps/time/src/convert.ts b/apps/time/src/convert.ts index 90b17d7..ccfd98f 100644 --- a/apps/time/src/convert.ts +++ b/apps/time/src/convert.ts @@ -189,7 +189,9 @@ export function convertView(app: App): View { case "unix": return { ms: parsed.ms, - note: `Unix ${parsed.unit === "s" ? "seconds" : "milliseconds"}${parsed.detected ? ", by its size" : ""}.`, + note: + `Unix ${parsed.unit === "s" ? "seconds" : "milliseconds"}${parsed.detected ? ", by its size" : ""}.` + + (parsed.dropped ? " Digits past milliseconds dropped." : ""), }; case "instant": return { diff --git a/apps/time/src/lib/parse.ts b/apps/time/src/lib/parse.ts index 3cd9028..9b2f208 100644 --- a/apps/time/src/lib/parse.ts +++ b/apps/time/src/lib/parse.ts @@ -15,7 +15,7 @@ export const MAX_MS = 8.64e15; export type Parsed = /** A Unix timestamp, in the unit given or, failing that, the one its size suggests. */ - | { kind: "unix"; ms: number; unit: Unit; detected: boolean } + | { kind: "unix"; ms: number; unit: Unit; detected: boolean; dropped: boolean } /** A date and time with an offset: one instant. */ | { kind: "instant"; ms: number; offset: number; zone?: string; dropped?: boolean } /** A date, or a date and time, with no offset: a wall time in a zone still to be chosen. */ @@ -111,13 +111,24 @@ export function parseTime(input: string, unit?: Unit): Parsed { const text = input.trim(); if (!text) return { kind: "error", message: "Empty." }; - if (/^[+-]?\d+(\.\d+)?$/.test(text)) { - const value = Number(text); - const chosen = unit ?? detectUnit(value); - const ms = chosen === "s" ? value * 1000 : value; - if (Math.abs(ms) > MAX_MS) return { kind: "error", message: "Out of range for a date." }; - // Below a millisecond is beyond what a Date holds. - return { kind: "unix", ms: Math.trunc(ms), unit: chosen, detected: unit === undefined }; + const number = /^([+-]?)(\d+)(?:\.(\d+))?$/.exec(text); + if (number) { + const [, sign, whole, fraction = ""] = number; + const chosen = unit ?? detectUnit(Number(text)); + // Milliseconds from the digits themselves: 1.001 seconds times 1000 is 1000.9999… in + // floating point. Below a millisecond is beyond what a Date holds. + const kept = chosen === "s" ? 3 : 0; + const size = + Number(whole) * (chosen === "s" ? 1000 : 1) + + Number(fraction.slice(0, kept).padEnd(kept, "0") || 0); + if (size > MAX_MS) return { kind: "error", message: "Out of range for a date." }; + return { + kind: "unix", + ms: sign === "-" && size ? -size : size, + unit: chosen, + detected: unit === undefined, + dropped: /[1-9]/.test(fraction.slice(kept)), + }; } const iso = ISO.exec(text) ?? ISO_BASIC.exec(text); diff --git a/apps/time/src/lib/time.test.ts b/apps/time/src/lib/time.test.ts index 4aac1f4..0e1552f 100644 --- a/apps/time/src/lib/time.test.ts +++ b/apps/time/src/lib/time.test.ts @@ -140,12 +140,14 @@ describe("parsing", () => { ms: 1760264100000, unit: "s", detected: true, + dropped: false, }); expect(parseTime("1760264100000")).toEqual({ kind: "unix", ms: 1760264100000, unit: "ms", detected: true, + dropped: false, }); expect(parseTime("1760264100000000", "s")).toEqual({ kind: "error", @@ -156,10 +158,22 @@ describe("parsing", () => { ms: 1760264100, unit: "ms", detected: false, + dropped: false, }); expect(parseTime("-1.5")).toMatchObject({ kind: "unix", ms: -1500 }); expect(detectUnit(99_999_999_999)).toBe("s"); }); + test("a Unix time keeps every millisecond it is given, and says when it drops more", () => { + // In floating point, 1.001 × 1000 is 1000.9999… + for (let i = 0; i < 1000; i++) { + const text = `1.${String(i).padStart(3, "0")}`; + expect(parseTime(text)).toMatchObject({ ms: 1000 + i, dropped: false }); + expect(parseTime(`-${text}`)).toMatchObject({ ms: -1000 - i }); + } + expect(parseTime("1760264100.1234567")).toMatchObject({ ms: 1760264100123, dropped: true }); + expect(parseTime("1760264100123.5", "ms")).toMatchObject({ ms: 1760264100123, dropped: true }); + expect(parseTime("-0.0004")).toMatchObject({ ms: 0, dropped: true }); + }); test("ISO 8601 with an offset is one instant", () => { expect(parseTime("2026-10-12T10:15:00Z")).toMatchObject({ kind: "instant", diff --git a/apps/time/src/meet.ts b/apps/time/src/meet.ts index 45f7373..97214c8 100644 --- a/apps/time/src/meet.ts +++ b/apps/time/src/meet.ts @@ -581,6 +581,20 @@ export function meetView(app: App): View { { class: "time-suggestion-range" }, `or start any time until ${clockTime(s.last, zone(), options)}` ), + // Everyone's own clock at the start, and their date where it is not the one above. + h( + "span", + { class: "time-suggestion-local" }, + people + .map((p) => { + const theirDay = shortDate(s.start, p.zone); + return ( + `${p.name || "Unnamed"} ${clockTime(s.start, p.zone, options)}` + + (theirDay === startDay ? "" : ` (${theirDay})`) + ); + }) + .join(" · ") + ), h("span", { class: "time-suggestion-why" }, explain(people, s.costs)) ); button.addEventListener("click", () => pick(s.start)); diff --git a/apps/time/src/styles.css b/apps/time/src/styles.css index 6986b62..e4814a5 100644 --- a/apps/time/src/styles.css +++ b/apps/time/src/styles.css @@ -366,6 +366,9 @@ color: var(--muted); font-size: 12px; } +.time-suggestion-local { + font-size: 13px; +} #more { margin-top: 8px; } diff --git a/docs/apps/time.md b/docs/apps/time.md index 30337f6..cf4cb49 100644 --- a/docs/apps/time.md +++ b/docs/apps/time.md @@ -61,7 +61,8 @@ Ranking: a person's cost is the minutes of the call outside their preferred wind need nobody's unavailable time come first; then the fewest unavailable minutes any one person gives up, and in total; then the most minutes any one person spends outside preferred hours, so nobody bears a large compromise to spare the group a small one; then the group's total; then the -earliest. Each suggestion says why: "Sam: 1 h outside preferred hours; everyone else: preferred." +earliest. Each suggestion gives everyone's own clock at the start, with their date where it is +not the one shown, and says why: "Sam: 1 h outside preferred hours; everyone else: preferred." Unavailable time is used only for someone whose "Allow unavailable time" is on. When nothing fits, the search names anyone with no stretch long enough of their own, and each person without whom the From 92130cf17e76e108df365183dc16c18b6aee6e1d Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Tue, 13 Oct 2026 22:46:37 +0200 Subject: [PATCH 04/10] Use the 24-hour clock everywhere, and act on the Time review 24-hour clock, in every app: - Times are formatted with hourCycle h23 whatever the browser's language (Time, JWT's claim dates, the SVG app's document list). - Times of day are typed .ui-clock fields (@tools/ui clock.ts: parseClock, formatClock, bindClockFields), never native time or datetime-local fields, which follow the language into AM/PM. Time and Codes (an event's start and end, each a date and a time) use them; moved to the shared package now that a second app needs it. AGENTS.md says so. - Time's 12/24 setting and its Settings menu go; old saved prefs still read. The header was measured again: compactHeader 450, views to a dropdown below 363px. Find a time: a window is two lines, its remove button in the corner; a chevron shows a person folds; the example is two people in two zones with one window each, and a new person one window. Convert: Now writes the unit chosen; the date and time pickers update the input as they change, not once the picker closes. Duration: no presets, just From and To with Now (To starts on Now); the next-anniversary line serves any past date; Elapsed drops Weeks and says each line is the whole span in that unit; an unreadable time says so instead of counting from midnight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- AGENTS.md | 2 +- apps/codes/index.html | 51 ++++++++-- apps/codes/src/main.ts | 29 +++++- apps/codes/src/styles.css | 9 ++ apps/jwt/src/main.ts | 2 + apps/svg/src/lib/documents.ts | 8 +- apps/time/index.html | 129 ++++++++++--------------- apps/time/src/app.ts | 2 - apps/time/src/convert.ts | 50 ++++------ apps/time/src/duration-view.ts | 102 +++++++------------ apps/time/src/lib/availability.test.ts | 15 +-- apps/time/src/lib/civil.ts | 13 --- apps/time/src/lib/duration.ts | 1 - apps/time/src/lib/format.ts | 33 ++++--- apps/time/src/lib/plan.ts | 12 +-- apps/time/src/lib/time.test.ts | 6 -- apps/time/src/main.ts | 17 +--- apps/time/src/meet.ts | 98 +++++++------------ apps/time/src/prefs.ts | 18 +--- apps/time/src/resolution.ts | 10 +- apps/time/src/styles.css | 57 +++++++++-- docs/apps/codes.md | 3 +- docs/apps/time.md | 38 +++++--- packages/catalog/src/index.ts | 2 +- packages/ui/base.css | 13 +++ packages/ui/src/clock.test.ts | 22 +++++ packages/ui/src/clock.ts | 71 ++++++++++++++ packages/ui/src/index.ts | 1 + tests/browser/developer-tools.spec.ts | 6 +- tests/browser/time.spec.ts | 53 +++++++++- 30 files changed, 506 insertions(+), 367 deletions(-) create mode 100644 packages/ui/src/clock.test.ts create mode 100644 packages/ui/src/clock.ts diff --git a/AGENTS.md b/AGENTS.md index d836c4c..fe46915 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ - **App wiring:** an app's `vite.config.ts` is `defineConfig(toolsApp("<slug>"))` from `@tools/ui/vite`, used to build it; the site's one dev server serves it (see [Running it](#running-it)). It sets the base path and output folder, and injects `<title>`, description, icon and manifest from the catalog — so an app's `index.html` must not set those itself (the build fails if it does). Each app keeps its own `public/icon.svg`. The page links its stylesheet in `index.html` (`<link rel="stylesheet" href="/src/styles.css">`, which `@import`s `@tools/ui/base.css`) rather than importing CSS from the script: a linked stylesheet holds the first paint, so the page never shows unstyled while the script loads under the dev server. - **Header:** every app's page has one `<header data-tools-header>` holding only its own controls. The build writes its start from the catalog — "‹ Tools", the app's name and, unless it is stable, its status badge — styled by `@tools/ui/header.css` (base.css imports it). Put the far-end controls (theme switch, Help) in `.ui-header-end`. The build fails if the marker is missing or the page writes its own home link or name. - **Light / dark:** every page follows the browser until someone presses a theme switch; from then on the choice (`tools.theme` in localStorage, shared by the whole site) is light or dark, never "system" again. The Vite plugin inlines a script that applies it before first paint. Colours are CSS custom properties with a dark base and a light override keyed on `prefers-color-scheme` and `<html data-theme>` (see `packages/ui/tokens.css`); an app adds a button and calls `bindThemeToggle(button)` from `@tools/ui`, and repaints anything drawn outside CSS (a canvas) on `THEME_EVENT`. -- **Shared UI:** icons, side panels, menus, colour pickers and SVG-to-PNG export come from `@tools/ui`, never drawn again in an app. An empty `<svg data-ui-icon="help">` (or sprite `<symbol data-ui-icon>`) is filled at build time from `icons.ts`; code uses `iconSvg`. Help and other side panels are `.ui-dock` with `bindDock` (open state kept per tab, placed under the header, full width on a phone); menus are `.ui-menu` popovers with `bindMenu` (styles in `packages/ui/panels.css`); colours are a `.color-swatch` with `bindColorSwatch`, which opens the one colour picker (hue, opacity, hex; colours as `#rrggbb` or `#rrggbbaa`, helpers in `color.ts`); a PNG of an SVG is `renderPng`. A setting that is on or off is a `.ui-switch` button (`role="switch"`, `bindSwitch`, `isOn` / `setOn`), never a checkbox; work that waits for typing to pause is `debounce`; a tab's draft is `readDraft` / `writeDraft` (versioned, kept per tab, `restoreFields` / `fieldValues` for form fields). In the text tools (`tool.css`), a card is `.ui-card` with its heading row `.ui-row`, a count or error beside the heading is `.ui-note`, and an editor holding data kept exactly is `.ui-editor.ui-data`. One app opens another on the same input by writing that app's draft for the tab and following a plain link (Codec ⇄ Digests, JWT → Time). +- **Shared UI:** icons, side panels, menus, colour pickers and SVG-to-PNG export come from `@tools/ui`, never drawn again in an app. An empty `<svg data-ui-icon="help">` (or sprite `<symbol data-ui-icon>`) is filled at build time from `icons.ts`; code uses `iconSvg`. Help and other side panels are `.ui-dock` with `bindDock` (open state kept per tab, placed under the header, full width on a phone); menus are `.ui-menu` popovers with `bindMenu` (styles in `packages/ui/panels.css`); colours are a `.color-swatch` with `bindColorSwatch`, which opens the one colour picker (hue, opacity, hex; colours as `#rrggbb` or `#rrggbbaa`, helpers in `color.ts`); a PNG of an SVG is `renderPng`. A setting that is on or off is a `.ui-switch` button (`role="switch"`, `bindSwitch`, `isOn` / `setOn`), never a checkbox; work that waits for typing to pause is `debounce`; a tab's draft is `readDraft` / `writeDraft` (versioned, kept per tab, `restoreFields` / `fieldValues` for form fields). A time of day is on the 24-hour clock in every app, whatever the browser's language: format it with `hourCycle: "h23"`, and take it in a `.ui-clock` field (`CLOCK_FIELD`, `bindClockFields`, `parseClock` / `formatClock` in `clock.ts`), never a native `time` or `datetime-local` field, which follows the language into AM/PM. In the text tools (`tool.css`), a card is `.ui-card` with its heading row `.ui-row`, a count or error beside the heading is `.ui-note`, and an editor holding data kept exactly is `.ui-editor.ui-data`. One app opens another on the same input by writing that app's draft for the tab and following a plain link (Codec ⇄ Digests, JWT → Time). - **Settings:** preferences for the whole app go in a Settings (cog) menu in the header; what belongs to the document being edited stays with the document (the SVG app's size, grid and background, in its Document panel). - **Phone header:** the header collapses in levels, each where the one before stops fitting (measure what the header needs; do not guess). First "‹ Tools Name" reduces to "‹", at the app's `compactHeader` width in the catalog (the build writes that rule; 720px by default); an app whose name in full needs the room may drop "Tools" alone before that, at its `compactHome`. Then the app's own steps (JSON: the views become a dropdown at 645px, then the phone layout at 475px; SVG: the phone layout at 590px). The phone layout is one row that never wraps. What does not fit folds into a "⋯" menu, Help stays last in the bar, and the theme switch stays in the bar at every width. Buttons keep their size and spacing at every width (the header's measures in `header.css`: `--ui-control`, `--ui-group-gap`, `--ui-header-gap`); a level only moves, hides or folds things. Layout and button sizes follow the width alone, never the pointer (`pointer: coarse`), so a touch screen gets the same layout as a mouse at the same width; only what happens on a canvas (hit targets, drag thresholds) and which help is shown adapt to touch. The header stays a solid row at the top in every app; an app whose work is a canvas may float its drawing tools in a bar over the bottom of it (the SVG app). Buttons that belong together are joined into one control with `.ui-seg` (`header.css`). - **Install and offline:** the site installs as one app — the index, with every tool inside its scope, so moving between tools never leaves the installed window. Every page links the one manifest at the site root (emitted by the index build; each listed app is a shortcut in it). Pages call `registerServiceWorker()` from `@tools/ui`, which registers the one worker at the site root; `scripts/build-site.ts` writes it last (`packages/ui/src/site-worker.ts` + `packages/ui/sw.js`) with a version hash and every file of every app to precache, so one visit makes all tools work offline and every deploy replaces the previous cache. Nothing is registered under the dev server. diff --git a/apps/codes/index.html b/apps/codes/index.html index a031392..ca4ee28 100644 --- a/apps/codes/index.html +++ b/apps/codes/index.html @@ -237,13 +237,50 @@ <h2 class="codes-step"><span aria-hidden="true">2</span>Content</h2> ><span>Title</span><input class="ui-input" id="ev-title" autocomplete="off" /></label> <div class="codes-pair"> - <label class="ui-field" - ><span>Starts</span><input class="ui-input" id="ev-start" type="datetime-local" - /></label> - <label class="ui-field" - ><span>Ends · an hour later if empty</span - ><input class="ui-input" id="ev-end" type="datetime-local" - /></label> + <div class="ui-field"> + <span id="ev-start-label">Starts</span> + <div class="codes-when"> + <input + class="ui-input" + id="ev-start-date" + type="date" + aria-labelledby="ev-start-label" + /> + <input + class="ui-input ui-clock" + id="ev-start-time" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="HH:MM" + maxlength="8" + aria-label="Start time, 24-hour" + /> + </div> + </div> + <div class="ui-field"> + <span id="ev-end-label">Ends · an hour later if empty</span> + <div class="codes-when"> + <input + class="ui-input" + id="ev-end-date" + type="date" + aria-labelledby="ev-end-label" + /> + <input + class="ui-input ui-clock" + id="ev-end-time" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="HH:MM" + maxlength="8" + aria-label="End time, 24-hour" + /> + </div> + </div> </div> <label class="ui-field" ><span>Location · optional</span diff --git a/apps/codes/src/main.ts b/apps/codes/src/main.ts index c6a708a..e6e3da9 100644 --- a/apps/codes/src/main.ts +++ b/apps/codes/src/main.ts @@ -1,6 +1,9 @@ import { + bindClockFields, bindColorSwatch, bindToolHelp, + formatClock, + parseClock, debounce, byId, contrast, @@ -116,8 +119,10 @@ const ids = [ "vc-country", "vc-note", "ev-title", - "ev-start", - "ev-end", + "ev-start-date", + "ev-start-time", + "ev-end-date", + "ev-end-time", "ev-location", "ev-description", "symbology", @@ -171,6 +176,16 @@ function showDesign(fields: Record<string, string | boolean>): void { const input = (id: string) => byId<HTMLInputElement>(id); const value = (id: string) => input(id).value; +/** + * A date field and a 24-hour time field as one moment, in the browser's time zone; an invalid + * Date, which the event reports, when either is missing or unreadable. + */ +function localTime(date: string, time: string): Date { + const clock = parseClock(time); + return date && clock + ? new Date(`${date}T${formatClock(clock.minutes, clock.second)}`) + : new Date(NaN); +} const checked = (id: string) => isOn(byId(id)); const oneOf = <T extends string>(options: readonly T[], candidate: string, fallback: T): T => options.includes(candidate as T) ? (candidate as T) : fallback; @@ -283,9 +298,11 @@ function content(kind: Exclude<ContentType, "barcode">): string { title: value("ev-title"), location: value("ev-location"), description: value("ev-description"), - // A datetime-local value is read in the browser's time zone. - start: new Date(value("ev-start")), - ...(value("ev-end") ? { end: new Date(value("ev-end")) } : {}), + start: localTime(value("ev-start-date"), value("ev-start-time")), + // An end time alone is on the day the event starts. + ...(value("ev-end-time").trim() + ? { end: localTime(value("ev-end-date") || value("ev-start-date"), value("ev-end-time")) } + : {}), }); } } @@ -642,6 +659,8 @@ function morph(shown: Element, next: Element): void { // ---------- Controls ---------- +bindClockFields(); + for (const id of ids) { const element = byId(id); if (element.getAttribute("role") === "switch") bindSwitch(element, update); diff --git a/apps/codes/src/styles.css b/apps/codes/src/styles.css index faa6cf8..a8e4b8b 100644 --- a/apps/codes/src/styles.css +++ b/apps/codes/src/styles.css @@ -94,6 +94,15 @@ grid-template-columns: minmax(0, 1fr); } } +/* A date and a time on one line. */ +.codes-when { + display: flex; + gap: 6px; +} +.codes-when input[type="date"] { + flex: 1; + min-width: 0; +} .codes .codes-short { min-height: 72px; } diff --git a/apps/jwt/src/main.ts b/apps/jwt/src/main.ts index 5d0ee43..546d823 100644 --- a/apps/jwt/src/main.ts +++ b/apps/jwt/src/main.ts @@ -249,6 +249,8 @@ const dateFormat = new Intl.DateTimeFormat(undefined, { hour: "2-digit", minute: "2-digit", second: "2-digit", + // On the 24-hour clock whatever the language, as across the site. + hourCycle: "h23", timeZoneName: "short", }); /** Says beside each known member what it means, with time claims as local dates. */ diff --git a/apps/svg/src/lib/documents.ts b/apps/svg/src/lib/documents.ts index 9a65dca..f722451 100644 --- a/apps/svg/src/lib/documents.ts +++ b/apps/svg/src/lib/documents.ts @@ -147,9 +147,11 @@ function renderDocList(): void { li.dataset.docId = d.id; const open = expandedDocs.has(d.id); li.className = `acc-item doc-row${isCurrent ? " current" : ""}${open ? " expanded" : ""}`; + // On the 24-hour clock whatever the language, as across the site. const when = new Date(d.updated).toLocaleString([], { dateStyle: "short", timeStyle: "short", + hourCycle: "h23", }); li.title = isCurrent ? `Open since ${when}` : `Open (last saved ${when})`; li.innerHTML = `<div class="acc-header-row"> @@ -211,7 +213,11 @@ async function fillStats(li: HTMLElement, d: DocumentMeta): Promise<void> { const content = li.querySelector<HTMLElement>(".doc-stats-content"); const file = li.querySelector<HTMLElement>(".doc-stats-file"); if (!content || !file) return; - const when = new Date(d.updated).toLocaleString([], { dateStyle: "medium", timeStyle: "short" }); + const when = new Date(d.updated).toLocaleString([], { + dateStyle: "medium", + timeStyle: "short", + hourCycle: "h23", + }); let details: Details; if (d.id === currentDoc.id) { details = detailsOf(getState()); diff --git a/apps/time/index.html b/apps/time/index.html index 8f43bee..e204bc0 100644 --- a/apps/time/index.html +++ b/apps/time/index.html @@ -25,33 +25,6 @@ </select> <svg data-ui-icon="chevron-down" aria-hidden="true"></svg> </div> - <button - type="button" - class="ui-btn ui-icon-btn" - id="settings-btn" - title="Settings" - aria-label="Settings" - > - <svg data-ui-icon="settings" aria-hidden="true"></svg> - </button> - <div class="ui-menu time-settings" id="settings" aria-label="Settings"> - <div class="ui-menu-label">Clock</div> - <div class="ui-menu-row"> - <span>Hours</span> - <div class="ui-seg" role="group" aria-label="Clock"> - <button type="button" class="ui-btn ui-btn--small" data-clock="24" aria-pressed="true"> - 24-hour</button - ><button - type="button" - class="ui-btn ui-btn--small" - data-clock="12" - aria-pressed="false" - > - 12-hour - </button> - </div> - </div> - </div> <div class="ui-header-end"> <button type="button" class="ui-btn ui-icon-btn ui-theme-toggle" id="theme-toggle"></button ><button @@ -131,10 +104,15 @@ <h2 id="input-title"><label for="input">Time</label></h2> aria-describedby="pick-label" /> <input - class="ui-input" - type="time" + class="ui-input ui-clock" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="HH:MM" + maxlength="8" id="pick-time" - step="1" + data-seconds aria-label="Time to convert" aria-describedby="pick-label" /> @@ -284,7 +262,16 @@ <h2 id="timeline-title">Timeline</h2> <div class="time-picked" id="picked"> <div class="time-option"> <label for="picked-time">Call at</label> - <input class="ui-input" type="time" id="picked-time" step="900" /> + <input + class="ui-input ui-clock" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="HH:MM" + maxlength="8" + id="picked-time" + /> <span class="ui-note" id="picked-zone"></span> <button type="button" class="ui-btn ui-btn--small" id="picked-copy" disabled> <svg data-ui-icon="copy"></svg>Copy summary @@ -302,41 +289,10 @@ <h2 id="timeline-title">Timeline</h2> <section class="ui-card" aria-labelledby="measure-title"> <div class="ui-row"> <h2 id="measure-title">Measure</h2> - <div class="ui-seg" role="group" aria-label="Preset"> - <button - type="button" - class="ui-btn ui-btn--small" - data-preset="age" - aria-pressed="true" - > - Age</button - ><button - type="button" - class="ui-btn ui-btn--small" - data-preset="since" - aria-pressed="false" - > - Since</button - ><button - type="button" - class="ui-btn ui-btn--small" - data-preset="until" - aria-pressed="false" - > - Until</button - ><button - type="button" - class="ui-btn ui-btn--small" - data-preset="between" - aria-pressed="false" - > - Between - </button> - </div> </div> <div class="dur-ends"> <fieldset class="dur-end"> - <legend id="start-legend">Born</legend> + <legend id="start-legend">From</legend> <div class="time-option"> <input class="ui-input" @@ -346,10 +302,15 @@ <h2 id="measure-title">Measure</h2> /> <span id="start-date-label" hidden>date</span> <input - class="ui-input" - type="time" + class="ui-input ui-clock" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="HH:MM" + maxlength="8" id="start-time" - step="1" + data-seconds aria-labelledby="start-legend start-time-label" /> <span id="start-time-label" hidden>time (optional)</span> @@ -366,7 +327,7 @@ <h2 id="measure-title">Measure</h2> </div> </fieldset> <fieldset class="dur-end"> - <legend id="end-legend">Until</legend> + <legend id="end-legend">To</legend> <div class="time-option"> <input class="ui-input" @@ -376,10 +337,15 @@ <h2 id="measure-title">Measure</h2> /> <span id="end-date-label" hidden>date</span> <input - class="ui-input" - type="time" + class="ui-input ui-clock" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="HH:MM" + maxlength="8" id="end-time" - step="1" + data-seconds aria-labelledby="end-legend end-time-label" /> <span id="end-time-label" hidden>time (optional)</span> @@ -444,7 +410,7 @@ <h2 id="span-title">Calendar</h2> <section class="ui-card" aria-labelledby="totals-title"> <div class="ui-row"> <h2 id="totals-title">Elapsed</h2> - <span class="ui-note">A day is 24 hours.</span> + <span class="ui-note">The whole span in each unit; a day is 24 hours.</span> <div class="ui-seg" role="group" aria-label="Rounding"> <button type="button" @@ -490,6 +456,10 @@ <h2 class="ui-dock-title">Help</h2> offset depends on the date, because of daylight-saving time. Type a zone's name or just its city. "Now" is this device's clock, as exact as its setting. </li> + <li> + Times are on the 24-hour clock. Type them as 14:30, or 1430 or 9:30; a field that cannot + be read is outlined in red. + </li> <li> What you type stays in this tab; reloading restores it. People and pinned zones are kept in this browser. Light / dark applies to every tool. @@ -529,7 +499,8 @@ <h3>Find a time</h3> </li> <li> Weekly windows apply on the days ticked. A date exception replaces the windows that - start on that date; with no windows, the person is unavailable that day. + start on that date; with no windows, the person is unavailable that day. The chevron + beside a name folds or opens that person. </li> <li> The whole call must fit, within the dates searched. On a night the clocks change, a @@ -556,18 +527,20 @@ <h3>Find a time</h3> <h3>Duration</h3> <ul> <li> - Age, Since, Until and Between set which end is now; either can be switched. A time is - optional: without one, 00:00 in the chosen zone is assumed, and the result says so. + From and To are each a date, with an optional time, or Now. To starts on Now: give a + birth date for an age, or the day you met. For a countdown, switch From to Now. Without + a time, 00:00 in the chosen zone is assumed, and the result says so. </li> <li> Calendar counts whole years, months and days on the wall clock, so a day of 23 or 25 hours at a clock change is still one day. A month too short for the day ends at its last - day: 29 February birthdays count on 28 February in other years. + day: 29 February birthdays count on 28 February in other years. From a past date to now, + it says when that date next comes round. </li> <li> - Elapsed counts exact time, a day being 24 hours. Completed counts only whole units; - Nearest rounds to the nearest whole unit. It counts live, every second, while one end is - now. + Elapsed gives the whole span in days, in hours, in minutes and in seconds, a day being + 24 hours. Completed counts only whole units; Nearest rounds to the nearest whole unit. + It counts live, every second, while one end is now. </li> </ul> </div> diff --git a/apps/time/src/app.ts b/apps/time/src/app.ts index 91dfb4e..4521cc7 100644 --- a/apps/time/src/app.ts +++ b/apps/time/src/app.ts @@ -14,8 +14,6 @@ export interface App { clipboardFailed(where: HTMLElement): void; /** Opens a view, as its header button does. */ show(view: "convert" | "meet" | "duration"): void; - /** Called when the clock setting changes, to draw times again. */ - onPrefs(listener: () => void): void; } /** A view: its draft fields, and what to do as it is shown and hidden. */ diff --git a/apps/time/src/convert.ts b/apps/time/src/convert.ts index ccfd98f..7a9c83e 100644 --- a/apps/time/src/convert.ts +++ b/apps/time/src/convert.ts @@ -1,7 +1,7 @@ /** Convert: a timestamp or date to every form and zone; several lines to a table. */ -import { byId, copyText, iconSvg, showMessage } from "@tools/ui"; -import { formatClock, formatDate, parseClock, parseDate } from "./lib/civil.js"; +import { byId, copyText, formatClock, iconSvg, parseClock, showMessage } from "@tools/ui"; +import { formatDate, parseDate } from "./lib/civil.js"; import type { WallTime } from "./lib/civil.js"; import { httpDate, @@ -14,7 +14,7 @@ import { import { parseTime } from "./lib/parse.js"; import type { Parsed, Unit } from "./lib/parse.js"; import { localZone, offsetAt, resolveWall, UTC, wallAt } from "./lib/zone.js"; -import { MAX_PINNED, readable } from "./prefs.js"; +import { MAX_PINNED } from "./prefs.js"; import { text } from "./app.js"; import type { App, View } from "./app.js"; import { copyButton, h, press } from "./dom.js"; @@ -40,9 +40,6 @@ function unixSeconds(ms: number): string { return sign + whole + (fraction ? "." + String(fraction).padStart(3, "0") : ""); } -const wallInput = (wall: WallTime) => - `${formatDate(wall)}T${formatClock(wall.hour * 60 + wall.minute)}:${String(wall.second).padStart(2, "0")}`; - export function convertView(app: App): View { const input = byId<HTMLTextAreaElement>("input"); const note = byId("input-note"); @@ -117,7 +114,7 @@ export function convertView(app: App): View { ) ), h("code", { class: "time-result-value" }, iso), - h("span", { class: "time-result-readable" }, readableDate(ms, zone, readable(app.prefs))), + h("span", { class: "time-result-readable" }, readableDate(ms, zone)), actions ); } @@ -203,17 +200,10 @@ export function convertView(app: App): View { }; case "wall": { const zone = parsed.zone ?? zoneField.zone; - const resolved = resolveShown( - parsed.wall, - zone, - readable(app.prefs), - choiceUi, - choice, - (next) => { - choice = next; - update(); - } - ); + const resolved = resolveShown(parsed.wall, zone, choiceUi, choice, (next) => { + choice = next; + update(); + }); const assumed = parsed.dateOnly ? `00:00 in ${zone} assumed.` : `Read in ${zone}.`; return { ms: resolved.ms, @@ -243,11 +233,13 @@ export function convertView(app: App): View { showMessage(note, (label ? label + ": " : "") + found.note, parsed.kind === "error"); if (found.ms === undefined) return clearResults(); showResults(found.ms, found.readIn); - // The pickers follow what was typed, in the zone things are read in. + // The pickers follow what was typed, in the zone things are read in; not the one being + // typed in, which would move the caret. const wall = found.wall ?? wallAt(found.ms, zoneField.zone); if (wall.year >= 1 && wall.year <= 9999) { - pickDate.value = formatDate(wall); - pickTime.value = wallInput(wall).slice(11); + if (document.activeElement !== pickDate) pickDate.value = formatDate(wall); + if (document.activeElement !== pickTime) + pickTime.value = formatClock(wall.hour * 60 + wall.minute, wall.second || undefined); } } @@ -333,17 +325,18 @@ export function convertView(app: App): View { const fromPicker = () => { const date = parseDate(pickDate.value); if (!date) return; - const clock = pickTime.value ? parseClock(pickTime.value) : 0; - if (clock === undefined) return; - const seconds = /:(\d{2}):(\d{2})/.exec(pickTime.value)?.[2] ?? "00"; - input.value = `${formatDate(date)}T${formatClock(clock)}:${seconds}`; + const clock = pickTime.value.trim() ? parseClock(pickTime.value) : { minutes: 0, second: 0 }; + if (!clock) return; + input.value = `${formatDate(date)}T${formatClock(clock.minutes, clock.second)}`; choice = undefined; update(); }; - pickDate.addEventListener("change", fromPicker); - pickTime.addEventListener("change", fromPicker); + // As the picker or the typing changes it, not only once the picker closes. + pickDate.addEventListener("input", fromPicker); + pickTime.addEventListener("input", fromPicker); byId("now").addEventListener("click", () => { - input.value = String(Math.floor(Date.now() / 1000)); + // In the unit chosen: Unix seconds unless milliseconds were asked for. + input.value = String(unit === "ms" ? Date.now() : Math.floor(Date.now() / 1000)); choice = undefined; update(); }); @@ -376,7 +369,6 @@ export function convertView(app: App): View { pin(); } }); - app.onPrefs(update); setInterval(() => { if (visible) tick(); }, 15_000); diff --git a/apps/time/src/duration-view.ts b/apps/time/src/duration-view.ts index 6d4e107..4936991 100644 --- a/apps/time/src/duration-view.ts +++ b/apps/time/src/duration-view.ts @@ -1,13 +1,12 @@ /** Duration: calendar age and elapsed time between two dates, either of which can be now. */ -import { bindSwitch, byId, copyText, isOn, setOn, showMessage } from "@tools/ui"; -import { dayNumber, parseClock, parseDate } from "./lib/civil.js"; +import { bindSwitch, byId, copyText, isOn, parseClock, setOn, showMessage } from "@tools/ui"; +import { dayNumber, parseDate } from "./lib/civil.js"; import type { WallTime } from "./lib/civil.js"; import { calendarSpan, describeSpan, nextAnniversary, totalIn } from "./lib/duration.js"; import type { Rounding, TotalUnit } from "./lib/duration.js"; -import { readableDate, relativeTime } from "./lib/format.js"; +import { formatDay, readableDate, relativeTime } from "./lib/format.js"; import { localZone, wallAt } from "./lib/zone.js"; -import { readable } from "./prefs.js"; import { text } from "./app.js"; import type { App, View } from "./app.js"; import { copyButton, h, press } from "./dom.js"; @@ -15,23 +14,11 @@ import { resolveShown } from "./resolution.js"; import type { Choice } from "./resolution.js"; import { bindZoneField } from "./zone-field.js"; -type Preset = "age" | "since" | "until" | "between"; type End = "start" | "end"; -const LEGENDS: Record<Preset, [string, string]> = { - age: ["Born", "Age on"], - since: ["Since", "Until"], - until: ["From", "Until"], - between: ["From", "To"], -}; -const NOW: Record<Preset, [boolean, boolean]> = { - age: [false, true], - since: [false, true], - until: [true, false], - between: [false, false], -}; +/** Each end is a date, or now; until someone says otherwise, from a date to now. */ +const NOW: Record<End, boolean> = { start: false, end: true }; const UNITS: [TotalUnit, string][] = [ - ["weeks", "Weeks"], ["days", "Days"], ["hours", "Hours"], ["minutes", "Minutes"], @@ -40,7 +27,6 @@ const UNITS: [TotalUnit, string][] = [ export function durationView(app: App): View { const saved = app.saved; - const presetButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-preset]")]; const roundingButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-rounding]")]; const field = (end: End, part: "date" | "time") => byId<HTMLInputElement>(`${end}-${part}`); const now = (end: End) => byId(`${end}-now`); @@ -52,8 +38,6 @@ export function durationView(app: App): View { }); const live = byId("live"); - let preset: Preset = - (["age", "since", "until", "between"] as const).find((p) => p === saved.preset) ?? "age"; let rounding: Rounding = saved.rounding === "nearest" ? "nearest" : "down"; const choices: Partial<Record<End, Choice>> = {}; let visible = false; @@ -61,13 +45,15 @@ export function durationView(app: App): View { field(end, "date").value = text(saved[`${end}Date`]); field(end, "time").value = text(saved[`${end}Time`]); const on = saved[`${end}Now`]; - setOn(now(end), typeof on === "boolean" ? on : NOW[preset][end === "start" ? 0 : 1]); + setOn(now(end), typeof on === "boolean" ? on : NOW[end]); } if (typeof saved.live === "boolean") setOn(live, saved.live); const zoneField = bindZoneField(byId("dur-zone"), text(saved.durZone) || localZone(), () => update() ); + const legend = (end: End) => (end === "start" ? "From" : "To"); + /** One end as an instant, or what is missing; `timed` when it has a time of day. */ /** `timed` is undefined for now, which has every time of day. */ function instant(end: End, zone: string): { ms?: number; timed?: boolean; note: string } { @@ -76,42 +62,31 @@ export function durationView(app: App): View { const date = parseDate(field(end, "date").value); if (!date) return { timed: false, note: `Choose the ${end === "start" ? "first" : "second"} date.` }; - const clock = field(end, "time").value; - const minutes = clock ? parseClock(clock) : 0; - const second = Number(/:\d{2}:(\d{2})/.exec(clock)?.[1] ?? 0); + const clock = field(end, "time").value.trim(); + const time = clock ? parseClock(clock) : { minutes: 0, second: 0 }; + if (!time) + return { timed: true, note: `${legend(end)}: type the time as HH:MM, on the 24-hour clock.` }; const wall: WallTime = { ...date, - hour: Math.floor((minutes ?? 0) / 60), - minute: (minutes ?? 0) % 60, - second, + hour: Math.floor(time.minutes / 60), + minute: time.minutes % 60, + second: time.second, ms: 0, }; - const resolved = resolveShown( - wall, - zone, - readable(app.prefs), - choiceUi(end), - choices[end], - (choice) => { - choices[end] = choice; - update(); - } - ); - const legend = byId(`${end}-legend`).textContent; - const note = clock ? resolved.note : `${legend}: 00:00 assumed. ${resolved.note}`.trim(); + const resolved = resolveShown(wall, zone, choiceUi(end), choices[end], (choice) => { + choices[end] = choice; + update(); + }); + const note = clock ? resolved.note : `${legend(end)}: 00:00 assumed. ${resolved.note}`.trim(); return { ms: resolved.ms, timed: Boolean(clock), note }; } function show(): void { const zone = zoneField.zone; - const [startLegend, endLegend] = LEGENDS[preset]; - byId("start-legend").textContent = startLegend; - byId("end-legend").textContent = endLegend; for (const end of ["start", "end"] as const) { field(end, "date").disabled = isOn(now(end)); field(end, "time").disabled = isOn(now(end)); } - press(presetButtons, "preset", preset); press(roundingButtons, "rounding", rounding); const start = instant("start", zone); const end = instant("end", zone); @@ -142,21 +117,25 @@ export function durationView(app: App): View { } } - /** The age to come, or the date being counted to or from. */ + /** + * From a date in the past to now: when it next comes round (a birthday, the day two people met). + * With one end now otherwise: the other end, and how far off it is. + */ function extraLine(start: number, end: number, zone: string): string { - const options = readable(app.prefs); - if (preset === "age" && !isOn(now("start"))) { - const birth = parseDate(field("start", "date").value)!; - const today = wallAt(isOn(now("end")) ? Date.now() : end, zone); - const next = nextAnniversary(birth, today); - const days = dayNumber(next.date) - dayNumber(today); + const startNow = isOn(now("start")); + const endNow = isOn(now("end")); + if (!startNow && endNow && start < end) { + const from = parseDate(field("start", "date").value)!; + const today = wallAt(end, zone); + const next = nextAnniversary(from, today); if (next.years === 0) return ""; + const days = dayNumber(next.date) - dayNumber(today); const when = days === 0 ? "today" : days === 1 ? "tomorrow" : `in ${days} days`; - return `Turns ${next.years} ${when}.`; + return `${next.years} ${next.years === 1 ? "year" : "years"} on ${formatDay(next.date)}, ${when}.`; } - const other = isOn(now("start")) ? end : start; - if (isOn(now("start")) || isOn(now("end"))) - return `${readableDate(other, zone, options)} · ${relativeTime(other, Date.now())}`; + const other = startNow ? end : start; + if (startNow || endNow) + return `${readableDate(other, zone)} · ${relativeTime(other, Date.now())}`; return ""; } @@ -201,15 +180,6 @@ export function durationView(app: App): View { app.save(); } - for (const button of presetButtons) - button.addEventListener("click", () => { - preset = button.dataset.preset as Preset; - setOn(now("start"), NOW[preset][0]); - setOn(now("end"), NOW[preset][1]); - update(); - const first = (["start", "end"] as const).find((end) => !isOn(now(end))); - if (first) field(first, "date").focus(); - }); for (const button of roundingButtons) button.addEventListener("click", () => { rounding = button.dataset.rounding as Rounding; @@ -228,12 +198,10 @@ export function durationView(app: App): View { if (!(await copyText(byId("span").textContent ?? "", byId("span-copy")))) app.clipboardFailed(byId("span-note")); }); - app.onPrefs(show); show(); return { values: () => ({ - preset, rounding, live: isOn(live), durZone: zoneField.zone, diff --git a/apps/time/src/lib/availability.test.ts b/apps/time/src/lib/availability.test.ts index 2ee9522..cd19dd5 100644 --- a/apps/time/src/lib/availability.test.ts +++ b/apps/time/src/lib/availability.test.ts @@ -249,19 +249,14 @@ describe("finding a time", () => { }); }); - test("a summary gives each person their own date and time", () => { + test("a summary gives each person their own date and time, on the 24-hour clock", () => { const tokyo = person("Kai", "Asia/Tokyo", []); - expect( - summary([ana, ben, tokyo], utc("2026-10-12T18:00:00Z"), 60, { - locale: "en-GB", - hour12: false, - }) - ).toBe( + expect(summary([ana, ben, tokyo], utc("2026-10-12T18:00:00Z"), 60, { locale: "en-US" })).toBe( [ "Call, 1 h", - "Ana (Europe/Paris): Mon 12 Oct, 20:00–21:00", - "Ben (America/New_York): Mon 12 Oct, 14:00–15:00", - "Kai (Asia/Tokyo): Tue 13 Oct, 03:00–04:00", + "Ana (Europe/Paris): Mon, Oct 12, 20:00–21:00", + "Ben (America/New_York): Mon, Oct 12, 14:00–15:00", + "Kai (Asia/Tokyo): Tue, Oct 13, 03:00–04:00", ].join("\n") ); }); diff --git a/apps/time/src/lib/civil.ts b/apps/time/src/lib/civil.ts index 24c16b1..e33f402 100644 --- a/apps/time/src/lib/civil.ts +++ b/apps/time/src/lib/civil.ts @@ -129,16 +129,3 @@ export function parseDate(value: string): PlainDate | undefined { const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; return isValidDate(year, month, day) ? { year, month, day } : undefined; } - -/** `HH:MM` from minutes after midnight. */ -export function formatClock(minutes: number): string { - return `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; -} - -/** Minutes after midnight from `HH:MM` (or `HH:MM:SS`, seconds dropped); undefined otherwise. */ -export function parseClock(value: string): number | undefined { - const match = /^(\d{2}):(\d{2})(?::\d{2}(?:\.\d+)?)?$/.exec(value.trim()); - if (!match) return undefined; - const [hour, minute] = [Number(match[1]), Number(match[2])]; - return hour < 24 && minute < 60 ? hour * 60 + minute : undefined; -} diff --git a/apps/time/src/lib/duration.ts b/apps/time/src/lib/duration.ts index 109c102..0213047 100644 --- a/apps/time/src/lib/duration.ts +++ b/apps/time/src/lib/duration.ts @@ -82,7 +82,6 @@ export function describeSpan(span: CalendarSpan, withTime: boolean): string { export type Rounding = "down" | "nearest"; export const UNIT_MS = { - weeks: 7 * DAY, days: DAY, hours: HOUR, minutes: MINUTE, diff --git a/apps/time/src/lib/format.ts b/apps/time/src/lib/format.ts index e5af307..6a39a14 100644 --- a/apps/time/src/lib/format.ts +++ b/apps/time/src/lib/format.ts @@ -1,6 +1,7 @@ /** Instants written out: ISO 8601, HTTP dates, offsets, a reader's own format, and "in 3 hours". */ -import { formatDate, formatYear, HOUR, MINUTE } from "./civil.js"; +import { DAY, dayNumber, formatDate, formatYear, HOUR, MINUTE } from "./civil.js"; +import type { PlainDate } from "./civil.js"; import { offsetAt, UTC, wallAt } from "./zone.js"; const pad = (value: number, width = 2) => String(value).padStart(width, "0"); @@ -47,9 +48,11 @@ export function httpDate(ms: number): string { ); } +/** + * How a reader sees a date: in their language, the browser's by default. Times of day are on the + * 24-hour clock whatever the language, as across the site. + */ export interface ReadableOptions { - hour12?: boolean; - /** The reader's language; the browser's by default. */ locale?: string; } @@ -62,19 +65,27 @@ function reader(key: string, make: () => Intl.DateTimeFormat): Intl.DateTimeForm /** The date and time as the reader's language writes them, e.g. "Monday 12 October 2026 at 10:15:00". */ export function readableDate(ms: number, zone: string, options: ReadableOptions = {}): string { - const { locale, hour12 } = options; + const { locale } = options; return reader( - `full|${locale}|${zone}|${hour12}`, + `full|${locale}|${zone}`, () => new Intl.DateTimeFormat(locale, { timeZone: zone, dateStyle: "full", timeStyle: "medium", - hour12, + hourCycle: "h23", }) ).format(ms); } +/** A calendar date, with no time or zone, as the reader's language writes it: "14 Mar 2027". */ +export function formatDay(date: PlainDate, locale?: string): string { + return reader( + `day|${locale}`, + () => new Intl.DateTimeFormat(locale, { timeZone: UTC, dateStyle: "medium" }) + ).format(dayNumber(date) * DAY); +} + /** A short weekday and date, e.g. "Sat 14 Nov". */ export function shortDate(ms: number, zone: string, locale?: string): string { return reader( @@ -89,17 +100,17 @@ export function shortDate(ms: number, zone: string, locale?: string): string { ).format(ms); } -/** A clock time, e.g. "20:00" or "8:00 PM". */ +/** A clock time, e.g. "08:00" or "20:00". */ export function clockTime(ms: number, zone: string, options: ReadableOptions = {}): string { - const { locale, hour12 } = options; + const { locale } = options; return reader( - `clock|${locale}|${zone}|${hour12}`, + `clock|${locale}|${zone}`, () => new Intl.DateTimeFormat(locale, { timeZone: zone, - hour: hour12 === false ? "2-digit" : "numeric", + hour: "2-digit", minute: "2-digit", - hour12, + hourCycle: "h23", }) ).format(ms); } diff --git a/apps/time/src/lib/plan.ts b/apps/time/src/lib/plan.ts index 503c609..ed8279d 100644 --- a/apps/time/src/lib/plan.ts +++ b/apps/time/src/lib/plan.ts @@ -116,20 +116,14 @@ export function sharePlan(plan: Plan): Record<string, unknown> { }; } -export const ALL_DAYS = [1, 2, 3, 4, 5, 6, 7]; -export const WEEKDAYS = [1, 2, 3, 4, 5]; -export const WEEKEND = [6, 7]; - -/** A new person: acceptable from 08:00 to 22:00 every day, preferred on weekday evenings. */ -export function newPerson(name: string, zone: string): Person { +/** A new person: one window, preferred every day, from 09:00 to 17:00 unless other hours are given. */ +export function newPerson(name: string, zone: string, from = 9, until = 17): Person { return { id: newId(), name, zone, windows: [ - { days: ALL_DAYS, start: 8 * 60, end: 22 * 60, level: "acceptable" }, - { days: WEEKDAYS, start: 18 * 60, end: 21 * 60, level: "preferred" }, - { days: WEEKEND, start: 10 * 60, end: 18 * 60, level: "preferred" }, + { days: [1, 2, 3, 4, 5, 6, 7], start: from * 60, end: until * 60, level: "preferred" }, ], exceptions: [], relaxed: false, diff --git a/apps/time/src/lib/time.test.ts b/apps/time/src/lib/time.test.ts index 0e1552f..0d12d3a 100644 --- a/apps/time/src/lib/time.test.ts +++ b/apps/time/src/lib/time.test.ts @@ -5,7 +5,6 @@ import { HOUR, civilFromDays, daysFromCivil, - parseClock, weekday, wallFromMs, wallMs, @@ -57,11 +56,6 @@ describe("civil dates", () => { day: 15, }); }); - test("clock times", () => { - expect(parseClock("09:30")).toBe(570); - expect(parseClock("23:59:59")).toBe(1439); - expect(parseClock("24:00")).toBeUndefined(); - }); }); describe("zones", () => { diff --git a/apps/time/src/main.ts b/apps/time/src/main.ts index 27f1f0f..a6a8c3a 100644 --- a/apps/time/src/main.ts +++ b/apps/time/src/main.ts @@ -1,5 +1,5 @@ import { - bindMenu, + bindClockFields, bindToolHelp, byId, debounce, @@ -25,7 +25,6 @@ const NAMES: readonly ViewName[] = ["convert", "meet", "duration"]; const DRAFT = 1; const draft = readDraft("time", DRAFT); const prefs = readPrefs(); -const listeners: (() => void)[] = []; /** Filled once every view is made; a save asked for while they are being made waits for it. */ const views = {} as Record<ViewName, View>; // A share link opens on the people it holds. @@ -63,9 +62,6 @@ const app: App = { if (views.duration) show(view); else current = view; }, - onPrefs(listener) { - listeners.push(listener); - }, }; Object.assign(views, { @@ -90,16 +86,7 @@ for (const button of viewButtons) button.addEventListener("click", () => show(button.dataset.view as ViewName)); viewSelect.addEventListener("change", () => show(viewSelect.value as ViewName)); -const clockButtons = [...document.querySelectorAll<HTMLButtonElement>("[data-clock]")]; -press(clockButtons, "clock", prefs.clock); -bindMenu(byId("settings-btn"), byId("settings"), { align: "end" }); -for (const button of clockButtons) - button.addEventListener("click", () => { - prefs.clock = button.dataset.clock === "12" ? "12" : "24"; - press(clockButtons, "clock", prefs.clock); - writePrefs(prefs); - for (const listener of listeners) listener(); - }); +bindClockFields(); if (draft.error) { const status = byId("draft-status"); diff --git a/apps/time/src/meet.ts b/apps/time/src/meet.ts index 97214c8..3a3dc0c 100644 --- a/apps/time/src/meet.ts +++ b/apps/time/src/meet.ts @@ -3,9 +3,12 @@ import { fromBase64, toBase64, utf8, textFromBytes } from "@tools/bytes"; import { byId, + CLOCK_FIELD, copyText, debounce, + formatClock, iconSvg, + parseClock, readStored, setOn, showMessage, @@ -32,17 +35,7 @@ import type { TimeWindow, WeeklyWindow, } from "./lib/availability.js"; -import { - addDays, - atMinutes, - dayNumber, - DAY, - formatClock, - formatDate, - MINUTE, - parseClock, - parseDate, -} from "./lib/civil.js"; +import { addDays, atMinutes, dayNumber, DAY, formatDate, MINUTE, parseDate } from "./lib/civil.js"; import type { PlainDate } from "./lib/civil.js"; import { clockTime, formatMinutes, shortDate, utcOffset } from "./lib/format.js"; import { @@ -55,7 +48,6 @@ import { } from "./lib/plan.js"; import type { Plan } from "./lib/plan.js"; import { canonicalZone, instantOf, localZone, offsetAt, wallAt } from "./lib/zone.js"; -import { readable } from "./prefs.js"; import { text } from "./app.js"; import type { App, View } from "./app.js"; import { h } from "./dom.js"; @@ -73,14 +65,15 @@ const LEVEL_NAMES: Record<Level, string> = { }; export const SHARE_PREFIX = "#plan="; -/** First visit: three people in three zones, so every part of the view has something to show. */ +/** + * First visit: two people in two zones, one window each, overlapping for three hours (19:00 to + * 22:00 in Paris is 13:00 to 16:00 in New York): enough for every part of the view to show. + */ function examplePlan(): Plan { - const here = localZone(); return { people: [ - newPerson("You", here), - newPerson("Sam", here === "America/New_York" ? "America/Los_Angeles" : "America/New_York"), - newPerson("Kai", here === "Asia/Tokyo" ? "Australia/Sydney" : "Asia/Tokyo"), + newPerson("Alex", "Europe/Paris", 18, 22), + newPerson("Sam", "America/New_York", 13, 17), ], duration: DEFAULT_DURATION, }; @@ -124,10 +117,7 @@ export function meetView(app: App): View { const folded = new Set<string>( Array.isArray(saved.folded) ? saved.folded.filter((x): x is string => typeof x === "string") - : // The example opens on its first person; the others are folded to their name and zone. - stored - ? [] - : plan.people.slice(1).map((p) => p.id) + : [] ); let shownCount = PAGE; let suggestions: Suggestion[] = []; @@ -204,9 +194,7 @@ export function meetView(app: App): View { : undefined; const clock = (field: "start" | "end", label: string) => h("input", { - class: "ui-input", - type: "time", - step: "60", + ...CLOCK_FIELD, "data-field": field, "aria-label": label, value: formatClock(window[field]), @@ -226,17 +214,18 @@ export function meetView(app: App): View { clock("start", "From"), h("span", { "aria-hidden": "true" }, "–"), clock("end", "Until"), - levelSelect(window.level), - h( - "button", - { - type: "button", - class: "ui-btn ui-icon-btn", - "data-action": "remove-window", - "aria-label": "Remove window", - }, - iconSvg("close") - ) + levelSelect(window.level) + ), + h( + "button", + { + type: "button", + class: "ui-btn ui-icon-btn time-window-remove", + "data-action": "remove-window", + "aria-label": "Remove window", + title: "Remove window", + }, + iconSvg("close") ) ); } @@ -266,7 +255,8 @@ export function meetView(app: App): View { {}, h("span", { class: "time-person-name" }, person.name || "Unnamed"), h("span", { class: "time-person-meta" }, meta), - person.relaxed && h("span", { class: "time-badge" }, "Relaxed") + person.relaxed && h("span", { class: "time-badge" }, "Relaxed"), + h("span", { class: "time-person-chevron", "aria-hidden": "true" }, iconSvg("chevron-down")) ), h( "div", @@ -417,8 +407,8 @@ export function meetView(app: App): View { break; case "start": case "end": { - const minutes = parseClock(target.value); - if (window && minutes !== undefined) window[target.dataset.field] = minutes; + const clock = parseClock(target.value); + if (window && clock) window[target.dataset.field] = clock.minutes; break; } case "level": @@ -556,14 +546,13 @@ export function meetView(app: App): View { function renderSuggestions(): void { const list = byId("suggestions"); - const options = readable(app.prefs); const people = usable(); list.replaceChildren( ...suggestions.slice(0, shownCount).map((s) => { const end = s.start + plan.duration * MINUTE; const startDay = shortDate(s.start, zone()); const endDay = shortDate(end, zone()); - const when = `${startDay}, ${clockTime(s.start, zone(), options)}–${clockTime(end, zone(), options)}${endDay === startDay ? "" : ` (${endDay})`}`; + const when = `${startDay}, ${clockTime(s.start, zone())}–${clockTime(end, zone())}${endDay === startDay ? "" : ` (${endDay})`}`; const button = h( "button", { @@ -579,7 +568,7 @@ export function meetView(app: App): View { h( "span", { class: "time-suggestion-range" }, - `or start any time until ${clockTime(s.last, zone(), options)}` + `or start any time until ${clockTime(s.last, zone())}` ), // Everyone's own clock at the start, and their date where it is not the one above. h( @@ -589,7 +578,7 @@ export function meetView(app: App): View { .map((p) => { const theirDay = shortDate(s.start, p.zone); return ( - `${p.name || "Unnamed"} ${clockTime(s.start, p.zone, options)}` + + `${p.name || "Unnamed"} ${clockTime(s.start, p.zone)}` + (theirDay === startDay ? "" : ` (${theirDay})`) ); }) @@ -704,10 +693,6 @@ export function meetView(app: App): View { function hourLabel(ms: number, zoneName: string): string { const wall = wallAt(ms, zoneName); - if (app.prefs.clock === "12") { - const hour = wall.hour % 12 || 12; - return `${hour}${wall.minute ? ":" + String(wall.minute).padStart(2, "0") : ""}${wall.hour < 12 ? "a" : "p"}`; - } return wall.minute ? formatClock(wall.hour * 60 + wall.minute) : String(wall.hour); } @@ -764,12 +749,11 @@ export function meetView(app: App): View { /** Words for a row's levels, for those who cannot see the colours. */ function describeRow(name: string, segments: Segment[], zoneName: string): string { - const options = readable(app.prefs); const parts = segments .filter((s) => s.level !== "unavailable") .map( (s) => - `${LEVEL_NAMES[s.level].toLowerCase()} ${clockTime(s.start, zoneName, options)}–${clockTime(s.end, zoneName, options)}` + `${LEVEL_NAMES[s.level].toLowerCase()} ${clockTime(s.start, zoneName)}–${clockTime(s.end, zoneName)}` ); return `${name}: ${parts.length ? parts.join(", ") : "unavailable all day"}`; } @@ -855,12 +839,7 @@ export function meetView(app: App): View { : "") + explain(people, costs), blocked.length > 0 ); - byId("picked-summary").textContent = summary( - people, - selected, - plan.duration, - readable(app.prefs) - ); + byId("picked-summary").textContent = summary(people, selected, plan.duration); copy.disabled = false; } @@ -882,9 +861,9 @@ export function meetView(app: App): View { pick(from + Math.max(0, Math.floor((t - from) / (STEP * MINUTE))) * STEP * MINUTE); }); byId("picked-time").addEventListener("change", () => { - const minutes = parseClock(byId<HTMLInputElement>("picked-time").value); - if (minutes === undefined) return; - pick(instantOf(atMinutes(day ?? wallAt(Date.now(), zone()), minutes), zone())); + const clock = parseClock(byId<HTMLInputElement>("picked-time").value); + if (!clock) return; + pick(instantOf(atMinutes(day ?? wallAt(Date.now(), zone()), clock.minutes), zone())); }); byId("picked-copy").addEventListener("click", async () => { if (!(await copyText(byId("picked-summary").textContent ?? "", byId("picked-copy")))) @@ -972,11 +951,6 @@ export function meetView(app: App): View { readShared(); window.addEventListener("hashchange", readShared); - app.onPrefs(() => { - renderSuggestions(); - renderTimeline(); - }); - renderPeople(); research(); return { diff --git a/apps/time/src/prefs.ts b/apps/time/src/prefs.ts index d50f0c2..5a1d475 100644 --- a/apps/time/src/prefs.ts +++ b/apps/time/src/prefs.ts @@ -1,35 +1,27 @@ /** - * What Time keeps for the whole app, in this browser: the clock (24- or 12-hour) and the pinned - * zones. A saved format: read field by field, anything missing or unknown given its default. + * What Time keeps for the whole app, in this browser: the pinned zones. A saved format: read field + * by field, anything missing or unknown given its default. (Settings saved before times became + * 24-hour everywhere also hold `clock`, which is no longer read.) */ import { readStored, writeStored } from "@tools/ui"; import { canonicalZone } from "./lib/zone.js"; -import type { ReadableOptions } from "./lib/format.js"; const KEY = "tools.time.prefs"; export interface Prefs { - clock: "24" | "12"; pinned: string[]; } export const MAX_PINNED = 12; const DEFAULT_PINNED = ["America/New_York", "Europe/London", "Asia/Tokyo"]; -/** The reader's own habit, until they choose. */ -function localeClock(): Prefs["clock"] { - const cycle = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).resolvedOptions().hourCycle; - return cycle === "h11" || cycle === "h12" ? "12" : "24"; -} - export function readPrefs(): Prefs { const raw = readStored(KEY); const stored = typeof raw === "object" && raw !== null && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {}; - const clock = stored.clock === "12" || stored.clock === "24" ? stored.clock : localeClock(); const pinned = Array.isArray(stored.pinned) ? [ ...new Set( @@ -40,11 +32,9 @@ export function readPrefs(): Prefs { ), ].slice(0, MAX_PINNED) : DEFAULT_PINNED; - return { clock, pinned }; + return { pinned }; } export function writePrefs(prefs: Prefs): void { writeStored(KEY, prefs); } - -export const readable = (prefs: Prefs): ReadableOptions => ({ hour12: prefs.clock === "12" }); diff --git a/apps/time/src/resolution.ts b/apps/time/src/resolution.ts index a98f7c1..39e1d7e 100644 --- a/apps/time/src/resolution.ts +++ b/apps/time/src/resolution.ts @@ -4,10 +4,10 @@ * chooses which before anything is shown. Convert and Duration both ask this. */ -import { formatDate, formatClock } from "./lib/civil.js"; +import { formatClock } from "@tools/ui"; +import { formatDate } from "./lib/civil.js"; import type { WallTime } from "./lib/civil.js"; import { clockTime, formatMinutes, utcOffset } from "./lib/format.js"; -import type { ReadableOptions } from "./lib/format.js"; import { offsetAt, resolveWall } from "./lib/zone.js"; import { h } from "./dom.js"; @@ -27,13 +27,11 @@ export interface Resolved { } const wallClock = (wall: WallTime) => - formatClock(wall.hour * 60 + wall.minute) + - (wall.second ? ":" + String(wall.second).padStart(2, "0") : ""); + formatClock(wall.hour * 60 + wall.minute, wall.second || undefined); export function resolveShown( wall: WallTime, zone: string, - options: ReadableOptions, ui: ChoiceBox, chosen: Choice | undefined, onChoose: (choice: Choice) => void @@ -48,7 +46,7 @@ export function resolveShown( ms: found.ms, note: `${when} does not exist: the clocks go forward ${shift} there. ` + - `Read as ${clockTime(found.ms, zone, options)} (${utcOffset(offsetAt(found.ms, zone))}), the time after the change.`, + `Read as ${clockTime(found.ms, zone)} (${utcOffset(offsetAt(found.ms, zone))}), the time after the change.`, }; } const shift = formatMinutes(Math.round((found.later - found.earlier) / 60_000)); diff --git a/apps/time/src/styles.css b/apps/time/src/styles.css index e4814a5..3954935 100644 --- a/apps/time/src/styles.css +++ b/apps/time/src/styles.css @@ -36,7 +36,7 @@ border-color: var(--danger); } .time input[type="date"], -.time input[type="time"] { +.time .ui-clock { min-height: 32px; padding: 4px 8px; } @@ -221,16 +221,44 @@ border-radius: var(--radius); background: var(--bg); } +/* The card folds to its name and zone; the chevron at the end of the row says so. */ .time-person summary { display: flex; flex-wrap: wrap; - align-items: baseline; + align-items: center; gap: 4px 10px; padding: 8px 10px; + list-style: none; cursor: pointer; } -.time-person summary::marker { +.time-person summary::-webkit-details-marker { + display: none; +} +.time-person-chevron { + display: grid; + place-items: center; + width: 28px; + height: 28px; + margin-left: auto; + border-radius: var(--radius); color: var(--muted); + transition: transform 0.15s ease; +} +.time-person-chevron svg { + width: 18px; + height: 18px; +} +.time-person summary:hover .time-person-chevron { + background: var(--panel); + color: var(--text); +} +.time-person:not([open]) .time-person-chevron { + transform: rotate(-90deg); +} +.time-person summary:focus-visible { + outline: 2px solid var(--accent); + outline-offset: -2px; + border-radius: var(--radius); } .time-person-name { font-weight: 600; @@ -264,9 +292,11 @@ width: 100%; color: var(--text); } +/* Two lines: the days, with the remove button in the corner, then the hours. A date's window has + no days: its hours and the button share one line. */ .time-window { - display: flex; - flex-wrap: wrap; + display: grid; + grid-template-columns: minmax(0, 1fr) auto; align-items: center; gap: 6px; margin-bottom: 6px; @@ -282,8 +312,12 @@ .time-window[data-level="unavailable"] { border-left-color: var(--level-unavailable); } -.time-window-hours > .ui-icon-btn { - margin-left: auto; +.time-window-remove { + grid-row: 1; + grid-column: 2; +} +.time-days { + justify-self: start; } .time-days .ui-btn { min-width: 30px; @@ -291,11 +325,13 @@ } .time-window-hours { display: flex; - flex: 1 1 auto; flex-wrap: wrap; align-items: center; gap: 6px; } +.time-days ~ .time-window-hours { + grid-column: 1 / -1; +} .time-window-hours select { min-height: 32px; padding: 4px 6px; @@ -584,8 +620,9 @@ /* ---------- Header ---------- */ -/* When the three views no longer fit beside "‹" and the settings, they become a dropdown. */ -@media (width < 406px) { +/* When the three views no longer fit beside "‹" and the theme and Help buttons (measured: 363px), + they become a dropdown. */ +@media (width < 363px) { .time-header .time-views { display: none; } diff --git a/docs/apps/codes.md b/docs/apps/codes.md index ae6e2fd..913d2c3 100644 --- a/docs/apps/codes.md +++ b/docs/apps/codes.md @@ -8,7 +8,8 @@ hold does not change. A code can hold a link (`https://` is added when it is missing), plain text, an email, a call, an SMS, a WhatsApp chat, Wi-Fi credentials, a vCard contact or a calendar event in the local time -zone. A barcode is Code 128 (printable ASCII, up to 80 characters), EAN-13 or UPC-A. Enter one +zone, its start and end each a date and a time on the 24-hour clock (an end time alone is on the +start's date; with no end, the event lasts an hour). A barcode is Code 128 (printable ASCII, up to 80 characters), EAN-13 or UPC-A. Enter one digit less and the check digit is calculated; enter all of them and it is checked. A Wi-Fi password is readable by anyone who scans the code. diff --git a/docs/apps/time.md b/docs/apps/time.md index cf4cb49..396b88a 100644 --- a/docs/apps/time.md +++ b/docs/apps/time.md @@ -10,9 +10,11 @@ one at that date, fractional hours and daylight-saving time included. A zone can name in any case or by its city alone. "Now" is the device clock, as exact as its setting; it is not an independently synchronised clock like [Time.is](https://time.is/). -What is typed stays in the tab's draft. People, windows and pinned zones, and the 24- or -12-hour clock (Settings), are kept in the browser. The calendar is the proleptic Gregorian one, -with year 0 for 1 BC. +Times of day are on the 24-hour clock, shown and typed, whatever the browser's language: a time is +typed as `14:30` (or `1430`, `9:30`) in a field of its own, since a native time field would follow +the language into AM and PM. Dates keep the browser's date picker. What is typed stays in the +tab's draft. People, windows and pinned zones are kept in the browser. The calendar is the +proleptic Gregorian one, with year 0 for 1 BC. ## Convert @@ -25,7 +27,8 @@ A date or time without an offset is read in the chosen zone, and a date alone at note states the assumption. Where clocks go back, a time happens twice and nothing is shown until the person chooses the first or second; where they go forward, it never happens, and it is read as the time on the clock after the change (02:30 in a one-hour gap is 03:30), with the reason. -A date and time picker writes the input, and follows it. +A date picker and a time field write the input as they change, and follow it. Now writes the +current time in the unit chosen: Unix seconds, or milliseconds when Milliseconds is pressed. Results give Unix seconds and milliseconds, UTC, the device's zone, the zone the input was read in and each pinned zone (New York, London and Tokyo until changed), as ISO 8601 with the offset and @@ -44,8 +47,11 @@ Each person has a name, a zone, and windows on their own wall clock: **preferred overlap, unavailable wins over preferred, and preferred over acceptable. A window whose end is not after its start runs past midnight; one that ends where it starts is the whole day. Weekly windows apply on the weekdays ticked; a date exception replaces the windows that start on that date, and -with none the person is unavailable that day. A new person is acceptable from 08:00 to 22:00 and -prefers weekday evenings and weekend days; the first visit opens on three example people. +with none the person is unavailable that day. A window is two lines: its days, with the button +that removes it in the corner, then its hours and level. A person's card folds to their name and +zone with the chevron at the end of its row. A new person prefers 09:00 to 17:00 every day; the +first visit opens on two example people, Alex in Paris (18:00 to 22:00) and Sam in New York (13:00 +to 17:00). The search covers up to 31 days, from midnight to midnight in the zone the results are shown in, for a call of 15 minutes to 3 hours. Windows are resolved date by date in each person's zone, so a @@ -85,18 +91,22 @@ the saved people as they are. ## Duration -Age, Since, Until and Between set which end is now; either can be switched. A time is optional: -without one, 00:00 in the chosen zone is assumed, and the note says so. Ambiguous and skipped -times are resolved as in Convert. +From and To are each a date, with an optional time, or Now; To starts on Now, so "how old am I?" +and "how long since we met?" need only the first date, and "how long until my holiday?" switches +From to Now. A time is optional: without one, 00:00 in the chosen zone is assumed, and the note +says so. Ambiguous and skipped times are resolved as in Convert. Calendar counts whole years, months and days on the wall clock, then the time left when every dated end has a time. A day of 23 or 25 hours at a clock change is still one day. A month too short for the day ends at its last day: one month after 31 January is 28 or 29 February, and a -29 February birthday counts on 28 February in other years. Age adds when the next birthday is. - -Elapsed gives weeks, days, hours, minutes and seconds of exact time, a day being 24 hours. -Completed counts only whole units, toward zero for a negative span; Nearest rounds to the nearest -whole unit, half away from zero. While one end is now, both count live, every second. +29 February birthday counts on 28 February in other years. From a date in the past to now, it +also says when that date next comes round: "37 years on 14 Mar 2027, in 152 days". With one end +now otherwise, it gives the other end and how far off it is. + +Elapsed gives the whole span in days, in hours, in minutes and in seconds of exact time, each on +its own, a day being 24 hours. Completed counts only whole units, toward zero for a negative span; +Nearest rounds to the nearest whole unit, half away from zero. While one end is now, both count +live, every second. ## Later diff --git a/packages/catalog/src/index.ts b/packages/catalog/src/index.ts index 747c5be..46dc11a 100644 --- a/packages/catalog/src/index.ts +++ b/packages/catalog/src/index.ts @@ -142,7 +142,7 @@ export const APPS: readonly ToolsApp[] = [ icon: "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0zM12 7v5l3 2", tags: ["time", "timestamp", "timezone", "iso-8601", "meeting", "duration", "age"], art: true, - compactHeader: 500, + compactHeader: 450, jsBudget: { entryGzip: 24, totalGzip: 24 }, status: "stable", listed: true, diff --git a/packages/ui/base.css b/packages/ui/base.css index 3dc6b5c..2dd0641 100644 --- a/packages/ui/base.css +++ b/packages/ui/base.css @@ -58,6 +58,19 @@ a { border-color: var(--accent); } +/* A time of day on the 24-hour clock (`clock.ts`): wide enough for its HH:MM placeholder, or for + 23:59:59. */ +.ui-clock { + width: calc(6.5ch + 22px); + font-variant-numeric: tabular-nums; +} +.ui-clock[data-seconds] { + width: calc(8.5ch + 22px); +} +.ui-clock[aria-invalid="true"] { + border-color: var(--danger); +} + /* ---------- Layout pieces ---------- */ .ui-label { diff --git a/packages/ui/src/clock.test.ts b/packages/ui/src/clock.test.ts new file mode 100644 index 0000000..87868fc --- /dev/null +++ b/packages/ui/src/clock.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { formatClock, parseClock } from "./clock.js"; + +describe("clock", () => { + test("times of day are read on the 24-hour clock, however they are typed", () => { + const at = (minutes: number, second = 0) => ({ minutes, second }); + expect(parseClock("09:30")).toEqual(at(570)); + for (const typed of ["9:30", "0930", "930", "9.30", "9h30", " 09:30 "]) + expect(parseClock(typed)).toEqual(at(570)); + expect(parseClock("9")).toEqual(at(540)); + expect(parseClock("23:59:59")).toEqual(at(1439, 59)); + expect(parseClock("235959")).toEqual(at(1439, 59)); + expect(parseClock("14:30:15.5")).toEqual(at(870, 15)); + for (const wrong of ["24:00", "12:60", "12:30:60", "9:3", "2:30 PM", "", "12:30:"]) + expect(parseClock(wrong)).toBeUndefined(); + }); + test("they are written as HH:MM, with seconds when given", () => { + expect(formatClock(570)).toBe("09:30"); + expect(formatClock(570, 5)).toBe("09:30:05"); + expect(formatClock(1439, 0)).toBe("23:59:00"); + }); +}); diff --git a/packages/ui/src/clock.ts b/packages/ui/src/clock.ts new file mode 100644 index 0000000..7bd07ef --- /dev/null +++ b/packages/ui/src/clock.ts @@ -0,0 +1,71 @@ +/** + * Times of day across the site are on the 24-hour clock, whatever the browser's language. + * + * A time field is a typed `.ui-clock` field rather than a native time field: a native one writes + * the time as the browser's language does, AM/PM in American English, with no way to ask + * otherwise. A field accepts `14:30`, `9:30` or `0930` (and seconds where it shows them, + * `data-seconds`), is marked invalid while it cannot be read, and is written out as `HH:MM` once + * the person leaves it. Pages read it with `parseClock`. + */ + +const pad = (value: number) => String(value).padStart(2, "0"); + +/** `HH:MM` from minutes after midnight, on the 24-hour clock; `HH:MM:SS` given the seconds. */ +export function formatClock(minutes: number, second?: number): string { + const clock = `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; + return second === undefined ? clock : `${clock}:${pad(second)}`; +} + +/** + * A time of day as typed on the 24-hour clock: `14:30`, `14:30:15`, `9:30`, `0930`, `14h30` or + * just `9`. Fractions of a second are dropped. Undefined when it is not one. + */ +export function parseClock(value: string): { minutes: number; second: number } | undefined { + const match = /^(\d{1,2})(?:[:.h]?(\d{2})(?:[:.]?(\d{2})(?:[.,]\d+)?)?)?$/i.exec(value.trim()); + if (!match) return undefined; + const [hour, minute, second] = [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)]; + return hour < 24 && minute < 60 && second < 60 + ? { minutes: hour * 60 + minute, second } + : undefined; +} + +const isClock = (target: EventTarget | null): target is HTMLInputElement => + target instanceof HTMLInputElement && target.classList.contains("ui-clock"); + +function mark(input: HTMLInputElement): void { + if (input.value.trim() !== "" && parseClock(input.value) === undefined) + input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); +} + +let bound = false; +/** Binds every clock field on the page, those drawn later included. */ +export function bindClockFields(): void { + if (bound) return; + bound = true; + document.addEventListener("input", (event) => { + // While typing, only clear a mark: "9:" is on its way to being a time. + if (isClock(event.target) && parseClock(event.target.value)) mark(event.target); + }); + document.addEventListener("change", (event) => { + if (!isClock(event.target)) return; + const input = event.target; + const clock = parseClock(input.value); + if (clock) { + const second = input.dataset.seconds !== undefined && clock.second ? clock.second : undefined; + input.value = formatClock(clock.minutes, second); + } + mark(input); + }); +} + +/** The attributes of a clock field, for one made in code; the page's own carry the same. */ +export const CLOCK_FIELD = { + class: "ui-input ui-clock", + type: "text", + inputmode: "numeric", + autocomplete: "off", + spellcheck: "false", + placeholder: "HH:MM", + maxlength: "8", +} as const; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index d669488..9d99fda 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,6 +12,7 @@ export * from "./color.js"; export * from "./color-picker.js"; export * from "./png.js"; export * from "./debounce.js"; +export * from "./clock.js"; declare global { interface ImportMetaEnv { diff --git a/tests/browser/developer-tools.spec.ts b/tests/browser/developer-tools.spec.ts index 55a1e58..4727bd3 100644 --- a/tests/browser/developer-tools.spec.ts +++ b/tests/browser/developer-tools.spec.ts @@ -519,7 +519,11 @@ test("Codes makes each kind of content, keeps the preview in place and exports s await fill("sms", { "#sms-number": "+1 555 0100", "#sms-message": "Hi" }, "SMSTO:+15550100:Hi"); await fill("whatsapp", { "#wa-number": "+44 7700 900000" }, "https://wa.me/447700900000"); await fill("vcard", { "#vc-first": "Ada", "#vc-last": "Lovelace" }, /^BEGIN:VCARD/); - await fill("event", { "#ev-title": "Launch", "#ev-start": "2026-10-02T09:00" }, /^BEGIN:VEVENT/); + await fill( + "event", + { "#ev-title": "Launch", "#ev-start-date": "2026-10-02", "#ev-start-time": "9:00" }, + /^BEGIN:VEVENT/ + ); await fill("text", { "#text-content": "Hello, 🌍!" }, "Hello, 🌍!"); await page.locator('[data-type="wifi"]').click(); diff --git a/tests/browser/time.spec.ts b/tests/browser/time.spec.ts index 4acb7e1..26aa2c9 100644 --- a/tests/browser/time.spec.ts +++ b/tests/browser/time.spec.ts @@ -186,7 +186,9 @@ test("Time finds a call time, explains compromises, relaxes on request and share test("Time measures ages and spans on the calendar and in elapsed units", async ({ page }) => { await page.goto("/Tools/time/"); await page.locator('[data-view="duration"]').click(); - await page.locator('[data-preset="between"]').click(); + // To starts on now; between two dates, it is switched off. + await expect(page.locator("#end-now")).toHaveAttribute("aria-checked", "true"); + await page.locator("#end-now").click(); await page.locator("#start-date").fill("2000-02-29"); await page.locator("#end-date").fill("2001-02-28"); await expect(page.locator("#span")).toHaveText("1 year"); @@ -211,13 +213,58 @@ test("Time measures ages and spans on the calendar and in elapsed units", async "1" ); - await page.locator('[data-preset="age"]').click(); + // An age: a birth date to now, and when it next comes round. + await page.locator("#end-now").click(); await page.locator("#start-date").fill("1990-05-20"); await page.locator("#start-time").fill(""); await expect(page.locator("#span")).toHaveText(/^\d+ years, /); await expect(page.locator("#span")).not.toContainText("hour"); - await expect(page.locator("#span-extra")).toContainText(/Turns \d+/); + await expect(page.locator("#span-extra")).toContainText( + /^\d+ years on \d+ May \d{4}, in \d+ days/ + ); await expect(page.locator("#end-date")).toBeDisabled(); + await expect(page.locator(".time-total").filter({ hasText: "Weeks" })).toHaveCount(0); + // A time that cannot be read says so, rather than counting from midnight. + await page.locator("#start-time").fill("25:00"); + await page.locator("#start-time").blur(); + await expect(page.locator("#start-time")).toHaveAttribute("aria-invalid", "true"); + await expect(page.locator("#dur-assume")).toContainText("type the time as HH:MM"); + await expect(page.locator("#span")).toHaveText("—"); +}); + +test.describe("in American English", () => { + test.use({ locale: "en-US" }); + test("times are on the 24-hour clock, shown and typed", async ({ page }) => { + await open(page, PEOPLE, { + view: "meet", + rangeFrom: "2026-10-12", + rangeTo: "2026-10-12", + meetZone: "Europe/Paris", + }); + const first = page.locator(".time-suggestion").first(); + await expect(first).toContainText("18:00–19:00"); + await expect(first).toContainText("Ana 18:00 · Ben 12:00"); + await expect(page.locator("#view-meet")).not.toContainText(/\d (AM|PM)\b/); + // A window's hours are typed fields, read however they are typed and written as HH:MM. + const from = page.locator('[data-person="a"] [data-field="start"]').first(); + await expect(from).toHaveValue("08:00"); + await from.fill("930"); + await from.blur(); + await expect(from).toHaveValue("09:30"); + + await page.locator('[data-view="convert"]').click(); + await page.locator("#input").fill("1760264100"); + await expect(row(page, "This device")).toContainText("12:15:00"); + await expect(page.locator("#view-convert")).not.toContainText(/\d (AM|PM)\b/); + // Now writes the unit chosen: milliseconds once Milliseconds is pressed. + await page.locator('[data-unit="ms"]').click(); + await page.locator("#now").click(); + await expect(page.locator("#input")).toHaveValue(/^\d{13}$/); + // The time field writes the input as it is typed. + await page.locator("#pick-date").fill("2026-12-25"); + await page.locator("#pick-time").fill("9:05"); + await expect(page.locator("#input")).toHaveValue("2026-12-25T09:05:00"); + }); }); test("JWT opens Time on the token's time claims", async ({ page }) => { From 8ad2766f55f2a32460f4a6f6d81bd80db4675599 Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Tue, 13 Oct 2026 23:27:27 +0200 Subject: [PATCH 05/10] Time: Find a time first, typed dates, and the second review - Dates are typed YYYY-MM-DD fields across the site, as times are 24-hour ones: @tools/ui's time-fields.ts (was clock.ts) adds .ui-date with parseIsoDate / formatIsoDate, and a field written out in full on leaving tells the page with an input event. Time and Codes use them. - Find a time is the first view, and a new tab opens on it. - A person is removed with a red bin beside the chevron, over the card's row rather than inside its summary, so it works folded or not. - The example and a new person keep business hours: 09:00 to 17:00 on weekdays, Alex in Paris and Sam in New York. - The call is placed on the best suggestion until someone places it themselves; new dates or an opened link put it back. - Convert: Seconds and Milliseconds rewrite the timestamps typed in that unit, the same instant, instead of reading the digits again. - Duration: only one end can be now; switching one on frees the other. - The tab's draft is also saved as the page is left, so a reload just after an edit keeps it. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- AGENTS.md | 2 +- apps/codes/index.html | 18 ++- apps/codes/src/main.ts | 4 +- apps/codes/src/styles.css | 2 +- apps/time/index.html | 127 +++++++++++------ apps/time/src/convert.ts | 19 ++- apps/time/src/duration-view.ts | 9 +- apps/time/src/lib/civil.ts | 2 +- apps/time/src/lib/plan.ts | 8 +- apps/time/src/main.ts | 10 +- apps/time/src/meet.ts | 68 ++++++--- apps/time/src/styles.css | 37 ++++- docs/apps/codes.md | 9 +- docs/apps/time.md | 82 ++++++----- packages/ui/base.css | 15 +- packages/ui/src/clock.ts | 71 ---------- packages/ui/src/index.ts | 2 +- .../{clock.test.ts => time-fields.test.ts} | 29 +++- packages/ui/src/time-fields.ts | 133 ++++++++++++++++++ tests/browser/time.spec.ts | 35 ++++- 20 files changed, 465 insertions(+), 217 deletions(-) delete mode 100644 packages/ui/src/clock.ts rename packages/ui/src/{clock.test.ts => time-fields.test.ts} (51%) create mode 100644 packages/ui/src/time-fields.ts diff --git a/AGENTS.md b/AGENTS.md index fe46915..6a2702b 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -31,7 +31,7 @@ - **App wiring:** an app's `vite.config.ts` is `defineConfig(toolsApp("<slug>"))` from `@tools/ui/vite`, used to build it; the site's one dev server serves it (see [Running it](#running-it)). It sets the base path and output folder, and injects `<title>`, description, icon and manifest from the catalog — so an app's `index.html` must not set those itself (the build fails if it does). Each app keeps its own `public/icon.svg`. The page links its stylesheet in `index.html` (`<link rel="stylesheet" href="/src/styles.css">`, which `@import`s `@tools/ui/base.css`) rather than importing CSS from the script: a linked stylesheet holds the first paint, so the page never shows unstyled while the script loads under the dev server. - **Header:** every app's page has one `<header data-tools-header>` holding only its own controls. The build writes its start from the catalog — "‹ Tools", the app's name and, unless it is stable, its status badge — styled by `@tools/ui/header.css` (base.css imports it). Put the far-end controls (theme switch, Help) in `.ui-header-end`. The build fails if the marker is missing or the page writes its own home link or name. - **Light / dark:** every page follows the browser until someone presses a theme switch; from then on the choice (`tools.theme` in localStorage, shared by the whole site) is light or dark, never "system" again. The Vite plugin inlines a script that applies it before first paint. Colours are CSS custom properties with a dark base and a light override keyed on `prefers-color-scheme` and `<html data-theme>` (see `packages/ui/tokens.css`); an app adds a button and calls `bindThemeToggle(button)` from `@tools/ui`, and repaints anything drawn outside CSS (a canvas) on `THEME_EVENT`. -- **Shared UI:** icons, side panels, menus, colour pickers and SVG-to-PNG export come from `@tools/ui`, never drawn again in an app. An empty `<svg data-ui-icon="help">` (or sprite `<symbol data-ui-icon>`) is filled at build time from `icons.ts`; code uses `iconSvg`. Help and other side panels are `.ui-dock` with `bindDock` (open state kept per tab, placed under the header, full width on a phone); menus are `.ui-menu` popovers with `bindMenu` (styles in `packages/ui/panels.css`); colours are a `.color-swatch` with `bindColorSwatch`, which opens the one colour picker (hue, opacity, hex; colours as `#rrggbb` or `#rrggbbaa`, helpers in `color.ts`); a PNG of an SVG is `renderPng`. A setting that is on or off is a `.ui-switch` button (`role="switch"`, `bindSwitch`, `isOn` / `setOn`), never a checkbox; work that waits for typing to pause is `debounce`; a tab's draft is `readDraft` / `writeDraft` (versioned, kept per tab, `restoreFields` / `fieldValues` for form fields). A time of day is on the 24-hour clock in every app, whatever the browser's language: format it with `hourCycle: "h23"`, and take it in a `.ui-clock` field (`CLOCK_FIELD`, `bindClockFields`, `parseClock` / `formatClock` in `clock.ts`), never a native `time` or `datetime-local` field, which follows the language into AM/PM. In the text tools (`tool.css`), a card is `.ui-card` with its heading row `.ui-row`, a count or error beside the heading is `.ui-note`, and an editor holding data kept exactly is `.ui-editor.ui-data`. One app opens another on the same input by writing that app's draft for the tab and following a plain link (Codec ⇄ Digests, JWT → Time). +- **Shared UI:** icons, side panels, menus, colour pickers and SVG-to-PNG export come from `@tools/ui`, never drawn again in an app. An empty `<svg data-ui-icon="help">` (or sprite `<symbol data-ui-icon>`) is filled at build time from `icons.ts`; code uses `iconSvg`. Help and other side panels are `.ui-dock` with `bindDock` (open state kept per tab, placed under the header, full width on a phone); menus are `.ui-menu` popovers with `bindMenu` (styles in `packages/ui/panels.css`); colours are a `.color-swatch` with `bindColorSwatch`, which opens the one colour picker (hue, opacity, hex; colours as `#rrggbb` or `#rrggbbaa`, helpers in `color.ts`); a PNG of an SVG is `renderPng`. A setting that is on or off is a `.ui-switch` button (`role="switch"`, `bindSwitch`, `isOn` / `setOn`), never a checkbox; work that waits for typing to pause is `debounce`; a tab's draft is `readDraft` / `writeDraft` (versioned, kept per tab, `restoreFields` / `fieldValues` for form fields). Dates are `YYYY-MM-DD` and times of day on the 24-hour clock in every app, whatever the browser's language: format a time with `hourCycle: "h23"`, and take dates and times in typed `.ui-date` and `.ui-clock` fields (`DATE_FIELD`, `CLOCK_FIELD`, `bindTimeFields`, `parseIsoDate`, `parseClock` and their `format…` in `time-fields.ts`), never a native `date`, `time` or `datetime-local` field, which follows the language into `10/13/2026` and AM/PM. In the text tools (`tool.css`), a card is `.ui-card` with its heading row `.ui-row`, a count or error beside the heading is `.ui-note`, and an editor holding data kept exactly is `.ui-editor.ui-data`. One app opens another on the same input by writing that app's draft for the tab and following a plain link (Codec ⇄ Digests, JWT → Time). - **Settings:** preferences for the whole app go in a Settings (cog) menu in the header; what belongs to the document being edited stays with the document (the SVG app's size, grid and background, in its Document panel). - **Phone header:** the header collapses in levels, each where the one before stops fitting (measure what the header needs; do not guess). First "‹ Tools Name" reduces to "‹", at the app's `compactHeader` width in the catalog (the build writes that rule; 720px by default); an app whose name in full needs the room may drop "Tools" alone before that, at its `compactHome`. Then the app's own steps (JSON: the views become a dropdown at 645px, then the phone layout at 475px; SVG: the phone layout at 590px). The phone layout is one row that never wraps. What does not fit folds into a "⋯" menu, Help stays last in the bar, and the theme switch stays in the bar at every width. Buttons keep their size and spacing at every width (the header's measures in `header.css`: `--ui-control`, `--ui-group-gap`, `--ui-header-gap`); a level only moves, hides or folds things. Layout and button sizes follow the width alone, never the pointer (`pointer: coarse`), so a touch screen gets the same layout as a mouse at the same width; only what happens on a canvas (hit targets, drag thresholds) and which help is shown adapt to touch. The header stays a solid row at the top in every app; an app whose work is a canvas may float its drawing tools in a bar over the bottom of it (the SVG app). Buttons that belong together are joined into one control with `.ui-seg` (`header.css`). - **Install and offline:** the site installs as one app — the index, with every tool inside its scope, so moving between tools never leaves the installed window. Every page links the one manifest at the site root (emitted by the index build; each listed app is a shortcut in it). Pages call `registerServiceWorker()` from `@tools/ui`, which registers the one worker at the site root; `scripts/build-site.ts` writes it last (`packages/ui/src/site-worker.ts` + `packages/ui/sw.js`) with a version hash and every file of every app to precache, so one visit makes all tools work offline and every deploy replaces the previous cache. Nothing is registered under the dev server. diff --git a/apps/codes/index.html b/apps/codes/index.html index ca4ee28..07cbb8b 100644 --- a/apps/codes/index.html +++ b/apps/codes/index.html @@ -241,9 +241,14 @@ <h2 class="codes-step"><span aria-hidden="true">2</span>Content</h2> <span id="ev-start-label">Starts</span> <div class="codes-when"> <input - class="ui-input" + class="ui-input ui-date" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="YYYY-MM-DD" + maxlength="10" id="ev-start-date" - type="date" aria-labelledby="ev-start-label" /> <input @@ -263,9 +268,14 @@ <h2 class="codes-step"><span aria-hidden="true">2</span>Content</h2> <span id="ev-end-label">Ends · an hour later if empty</span> <div class="codes-when"> <input - class="ui-input" + class="ui-input ui-date" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="YYYY-MM-DD" + maxlength="10" id="ev-end-date" - type="date" aria-labelledby="ev-end-label" /> <input diff --git a/apps/codes/src/main.ts b/apps/codes/src/main.ts index e6e3da9..5ec95af 100644 --- a/apps/codes/src/main.ts +++ b/apps/codes/src/main.ts @@ -1,5 +1,5 @@ import { - bindClockFields, + bindTimeFields, bindColorSwatch, bindToolHelp, formatClock, @@ -659,7 +659,7 @@ function morph(shown: Element, next: Element): void { // ---------- Controls ---------- -bindClockFields(); +bindTimeFields(); for (const id of ids) { const element = byId(id); diff --git a/apps/codes/src/styles.css b/apps/codes/src/styles.css index a8e4b8b..c483eb7 100644 --- a/apps/codes/src/styles.css +++ b/apps/codes/src/styles.css @@ -99,7 +99,7 @@ display: flex; gap: 6px; } -.codes-when input[type="date"] { +.codes-when .ui-date { flex: 1; min-width: 0; } diff --git a/apps/time/index.html b/apps/time/index.html index e204bc0..2d2d32b 100644 --- a/apps/time/index.html +++ b/apps/time/index.html @@ -9,9 +9,10 @@ <!-- "‹ Tools" and the name are written in at build time, from the catalog. --> <header class="ui-header time-header" data-tools-header> <div class="ui-seg time-views" role="group" aria-label="View"> - <button type="button" class="ui-btn" data-view="convert" aria-pressed="true">Convert</button - ><button type="button" class="ui-btn" data-view="meet" aria-pressed="false"> + <button type="button" class="ui-btn" data-view="meet" aria-pressed="true"> Find a time</button + ><button type="button" class="ui-btn" data-view="convert" aria-pressed="false"> + Convert</button ><button type="button" class="ui-btn" data-view="duration" aria-pressed="false"> Duration </button> @@ -19,8 +20,8 @@ <!-- The same choice on a phone, where three buttons would not fit beside the others. --> <div class="ui-view-picker"> <select class="ui-view-select" id="view-select" aria-label="View"> - <option value="convert">Convert</option> <option value="meet">Find a time</option> + <option value="convert">Convert</option> <option value="duration">Duration</option> </select> <svg data-ui-icon="chevron-down" aria-hidden="true"></svg> @@ -44,7 +45,7 @@ <datalist id="zone-list"></datalist> <!-- Convert: one value, or one a line. --> - <div class="time-view" id="view-convert"> + <div class="time-view" id="view-convert" hidden> <section class="ui-card" aria-labelledby="input-title"> <div class="ui-row"> <h2 id="input-title"><label for="input">Time</label></h2> @@ -97,8 +98,13 @@ <h2 id="input-title"><label for="input">Time</label></h2> <div class="time-option"> <span id="pick-label">Or pick</span> <input - class="ui-input" - type="date" + class="ui-input ui-date" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="YYYY-MM-DD" + maxlength="10" id="pick-date" aria-label="Date to convert" aria-describedby="pick-label" @@ -159,7 +165,7 @@ <h2 id="batch-title">Results</h2> </div> <!-- Find a time: people, the search, what fits, and a day's timeline. --> - <div class="time-view meet" id="view-meet" hidden> + <div class="time-view meet" id="view-meet"> <div class="meet-side"> <section class="ui-card" aria-labelledby="people-title"> <div class="ui-row"> @@ -210,9 +216,27 @@ <h2 id="search-title">Search</h2> </div> <div class="time-option"> <label for="range-from">From</label> - <input class="ui-input" type="date" id="range-from" /> + <input + class="ui-input ui-date" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="YYYY-MM-DD" + maxlength="10" + id="range-from" + /> <label for="range-to">to</label> - <input class="ui-input" type="date" id="range-to" /> + <input + class="ui-input ui-date" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="YYYY-MM-DD" + maxlength="10" + id="range-to" + /> </div> <div class="time-option"> <label for="meet-zone">Show times in</label> @@ -295,8 +319,13 @@ <h2 id="measure-title">Measure</h2> <legend id="start-legend">From</legend> <div class="time-option"> <input - class="ui-input" - type="date" + class="ui-input ui-date" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="YYYY-MM-DD" + maxlength="10" id="start-date" aria-labelledby="start-legend start-date-label" /> @@ -330,8 +359,13 @@ <h2 id="measure-title">Measure</h2> <legend id="end-legend">To</legend> <div class="time-option"> <input - class="ui-input" - type="date" + class="ui-input ui-date" + type="text" + inputmode="numeric" + autocomplete="off" + spellcheck="false" + placeholder="YYYY-MM-DD" + maxlength="10" id="end-date" aria-labelledby="end-legend end-date-label" /> @@ -457,38 +491,14 @@ <h2 class="ui-dock-title">Help</h2> its city. "Now" is this device's clock, as exact as its setting. </li> <li> - Times are on the 24-hour clock. Type them as 14:30, or 1430 or 9:30; a field that cannot - be read is outlined in red. + Dates are YYYY-MM-DD and times are on the 24-hour clock. Type a date as 2026-10-13 or + 20261013, a time as 14:30, 1430 or 9:30; a field that cannot be read is outlined in red. </li> <li> What you type stays in this tab; reloading restores it. People and pinned zones are kept in this browser. Light / dark applies to every tool. </li> </ul> - <h3>Convert</h3> - <ul> - <li> - Type a Unix timestamp, an ISO 8601 date (2026-10-12T14:30, with or without an offset), - or an HTTP or email date. A number from 100,000,000,000 up is read as milliseconds, - below that as seconds; Seconds or Milliseconds overrides it. - </li> - <li> - A date or time without an offset is read in the zone you choose, and a date alone at - 00:00 there; the result says so. Where clocks go back, a time happens twice: choose - which. Where they go forward, it never happens: it is read as the time on the clock - after the change, and the result says so. - </li> - <li> - Results show Unix seconds and milliseconds, UTC, this device's zone, the zone it was - read in and your pinned zones, each with its offset at that date, plus an HTTP date and - how long ago or ahead it is. Each has a copy button. - </li> - <li> - Several lines convert together into a table. A line may start with a label, as in - <code>exp: 1760264100</code> or a JSON line <code>"exp": 1760264100,</code>. A time that - happens twice is not guessed in a table: give it an offset. - </li> - </ul> <h3>Find a time</h3> <ul> <li> @@ -500,7 +510,7 @@ <h3>Find a time</h3> <li> Weekly windows apply on the days ticked. A date exception replaces the windows that start on that date; with no windows, the person is unavailable that day. The chevron - beside a name folds or opens that person. + beside a name folds or opens that person; the red bin removes them. </li> <li> The whole call must fit, within the dates searched. On a night the clocks change, a @@ -515,21 +525,48 @@ <h3>Find a time</h3> stand in the way. </li> <li> - The timeline shows one day in the zone you choose, with each person's own hours above - their row. Click it, or choose a suggestion, to place the call; Copy summary gives - everyone's own date and time. + The call starts on the best suggestion. The timeline shows one day in the zone you + choose, with each person's own hours above their row. Click it, or choose another + suggestion, to place the call elsewhere; Copy summary gives everyone's own date and + time. </li> <li> Share link copies a link holding the people and their windows. It is a snapshot of what was entered, not live answers. Opening one asks before replacing or adding to yours. </li> </ul> + <h3>Convert</h3> + <ul> + <li> + Type a Unix timestamp, an ISO 8601 date (2026-10-12T14:30, with or without an offset), + or an HTTP or email date. A number from 100,000,000,000 up is read as milliseconds, + below that as seconds. Seconds or Milliseconds rewrites the timestamp in that unit, the + same moment, and reads what you type next in it. + </li> + <li> + A date or time without an offset is read in the zone you choose, and a date alone at + 00:00 there; the result says so. Where clocks go back, a time happens twice: choose + which. Where they go forward, it never happens: it is read as the time on the clock + after the change, and the result says so. + </li> + <li> + Results show Unix seconds and milliseconds, UTC, this device's zone, the zone it was + read in and your pinned zones, each with its offset at that date, plus an HTTP date and + how long ago or ahead it is. Each has a copy button. + </li> + <li> + Several lines convert together into a table. A line may start with a label, as in + <code>exp: 1760264100</code> or a JSON line <code>"exp": 1760264100,</code>. A time that + happens twice is not guessed in a table: give it an offset. + </li> + </ul> <h3>Duration</h3> <ul> <li> From and To are each a date, with an optional time, or Now. To starts on Now: give a - birth date for an age, or the day you met. For a countdown, switch From to Now. Without - a time, 00:00 in the chosen zone is assumed, and the result says so. + birth date for an age, or the day you met. For a countdown, switch From to Now, which + switches To back to a date. Without a time, 00:00 in the chosen zone is assumed, and the + result says so. </li> <li> Calendar counts whole years, months and days on the wall clock, so a day of 23 or 25 diff --git a/apps/time/src/convert.ts b/apps/time/src/convert.ts index 7a9c83e..78e228f 100644 --- a/apps/time/src/convert.ts +++ b/apps/time/src/convert.ts @@ -317,9 +317,26 @@ export function convertView(app: App): View { choice = undefined; update(); }); + /** + * Seconds and Milliseconds write each timestamp typed in that unit, the same instant: 1760264100 + * becomes 1760264100000, not 21 January 1970. What is typed after is read in the unit chosen. + */ + function rewriteIn(next: Unit): void { + input.value = input.value + .split("\n") + .map((line) => { + const { value } = splitLabel(line); + const parsed = value ? parseTime(value, requested()) : undefined; + if (parsed?.kind !== "unix" || parsed.unit === next) return line; + return line.replace(value, next === "ms" ? String(parsed.ms) : unixSeconds(parsed.ms)); + }) + .join("\n"); + } for (const button of unitButtons) button.addEventListener("click", () => { - unit = button.dataset.unit as UnitChoice; + const next = button.dataset.unit as UnitChoice; + if (next !== "auto") rewriteIn(next); + unit = next; update(); }); const fromPicker = () => { diff --git a/apps/time/src/duration-view.ts b/apps/time/src/duration-view.ts index 4936991..40a0ec8 100644 --- a/apps/time/src/duration-view.ts +++ b/apps/time/src/duration-view.ts @@ -191,7 +191,14 @@ export function durationView(app: App): View { delete choices[end]; update(); }); - bindSwitch(now(end), () => update()); + bindSwitch(now(end), () => { + // Now to now is no span: the other end goes back to a date, ready to be typed. + const other = end === "start" ? "end" : "start"; + const freed = isOn(now(end)) && isOn(now(other)); + if (freed) setOn(now(other), false); + update(); + if (freed && !field(other, "date").value) field(other, "date").focus(); + }); } bindSwitch(live, () => update()); byId("span-copy").addEventListener("click", async () => { diff --git a/apps/time/src/lib/civil.ts b/apps/time/src/lib/civil.ts index e33f402..896596a 100644 --- a/apps/time/src/lib/civil.ts +++ b/apps/time/src/lib/civil.ts @@ -122,7 +122,7 @@ export function formatDate(date: PlainDate): string { return `${formatYear(date.year)}-${pad(date.month)}-${pad(date.day)}`; } -/** A date as an `<input type="date">` holds it, `YYYY-MM-DD`; undefined when it is not one. */ +/** A date as stored and as a date field writes it out, `YYYY-MM-DD`; undefined when it is not one. */ export function parseDate(value: string): PlainDate | undefined { const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value.trim()); if (!match) return undefined; diff --git a/apps/time/src/lib/plan.ts b/apps/time/src/lib/plan.ts index ed8279d..67091b7 100644 --- a/apps/time/src/lib/plan.ts +++ b/apps/time/src/lib/plan.ts @@ -116,15 +116,13 @@ export function sharePlan(plan: Plan): Record<string, unknown> { }; } -/** A new person: one window, preferred every day, from 09:00 to 17:00 unless other hours are given. */ -export function newPerson(name: string, zone: string, from = 9, until = 17): Person { +/** A new person: business hours, preferred from 09:00 to 17:00 on weekdays. */ +export function newPerson(name: string, zone: string): Person { return { id: newId(), name, zone, - windows: [ - { days: [1, 2, 3, 4, 5, 6, 7], start: from * 60, end: until * 60, level: "preferred" }, - ], + windows: [{ days: [1, 2, 3, 4, 5], start: 9 * 60, end: 17 * 60, level: "preferred" }], exceptions: [], relaxed: false, }; diff --git a/apps/time/src/main.ts b/apps/time/src/main.ts index a6a8c3a..97b4509 100644 --- a/apps/time/src/main.ts +++ b/apps/time/src/main.ts @@ -1,5 +1,5 @@ import { - bindClockFields, + bindTimeFields, bindToolHelp, byId, debounce, @@ -19,7 +19,7 @@ bindToolHelp("time"); registerServiceWorker(); type ViewName = "convert" | "meet" | "duration"; -const NAMES: readonly ViewName[] = ["convert", "meet", "duration"]; +const NAMES: readonly ViewName[] = ["meet", "convert", "duration"]; /** The tab's draft: every view's fields, and which view is open. Other apps may write it too. */ const DRAFT = 1; @@ -30,7 +30,7 @@ const views = {} as Record<ViewName, View>; // A share link opens on the people it holds. let current: ViewName = location.hash.startsWith(SHARE_PREFIX) ? "meet" - : (NAMES.find((name) => name === draft.value.view) ?? "convert"); + : (NAMES.find((name) => name === draft.value.view) ?? "meet"); function save(): void { // An incompatible draft stays as it was: this tab works without saving over it. @@ -48,6 +48,8 @@ function save(): void { } } const saveSoon = debounce(save, 300); +// A reload or a closed tab just after an edit still keeps it. +window.addEventListener("pagehide", save); const app: App = { prefs, @@ -86,7 +88,7 @@ for (const button of viewButtons) button.addEventListener("click", () => show(button.dataset.view as ViewName)); viewSelect.addEventListener("change", () => show(viewSelect.value as ViewName)); -bindClockFields(); +bindTimeFields(); if (draft.error) { const status = byId("draft-status"); diff --git a/apps/time/src/meet.ts b/apps/time/src/meet.ts index 3a3dc0c..0d6f002 100644 --- a/apps/time/src/meet.ts +++ b/apps/time/src/meet.ts @@ -5,6 +5,7 @@ import { byId, CLOCK_FIELD, copyText, + DATE_FIELD, debounce, formatClock, iconSvg, @@ -66,15 +67,12 @@ const LEVEL_NAMES: Record<Level, string> = { export const SHARE_PREFIX = "#plan="; /** - * First visit: two people in two zones, one window each, overlapping for three hours (19:00 to - * 22:00 in Paris is 13:00 to 16:00 in New York): enough for every part of the view to show. + * First visit: two people in business hours, 09:00 to 17:00 on weekdays, in Paris and New York. + * They overlap for two hours (15:00 to 17:00 in Paris is 09:00 to 11:00 in New York). */ function examplePlan(): Plan { return { - people: [ - newPerson("Alex", "Europe/Paris", 18, 22), - newPerson("Sam", "America/New_York", 13, 17), - ], + people: [newPerson("Alex", "Europe/Paris"), newPerson("Sam", "America/New_York")], duration: DEFAULT_DURATION, }; } @@ -123,6 +121,8 @@ export function meetView(app: App): View { let suggestions: Suggestion[] = []; let selected: number | undefined = typeof saved.selected === "number" ? saved.selected : undefined; + /** Whether the call was placed by the person, rather than on the best suggestion. */ + let placedByHand = saved.placedByHand === true; let day: PlainDate | undefined = parseDate(text(saved.day)); let visible = false; @@ -256,6 +256,8 @@ export function meetView(app: App): View { h("span", { class: "time-person-name" }, person.name || "Unnamed"), h("span", { class: "time-person-meta" }, meta), person.relaxed && h("span", { class: "time-badge" }, "Relaxed"), + // Room for the remove button, which sits over the row beside the chevron. + h("span", { class: "time-person-room", "aria-hidden": "true" }), h("span", { class: "time-person-chevron", "aria-hidden": "true" }, iconSvg("chevron-down")) ), h( @@ -295,8 +297,7 @@ export function meetView(app: App): View { "div", { class: "time-exception-head" }, h("input", { - class: "ui-input", - type: "date", + ...DATE_FIELD, "data-field": "date", value: x.date, "aria-label": "Date", @@ -339,13 +340,7 @@ export function meetView(app: App): View { h( "div", { class: "time-person-foot" }, - h("label", { class: "time-switch" }, relaxed, "Allow unavailable time"), - h( - "button", - { type: "button", class: "ui-btn ui-btn--small", "data-action": "remove-person" }, - iconSvg("trash"), - "Remove" - ) + h("label", { class: "time-switch" }, relaxed, "Allow unavailable time") ) ) ); @@ -355,15 +350,32 @@ export function meetView(app: App): View { refreshSummary(person); }); if (!valid) zoneInput.setAttribute("aria-invalid", "true"); - return card; + // Outside the summary, where a button would be one control inside another, and outside the + // card's body, so it stays in reach while the card is folded. + const remove = h( + "button", + { + type: "button", + class: "ui-btn ui-icon-btn time-person-remove", + "data-action": "remove-person", + "aria-label": `Remove ${person.name || "this person"}`, + title: "Remove person", + }, + iconSvg("trash") + ); + return h("div", { class: "time-person-box", "data-person": person.id }, card, remove); } function refreshSummary(person: Person): void { - const card = peopleBox.querySelector<HTMLDetailsElement>(`[data-person="${person.id}"]`); - if (!card) return; - const old = card.querySelector("summary")!; - const fresh = personCard(person).querySelector("summary")!; - old.replaceChildren(...fresh.childNodes); + const box = peopleBox.querySelector<HTMLElement>( + `.time-person-box[data-person="${person.id}"]` + ); + if (!box) return; + const fresh = personCard(person); + box.querySelector("summary")!.replaceChildren(...fresh.querySelector("summary")!.childNodes); + box + .querySelector(".time-person-remove")! + .replaceWith(fresh.querySelector(".time-person-remove")!); } function renderPeople(): void { @@ -677,6 +689,11 @@ export function meetView(app: App): View { renderDiagnosis(search); if (!day) day = wallAt(suggestions[0]?.start ?? search.from, zone()); } + // Until someone places the call themselves, it is on the best suggestion. + if (!placedByHand) { + selected = suggestions[0]?.start; + if (selected !== undefined) day = wallAt(selected, zone()); + } shownCount = PAGE; renderSuggestions(); renderTimeline(); @@ -844,6 +861,7 @@ export function meetView(app: App): View { } function pick(start: number): void { + placedByHand = true; selected = start; day = wallAt(start, zone()); renderSuggestions(); @@ -886,9 +904,11 @@ export function meetView(app: App): View { changed(); }); for (const input of [rangeFrom, rangeTo]) - input.addEventListener("change", () => { + // Typed dates: searched once the typing pauses. + input.addEventListener("input", () => { day = undefined; - research(); + placedByHand = false; + researchSoon(); }); // ---------- Sharing ---------- @@ -918,6 +938,7 @@ export function meetView(app: App): View { pending = undefined; showSnapshot(); selected = undefined; + placedByHand = false; day = undefined; renderPeople(); savePlan(); @@ -959,6 +980,7 @@ export function meetView(app: App): View { rangeFrom: rangeFrom.value, rangeTo: rangeTo.value, selected, + placedByHand, day: day ? formatDate(day) : "", folded: [...folded], }), diff --git a/apps/time/src/styles.css b/apps/time/src/styles.css index 3954935..f39dc73 100644 --- a/apps/time/src/styles.css +++ b/apps/time/src/styles.css @@ -35,7 +35,7 @@ .time [aria-invalid="true"] { border-color: var(--danger); } -.time input[type="date"], +.time .ui-date, .time .ui-clock { min-height: 32px; padding: 4px 8px; @@ -216,7 +216,6 @@ } .time-person { - margin-top: 8px; border: 1px solid var(--border); border-radius: var(--radius); background: var(--bg); @@ -234,12 +233,42 @@ .time-person summary::-webkit-details-marker { display: none; } +/* The remove button sits over the summary row, in the room left for it beside the chevron. */ +.time-person-box { + position: relative; + margin-top: 8px; +} +.time-person-room { + flex: none; + width: 28px; + margin-left: auto; +} +.time-person-remove.ui-icon-btn { + position: absolute; + top: 9px; + right: 49px; + width: 28px; + min-width: 28px; + height: 28px; + min-height: 28px; + border-color: transparent; + background: transparent; + color: var(--danger); +} +.time-person-remove.ui-icon-btn:hover, +.time-person-remove.ui-icon-btn:focus-visible { + border-color: color-mix(in srgb, var(--danger) 45%, transparent); + background: color-mix(in srgb, var(--danger) 12%, transparent); +} +.time-person-remove svg { + width: 18px; + height: 18px; +} .time-person-chevron { display: grid; place-items: center; width: 28px; height: 28px; - margin-left: auto; border-radius: var(--radius); color: var(--muted); transition: transform 0.15s ease; @@ -391,7 +420,7 @@ } .time-suggestion[aria-pressed="true"] { border-color: var(--accent); - background: color-mix(in srgb, var(--accent) 14%, var(--bg)); + background: color-mix(in srgb, var(--accent) 10%, var(--bg)); } .time-suggestion-when { font-size: 14px; diff --git a/docs/apps/codes.md b/docs/apps/codes.md index 913d2c3..02d07f7 100644 --- a/docs/apps/codes.md +++ b/docs/apps/codes.md @@ -8,10 +8,11 @@ hold does not change. A code can hold a link (`https://` is added when it is missing), plain text, an email, a call, an SMS, a WhatsApp chat, Wi-Fi credentials, a vCard contact or a calendar event in the local time -zone, its start and end each a date and a time on the 24-hour clock (an end time alone is on the -start's date; with no end, the event lasts an hour). A barcode is Code 128 (printable ASCII, up to 80 characters), EAN-13 or UPC-A. Enter one -digit less and the check digit is calculated; enter all of them and it is checked. A Wi-Fi password -is readable by anyone who scans the code. +zone, its start and end each a date (`YYYY-MM-DD`) and a time on the 24-hour clock (an end time +alone is on the start's date; with no end, the event lasts an hour). A barcode is Code 128 +(printable ASCII, up to 80 characters), EAN-13 or UPC-A. Enter one digit less and the check digit +is calculated; enter all of them and it is checked. A Wi-Fi password is readable by anyone who +scans the code. Colours, shapes, a logo and a frame change how a QR code looks. A barcode takes the colours only. Every colour has an opacity; a background at 0% is transparent. A logo can cover the middle, with diff --git a/docs/apps/time.md b/docs/apps/time.md index 396b88a..a2df9aa 100644 --- a/docs/apps/time.md +++ b/docs/apps/time.md @@ -3,43 +3,20 @@ Convert Unix timestamps and ISO dates across time zones, find a call time for people in several zones, and measure durations and ages. -Three views share the same time-zone field and date controls: **Convert**, **Find a time** and -**Duration**. Zones are named IANA zones (`Europe/Paris`), read from the browser's own copy of +Three views share the same time-zone field and date controls: **Find a time**, which a new tab +opens on, **Convert** and **Duration**. Zones are named IANA zones (`Europe/Paris`), read from the browser's own copy of the [time-zone database](https://www.iana.org/time-zones) through `Intl`, so every offset is the one at that date, fractional hours and daylight-saving time included. A zone can be typed by name in any case or by its city alone. "Now" is the device clock, as exact as its setting; it is not an independently synchronised clock like [Time.is](https://time.is/). -Times of day are on the 24-hour clock, shown and typed, whatever the browser's language: a time is -typed as `14:30` (or `1430`, `9:30`) in a field of its own, since a native time field would follow -the language into AM and PM. Dates keep the browser's date picker. What is typed stays in the -tab's draft. People, windows and pinned zones are kept in the browser. The calendar is the +Dates are `YYYY-MM-DD` and times of day are on the 24-hour clock, shown and typed, whatever the +browser's language. Each is typed in a field of its own, a date as `2026-10-13` (or `2026-10-5`, +`20261013`) and a time as `14:30` (or `1430`, `9:30`), since native date and time fields would +follow the language into `10/13/2026` and AM/PM; a field is written out in full when it is left, +and marked while it cannot be read. What is typed stays in the tab's draft. People, windows and pinned zones are kept in the browser. The calendar is the proleptic Gregorian one, with year 0 for 1 BC. -## Convert - -The input takes a Unix timestamp, an ISO 8601 / RFC 3339 date (extended or basic, with an -optional `[Zone/Name]` suffix), or an HTTP or email date (RFC 9110, RFC 2822). A number from -100,000,000,000 up is read as milliseconds and below that as seconds; the note says which, and -Seconds or Milliseconds overrides it. Digits past milliseconds are dropped, and the note says so. - -A date or time without an offset is read in the chosen zone, and a date alone at 00:00 there; the -note states the assumption. Where clocks go back, a time happens twice and nothing is shown until -the person chooses the first or second; where they go forward, it never happens, and it is read -as the time on the clock after the change (02:30 in a one-hour gap is 03:30), with the reason. -A date picker and a time field write the input as they change, and follow it. Now writes the -current time in the unit chosen: Unix seconds, or milliseconds when Milliseconds is pressed. - -Results give Unix seconds and milliseconds, UTC, the device's zone, the zone the input was read in -and each pinned zone (New York, London and Tokyo until changed), as ISO 8601 with the offset and -abbreviation at that instant and as the reader's language writes it, plus an HTTP date and a -relative time ("in 3 hours") that keeps up with the clock. Each has a copy button. - -Several lines convert together into a table (label, input, Unix seconds, UTC, the chosen zone, a -note), copied as tab-separated text. A line may start with a label, `exp: 1760264100`, or be a -JSON member, `"exp": 1760264100,`. A time that happens twice is not guessed in a table: the line -asks for an offset. JWT's payload card opens Time on the token's `exp`, `iat` and `nbf`. - ## Find a time Each person has a name, a zone, and windows on their own wall clock: **preferred**, @@ -49,9 +26,9 @@ after its start runs past midnight; one that ends where it starts is the whole d apply on the weekdays ticked; a date exception replaces the windows that start on that date, and with none the person is unavailable that day. A window is two lines: its days, with the button that removes it in the corner, then its hours and level. A person's card folds to their name and -zone with the chevron at the end of its row. A new person prefers 09:00 to 17:00 every day; the -first visit opens on two example people, Alex in Paris (18:00 to 22:00) and Sam in New York (13:00 -to 17:00). +zone with the chevron at the end of its row; the red bin beside the chevron removes them, folded +or not. A new person keeps business hours, preferred from 09:00 to 17:00 on weekdays; the first +visit opens on two such people, Alex in Paris and Sam in New York, who overlap for two hours. The search covers up to 31 days, from midnight to midnight in the zone the results are shown in, for a call of 15 minutes to 3 hours. Windows are resolved date by date in each person's zone, so a @@ -77,9 +54,11 @@ others would find times, with a button to allow that person's unavailable time. The timeline shows one day in the chosen zone: a row for everyone combined and one a person, all aligned to the same instants, each with that person's own hours and date changes above it. Preferred and acceptable are coloured, unavailable is hatched, and each row also reads as text -for a screen reader. Choosing a suggestion, clicking the timeline or typing a time places the call; -it then says how it sits with everyone and gives a summary to copy, with each person's own date -and time. +for a screen reader. The call is placed on the best suggestion, and stays there as people and +windows change, until someone places it themselves: choosing a suggestion, clicking the timeline +or typing a time. New dates to search or an opened share link put it back on the best suggestion. +The placed call says how it sits with everyone and gives a summary to copy, with each person's +own date and time. Share link copies a link holding the people, their windows and the call length: JSON, compressed with `deflate-raw` where the browser can, in Base64url after `#plan=`. Nothing is sent to a @@ -89,12 +68,39 @@ the share link are one format, read field by field, and versioned for a change t A plan or link from a later version of Time is not read as this one: the page says so, and leaves the saved people as they are. +## Convert + +The input takes a Unix timestamp, an ISO 8601 / RFC 3339 date (extended or basic, with an +optional `[Zone/Name]` suffix), or an HTTP or email date (RFC 9110, RFC 2822). A number from +100,000,000,000 up is read as milliseconds and below that as seconds; the note says which. +Seconds or Milliseconds writes each timestamp typed in that unit, the same instant (1760264100 +becomes 1760264100000), and reads what is typed after in it. Digits past milliseconds are dropped, +and the note says so. + +A date or time without an offset is read in the chosen zone, and a date alone at 00:00 there; the +note states the assumption. Where clocks go back, a time happens twice and nothing is shown until +the person chooses the first or second; where they go forward, it never happens, and it is read +as the time on the clock after the change (02:30 in a one-hour gap is 03:30), with the reason. +A date field and a time field write the input as they are typed, and follow it. Now writes the +current time in the unit chosen: Unix seconds, or milliseconds when Milliseconds is pressed. + +Results give Unix seconds and milliseconds, UTC, the device's zone, the zone the input was read in +and each pinned zone (New York, London and Tokyo until changed), as ISO 8601 with the offset and +abbreviation at that instant and as the reader's language writes it, plus an HTTP date and a +relative time ("in 3 hours") that keeps up with the clock. Each has a copy button. + +Several lines convert together into a table (label, input, Unix seconds, UTC, the chosen zone, a +note), copied as tab-separated text. A line may start with a label, `exp: 1760264100`, or be a +JSON member, `"exp": 1760264100,`. A time that happens twice is not guessed in a table: the line +asks for an offset. JWT's payload card opens Time on the token's `exp`, `iat` and `nbf`. + ## Duration From and To are each a date, with an optional time, or Now; To starts on Now, so "how old am I?" and "how long since we met?" need only the first date, and "how long until my holiday?" switches -From to Now. A time is optional: without one, 00:00 in the chosen zone is assumed, and the note -says so. Ambiguous and skipped times are resolved as in Convert. +From to Now. Only one end can be now: switching one on switches the other back to its date. A +time is optional: without one, 00:00 in the chosen zone is assumed, and the note says so. +Ambiguous and skipped times are resolved as in Convert. Calendar counts whole years, months and days on the wall clock, then the time left when every dated end has a time. A day of 23 or 25 hours at a clock change is still one day. A month too diff --git a/packages/ui/base.css b/packages/ui/base.css index 2dd0641..a6e0e56 100644 --- a/packages/ui/base.css +++ b/packages/ui/base.css @@ -58,16 +58,23 @@ a { border-color: var(--accent); } -/* A time of day on the 24-hour clock (`clock.ts`): wide enough for its HH:MM placeholder, or for - 23:59:59. */ +/* Typed dates and times (`time-fields.ts`): wide enough for their YYYY-MM-DD and HH:MM + placeholders, or for 23:59:59. */ +.ui-clock, +.ui-date { + font-variant-numeric: tabular-nums; +} .ui-clock { width: calc(6.5ch + 22px); - font-variant-numeric: tabular-nums; } .ui-clock[data-seconds] { width: calc(8.5ch + 22px); } -.ui-clock[aria-invalid="true"] { +.ui-date { + width: calc(12ch + 22px); +} +.ui-clock[aria-invalid="true"], +.ui-date[aria-invalid="true"] { border-color: var(--danger); } diff --git a/packages/ui/src/clock.ts b/packages/ui/src/clock.ts deleted file mode 100644 index 7bd07ef..0000000 --- a/packages/ui/src/clock.ts +++ /dev/null @@ -1,71 +0,0 @@ -/** - * Times of day across the site are on the 24-hour clock, whatever the browser's language. - * - * A time field is a typed `.ui-clock` field rather than a native time field: a native one writes - * the time as the browser's language does, AM/PM in American English, with no way to ask - * otherwise. A field accepts `14:30`, `9:30` or `0930` (and seconds where it shows them, - * `data-seconds`), is marked invalid while it cannot be read, and is written out as `HH:MM` once - * the person leaves it. Pages read it with `parseClock`. - */ - -const pad = (value: number) => String(value).padStart(2, "0"); - -/** `HH:MM` from minutes after midnight, on the 24-hour clock; `HH:MM:SS` given the seconds. */ -export function formatClock(minutes: number, second?: number): string { - const clock = `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; - return second === undefined ? clock : `${clock}:${pad(second)}`; -} - -/** - * A time of day as typed on the 24-hour clock: `14:30`, `14:30:15`, `9:30`, `0930`, `14h30` or - * just `9`. Fractions of a second are dropped. Undefined when it is not one. - */ -export function parseClock(value: string): { minutes: number; second: number } | undefined { - const match = /^(\d{1,2})(?:[:.h]?(\d{2})(?:[:.]?(\d{2})(?:[.,]\d+)?)?)?$/i.exec(value.trim()); - if (!match) return undefined; - const [hour, minute, second] = [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)]; - return hour < 24 && minute < 60 && second < 60 - ? { minutes: hour * 60 + minute, second } - : undefined; -} - -const isClock = (target: EventTarget | null): target is HTMLInputElement => - target instanceof HTMLInputElement && target.classList.contains("ui-clock"); - -function mark(input: HTMLInputElement): void { - if (input.value.trim() !== "" && parseClock(input.value) === undefined) - input.setAttribute("aria-invalid", "true"); - else input.removeAttribute("aria-invalid"); -} - -let bound = false; -/** Binds every clock field on the page, those drawn later included. */ -export function bindClockFields(): void { - if (bound) return; - bound = true; - document.addEventListener("input", (event) => { - // While typing, only clear a mark: "9:" is on its way to being a time. - if (isClock(event.target) && parseClock(event.target.value)) mark(event.target); - }); - document.addEventListener("change", (event) => { - if (!isClock(event.target)) return; - const input = event.target; - const clock = parseClock(input.value); - if (clock) { - const second = input.dataset.seconds !== undefined && clock.second ? clock.second : undefined; - input.value = formatClock(clock.minutes, second); - } - mark(input); - }); -} - -/** The attributes of a clock field, for one made in code; the page's own carry the same. */ -export const CLOCK_FIELD = { - class: "ui-input ui-clock", - type: "text", - inputmode: "numeric", - autocomplete: "off", - spellcheck: "false", - placeholder: "HH:MM", - maxlength: "8", -} as const; diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index 9d99fda..f61ff13 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -12,7 +12,7 @@ export * from "./color.js"; export * from "./color-picker.js"; export * from "./png.js"; export * from "./debounce.js"; -export * from "./clock.js"; +export * from "./time-fields.js"; declare global { interface ImportMetaEnv { diff --git a/packages/ui/src/clock.test.ts b/packages/ui/src/time-fields.test.ts similarity index 51% rename from packages/ui/src/clock.test.ts rename to packages/ui/src/time-fields.test.ts index 87868fc..43d26ea 100644 --- a/packages/ui/src/clock.test.ts +++ b/packages/ui/src/time-fields.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "bun:test"; -import { formatClock, parseClock } from "./clock.js"; +import { formatClock, formatIsoDate, parseClock, parseIsoDate } from "./time-fields.js"; -describe("clock", () => { +describe("time fields", () => { test("times of day are read on the 24-hour clock, however they are typed", () => { const at = (minutes: number, second = 0) => ({ minutes, second }); expect(parseClock("09:30")).toEqual(at(570)); @@ -19,4 +19,29 @@ describe("clock", () => { expect(formatClock(570, 5)).toBe("09:30:05"); expect(formatClock(1439, 0)).toBe("23:59:00"); }); + test("dates are read year first, however they are typed, and only if the calendar has them", () => { + const day = { year: 2026, month: 10, day: 5 }; + for (const typed of [ + "2026-10-05", + "2026-10-5", + "2026/10/05", + "2026.10.5", + "20261005", + " 2026-10-05 ", + ]) + expect(parseIsoDate(typed)).toEqual(day); + expect(parseIsoDate("2024-02-29")).toEqual({ year: 2024, month: 2, day: 29 }); + for (const wrong of [ + "2026-02-29", + "2026-13-01", + "2026-04-31", + "10/05/2026", + "05.10.2026", + "", + "2026-10", + ]) + expect(parseIsoDate(wrong)).toBeUndefined(); + expect(formatIsoDate(day)).toBe("2026-10-05"); + expect(formatIsoDate({ year: 33, month: 1, day: 1 })).toBe("0033-01-01"); + }); }); diff --git a/packages/ui/src/time-fields.ts b/packages/ui/src/time-fields.ts new file mode 100644 index 0000000..681308e --- /dev/null +++ b/packages/ui/src/time-fields.ts @@ -0,0 +1,133 @@ +/** + * Dates and times of day are typed fields across the site, written the same whatever the + * browser's language: dates as `YYYY-MM-DD`, times on the 24-hour clock. + * + * Native date and time fields write them as the browser's language does (`10/13/2026` and AM/PM + * in American English), with no way to ask otherwise. A `.ui-date` field accepts `2026-10-13`, + * `2026-10-5` or `20261013`; a `.ui-clock` field accepts `14:30`, `9:30` or `0930` (and seconds + * where it shows them, `data-seconds`). Each is marked invalid while it cannot be read, and is + * written out in full once the person leaves it, with an `input` event so the page reads it again. + * Pages read them with `parseIsoDate` and `parseClock`. + */ + +const pad = (value: number, width = 2) => String(value).padStart(width, "0"); + +/** `HH:MM` from minutes after midnight, on the 24-hour clock; `HH:MM:SS` given the seconds. */ +export function formatClock(minutes: number, second?: number): string { + const clock = `${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}`; + return second === undefined ? clock : `${clock}:${pad(second)}`; +} + +/** + * A time of day as typed on the 24-hour clock: `14:30`, `14:30:15`, `9:30`, `0930`, `14h30` or + * just `9`. Fractions of a second are dropped. Undefined when it is not one. + */ +export function parseClock(value: string): { minutes: number; second: number } | undefined { + const match = /^(\d{1,2})(?:[:.h]?(\d{2})(?:[:.]?(\d{2})(?:[.,]\d+)?)?)?$/i.exec(value.trim()); + if (!match) return undefined; + const [hour, minute, second] = [Number(match[1]), Number(match[2] ?? 0), Number(match[3] ?? 0)]; + return hour < 24 && minute < 60 && second < 60 + ? { minutes: hour * 60 + minute, second } + : undefined; +} + +export interface IsoDate { + year: number; + /** 1 to 12. */ + month: number; + day: number; +} + +const daysIn = (year: number, month: number) => + month === 2 + ? (year % 4 === 0 && year % 100 !== 0) || year % 400 === 0 + ? 29 + : 28 + : [4, 6, 9, 11].includes(month) + ? 30 + : 31; + +/** + * A date as typed, year first: `2026-10-13`, `2026-10-5`, `2026/10/13`, `2026.10.13` or + * `20261013`. Undefined when it is not one, or not a day the calendar has (`2026-02-30`). + */ +export function parseIsoDate(value: string): IsoDate | undefined { + const text = value.trim(); + const match = + /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/.exec(text) ?? /^(\d{4})(\d{2})(\d{2})$/.exec(text); + if (!match) return undefined; + const [year, month, day] = [Number(match[1]), Number(match[2]), Number(match[3])]; + return month >= 1 && month <= 12 && day >= 1 && day <= daysIn(year, month) + ? { year, month, day } + : undefined; +} + +/** `YYYY-MM-DD`. */ +export function formatIsoDate(date: IsoDate): string { + return `${pad(date.year, 4)}-${pad(date.month)}-${pad(date.day)}`; +} + +/** The field's value written out in full, or undefined when it cannot be read. */ +function tidy(input: HTMLInputElement): string | undefined { + if (input.classList.contains("ui-date")) { + const date = parseIsoDate(input.value); + return date && formatIsoDate(date); + } + const clock = parseClock(input.value); + if (!clock) return undefined; + const second = input.dataset.seconds !== undefined && clock.second ? clock.second : undefined; + return formatClock(clock.minutes, second); +} + +const isField = (target: EventTarget | null): target is HTMLInputElement => + target instanceof HTMLInputElement && + (target.classList.contains("ui-clock") || target.classList.contains("ui-date")); + +function mark(input: HTMLInputElement, readable: boolean): void { + if (input.value.trim() !== "" && !readable) input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); +} + +let bound = false; +/** Binds every date and time field on the page, those drawn later included. */ +export function bindTimeFields(): void { + if (bound) return; + bound = true; + document.addEventListener("input", (event) => { + // While typing, only clear a mark: "9:" is on its way to being a time. + if (isField(event.target) && tidy(event.target) !== undefined) mark(event.target, true); + }); + document.addEventListener("change", (event) => { + if (!isField(event.target)) return; + const input = event.target; + const full = tidy(input); + mark(input, full !== undefined); + if (full !== undefined && full !== input.value) { + input.value = full; + input.dispatchEvent(new Event("input", { bubbles: true })); + } + }); +} + +const TYPED = { + type: "text", + inputmode: "numeric", + autocomplete: "off", + spellcheck: "false", +} as const; + +/** The attributes of a time field, for one made in code; the page's own carry the same. */ +export const CLOCK_FIELD = { + ...TYPED, + class: "ui-input ui-clock", + placeholder: "HH:MM", + maxlength: "8", +} as const; + +/** The attributes of a date field, for one made in code. */ +export const DATE_FIELD = { + ...TYPED, + class: "ui-input ui-date", + placeholder: "YYYY-MM-DD", + maxlength: "10", +} as const; diff --git a/tests/browser/time.spec.ts b/tests/browser/time.spec.ts index 26aa2c9..d8c059f 100644 --- a/tests/browser/time.spec.ts +++ b/tests/browser/time.spec.ts @@ -31,7 +31,8 @@ const PEOPLE = [ ]; async function open(page: Page, people: unknown[], draft: Record<string, unknown> = {}) { - await page.goto("/Tools/time/"); + // Written from another page of the site: Time keeps its own draft as it is left. + await page.goto("/Tools/"); await page.evaluate( ([people, draft]) => { localStorage.setItem("tools.time.plan", JSON.stringify({ version: 1, duration: 60, people })); @@ -39,7 +40,7 @@ async function open(page: Page, people: unknown[], draft: Record<string, unknown }, [people, draft] as const ); - await page.reload(); + await page.goto("/Tools/time/"); } const row = (page: Page, label: string) => @@ -51,6 +52,9 @@ test("Time converts both ways, asks which of a doubled time and explains a skipp page, }) => { await page.goto("/Tools/time/"); + // A new tab opens on Find a time, the first view. + await expect(page.locator("#view-meet")).toBeVisible(); + await page.locator('[data-view="convert"]').click(); const input = page.locator("#input"); await input.fill("1760264100"); await expect(page.locator("#input-note")).toHaveText("Unix seconds, by its size."); @@ -58,10 +62,17 @@ test("Time converts both ways, asks which of a doubled time and explains a skipp await expect(row(page, "This device").locator("code")).toHaveText("2025-10-12T12:15:00+02:00"); await expect(row(page, "Unix milliseconds").locator("code")).toHaveText("1760264100000"); await expect(row(page, "HTTP date").locator("code")).toHaveText("Sun, 12 Oct 2025 10:15:00 GMT"); + // A unit writes the timestamp in it, the same instant; what is typed after is read in it. await page.locator('[data-unit="ms"]').click(); + await expect(input).toHaveValue("1760264100000"); await expect(page.locator("#input-note")).toHaveText("Unix milliseconds."); - await expect(row(page, "UTC").locator("code")).toHaveText("1970-01-21T08:57:44.100Z"); + await expect(row(page, "UTC").locator("code")).toHaveText("2025-10-12T10:15:00Z"); + await page.locator('[data-unit="s"]').click(); + await expect(input).toHaveValue("1760264100"); + await input.fill("1760264100123"); + await expect(row(page, "UTC").locator("code")).not.toContainText("2025-"); await page.locator('[data-unit="auto"]').click(); + await expect(row(page, "UTC").locator("code")).toHaveText("2025-10-12T10:15:00.123Z"); // Paris turns its clocks back on 25 October 2026: 02:30 happens twice. await input.fill("2026-10-25T02:30"); @@ -124,7 +135,8 @@ test("Time finds a call time, explains compromises, relaxes on request and share await expect(first).toContainText("or start any time until 20:00"); await expect(first).toContainText("Everyone: preferred hours."); await expect(first).toHaveAttribute("data-tier", "preferred"); - await first.click(); + // The call is on the best suggestion before anyone chooses. + await expect(first).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("#picked-summary")).toHaveText( [ "Call, 1 h", @@ -181,6 +193,10 @@ test("Time finds a call time, explains compromises, relaxes on request and share await other.locator("#snapshot-replace").click(); await expect(other.locator(".time-person")).toHaveCount(3); await expect(other.locator(".time-person summary").first()).toContainText("Relaxed"); + // The bin beside a person's chevron removes them, without folding or opening the card. + await other.getByRole("button", { name: "Remove Cy" }).click(); + await expect(other.locator(".time-person")).toHaveCount(2); + await expect(other.locator(".time-person").first()).toHaveAttribute("open", ""); }); test("Time measures ages and spans on the calendar and in elapsed units", async ({ page }) => { @@ -215,7 +231,10 @@ test("Time measures ages and spans on the calendar and in elapsed units", async // An age: a birth date to now, and when it next comes round. await page.locator("#end-now").click(); - await page.locator("#start-date").fill("1990-05-20"); + // A date is typed year first, however short, and written out in full. + await page.locator("#start-date").fill("1990-5-20"); + await page.locator("#start-date").blur(); + await expect(page.locator("#start-date")).toHaveValue("1990-05-20"); await page.locator("#start-time").fill(""); await expect(page.locator("#span")).toHaveText(/^\d+ years, /); await expect(page.locator("#span")).not.toContainText("hour"); @@ -230,6 +249,11 @@ test("Time measures ages and spans on the calendar and in elapsed units", async await expect(page.locator("#start-time")).toHaveAttribute("aria-invalid", "true"); await expect(page.locator("#dur-assume")).toContainText("type the time as HH:MM"); await expect(page.locator("#span")).toHaveText("—"); + // Only one end is now: switching From to now switches To back to its date. + await page.locator("#start-now").click(); + await expect(page.locator("#start-now")).toHaveAttribute("aria-checked", "true"); + await expect(page.locator("#end-now")).toHaveAttribute("aria-checked", "false"); + await expect(page.locator("#end-date")).toBeEnabled(); }); test.describe("in American English", () => { @@ -258,6 +282,7 @@ test.describe("in American English", () => { await expect(page.locator("#view-convert")).not.toContainText(/\d (AM|PM)\b/); // Now writes the unit chosen: milliseconds once Milliseconds is pressed. await page.locator('[data-unit="ms"]').click(); + await expect(page.locator("#input")).toHaveValue("1760264100000"); await page.locator("#now").click(); await expect(page.locator("#input")).toHaveValue(/^\d{13}$/); // The time field writes the input as it is typed. From aa849d204dab9626ff8158f66c0bb7e95141a3c8 Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Tue, 13 Oct 2026 23:40:48 +0200 Subject: [PATCH 06/10] Time: fold Suggestions, and reload on the view the tab was on - Suggestions fold to their heading and count, open by default: the heading is a disclosure button and its whole row takes a click; kept in the draft. - A reload no longer shows Find a time for a moment before the view the tab was on: a script in the head reads the draft and marks <html> with the view (and Suggestions folded) before the body paints; the page's styles show the view from that, and a second small script presses its header button. main.ts keeps the attributes from then on. - @tools/ui withIcons fills an icon whose closing tag Prettier wrapped (</svg\n>); it was skipped silently, shipping a blank icon. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/time/index.html | 63 ++++++++++++++++++++++++++++------- apps/time/src/main.ts | 3 +- apps/time/src/meet.ts | 16 ++++++++- apps/time/src/styles.css | 51 ++++++++++++++++++++++++---- docs/apps/time.md | 6 +++- packages/ui/src/icons.test.ts | 7 ++++ packages/ui/src/icons.ts | 11 +++--- tests/browser/time.spec.ts | 32 ++++++++++++++++++ 8 files changed, 163 insertions(+), 26 deletions(-) diff --git a/apps/time/index.html b/apps/time/index.html index 2d2d32b..5492f67 100644 --- a/apps/time/index.html +++ b/apps/time/index.html @@ -4,6 +4,19 @@ <meta charset="UTF-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" /> <link rel="stylesheet" href="/src/styles.css" /> + <!-- Runs while the head is parsed, from the tab's draft: a reload shows the view this tab was + on, and Suggestions folded or not, from the first paint rather than a moment after. + main.ts keeps both attributes from then on. --> + <script> + try { + var draft = JSON.parse(sessionStorage.getItem("tools.time.draft") || "{}").value || {}; + var view = location.hash.indexOf("#plan=") === 0 ? "meet" : draft.view; + if (view === "convert" || view === "duration") + document.documentElement.setAttribute("data-time-view", view); + if (draft.suggestionsOpen === false) + document.documentElement.setAttribute("data-time-folded", ""); + } catch (e) {} + </script> </head> <body> <!-- "‹ Tools" and the name are written in at build time, from the catalog. --> @@ -39,13 +52,22 @@ </button> </div> </header> + <!-- The view the head chose, pressed in the header from the first paint too. --> + <script> + (function (view) { + var buttons = document.querySelectorAll(".time-views [data-view]"); + for (var i = 0; i < buttons.length; i++) + buttons[i].setAttribute("aria-pressed", String(buttons[i].dataset.view === view)); + document.getElementById("view-select").value = view; + })(document.documentElement.getAttribute("data-time-view") || "meet"); + </script> <main class="ui-workbench time"> <p class="ui-message" id="draft-status" role="status" hidden></p> <datalist id="zone-list"></datalist> <!-- Convert: one value, or one a line. --> - <div class="time-view" id="view-convert" hidden> + <div class="time-view" id="view-convert"> <section class="ui-card" aria-labelledby="input-title"> <div class="ui-row"> <h2 id="input-title"><label for="input">Time</label></h2> @@ -245,14 +267,31 @@ <h2 id="search-title">Search</h2> </div> </section> - <section class="ui-card" aria-labelledby="suggestions-title"> - <div class="ui-row"> - <h2 id="suggestions-title">Suggestions</h2> + <!-- Folds to its heading and count: the heading is the button, and the whole row takes + a click. Folded is <html data-time-folded>, set in the head before the first paint. --> + <section class="ui-card time-fold" aria-labelledby="suggestions-title"> + <div class="ui-row time-fold-row" id="suggestions-row"> + <h2 id="suggestions-title"> + <button + type="button" + class="time-fold-toggle" + id="suggestions-toggle" + aria-expanded="true" + aria-controls="suggestions-body" + > + Suggestions + </button> + </h2> <span class="ui-note" id="suggestions-note" role="status"></span> + <span class="time-chevron" aria-hidden="true" + ><svg data-ui-icon="chevron-down"></svg + ></span> + </div> + <div class="time-fold-body" id="suggestions-body"> + <ol class="time-suggestions" id="suggestions"></ol> + <div class="time-diagnosis" id="diagnosis" hidden></div> + <button type="button" class="ui-btn ui-btn--small" id="more" hidden>Show more</button> </div> - <ol class="time-suggestions" id="suggestions"></ol> - <div class="time-diagnosis" id="diagnosis" hidden></div> - <button type="button" class="ui-btn ui-btn--small" id="more" hidden>Show more</button> </section> <section class="ui-card" aria-labelledby="timeline-title"> @@ -309,7 +348,7 @@ <h2 id="timeline-title">Timeline</h2> </div> <!-- Duration: between two dates, or from or to now. --> - <div class="time-view" id="view-duration" hidden> + <div class="time-view" id="view-duration"> <section class="ui-card" aria-labelledby="measure-title"> <div class="ui-row"> <h2 id="measure-title">Measure</h2> @@ -525,10 +564,10 @@ <h3>Find a time</h3> stand in the way. </li> <li> - The call starts on the best suggestion. The timeline shows one day in the zone you - choose, with each person's own hours above their row. Click it, or choose another - suggestion, to place the call elsewhere; Copy summary gives everyone's own date and - time. + Suggestions fold to their heading: click anywhere on its row. The call starts on the + best suggestion. The timeline shows one day in the zone you choose, with each person's + own hours above their row. Click it, or choose another suggestion, to place the call + elsewhere; Copy summary gives everyone's own date and time. </li> <li> Share link copies a link holding the people and their windows. It is a snapshot of what diff --git a/apps/time/src/main.ts b/apps/time/src/main.ts index 97b4509..4dc7129 100644 --- a/apps/time/src/main.ts +++ b/apps/time/src/main.ts @@ -77,9 +77,10 @@ const viewSelect = byId<HTMLSelectElement>("view-select"); function show(name: ViewName): void { current = name; press(viewButtons, "view", name); + // The page's styles show this view (see the head of index.html). + document.documentElement.dataset.timeView = name; viewSelect.value = name; for (const view of NAMES) { - byId(`view-${view}`).hidden = view !== name; views[view].shown(view === name); } saveSoon(); diff --git a/apps/time/src/meet.ts b/apps/time/src/meet.ts index 0d6f002..a474237 100644 --- a/apps/time/src/meet.ts +++ b/apps/time/src/meet.ts @@ -258,7 +258,7 @@ export function meetView(app: App): View { person.relaxed && h("span", { class: "time-badge" }, "Relaxed"), // Room for the remove button, which sits over the row beside the chevron. h("span", { class: "time-person-room", "aria-hidden": "true" }), - h("span", { class: "time-person-chevron", "aria-hidden": "true" }, iconSvg("chevron-down")) + h("span", { class: "time-chevron", "aria-hidden": "true" }, iconSvg("chevron-down")) ), h( "div", @@ -899,6 +899,19 @@ export function meetView(app: App): View { renderSuggestions(); }); + // Suggestions fold to their heading: the page's styles hide them while <html> is marked folded, + // which the head of index.html sets from the draft before the first paint. + const root = document.documentElement; + const foldToggle = byId("suggestions-toggle"); + const suggestionsOpen = () => !root.hasAttribute("data-time-folded"); + foldToggle.setAttribute("aria-expanded", String(suggestionsOpen())); + // The heading's button, or anywhere else on its row. + byId("suggestions-row").addEventListener("click", () => { + root.toggleAttribute("data-time-folded", suggestionsOpen()); + foldToggle.setAttribute("aria-expanded", String(suggestionsOpen())); + app.save(); + }); + length.addEventListener("change", () => { plan.duration = Number(length.value); changed(); @@ -981,6 +994,7 @@ export function meetView(app: App): View { rangeTo: rangeTo.value, selected, placedByHand, + suggestionsOpen: suggestionsOpen(), day: day ? formatDate(day) : "", folded: [...folded], }), diff --git a/apps/time/src/styles.css b/apps/time/src/styles.css index f39dc73..a1f4b43 100644 --- a/apps/time/src/styles.css +++ b/apps/time/src/styles.css @@ -181,15 +181,28 @@ /* ---------- Find a time ---------- */ -.meet { +/* One view shows: the one the page's data-time-view names, set in the head from the tab's draft + before the first paint, then by main.ts; Find a time when none is named. */ +.time-view { + display: none; +} +:root:not([data-time-view]) #view-meet, +:root[data-time-view="meet"] #view-meet { display: grid; +} +:root[data-time-view="convert"] #view-convert, +:root[data-time-view="duration"] #view-duration { + display: block; +} + +.meet { gap: 10px; } .meet .ui-card + .ui-card { margin-top: 10px; } @media (width >= 1000px) { - .meet:not([hidden]) { + .meet { grid-template-columns: minmax(360px, 440px) minmax(0, 1fr); align-items: start; } @@ -264,7 +277,7 @@ width: 18px; height: 18px; } -.time-person-chevron { +.time-chevron { display: grid; place-items: center; width: 28px; @@ -273,17 +286,43 @@ color: var(--muted); transition: transform 0.15s ease; } -.time-person-chevron svg { +.time-chevron svg { width: 18px; height: 18px; } -.time-person summary:hover .time-person-chevron { +.time-person summary:hover .time-chevron, +.time-fold-row:hover .time-chevron { background: var(--panel); color: var(--text); } -.time-person:not([open]) .time-person-chevron { +.time-person:not([open]) .time-chevron, +:root[data-time-folded] .time-fold .time-chevron { transform: rotate(-90deg); } + +/* A card that folds to its heading row (Suggestions): the heading is its button. */ +.time-fold-row { + cursor: pointer; +} +.time-fold-toggle { + padding: 0; + border: 0; + background: none; + color: inherit; + font: inherit; + cursor: pointer; +} +.time-fold-toggle:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + border-radius: 2px; +} +:root[data-time-folded] .time-fold-body { + display: none; +} +:root[data-time-folded] .time-fold .ui-row { + margin-bottom: 0; +} .time-person summary:focus-visible { outline: 2px solid var(--accent); outline-offset: -2px; diff --git a/docs/apps/time.md b/docs/apps/time.md index a2df9aa..fd3784d 100644 --- a/docs/apps/time.md +++ b/docs/apps/time.md @@ -14,7 +14,9 @@ Dates are `YYYY-MM-DD` and times of day are on the 24-hour clock, shown and type browser's language. Each is typed in a field of its own, a date as `2026-10-13` (or `2026-10-5`, `20261013`) and a time as `14:30` (or `1430`, `9:30`), since native date and time fields would follow the language into `10/13/2026` and AM/PM; a field is written out in full when it is left, -and marked while it cannot be read. What is typed stays in the tab's draft. People, windows and pinned zones are kept in the browser. The calendar is the +and marked while it cannot be read. What is typed stays in the tab's draft, saved as typing pauses +and as the page is left; a reload shows the view it was on, and Suggestions folded or not, from +the first paint (a script in the page's head reads the draft before the body is drawn). People, windows and pinned zones are kept in the browser. The calendar is the proleptic Gregorian one, with year 0 for 1 BC. ## Find a time @@ -46,6 +48,8 @@ gives up, and in total; then the most minutes any one person spends outside pref nobody bears a large compromise to spare the group a small one; then the group's total; then the earliest. Each suggestion gives everyone's own clock at the start, with their date where it is not the one shown, and says why: "Sam: 1 h outside preferred hours; everyone else: preferred." +Suggestions are open to begin with and fold to their heading and count, from the heading's button +or anywhere on its row. Unavailable time is used only for someone whose "Allow unavailable time" is on. When nothing fits, the search names anyone with no stretch long enough of their own, and each person without whom the diff --git a/packages/ui/src/icons.test.ts b/packages/ui/src/icons.test.ts index d29ba66..2726b58 100644 --- a/packages/ui/src/icons.test.ts +++ b/packages/ui/src/icons.test.ts @@ -19,6 +19,13 @@ describe("withIcons", () => { ); }); + test("fills an svg whose closing tag Prettier wrapped", () => { + const html = withIcons('<span\n ><svg data-ui-icon="help"></svg\n></span>'); + expect(html).toBe( + `<span\n ><svg data-ui-icon="help" viewBox="0 0 24 24">${ICONS.help}</svg></span>` + ); + }); + test("fills a sprite symbol, and leaves a viewBox it already has", () => { const html = withIcons( '<symbol id="icon-copy" viewBox="0 0 24 24" data-ui-icon="copy">\n</symbol>' diff --git a/packages/ui/src/icons.ts b/packages/ui/src/icons.ts index 330adb6..23c4c46 100644 --- a/packages/ui/src/icons.ts +++ b/packages/ui/src/icons.ts @@ -120,15 +120,16 @@ export function withIcons(html: string, sprite?: string): string { const [whole, tag, attrs] = open as unknown as [string, string, string]; const name = /\sdata-ui-icon="([^"]*)"/.exec(attrs)?.[1]; if (name === undefined) continue; - // Only an empty element is filled: nothing but white space before its closing tag. + // Only an empty element is filled: nothing but white space before its closing tag, which may + // hold white space itself (`</svg\n>`, as Prettier wraps a tag between inline elements). const start = open.index + whole.length; const lt = html.indexOf("<", start); - const close = `</${tag}>`; - if (lt < 0 || html.slice(start, lt).trim() || !html.startsWith(close, lt)) continue; + const close = lt < 0 ? null : new RegExp(`^</${tag}\\s*>`).exec(html.slice(lt, lt + 64)); + if (!close || html.slice(start, lt).trim()) continue; if (!isIconName(name)) throw new Error(`Unknown icon "${name}" in ${ICON_ATTR}`); const box = /\sviewBox=/.test(attrs) ? "" : ' viewBox="0 0 24 24"'; - out += `${html.slice(done, open.index)}<${tag}${attrs}${box}>${ICONS[name]}${close}`; - done = lt + close.length; + out += `${html.slice(done, open.index)}<${tag}${attrs}${box}>${ICONS[name]}</${tag}>`; + done = lt + close[0].length; } return out + html.slice(done); } diff --git a/tests/browser/time.spec.ts b/tests/browser/time.spec.ts index d8c059f..28acfb8 100644 --- a/tests/browser/time.spec.ts +++ b/tests/browser/time.spec.ts @@ -256,6 +256,38 @@ test("Time measures ages and spans on the calendar and in elapsed units", async await expect(page.locator("#end-date")).toBeEnabled(); }); +test("a reload shows the view and the folds the tab had, from the first paint", async ({ + page, +}) => { + await open(page, PEOPLE, { view: "meet", rangeFrom: "2026-10-12", rangeTo: "2026-10-12" }); + // Suggestions fold from their heading, or anywhere on its row, and open again. + const toggle = page.locator("#suggestions-toggle"); + await expect(page.locator("#suggestions")).toBeVisible(); + await expect(page.locator("#suggestions-row .time-chevron svg path")).toHaveCount(1); + await toggle.click(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(page.locator("#suggestions")).toBeHidden(); + await expect(page.locator("#suggestions-note")).toBeVisible(); + await page.locator('[data-view="convert"]').click(); + + // With the app's script blocked, what shows is what the page paints before it runs. + await page.route(/\/time\/assets\/.*\.js$/, (route) => route.abort()); + await page.reload(); + await expect(page.locator("#view-convert")).toBeVisible(); + await expect(page.locator("#view-meet")).toBeHidden(); + await expect(page.locator('[data-view="convert"]')).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator('[data-view="meet"]')).toHaveAttribute("aria-pressed", "false"); + await page.unroute(/\/time\/assets\/.*\.js$/); + + await page.reload(); + await page.locator('[data-view="meet"]').click(); + await expect(page.locator("#suggestions")).toBeHidden(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await page.locator("#suggestions-row").click({ position: { x: 300, y: 10 } }); + await expect(page.locator("#suggestions")).toBeVisible(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); +}); + test.describe("in American English", () => { test.use({ locale: "en-US" }); test("times are on the 24-hour clock, shown and typed", async ({ page }) => { From a049f0d36e5248c1729cc0b6e630289f81a77175 Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Tue, 13 Oct 2026 23:53:45 +0200 Subject: [PATCH 07/10] Time: drag the call on the timeline, Suggestions folded at first; JSON's view from the first paint - Time: pressing on the timeline places the call and dragging moves it live, a quarter hour at a time; grabbed on the call, it keeps its hold. Pointer capture stays with the timeline's container as the rows are drawn again. - Time: the legend names the call (the outlined box) and now (the red line). - Time: Suggestions start folded, since the call is already placed on the best one; opened, they stay open in the tab. Why nothing fits moves out of the fold, so it shows either way. - JSON: a reload pressed the JSON view button for a moment before the view kept in the settings. A script after the header presses the right one from the first paint, as Time does; tested with the app's script blocked. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/json/index.html | 13 ++++++++++ apps/time/index.html | 18 ++++++++------ apps/time/src/meet.ts | 42 ++++++++++++++++++++++++++----- apps/time/src/styles.css | 21 ++++++++++++++++ docs/apps/time.md | 13 ++++++---- tests/browser/editors.spec.ts | 13 ++++++++++ tests/browser/time.spec.ts | 47 +++++++++++++++++++++++++++++------ 7 files changed, 141 insertions(+), 26 deletions(-) diff --git a/apps/json/index.html b/apps/json/index.html index e9cc87f..a07be35 100644 --- a/apps/json/index.html +++ b/apps/json/index.html @@ -215,6 +215,19 @@ </button> </div> </header> + <!-- The view kept in the settings, pressed in the header from the first paint rather than + JSON for a moment; main.ts reads the same setting and keeps it from then on. --> + <script> + try { + var view = (JSON.parse(localStorage.getItem("tools.json.prefs") || "{}") || {}).view; + if (["yaml", "csv", "ts", "schema"].indexOf(view) >= 0) { + var buttons = document.querySelectorAll(".views [data-view]"); + for (var i = 0; i < buttons.length; i++) + buttons[i].setAttribute("aria-pressed", String(buttons[i].dataset.view === view)); + document.getElementById("view-select").value = view; + } + } catch (e) {} + </script> <!-- Docked right under the header, as in every Tools app (@tools/ui bindDock): it stays open beside the work until its button closes it. --> diff --git a/apps/time/index.html b/apps/time/index.html index 5492f67..bf54df2 100644 --- a/apps/time/index.html +++ b/apps/time/index.html @@ -13,7 +13,8 @@ var view = location.hash.indexOf("#plan=") === 0 ? "meet" : draft.view; if (view === "convert" || view === "duration") document.documentElement.setAttribute("data-time-view", view); - if (draft.suggestionsOpen === false) + // Folded until opened: the call is already placed on the best one. + if (draft.suggestionsOpen !== true) document.documentElement.setAttribute("data-time-folded", ""); } catch (e) {} </script> @@ -276,7 +277,7 @@ <h2 id="suggestions-title"> type="button" class="time-fold-toggle" id="suggestions-toggle" - aria-expanded="true" + aria-expanded="false" aria-controls="suggestions-body" > Suggestions @@ -289,9 +290,10 @@ <h2 id="suggestions-title"> </div> <div class="time-fold-body" id="suggestions-body"> <ol class="time-suggestions" id="suggestions"></ol> - <div class="time-diagnosis" id="diagnosis" hidden></div> <button type="button" class="ui-btn ui-btn--small" id="more" hidden>Show more</button> </div> + <!-- Why nothing fits shows folded or not. --> + <div class="time-diagnosis" id="diagnosis" hidden></div> </section> <section class="ui-card" aria-labelledby="timeline-title"> @@ -321,6 +323,8 @@ <h2 id="timeline-title">Timeline</h2> <span class="level-preferred">Preferred</span> <span class="level-acceptable">Acceptable</span> <span class="level-unavailable">Unavailable</span> + <span class="legend-call">The call: drag it, or click to place it</span> + <span class="legend-now">Now</span> </div> <div class="time-picked" id="picked"> <div class="time-option"> @@ -564,10 +568,10 @@ <h3>Find a time</h3> stand in the way. </li> <li> - Suggestions fold to their heading: click anywhere on its row. The call starts on the - best suggestion. The timeline shows one day in the zone you choose, with each person's - own hours above their row. Click it, or choose another suggestion, to place the call - elsewhere; Copy summary gives everyone's own date and time. + The call starts on the best suggestion. Suggestions start folded: click their heading + row to see them all. The timeline shows one day in the zone you choose, with each + person's own hours above their row; the red line is now. Click or drag on it to move the + call, or choose another suggestion; Copy summary gives everyone's own date and time. </li> <li> Share link copies a link holding the people and their windows. It is a snapshot of what diff --git a/apps/time/src/meet.ts b/apps/time/src/meet.ts index a474237..baff631 100644 --- a/apps/time/src/meet.ts +++ b/apps/time/src/meet.ts @@ -869,15 +869,45 @@ export function meetView(app: App): View { app.save(); } - byId("timeline").addEventListener("click", (event) => { - const bar = (event.target as HTMLElement).closest(".tl-track"); - if (!bar) return; - const box = bar.getBoundingClientRect(); + // A press on the timeline places the call there; a drag moves it as the pointer moves. Pressed + // on the call itself, the call keeps its place under the pointer rather than jumping to start + // there. Starts snap to the quarter hour, within the day shown. + const timeline = byId("timeline"); + let drag: { box: DOMRect; from: number; to: number; offset: number } | undefined; + let frame = 0; + function dragTo(clientX: number): void { + if (!drag) return; + const { box, from, to, offset } = drag; + const t = from + ((clientX - box.left) / box.width) * (to - from) - offset; + const steps = Math.floor((t - from) / (STEP * MINUTE)); + const start = + from + Math.min(Math.max(0, steps), (to - from) / (STEP * MINUTE) - 1) * STEP * MINUTE; + if (start !== selected) pick(start); + } + timeline.addEventListener("pointerdown", (event) => { + const track = (event.target as HTMLElement).closest(".tl-track"); + if (!track || event.button !== 0) return; + const box = track.querySelector(".tl-bar")!.getBoundingClientRect(); const [from, to] = dayBounds(); const t = from + ((event.clientX - box.left) / box.width) * (to - from); - // Snapped to a quarter hour, the call starting where it was clicked. - pick(from + Math.max(0, Math.floor((t - from) / (STEP * MINUTE))) * STEP * MINUTE); + const onCall = selected !== undefined && t >= selected && t < selected + plan.duration * MINUTE; + drag = { box, from, to, offset: onCall ? t - selected! : 0 }; + // The timeline is drawn again as the call moves: the capture stays with its container. + timeline.setPointerCapture(event.pointerId); + timeline.classList.add("dragging"); + dragTo(event.clientX); + }); + timeline.addEventListener("pointermove", (event) => { + if (!drag) return; + cancelAnimationFrame(frame); + frame = requestAnimationFrame(() => dragTo(event.clientX)); }); + const endDrag = () => { + drag = undefined; + timeline.classList.remove("dragging"); + }; + timeline.addEventListener("pointerup", endDrag); + timeline.addEventListener("pointercancel", endDrag); byId("picked-time").addEventListener("change", () => { const clock = parseClock(byId<HTMLInputElement>("picked-time").value); if (!clock) return; diff --git a/apps/time/src/styles.css b/apps/time/src/styles.css index a1f4b43..747c7d9 100644 --- a/apps/time/src/styles.css +++ b/apps/time/src/styles.css @@ -477,6 +477,7 @@ margin-top: 8px; } .time-diagnosis { + margin-top: 8px; font-size: 13px; } .time-diagnosis p { @@ -510,8 +511,18 @@ color: var(--muted); font-size: 11px; } +/* A press places the call and a drag moves it, live; touch still scrolls the page up and down. */ .tl-track { cursor: crosshair; + touch-action: pan-y; + user-select: none; +} +.tl-call { + cursor: grab; +} +.time-timeline.dragging, +.time-timeline.dragging .tl-call { + cursor: grabbing; } .tl-hours { position: relative; @@ -601,6 +612,16 @@ color-mix(in srgb, var(--muted) 40%, transparent) 2px 3px ); } +.time-legend .legend-call::before { + border: 2px solid var(--accent); + background: color-mix(in srgb, var(--accent) 22%, transparent); +} +.time-legend .legend-now::before { + width: 2px; + border: 0; + border-radius: 0; + background: var(--danger); +} .time-picked { margin-top: 10px; padding-top: 10px; diff --git a/docs/apps/time.md b/docs/apps/time.md index fd3784d..53d3a62 100644 --- a/docs/apps/time.md +++ b/docs/apps/time.md @@ -48,8 +48,9 @@ gives up, and in total; then the most minutes any one person spends outside pref nobody bears a large compromise to spare the group a small one; then the group's total; then the earliest. Each suggestion gives everyone's own clock at the start, with their date where it is not the one shown, and says why: "Sam: 1 h outside preferred hours; everyone else: preferred." -Suggestions are open to begin with and fold to their heading and count, from the heading's button -or anywhere on its row. +Suggestions start folded to their heading and count, since the call is already placed on the best +one, and open from the heading's button or anywhere on its row; opened, they stay open in that +tab. Why nothing fits shows below the heading, folded or not. Unavailable time is used only for someone whose "Allow unavailable time" is on. When nothing fits, the search names anyone with no stretch long enough of their own, and each person without whom the @@ -58,9 +59,11 @@ others would find times, with a button to allow that person's unavailable time. The timeline shows one day in the chosen zone: a row for everyone combined and one a person, all aligned to the same instants, each with that person's own hours and date changes above it. Preferred and acceptable are coloured, unavailable is hatched, and each row also reads as text -for a screen reader. The call is placed on the best suggestion, and stays there as people and -windows change, until someone places it themselves: choosing a suggestion, clicking the timeline -or typing a time. New dates to search or an opened share link put it back on the best suggestion. +for a screen reader. The call is the outlined box and the red line is now; the legend says so. +The call is placed on the best suggestion, and stays there as people and windows change, until +someone places it themselves: choosing a suggestion, typing a time, or pressing on the timeline, +which places the call there and moves it live while the pointer is dragged, a quarter hour at a +time. Pressed on the call itself, it keeps its hold rather than jumping to start there. New dates to search or an opened share link put it back on the best suggestion. The placed call says how it sits with everyone and gives a summary to copy, with each person's own date and time. diff --git a/tests/browser/editors.spec.ts b/tests/browser/editors.spec.ts index 3ff1b77..85a386d 100644 --- a/tests/browser/editors.spec.ts +++ b/tests/browser/editors.spec.ts @@ -52,6 +52,19 @@ test("JSON preserves exact numbers through formatting, conversion, undo and relo await expect(editor).toContainText("9223372036854775807"); }); +test("JSON reloads with the view it was on pressed from the first paint", async ({ page }) => { + await page.goto("/Tools/json/"); + await page.locator('[data-view="yaml"]').click(); + // With the app's script blocked, what shows is what the page paints before it runs. + await page.route(/\/json\/assets\/.*\.js$/, (route) => route.abort()); + await page.reload(); + await expect(page.locator('.views [data-view="yaml"]')).toHaveAttribute("aria-pressed", "true"); + await expect(page.locator('.views [data-view="json"]')).toHaveAttribute("aria-pressed", "false"); + await expect(page.locator("#view-select")).toHaveValue("yaml"); + // The blocked script is reported as a failed load: expected here, not an error of the page. + errorLists.get(page)!.length = 0; +}); + test("SVG draws, undoes, redoes and restores a rectangle", async ({ page }) => { await page.goto("/Tools/svg/"); await page.locator("#menu-document-btn").click(); diff --git a/tests/browser/time.spec.ts b/tests/browser/time.spec.ts index 28acfb8..fbd885c 100644 --- a/tests/browser/time.spec.ts +++ b/tests/browser/time.spec.ts @@ -260,14 +260,17 @@ test("a reload shows the view and the folds the tab had, from the first paint", page, }) => { await open(page, PEOPLE, { view: "meet", rangeFrom: "2026-10-12", rangeTo: "2026-10-12" }); - // Suggestions fold from their heading, or anywhere on its row, and open again. + // Suggestions start folded to their heading and count; the call is already on the best one. const toggle = page.locator("#suggestions-toggle"); - await expect(page.locator("#suggestions")).toBeVisible(); + await expect(page.locator("#suggestions")).toBeHidden(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); + await expect(page.locator("#suggestions-note")).toContainText("preferred by everyone"); await expect(page.locator("#suggestions-row .time-chevron svg path")).toHaveCount(1); + await expect(page.locator("#picked-time")).toHaveValue("18:00"); + // They open from their heading, or anywhere on its row. await toggle.click(); - await expect(toggle).toHaveAttribute("aria-expanded", "false"); - await expect(page.locator("#suggestions")).toBeHidden(); - await expect(page.locator("#suggestions-note")).toBeVisible(); + await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await expect(page.locator("#suggestions")).toBeVisible(); await page.locator('[data-view="convert"]').click(); // With the app's script blocked, what shows is what the page paints before it runs. @@ -281,11 +284,39 @@ test("a reload shows the view and the folds the tab had, from the first paint", await page.reload(); await page.locator('[data-view="meet"]').click(); - await expect(page.locator("#suggestions")).toBeHidden(); - await expect(toggle).toHaveAttribute("aria-expanded", "false"); - await page.locator("#suggestions-row").click({ position: { x: 300, y: 10 } }); await expect(page.locator("#suggestions")).toBeVisible(); await expect(toggle).toHaveAttribute("aria-expanded", "true"); + await page.locator("#suggestions-row").click({ position: { x: 300, y: 10 } }); + await expect(page.locator("#suggestions")).toBeHidden(); + await expect(toggle).toHaveAttribute("aria-expanded", "false"); +}); + +test("dragging the call along the timeline moves it as the pointer moves", async ({ page }) => { + await open(page, PEOPLE, { + view: "meet", + rangeFrom: "2026-10-12", + rangeTo: "2026-10-12", + meetZone: "Europe/Paris", + }); + const picked = page.locator("#picked-time"); + await expect(picked).toHaveValue("18:00"); + const bar = (await page.locator(".tl-all .tl-bar").boundingBox())!; + const hour = bar.width / 24; + const y = bar.y + bar.height / 2; + // Grabbed in its middle, the call keeps that hold as it moves, an hour at a time here. + await page.mouse.move(bar.x + 18.5 * hour, y); + await page.mouse.down(); + await page.mouse.move(bar.x + 19.5 * hour, y, { steps: 4 }); + await expect(picked).toHaveValue("19:00"); + await page.mouse.move(bar.x + 20.5 * hour, y, { steps: 4 }); + await expect(picked).toHaveValue("20:00"); + await page.mouse.move(bar.x + 21.5 * hour, y, { steps: 4 }); + await expect(picked).toHaveValue("21:00"); + await page.mouse.up(); + await expect(page.locator("#picked-text")).toContainText("Ana: 1 h outside preferred hours"); + // A press away from the call places it there. + await page.mouse.click(bar.x + 9.2 * hour, y); + await expect(picked).toHaveValue("09:00"); }); test.describe("in American English", () => { From 1e217674f20f12c66aeebc80ba083db88e1d7c06 Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Wed, 14 Oct 2026 00:03:20 +0200 Subject: [PATCH 08/10] Time: never suggest a time already past The search covered whole days from midnight, so today's suggestions began hours behind the clock. It now starts at the next quarter hour from now, says so when no time is left in its dates, and searches again as each quarter hour passes while the page is open. A call placed in the past by hand says so. The browser tests run Find a time at a fixed moment (page.clock), the evening before the day they search. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/time/index.html | 10 +++++----- apps/time/src/meet.ts | 36 ++++++++++++++++++++++++------------ docs/apps/time.md | 5 ++++- tests/browser/time.spec.ts | 37 ++++++++++++++++++++++++++++++++++++- 4 files changed, 69 insertions(+), 19 deletions(-) diff --git a/apps/time/index.html b/apps/time/index.html index bf54df2..d393c12 100644 --- a/apps/time/index.html +++ b/apps/time/index.html @@ -556,11 +556,11 @@ <h3>Find a time</h3> beside a name folds or opens that person; the red bin removes them. </li> <li> - The whole call must fit, within the dates searched. On a night the clocks change, a - window in a repeated hour counts both times, and a skipped hour does not exist. Times - that are preferred by everyone come first. Compromises then rank by the most minutes any - one person spends outside their preferred hours, then by everyone's total, then the - earliest. A run of starts that cost the same is one suggestion. + The whole call must fit, within the dates searched and from now on. On a night the + clocks change, a window in a repeated hour counts both times, and a skipped hour does + not exist. Times that are preferred by everyone come first. Compromises then rank by the + most minutes any one person spends outside their preferred hours, then by everyone's + total, then the earliest. A run of starts that cost the same is one suggestion. </li> <li> Unavailable time is never used unless that person's "Allow unavailable time" is on; diff --git a/apps/time/src/meet.ts b/apps/time/src/meet.ts index baff631..b336d56 100644 --- a/apps/time/src/meet.ts +++ b/apps/time/src/meet.ts @@ -539,16 +539,17 @@ export function meetView(app: App): View { if (days < 1) return { problem: "The last date is before the first." }; if (days > MAX_SEARCH_DAYS) return { problem: `Search up to ${MAX_SEARCH_DAYS} days at a time.` }; - return { - search: { - from: instantOf(atMinutes(from, 0), zone()), - to: instantOf(atMinutes(addDays(to, 1), 0), zone()), - duration: plan.duration, - step: STEP, - }, - }; + const end = instantOf(atMinutes(addDays(to, 1), 0), zone()); + // A time already past is no time to meet: starts begin at the next quarter hour from now. + const soonest = Math.max(instantOf(atMinutes(from, 0), zone()), nextStart()); + if (soonest + plan.duration * MINUTE > end) + return { problem: "No time is left in these dates: search from today on." }; + return { search: { from: soonest, to: end, duration: plan.duration, step: STEP } }; } + /** The next quarter hour from now: every zone in use is a whole number of them from UTC. */ + const nextStart = () => Math.ceil(Date.now() / (STEP * MINUTE)) * STEP * MINUTE; + const usable = () => plan.people.filter((p) => canonicalZone(p.zone) !== undefined); function tier(s: Suggestion): "preferred" | "compromise" | "relaxed" { @@ -662,6 +663,7 @@ export function meetView(app: App): View { function research(): void { const note = byId("search-note"); const { search, problem } = range(); + searchedFrom = search?.from; const people = usable(); const broken = plan.people.filter((p) => !people.includes(p)).map((p) => p.name || "Unnamed"); showMessage( @@ -701,6 +703,13 @@ export function meetView(app: App): View { } const researchSoon = debounce(research, 150); + // A page left open searches again as the next quarter hour comes, while the search starts now, + // so its suggestions never fall behind the clock. + let searchedFrom: number | undefined; + setInterval(() => { + if (visible && !document.hidden && range().search?.from !== searchedFrom) research(); + }, 30_000); + function changed(): void { savePlan(); researchSoon(); @@ -849,12 +858,15 @@ export function meetView(app: App): View { const blocked = people .filter((p, i) => !p.relaxed && costs[i].unavailable > 0) .map((p) => p.name || "Unnamed"); + const passed = selected < Date.now(); showMessage( byId("picked-text"), - (blocked.length - ? `Does not fit: ${blocked.join(", ")} ${blocked.length === 1 ? "is" : "are"} unavailable for part of it. ` - : "") + explain(people, costs), - blocked.length > 0 + (passed ? "That time has passed. " : "") + + (blocked.length + ? `Does not fit: ${blocked.join(", ")} ${blocked.length === 1 ? "is" : "are"} unavailable for part of it. ` + : "") + + explain(people, costs), + passed || blocked.length > 0 ); byId("picked-summary").textContent = summary(people, selected, plan.duration); copy.disabled = false; diff --git a/docs/apps/time.md b/docs/apps/time.md index 53d3a62..dae8636 100644 --- a/docs/apps/time.md +++ b/docs/apps/time.md @@ -33,7 +33,10 @@ or not. A new person keeps business hours, preferred from 09:00 to 17:00 on week visit opens on two such people, Alex in Paris and Sam in New York, who overlap for two hours. The search covers up to 31 days, from midnight to midnight in the zone the results are shown in, -for a call of 15 minutes to 3 hours. Windows are resolved date by date in each person's zone, so a +for a call of 15 minutes to 3 hours. A time already past is never suggested: the search starts at +the next quarter hour from now when its first day is today, says so when no time is left in its +dates, and searches again as each quarter hour passes while the page is open. A call placed in the +past by hand says that it has passed. Windows are resolved date by date in each person's zone, so a recurring evening follows that person's daylight-saving changes. A window covers the real time when that person's clock reads inside it: where the clocks go back, a window in the repeated hour is there twice and the time between is not; where they go forward, the part in the skipped hour does diff --git a/tests/browser/time.spec.ts b/tests/browser/time.spec.ts index fbd885c..0beee3a 100644 --- a/tests/browser/time.spec.ts +++ b/tests/browser/time.spec.ts @@ -30,7 +30,17 @@ const PEOPLE = [ }, ]; -async function open(page: Page, people: unknown[], draft: Record<string, unknown> = {}) { +/** + * Time on a plan and a draft, at a fixed moment: by default 22:00 in Paris on Sunday 11 October + * 2026, the evening before the day the tests search, since times already past are never suggested. + */ +async function open( + page: Page, + people: unknown[], + draft: Record<string, unknown> = {}, + now = "2026-10-11T20:00:00Z" +) { + await page.clock.setFixedTime(new Date(now)); // Written from another page of the site: Time keeps its own draft as it is left. await page.goto("/Tools/"); await page.evaluate( @@ -291,6 +301,31 @@ test("a reload shows the view and the folds the tab had, from the first paint", await expect(toggle).toHaveAttribute("aria-expanded", "false"); }); +test("times already past are never suggested", async ({ page }) => { + const day = { + view: "meet", + rangeFrom: "2026-10-12", + rangeTo: "2026-10-12", + meetZone: "Europe/Paris", + }; + // 18:20 in Paris: the overlap from 18:00 has begun, so the soonest start is 18:30. + await open(page, PEOPLE, day, "2026-10-12T16:20:00Z"); + const first = page.locator(".time-suggestion").first(); + await expect(first).toContainText("Mon 12 Oct, 18:30–19:30"); + await expect(first).toContainText("or start any time until 20:00"); + await expect(page.locator("#picked-time")).toHaveValue("18:30"); + // A call placed in the past by hand says so. + await page.locator("#picked-time").fill("10:00"); + await page.locator("#picked-time").dispatchEvent("change"); + await expect(page.locator("#picked-text")).toContainText("That time has passed."); + // Dates wholly past leave nothing to search. + await open(page, PEOPLE, day, "2026-10-13T06:00:00Z"); + await expect(page.locator("#search-note")).toHaveText( + "No time is left in these dates: search from today on." + ); + await expect(page.locator(".time-suggestion")).toHaveCount(0); +}); + test("dragging the call along the timeline moves it as the pointer moves", async ({ page }) => { await open(page, PEOPLE, { view: "meet", From 6af65f52121fa9bace8750ef9ddb2d35c92343a4 Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Wed, 14 Oct 2026 00:04:45 +0200 Subject: [PATCH 09/10] Time: Find a time first in the index card's picture, Unix seconds below Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/time/public/art.svg | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/time/public/art.svg b/apps/time/public/art.svg index 8130cdb..d0f9152 100644 --- a/apps/time/public/art.svg +++ b/apps/time/public/art.svg @@ -1 +1 @@ -<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320"><defs><linearGradient id="backdrop" x2="0" y2="1"><stop stop-color="#5b8def" stop-opacity=".16"/><stop offset="1" stop-color="#5b8def" stop-opacity=".04"/></linearGradient><pattern id="off" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><path d="M0 0v6" stroke="#8a93a8" stroke-opacity=".35" stroke-width="2"/></pattern><clipPath id="bounds"><rect x="16" y="16" width="288" height="288" rx="9"/></clipPath></defs><rect width="320" height="320" fill="url(#backdrop)"/><g font-family="ui-monospace, Cascadia Code, Menlo, Consolas, monospace" clip-path="url(#bounds)"><rect x="16" y="16" width="288" height="118" rx="9" fill="#8a93a8" fill-opacity=".10" stroke="#8a93a8" stroke-opacity=".35"/><text x="28" y="39" fill="#4f86e8" font-size="12">Unix seconds</text><path d="M16 49h288" stroke="#8a93a8" stroke-opacity=".3"/><text x="28" y="76" fill="#c48a1c" font-size="16">1760264100</text><text x="28" y="100" fill="#3a9d62" font-size="12">2025-10-12T10:15:00Z</text><text x="28" y="120" fill="#8a90a0" font-size="11">12:15 Paris · 06:15 New York</text><rect x="16" y="146" width="288" height="158" rx="9" fill="#8a93a8" fill-opacity=".10" stroke="#8a93a8" stroke-opacity=".35"/><text x="28" y="169" fill="#a066bc" font-size="12">Find a time</text><path d="M16 179h288" stroke="#8a93a8" stroke-opacity=".3"/><g font-size="10" fill="#8a90a0"><text x="28" y="204">Paris</text><text x="28" y="234">New York</text><text x="28" y="264">Tokyo</text></g><g transform="translate(96 194)"><rect width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="58" width="74" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="132" width="50" height="14" fill="#5fbf7f" fill-opacity=".7"/><rect y="30" width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="66" y="30" width="56" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="122" y="30" width="74" height="14" fill="#5fbf7f" fill-opacity=".7"/><rect y="60" width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="0" y="60" width="40" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="138" y="60" width="58" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="140" y="-5" width="24" height="84" rx="3" fill="#5b8def" fill-opacity=".18" stroke="#5b8def" stroke-width="2"/></g><text x="28" y="290" fill="#3a9d62" font-size="11">Mon 20:00 · 14:00 · Tue 03:00</text></g></svg> +<svg xmlns="http://www.w3.org/2000/svg" width="320" height="320" viewBox="0 0 320 320"><defs><linearGradient id="backdrop" x2="0" y2="1"><stop stop-color="#5b8def" stop-opacity=".16"/><stop offset="1" stop-color="#5b8def" stop-opacity=".04"/></linearGradient><pattern id="off" width="6" height="6" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><path d="M0 0v6" stroke="#8a93a8" stroke-opacity=".35" stroke-width="2"/></pattern><clipPath id="bounds"><rect x="16" y="16" width="288" height="288" rx="9"/></clipPath></defs><rect width="320" height="320" fill="url(#backdrop)"/><g font-family="ui-monospace, Cascadia Code, Menlo, Consolas, monospace" clip-path="url(#bounds)"><g transform="translate(0 -130)"><rect x="16" y="146" width="288" height="158" rx="9" fill="#8a93a8" fill-opacity=".10" stroke="#8a93a8" stroke-opacity=".35"/><text x="28" y="169" fill="#a066bc" font-size="12">Find a time</text><path d="M16 179h288" stroke="#8a93a8" stroke-opacity=".3"/><g font-size="10" fill="#8a90a0"><text x="28" y="204">Paris</text><text x="28" y="234">New York</text><text x="28" y="264">Tokyo</text></g><g transform="translate(96 194)"><rect width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="58" width="74" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="132" width="50" height="14" fill="#5fbf7f" fill-opacity=".7"/><rect y="30" width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="66" y="30" width="56" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="122" y="30" width="74" height="14" fill="#5fbf7f" fill-opacity=".7"/><rect y="60" width="196" height="14" rx="3" fill="url(#off)" stroke="#8a93a8" stroke-opacity=".35"/><rect x="0" y="60" width="40" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="138" y="60" width="58" height="14" fill="#e0b050" fill-opacity=".55"/><rect x="140" y="-5" width="24" height="84" rx="3" fill="#5b8def" fill-opacity=".18" stroke="#5b8def" stroke-width="2"/></g><text x="28" y="290" fill="#3a9d62" font-size="11">Mon 20:00 · 14:00 · Tue 03:00</text></g><g transform="translate(0 170)"><rect x="16" y="16" width="288" height="118" rx="9" fill="#8a93a8" fill-opacity=".10" stroke="#8a93a8" stroke-opacity=".35"/><text x="28" y="39" fill="#4f86e8" font-size="12">Unix seconds</text><path d="M16 49h288" stroke="#8a93a8" stroke-opacity=".3"/><text x="28" y="76" fill="#c48a1c" font-size="16">1760264100</text><text x="28" y="100" fill="#3a9d62" font-size="12">2025-10-12T10:15:00Z</text><text x="28" y="120" fill="#8a90a0" font-size="11">12:15 Paris · 06:15 New York</text></g></g></svg> From 91ffd94a96404505f3e98e1639514100b59e8514 Mon Sep 17 00:00:00 2001 From: Peter Hewat <peter.hewat@gmail.com> Date: Wed, 14 Oct 2026 00:28:38 +0200 Subject: [PATCH 10/10] Time: header steps and date checks that hold on Linux CI on Linux failed what passed on Windows: - Linux fonts make the full Time header 482px (450px on Windows), so compactHeader is 482, and the views become a dropdown below 400px rather than 363px (measured on Windows, plus what Linux adds; the header test checks 390px and 420px). - WebKit on Linux writes a short date "Mon, 12 Oct", others "Mon 12 Oct": the browser tests accept either. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --- apps/time/src/styles.css | 7 ++++--- packages/catalog/src/index.ts | 2 +- tests/browser/time.spec.ts | 11 ++++------- 3 files changed, 9 insertions(+), 11 deletions(-) diff --git a/apps/time/src/styles.css b/apps/time/src/styles.css index 747c7d9..706b217 100644 --- a/apps/time/src/styles.css +++ b/apps/time/src/styles.css @@ -709,9 +709,10 @@ /* ---------- Header ---------- */ -/* When the three views no longer fit beside "‹" and the theme and Help buttons (measured: 363px), - they become a dropdown. */ -@media (width < 363px) { +/* When the three views no longer fit beside "‹" and the theme and Help buttons, they become a + dropdown. Measured at 363px on Windows; Linux's fonts take about 30px more (the full header is + 482px in CI against 450px here), so 400px, which the header test checks at 390px and 420px. */ +@media (width < 400px) { .time-header .time-views { display: none; } diff --git a/packages/catalog/src/index.ts b/packages/catalog/src/index.ts index 46dc11a..d13a016 100644 --- a/packages/catalog/src/index.ts +++ b/packages/catalog/src/index.ts @@ -142,7 +142,7 @@ export const APPS: readonly ToolsApp[] = [ icon: "M21 12a9 9 0 1 1-18 0 9 9 0 0 1 18 0zM12 7v5l3 2", tags: ["time", "timestamp", "timezone", "iso-8601", "meeting", "duration", "age"], art: true, - compactHeader: 450, + compactHeader: 482, jsBudget: { entryGzip: 24, totalGzip: 24 }, status: "stable", listed: true, diff --git a/tests/browser/time.spec.ts b/tests/browser/time.spec.ts index 0beee3a..fa0d56a 100644 --- a/tests/browser/time.spec.ts +++ b/tests/browser/time.spec.ts @@ -141,18 +141,15 @@ test("Time finds a call time, explains compromises, relaxes on request and share }; await open(page, PEOPLE, day); const first = page.locator(".time-suggestion").first(); - await expect(first).toContainText("Mon 12 Oct, 18:00–19:00"); + // The short date is the browser's: "Mon 12 Oct" in some, "Mon, 12 Oct" in WebKit on Linux. + await expect(first).toContainText(/Mon,? 12 Oct, 18:00–19:00/); await expect(first).toContainText("or start any time until 20:00"); await expect(first).toContainText("Everyone: preferred hours."); await expect(first).toHaveAttribute("data-tier", "preferred"); // The call is on the best suggestion before anyone chooses. await expect(first).toHaveAttribute("aria-pressed", "true"); await expect(page.locator("#picked-summary")).toHaveText( - [ - "Call, 1 h", - "Ana (Europe/Paris): Mon 12 Oct, 18:00–19:00", - "Ben (America/New_York): Mon 12 Oct, 12:00–13:00", - ].join("\n") + /^Call, 1 h\nAna \(Europe\/Paris\): Mon,? 12 Oct, 18:00–19:00\nBen \(America\/New_York\): Mon,? 12 Oct, 12:00–13:00$/ ); await expect(page.locator(".tl-row")).toHaveCount(3); await expect(page.locator(".tl-call")).toHaveCount(3); @@ -311,7 +308,7 @@ test("times already past are never suggested", async ({ page }) => { // 18:20 in Paris: the overlap from 18:00 has begun, so the soonest start is 18:30. await open(page, PEOPLE, day, "2026-10-12T16:20:00Z"); const first = page.locator(".time-suggestion").first(); - await expect(first).toContainText("Mon 12 Oct, 18:30–19:30"); + await expect(first).toContainText(/Mon,? 12 Oct, 18:30–19:30/); await expect(first).toContainText("or start any time until 20:00"); await expect(page.locator("#picked-time")).toHaveValue("18:30"); // A call placed in the past by hand says so.