'use strict';
class InputError extends Error {
constructor(code, path) { super(code); this.code = code; this.path = path; }
}
const fail = (code, path) => { throw new InputError(code, path); };
const HOUR = 3600000;
const fullMatch = (re, text) => {
const m = re.exec(text);
return m && m[0] === text ? m : null;
};
function parseInstant(text, path) {
if (typeof text !== 'string') fail('TIME_TYPE', path);
if (fullMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}$/, text)) {
fail('TIMEZONE_REQUIRED', path);
}
const m = fullMatch(/^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})\.(\d{3})(Z|\+08:00)$/, text);
if (!m) fail('TIME_FORMAT', path);
const [y, mo, d, h, mi, s, ms] = m.slice(1, 8).map(Number);
if (y < 2000 || y > 2099) fail('YEAR_RANGE', path);
const wallAsUtc = Date.UTC(y, mo - 1, d, h, mi, s, ms);
// Date.UTC can normalize an impossible date; require exact component round-trip.
if (new Date(wallAsUtc).toISOString() !== text.slice(0, 23) + 'Z') {
fail('INVALID_DATE', path);
}
return wallAsUtc - (m[8] === '+08:00' ? 8 * HOUR : 0);
}
function display(instant, zone) {
if (zone === 'UTC') return new Date(instant).toISOString();
return new Date(instant + 8 * HOUR).toISOString().slice(0, -1) + '+08:00';
}
function countEvents(events, config) {
try {
if (!config || typeof config !== 'object' || Array.isArray(config)) {
fail('WINDOW_OBJECT', 'window');
}
const { start, end, zone } = config;
if (zone === undefined || zone === null || zone === '') fail('TIMEZONE_REQUIRED', 'window.zone');
if (zone !== 'UTC' && zone !== 'UTC+08:00') fail('WINDOW_ZONE', 'window.zone');
const startMs = parseInstant(start, 'window.start');
const endMs = parseInstant(end, 'window.end');
if (startMs >= endMs) fail('WINDOW_ORDER', 'window');
if (!Array.isArray(events)) fail('EVENTS_ARRAY', 'events');
const seen = new Map();
const includedIds = [];
const decisions = [];
for (let i = 0; i < events.length; i++) {
const row = events[i];
const path = `events[${i}]`;
if (!row || typeof row !== 'object' || Array.isArray(row)) fail('EVENT_OBJECT', path);
if (typeof row.id !== 'string' || !fullMatch(/^[a-z][a-z0-9-]{0,15}$/, row.id)) {
fail('EVENT_ID', path + '.id');
}
const instant = parseInstant(row.at, path + '.at');
const duplicate = seen.has(row.id);
if (duplicate && seen.get(row.id) !== instant) fail('EVENT_TIME_CONFLICT', path + '.at');
seen.set(row.id, instant);
const inWindow = startMs <= instant && instant < endMs;
const counted = inWindow && !duplicate;
const reason = duplicate ? 'SAME_EVENT_DUPLICATE'
: instant < startMs ? 'BEFORE_START'
: instant === startMs ? 'AT_START'
: instant < endMs ? 'INSIDE'
: instant === endMs ? 'AT_END' : 'AFTER_END';
if (counted) includedIds.push(row.id);
decisions.push({ row: i + 1, id: row.id, instantUtc: new Date(instant).toISOString(), inWindow, counted, reason });
}
return {
ok: true,
window: { zone, startUtc: new Date(startMs).toISOString(), endUtc: new Date(endMs).toISOString(), startDisplay: display(startMs, zone), endDisplay: display(endMs, zone) },
count: includedIds.length, includedIds, decisions
};
} catch (error) {
if (!(error instanceof InputError)) throw error;
return { ok: false, error: { code: error.code, path: error.path } };
}
}
const window = { start: '2026-09-27T21:00:00.000+08:00', end: '2026-09-28T21:00:00.000+08:00', zone: 'UTC+08:00' };
const events = [
{ id: 'before', at: '2026-09-27T20:59:59.999+08:00' },
{ id: 'start', at: '2026-09-27T21:00:00.000+08:00' },
{ id: 'inside', at: '2026-09-28T00:00:00.000+08:00' },
{ id: 'same', at: '2026-09-28T04:00:00.000Z' },
{ id: 'same', at: '2026-09-28T12:00:00.000+08:00' },
{ id: 'end', at: '2026-09-28T21:00:00.000+08:00' },
{ id: 'after', at: '2026-09-28T21:00:00.001+08:00' }
];
const samples = {
boundaries: { events, window },
utc_display: { events, window: { ...window, zone: 'UTC' } },
next_window: { events, window: { ...window, start: window.end, end: '2026-09-29T21:00:00.000+08:00' } },
empty: { events: [], window }
};
const invalidTimes = [
['missing_event_zone', '2026-09-28T12:00:00.000'],
['invalid_day', '2026-02-30T12:00:00.000Z'],
['invalid_month', '2026-13-01T12:00:00.000Z'],
['unsupported_offset', '2026-09-28T12:00:00.000+09:00'],
['missing_millis', '2026-09-28T12:00:00Z'],
['hour_24', '2026-09-28T24:00:00.000Z'],
['leap_second', '2026-09-28T12:00:60.000Z'],
['trailing_newline', '2026-09-28T12:00:00.000Z\n'],
['year_0099', '0099-09-28T12:00:00.000Z'],
['year_2100', '2100-09-28T12:00:00.000Z'],
['time_not_string', null]
];
for (const [name, at] of invalidTimes) samples[name] = { events: [{ id: 'x', at }], window };
const conflict = [{ id: 'same', at: '2026-09-28T04:00:00.000Z' }, { id: 'same', at: '2026-09-28T13:00:00.000Z' }];
samples.conflict = { events: conflict, window };
samples.conflict_reversed = { events: [...conflict].reverse(), window };
samples.missing_window_zone = { events: [], window: { start: window.start, end: window.end } };
samples.unsupported_window_zone = { events: [], window: { ...window, zone: 'Asia/Shanghai' } };
samples.equal_window = { events: [], window: { ...window, end: window.start } };
samples.reverse_window = { events: [], window: { ...window, start: window.end, end: window.start } };
samples.missing_start_zone = { events: [], window: { ...window, start: '2026-09-27T21:00:00.000' } };
samples.not_array = { events: {}, window };
samples.bad_record = { events: [null], window };
samples.empty_id = { events: [{ id: '', at: window.start }], window };
if (require.main === module) {
for (const [name, input] of Object.entries(samples)) {
console.log(JSON.stringify({ name, result: countEvents(input.events, input.window) }));
}
}
module.exports = { countEvents, samples };