Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
71 changes: 71 additions & 0 deletions src/helpers/__tests__/reporthelper.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
const moment = require('moment-timezone');

jest.mock('../../models/userProfile', () => ({ aggregate: jest.fn() }));

const userProfile = require('../../models/userProfile');
const reporthelper = require('../reporthelper')();

const TZ = 'America/Los_Angeles';
const endOfDayPt = (s) => moment.tz(s, TZ).endOf('day').toDate();

describe('reporthelper.weeklySummaries final week handling', () => {
beforeEach(() => {
jest.useFakeTimers();
// Tuesday, Sep 22 2026: Last Week is Sep 13-19.
jest.setSystemTime(moment.tz('2026-09-22 12:00', TZ).toDate());
userProfile.aggregate.mockReset();
});

afterEach(() => {
jest.useRealTimers();
});

it('matches active users and inactive, non-paused users who left in the requested weeks', async () => {
userProfile.aggregate.mockResolvedValue([]);

await reporthelper.weeklySummaries(1, 1);

const [pipeline] = userProfile.aggregate.mock.calls[0];
const { $or } = pipeline[0].$match;
expect($or[0]).toEqual({ isActive: true });
expect($or[1]).toMatchObject({ isActive: false, inactiveReason: { $ne: 'Paused' } });
expect($or[1].endDate.$gte).toEqual(moment.tz('2026-09-13', TZ).startOf('day').toDate());
expect($or[1].endDate.$lte).toEqual(moment.tz('2026-09-19', TZ).endOf('day').toDate());
});

it('gives a deactivated user the index of the week they left, even with no time logged', async () => {
userProfile.aggregate.mockResolvedValue([
{ isActive: false, endDate: endOfDayPt('2026-09-16'), timeEntries: [] },
]);

const [user] = await reporthelper.weeklySummaries(1, 1);

expect(user.finalWeekIndex).toBe(1);
});

it('returns the same index whichever week is requested', async () => {
const endDate = endOfDayPt('2026-09-16');
userProfile.aggregate.mockImplementation(async () => [
{ isActive: false, endDate, timeEntries: [] },
]);

const indexes = [];
for (const week of [0, 1, 2, 3]) {
// eslint-disable-next-line no-await-in-loop
const [user] = await reporthelper.weeklySummaries(week, week);
indexes.push(user.finalWeekIndex);
}

expect(indexes).toEqual([1, 1, 1, 1]);
});

it('does not give an active user with a scheduled end date a final week', async () => {
userProfile.aggregate.mockResolvedValue([
{ isActive: true, endDate: endOfDayPt('2026-10-10'), timeEntries: [] },
]);

const [user] = await reporthelper.weeklySummaries(0, 0);

expect(user.finalWeekIndex).toBeNull();
});
});
26 changes: 20 additions & 6 deletions src/helpers/reporthelper.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
/* eslint-disable quotes */
const moment = require('moment-timezone');
const userProfile = require('../models/userProfile');
const { getFinalWeekIndex } = require('../utilities/finalWeekIndex');

/**
*
Expand Down Expand Up @@ -40,7 +41,19 @@ const reporthelper = function () {

const results = await userProfile.aggregate([
{
$match: { isActive: { $in: [true, false] } },
// Active users, plus users who left during the requested weeks so
// their final week can still be reported. Paused users are
// excluded because they are expected to return.
$match: {
$or: [
{ isActive: true },
{
isActive: false,
inactiveReason: { $ne: 'Paused' },
endDate: { $gte: pstStart, $lte: pstEnd },
},
],
},
},
{
$lookup: {
Expand Down Expand Up @@ -177,6 +190,12 @@ const reporthelper = function () {
result.totalSeconds = [0, 0, 0, 0];
result.totalTangibleSeconds = [0, 0, 0, 0];

// Only users who have already left have a final week. Active users
// may carry an endDate for a scheduled separation; ignore it.
// Set before the early return below, so a user who logged no time in
// their final week still gets an index.
result.finalWeekIndex = result.isActive ? null : getFinalWeekIndex(result.endDate);

if (!result.timeEntries || result.timeEntries.length === 0) return;
const isSingleWeekRequest = startWeekIndex === endWeekIndex;
result.timeEntries.forEach((entry) => {
Expand Down Expand Up @@ -207,11 +226,6 @@ const reporthelper = function () {
seconds === 0 ? undefined : seconds,
);

if (result.endDate) {
result.finalWeekIndex = absoluteDifferenceInWeeks(result.endDate, pstEnd);
} else {
result.finalWeekIndex = undefined;
}
delete result.timeEntries;
});

Expand Down
75 changes: 75 additions & 0 deletions src/utilities/__tests__/finalWeekIndex.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
const moment = require('moment-timezone');
const { getFinalWeekIndex } = require('../finalWeekIndex');

const TZ = 'America/Los_Angeles';
const pt = (s) => moment.tz(s, TZ).toDate();
// Deactivation stores endDate as the end of the chosen day in Pacific time.
const endOfDayPt = (s) => moment.tz(s, TZ).endOf('day').toDate();

describe('getFinalWeekIndex', () => {
// Tuesday, Sep 22 2026. Tabs: This Week = Sep 20-26, Last Week = Sep 13-19,
// Week Before Last = Sep 6-12, Three Weeks Ago = Aug 30-Sep 5.
const now = pt('2026-09-22 12:00');

describe('maps an end date to the tab of the week it falls in', () => {
it.each([
['This Week', '2026-09-21', 0],
['Last Week', '2026-09-16', 1],
['Week Before Last', '2026-09-09', 2],
['Three Weeks Ago', '2026-09-02', 3],
])('%s (%s) -> %i', (_tab, date, expected) => {
expect(getFinalWeekIndex(endOfDayPt(date), now)).toBe(expected);
});
});

describe('returns null outside the four-week window', () => {
it('for an end date older than Three Weeks Ago', () => {
expect(getFinalWeekIndex(endOfDayPt('2026-08-29'), now)).toBeNull();
});

it('for a scheduled end date in a future week', () => {
expect(getFinalWeekIndex(endOfDayPt('2026-10-10'), now)).toBeNull();
});
});

describe('treats week boundaries in Pacific time, not UTC', () => {
it('puts Saturday 11:59:59 PM PT in the earlier week', () => {
expect(getFinalWeekIndex(pt('2026-09-19 23:59:59'), now)).toBe(1);
});

it('puts Sunday 12:00 AM PT in the later week', () => {
expect(getFinalWeekIndex(pt('2026-09-20 00:00:00'), now)).toBe(0);
});

it('reads a UTC ISO string that is already Sunday in UTC as Saturday PT', () => {
// 2026-09-20T06:59:59Z is Sep 19, 11:59:59 PM PDT.
expect(getFinalWeekIndex('2026-09-20T06:59:59.999Z', now)).toBe(1);
});
});

describe('is not thrown off by daylight saving changes', () => {
it('handles the week DST ends (Nov 1 2026)', () => {
const afterFallBack = pt('2026-11-03 12:00');
expect(getFinalWeekIndex(endOfDayPt('2026-10-28'), afterFallBack)).toBe(1);
expect(getFinalWeekIndex(endOfDayPt('2026-11-01'), afterFallBack)).toBe(0);
});

it('handles the week DST starts (Mar 14 2027)', () => {
const afterSpringForward = pt('2027-03-16 12:00');
expect(getFinalWeekIndex(endOfDayPt('2027-03-10'), afterSpringForward)).toBe(1);
expect(getFinalWeekIndex(endOfDayPt('2027-03-14'), afterSpringForward)).toBe(0);
});
});

describe('handles missing or bad input', () => {
it.each([null, undefined, '', 'not-a-date'])('returns null for %p', (value) => {
expect(getFinalWeekIndex(value, now)).toBeNull();
});
});

it('accepts both Date objects and ISO strings', () => {
const date = endOfDayPt('2026-09-16');
expect(getFinalWeekIndex(date, now)).toBe(1);
expect(getFinalWeekIndex(date.toISOString(), now)).toBe(1);
});
});
40 changes: 40 additions & 0 deletions src/utilities/finalWeekIndex.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
const moment = require('moment-timezone');

const COMPANY_TZ = 'America/Los_Angeles';
const MAX_WEEK_INDEX = 3;

/**
* Returns which Weekly Summaries Report tab a user's final week belongs to,
* measured back from the current week in company time (Pacific):
* 0 = This Week, 1 = Last Week, 2 = Week Before Last, 3 = Three Weeks Ago.
*
* Returns null when endDate is missing or invalid, falls in a future week,
* or is older than the four-week window the report shows.
*
* The caller decides whether the index applies. It is only meaningful for
* users who are no longer active; a scheduled separation that has not
* happened yet should not be treated as a final week.
*
* @param {Date|string|null|undefined} endDate The user's final day.
* @param {Date} [now=new Date()] Reference time, injectable for tests.
* @returns {number|null}
*/
const getFinalWeekIndex = (endDate, now = new Date()) => {
if (!endDate) return null;
// Parse strings strictly as ISO 8601 so malformed values return null
// instead of falling back to moment's deprecated loose parsing.
const end = endDate instanceof Date ? moment(endDate) : moment(endDate, moment.ISO_8601, true);
if (!end.isValid()) return null;

const endWeekStart = end.tz(COMPANY_TZ).startOf('week');
const currentWeekStart = moment(now).tz(COMPANY_TZ).startOf('week');

// Count calendar days and round, so a DST shift (a 167- or 169-hour
// week) cannot truncate the result by one.
const weeksAgo = Math.round(currentWeekStart.diff(endWeekStart, 'days', true) / 7);

if (weeksAgo < 0 || weeksAgo > MAX_WEEK_INDEX) return null;
return weeksAgo;
};

module.exports = { getFinalWeekIndex, COMPANY_TZ, MAX_WEEK_INDEX };
Loading