Merge pull request 'corect timezone' (#74) from SQS_BROKER into main
Deploy to S3 / deploy (push) Successful in 37s Details

Reviewed-on: #74
pull/75/head^2
ahmed.mujtaba 2026-09-07 09:21:32 +00:00
commit 57167e77ba
4 changed files with 96 additions and 8 deletions

50
frontend/format.test.mjs Normal file
View File

@ -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')

View File

@ -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",

View File

@ -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`

View File

@ -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,