Calendar — company holidays & the working-day source of truth
The whole calendar model reduces to one idea: a
Calendarrow is one dated entry per company — either a holiday or a plain event — and the only HR-functional thing it does is let the system subtract holidays from working-day counts. The schema is deliberately tiny (title,date,isHoliday). The leverage is inCalendarService's pure date math (findHolidays,workingDayStrings,countWorkingDays), which is the single source of truth for "which days count" — consumed identically by leave-duration calculation and payroll (absence + unpaid-leave deductions) so they never disagree.
Source: BE src/modules/hr/calendar · Admin src/modules/hr/calendar, src/modules/hr/company-profile (Note: the company-profile screen manages company identity/fiscal-year only — it does not configure holidays, working days, or weekends. The working week is not stored on the company; see §4.3 and §9.)
1. Purpose & scope
The calendar module owns the company-level list of dated entries and the date arithmetic that turns those entries + a working-week definition into day counts. It is responsible for:
- Storing holiday and event entries (
Calendarcollection), one per company per date. - CRUD over those entries (admin "HR Calendar" page; ESS read-only month view).
- Returning the holidays in a date range (
findHolidays) as a list of timestamps. - Computing the set of working dates and the count of working days in a range, excluding weekends (per a supplied working-week) and holidays —
workingDayStrings/countWorkingDays.
It explicitly does NOT:
- Define the working week / weekend itself.
CalendarServiceaccepts aworkingDays: number[]argument (0=Sun … 6=Sat); it does not own or persist it. The default is Mon–Fri (DEFAULT_WORKING_DAYS = [1,2,3,4,5]). The real working week per employee is derived elsewhere — payroll resolves it from the employee's AttendanceGroup → Shift → ShiftDay schedule (see §4.3 and attendance). - Compute leave balances, payroll amounts, or attendance — it only supplies the day-count primitives those modules call. The financial math (daily rate, deduction amounts) lives in payroll; the entitlement check lives in leave.
- Scope per-employee. Calendar entries are per-
companyIdonly. There is no per-branch or per-employee calendar. (employee has acalenderIdfield but it is unused — the calendar service resolves holidays per company, never per employee. See employee §9 #9.)
2. Data model
2.1 hr_calendars collection
Schema: hr/calendar/calendar.schema.ts (class Calendar extends BaseSchema, decorated @HrSchema({ collection: "calendars" })). The @HrSchema decorator applies the hr_ collection prefix, so the physical collection is hr_calendars (the audit-meta on the resolver confirms collection: 'hr_calendars'). Soft-delete via mongoose-delete (deletedAt).
@HrSchema({ collection: "calendars" })
export class Calendar extends BaseSchema {
@Prop({ required: true }) title: string;
@Prop({ required: true }) date: number; // unix ms — the entry's calendar date
@Prop({ default: false }) isHoliday: boolean; // true = public holiday (excluded from working days)
}| field | type | required | description |
|---|---|---|---|
title |
string | yes | Entry label (e.g. "New Year's Day"). Searchable (keyword → regex on title). |
date |
number (unix ms) | yes | The date of the entry. Range queries filter on this. Holiday matching is by toDateString() (calendar day), so the exact ms time-of-day does not matter for working-day math. |
isHoliday |
boolean | default false |
The discriminator that matters: true → the date is subtracted from working days. false → a plain event (shown on calendars, but does not affect any day count). |
BaseSchema (core/database/database.scheme.ts) contributes the usual envelope: _id, companyId, branchId (indexed, multi-tenant), documentCode/Date, createdAt/By, updatedAt/By, deletedAt, deleted, etc. companyId is the tenant boundary — every query filters by it.
2.2 No enums
There are no enums in this module. isHoliday is a plain boolean; "holiday vs working-day/event" is the only distinction. (The admin labels isHoliday === false rows as "Working Day" in the table, but functionally a non-holiday entry is just a marked event — it does not add a working day; working days come from the weekday/working-week logic.)
2.3 GraphQL type (calendar.dto.ts)
type Calendar { # extends BaseDto fields (companyId, ref, createdAt, ...)
_id: String!
title: String
date: Float
isHoliday: Boolean
createdAt: Float!
# ...plus inherited base fields
}
type CalendarPageResult { totalRecords: Float! data: [Calendar!]! }
input CreateCalendarInput { title: String! date: Float! isHoliday: Boolean }
input UpdateCalendarInput { title: String date: Float isHoliday: Boolean } # PartialType
input CalendarPageInput { skip: Int! take: Int! keyword: String fromDate: Float toDate: Float }
input CalendarQueryInput { _id: String fromDate: Float toDate: Float } # defined but unused by the resolver2.4 Relationships & scoping
- Referenced by: nothing stores a
calendarIdFK. Consumers (leave, payroll) callCalendarServicemethods with a date range +companyId; they read the holiday dates, not the rows. - Soft-delete: all reads exclude
deleted: { $ne: true }(so a deleted holiday stops being subtracted from working days automatically). - Tenant scoping:
companyIdfromApContextService(or passed explicitly tofindHolidays).
3. API surface
GraphQL, on CalendarResolver (extends ApBaseResolver<Calendar>). Class-level guards: @UseGuards(GqlFeatureGuard) + @RequireFeature('HR_MODULE') + @ApGqlAuthorize(). Mutations carry @AuditMeta({ module: 'calendar', collection: 'hr_calendars' }).
| Operation | Type | Input | Returns | Audit |
|---|---|---|---|---|
createCalendar |
Mutation | calendar: CreateCalendarInput |
Calendar |
CREATE |
updateCalendar |
Mutation | id, calendar: UpdateCalendarInput |
Calendar |
UPDATE |
deleteCalendar |
Mutation | id |
Boolean (soft-delete) |
DELETE |
calendarPage |
Query | page: CalendarPageInput |
CalendarPageResult |
— |
calendarEvents |
Query | fromDate: Float!, toDate: Float! |
[Calendar!]! |
— |
calendarPage→CalendarService.page→CalendarRepository.page(aggregation withhandlePageFacet/handlePageResult;buildQueryfilters by base schema keys +keywordregex ontitle, withdateas thedateKeysofromDate/toDatework via the base range logic).calendarEvents(from, to)→findByDateRange→CalendarRepository.findByDateRange(date: { $gte, $lte },deleted: { $ne: true }, sorted ascending). Returns all entries in range (holidays + events) — used by the calendar UIs. Added by the ESS-calendar plan (2026-05-10-ess-company-calendar.md).
No REST controller. The internal day-count methods (findHolidays, workingDayStrings, countWorkingDays) are service methods only — not exposed over GraphQL; other BE services call them directly.
4. Business rules & calculations
This module's value is its date math. All of it lives in calendar.service.ts and is pure / deterministic (no DB except findHolidays).
4.1 findHolidays(companyId, fromDate, toDate) → number[]
Returns the timestamps of holiday entries in the range. Day-boundary normalised (start of fromDate's day → end of toDate's day):
const start = new Date(fromDate); start.setHours(0, 0, 0, 0);
const end = new Date(toDate); end.setHours(23, 59, 59, 999);
const holidays = await this.calendarRepo.find({
companyId: this.calendarRepo.toObjectId(companyId),
isHoliday: true,
date: { $gte: start.getTime(), $lte: end.getTime() },
});
return holidays.map((h) => h.date);Only isHoliday: true rows are returned — plain events never affect day counts.
4.2 workingDayStrings(fromDate, toDate, holidayDates, workingDays?) → Set<string>
The single source of truth for "which days count." Walks every calendar day in [fromDate, toDate] inclusive, and includes a day iff its weekday is in workingDays AND it is not a holiday. Days are keyed by Date.toDateString() (so comparisons are day-level, ignoring time-of-day):
public static readonly DEFAULT_WORKING_DAYS = [1, 2, 3, 4, 5]; // Mon–Fri
public workingDayStrings(fromDate, toDate, holidayDates, workingDays = DEFAULT_WORKING_DAYS): Set<string> {
const holidaySet = new Set(holidayDates.map((ts) => new Date(ts).toDateString()));
const workingSet = new Set(workingDays); // weekdays that count (0=Sun … 6=Sat)
const result = new Set<string>();
const current = new Date(fromDate); current.setHours(0, 0, 0, 0);
const end = new Date(toDate); end.setHours(0, 0, 0, 0);
while (current <= end) {
if (workingSet.has(current.getDay()) && !holidaySet.has(current.toDateString())) {
result.add(current.toDateString());
}
current.setDate(current.getDate() + 1);
}
return result;
}Rules encoded here:
- Inclusive range — both endpoints counted.
- Weekend = any weekday not in
workingDays. The "weekend" is the complement of the working-week set, so a Gulf week[0,1,2,3,4](Sun–Thu) makes Fri+Sat the weekend; a 6-day week[1..6]makes only Sunday the weekend. (Tests cover both.) - Holiday exclusion is day-level via
toDateString(). A holiday that falls on a non-working day has no effect (it was never in the set). - Default Mon–Fri when
workingDaysis omitted.
4.3 countWorkingDays(fromDate, toDate, holidayDates, isHalfDay?, workingDays?) → number
Thin wrapper: half-day short-circuits to 0.5; otherwise it's workingDayStrings(...).size.
public countWorkingDays(fromDate, toDate, holidayDates, isHalfDay = false, workingDays = DEFAULT_WORKING_DAYS): number {
if (isHalfDay) return 0.5;
return this.workingDayStrings(fromDate, toDate, holidayDates, workingDays).size;
}Where does
workingDaysactually come from? The calendar module only defaults it to Mon–Fri. The concrete working week is resolved by the caller:
- Leave (leave.service.ts) calls
countWorkingDays(from, to, holidays, isHalfDay)without aworkingDaysargument → so leave duration today always uses the Mon–Fri default, ignoring any custom shift. Flag (§9 #4).- Payroll (payroll/employee) resolves it per employee from
AttendanceGroup → Shift → ShiftDay(getWorkingDaysOfWeek), falling back toDEFAULT_WORKING_DAYSwhen no shift is configured. So payroll can honour a custom/Gulf/6-day week; leave currently cannot.
4.4 How it feeds leave day counts
leave.service.ts → createLeave:
const [leaveGroup, holidays] = await Promise.all([
this.leaveGroupSvc.findById(employee.leaveGroupId.toString()),
this.calendarSvc.findHolidays(employee.companyId?.toString(), input.fromDate, input.toDate),
]);
// ...
const duration = this.calendarSvc.countWorkingDays(input.fromDate, input.toDate, holidays, input.isHalfDay);duration= working days in the requested span, weekends + holidays removed (Mon–Fri default), or0.5for a half-day.- That
durationis then checked against the leave-type entitlement (usedDays + duration > entitled→ reject) and persisted on theLeaverow. So a leave spanning a public holiday or weekend does not consume entitlement for those days. See leave §business rules.
4.5 How it feeds payroll day counts (absence + unpaid leave)
payroll/employee/employee.service.ts builds one shared WorkingContext per pay period and uses it for both deductions so they stay consistent:
private async getWorkingContext(employee, fromDate, toDate): Promise<WorkingContext> {
const [holidays, workingDays] = await Promise.all([
companyId ? this.calendarSvc.findHolidays(companyId, fromDate, toDate) : Promise.resolve([]),
this.getWorkingDaysOfWeek(employee), // AttendanceGroup→Shift→ShiftDay, else Mon–Fri
]);
const workingDaySet = this.calendarSvc.workingDayStrings(fromDate, toDate, holidays, workingDays);
return { workingDaySet, totalWorkingDays: workingDaySet.size };
}- Pay period:
resolvePerioddefaults to the calendar month ofpayDatewhenfromDate/toDateare unset. totalWorkingDays= the size of the working-day set for the period → the divisor for the daily rate.- Absence deduction (
addAbsenceDeductions): build apresentSetfrom attended dates + approved leave days;absentDays = totalWorkingDays − presentDays(over the working-day set only);dailyRate = salary / totalWorkingDays;absenceAmount = round(absentDays * dailyRate, 2). Holidays/weekends are never "absent" because they aren't in the set. - Unpaid-leave deduction (
addUnpaidLeaves): for each approved unpaid leave overlapping the period, clamp to the period, count its days within the sameworkingDaySet(half-day = 0.5 if that day is a working day),dailyRate = salary / totalWorkingDays,unpaidAmount = round(totalDays * dailyRate, 2).
The key invariant: absence and unpaid-leave deductions share the same
workingDaySetand the sametotalWorkingDaysdivisor, so a day is never both "absent" and "unpaid leave," and the per-day money is consistent. This is the design intent called out in the service comments.
4.6 State machine / transactionality
N/A — calendar entries have no status/lifecycle (just create/update/soft-delete). The day-count methods are stateless pure functions; no transaction. (Leave/payroll wrap their own writes in transactions; calendar reads are plain finds.)
5. Permissions
- Feature gate:
@RequireFeature('HR_MODULE')+GqlFeatureGuard(tenant subscription must include HR). See permissions-access. - Auth:
@ApGqlAuthorize()(JWT) on all ops — any authenticated company user can read; the ESS month view reusescalendarEvents. - Admin RBAC (UI): the admin "Add Entry" button is gated by
USER_ACCESS.HR_CALENDAR(module+ACTIONS.CREATE) (UserAccess.ts:539); the route runsguard.haveModuleAccess('/hr/calendar', ...)ingetServerSideProps. The BE resolver itself does not apply a per-action CASL gate beyond the feature guard.
6. Flows
6.1 Admin creates a holiday
Admin /hr/calendar → "Add Entry" (or click an empty date in month view)
→ CreateCalendarEntry (Formik: title required, date required, isHoliday switch)
→ useHRCalendarState().createCalendar({ title, date, isHoliday })
→ GQL createCalendar(CreateCalendarInput)
→ CalendarResolver.create → CalendarService.create → repo.create (stamps companyId from context)
→ audit CREATE (hr_calendars)
← Calendar → toast + refetch calendarPage
6.2 Holiday flows into a leave request
Employee/admin submits leave (fromDate..toDate, isHalfDay?)
→ leave.createLeave
├─ findHolidays(companyId, from, to) // holiday timestamps in range
└─ countWorkingDays(from, to, holidays, isHalfDay) // Mon–Fri minus holidays (or 0.5)
→ duration checked vs entitlement → persisted as Leave.duration
6.3 Holiday flows into a payroll run
Payroll run for an employee (period = month of payDate by default)
→ getWorkingContext(employee, from, to)
├─ findHolidays(companyId, from, to)
├─ getWorkingDaysOfWeek(employee) // AttendanceGroup→Shift→ShiftDay, else Mon–Fri
└─ workingDayStrings(...) → { workingDaySet, totalWorkingDays }
→ addAbsenceDeductions uses (totalWorkingDays − presentDays) * (salary/totalWorkingDays)
→ addUnpaidLeaves uses daysInWorkingSet(leave) * (salary/totalWorkingDays)
6.4 Unhappy / edge paths
- No holidays in range →
findHolidaysreturns[]; counts use weekends-only exclusion. totalWorkingDays === 0(e.g. a period that is all weekend/holiday) → payroll sets absence/unpaid amounts to0(guarded; avoids divide-by-zero).- Deleted holiday → excluded from
findHolidays(soft-delete), so its date silently becomes a working day again. - Holiday on a weekend → no effect on counts.
- Half-day leave →
countWorkingDays(..., true) = 0.5regardless of range.
7. Admin UI
Admin "HR Calendar" (src/modules/hr/calendar, route /hr/calendar)
- Route:
src/pages/hr/calendar/index.tsx→HRLayout selectedKeys={['hr-calendar']}wrappingCalendarPage. TheHRCalendarContextProvideris mounted globally insrc/Context.tsx(not in the page route). - Page (
page.tsx): two views toggled byview: 'table' | 'calendar'(defaultcalendar):- Calendar view —
ApCalendarmonth grid; entries mapped toApCalendarEventwithcolor: isHoliday ? 'red' : 'blue'.onMonthChange(from, to)refetches withpageSize: 100; clicking a date with events opens the edit modal, an empty date opens create pre-filled with that date. - Table view —
ApTablewith columns Title / Date (fmtDate) / Type (<Tag red|blue>"Holiday"/"Working Day") / Actions (edit, delete-with-confirm).ApSearchInputfilters bykeyword; paginated (page/pageSize).
- Calendar view —
- Create/edit modal (
components/create.tsx): Formik form —ApTextInput title(required),ApDateInput date(required),ApSwitchInput isHoliday. Submits{ title, date, isHoliday }tocreateCalendar/updateCalendar.
Context methods (context.tsx)
useHRCalendarState() exposes calendars, loading, totalRecords, modal/setModal, and fetchCalendarPage, createCalendar, updateCalendar, deleteCalendar. It is the only consumer of useHRCalendarQuery() (gql/query.ts: CALENDAR_PAGE lazy query + CREATE/UPDATE/DELETE_CALENDAR mutations, all no-cache, errors → toastSvc.graphQlError). After create the list refetches; update/delete patch state in place.
ESS month view (src/pages/ess/calendar.tsx)
Read-only mobile month grid (added by 2026-05-10-ess-company-calendar.md). Queries calendarEvents(fromDate, toDate) for the displayed month, marks holidays red / events blue, lists the month's entries below the grid. No mutation.
Company-profile (src/modules/hr/company-profile)
Listed as a related source, but it manages company identity only — name, contact, address, registration no./date, fiscal year start/end, industry, currency, logo (saveCompany via useCompanyState). It does not edit holidays, the working week, or weekends. There is no UI anywhere to configure the company's working-week/weekend directly — it is inferred from attendance-group shifts (payroll) or defaults to Mon–Fri (§4.3). Flag (§9 #4).
8. Dependencies & integrations
Calendar depends on / calls:
CalendarRepository(Mongoose modelhr_calendars),AbstractBaseService/AbstractBaseRepository,TransactionManager,ApContextService(companyId).AuthModule,SubscriptionModule(guards).
Consumed by (call CalendarService directly):
- leave —
findHolidays+countWorkingDaysfor leave duration / entitlement. - payroll (
hr/payroll/employee/employee.service.ts) —findHolidays+workingDayStringsfor absence and unpaid-leave deductions;DEFAULT_WORKING_DAYSas the shift fallback. - Admin & ESS calendar UIs (via
calendarPage/calendarEvents). CalendarModuleis exported fromhr/index.tsfor cross-module injection.
Indirect dependency for the working week: payroll's getWorkingDaysOfWeek reaches into attendance (AttendanceGroup) → Shift → ShiftDay. The calendar module itself has no knowledge of shifts.
Events / cron / external: none. No events emitted/consumed, no jobs, no external services. (No auto-seeding of public holidays — every holiday is entered by an admin.)
9. Gotchas & project-specific rules
- Tiny schema, big leverage. The whole feature is three fields; the real logic is the pure date math in the service. To replicate: port
workingDayStrings/countWorkingDays/findHolidaysfirst — they are the contract every consumer relies on. - Holiday matching is day-level (
toDateString()), not by raw timestamp. Storing a holiday at any time-of-day on the right calendar day works; comparisons normalise to the day. ButtoDateString()uses the server's local timezone — a holiday near midnight UTC could land on the wrong day if the server TZ differs from the company's. See multi-tenancy timezone notes. Flag. isHoliday: falseentries are inert for day counts. A non-holiday "event" shows on the calendars but never adds or removes a working day; onlyisHoliday: truematters to leave/payroll. The table's "Working Day" tag is cosmetic.- The working week / weekend is not configurable in the UI.
CalendarServicetakesworkingDaysas a parameter but nothing persists it. Payroll derives it from attendance-group shifts (falling back to Mon–Fri); leave ignores it entirely and always uses Mon–Fri (createLeavecallscountWorkingDayswithout theworkingDaysarg). So a Gulf/6-day-week tenant will get correct payroll day-counts but incorrect leave durations until leave is updated to pass the employee's working week. Biggest correctness caveat. - No per-employee / per-branch calendar. Entries are company-wide.
Employee.calenderIdexists but is unused (see employee §9 #9). - Absence and unpaid-leave deductions intentionally share one
workingDaySet+ onetotalWorkingDaysdivisor so they can't double-count and use a consistent daily rate. Don't split them onto separate computations when porting. totalWorkingDays === 0is guarded in payroll (amounts → 0) to avoid divide-by-zero; replicate that guard.- Collection is
hr_calendars, notcalendars— the@HrSchemadecorator prefixes it (the class arg says"calendars"but the audit-meta and physical collection arehr_calendars). Contrast withemployees/departmentswhich are not prefixed (@ApSchema). See employee §9 #1. CalendarQueryInputis defined but not wired to any resolver query — dead DTO.- No automatic public-holiday seeding. Every holiday is manual admin entry; a fresh tenant has an empty calendar (so all weekdays count until holidays are added).