corect timezone #74
|
|
@ -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')
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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`
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue