Milers rebrand, /doormile/home, and a set of console fixes
Two bodies of work that arrived in one working tree and are intertwined in
four files, so they commit together rather than pretending to a split the
diffs do not have.
TEAM WORK (pre-existing in the tree, uncommitted)
- "Riders" -> "Milers" across en.json and ~20 pages. Done carefully:
Deliveries.jsx checks BOTH prefixes ("Miler #" and "Rider #") so rows
written before the rename still render, and routes and query keys stay
rider*/riderssummary — renaming those would have broken bookmarks and
cache keys.
- MileTruth assistant rework: rename from "Doormile AI", HStack composer,
maximise/minimise/reset controls, open state persisted to localStorage.
- New /doormile/home landing page; / and /doormile now redirect there
instead of /doormile/dispatch. AdminLayout nav restructured with icons
and descriptions.
FIXES
Tab strip was unreachable (Deliveries: Delivered and Cancelled)
The pill variant is one non-wrapping inline-flex row. Given less width
than it needs, flex COMPRESSED it — 872px squeezed into 780px, truncating
labels inside the buttons — and with nothing scrollable no gesture could
recover the last two tabs. Four of six tab pages had each hand-rolled the
same wrapper; Deliveries and CompetitiveIntel had not. Fixed in the
component, so no page can forget it. Verified in a browser at the real
content width: 102px of scroll range, "Cancelled" reachable. 11 tests.
Home.jsx crash: Cannot read properties of null (reading 'flow')
Introduced by the change below, which made deriveVisualData return null.
All 12 .flow/.table dereferences are now behind visual?. gates.
Home.jsx presented fabricated figures as live operations
deriveVisualData keyword-matched the prompt and returned hardcoded values
for whole topics — revenue as a flat Rs 48,650, a workforce of 46 with 38
active, six named hubs, four booking numbers — ignoring the real result it
had been handed. Those branches sat ABOVE the one reading res.stats, so
the correct code was unreachable: asking about revenue could not return
the real number because a literal answered first.
Two were worse than wrong figures. The default branch returned a staffing
table for ANY unmatched question. And the no-answer path built a
sourceCalls entry claiming /admin/milers had been called, status
"complete", "46 milers scanned" — forging the provenance trail that exists
so an operator can check where a number came from. A fabricated figure is
a bug; a fabricated citation defeats the mechanism for catching one.
190 lines removed. Every branch now reads the result and returns null when
there is nothing to draw. No-answer says so; errors report as errors.
Found while verifying: 'Inactive'.includes('active') is true, so every hub
counted as active — carried from the original, whose own sample data
tripped it (six hubs, one Inactive, shown as 6/6). Now an explicit
vocabulary match, with unknown treated as down: a hub wrongly shown
offline gets investigated, one wrongly shown online hides an outage.
Home launcher tiles named pages that do not exist
Every tile now names its destination. Task Board -> Tripsheets,
Staff -> Milers, Availability -> Milers Summary (it pointed at the same
page as the tile beside it), Compliance -> Exceptions,
Invoices -> Bookings, Payroll -> Profitability,
AI Reports -> Orders Summary. Group headings: WORKFORCE SUITE -> FLEET,
FINANCE & CRM -> CLIENTS & REVENUE, AI TOOLS -> REPORTS & AI. "Invoices",
"Payroll" and "CRM" named systems Doormile does not run.
Four hardcoded badges removed (3, 38, 7, "New"). None was computed. A red
badge means "this many things need you", and one that never changes
teaches operators to ignore the real ones.
Navbar MileTruth control misaligned, hover dead
One cause: a 36px image (h-9 w-9 max-w-none) inside a 32px button. It
stood proud of the bell and avatar AND covered its own button, leaving
hover:bg-surface-sunken nowhere to render. Now 20px.
Assistant dock 30% -> 20%, and responsive
clamp(320px, 20vw, 460px) rather than max(): the cap is what makes a large
monitor work, since 20vw is 768px at 4K. Verified across seven screen
classes — 384px/20% at 1920, capped at 460px beyond 2300px, full-width
overlay at or below 900px. At 1024px the 320px floor wins (31%), because
20vw would be 205px and too narrow for the composer.
Suggestion chips took a third of the panel
.dai-suggestions was flex-direction:column, overriding the component's own
wrap="wrap", so four chips became four full rows. Row + wrap, trimmed
padding, ellipsis on the text span where text-overflow can act. Measured
at the 320px floor: 4 rows/177px -> 2 rows/84px, nothing clipped.
Pricing and Customers were unreachable on mobile
Moving them off the nav bar into the account dropdown removed them from
the phone entirely — the mobile sheet builds from NAV plus NAV_GROUPS and
they were in neither. Both stayed routed, so only a typed URL reached
them. Now one SETTINGS_NAV array that the dropdown and the sheet share.
calculateDrivingDistance was called and never imported
CreateOrder.jsx:371 — a ReferenceError on every customer-pickup order with
a pinned collection address, thrown synchronously inside a useEffect so
the .catch() on that chain could never see it. Neither the build nor the
lint config catches an unbound identifier in a .jsx file: eslint.config.js
spreads pluginJs.configs.recommended and then declares its own rules
object, which replaces the spread rules wholesale, so no-undef has never
run. A repo-wide sweep with it enabled found this was the only instance.
Pre-existing; found while auditing the coordinate changes.
Dead code and stale docs
ALL_DESTINATIONS (declared, never read; its comment claimed it was what
the mobile sheet renders). --dai-panel-width (declared twice, read
nowhere) and the max-width:1279px block that only set it. AdminLayout's
header comment said "three grouped menus (Fleet Ops, Reports, Settings)"
when there are two, and Settings is a section in the account menu — that
difference in routing is what caused the mobile gap above.
VERIFIED
878 tests across 26 suites, build clean, no new lint problems. The tab
strip, dock width, chip wrapping and navbar sizing were measured in a real
browser against the shipped stylesheet. deriveVisualData is module-private,
so it was extracted and driven through 16 logic checks.
NOT VERIFIED
/doormile/home has never been rendered in a browser, and the responsive
pass covers the assistant panel only — no page has been viewed at any
breakpoint. Both need a signed-in session. DataTable carries its own
overflow-x-auto and only two fixed widths above 390px exist in src/pages,
but that is grep, not eyes.
STILL OPEN
customerAppBookings.js lost its customerstatus/customerstage grouping in
49ee0c5 and has not been restored, so the Bookings tabs still read only the
operational status. The Status column (Bookings.jsx:98, :415) was never
stage-aware.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
|
||||
import PropTypes from 'prop-types';
|
||||
import { LuArrowUp } from 'react-icons/lu';
|
||||
import { LuSend } from 'react-icons/lu';
|
||||
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
@@ -111,7 +111,7 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
|
||||
return (
|
||||
<VStack className="dai-composer-wrap" gap={1} padding={0}>
|
||||
<VStack className="dai-composer" data-focused={isFocused} gap={1} padding={0}>
|
||||
<HStack className="dai-composer" data-focused={isFocused} gap={1} padding={0} vAlign="center">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
@@ -120,13 +120,9 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setIsFocused(true)}
|
||||
onBlur={() => setIsFocused(false)}
|
||||
placeholder={placeholder}
|
||||
aria-label="Ask Doormile AI about operations"
|
||||
placeholder={placeholder || 'Ask MileTruth anything...'}
|
||||
aria-label="Ask MileTruth about operations"
|
||||
/>
|
||||
{/* Both controls group on the RIGHT. `justify="between"` pushed the mic
|
||||
to the far left and the send button to the far right, leaving a
|
||||
wide dead gap between two things that do the same job — put a
|
||||
message in. Together they read as one action cluster. */}
|
||||
<HStack gap={1} padding={0} vAlign="center" justify="end">
|
||||
<ChatDictationButton dictation={dictation} size="sm" className="dai-mic" />
|
||||
<button
|
||||
@@ -136,10 +132,10 @@ const AIComposer = ({ value, onChange, onSubmit, isBusy, placeholder }) => {
|
||||
disabled={!canSend}
|
||||
aria-label={isBusy ? 'Waiting for the current answer' : 'Send message'}
|
||||
>
|
||||
<LuArrowUp size={16} strokeWidth={2.4} aria-hidden="true" />
|
||||
<LuSend size={15} aria-hidden="true" />
|
||||
</button>
|
||||
</HStack>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<Text className="dai-hint">
|
||||
{dictation.isListening ? 'Listening — sends automatically after a short pause' : 'Enter to send · Shift + Enter for a new line'}
|
||||
</Text>
|
||||
|
||||
@@ -43,7 +43,7 @@ const AssistantMessage = ({ message, onCopy, onSubmitForm, onCancelAction, onCho
|
||||
<HStack gap={1.5} padding={0} vAlign="center" justify="between">
|
||||
<HStack gap={1} padding={0} vAlign="center">
|
||||
<Spark size="sm" />
|
||||
<Text className="dai-msg-name">Doormile AI</Text>
|
||||
<Text className="dai-msg-name">MileTruth</Text>
|
||||
</HStack>
|
||||
<HStack className="dai-msg-actions" gap={0} padding={0}>
|
||||
<IconButton
|
||||
|
||||
@@ -4,8 +4,17 @@ import PropTypes from 'prop-types';
|
||||
import { useLocation } from 'react-router-dom';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import dayjs from 'dayjs';
|
||||
import { AiOutlineClose as CloseOutlined, AiOutlineMore as MoreOutlined } from 'react-icons/ai';
|
||||
import { LuChevronDown, LuPanelRightOpen, LuPanelRightClose, LuHistory, LuTrash2, LuMessageSquare } from 'react-icons/lu';
|
||||
import { AiOutlineMore as MoreOutlined } from 'react-icons/ai';
|
||||
import {
|
||||
LuChevronDown,
|
||||
LuHistory,
|
||||
LuTrash2,
|
||||
LuMessageSquare,
|
||||
LuRotateCcw,
|
||||
LuMaximize2,
|
||||
LuMinimize2,
|
||||
LuX
|
||||
} from 'react-icons/lu';
|
||||
|
||||
import { HStack } from '@astryxdesign/core/HStack';
|
||||
import { VStack } from '@astryxdesign/core/VStack';
|
||||
@@ -51,14 +60,17 @@ const CONVERSATIONS_KEY = 'doormileBotConversations';
|
||||
const CONVERSATIONS_LIMIT = 25;
|
||||
const PIN_THRESHOLD_PX = 48;
|
||||
|
||||
// Dock widths. Each is a percentage of the viewport with a pixel floor — 25vw
|
||||
// is only 320px on a 1280 laptop, and the floor catches anything narrower than
|
||||
// that, below which the panel stops being readable. Both values are also what
|
||||
// the page reserves beside the panel, so they must stay a single expression
|
||||
// (see the --dai-dock-width comment in DoormileAI.css for why a percentage
|
||||
// cannot be used here).
|
||||
const DOCK_NORMAL = 'max(300px, 25vw)';
|
||||
const DOCK_WIDE = 'max(340px, 30vw)';
|
||||
// Dock widths. Each is a viewport fraction with a pixel floor — 20vw is only
|
||||
// 256px on a 1280 laptop, and the floor catches anything narrower than that,
|
||||
// below which the composer and the thread stop being readable. Both values are
|
||||
// also what the page reserves beside the panel, so they must stay a single
|
||||
// expression (see the --dai-dock-width comment in DoormileAI.css for why a
|
||||
// percentage cannot be used here).
|
||||
// Must stay byte-identical to the --dai-dock-width default in DoormileAI.css:
|
||||
// the CSS value applies before this effect runs on first paint, and any
|
||||
// difference between the two shows as the page jumping sideways on load.
|
||||
const DOCK_NORMAL = 'clamp(320px, 20vw, 460px)';
|
||||
const DOCK_WIDE = 'clamp(420px, 30vw, 680px)';
|
||||
const WIDTH_KEY = 'doormileBotWidth';
|
||||
|
||||
// Seeded from the clock, not from 1.
|
||||
@@ -1378,7 +1390,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
? nextSteps.length > 0
|
||||
? nextSteps
|
||||
: pageContext.suggestions.slice(0, 4)
|
||||
: [...pageContext.suggestions, ...(pageContext.more || [])];
|
||||
: pageContext.suggestions.slice(0, 4);
|
||||
|
||||
// Few chips => the strip is fixed and always fully visible. Twenty => it may
|
||||
// shrink and scroll. See the [data-compact] rule in DoormileAI.css.
|
||||
@@ -1401,7 +1413,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
stays live and operable, so announcing it as a dialog would be a
|
||||
lie to exactly the users who cannot see that it is docked. */
|
||||
role="complementary"
|
||||
aria-label="Doormile AI — Operations Copilot"
|
||||
aria-label="MileTruth — Operations Copilot"
|
||||
tabIndex={-1}
|
||||
>
|
||||
{/* ---- header ---- */}
|
||||
@@ -1410,30 +1422,16 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<HStack gap={1.5} padding={0} vAlign="center">
|
||||
<Spark size="md" />
|
||||
<VStack gap={0} padding={0}>
|
||||
<Text className="dai-title">Doormile AI</Text>
|
||||
<Text className="dai-title">MileTruth</Text>
|
||||
<Text className="dai-subtitle">Operations Copilot</Text>
|
||||
</VStack>
|
||||
</HStack>
|
||||
<HStack gap={1} padding={0} vAlign="center">
|
||||
<LiveIndicator />
|
||||
{/* No `size` prop on the icon — deliberately.
|
||||
|
||||
Its two siblings here are Ant Design icons, which render at
|
||||
1em and so inherit the button's 16px. Pinning this one to 15
|
||||
made it a pixel smaller than the ⋯ and × beside it, which is
|
||||
exactly the kind of one-pixel mismatch that reads as "off"
|
||||
without being obviously wrong. Left unset, react-icons also
|
||||
defaults to 1em and all three track the button together.
|
||||
|
||||
Panel icons rather than chevrons: a bare chevron next to a
|
||||
close button is ambiguous (collapse? navigate? close?),
|
||||
whereas these draw the side panel itself getting wider or
|
||||
narrower, which is literally what the control does. */}
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label={view === 'history' ? 'Back to the conversation' : 'Conversation history'}
|
||||
tooltip={view === 'history' ? 'Back to the conversation' : 'History'}
|
||||
label={view === 'history' ? 'Back to conversation' : 'Conversation history'}
|
||||
tooltip={view === 'history' ? 'Back to conversation' : 'History'}
|
||||
aria-pressed={view === 'history'}
|
||||
icon={view === 'history' ? <LuMessageSquare /> : <LuHistory />}
|
||||
onClick={() => setView((v) => (v === 'history' ? 'chat' : 'history'))}
|
||||
@@ -1441,39 +1439,32 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label={isWide ? 'Narrow the assistant' : 'Widen the assistant'}
|
||||
tooltip={isWide ? 'Narrow to 25%' : 'Widen to 30%'}
|
||||
aria-pressed={isWide}
|
||||
icon={isWide ? <LuPanelRightClose /> : <LuPanelRightOpen />}
|
||||
onClick={() => setIsWide((v) => !v)}
|
||||
label="New conversation"
|
||||
tooltip="New conversation"
|
||||
icon={<LuRotateCcw />}
|
||||
onClick={newConversation}
|
||||
/>
|
||||
<DropdownMenu
|
||||
hasChevron={false}
|
||||
button={{ label: 'Conversation options', icon: <MoreOutlined />, isIconOnly: true, variant: 'ghost', size: 'sm' }}
|
||||
items={[
|
||||
// These were both wired to clearConversation, so the safe
|
||||
// option and the destructive one did the same thing. Now
|
||||
// "New" keeps the thread and "Delete" says that it does not.
|
||||
{ label: 'New conversation', onClick: newConversation },
|
||||
{ label: 'Conversation history', onClick: () => setView('history') },
|
||||
{ label: 'Delete this conversation', onClick: clearConversation }
|
||||
]}
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
|
||||
tooltip={isWide ? 'Standard width (20%)' : 'Expand width (30%)'}
|
||||
aria-pressed={isWide}
|
||||
icon={isWide ? <LuMinimize2 /> : <LuMaximize2 />}
|
||||
onClick={() => setIsWide((v) => !v)}
|
||||
/>
|
||||
<IconButton
|
||||
size="sm"
|
||||
variant="ghost"
|
||||
label="Close assistant"
|
||||
tooltip="Close assistant"
|
||||
icon={<CloseOutlined />}
|
||||
icon={<LuX />}
|
||||
onClick={onClose}
|
||||
/>
|
||||
</HStack>
|
||||
</HStack>
|
||||
</VStack>
|
||||
|
||||
{/* ---- page context ---- */}
|
||||
<Text className="dai-context">{pageContext.label} · Today · All locations</Text>
|
||||
|
||||
{/* ---- conversation / welcome ---- */}
|
||||
<VStack className="dai-scroll-wrap" gap={0} padding={0}>
|
||||
<VStack ref={scrollRef} className="dai-scroll" gap={0} padding={0} onScroll={handleScroll}>
|
||||
@@ -1525,7 +1516,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
<VStack className="dai-msg" gap={1.5} padding={0}>
|
||||
<HStack gap={1} padding={0} vAlign="center">
|
||||
<Spark size="sm" />
|
||||
<Text className="dai-msg-name">Doormile AI</Text>
|
||||
<Text className="dai-msg-name">MileTruth</Text>
|
||||
</HStack>
|
||||
<TypingIndicator />
|
||||
</VStack>
|
||||
@@ -1563,7 +1554,7 @@ const AIPanel = ({ isOpen, onClose }) => {
|
||||
)}
|
||||
|
||||
{/* ---- composer ---- */}
|
||||
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, riders, hubs…" />
|
||||
<AIComposer value={value} onChange={setValue} onSubmit={ask} isBusy={isSending} placeholder="Ask about orders, milers, hubs…" />
|
||||
</VStack>
|
||||
</>,
|
||||
document.body
|
||||
|
||||
@@ -38,7 +38,7 @@ LiveIndicator.propTypes = { label: PropTypes.string };
|
||||
// Thinking state. Three dots plus two shimmer bars — enough to read as
|
||||
// "working", not enough to be a light show.
|
||||
export const TypingIndicator = () => (
|
||||
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="Doormile AI is thinking">
|
||||
<VStack gap={1.5} padding={0} aria-live="polite" aria-label="MileTruth is thinking">
|
||||
<HStack className="dai-typing" padding={0} gap={0} aria-hidden="true">
|
||||
<span />
|
||||
<span />
|
||||
|
||||
@@ -77,7 +77,7 @@ const AIWelcome = () => {
|
||||
<Text className="dai-welcome-lead">How can I help with today’s operations?</Text>
|
||||
</VStack>
|
||||
<Text className="dai-welcome-note">
|
||||
I read live orders, riders, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
||||
I read live orders, milers, vehicles and hubs. Every number comes from a real call — I never estimate one.
|
||||
</Text>
|
||||
</VStack>
|
||||
);
|
||||
|
||||
@@ -23,13 +23,13 @@ const DoormileAITrigger = () => {
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip content="Doormile AI">
|
||||
<Tooltip content="MileTruth">
|
||||
<button
|
||||
ref={buttonRef}
|
||||
type="button"
|
||||
className="dai-root dai-trigger"
|
||||
data-active={isOpen}
|
||||
aria-label="Open Doormile AI"
|
||||
aria-label="Open MileTruth"
|
||||
aria-expanded={isOpen}
|
||||
onClick={() => (isOpen ? close() : setIsOpen(true))}
|
||||
>
|
||||
|
||||
Reference in New Issue
Block a user