Calendar — company holidays & the working-day source of truth

The whole calendar model reduces to one idea: a Calendar row 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 in CalendarService'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 (Calendar collection), 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. CalendarService accepts a workingDays: 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-companyId only. There is no per-branch or per-employee calendar. (employee has a calenderId field 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 resolver

2.4 Relationships & scoping

  • Referenced by: nothing stores a calendarId FK. Consumers (leave, payroll) call CalendarService methods 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: companyId from ApContextService (or passed explicitly to findHolidays).

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!]!
  • calendarPageCalendarService.pageCalendarRepository.page (aggregation with handlePageFacet / handlePageResult; buildQuery filters by base schema keys + keyword regex on title, with date as the dateKey so fromDate/toDate work via the base range logic).
  • calendarEvents(from, to)findByDateRangeCalendarRepository.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:

  1. Inclusive range — both endpoints counted.
  2. 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.)
  3. 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).
  4. Default Mon–Fri when workingDays is 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 workingDays actually 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 a workingDays argument → 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 to DEFAULT_WORKING_DAYS when 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), or 0.5 for a half-day.
  • That duration is then checked against the leave-type entitlement (usedDays + duration > entitled → reject) and persisted on the Leave row. 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: resolvePeriod defaults to the calendar month of payDate when fromDate/toDate are unset.
  • totalWorkingDays = the size of the working-day set for the period → the divisor for the daily rate.
  • Absence deduction (addAbsenceDeductions): build a presentSet from 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 same workingDaySet (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 workingDaySet and the same totalWorkingDays divisor, 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 reuses calendarEvents.
  • Admin RBAC (UI): the admin "Add Entry" button is gated by USER_ACCESS.HR_CALENDAR (module + ACTIONS.CREATE) (UserAccess.ts:539); the route runs guard.haveModuleAccess('/hr/calendar', ...) in getServerSideProps. 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 rangefindHolidays returns []; counts use weekends-only exclusion.
  • totalWorkingDays === 0 (e.g. a period that is all weekend/holiday) → payroll sets absence/unpaid amounts to 0 (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 leavecountWorkingDays(..., true) = 0.5 regardless of range.

7. Admin UI

Admin "HR Calendar" (src/modules/hr/calendar, route /hr/calendar)

  • Route: src/pages/hr/calendar/index.tsxHRLayout selectedKeys={['hr-calendar']} wrapping CalendarPage. The HRCalendarContextProvider is mounted globally in src/Context.tsx (not in the page route).
  • Page (page.tsx): two views toggled by view: 'table' | 'calendar' (default calendar):
    • Calendar viewApCalendar month grid; entries mapped to ApCalendarEvent with color: isHoliday ? 'red' : 'blue'. onMonthChange(from, to) refetches with pageSize: 100; clicking a date with events opens the edit modal, an empty date opens create pre-filled with that date.
    • Table viewApTable with columns Title / Date (fmtDate) / Type (<Tag red|blue> "Holiday"/"Working Day") / Actions (edit, delete-with-confirm). ApSearchInput filters by keyword; paginated (page/pageSize).
  • Create/edit modal (components/create.tsx): Formik form — ApTextInput title (required), ApDateInput date (required), ApSwitchInput isHoliday. Submits { title, date, isHoliday } to createCalendar/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 model hr_calendars), AbstractBaseService/AbstractBaseRepository, TransactionManager, ApContextService (companyId).
  • AuthModule, SubscriptionModule (guards).

Consumed by (call CalendarService directly):

  • leavefindHolidays + countWorkingDays for leave duration / entitlement.
  • payroll (hr/payroll/employee/employee.service.ts) — findHolidays + workingDayStrings for absence and unpaid-leave deductions; DEFAULT_WORKING_DAYS as the shift fallback.
  • Admin & ESS calendar UIs (via calendarPage / calendarEvents).
  • CalendarModule is exported from hr/index.ts for 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

  1. 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 / findHolidays first — they are the contract every consumer relies on.
  2. 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. But toDateString() 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.
  3. isHoliday: false entries are inert for day counts. A non-holiday "event" shows on the calendars but never adds or removes a working day; only isHoliday: true matters to leave/payroll. The table's "Working Day" tag is cosmetic.
  4. The working week / weekend is not configurable in the UI. CalendarService takes workingDays as 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 (createLeave calls countWorkingDays without the workingDays arg). 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.
  5. No per-employee / per-branch calendar. Entries are company-wide. Employee.calenderId exists but is unused (see employee §9 #9).
  6. Absence and unpaid-leave deductions intentionally share one workingDaySet + one totalWorkingDays divisor so they can't double-count and use a consistent daily rate. Don't split them onto separate computations when porting.
  7. totalWorkingDays === 0 is guarded in payroll (amounts → 0) to avoid divide-by-zero; replicate that guard.
  8. Collection is hr_calendars, not calendars — the @HrSchema decorator prefixes it (the class arg says "calendars" but the audit-meta and physical collection are hr_calendars). Contrast with employees/departments which are not prefixed (@ApSchema). See employee §9 #1.
  9. CalendarQueryInput is defined but not wired to any resolver query — dead DTO.
  10. 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).