diff --git a/CLAUDE.md b/CLAUDE.md index cb47dd36..ce1109bb 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,15 +73,18 @@ Every database query MUST be scoped by `organization_id`. The `GlobalOrganizatio - **Local-first timer architecture**: local SQLite is the SOLE source of truth for tracked time, and the desktop is the only writer. `startTimer()` / `stopTimer()` / `switchProject()` and idle decisions write `timer_sessions` and RETURN — **no network call is on any of those paths**, so connectivity can delay an upload but can never affect or lose tracked time. Each session carries a client-generated uuid (`idempotency_key`) and a `revision` bumped by every local mutation; `synced_revision` records what the server confirmed, so a row is dirty iff `synced_revision <> revision`. `SessionSyncWorker` (`session-sync-worker.js`) pushes dirty rows to `POST /timer/sessions/sync` as an idempotent UPSERT. There is NO reconciliation layer — `reconcileTimerState()`, `syncOpenTimerFromServerStatus()`, `adoptServerStartedAt()` and their mutexes were deleted, because with one writer there is nothing to reconcile. Decision logic lives in `session-rules.js` (pure, no DB/Electron — better-sqlite3 is built for Electron's ABI and cannot load under Jest, so anything expressed as SQL is untestable in CI). See `bugs/offline-first-time-sync-refactor.md`. - **Never split a corpse.** The midnight split runs only on a session that is genuinely ALIVE: `maybeSplitAtMidnight()` first calls `staleLiveSessionDecision()` (pure, in `session-rules.js`) and, if there has been no real input for longer than the idle threshold, CLOSES the session at the last input instead of splitting it. Splitting a dead session manufactures one perfect 24-hour "work day" per calendar day it spans — that is how a Friday session nobody stopped became 24h + 24h + 11.7h = 63 hours by Monday. The guard fails OPEN (no last-active stamp, or no threshold ⇒ not stale) so it can never stop a timer someone is using. `lastActiveAt` — the anchor EVERY back-dated auto-stop closes at — is stamped LOCALLY from `powerMonitor.getSystemIdleTime()` on the 1s tick, never from a server-acknowledged heartbeat: with a 10-minute upload cadence a heartbeat cannot be accepted until its session syncs, so a server-driven stamp goes stale while the user is typing. See `bugs/desktop-force-quit-and-dead-machine-leave-time-open.md`. - **Abandoned entries (server backstop)**: `TimeEntrySyncService::closeAbandonedOpenEntries()` is the SINGLE implementation, driven by `timer:cleanup-stale` every 5 minutes; window `timer.abandoned_after_minutes` (**60 min**). It closes an open entry at its last **heartbeat** (else its own start), NEVER at `now()` and never at `client_synced_at`. **Liveness is the most recent of the last heartbeat AND `client_synced_at`** — the sync endpoint stamps the latter on every push of the live session including a no-change one, which is the only reason a 60-minute window is safe for an agent that is merely offline with queued heartbeats. The close is PROVISIONAL: `client_revision` is deliberately not touched, so a returning agent's next push overrides it and the one-writer contract holds. `CloseStaleTimerEntriesJob` was DELETED — it duplicated this on a wider window and closed at `updated_at`, i.e. at the agent's last push rather than the user's last input, billing the dead time in between. +- **Return-from-break notification**: the idle watchdog self-gates on `isTimerRunning`, so the moment it auto-stops the timer it also stops watching — and on a machine that never sleeps and never locks (charger + external display) NO other path reaches the user either: the auto-stop toast fires into an empty room, `notifyTrackingState()` only runs on `wake`/`unlock`/`startup`, and the idle alert was dismissed by the stop. `startReturnWatch()` is the mirror image — it runs while the timer is STOPPED, polls `powerMonitor.getSystemIdleTime()` every 15s, and announces when the user is active again after being away past the org idle threshold. Absence must be **peak-tracked** (`trackPeakIdle`), because the poll that catches the return reads 0 — the OS counter resets on the first keystroke — so a last-reading model makes every absence invisible; an unreadable counter fails SILENT, never spurious. Decision logic is pure in `desktop/src/main/return-to-work.js`. Three cues, because each alone gets swallowed: a `silent:false` notification with a UNIQUE id (Action Center dedups it against the earlier auto-stop toast otherwise), an in-renderer WebAudio beep (OS notification sound is dropped by macOS Focus / Windows Action Center — descending tones, deliberately distinct from the idle alert's rising ones), and the window itself with a banner that clears on the next start. Torn down in `removeSessionListeners()`. See `bugs/desktop-no-notification-on-return-from-break.md`. - **Termination**: `SIGTERM`/`SIGINT`/`SIGHUP` are routed into `app.quit()` so every catchable kill (`kill`, Ctrl+C, Activity Monitor "Quit", Task Manager "End task", OS shutdown) runs the same `before-quit` graceful close + bounded flush as tray > Quit. `SIGKILL` (Force Quit, `kill -9`, power loss) runs NO JavaScript on any OS — nothing can close the session at that instant, and the guarantee comes from local-first recovery instead: the row is durable in SQLite and closed at the last real input on the next launch. - **Midnight split**: a live session crossing 00:00 is closed at the boundary and immediately reopened, so no entry ever spans two calendar days and daily rollups (`ReportService`, `AttendanceService::generateDailyAttendance()`, `DailyActivitySummaryService`, payroll) stay exact. The boundary is computed in the **ORGANIZATION's** timezone — the `timezone` field on `GET /agent/config`, which is `User::getTimezoneForDates()`, the same zone `TimezoneAwareDateRange` uses server-side. Splitting on the machine's local zone would mis-attribute hours for anyone travelling. Runs on the 1s tray tick (a purely local correctness operation — must happen regardless of connectivity) and LOOPS over every boundary crossed, so a machine asleep Friday→Monday produces one row per day. Expected UI effect: the elapsed counter resets at midnight, because the current session genuinely restarts. -- **05:00 purge**: `SessionSyncWorker.purgeIfDue()` deletes local rows that are closed AND have a `server_entry_id` AND a `confirmed_at` AND `synced_revision = revision` AND were confirmed more than 24h ago. Live, dirty and unconfirmed rows are structurally unreachable by that statement. Scheduled by comparing the most recent 05:00 boundary (org timezone) against `sync_meta.last_purge_at` — sleep-safe by construction, unlike a `setTimeout` a sleeping laptop would skip. +- **05:00 purge**: `SessionSyncWorker.purgeIfDue()` deletes local rows that are closed AND have a `server_entry_id` AND a `confirmed_at` AND `synced_revision = revision` AND were confirmed more than 24h ago. Live, dirty and unconfirmed rows are structurally unreachable by that statement. Scheduled by comparing the most recent 05:00 boundary (org timezone) against `sync_meta.last_purge_at` — sleep-safe by construction, unlike a `setTimeout` a sleeping laptop would skip. **A confirmed row is still load-bearing while the offline queue references it**: `purgeConfirmed()` takes a keep-list fed by `offlineQueue.referencedSessionKeys()`, because the two SQLite databases have a foreign-key relationship and no constraint enforcing it — deleting the row a queued screenshot resolves through strands that shot permanently. - **Broadcast isolation**: `TimerStarted`/`TimerStopped` are `ShouldBroadcastNow`, so dispatching them talks to Reverb synchronously over HTTP. `TimeEntrySyncService` QUEUES them and dispatches after the transaction commits, each wrapped in try/catch. Dispatching inline made a Reverb outage roll back the write and fail the sync — a websocket restart would have silently blocked all tracked time from uploading. Regression: `test_a_broadcast_failure_never_loses_the_session`. - **Upload cadence — periodic ONLY (owner decision, 2026-08-03)**: tracking is local and continuous; uploading is a batch on a fixed **10-minute** interval (`SYNC_INTERVAL_MS`). There are NO event-driven pushes — start, stop, project switch, idle resolve, midnight split, wake and reconnect all used to fire one and no longer do. The only out-of-band flushes left exist to protect DATA, not dashboard freshness: launch (previous run's backlog), sign-out, quit, pre-update — after those the process may not be alive for the next tick. Accepted consequence: the web dashboard lags reality by up to one interval, including screenshots and heartbeats (both FK to a synced entry). Anything that reads a server total right after a local mutation MUST add `getUnsyncedCompletedSecondsForToday()` on top, or the displayed total drops by the work just finished (that is why the post-stop refresh does). - **Offline sync protocol**: one cycle = health gate (`GET /health/live` — NOT `/health`, which probes S3 and counts failed jobs and is far too expensive to poll on every cycle) → push dirty rows in batches of 100 (max 10 batches/cycle) → confirm → **then** flush `offlineQueue`. That ordering is mandatory: screenshots and heartbeats FK to `time_entries.id`, which exists only once the owning session has synced. The row's `revision` is captured at SEND time and written back on confirm — using the row's CURRENT revision would mark a session the user stopped mid-flight as synced, and the purge would then delete it. Backoff 30s/60s/2m/5m/10m, reset on success; `{ignoreBackoff:true}` for launch/logout/quit/pre-update. Triggers: the 10-minute interval, plus launch, logout, quit and pre-update — nothing else. A `rejected` row is KEPT locally — a rejection is never a licence to delete tracked time. - **Platform-specific network detection**: macOS/Linux use Electron's `net.isOnline()` directly. Windows adds a ping fallback (`ping 1.1.1.1` with 3s timeout) since `net.isOnline()` can report false positives on Windows. - **Sleep/wake behavior**: On suspend, `_suspendedAt` is recorded and capture services are paused. On resume, the sleep gap is calculated and compared against the idle threshold. Long sleeps trigger the idle alert; short sleeps resume tracking normally. After any resume, `sessionSyncWorker.syncNow({ignoreBackoff:true})` pushes sessions and then flushes the offline queue. **If an idle alert is genuinely showing at the moment of lock/sleep**, `isIdleAlertActive()` routes suspend through `idleDetector.suspend()` instead of the hard auto-stop: the window is hidden (not destroyed), `idleStartedAt` is preserved, and the timer stays LOCALLY paused (there is no server-side pause any more) — non-idle lid-close is unaffected and still hard-stops. On resume, `idleDetector.resume()` + `setAlertState()` re-enters `ALERTING` with the same `idleStartedAt` (idle duration spans the sleep gap) and the alert re-shows so the decision is never lost. A live session that slept across one or more midnights is split into per-day rows on the first tray tick after resume. The idle alert itself is also hardened for multi-screen/multi-Space setups: an in-renderer WebAudio beep (no CSP change) plus a unique-id system notification guarantee a cue regardless of OS notification policy, macOS windows set `fullScreenable:false` and re-assert `setVisibleOnAllWorkspaces(true,{visibleOnFullScreen:true})` after `show()` so the alert overlays fullscreen Spaces, and Windows uses `flashFrame()`+`moveTop()` to defeat the foreground-lock. **The alert is a SINGLETON — exactly ONE window, on the display under the cursor.** The per-display mirrors are retired (they gave a two-monitor desk two identical modals, and a stale primary surviving a lock/sleep stacked more on top); `_destroyAllIdleAlertWindows()` sweeps every live alert window before a new one is created, on primary close, and in `dismissIdleAlert()`. Keep `showIdleAlert()` free of any `await` between its already-showing guard and the window assignment, or two concurrent calls can interleave into two windows. See `bugs/desktop-idle-window-multiscreen-and-sleep.md`. -- **Offline queue**: Stores heartbeat data, screenshot file paths, and timer start/stop events only — no tokens or sensitive credentials in SQLite. Queue is closed and nullified on both logout paths to prevent cross-user data leakage. `add()` MUST persist every field `flush()` reads back (`idempotency_key`, `activity_score`, `display_index`/`display_count`) — dropping them cost presign dedupe, display identity, and could make a `local-` screenshot look like an unresolvable orphan. The per-shot size cap in `ScreenshotService._queueForOffline()` (`MAX_OFFLINE_SCREENSHOT_BYTES`) must stay in sync with the queue's `MAX_SCREENSHOT_SIZE` (2MB). +- **Screenshot capture is bound to a SERVER entry id, and must be rebound**: `POST /screenshots/presign` validates `time_entry_id` as a **uuid** and looks it up in `time_entries`. Since `startTimer()` makes no network call, `currentEntry.id` is the local SQLite id `local--` — passing that to `screenshotService.start()` 422s every live shot and dumps the whole day into the offline queue (measured: live uploads went 1627/1639 to 0/835 the week the refactor shipped). Two rules: `screenshotService.start()` takes `liveCaptureEntryId()`, which prefers an already-known `server_entry_id`; and `SessionSyncWorker`'s `onSessionConfirmed(localId, serverEntryId)` calls `screenshotService.rebindEntryId()` the first time a session earns a server id. That callback is the ONLY signal that can fix a session started offline — capture is bound before any server id exists. `rebindEntryId()` previously depended on `reconcileTimerState()`, which the offline-first refactor deleted, and nothing replaced it. See `bugs/desktop-screenshots-bound-to-local-entry-id.md`. +- **A queued screenshot carries the SESSION's uuid, not just its own**: `_resolveEntryId()` maps a `local-…` id to a real entry through `timer_sessions`, so a queued item dies if that row is deleted first — which the 05:00 purge and `clearForLogout()` both do. The per-shot `idempotency_key` is presign's dedupe key and matches no session, so `_queueForOffline()` also records `session_uuid` (`add()` must persist it, per the rule below) and resolution prefers it. Unresolvable items are HELD, never dropped — which means a silent leak unless it is reported: a flush that holds screenshots logs the count and escalates past 50. +- **Offline queue**: Stores heartbeat data, screenshot file paths, and timer start/stop events only — no tokens or sensitive credentials in SQLite. Queue is closed and nullified on both logout paths to prevent cross-user data leakage. `add()` MUST persist every field `flush()` reads back (`idempotency_key`, `session_uuid`, `activity_score`, `display_index`/`display_count`) — dropping them cost presign dedupe, display identity, and could make a `local-` screenshot look like an unresolvable orphan. The per-shot size cap in `ScreenshotService._queueForOffline()` (`MAX_OFFLINE_SCREENSHOT_BYTES`) must stay in sync with the queue's `MAX_SCREENSHOT_SIZE` (2MB). - **Offline screenshot backfill**: `POST /screenshots/presign` accepts a capture for a CLOSED entry when `captured_at` falls inside `[started_at − 2min, ended_at + 2min]` and the entry ended within `offlineBackfillDays()`, which tracks `config('timer.max_past_skew')` (30d) rather than a fixed 7 days; live uploads keep a 5-minute grace. It MUST NOT be shorter than the session window: the agent flushes sessions BEFORE screenshots, so a narrower horizon accepts the entry and then permanently 422s its evidence. See `bugs/offline-screenshots-rejected-after-entry-closed.md`. - **`pauseTimerForIdle()` is SYNCHRONOUS** (local-only since the offline-first refactor) — never `await` it or chain `.catch()`/`.then()` onto it. Doing so threw on every idle detection, after the pause had frozen tracking and before `showIdleAlert()` ran: no idle window on any OS, and the detector left wedged in DETECTED with no interval, which silently stood the idle hard-stop watchdog down for the whole session. `IdleDetector._check()` now wraps the `onIdleDetected` callback in try/catch and only re-arms to ALERTING when the callback left it in DETECTED (the `keep_idle_time` `always`/`never` policies resolve and re-start themselves). See `bugs/desktop-idle-alert-never-appears.md`. - **Idle alert owns the timer while it is up**: idle is resolved ENTIRELY in local SQLite — `POST /timer/idle` is gone, along with `pauseTimerForIdle`'s server round-trip, `resumeTimerAfterIdle`, `reanchorFromOfflineIdle`, `retryIdlePauseIfUnsynced`, `isIdlePauseAuthoritative` and the `idle_discard` queue type. "Continue tracking" closes the live session at `idleStartedAt` and opens a new one at NOW (one SQLite transaction), so the idle gap belongs to no session. **Two instants, not one**: `closeAndReopen(id, atIso, {reopenAtIso})` defaults to CONTIGUOUS (correct for project switch and the midnight split — no instant may belong to no session) and the idle discard is the ONE caller that must pass a later `reopenAtIso`. Passing a single instant does not discard the gap, it moves it into the successor row and still bills it — that shipped, and cost every idle cycle its gap (`bugs/desktop-idle-continue-still-bills-the-idle-gap.md`). Note the trap that hid it: `idle-discard-split.test.js` asserts the arithmetic against a MIRROR of `handleIdleAction` (index.js cannot be imported under Jest), so mirrors must be pinned to the real call site or they pass while the code is wrong; "Stop timer" closes at `idleStartedAt` without reopening. Pre-idle work is credited measured to idle-START, never idle-end — measuring to idle-end double-counts the discarded gap. `keep`/`reassign` remain DISABLED (owner policy, 2026-07-16) and the refactor does not revive them; note the server can no longer enforce that policy, since the sync endpoint receives opaque sessions (see the Known Consequences section of `bugs/offline-first-time-sync-refactor.md`). **`keep_idle_time` is now `prompt` or `never` only** — the `always` ("always keep idle time") org setting was the last path in the product that still billed an idle gap, resolving the cycle and counting on from the original start, and it now resolves as a DISCARD exactly like `never`. Normalised in THREE places, deliberately: `AgentController::idlePolicy()` folds `always`→`never` on read (so agents that predate this build are corrected without waiting for a release), the desktop's idle handler treats the two identically (so a stale cached config cannot bill either), and the settings page no longer offers the option. The stored value is left intact and `SettingsController` still ACCEPTS `always`, so no org is 422'd mid-migration. The idle WATCHDOG's `always` exemption is gone with it. The idle WATCHDOG still stands down while an alert is showing, and the popup/tray LOCK (`idle-lock` IPC + `idleLocked` on `get-timer-state`) is unchanged. diff --git a/backend/app/Http/Controllers/Api/V1/DashboardController.php b/backend/app/Http/Controllers/Api/V1/DashboardController.php index 9241af84..a28e46c7 100644 --- a/backend/app/Http/Controllers/Api/V1/DashboardController.php +++ b/backend/app/Http/Controllers/Api/V1/DashboardController.php @@ -266,8 +266,9 @@ private function employeeDashboard(User $user, Request $request): JsonResponse : null; // Week range uses the user's timezone so the boundaries align with their calendar week - $weekStart = Carbon::now($tz)->startOfWeek(); // Monday 00:00 local - $weekEnd = Carbon::now($tz)->endOfWeek(); // Sunday 23:59 local + $weekOffset = (int) $request->input('week_offset', 0); + $weekStart = Carbon::now($tz)->startOfWeek()->addWeeks($weekOffset); + $weekEnd = Carbon::now($tz)->endOfWeek()->addWeeks($weekOffset); [$weekStartUtc, $weekEndUtc] = TimezoneAwareDateRange::toUtcBounds( $weekStart->toDateString(), $weekEnd->toDateString(), @@ -334,6 +335,9 @@ private function employeeDashboard(User $user, Request $request): JsonResponse 'weekly_hours_target' => $weeklyTarget, 'daily_breakdown' => $dailyBreakdown, 'activity_percentage' => $activityPercentage, + 'week_offset' => $weekOffset, + 'week_start' => $weekStart->toDateString(), + 'week_end' => $weekEnd->toDateString(), 'date_from' => $responseDateFrom, 'date_to' => $responseDateTo, ]); diff --git a/backend/app/Http/Controllers/Api/V1/Hr/DepartmentController.php b/backend/app/Http/Controllers/Api/V1/Hr/DepartmentController.php index c092920f..e0700df2 100644 --- a/backend/app/Http/Controllers/Api/V1/Hr/DepartmentController.php +++ b/backend/app/Http/Controllers/Api/V1/Hr/DepartmentController.php @@ -20,6 +20,15 @@ public function index(Request $request): JsonResponse { $query = Department::where('organization_id', $request->user()->organization_id); + if ($request->user()->isEmployee()) { + $deptId = $request->user()->employeeProfile?->department_id; + if ($deptId) { + $query->where('id', $deptId); + } else { + $query->whereRaw('1 = 0'); + } + } + if ($request->has('is_active')) { $query->where('is_active', filter_var($request->input('is_active'), FILTER_VALIDATE_BOOLEAN)); } @@ -82,6 +91,20 @@ public function destroy(Request $request, string $id): JsonResponse public function tree(Request $request): JsonResponse { + if ($request->user()->isEmployee()) { + $deptId = $request->user()->employeeProfile?->department_id; + if ($deptId) { + $dept = Department::where('organization_id', $request->user()->organization_id) + ->with('positions') + ->find($deptId); + $tree = $dept ? [$dept->toArray()] : []; + } else { + $tree = []; + } + + return response()->json(['tree' => $tree]); + } + $tree = $this->service->getOrgTree($request->user()->organization); return response()->json(['tree' => $tree]); diff --git a/backend/app/Http/Controllers/Api/V1/TimeEntryController.php b/backend/app/Http/Controllers/Api/V1/TimeEntryController.php index 5d792301..659f3e23 100644 --- a/backend/app/Http/Controllers/Api/V1/TimeEntryController.php +++ b/backend/app/Http/Controllers/Api/V1/TimeEntryController.php @@ -114,6 +114,7 @@ public function update(Request $request, string $id): JsonResponse $request->validate([ 'project_id' => 'nullable|uuid', 'task_id' => 'nullable|uuid', + 'task_name' => 'nullable|string|max:255', 'started_at' => 'sometimes|date', 'ended_at' => 'nullable|date|after:started_at', 'notes' => 'nullable|string|max:1000', @@ -129,7 +130,33 @@ public function update(Request $request, string $id): JsonResponse $request->user()->organization->tasks()->findOrFail($request->task_id); } - $data = $request->only(['project_id', 'task_id', 'started_at', 'ended_at', 'notes']); + // Auto-create task from free-text name if no task_id provided + $taskId = $request->task_id; + if (! $taskId && ! empty($request->task_name)) { + $projectId = $request->project_id ?? $entry->project_id; + if ($projectId) { + $org = $request->user()->organization; + $task = \App\Models\Task::firstOrCreate( + ['organization_id' => $org->id, 'project_id' => $projectId, 'name' => trim($request->task_name)], + ['created_by' => $request->user()->id] + ); + $taskId = $task->id; + } + } + + $data = $request->only(['project_id', 'started_at', 'ended_at', 'notes']); + if ($request->has('task_id') || $request->has('task_name')) { + $data['task_id'] = $taskId; + } + + // Check if time fields actually changed before we apply the update. + $timeChanged = false; + if ($request->has('started_at') && $entry->started_at->toISOString() !== \Carbon\Carbon::parse($request->started_at)->toISOString()) { + $timeChanged = true; + } + if ($request->has('ended_at') && ($entry->ended_at?->toISOString() ?? null) !== ($request->ended_at ? \Carbon\Carbon::parse($request->ended_at)->toISOString() : null)) { + $timeChanged = true; + } // Capture pre-edit approval state so we know whether cached report totals // (which only include approved entries) need busting after the mutation. @@ -144,15 +171,12 @@ public function update(Request $request, string $id): JsonResponse ]); } - // Approval integrity (HIGH): a self-edit of a manual entry must not keep a - // stale 'approved'/'rejected' status when the editor lacks approve - // authority — the (possibly inflated) hours must not reach billable/payroll - // totals without a fresh re-approval. mustResetOnEdit() preserves the - // create() auto-approve-self rule so an org/project-scoped approver editing - // their OWN entry is NOT trapped in a queue only they could clear. - // Tracked/idle entries are not part of the approval workflow — leave them. + // Only reset approval when TIME fields changed — task/notes edits update + // immediately without re-approval. + $approvalReset = false; if ( - $entry->type === 'manual' + $timeChanged + && $entry->type === 'manual' && in_array($entry->approval_status, ['approved', 'rejected'], true) && $this->manualTimeEntries->mustResetOnEdit($request->user(), $entry) ) { @@ -163,6 +187,7 @@ public function update(Request $request, string $id): JsonResponse 'approved_at' => null, 'rejection_reason' => null, ]); + $approvalReset = true; } // Cache staleness (LOW 1): editing a manual entry, or any entry that was @@ -172,7 +197,10 @@ public function update(Request $request, string $id): JsonResponse app(ReportService::class)->flushForOrg($entry->organization_id); } - return response()->json(['entry' => $entry->fresh()->load(['project', 'task'])]); + return response()->json([ + 'entry' => $entry->fresh()->load(['project', 'task']), + 'approval_reset' => $approvalReset, + ]); } // TIME-07: Delete entry diff --git a/backend/app/Http/Requests/Hr/StoreLeaveRequestRequest.php b/backend/app/Http/Requests/Hr/StoreLeaveRequestRequest.php index e2103500..dafdb11e 100644 --- a/backend/app/Http/Requests/Hr/StoreLeaveRequestRequest.php +++ b/backend/app/Http/Requests/Hr/StoreLeaveRequestRequest.php @@ -22,7 +22,7 @@ public function rules(): array 'uuid', Rule::exists('leave_types', 'id')->where('organization_id', $orgId), ], - 'start_date' => ['required', 'date', 'after_or_equal:today'], + 'start_date' => ['required', 'date', 'after_or_equal:'.now()->subDays(7)->toDateString()], 'end_date' => ['required', 'date', 'after_or_equal:start_date'], 'reason' => ['required', 'string', 'max:1000'], 'half_day' => ['sometimes', 'boolean'], diff --git a/backend/app/Http/Requests/StoreTimeEntryRequest.php b/backend/app/Http/Requests/StoreTimeEntryRequest.php index 64728c3a..e2f31a93 100644 --- a/backend/app/Http/Requests/StoreTimeEntryRequest.php +++ b/backend/app/Http/Requests/StoreTimeEntryRequest.php @@ -29,6 +29,7 @@ public function rules(): array ], 'project_id' => ['nullable', 'uuid'], 'task_id' => ['nullable', 'uuid'], + 'task_name' => ['nullable', 'string', 'max:255'], 'started_at' => ['required', 'date', 'before_or_equal:now'], 'ended_at' => ['required', 'date', 'after:started_at'], 'notes' => ['nullable', 'string', 'max:1000'], diff --git a/backend/app/Policies/TimeEntryPolicy.php b/backend/app/Policies/TimeEntryPolicy.php index 0d27a448..18861c5d 100644 --- a/backend/app/Policies/TimeEntryPolicy.php +++ b/backend/app/Policies/TimeEntryPolicy.php @@ -74,9 +74,15 @@ public function delete(User $user, TimeEntry $entry): bool return false; } - // Own entry — only if user has delete permission at own scope + // Own entry — allow deleting own manual entries that are pending or rejected + // (even without time_entries.delete permission) if ($user->id === $entry->user_id) { - return app(PermissionService::class)->hasPermission($user, 'time_entries.delete'); + if (app(PermissionService::class)->hasPermission($user, 'time_entries.delete')) { + return true; + } + + return $entry->type === 'manual' + && in_array($entry->approval_status, ['pending', 'rejected'], true); } $service = app(PermissionService::class); diff --git a/backend/app/Services/ManualTimeEntryService.php b/backend/app/Services/ManualTimeEntryService.php index 4ee381f2..28cec37f 100644 --- a/backend/app/Services/ManualTimeEntryService.php +++ b/backend/app/Services/ManualTimeEntryService.php @@ -2,6 +2,7 @@ namespace App\Services; +use App\Models\Task; use App\Models\TimeEntry; use App\Models\User; use Carbon\Carbon; @@ -84,15 +85,25 @@ public function create(User $actor, array $data): TimeEntry $org->tasks()->findOrFail($data['task_id']); } + // Auto-create task from free-text name if no task_id provided + $taskId = $data['task_id'] ?? null; + if (! $taskId && ! empty($data['task_name']) && ! empty($data['project_id'])) { + $task = Task::firstOrCreate( + ['organization_id' => $org->id, 'project_id' => $data['project_id'], 'name' => trim($data['task_name'])], + ['created_by' => $actor->id] + ); + $taskId = $task->id; + } + $startedAt = Carbon::parse($data['started_at']); $endedAt = Carbon::parse($data['ended_at']); - return DB::transaction(function () use ($actor, $targetUser, $org, $data, $startedAt, $endedAt, $approved) { + return DB::transaction(function () use ($actor, $targetUser, $org, $data, $startedAt, $endedAt, $approved, $taskId) { $entry = TimeEntry::create([ 'organization_id' => $org->id, 'user_id' => $targetUser->id, 'project_id' => $data['project_id'] ?? null, - 'task_id' => $data['task_id'] ?? null, + 'task_id' => $taskId, 'started_at' => $startedAt, 'ended_at' => $endedAt, 'duration_seconds' => (int) abs($endedAt->diffInSeconds($startedAt)), diff --git a/bugs/README.md b/bugs/README.md index 07ce64ad..0e1909cb 100644 --- a/bugs/README.md +++ b/bugs/README.md @@ -27,6 +27,8 @@ Verify `file:line` references still match the codebase before implementing from | File | Area | Severity | Status / symptom | | ------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| [desktop-no-notification-on-return-from-break.md](desktop-no-notification-on-return-from-break.md) | Desktop notifications + idle watchdog | P1 | ✅ FIXED (2026-08-20) — employees came back from a break, saw nothing, and kept working untracked after the idle watchdog had auto-stopped the timer. Needs one ordinary setup to reproduce: a machine that never sleeps and never locks (charger + external display), where **every** existing path misses the user. The idle alert beeps into an empty room and is then dismissed by the stop; the "Timer auto-stopped" toast fires at the moment of the stop, hours before anyone returns; `notifyTrackingState()` — the function whose entire job is telling the user their state — only runs on `wake`/`unlock`/`startup`, and such a machine emits none of those; and the idle watchdog self-gates on `isTimerRunning`, so the instant it stops the timer it stops looking. Nothing watched for the user coming BACK. Fix: a return-from-break watcher — the mirror of the watchdog, running while the timer is STOPPED — polling `getSystemIdleTime()` every 15s and firing when the user has been away past the org idle threshold and is active again. Absence is **peak-tracked**, because the poll that catches the return reads 0 (the OS counter resets on the first keystroke) and a last-reading model would make every absence invisible; a `NaN` reading fails silent rather than inventing an absence. Three cues, since each alone is routinely swallowed: a `silent:false` toast with a unique id (Action Center would otherwise dedup it against the old auto-stop toast), an in-renderer WebAudio beep (macOS Focus / Windows Action Center drop notification sound — three descending tones, distinct from the idle alert's two rising ones), and the window itself with a red banner that clears when tracking resumes | +| [desktop-screenshots-bound-to-local-entry-id.md](desktop-screenshots-bound-to-local-entry-id.md) | Desktop screenshot capture + offline queue + session purge | P1 | ✅ FIXED (2026-08-20) — screenshots stopped reaching the dashboard while time sync stayed healthy: on 2026-08-20 seventeen users tracked 5–8h each and three had any screenshot at all. Live uploads (arrival within 60s of capture) went **1627/1639 on Aug 12 → 0/835 on Aug 18** — the offline-first rollout landed Aug 13. `startTimer()` makes no network call any more, so `currentEntry.id` is the local SQLite id `local--`, and it was passed straight to `screenshotService.start()`; `POST /screenshots/presign` validates `time_entry_id` as a **uuid** and looks it up in `time_entries`, so every live shot 422'd and fell into the offline queue. `rebindEntryId()` — written for exactly this — survived the refactor but its only caller, `reconcileTimerState()`, was deleted with it. The queue then lost them for good: resolution reads `timer_sessions.server_entry_id`, but the 05:00 purge (24h) and `clearForLogout()` delete that row with no knowledge of the queue's separate DB, and with a **15–42 hour** backlog lag shots routinely outlived their own session; the `idempotency_key` fallback could not help because the queued key is the SHOT's dedupe key, never the session's. Unresolvable items are *held*, not dropped, so they piled up behind `LIMIT 500` and were finally deleted **silently, with their image files**, by the 7-day TTL sweep. Heartbeats were unaffected (priority 1, and they carry the session uuid) — which is what isolated the fault. Fix: `onSessionConfirmed` rebinds capture to the real entry id the moment a session first syncs; `liveCaptureEntryId()` seeds `start()` from an already-known id; queued shots record `session_uuid` and resolve through it; `purgeConfirmed()` takes a keep-list fed by `offlineQueue.referencedSessionKeys()`; held shots are now logged instead of vanishing | | [approved-manual-time-missing-from-dashboard-and-reports.md](approved-manual-time-missing-from-dashboard-and-reports.md) | Backend dashboard + timer totals + reports, web reports page | P1 | ✅ FIXED (2026-08-18) — a manual entry showed **Approved** on Time Entries and counted in the day's page total (7:17:34), but Today's Hours read 7h 5m and the Reports summary read 5:31:35 while listing **5 entries** — it counted the row and dropped its 13 minutes. Two separate filters, one mistake: `DashboardController` (team/today/week/daily/active-projects) and `TimerService` (`status()`, `todayTotal()`) narrowed to `type='tracked'`, and the reports page/CSV/PDF rendered `total_seconds_tracked` beside a `COUNT(*)` taken over the whole approved set. Every other rollup — ReportService, timesheets, AttendanceService, DailyActivitySummaryService — already counted approved manual time, and `todayTotal` is documented as the one place the desktop picks manual entries up. All of them now apply one rule: worked time = every APPROVED entry that is not idle. `ReportService` gained `worked_seconds`/`total_seconds_worked`; `tracked_seconds` stays narrow because `idle_percent` and the billable gate are defined against it | | [overnight-shift-checkout-off-time-anchored-to-wrong-day.md](overnight-shift-checkout-off-time-anchored-to-wrong-day.md) | Backend check-in/checkout + shift schedule | P1 | ✅ FIXED (2026-08-12) — six employees moved onto a 16:00–01:00 evening shift were each booked **1,379 minutes (~23h) of overtime** for a normal 7.5-hour day, and were force-checked-out at 23:59, an hour before their shift ended, losing the 35–101 minutes each had worked past midnight. A shift whose `end_time` is at or before its `start_time` finishes the NEXT day, but three places anchored the off time to the record’s own date, resolving it ~15 hours BEFORE the shift began: `recomputeRecordRollups()` then read every checkout as "at or after the off time" (the phantom overtime), `resolveForcedCheckoutInstant()` could never reach its off-time fallback and collapsed to the `check_in_at + 1s` guard (a one-second working day), and `lastTrackedActivityInstant()` searched only the calendar day so post-midnight work was invisible. A fourth defect: the 00:00 sweep closed a shift that was still running — the very thing its docblock promises never to do. `AttendanceService` had carried the `addDay()` guard all along, so the tracker figures were right and only the check-in signal was wrong, which is why it stayed silent until the first overnight shift existed. One `offTimeFor()` helper now serves all call sites, the midnight sweep skips still-running shifts, and a second 06:00 PKT sweep closes them once the shift ends | | [web-timer-counts-a-session-that-already-ended.md](web-timer-counts-a-session-that-already-ended.md) | Timer status API + web dashboard timer | P1 | ✅ FIXED (2026-08-10) — desktop showed 00:11:16 while the web header climbed through 28:57. `computeOpenEntryElapsed()` was `now() − started_at`, so an open entry the agent had already closed locally kept accruing until the next 10-minute push — it did not lag, it INVENTED time (an idle gap here; up to a full cadence of "still tracking" after Stop; until the 60-min backstop after a force-quit). Found while verifying: `processHeartbeat()` attributed heartbeats by the server's Redis pointer, so a session closed at 12:37:16 collected 25 heartbeats through 13:03:28 — corrupting its final activity score, starving its successor, and making the stale entry look alive to any liveness check. Heartbeats now carry `session_uuid` (the client `idempotency_key`) and are refused when the session has not synced (the agent queues and replays them); elapsed freezes at `live_as_of` past `timer.live_elapsed_grace_minutes`; the web stops ticking and shows "as of HH:MM" | diff --git a/bugs/desktop-no-notification-on-return-from-break.md b/bugs/desktop-no-notification-on-return-from-break.md new file mode 100644 index 00000000..44c84788 --- /dev/null +++ b/bugs/desktop-no-notification-on-return-from-break.md @@ -0,0 +1,86 @@ +# No notification (or sound) when an employee returns from a break to a stopped timer + +**Status:** fixed +**Reported:** 2026-08-20 (owner, from employees) +**Severity:** P1 — silent loss of tracked time; employees work untracked without knowing + +## Symptom + +Employees came back from a break, saw nothing, and carried on working. The timer had been +auto-stopped while they were away, and the first they knew of it was noticing the tray +much later — by which point the intervening work was never recorded. + +## Why every existing path missed them + +The failure needs one specific (and completely ordinary) setup: **a machine that never +sleeps and never locks** — on charger, external display, screensaver off. That is the +normal office desktop. + +1. The idle detector fires and the idle alert appears — with a beep — but nobody is at + the desk to hear it. +2. The alert goes unanswered. The **idle watchdog** hard-stops the timer and calls + `dismissIdleAlert()`, so the modal is gone too. +3. `autoStopTimerForPowerEvent()` shows the "Timer auto-stopped" toast **at the moment of + the stop** — into an empty room. It is long gone from the screen by the time anyone + returns. +4. `notifyTrackingState()` — the function whose whole job is "tell the user their current + state" — is only ever called on `wake`, `unlock`, and `startup`. A machine that never + slept and never locked emits **none of those events**. +5. The idle watchdog itself self-gates on `isTimerRunning`, so the instant it stops the + timer it also stops looking. Nothing was watching for the user coming back. + +The result is a desktop that looks completely normal and a timer that is off. + +## Fix + +A return-from-break watcher: the mirror image of the idle watchdog, with the opposite +gate. It runs while the timer is **stopped**, polls `powerMonitor.getSystemIdleTime()` +every 15s, and fires when the user has been away past the org's idle threshold and is +now active again. + +Decision logic is pure and unit-tested in `desktop/src/main/return-to-work.js`: + +- **Peak-tracked absence.** The poll that observes the return sees `systemIdleSec === 0`, + because the OS counter resets on the first keystroke. Keying on the last reading would + make every absence invisible, so the longest reading of the absence is retained. +- **Announce on the return, never during the absence** — that is the entire point; the + auto-stop toast already covers the moment of the stop. +- **Fails silent, not spurious.** A `NaN` from `getSystemIdleTime()` (some Wayland + sessions throw) must not read as "away for ages". +- Defers to a live idle alert, never fires signed-out, one announcement per absence. + +### Three cues, deliberately + +Each one alone is routinely swallowed, so the alert uses all three: + +1. **System notification** with `silent: false` and a unique id — Windows Action Center + dedups back-to-back toasts, and this one must not be merged with the auto-stop toast + that fired hours earlier. +2. **In-renderer WebAudio beep** — macOS Focus and Windows Action Center drop notification + sound with no fallback, which is exactly why the idle alert already carries its own. + Three *descending* tones, distinct from the idle alert's two rising ones, so + "you are not being tracked" never sounds like "are you still there?". No external + resource, so the strict CSP (`default-src 'none'; script-src 'self'`) is unchanged. +3. **The window itself**, surfaced with a red banner. This is the cue no notification + policy can suppress, and it is still there if the user was out of earshot. + +The banner clears the moment tracking resumes. + +## Regression tests + +`desktop/test/return-to-work.test.js` — 24 tests covering the decision function +(return vs still-away, short break, idle-alert deference, signed-out, cooldown, NaN +handling, org threshold), peak-idle tracking, the notification copy, and the wiring +(stopped-gate, session lifecycle, all three cues, contextBridge-only channel, CSP-safe +beep, banner cleared on start). + +## Lessons + +- **A self-gating watchdog only covers half a transition.** `isTimerRunning` made the + idle watchdog correct and made it blind the instant it acted. Anything that changes + state on the user's behalf needs a matching watcher for the state it moved *to*. +- **A notification delivered to an empty room has not been delivered.** The auto-stop + toast was firing correctly and was worth nothing — the timing was the bug. +- The three notification paths that already existed all keyed off OS power events, so a + machine that never sleeps had no coverage at all. Worth asking of any user-facing + signal: what hardware setup makes this never fire? diff --git a/bugs/desktop-screenshots-bound-to-local-entry-id.md b/bugs/desktop-screenshots-bound-to-local-entry-id.md new file mode 100644 index 00000000..e9b8d005 --- /dev/null +++ b/bugs/desktop-screenshots-bound-to-local-entry-id.md @@ -0,0 +1,108 @@ +# Screenshots stopped uploading after the offline-first refactor + +**Status:** fixed +**Found:** 2026-08-20 (production) +**Introduced:** 2026-08-13 — the offline-first time-sync rollout (desktop v1.0.46) +**Severity:** P1 — ~90% of screenshots permanently lost; monitoring evidence gone for most of the org + +## Symptom + +Time tracking synced correctly on the 10-minute cadence, but screenshots stopped appearing +on the dashboard. On 2026-08-20, 17 users tracked 5–8 hours each and only 3 had a single +screenshot between them. Each install stopped on its own date rather than all at once, +which made it look like an intermittent per-machine problem. + +## Evidence + +Screenshots arriving within 60s of capture (`created_at - captured_at`), production: + +| Date | live | total | +|---|---|---| +| 2026-08-10 | 1613 | 1692 | +| 2026-08-11 | 1665 | 1677 | +| 2026-08-12 | 1627 | 1639 | +| **2026-08-13** | **47** | 292 | +| 2026-08-14 | 0 | 106 | +| 2026-08-18 | 0 | 835 | +| 2026-08-19 | 2 | 1025 | + +The live path died on the exact day the offline-first refactor reached production. +Everything after it dribbles in through the offline queue: mean capture→arrival lag of +**15–42 hours**, with 833 shots in the last 7 days taking more than 24 hours. + +Heartbeats were unaffected — all 17 users were sending them up to the current minute — +which is what ruled out connectivity, auth, sync, and the S3 pipeline, and isolated the +fault to the screenshot path. + +## Root cause + +`startTimer()` makes no network call any more, so `currentEntry.id` is the local SQLite +id `local--`. That id was handed straight to the capture service: + +```js +screenshotService.start(currentEntry.id); // index.js — a `local-…` id +``` + +`POST /screenshots/presign` validates `'time_entry_id' => 'required|uuid'` and then +`firstOrFail()`s it against `time_entries`. `local-1755690000-ab3f` is not a uuid, so +**every live screenshot 422'd**, exhausted its 3 retries, and fell into the offline queue. + +`rebindEntryId()` — the function written for exactly this — was only ever called by +`reconcileTimerState()`, which the refactor deleted. The one surviving call site (the +midnight split) passes another `local-…` id. So `currentEntryId` was never a real server +id for the entire life of the process. The rare live successes came from +`restoreLocalActiveSession()`, the only path using `server_entry_id || localActive.id`, +i.e. an app restart mid-session. + +### Why the queued fallback then lost them for good + +Queued screenshots resolve `local-…` → real id by reading `timer_sessions.server_entry_id`. +Two deletes remove that row with no knowledge of the queue's separate database: + +- `purgeConfirmed()` — 24h after confirmation, on the 05:00 sweep +- `clearForLogout()` — every sign-out + +With a backlog lag of 15–42 hours, shots routinely outlived their own session row. The +`idempotency_key` fallback could not save them: the queued key is the **screenshot's** +per-shot dedupe key, never the session's. Unresolvable items are then *held*, not dropped — +`continue; // do NOT count an attempt` — so they accumulated at the head of +`ORDER BY priority DESC, id ASC LIMIT 500` and were finally deleted, silently, with their +image files, by the 7-day TTL sweep. Heartbeats escaped all of this because they are +priority 1 and carry the session's uuid as their `idempotency_key`. + +## Fix + +1. **Rebind on confirm.** `SessionSyncWorker` gained `onSessionConfirmed(localId, serverEntryId)`, + fired once per session on the transition from "server has never seen this" to "id known". + `index.js` wires it to `screenshotService.rebindEntryId()`. This is the only signal that + can un-break the live path, because capture is bound before any server id exists. +2. **Seed correctly.** `liveCaptureEntryId()` prefers an already-known server id, covering + restore-after-restart, project switch, and any session older than one sync cycle. +3. **Anchor queued shots to the session.** `_queueForOffline()` records `session_uuid` + (the session's identity) alongside the per-shot `idempotency_key`, `add()` persists it, + and `_resolveEntryId()` resolves through it first. +4. **Purge guard.** `purgeConfirmed()` takes a keep-list; the worker feeds it + `offlineQueue.referencedSessionKeys()` so a session is never deleted while a queued + item still needs it. +5. **No silent loss.** A flush that holds screenshots now logs it, escalating to + `console.error` past 50 — the failure mode above was completely silent. + +## Regression tests + +`desktop/test/screenshot-entry-id-binding.test.js` — 12 tests covering the rebind +callback (fires once, never re-fires, survives a throwing handler), the index.js wiring, +`session_uuid` persistence and resolution precedence, and the purge keep-list. + +## Lessons + +- **Deleting a caller can silently orphan the fix it existed for.** `rebindEntryId()` + survived the refactor; the only thing that called it did not. Its comment still named + `reconcileTimerState()` as the caller — a dangling reference in a comment is a signal + worth grepping for whenever a subsystem is removed. +- **Two SQLite databases with a foreign-key relationship and no constraint between them + will drift.** The purge was provably safe against everything in *its own* database. +- **"Held, not dropped" is not a safe default** when nothing bounds or reports the hold. + It converted a loud, fixable 422 into silent deletion a week later. +- The blast radius came from a fallback path being promoted to the primary path. The + offline queue was sized for occasional outages and was suddenly carrying 36 shots/hour + per user, forever. diff --git a/compose.yaml b/compose.yaml index 7ecf8663..c33f3375 100644 --- a/compose.yaml +++ b/compose.yaml @@ -184,11 +184,10 @@ services: XDEBUG_MODE: '${SAIL_XDEBUG_MODE:-off}' XDEBUG_CONFIG: '${SAIL_XDEBUG_CONFIG:-client_host=host.docker.internal}' IGNITION_LOCAL_SITES_PATH: '${PWD}' + PHP_CLI_SERVER_WORKERS: 4 healthcheck: - # Route exists at backend/routes/api.php:46 and touches no database, - # so it goes healthy as soon as PHP is serving. test: ['CMD', 'curl', '-f', 'http://localhost/api/v1/health/live'] - interval: 10s + interval: 60s timeout: 5s retries: 5 start_period: 30s diff --git a/desktop/src/main/index.js b/desktop/src/main/index.js index 4e2dce82..eb9abdbf 100644 --- a/desktop/src/main/index.js +++ b/desktop/src/main/index.js @@ -92,6 +92,11 @@ const { buildTrackingStateNotification, shouldNotifyTrackingState, } = require("./system-notifications"); +const { + returnToWorkDecision, + trackPeakIdle, + buildReturnToWorkNotification, +} = require("./return-to-work"); const PowerManager = require("./power-manager"); const WEB_DASHBOARD_URL = @@ -1554,6 +1559,25 @@ function resolveServerEntryIdForQueue(meta) { return null; } +/** + * The entry id the LIVE capture path must post to `POST /screenshots/presign`. + * + * presign validates `time_entry_id` as a uuid and looks it up in `time_entries`, so a + * `local-…` id is rejected outright. Prefer the server entry id whenever the session has + * already synced (restore-after-restart, project switch, any session older than one sync + * cycle); fall back to the local id only while the server has genuinely never seen this + * session — `onSessionConfirmed` rebinds the moment that changes. + */ +function liveCaptureEntryId() { + if (!currentEntry) return null; + const localId = currentEntry._localId || currentEntry.id; + const resolved = resolveServerEntryIdForQueue({ + time_entry_id: localId, + idempotency_key: currentEntry.idempotency_key || null, + }); + return resolved || currentEntry.id; +} + // ── Global Error Handlers ──────────────────────────────────────────────────── process.on("uncaughtException", (error) => { @@ -1781,6 +1805,7 @@ function cleanupOnExit() { function removeSessionListeners() { PowerManager.unregisterPowerHandlers(); stopIdleWatchdog(); + stopReturnWatch(); app.removeAllListeners("browser-window-focus"); // Screen-recording permission is machine state, not session state — put its // focus re-check back after the sweep above, or a logout would silently @@ -1791,6 +1816,7 @@ function removeSessionListeners() { _lastStateNotifAt = 0; _lastNotifiedTracking = null; _lastAutoStopNotifAt = 0; + _lastReturnNotifAt = 0; } // Force logout — called when token refresh fails (password changed, tokens revoked). @@ -2059,6 +2085,17 @@ async function initializeApp() { apiClient, offlineQueue, getTimeZone: () => config?.timezone || DEFAULT_CONFIG.timezone, + // The live capture is bound to a `local-…` id at start() time, because starting + // a timer makes no network call any more. `POST /screenshots/presign` validates + // `time_entry_id` as a uuid, so every live shot 422s until the session earns a + // real server id — which only happens here, on the first confirmed push. + // Rebinding is what keeps screenshots on the LIVE path instead of dumping the + // whole day into the offline queue. + onSessionConfirmed: (localId, serverEntryId) => { + if (!currentEntry) return; + if ((currentEntry._localId || currentEntry.id) !== localId) return; + screenshotService?.rebindEntryId(serverEntryId); + }, }); sessionSyncWorker.start(); @@ -2084,6 +2121,17 @@ async function initializeApp() { getIsAppVisible, activityMonitor, ); + // Same anchor the activity monitor uses. A queued screenshot records the SESSION's + // uuid so it can still resolve to a server entry id after the row its `local-…` id + // points at has been purged. + screenshotService.getCurrentEntryMeta = () => + currentEntry + ? { + time_entry_id: + currentEntry._localId || currentEntry.id || null, + idempotency_key: currentEntry.idempotency_key || null, + } + : null; // Register the screenshot-captured callback ONCE at service creation so that // _lastScreenshotAt is updated and a live `activity-update` is pushed to the // popup on EVERY capture — regardless of which path called screenshotService @@ -2513,6 +2561,9 @@ async function initializeApp() { // 12h phantom even when idle detection is off or the alert never showed. // Torn down in removeSessionListeners() (both logout paths). startIdleWatchdog(); + // The other half of the same guarantee: the watchdog stops the timer when the user + // leaves, this tells them about it when they come back. + startReturnWatch(); // ── Instant sync on focus / unlock ────────────────────────────────────── // When the user returns to the app (unlock, focus), trigger an immediate @@ -3835,14 +3886,15 @@ function afterStartTimer(projectIdForTotal, todayTotalForPopup) { ); } try { + const captureEntryId = liveCaptureEntryId(); console.log( - `[afterStartTimer] Calling screenshotService.start(${currentEntry.id})`, + `[afterStartTimer] Calling screenshotService.start(${captureEntryId})`, ); // NOTE: the screenshot-captured callback (which updates _lastScreenshotAt // and pushes a live `activity-update`) is registered once at service // creation in initializeApp(), so it applies to every start() path — // not just this one. Do not re-register it here. - screenshotService.start(currentEntry.id); + screenshotService.start(captureEntryId); console.log("[afterStartTimer] screenshotService started"); } catch (e) { console.error( @@ -4445,6 +4497,124 @@ function stopIdleWatchdog() { } } +// ── Return-from-break notifier ─────────────────────────────────────────────── +// +// The idle watchdog above self-gates on `isTimerRunning`, so the moment it stops the +// timer it also stops looking. Nothing else watches for the user coming BACK: the +// auto-stop toast fired into an empty room, `notifyTrackingState()` only runs on +// wake/unlock/startup (a desktop that never slept emits none of those), and the idle +// alert was dismissed by the stop. This interval is the missing half — it runs while +// the timer is STOPPED and announces, with sound, the moment the user is back. +// +// Deliberately the mirror image of the watchdog: same cheap one-call-per-tick shape, +// opposite gate. Decision logic is pure and unit-tested in return-to-work.js. +const RETURN_WATCH_TICK_MS = 15_000; +let _returnWatchInterval = null; +let _returnPeakIdleSec = 0; +let _lastReturnNotifAt = 0; + +function _returnWatchTick() { + try { + if (isTimerRunning) { + // Tracking again — this absence is over and must not carry into the next. + _returnPeakIdleSec = 0; + return; + } + + let systemIdleSec; + try { + systemIdleSec = powerMonitor.getSystemIdleTime(); + } catch { + return; // unreadable counter (some Wayland sessions) — say nothing + } + + _returnPeakIdleSec = trackPeakIdle(_returnPeakIdleSec, systemIdleSec); + + const now = Date.now(); + const { notify, reason } = returnToWorkDecision({ + isAuthenticated, + isTracking: isTimerRunning, + isIdleAlertActive: isIdleAlertActive(), + systemIdleSec, + peakIdleSec: _returnPeakIdleSec, + now, + lastNotifiedAt: _lastReturnNotifAt, + awayThresholdSec: getSleepGapThresholdSec(), + }); + + if (!notify) return; + + const awaySec = _returnPeakIdleSec; + _lastReturnNotifAt = now; + _returnPeakIdleSec = 0; // one announcement per absence + + console.log( + `[ReturnWatch] user back after ${awaySec}s with timer stopped (${reason})`, + ); + logToFile("info", `[RETURN_FROM_BREAK] awaySec=${awaySec}`); + notifyReturnToWork(awaySec); + } catch (e) { + console.error("[ReturnWatch] tick failed:", e.message); + } +} + +/** + * Tell the user — loudly — that they are back and NOT being tracked. + * + * Three independent cues, because each one alone is routinely swallowed: macOS Focus + * and Windows Action Center drop notification SOUND with no fallback (the same reason + * the idle alert grew an in-renderer beep), and a toast is missable on a second monitor. + * The window itself is the cue that cannot be suppressed by notification policy. + */ +function notifyReturnToWork(awaySec) { + const { title, body } = buildReturnToWorkNotification(awaySec); + + // 1. System notification, with sound, and a unique id so Windows does not dedup it + // against the auto-stop toast that fired while the user was away. + showSystemNotification({ + title, + body, + silent: false, + durationMs: 12_000, + id: `trackflow-return-${Date.now()}`, + onClick: () => { + try { + showPopup(); + } catch {} + }, + }); + + // 2. In-renderer WebAudio beep — independent of OS notification policy. + // 3. Surface the window so the stopped state is visible, not just audible. + try { + showPopup(); + notifyPopup("return-from-break", { awaySec, playSound: true }); + } catch (e) { + console.warn("[ReturnWatch] could not surface the window:", e.message); + } + + // This IS the state message for the return — stop the generic "not tracking" + // notif from firing a contradictory second toast if an unlock follows. + markAutoStopNotified(); +} + +function startReturnWatch() { + stopReturnWatch(); + _returnPeakIdleSec = 0; + _returnWatchInterval = setInterval(() => { + _returnWatchTick(); + }, RETURN_WATCH_TICK_MS); + if (_returnWatchInterval.unref) _returnWatchInterval.unref(); +} + +function stopReturnWatch() { + if (_returnWatchInterval) { + clearInterval(_returnWatchInterval); + _returnWatchInterval = null; + } + _returnPeakIdleSec = 0; +} + /** * Close stale open sessions when the app was offline/crashed longer than the gap threshold. * Runs before reconcileTimerState on startup. diff --git a/desktop/src/main/offline-queue.js b/desktop/src/main/offline-queue.js index dbfbd471..158c9f9b 100644 --- a/desktop/src/main/offline-queue.js +++ b/desktop/src/main/offline-queue.js @@ -88,7 +88,11 @@ class OfflineQueue { try { const resolved = this.resolveServerEntryId({ time_entry_id: raw, - idempotency_key: meta && meta.idempotency_key, + // Screenshots carry the owning session's uuid separately, because their own + // `idempotency_key` is a per-shot dedupe key that matches no session. For + // heartbeats the two are the same value, so passing session_uuid first is + // strictly a widening of what can resolve. + idempotency_key: (meta && meta.session_uuid) || (meta && meta.idempotency_key), }); if (resolved && !String(resolved).startsWith('local-')) return String(resolved); } catch (e) { @@ -105,7 +109,10 @@ class OfflineQueue { // resolve. Holding it loops forever: it is re-read and "held" every flush cycle, // spamming the log and blocking the queue. Such orphans must be DROPPED, not held. _isUnresolvableOrphan(meta) { - const hasIdem = !!(meta && meta.idempotency_key != null && meta.idempotency_key !== ''); + const hasIdem = !!( + (meta && meta.session_uuid != null && meta.session_uuid !== '') || + (meta && meta.idempotency_key != null && meta.idempotency_key !== '') + ); if (hasIdem) return false; const raw = meta && meta.time_entry_id != null ? String(meta.time_entry_id) : null; // In the unresolved branch a real id would already have resolved, so raw here is @@ -113,6 +120,31 @@ class OfflineQueue { return raw == null; } + /** + * Local session ids / uuids that queued items still need in order to resolve a server + * entry id. The purge must not delete a timer_sessions row while one of these is + * outstanding — doing so strands the item permanently (it holds forever, then dies + * silently in the 7-day TTL sweep, taking its screenshot file with it). + */ + referencedSessionKeys() { + if (!this.db) return []; + const keys = new Set(); + try { + const rows = this.db.prepare('SELECT data FROM queue').all(); + for (const r of rows) { + let data; + try { data = JSON.parse(r.data); } catch { continue; } + const raw = data && data.time_entry_id != null ? String(data.time_entry_id) : null; + if (raw && raw.startsWith('local-')) keys.add(raw); + if (data && data.session_uuid) keys.add(String(data.session_uuid)); + if (data && data.idempotency_key) keys.add(String(data.idempotency_key)); + } + } catch (e) { + console.warn('[OfflineQueue] referencedSessionKeys failed:', e.message); + } + return Array.from(keys); + } + init() { try { const Database = require('better-sqlite3'); @@ -298,6 +330,10 @@ class OfflineQueue { captured_at: data.captured_at, idempotency_key: data.idempotency_key, }; + // The owning session's uuid — the only handle that still resolves to a server + // entry id after the 05:00 purge (or a sign-out) deletes the timer_sessions row + // this shot's `local-…` id points at. + if (data.session_uuid) queueData.session_uuid = data.session_uuid; if (data.app_name) queueData.app_name = data.app_name; if (data.window_title) queueData.window_title = data.window_title; if (data.activity_score != null) queueData.activity_score = data.activity_score; @@ -344,6 +380,7 @@ class OfflineQueue { const screenshotFilesToDelete = []; // Track files to delete after successful upload let transientStop = false; // Set when we hit rate-limit/server/network — pause & retry later let screenshotsUploadedThisFlush = 0; // For pacing between screenshot uploads + let heldScreenshots = 0; // Unresolvable this cycle — surfaced below, never silent for (const item of items) { let data; @@ -405,6 +442,7 @@ class OfflineQueue { deleteIds.push(item.id); continue; } + heldScreenshots++; console.log(`[OfflineQueue] Holding screenshot — entry not synced yet (entry=${data.time_entry_id})`); continue; // leave in queue + keep file; do NOT count an attempt } @@ -523,6 +561,16 @@ class OfflineQueue { } } + // A held screenshot costs nothing once; a persistently held one is evidence being + // lost on a timer, since the 7-day TTL sweep deletes it and its file without a word. + if (heldScreenshots > 0) { + const level = heldScreenshots >= 50 ? 'error' : 'warn'; + console[level]( + `[OfflineQueue] ${heldScreenshots} screenshot(s) could not resolve a server entry id this flush` + + (heldScreenshots >= 50 ? ' — these will be dropped by the TTL sweep if they stay unresolvable' : '') + ); + } + if (transientStop) { // Hit rate-limit/server/network — advance backoff so the scheduled retry // waits longer (5→15→30→60→120s), letting the rate-limit window clear. diff --git a/desktop/src/main/return-to-work.js b/desktop/src/main/return-to-work.js new file mode 100644 index 00000000..caa0e7ca --- /dev/null +++ b/desktop/src/main/return-to-work.js @@ -0,0 +1,123 @@ +/** + * "You're back, and you are NOT being tracked." + * + * The gap this closes: an employee walks away for a break on a machine that never + * sleeps and never locks (on charger, external display, screensaver off — the normal + * office desktop). The idle detector fires, the alert goes unanswered, and the idle + * watchdog hard-stops the timer. Every existing notification path then misses them: + * + * - the auto-stop toast fires at the moment of the stop, while nobody is at the desk, + * and is gone from the screen long before they sit back down; + * - `notifyTrackingState()` only runs on wake / unlock / startup, and a machine that + * never slept emits none of those; + * - the idle alert window was dismissed by the stop itself. + * + * So the user returns to a normal-looking desktop and works untracked until they happen + * to glance at the tray. This module decides when to tell them. + * + * Pure: no Electron, no I/O, no timers — index.js owns the polling and the effects. + * (Same constraint as session-rules.js: anything importable is anything testable.) + */ + +/** A break worth announcing. Shorter absences are noise — the user knows they stepped away. */ +const DEFAULT_AWAY_THRESHOLD_SEC = 120; + +/** + * OS idle seconds at or below which the user counts as "back at the keyboard". + * `getSystemIdleTime()` resets to 0 on the first input event, so this only needs to + * absorb the poll interval, not real think-time. + */ +const DEFAULT_RETURN_ACTIVE_SEC = 15; + +/** One announcement per absence; re-arming needs a fresh absence, not a fresh poll. */ +const DEFAULT_COOLDOWN_MS = 60_000; + +/** + * Decide whether the user has just come back from a break to a stopped timer. + * + * @param {object} o + * @param {boolean} o.isAuthenticated no notifications to a signed-out app + * @param {boolean} o.isTracking a running timer needs no warning + * @param {boolean} o.isIdleAlertActive the alert is already asking them — don't stack + * @param {number} o.systemIdleSec `powerMonitor.getSystemIdleTime()` + * @param {number} o.peakIdleSec the longest idle reading observed in this absence + * @param {number} o.now ms + * @param {number} [o.lastNotifiedAt=0] ms of the previous announcement + * @param {number} [o.awayThresholdSec] + * @param {number} [o.returnActiveSec] + * @param {number} [o.cooldownMs] + * @returns {{ notify: boolean, reason: string }} + */ +function returnToWorkDecision({ + isAuthenticated, + isTracking, + isIdleAlertActive = false, + systemIdleSec, + peakIdleSec, + now, + lastNotifiedAt = 0, + awayThresholdSec = DEFAULT_AWAY_THRESHOLD_SEC, + returnActiveSec = DEFAULT_RETURN_ACTIVE_SEC, + cooldownMs = DEFAULT_COOLDOWN_MS, +} = {}) { + if (!isAuthenticated) return { notify: false, reason: 'signed-out' }; + + // A running timer is the good case — saying nothing is correct. + if (isTracking) return { notify: false, reason: 'tracking' }; + + // The idle alert is a modal asking this exact question. Two prompts is worse than one. + if (isIdleAlertActive) return { notify: false, reason: 'idle-alert-active' }; + + // Fail SAFE, not silent: an unreadable idle counter (some Wayland sessions throw) + // must not be mistaken for "the user is away", or we would announce nothing forever. + if (!Number.isFinite(systemIdleSec) || !Number.isFinite(peakIdleSec)) { + return { notify: false, reason: 'no-idle-reading' }; + } + + // Never been away long enough for this to be a "break". + if (peakIdleSec < awayThresholdSec) return { notify: false, reason: 'not-away' }; + + // Away, but still away — announce on the RETURN, not while the desk is empty. + // This is the whole point: the auto-stop toast already fired into an empty room. + if (systemIdleSec > returnActiveSec) return { notify: false, reason: 'still-away' }; + + if (now - lastNotifiedAt < cooldownMs) return { notify: false, reason: 'cooldown' }; + + return { notify: true, reason: 'returned-to-stopped-timer' }; +} + +/** + * Track the longest idle reading of the current absence. + * + * Peak rather than "last reading" because the poll can easily land AFTER the user has + * already touched the keyboard, by which point `getSystemIdleTime()` has reset to 0 and + * the absence would be invisible. Reset once the user is back and has been told. + * + * @returns {number} the new peak + */ +function trackPeakIdle(peakIdleSec, systemIdleSec) { + if (!Number.isFinite(systemIdleSec)) return peakIdleSec; + if (!Number.isFinite(peakIdleSec)) return systemIdleSec; + return Math.max(peakIdleSec, systemIdleSec); +} + +/** Wording for the announcement. Pure so the copy is unit-testable. */ +function buildReturnToWorkNotification(awaySec) { + const mins = Math.max(1, Math.round((Number(awaySec) || 0) / 60)); + const away = mins >= 60 + ? `${Math.floor(mins / 60)}h ${mins % 60}m` + : `${mins} min`; + return { + title: 'TrackFlow — Welcome back. You are NOT being tracked', + body: `The timer stopped while you were away (${away}). Time since then has NOT been recorded — start the timer to resume tracking.`, + }; +} + +module.exports = { + returnToWorkDecision, + trackPeakIdle, + buildReturnToWorkNotification, + DEFAULT_AWAY_THRESHOLD_SEC, + DEFAULT_RETURN_ACTIVE_SEC, + DEFAULT_COOLDOWN_MS, +}; diff --git a/desktop/src/main/screenshot-service.js b/desktop/src/main/screenshot-service.js index b04e22fe..bb3c6fd2 100644 --- a/desktop/src/main/screenshot-service.js +++ b/desktop/src/main/screenshot-service.js @@ -128,6 +128,24 @@ class ScreenshotService { this._onWallpaperDetected = null; // Optional callback when a screenshot is captured (uploaded or queued) this._onScreenshotCaptured = null; + // Injected by index.js — returns { time_entry_id, idempotency_key } for the LIVE + // session, mirroring ActivityMonitor.getCurrentEntryMeta. A queued screenshot must + // record the SESSION's uuid (never its own per-shot idempotency_key, which matches + // nothing in timer_sessions) or it becomes unresolvable the moment the 05:00 purge + // or a sign-out deletes the row it was going to resolve through. + /** @type {(() => ({time_entry_id?: string, idempotency_key?: string}|null))|null} */ + this.getCurrentEntryMeta = null; + } + + // The live session's uuid, when index.js has wired the accessor. + _currentSessionUuid() { + if (typeof this.getCurrentEntryMeta !== 'function') return null; + try { + const meta = this.getCurrentEntryMeta(); + return (meta && meta.idempotency_key) || null; + } catch { + return null; + } } // Set a callback that saves restart state before showing the permission dialog @@ -1140,6 +1158,11 @@ class ScreenshotService { captured_at: capturedAt || new Date().toISOString(), idempotency_key: idempotencyKey || crypto.randomUUID(), }; + // Second, independent route back to the server entry id. `idempotency_key` above + // is this SHOT's dedupe key for presign; `session_uuid` is the owning session's + // identity, which is what timer_sessions is keyed on. + const sessionUuid = this._currentSessionUuid(); + if (sessionUuid) data.session_uuid = sessionUuid; if (appName) data.app_name = appName; if (windowTitle) data.window_title = windowTitle; if (activityScore != null) data.activity_score = activityScore; diff --git a/desktop/src/main/session-sync-worker.js b/desktop/src/main/session-sync-worker.js index 00e47d1a..469582c4 100644 --- a/desktop/src/main/session-sync-worker.js +++ b/desktop/src/main/session-sync-worker.js @@ -52,13 +52,19 @@ class SessionSyncWorker { * @param {function} deps.getTimeZone () => IANA zone for day boundaries * @param {object} [deps.offlineQueue] flushed after sessions land * @param {function} [deps.onPurge] called with the number of rows purged + * @param {function} [deps.onSessionConfirmed] (localId, serverEntryId) => void, fired + * the moment a session first earns a real server entry id. This is the ONLY signal + * that can un-break the live screenshot path: capture is bound at start() time, when + * no server id exists yet, so without this the whole session keeps posting its + * `local-…` id. See bugs/desktop-screenshots-bound-to-local-entry-id.md */ - constructor({ store, apiClient, getTimeZone, offlineQueue = null, onPurge = null } = {}) { + constructor({ store, apiClient, getTimeZone, offlineQueue = null, onPurge = null, onSessionConfirmed = null } = {}) { this.store = store; this.apiClient = apiClient; this.getTimeZone = getTimeZone || (() => DEFAULT_TIMEZONE); this.offlineQueue = offlineQueue; this.onPurge = onPurge; + this.onSessionConfirmed = onSessionConfirmed; this._syncTimer = null; this._purgeTimer = null; @@ -220,12 +226,23 @@ class SessionSyncWorker { `[SessionSync] Duration differs for ${row.idempotency_key}: local=${row.duration_seconds}s server=${result.duration_seconds}s`, ); } + // Capture whether this row had a server id BEFORE the write, so the + // callback fires exactly once per session — on the transition from + // "server has never seen this" to "server entry id known". + const hadServerId = !!row.server_entry_id; this.store.markConfirmed( id, revision, result.time_entry_id, result.duration_seconds ?? null, ); + if (!hadServerId && result.time_entry_id && this.onSessionConfirmed) { + try { + this.onSessionConfirmed(id, String(result.time_entry_id)); + } catch (e) { + console.warn('[SessionSync] onSessionConfirmed threw:', e.message); + } + } confirmed++; continue; } @@ -284,7 +301,16 @@ class SessionSyncWorker { return 0; } - const removed = this.store.purgeConfirmed(nowMs); + let keepKeys = []; + try { + if (this.offlineQueue && typeof this.offlineQueue.referencedSessionKeys === 'function') { + keepKeys = this.offlineQueue.referencedSessionKeys(); + } + } catch (e) { + console.warn('[SessionSync] referencedSessionKeys failed:', e.message); + } + + const removed = this.store.purgeConfirmed(nowMs, undefined, keepKeys); this.store.setMeta('last_purge_at', String(nowMs)); if (removed > 0) { diff --git a/desktop/src/main/work-session-store.js b/desktop/src/main/work-session-store.js index 308f4e7e..57a3d4ae 100644 --- a/desktop/src/main/work-session-store.js +++ b/desktop/src/main/work-session-store.js @@ -451,9 +451,19 @@ class WorkSessionStore { * unreachable by this statement. The age grace means a row is never deleted in the * same breath as its acknowledgement. */ - purgeConfirmed(nowMs = Date.now(), minAgeMs = PURGE_MIN_AGE_MS) { + purgeConfirmed(nowMs = Date.now(), minAgeMs = PURGE_MIN_AGE_MS, keepKeys = []) { try { const cutoff = new Date(nowMs - minAgeMs).toISOString(); + // A confirmed session is still LOAD-BEARING while the offline queue holds a + // screenshot or heartbeat that resolves its server entry id through this row. + // Deleting it there is unrecoverable: the queued item can never resolve, so it + // is held forever and then dropped by the TTL sweep. Uploaded evidence for + // already-uploaded time is exactly what goes missing. + const keep = (keepKeys || []).map(String).filter(Boolean); + const placeholders = keep.map(() => '?').join(','); + const guard = keep.length + ? ` AND id NOT IN (${placeholders}) AND idempotency_key NOT IN (${placeholders})` + : ''; const result = this.db .prepare( `DELETE FROM timer_sessions @@ -461,9 +471,9 @@ class WorkSessionStore { AND server_entry_id IS NOT NULL AND confirmed_at IS NOT NULL AND synced_revision = revision - AND confirmed_at < ?`, + AND confirmed_at < ?${guard}`, ) - .run(cutoff); + .run(cutoff, ...keep, ...keep); return result.changes || 0; } catch (e) { console.error('[WorkSessionStore] purge failed:', e.message); diff --git a/desktop/src/preload/index.js b/desktop/src/preload/index.js index 5ba36b26..ef2b25cd 100644 --- a/desktop/src/preload/index.js +++ b/desktop/src/preload/index.js @@ -67,6 +67,10 @@ contextBridge.exposeInMainWorld('trackflow', { onProjectsReady: (callback) => safeOn('projects-ready', () => callback()), onIdleData: (callback) => safeOn('idle-data', (_, data) => callback(data)), onAutoStopped: (callback) => safeOn('auto-stopped', (_, data) => callback(data)), + // Fired when the user returns from a break to a STOPPED timer. Carries playSound so + // the renderer can beep — the OS notification's own sound is routinely dropped by + // macOS Focus / Windows Action Center, same reason the idle alert has its own. + onReturnFromBreak: (callback) => safeOn('return-from-break', (_, data) => callback(data)), // Popup lock while an idle alert is waiting for an answer — the main window's // timer controls are disabled so the idle window is the only place to act. onIdleLock: (callback) => safeOn('idle-lock', (_, data) => callback(data)), diff --git a/desktop/src/renderer/index-renderer.js b/desktop/src/renderer/index-renderer.js index 906600d2..d43bab0a 100644 --- a/desktop/src/renderer/index-renderer.js +++ b/desktop/src/renderer/index-renderer.js @@ -782,6 +782,8 @@ window.trackflow.onTimerStarted((data) => { return; } if (data?._stateVersion != null) _lastStateVersion = data._stateVersion; + // Tracking is back on — the "not being tracked" warning is no longer true. + hideReturnBanner(); setStartedAt(data?.started_at || new Date().toISOString()); if (data?.todayTotal > 0) todayTotalBase = data.todayTotal; @@ -814,6 +816,68 @@ if (window.trackflow.onTimerResumed) { }); } +// ── Return-from-break alert ────────────────────────────────────────────────── +// The user walked away, the watchdog stopped the timer while the desk was empty, and +// every toast fired into an empty room. This is the cue they actually see and hear on +// sitting back down. The WebAudio beep needs no external resource, so the strict CSP +// (default-src 'none'; script-src 'self') is satisfied without change — same approach +// as the idle alert, and for the same reason: OS notification sound is unreliable. +let _returnAudioCtx = null; + +function playReturnBeep() { + try { + const AC = window.AudioContext || window.webkitAudioContext; + if (!AC) return; + if (!_returnAudioCtx) _returnAudioCtx = new AC(); + if (_returnAudioCtx.state === 'suspended') _returnAudioCtx.resume().catch(() => {}); + const now = _returnAudioCtx.currentTime; + // Three descending tones — deliberately distinct from the idle alert's two rising + // ones, so "you are not being tracked" never sounds like "are you still there?". + [740, 620, 500].forEach((freq, i) => { + const osc = _returnAudioCtx.createOscillator(); + const gain = _returnAudioCtx.createGain(); + osc.type = 'sine'; + osc.frequency.value = freq; + const t0 = now + i * 0.2; + gain.gain.setValueAtTime(0.0001, t0); + gain.gain.exponentialRampToValueAtTime(0.28, t0 + 0.02); + gain.gain.exponentialRampToValueAtTime(0.0001, t0 + 0.18); + osc.connect(gain).connect(_returnAudioCtx.destination); + osc.start(t0); + osc.stop(t0 + 0.2); + }); + } catch { + // Sound is one of three cues, never the only one — a failure here is not fatal. + } +} + +function formatAwayLabel(awaySec) { + const mins = Math.max(1, Math.round((Number(awaySec) || 0) / 60)); + if (mins < 60) return `${mins} min`; + return `${Math.floor(mins / 60)}h ${mins % 60}m`; +} + +// Looked up lazily: hideReturnBanner() is called from the timer-started handler above +// this point, and a module-scope const would sit in its temporal dead zone. +function hideReturnBanner() { + const el = document.getElementById('returnBanner'); + if (el) el.style.display = 'none'; +} + +window.trackflow.onReturnFromBreak((data) => { + const away = formatAwayLabel(data && data.awaySec); + const banner = document.getElementById('returnBanner'); + const bannerText = document.getElementById('returnBannerText'); + if (bannerText) { + bannerText.textContent = + `Welcome back — the timer stopped while you were away (${away}). ` + + `Time since then was NOT tracked. Start the timer to resume.`; + } + if (banner) banner.style.display = 'flex'; + if (!data || data.playSound !== false) playReturnBeep(); + showNotification('Timer is stopped — you are not being tracked'); +}); + window.trackflow.onTimerStopped((data) => { // RACE-FIX: Discard stale notifications that arrive out of order. if (data?._stateVersion != null && data._stateVersion < _lastStateVersion) { diff --git a/desktop/src/renderer/index.html b/desktop/src/renderer/index.html index b5b16dab..4b749694 100644 --- a/desktop/src/renderer/index.html +++ b/desktop/src/renderer/index.html @@ -466,6 +466,10 @@ + {isLoading ? ( ) : (

- {employeeActivityScore !== null ? `${employeeActivityScore}%` : 'N/A'} + {`${employeeActivityScore ?? 0}%`}

)} @@ -844,6 +854,7 @@ export default function DashboardPage() { width={30} fontSize={10} tickFormatter={(v: number) => `${v}h`} + domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} /> } /> 0 && ( -
- 0 ? 'lg:col-span-3' : 'lg:col-span-5'}`}> +
+
-

Your Hours This Week

-

Daily hours tracked (Mon - Sun)

+

+ {weekOffset === 0 ? 'Your Hours This Week' : 'Weekly Hours'} +

+

+ {data?.weekStart && data?.weekEnd + ? `${format(new Date(data.weekStart + 'T00:00:00'), 'MMM d')} – ${format(new Date(data.weekEnd + 'T00:00:00'), 'MMM d, yyyy')}` + : 'Daily hours tracked (Mon - Sun)'} +

+
+ + {weekOffset !== 0 && ( + + )} + +
@@ -1002,6 +1044,7 @@ export default function DashboardPage() { width={30} fontSize={10} tickFormatter={(v: number) => `${v}h`} + domain={[0, (dataMax: number) => Math.max(dataMax, 1)]} /> } /> {(data?.weeklyHoursTarget ?? 0) > 0 && ( @@ -1028,13 +1071,14 @@ export default function DashboardPage() {
{/* Weekly Target — beside the chart */} - {isEmployeeView && (filterPreset === 'today' || filterPreset === 'week') && (data?.weeklyHoursTarget ?? 0) > 0 && (() => { - const target = data!.weeklyHoursTarget; - const targetSec = target * 3600; + {(() => { + const target = data?.weeklyHoursTarget ?? 0; + const hasTarget = target > 0; + const targetSec = hasTarget ? target * 3600 : 0; const ws = data?.weekSeconds || 0; - const pct = Math.min(ws / targetSec, 1); - const completed = ws >= targetSec; - const remainSec = Math.max(0, targetSec - ws); + const pct = hasTarget ? Math.min(ws / targetSec, 1) : 0; + const completed = hasTarget && ws >= targetSec; + const remainSec = hasTarget ? Math.max(0, targetSec - ws) : 0; const remainH = Math.floor(remainSec / 3600); const remainM = Math.round((remainSec % 3600) / 60); const workedH = Math.floor(ws / 3600); @@ -1052,35 +1096,66 @@ export default function DashboardPage() { )}
-

Weekly Target

-

{target}h required

+

Weekly Progress

+

+ {data?.weekStart && data?.weekEnd + ? `${format(new Date(data.weekStart + 'T00:00:00'), 'MMM d')} – ${format(new Date(data.weekEnd + 'T00:00:00'), 'MMM d')}` + : hasTarget ? `${target}h target` : (weekOffset === 0 ? 'Hours this week' : 'Hours tracked')} +

- +
+ + {weekOffset !== 0 && ( + + )} + +
+
- - = 0.75 ? 'text-blue-500' : 'text-violet-500'} - stroke="currentColor" - strokeDasharray={`${Math.round(pct * 264)} 264`} - /> + + {hasTarget && ( + = 0.75 ? 'text-blue-500' : 'text-violet-500'} + stroke="currentColor" + strokeDasharray={`${Math.round(pct * 264)} 264`} + /> + )}
- {Math.round(pct * 100)}% + {hasTarget ? ( + {Math.round(pct * 100)}% + ) : ( + {workedH}h {workedM}m + )}
-

{workedH}h {workedM}m / {target}h

- {completed ? ( - - Goal Achieved - + {hasTarget ? ( + <> +

{workedH}h {workedM}m / {target}h

+ {completed ? ( + + Goal Achieved + + ) : ( +

+ {remainH}h {remainM}m remaining +

+ )} + ) : ( -

- {remainH}h {remainM}m remaining +

+ {weekOffset === 0 ? 'Total tracked this week' : 'Total tracked'}

)}
@@ -1126,6 +1201,7 @@ export default function DashboardPage() { { setAttCustomFrom(e.target.value); if (e.target.value > attCustomTo) setAttCustomTo(e.target.value); @@ -1420,13 +1496,21 @@ export default function DashboardPage() { {timeEntries.slice(0, maxEntries).map((entry) => ( - + {(() => { try { - const d = new Date(entry.started_at); - if (!isNaN(d.getTime())) return format(d, 'MMM d, yyyy HH:mm'); - } catch { /* invalid date */ } - return '—'; + const start = new Date(entry.started_at); + if (isNaN(start.getTime())) return ; + const end = entry.ended_at ? new Date(entry.ended_at) : null; + return ( +
+ {format(start, 'MMM d, yyyy')} +
+ {format(start, 'hh:mm a')}{end ? ` - ${format(end, 'hh:mm a')}` : ''} +
+
+ ); + } catch { return ; } })()}
@@ -1435,8 +1519,8 @@ export default function DashboardPage() { - {entry.task?.title ? ( - {entry.task.title} + {entry.task?.name ? ( + {entry.task.name} ) : ( No task )} diff --git a/web/src/app/(dashboard)/hr/attendance/management/page.tsx b/web/src/app/(dashboard)/hr/attendance/management/page.tsx index 7fd0acb1..5ad1fe35 100644 --- a/web/src/app/(dashboard)/hr/attendance/management/page.tsx +++ b/web/src/app/(dashboard)/hr/attendance/management/page.tsx @@ -301,7 +301,8 @@ function TeamTab() { { setDateFrom(e.target.value); setCurrentPage(1); }} + max={dateTo} + onChange={(e) => { setDateFrom(e.target.value); if (e.target.value > dateTo) setDateTo(e.target.value); setCurrentPage(1); }} className="h-8 text-xs w-[140px]" />
@@ -310,6 +311,7 @@ function TeamTab() { { setDateTo(e.target.value); setCurrentPage(1); }} className="h-8 text-xs w-[140px]" /> @@ -982,7 +984,7 @@ function ReportTab() {
{ setYear(Number(v)); setPage(1); }}> - + {year} {yearOptions.map((y) => ( diff --git a/web/src/app/(dashboard)/hr/attendance/page.tsx b/web/src/app/(dashboard)/hr/attendance/page.tsx index 1f3afab7..666b7162 100644 --- a/web/src/app/(dashboard)/hr/attendance/page.tsx +++ b/web/src/app/(dashboard)/hr/attendance/page.tsx @@ -55,6 +55,7 @@ import { CheckInStatusBadge, type CheckInBadgeStatus } from '@/components/hr/Che import { useAttendance, useAttendanceSummary, useRequestRegularization } from '@/hooks/hr/use-attendance'; import { useTodayStatus } from '@/hooks/hr/use-check-in'; import { usePermissionStore } from '@/stores/permission-store'; +import { useAuthStore } from '@/stores/auth-store'; import { regularizationSchema, type RegularizationFormData, type AttendanceRecord } from '@/lib/validations/attendance'; import { cn, formatDate } from '@/lib/utils'; import { @@ -102,8 +103,23 @@ export default function MyAttendancePage() { const { data: summary, isLoading: summaryLoading } = useAttendanceSummary(selectedMonth, selectedYear); const regularizeMutation = useRequestRegularization(); const { hasPermission } = usePermissionStore(); + const { user } = useAuthStore(); const canCheckIn = hasPermission('attendance.check_in'); + // Lateness is a management signal, not something a person is shown about themselves on + // their own attendance page — the OWNER is the only role that may see it here. This + // gates EVERY late surface on the page (the Late column, the Late badge on the status + // cell, and the Late Days stat tile); hiding only one of the three leaks the same + // number from the others, which is why the badge is included. Deliberately a ROLE + // check, not `attendance.view_all` — that permission is held by org_manager, + // hr_manager and finance_manager alike, none of whom may see it. + const canSeeLate = user?.role === 'owner'; + + // Present / Absent / [Late Days] / On Leave / Overtime. Whole class names so Tailwind + // sees them at build time; the skeleton count matches so the strip does not reflow. + const statCount = canSeeLate ? 5 : 4; + const statGridCols = canSeeLate ? 'lg:grid-cols-5' : 'lg:grid-cols-4'; + const { data: todayStatus } = useTodayStatus({ enabled: canCheckIn }); const policyCheckInTime = todayStatus?.policy?.check_in_time; const policyCheckoutTime = todayStatus?.policy?.checkout_time; @@ -167,8 +183,11 @@ export default function MyAttendancePage() { value={String(selectedMonth)} onValueChange={(v) => { setSelectedMonth(Number(v)); setCurrentPage(1); }} > - - + + + + {MONTHS[selectedMonth - 1]} + @@ -182,8 +201,10 @@ export default function MyAttendancePage() { value={String(selectedYear)} onValueChange={(v) => { setSelectedYear(Number(v)); setCurrentPage(1); }} > - - + + + {selectedYear} + @@ -201,17 +222,19 @@ export default function MyAttendancePage() { {/* Stats Strip */} {summaryLoading ? ( -
- {Array.from({ length: 5 }).map((_, i) => ( +
+ {Array.from({ length: statCount }).map((_, i) => ( ))}
) : summary ? ( -
+
{[ { label: 'Present', value: summary.present_days, sub: `of ${summary.total_working_days}`, icon: CheckCircle2, color: 'text-emerald-500', bg: 'bg-emerald-500/10' }, { label: 'Absent', value: summary.absent_days, icon: XCircle, color: 'text-red-500', bg: 'bg-red-500/10' }, - { label: 'Late Days', value: summary.late_days, icon: Clock, color: 'text-amber-500', bg: 'bg-amber-500/10' }, + ...(canSeeLate + ? [{ label: 'Late Days', value: summary.late_days, icon: Clock, color: 'text-amber-500', bg: 'bg-amber-500/10' }] + : []), { label: 'On Leave', value: summary.on_leave_days, icon: Palmtree, color: 'text-blue-500', bg: 'bg-blue-500/10' }, { label: 'Overtime', value: `${Number(summary.overtime_hours).toFixed(1)}h`, icon: Timer, color: 'text-violet-500', bg: 'bg-violet-500/10' }, ].map((s) => ( @@ -306,21 +329,25 @@ export default function MyAttendancePage() { - - - - {hasAnyShift && } - - - - - + + + + {hasAnyShift && } + + + + {canSeeLate && } + {records.map((record) => { const sd = statusDot[record.status] ?? statusDot.absent; - const checkInBadges = deriveCheckInBadges(record); + // The late badge carries the same signal as the Late column, so it + // rides the same gate — hiding the column alone would leak it here. + const checkInBadges = deriveCheckInBadges(record).filter( + (badge) => canSeeLate || badge !== 'late' + ); const secs = record.worked_seconds != null ? record.worked_seconds @@ -363,27 +390,29 @@ export default function MyAttendancePage() { - + {canSeeLate && ( + + )}
DateDayStatusShiftClock InClock OutHoursLateActionsDateDayStatusShiftClock InClock OutHoursLateActions
{secs > 0 ? formatDuration(secs) : } - {record.late_minutes > 0 ? ( - - } - className="cursor-help text-[0.75rem] tabular-nums text-amber-600 dark:text-amber-400 font-medium" - tabIndex={0} - > - {formatMinutes(record.late_minutes)} - - - {checkInBadgeTooltip('late', { - lateMinutes: record.late_minutes, - checkInTime: policyCheckInTime, - })} - - - ) : ( - - )} - + {record.late_minutes > 0 ? ( + + } + className="cursor-help text-[0.75rem] tabular-nums text-amber-600 dark:text-amber-400 font-medium" + tabIndex={0} + > + {formatMinutes(record.late_minutes)} + + + {checkInBadgeTooltip('late', { + lateMinutes: record.late_minutes, + checkInTime: policyCheckInTime, + })} + + + ) : ( + + )} + {canRegularize(record) ? ( - {Number(req.days_count) % 1 === 0 ? Math.round(Number(req.days_count)) : req.days_count} + {Number(req.days_count) === 0.5 ? 'Half day' : Math.round(Number(req.days_count))} diff --git a/web/src/app/(dashboard)/hr/payroll/periods/page.tsx b/web/src/app/(dashboard)/hr/payroll/periods/page.tsx index 7f2cde19..b7ecb2e5 100644 --- a/web/src/app/(dashboard)/hr/payroll/periods/page.tsx +++ b/web/src/app/(dashboard)/hr/payroll/periods/page.tsx @@ -303,7 +303,13 @@ export default function PayrollPeriodsPage() { type="date" className="h-8 text-xs" value={formData.start_date} - onChange={(e) => setFormData((d) => ({ ...d, start_date: e.target.value }))} + max={formData.end_date} + onChange={(e) => setFormData((d) => { + const v = e.target.value; + const patch: typeof d = { ...d, start_date: v }; + if (v > d.end_date) patch.end_date = v; + return patch; + })} />
@@ -313,6 +319,7 @@ export default function PayrollPeriodsPage() { type="date" className="h-8 text-xs" value={formData.end_date} + min={formData.start_date} onChange={(e) => setFormData((d) => ({ ...d, end_date: e.target.value }))} />
diff --git a/web/src/app/(dashboard)/reports/app-usage/page.tsx b/web/src/app/(dashboard)/reports/app-usage/page.tsx index 884a7507..c10b0d4f 100644 --- a/web/src/app/(dashboard)/reports/app-usage/page.tsx +++ b/web/src/app/(dashboard)/reports/app-usage/page.tsx @@ -615,12 +615,13 @@ export default function AppUsagePage() { { setStartDate(val); if (val > endDate) setEndDate(val); }} + maxDate={endDate} />
- +
diff --git a/web/src/app/(dashboard)/reports/page.tsx b/web/src/app/(dashboard)/reports/page.tsx index 3d2cbed7..5331a875 100644 --- a/web/src/app/(dashboard)/reports/page.tsx +++ b/web/src/app/(dashboard)/reports/page.tsx @@ -756,14 +756,16 @@ export default function ReportsPage() {
{ setDateFrom(val); if (val > dateTo) setDateTo(val); }} placeholder="Start date" + maxDate={dateTo} /> to
)} @@ -1407,9 +1409,11 @@ export default function ReportsPage() { value={builderDateFrom} onChange={(val) => { setBuilderDateFrom(val); + if (val > builderDateTo) setBuilderDateTo(val); setShouldFetch(false); }} placeholder="Start date" + maxDate={builderDateTo} />
@@ -1423,6 +1427,7 @@ export default function ReportsPage() { setShouldFetch(false); }} placeholder="End date" + minDate={builderDateFrom} />
diff --git a/web/src/app/(dashboard)/time/page.tsx b/web/src/app/(dashboard)/time/page.tsx index 3577816f..19af9f81 100644 --- a/web/src/app/(dashboard)/time/page.tsx +++ b/web/src/app/(dashboard)/time/page.tsx @@ -14,6 +14,7 @@ import { Plus, Search, Timer, + Trash2, X, } from 'lucide-react'; import { toast } from 'sonner'; @@ -91,7 +92,7 @@ interface TimeEntry { }; task?: { id: string; - title: string; + name: string; }; user?: { id: string; @@ -132,11 +133,13 @@ export default function TimePage() { const searchParams = useSearchParams(); const { hasPermission, hasPermissionWithScope } = usePermissionStore(); const canApprove = hasPermission('time_entries.approve'); + const canDeleteAll = hasPermission('time_entries.delete'); + const canDeleteOwnManual = !canDeleteAll; const isManagerOrAbove = hasPermissionWithScope('time_entries.view', 'project'); - const [dateFrom, setDateFrom] = useState(() => searchParams.get('from') || format(new Date(), 'yyyy-MM-dd')); - const [dateTo, setDateTo] = useState(() => searchParams.get('to') || format(new Date(), 'yyyy-MM-dd')); + const [dateFrom, setDateFrom] = useState(() => searchParams.get('from') || ''); + const [dateTo, setDateTo] = useState(() => searchParams.get('to') || ''); const [projectFilter, setProjectFilter] = useState('all'); const [typeFilter, setTypeFilter] = useState('all'); const [memberFilter, setMemberFilter] = useState('all'); @@ -145,7 +148,8 @@ export default function TimePage() { const [projectComboboxOpen, setProjectComboboxOpen] = useState(false); const [memberComboboxOpen, setMemberComboboxOpen] = useState(false); const [manualEntryOpen, setManualEntryOpen] = useState(false); - const [showFilters, setShowFilters] = useState(true); + const [viewEntry, setViewEntry] = useState(null); + const [showFilters, setShowFilters] = useState(false); const { data: projects } = useQuery({ queryKey: ['projects-list'], @@ -168,11 +172,11 @@ export default function TimePage() { queryKey: ['time-entries', dateFrom, dateTo, projectFilter, typeFilter, memberFilter, page], queryFn: async () => { const params: Record = { - date_from: dateFrom, - date_to: dateTo, page, per_page: 20, }; + if (dateFrom) params.date_from = dateFrom; + if (dateTo) params.date_to = dateTo; if (projectFilter && projectFilter !== 'all') { params.project_id = projectFilter; } @@ -248,6 +252,20 @@ export default function TimePage() { }, }); + const deleteMutation = useMutation({ + mutationFn: async (entryIds: string[]) => { + await Promise.all(entryIds.map((id) => api.delete(`/time-entries/${id}`))); + }, + onSuccess: (_data, variables) => { + queryClient.invalidateQueries({ queryKey: ['time-entries'] }); + setSelectedEntries([]); + toast.success(`${variables.length} time ${variables.length === 1 ? 'entry' : 'entries'} deleted`); + }, + onError: () => { + toast.error('Failed to delete entries'); + }, + }); + const toggleEntry = (id: string) => { setSelectedEntries((prev) => prev.includes(id) ? prev.filter((e) => e !== id) : [...prev, id] @@ -255,23 +273,35 @@ export default function TimePage() { }; const toggleAll = () => { - const pendingIds = entries.filter((e) => e.status === 'pending').map((e) => e.id); - if (selectedEntries.length === pendingIds.length) { + const allIds = entries.map((e) => e.id); + if (selectedEntries.length === allIds.length) { setSelectedEntries([]); } else { - setSelectedEntries(pendingIds); + setSelectedEntries(allIds); } }; + const canDelete = canDeleteAll || canDeleteOwnManual; + const showCheckboxes = canDelete || canApprove; + const selectedPendingCount = selectedEntries.filter((id) => entries.find((e) => e.id === id && e.status === 'pending')).length; + const selectedDeletableCount = canDeleteAll + ? selectedEntries.length + : selectedEntries.filter((id) => + entries.find((e) => e.id === id && e.user_id === user?.id && e.type === 'manual' && (e.status === 'pending' || e.status === 'rejected')) + ).length; + const totalSeconds = entries.reduce((sum, e) => sum + getDisplayDuration(e), 0); const activeFilterCount = [ + dateFrom || dateTo, projectFilter !== 'all', typeFilter !== 'all', isManagerOrAbove && memberFilter !== 'all', ].filter(Boolean).length; const clearFilters = () => { + setDateFrom(''); + setDateTo(''); setProjectFilter('all'); setTypeFilter('all'); setMemberFilter('all'); @@ -298,6 +328,14 @@ export default function TimePage() { canLogOnBehalf={canApprove} /> + { if (!open) setViewEntry(null); }} + canLogOnBehalf={canApprove} + entry={viewEntry} + initialMode="view" + /> + {/* Stats Strip */}
@@ -362,16 +400,18 @@ export default function TimePage() { {/* Date Range */} { setDateFrom(val); setPage(1); }} - placeholder="From" + onChange={(val) => { setDateFrom(val); if (dateTo && val > dateTo) setDateTo(val); setPage(1); }} + placeholder="From date" className="w-[140px] h-8 text-xs" + maxDate={dateTo || undefined} /> to { setDateTo(val); setPage(1); }} - placeholder="To" + placeholder="To date" className="w-[140px] h-8 text-xs" + minDate={dateFrom || undefined} />
@@ -399,21 +439,51 @@ export default function TimePage() { )} - {/* Approve Button (right side) */} - {canApprove && selectedEntries.length > 0 && ( - )} - Approve ({selectedEntries.length}) - + {canDelete && selectedDeletableCount > 0 && ( + + )} +
)}
@@ -602,16 +672,15 @@ export default function TimePage() { - {canApprove && ( + {showCheckboxes && ( e.status === 'pending').length && - entries.filter((e) => e.status === 'pending').length > 0 + selectedEntries.length === entries.length && + entries.length > 0 } onCheckedChange={toggleAll} - aria-label="Select all pending entries" + aria-label="Select all entries" /> )} @@ -645,16 +714,14 @@ export default function TimePage() { {entries.map((entry) => ( - - {canApprove && ( - - {entry.status === 'pending' && ( - toggleEntry(entry.id)} - aria-label={`Select entry ${entry.id}`} - /> - )} + { if (entry.type === 'manual') setViewEntry(entry); }}> + {showCheckboxes && ( + e.stopPropagation()}> + toggleEntry(entry.id)} + aria-label={`Select entry ${entry.id}`} + /> )} @@ -709,8 +776,12 @@ export default function TimePage() { No project )} - - {entry.task?.title || No task} + + {entry.task?.name ? ( + {entry.task.name} + ) : ( + No task + )} {entry.ended_at ? ( diff --git a/web/src/components/date-filter.tsx b/web/src/components/date-filter.tsx index 2df8cb2b..7966f80c 100644 --- a/web/src/components/date-filter.tsx +++ b/web/src/components/date-filter.tsx @@ -108,9 +108,10 @@ export function DateFilter({
{ setLocalFrom(val); if (val > localTo) setLocalTo(val); }} placeholder="From" className="flex-1 min-w-0" + maxDate={localTo} /> --