Cross-device PWA for world clock, time zone conversion and meeting planning. Client-only and offline-first — no account, no server, your data stays on your device.
Live: https://oinsio.github.io/time-zones/ — installable to the home screen, works offline after the first visit.
The Cards view on phone and tablet. Details: docs/design.
![]() |
![]() |
| Light theme | Dark theme |
- Locations — add and remove locations via search by city, country or time zone abbreviation (
EST,IST). Each location is stored by its IANA identifier ("Europe/Moscow"), never by a raw UTC offset. - Persistence — the list of locations is saved on the device and restored on the next visit, fully offline.
- Home location — optionally mark one location as home; every other location shows its offset difference from it (
−2 h,+0:30). Without home, differences are counted from the device time zone, which is always on screen: as the Here mark on a matching location or as the Here entry. - Day-night comparison — every location is a card with its time and a day track colored by its local day periods (night / morning / working hours / evening), with the day boundary marked.
- Choosing a time — drag the handle on any day track or type a time in any location; all other locations are recalculated.
- Date selection — pick a date; UTC offsets and daylight saving time are resolved for the selected date, not for today.
- 12/24-hour format — switch between 12-hour and 24-hour time.
Russian and English are supported from the start. The locale system is built to be extended with new languages without code changes:
- Auto-discovered locale files — every
src/locales/<code>.jsonis picked up automatically. Each file carries a_metablock (code,name,nativeName,baseLanguage,emoji);_meta.codemust match the file name. - Adding a language — drop a new locale file with a full set of keys; it appears in the language switcher.
- Easter-egg dialects — themed locales override only some keys of their
baseLanguageand fall back to it for everything else, including plural rules. - Language detection — on first visit the browser language is used (
en-US→en); the user's choice is then persisted locally.
- React 18, TypeScript, Vite, Tailwind CSS
- Temporal API (
temporal-polyfill) for all date/time math - i18next + react-i18next
- PWA via
vite-plugin-pwa(Workbox) - Testing: Vitest, vitest-cucumber (BDD unit), playwright-bdd (BDD E2E), axe-core, Stryker (mutation)
Requires Node.js >= 26 (the exact major is in .nvmrc) and pnpm >= 9.
The project's ubiquitous language — every domain term, the code that owns it and its Russian name — is defined in the glossary. Start there before changing domain code.
pnpm install
pnpm dev # start dev server
pnpm build # production build
pnpm --filter @time-zones/client preview # serve the production build (with the service worker)
pnpm preflight # lint + typecheck + testsThe app is served under /time-zones/ in every mode (dev, preview, production), so open http://localhost:<port>/time-zones/. The base path is defined once in packages/client/app.config.ts.
All icons (favicon, Apple touch icon, 192/512 px and maskable manifest icons) are generated during the build from one image. To change the logo, replace packages/client/assets/app-icon-source.png with a square PNG with a transparent background (at least 512×512 px) and run pnpm build — no other edits are needed. Padding and background of the maskable and Apple icons are set in packages/client/pwa-assets.config.ts.
Features are developed with OpenSpec: /opsx:propose → /opsx:apply → /opsx:archive.
- Every pull request runs CI: lint, typecheck, unit tests, production build, initial JS budget (150 KB gzipped), a check that the build left sources untouched, and the smoke E2E.
- Every push to
mainruns the same checks and, only if they pass, deploys the build to GitHub Pages. A failed check leaves the previous version live; rollback is a revert onmain.
Gnomish Factory runs AI agents ("gnomes") through a pipeline of declarative stages that live in this repository. Every stage ends in automated checks and an LLM judge; a stage that keeps failing escalates to a human instead of shipping. The whole setup lives in .gnomish/:
| Path | What it is |
|---|---|
config.yaml |
tracker (GitHub issues of oinsio/time-zones), attempt limit, WIP limit, labels |
pipeline.yaml |
stage order |
lib/ |
shell helpers the stage checks source; task-change.sh resolves the task's change and reports |
stages/<name>/ |
one stage: stage.yaml (executor, checks), instructions.md (the brief), acceptance.md (judge criteria) |
factory/ |
everything that launches the factory, next to the pipeline rather than part of it — see below |
factory/ holds:
| Path | What it is |
|---|---|
gnomish |
wrapper script — the only way to run the factory here |
gnomish-up |
serve plus a live dashboard and the INFO log in one terminal, via the wrapper |
gnomish.env |
the wrapper's own settings: project name, log level, factory version, gnomish-up |
gnomish.local.env |
optional personal overrides of gnomish.env, git-ignored |
project.yaml.example.host |
template of the factory's project file ~/.gnomish/projects/time-zones/project.yaml, host binding |
project.yaml.example.container |
the same template for the container binding |
gnomish-<version>/ |
the unpacked factory release, git-ignored, you put it there yourself |
sandbox/ |
Dockerfile of the box gnomes run in under the container binding |
build-sandbox |
builds that image with the tool versions this repository pins |
The pipeline (pipeline.yaml) takes a task from an idea — or from a change already written on main — to a pull request in nine stages, then reports what it cost. Reviewers and the judges that arbitrate between reviewer and fixer run on Opus; the stages that write run on Sonnet; the report runs on Haiku.
| # | Stage | What the gnome does | Accepted when |
|---|---|---|---|
| 1 | specify |
picks the change the task names, or writes a new one with /opsx:propose; checks its dependencies; writes the change record |
change record names an active change, verdict no-blocking-dependencies, openspec validate <change> --strict, proposal sections and FR/M ids from .claude/rules/, judge |
| 2 | review-specs |
reviews the change for freshness, completeness and consistency and writes findings to review-specs.md, changing nothing else |
report sections, well-formed R<n> findings all open, no other file touched, judge |
| 3 | fix-specs |
fixes or rejects every finding with evidence, revising only files of the change | no open findings, a resolution for each, nothing outside the change, strict validation, judge |
| 4 | implement |
implements the change in packages/client with TDD (/opsx:apply) and ticks every task |
all tasks ticked, 300-line cap, the CI steps: lint, typecheck, test, build, bundle size, BDD E2E; judge |
| 5 | review-code |
reviews the implementation (tasks, requirements, scoped mutation runs) and writes findings to review-code.md, changing no code |
same report format as review-specs, no other file touched, judge |
| 6 | fix-code |
fixes the findings worth fixing with TDD, rejects the rest with evidence | no open findings, tasks stay ticked, the CI steps again, judge |
| 7 | archive |
archives the change under openspec/changes/archive/YYYY/MM/ and folds its spec deltas into openspec/specs/ |
the task's change is no longer active, grouped archive layout, openspec validate --specs --strict, judge |
| 8 | cleanup |
removes the task's reports, temporary-docs/gnomish/<task>/, so the pull request carries no scratch notes |
the directory is gone, nothing else touched |
| 9 | deliver |
opens (or updates) a pull request to main and mirrors its body into pr-body.md |
open PR to main with a real title, a body that references the issue and matches pr-body.md, judge |
| 10 | usage-report |
runs one script that posts the task's agent time, check time and tokens per stage and per model as one comment on the pull request; reads the usage from the task branch's round commits, so it needs no factory jar | the pull request carries the marked ## Factory usage comment, no file touched |
Which change a task works on. openspec/changes/ may hold several active changes written ahead of time; only one is the task's. specify records it in temporary-docs/gnomish/<task>/change.md (Change: and Verdict: lines), and every later stage reads the name from there — the other active changes stay untouched. To run a change that is already on main, name it in the issue: in the title, or as a Change: <name> line in the body, which wins over every other name the issue mentions. All reports of a task (change.md, review-specs.md, review-code.md) live in the same directory; cleanup removes it before the pull request, and the reports stay in the branch history.
Every stage advances automatically (advancement: auto); after autonomy.attemptLimit (2) failed attempts the task escalates to a human. The checks compare the branch against origin/main, so .gnomish/ itself has to be on main before the factory works tasks — see Running.
Do this once per machine, from the root of your clone. The factory keeps everything personal — settings, secrets, logs, worktrees — in its home, ~/.gnomish (or $GNOMISH_HOME), outside the clone, so nothing of yours ends up in git.
1. Install the tools.
-
Java 25+ and the Claude Code CLI (
claude) onPATH, logged in once withclaude. -
git 2.56.0 or newer on
PATH: the factory refuses an older one at startup (macOS:brew install git). -
The OpenSpec CLI, installed globally, at the version pinned in
package.json(1.13.2). The gnome works in a worktree outside this clone (~/.gnomish/projects/time-zones/worktrees/time-zones/<task>), wherenode_modulesdoes not exist until the stage's firstpnpm install:npm install -g @fission-ai/openspec@1.13.2 # or: brew install openspec -
git pushtooriginworking from this clone without a prompt (SSH key or a credential helper). The factory pushes each task branchgnomish/<task-id>itself, with your git credentials, and never asks for a password: a push that would prompt fails. -
For the host binding: the project toolchain — Node.js >= 26, pnpm,
gh,jqand Playwright Chromium for the BDD E2E check ofimplementandfix-code:pnpm install pnpm --filter @time-zones/client exec playwright install chromium -
For the container binding: Docker, running. The image carries the toolchain itself (step 6).
2. Unpack the factory release next to the wrapper (it is git-ignored). The version is GNOMISH_VERSION in gnomish.env; to upgrade, change it there and repeat:
cd .gnomish/factory
VERSION=0.1.0
BASE=https://github.com/oinsio/gnomish-factory/releases/download/v$VERSION
curl -fL -O "$BASE/gnomish-$VERSION.tar.gz" -O "$BASE/SHA256SUMS"
shasum -a 256 -c --ignore-missing SHA256SUMS # Linux: sha256sum -c --ignore-missing SHA256SUMS
tar -xzf "gnomish-$VERSION.tar.gz"
gnomish-$VERSION/bin/gnomish --version # prints 0.1.0
cd -3. Register the clone and choose where gnomes run. Every factory command refuses an unregistered directory. Registering creates the project file ~/.gnomish/projects/time-zones/project.yaml; then append the template for one of the two bindings:
| Template | Gnomes run | Choose it when |
|---|---|---|
project.yaml.example.host |
on this machine, as you: your files, your network, no limits | you trust the tasks and want the simplest setup |
project.yaml.example.container |
in an ephemeral Docker box per task, network limited to three hosts | you want isolation from your machine |
.gnomish/factory/gnomish project add time-zones --dir="$PWD"
cat .gnomish/factory/project.yaml.example.host >> ~/.gnomish/projects/time-zones/project.yaml # or .containerThe project file is where the factory's settings live — the binding, the sandbox image, the egress allowlist. The wrapper expects the project name time-zones (GNOMISH_PROJECT_NAME in gnomish.env); if you register under another name, set it in .gnomish/factory/gnomish.local.env.
4. Put the tokens in place. Each secret is a file in ~/.gnomish/projects/time-zones/secrets/, named exactly like the variable, holding only the bare value (no KEY=, no quotes), mode 600 — a file readable by others is refused.
| File | Needed | What to put in it | Who uses it |
|---|---|---|---|
GNOMISH_GITHUB_TOKEN |
yes, for take and serve |
GitHub token for the tracker repository (tracker.github.repo in config.yaml): Issues read/write |
the factory: claims issues, moves gnomish:* labels, posts comments |
GH_TOKEN |
recommended | fine-grained token for the same repository: Contents and Pull requests read/write | gh in the deliver stage, to open the pull request; exported by the wrapper |
CLAUDE_CODE_OAUTH_TOKEN |
container: yes; host: optional | the token claude setup-token prints |
the agent and the judges; exported by the wrapper. On the host, without it, your claude login is used |
Without GH_TOKEN the wrapper hands gh the tracker token instead — then it needs Contents and Pull requests too, and the gnome holds its issue rights as well. A plain run without the tracker needs no GNOMISH_GITHUB_TOKEN.
secrets=~/.gnomish/projects/time-zones/secrets
mkdir -p -m 700 "$secrets"
for name in GNOMISH_GITHUB_TOKEN GH_TOKEN CLAUDE_CODE_OAUTH_TOKEN; do
install -m 600 /dev/null "$secrets/$name"
done
claude setup-token # prints the token for CLAUDE_CODE_OAUTH_TOKEN
$EDITOR "$secrets/GNOMISH_GITHUB_TOKEN" # paste each token into its file and saveA token you use for every project can go into ~/.gnomish/secrets/<NAME> once instead: the project folder is searched first, then that one.
5. Check the setup.
.gnomish/factory/gnomish project show time-zones # the binding and every setting, with the file and line it came from
.gnomish/factory/gnomish board # reaches the tracker with your token: three empty or filled columnsA misplaced setting or a secret file with loose permissions stops the factory before it touches anything, listing every problem with its fix.
6. Container binding only: build the image — see Running in Docker:
.gnomish/factory/build-sandbox7. Run. serve and take work only what is on main, so start from an up-to-date main:
.gnomish/factory/gnomish run --task="Add a meeting planner view" # one task, no tracker
.gnomish/factory/gnomish-up # daemon over issues labelled gnomish:ready, with a dashboardThe commands, logs and labels are in Running.
The wrapper adds --dir (this project), loads gnomish.env and exports GH_TOKEN and CLAUDE_CODE_OAUTH_TOKEN from the secrets folder. Factory settings come from ~/.gnomish/factory.yaml (host), then ~/.gnomish/projects/time-zones/project.yaml, then a flag (--factory.git-network-timeout=10m) — each outranks the one before it, except the sandbox-boundary keys, which only project.yaml may set. The wrapper's own settings are overridden in gnomish.local.env or the shell (GNOMISH_LOG_LEVEL=DEBUG .gnomish/factory/gnomish ...).
# One ad-hoc task, no tracker: branch gnomish/<task-id> in a worktree, the clone is not touched
.gnomish/factory/gnomish run --task="Add a meeting planner view"
# Tasks from GitHub issues: label an issue gnomish:ready, then
.gnomish/factory/gnomish take 42 # work that issue
.gnomish/factory/gnomish serve --drain # work the whole ready queue, then exit
# The same daemon, but watchable: opens the dashboard in a browser and follows the INFO log
.gnomish/factory/gnomish-up # serve flags pass through: --drain, --slots=2
.gnomish/factory/gnomish-up --no-open --no-logs
.gnomish/factory/gnomish status <task-id> # where a task is and what happened to itserve and take read tracker: from the default branch and the stages from the task's base (main), so commit .gnomish/ changes and merge them to main before they apply there. Start run from an up-to-date main too: fix-specs, implement and fix-code diff the branch against origin/main, and unmerged commits of another branch would count as the task's own changes.
Without --base, run reads .gnomish/ from the working tree, so uncommitted edits to a stage take effect immediately. A finished task leaves a gnomish/<task-id> branch; squash-merge it so the round-by-round history stays on the branch. Logs: ~/.gnomish/projects/time-zones/logs/default.log. Labels: gnomish:ready → gnomish:working → gnomish:delivered, or gnomish:needs-human when a task escalates.
With factory.bindings.default: host in project.yaml every gnome process runs on this machine as you, with access to your files and no network restrictions. The container binding (project.yaml.example.container) runs each task in an ephemeral Docker box instead, behind an egress guard that lets through only api.anthropic.com, registry.npmjs.org and api.github.com.
-
Docker running on this machine.
-
The image, built once and again whenever pnpm, openspec or Playwright change in the repository (bump the
factory.sandbox.imagetag inproject.yamlthen —build-sandboxreads it from there). It carries node from.nvmrc, pnpm, openspec, the Claude Code CLI,gh,jqand Playwright Chromium at the versionspackage.jsonandpnpm-lock.yamlpin:.gnomish/factory/build-sandbox
-
CLAUDE_CODE_OAUTH_TOKENin the secrets folder (step 4) orANTHROPIC_API_KEYin the shell — a box has no keychain, so the host login does not carry over.
The mode is factory.bindings.default in ~/.gnomish/projects/time-zones/project.yaml: host or container. It is a sandbox-boundary key, so that file is the only place it can be set — a --factory.bindings.default=... flag stops the factory at startup. To switch, edit the file:
factory:
bindings:
default: host # or containerThe two templates show what each mode needs: container also sets the image, the egress allowlist and the resource limits. Every stage of the pipeline runs in the same mode — the factory refuses a per-stage mix of host and container.
The Java 25 runtime and the factory release stay on the host: the factory itself runs there and drives the boxes through Docker. A host a tool needs but the guard denies shows up as an egress denial: line in gnomish status; add it to factory.sandbox.egress-allowlist in project.yaml only once you know which tool asked for it.
Full reference: the factory's operator guides (operator-guide.md for the tracker, -run.md for run, -serve.md for serve).


