diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 2651ed744..801641d83 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index 8c1eb83f7..95e03ed24 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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/` ↔ `client/src/mobile/screens/.../M`) 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/` ↔ `client/src/mobile/screens/.../M`) 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. diff --git a/client/CLAUDE.md b/client/CLAUDE.md index 5cb9e7d2d..eb500422d 100644 --- a/client/CLAUDE.md +++ b/client/CLAUDE.md @@ -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 , 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/')` 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()`, ``, `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//` (or `hooks/`/`utils/` when it is not bound to one feature; a page's own logic stays in its `use` hook and models in `pages//`, 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 diff --git a/client/package.json b/client/package.json index 49bb80c4d..63b5929db 100644 --- a/client/package.json +++ b/client/package.json @@ -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", diff --git a/client/scripts/feature-pairs.json b/client/scripts/feature-pairs.json new file mode 100644 index 000000000..335703b96 --- /dev/null +++ b/client/scripts/feature-pairs.json @@ -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." + } +} diff --git a/client/scripts/lib/pairs.mjs b/client/scripts/lib/pairs.mjs new file mode 100644 index 000000000..8374d7070 --- /dev/null +++ b/client/scripts/lib/pairs.mjs @@ -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 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; +} diff --git a/client/scripts/pairs-lint.mjs b/client/scripts/pairs-lint.mjs new file mode 100644 index 000000000..d6896d7cc --- /dev/null +++ b/client/scripts/pairs-lint.mjs @@ -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') })); diff --git a/client/tests/unit/scripts/pairs-lint.test.ts b/client/tests/unit/scripts/pairs-lint.test.ts new file mode 100644 index 000000000..5ac7a26b2 --- /dev/null +++ b/client/tests/unit/scripts/pairs-lint.test.ts @@ -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 = { + [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 = 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); + }); +}); diff --git a/wiki/Development-environment.md b/wiki/Development-environment.md index 07ed57a5e..334ffd126 100644 --- a/wiki/Development-environment.md +++ b/wiki/Development-environment.md @@ -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.