Skip to content

MAIN - #17280

Open
niteeshkanna-sh wants to merge 398 commits into
react:mainfrom
niteeshkanna-sh:main
Open

MAIN#17280
niteeshkanna-sh wants to merge 398 commits into
react:mainfrom
niteeshkanna-sh:main

Conversation

@niteeshkanna-sh

Copy link
Copy Markdown

No description provided.

@meta-cla

meta-cla Bot commented Aug 27, 2026

Copy link
Copy Markdown

Hi @niteeshkanna-sh!

Thank you for your pull request and welcome to our community.

Action Required

In order to merge any pull request (code, docs, etc.), we require contributors to sign our Contributor License Agreement, and we don't seem to have one on file for you.

Process

In order for us to review and merge your suggested changes, please sign at https://code.facebook.com/cla. If you are contributing on behalf of someone else (eg your employer), the individual CLA may not be sufficient and your employer may need to sign the corporate CLA.

Once the CLA is signed, our tooling will perform checks and validations. Afterwards, the pull request will be tagged with CLA signed. The tagging process may take up to 1 hour after signing. Please give it that time before contacting us about it.

If you have received this in error or have any questions, please contact us at cla@meta.com. Thanks!

claude and others added 29 commits September 18, 2026 09:55
…rives

The three highlights were full-width rows, each split into a description and
a bulleted panel. Three of them ran to about two and a half screens, and every
row left a column of empty navy beside whichever half had less to say -- which
is what the gap in the screenshot is. Side by side they are one screen, and
three offers next to each other can be compared, which is the only reason to
put three offers next to each other.

The inner panel goes with it: a bordered box inside a bordered box was what
made the emptiness look deliberate. The points sit under a gold hairline in
the same card now, and the button is pushed down with mt-auto so all three
line up however long the copy above them runs. They stagger in on scroll and
lift on hover -- .card-lift as the light cards already use, with a gold glow
instead of a drop shadow, because a dark shadow under a dark card is nothing.

--- and then the speed.

The page was served as an empty <div id="root"> with 365 KB of JavaScript
beside it. Nothing at all was on screen until that had been fetched, parsed
and run: three seconds on a mid-range phone on a rural connection, which is
most of this site's visitors, and everything a page is scored on happens
inside that window. Three changes, largest first.

The site is rendered to HTML at build time. vite build --ssr produces a server
bundle, and prerender-seo.mjs -- which already wrote each route's title and
description -- now also writes its markup. The script still loads and takes
over; it is what makes the fleet list and the enquiry form work. But the words
and the layout are in the file the server sends, so the page is readable while
it arrives. prerenderToNodeStream rather than renderToString, because the
routes suspend now and renderToString would bake the fallback into every page
but the home page.

main.tsx keeps createRoot rather than switching to hydrateRoot, deliberately.
The footer prints the current year and the date picker opens on today's month,
so a page built yesterday and read today would not match and React would tear
it down anyway, loudly. createRoot replaces the markup in the same frame it
paints, which costs one extra render and cannot be wrong.

Every route but the home page is now fetched when it is asked for. One bundle
held all fourteen, so someone landing on the home page downloaded the tariff
table, the blog, the places list and the enquiry form before the first word
appeared. Home stays eager: it is where most people arrive, and splitting it
would add a round trip before the thing they came for.

Poppins is served from this site instead of Google. A third-party stylesheet
is the slowest thing on a cold load -- the browser cannot paint until it has
it, and getting it costs a DNS lookup, a handshake and a round trip, then all
three again to fonts.gstatic.com for the files it names. The rupee sign was
its own tax: Google puts U+20B9 in the devanagari subset, so every page with a
price pulled 38 KB of Hindi per weight. Subset to that one glyph it is 570
bytes, and the four weights together are 2.3 KB instead of 155 KB.

The hero photograph is marked as the priority fetch, since it is what Google
measures this page's loading time by.

Measured on a throttled phone -- 390px, 1.6 Mbps, 150 ms latency, 4x CPU:

              first paint        largest paint
  /           3124 -> 1436 ms    3452 -> 2032 ms
  /cars       2784 -> 1108 ms    3048 -> 1440 ms
  /tariff     2760 -> 1112 ms    3024 -> 1448 ms

All thirteen routes checked after: no console errors, one header and one
footer each, no sideways scrolling. An upload in the panel still replaces the
committed photograph once the panel answers, and the committed one is what
goes into the HTML -- which is the right way round, since a crawler should not
wait on a request to /admin.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Three offers across, and a page that is readable before the script arrives
Every image on this site is stored above the document root and streamed back
by a small PHP reader, so the address a visitor sees is one this code makes
up. It made up photo.php?f=v12-9f3a1c4b7d2e5a60.jpg. On a business whose
traffic comes from searching for "self drive car rental Nagercoil", the file
name is one of the few things Google has to go on about a picture besides its
alt text and the page around it -- and that one said nothing at all.

They are named for their subject now:

  /admin/images/self-drive-car-rental-nagercoil-9f3a1c4b7d2e5a60.webp
  /admin/photos/maruti-swift-self-drive-car-rental-nagercoil-<hex>.jpg
  /admin/place-photos/vattakottai-fort-places-to-visit-kanyakumari-<hex>.jpg

The words come from the site, never from the uploader. Each site-image slot
has a phrase in SITE_IMAGE_KEYWORDS -- what is in the picture, not where it
goes, because "Cars page banner" is of no use to anyone searching. A vehicle's
photograph takes the car's own make and model; a place's takes the place's
name. The random suffix stays: it is what makes a replaced picture a new
address, which is what lets the reader cache for a year.

A path rather than a query string, so the words are in the address and not
only in a parameter. admin/.htaccess routes /admin/images/, /admin/photos/ and
/admin/place-photos/ to the three readers, which still answer ?f= as well --
every address stored before today keeps working, and so does every file name,
because the one pattern that replaces the three old ones matches v12-<hex>,
p3-<hex> and bcars-hero-<hex> too. No migration, nothing to re-upload.

The pattern is the security, not the rewrite. Lowercase words joined by single
hyphens, sixteen hex, one extension: there is no way to write `..` or a
separator in that. Checked both ways round -- ten kinds of hostile input
reduced to harmless words (../../nitesha-config/config becomes
nitesha-config-config), and twelve shapes that must never be answered,
including name-<hex>.jpg.php and a traversal appended to a valid name.

Two things found while testing it.

admin/src/ had no deny rule, so its files were reachable over HTTP. Nothing
leaks today -- every one of them only defines functions, so a request returns
an empty 200 -- but that is a property of each file continuing to be written
that way rather than a guarantee, and sql/ and tools/ were already denied.

And the readers now answer a conditional request with 304 instead of the file.
These addresses change whenever the picture does, so the year-long cache was
already right; what was missing was the ETag that lets a returning visitor
spend a few hundred bytes instead of a few hundred kilobytes.

Alt text, which is the other half of this. The six service cards and the three
steps passed none, so an uploaded photograph of a wedding car was announced to
nobody and read by nothing. They pass the card's own title now. The drawings
stay decorative, which is still correct -- SectionArt already makes that call.

The site check tells the two new failure modes apart. If a picture will not
load at its new address it tries the old one: if that works, the file is fine
and the rewrite is not, and the page says so and names the remedy rather than
reporting a missing picture.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Name every uploaded picture after what is in it
Two things: the site got a lot faster, and the block of structured data every
page carries now describes an actual business rather than a name and a phone
number.

--- speed

Nothing was compressed. That did not matter much while every page was an empty
<div id="root">; it matters a great deal now that each one carries its own
markup, because the home page went from 3 KB to 74 KB in the process. Gzipped
it is 13 KB. Brotli first where the host has it, gzip behind it, and images
and fonts deliberately left alone -- WebP, AVIF, JPEG and WOFF2 are already
compressed and running them through again spends CPU to make the file very
slightly larger.

Measured on a throttled phone -- 390px, 1.6 Mbps, 150 ms latency, 4x CPU:

              first paint       largest paint
  /           1484 -> 692 ms    2124 -> 1344 ms
  /cars       1108 -> 652 ms    1356 ->  908 ms
  /tariff     1108 -> 640 ms    1380 ->  916 ms

Against where this started two changes ago, the home page's first paint has
gone from 3124 ms to 692 ms.

One thing removed rather than added. The first version of this preloaded each
page's banner photograph by hand, and that was a second download of a file the
page already had: React emits a preload for an image marked fetchPriority=high
as it renders, with the exact URL the <img> uses, and mine used the absolute
form of the same address. Found by reading the built HTML rather than by
trusting it.

--- what the structured data says

It said the name, a phone number, a locality and a district. It now says:

  the postal address, including the postcode, read out of the panel's own
    footer fields rather than typed a second time for the crawler -- the two
    disagreeing is exactly how a business loses Google's trust in both;
  opening hours, from a new field in the panel, in the form schema.org wants,
    shown in the footer as well when it is filled in;
  every town served as its own City, not just "Kanyakumari district". Someone
    searching for a car in Marthandam is searching for a town, and a district
    named on its own does not say the town is covered. Twelve of them;
  the social profiles, as sameAs, which is how a page claims an account rather
    than merely linking to one. Only the ones that are filled in;
  a stable @id, so thirteen pages describe one business rather than thirteen
    businesses that share a name;
  hasMap, pointing at the same place the footer's map does.

serviceArea is gone: areaServed now says the same thing town by town, and two
properties describing one thing with different precision is a pair to keep in
step for no gain.

Every inner page also carries a BreadcrumbList, so a result prints
"niteshacars.in > Wedding car rental" instead of a bare URL. The leaf is the
title cut at the business name or the place, because a breadcrumb is read at a
glance and the full title is not.

robots.txt stops sending crawlers into the panel -- a login page can only ever
rank for the business's own name and disappoint whoever clicks it -- while
still allowing the three image readers, because a photograph that cannot be
fetched cannot be in image search.

Checked: all thirteen routes render clean, the structured data parses on every
page, and exactly one preload for the banner rather than two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Compress what is served, and tell Google what this business is
Somebody in Marthandam looking for a car does not search for "Kanyakumari
district" -- they search for Marthandam. The site named twelve towns in a row
of chips on the home page and competed properly for none of them, because a
page that mentions a town in passing has nothing to say about it.

Twelve town pages now, and a hub above them at /car-rental. Each one says what
that town is, what the roads around it are like, what people actually hire
there, and how far the drives worth making from it are.

The risk in doing this is the opposite failure, and it is worse than not doing
it: twelve pages that are one page with the name swapped. Google calls those
doorway pages and treats them as a reason to trust the whole site less. So
every page is written from its own entry in towns.json rather than generated
from a template with a placeholder in it -- Colachel is about a fishing
harbour and a battle in 1741, Marthandam about the rubber belt and the airport
run, Padmanabhapuram about a palace that shuts for a long lunch and catches
people out. Measured rather than assumed: the two most similar pages share 63%
of their vocabulary, and most of that is the header, the nav and the footer
that every page of the site carries.

Distances are approximate road distances and are in the data file, so they are
one edit each rather than a search through markup. They want checking against
what the owner actually drives -- that is the one part of this I cannot verify
from here, and it is flagged rather than left to be discovered.

Structure, because a set of pages nothing links to is a set of orphans:

  the town names on the home page are links now rather than chips that do
    nothing -- that section was the obvious place and it led nowhere;
  every town page links to the other eleven, so a crawler that finds one finds
    all of them;
  "Where we deliver" is in the footer, which puts the hub on every page of the
    site rather than only on the home page;
  all thirteen are in the sitemap, derived from towns.json by the same module
    the app reads, so adding a town cannot produce a page the sitemap does not
    list.

Each town page also carries a Service node naming that one town, pointing at
the business's @id rather than repeating it, and a three-step breadcrumb --
Home > Where we deliver > Marthandam. The leaf was "Self Drive Car Rental" on
all twelve at first: the rule that shortens a title by cutting at " in " threw
away the only word that distinguished them.

An unknown slug renders the 404 page rather than an empty frame with a name in
it, which is what a route matching anything would otherwise turn every typo
into.

readablePhone moved out of the footer into lib/phone.ts. The town pages print
the number too, and the second copy of a formatter is the one that goes out of
step with the first.

Checked: all twenty-six routes render with one h1, one header and one footer,
no sideways scrolling at 390px, and no console errors.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Five boxes in the booking screens take a file: Payment Screenshot / Receipt,
Deposit Proof, Refund Proof, Pickup Photos, Return Photos. They have been
there since those screens were built and were wired to nothing at all. No code
read the input, there was no table to put a file in, and nothing anywhere
displayed one. Choosing a screenshot and pressing Save recorded the payment
and dropped the file on the floor, silently -- which from the outside is
indistinguishable from it having worked.

Three things were missing and all three are here now: a preview so you can see
what you picked, the upload itself, and somewhere on the booking for them to
appear afterwards.

  booking_files, one row per file. kind says which box it came from, and
    ref_id ties a payment screenshot to the payment it proves rather than to
    the booking in general -- four payments would otherwise leave four
    screenshots in a heap nobody can match up;
  api/booking-files.php takes several files in one request, because the pickup
    and return boxes accept several and five round trips over a phone
    connection is how some of them go missing. A file refused for being the
    wrong sort is reported without losing the others;
  the detail screen shows each kind under the section it belongs to, click to
    open full size, with a remove button on the corner of each.

The upload runs after the record it belongs to is saved, not with it. A
booking_files row points at a booking, and it also means a refused image never
costs someone the payment they just typed in: that is already saved, and the
file problem is reported on its own.

These are not like the other images on this site, and the reader is where that
shows. brand.php and photo.php are deliberately public -- a vehicle photograph
is an advertisement. A payment screenshot carries a customer's name, their
bank and an amount; a pickup photograph shows a registration plate. So
booking-file.php requires a signed-in user, sends Cache-Control: private,
no-store so nothing in the middle keeps a copy, and the names are random
rather than descriptive -- a file name should not read "advance-payment-10000".
A random name is not access control; the session is.

Verified rather than assumed. Signed out, the reader answers 302 and zero
bytes. Signed in, the image and no more. Signed in with a name that is not
ours -- a traversal, a .php, a .jpg.php, a short hex, an uppercase one -- 404
every time. In the panel: five inputs wired, two files chosen give two
previews with the right names, reopening the modal clears them, the detail
screen groups by kind and renders nothing for a kind with no files, and a
caption containing <img src=x onerror=...> comes out as text.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Keep the screenshots the panel has always asked for
Thirty-nine figures changed. They were written from recollection and flagged
as such; these are checked against the towns' own Wikipedia articles where
those state a distance, and against the road figures the travel sites agree on
where they do not.

Two were badly wrong rather than slightly off:

  Kuzhithurai is 26 km from Nagercoil, not 35;
  Boothapandi to Tirunelveli is about 70 km, not 60 -- Nagercoil to Tirunelveli
    is 81 km by road, and Boothapandi is only 11 km up that road.

Azhagiapandipuram was out by nearly half: 15 km north of Nagercoil rather than
22. Colachel is 20 km, not 27. Marthandam is 26, not 30. Thuckalay 16, not 20.
Eraniel is further than stated rather than nearer -- 20 km, not 17.

The airport runs were both overstated, which on a page about hiring a car for
exactly that run is the number people would have checked: Marthandam to
Trivandrum airport is about 45 km rather than 55, and Kuzhithurai about 42
rather than 48.

Smaller corrections follow from the corrected bases: a trip between two towns
cannot be longer than going through Nagercoil when Nagercoil is on the way, and
several were. Marunthuvazh Malai is 11 km from Nagercoil and 5 from Suchindram,
both verified, and both were out.

One sentence had to go with them. Kuzhithurai's page opened "closer to
Thiruvananthapuram than to Nagercoil", which the corrected figures make false
-- 26 km against 42. A number that is wrong is checkable; a sentence that
sounds like local knowledge and is wrong is worse, because nobody checks it.
The point it was making, that the work up there points north, is made without
the claim.

Two notes were rewritten for the same reason: "the long way round the coast"
for a drive now routed through Nagercoil, and "under an hour and a half" for a
42 km run that takes about an hour.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Check every distance on the town pages, and fix the wrong ones
An audit of the three panel pages -- every tab walked, the DOM checked for the
faults that make a control lie about what it does -- and the things it found.

Twenty-two duplicate element ids on Website content. Each section posts its own
form, so "heading" in one and "heading" in another are different fields with
the same name, which is right for the form and wrong for the document. The
consequence was not theoretical: <label for="f_f_heading_"> finds whichever
element the browser reaches first, so clicking the label above one section's
Heading box put the cursor in a different section's. Ids carry the section now.
Checked by clicking every label that reads "Heading" and seeing where the
focus lands: seven sections, seven correct fields.

A booking whose total did not match the rental amount above it. The screen said
Rental Amount 5,000 and Rental Amount Due 6,542 with nothing on it accounting
for the 1,542, because extra KM is charged in the total and shown three
sections further down. The Payments block adds up in the open now -- the
agreed rental, the extra KM with the count, any other charges or discount, and
then the amount due. The field at the top is "Agreed Rental", because two
things called Rental Amount that differ is the confusion itself.

A balance of -3,458. The minus is easy to miss on a phone and reads as money
owed when it is money to give back. It says "₹3,458 to return" now, and
"₹3,458 due" the other way, and "settled" at zero.

install.php and session-check.php were reachable over HTTP. Neither is a way
in -- the installer refuses to run once an account exists -- but both print
server paths, the PHP build and the cookie settings, which is nobody's
business. Denied in admin/.htaccess rather than deleted, because deleting them
is not the fix it sounds like: this host publishes by cloning the repository
into the web root, so a file removed by hand is back on the next deploy. The
rule says which two lines to comment out to install on a new host. That
correction is mine: I asked for them to be deleted from the server several
times, and it would not have held.

The panel's own thumbnails fall back to the older address. They go through
/admin/images/<name> now, which needs the rewrite rule, and the panel is
exactly where someone looks to see whether an upload worked -- a thumbnail
that depends on a server setting is the worst place for one to be missing. If
the new address fails, the image and the crop window both retry brand.php?f=
once.

The enquiry search box had a placeholder and no label. A placeholder vanishes
the moment anyone types and a screen reader need not announce it.

Also checked and left alone: every PHP file and every panel script parses, the
money tests pass 35 of 35, and the two remaining "unlabelled" fields the audit
flagged are wrapped in a label element, which the check does not see.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Clear what was actually wrong in the panel
Three things asked for, and one found on the way that was quietly wrong.

--- somebody else's car

The business does two things and the schema described one. Most bookings are
our own car, where the whole rental is ours. Some are a car sourced from
another owner for a customer, and there the rental is mostly passed on and
what the business earns is a commission.

Recording those the same way overstated revenue by the owner's share every
time -- the kind of error that only shows up when the money does not match the
bank.

A car now says whose it is. Ours, or someone else's with the owner's name and
phone, and a "Temporary" tick for one brought in for a hire or two so it can
be found and retired rather than sitting in the fleet forever. Partner cars
carry a badge on the card, because whose car it is decides what a booking on
it earns and nobody should have to open a dialog to find out.

A booking on such a car asks for the commission -- and only such a booking
does. A box that does nothing on our own cars is a box somebody eventually
types into, and a commission entered there would quietly shrink the revenue
that booking reports. The detail screen then shows the three figures that
matter: what the customer pays, what we keep, what is payable to the owner.
The dashboard totals both.

--- extra KM was counted twice

Found while doing that. b.total already includes the extra-KM charge, and Net
Revenue then added extraKmRevenue on top of it, so every booking that ran over
its allowance was counted twice for the overage -- on the dashboard and in the
Revenue Report. The comment above it claimed the opposite.

The extra KM comes out of the rental figure now, so the two cards add up
rather than overlap, and both sums work from what the business earns rather
than from what the customer pays.

--- deleting a cancelled booking

Only cancelled ones, and only once no money is attached. A cancelled booking
still holding a payment or a deposit is not finished -- it is a refund waiting
to be made, and deleting it would take the record of the money owed with it.
The endpoint refuses and says how much is holding it.

Cancelling stays the ordinary ending. This is for the ones that should never
have existed: a test, a duplicate, a booking taken against the wrong car. The
charges, readings, attachments and the files on disk go with it; the audit
entry stays, so what a booking said is gone but that it existed and who
removed it is still readable.

--- part payments

These already worked -- Advance, Balance, Additional, Extra KM, as many as the
booking needs -- so what was missing was not the recording but the expecting.
A monthly hire paid half now and the rest in a few days had no date on it, and
the second half was remembered by whoever took the booking and nobody else.
There is an optional "Balance due by" on a booking now, shown beside the
balance while anything is still owed.

Checked: nineteen browser assertions over the owner fields, the commission
box, the brokered booking's figures, when Delete is offered, and the revenue
sums; fifteen more over the split itself, including a database that has not
run the migration yet, where every car reads as ours and every total is what
it was. The money tests still pass 35 of 35.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Commission on other owners' cars, deletable cancellations, part payments
Most of the nine sections asked for were already here -- payment history with
proofs, deposit collection and refund with deductions, pickup and return
readings, extra-KM maths, a seven-stage lifecycle. What was missing was the
evidence: the licence that was checked, the state the car left in, what came
back damaged, and what was charged for it. Those are what a dispute turns on,
and they were on somebody's phone or nowhere.

--- the customer

A WhatsApp number, because the one that answers a call is often not the one
that answers a message and WhatsApp is how this business talks. A licence
expiry, because a licence that runs out mid-hire is the owner's liability and
worth knowing before the keys move. An ID number. A customer type -- new,
returning, corporate. An estimated KM, which is useful before a hire for
quoting and after it for knowing whose estimates to trust.

--- documents

Driving licence, Aadhaar or ID, passport, and anything else. PDF as well as
photographs, because a licence usually arrives as a PDF -- and a PDF is
recognised by its first five bytes rather than by what the file was called, for
the same reason the images are.

Against the customer rather than the booking. A returning customer's licence is
the same licence, and asking for it again every hire is how a file ends up
attached to three bookings and missing from the fourth. Uploading a licence
replaces the one on file rather than stacking a fourth copy nobody can pick
between; "other" is the exception, being where everything else goes.

An expiry that has passed is marked, in red, on the booking.

These are the most private things the system holds -- a licence carries a name,
an address, a date of birth and a photograph -- so the reader requires a
signed-in user, sends no-store, and the file names carry nothing. Signed out it
answers 302 and zero bytes; a traversal, a .php and a .pdf.php all answer 404.

--- the handover check

Thirteen items on both legs: exterior, interior, tyres, spare, tools, papers in
the car, AC, lights, indicators, horn, mirrors, wipers, toolkit. Stored as JSON
on the reading rather than as thirteen columns, because the list changes -- a
business that starts hiring bikes wants a different one -- and a schema change
per item is how a checklist stops being maintained.

Unticked is stored as false rather than left out. "Not checked" and "checked
and wrong" are different things and only the first is what a blank should mean.

--- charges at return

Cleaning, fuel, late return, other -- each its own line with its own amount and
note, and each feeding the booking's total. Itemised rather than folded into
other_charges, because "₹1,150 of other charges" is not something a customer
accepts and not something anyone can explain a month later. An "other" charge
with no note is refused, and refusing it does not take the fuel charge down
with it.

Raised after the reading is saved, not with it, so a rejected charge never
costs someone the reading they just took.

--- damage

What was damaged, what it is thought to cost, which leg it was noticed at,
notes and photographs. Deliberately not money in itself: an estimate is not a
charge. Some damage is billed, some is held back from the deposit, some is
absorbed -- and a record that assumed the first would make the other two wrong.
A tick on the return form raises the charge; leaving it unticked records the
damage and bills nothing.

--- the lifecycle

Not changed, and this is the one place I did not do as asked. The statuses
already run Enquiry, Confirmed, Ready, Active, Returned, Completed, Cancelled.
The extra ones wanted -- Advance Paid, Payment Pending, Deposit Pending -- are
not stages a booking is moved through: they are facts about the money, true or
false on their own, and the panel already derives and shows them. Making them
statuses would mean somebody remembering to move a booking to "Payment
Pending", and a status nobody remembers to set is worse than none.

Checked: twenty-nine browser assertions over the new fields, the checklist
round trip, the charges going up line by line, the refusal that spares the
rest, and what the detail screen shows; the document validator against a real
multipart upload, where a PHP script renamed .pdf is refused and a genuine PDF
is not; the reader's session guard; and the earlier suites still passing -- 35
money tests, 15 commission-split tests, 19 feature tests.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The records a rental business is asked for when something goes wrong
A tick says "done". None of these lists are things that have been done: they
are what is included in a hire, what the monthly rate depends on, what to
bring, what we arrange for a wedding. A dot says "item", which is what they
are.

Thirty-one of them across the site. The three offer cards had a gold disc with
a check mark inside it, which was also the heaviest thing in a card whose job
is to be read; the hero, the monthly page, the NRI page and the service pages
had a bare gold check mark. All of them are the same bullet now, and it is the
same bullet the areas list and the town pages were already using -- there were
two bullets on this site and now there is one.

Sized and positioned in CSS rather than with a margin at each call site,
because the one thing a bullet has to get right is sitting on the optical
centre of the first line, and that depends on the line height beside it. The
hero's list is 14px text and the service pages' is 16px, and a fixed margin
cannot be right for both. lh gets it exactly, with an em fallback that is close
enough everywhere it is used. Measured: the dot's centre lands at 10px against
a first line whose centre is 10px.

The one check mark left is the "your enquiry was sent" mark on the contact
form. That one genuinely does mean done.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Four of the eleven points were already built, so this is the other seven --
and of those, the ones with a deadline.

--- already there, checked rather than rebuilt

Double booking is already refused before saving, naming the booking that
clashes and its dates. The activity log already exists: every change is
audited and the booking shows its own timeline. An enquiry already converts
into a booking carrying the customer's details, and can be rejected. Reports
already export to CSV.

--- what a car needs, and when

Insurance, the pollution certificate and the fitness certificate all expire,
and a service falls due by distance or by date. Every one of those was
somebody remembering. They get remembered until the day one does not, and that
day the car is off the road or is out with a customer uninsured.

A vehicle now carries all five dates, the next-service distance, and the
tracker: provider, device id and a link. Per car rather than per fleet,
because the trackers are from different suppliers. A link rather than an API
key -- what is wanted today is the provider's own page for this car, and a key
stored here would be a credential in a database that does not need one.

Service history is its own record, separate from expenses. Expenses say money
left the business; this says what was done to a car and when the next one is
due -- the same event from the workshop rather than the ledger, and only one
of them can answer "what needs servicing this month". Saving a service moves
the car's next-due markers and its odometer, so the alert has one source
rather than two that can disagree. A next-service reading below the one just
taken is refused: it would make the car overdue the moment it was serviced.

--- needs attention

The dashboard had ten figures and no answer to "what do I do this morning". A
number says where the business stands; this says what is about to go wrong,
and only one of the two has a deadline.

Six kinds, overdue first: a paper about to expire or already expired, a
service due by date or distance, a car due back today or already late, money
still to collect where a date was agreed or the car is back, a deposit still
held on a finished booking, and a new enquiry. Each row says which record and
why in one sentence, with a button that opens it.

Urgency is a colour and a word, because colour alone is not a message to
everyone. Nothing to do hides the panel entirely: an empty "Needs attention"
heading every morning is how people stop reading the one that is not empty.

--- who this phone number belongs to

The number was already matched when a booking was saved -- silently, and too
late. Whoever was typing had re-keyed the address and the licence, and a
different spelling quietly kept whichever came first.

It is asked when the number is finished now. The details arrive, their
previous hires arrive with them, and only empty boxes are filled: a name
somebody has just corrected is left alone, or the lookup becomes a thing to
fight rather than a thing that helps.

--- still to do

The period filter on the dashboard, the fleet calendar, and splitting the
booking detail into tabs. Those three are real work and none of them has a
deadline attached, which is why they are after these.

Checked: twenty-eight browser assertions over the vehicle fields, the alert
strip, the lookup and the service modal; ten more over the alert rules
themselves, including a blank date being ignored rather than read as year
zero; and the earlier suites still green.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
What needs doing this morning, and what keeps the fleet on the road
…d discounts

Seven of the twenty points in the latest list. The other thirteen were either
already built or are named in the PR as still to come.

Search everything (#17). One box on the dashboard over bookings, vehicles and
customers, each hit saying which it is. A phone number is matched on digits
alone, because the same number is typed with spaces as often as without.
Debounced at 250ms and silent under three characters: two letters match most
of the database, which is the same as matching nothing.

Quick actions (#24). The six things started most often, as buttons, instead of
finding the tab first.

End-of-day dashboard (#23). Pickups, returns, money due, deposits to refund,
cars in for servicing, new enquiries. The money figures are summed from the
same alerts shown underneath rather than queried again, so the number at the
top and the list under it cannot disagree.

Cancellation management (#6). Who cancelled and when, alongside the reason --
the question that comes up a month later is whether a fee was fair to keep,
and only a reason cannot answer it. A cancellation fee is raised as an
itemised charge rather than a column of its own, so it lands in the total, the
balance and the bill without a second set of arithmetic to drift from the
first. On a panel that has not run the migration the fee cannot be stored, and
that now says so instead of losing the money quietly.

Pricing (#7) and discounts (#8, partly). Discount and other-charges fields
that write to columns which existed and nothing filled, with what the customer
pays worked out live under them -- a discount typed into a box with no visible
effect is a discount entered twice. A discount larger than the bill is
rejected: unchecked it made the total negative and carried into revenue.

Expense categories (#9). Fifteen instead of six.

Referral source (#13). Ten sources on the booking form, closed set, blank by
default so it is never guessed.

Also here: rupees() moves from the alerts endpoint into money.php now that two
callers want it, with the minus sign ahead of the symbol rather than between
it and the digits, and eleven cases for it in the money suite.

Tested: 52 browser checks for the new screens, 46 money checks, the
cancellation arithmetic on its own, and the existing enquiry, ERP and fleet
suites, all passing. The panel audit reports no duplicate ids, no labels
pointing at nothing, no untyped form buttons and no overflow at 390px.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
One search box, quick actions, today's shape, cancellations and discounts
It counted everything unresolved -- New, Contacted and Pending -- so it lit up
the moment an enquiry arrived and stayed lit for days while somebody waited on
a callback. A number that is always on is a number nobody reads, and the
morning it says 3 looks like every other morning.

Read and resolved are different questions. Status says where an enquiry got
to; viewed_at says whether anybody has seen it. The badge now asks the second,
and opening an enquiry is what answers it -- the count clears itself the
moment the detail opens, without waiting for a status change.

The stamp is written once. Opening the same enquiry again does not move it,
so "when was this first picked up" stays answerable, and it records who
looked.

In the list, an unopened enquiry gets a gold edge and an "Unread" pill, so the
ones still needing a first look are findable and not only countable. Nothing
is marked until the column exists: without the migration every viewed_at reads
as null, which is indistinguishable from never opened, and the whole list
would have claimed to be unread.

The migration backfills everything already dealt with as seen. Without it the
badge would light up with the entire history the first time the panel loads
after the update -- exactly the noise this removes.

The three helpers live in src/enquiry.php rather than in the endpoint, so they
can be tested without a session or a router, and so the alerts endpoint can
use the same count later.

Tested: 18 browser checks and 17 server checks, plus the existing suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The Inquiries badge counts what nobody has opened
The bar said "Dashboard" whatever was on screen, so the heading and the
highlighted sidebar item disagreed about where you were. It now takes its
words from the sidebar itself rather than a second list of names, and the
browser tab says the same thing.

A bell beside it opens the reminders. Same list the dashboard shows under
"Needs attention" -- returns due, money still to collect, deposits still to
refund, papers about to expire, servicing, new enquiries -- but reachable from
every admin page, because a reminder that only exists on the dashboard is one
you do not see while you are doing anything else.

The number on it counts what is late or due, not the whole list. A new enquiry
is worth showing when the panel is open and is not something running late, and
counting it would put the bell permanently on.

Clicking a reminder opens the thing it is about. On the dashboard that is a
modal opening in place. From Website content or Places there is no modal to
open, so it carries which booking to the dashboard and opens it on arrival --
otherwise the click lands on a tab and leaves somebody scrolling for the
booking they had just pointed at. The link is taken back out of the address
bar once used, or a reload would reopen it long after it was dealt with.

The dashboard hands its list to the bell rather than letting it fetch its own.
One call to an endpoint that prices every open booking, and no way for the two
to tell different stories about what is owed.

Two things the tests caught. The panel was anchored to the bell and ran 53px
off the left edge of a phone, because the bell sits inside the bar with the
Admin tag beside it; it hangs from the bar now. And the count covered the icon
completely, so the control read as a red dot with no clue what it was for --
it sits on the corner now and takes a third of the icon rather than all of it.

Tested: 64 browser checks over the heading, the bell, the panel, the links and
both layouts, plus the existing suites.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
claude and others added 30 commits October 13, 2026 08:21
Every other "read more" link on the site grew for touch a week ago --
the fleet's "More about the Swift", the footer's "Get directions",
"Why hire from us". The blog index was missed, so its five "Read it"
links stayed 20px tall on a phone where the rest are 44.

One class, the same one the others use. 69x20 to 69x44.

Found by crawling all 43 routes at phone and desktop width. The three
links reported at desktop width are the deliberate scope of
.tap-target, which only grows under (hover: none) or 700px -- a
decision to treat this as a touch concern, not an oversight, and one
to revisit on its own rather than inside this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
On a phone the blog index asked for a twenty-pixel thumb
.tap-target and .tap-list only grew under (hover: none) or 700px, so
with a mouse the footer's "Get directions", the fleet's "More about
the Swift" and the blog's "Read it" stayed 20px tall -- a line of 14px
text with nothing around it. WCAG 2.5.8 asks 24px of a target under
any pointer, and any pointer includes a trackpad, a tremor, a laptop
on a train.

So 24px is the floor at every width now, and the touch sizes stay
where they were. That is the whole change: the three links go to 24
and the footer bar's to 28, which is four pixels and one pixel. The
alternative -- giving a mouse the 44px a thumb wants -- would have put
two centimetres of nothing under every footer link on every page to
satisfy a rule that asks for half of it.

Checked at both widths rather than reasoned about: 43 routes, desktop
and phone, no target under its floor, and the phone sizes unchanged at
40 and 44.

Three times the crawl was wrong before it was right, which is worth
recording because each one would have become a bad change:

  - It skipped every display:inline element as "in a sentence". A
    standalone nav link is inline too. Tightened to ask whether there
    is other prose around it.
  - Tightened, it then flagged the fleet sentence on the twelve town
    pages -- "Maruti Suzuki Swift, Toyota Glanza Automatic, ... and
    Toyota Innova" -- because each car name is wrapped in its own
    span, and a span holding one link has no other text in it. It now
    measures against the nearest block.
  - It still flagged "or call +91 63749 42976", on a threshold of
    eight characters of surrounding text. "or call" is seven. The
    threshold is gone: any prose in the block means the link is in a
    sentence, which is what 2.5.8 says and what padding that link
    would spoil.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
useSeo() keeps the tab title, description and canonical in step after a
client-side navigation, because navigation never fetches HTML again. To
know what each route is called it imported towns.json, models.json,
service-areas.json and posts.json and composed the list in the browser.

All four landed in the entry chunk. So every page of the site carried
every blog post's body, every car's luggage capacity and the prose for
twelve town pages -- about seventy kilobytes of object literal, run at
module top level before anything on screen could answer a tap -- to end
up with a path, a title and a sentence.

The list is composed at build time now, by scripts/seo-routes.mjs, from
exactly the same four files and the same four helpers. prerender-seo.mjs
reads what it writes instead of composing its own copy, so the titles
written into each page's HTML and the titles useSeo puts in the tab are
one list and cannot drift -- which is what the old comment in useSeo was
reaching for when it said a second list is a second list to forget.

  entry chunk   374,166 -> 314,667 bytes   (-59.5 KB, -16%)

Every page pays that, not just the home page.

What it did for blocking time, phone at CPU 4x and 1.6 Mbps, median of
eleven runs per arm, two builds measured the same way:

                   TBT    longest task   long tasks
  before           278ms      152ms           6
  after            256ms      144ms           5

Honest about the size of that: the runs scatter 208-388 before and
153-351 after, so a 22ms median is a real direction and not a precise
figure. The 59.5 KB is the certain part.

Verified the output did not move: across all 44 prerendered pages the
only lines that changed are the hashed asset filenames. Not one title or
description differs. A browser check confirms the other half -- clicking
Tariff, Blog and Contact retitles the tab and moves the canonical, which
is the whole job of the file this touched.

Two wrong turns on the way, both measured and both reverted rather than
shipped. content-visibility on the offscreen sections, which cut the
longest layout pass from 73ms to 41ms and did nothing for blocking time,
because the cost was never layout. And the scene drawings: stripping all
176 SVG nodes changed TBT by less than the noise. The page is not slow
because of what it draws. It was slow because of what it carried.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Every page shipped the blog to put a title in the tab
There was no document to send anybody. The panel takes PDFs in -- a
licence arrives as one -- and Reports has a Print/PDF button for the
owner's own figures, but nothing produced a page for the person who
booked the car.

A Confirmation button now sits beside Edit and Cancel on a booking.
It prints one page: who it is for, which car and its registration,
from when until when, what was agreed -- daily rate, KM included,
the rate beyond that, deposit -- and what the money is: rental, other
charges, discount, extra KM, extras, total, received, still to pay,
deposit held. Then a line about bringing the licence, and who to call.

Printed rather than generated as a file. The browser's own dialogue
saves a PDF on every machine and phone the panel is used from, with no
library to ship and nothing to install on Hostinger. It costs one
extra step -- choose Save as PDF -- and it works the first time on a
phone at a handover counter, which a server-side generator would not
have done without a dependency and a deploy.

The filename is what makes it sendable, and browsers take it from
document.title, so the title becomes the booking number for the length
of the dialogue and is put back after. A customer receives
NSC-2026-0024.pdf and knows what it is before opening it.

The letterhead reads what already exists rather than adding a third
copy of the phone number: name, phone and email from the website's
seo.json, the address from the panel's own Website content, which is
where the owner already edits it. Anything missing is left off instead
of guessed -- a confirmation with no email line is still a
confirmation; one with last year's phone number is a customer who
cannot reach anybody.

One defect found by reading the output rather than the code. The
discount printed as a plain 500, so the column did not reach the
total: 7,500 + 600 + 500 + 2,600 + 1,200 is 12,400, and the total said
11,400. The total was right -- it already had the discount off -- so
the line now prints it taken off, and a customer adding it up arrives
where the document says. That is the kind of thing a handover argument
is made of.

Verified by rendering real A4 PDFs from the shipped functions and the
shipped stylesheet, not from a copy written for the test: four cases
-- typical, completed with extra KM and extras and a discount, one
with no address or licence or registration at all, and one with names
long enough to wrap -- each one page, no errors. A test reads the
printed figures back and adds them to check they reach the printed
total. The panel is hidden while the sheet prints and the page is put
back whichever way the dialogue ends.

No database in this container, so the live panel could not be driven;
the money and crash-handling suites pass (53 checks).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
A booking confirmation the customer can be given
The confirmation had nothing to point at if a customer disputes the
fuel level or comes back a day late. It does now -- but the terms are
written in the panel, not here.

That is the whole design. These are the business's terms of hire, and
a rental agreement is not something for this file to invent and print
on a document a customer is handed. So Website content gains a
"Booking terms (printed on confirmations)" section: a heading and a
list, one line each. The note beside it says what is worth covering --
fuel, late return, what the deposit covers and when it comes back,
who pays a fine, what to do after an accident -- and leaves the words
to the person who has to stand behind them.

Written none, and the confirmation simply has no terms section. That
is the honest result of nobody having written any, and better than a
document carrying rules the business never agreed to. It ships empty.

They print numbered, in 9pt under the money, because "point four" is
how somebody refers to one on the phone.

Eight terms is what a real set runs to, and on the fullest booking --
extra KM, extras and a discount, so three more money lines -- that
spilled onto a second page. A confirmation sent on WhatsApp wants to
be one file and one page.

Tightening the margins would have bought about seventy pixels of the
eighty-one needed and left the page cramped. Instead "What was
agreed" now sits beside "The money" rather than above it: four lines
next to nine instead of four lines then nine. That recovered 154px,
and it reads better -- the rate and the KM allowance are the terms
the bill beside them is worked out from.

Six cases rendered as real A4 PDFs, each one page: typical, completed
with everything on it, bare with no optional field filled in, long
names that wrap, and both of those again with eight terms. Three more
check the empty state the panel ships in -- no terms written, the
field present but empty, and rows containing only spaces -- none of
which produces a terms section, a stray heading or an error. The
money column still adds up to the printed total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The logo. The letterhead was the business name typed out, on a
document meant to look like it came from somewhere. It now carries
the logo already uploaded in Website content, beside the name, capped
by height so a wide mark and a square one both sit on the same line.
No logo uploaded, no image -- the name still says who sent it.

The colours. It was black on white, which was the right instinct for
ink and the wrong one for a document a customer recognises. Navy
headings now, and gold twice: under the title and above the total.
Twice reads as the brand; everywhere would read as a template. All of
it is text and borders, never a background, because a browser printing
a page leaves backgrounds out unless somebody ticks a box.

The status line. The top right read "Completed" on a page headed
Booking confirmation, which raises a question it does not answer. The
status is the panel's business. It is gone -- except for Cancelled,
which is the one state that has to be said out loud, because handing
somebody a confirmation for a booking that is off is the only way
this document could mislead. That prints as a red box beside the
number.

The deposit. It was a line under "What was agreed" and a line again
in the money column, which reads as being charged for it twice. It is
not part of the total, so it is no longer a row in the column that
adds up to one: it is a note under it, saying how much is held and
that it comes back. One place, and the arithmetic is unambiguous.

A fifth thing the testing turned up rather than the eye. The logo is
written into the page and print() is called in the same breath, so
whether the image had arrived was a race -- and it lost often enough
in the harness to show up as a missing logo in three of eight PDFs.
Browsers usually wait. "Usually" is a confirmation handed over with
no logo on it, which is the kind of fault nobody reports and everybody
notices. printConfirmation now waits for it, for at most a second and
a half, and prints without it rather than not at all.

Eight cases rendered as real A4 PDFs, each one page, each checked for
whether the logo actually decoded: typical, completed with everything,
bare, long names, terms on two of them, cancelled, and one with no
logo uploaded. The money column still adds to the printed total and
the three empty-terms states still produce no terms section.

The harness had two faults of its own, both fixed: it named two files
the same so one case overwrote the other, and it extracted functions
by matching "function name(" which stopped finding this one the
moment it became async -- silently, which is the worst way.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The confirmation was a plain sheet of labelled rows. This is the layout
from the two screenshots: a navy band carrying the logo, the business
details and the hero photo, with the booking number set over a scrim on
the photo; a gold-ruled title; the booking as four cards in a 2x2 grid,
each headed by an icon on a gold disc; the terms below; and a closing
line. The footer bar marked in the second screenshot is left out.

Three things the design did not settle, decided here:

The deposit is one figure in one place. It sits under "What was agreed",
which is where the agreed amounts are, and the note beside the money
only says why the total does not include it. Printing the number twice
reads as being charged for it twice.

The closing line follows the booking's state. "Please bring the original
licence" contradicts the Cancelled badge on a cancelled booking, and
asks a finished hire for a licence already brought, so cancelled and
completed each get the line that is true of them.

Images are awaited before the print dialogue opens, up to two seconds.
Without it the logo raced the dialogue and was missing from three runs
in eight.

Printed with print-color-adjust: exact, or browsers drop every
background and the band comes out white. The confirmation prints
full-bleed through a named page, @page confirmation { margin: 0 },
which leaves the reports on their 14mm.

Checked by rendering eight bookings to real A4 PDFs -- typical,
completed with extra KM and a discount, bare, cancelled, no logo, long
names that wrap, and both with and without terms. All eight come to one
page with the logo decoded, the money column adds up to the total it
prints, and an empty terms field prints no terms section at all.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The closing strip sat wherever the text stopped, which on a booking with
no terms was two thirds of the way up with a third of the page blank
beneath it. The sheet is a flex column now and the strip takes
margin-top:auto, so it lands under the last thing on a full page and at
the bottom of the paper on a short one.

The mark moves into the strip rather than floating loose above it.
Anchored to the paper it had nowhere safe to sit once the strip claimed
the bottom: high enough to clear the strip put it through the terms.
In the strip it has a place of its own and cannot land on anything, and
the booking with no logo uploaded just gets a strip without it.

Also keeps the phone number whole. It has a space in it, and with the
mark taking room at the right the line broke through the middle of it,
leaving the customer half a number to dial.

Re-rendered the same eight bookings: all one page, logo decoded, money
column still adding up to the printed total.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Booking confirmation: terms, the supplied design, and a footer at the foot of the page
The district had twelve town pages and twenty towns worth having one.
These are the eight with something to say that the existing pages do
not: Thovalai on the Madurai road, Kulasekharam for the falls and the
hill country, Karungal, Muttom for the lighthouse and the rock shore,
Manavalakurichi, Mandaikadu for the temple and the March festival,
Kaliyakkavilai at the Kerala border, and Thiruvithancode.

Muttom was already named as a handover point in pickups.json and had
no page of its own, which is the kind of gap that makes a site look
thinner than the business is.

Data only -- towns.json drives the route, the metadata, the sitemap
entry and the prerendered HTML, so adding a town adds a page. 43 routes
to 51.

Each page is written for the place rather than templated from the last
one: what the roads are like, when the market or the festival makes
them impossible, where a small car is the easier thing. The build's own
length guard caught two descriptions that would have been cut in a
search result; both shortened.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Eight phrases from the owner, placed on the page each one belongs to
rather than repeated across the site:

  self drive car rental in Nagercoil   already the page title
  car rental in Nagercoil              already, 14 pages
  self drive car rental in Kanyakumari already the page title
  car rental in Kanyakumari            already
  self drive cars in Kanyakumari       added, Kanyakumari page
  cheap car rental in Nagercoil        added, tariff
  7 seater car rental in Nagercoil     added, tariff and Nagercoil
  automatic car rental in Nagercoil    added, tariff and Nagercoil

Not a keywords meta tag: Google stopped reading that in 2009, and the
same ten phrases on every page is stuffing, which is a demotion risk
rather than a ranking one. These are sentences a customer would want
to read whether or not a search engine existed -- which car is actually
cheapest and why fuel matters more than the rate, that there are two
seven-seaters and one automatic so a day's notice is the difference
between having one and not.

The two new tariff questions land in the page's FAQPage structured
data, so they are eligible to show as rich results rather than only as
body text. Verified by reading the built HTML back: every phrase
resolves to the page it should.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Eight more towns, and the owner's search phrases placed where they are true
1. A phone number now has a country code in front of it, picked from a
list of thirteen -- India, the Gulf, and the English-speaking countries
the NRI customers come home from -- and the digits are counted against
that country. The field took free text before, so "9876543210",
"98765 43210" and "976543210" all went in and only two of them could be
dialled. A number that cannot be dialled is worse than a missing one:
nobody finds out until the customer is late and the car is gone.

Stored as "+91 9876543210", which fits the VARCHAR(20) the columns
already are. Numbers saved before this read back as +91, because that is
who was being booked. Checked in the browser so the person typing finds
out while the field is under the cursor, and again on the server, which
is what actually keeps a bad number out of the column. Applies to the
booking phone, the WhatsApp number, the partner owner's phone and the
new enquiry correction form.

2. The address is a textarea rather than a single-line input. An address
has lines in it; the old field made people run the whole thing together
with commas, and that is what printed on the confirmation.

3. An enquiry's details can be corrected. The website form is filled in
on a phone in a hurry, and a digit goes missing or the pickup is written
as something only the sender would recognise. The panel could read that
and not fix it, so the correction went into a note while the wrong
number stayed in the field the panel dials from. The endpoint writes the
before and after into the audit log, which is the difference between
correcting a record and quietly rewriting it.

4. The rental now follows the dates. Changing the return date already
moved the days preview and left the money alone, so a customer who
brought the car back three days early kept the full hire on the booking
until somebody retyped the figure from memory. The rate card's band for
the days now on the form is offered under the amount, with a button.
Offered, not applied: a rate agreed off the card is a real thing and
overwriting it silently would be worse than never suggesting anything.

Tested what can be tested without a database. The PHP splitter and
joiner against nine stored formats including the pre-code ones, a round
trip, and the digit counts. The browser half in headless Chromium:
loading a stored number into two fields, nine length cases across four
countries, and that switching India to Singapore makes a correct number
wrong on the spot. The rate bands against nine hire lengths including
the early-return case and a car with no weekly rate. The rendered
controls for layout at desktop and phone width.

Not tested: anything that needs the database -- this container has no
MariaDB. The save paths are syntax-checked only.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Four corrections to the panel's entry forms
The overview ran to 1,436px against about 674px of usable height on the
owner's monitor -- twice the screen -- and the window scrolled, which
took the sidebar and the search box with it. Three changes, measured
rather than guessed.

The window no longer scrolls on a desktop. The shell is the height of
the screen and the page area inside it does the scrolling, so the
sidebar, the top bar and whatever is at the head of a panel stay put. A
phone keeps ordinary page scrolling: a fixed-height shell there fights
the address bar hiding and showing, and loses.

The overview is two columns. Counters on the left, because they are a
fixed block you scan; what needs doing on the right, because that is
the part that grows. Each column scrolls on its own, so the overview is
one screen whether there are two alerts or twenty. It was ten sections
stacked down a 1,180px strip with a third of the monitor empty beside
it; the width cap goes to 1,520px for the same reason the lists already
had it -- counters and lists are not a paragraph.

A density pass over the whole panel: padding, gaps, card and heading
sizes. Nothing changed colour or disappeared.

The lists fill the screen and scroll inside themselves. A fixed 72vh
left dead space under the table on a tall monitor and cut it off on a
short one; the panel, the view and the wrapper each hand their
remaining height down, so the table ends where the screen does and the
filters and pager stay on screen. Rows went from 63px to 42px.

Measured at 1536x730 and 1920x950 by rendering the real markup against
the real stylesheet:

  overview        1,436px -> 674px, fits exactly, window does not scroll
  bookings        4 rows and 60 rows: window still, pager on screen,
                  table scrolls inside, header sticky over the rows
  a tall tab      bottom still reachable at 1536x730, 1280x620, 390x780

Two of those checks failed first on the harness rather than the CSS --
forty cards in a grid fit in five rows and prove nothing about
scrolling -- and were rewritten before they passed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The panel fits the screen, and the screen stops scrolling
Six pages held their content to a 768px reading measure while the
header, the hero above them and the sections below them all used the
site's own 1376px container. On a 1800px screen that put the body 304px
to the right of the heading it belonged to: the About page's hero text
started at x212, its paragraphs at x516, and the Benefits block under
them at x212 again.

They now use the same shell as everything else --
mx-auto max-w-[86rem] px-5 py-16 sm:px-8 lg:px-12 -- so the content
starts where the heading starts and ends where the header ends. About,
NRI, Monthly, the shared service page (bikes, wedding cars, tourist
vehicles), the blog index and the 404.

Measured across fifteen routes at 1800px and 1440px: every page's
content block now matches the header's left edge and width exactly, and
nothing scrolls sideways at 390px or 768px.

Left as it was: a blog post. /blog/one-day-kanyakumari-by-car runs its
article at 736px on purpose -- it is the one page on the site with
several hundred words of continuous prose, and at 1376px those lines
are about 150 characters, which is past the point where the eye loses
its place coming back to the start of a line. Everything around the
article is at the site's width; the article column is not.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Page content lines up with the rest of the site
The fleet and tariff lists showed only vehicles whose status was
Available, so the moment a car went out it disappeared from the site
and came back when it was returned. A customer looking on Monday saw
five cars and on Tuesday four, and drew the obvious wrong conclusion
about what the business owns.

The site is a catalogue of what is rented, not an availability board.
Available, Booked and On Rental are all listed now. Maintenance and
Inactive stay out: one is off the road and the other has usually been
sold.

Which days a car is free was never this query's job and is already
answered properly -- public-availability.php returns the taken days per
vehicle and the date pickers grey them out, so nobody can pick a day a
car is out on. Removing the car entirely said "we do not have this
car", which was not true; greying out next weekend says what is
actually the case.

The set is one constant, LISTED_STATUSES in vocab.php, because two
endpoints depend on it agreeing. The second is the fleet-size count
behind "every car is out", and it was wrong in a way nobody would have
noticed: counting only Available cars, a fleet of six with two out
counted four, so a day with those four booked hit 4 of 4 and was
reported fully busy while two cars sat free. Both now count the same
set.

Checked without a database, which this container has none of: the
status set, that every status in the enum is either advertised or
deliberately excluded, that the placeholder count matches the bind list
in both queries, and the arithmetic above. Added four cases to
test-public.sh -- on-rental listed, maintenance and inactive not -- for
the next run somewhere with MySQL.

Two comments that now described the opposite of the code were corrected
rather than left: the docblock in public-vehicles.php and the header of
my-app/src/lib/vehicles.ts.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
"322" went into Customer Name and "gfgffg" into Phone Number, and both
saved. Neither is a typo anybody notices: a name is never read back,
and a phone number is found to be wrong when the customer is late and
there is no way to ring them.

Four rules, declared with data-rule next to the field rather than in a
list that would go stale:

  name    letters of any script, the marks that go with them, and the
          three punctuation marks names contain. No digits.
          -- bkCustomerName, enqEditName, carOwnerName, uName,
             admin_name
  digits  a phone number is digits; the country code has its own select
          -- bkPhone, bkWhatsapp, carOwnerPhone, enqEditPhone
  upper   a licence or ID is capitals on the document it is copied
          from, and one typed in lower case is not found by a search
          -- bkLicence, bkIdNumber
  plate   capitals and digits, keeping the spaces or dashes between the
          groups -- carRegNumber

Deliberately left alone: car brand and listing name, garage, vendor and
place names all legitimately contain digits -- "Grand i10", "Innova
Crysta 2.4". A rule there would be a new bug.

Number inputs look like they refuse letters and do not: Chrome takes
e and E as scientific notation and + and - as a sign, then reports the
field empty rather than invalid. Those four keys are now refused, by
keystroke and by paste, on every number field in the panel.

Delegated from document, because every modal is written into the page
after the script runs and a per-field binding would have worked on the
first form and no other. On input rather than keydown, so pasting,
dragging text in, dictation and autofill are filtered too.

The caret is preserved. Setting .value moves it to the end, which on an
edit in the middle of a name throws you to the end of the word on every
keystroke; each rule is per-character, so running it over the text left
of the caret counts how many survived and that is where the caret goes.

Tested in a browser by typing key by key: 22 cases across the four
rules including a Tamil name, D'Souza, Dr. A.R. Rahman, Anne-Marie,
pasting, scientific notation, and editing mid-word with the caret
checked after both a refused and an accepted character.

Two things removed as now dead: a text-transform:uppercase on the
registration field, which only made the value look uppercase, and a
rule on bkVehicleReg, which is readonly and never typed into.

My own first pass corrupted install.php -- the regex matched the ">"
of a "<?= ... ?>" inside the tag and ate the "?". Caught by php -l and
repaired.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
Fields accept what their label says, while you type
The gaps down the home page were 80, 176 and 160 depending on the
section, and three sections were simply on different values from the
rest.

What was actually wrong:

  the towns band    72px padding, not 80
  the FAQ           56px (py-11, py-14 above sm), not 80
  why rent from us  64px below sm (py-16), not 80
  our services      80px, plus 16px more from the rail beneath it

The first three are now 80 like everything else. The fourth was not a
mistake to delete: the services rail carries pb-4 so the cards' drop
shadows are not clipped by its own overflow-x. That 16px reads as
section spacing whether or not it was meant to, so the section's own
bottom padding comes down to 64 and the two add to the same 80.

Every boundary between two page-coloured sections is now 160px, at
1280px and at 1920px. The hero is 80 because a full-bleed band has no
bottom padding of its own, and the navy FAQ has none either side
because the colour change is the separator.

Four measurements before this one were wrong and were thrown away, in
case the next person wonders why the obvious numbers do not reproduce:

  - the scroll-reveal leaves anything not yet scrolled past at opacity
    0, so a probe that skipped invisible elements found no text in
    those sections and fell back to their box edges, reporting 0
  - forcing opacity and transform off to fix that moved the content
    being measured, which is its own kind of wrong answer
  - counting any element with a background colour as "ink" counted the
    full-bleed decorative layers, so banded sections measured as
    having no padding at all
  - picking "the heading" with a querySelector that matched h2 in some
    sections and the eyebrow in others manufactured a 60px difference
    that was not there

The numbers above come from probing which rows of the page contain
anything that draws, on a settled page with nothing overridden.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The questions sat in a 56rem block centred inside the 86rem container,
so the one section on the page that could have used the width was
inset about 240px from the content either side of it. Nothing else on
the site does that.

max-w-4xl removed from the grid. At 1800px the questions now run
1280px wide like the header, the hero and every section above them,
and most of them stop wrapping onto a second line.

The paragraphs nearby keep their own measure on purpose: the heading's
lede is max-w-xl and the intro max-w-2xl, because a centred sentence
set 1280px wide is a worse thing than a narrow one.

Checked at 1800, 1440, 1280 and 390px, and on /tariff as well as the
home page, that the grid's left edge and width match the header's
content box exactly.

The first version of that check compared one grid column against the
container and failed all five cases while the markup was already
right.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0128YzbhrfGdegUSc9RrARRf
The FAQ grid fills the page like everything else

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants