26 KiB
CLAUDE.md — Doormile Express Console (xpressconsole)
Project-level rules and conventions for Claude Code when working in this repo. Read this in full before editing. When in doubt about a pattern, copy from
src/pages/nearle/deliveries/deliveries.js— it is the canonical reference for both the design system and the data layer.
1. What this is
A React 18 operator console for the Doormile Express dispatch platform. Operators use it to manage orders, run the AI dispatch optimiser, watch a live map of riders, edit tenants/pricing/invoices, and pull BI reports. Production users are warehouse staff, not end customers.
For the per-page API map and architectural flow chart, see the project skill nearlexpress-docs (.claude/skills/nearlexpress-docs/SKILL.md). Do not duplicate that content here.
2. Stack (pinned — do not upgrade without a deliberate audit)
- React 19.2 + react-app-rewired 2.2 (CRA, not Next.js — no
pages/file-system routing; routes live insrc/routes/MainRoutes.js). - Astryx 0.1.9 (
@astryxdesign/core) is the UI library — see §6. MUI 5.12 (@mui/material) and Ant Design (antd 5.11) are legacy, present only on the pages not yet converted, and are being removed. Never write new UI against them. - Icons:
@ant-design/icons(page actions:EditOutlined,EyeOutlined,CloseOutlined, etc.) andreact-icons(page identity:Md*,Tb*,Fi*,Gi*,Lu*). Both coexist; pick by what's already imported nearby. - Data layer:
@tanstack/react-query 5.17(useQuery,useInfiniteQuery,useMutation). All server fetches go through this. Do not introducefetch,swr, oruseEffect-driven fetches for new code. - HTTP:
axios 1.3. Most pages importaxiosdirectly (raw).src/utils/axios.jsexists with a 401 →/logininterceptor but is not widely adopted — match the surrounding file rather than introducing it. - State:
@reduxjs/toolkit 1.9for cross-page state (FCM token, login user, menu, snackbar, toast). Page-local UI state stays inuseState. - Routing:
react-router-dom 6.10, lazy-loaded viacomponents/Loadable. - Forms:
formik 2.2+yup 1.1where present; new simple forms can use plainuseState. - Dates:
dayjs 1.11withutcplugin (already extended at the top ofdeliveries.js). Usedayjs(...).utc()for backend timestamps and baredayjs(...)for local-time bucketing — see the batch-bucketing comment indeliveries.jsfor the rationale. - Maps:
leaflet+react-leafletonly. Geocoding/address search via free OSM Nominatim (src/components/nearle_components/AddressAutocomplete.js) and routing via OSRM — no API key required. Google Maps (@react-google-maps/api,react-geocode,react-google-autocomplete) has been fully removed; do not reintroduce it. - Notifications:
firebase 10.14(FCM) — seesrc/firebase_notification/. Toasts vianotistack 3. - Drag-and-drop:
react-dnd(used on the Dispatch Preview page).
3. Dev workflow
# Install
npm install # or `yarn`
# Run locally (uses .env)
npm start
# Run with a specific env file
npm run start:dev # env.development
npm run start:staging # env.staging
# Build
npm run build
npm run build:dev
npm run build:staging
# Lint (the only checked gate — there are no tests of consequence)
npm run lint
- Env files at repo root:
env.stagingis committed;.env.development/.env.productionare typically gitignored. Pull from a teammate when missing. - Required env vars:
REACT_APP_DOORMILE_URL— the only API base (https://api.doormile.com/api/v1), used by every/admin/*call viautils/doormileAxios.js. The old jupiter-backedREACT_APP_URL/REACT_APP_URL2/REACT_APP_URL3have been fully retired — do not reintroduce them. No Maps API key is needed — maps and address search run on free Leaflet/OSM services. The optimiser URLs (routes.workolik.com,routemate.workolik.com) and the final-delivery-commit URL (jupiter.nearle.app) remain hardcoded — they're a separate solver service with no equivalent in the new API, deliberately left untouched by the backend migration. See thenearlexpress-docsskill andexpress-console-api.md. - Dev server runs on
http://localhost:3000. The user usually has it running already — assume it is up when reporting "reload to see it".
4. Hard constraints (do NOT)
- Do not introduce Next.js / SSR patterns. No
getServerSideProps, noapp/directory, nonext/*imports. This is CRA. - Do not rewrite shared design tokens. Every new page that needs the polished UI must reuse the
DTtoken block (see §6). Do not invent a parallel palette. - Do not change
package.jsondependency versions unless explicitly asked. The build is sensitive to webpack/svgr/react-scripts versions (seeresolutionsinpackage.json). - Do not bypass the dispatch reconcile step. After any manual edit on
/doormile/dispatch/preview(rider swap, step reorder), the page must callPOST /optimization/reconcile-stepsbeforePOST /deliveries/createdeliveries. Skipping this corrupts route sequences. - Do not commit
.env*files beyondenv.staging(which is the agreed-shared staging baseline). - Do not introduce TypeScript files (
.ts/.tsx) into this repo. It is JavaScript; mixing creates lint and tooling friction. - Do not use absolute
http://localhostURLs in code. Always read fromprocess.env.REACT_APP_DOORMILE_URL(or usedoormileAxios, which already defaults to it). - Do not log
userid,authname, FCM tokens, or PII toconsole.login production paths. The codebase has many leftoverconsole.logcalls — when editing nearby, remove them rather than add more. - Do not add or remove items from the sidebar without updating
src/menu-items/nearle.js— the menu drives both display and i18n keys. - Do not use destructive git (
reset --hard,push --force, branch deletion) without explicit user instruction.
5. Architecture map
src/
├── App.js # Auth gate (localStorage('authname') → /login), mounts FCM listener, ThemeCustomization, Locales, Notistack, Snackbar
├── index.js # Provider wiring: Redux store, TanStack QueryClient, Router
├── config.js # Theme constants (DRAWER_WIDTH=260, fontFamily, mode, presetColor, ThemeMode, MenuOrientation, ThemeDirection)
├── routes/
│ ├── index.js # Combines MainRoutes + LoginRoutes
│ ├── MainRoutes.js # All /doormile/* routes, lazy-loaded via Loadable(lazy(...))
│ └── LoginRoutes.js
├── layout/
│ ├── MainLayout/ # Sidebar + header frame (used for all logged-in pages)
│ └── CommonLayout/ # Bare frame (login, maintenance pages)
├── menu-items/
│ └── nearle.js # Sidebar definition — id, title (FormattedMessage), url, icon. Must add new pages here.
├── store/
│ ├── index.js # configureStore + useDispatch/useSelector exports
│ └── reducers/ # fcmSlice, loginUserSlice, menu, snackbar, toastSlice, auth, actions
├── themes/ # MUI theme, palette, typography, shadows, overrides
├── pages/
│ ├── api/api.js # CENTRAL API layer — every query/mutation function lives here
│ └── nearle/ # All operator pages, grouped by feature folder
└── components/
├── Loadable.js # React.Suspense wrapper for lazy routes
├── Loader.js # Full-page backdrop spinner
├── nearle_components/
│ ├── DebounceSearchBar.js # 500ms-debounced search input with ⌘/Ctrl+K focus shortcut
│ ├── LocationAutocomplete.js # Zone picker (has `pill` variant — use it for new pages)
│ ├── LoaderWithImage.js
│ ├── GlobalToast.js
│ ├── SearchBar.js
│ ├── TableLoader.js
│ └── TitleCard.js # LEGACY — do not use for new pages (replaced by the gradient Paper header in §6)
└── third-party/
└── OpenToast.js # Wrapper around notistack — use this for toast emissions
- Absolute imports work from
src/thanks tojsconfig.json("baseUrl": "src"). Preferimport x from 'pages/api/api'over deep relative paths. Look at the surrounding file's import style and match it.
6. Design system (Astryx + the DT tokens)
The UI is Astryx (@astryxdesign/core@0.1.9). MUI is legacy and on its way out — see §6.6. Discover components with the CLI, don't guess:
npx -y @astryxdesign/cli component <Name>
(The root README's yarn dlx form does not work here — yarn is not on PATH.)
6.1 Where the design system lives
| Module | What it owns |
|---|---|
themes/astryx.js |
The theme. doormileTheme — brand accent, card/input radii, component overrides. Mounted by layout/MainLayout and login.js via <Theme theme={doormileTheme} mode="light">. |
themes/dt/status.js |
The status registry. STATUS_META, STATUS_ALIASES, getStatusMeta() — every lifecycle state's label, hex, Astryx Badge variant, StatusDot variant, and icon. |
themes/dt/tokens.js |
DT (radii, shadows, surfaces, DT.brand), STATUS raw hexes, alpha helpers a/tint/soft/ring/edge. Its *Sx exports are legacy MUI only. |
themes/dt/primitives.js |
TableScroll (sticky-header + brand scrollbar wrapper), AccentAvatar, softPopupStyle. |
components/nearle_components/ |
PageHeader, StatCard, StatusBadge, StatusTabs, DebounceSearchBar, TableLoader, … |
Never redeclare these locally. Seventeen pages once carried private DT copies and drifted (orders.js sat on radiusCard: 16 and the old heavy shadows while everything else had moved to 14); five pages carried private STATUS_META copies that disagreed on labels for the same order. Change the shared module so every page moves together.
6.2 Brand colour
The brand is black (#000000) — DT.brand, pinned into the theme as --color-accent. The palette is deliberately quiet: chrome is neutral, and colour carries meaning only (status). Do not introduce a second brand accent.
Astryx's accent generator derives a ramp from the accent's hue and lightness. Black has neither, so feeding it
#000000made the generator invent a hue and render every primary button magenta. That's whythemes/astryx.jspins the accent tokens explicitly instead of usingcolor: { accent }. Don't "simplify" it back.
Anything genuinely brand-toned should read var(--color-accent) / DT.brand, never a literal #000000.
6.3 Status palette (semantic — the only place colour is allowed to shout)
Never render a status by picking a colour by hand. Resolve it:
import StatusBadge from 'components/nearle_components/StatusBadge';
<StatusBadge status={row.orderstatus} /> // handles casing, backend enums, unknowns
getStatusMeta() accepts a canonical key (pending), a raw backend enum (miler_assigned, converted_to_consignment), or any casing, and falls back to a neutral badge showing the raw string so an unmapped state is visible rather than blank.
| Meaning | Hex | Astryx Badge |
StatusDot |
|---|---|---|---|
| Pending / waiting | #f59e0b |
yellow |
warning |
| Accepted / assigned | #6366f1 |
blue |
accent |
| Arrived | #06b6d4 |
cyan |
accent |
| Picked up | #8b5cf6 |
purple |
accent |
| Active / in-transit | #14b8a6 |
teal |
success |
| Delivered / success | #10b981 |
green |
success |
| Skipped | #f97316 |
orange |
warning |
| Cancelled / error | #ef4444 |
error |
error |
| Neutral / unknown | #94a3b8 |
neutral |
neutral |
Badges use the tinted non-semantic variants on purpose. Astryx reserves the solid success/warning/error variants for states that demand attention, and a table where every row shouts is a table where nothing does — cancelled is the one lifecycle state that keeps a loud variant.
The hexes in STATUS (tokens.js) are the raw layer, for surfaces taking an arbitrary accent (StatCard color=, AccentAvatar, chart series). Use status.js to render; use STATUS only when you need a bare colour.
6.4 Page anatomy
Follow this order. pages/nearle/hubs/hubs.js is the reference implementation — copy its structure.
<PageHeader>— title,livesubtitle with pulsingStatusDot, and anactionslot (primaryButton, zone picker). Plain and un-boxed; the old gradient header Paper is gone.- KPI row —
<Grid columns={{ minWidth: 220 }} gap={3}>of<StatCard>.coloris a status hex, orDT.brandfor the primary tile. No top-stripes, no rainbow. <StatusTabs>— the status filter strip. Supply the tab order and where each count comes from; labels and icons resolve from the registry.- Filter + search bar — a
<Card padding={3}>holding anHStack justify="between"with the row count on the left and<DebounceSearchBar>on the right. - Table —
<Card padding={0}>→<TableScroll minWidth={…}>→<Table density="balanced" dividers="rows" hasHover>.TableScrollsupplies the sticky header and scrollbar that MUI'sTableContainerused to. - Status cells —
<StatusBadge>. Never a hand-rolled tintedBox. - Row actions —
<IconButton size="sm" variant="ghost">,variant="destructive"for delete. Always passlabelandtooltip. - Empty state —
<EmptyState title description icon>inside a full-widthTableCell colSpan={n}. Never antd<Empty/>. - Loading —
<TableLoader>for rows,<Loader>for a blocking full-page backdrop. These are not interchangeable.
6.5 Astryx rules and known gotchas
- No
<div>for layout.HStack/VStack/Grid/Center/Stackdo spacing and alignment. No raw<span>wrappers either. - No raw values. Colours and spacing come from tokens (
var(--color-*),var(--spacing-*),var(--radius-*)). An inlinestyleis the escape hatch only for a caller-supplied status hex, which the token system genuinely can't express (seeStatCard). - No
sxprop — it doesn't exist. Restyle throughthemes/astryx.jsso every call site moves at once. Gridtakescolumns={{ minWidth: N }}, not{ base, sm }breakpoints.Tablechildren-mode cells have noalignprop — usestyle.- There is no
Tabsand noStepperin 0.1.9. UseTabList/Tab(withendContentfor counts,TabMenufor overflow) andSegmentedControl/ProgressBarfor wizard steps. Spinnermaxes out at 18px — large loaders are CSS rings.Selectordoesn't forwardref;Typeaheadhas no freeSolo, which is whyAddressAutocompleteis aTextInputplus a hand-rolled list.- Astryx has 153 components — search before hand-rolling:
npx -y @astryxdesign/cli search "<thing>".
6.6 Migration status
Converted: the app shell, login.js, the maintenance pages, the entire shared-component layer, and the pages hubs, vehicles, reports/{profitability, RidersRoutes, mapWithRoute}.
Still MUI: 25 operator pages and 9 src/themes/* files. Four pages still import antd <Empty/>.
Rules while both stacks coexist:
- A page is converted whole, never half. Mixed-stack pages are how the palette drifts.
ThemeCustomizationmust stay mounted inApp.jsuntil the final page converts; the MUI theme files and the@mui/*/@emotion/*/antddependencies are removed only after that.OrdersTableSkeletonrenders rows into a page's table, so it can only convert alongside its last consumer. Converted pages useTableLoaderinstead.- Don't add a call site for anything marked ⛔ LEGACY in
tokens.js.
7. Data layer rules
- Every server call belongs in
src/pages/api/api.jsas an exported async function. Page files should never construct URLs inline forGETs. Mutations are sometimes kept inline (e.g. tenant pricing update) — that's tolerated but discouraged. - Use TanStack Query for all reads. Query keys must include every filter that affects the response so the cache invalidates correctly. Example:
useQuery({ queryKey: ['fetchCountData', appId, userid, startdate, enddate, tenantid, locationid, riderid, tabstatus], queryFn: () => fetchCountAPI(appId, userid, ...) }); - Use
useInfiniteQueryfor paginated rows.getNextPageParam: (lastPage) => lastPage.nextPage ?? undefined. Auto-drain pages with anIntersectionObserveron a sentinel<div ref={loadMoreRef} />placed at the bottom of the<TableContainer>. The canonical pattern lives indeliveries.js(search foruseInfiniteQuery({and the adjacentIntersectionObserversetup). - Mutations go through
useMutationwithonSuccess/onError. After a successful mutation, call.refetch()on every related query — the codebase does not yet usequeryClient.invalidateQueries. Match the surrounding file. - Errors →
OpenToast(message, 'error', 2000)fromcomponents/third-party/OpenToast. Toasts always anchor top-right. Duration is 2000ms for normal, 3000ms for "must be acknowledged". - Skeleton loading: use
OrdersTableSkeletonfor tables (props:rowsPerPagedefault 5,coldefault 1 —colis the count of variable middle columns; total rendered columns =col + 4since checkbox, serial, notes, status, and actions are always present). UseLoaderfor full-page modal backdrop andLoaderWithImagefor inline "loading deliveries…" states.
8. State & auth
- Auth state lives in
localStorage. The keys to know:authname(gate inApp.jsandutils/session.js'sAUTH_PRESENCE_KEY— unrelated to which backend authenticated the session, kept as the presence flag across the migration),doormileToken(the actual JWT sent asAuthorization: Beareron every/admin/*call — seeutils/doormileAxios.js),doormileUser,userid,roleid,tenantid,userfcmtoken,applocations(cached zone list, now hub-derived). When adding auth-touching code, read these directly — there is nouseAuth()hook. - The 401 redirect for the Doormile API comes from
utils/doormileAxios.js(auto-attaches the bearer token; on 401 does a fulllocalStorage.clear()+ hard navigate to/login, matchingutils/session.js'sperformSessionLogoutcontract).src/utils/axios.jsis a separate, older, unrelated mock-service client — don't confuse the two. - Redux slices live in
src/store/reducers/. Use them only for cross-page state (FCM token, login user, sidebar menu open, global snackbar). Do not put per-page form state in Redux.
9. FCM / notifications
- Initialised once in
App.jsviagenerateToken()+initFirebaseNotificationListener()fromfirebase_notification/notification.js. - After any mutation that affects a rider (assign, cancel, change rider), call
POST /utils/notifyuserwith the target rider'suserfcmtoken. Don't forget to also callnotifyRidermutation on success. - Service worker file:
public/firebase-messaging-sw.js— do not edit unless the user explicitly asks. Subtle bugs here cause silent delivery failures.
10. Routing & adding a new page
- Create the page file under
src/pages/nearle/<feature>/<name>.js. - Add the lazy import at the top of
src/routes/MainRoutes.js:const MyPage = Loadable(lazy(() => import('pages/nearle/<feature>/<name>'))); - Register the route inside the
nearlechildren array. - Add a sidebar entry in
src/menu-items/nearle.js(id,<FormattedMessage id="..." />title, url, icon). - Add the i18n key in the relevant
src/utils/locales/*.jsonfile (or wherever the project keeps them; most existing items use the English string as the id).
11. Common gotchas
utcplugin pollution:deliveries.jsextends dayjs withutcat module load. If you import dayjs elsewhere and call.utc()you'll get UTC behaviour even if you didn't ask. Match what the surrounding page does —deliveries.jsdeliberately bucket-parses in local time, not UTC, to stay in sync with the dispatch page.- One API base:
REACT_APP_DOORMILE_URL(api.doormile.com/api/v1), viautils/doormileAxios.js. Seesrc/pages/api/CLAUDE.mdfor the full migration-era rule sheet, including what the new backend has no equivalent for. - The
applocationid"zone" concept no longer exists as a filterable resource on the new API — it was a jupiter-only concept.LocationAutocomplete/fetchAppLocationsnow derive a picker list fromGET /admin/hubsinstead of a real zones endpoint; most list endpoints on the new backend don't accept anapplocationidfilter at all. Don't assume it's wired through end-to-end on a page you haven't checked. rolegating useslocalStorage.getItem('roleid'). Some buttons are conditionally rendered based on it. Do not hide UI based on string equality alone — check existing patterns.- Skeleton vs Loader vs LoaderWithImage — these are different. Skeleton = per-row placeholder, Loader = full-screen backdrop blocking interaction, LoaderWithImage = inline branded spinner. Don't swap them.
- No Maps API key exists in this project anymore. Address search/geocoding goes through
AddressAutocomplete.js(Nominatim) and routing through OSRM — both free, no key. Do not addprocess.env.REACT_APP_GOOGLE_MAPS_API_KEYor any Google Maps script/dependency back in. Tenants.jshas known dangling references (setClientstatus,setState,setSuburb,setTenanatPricing,<Collapse in={open}>) inherited from legacy code. They are tolerated. Do not "fix" them as a drive-by — they are out of scope and removing them risks breaking the row collapse contents.
12. Communication style for changes
- For UI changes, the user runs the dev server at
http://localhost:3000. After editing a page, end with one short sentence telling them which route to reload (e.g. "Reload http://localhost:3000/nearle/pricing to see it"). - Don't commit unless asked. The project has
package-lock.json+yarn.lockboth present — match the user's last commit's lockfile choice before suggestingnpm installvsyarn install. - Verify after edits with a quick JSX parse check (the user has
acornavailable innode_modules) — do not assume the build will pass just because Edit succeeded. - Mark
// removed/// unusedcomments as code smells — delete dead code instead of commenting it out, unless the user explicitly says "keep it commented for now".
13. Cross-references
- For the per-page API map and the architectural flow chart (which endpoint each page calls, what the optimisation pipeline does, FCM flow), invoke the project skill
nearlexpress-docs(.claude/skills/nearlexpress-docs/SKILL.md) rather than restating the content here. - For shared design patterns between pages, the sources of truth are
src/themes/astryx.js(the theme),src/themes/dt/status.js(lifecycle rendering),src/themes/dt/tokens.js(radii, shadows, raw hexes), andsrc/themes/dt/primitives.js(TableScroll,AccentAvatar). Import from them; don't redesign and don't re-declare locally.src/pages/nearle/hubs/hubs.jsis the reference for how they compose into a page (header → KPIs → tabs → filter → table).
Astryx v0.1.9 · 153 components
CLI: run every command as yarn dlx @astryxdesign/cli <cmd> (shown below as astryx ...).
SETUP (once, in your app entry e.g. main.tsx) — without these, components render unstyled: import "@astryxdesign/core/reset.css"; import "@astryxdesign/core/astryx.css";
WORKFLOW — discover, don't guess. Before writing UI:
astryx build "<idea>"— START HERE: returns a kit (closest [page] + [block]s + [component]s). No args = full playbook.astryx template <name> [--skeleton]— scaffold the [page]/[block]s it named, or study their layout. Templates are reference code.astryx component <Name>— props + examples for every component you use.
RULES:
- No — components do all layout/spacing. Full page → AppShell; sidebar nav → SideNav.
- Frame first: pick the shell (AppShell / Layout+LayoutPanel) and budget regions in px BEFORE writing content (
astryx docs layout). - Dense data = rows (Table, List/Item) edge-to-edge — never Card-wrapped list items. Card = dashboard widgets, galleries, settings groups only.
- Status → StatusDot/Token; Badge only for counts and enumerated states, never decoration.
- Custom styling: component props first; else style/className with tokens — var(--color-|--spacing-|--radius-*). No raw hex/px. (No StyleX/Tailwind compiler here — don't use xstyle/utility classes.)
- Tokens for every value (
astryx docs tokens). Brand/accent viaastryx theme— never override --color-* in :root. - SELF-CHECK before you finish: re-read the file and replace any raw / layout, imported .css/@apply, or hardcoded value (#hex, 16px) with the component or a token (var(--color-|--spacing-|…)). If unsure a component/prop exists, run
astryx component <Name>/astryx search "<thing>"; don't hand-roll CSS.
MORE CLI: search "" find any component / hook / doc / template / block component --list 153 components by category template --list page + block recipes docs color, elevation, icons, illustrations, internationalization, layout, migration, motion, principles, shape, spacing, styling, theme, tokens, typography swizzle eject component source for deep customization upgrade --apply run after any @astryxdesign/core bump