diff --git a/backend/g_sheet/enums.py b/backend/g_sheet/enums.py index 6769e68..3918bf5 100644 --- a/backend/g_sheet/enums.py +++ b/backend/g_sheet/enums.py @@ -236,9 +236,10 @@ FORM_DATA_FIELDS: tuple[str, ...] = tuple(member.value for member in FormDataCol # -- Date parsing ------------------------------------------------------------ class DateFormat(str, Enum): - """strptime patterns tried in definition order. + """strptime patterns tried in definition order after numeric slash dates. - DD/MM before MM/DD: 14/10/20 is ambiguous and DD/MM is the local convention. + Numeric D/M vs M/D is resolved in parse_date (8/28 → Aug 28, 28/8 → 28 Aug, + 8/12 follows prefer_mdy). These patterns cover named months and ISO. """ D_MON_Y_DASH = "%d-%b-%Y" diff --git a/backend/g_sheet/plugins.py b/backend/g_sheet/plugins.py index 3bce00e..e0b2d68 100644 --- a/backend/g_sheet/plugins.py +++ b/backend/g_sheet/plugins.py @@ -446,6 +446,7 @@ def stringify_rows(rows): _TIME_RE=re.compile(r"(\d{1,2}:\d{2}\s*(?:[AaPp][Mm])?)") _DAY_ORDINAL_RE=re.compile(r"\b(\d+)(st|nd|rd|th)\b",re.I) _DIGIT_RE=re.compile(r"\d") +_NUMERIC_DATE_RE=re.compile(r"^(\d{1,2})([/\-.])(\d{1,2})\2(\d{2,4})$") _AGE_RE=re.compile(r"\d+") _SCORE_RE=re.compile(r"\d+") _SALARY_UNIT_RE=re.compile( @@ -510,8 +511,31 @@ def _normalise_month_spellings(text): return text -def parse_date(value): - """Tolerant date parse → aware UTC datetime, or None. Never raises.""" +def _from_numeric_date(first,second,year,prefer_mdy): + """Slash/dash/dot numeric dates. 8/28 is MDY; 28/8 is DMY; 8/12 is ambiguous.""" + if year<100: + year+=2000 + if first>12 and 1<=second<=12: + day,month=first,second + elif second>12 and 1<=first<=12: + month,day=first,second + elif prefer_mdy: + month,day=first,second + else: + day,month=first,second + try: + return datetime(year,month,day,tzinfo=timezone.utc) + except ValueError: + return None + + +def parse_date(value,prefer_mdy=False): + """Tolerant date parse → aware UTC datetime, or None. Never raises. + + prefer_mdy=True for Google Form Timestamp (US M/D/YYYY). Leave False for + local DD/MM fields like date of birth. Unambiguous values (8/28, 28/8) + are resolved from the numbers, not the flag. + """ if value is None: return None text=str(value).strip() @@ -532,6 +556,17 @@ def parse_date(value): date_part=_normalise_month_spellings(date_part) date_part=re.sub(r"\s+"," ",date_part).strip(" ,;") + numeric=_NUMERIC_DATE_RE.match(date_part) + if numeric: + parsed=_from_numeric_date( + int(numeric.group(1)), + int(numeric.group(3)), + int(numeric.group(4)), + prefer_mdy, + ) + if parsed is not None: + return parsed + for fmt in DateFormat: try: return datetime.strptime(date_part,fmt.value).replace(tzinfo=timezone.utc) @@ -541,8 +576,11 @@ def parse_date(value): def parse_date_time(value): - """(datetime|None, time_string|None) — fills entry_time when the cell carries one.""" - parsed=parse_date(value) + """(datetime|None, time_string|None) — fills entry_time when the cell carries one. + + Google Form Timestamp is M/D/YYYY, so 8/12/2026 is 12 Aug, not 8 Dec. + """ + parsed=parse_date(value,prefer_mdy=True) if value is None: return parsed,None text=str(value).strip() diff --git a/backend/job/app.py b/backend/job/app.py index e9dacd6..feee8fc 100644 --- a/backend/job/app.py +++ b/backend/job/app.py @@ -1010,7 +1010,7 @@ async def fetch_interview( recruiter_id:str=Query(None), top:int=Query(None), skip:int=Query(0,ge=0), - current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_VIEW)), + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_VIEW,PermissionTag.CANDIDATES_VIEW,require_all=False)), session: AsyncSession = Depends(get_session), ): try: @@ -1038,7 +1038,7 @@ async def fetch_interview( @router.post("/interview/create") async def create_interview( payload:InterviewCreate, - current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_CREATE)), + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_CREATE,PermissionTag.CANDIDATES_CREATE,require_all=False)), session: AsyncSession = Depends(get_session), ): try: @@ -1055,7 +1055,7 @@ async def create_interview( async def update_interview( interview_id:str=Query(...), payload:InterviewUpdate=..., - current_user: dict = Depends(require_permission(PermissionTag.CANDIDATES_EDIT)), + current_user: dict = Depends(require_permission(PermissionTag.INTERVIEWS_EDIT,PermissionTag.CANDIDATES_EDIT,require_all=False)), session: AsyncSession = Depends(get_session), ): try: diff --git a/backend/migrations/manual/028_requisition_interview_tab_rbac.sql b/backend/migrations/manual/028_requisition_interview_tab_rbac.sql new file mode 100644 index 0000000..d05d2ae --- /dev/null +++ b/backend/migrations/manual/028_requisition_interview_tab_rbac.sql @@ -0,0 +1,53 @@ +-- 028: Slim bundles for custom Access Control roles that should see only the +-- Requisitions and Interviews/Calendar tabs. The tags already exist (001, 019); +-- the seeded bundles are too wide — requisitions_management includes +-- requisitions.manage (org-wide list), analytics_dashboard hangs interviews.view +-- off dashboard/analytics/offers, hiring_forms has interview writes but no view. +-- +-- These two are NOT attached to seeded staff roles (those already have the wide +-- bundles). Admins tick them on a new role in Access Control. +-- Applied at startup by alembic_setup.run_manual_sql(). Log in again after. + +-- ============================================================================= +-- 1. Own requisitions only (omit .manage so is_admin() stays false) +-- ============================================================================= +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'requisitions_self', + 'Own employee requisition forms: view, create, edit (not org-wide manage)', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND tag_name IN ('requisitions.view', 'requisitions.create', 'requisitions.edit') + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'requisitions_self' +); + +-- ============================================================================= +-- 2. Interviews / Calendar tab (view + schedule + amend) +-- ============================================================================= +INSERT INTO app.permissions (name, description, permission_tags, is_system, created_at, updated_at, is_active, is_deleted) +SELECT + 'interviews_tab', + 'Interviews and Calendar tabs: list, schedule, reschedule', + ( + SELECT COALESCE(jsonb_agg(id ORDER BY id), '[]'::jsonb) + FROM app.permission_tags + WHERE is_deleted = false + AND tag_name IN ('interviews.view', 'interviews.create', 'interviews.edit') + ), + true, + NOW(), + NOW(), + true, + false +WHERE NOT EXISTS ( + SELECT 1 FROM app.permissions WHERE name = 'interviews_tab' +); diff --git a/frontend/src/api/assessments.js b/frontend/src/api/assessments.js index 15ffede..32cdea9 100644 --- a/frontend/src/api/assessments.js +++ b/frontend/src/api/assessments.js @@ -1,4 +1,5 @@ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' /** Assessments — backend/assessments/app.py. Dual-key: exactly one of inbox_id / manual_upload_candidate_id. */ @@ -89,9 +90,9 @@ export function toAssessmentView(row) { sectionScores: Array.isArray(row.section_scores) ? row.section_scores : [], duration: durationLabel(row.duration_minutes) || '—', durationMinutes: row.duration_minutes, - assigned: row.assigned_at ? new Date(row.assigned_at) : null, - due: row.due_at ? new Date(row.due_at) : null, - completedAt: row.completed_at ? new Date(row.completed_at) : null, - remindedAt: row.reminded_at ? new Date(row.reminded_at) : null, + assigned: toDate(row.assigned_at), + due: toDate(row.due_at), + completedAt: toDate(row.completed_at), + remindedAt: toDate(row.reminded_at), } } diff --git a/frontend/src/api/assignments.js b/frontend/src/api/assignments.js index 78ce874..b661fea 100644 --- a/frontend/src/api/assignments.js +++ b/frontend/src/api/assignments.js @@ -1,4 +1,5 @@ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' /* ============================================================ assignments.js — who owns a requisition, and who owns an application. @@ -70,8 +71,8 @@ export function toAssignmentView(row, namesById) { role: row.assignment_role || 'primary_recruiter', jobPostId: row.job_post_id ?? null, inboxId: row.inbox_id ?? null, - validFrom: row.valid_from ? new Date(row.valid_from) : null, - validTo: row.valid_to ? new Date(row.valid_to) : null, + validFrom: toDate(row.valid_from), + validTo: toDate(row.valid_to), assignedBy: row.assigned_by ?? null, } } diff --git a/frontend/src/api/candidates.js b/frontend/src/api/candidates.js index ee27bfa..29253b3 100644 --- a/frontend/src/api/candidates.js +++ b/frontend/src/api/candidates.js @@ -13,6 +13,7 @@ ============================================================ */ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' +import { toDate } from '../lib/format' import { STATUS_FROM_STAGE } from './pipeline' /** Active job posts for pickers. Needs job_board.view OR candidates.view. @@ -158,7 +159,7 @@ export function toCandidateView(row) { scoringStatus: row.status, // 'completed' | 'failed' errorCode: row.error_code ?? null, errorMessage: row.error_message ?? null, - applied: row.created_at ? new Date(row.created_at) : null, + applied: toDate(row.created_at), } } @@ -210,7 +211,7 @@ export function toCandidateUserView(row) { email: row.email ?? null, isActive: row.is_active ?? null, roleName: row.role_name ?? null, - applied: row.created_at ? new Date(row.created_at) : null, + applied: toDate(row.created_at), // No ATS data on a users row — see the note above. jobId: null, filename: null, diff --git a/frontend/src/api/costs.js b/frontend/src/api/costs.js index 19fe1c0..498104b 100644 --- a/frontend/src/api/costs.js +++ b/frontend/src/api/costs.js @@ -1,4 +1,5 @@ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' /* ============================================================ costs.js — hiring costs, backend/job/app.py `/job/costs/*` (jobs.view / jobs.edit). @@ -38,8 +39,8 @@ export function toCostView(row) { amount: Number(row.amount ?? 0), currency: row.currency || 'USD', description: row.description || null, - incurredAt: row.incurred_at ? new Date(row.incurred_at) : null, - created: row.created_at ? new Date(row.created_at) : null, + incurredAt: toDate(row.incurred_at), + created: toDate(row.created_at), } } diff --git a/frontend/src/api/interviews.js b/frontend/src/api/interviews.js index cea80ee..9c14296 100644 --- a/frontend/src/api/interviews.js +++ b/frontend/src/api/interviews.js @@ -1,4 +1,5 @@ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' /* ============================================================ interviews.js — backend/job/app.py `/interview/*`. @@ -14,9 +15,9 @@ import { request } from '../lib/apiClient' is required" — so `list()` always sends `top`, and the screens never call it bare. - Permissions are candidates.*, NOT interviews.* — the eight interviews.* tags - exist in the catalogue but no route reads them. A user holding only - interviews.view gets a 403 here. + Permissions are interviews.* OR candidates.* (either tag is enough). A custom + role with only the interviews_tab bundle can list/schedule here without + candidates.view. Recruiter / hiring_manager still pass via candidates.*. ============================================================ */ /** Status vocabulary. `interview_status` is a free-text column, so this file is @@ -104,7 +105,7 @@ export function update(interviewId, { instant, type, status } = {}) { */ export function toInterviewView(row) { const whenRaw = row.interview_date || row.interview_time - const when = whenRaw ? new Date(whenRaw) : null + const when = toDate(whenRaw) return { id: row.id, inboxId: row.inbox_id, diff --git a/frontend/src/api/jobStats.js b/frontend/src/api/jobStats.js index 5bbd82e..cad5c6b 100644 --- a/frontend/src/api/jobStats.js +++ b/frontend/src/api/jobStats.js @@ -6,6 +6,7 @@ ============================================================ */ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' import { REQUISITION_STATUSES } from './jobs' const REQ_STATUS_LABEL = Object.fromEntries(REQUISITION_STATUSES.map((s) => [s.value, s.label])) @@ -30,7 +31,7 @@ export function list({ jobPostId, search, ids, top, skip, activeOnly } = {}) { /** Whole days from created_at to the browser clock. Null if timestamp missing. */ export function daysOpen(createdAt, now = Date.now()) { if (!createdAt) return null - const start = new Date(createdAt).getTime() + const start = toDate(createdAt)?.getTime() if (!Number.isFinite(start)) return null return Math.max(0, Math.floor((now - start) / 86_400_000)) } diff --git a/frontend/src/api/jobs.js b/frontend/src/api/jobs.js index 64ee2c9..9f1ce8f 100644 --- a/frontend/src/api/jobs.js +++ b/frontend/src/api/jobs.js @@ -1,4 +1,5 @@ import { downloadFile, fetchBlobUrl, request } from '../lib/apiClient' +import { toDate } from '../lib/format' /** * Job requisitions — backend/job/app.py `GET /jobs/fetch`. @@ -82,8 +83,8 @@ export function toJobView(row) { createdByName: row.created_by_name, applicantCount: row.applicant_count ?? 0, // A real Date, so the existing `sortValue: j => j.created.getTime()` keeps working. - created: row.created_at ? new Date(row.created_at) : null, - closedAt: row.closed_at ? new Date(row.closed_at) : null, + created: toDate(row.created_at), + closedAt: toDate(row.closed_at), requisitionStatus: row.requisition_status, experienceMin: row.experience_min, experienceMax: row.experience_max, diff --git a/frontend/src/api/notifications.js b/frontend/src/api/notifications.js index d92bff6..156ea8f 100644 --- a/frontend/src/api/notifications.js +++ b/frontend/src/api/notifications.js @@ -1,4 +1,5 @@ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' /** In-app notifications — backend/notifications/app.py. Scoped to the caller; no RBAC tag. */ @@ -35,8 +36,8 @@ export function remove(recordId) { function relTime(iso) { if (!iso) return '' - const then = new Date(iso) - if (Number.isNaN(then.getTime())) return '' + const then = toDate(iso) + if (!then) return '' const mins = Math.max(0, Math.round((Date.now() - then.getTime()) / 60000)) if (mins < 60) return `${mins}m ago` if (mins < 1440) return `${Math.floor(mins / 60)}h ago` diff --git a/frontend/src/api/offers.js b/frontend/src/api/offers.js index 21417d1..7b7e8ae 100644 --- a/frontend/src/api/offers.js +++ b/frontend/src/api/offers.js @@ -1,4 +1,5 @@ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' /* ============================================================ offers.js — backend/offer/app.py. @@ -132,11 +133,11 @@ export function toOfferView(row, { people, jobTitles } = {}) { noticePeriod: row.notice_period || null, workLocation: row.work_location || null, workTimings: row.work_timings || null, - startDate: row.start_date ? new Date(row.start_date) : null, - expiry: row.expiry_date ? new Date(row.expiry_date) : null, - sent: row.sent_at ? new Date(row.sent_at) : null, - respondedAt: row.responded_at ? new Date(row.responded_at) : null, - closedAt: row.closed_at ? new Date(row.closed_at) : null, - created: row.created_at ? new Date(row.created_at) : null, + startDate: toDate(row.start_date), + expiry: toDate(row.expiry_date), + sent: toDate(row.sent_at), + respondedAt: toDate(row.responded_at), + closedAt: toDate(row.closed_at), + created: toDate(row.created_at), } } diff --git a/frontend/src/api/pipeline.js b/frontend/src/api/pipeline.js index 727d785..58eba2f 100644 --- a/frontend/src/api/pipeline.js +++ b/frontend/src/api/pipeline.js @@ -11,6 +11,7 @@ ============================================================ */ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' /** * Candidate_application_Status (backend/inbox/enums.py) -> the board column. @@ -214,7 +215,7 @@ export function toBoardCard(row, kind = 'inbox') { experience: asText(row.experience), aiScore: asScore(row.ats_result?.overall_score), recommendation: asText(row.ats_result?.band), - applied: row.created_at ? new Date(row.created_at) : null, + applied: toDate(row.created_at), } } diff --git a/frontend/src/api/talent.js b/frontend/src/api/talent.js index 26cbf28..a0bcf64 100644 --- a/frontend/src/api/talent.js +++ b/frontend/src/api/talent.js @@ -9,6 +9,7 @@ ============================================================ */ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' const TERMINAL = new Set(['succeeded', 'failed', 'timed_out', 'aborted']) @@ -91,9 +92,9 @@ export function toRunView(row) { profilesFound: row.profiles_found ?? 0, costUsd: row.cost_usd ?? null, error: row.apify_error ?? null, - startedAt: row.started_at ? new Date(row.started_at) : null, - finishedAt: row.finished_at ? new Date(row.finished_at) : null, - createdAt: row.created_at ? new Date(row.created_at) : null, + startedAt: toDate(row.started_at), + finishedAt: toDate(row.finished_at), + createdAt: toDate(row.created_at), isTerminal: TERMINAL.has(row.status), } } @@ -104,7 +105,7 @@ export function toAccountView(row) { balanceUsd: r.balance_usd ?? null, spentUsd: r.spent_this_cycle_usd ?? null, monthlyLimitUsd: r.monthly_limit_usd ?? null, - cycleEndsAt: r.cycle_ends_at ? new Date(r.cycle_ends_at) : null, + cycleEndsAt: toDate(r.cycle_ends_at), // null until at least one search has recorded its cost (runs from before // cost tracking carry no spend data and are excluded from the average). costPerProfileUsd: r.cost_per_profile_usd ?? null, @@ -129,11 +130,11 @@ export function toProfileView(row) { skills: Array.isArray(row.skills) ? row.skills : [], matchScore: row.match_score ?? null, outreachStatus: row.outreach_status ?? 'sourced', - shortlistedAt: row.shortlisted_at ? new Date(row.shortlisted_at) : null, + shortlistedAt: toDate(row.shortlisted_at), shortlistedByName: row.shortlisted_by_name ?? null, - contactedAt: row.contacted_at ? new Date(row.contacted_at) : null, + contactedAt: toDate(row.contacted_at), contactedByName: row.contacted_by_name ?? null, - lastSeenAt: row.last_seen_at ? new Date(row.last_seen_at) : null, + lastSeenAt: toDate(row.last_seen_at), // Non-null when a CV in the ATS carries this profile's /in/ link: // { source, status, job_post_id, candidate, applied_at, same_job, applications } alreadyApplied: row.already_applied ?? null, @@ -143,7 +144,7 @@ export function toProfileView(row) { export function toProfileDetailView(row) { return { ...toProfileView(row), - firstSeenAt: row.first_seen_at ? new Date(row.first_seen_at) : null, + firstSeenAt: toDate(row.first_seen_at), experience: (row.experience ?? []).map((e) => ({ title: e.title, company: e.company, diff --git a/frontend/src/api/tasks.js b/frontend/src/api/tasks.js index 6420d78..92d4e16 100644 --- a/frontend/src/api/tasks.js +++ b/frontend/src/api/tasks.js @@ -1,4 +1,5 @@ import { request } from '../lib/apiClient' +import { toDate } from '../lib/format' /** * Tasks — backend/tasks/app.py. @@ -44,10 +45,10 @@ export function toTaskView(row) { title: row.title, done: row.status === 'done', priority: row.priority ? row.priority[0].toUpperCase() + row.priority.slice(1) : 'Medium', - due: row.due_date ? new Date(row.due_date) : null, + due: toDate(row.due_date), assignee: row.assignee_name || '—', assigneeRole: row.assignee_role || null, assigneeId: row.assignee_id, - created: row.created_at ? new Date(row.created_at) : null, + created: toDate(row.created_at), } } diff --git a/frontend/src/data/seed.js b/frontend/src/data/seed.js index af88575..42790d4 100644 --- a/frontend/src/data/seed.js +++ b/frontend/src/data/seed.js @@ -9,9 +9,12 @@ endpoints exist read the API instead; the rest resolve from here. ============================================================ */ +import { fmtDate, fmtShort } from '../lib/format' + // The prototype pinned "today" to 2026-07-09 in ~8 places across five files so // the generated relative dates stayed stable. Exported from one place now, so // switching the app to real time is a one-line change. + export const TODAY = new Date('2026-07-09T09:00:00'); // ---------- seeded pseudo-random for stable data ---------- @@ -63,13 +66,6 @@ export const TODAY = new Date('2026-07-09T09:00:00'); function email(name) { return name.toLowerCase().replace(/[^a-z ]/g, '').replace(/ /g, '.') + '@' + pick(['gmail.com', 'outlook.com', 'proton.me', 'icloud.com']); } function phone() { return '+1 (' + int(200, 989) + ') ' + int(200, 999) + '-' + int(1000, 9999); } function daysAgo(n) { const d = new Date(TODAY); d.setDate(d.getDate() - n); return d; } - function fmtDate(d) { - if (d == null || d === '') return '' - const date = d instanceof Date ? d : new Date(d) - if (Number.isNaN(date.getTime())) return '' - return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) - } - function fmtShort(d) { return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' }); } const avatarColors = ['var(--av-1)', 'var(--av-2)', 'var(--av-3)', 'var(--av-4)', 'var(--av-5)', 'var(--av-6)', 'var(--av-7)', 'var(--av-8)']; function avatarColor(name) { @@ -233,7 +229,7 @@ export const TODAY = new Date('2026-07-09T09:00:00'); // ---------- Notifications ---------- const notifications = [ { icon: 'user-plus', color: 'i-green', title: 'New application', text: candidates[0].name + ' applied for ' + candidates[0].jobTitle, time: '8m ago', unread: true }, - { icon: 'calendar', color: 'i-blue', title: 'Interview reminder', text: 'Technical interview at 2:00 PM today', time: '25m ago', unread: true }, + { icon: 'calendar', color: 'i-blue', title: 'Interview reminder', text: 'Technical interview at 2:00pm today', time: '25m ago', unread: true }, { icon: 'check', color: 'i-teal', title: 'Offer accepted', text: offers[0].candidate + ' accepted the offer 🎉', time: '1h ago', unread: true }, { icon: 'message', color: 'i-purple', title: 'New message', text: 'Hiring manager left feedback on a candidate', time: '2h ago', unread: true }, { icon: 'star', color: 'i-amber', title: 'Assessment completed', text: assessments[0].candidate + ' scored 87% on Coding Challenge', time: '4h ago', unread: false }, diff --git a/frontend/src/lib/format.js b/frontend/src/lib/format.js index 97ec6a7..b227af9 100644 --- a/frontend/src/lib/format.js +++ b/frontend/src/lib/format.js @@ -9,3 +9,117 @@ export function formatRole(name) { .map((w) => (w ? w[0].toUpperCase() + w.slice(1).toLowerCase() : w)) .join(' ') } + +/* Platform date/time display. One spelling everywhere: + date → 3rd Sept 2026 + time → 7:30pm (12-hour, lowercase, no space) + both → 3rd Sept 2026 - 7:30pm + + API timestamps are year-month-day, then optional time, e.g. + 2026-08-28 03:56:19.685066-07 + 2026-08-28T03:56:19.685066-07:00 + 2026-08-28 + Display uses those numbers as written: 2026 = year, 08 = month (Aug), + 28 = day, 03:56 = 3:56am. The timezone suffix is not applied. */ +const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec'] +const WEEKDAYS = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'] +const WEEKDAYS_SHORT = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat'] + +const API_TS = /^(\d{4})-(\d{2})-(\d{2})(?:[T\s](\d{1,2}):(\d{2})(?::(\d{2})(?:\.\d+)?)?)?/ + +/** Parse an API timestamp into a Date whose local fields match the payload. */ +export function toDate(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 + } + const s = String(value).trim() + const m = s.match(API_TS) + if (!m) return null + const year = Number(m[1]) + const month = Number(m[2]) + const day = Number(m[3]) + const hour = m[4] != null ? Number(m[4]) : 0 + const minute = m[5] != null ? Number(m[5]) : 0 + const second = m[6] != null ? Number(m[6]) : 0 + if (month < 1 || month > 12 || day < 1 || day > 31) return null + const d = new Date(year, month - 1, day, hour, minute, second) + if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null + return d +} + +function ordinal(n) { + const v = n % 100 + if (v >= 11 && v <= 13) return `${n}th` + switch (n % 10) { + case 1: return `${n}st` + case 2: return `${n}nd` + case 3: return `${n}rd` + default: return `${n}th` + } +} + +/** 3rd Sept 2026 */ +export function fmtDate(value) { + const d = toDate(value) + if (!d) return '' + return `${ordinal(d.getDate())} ${MONTHS[d.getMonth()]} ${d.getFullYear()}` +} + +/** Same as fmtDate — the platform uses one date spelling. */ +export function fmtShort(value) { + return fmtDate(value) +} + +/** 7:30pm */ +export function fmtTime(value) { + const d = toDate(value) + if (!d) return '' + let hours = d.getHours() + const minutes = d.getMinutes() + const suffix = hours >= 12 ? 'pm' : 'am' + hours = hours % 12 + if (hours === 0) hours = 12 + return `${hours}:${String(minutes).padStart(2, '0')}${suffix}` +} + +/** 3rd Sept 2026 - 7:30pm */ +export function fmtDateTime(value, sep = ' - ') { + const d = toDate(value) + if (!d) return '' + return `${fmtDate(d)}${sep}${fmtTime(d)}` +} + +/** Sept 2026 — calendar month headers, not a day. */ +export function fmtMonthYear(value) { + const d = toDate(value) + if (!d) return '' + return `${MONTHS[d.getMonth()]} ${d.getFullYear()}` +} + +/** Thursday, 3rd Sept 2026 — dashboard "today" line. */ +export function fmtWeekdayDate(value) { + const d = toDate(value) + if (!d) return '' + return `${WEEKDAYS[d.getDay()]}, ${fmtDate(d)}` +} + +export function fmtWeekdayShort(value) { + const d = toDate(value) + if (!d) return '' + return WEEKDAYS_SHORT[d.getDay()] +} + +/** yyyy-mm-dd for , from the payload's calendar day. */ +export function toDateInput(value) { + const d = toDate(value) + if (!d) return '' + const y = d.getFullYear() + const m = String(d.getMonth() + 1).padStart(2, '0') + const day = String(d.getDate()).padStart(2, '0') + return `${y}-${m}-${day}` +} diff --git a/frontend/src/screens/Analytics.jsx b/frontend/src/screens/Analytics.jsx index 473152c..8bf71f9 100644 --- a/frontend/src/screens/Analytics.jsx +++ b/frontend/src/screens/Analytics.jsx @@ -34,6 +34,7 @@ import ChartCard from '../ui/ChartCard' import DataTable from '../ui/DataTable' import PageHeader from '../ui/PageHeader' import { EmptyState, Icon } from '../ui/primitives' +import { fmtDate } from '../lib/format' import { qk } from '../lib/queryKeys' import { RANGES, rangeWindow } from '../lib/timeRanges' import { friendlyAuthError } from '../lib/errors' @@ -117,8 +118,8 @@ function AskAnalyticsCard() {

Answered from the {INTENT_LABELS[result.intent] ?? result.intent} query {result.params?.department ? ` · ${result.params.department}` : ''} - {result.params?.from_date ? ` · from ${new Date(result.params.from_date).toLocaleDateString()}` : ''} - {result.params?.to_date ? ` · to ${new Date(result.params.to_date).toLocaleDateString()}` : ''} + {result.params?.from_date ? ` · from ${fmtDate(result.params.from_date)}` : ''} + {result.params?.to_date ? ` · to ${fmtDate(result.params.to_date)}` : ''}

)} {tableColumns && ( diff --git a/frontend/src/screens/Calendar.jsx b/frontend/src/screens/Calendar.jsx index 51286f4..0c8ad62 100644 --- a/frontend/src/screens/Calendar.jsx +++ b/frontend/src/screens/Calendar.jsx @@ -22,6 +22,7 @@ import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as interviewsApi from '../api/interviews' import { byInboxId, useApplications } from '../lib/useApplications' +import { fmtDate, fmtMonthYear, fmtTime } from '../lib/format' import { avatarColor, initials as initialsOf } from '../data/seed' /* Round -> event colour. Unknown rounds fall through to blue rather than @@ -105,7 +106,7 @@ export default function Calendar() { }, [events]) const cells = useMemo(() => buildCells(year, month), [year, month]) - const monthName = new Date(year, month).toLocaleDateString('en-US', { month: 'long', year: 'numeric' }) + const monthName = fmtMonthYear(new Date(year, month, 1)) const todayKey = today.toDateString() const todayIvs = byDay.get(todayKey) ?? [] @@ -157,7 +158,7 @@ export default function Calendar() {
{friendlyAuthError(monthQuery.error, 'The server did not return interviews.')} - {' '}This screen needs the candidates.view permission. + {' '}This screen needs the interviews.view permission.
@@ -180,7 +181,7 @@ export default function Calendar() { title={`${iv.candidate} · ${iv.type}${iv.jobTitle ? ` · ${iv.jobTitle}` : ''}`} onClick={() => openCandidate(iv.userId)} > - {iv.when.toLocaleTimeString('en-US', { hour: 'numeric' })} {iv.candidate.split(' ')[0]} + {fmtTime(iv.when)} {iv.candidate.split(' ')[0]} ))} {dayEvents.length > 3 && ( @@ -198,7 +199,7 @@ export default function Calendar() {

Today

- {today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })} + {fmtDate(today)}
@@ -227,7 +228,7 @@ export default function Calendar() {
- {iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })} + {fmtTime(iv.when)}
{iv.webLink && (
{r.interviewer_name || r.created_by_name || 'Unknown'}
- {toDateInput(r.form_date) || toDateInput(r.created_at)} + {fmtDate(r.form_date) || fmtDate(r.created_at) || '—'} {r.updated_at && r.updated_at !== r.created_at ? ' · revised' : ''}
diff --git a/frontend/src/screens/CandidateProfile.jsx b/frontend/src/screens/CandidateProfile.jsx index cceae3b..ebad426 100644 --- a/frontend/src/screens/CandidateProfile.jsx +++ b/frontend/src/screens/CandidateProfile.jsx @@ -18,7 +18,8 @@ import * as formsApi from '../api/forms' import * as pipelineApi from '../api/pipeline' import * as s3Api from '../api/s3' import CandidateFormsTab from './CandidateForms' -import { companies, fmtDate, moneyK, pick } from '../data/seed' +import { fmtDate, fmtTime, toDate } from '../lib/format' +import { companies, moneyK, pick } from '../data/seed' /* Workflow order: learn (Overview, Resume, Documents) → interview (Interview, Forms) → track (Notes, Activity) → audit (Timeline, History). */ @@ -30,22 +31,19 @@ const INTERVIEW_TYPES = ['Phone Screen', 'Technical', 'System Design', 'Culture const INTERVIEW_STATES = ['Scheduled', 'Completed', 'Cancelled', 'No Show'] const ACTIVITY_TYPES = ['Call', 'Email', 'Meeting', 'Screening', 'Assessment', 'Note'] -/** Seed timestamps are Date objects; the API sends ISO strings. */ +/** Seed timestamps are Date objects; the API sends YYYY-MM-DD[ T]HH:MM strings. */ function fmtWhen(value, fallback = '—') { if (!value) return fallback - const d = value instanceof Date ? value : new Date(value) - return Number.isNaN(d.getTime()) ? String(value) : fmtDate(d) + return fmtDate(value) || fallback } function fmtClock(value) { - if (!value) return null - const d = new Date(value) - return Number.isNaN(d.getTime()) ? null : d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) + return fmtTime(value) || null } function stamp(value) { - const d = value instanceof Date ? value : new Date(value ?? NaN) - return Number.isNaN(d.getTime()) ? 0 : d.getTime() + const d = toDate(value) + return d ? d.getTime() : 0 } /** + -> one ISO instant, or null. */ @@ -749,8 +747,8 @@ const HISTORY_TITLE = { } function historyDayLabel(value) { - const d = value instanceof Date ? value : new Date(value ?? NaN) - if (Number.isNaN(d.getTime())) return 'Unknown' + const d = toDate(value) + if (!d) return 'Unknown' const today = new Date() const yday = new Date() yday.setDate(today.getDate() - 1) diff --git a/frontend/src/screens/Candidates.jsx b/frontend/src/screens/Candidates.jsx index 4855899..3ec14f5 100644 --- a/frontend/src/screens/Candidates.jsx +++ b/frontend/src/screens/Candidates.jsx @@ -226,7 +226,7 @@ function HiringManagerCandidates() { sortValue: (r) => r.created_at || '', render: (r) => ( - {r.created_at ? fmtDate(new Date(r.created_at)) : '—'} + {r.created_at ? fmtDate(r.created_at) : '—'} ), }, @@ -497,18 +497,18 @@ function RecruiterCandidates() { await exportStyledXlsx({ filename: `candidates-${new Date().toISOString().slice(0, 10)}`, title: 'Candidates', - subtitle: `${rows.length} candidate account${rows.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`, + subtitle: `${rows.length} candidate account${rows.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`, columns: [ { header: 'Name', key: 'name', width: 26 }, { header: 'Email', key: 'email', width: 30 }, { header: 'Role', key: 'role', width: 22 }, - { header: 'Applied', key: 'applied', width: 12 }, + { header: 'Applied', key: 'applied', width: 16 }, { header: 'Source', key: 'source', width: 14 }, { header: 'Account', key: 'account', width: 12 }, ], rows: rows.map((c) => ({ name: c.name, email: c.email, role: formatRole(c.roleName), - applied: c.applied ? c.applied.toLocaleDateString() : '', + applied: c.applied ? fmtDate(c.applied) : '', source: c.source, account: c.isActive ? 'Active' : 'Unconfirmed', })), @@ -646,7 +646,7 @@ function RecruiterCandidates() { - {c.applied ? c.applied.toLocaleDateString() : '—'} + {c.applied ? fmtDate(c.applied) : '—'} @@ -729,9 +729,7 @@ const asList = (value) => (Array.isArray(value) ? value : []) /** ISO stamp -> display date; ats_results.computed_at is a string, fmtDate takes a Date. */ function fmtStamp(value) { - if (!value) return null - const d = new Date(value) - return Number.isNaN(d.getTime()) ? null : fmtDate(d) + return fmtDate(value) || null } /** diff --git a/frontend/src/screens/CvImport.jsx b/frontend/src/screens/CvImport.jsx index c0772f3..8ffbe71 100644 --- a/frontend/src/screens/CvImport.jsx +++ b/frontend/src/screens/CvImport.jsx @@ -21,6 +21,7 @@ import { Badge, EmptyState, Icon, ScoreChip } from '../ui/primitives' import { useToast } from '../ui/Toast' import JobCandidates from './JobCandidates' import { qk } from '../lib/queryKeys' +import { fmtDate } from '../lib/format' import { friendlyAuthError } from '../lib/errors' import * as candidatesApi from '../api/candidates' import * as s3Api from '../api/s3' @@ -429,7 +430,7 @@ function CvBank() {
{r.file_name || 'CV'}
{r.candidate_email || 'No email detected'} - {r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''} + {r.created_at ? ` · added ${fmtDate(r.created_at)}` : ''}
{r.linkedin_url ? (
diff --git a/frontend/src/screens/Dashboard.jsx b/frontend/src/screens/Dashboard.jsx index 30f6689..aa90eaa 100644 --- a/frontend/src/screens/Dashboard.jsx +++ b/frontend/src/screens/Dashboard.jsx @@ -35,7 +35,8 @@ import { useAuth } from '../auth/AuthContext' import { isHiringManager } from '../auth/permissions' import { qk } from '../lib/queryKeys' import { RANGES, rangeLabel, rangeWindow } from '../lib/timeRanges' -import { fmtShort, money } from '../data/seed' +import { fmtWeekdayDate, fmtShort } from '../lib/format' +import { money } from '../data/seed' import * as activityApi from '../api/activity' import * as analyticsApi from '../api/analytics' import * as inboxApi from '../api/inbox' @@ -94,18 +95,11 @@ function greetingFor(now = new Date()) { } function formatDashDate(d = new Date()) { - return d.toLocaleDateString('en-US', { - weekday: 'long', - month: 'long', - day: 'numeric', - year: 'numeric', - }) + return fmtWeekdayDate(d) } function fmtWhen(iso) { - if (!iso) return '—' - const d = new Date(iso) - return Number.isNaN(d.getTime()) ? '—' : fmtShort(d) + return fmtShort(iso) || '—' } function ListGate({ query, title, permission, children, emptyTitle, emptyHint }) { diff --git a/frontend/src/screens/Inbox.jsx b/frontend/src/screens/Inbox.jsx index 41a2b54..ed3bd48 100644 --- a/frontend/src/screens/Inbox.jsx +++ b/frontend/src/screens/Inbox.jsx @@ -26,13 +26,13 @@ import { useAuth } from '../auth/AuthContext' import { seedQuery, useSeedMutation } from '../data/seedQueries' import { qk } from '../lib/queryKeys' import { exportStyledXlsx } from '../lib/exportXlsx' -import { formatRole } from '../lib/format' +import { formatRole, fmtDate, fmtDateTime, fmtTime, toDate } from '../lib/format' import { friendlyAuthError } from '../lib/errors' import * as inboxApi from '../api/inbox' import * as sheetApi from '../api/sheet' import * as s3Api from '../api/s3' import { - atsRecommendationClass, avatarColor, fmtDate, initials as initialsOf, + atsRecommendationClass, avatarColor, initials as initialsOf, inboxSources, sourceMeta, } from '../data/seed' @@ -101,44 +101,11 @@ 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. An unparseable value yields - * an Invalid Date that every fmt* helper renders as the literal "Invalid Date", - * so return null instead and let the call sites decide what to show. + * (backend/inbox/models.py:54-56), not timestamps. Unparseable values + * become null — never "Invalid Date". */ function parseDate(value) { - if (!value) return null - const d = new Date(value) - return Number.isNaN(d.getTime()) ? null : d -} - -function startOfDay(d) { - return new Date(d.getFullYear(), d.getMonth(), d.getDate()) -} - -/** - * Outlook-style list timestamps in the client's local timezone. - * Within 7 days: weekday + AM/PM time. Older: dd/mm/yyyy only — like Outlook. - * The date-plus-time form was ~105px wide, and in the 380px queue rail that - * squeezed .ii-main to 148px: sender names painted over the timestamp and the - * meta chips wrapped one-per-line. The full timestamp is in the detail pane. - */ -function outlookListTime(value) { - if (!value) return '—' - const d = value instanceof Date ? value : new Date(value) - if (Number.isNaN(d.getTime())) return '—' - const daysAgo = Math.round((startOfDay(new Date()) - startOfDay(d)) / 86400000) - if (daysAgo < 7) { - const weekday = d.toLocaleDateString(undefined, { weekday: 'short' }) - const time = d.toLocaleTimeString(undefined, { - hour: 'numeric', - minute: '2-digit', - hour12: true, - }) - return `${weekday} ${time}` - } - const dd = String(d.getDate()).padStart(2, '0') - const mm = String(d.getMonth() + 1).padStart(2, '0') - return `${dd}/${mm}/${d.getFullYear()}` + return toDate(value) } /** @@ -160,12 +127,65 @@ function sourceFrom(messageTo) { /** Sheet Forms always chip as Google Sheet — not the form's "where did you hear" answer. */ const FORM_LIST_SOURCE = { source: 'Google Sheet', sourceMeta: SHEET_SOURCE_META } -/** entry_date is midnight UTC; entry_time is a separate "HH:MM" string from the sheet. */ -function formReceivedAt(entryDate, entryTime) { - const d = parseDate(entryDate) +/** Google Form "Timestamp" cell — US M/D/YYYY, e.g. 8/12/2026 8:25:19 → 12th Aug. */ +function rawFormTimestamp(row) { + const raw = row?.raw_record + if (!raw || typeof raw !== 'object') return null + for (const [key, val] of Object.entries(raw)) { + if (String(key).trim().toLowerCase() === 'timestamp' && val != null && String(val).trim()) { + return String(val).trim() + } + } + return null +} + +function parseFormTimestamp(value) { + if (value == null || value === '') return null + const s = String(value).trim() + const m = s.match(/^(\d{1,2})[/\-.](\d{1,2})[/\-.](\d{2,4})(?:[,\s]+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(am|pm)?)?/i) + if (m) { + const a = Number(m[1]) + const b = Number(m[2]) + let year = Number(m[3]) + if (year < 100) year += 2000 + let month + let day + if (a > 12 && b <= 12) { + day = a + month = b + } else { + month = a + day = b + } + if (month < 1 || month > 12 || day < 1 || day > 31) return null + let hours = m[4] != null ? Number(m[4]) : 0 + const minutes = m[5] != null ? Number(m[5]) : 0 + const seconds = m[6] != null ? Number(m[6]) : 0 + const ap = (m[7] || '').toLowerCase() + if (ap === 'pm' && hours < 12) hours += 12 + if (ap === 'am' && hours === 12) hours = 0 + const d = new Date(year, month - 1, day, hours, minutes, seconds) + if (d.getFullYear() !== year || d.getMonth() !== month - 1 || d.getDate() !== day) return null + return d + } + return toDate(s) +} + +/** Prefer the original sheet Timestamp so already-imported swapped dates still display right. */ +function formReceivedAt(entryDate, entryTime, timestampRaw) { + const fromSheet = parseFormTimestamp(timestampRaw) + if (fromSheet) return fromSheet + const d = toDate(entryDate) if (!d) return null - const m = String(entryTime || '').match(/(\d{1,2}):(\d{2})/) - if (m) d.setHours(Number(m[1]), Number(m[2]), 0, 0) + const m = String(entryTime || '').trim().match(/(\d{1,2}):(\d{2})\s*(am|pm)?/i) + if (m) { + let hours = Number(m[1]) + const minutes = Number(m[2]) + const ap = (m[3] || '').toLowerCase() + if (ap === 'pm' && hours < 12) hours += 12 + if (ap === 'am' && hours === 12) hours = 0 + d.setHours(hours, minutes, 0, 0) + } return d } @@ -206,7 +226,7 @@ function mapFormRow(row) { phone: row.candidate_number || '', position: row.position_applied_for || '—', ...FORM_LIST_SOURCE, - received: formReceivedAt(row.entry_date, row.entry_time), + received: formReceivedAt(row.entry_date, row.entry_time, rawFormTimestamp(row)), screenedBy: row.screened_by || '', hrComments: row.hr_comments || '', gender: row.gender || '', @@ -1093,7 +1113,7 @@ export default function Inbox() { { header: 'Position', key: 'position', width: 34 }, { header: 'Channel', key: 'channel', width: 12 }, { header: 'Source', key: 'source', width: 20 }, - { header: 'Received', key: 'received', width: 12 }, + { header: 'Received', key: 'received', width: 16 }, { header: 'Status', key: 'status', width: 12 }, { header: 'City', key: 'city', width: 14 }, { header: 'Notice period', key: 'notice', width: 13 }, @@ -1104,7 +1124,7 @@ export default function Inbox() { name: r.name, email: r.email, phone: r.phone, position: r.position, channel: r.kind === 'form' ? 'Sheet Form' : 'Email', source: r.source, - received: r.received ? r.received.toISOString().slice(0, 10) : '', + received: r.received ? fmtDate(r.received) : '', status: r.processing, city: r.residingCity, notice: r.noticePeriod, ats: r.atsScore, job: r.assignedPost?.title, })), @@ -1340,14 +1360,16 @@ export default function Inbox() {
{i.position}
- {outlookListTime(i.received)} + {i.received ? ( + <> +
{fmtDate(i.received)}
+
{fmtTime(i.received)}
+ + ) : '—'}
{asAtsScore(i.atsScore) != null && (
)} - {i.kind === 'form' && i.noticePeriod && ( -
{i.noticePeriod}
- )}
{i.processing} @@ -1710,7 +1732,7 @@ function FormApplicantDetail({
Email
{orDash(i.email)}
Phone
{orDash(i.phone)}
-
Applied
{i.received ? fmtDate(i.received) : '—'}
+
Applied
{i.received ? fmtDateTime(i.received) : '—'}
Source
{orDash(i.source)}
Screened by
{orDash(i.screenedBy)}
Notice period
{orDash(i.noticePeriod)}
@@ -1746,68 +1768,6 @@ function FormApplicantDetail({ )}
-
-
Matching roles
-
- Matched by position applied for: {orDash(i.position)} - {i.jobPosts?.length > 1 ? ` · ${i.jobPosts.length} roles` : ''} -
- {matchCards.length === 0 && !manualPost ? ( - -

No job post title matches this position. Choose a role manually.

-
- -
-
- ) : ( - matchCards.map(({ rank, post }) => ( - setSelection(String(id))} - /> - )) - )} - {manualPost && ( - setSelection(String(id))} - /> - )} - -
Assigned Recruiter
{orDash(i.recruiter)}
Received
-
{i.received ? fmtDate(i.received) : '—'}
+
{i.received ? fmtDateTime(i.received) : '—'}
{i.sentAt && ( -
Sent
{fmtDate(i.sentAt)}
+
Sent
{fmtDateTime(i.sentAt)}
)} {i.cc &&
CC
{i.cc}
} {i.bcc &&
BCC
{i.bcc}
} diff --git a/frontend/src/screens/Interviews.jsx b/frontend/src/screens/Interviews.jsx index 056978a..00a8084 100644 --- a/frontend/src/screens/Interviews.jsx +++ b/frontend/src/screens/Interviews.jsx @@ -36,7 +36,8 @@ import * as candidatesApi from '../api/candidates' import * as feedbackApi from '../api/feedback' import { INTERVIEW_STATUSES, INTERVIEW_TYPES } from '../api/interviews' import { byInboxId, useApplications } from '../lib/useApplications' -import { avatarColor, fmtShort, initials as initialsOf } from '../data/seed' +import { fmtShort, fmtTime } from '../lib/format' +import { avatarColor, initials as initialsOf } from '../data/seed' const FETCH_TOP = 200 @@ -48,7 +49,7 @@ function toInstant(date, time) { } function clock(d) { - return d ? d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) : '—' + return fmtTime(d) || '—' } function sameDay(a, b) { @@ -313,7 +314,7 @@ export default function Interviews() {
{friendlyAuthError(listQuery.error, 'The server did not return interviews.')} - {' '}This screen needs the candidates.view permission. + {' '}This screen needs the interviews.view permission.
)} diff --git a/frontend/src/screens/JobBoard.jsx b/frontend/src/screens/JobBoard.jsx index 0c83f08..51a835e 100644 --- a/frontend/src/screens/JobBoard.jsx +++ b/frontend/src/screens/JobBoard.jsx @@ -33,6 +33,7 @@ import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import { platformLabel, platformOptions, platformService } from '../lib/platforms' import * as jobPostsApi from '../api/jobPosts' +import { toDate } from '../lib/format' import { fmtShort } from '../data/seed' const POST_LIMIT = 300 @@ -74,10 +75,10 @@ export default function JobBoard() { status: row.status || 'draft', link: row.buffer_external_link || null, postId: row.buffer_post_id || null, - sentAt: row.buffer_sent_at ? new Date(row.buffer_sent_at) : null, + sentAt: toDate(row.buffer_sent_at), error: row.buffer_error || null, isActive: row.is_active, - created: row.created_at ? new Date(row.created_at) : null, + created: toDate(row.created_at), createdBy: row.created_by_name || null, location: row.location || null, employmentType: row.employment_type || null, diff --git a/frontend/src/screens/Jobs.jsx b/frontend/src/screens/Jobs.jsx index e3c516c..870c1c1 100644 --- a/frontend/src/screens/Jobs.jsx +++ b/frontend/src/screens/Jobs.jsx @@ -29,7 +29,8 @@ import * as assignmentsApi from '../api/assignments' import * as tasksApi from '../api/tasks' import * as usersApi from '../api/users' import * as requisitionsApi from '../api/requisitions' -import { empTypes, fmtShort } from '../data/seed' +import { fmtDateTime, fmtShort, toDate } from '../lib/format' +import { empTypes } from '../data/seed' // Backend allows up to 500; stay at 100 so we match Managers.jsx (shared qk.jobs.list). const JOB_LIMIT = 100 @@ -407,10 +408,7 @@ const MAX_IMAGE_MB = 5 const ROLE_LABEL = { primary_recruiter: 'Recruiter', hiring_manager: 'Hiring manager' } function fmtWhen(value) { - const d = value instanceof Date ? value : new Date(value ?? NaN) - if (Number.isNaN(d.getTime())) return null - const clock = d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' }) - return `${fmtShort(d)} · ${clock}` + return fmtDateTime(value) || null } /** @@ -1227,7 +1225,7 @@ function JobHistory({ historyQuery, statusQuery }) { ...statusRows.map((row) => ({ kind: 'status', id: `s-${row.id}`, - at: row.created_at ? new Date(row.created_at) : null, + at: toDate(row.created_at), row, })), ].sort((a, b) => (b.at?.getTime() || 0) - (a.at?.getTime() || 0)) diff --git a/frontend/src/screens/Matching.jsx b/frontend/src/screens/Matching.jsx index d311d84..de45027 100644 --- a/frontend/src/screens/Matching.jsx +++ b/frontend/src/screens/Matching.jsx @@ -25,6 +25,7 @@ import * as candidatesApi from '../api/candidates' import * as jobPostsApi from '../api/jobPosts' import * as s3Api from '../api/s3' import { avatarColor, fmtDate, initials as initialsOf } from '../data/seed' +import { toDate } from '../lib/format' const TABS = [ { key: 'needs', label: 'Needs assignment' }, @@ -42,12 +43,6 @@ const PAGE_SIZE_MAX = 500 const CV_BANK_META = { icon: 'file', color: 'var(--c2)', channel: 'Upload' } -function parseDate(value) { - if (!value) return null - const d = new Date(value) - return Number.isNaN(d.getTime()) ? null : d -} - function mapRow(row) { const name = row.name || row.email || row.file_name || 'Unknown' return { @@ -59,7 +54,7 @@ function mapRow(row) { position: row.file_name || 'CV bank', source: 'CV bank', sourceMeta: CV_BANK_META, - received: parseDate(row.created_at), + received: toDate(row.created_at), resumeText: row.resume_text || '', filePath: row.file_path || '', assignedId: row.assigned_job_post_id ? String(row.assigned_job_post_id) : null, diff --git a/frontend/src/screens/Reports.jsx b/frontend/src/screens/Reports.jsx index 794431f..82938f6 100644 --- a/frontend/src/screens/Reports.jsx +++ b/frontend/src/screens/Reports.jsx @@ -46,6 +46,7 @@ import * as analyticsApi from '../api/analytics' import * as costsApi from '../api/costs' import * as jobsApi from '../api/jobs' import * as reportsApi from '../api/reports' +import { fmtDate, fmtDateTime, toDate } from '../lib/format' import { money } from '../data/seed' const DEPT_CAP = 12 @@ -108,7 +109,7 @@ function reportWindowLabel(filters) { function runSubtitle(result) { const win = result?.window || {} - const fmt = (v) => (v ? new Date(v).toLocaleDateString() : null) + const fmt = (v) => (v ? fmtDate(v) || null : null) const from = fmt(win.from_date) const to = fmt(win.to_date) const range = from || to ? `${from ?? '…'} – ${to ?? 'now'}` : 'All time' @@ -383,9 +384,9 @@ export default function Reports() { { key: '_window', label: 'Window', render: (r) => reportWindowLabel(r.filters) }, { key: 'last_run_at', label: 'Last Run', sortable: true, - sortValue: (r) => (r.last_run_at ? new Date(r.last_run_at).getTime() : 0), + sortValue: (r) => toDate(r.last_run_at)?.getTime() ?? 0, render: (r) => (r.last_run_at - ? new Date(r.last_run_at).toLocaleString() + ? fmtDateTime(r.last_run_at) : never), }, { diff --git a/frontend/src/screens/Requisitions.jsx b/frontend/src/screens/Requisitions.jsx index a4241e9..b2ed6ec 100644 --- a/frontend/src/screens/Requisitions.jsx +++ b/frontend/src/screens/Requisitions.jsx @@ -21,7 +21,7 @@ import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' import * as requisitionsApi from '../api/requisitions' import { EMPLOYMENT_TYPES, EMPLOYMENT_TYPE_LABEL, approvalStatus } from '../api/requisitions' -import { fmtShort } from '../data/seed' +import { fmtShort, toDateInput } from '../lib/format' const TYPE_BADGE = { permanent: 'b-indigo', @@ -36,12 +36,6 @@ const STATUS_BADGE = { open: 'b-indigo', } -function toDateInput(value) { - if (!value) return '' - const d = new Date(value) - return Number.isNaN(d.getTime()) ? String(value).slice(0, 10) : d.toISOString().slice(0, 10) -} - function emptyToNull(value) { if (value === '' || value == null) return null return value @@ -143,7 +137,7 @@ export default function Requisitions() { sortValue: (r) => r.position?.date_needed || '', render: (r) => ( - {r.position?.date_needed ? fmtShort(new Date(r.position.date_needed)) : '—'} + {r.position?.date_needed ? fmtShort(r.position.date_needed) : '—'} ), }, @@ -155,7 +149,7 @@ export default function Requisitions() { render: (r) => ( <>
{r.initiated_by || '—'}
-
{r.initiated_date ? fmtShort(new Date(r.initiated_date)) : ''}
+
{r.initiated_date ? fmtShort(r.initiated_date) : ''}
), }, diff --git a/frontend/src/screens/ScoredCandidateProfile.jsx b/frontend/src/screens/ScoredCandidateProfile.jsx index 420eaf9..eb03c6d 100644 --- a/frontend/src/screens/ScoredCandidateProfile.jsx +++ b/frontend/src/screens/ScoredCandidateProfile.jsx @@ -55,9 +55,7 @@ function formatExperience(value, unit) { /** ats_results.computed_at is an ISO string; fmtDate takes a Date. */ function fmtStamp(value) { - if (!value) return null - const d = new Date(value) - return Number.isNaN(d.getTime()) ? null : fmtDate(d) + return fmtDate(value) || null } function useCandidateDetail(userId) { diff --git a/frontend/src/screens/Settings.jsx b/frontend/src/screens/Settings.jsx index 2a4fa2f..41be6bf 100644 --- a/frontend/src/screens/Settings.jsx +++ b/frontend/src/screens/Settings.jsx @@ -18,7 +18,7 @@ import { useFormState } from '../components/AuthLayout' import { usePermission } from '../auth/AuthContext' import { qk } from '../lib/queryKeys' import { friendlyAuthError } from '../lib/errors' -import { formatRole } from '../lib/format' +import { formatRole, fmtDate } from '../lib/format' import * as rolesApi from '../api/roles' import * as usersApi from '../api/users' import * as orgSettingsApi from '../api/orgSettings' @@ -331,7 +331,7 @@ function Users() { {!u.is_active ? 'Pending' : u.is_approved ? 'Active' : 'Awaiting approval'} - {u.created_at ? String(u.created_at).slice(0, 10) : '—'} + {u.created_at ? fmtDate(u.created_at) : '—'}