diff --git a/frontend/format.test.mjs b/frontend/format.test.mjs new file mode 100644 index 0000000..ac50218 --- /dev/null +++ b/frontend/format.test.mjs @@ -0,0 +1,50 @@ +/** + * Date parsing — wall-clock digits vs UTC instants. + * + * node format.test.mjs + * + * Graph receivedDateTime is always UTC (…Z). toDate() prints those digits as + * written (7:52). toInstant() converts to the browser timezone the way Outlook + * does (12:52 in Pakistan). + */ +import { fmtTime, toDate, toInstant } from './src/lib/format.js' + +let failed = 0 +function eq(actual, expected, label) { + if (Object.is(actual, expected)) return + failed += 1 + console.error(`FAIL ${label}\n expected: ${JSON.stringify(expected)}\n actual: ${JSON.stringify(actual)}`) +} + +const GRAPH = '2026-09-07T07:52:00Z' +const GRAPH_NAKED = '2026-09-07T07:52:00' +const GRAPH_OFFSET = '2026-09-07T07:52:00+00:00' + +eq(toDate(null), null, 'toDate(null)') +eq(toInstant(null), null, 'toInstant(null)') +eq(toDate(''), null, 'toDate empty') +eq(toInstant('not-a-date'), null, 'toInstant garbage') + +const wall = toDate(GRAPH) +eq(wall instanceof Date, true, 'toDate returns Date') +eq(wall.getHours(), 7, 'toDate ignores Z — hour is the stored digit') +eq(wall.getMinutes(), 52, 'toDate minutes') + +const instant = toInstant(GRAPH) +eq(instant instanceof Date, true, 'toInstant returns Date') +eq(instant.getTime(), Date.parse(GRAPH), 'toInstant is the UTC instant') +eq(toInstant(GRAPH_NAKED).getTime(), Date.parse(GRAPH), 'Graph without Z is still UTC') +eq(toInstant(GRAPH_OFFSET).getTime(), Date.parse(GRAPH), 'Graph +00:00 is UTC') + +eq(fmtTime(wall), '7:52am', 'fmtTime(toDate) prints stored digits') +eq(fmtTime(instant), fmtTime(new Date(GRAPH)), 'fmtTime(toInstant) matches local clock') + +if (new Date().getTimezoneOffset() !== 0) { + eq(fmtTime(instant) === fmtTime(wall), false, 'non-UTC zone: instant display differs from wall-clock') +} + +if (failed) { + console.error(`\n${failed} failed`) + process.exit(1) +} +console.log('format.test.mjs ok') diff --git a/frontend/package.json b/frontend/package.json index c3b0f70..f06b864 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -11,11 +11,12 @@ "smoke": "node smoke.test.mjs", "test:token": "node token.test.mjs", "test:theme": "node theme.test.mjs", + "test:format": "node format.test.mjs", "test:inbox": "node inbox-loading.test.mjs", "test:candidates": "node candidates-table.test.mjs", "test:cvbank": "node cvbank.test.mjs", "test:mobile": "node mobile.test.mjs", - "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs" + "verify": "vite build && node smoke.test.mjs && node token.test.mjs && node theme.test.mjs && node format.test.mjs && node inbox-loading.test.mjs && node candidates-table.test.mjs && node cvbank.test.mjs" }, "dependencies": { "@tanstack/react-query": "^5.101.4", diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js index b227af9..c4433da 100644 --- a/frontend/src/lib/format.js +++ b/frontend/src/lib/format.js @@ -52,6 +52,36 @@ export function toDate(value) { return d } +const HAS_OFFSET = /[zZ]|[+-]\d{2}:?\d{2}$/ + +/** + * Parse a UTC instant (Graph receivedDateTime, timestamptz) into a Date + * whose local clock matches the browser — the same conversion Outlook does. + * + * toDate() is wall-clock: it prints the stored digits and ignores Z/+00:00. + * Graph always stores UTC, so that path shows 7:52 when Outlook shows 12:52 + * in Pakistan (UTC+5). Only call this for fields that are actually UTC. + */ +export function toInstant(value) { + if (value == null || value === '') return null + if (value instanceof Date) { + return Number.isNaN(value.getTime()) ? null : value + } + if (typeof value === 'number' && Number.isFinite(value)) { + const d = new Date(value) + return Number.isNaN(d.getTime()) ? null : d + } + let s = String(value).trim() + if (!s) return null + if (/^\d{4}-\d{2}-\d{2}[ T]\d/.test(s) && !HAS_OFFSET.test(s)) { + s = `${s.replace(' ', 'T')}Z` + } else { + s = s.replace(' ', 'T') + } + const d = new Date(s) + return Number.isNaN(d.getTime()) ? null : d +} + function ordinal(n) { const v = n % 100 if (v >= 11 && v <= 13) return `${n}th` diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index f97623e..e4246c0 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -26,7 +26,7 @@ import { useAuth } from '../auth/AuthContext' import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { exportStyledXlsx } from '../lib/exportXlsx' -import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate } from '../lib/format' +import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate, toInstant } from '../lib/format' import { PreviousApplications, ReappliedBadge } from '../components/ReapplicantHistory' import { friendlyAuthError } from '../lib/errors' import * as inboxApi from '../api/inbox' @@ -144,14 +144,21 @@ const FORM_PROCESSING_LABEL = { const SERVER_SCOPED_TABS = new Set(TABS) /** - * message_received_time / message_sent_time are plain string columns - * (backend/inbox/models.py:54-56), not timestamps. Unparseable values + * Sheet / date-of-birth values are wall-clock digits. Unparseable values * become null — never "Invalid Date". */ function parseDate(value) { return toDate(value) } +/** + * Outlook Graph sentDateTime / receivedDateTime are UTC instants (…Z). + * toDate() would print 7:52 when Outlook shows 12:52 in Pakistan. + */ +function parseGraphDate(value) { + return toInstant(value) +} + /** * `source` arrives as the raw To address, because that is where the board tag * lands — careers-rozee@, employee-referral@, mustakbil@ and so on. Strip @@ -417,7 +424,7 @@ async function fetchMessageDetail(recordId) { email: row.fromEmail || '', position: row.subject || '(no subject)', ...sourceFrom(row.message_to), - received: parseDate(row.when) ?? parseDate(row.message_sent_time), + received: parseGraphDate(row.when) ?? parseGraphDate(row.message_sent_time), unread: Boolean(row.unread), processing: row.unread ? 'Unread' : 'Read', resumeStatus: RESUME_STATUS[row.match_status] ?? 'Pending', @@ -428,7 +435,7 @@ async function fetchMessageDetail(recordId) { bodyHtml: row.body || '', cc: row.message_cc || '', bcc: row.message_bcc || '', - sentAt: parseDate(row.message_sent_time), + sentAt: parseGraphDate(row.message_sent_time), files: Array.isArray(row.files) ? row.files : [], filePath: row.file_path || '', linkedinSlug: row.linkedin_slug || '', @@ -437,7 +444,7 @@ async function fetchMessageDetail(recordId) { matchSummary: row.match_summary || '', matchReasoning: row.match_reasoning || '', matchError: row.match_error || '', - matchedAt: parseDate(row.matched_at), + matchedAt: parseGraphDate(row.matched_at), resumeText: row.resume_text || '', suggestedIds: Array.isArray(row.suggested_job_post_ids) ? row.suggested_job_post_ids.map(String) @@ -473,7 +480,7 @@ async function fetchApplications(params) { email: row.email || '', position: row.position || '(no subject)', ...sourceFrom(row.source), - received: parseDate(row.received), + received: parseGraphDate(row.received), unread: Boolean(row.unread), processing: row.processing || 'Unread', processingState: row.processing_state || null,