From 66d1d8b18b8237831901e089f429a08d01c8b4ba Mon Sep 17 00:00:00 2001 From: Aswinmcw Date: Fri, 28 Aug 2026 09:36:00 +0000 Subject: [PATCH] feat(watches): show the carrier's expected delivery date MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Blue Dart and Delhivery both publish an expected delivery date, and the public tracking page has always shown it — but a watch never kept it, so the dashboard and the alert emails didn't. Persist it and surface it in both. - migration 0010 adds watches.estimated_delivery (TEXT, nullable). Stored as the carrier renders it rather than parsed into a date: each carrier formats it differently and it is only ever displayed verbatim, exactly as the track page already does. - the poller refreshes it on every *successful* poll, not only when a scan changes — carriers revise the date without adding an event, so tying it to status changes would leave a stale date on the dashboard for days. It is only written when the carrier actually returned one: a single flaky scrape can't wipe a date we already know (bluedart.ts is a scraper, so that matters). - the alert email gains an "Expected delivery" row in the shipment card, and a line in the plain-text part. It falls back to the stored value when the poll that triggered the alert didn't return one. - the dashboard shows it as a muted line under the status pill, mirroring the existing "auto-removes in N days" note, and hides it once a shipment is delivered or cancelled where an expected date is just noise. Deliberately not a new column: the table is already 7 wide in a 960px container and has a history of layout fixes. Shiprocket exposes no ETA, so those watches simply show nothing. Verified: email template rendered with / without / null ETA (row and text line present only when set, and html-escaped); dashboard rendered against local D1 with three seeded watches — active-with-ETA shows "expected 30 Aug 2026" after its status, active-without shows nothing, and the finished one is excluded. Migration applied to local and remote D1 ahead of this landing. tsc clean, poller bundles at 50.35 KiB, web build green. Co-Authored-By: Claude Opus 5 (1M context) --- migrations/0010_estimated_delivery.sql | 8 ++++++++ src/app/dashboard/client.tsx | 7 +++++++ src/app/dashboard/page.tsx | 1 + src/lib/db.ts | 9 +++++++++ src/lib/email.ts | 17 +++++++++++++++-- src/notifiers/email-resend.ts | 1 + src/notifiers/types.ts | 2 ++ workers/poller/index.ts | 10 ++++++++-- 8 files changed, 51 insertions(+), 4 deletions(-) create mode 100644 migrations/0010_estimated_delivery.sql diff --git a/migrations/0010_estimated_delivery.sql b/migrations/0010_estimated_delivery.sql new file mode 100644 index 0000000..3752fc0 --- /dev/null +++ b/migrations/0010_estimated_delivery.sql @@ -0,0 +1,8 @@ +-- Carrier's expected delivery date for a watched shipment, as the carrier +-- renders it (e.g. "28 Aug 2026"). Kept as TEXT rather than a date: each +-- carrier formats it differently and we only ever display it verbatim, the +-- same way the public tracking page already does. +-- +-- Refreshed on every successful poll, so the dashboard can show it without +-- re-fetching from the carrier, and the alert email can include it. +ALTER TABLE watches ADD COLUMN estimated_delivery TEXT; diff --git a/src/app/dashboard/client.tsx b/src/app/dashboard/client.tsx index 26d6acb..781ae0e 100644 --- a/src/app/dashboard/client.tsx +++ b/src/app/dashboard/client.tsx @@ -16,6 +16,7 @@ interface ClientWatch { label: string | null; status: string; lastKnownStatus: string | null; + estimatedDelivery: string | null; lastPolledAt: number | null; createdAt: number; completedAt: number | null; @@ -301,6 +302,7 @@ function AddWatchModal({ label: label.trim() || null, status: body.status === "pending_confirmation" ? "pending" : "active", lastKnownStatus: null, + estimatedDelivery: null, lastPolledAt: null, createdAt: Math.floor(Date.now() / 1000), completedAt: null, @@ -727,6 +729,11 @@ function WatchRow({ {w.email} {statusText} + {!isFinished && w.estimatedDelivery && ( +
+ expected {w.estimatedDelivery} +
+ )} {purgeDaysLeft !== null && (
auto-removes in {purgeDaysLeft === 0 ? "<1 day" : `${purgeDaysLeft} day${purgeDaysLeft === 1 ? "" : "s"}`} diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 2e88818..45422fb 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -46,6 +46,7 @@ function serializeWatch(w: WatchRow) { label: w.label, status: w.status, lastKnownStatus: w.last_known_status, + estimatedDelivery: w.estimated_delivery, lastPolledAt: w.last_polled_at, createdAt: w.created_at, completedAt: w.completed_at, diff --git a/src/lib/db.ts b/src/lib/db.ts index d32d03e..2d22fea 100644 --- a/src/lib/db.ts +++ b/src/lib/db.ts @@ -11,6 +11,7 @@ export interface WatchRow { last_known_status: string | null; last_event_hash: string | null; last_polled_at: number | null; + estimated_delivery: string | null; created_at: number; confirmed_at: number | null; completed_at: number | null; @@ -226,6 +227,10 @@ export async function markPolled( updates: { lastKnownStatus?: string; lastEventHash?: string; + // Carrier's expected delivery date, verbatim. Only pass it when the carrier + // actually returned one: omitting leaves the stored value alone, so a single + // flaky scrape can't wipe a date we already know. + estimatedDelivery?: string; // Shipment reached a terminal state (delivered/returned). Stops further // polling but is distinct from a user-initiated cancellation. complete?: boolean; @@ -242,6 +247,10 @@ export async function markPolled( fields.push("last_event_hash = ?"); values.push(updates.lastEventHash); } + if (updates.estimatedDelivery !== undefined) { + fields.push("estimated_delivery = ?"); + values.push(updates.estimatedDelivery); + } if (updates.complete) { fields.push("status = 'completed'"); // Stamp completion time only on the transition (COALESCE keeps the original diff --git a/src/lib/email.ts b/src/lib/email.ts index 61d834c..2328d29 100644 --- a/src/lib/email.ts +++ b/src/lib/email.ts @@ -154,12 +154,22 @@ function button(href: string, label: string): string { } // A compact shipment "info card" used in alert + watch-created emails. -function shipmentCard(args: { carrier: string; trackingNumber: string; label?: string | null }): string { +function shipmentCard(args: { + carrier: string; + trackingNumber: string; + label?: string | null; + estimatedDelivery?: string | null; +}): string { const labelRow = args.label ? `Label${escapeHtml( args.label, )}` : ""; + const etaRow = args.estimatedDelivery + ? `Expected delivery${escapeHtml( + args.estimatedDelivery, + )}` + : ""; return `
@@ -170,6 +180,7 @@ function shipmentCard(args: { carrier: string; trackingNumber: string; label?: s args.trackingNumber, )} ${labelRow} + ${etaRow}
`; @@ -210,6 +221,7 @@ export function statusChangeEmail(args: { description: string; location?: string; timestamp?: string; + estimatedDelivery?: string | null; unsubscribeUrl: string; }): { subject: string; html: string; text: string } { const ref = args.label ?? args.trackingNumber; @@ -240,7 +252,8 @@ export function statusChangeEmail(args: {

`, }); - const text = `${humanStatus(args.newStatus)}: ${args.description}${meta ? ` (${meta})` : ""}\nUnsubscribe: ${args.unsubscribeUrl}`; + const eta = args.estimatedDelivery ? `\nExpected delivery: ${args.estimatedDelivery}` : ""; + const text = `${humanStatus(args.newStatus)}: ${args.description}${meta ? ` (${meta})` : ""}${eta}\nUnsubscribe: ${args.unsubscribeUrl}`; return { subject, html, text }; } diff --git a/src/notifiers/email-resend.ts b/src/notifiers/email-resend.ts index 45042c1..42c6f91 100644 --- a/src/notifiers/email-resend.ts +++ b/src/notifiers/email-resend.ts @@ -17,6 +17,7 @@ export const emailResend: Notifier = { description: payload.event.description, location: payload.event.location, timestamp: payload.event.timestamp, + estimatedDelivery: payload.estimatedDelivery, unsubscribeUrl: payload.unsubscribeUrl, }); await sendEmail( diff --git a/src/notifiers/types.ts b/src/notifiers/types.ts index d1df2af..d273b42 100644 --- a/src/notifiers/types.ts +++ b/src/notifiers/types.ts @@ -7,6 +7,8 @@ export interface NotificationPayload { oldStatus: string | null; newStatus: string; event: TrackingEvent; + // Carrier's expected delivery date, when it publishes one. + estimatedDelivery?: string | null; unsubscribeUrl: string; } diff --git a/workers/poller/index.ts b/workers/poller/index.ts index b07c16f..6ad207a 100644 --- a/workers/poller/index.ts +++ b/workers/poller/index.ts @@ -43,15 +43,19 @@ async function processWatch(env: Env, w: WatchRow): Promise { return; } + // The carrier can revise the expected delivery date without adding a scan, so + // refresh it on every successful poll rather than only on a status change. + const eta = result.estimatedDelivery; + const latest = result.events[result.events.length - 1]; if (!latest) { - await markPolled(env.DB, w.id, { lastKnownStatus: result.status }); + await markPolled(env.DB, w.id, { lastKnownStatus: result.status, estimatedDelivery: eta }); return; } const hash = await sha256Hex(`${latest.timestamp}|${latest.rawCode ?? ""}|${latest.description}`); if (hash === w.last_event_hash) { - await markPolled(env.DB, w.id); + await markPolled(env.DB, w.id, { estimatedDelivery: eta }); return; } @@ -74,6 +78,7 @@ async function processWatch(env: Env, w: WatchRow): Promise { oldStatus: w.last_known_status, newStatus: latest.status, event: latest, + estimatedDelivery: eta ?? w.estimated_delivery, unsubscribeUrl, }, ); @@ -85,6 +90,7 @@ async function processWatch(env: Env, w: WatchRow): Promise { await markPolled(env.DB, w.id, { lastKnownStatus: latest.status, lastEventHash: hash, + estimatedDelivery: eta, complete, }); }