Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
fd6ea29
chore(deps): modernize stack to Next 16, React 19, TS 5, Tailwind 4, …
cwjoshuak May 7, 2026
5163669
test: add Vitest + Playwright with regression tests on critical-path …
cwjoshuak May 7, 2026
f5c9b8c
feat(ui): scaffold shadcn — primitives, theme tokens, drop daisyui
cwjoshuak May 7, 2026
58d239e
feat(ui): migrate modals to controlled shadcn Dialog
cwjoshuak May 7, 2026
768abb8
feat(ui): rebuild page chrome with shadcn primitives and modern layout
cwjoshuak May 7, 2026
2d5bc98
feat(ui): rewrite event/merchant cells as cards with shadcn DropdownMenu
cwjoshuak May 7, 2026
310abe0
feat(ui): drop data-theme, polish merchants schedules section
cwjoshuak May 7, 2026
b01822a
fix(ui): dark-mode FOUC, cross-component toggle, layout polish
cwjoshuak May 7, 2026
1dcfd7b
data: refresh from scraper run on 2026-05-07
cwjoshuak May 7, 2026
18bba2a
fix(ui): light-mode contrast, sidebar icon size, group-repeat tooltip
cwjoshuak May 7, 2026
05d5565
fix(ui): drop --legacy-peer-deps, fix surface contrast on selects/fil…
cwjoshuak May 7, 2026
41ee848
fix(ui): prefer metadata names; filter buttons feel clickable
cwjoshuak May 7, 2026
0eb7e5c
fix(ui): disable-menu actions, filter alignment, merchants WIP banner
cwjoshuak May 7, 2026
57c11eb
fix(ui): disabled chip on event cards, soften settings, drop vote link
cwjoshuak May 7, 2026
7c8be1f
chore: remove unused API stubs and dead model files
cwjoshuak May 7, 2026
958c548
fix(a11y): add DialogDescription to all four modals
cwjoshuak May 7, 2026
587a0ab
chore(merchants): disconnect websocket pending crowdsourced rebuild
cwjoshuak May 7, 2026
456da3f
test: add e2e + useLocalStorage unit coverage
cwjoshuak May 7, 2026
fe9c736
feat(regions): adopt 2024 server topology + IANA timezones
cwjoshuak May 7, 2026
f241706
perf(alarms): lazy-load event data and add empty-state for empty days
cwjoshuak May 7, 2026
425c7c9
Revert \"perf(alarms): lazy-load event data and add empty-state for e…
cwjoshuak May 7, 2026
a9be2cc
perf(alarms): pre-build per-day data chunks, dynamic-import on demand
cwjoshuak May 7, 2026
dd632fc
fix(alarms): apply +10min offset to both start AND end of intervals
cwjoshuak May 7, 2026
929ad88
feat(merchants): amber ring around active merchant cards
cwjoshuak May 7, 2026
c7053ef
docs: project guide + decision log + deploy + data pipeline
cwjoshuak May 7, 2026
56832dc
docs: NEXT_SESSION.md status snapshot
cwjoshuak May 7, 2026
2a9962e
chore: drop unused should_deploy.sh + sound.d.ts; ignore artifacts
cwjoshuak May 7, 2026
dc4bdac
chore(deps): add @sentry/nextjs and pin jsdom to ^26
cwjoshuak May 7, 2026
7591b9d
test: fix Node 22+ localStorage shadowing jsdom Storage
cwjoshuak May 7, 2026
5a52c96
feat(observability): wire Sentry with VERCEL_ENV-strict gating
cwjoshuak May 7, 2026
bcc1b7f
feat(analytics): drop Google Analytics, add Umami via extracted compo…
cwjoshuak May 7, 2026
a47b960
chore: add .env.example documenting Umami + Sentry env vars
cwjoshuak May 7, 2026
3e7a7d6
chore(changelog): add 2026-05 entry; refresh planned list
cwjoshuak May 7, 2026
db77079
docs: migrate to ADR-per-file format; sanitize public docs
cwjoshuak May 7, 2026
53ebc09
feat(analytics): wire 18 typed Umami events across action sites
cwjoshuak May 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# Umami analytics (production only — leave unset locally to avoid polluting the dashboard)
NEXT_PUBLIC_UMAMI_SRC=
NEXT_PUBLIC_UMAMI_WEBSITE_ID=

# Sentry error monitoring (production only — leave unset locally)
NEXT_PUBLIC_SENTRY_DSN=
SENTRY_AUTH_TOKEN=
38 changes: 38 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
name: CI

on:
pull_request:
push:
branches: [main]

jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npm run typecheck
- run: npm run test

e2e:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: 22
cache: npm
- run: npm ci
- run: npx playwright install --with-deps chromium
- run: npm run build
- run: npm run test:e2e
- if: always()
uses: actions/upload-artifact@v4
with:
name: playwright-report
path: playwright-report/
retention-days: 7
8 changes: 8 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,8 @@

# testing
/coverage
/test-results/
/playwright-report/

# next.js
/.next/
Expand Down Expand Up @@ -35,3 +37,9 @@ yarn-error.log*

# typescript
*.tsbuildinfo

# Per-day chunks generated by scripts/build-day-data.mjs from data/data.json
/data/days/

# Sentry Config File
.env.sentry-build-plugin
1 change: 1 addition & 0 deletions .nvmrc
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
22
169 changes: 169 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
# lostark-alarms — agent guide

Public Next.js app at `lostarktimer.app`. Two routes: `/alarms` (event
schedule + countdown timers) and `/merchants` (currently parked behind
a WIP banner pending crowdsourced rebuild).

## Stack

- **Next.js 16.2.5** (Pages Router, Turbopack) / **React 19** / **TS 5**
- **Tailwind 4** (CSS-first config in `styles/globals.css`) — no Tailwind config file
- **shadcn/ui** (Radix primitives + cva) — components in `components/ui/`
- **Luxon 3** for time/timezone math (uses IANA names, not UTC offsets)
- **react-i18next** for i18n (EN/ZH); EN is auto-derived from `data/events.json` metadata, ZH overrides via `public/locales/zh/`
- **Howler** for alarm sound playback
- **Vitest** (unit, jsdom) + **Playwright** (e2e, chromium)
- **Node 22** pinned via `.nvmrc`. Older Node will fail Next 16's check.

## Layout

```
pages/
_app.tsx page shell, footer, modal-state owners (gh + changelog)
_document.tsx inline pre-hydration script that sets .dark class to avoid FOUC
_error.tsx Pages-Router error boundary; forwards exceptions to Sentry
alarms.tsx main route — date selector, filter sidebar, event cards
merchants.tsx merchant tracker (parked, WIP banner)
components/
ui/ shadcn primitives (Button, Dialog, Select, etc.)
modals/ Alarm/Merchant/Changelog/GitHub modals — controlled <Dialog>
analytics/ UmamiScript + CloudflareAnalytics (env-driven, prod-only)
GameEventTableCell.tsx per-event card with disable DropdownMenu
MerchantTableCell.tsx per-merchant card
NavBar.tsx header (brand, tabs, locale)
SideBar.tsx fixed icon column on lg+ (gh, paypal, changelog)
instrumentation.ts Next 16 server/edge bootstrap → Sentry init
instrumentation-client.ts Next 16 client bootstrap → Sentry init
sentry.{server,edge}.config.ts per-runtime Sentry init (gated on VERCEL_ENV)
.env.example documents Umami + Sentry public/private vars (no Firebase/Mongo — both unused)
util/
alarmTrigger.ts isWithinNotifyWindow predicate (pure, tested)
createTableData.tsx row-pairing + time-chip rendering
i18n.ts i18next bootstrap with bundled EN+ZH
static.ts RegionTimeZoneMapping + resolveRegion migration shim
useLocalStorage.ts in-house hook with cross-instance broadcast
usePrevious.tsx
types/types.ts RegionKey/ServerKey derived from data/regions.json
common/
GameEvent.ts event model (latest(t) gives next interval)
WanderingMerchant.ts merchant model
api/APIEventType.ts, APIGameEvent.ts
data/
data.json event schedule (committed, ~76 KB; regenerated by scraper)
events.json event id → [name, icon, ilevel] metadata
msgs.json event-type label/icon
days/ GENERATED, per-day chunks of data.json (gitignored)
regions.json region → server[] (current 2024 topology)
itemMapping.json, itemRarity.json, merchants.json, merchantSchedules.json
└─ merchant-only static data; loaded by /merchants
scripts/
build-day-data.mjs prebuild pass that splits data.json → data/days/
public/
sounds/alert_*.mp3 alarm sounds (loaded via URL strings, not webpack assets)
locales/{en,zh}/*.json i18next overrides (EN file is largely stale, kept for ZH)
test/
unit/ Vitest specs
e2e/ Playwright specs
```

## Critical conventions

### Don't expose `data/*.json` at a stable URL
The cleaned schedule is the project's value-add. Putting any of
`data/data.json`, `data/events.json`, `data/msgs.json` (or their
per-day chunks) under `public/` would let anyone curl them. Keep them
imported via `require()` / dynamic `import()` so they end up in
fingerprinted webpack chunks instead.

### Use IANA timezones, not UTC offsets
`util/static.ts` maps region → IANA tz (`America/New_York`, etc.).
Luxon then handles DST automatically. The legacy `UTC-7` strings
silently produced wrong times for half the year.

### Per-day chunks are derived, not committed
`scripts/build-day-data.mjs` runs on `predev` and `prebuild` and
splits `data/data.json` into `data/days/<month>-<day>.json` so the
alarms page only fetches the visible day. The output dir is
gitignored — recreated on every build. If you change `data.json`,
the next build picks it up.

### `+10 min` adjustment must apply to BOTH start and end
`pages/alarms.tsx` shifts certain type-IDs (sailing, ocean co-ops) and
event-IDs forward by 10 min to match in-game spawn timing. After
`normalize.py` started canonicalising single times into degenerate
`HH:MM-HH:MM` ranges, applying the offset only to start (the legacy
behavior in `pages/alarms.tsx`) produced 23h50m "spanning-midnight"
windows. The fix is to apply +10 to both. If you ever revisit the
adjustment, both call sites need the same treatment.

### `useLocalStorage` broadcasts on set
Multiple consumers of the same key (page + modal) used to drift —
toggling a checkbox in the settings modal didn't update the page
until reload. The hook now dispatches a `lostark:localStorage`
custom event on every `setValue`, and every other instance subscribed
to that key updates in place. Don't replace it with raw
`localStorage` access without restoring the broadcast.

### Theme uses `class="dark"`, not `data-theme`
`_document.tsx` has an inline pre-hydration script that reads
`localStorage.darkMode` and applies the `.dark` class on `<html>` so
there's no white flash on a dark-mode reload. The class hook in
`globals.css` is `@custom-variant dark (&:is(.dark *))`. If you ever
re-introduce `data-theme`, the FOUC script needs updating too.

### Modal pattern is controlled
All four modals (`AlarmConfigModal`, `MerchantConfigModal`,
`ChangeLogModal`, `GitHubModal`) take `open` + `onOpenChange` props.
State lives in the page (`pages/_app.tsx` for gh+changelog,
`pages/alarms.tsx` and `pages/merchants.tsx` for their config modals).
The DaisyUI checkbox-toggle pattern is gone.

### Each `<DialogContent>` needs a `<DialogDescription>`
Radix Dialog warns at runtime without one. Use `sr-only` if a visible
description doesn't fit the design.

## Commands

```bash
npm run dev # local dev server (runs prebuild data:days first)
npm run build # production build (runs prebuild data:days first)
npm run start # serve the build
npm run typecheck # tsc --noEmit
npm run test # Vitest (unit, jsdom)
npm run test:watch
npm run test:coverage
npm run test:e2e # Playwright (chromium)
npm run test:e2e:install # one-time chromium download
npm run data:days # regenerate data/days/ from data/data.json
```

CI: `.github/workflows/ci.yml` — typecheck + Vitest on PR; separate
e2e job builds and runs Playwright with artifact upload.

## Data pipeline

`data/{data,events,msgs}.json` is updated by an external pipeline that
runs weekly and opens PRs here with cleaned schedule files. PRs are
titled `chore(data): weekly schedule update`. The pipeline lives outside
this repo. See `docs/decisions/0015-scraper-resiliency.md`.

## What the merchants page is doing

Parked. The `wss://ws.lostarktimer.app` socket was disconnected in
this rewrite — there's no live data. The page renders a "Work in
Progress" banner explaining the upcoming crowdsourced-vote model.
`socket.io-client` is uninstalled. Static merchant data
(`data/merchants.json`, `data/merchantSchedules.json`) still feeds
the schedule reference cards.

## Related repos

- `~/Projects/WanderLost` — clone of `Xeio/WanderLost` (powers
`lostmerchants.com`). Reference for the 2024 server-merge topology
in `data/regions.json` and the eventual merchants rebuild.

## See also

- `docs/decisions/` — ADRs for non-obvious choices (start at the README)
- `docs/ARCHITECTURE.md` — runtime data flow diagram
- `docs/DEPLOY.md` — deploy steps
4 changes: 3 additions & 1 deletion common/GameEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ class GameEvent {
this.times.push(t)
}
latest(t: DateTime): Interval {
return this.times.filter((ti) => ti.start.diff(t).valueOf() > 0)[0]
return this.times.filter(
(ti) => ti.start !== null && ti.start.diff(t).valueOf() > 0
)[0]
}
}

Expand Down
9 changes: 0 additions & 9 deletions common/Item.ts

This file was deleted.

13 changes: 0 additions & 13 deletions common/Region.ts

This file was deleted.

12 changes: 0 additions & 12 deletions common/Server.ts

This file was deleted.

13 changes: 8 additions & 5 deletions common/WanderingMerchant.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
import { DateTime, Interval } from 'luxon'
import { v4 as uuidv4 } from 'uuid'
import Item from './Item'

type MerchantItems = { rapport: number[]; cards: number[]; cooking: number[] }
class WanderingMerchant {
Expand Down Expand Up @@ -51,9 +50,11 @@ class WanderingMerchant {
inProgress(serverTime: DateTime) {
if (this.spawnTime != null) return true
for (const interval of this.schedule) {
const { start, end } = interval
if (!start || !end) continue
if (
interval.start.diff(serverTime).toMillis() < 0 &&
interval.end.diff(serverTime).toMillis() > 0
start.diff(serverTime).toMillis() < 0 &&
end.diff(serverTime).toMillis() > 0
)
return interval
}
Expand All @@ -62,8 +63,10 @@ class WanderingMerchant {
nextSpawnTime(serverTime: DateTime) {
for (let idx = 0; idx < this.schedule.length; idx++) {
let interval = this.schedule[idx]
let startDiff = interval.start.diff(serverTime).toMillis()
let endDiff = interval.end.diff(serverTime).toMillis()
const { start, end } = interval
if (!start || !end) continue
let startDiff = start.diff(serverTime).toMillis()
let endDiff = end.diff(serverTime).toMillis()

// case: time before first event of day
if (startDiff > 0) return interval
Expand Down
21 changes: 21 additions & 0 deletions components.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "styles/globals.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"iconLibrary": "lucide"
}
Loading
Loading