# Attendance backend foundation

`EnsureAttendanceDay::handle($organization, $employee, 'YYYY-MM-DD', $schedule)` is an internal creation action, now reused by authenticated heartbeat capture. No bulk generation or scheduler is registered. The date is an organization calendar date, not a browser timestamp. The employee must have an organization membership.

Organization Details persists working days (ISO weekday numbers 1–7), expected start/end, optional break minutes, and observation start/end on the organization. Monday–Friday is the initial working-day selection; time fields remain unconfigured until saved. Local times use HH:MM and the existing organization timezone. Settings use one authorized save flow; presence capture enforces the observation window.

`ExpectedAttendanceSchedule::forOrganization($organization, $date)` returns the saved schedule for configured working dates and subtracts break minutes (null means zero). It returns null for non-working days, Mercantile holidays, or unconfigured hours. `EnsureAttendanceDay` uses this source when no explicit trusted schedule is supplied. A new working day with unconfigured hours still requires a trusted schedule; no default hours are invented. Existing records are returned without recalculating their schedule. Only same-day schedules are supported.

Server validation requires 1–7 distinct valid working days, paired HH:MM times, start before end, and an observation window containing the configured working schedule. Break minutes must be an integer between 0 and 480 and shorter than the schedule. Omitted fields preserve existing values; validation checks the resulting persisted combination.

Organization timezone is used to construct scheduled instants, with application timezone as fallback. All persisted instants follow the existing UTC application/database convention. The original timezone is also snapshotted as `schedule_timezone`. Attendance dates remain date-only values. Justification submission time comes from the server clock. Future evidence actions must also use authoritative server time.

`ResolveWorkingCalendarDay` reads the organization's imported Mercantile holidays first, then its selected working days. Holidays always override a working schedule. A selected Saturday/Sunday is a working day; an excluded weekend retains its Saturday/Sunday type. An excluded Monday–Friday uses `company_exception` to represent the organization's recurring non-working weekday. All non-working results have null expected schedules. No date-specific manual exception system is implemented. Default Monday–Friday settings preserve the previous weekend behavior.

Statuses: `pending` awaits finalization; `normal` meets requirements; `recovered` compensated for a deviation; `flagged` represents an unresolved anomaly; `non_working_day` has no attendance requirement. Creation assigns only pending/non-working results; finalization evaluates the complete day.

`SubmitAttendanceJustification` locks and reloads the durable record, requires the supplied organization and the employee's current membership/ownership, and accepts only text for unresolved flagged records. It sets the text, submitter, and server time; status, evidence, resolution, and schedule remain unchanged. Submission may replace prior justification text while unresolved. Causes such as outages belong in text, not exception codes.

The auto-discovered policy allows own-record reads and attendance management users to read within their organization. Management access uses an active organization-scoped Company Owner role or existing `organization.manage` permission, requiring a live organization, active verified account and organization membership. Only the record owner can justify it. No generic update permission or endpoint exists. Creation is a trusted internal action, not employee self-service.

The database enforces employee/organization/date uniqueness. Organization/date, user/date, and organization/status/date indexes support later queries. Principal organization/user foreign keys restrict hard deletion to preserve history; nullable reviewer/submitter audit references null on deletion. Organization soft deletion follows existing conventions.

Temporary session presence is stored separately as described below. Finalization calculates daily outcomes and escalation eligibility, without sending notifications. Reports, leave, overtime, and payroll remain deferred. Creating an owner or another user never automatically creates attendance.

## Heartbeat and session presence

`POST /attendance/heartbeat` uses the existing web session, CSRF, active-account, authenticated and verified-user middleware. A rate limit (120/minute) accommodates multiple tabs. Heartbeat and logout use Laravel session blocking; database writers consistently lock user, daily record, then presence sessions. The unique daily constraint and per-stream generation constraint provide additional protection. SQL row-lock behavior is intended for the application's MySQL/MariaDB database; SQLite tests exercise constraints and ordered interleavings, not real simultaneous row locking.

The body contains only `organization_id` as a context guard. It must match the server-resolved workspace membership. User identity and timestamps come from the authenticated request/server clock; client user IDs, dates, session IDs and time fields are ignored. The endpoint returns only `{ "ok": true }`. Outside-window, unconfigured-window and finalized-day pings are acknowledged without altering attendance; ineligible/cross-workspace requests are forbidden. New working days require configured expected hours, otherwise the existing schedule validation returns 422. No assumed schedule is added.

`AttendanceEligibility` requires an active, verified user with membership in a live organization and excludes the active organization-scoped `company-owner` role. This follows existing seeders and assignment conventions. Project-owner, administrator and other roles are not excluded. Eligibility is checked server-side on every capture and shared as a small Inertia hint; the frontend never infers eligibility from role names. Ownership is scoped to the organization, so a person may participate in another organization where they are not Company Owner.

`AttendanceObservationWindow` uses organization-local dates and boundaries. Start is inclusive and end exclusive; null/invalid windows disable capture, with no broad timezone/default-window fallback. Received timestamps use UTC. Non-working-day presence may be recorded, retaining `non_working_day` status and a null expected schedule. No early login, reconnect, or outside-window activity is classified as an anomaly.

`attendance_presence_sessions` stores one mutable segment per authenticated session/day/generation, not a row per heartbeat. `AttendanceSessionIdentity` HMAC-hashes Laravel's session ID with the app key. Tabs sharing the authentication cookie share that stream. Raw session IDs, tab IDs, device fingerprints, activity and client clocks are not stored. Separate browser sessions still update the same durable day; first/last evidence uses earliest/latest server instants. Presence does not compute or sum working minutes.

`CaptureAttendancePresence` updates an open segment, or closes its stale predecessor as inferred and starts the next generation. A reconnect updates the same daily record without finalizing it. `CloseStaleAttendanceSessions::handle()` can later close idle segments using the single grace setting in `config/attendance.php` (300 seconds). It rechecks staleness under locks, uses last seen as the inferred end, and is not scheduled in this pass. No action treats one missed ping as an end.

`ConfirmAttendanceLogout` runs before existing authentication invalidation and uses server time. It closes/upgrades the latest segment of the current browser for each organization-local current day, including after workspace switches. It does not close another browser or extend older dates. Logout remains available if evidence persistence fails; the failure is reported through existing exception logging. Explicit logout outside the observation window may confirm an existing segment: the actual logout instant is retained, without treating it as additional presence or worked minutes. It never creates attendance on logout.

`RefreshAttendanceEnd` keeps derived end null while any segment is open. Once all close, the latest end wins; confirmed evidence wins ties and is never replaced by an earlier inferred end. A later resumed session can end after an earlier confirmed logout, while the original confirmed timestamp remains preserved. Reconnect clears the provisional derived end, not confirmed logout history. Finalized durable records are not mutated by capture/logout/stale closure. Status, justification, recorded minutes and escalation are untouched.

The app-root `AttendanceHeartbeat` component runs across authenticated pages, starts immediately when eligible, then approximately every 60 seconds after completion, aborts after 15 seconds, avoids overlapping requests per tab, retries temporary failures quietly, and wakes on connectivity restoration. It follows shared workspace/eligibility props, stops before Inertia logout, and removes timers/listeners on unmount. Server session identity and locking make repeated tabs idempotent; there is no fragile frontend tab-election protocol. Browsers may throttle background timers; stale ends are provisional and reconnect remains valid.

No unload/sendBeacon signal is sent: individual tab closure cannot reliably establish that the shared browser session ended. Silence is handled by the reusable stale action. No scheduler or notification sender is registered.

Presence is temporary evidence linked by `attendance_daily_record_id`; ended/time indexes support future retention. No purge runs yet and segments remain until a later cleanup is implemented. Any future purge must require a finalized durable day, all segments closed, and copied durable evidence; it must preserve the daily record. Raw ping histories are never retained. Finalization requires no new migration.

## Daily finalization

`FinalizeAttendanceDay::handle($organization, $record)` locks the user and durable day, verifies organization ownership and current attendance eligibility, and returns an `AttendanceFinalizationResult`: `finalized`, `already_finalized`, `not_ready` with a reason, or `ineligible`. A finalized record is returned unchanged, including any subsequent justification. Current and future calendar dates are never finalized, even after today's observation window closes.

Readiness requires a configured, valid organization observation window and a previous date whose window has ended. Calendar boundaries use the record's `schedule_timezone` snapshot. Observation hours are not snapshotted by the existing schema, so current saved observation hours determine readiness only; they never become expected work hours. Missing working-day schedule snapshots return `not_ready` rather than inventing hours. Current active/verified membership and Company Owner exclusion remain prerequisites, including for historical pending records.

All segments must match the record's organization, user and date and have valid time boundaries. Invalid evidence returns `not_ready` before stale closure. `CloseStaleAttendanceSessions::handle($record)` scopes the existing stale logic to that day; its no-argument behavior is unchanged. Eligible stale sessions end at their existing last-seen instant. Fresh open sessions remain open and return `not_ready`. Existing closed timestamps, raw daily first/last/confirmed logout, schedule snapshots, justification and review data are preserved.

`AttendancePresenceMinutes` unions all closed segment intervals, merging overlaps and adjacent intervals, excluding gaps, summing seconds and flooring the total once to whole minutes. Concurrent browser streams do not add duplicate time. No segment evidence means zero recorded minutes; first/last daily timestamps alone do not invent an uninterrupted interval. This is session presence, not continuous activity measurement. Stored `expected_minutes` already excludes the configured break, so finalization does not subtract it again.

`config/attendance.php` centralizes arrival, departure and presence shortfall graces at five minutes each. Arrival later than expected start plus grace is late; an effective end earlier than expected end minus grace is early. A shortfall exceeding the presence grace, or zero recorded presence on a working day, is `flagged`. Sufficient presence with a late/early deviation is `recovered`; sufficient presence without either is `normal`. The single primary exception follows `insufficient_presence`, then `early_departure`, then `late_arrival`. Gaps and inferred logout alone are not anomalies. Non-working days retain `non_working_day`, null variance and no exception.

The existing `RefreshAttendanceEnd` precedence determines the effective end: latest closed evidence wins, confirmed wins ties, and an earlier inferred end cannot displace confirmed evidence. A later resumed session may supersede an earlier confirmed logout without rewriting that raw logout. Finalization saves recorded minutes, variance (recorded minus expected), status, primary exception, derived end and server-authoritative UTC `finalized_at`. Unresolved flagged records receive next calendar day noon in the snapshot timezone, stored in UTC, including weekends; other outcomes have null escalation eligibility. Justification does not automatically resolve a record.

`FinalizePreviousAttendanceDays::handle($organization)` processes only that organization's existing unfinalized pending/non-working records in chunks and returns counts for each result. The per-record readiness check safely skips current/future dates even if organization timezone settings changed. Reruns skip finalized rows. No missing dates or owner records are generated. No command, scheduler, UI, notification delivery, report, activity monitoring or retention job is added; future jobs can invoke these domain actions.

## Employee attendance sheet

The existing Attendance menu opens `GET /organizations/{organization}/attendance?month=YYYY-MM`, rendered by `Organizations/Attendance.vue` through `EmployeeAttendanceController` for normal employees. Organization authorization and workspace membership are required. The personal query always uses the authenticated user's ID and requested organization; a `user_id` selector is prohibited. Only durable daily fields needed for display are returned, never raw presence sessions. Reads do not create, capture or finalize attendance. Company Owners and authorized administrators receive the management queue described below instead. Other ineligible accounts receive a not-applicable state without personal records.

There is no reliable employment/join-date field in the current user or organization membership model. The earliest existing personal attendance date is the practical lower navigation bound; there is no three-month history limit. Missing dates are not synthesized. Month counts represent saved records, not every calendar date. Stored day types and expected schedules remain authoritative, while Mercantile holiday names are looked up in the organization's Working Calendar holiday data for display only. Later holiday imports may therefore change the displayed name without rewriting attendance evidence.

The sheet displays date, day type, expected hours, first login, durable effective end, recorded presence, status and reason. Display uses the organization's current timezone and date/time format settings, never browser-local time. The durable derived end takes precedence over an earlier confirmed logout after a resumed session; confirmed logout is a fallback when derived end is absent. Confirmed and inferred evidence are explicitly labelled. Pending values remain partial, with no frontend attendance calculations. Non-working rows have subtle weekend/holiday styling and no expected hours or required presence. Below desktop width the table becomes stacked labelled rows using the same data and theme tokens.

`POST /organizations/{organization}/attendance/{attendanceDailyRecord}/justification` accepts only `justification_text` for application updates. It explicitly enforces current-user and organization ownership, eligibility and the existing policy, then delegates to `SubmitAttendanceJustification`. Extra evidence/status fields are ignored and cannot update attendance. Only unresolved flagged records can add or replace free-text reasons, limited to 5000 characters. The shared focus-trapping dialog displays date, recorded presence and primary flag reason. Validation errors remain in the dialog; submission never resolves the flag. There is no employee selector, generic update action, reporting integration, notification delivery or monitoring added.

## Management attendance exceptions

The same Attendance menu renders `Organizations/AttendanceExceptions.vue` for active Company Owners and users with organization-level `organization.manage`. The dedicated `GET /organizations/{organization}/attendance/exceptions` endpoint uses the same authorization; normal employees cannot enter it. Membership/workspace checks, the organization policy and record resolution policy enforce boundaries server-side. No owner attendance records are created, and normal employee personal history/navigation remain unchanged.

`GetAttendanceExceptions` queries only flagged records in the requested organization, newest date first with ID as a stable tie-breaker. The rolling boundary is organization-local today minus three calendar months (without month overflow), inclusive through today. Future and older dates are excluded; no old data is deleted. The same date limit applies to resolution requests so manipulated record IDs cannot bypass the operational scope. Historical administration beyond this boundary remains deferred to Reports.

There are exactly three filters: `unresolved` means `resolved_at` is null, including justified records awaiting review; `justified` is that unresolved subset with nonblank justification text; `resolved` means `resolved_at` exists. A submitted reason never resolves the anomaly. The row badge prioritizes Resolved, then Justified, then Unresolved. These management labels are distinct from the retained historical `flagged` attendance status. No separate reviewed field exists, so `resolved_at` is the management completion marker.

The queue uses server-side pagination of 25 rows and eager-loads only employee/reviewer IDs and names. The nine columns are employee, date, expected schedule, first login, final end, presence, issue, reason preview and management status. Human-readable issue labels and existing organization date/time format helpers are reused. The effective durable end keeps the personal-page precedence: a later derived/inferred end after reconnect is not replaced by an earlier confirmed logout. Confirmed/inferred ends are text-labelled. Raw presence sessions and sensitive user fields are not returned.

View details opens the existing shared focus-trapping dialog with full justification, schedule/evidence, finalized time, escalation eligibility, and resolution time/reviewer. All evidence is read-only. The focused `POST /organizations/{organization}/attendance/exceptions/{attendanceDailyRecord}/resolve` action delegates to `ResolveAttendanceException`, locks/reloads the record and checks its organization, permission, flagged status and date scope. It assigns server-authoritative UTC `resolved_at` and `resolved_by` once; retries preserve the first resolution. Justification, historical status, all schedule/evidence and escalation fields remain untouched. Extra request fields cannot modify them.

Reopening is deferred because clearing the current resolution fields would discard review history; an audit-history design is needed first. No new schema, notifications, scheduler, reports, charts, scoring or activity monitoring is introduced. The queue and details use existing theme tokens and become labelled stacked rows below desktop width, preserving employee, date, issue, status, reason and timing without horizontal overflow.
