PSA/ee/docs/plans/2025-12-14-time-entry-work-date-plan.md
Hermes 284313f908
Some checks are pending
Bidi Control Character Guard / bidi-control-guard (push) Waiting to run
Circular Dependency Check / Check for new circular dependencies (push) Waiting to run
Citus Migration Smoke / Combined migrations on single-node Citus (push) Waiting to run
E2E Fresh Install Tests / fresh-install-e2e (push) Waiting to run
ext-v2 guardrails / Run ext-v2 guard and ESLint (push) Waiting to run
Integration Tests / Check for relevant changes (push) Waiting to run
Integration Tests / ${{ (github.event_name == 'schedule' || github.event.inputs.suite == 'full') && 'Full integration suite' || 'Tier-1 integration subset' }} (push) Blocked by required conditions
Mobile checks / Mobile lint + typecheck (push) Waiting to run
Mobile checks / Mobile unit tests (push) Waiting to run
Mobile checks / Mobile dependency audit (report) (push) Waiting to run
Mobile checks / Mobile reproducibility checks (push) Waiting to run
Secrets guard (env backups) / Ensure no tracked env backup files (push) Waiting to run
Temporal Readiness / fast-readiness (push) Waiting to run
Temporal Readiness / docker-parity (push) Waiting to run
TypeScript Type Check / Nx affected typecheck (push) Waiting to run
Unit Tests / Skipped-test budget (push) Waiting to run
Unit Tests / Nx affected unit tests (push) Waiting to run
Unit Tests / Server unit coverage (informational) (push) Waiting to run
Validate Tenant Management Schema / Check for relevant changes (push) Waiting to run
Validate Tenant Management Schema / Validate Tenant Management Schema (push) Blocked by required conditions
EE Workflows Build Guard / ee-workflows-build-guard (push) Waiting to run
Initial import of AlgaPSA codebase from PSA server
Excluded: .git, node_modules, secrets/, compose.env, assemblyscript tgz

Source: /opt/alga-psa on psa.joliet.tech
2026-06-22 16:12:17 -05:00

13 KiB
Raw Blame History

Time Entry “Work Date” Plan (Option A)

Date: December 14, 2025
Status: Proposed
Owner: Time Tracking / Billing

Decisions (Confirmed)

  • work_date is not user-editable.
  • work_date is computed in the users timezone (i.e., “the users calendar date”).
  • We will store the timezone used for computation on each entry as work_timezone (for audit/debug).

Problem

Today, time entry → time sheet → time period mapping is effectively instant-based (start_time/end_time as timestamps) while time periods and the timesheet UX are calendar-based (“Apr 1Apr 7”, “week of Apr 1”, etc.). Because instants are stored/compared in UTC-ish representations, users in non-UTC timezones can end up entering “Apr 1” locally while the system evaluates the entry against a different calendar day, creating confusing edge cases when determining the correct period_id/timesheet.

We want to keep the current architecture (persisted time_periods, time_sheets keyed by period_id, approvals, period CRUD, background period generation) while removing the conceptual mismatch between user-local calendar days and UTC instants.

Proposed Solution (Option A)

Introduce an explicit date-based bucketing field on time entries:

  • Add work_date to time_entries as a date (no time).
  • Define work_date as “the calendar date the work is attributed to” in the users timezone.
  • Determine the time period / timesheet association using work_date vs time_periods.start_date/end_date (date-to-date comparisons), not using UTC instants.

We will still store start_time and end_time as instants for duration, auditability, ordering, and billing calculations, but grouping and period membership will use work_date.

Timezone Source of Truth

We need a stable source of truth for the users timezone at entry creation time.

Recommended resolution order:

  1. users.timezone (preferred, already exists in schema)
  2. Tenant default timezone (if we have one)
  3. 'UTC' (last resort)

Note: even if we fall back to a non-user timezone, we still persist work_timezone so we can detect and remediate missing user timezone configuration.

Goals

  • Preserve existing persisted time_periods behavior (listing, manual overrides, scheduled creation job).
  • Make timesheet bucketing consistent with “calendar day” as the UI expresses it.
  • Minimize schema/code churn and keep backwards compatibility.
  • Avoid retroactively changing historical timesheet membership when settings/timezones change.

Non-Goals

  • Eliminating time_periods records.
  • Reworking the billing cycle subsystem.
  • Changing approval semantics.

Data Model Changes

1) time_entries

Add:

  • work_date DATE NOT NULL
  • work_timezone TEXT NOT NULL
    • Stores the timezone used to compute work_date at create/update time (e.g., America/Los_Angeles).

Indexes (initial recommendation):

  • (tenant, user_id, work_date)
  • (tenant, work_date)

2) (Optional) time_sheets snapshot fields

Not strictly required for Option A, but consider adding:

  • period_start_date DATE
  • period_end_date DATE

This reduces join dependency for display and provides a historical snapshot even if periods are edited. If we keep period edit capability, we must decide whether edits should:

  • update existing timesheets displayed boundaries, or
  • be blocked once timesheets exist (current behavior blocks editing if sheets exist in server/src/lib/actions/timePeriodsActions.ts).

Behavior Changes

A) Saving a time entry

When creating/updating a time entry:

  • Compute work_date from start_time using the resolved user timezone.
  • Persist work_date and work_timezone.
  • Ignore any incoming work_date/work_timezone fields from clients (not user-editable).

B) Choosing a timesheet / period for an entry

When an entry is created outside a timesheet context (API, quick-add):

  • Find the time_periods row where:
    • start_date <= work_date and end_date > work_date (treat periods as half-open [start,end) dates)
  • Then fetchOrCreateTimeSheet(user_id, period_id) and attach time_sheet_id.

When an entry is created from a specific timesheet UI:

  • Continue attaching directly to that time_sheet_id/period_id.
  • Still compute/persist work_date for consistency and reporting.

C) Validation rule

Replace “time entry must fall within a valid time period” checks that currently compare instants with checks that compare work_date with period date boundaries.

Migration / Backfill Plan

Existing rows in time_entries need a backfilled work_date.

Constraints:

  • Historical user timezone at time of entry may not be available.

Backfill strategy options:

  1. User timezone backfill (preferred): compute using users.timezone when present.
  2. Fallback backfill: if a user has no timezone, fall back to tenant default (if any) else 'UTC'.

Backfill must also populate work_timezone with whatever timezone was used per row so we can identify entries computed with fallback logic.

Implementation Surface (What Changes Where)

Project-Specific Gotchas / Clarifications

  • Time periods are date-based: migrate time_periods.start_date/end_date to DATE (timezone-agnostic), keeping half-open [start,end) semantics. This eliminates server-timezone and cast-related ambiguity and makes period membership comparisons explicitly date-to-date.
  • Resolving users.timezone server-side: server actions that compute work_date (and “current period”) need a reliable way to fetch the current user and then read users.timezone.
    • For interactive flows: resolve timezone from the authenticated user record.
    • For non-interactive/system contexts (background jobs, service accounts): define an explicit fallback rule (tenant default or 'UTC') and persist that choice to work_timezone.
  • Enterprise/Citus impact: EE has Citus distribution migrations that reference time/billing tables. Adding columns/indexes to time_entries should be verified against EE distributed table behavior (and include any EE-specific migration steps if required).
  • Validation/test ripple: adding work_date/work_timezone will require updates across:
    • TypeScript interfaces (server/src/interfaces/timeEntry.interfaces.ts)
    • Zod schemas used by UI/server actions (server/src/lib/schemas/timeSheet.schemas.ts) and API schemas (server/src/lib/api/schemas/timeEntry.ts)
    • Test factories and fixtures that insert time_entries rows
  • “Today” in UI: existing code uses UTC-derived strings (e.g. new Date().toISOString().slice(0, 10)) for “today/current period” checks. These must be replaced with browser-local date logic anywhere they influence “current” labeling or default date selection.

Screens (UI)

  • server/src/components/time-management/time-entry/time-sheet/TimeEntryDialog.tsx
    • Ensure create/update calls do not send work_date; server computes it.
    • Validate the date/time pickers produce correct instants for start_time/end_time (avoid date-only parsing pitfalls).
  • server/src/components/time-management/time-entry/time-sheet/TimeSheetTable.tsx
    • Any “quick add” path that constructs timestamps from a displayed date should construct instants in the users timezone (not via toISOString().slice(0,10) patterns).
  • server/src/components/time-management/time-entry/TimePeriodList.tsx
    • The “Current” period highlight should use the users local date (not UTC derived from toISOString()).
  • “Create time entry from elsewhere” entry points that call saveTimeEntry:
    • server/src/components/time-management/interval-tracking/IntervalManagement.tsx
    • server/src/components/tickets/ticket/TicketDetails.tsx
    • server/src/components/projects/TaskForm.tsx
    • server/src/components/interactions/InteractionDetails.tsx
    • server/src/components/tickets/TicketingDashboard.tsx
    • server/src/components/user-activities/ActivityDetailViewerDrawer.tsx
    • Confirm these flows still land in the expected timesheet/period after work_date becomes authoritative.

Server Actions (Next.js “use server”)

  • server/src/lib/actions/timeEntryCrudActions.ts
    • Compute and persist work_date/work_timezone on create/update.
    • Ignore any client-supplied values for these fields.
  • server/src/lib/actions/timePeriodsActions.ts
    • Update getCurrentTimePeriod() to compute “today” in the users timezone (not server timezone).
  • server/src/lib/actions/timeSheetOperations.ts
    • Ensure any “current period” or “entry belongs to period” logic is date-based where applicable.

API Services (REST-ish)

  • server/src/lib/api/services/TimeEntryService.ts
    • When creating entries (and when auto-creating timesheets), use work_date (computed via user timezone) to find the correct period and timesheet.
    • Update getTimePeriodForDate/getOrCreateTimeSheet logic to compare using date values, not Date objects interpreted in server timezone.
    • Update time-tracking session start/stop flows to persist work_date/work_timezone.
  • server/src/lib/api/services/TimeSheetService.ts
    • If any endpoints derive “current period” or generate periods based on “today”, ensure “today” is user-local where needed (or explicitly tenant-local if its admin/system).

Billing / Background Jobs

  • server/src/lib/billing/billingEngine.ts
    • rolloverUnapprovedTime(...) mutates start_time/end_time; it must recompute and persist work_date accordingly (using the entrys work_timezone if available, otherwise resolve via user timezone).

Types / Schemas

  • server/src/interfaces/timeEntry.interfaces.ts
    • Add work_date and work_timezone to the time entry interface(s) as appropriate.
  • API Zod schemas in server/src/lib/api/schemas/timeEntry.ts
    • Ensure work_date/work_timezone are not client-writable; return them in responses if useful.

Phases & To-Dos

Phase 0 — Confirm invariants + timezone readiness

  • Confirm users.timezone is populated in real environments; if not, define how it gets set (e.g., profile screen, onboarding, or a one-time “set timezone” prompt).
  • Decide how strictly to enforce missing users.timezone (warn + fallback vs block time entry creation until set).
  • Align on the definition of “users timezone” for service accounts/API clients (what timezone do we use when there is no interactive user?).

Phase 1 — Database migration + backfill

  • Add work_date and work_timezone columns to time_entries (start nullable).
  • Migrate time_periods.start_date/end_date to DATE (timezone-agnostic) and update any dependent code/queries accordingly.
  • Backfill existing rows:
    • Join to users to compute work_date using users.timezone when present.
    • Use fallback timezone when missing, and persist the fallback into work_timezone.
  • Add NOT NULL constraints after backfill.
  • Add indexes for expected query patterns ((tenant, user_id, work_date), (tenant, work_date)).

Phase 2 — Server actions write-path

  • Update saveTimeEntry flow to compute/persist work_date/work_timezone from start_time in the user timezone.
  • Ensure updates that modify start_time also update work_date (and keep work_timezone consistent).
  • Ensure client-supplied work_date/work_timezone are ignored/rejected.

Phase 3 — “Current period” and period membership logic

  • Update getCurrentTimePeriod() to use the users local date (derived from users.timezone) when selecting a period.
  • Audit any remaining “find period for date” logic to ensure it compares using date values and consistent half-open boundaries.

Phase 4 — REST API alignment

  • Update TimeEntryService create/update paths to compute/persist work_date/work_timezone.
  • Update time-tracking session start/stop to populate these fields.
  • Update period lookup + auto-timesheet creation to use work_date rather than server-interpreted Date comparisons.
  • Update API schemas to treat work_date/work_timezone as server-controlled fields.

Phase 5 — Frontend adjustments and regression fixes

  • Update any UI “current period” indicator logic that uses UTC date strings to use the browsers local date.
  • Audit “quick add”/inline creation code paths that build timestamps from a displayed date and ensure they produce correct instants.
  • Ensure all entry-creation entry points remain functional and land in the expected timesheet.

Phase 6 — Billing/job correctness

  • Update BillingEngine.rolloverUnapprovedTime(...) to recompute work_date whenever it adjusts timestamps.
  • Search for any other code paths that mutate time_entries.start_time/end_time and ensure they also maintain work_date.

Phase 7 — Tests, rollout, monitoring

  • Add/extend tests covering timezone edge cases (entries around local midnight mapping to the expected period).
  • Add a lightweight metric/log for “missing user timezone used fallback” to drive cleanup.
  • Roll out behind a feature flag if desired, or deploy as a schema-first migration with dual-write then cutover.

Open Questions

  • For API integrations (no browser context), what is the expected timezone rule for work_date?