Fix Missing AD Restaurants on Group Landing Page — Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Restore AD (sponsored) restaurants that are silently dropped during client-side deduplication on group landing pages.
Architecture: Two coupled fixes: (1) add the missing isMerged field to the Valibot schema in shared-type so the flag survives TypeScript parsing of the compact-restaurant API response, then (2) restore the guard in mergeSearchResult that reads the flag to skip merging when the backend says not to. A third line in the same function restores rankingId propagation. One new test file covers all three changes.
Tech Stack: TypeScript, Valibot (schema/type inference), Vitest 4.x, Turborepo
Global Constraints
- Tests run only via Turbo:
pnpm exec turbo run test --concurrency 2 --filter=@hh-pegasus/shared-api - OOM guard before every test run:
pgrep -f "turbo run test" && pgrep -f vitest— abort if any PID found - Never invoke
vitestdirectly orpnpm --filter <pkg> test - Typecheck via Turbo:
pnpm exec turbo run typecheck --concurrency 1 --filter=@hh-pegasus/shared-type(after Task 1); same filter forshared-api(after Task 2) - OOM guard before typecheck:
pgrep -a -f "turbo run typecheck"— abort if any PID found - Do not add
import.meta.env.*reads to any library package - Work on a branch or worktree — never commit to
maindirectly
File Map
| File | Action | Responsibility |
|---|---|---|
packages/shared-type/src/Restaurant.ts | Modify lines 252, 370 | Add isMerged?: boolean to both restaurant attribute Valibot schemas |
packages/shared-api/src/common/mergeSearchResult.ts | Modify lines 107–108 | Add isMerged === false guard + rankingId propagation |
packages/shared-api/src/common/__tests__/mergeSearchResult.test.ts | Create | Unit tests: no-duplicates pass-through, normal merge, isMerged: false bypass, rankingId propagation |
Task 1: Add isMerged to restaurant type schemas
Files:
- Modify:
packages/shared-type/src/Restaurant.ts(lines 251–252 and 368–369)
Interfaces:
- Produces:
FeaturedRestaurant["attributes"]["isMerged"]typed asboolean | undefined— consumed by Task 2’s guard
Steps
-
Step 1: Add
isMergedto the regular restaurant attributes schemaIn
packages/shared-type/src/Restaurant.ts, the regular restaurant attributes schema hasisAdsat line 251. AddisMergeddirectly after it:// Before (line 251): isAds: optional(boolean()), rank: optional(nullish(number())), // After: isAds: optional(boolean()), isMerged: optional(boolean()), rank: optional(nullish(number())), -
Step 2: Add
isMergedto the featured restaurant attributes schemaIn the same file,
featuredRestaurantAttrSchemahasisAdsat line 368. AddisMergeddirectly after it:// Before (line 368): isAds: optional(boolean()), position: optional(number()), // After: isAds: optional(boolean()), isMerged: optional(boolean()), position: optional(number()), -
Step 3: OOM guard, then typecheck shared-type
pgrep -a -f "turbo run typecheck" # Must return nothing. If any PID found: STOP, report to user. pnpm exec turbo run typecheck --concurrency 1 --filter=@hh-pegasus/shared-typeExpected: exits 0, no errors.
-
Step 4: Commit
git add packages/shared-type/src/Restaurant.ts git commit -m "fix(shared-type): add isMerged field to restaurant attribute schemas"
Task 2: Restore isMerged guard, rankingId propagation, and add tests
Files:
- Modify:
packages/shared-api/src/common/mergeSearchResult.ts - Create:
packages/shared-api/src/common/__tests__/mergeSearchResult.test.ts
Interfaces:
- Consumes:
FeaturedRestaurant["attributes"]["isMerged"]from Task 1 (now typed asboolean | undefined) - Consumes:
getCompactRestaurantsfrom../restaurant/getCompactRestaurants(already imported in the file)
Steps
-
Step 1: Write the failing test
Create
packages/shared-api/src/common/__tests__/mergeSearchResult.test.ts:import { beforeEach, describe, expect, it, vi } from "vitest"; import { getCompactRestaurants } from "../restaurant/getCompactRestaurants"; import { mergeSearchResult } from "../mergeSearchResult"; import type { FeaturedRestaurant, Restaurant, } from "@hh-pegasus/shared-type/Restaurant"; vi.mock("../restaurant/getCompactRestaurants"); // Minimal stubs — only the fields mergeSearchResult reads function makeRestaurant( id: string, branchId: number, overrides: Partial<{ isAds: boolean; rankingId: string }> = {}, ): Restaurant { return { id, type: "restaurants", rankingId: overrides.rankingId ?? null, searchScore: null, rankingScore: null, highlights: [], relationships: undefined, attributes: { branchId, isAds: overrides.isAds ?? false, }, } as unknown as Restaurant; } function makeCompact( branchId: number, overrides: Partial<{ isMerged: boolean }> = {}, ): FeaturedRestaurant { return { id: `compact-${branchId}`, type: "featured_restaurants", rankingId: null, searchScore: null, rankingScore: null, highlights: [], attributes: { branchId, ...(overrides.isMerged !== undefined ? { isMerged: overrides.isMerged } : {}), }, } as unknown as FeaturedRestaurant; } beforeEach(() => { vi.clearAllMocks(); }); describe("mergeSearchResult", () => { it("returns restaurants unchanged when there are no duplicate branchIds", async () => { const r1 = makeRestaurant("1", 10); const r2 = makeRestaurant("2", 20); const result = await mergeSearchResult("en", [r1, r2]); expect(result.success).toBe(true); expect(result.data).toHaveLength(2); // getCompactRestaurants should not be called — no duplicates expect(getCompactRestaurants).not.toHaveBeenCalled(); }); it("deduplicates duplicate branchIds using compact version when isMerged is not false", async () => { const organic = makeRestaurant("1", 42); const duplicate = makeRestaurant("2", 42); const compact = makeCompact(42); // isMerged undefined → merge vi.mocked(getCompactRestaurants).mockResolvedValue({ success: true, message: "", data: { data: [compact] }, }); const result = await mergeSearchResult("en", [organic, duplicate]); expect(result.success).toBe(true); // Two entries collapsed to one (the compact version) expect(result.data).toHaveLength(1); expect(result.data![0].id).toBe("compact-42"); }); it("keeps both entries when compact has isMerged: false", async () => { const organic = makeRestaurant("1", 55); const ad = makeRestaurant("2", 55, { isAds: true }); const compact = makeCompact(55, { isMerged: false }); vi.mocked(getCompactRestaurants).mockResolvedValue({ success: true, message: "", data: { data: [compact] }, }); const result = await mergeSearchResult("en", [organic, ad]); expect(result.success).toBe(true); // isMerged: false → both entries preserved as originals, not replaced by compact expect(result.data).toHaveLength(2); expect(result.data![0].id).toBe("1"); expect(result.data![1].id).toBe("2"); }); it("propagates rankingId from original to compact when duplicate branchIds trigger merge", async () => { const r1 = makeRestaurant("r1", 88, { rankingId: "rank-xyz" }); const r2 = makeRestaurant("r2", 88); const compact = makeCompact(88); vi.mocked(getCompactRestaurants).mockResolvedValue({ success: true, message: "", data: { data: [compact] }, }); const result = await mergeSearchResult("en", [r1, r2]); expect(result.success).toBe(true); expect(result.data).toHaveLength(1); // rankingId from the first (original) entry is copied to compact expect((result.data![0] as FeaturedRestaurant).rankingId).toBe( "rank-xyz", ); }); it("returns success: false when compact fetch fails", async () => { const r1 = makeRestaurant("a", 99); const r2 = makeRestaurant("b", 99); vi.mocked(getCompactRestaurants).mockResolvedValue({ success: false, message: "network error", data: null, }); const result = await mergeSearchResult("en", [r1, r2]); expect(result.success).toBe(false); expect(result.data).toBeNull(); }); }); -
Step 2: OOM guard, then run the tests to confirm they fail
pgrep -f "turbo run test" && pgrep -f vitest # Must return nothing. If any PID found: STOP, report to user. pnpm exec turbo run test --concurrency 2 --filter=@hh-pegasus/shared-api -- src/common/__tests__/mergeSearchResult.test.tsExpected: the “keeps both entries when compact has isMerged: false” and “propagates rankingId” tests FAIL. The “returns restaurants unchanged” and “returns success: false” tests should pass. If all pass, re-examine — the guard may already exist.
-
Step 3: Add the
isMergedguard tomergeSearchResult.tsIn
packages/shared-api/src/common/mergeSearchResult.ts, find theif (compact) {block (line 106). Add the guard as the first thing inside that block, before any existing logic:// Before: if (compact) { const existingIndex = mergedRestaurantIndexMap.get(branchId); // After: if (compact) { // Backend sets isMerged: false to prevent deduplication — e.g. an organic // entry and a sponsored (isAds: true) entry for the same branch must both // appear. Ported from hh-pegasus mergeSearchResult. if (compact.attributes.isMerged === false) { mergedRestaurants.push(restaurant); continue; } const existingIndex = mergedRestaurantIndexMap.get(branchId); -
Step 4: Add
rankingIdpropagation in the same fileIn the same file, find the first-seen merge block that ends with
mergedRestaurants.push(compact)(around line 130). AddrankingIdpropagation immediately before that push:// Before: compact.attributes.branchRestaurantIds = [String(restaurant.id)]; mergedRestaurants.push(compact); // After: compact.attributes.branchRestaurantIds = [String(restaurant.id)]; compact.rankingId = restaurant.rankingId; mergedRestaurants.push(compact); -
Step 5: OOM guard, then run all tests in the file to confirm they pass
pgrep -f "turbo run test" && pgrep -f vitest # Must return nothing. pnpm exec turbo run test --concurrency 2 --filter=@hh-pegasus/shared-api -- src/common/__tests__/mergeSearchResult.test.tsExpected: all 6 tests PASS.
-
Step 6: Run the full shared-api test suite to check for regressions
pgrep -f "turbo run test" && pgrep -f vitest # Must return nothing. pnpm exec turbo run test --concurrency 2 --filter=@hh-pegasus/shared-apiExpected: all tests pass, no regressions.
-
Step 7: OOM guard, then typecheck shared-api
pgrep -a -f "turbo run typecheck" # Must return nothing. pnpm exec turbo run typecheck --concurrency 1 --filter=@hh-pegasus/shared-apiExpected: exits 0, no type errors.
-
Step 8: Commit
git add packages/shared-api/src/common/mergeSearchResult.ts \ packages/shared-api/src/common/__tests__/mergeSearchResult.test.ts git commit -m "fix(shared-api): restore isMerged guard and rankingId propagation in mergeSearchResult"
Verification (manual, after both tasks)
Start the dev server for this worktree (in a separate terminal — agent-launched servers get reaped):
cd /Users/afifnandya/hungryhub/hh-migrate-project/hh-pegasus-monorepo/.worktree/feature/849-qr-booking-flow
pnpm --filter ionic-app dev
Then open http://localhost:5184/en/famous-and-special-restaurants?city_id=1 on iPhone 14 viewport (390×844) and confirm:
- AD restaurants appear in the list — small “Ad” label visible in the upper-right corner of at least one restaurant card image.
- The total restaurant count and list order matches
https://web.hungryhub.com/en/famous-and-special-restaurants?city_id=1. - Non-AD restaurant cards are unchanged (name, image, price still correct).