Implement_Changes #65

Merged
ahmed.mujtaba merged 2 commits from Implement_Changes into main 2026-09-03 13:18:41 +00:00
37 changed files with 402 additions and 251 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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 <input type="date">, 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}`
}

View File

@ -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() {
<p className="text-muted" style={{ marginTop: 8, fontSize: 12 }}>
<Icon name="info" /> 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)}` : ''}
</p>
)}
{tableColumns && (

View File

@ -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() {
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load the calendar">
{friendlyAuthError(monthQuery.error, 'The server did not return interviews.')}
{' '}This screen needs the <code>candidates.view</code> permission.
{' '}This screen needs the <code>interviews.view</code> permission.
</EmptyState>
</div>
</div>
@ -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]}
</div>
))}
{dayEvents.length > 3 && (
@ -198,7 +199,7 @@ export default function Calendar() {
<div>
<h3>Today</h3>
<span className="ch-sub">
{today.toLocaleDateString('en-US', { month: 'long', day: 'numeric', year: 'numeric' })}
{fmtDate(today)}
</span>
</div>
</div>
@ -227,7 +228,7 @@ export default function Calendar() {
</div>
<div className="lr-right">
<div className="fw-600 text-sm">
{iv.when.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}
{fmtTime(iv.when)}
</div>
{iv.webLink && (
<a

View File

@ -23,6 +23,7 @@ import { Badge, EmptyState, FieldError, Icon } from '../ui/primitives'
import { useToast } from '../ui/Toast'
import { useAuth } from '../auth/AuthContext'
import { isHiringManager } from '../auth/permissions'
import { fmtDate, toDateInput } from '../lib/format'
import { qk } from '../lib/queryKeys'
import { friendlyAuthError } from '../lib/errors'
import * as formsApi from '../api/forms'
@ -53,12 +54,6 @@ function titleCase(status) {
return s ? s.charAt(0).toUpperCase() + s.slice(1).toLowerCase() : 'Shortlist'
}
function toDateInput(value) {
if (!value) return ''
const d = new Date(value)
return Number.isNaN(d.getTime()) ? '' : d.toISOString().slice(0, 10)
}
/** Same shape as the profile's useProfileWrite, plus the forms/offers caches. */
function useFormsWrite({ userId, mutationFn, success, onDone }) {
const qc = useQueryClient()
@ -276,7 +271,7 @@ function FormRowList({ rows, defs, onEdit, canEdit }) {
<div className="lr-main">
<div className="lr-title">{r.interviewer_name || r.created_by_name || 'Unknown'}</div>
<div className="lr-sub">
{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' : ''}
</div>
</div>

View File

@ -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
}
/** <input type="date"> + <input type="time"> -> 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)

View File

@ -226,7 +226,7 @@ function HiringManagerCandidates() {
sortValue: (r) => r.created_at || '',
render: (r) => (
<span className="text-muted">
{r.created_at ? fmtDate(new Date(r.created_at)) : '—'}
{r.created_at ? fmtDate(r.created_at) : '—'}
</span>
),
},
@ -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() {
</td>
<td>
<span className="text-sm">
{c.applied ? c.applied.toLocaleDateString() : '—'}
{c.applied ? fmtDate(c.applied) : '—'}
</span>
</td>
</tr>
@ -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
}
/**

View File

@ -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() {
<div className="fw-600 text-sm">{r.file_name || 'CV'}</div>
<div className="cell-sub">
{r.candidate_email || 'No email detected'}
{r.created_at ? ` · added ${new Date(r.created_at).toLocaleDateString()}` : ''}
{r.created_at ? ` · added ${fmtDate(r.created_at)}` : ''}
</div>
{r.linkedin_url ? (
<div className="cell-sub" style={{ marginTop: 2 }}>

View File

@ -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 }) {

View File

@ -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() {
<div className="ii-pos">{i.position}</div>
<div className="ii-aside">
<div className="ii-time">
{outlookListTime(i.received)}
{i.received ? (
<>
<div>{fmtDate(i.received)}</div>
<div>{fmtTime(i.received)}</div>
</>
) : '—'}
</div>
{asAtsScore(i.atsScore) != null && (
<div style={{ marginTop: 6 }}><ScoreChip score={i.atsScore} /></div>
)}
{i.kind === 'form' && i.noticePeriod && (
<div className="cell-sub" style={{ marginTop: 6 }}>{i.noticePeriod}</div>
)}
</div>
<div className="ii-meta">
<SourceChip item={i} /> <Badge>{i.processing}</Badge>
@ -1710,7 +1732,7 @@ function FormApplicantDetail({
<div className="info-grid">
<div className="info-item"><div className="il">Email</div><div className="iv">{orDash(i.email)}</div></div>
<div className="info-item"><div className="il">Phone</div><div className="iv">{orDash(i.phone)}</div></div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDate(i.received) : '—'}</div></div>
<div className="info-item"><div className="il">Applied</div><div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div></div>
<div className="info-item"><div className="il">Source</div><div className="iv">{orDash(i.source)}</div></div>
<div className="info-item"><div className="il">Screened by</div><div className="iv">{orDash(i.screenedBy)}</div></div>
<div className="info-item"><div className="il">Notice period</div><div className="iv">{orDash(i.noticePeriod)}</div></div>
@ -1746,68 +1768,6 @@ function FormApplicantDetail({
)}
</div>
<div role="radiogroup" aria-label="Matching roles" style={{ flex: '0 1 360px', minWidth: 260 }}>
<div className="fw-600" style={{ marginBottom: 8 }}>Matching roles</div>
<div className="cell-sub" style={{ marginBottom: 10 }}>
Matched by position applied for: {orDash(i.position)}
{i.jobPosts?.length > 1 ? ` · ${i.jobPosts.length} roles` : ''}
</div>
{matchCards.length === 0 && !manualPost ? (
<EmptyState icon="alert" title="No matching roles">
<p>No job post title matches this position. Choose a role manually.</p>
<div style={{ display: 'flex', gap: 8, justifyContent: 'center', flexWrap: 'wrap', marginTop: 10 }}>
<button
className="btn btn-primary btn-sm"
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a role
</button>
</div>
</EmptyState>
) : (
matchCards.map(({ rank, post }) => (
<JobCard
key={post.id}
post={post}
rank={rank}
badge={`Match #${rank}`}
selected={String(selection) === String(post.id)}
onSelect={(id) => setSelection(String(id))}
/>
))
)}
{manualPost && (
<JobCard
post={manualPost}
rank={0}
manual
selected={String(selection) === String(manualPost.id)}
onSelect={(id) => setSelection(String(id))}
/>
)}
<button
className="btn btn-secondary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canEdit}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => setShowPicker(true)}
>
Choose a different role
</button>
<button
className="btn btn-primary"
style={{ width: '100%', marginTop: 8 }}
disabled={!canAssign}
title={!canEdit ? 'Requires inbox.edit' : undefined}
onClick={() => {
if (!selection || !canEdit) return
assignMutation.mutate({ recordId: i.id, jobPostId: selection })
}}
>
Assign
</button>
<div
role="radiogroup"
aria-label="Matching roles"
@ -2086,10 +2046,10 @@ function ApplicationDetail({
<div className="info-item"><div className="il">Assigned Recruiter</div><div className="iv">{orDash(i.recruiter)}</div></div>
<div className="info-item">
<div className="il">Received</div>
<div className="iv">{i.received ? fmtDate(i.received) : '—'}</div>
<div className="iv">{i.received ? fmtDateTime(i.received) : '—'}</div>
</div>
{i.sentAt && (
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDate(i.sentAt)}</div></div>
<div className="info-item"><div className="il">Sent</div><div className="iv">{fmtDateTime(i.sentAt)}</div></div>
)}
{i.cc && <div className="info-item"><div className="il">CC</div><div className="iv">{i.cc}</div></div>}
{i.bcc && <div className="info-item"><div className="il">BCC</div><div className="iv">{i.bcc}</div></div>}

View File

@ -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() {
<div className="card-body">
<EmptyState icon="alert" title="Couldnt load interviews">
{friendlyAuthError(listQuery.error, 'The server did not return interviews.')}
{' '}This screen needs the <code>candidates.view</code> permission.
{' '}This screen needs the <code>interviews.view</code> permission.
</EmptyState>
</div>
)}

View File

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

View File

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

View File

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

View File

@ -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)
: <span className="text-muted">never</span>),
},
{

View File

@ -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) => (
<span className="text-muted">
{r.position?.date_needed ? fmtShort(new Date(r.position.date_needed)) : '—'}
{r.position?.date_needed ? fmtShort(r.position.date_needed) : '—'}
</span>
),
},
@ -155,7 +149,7 @@ export default function Requisitions() {
render: (r) => (
<>
<div className="cell-primary text-sm">{r.initiated_by || '—'}</div>
<div className="cell-sub">{r.initiated_date ? fmtShort(new Date(r.initiated_date)) : ''}</div>
<div className="cell-sub">{r.initiated_date ? fmtShort(r.initiated_date) : ''}</div>
</>
),
},

View File

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

View File

@ -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'}
</Badge>
</td>
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
<td className="text-muted">{u.created_at ? fmtDate(u.created_at) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<div className="row-actions">
<button
@ -427,7 +427,7 @@ function Approvals() {
</div>
</td>
<td><Badge className="b-indigo">{formatRole(u.role_name) || 'No role'}</Badge></td>
<td className="text-muted">{u.created_at ? String(u.created_at).slice(0, 10) : '—'}</td>
<td className="text-muted">{u.created_at ? fmtDate(u.created_at) : '—'}</td>
<td style={{ textAlign: 'right' }}>
<button
className="btn btn-primary btn-sm"

View File

@ -160,7 +160,7 @@ function AppliedBadge({ applied }) {
const tip = [
applied.candidate,
applied.status ? `status ${applied.status}` : null,
applied.applied_at ? `applied ${new Date(applied.applied_at).toLocaleDateString()}` : null,
applied.applied_at ? `applied ${fmtDate(applied.applied_at)}` : null,
applied.applications > 1 ? `${applied.applications} applications` : null,
].filter(Boolean).join(' · ')
return (

View File

@ -46,6 +46,7 @@ import { friendlyAuthError } from '../lib/errors'
import * as candidatesApi from '../api/candidates'
import * as jobPostsApi from '../api/jobPosts'
import * as pipelineApi from '../api/pipeline'
import { fmtDate } from '../lib/format'
import { avatarColor, initials as initialsOf } from '../data/seed'
/** Backend GET /candidate/fetch caps `limit` at 100. */
@ -258,7 +259,7 @@ export default function TalentPool() {
await exportStyledXlsx({
filename: `talent-pool-${new Date().toISOString().slice(0, 10)}`,
title: 'Talent Pool',
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${new Date().toLocaleDateString()}`,
subtitle: `${list.length} candidate${list.length === 1 ? '' : 's'} · exported ${fmtDate(new Date())}`,
columns: [
{ header: 'Name', key: 'name', width: 24 },
{ header: 'Email', key: 'email', width: 28 },

View File

@ -1020,7 +1020,7 @@ canvas { width: 100%; max-width: 100%; display: block; }
.ii-name { font-weight: 600; font-size: var(--fs-base); display: flex; align-items: center; gap: 6px; min-width: 0; }
.ii-pos { font-size: 12.5px; color: var(--text-2); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.ii-meta { display: flex; align-items: center; gap: 8px; margin-top: 5px; }
.ii-time { font-size: 11px; color: var(--text-3); white-space: nowrap; }
.ii-time { font-size: 11px; color: var(--text-3); line-height: 1.35; }
/* Inbox sidebar only: fit the list instead of scrolling sideways.
Username (.ii-name) and subject (.ii-pos) are left alone.
The list column yields (34%, floor 280px) instead of holding a hard 420px,