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, }); }