Use Denver local time for all scraper DB timestamps
All checks were successful
CI/CD Pipeline - Apartment API / Lint & Test (push) Successful in 44s
CI/CD Pipeline - Apartment API / Scan Dependencies (push) Successful in 13s
CI/CD Pipeline - Apartment API / Send Webhook Notification (push) Has been skipped
CI/CD Pipeline - Apartment API / Build & Push Image (push) Successful in 1m37s
CI/CD Pipeline - Apartment API / Deploy to Production (push) Successful in 15s

The scraper used new Date().toISOString() for timestamp fields written
to MongoDB, producing UTC times. This replaces all 6 DB timestamp sites
with a new getNow() helper that uses Intl.DateTimeFormat with the
configured SCRAPER_TIMEZONE to produce Denver-local timestamps.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-09 12:35:48 -07:00
parent 275857963c
commit 911f76520f
6 changed files with 51 additions and 27 deletions

View File

@ -15,8 +15,9 @@
const { MongoClient } = require('mongodb');
const { MongoMemoryServer } = require('mongodb-memory-server');
// Will require insertPrices after implementation
// Will require insertPrices and getNow after implementation
let insertPrices;
let getNow;
let mongoServer;
let client;
@ -41,7 +42,7 @@ beforeAll(async () => {
db = client.db('test_apartments');
// Dynamically require to pick up implementation
({ insertPrices } = require('../../services/scraperService'));
({ insertPrices, getNow } = require('../../services/scraperService'));
});
afterAll(async () => {
@ -241,12 +242,12 @@ describe('insertPrices', () => {
it('should include last_updated as an ISO timestamp string', async () => {
const logger = createMockLogger();
const beforeTime = new Date().toISOString();
const beforeTime = getNow();
const units = [{ unit_code: 'F401', price: 1600 }];
await insertPrices(db, units, TODAY, logger);
const afterTime = new Date().toISOString();
const afterTime = getNow();
const doc = await db.collection(PRICES_COLLECTION).findOne({});
expect(doc.last_updated).toBeDefined();

View File

@ -15,6 +15,7 @@ const { MongoMemoryServer } = require('mongodb-memory-server');
// Will require after implementation
let recordScraperRun;
let getNow;
let mongoServer;
let client;
@ -29,7 +30,7 @@ beforeAll(async () => {
db = client.db('test_apartments');
// Dynamically require to pick up implementation
({ recordScraperRun } = require('../../services/scraperService'));
({ recordScraperRun, getNow } = require('../../services/scraperService'));
});
afterAll(async () => {
@ -269,26 +270,26 @@ describe('recordScraperRun', () => {
// 6. recordedAt is a valid Date object
// ---------------------------------------------------------------
describe('recordedAt field', () => {
it('should set recordedAt as a Date instance', async () => {
it('should set recordedAt as a string timestamp', async () => {
const runData = createSampleRunData();
await recordScraperRun(db, runData, logger);
const doc = await db.collection(SCRAPER_RUNS_COLLECTION).findOne({});
expect(doc.recordedAt).toBeInstanceOf(Date);
expect(typeof doc.recordedAt).toBe('string');
});
it('should set recordedAt close to current time', async () => {
const beforeTime = new Date();
const beforeTime = getNow();
const runData = createSampleRunData();
await recordScraperRun(db, runData, logger);
const afterTime = new Date();
const afterTime = getNow();
const doc = await db.collection(SCRAPER_RUNS_COLLECTION).findOne({});
expect(doc.recordedAt.getTime()).toBeGreaterThanOrEqual(beforeTime.getTime());
expect(doc.recordedAt.getTime()).toBeLessThanOrEqual(afterTime.getTime());
expect(doc.recordedAt >= beforeTime).toBe(true);
expect(doc.recordedAt <= afterTime).toBe(true);
});
it('should not overwrite any existing runData fields with recordedAt', async () => {
@ -301,7 +302,7 @@ describe('recordScraperRun', () => {
// Verify the original fields still exist alongside recordedAt
expect(doc.jobId).toBe(runData.jobId);
expect(doc.status).toBe(runData.status);
expect(doc.recordedAt).toBeInstanceOf(Date);
expect(typeof doc.recordedAt).toBe('string');
});
});
});

View File

@ -121,7 +121,7 @@ describe('recordScraperRun', () => {
const saved = await db.collection('scraper_runs').findOne({ jobId: 'test-job-001' });
expect(saved).toBeTruthy();
expect(saved.status).toBe('success');
expect(saved.recordedAt).toBeInstanceOf(Date);
expect(typeof saved.recordedAt).toBe('string');
});
it('should not throw when insert fails', async () => {
@ -263,7 +263,7 @@ describe('runScrape', () => {
const runRecord = await db.collection('scraper_runs').findOne({ jobId: 'test-record-success' });
expect(runRecord).toBeTruthy();
expect(runRecord.status).toBe('success');
expect(runRecord.recordedAt).toBeInstanceOf(Date);
expect(typeof runRecord.recordedAt).toBe('string');
expect(runRecord.unitsProcessed).toBe(1);
});

View File

@ -554,8 +554,8 @@ describe('updateDailySummary', () => {
const doc = await db.collection(DAILY_SUMMARIES_COLLECTION).findOne({ date: '2026-02-05' });
expect(doc.timestamp).toBeDefined();
expect(typeof doc.timestamp).toBe('string');
// Verify it is a valid ISO timestamp
expect(new Date(doc.timestamp).toISOString()).toBe(doc.timestamp);
// Verify it matches the Denver-local timestamp format (YYYY-MM-DDTHH:mm:ss.mmm)
expect(doc.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/);
});
});
});

View File

@ -15,8 +15,9 @@
const { MongoClient } = require('mongodb');
const { MongoMemoryServer } = require('mongodb-memory-server');
// We will require upsertUnits after implementation
// We will require upsertUnits and getNow after implementation
let upsertUnits;
let getNow;
let mongoServer;
let client;
@ -39,7 +40,7 @@ beforeAll(async () => {
db = client.db('test_apartments');
// Dynamically require to pick up implementation
({ upsertUnits } = require('../../services/scraperService'));
({ upsertUnits, getNow } = require('../../services/scraperService'));
});
afterAll(async () => {
@ -145,12 +146,12 @@ describe('upsertUnits', () => {
describe('$set fields', () => {
it('should set last_scraped timestamp on every upsert', async () => {
const logger = createMockLogger();
const beforeTime = new Date().toISOString();
const beforeTime = getNow();
const units = [{ unit_code: 'T100', price: 1000 }];
await upsertUnits(db, units, logger);
const afterTime = new Date().toISOString();
const afterTime = getNow();
const doc = await db.collection(UNITS_COLLECTION).findOne({ unit_code: 'T100' });
expect(doc.last_scraped).toBeDefined();
@ -221,7 +222,7 @@ describe('upsertUnits', () => {
describe('$setOnInsert for first_seen', () => {
it('should set first_seen on initial insert', async () => {
const logger = createMockLogger();
const beforeTime = new Date().toISOString();
const beforeTime = getNow();
await upsertUnits(db, [{ unit_code: 'F100', price: 1000 }], logger);

View File

@ -403,7 +403,7 @@ function getYesterday(dateStr) {
*/
async function upsertUnits(db, units, logger) {
const collection = db.collection(config.COLLECTIONS.UNITS);
const now = new Date().toISOString();
const now = getNow();
const operations = units.map(unit => ({
updateOne: {
@ -461,7 +461,7 @@ async function upsertUnits(db, units, logger) {
*/
async function insertPrices(db, units, date, logger) {
const collection = db.collection(config.COLLECTIONS.PRICES);
const now = new Date().toISOString();
const now = getNow();
// Filter out units without prices
const unitsWithPrices = units.filter(u => u.price !== null);
@ -581,7 +581,7 @@ async function updateDailySummary(db, summaryData, logger) {
const summary = {
date,
timestamp: new Date().toISOString(),
timestamp: getNow(),
new_units: newUnits,
rented_units: rentedUnits,
stale_units: [], // Stale units list is not tracked per PRD
@ -633,6 +633,26 @@ function getToday() {
return new Date().toLocaleDateString('en-CA', { timeZone: config.SCRAPER_TIMEZONE });
}
/**
* Get current timestamp in the configured timezone as an ISO-like string.
* Returns format: YYYY-MM-DDTHH:mm:ss.mmm (no Z suffix, local time).
* @returns {string} Current timestamp in configured timezone
*/
function getNow() {
const now = new Date();
const formatter = new Intl.DateTimeFormat('en-CA', {
timeZone: config.SCRAPER_TIMEZONE,
year: 'numeric', month: '2-digit', day: '2-digit',
hour: '2-digit', minute: '2-digit', second: '2-digit',
hour12: false
});
const parts = Object.fromEntries(
formatter.formatToParts(now).map(({ type, value }) => [type, value])
);
const ms = String(now.getMilliseconds()).padStart(3, '0');
return `${parts.year}-${parts.month}-${parts.day}T${parts.hour}:${parts.minute}:${parts.second}.${ms}`;
}
/**
* Get the set of unit codes that had price records yesterday.
* Used to calculate new and rented units by comparison.
@ -718,7 +738,7 @@ async function recordScraperRun(db, runData, logger) {
const collection = db.collection(config.COLLECTIONS.SCRAPER_RUNS);
const result = await collection.insertOne({
...runData,
recordedAt: new Date()
recordedAt: getNow()
});
return result;
@ -790,7 +810,7 @@ async function runScrape(db, options = {}) {
trigger,
dryRun,
status: 'running',
startedAt: new Date().toISOString(),
startedAt: getNow(),
completedAt: null,
duration: null,
unitsProcessed: 0,
@ -875,7 +895,7 @@ async function runScrape(db, options = {}) {
result.errors.push(cleanError.message);
} finally {
result.completedAt = new Date().toISOString();
result.completedAt = getNow();
result.duration = Date.now() - startTime;
// Record run to history (always runs, even on failure)
@ -905,6 +925,7 @@ module.exports = {
createScraperIndexes,
// Export helpers for testing
getToday,
getNow,
getYesterdayUnitCodes,
getYesterday,
parseInteger,