Case study · Web app

Sevkiyat Defteri

("Shipment Logbook" in Turkish)

A web app that tracks every delivery a small trading company sends out: what went where, with which carrier, for how much, and whether it arrived on time.

  • React 19
  • TypeScript (strict)
  • Vite
  • Supabase · Postgres
  • Vercel
Open jobs screen with trip cards. Each card shows a delivery note number, a route line from warehouse to city, the carrier and a status label
Assistant answer card that checks a carrier quote against past prices and shows the usual price range
Monthly analysis summary with the freight total, trip count, cost per kilogram and on-time rate
1,153automated tests, all passing
44database migrations
167versions in about 10 weeks
26tables, every one with row-level security

Why it was built

The spreadsheet could not answer the question the boss kept asking

The company sends goods by truck every day. Each trip had a delivery note, a carrier, a price and a promised delivery time. This lived in several spreadsheets and in people's heads. When the boss asked "what does it cost to send a pallet to this city?", someone had to search for an answer.

Sevkiyat Defteri puts every trip in one place. Staff enter it once on a phone. The app then answers the questions: what is on the road, which carrier is late, what a fair price looks like.

Open jobs

See everything that is on the road

Each trip that has not been delivered shows as a card with a route line: from the warehouse to the city, and how far along the trip should be. A second view puts a list, a map and the details side by side. The truck position is an estimate from the departure time and the promised days. It is not GPS, and the page says so.

  • Late trips get a coloured side strip
  • One big button on the card marks a trip as delivered
  • The logbook lists every trip, with totals for kilograms and freight

The app is in Turkish. "Açık işler" means "open jobs". "Takip" means "tracking". "Defter" means "logbook".

Open jobs list with trip cards, route lines and one late trip
Open jobs
Logbook with totals for open jobs, records, kilograms and freight, and a trip card with a Delivered button
The logbook

Quotes

Compare a quote with what was paid before

Staff collect two or three carrier quotes for each trip and pick one. A price check then looks at past trips on the same route and says whether the chosen price is normal. It uses the price per pallet and the distance. It leaves out extra costs such as waiting fees, because those were only known later and made good trips look expensive.

  • The check shows the usual range, not one magic number
  • Two delivery notes on the same truck count as one trip
  • A trip where the customer pays the freight has its own switch and stays out of every cost figure

"Teklif kontrol" means "quote check". "Pazarlık için" means "for bargaining".

Quote check answer: a 26,000 lira offer for 10 pallets to one city is expensive, the expected price is 22,450 lira, shown on a range bar
A quote checked against past trips

Analysis

One screen for the question "are we spending more?"

The analysis page shows the freight total for the month, the change against the last month and the cost per kilogram. A second tab ranks carriers by cost and by how often they arrive on time. A short list at the top points at what needs a look, such as a trip that cost more than twice the usual price for its route.

  • Every number comes from recorded payments, nothing is predicted
  • Carrier cards show cost per kilogram and on-time rate
  • Trips the customer paid for count as trips and tonnes, never as cost

"Analiz" means "analysis". "Nakliyeci" means "carrier". "Navlun" means "freight".

Analysis summary with the freight total, trip count, cost per kilogram and on-time rate
Summary
Carrier cards with freight, cost per kilogram, on-time rate and average delay for each carrier
Carriers

Assistant

Ask in plain Turkish

A small box mascot answers questions such as "how much did we pay carriers last month" or "which invoices are due this week". It does not use a language model. It reads the sentence with plain rules and picks a question type, and the numbers come from the data. If it does not understand a sentence, it logs it, and the rules are extended from that log.

  • It only reads data. It cannot change anything, and a test checks that
  • Each answer shows the rows behind the number, with links
  • It covers shipments and the import (supplier invoice) side

"Koli" means "parcel". The mascot is named Koli. "Tedarik vadeleri" means "supplier due dates".

Assistant answer comparing this month with last month: shipments, kilograms, freight paid and cost per kilogram
Month against month
Assistant answer listing two supplier invoices that are due soon, with totals per currency
Supplier due dates

Emails

Emails that go out without anyone remembering

When a trip is marked delivered, the sales rep gets an email with the products, the amount and the delivery time. Five days before a supplier invoice is due, and on the due day, a reminder goes to a list of people set in the app settings. A weekly backup of the whole database arrives by email too. Gmail sends them through a small bridge script, and a daily quota keeps room for login codes.

  • Sales prices never appear in the delivery email
  • Each email is sent once, enforced by a unique key in the database
  • An admin switches each email type on; nothing is sent before that

"Teslim edildi" means "delivered". "Vade" means "due date". "Satıcı" means "seller".

A delivery email on a phone with the customer, two products, the carrier, the freight amount and the delivery time
A delivery email

Order email

An email that is prepared, never sent

Every trip needs the same email to the warehouse manager: the order, the quantities, the delivery address and the approved delivery note as a PDF. Staff used to type it by hand. Now, when a trip is saved, the app prepares it. It does not send anything. It builds a draft that opens in Outlook, and the person reads it and presses Send.

  • The delivery-note PDF is chosen in the browser and goes into the draft file. It is never uploaded or stored
  • Recipients come from a list an admin edits in the settings, with full names. They can be removed or added for one email
  • The subject skips repeats: when the customer and the delivery point share a name, it is written once
  • The price is never in the email, because several people are in copy

"Sipariş e-postası" means "order email". "Kime" means "to" and "Bilgi" means "cc". "Outlook'ta taslağı aç" means "open the draft in Outlook".

Order email window: recipients as removable chips, the subject line, a PDF picker, and buttons to open the draft in Outlook or copy the text
The window after saving a trip
The prepared email: customer name, a short greeting, two products with quantities and a total, and the delivery address highlighted
The email the app prepares (invented data)

Lessons from real bugs

What actually went wrong

Slow network

The page that sometimes did not open

Some people said the app "sometimes does not open". The server had no errors. The cause was in the browser: network requests had no time limit. On a weak connection a request could hang without failing, and the loading screen stayed forever.

Fix: every request now has a time limit (15 seconds to read, 60 to write), the service worker serves the saved page after 4 seconds, and the app opens from its local copy and recovers by itself. A real-browser test reproduces the hang. The old version never opened in 25 seconds. The new one opened in 4.2 seconds.

A request that never fails is worse than one that fails.

Database

The security fix that slowed every read

A security change made a permission check run once for every row. Reading the shipments table went from 102 ms to 432 ms in a local test with 3,000 shipments. The change had not been timed before release.

Fix: the check was wrapped so the database runs it once per query. Reading dropped to 5 ms. Now every SQL change is timed before and after, and a test checks the wrapped form.

Time a change before you call it safe.

Data model

One truck, two delivery notes

When one truck carried two delivery notes to the same address, staff split the carrier's price by hand and typed a part into each note. Each part then looked like a cheap trip. The price check and the suggestions slowly drifted low.

Fix: the price is typed once for the truck and split by weight. Price checks count the truck as one trip. If one note is cancelled, the others share the price.

Model the real thing (one truck), not the paperwork (two documents).

Testing

The safety net that quietly stopped working

One end-to-end test guards the sync between phone and server. A screen redesign changed the page structure, and the test started failing on its first line. Nobody saw it from version 2.69 to 2.80, because it ran only when someone asked for it.

Fix: the test was rewritten against the new screens and now passes all 33 checks. Any change to the sync code requires it to pass.

A check nobody runs is not a check.

Business rule

One box, two meanings

Each quote has a promised delivery time in days. An empty box was saved as 0, and 0 also meant "delivered on the day it leaves", so the two could not be told apart. Three screens also measured lateness in three ways: for a one-day promise, one screen marked a trip late after 24 hours and the other two after 36.

Fix: the rule now lives in one module and counts calendar days. 0 is the day the truck leaves, 1 is the next day, an empty box means no promise. A trip is late only after the promised day has ended. A one-time migration turned the old zeros back into empty boxes.

One rule, written in one place.

Email

The email that looked right in the browser

The first order email looked fine in a browser preview. In Outlook it was stretched across the whole window, text was squeezed together, and the logo vanished into the header of the same colour. Outlook draws email with the Word engine, which ignores a maximum width and does not keep padding on text blocks.

Fix: the email now has a fixed width, the spacing is made of empty rows instead of padding, and the logo is gone. The design was checked against screenshots of the real Outlook window, over several rounds, until it fit.

Test where it will be read, not where it was written.

Under the hood

The parts that are not on screen

Security

Every table has row-level security, and the rules run in the database, so hiding a button is never the protection. Three roles: admin, writer, viewer. The browser holds only the public key. Secret keys live on the server only, and a test scans the code for them on every build. The anonymous role lost its table privileges (migration 042), as a second layer.

Data you can trust

Deleting is soft: a record goes to a bin and can be restored. The audit log cannot be edited or deleted. Timestamps come from the server, not the phone. Database constraints reject impossible values.

Weak signal

The app installs from the browser and opens from a saved copy when the network is slow. Changes made on one phone appear on the others within seconds through a live connection, and a slower refresh runs as a safety net.

Tests before release

The build stops if a type check or a test fails. Eight scripts drive a real browser against a fake server, so no test touches real data. Every pull request is also scanned for leaked secrets (Gitleaks) and for unsafe workflow settings (zizmor), and Dependabot proposes dependency updates once a month. A checklist in the project notes names the checks that must pass before a push.

Email

A Gmail bridge sends delivery notes, due-date reminders, weekly backups and login codes. The server checks a shared key on every call. A quota guard stops sending before the daily limit, so login codes always have room.

Privacy

A written data-flow table lists what each outside service receives. No analytics, no ads, no AI service. Fonts are served by the app itself. Address lookups go to open map services and are the one place where delivery addresses leave the system. The notes say so. The delivery-note PDF used for an order email never leaves the browser: it is not uploaded and not stored.

Timeline

About ten weeks, in eight steps

  1. 28 Aug 2026Version 1.0.0, the first version in the repository.
  2. 8 to 9 SepLogin becomes mandatory. Roles and row-level security arrive (versions 2.0 to 2.3).
  3. 13 to 24 SepInstallable on a phone, offline start, daily sales report, live updates between users (2.15 to 2.37).
  4. 27 Sep to 4 OctVector maps, Gmail bridge for delivery emails, login by emailed code (2.42 to 2.48).
  5. By 8 OctQuote suggestions from similar past trips, shared truck (2.56 to 2.60).
  6. 27 Oct to 2 NovRules-only assistant, supplier-invoice reminders, slow-network fix, "customer pays freight" switch (2.72 to 2.81).
  7. 3 NovDelivery promise counted in calendar days, code reorganised into feature folders (the sync core included, with no change in behaviour), secret and workflow scanning on every pull request (2.82).
  8. 4 NovOrder email drafted in Outlook when a trip is saved, never sent by the app (2.83).

About

Who did what

The product decisions, the business rules and the testing on real phones are Kemal's. The code was written together with Claude. Every screen on this page uses invented names, amounts and places. The app interface is in Turkish. Turkish labels are explained next to each screen.

Built with Claude as a pair programmer: Kemal made the product decisions and tested on real phones, Claude wrote the code with him.