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