Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions migrations/0010_estimated_delivery.sql
Original file line number Diff line number Diff line change
@@ -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;
7 changes: 7 additions & 0 deletions src/app/dashboard/client.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -727,6 +729,11 @@ function WatchRow({
<td style={td} data-label="Email"><span style={{ color: "var(--muted)" }}>{w.email}</span></td>
<td style={td} data-label="Status">
<span style={statusPillStyle(pillStatus)}>{statusText}</span>
{!isFinished && w.estimatedDelivery && (
<div style={{ color: "var(--muted)", fontSize: 11, marginTop: 3 }}>
expected {w.estimatedDelivery}
</div>
)}
{purgeDaysLeft !== null && (
<div style={{ color: "var(--muted-soft)", fontSize: 11, marginTop: 3 }}>
auto-removes in {purgeDaysLeft === 0 ? "<1 day" : `${purgeDaysLeft} day${purgeDaysLeft === 1 ? "" : "s"}`}
Expand Down
1 change: 1 addition & 0 deletions src/app/dashboard/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
9 changes: 9 additions & 0 deletions src/lib/db.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
Expand All @@ -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
Expand Down
17 changes: 15 additions & 2 deletions src/lib/email.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
? `<tr><td style="padding:2px 0;color:#94a3b8;font-size:13px;">Label</td><td style="padding:2px 0;color:#0f172a;font-size:13px;font-weight:600;text-align:right;">${escapeHtml(
args.label,
)}</td></tr>`
: "";
const etaRow = args.estimatedDelivery
? `<tr><td style="padding:2px 0;color:#94a3b8;font-size:13px;">Expected delivery</td><td style="padding:2px 0;color:#0f172a;font-size:13px;font-weight:600;text-align:right;">${escapeHtml(
args.estimatedDelivery,
)}</td></tr>`
: "";
return `<table role="presentation" width="100%" cellpadding="0" cellspacing="0" style="background:#f8fafc;border:1px solid #e2e8f0;border-radius:12px;margin:4px 0 8px;">
<tr><td style="padding:14px 18px;">
<table role="presentation" width="100%" cellpadding="0" cellspacing="0">
Expand All @@ -170,6 +180,7 @@ function shipmentCard(args: { carrier: string; trackingNumber: string; label?: s
args.trackingNumber,
)}</td></tr>
${labelRow}
${etaRow}
</table>
</td></tr>
</table>`;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -240,7 +252,8 @@ export function statusChangeEmail(args: {
</p>
`,
});
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 };
}

Expand Down
1 change: 1 addition & 0 deletions src/notifiers/email-resend.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
2 changes: 2 additions & 0 deletions src/notifiers/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
10 changes: 8 additions & 2 deletions workers/poller/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,15 +43,19 @@ async function processWatch(env: Env, w: WatchRow): Promise<void> {
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;
}

Expand All @@ -74,6 +78,7 @@ async function processWatch(env: Env, w: WatchRow): Promise<void> {
oldStatus: w.last_known_status,
newStatus: latest.status,
event: latest,
estimatedDelivery: eta ?? w.estimated_delivery,
unsubscribeUrl,
},
);
Expand All @@ -85,6 +90,7 @@ async function processWatch(env: Env, w: WatchRow): Promise<void> {
await markPolled(env.DB, w.id, {
lastKnownStatus: latest.status,
lastEventHash: hash,
estimatedDelivery: eta,
complete,
});
}
Expand Down
Loading