ci(client): lint:pairs keeps a desktop view and its phone twin on one hook

Most features have a desktop view and a phone view, and until now each
carried its own copy of the feature's logic, so a fix on one side drifted
away from the other. With the logic moved into one shared hook per
feature, scripts/feature-pairs.json names every feature with its hooks
and the desktop and phone files that run on them.

lint:pairs fails when a listed view stops importing one of its pair's
hooks (a type only import does not count), when a listed file is gone,
when a hook sits in a view layer rather than under components, hooks,
utils or the folder of a page under pages/, and when a file under
src/mobile/screens is in no pair and not under mobileOnly with the
reason it has no twin. A new phone screen is so either wired to the
desktop feature's hook or marked as phone only on purpose. lint:dup
catches the copies that look alike; this one catches the copies written
anew.

The manifest starts with 120 features and 62 phone only screen files,
each with the reason it stays on the phone alone.

client/CLAUDE.md says how to change a feature, add one with both views
and add a phone screen.
This commit is contained in:
Maurice
2026-10-09 10:31:52 +02:00
parent 88d196bae8
commit feef872776
9 changed files with 1279 additions and 6 deletions
+8
View File
@@ -356,6 +356,14 @@ jobs:
# its phone twin share stays in one hook (scripts/dup-lint.mjs).
run: cd client && npm run lint:dup
- name: Desktop and phone pairs check
# Each view in scripts/feature-pairs.json imports its feature's shared
# hook, and every file under src/mobile/screens is listed there, as a
# view of a pair or as phone only with a reason, so a new phone screen
# cannot grow its own copy of a desktop feature's logic
# (scripts/pairs-lint.mjs).
run: cd client && npm run lint:pairs
- name: Skipped and focused tests check
run: cd client && npm run lint:skips
+3 -2
View File
@@ -36,6 +36,7 @@ cd client && npm run e2e # Playwright (CI runs the pub
cd client && npm run lint:pages # enforce the Page pattern (CI gate)
cd client && npm run lint:rtl # physical left/right sides may only shrink per file (CI gate)
cd client && npm run lint:dup # lines in copied code may only shrink per file, at SonarCloud's thresholds (CI gate)
cd client && npm run lint:pairs # desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate)
cd client && npm run lint:size # no source file past 1000 lines, longer ones only shrink (CI gate; server has the same)
cd client && npm run lint:warnings # eslint errors fail, warnings per rule may only shrink (CI gate)
cd client && npm run lint:format # Prettier outside the baseline list (CI gate)
@@ -122,7 +123,7 @@ These principles come out of a full-repo audit and shape all new code:
## Quality gates in CI
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:boundaries`, `lint:tx`, `lint:dialect` and `lint:test-sql`, the client's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
`.github/workflows/test.yml` runs, per package: typecheck (server also `typecheck:tests`), lint (client through `lint:warnings`, which also holds the warning count per rule), `lint:pages`, `lint:rtl`, `lint:size` (client and server), the server's `lint:boundaries`, `lint:tx`, `lint:dialect` and `lint:test-sql`, the client's `lint:format`, `lint:layers`, `lint:offline`, `lint:dup`, `lint:pairs`, `lint:skips` and `lint:i18n-keys`, shared's `contracts:open`, `check:plugin-facts`, `i18n:parity:strict` (with the untranslated-strings ratchet), the S3 contract suite, the Postgres probe (`postgres-probe`: every dialect helper on SQLite and Postgres, and the statements every repository method reaches with sample arguments against a Postgres service; the SQL errors per method and the list of methods it cannot measure are held to a baseline that only shrinks and fails while it is still unmeasured; see `server/CLAUDE.md`), the browser end-to-end flows (Chromium and WebKit), a client build with `check:gl-split`, coverage for all four packages, then a SonarCloud scan. It also builds the Docker image and boots it until `/api/health/ready` answers (`docker-image`), and lints and renders the Helm chart against the Kubernetes schemas and runs `docker compose config` on every compose file (`deploy-manifests`). `lint-prettier.yml` additionally runs `lint` + `format:check` on `shared/`. `plugin-sdk` has no lint script and is outside the eslint gates. **Not in CI**: `theme:lint` and the screenshot and help-media Playwright projects; run them locally. There are no git hooks; nothing runs before a commit except you. Other workflows build Docker images, scan them, and publish `trek-plugin-sdk` on `plugin-sdk-v*` tags.
- **`ci-ok` is the required check.** The branch ruleset requires `ci-ok` (plus `lint` and `check-target` from their own workflows), and `ci-ok` needs every job above except the scan. It fails if any of them failed or was cancelled and passes if they passed or were skipped. A new gate job counts only once it is listed in `ci-ok`'s `needs:`.
- **What runs is decided by `changes`, not by `on.paths`**: `scripts/ci/changed-areas.mjs` maps the changed files to the `code`, `image` and `deploy` areas (tests: `npm run test:ci-scripts`). A path filter on the workflow would leave `ci-ok` unreported on a docs-only PR and block it. Add a new top-level file the build reads to the right area there.
@@ -139,7 +140,7 @@ These principles come out of a full-repo audit and shape all new code:
| Security / reliability / maintainability rating | A |
| Security hotspots reviewed | 100% |
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors that are already heavily duplicated. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. `lint:dup` finds the same copies in `client/src` before the push and holds every file to `client/scripts/dup-baseline.json`. Locale files are excluded from duplication, so i18n additions are free.
- **Duplication is the one that bites.** Desktop and phone shells (`client/src/components/<X>` ↔ `client/src/mobile/screens/.../M<X>`) are deliberate mirrors of each other's markup; their logic lives in one shared hook per feature. Every line added identically to both counts against the 3% budget, so a feature touching both shells fails on its own. Put the logic in ONE hook/module (precedents: `useInstanceSettings`, `useRangeBypass` under `client/src/components/Admin/`) and leave only markup in each shell. `lint:dup` finds the same copies in `client/src` before the push and holds every file to `client/scripts/dup-baseline.json`; `lint:pairs` holds each pair in `client/scripts/feature-pairs.json` to its shared hook and makes every new phone screen either join a pair or say why it has no twin. Locale files are excluded from duplication, so i18n additions are free.
- **Coverage is per new line across all four packages.** A new file without tests drags the PR under 80%. Files in `sonar.coverage.exclusions` don't count either way.
- **Ratings are about new issues.** One new bug drops reliability; one new vulnerability drops security. Don't introduce hotspot patterns (`Math.random` in anything security-adjacent, `child_process` through PATH, backtracking regexes, secret-looking strings). Path-wide suppressions live only in `sonar-project.properties` with a written justification; one-off findings are resolved on SonarCloud, never with an inline `// NOSONAR`.
- **Check it yourself.** The `sonarqube` MCP is connected: `list_pull_requests` → `get_project_quality_gate_status` shows each condition's value; `search_duplicated_files` + `get_duplications` show what tripped duplication; `search_sonar_issues_in_projects` lists new issues. Test files are excluded from analysis — when duplication fires, the source is the problem.
+10 -3
View File
@@ -16,6 +16,7 @@ npm run lint:size # no source file or stylesheet past 1000 lines, no tes
npm run lint:format # Prettier: every .ts/.tsx/.mjs/.css file under src/, tests/, e2e/ and scripts/ outside scripts/format-baseline.json must be formatted, the list only shrinks (CI gate; npx prettier --write <file>, then --update)
npm run lint:layers # imports go downwards: components never import pages/ or mobile/, nothing under the views imports a view; per-file counts may only shrink (CI gate; --list, --update)
npm run lint:offline # no view file (components/mobile/pages/hooks) imports src/api/ beyond scripts/offline-baseline.json, which only shrinks (CI gate; --list, --update)
npm run lint:pairs # each desktop/phone pair in scripts/feature-pairs.json imports its shared hooks, and every file under src/mobile/screens is listed there, in a pair or under mobileOnly with a reason (CI gate; --list prints the pairs)
npm run lint:dup # copied code: the lines of a file inside a block that repeats elsewhere (100 tokens over 10 lines, SonarCloud's thresholds; .ts and .tsx read as one language) may only shrink against scripts/dup-baseline.json (CI gate; --list shows each block with its other side, --update lowers it)
npm run lint:skips # no .only anywhere; skipped/todo/fixme tests per file may only shrink against scripts/skip-baseline.json (CI gate; skipIf/runIf and Playwright's test.skip(condition, 'why') are fine, but skipIf(true) and runIf(false) count as skips)
npm run lint:i18n-keys # every translation key src/ names exists in shared en (CI gate; --unused lists en keys nothing reaches)
@@ -68,13 +69,19 @@ The offline core is flagship work surrounded by a periphery that ignores it. New
- **Maps** (`src/components/Map/`): two interchangeable renderers — Leaflet and Mapbox/MapLibre GL — chosen at runtime by `MapViewAuto.tsx`. Keep both in sync when changing map features, and keep `mapbox-gl` and `maplibre-gl` in separate chunks (`check:gl-split`). The raster prefetcher fetches tiles `no-cors` so custom tile providers without CORS headers keep working; the vector prefetcher deliberately uses `cors` because it has to read the responses — don't "align" one with the other.
- **i18n** (`src/i18n/TranslationContext.tsx`): `en` is bundled; every other locale is a dynamic `import('@trek/shared/i18n/<locale>')` so Vite emits one chunk per locale. Strings live in `shared/`, never here. `t()` returns an unknown key unchanged, so `lint:i18n-keys` (`scripts/i18n-keys.mjs`) resolves every literal key in `t()`, `tHtml()`, `tr()`, `<TransHtml html>`, `translateApiError` and `*Key` props against `shared/src/i18n/en`; a template whose interpolations only choose between string literals is expanded and each branch checked as a key; any other template key is read with each interpolation as one key segment and passes by itself only when it reaches between 1 and `MAX_IMPLICIT_MATCHES` (30) en keys; a wider one, or one with no fixed dotted prefix, needs a `DYNAMIC_ALLOWED` entry keyed by file and template that says what bounds the value, and an entry the check no longer needs fails. Pass counts as numbers, and let a plural group pick the wording instead of switching keys on the count.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both — and the shared-hook rule below.
- **Mobile shell** (`src/mobile/`): below the phone breakpoint (`useIsPhone`) `App.tsx` wraps routes in `MobileShell` and the `M*` screens under `mobile/screens/` take over. A UI change to a domain with an `M*` twin usually needs both, and the logic change goes into the shared hook (see "Desktop and phone views" below).
- **Plugins UI** (`src/components/Plugins/`): third-party surfaces render only inside the sandboxed `PluginFrame` iframe (postMessage bridge, live theme-token sync). Host-rendered contribution points (widgets, schedule rows, map layers, planner columns/actions) render server-normalized primitives only — plugin markup never runs inline. Authoring is covered by the `trek-plugin-dev` skill.
- **Managed installs** (`src/managed/index.tsx`): the attachment point for screens that only exist on a centrally administered install. Empty here by design — an operator replaces it at build time. Don't put features there.
## Desktop ↔ mobile shells and the SonarQube duplication gate
## Desktop and phone views
Every desktop component with a phone twin is a deliberate copy that is already heavily duplicated. CI's SonarCloud gate allows **≤ 3% duplicated lines on the PR's new code**, so adding the same lines to both shells fails the PR by itself. Put logic in one shared hook/module (`useInstanceSettings`, `useRangeBypass` under `components/Admin/` are the pattern) and keep only markup in each shell. `npm run lint:dup` finds such copies before the push and holds every file to `scripts/dup-baseline.json`. Full gate rules in the root `CLAUDE.md`.
Most features have a desktop view (`components/`, `pages/`) and a phone view (`mobile/screens/`). They share one hook or model per feature that holds the logic (state, effects, loading, handlers, validation, derived values, store and repo calls); each view keeps only its own markup, layout and dialogs or sheets. `useInstanceSettings` and `useRangeBypass` under `components/Admin/` show the shape. `scripts/feature-pairs.json` lists every pair with its hooks.
- **Changing a feature:** change the shared hook, not one view. A behaviour that really differs between the two views is a parameter of the hook, so both stay visible in one place and its test covers both.
- **A new feature with both views:** write the hook first under `components/<Feature>/` (or `hooks/`/`utils/` when it is not bound to one feature; a page's own logic stays in its `use<Page>` hook and models in `pages/<page>/`, which the phone shell imports too), give it its own test, build both views on it and add the pair to `scripts/feature-pairs.json`.
- **A new phone screen:** list it in `scripts/feature-pairs.json`, as a view of the pair whose hook it runs on or under `mobileOnly` with the reason it has no twin. A file under `mobile/screens/` that is in neither fails `lint:pairs`.
Two CI gates hold this: `lint:pairs` fails when a listed view stops importing its pair's hook or a phone screen is not listed, and `lint:dup` holds the lines inside copied blocks to `scripts/dup-baseline.json`, so a copy that looks alike fails too. SonarCloud allows **≤ 3% duplicated lines on the PR's new code**, so the same lines added to both views fail a PR by themselves. Full gate rules in the root `CLAUDE.md`.
## Tests
+1
View File
@@ -28,6 +28,7 @@
"lint:layers": "node scripts/layer-lint.mjs",
"lint:offline": "node scripts/offline-lint.mjs",
"lint:dup": "node scripts/dup-lint.mjs",
"lint:pairs": "node scripts/pairs-lint.mjs",
"lint:skips": "node scripts/skip-lint.mjs",
"lint:i18n-keys": "node scripts/i18n-keys.mjs",
"check:gl-split": "node scripts/check-gl-split.mjs",
+924
View File
@@ -0,0 +1,924 @@
{
"pairs": [
{
"feature": "Accommodation (stay) create/edit",
"hooks": ["src/components/Planner/stayFormModel.ts"],
"desktop": ["src/components/Planner/DayDetailPanel.tsx", "src/components/Planner/useDayDetail.ts"],
"mobile": ["src/mobile/screens/trip/sheets/MAccommodationSheet.tsx"]
},
{
"feature": "Add place to collection",
"hooks": ["src/components/Collections/addPlaceModel.ts", "src/components/Collections/useAddPlaceToCollection.ts"],
"desktop": ["src/components/Collections/AddPlaceToCollectionModal.tsx"],
"mobile": ["src/mobile/screens/collections/MCollAddSheet.tsx"]
},
{
"feature": "Addon manager",
"hooks": [
"src/components/Admin/addons/addonModel.ts",
"src/components/Admin/addons/useAddonManager.ts",
"src/components/Admin/addons/useLlmParsingConfig.ts"
],
"desktop": ["src/components/Admin/AddonManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAddonManager.tsx"]
},
{
"feature": "Admin notification preference matrix",
"hooks": ["src/components/Admin/useAdminNotificationMatrix.ts", "src/pages/admin/AdminPage.constants.ts"],
"desktop": ["src/pages/admin/AdminNotificationsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminNotifyMatrix.tsx"]
},
{
"feature": "Admin notifications tab",
"hooks": ["src/components/Admin/useAdminNotificationSettings.ts"],
"desktop": ["src/pages/admin/AdminNotificationsTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminNotificationsSection.tsx"]
},
{
"feature": "Admin page shell",
"hooks": ["src/pages/admin/useAdmin.ts"],
"desktop": ["src/pages/AdminPage.tsx"],
"mobile": ["src/mobile/screens/admin/MAdmin.tsx"]
},
{
"feature": "Admin settings tab: trekApiModel",
"hooks": ["src/components/Admin/trekApiModel.ts"],
"desktop": ["src/pages/admin/TrekApiCard.tsx"],
"mobile": ["src/mobile/screens/admin/MApiProviderBlocks.tsx"]
},
{
"feature": "Admin settings tab: useAdminSettingsActions",
"hooks": ["src/components/Admin/useAdminSettingsActions.ts"],
"desktop": ["src/pages/admin/AdminSettingsTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminSettingsSection.tsx"]
},
{
"feature": "Admin users invite status",
"hooks": ["src/pages/admin/adminModel.ts"],
"desktop": ["src/pages/admin/AdminUsersTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminUsersSection.tsx"]
},
{
"feature": "Atlas country/region action sheet",
"hooks": ["src/pages/atlas/useAtlasCountryActions.ts"],
"desktop": ["src/pages/AtlasPage.tsx"],
"mobile": ["src/mobile/screens/atlas/MAtlasCountryPopup.tsx"]
},
{
"feature": "Atlas page: atlasModel",
"hooks": ["src/pages/atlas/atlasModel.ts"],
"desktop": [
"src/components/Atlas/AtlasCountryPlaces.tsx",
"src/components/shared/CountryFlag.tsx",
"src/pages/AtlasPage.tsx"
],
"mobile": ["src/mobile/screens/atlas/MAtlas.tsx", "src/mobile/screens/atlas/MAtlasBucketSheet.tsx"]
},
{
"feature": "Atlas page: useAtlas",
"hooks": ["src/pages/atlas/useAtlas.ts"],
"desktop": ["src/pages/AtlasPage.tsx"],
"mobile": ["src/mobile/screens/atlas/MAtlas.tsx", "src/mobile/screens/atlas/atlasController.ts"]
},
{
"feature": "Audit log",
"hooks": ["src/components/Admin/useAuditLog.ts"],
"desktop": ["src/components/Admin/AuditLogPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminAuditLogPanel.tsx"]
},
{
"feature": "Backup panel",
"hooks": ["src/components/Admin/useBackupAdmin.ts"],
"desktop": ["src/components/Admin/BackupPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminBackupPanel.tsx"]
},
{
"feature": "Booking forms: linked expense and file attach",
"hooks": ["src/components/Planner/useBookingExpenseIntent.ts"],
"desktop": ["src/components/Planner/ReservationModal.tsx", "src/components/Planner/TransportModal.tsx"],
"mobile": [
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
]
},
{
"feature": "Bookings and transports lists",
"hooks": ["src/components/Planner/bookings/bookingsModel.ts"],
"desktop": [
"src/components/Planner/DayDetailPanel.tsx",
"src/components/Planner/DayPlanSidebar.tsx",
"src/components/Planner/PlaceInspector.tsx",
"src/components/Planner/ReservationModal.tsx",
"src/components/Planner/ReservationsPanel.tsx",
"src/components/Planner/TransportModal.tsx",
"src/components/Planner/bookings/BookingCard.tsx",
"src/components/Planner/bookings/BookingDetailDialog.tsx",
"src/components/Planner/bookings/BookingDetailHost.tsx",
"src/components/Planner/bookings/BookingDialogShell.tsx",
"src/components/Planner/bookings/BookingsHeader.tsx",
"src/components/Planner/bookings/BookingsList.tsx",
"src/components/Planner/bookings/BookingsTimeline.tsx",
"src/components/Planner/bookings/bookingFacts.ts",
"src/components/Planner/bookings/bookingParts.tsx",
"src/components/Planner/bookings/timelineModel.ts",
"src/components/Planner/bookings/useReservationListFilter.ts",
"src/components/Planner/planCosts.ts",
"src/pages/sharedTrip/SharedBookingsView.tsx",
"src/pages/sharedTrip/SharedDayCard.tsx"
],
"mobile": ["src/mobile/screens/trip/tabs/MBookingsTab.tsx", "src/mobile/screens/trip/tabs/MTransportsTab.tsx"]
},
{
"feature": "Bottom nav context-aware create action",
"hooks": ["src/components/Layout/useCreateAction.ts"],
"desktop": ["src/components/Layout/BottomNav.tsx"],
"mobile": ["src/mobile/components/MBottomNav.tsx"]
},
{
"feature": "Bulk save selected places to a collection",
"hooks": ["src/components/Collections/useSaveTripPlacesToList.ts"],
"desktop": ["src/components/Collections/SaveTripPlacesToListModal.tsx"],
"mobile": ["src/mobile/screens/trip/places/MPlacesSaveToCollectionSheet.tsx"]
},
{
"feature": "Category manager",
"hooks": ["src/components/Admin/useCategoryAdmin.ts"],
"desktop": ["src/components/Admin/CategoryManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminCategoryManager.tsx"]
},
{
"feature": "Collab chat: collabModel",
"hooks": ["src/components/Collab/collabModel.ts"],
"desktop": [
"src/components/Collab/CollabChatMessages.tsx",
"src/components/Collab/CollabPolls.tsx",
"src/components/Collab/useCollabChat.ts"
],
"mobile": [
"src/mobile/screens/trip/tabs/MCollabChat.tsx",
"src/mobile/screens/trip/tabs/MCollabNotes.tsx",
"src/mobile/screens/trip/tabs/MCollabPolls.tsx"
]
},
{
"feature": "Collab chat: useCollabChatData",
"hooks": ["src/components/Collab/useCollabChatData.ts"],
"desktop": ["src/components/Collab/useCollabChat.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MCollabChat.tsx"]
},
{
"feature": "Collab notes",
"hooks": ["src/components/Collab/useCollabNotesData.ts"],
"desktop": ["src/components/Collab/CollabNotes.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCollabNotes.tsx"]
},
{
"feature": "Collab polls",
"hooks": ["src/components/Collab/useCollabPolls.ts"],
"desktop": ["src/components/Collab/CollabPolls.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCollabPolls.tsx"]
},
{
"feature": "Collection list editor",
"hooks": ["src/components/Collections/useListEditor.ts"],
"desktop": ["src/components/Collections/ListEditorModal.tsx"],
"mobile": ["src/mobile/screens/collections/MCollEditSheet.tsx"]
},
{
"feature": "Collection place detail",
"hooks": ["src/components/Collections/useCollectionPlaceForm.ts"],
"desktop": ["src/components/Collections/CollectionPlaceDetail.tsx"],
"mobile": ["src/mobile/screens/collections/MCollPlaceSheet.tsx"]
},
{
"feature": "Collection sharing",
"hooks": ["src/components/Collections/useCollectionSharing.ts"],
"desktop": ["src/components/Collections/ShareCollectionModal.tsx"],
"mobile": ["src/mobile/screens/collections/MCollShareSheet.tsx"]
},
{
"feature": "Collections page: collectionsModel",
"hooks": ["src/pages/collections/collectionsModel.ts"],
"desktop": [
"src/components/Collections/AddPlaceToCollectionModal.tsx",
"src/components/Collections/CollectionFilterBar.tsx",
"src/components/Collections/CollectionMap.tsx",
"src/components/Collections/CollectionPicker.tsx",
"src/components/Collections/CollectionPlaceDetail.tsx",
"src/components/Collections/ListEditorModal.tsx",
"src/components/Collections/MSaveToCollectionSheet.tsx",
"src/components/Collections/StatusBadge.tsx"
],
"mobile": [
"src/mobile/screens/collections/MCollAddSheet.tsx",
"src/mobile/screens/collections/MCollEditSheet.tsx",
"src/mobile/screens/collections/MCollPlaceRow.tsx",
"src/mobile/screens/collections/MCollPlaceSheet.tsx",
"src/mobile/screens/collections/MCollections.tsx"
]
},
{
"feature": "Collections page: useCollections",
"hooks": ["src/pages/collections/useCollections.ts"],
"desktop": ["src/pages/CollectionsPage.tsx"],
"mobile": ["src/mobile/screens/collections/MCollections.tsx"]
},
{
"feature": "Copy collection places to a trip",
"hooks": ["src/components/Collections/useTripCopyPicker.ts"],
"desktop": ["src/components/Collections/CopyToTripModal.tsx"],
"mobile": ["src/mobile/screens/collections/MCollTripPickerSheet.tsx"]
},
{
"feature": "Costs/budget ledger: costsModel",
"hooks": ["src/components/Budget/costsModel.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx", "src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Costs/budget ledger: useCostsLedger, useSettlementForm",
"hooks": ["src/components/Budget/useCostsLedger.ts", "src/components/Budget/useSettlementForm.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MCostsTab.tsx"]
},
{
"feature": "Create/edit trip form",
"hooks": ["src/components/Trips/tripFormModel.ts", "src/components/Trips/useTripCoverPicker.ts"],
"desktop": ["src/components/Trips/TripFormModal.tsx"],
"mobile": ["src/mobile/screens/dashboard/MNewTripSheet.tsx"]
},
{
"feature": "Dashboard page",
"hooks": ["src/pages/dashboard/useDashboard.ts"],
"desktop": ["src/pages/DashboardPage.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashboard.tsx"]
},
{
"feature": "Dashboard residuals: dashboardModel",
"hooks": ["src/pages/dashboard/dashboardModel.ts"],
"desktop": ["src/pages/DashboardPage.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx", "src/mobile/screens/dashboard/MDashboard.tsx"]
},
{
"feature": "Dashboard residuals: useDashboardPlugins",
"hooks": ["src/components/Dashboard/useDashboardPlugins.ts"],
"desktop": ["src/pages/DashboardPage.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashboard.tsx"]
},
{
"feature": "Dashboard widgets: useCollectionLists",
"hooks": ["src/components/Dashboard/useCollectionLists.ts"],
"desktop": ["src/components/Dashboard/CollectionsWidget.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx"]
},
{
"feature": "Dashboard widgets: useCurrencyConverter, useDashboardTimezones",
"hooks": [
"src/components/Dashboard/useCurrencyConverter.ts",
"src/components/Dashboard/useDashboardTimezones.ts"
],
"desktop": ["src/pages/DashboardPage.tsx"],
"mobile": ["src/mobile/screens/dashboard/MDashWidgets.tsx"]
},
{
"feature": "Day detail",
"hooks": ["src/components/Planner/dayDetailModel.ts"],
"desktop": ["src/components/Planner/DayDetailPanel.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
},
{
"feature": "Day note create/edit/delete",
"hooks": ["src/hooks/useDayNotes.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MDaySheet.tsx", "src/mobile/screens/trip/sheets/MNoteSheet.tsx"]
},
{
"feature": "Day plan reorder",
"hooks": ["src/utils/mergedOrder.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "Day route optimize + Google Maps / CoMaps export",
"hooks": ["src/components/Planner/dayRoute.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
},
{
"feature": "Day weather chip anchor + weather icon map: dayWeather",
"hooks": ["src/utils/dayWeather.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx", "src/pages/TripPlannerPage.tsx"],
"mobile": ["src/mobile/screens/trip/plan/useMPlanTimeline.ts", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
},
{
"feature": "Day weather chip anchor + weather icon map: weatherIcons",
"hooks": ["src/components/Weather/weatherIcons.ts"],
"desktop": ["src/components/Planner/DayDetailPanel.tsx", "src/components/Weather/WeatherWidget.tsx"],
"mobile": ["src/mobile/screens/trip/plan/MPlanTimeline.tsx", "src/mobile/screens/trip/sheets/MDaySheet.tsx"]
},
{
"feature": "Default user settings",
"hooks": ["src/components/Admin/useDefaultUserSettings.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminDefaultUserSettings.tsx"]
},
{
"feature": "Dev test notifications panel",
"hooks": ["src/components/Admin/useDevNotifications.ts"],
"desktop": ["src/components/Admin/DevNotificationsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminDevNotificationsPanel.tsx"]
},
{
"feature": "Document sync leftovers: docSyncModel",
"hooks": ["src/components/Files/docsync/docSyncModel.ts"],
"desktop": ["src/components/Files/docsync/DocSyncFlow.tsx", "src/components/Files/docsync/DocSyncPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MDocSyncSheet.tsx"]
},
{
"feature": "Document sync leftovers: useScopePicker",
"hooks": ["src/components/Files/docsync/useScopePicker.ts"],
"desktop": ["src/components/Files/docsync/DocSyncScopeModal.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MDocSyncSheet.tsx"]
},
{
"feature": "Expense editor",
"hooks": ["src/components/Budget/useExpenseForm.ts"],
"desktop": ["src/components/Budget/CostsPanel.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MCostSheet.tsx"]
},
{
"feature": "GitHub releases panel",
"hooks": ["src/components/Admin/useGithubReleases.ts", "src/utils/releaseNotes.ts"],
"desktop": ["src/components/Admin/GitHubPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminGitHubPanel.tsx"]
},
{
"feature": "Google/Naver shared-list import",
"hooks": ["src/components/Planner/useListImport.ts"],
"desktop": ["src/components/Planner/usePlacesSidebar.ts"],
"mobile": ["src/mobile/screens/trip/sheets/ImpListStep.tsx"]
},
{
"feature": "GPX/KML/KMZ file import into places",
"hooks": ["src/components/Planner/usePlacesFileImport.ts"],
"desktop": ["src/components/Planner/FileImportModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/ImpFileStep.tsx"]
},
{
"feature": "Journey detail page: JourneyDetailPage.helpers",
"hooks": ["src/pages/journeyDetail/JourneyDetailPage.helpers.ts"],
"desktop": [
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx",
"src/components/Journey/JourneyDetailPageEntryCard.tsx",
"src/components/Journey/JourneyDetailPageEntryEditor.tsx",
"src/components/Journey/JourneyDetailPageGalleryView.tsx",
"src/components/Journey/JourneyDetailPageMapView.tsx",
"src/components/Journey/JourneyDetailPagePhotoGrid.tsx",
"src/components/Journey/JourneyDetailPageProviderPicker.tsx",
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx",
"src/components/Journey/JourneyEntryCover.tsx",
"src/components/Journey/MobileEntryView.tsx",
"src/pages/JourneyDetailPage.tsx",
"src/pages/JourneyPublicPage.tsx",
"src/pages/journeyPublic/useJourneyPublic.ts"
],
"mobile": [
"src/mobile/screens/journey/MJourneyCreateSheet.tsx",
"src/mobile/screens/journey/MJourneyDetail.tsx",
"src/mobile/screens/journey/MJourneyEntrySheet.tsx",
"src/mobile/screens/journey/MJourneySettingsSheet.tsx",
"src/mobile/screens/journey/mobileJourneyMeta.ts"
]
},
{
"feature": "Journey detail page: useJourneyDetail",
"hooks": ["src/pages/journeyDetail/useJourneyDetail.ts"],
"desktop": ["src/pages/JourneyDetailPage.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
},
{
"feature": "Journey detail screen: useGalleryUpload",
"hooks": ["src/components/Journey/useGalleryUpload.ts"],
"desktop": ["src/components/Journey/JourneyDetailPageGalleryView.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
},
{
"feature": "Journey detail screen: useJourneyCarouselSync",
"hooks": ["src/components/Journey/useJourneyCarouselSync.ts"],
"desktop": ["src/components/Journey/MobileMapTimeline.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyDetail.tsx"]
},
{
"feature": "Journey entry editor",
"hooks": ["src/components/Journey/useJourneyEntryForm.ts"],
"desktop": ["src/components/Journey/JourneyDetailPageEntryEditor.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneyEntrySheet.tsx"]
},
{
"feature": "Journey list page",
"hooks": ["src/pages/journey/useJourney.ts"],
"desktop": ["src/pages/JourneyPage.tsx"],
"mobile": ["src/mobile/screens/journey/MJourney.tsx"]
},
{
"feature": "Journey settings: useJourneySettings",
"hooks": ["src/components/Journey/useJourneySettings.ts"],
"desktop": [
"src/components/Journey/JourneyDetailPageAddTripDialog.tsx",
"src/components/Journey/JourneyDetailPageSettingsDialog.tsx"
],
"mobile": ["src/mobile/screens/journey/MJourneySettingsSheet.tsx"]
},
{
"feature": "Journey settings: useJourneyShareLink",
"hooks": ["src/components/Journey/useJourneyShareLink.ts"],
"desktop": ["src/components/Journey/JourneyShareSection.tsx"],
"mobile": ["src/mobile/screens/journey/MJourneySettingsSheet.tsx"]
},
{
"feature": "Lists sub-tab (packing/todo) persistence",
"hooks": ["src/hooks/useListsSubTab.ts"],
"desktop": ["src/pages/tripPlanner/ListsContainer.tsx"],
"mobile": ["src/mobile/screens/trip/MTripShell.tsx"]
},
{
"feature": "MCP tokens / OAuth sessions admin",
"hooks": ["src/components/Admin/useMcpTokensAdmin.ts"],
"desktop": ["src/components/Admin/AdminMcpTokensPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminMcpTokensPanel.tsx"]
},
{
"feature": "Media lightbox",
"hooks": ["src/components/Files/useMediaLightbox.ts"],
"desktop": ["src/components/Files/FileManagerImageLightbox.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MFileLightbox.tsx"]
},
{
"feature": "Notifications list row actions",
"hooks": ["src/components/Notifications/useNotificationItemActions.ts"],
"desktop": ["src/components/Notifications/InAppNotificationItem.tsx"],
"mobile": ["src/mobile/screens/notifications/MNotificationRow.tsx"]
},
{
"feature": "Notifications page",
"hooks": ["src/pages/inAppNotifications/useInAppNotifications.ts"],
"desktop": ["src/pages/InAppNotificationsPage.tsx"],
"mobile": ["src/mobile/screens/notifications/MNotifications.tsx"]
},
{
"feature": "OAuth scope group picker",
"hooks": ["src/components/OAuth/useScopeSelection.ts"],
"desktop": ["src/components/OAuth/ScopeGroupPicker.tsx"],
"mobile": ["src/mobile/screens/settings/MScopeGroupPicker.tsx"]
},
{
"feature": "Packing bag card and bag weight summary",
"hooks": ["src/components/Packing/useBagCardEditor.ts"],
"desktop": ["src/components/Packing/PackingListPanelBagCard.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MBagsSheet.tsx"]
},
{
"feature": "Packing list: packingListModel",
"hooks": ["src/components/Packing/packingListModel.ts"],
"desktop": [
"src/components/Packing/packingPrintDocument.ts",
"src/components/Packing/usePackingExport.ts",
"src/components/Packing/usePackingListPanel.ts"
],
"mobile": [
"src/mobile/screens/trip/tabs/MBagsSheet.tsx",
"src/mobile/screens/trip/tabs/MPackItemSheet.tsx",
"src/mobile/screens/trip/tabs/MPackingListTab.tsx"
]
},
{
"feature": "Packing list: usePackingBags, usePackingCategoryAssignees, usePackingItemActions, usePackingTemplates",
"hooks": [
"src/components/Packing/usePackingBags.ts",
"src/components/Packing/usePackingCategoryAssignees.ts",
"src/components/Packing/usePackingItemActions.ts",
"src/components/Packing/usePackingTemplates.ts"
],
"desktop": ["src/components/Packing/usePackingListPanel.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MPackingListTab.tsx"]
},
{
"feature": "Packing list: usePackingImport",
"hooks": ["src/components/Packing/usePackingImport.ts"],
"desktop": ["src/components/Packing/usePackingListPanel.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MPackingImportSheet.tsx"]
},
{
"feature": "Packing template manager",
"hooks": ["src/components/Admin/usePackingTemplateAdmin.ts"],
"desktop": ["src/components/Admin/PackingTemplateManager.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPackingTemplateManager.tsx"]
},
{
"feature": "Per-leg travel mode and connector mode menu",
"hooks": ["src/components/Planner/useLegModeActions.ts"],
"desktop": ["src/components/Planner/DayPlanSidebar.tsx"],
"mobile": ["src/mobile/screens/trip/plan/MPlanTimeline.tsx", "src/mobile/screens/trip/plan/useMPlanTimeline.ts"]
},
{
"feature": "Permissions panel",
"hooks": ["src/components/Admin/usePermissionsAdmin.ts"],
"desktop": ["src/components/Admin/PermissionsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPermissionsPanel.tsx"]
},
{
"feature": "Place add/edit form and maps search: usePlaceForm",
"hooks": ["src/components/Planner/usePlaceForm.ts"],
"desktop": ["src/components/Planner/PlaceFormModal.tsx"],
"mobile": [
"src/mobile/screens/trip/sheets/MPlaceEditSheet.tsx",
"src/mobile/screens/trip/sheets/PlCategoryPicker.tsx"
]
},
{
"feature": "Place add/edit form and maps search: usePlaceSearch",
"hooks": ["src/components/Planner/usePlaceSearch.ts"],
"desktop": ["src/components/Planner/PlaceFormModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/PlPlaceSearch.tsx"]
},
{
"feature": "Place detail actions",
"hooks": ["src/components/Planner/usePlaceActions.ts"],
"desktop": [
"src/components/Planner/PlaceInspector.tsx",
"src/components/shared/PlaceAvatarUpload.tsx",
"src/pages/tripPlanner/useTripPlannerPage.ts"
],
"mobile": ["src/mobile/screens/trip/sheets/MPlaceSheet.tsx"]
},
{
"feature": "Places pool list",
"hooks": ["src/components/Planner/usePlacesPool.ts"],
"desktop": ["src/components/Planner/PlacesSidebarHeader.tsx", "src/components/Planner/usePlacesSidebar.ts"],
"mobile": [
"src/mobile/screens/trip/places/MPlacesBrowser.tsx",
"src/mobile/screens/trip/places/MPlacesFilterControls.tsx",
"src/mobile/screens/trip/places/MPlacesFilterSheet.tsx"
]
},
{
"feature": "Plugins admin",
"hooks": [
"src/components/Admin/plugins/pluginModel.ts",
"src/components/Admin/plugins/usePluginDetail.ts",
"src/components/Admin/plugins/usePluginsAdmin.ts"
],
"desktop": ["src/components/Admin/AdminPluginsPanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminPluginsPanel.tsx"]
},
{
"feature": "Reservation form incl. hotel accommodation creation",
"hooks": ["src/components/Planner/reservationFormModel.ts", "src/components/Planner/useReservationForm.ts"],
"desktop": ["src/components/Planner/ReservationModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MReservationSheet.tsx"]
},
{
"feature": "Road-trip add-stop draft",
"hooks": ["src/components/Roadtrip/useStopDraftForm.ts"],
"desktop": ["src/components/Roadtrip/RoadtripStopPopup.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtDraftSheet.tsx"]
},
{
"feature": "Road-trip stay length draft",
"hooks": ["src/components/Roadtrip/useStayDraft.ts"],
"desktop": ["src/components/Roadtrip/RoadtripStayModal.tsx"],
"mobile": ["src/mobile/screens/trip/roadtrip/MRtStaySheet.tsx"]
},
{
"feature": "Settings / Account",
"hooks": ["src/components/Settings/useAccountSettings.ts"],
"desktop": ["src/components/Settings/AccountTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
},
{
"feature": "Settings / Account / Passkeys",
"hooks": ["src/components/Settings/usePasskeys.ts"],
"desktop": ["src/components/Settings/PasskeysSection.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAccount.tsx"]
},
{
"feature": "Settings / Appearance",
"hooks": ["src/components/Settings/appearanceModel.ts", "src/components/Settings/useAppearanceEditor.ts"],
"desktop": ["src/components/Settings/AppearanceSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsAppearance.tsx"]
},
{
"feature": "Settings / General/Display",
"hooks": ["src/components/Settings/useSettingSaver.ts"],
"desktop": ["src/components/Settings/DisplaySettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsGeneral.tsx"]
},
{
"feature": "Settings / Integrations / AI parsing",
"hooks": ["src/components/Settings/useLlmConnection.ts"],
"desktop": ["src/components/Settings/LlmConnectionSection.tsx"],
"mobile": ["src/mobile/screens/settings/MLlmConnectionSection.tsx"]
},
{
"feature": "Settings / Integrations / AirTrail",
"hooks": ["src/components/Settings/useAirTrailSettings.ts"],
"desktop": ["src/components/Settings/AirTrailConnectionSection.tsx"],
"mobile": ["src/mobile/screens/settings/MAirTrailConnectionSection.tsx"]
},
{
"feature": "Settings / Integrations / MCP",
"hooks": ["src/components/Settings/useMcpIntegration.ts"],
"desktop": ["src/components/Settings/IntegrationsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMcp.tsx"]
},
{
"feature": "Settings / Integrations / Photo providers",
"hooks": ["src/components/Settings/usePhotoProviderConnections.ts"],
"desktop": ["src/components/Settings/PhotoProvidersSection.tsx"],
"mobile": ["src/mobile/screens/settings/MPhotoProvidersSection.tsx"]
},
{
"feature": "Settings / Map: mapSettingsModel",
"hooks": ["src/components/Settings/mapSettingsModel.ts"],
"desktop": ["src/components/Admin/DefaultUserSettingsTab.tsx", "src/components/Settings/MapSettingsTab.tsx"],
"mobile": [
"src/mobile/screens/admin/MAdminDefaultUserSettings.tsx",
"src/mobile/screens/settings/MSettingsMap.tsx"
]
},
{
"feature": "Settings / Map: useMapSettingsForm",
"hooks": ["src/components/Settings/useMapSettingsForm.ts"],
"desktop": ["src/components/Settings/MapSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsMap.tsx"]
},
{
"feature": "Settings / Notifications",
"hooks": ["src/components/Settings/useNotificationSettings.ts"],
"desktop": ["src/components/Settings/NotificationsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsNotifications.tsx"]
},
{
"feature": "Settings / Plugins: usePluginActivity",
"hooks": ["src/components/Settings/usePluginActivity.ts"],
"desktop": ["src/components/Settings/PluginActivityPanel.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsPlugins.tsx"]
},
{
"feature": "Settings / Plugins: usePluginUserSettings",
"hooks": ["src/components/Plugins/usePluginUserSettings.ts"],
"desktop": ["src/components/Settings/PluginSettingsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsPlugins.tsx"]
},
{
"feature": "Settings page",
"hooks": ["src/pages/settings/useSettings.ts"],
"desktop": ["src/pages/SettingsPage.tsx"],
"mobile": ["src/mobile/screens/settings/MSettings.tsx"]
},
{
"feature": "Settings shell: visible tabs + integrations gating",
"hooks": ["src/components/Settings/useIntegrationGates.ts"],
"desktop": ["src/components/Settings/IntegrationsTab.tsx"],
"mobile": ["src/mobile/screens/settings/MSettingsIntegrations.tsx"]
},
{
"feature": "Storage backends admin: useBackendForm",
"hooks": ["src/components/Admin/storage/useBackendForm.ts"],
"desktop": ["src/components/Admin/storage/BackendForm.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminStoragePanel.tsx"]
},
{
"feature": "Storage backends admin: useStoragePanel",
"hooks": ["src/components/Admin/storage/useStoragePanel.ts"],
"desktop": ["src/components/Admin/storage/AdminStoragePanel.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminStoragePanel.tsx"]
},
{
"feature": "To-do list and task add/edit form: useTaskForm",
"hooks": ["src/components/Todo/useTaskForm.ts"],
"desktop": ["src/components/Todo/TodoListPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MTaskSheet.tsx"]
},
{
"feature": "To-do list and task add/edit form: useTodoList",
"hooks": ["src/components/Todo/useTodoList.ts"],
"desktop": ["src/components/Todo/TodoListPanel.tsx"],
"mobile": ["src/mobile/screens/trip/tabs/MTodoListTab.tsx"]
},
{
"feature": "Toast presenter queue",
"hooks": ["src/components/shared/useToastQueue.ts"],
"desktop": ["src/components/shared/Toast.tsx"],
"mobile": ["src/mobile/components/MToastHost.tsx"]
},
{
"feature": "Tours list filter",
"hooks": ["src/components/Tours/tourPresentation.ts"],
"desktop": [
"src/components/Tours/TourDetailDialog.tsx",
"src/components/Tours/ToursSidebar.tsx",
"src/components/Tours/planner/TourPlannerPanels.tsx",
"src/components/Tours/tourParts.tsx"
],
"mobile": ["src/mobile/screens/trip/places/MToursSelectionList.tsx"]
},
{
"feature": "Transport booking detail view: reservationFiles",
"hooks": ["src/utils/reservationFiles.ts"],
"desktop": [
"src/components/Planner/DayPlanSidebarTransportDetailModal.tsx",
"src/components/Planner/ReservationsPanel.tsx",
"src/components/Planner/bookings/BookingDetailHost.tsx"
],
"mobile": [
"src/mobile/screens/trip/sheets/MTransportSheet.tsx",
"src/mobile/screens/trip/tabs/MBookingsTab.tsx",
"src/mobile/screens/trip/tabs/MTransportsTab.tsx"
]
},
{
"feature": "Transport booking detail view: transportDetailModel",
"hooks": ["src/components/Planner/transportDetailModel.ts"],
"desktop": ["src/components/Planner/DayPlanSidebarTransportDetailModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MTransportSheet.tsx"]
},
{
"feature": "Transport booking form: bookingFormModel",
"hooks": ["src/components/Planner/bookingFormModel.ts"],
"desktop": [
"src/components/Planner/ReservationModal.tsx",
"src/components/Planner/TransportModal.tsx",
"src/components/Planner/useBookingFileAttach.ts"
],
"mobile": [
"src/mobile/screens/trip/sheets/MReservationSheet.tsx",
"src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"
]
},
{
"feature": "Transport booking form: transportEndpoints, useTransportForm",
"hooks": ["src/components/Planner/transportEndpoints.ts", "src/components/Planner/useTransportForm.ts"],
"desktop": ["src/components/Planner/TransportModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MTransportFormSheet.tsx"]
},
{
"feature": "Trip export",
"hooks": ["src/components/Planner/useTripExport.ts"],
"desktop": ["src/components/Planner/TripExportModal.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MExportSheet.tsx"]
},
{
"feature": "Trip files: fileActions",
"hooks": ["src/components/Files/fileActions.ts"],
"desktop": ["src/components/Files/FileManagerAssignModal.tsx", "src/components/Files/useFileManager.ts"],
"mobile": [
"src/mobile/screens/trip/tabs/MFileLinkSheet.tsx",
"src/mobile/screens/trip/tabs/MFileMenuSheet.tsx",
"src/mobile/screens/trip/tabs/MFilesTab.tsx"
]
},
{
"feature": "Trip files: fileListRules",
"hooks": ["src/components/Files/fileListRules.ts"],
"desktop": ["src/components/Files/FileManagerRow.tsx", "src/components/Files/useFileManager.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MFileLinkSheet.tsx", "src/mobile/screens/trip/tabs/filesModel.ts"]
},
{
"feature": "Trip files: useFileTrash",
"hooks": ["src/components/Files/useFileTrash.ts"],
"desktop": ["src/components/Files/useFileManager.ts"],
"mobile": ["src/mobile/screens/trip/tabs/MFileTrashSheet.tsx"]
},
{
"feature": "Trip loading splash",
"hooks": ["src/components/shared/useTripSplashBeats.ts"],
"desktop": ["src/components/shared/TripLoadingSplash.tsx"],
"mobile": ["src/mobile/screens/trip/MTripLoadingSplash.tsx"]
},
{
"feature": "Trip planner page",
"hooks": ["src/pages/tripPlanner/useTripPlanner.ts"],
"desktop": ["src/pages/tripPlanner/useTripPlannerPage.ts"],
"mobile": ["src/mobile/screens/trip/MTripShell.tsx"]
},
{
"feature": "Trip sheet wiring: transportEditorOpeners",
"hooks": ["src/pages/tripPlanner/transportEditorOpeners.ts"],
"desktop": ["src/pages/TripPlannerPage.tsx", "src/pages/tripPlanner/usePlannerDialogs.ts"],
"mobile": ["src/mobile/screens/trip/sheets/MTripSheets.tsx"]
},
{
"feature": "Trip sheet wiring: tripCover, useBookingExpenseEditor",
"hooks": ["src/pages/tripPlanner/tripCover.ts", "src/pages/tripPlanner/useBookingExpenseEditor.ts"],
"desktop": ["src/pages/TripPlannerPage.tsx"],
"mobile": ["src/mobile/screens/trip/sheets/MTripSheets.tsx"]
},
{
"feature": "User modals/sheets",
"hooks": ["src/components/Admin/useAdminUserActions.ts"],
"desktop": ["src/pages/admin/AdminUserModals.tsx"],
"mobile": ["src/mobile/screens/admin/MAdminSheets.tsx"]
},
{
"feature": "Vacay calendar logging logic: useVacayCalendarLogic",
"hooks": ["src/components/Vacay/useVacayCalendarLogic.ts"],
"desktop": ["src/components/Vacay/VacayCalendar.tsx", "src/components/Vacay/VacayPersons.tsx"],
"mobile": ["src/mobile/screens/vacay/useMVacay.ts"]
},
{
"feature": "Vacay calendar logging logic: vacayFormat",
"hooks": ["src/components/Vacay/vacayFormat.ts"],
"desktop": ["src/components/Vacay/VacayStats.tsx"],
"mobile": ["src/mobile/screens/vacay/MVacay.tsx"]
},
{
"feature": "Vacay fusion invite",
"hooks": ["src/components/Vacay/useVacayUserPicker.ts"],
"desktop": ["src/components/Vacay/VacayPersons.tsx", "src/components/Vacay/VacaySharedCalendars.tsx"],
"mobile": ["src/mobile/screens/vacay/MVacayInviteSheet.tsx", "src/mobile/screens/vacay/MVacayShareSheet.tsx"]
},
{
"feature": "Vacay page",
"hooks": ["src/pages/vacay/useVacay.ts"],
"desktop": ["src/pages/VacayPage.tsx"],
"mobile": ["src/mobile/screens/vacay/useMVacay.ts"]
},
{
"feature": "Vacay read-only calendar sharing",
"hooks": ["src/components/Vacay/useVacayShareActions.ts"],
"desktop": ["src/components/Vacay/VacaySharedCalendars.tsx"],
"mobile": ["src/mobile/screens/vacay/MVacayShareSheet.tsx", "src/mobile/screens/vacay/useMVacay.ts"]
},
{
"feature": "Vacay settings",
"hooks": ["src/components/Vacay/useVacaySettings.ts"],
"desktop": ["src/components/Vacay/VacaySettings.tsx"],
"mobile": ["src/mobile/screens/vacay/MVacaySettingsSheet.tsx"]
}
],
"mobileOnly": {
"src/mobile/screens/admin/MAdminUi.tsx": "Phone only admin UI primitives (button, card, field, input, secret input, sheet frame).",
"src/mobile/screens/atlas/MAtlasSearch.tsx": "Phone full screen country search overlay; options, suggestions and selection come through props from MAtlas.",
"src/mobile/screens/atlas/MAtlasStatsCard.tsx": "Markup only; renders the AtlasStats that MAtlas passes in.",
"src/mobile/screens/collections/MCollCategoryPicker.tsx": "Phone only category dropdown; value and categories come through props from the collection sheets.",
"src/mobile/screens/collections/MCollLabelsSheet.tsx": "Phone label sheet; create, update, delete and assign come through props from useCollections.",
"src/mobile/screens/collections/MCollLinksEditor.tsx": "Markup only; link rows and the change handler come through props from the collection sheets.",
"src/mobile/screens/collections/MCollListPickerSheet.tsx": "Phone list picker sheet; the move or copy action comes through onPick from useCollections.",
"src/mobile/screens/collections/MCollSheetKit.tsx": "Phone only sheet UI primitives for the collections screens (header, footer, pill, eyebrow).",
"src/mobile/screens/collections/collectionsMobileModel.ts": "Visual constants only the phone collections screens use (category and status specs, swatches, cover gradient).",
"src/mobile/screens/dashboard/MUserMenu.tsx": "Phone avatar menu with a three state theme cycle the desktop navbar does not have.",
"src/mobile/screens/settings/MConfirmSheet.tsx": "Phone only bottom sheet confirm dialog used across the mobile screens.",
"src/mobile/screens/settings/MDawarichConnectionSection.tsx": "Already shares hooks/useDawarichConnection with components/Settings/DawarichConnectionSection.tsx.",
"src/mobile/screens/settings/MMobileDashOrder.tsx": "Phone only setting that orders the mobile dashboard blocks; the desktop has no such setting.",
"src/mobile/screens/settings/MMobileNavCustomizer.tsx": "Phone only editor for the mobile bottom nav, built on components/Settings/useMobileNavEditor.",
"src/mobile/screens/settings/MSetPickerSheet.tsx": "Phone only option picker bottom sheet, a generic UI primitive.",
"src/mobile/screens/settings/MSettingsAbout.tsx": "Markup only about card with static links and no state.",
"src/mobile/screens/settings/MSettingsOffline.tsx": "Already shares components/Settings/useOfflineSettings with components/Settings/OfflineTab.tsx.",
"src/mobile/screens/settings/MSettingsUi.tsx": "Phone only settings UI primitives (card, row, input, button, segments, hint).",
"src/mobile/screens/settings/MWebPushCard.tsx": "Already shares hooks/useWebPush with components/Settings/WebPushCard.tsx.",
"src/mobile/screens/trip/dockTabs.ts": "Phone only helper that picks which trip tabs get a seat in the mobile dock.",
"src/mobile/screens/trip/lib/dayNotes.ts": "Phone only helper that splits a leading time off a day note for the plan timeline.",
"src/mobile/screens/trip/map/MMapArea.tsx": "Phone map stage framing; already shares usePoiExplore and useMergedMapPois with the desktop trip planner.",
"src/mobile/screens/trip/map/MPlacesFilterPill.tsx": "Phone only map control for the active places filter count, built on the shared MapTogglePill.",
"src/mobile/screens/trip/places/MPlacesBulkCategorySheet.tsx": "Markup only; categories and the pick handler come through props from MPlacesBrowser.",
"src/mobile/screens/trip/places/MPlacesToursModeSwitch.tsx": "Markup only places or tours toggle; value and onChange come through props from MPlacesBrowser.",
"src/mobile/screens/trip/places/placesBrowserModel.ts": "Phone pool helpers over the shared utils/placesFilter, placeSearch and plannedPlaces.",
"src/mobile/screens/trip/plan/MPlanTimelineRows.tsx": "Markup only timeline rows; row data comes from MPlanTimeline and planTimelineModel.",
"src/mobile/screens/trip/plan/planTimelineModel.ts": "Phone plan timeline row builder over the shared utils/dayMerge, dayOrder and dayTourProjection.",
"src/mobile/screens/trip/plan/useMPlanDaySwipe.ts": "Phone only touch swipe gesture for switching days.",
"src/mobile/screens/trip/plan/useMPlanDragReorder.ts": "Phone only long press drag gesture; the reorder itself runs through the handler it is given.",
"src/mobile/screens/trip/roadtrip/MRoadtripRows.tsx": "Markup only road trip rows over the shared Roadtrip models the desktop sidebar rows also use.",
"src/mobile/screens/trip/roadtrip/MRoadtripTab.tsx": "Phone road trip tab over the shared roadtripRowModel, stopBookings and nightBookend RoadtripSidebar uses.",
"src/mobile/screens/trip/roadtrip/MRtAlternativesBar.tsx": "Markup only; state from useMRtAlternatives, text from the shared alternativeOverlays.",
"src/mobile/screens/trip/roadtrip/MRtCorridorBar.tsx": "Markup only; its corridor state comes through props from useMRtCorridor.",
"src/mobile/screens/trip/roadtrip/MRtCorridorSheet.tsx": "Phone corridor search sheet over useMRtCorridor and the planner's shared corridor state.",
"src/mobile/screens/trip/roadtrip/MRtInfoSheet.tsx": "Read only figures sheet that already shares useHotelBookends, dayWindow and parseAvoid with RoadtripLimitsCard.",
"src/mobile/screens/trip/roadtrip/MRtKindSheet.tsx": "Thin sheet over planner.setRoadtripStopKind that already shares STOP_KINDS with StopKindPicker.",
"src/mobile/screens/trip/roadtrip/MRtStopSheet.tsx": "Phone only stop detail sheet over the shared roadtripRowModel, stayReading and planner writers.",
"src/mobile/screens/trip/roadtrip/stageBadges.ts": "Badge helpers for the phone stage header and alternatives bar, which have no desktop counterpart.",
"src/mobile/screens/trip/roadtrip/useMRoadtrip.ts": "Phone stage screen adapter over the shared roadtripRowModel plus a live clock tick.",
"src/mobile/screens/trip/roadtrip/useMRtAlternatives.ts": "Tap to preview, confirm to write layer over the planner's shared route alternatives.",
"src/mobile/screens/trip/roadtrip/useMRtCorridor.ts": "Phone reach and anchor layer over the planner's shared corridor state.",
"src/mobile/screens/trip/sheets/MBookingFilesCosts.tsx": "Phone booking sheet footer that composes PlFileAttach and MLinkedCosts.",
"src/mobile/screens/trip/sheets/MBrowseActionsSheet.tsx": "Phone action sheet for a pool place that calls planner handlers and the shared placeToSaveTarget.",
"src/mobile/screens/trip/sheets/MDaysSheet.tsx": "Button based day reorder sheet over planner.handleReorderDays; the desktop reorders by drag.",
"src/mobile/screens/trip/sheets/MImportSheet.tsx": "Menu that routes to ImpFileStep and ImpListStep, with no logic of its own.",
"src/mobile/screens/trip/sheets/MLinkedCosts.tsx": "Already shares useExpenseLinks with the desktop BookingCostsSection; only markup and a search filter are local.",
"src/mobile/screens/trip/sheets/MMehrSheet.tsx": "Phone dock overflow menu that only opens tabs and sheets.",
"src/mobile/screens/trip/sheets/MTripSheetUi.tsx": "Phone sheet UI primitives (eyebrow, headers, stat boxes, action circles) and a time display helper.",
"src/mobile/screens/trip/sheets/PlFileAttach.tsx": "Markup only; pending files and link actions come through props from the phone form sheets.",
"src/mobile/screens/trip/sheets/PlSheetChrome.tsx": "Phone form sheet chrome primitives (header, footer, field classes).",
"src/mobile/screens/trip/sheets/PlTimeFields.tsx": "Phone time fields that already share timeCollisions with the desktop PlaceFormModal.",
"src/mobile/screens/trip/tabs/MCollabTab.tsx": "Phone collab sub tab router that reuses the desktop CollabLinks component.",
"src/mobile/screens/trip/tabs/MListsTab.tsx": "Phone router between the packing and to-do bodies, with no logic of its own.",
"src/mobile/screens/trip/tabs/MReceiptScanButton.tsx": "Already shares useReceiptScan and ReceiptScanModal with the desktop CostsPanel.",
"src/mobile/screens/trip/tabs/MTripTabPanel.tsx": "Phone tab router that mounts the per tab screens and plugin frames, with no logic of its own.",
"src/mobile/screens/trip/tabs/listsModel.ts": "Priority colour and label constants only the phone to-do list and task sheet use.",
"src/mobile/screens/trip/tabs/tabChrome.tsx": "Phone tab chrome primitives (scroller, section headers, fields, traveler chips, plugin slots).",
"src/mobile/screens/trip/tabs/tabModel.ts": "Phone tab prop type and status colour tokens only the phone tabs use.",
"src/mobile/screens/trip/tabs/transportsModel.ts": "Phone transport card readers (metadata, day and time cells) for MTransportsTab and MBookingsTab.",
"src/mobile/screens/vacay/MVacayMonth.tsx": "Markup only; its day cell colours come from the phone vacayDayModel.",
"src/mobile/screens/vacay/vacayDayModel.ts": "Phone day cell colour model for MVacayMonth; the desktop card styles its cells inline."
}
}
+147
View File
@@ -0,0 +1,147 @@
/*
* The manifest and the checks behind lint:pairs (scripts/pairs-lint.mjs).
*/
import { existsSync } from 'node:fs';
import { dirname, join, resolve } from 'node:path';
import { importsOf } from './imports.mjs';
import { listFiles, readBaseline, readText, TEST_FILE, toKey } from './ratchet.mjs';
/** The phone screens every entry of the manifest is weighed against. */
export const SCREENS = 'src/mobile/screens';
/**
* Where a shared hook may live: below the views that both import it, or next
* to a page in its folder under pages/ (use<Page> and its models), which the
* phone shell of that page imports as well. Never in a view itself.
*/
const SHARED = /^src\/(?:components|hooks|utils)\/|^src\/pages\/[^/]+\//;
const SOURCE = /\.tsx?$/;
const isPath = (value) => typeof value === 'string' && SOURCE.test(value) && !value.includes('\\');
const isPathList = (value) => Array.isArray(value) && value.length > 0 && value.every(isPath);
/**
* scripts/feature-pairs.json: pairs is a list of features, each with the
* shared hooks or models that hold its logic and the desktop and phone files
* that run on them; mobileOnly maps each phone screen file that has no
* desktop twin, or no logic a twin could share, to the reason.
*/
export function manifestShape(value) {
if (!value || typeof value !== 'object' || Array.isArray(value)) return 'it is not an object';
const keys = Object.keys(value).sort().join(',');
if (keys !== 'mobileOnly,pairs') return 'it needs exactly the keys "pairs" and "mobileOnly"';
if (!Array.isArray(value.pairs)) return '"pairs" is not a list';
const names = new Set();
for (const pair of value.pairs) {
if (!pair || typeof pair !== 'object' || typeof pair.feature !== 'string' || !pair.feature.trim())
return `a pair without a feature name: ${JSON.stringify(pair)}`;
if (names.has(pair.feature)) return `the feature "${pair.feature}" is listed twice`;
names.add(pair.feature);
for (const field of ['hooks', 'desktop', 'mobile'])
if (!isPathList(pair[field]))
return `"${pair.feature}" needs "${field}" as a list of source files from the client root`;
}
const only = value.mobileOnly;
if (!only || typeof only !== 'object' || Array.isArray(only)) return '"mobileOnly" is not an object of file → reason';
for (const [key, reason] of Object.entries(only)) {
if (!isPath(key)) return `"${key}" in "mobileOnly" is not a source file from the client root`;
if (typeof reason !== 'string' || !reason.trim()) return `"${key}" in "mobileOnly" has no reason`;
}
return null;
}
/** The path a relative import lands on from the client root, without its extension. */
function targetOf(root, fromFile, specifier) {
if (!specifier.startsWith('.')) return null;
return toKey(root, resolve(dirname(fromFile), specifier)).replace(/(?:\/index)?(?:\.[cm]?[jt]sx?)?$/, '');
}
const stem = (key) => key.replace(/\.[cm]?[jt]sx?$/, '');
/** The modules a file imports for more than types, as paths from the root without extensions. */
function runtimeImports(root, key) {
const path = join(root, key);
return new Set(
importsOf(readText(path), path)
.filter((imp) => !imp.typeOnly)
.map((imp) => targetOf(root, path, imp.specifier))
.filter(Boolean)
);
}
/**
* Everything wrong with the manifest against the tree, as lines to print:
* a listed file that is gone, a hook outside the shared layers, a view of a
* pair that does not import every hook of the pair, a phone screen listed
* both as a pair and as phone only, and a phone screen that is in neither.
*/
export function problems(root, manifest) {
const found = [];
const listed = new Set();
const present = (key, where) => {
if (existsSync(join(root, key))) return true;
found.push(`${key} is listed under ${where} but does not exist`);
return false;
};
for (const pair of manifest.pairs) {
const where = `"${pair.feature}"`;
const hooks = pair.hooks.filter((key) => present(key, where));
for (const key of hooks)
if (!SHARED.test(key))
found.push(`${key}, the hook of ${where}, is not under src/components, src/hooks, src/utils or a page folder`);
for (const key of [...pair.desktop, ...pair.mobile]) {
if (pair.mobile.includes(key)) listed.add(key);
if (!present(key, where)) continue;
const imported = runtimeImports(root, key);
const missing = hooks.filter((hook) => !imported.has(stem(hook)));
for (const hook of missing) found.push(`${key} is a view of ${where} but does not import ${hook}`);
}
}
for (const key of Object.keys(manifest.mobileOnly)) {
if (!present(key, 'mobileOnly')) continue;
if (listed.has(key)) found.push(`${key} is listed in a pair and under mobileOnly; it is one or the other`);
listed.add(key);
}
const screens = listFiles(
root,
[SCREENS],
(key) => SOURCE.test(key) && !TEST_FILE.test(key) && !key.endsWith('.d.ts')
);
for (const path of screens) {
const key = toKey(root, path);
if (!listed.has(key)) found.push(`${key} is a phone screen file that scripts/feature-pairs.json does not list`);
}
return found;
}
/** Runs the check against the manifest at manifestPath and returns the exit code. */
export function check({
root,
manifestPath = join(root, 'scripts/feature-pairs.json'),
list = false,
log = console.log,
error = console.error,
}) {
const manifest = readBaseline(manifestPath, manifestShape);
const found = problems(root, manifest);
if (list) {
for (const pair of manifest.pairs) log(`${pair.feature}: ${pair.hooks.join(', ')}`);
}
for (const line of found) error(`FAIL ${line}`);
if (found.length) {
error(
'A feature that has a desktop view and a phone view keeps its logic in one shared hook both import. ' +
'Add a new phone screen to scripts/feature-pairs.json: as a view of the pair whose hook it runs on, ' +
'or under mobileOnly with the reason it has no twin. See client/CLAUDE.md, "Desktop and phone views".'
);
}
const only = Object.keys(manifest.mobileOnly).length;
log(
`pairs: ${manifest.pairs.length} shared feature(s), ${only} phone-only screen file(s), ${found.length} problem(s)`
);
return found.length ? 1 : 0;
}
+26
View File
@@ -0,0 +1,26 @@
#!/usr/bin/env node
/*
* lint:pairs: a desktop view and its phone twin run on the same logic.
*
* Most features have a desktop view under components/ or pages/ and a phone
* view under mobile/, and each used to carry its own copy of the feature's
* state, loading and handlers, so a fix on one side drifted away from the
* other. The logic now lives in one shared hook per feature, and
* scripts/feature-pairs.json names, for every feature, the hooks and the
* desktop and phone files that run on them. The check fails when a listed
* view stops importing one of its pair's hooks, when a listed file is gone,
* when a hook sits in a view layer, and when a file under src/mobile/screens
* is in no pair and not under mobileOnly with the reason it has no twin. A
* new phone screen is so either wired to the desktop feature's hook or marked
* as phone only on purpose, never a quiet second copy. lint:dup catches the
* copies that look alike; this catches the ones written anew.
*
* npm run lint:pairs check the manifest against the tree (CI)
* npm run lint:pairs -- --list print every pair with its hooks
*/
import { fileURLToPath } from 'node:url';
import { check } from './lib/pairs.mjs';
import { runCli } from './lib/ratchet.mjs';
const root = fileURLToPath(new URL('..', import.meta.url));
await runCli('lint:pairs', (args) => check({ root, list: args.includes('--list') }));
@@ -0,0 +1,158 @@
import { afterEach, describe, expect, it } from 'vitest';
import { check, manifestShape, problems } from '../../../scripts/lib/pairs.mjs';
import { RatchetError } from '../../../scripts/lib/ratchet.mjs';
import { ratchetTree, type RatchetTree } from '../../helpers/ratchetFixture';
let tree: RatchetTree;
afterEach(() => tree?.remove());
const MANIFEST = 'scripts/feature-pairs.json';
const HOOK = 'src/components/Packing/usePackingList.ts';
const DESKTOP = 'src/components/Packing/PackingPanel.tsx';
const PHONE = 'src/mobile/screens/trip/tabs/MPackingTab.tsx';
const PAIR = { feature: 'Packing list', hooks: [HOOK], desktop: [DESKTOP], mobile: [PHONE] };
const FILES: Record<string, string> = {
[HOOK]: 'export function usePackingList() {}\n',
[DESKTOP]: "import { usePackingList } from './usePackingList'\n",
[PHONE]: "import { usePackingList } from '../../../../components/Packing/usePackingList'\n",
};
function run(manifest: unknown, files: Record<string, string> = FILES) {
tree = ratchetTree({ [MANIFEST]: JSON.stringify(manifest), ...files });
return check({ root: tree.root, manifestPath: tree.path(MANIFEST), ...tree.out });
}
describe('lint:pairs', () => {
it('PAIRS-001: passes when both views import the hook and every phone screen is listed', () => {
expect(run({ pairs: [PAIR], mobileOnly: {} })).toBe(0);
expect(tree.error).toEqual([]);
expect(tree.log.slice(-1)).toEqual(['pairs: 1 shared feature(s), 0 phone-only screen file(s), 0 problem(s)']);
});
it('PAIRS-002: fails when a view of a pair stops importing its hook', () => {
expect(run({ pairs: [PAIR], mobileOnly: {} }, { ...FILES, [PHONE]: 'export const MPackingTab = 1\n' })).toBe(1);
expect(tree.error[0]).toBe(`FAIL ${PHONE} is a view of "Packing list" but does not import ${HOOK}`);
});
it('PAIRS-003: a type-only import of the hook does not count as running on it', () => {
const typeOnly = "import type { PackingList } from '../../../../components/Packing/usePackingList'\n";
expect(run({ pairs: [PAIR], mobileOnly: {} }, { ...FILES, [PHONE]: typeOnly })).toBe(1);
});
it('PAIRS-004: accepts an import with an extension, a re-export and a dynamic import', () => {
const files = {
...FILES,
[DESKTOP]: "export { usePackingList } from './usePackingList.ts'\n",
[PHONE]: "const load = () => import('../../../../components/Packing/usePackingList')\n",
};
expect(run({ pairs: [PAIR], mobileOnly: {} }, files)).toBe(0);
});
it('PAIRS-005: fails on a phone screen file that is in no pair and not phone only', () => {
const extra = 'src/mobile/screens/trip/sheets/MNewSheet.tsx';
expect(run({ pairs: [PAIR], mobileOnly: {} }, { ...FILES, [extra]: 'export {}\n' })).toBe(1);
expect(tree.error[0]).toBe(`FAIL ${extra} is a phone screen file that scripts/feature-pairs.json does not list`);
expect(tree.error.slice(-1)[0]).toContain('mobileOnly');
});
it('PAIRS-006: a phone only screen with a reason passes, tests and files outside screens are not weighed', () => {
const files = {
...FILES,
'src/mobile/screens/trip/sheets/MImportSheet.tsx': 'export {}\n',
'src/mobile/screens/trip/sheets/MImportSheet.test.tsx': 'export {}\n',
'src/mobile/components/MSheet.tsx': 'export {}\n',
};
const manifest = {
pairs: [PAIR],
mobileOnly: { 'src/mobile/screens/trip/sheets/MImportSheet.tsx': 'menu that routes to the import steps' },
};
expect(run(manifest, files)).toBe(0);
expect(tree.log.slice(-1)).toEqual(['pairs: 1 shared feature(s), 1 phone-only screen file(s), 0 problem(s)']);
});
it('PAIRS-007: fails on listed files that are gone', () => {
tree = ratchetTree(FILES);
const gone = { ...PAIR, desktop: ['src/components/Packing/Gone.tsx'] };
expect(problems(tree.root, { pairs: [gone], mobileOnly: { 'src/mobile/screens/MOld.tsx': 'old' } })).toEqual([
'src/components/Packing/Gone.tsx is listed under "Packing list" but does not exist',
'src/mobile/screens/MOld.tsx is listed under mobileOnly but does not exist',
]);
});
it('PAIRS-008: fails on a hook that lives in a view layer', () => {
const hook = 'src/mobile/screens/trip/lib/usePacking.ts';
const files = {
[hook]: 'export function usePacking() {}\n',
[DESKTOP]: "import { usePacking } from '../../mobile/screens/trip/lib/usePacking'\n",
[PHONE]: "import { usePacking } from '../lib/usePacking'\n",
};
const manifest = { pairs: [{ ...PAIR, hooks: [hook] }], mobileOnly: { [hook]: 'the hook itself' } };
expect(run(manifest, files)).toBe(1);
expect(tree.error[0]).toBe(
`FAIL ${hook}, the hook of "Packing list", is not under src/components, src/hooks, src/utils or a page folder`
);
});
it('PAIRS-015: a hook in a page folder counts as shared, the page file itself does not', () => {
const hook = 'src/pages/packing/usePacking.ts';
const page = 'src/pages/PackingPage.tsx';
const files = {
[hook]: 'export function usePacking() {}\n',
[page]: 'export function PackingPage() {}\n',
[DESKTOP]:
"import { usePacking } from '../../pages/packing/usePacking'\nimport { PackingPage } from '../../pages/PackingPage'\n",
[PHONE]:
"import { usePacking } from '../../../../pages/packing/usePacking'\nimport { PackingPage } from '../../../../pages/PackingPage'\n",
};
expect(run({ pairs: [{ ...PAIR, hooks: [hook] }], mobileOnly: {} }, files)).toBe(0);
tree.remove();
expect(run({ pairs: [{ ...PAIR, hooks: [page] }], mobileOnly: {} }, files)).toBe(1);
expect(tree.error[0]).toContain('is not under src/components, src/hooks, src/utils or a page folder');
});
it('PAIRS-009: fails on a phone screen listed both in a pair and as phone only', () => {
expect(run({ pairs: [PAIR], mobileOnly: { [PHONE]: 'no twin' } })).toBe(1);
expect(tree.error[0]).toBe(`FAIL ${PHONE} is listed in a pair and under mobileOnly; it is one or the other`);
});
it('PAIRS-010: a view must import every hook of its pair', () => {
const model = 'src/components/Packing/packingModel.ts';
const files = { ...FILES, [model]: 'export const x = 1\n' };
expect(run({ pairs: [{ ...PAIR, hooks: [HOOK, model] }], mobileOnly: {} }, files)).toBe(1);
expect(tree.error.filter((line) => line.includes(model))).toHaveLength(2);
});
it('PAIRS-011: --list prints every pair with its hooks', () => {
tree = ratchetTree({ [MANIFEST]: JSON.stringify({ pairs: [PAIR], mobileOnly: {} }), ...FILES });
check({ root: tree.root, manifestPath: tree.path(MANIFEST), list: true, ...tree.out });
expect(tree.log[0]).toBe(`Packing list: ${HOOK}`);
});
it('PAIRS-012: rejects a manifest of the wrong shape with the reason', () => {
expect(manifestShape([])).toBe('it is not an object');
expect(manifestShape({ pairs: [] })).toBe('it needs exactly the keys "pairs" and "mobileOnly"');
expect(manifestShape({ pairs: [{ feature: '' }], mobileOnly: {} })).toMatch(/without a feature name/);
expect(manifestShape({ pairs: [PAIR, PAIR], mobileOnly: {} })).toBe('the feature "Packing list" is listed twice');
expect(manifestShape({ pairs: [{ ...PAIR, hooks: [] }], mobileOnly: {} })).toMatch(/needs "hooks"/);
expect(manifestShape({ pairs: [{ ...PAIR, mobile: ['src\\m.tsx'] }], mobileOnly: {} })).toMatch(/needs "mobile"/);
expect(manifestShape({ pairs: [], mobileOnly: { 'src/m.tsx': ' ' } })).toBe(
'"src/m.tsx" in "mobileOnly" has no reason'
);
expect(manifestShape({ pairs: [], mobileOnly: { 'src/m.css': 'x' } })).toMatch(/not a source file/);
expect(manifestShape({ pairs: [PAIR], mobileOnly: {} })).toBeNull();
});
it('PAIRS-013: a missing or broken manifest stops the check instead of passing', () => {
tree = ratchetTree({ ...FILES, [MANIFEST]: '{ not json' });
expect(() => check({ root: tree.root, manifestPath: tree.path(MANIFEST), ...tree.out })).toThrow(RatchetError);
expect(() => check({ root: tree.root, manifestPath: tree.path('scripts/none.json'), ...tree.out })).toThrow(
RatchetError
);
});
it('PAIRS-014: a tree without phone screens stops the check instead of passing', () => {
tree = ratchetTree({ [MANIFEST]: JSON.stringify({ pairs: [], mobileOnly: {} }), [HOOK]: 'export {}\n' });
expect(() => check({ root: tree.root, manifestPath: tree.path(MANIFEST), ...tree.out })).toThrow(RatchetError);
});
});
+2 -1
View File
@@ -206,6 +206,7 @@ The `@trek/shared` package is the single source of truth for code shared between
| `npm run lint:layers` | Imports only go downwards through the layers (CI gate) |
| `npm run lint:offline` | Views reach `src/api/` only through the offline core; the baseline only shrinks (CI gate) |
| `npm run lint:dup` | Lines in copied code blocks may only shrink per file, at SonarCloud's thresholds (CI gate) |
| `npm run lint:pairs` | Desktop and phone views of a feature import its shared hook; every phone screen is listed (CI gate) |
| `npm run lint:skips` | No focused test, and skipped tests may only go away (CI gate) |
| `npm run lint:i18n-keys` | Every translation key the client names exists in `en` (CI gate) |
| `npm run theme:lint` | Flag styling that bypasses the appearance tokens (not run in CI) |
@@ -243,6 +244,6 @@ Then open a Pull Request from your fork to `liketrek/TREK` targeting the `dev` b
- Run tests before pushing: `npm test` at the repo root runs all workspaces. That alone is not the full CI gate. With `shared` built, these are the checks CI runs before any test (`.github/workflows/test.yml` is the source of truth):
- in `shared/`: `npm run typecheck && npm run contracts:open`
- in `server/`: `npm run build && npm run typecheck && npm run typecheck:tests && npm run typecheck:scripts && npm run check:entities && npm run check:db-types && npm run lint:check && npm run db:call-graph -- --sync --tx && npm run lint:size && npm run lint:boundaries && npm run lint:tx && npm run lint:test-sql && npm run lint:dialect && npm run check:plugin-facts`
- in `client/`: `npm run typecheck && npm run lint:warnings && npm run lint:pages && npm run lint:rtl && npm run lint:size && npm run lint:format && npm run lint:layers && npm run lint:offline && npm run lint:dup && npm run lint:skips && npm run lint:i18n-keys`
- in `client/`: `npm run typecheck && npm run lint:warnings && npm run lint:pages && npm run lint:rtl && npm run lint:size && npm run lint:format && npm run lint:layers && npm run lint:offline && npm run lint:dup && npm run lint:pairs && npm run lint:skips && npm run lint:i18n-keys`
- at the root, if you touched translations: `npm run i18n:parity:strict --workspace=shared`
- Follow the commit message conventions described in the [[Contributing]] guidelines.