Skip to content

feat(oceanpark): add Ocean Park Hong Kong#130

Open
technodisney wants to merge 3 commits intoThemeParks:mainfrom
technodisney:oceanpark-pr
Open

feat(oceanpark): add Ocean Park Hong Kong#130
technodisney wants to merge 3 commits intoThemeParks:mainfrom
technodisney:oceanpark-pr

Conversation

@technodisney
Copy link
Copy Markdown
Contributor

@technodisney technodisney commented Apr 8, 2026

Summary

Adds Ocean Park Hong Kong as a new destination.

Single destination, one park. No API credentials required — the park's mobile API uses a short-lived bearer token obtained from a public endpoint.

Implementation

Auth

  • optoken header injected via @inject on all requests to sop.oceanpark.com.hk
  • Device UUID generated once and persisted 90 days via @cache
  • Token TTL driven by tokenExpire field in the API response

Coordinates

  • reference_points.json from map.oceanpark.com.hk provides pixel→lat/lng anchor points
  • Affine transform (least-squares, Cramer's rule) projects each entity's pixel position to real-world coordinates across 6 map categories

Entities

  • Rides (sortId: 8), transport (sortId: 7), shows (sortId: 15), dining (sortId: 17)
  • Tags: minimum/maximum height, wet rides, unsuitable for pregnant, FastPass (paidReturnTime)

Live data

  • Wait times and today's operating hours for rides/transport (from pflowInfo)
  • Show times from activityList on entity detail endpoint

Schedules

  • 30-day park operating hours, parking hours, Summit zone informational entries

Test plan

  • npm run dev -- oceanparkhongkong — entities, live data, and schedules return data
  • npm run dev -- oceanparkhongkong --ignore-cache — fresh fetch works correctly
  • npm test — existing tests unaffected

🤖 Generated with Claude Code

- Auth token (optoken) injected via @Inject; device UUID persisted
  90 days via @cache; dynamic TTL from API tokenExpire field
- Coordinate affine transform: fetches reference_points.json from map
  subdomain and projects pixel positions to lat/lng for all entities
- Entities: rides, transport, shows, dining — with height restriction
  tags, wet rides, pregnant warning, and FastPass (paidReturnTime)
- Live data: wait times + today's operating hours (rides/transport),
  showtimes from activityList (shows)
- Schedules: 30-day park operating hours, parking, Summit zone entries

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Copy link
Copy Markdown
Member

@cubehouse cubehouse left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nice work — well-structured implementation with clean auth flow and good use of the decorator patterns. A few items to address:

m[0][1] * (m[1][0] * m[2][2] - m[1][2] * m[2][0]) +
m[0][2] * (m[1][0] * m[2][1] - m[1][1] * m[2][0]);

const D = det(M);
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the map API ever returns collinear or duplicate reference points, D will be zero and all coordinates become NaN/Infinity — which silently propagates through entity locations (the if (coords) check passes because {latitude: NaN, longitude: NaN} is truthy).

Add a guard here:

const D = det(M);
if (Math.abs(D) < 1e-10) return null;

Then handle the null in getCoordinateMapEntries by falling back to no coordinates for that category.

Comment thread src/parks/oceanpark/oceanpark.ts Outdated
// ── Implementation ──────────────────────────────────────────────────────────

@destinationController({category: 'Ocean Park'})
@config
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The @config class decorator is not needed here — @destinationController already applies @config internally (see destinationRegistry.ts line 208). Having both double-wraps the class in config proxies. Remove this line.

Comment thread src/parks/oceanpark/oceanpark.ts Outdated
const coeffs = computeAffineTransform(refPoints);
const entries: [string, {latitude: number; longitude: number}][] = [];

for (const category of MAP_CATEGORIES) {
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These 6 map category fetches are independent but awaited sequentially. With the HTTP queue rate limit (250ms), this adds ~1.5s of wall time on a cold cache.

Use Promise.all to parallelize:

const responses = await Promise.all(
  MAP_CATEGORIES.map(cat => this.fetchMapCategoryData(cat))
);
for (const resp of responses) {
  const entities: OceanParkMapEntity[] = await resp.json();
  // ...
}

@cubehouse cubehouse changed the base branch from ts to main April 14, 2026 07:38
@cubehouse
Copy link
Copy Markdown
Member

Hey @technodisney — just checking in on this. There are 3 inline review comments from April 8 that still need addressing:

  1. Affine transform guard — add a null check when the determinant is zero to prevent NaN coordinates
  2. Remove @config class decorator@destinationController already applies it, so having both double-wraps the class
  3. Parallel map fetches — the 6 sequential category fetches can use Promise.all for a ~1.5s speedup

Let us know if you need any help with these or if you have questions!

- Guard affine transform against degenerate reference points
  (determinant ≈ 0 → return null, caller returns empty coords)
- Remove duplicate @config class decorator (already applied by
  @destinationController)
- Parallelize map category fetches with Promise.all

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
@cubehouse
Copy link
Copy Markdown
Member

Hi @technodisney — I've pushed a fixup commit (af7c195) addressing the 3 review items:

  1. ✅ Affine transform now returns null when the determinant is near-zero; caller handles it gracefully
  2. ✅ Removed the duplicate @config class decorator
  3. ✅ Map category fetches parallelized via Promise.all

CI didn't trigger automatically on the fork branch. @cubehouse — this should be good to merge if you're happy with the implementation after a local npm run dev -- oceanpark test.

…ber guards

Three issues surfaced when reviewing the output:

1. **Schedule timestamps were `2026-05-01T10:00:00GMT+8`** instead of
   `+08:00`. The pre-existing `formatInTimezone(... 'iso')` helper passed
   `Intl.DateTimeFormat`'s `shortOffset` value through verbatim, but for
   timezones like Asia/Hong_Kong the API returns `GMT+8` rather than the
   RFC 3339 `+HH:MM` form. Other parks dodge this by routing through
   `constructDateTime`, which has its own GMT-style normalizer (lines
   183-191 of datetime.ts). Move the same normalization into
   `formatInTimezone` so callers always get a valid ISO 8601 string.

2. **Hardcoded URL defaults** in `@config baseURL` / `@config mapURL`
   violated the project convention (CLAUDE.md: "no hardcoded URLs/secrets;
   all in @config with empty defaults, loaded from .env"). Set both to
   `''`; configuration is via `OCEANPARK_BASEURL` / `OCEANPARK_MAPURL` env
   vars per the project's standard config-prefix mechanism.

3. **`Number()` coercion on schedule timestamps** could silently produce
   `NaN` → `Invalid Date` for malformed `parkOpenTime` / `parkCloseTime` /
   `parkingOpenTime` / `parkingCloseTime` / `summitCloseTime`. Add a
   `parseTs` helper that uses `Number.isFinite()` (per CLAUDE.md numeric-
   validation guidance) and returns null for non-finite values; skip the
   day or sub-block when any required timestamp fails to parse.

Coordinate transform was not dead code as initially feared — after a fresh
cache, ~50 of 94 entities receive real coordinates (the rest are newer
attractions not yet present in the static map JSONs and fall back to the
destination centroid). No change required there.

Also updates one DST-transition test that asserted on the legacy
`GMT-5` / `GMT-4` substring; relaxed to accept the new canonical
`-05:00` / `-04:00` form alongside the old shape.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@cubehouse
Copy link
Copy Markdown
Member

Hi @technodisney — really nice contribution, thank you for the thorough work on this. The Sanrio/etc. coordinate fallback behaviour is sensible and the affine-transform approach for deriving lat/lng from map pixels is elegant.

I've pushed a maintainer-edit commit (e1bd7c0) addressing three small issues that came up during review. Quick rundown so you can see what changed and weigh in:

1. Schedule timestamps were not RFC 3339. Output looked like:

"openingTime": "2026-05-01T10:00:00GMT+8"

Root cause turned out to be a pre-existing bug in formatInTimezone(date, tz, 'iso'): it passed Intl.DateTimeFormat's shortOffset value through verbatim, but for some timezones (Hong Kong included) the API returns GMT+8 rather than the canonical +HH:MM. Other parks happen to dodge this by routing through constructDateTime, which already has a GMT-style normalizer (lines 183-191). I moved the same normalization into formatInTimezone itself so callers always get a valid ISO 8601 string. Output now reads:

"openingTime": "2026-05-01T10:00:00+08:00"

(Side benefit: any future park hitting this same case is now safe.)

2. Hardcoded URL defaults. The two @config URLs had defaults baked in:

@config baseURL: string = 'https://sop.oceanpark.com.hk';
@config mapURL: string = 'https://map.oceanpark.com.hk';

This repo's convention is empty @config defaults loaded from .env via the OCEANPARK_* prefix mechanism (CLAUDE.md: "no hardcoded URLs/secrets"). Switched both to '' — the actual URLs now live in .env as OCEANPARK_BASEURL / OCEANPARK_MAPURL.

3. Number coercion guard on schedule timestamps. Number(day.parkOpenTime) could silently produce NaNInvalid Date. Added a parseTs helper using Number.isFinite() (per CLAUDE.md numeric-validation guidance) that returns null for non-finite values; the relevant day or sub-block is skipped if the parse fails.

One thing I checked but didn't change: I initially thought the affine-transform was dead code (every entity was getting the destination fallback location), but that turned out to be a stale-cache artifact. After a clean run, ~50 of 94 entities receive real coordinates. The unmatched ones are newer attractions (Sanrio, Pompompurin, etc.) not yet present in the static map JSONs — falling back to destination centroid is reasonable for those.

All 1094 tests pass; verified live output. Happy to revert any of this if you'd prefer to handle it differently — and again, thanks for the contribution! 🙏

@technodisney
Copy link
Copy Markdown
Contributor Author

Thank you for that. Give me a few more days to check out the unmapped attractions. Sanrio wasn't present in my initial testing.
Thank you for your patience

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants