fix(desktop): skip empty offline updates, add connectivity notices, selection prefill and web app link

Merged
EV
15d ago
+2648-56

Summary

Four Desktop Timer fixes and improvements, each kept in its own small module with specs:

  • #7811: the timer no longer fails with Knex's Empty .update() call detected!. Every local (SQLite) update now drops undefined values first and skips the write, with a warning, when nothing is left.
  • #8415: the timer shows a notice when the connection drops and another when it comes back. The second one says how many entries are waiting to sync.
  • #7753: when nothing is running and nothing is selected, the timer pre-selects the client, project, team, task and note of the employee's last time log.
  • #9517: a small link icon in the timer footer, next to the employee name, opens the employee's time and activity for today in the web app. It is shown only when the web app's address is known.

Why

  • #7811: TimerService.checkEmptyUpdate counted the keys of Timer.toObject(). That object always has every key, including undefined ones, so the guard never fired. Knex drops undefined values and throws when none remain. The other offline transactions passed values to .update() with no guard at all.
  • #8415: offline detection and the Online/Offline footer label exist, but nothing told the user when the state changed.
  • #7753: start/stop and the selection already sync while a timer runs on another device. A timer that starts fresh on a second device kept its own, often empty, selection.
  • #9517: the timer had no quick way to open the employee's own data in the web app. The owner described the footer link and target page in the issue.

What changed

packages/desktop-lib

  • offline/utils/update-payload.ts (new):
    • definedValues() and toUpdatePayload() drop undefined values (null stays, since null is a real write) and the key columns. toUpdatePayload() returns null when nothing is left.
    • warnEmptyUpdate() logs the skipped write.
  • TimerService.update and every update() in offline/transactions and offline/dao (timer, interval, kb-mouse activity, project, task, user, audit log, audit queue, client, tag) now go through toUpdatePayload():
    • An all-undefined payload is skipped before any transaction opens.
    • Valid updates write the same values as before.
    • TimerService's broken private checkEmptyUpdate is replaced by the shared helper.
    • The screenshot transaction always writes retries, so it can never be empty and is unchanged.
  • ServerConfig.localUiUrl(isUiServerRunning) (new): the address of the web UI this app serves, or null when its UI server does not run.
  • offline/connectivity-timing.ts (new) and states/concretes/api-server-connectivity.ts (#8415): a lost API connection is noticed within the 30 s the issue asks for.
    • The budget is 10 s between probes plus 20 s for one probe.
    • The probe is now bounded as a whole, connecting and answering. Before, it could wait 30 s just to connect, then without limit for an answer.
  • desktop-ipc.ts: new GET_LOCAL_WEB_APP_URL handler. It is read-only and is added to removeAllHandlers. It returns that address only while this app's web UI server runs (Gauzy Server, API Server). Gauzy Desktop's integrated server runs the API only, so it gets no link.

packages/desktop-ui-lib

  • shared/+state/list-request-guard.ts (new) and the four selector services (client, project, team, task): a late answer is dropped when the list was emptied since the request or the organization changed. Emptying happens on an organization switch, a new search or another project. Entries of the organization left can no longer come back into the new one's lists, where they could be picked.
  • services/toastr-notification.service.ts:
    • success/error/warn/info take an optional Nebular config, merged over the existing defaults.
    • They now return the toast reference. Existing callers are unaffected.
  • time-tracker/connectivity/ (new):
    • connectivityNotice() is a pure helper: no notice when the state is unchanged, an offline notice, or an online notice with the pending count.
    • ConnectivityNotifierService shows the notice:
      • The offline toast stays until clicked or until the connection returns.
      • The online toast closes the offline one and auto-dismisses.
    • Nothing is shown at startup when online.
  • time-tracker/selection-prefill/ (new):
    • selectionFromLastLog() is a pure helper. It prefills only when nothing runs locally or on the server, no client/project/team/task is selected, and the log belongs to the current organization. The note is used only if the local note is empty.
    • selection-state.ts: small pure helpers to compare and merge selections.
    • findChoice(): looks for an entry in a selector's list. The list is reloaded for the choices made so far, then paged through, up to 5 pages of 10. The lists only offer what the employee may pick, so this also checks the employee can still track on it.
    • SelectionPrefillService:
      • Order: makes the choices one after another (client, project, team, task), each in its list reloaded for the choices before it. Choosing a project clears the team and task lists, so the team is chosen only after they reload.
      • Once per user and organization: an organization switch resets this, and an answer still pending for the context left is dropped.
      • Retries: a failed attempt is tried again on the next load. That covers a failed lookup, a list that cannot load, or going offline. The attempt's own choices are taken back and nothing is stored. Only one lookup runs at a time.
      • Never over the user's choice: a selection saved on this device counts as the user's own, even when the selectors could not show it yet. If the user starts choosing, the prefill stops.
      • Timer start and reload: nothing more is chosen once a timer start begins, because the start sends what the selectors show then. A reload cancels an attempt under way, takes back what it chose, and starts over.
      • Persistence: stores only what it chose and the form still has, through the existing update_project_on channel, so a refresh keeps it.
      • Never throws into the component.
  • time-tracker/web-portal/ (new):
    • resolveWebAppBaseUrl() picks the web app's base address. A web UI this app serves on this computer wins. Otherwise Gauzy Cloud (GAUZY_CLOUD_APP) is used when the API is https://api.gauzy.co. Anything else gets null: custom servers are never guessed.
    • buildTimeActivitiesUrl() builds the link to /#/pages/employees/activity/time-activities with organizationId, date and date_end (today in local time), unit_of_time=day, employeeId and is_custom_date=false, as the owner described.
    • WebPortalService exposes baseUrl$ and opens the link with shell.openExternal, only for http(s) URLs.
  • time-tracker.component.ts/html/scss: the component only wires these in.
    • The offline-handler and timer_tracker_show handlers go through one _setOffline().
    • The prefill runs after the timer view loads.
    • The footer gets the link icon (tooltip, keyboard accessible) and thin dividers.

packages/ui-core/i18n

  • New keys TIMER_TRACKER.OPEN_WEB_PORTAL and TIMER_TRACKER.TOASTR.CONNECTION_LOST / CONNECTION_RESTORED / CONNECTION_RESTORED_SYNCING.
  • Added to en and to every language file that has a TIMER_TRACKER section: ar, bg, de, es, fr, he, it, nl, pl, pt, ru, zh. ach has none, so it falls back to English.

Flags/settings and defaults

No new flags or settings.

  • The #7811 guard only skips writes that would have thrown.
  • The notices and the link add information without blocking anything.
  • The prefill only fills an empty selection and never overrides one the user made.

Tests

# desktop-lib (Node project, needs the ESM flag)
NODE_OPTIONS=--experimental-vm-modules npx jest -c packages/desktop-lib/jest.config.ts --rootDir packages/desktop-lib src/lib/offline src/lib/states src/lib/config/server-config
# Test Suites: 5 passed, 5 total; Tests: 46 passed, 46 total

# desktop-ui-lib (Angular)
npx jest -c packages/desktop-ui-lib/jest.config.ts --rootDir packages/desktop-ui-lib
# Test Suites: 58 passed, 58 total; Tests: 179 passed, 179 total (the whole library)

New specs:

  • update-payload.spec.ts (9)
  • update-transactions.spec.ts (19): every transaction/DAO skips an all-undefined payload and writes only defined values otherwise.
  • timer.service.spec.ts (6): new Timer({ id }) does not reach the DAO.
  • server-config.spec.ts (+3): a link only while the app's UI server runs; none for the integrated (API-only) or a remote server.
  • api-server-connectivity.spec.ts (3): the whole probe is bounded by its timeout, a timed-out probe means offline, and the interval plus the probe stay within 30 s.
  • toastr-notification.service.spec.ts (3)
  • connectivity-notice.spec.ts (5) and connectivity-notifier.service.spec.ts (5)
  • last-log-selection.spec.ts (8), selection-state.spec.ts (6) and find-choice.spec.ts (6)
  • selection-prefill.service.spec.ts (18):
    • linked choices are made in stages, with a CONTROL case showing a one-shot setState losing the team;
    • a choice is found beyond the first page, and one that is gone is dropped;
    • a stale answer after an organization switch is dropped;
    • a lookup is retried after a failure, and only one runs at a time;
    • an attempt whose list fails to load, or that goes offline, is taken back and retried;
    • a saved, not-yet-shown choice is respected;
    • the prefill stops when the user starts choosing;
    • nothing more is chosen once a timer start begins;
    • an attempt a reload interrupts is taken back and starts over, and a settled context stays settled.
  • web-portal-url.spec.ts (10) and web-portal.service.spec.ts (5)
  • list-request-guard.spec.ts (3) and shared/+state/selector.service.spec.ts (16): for each of the four selectors, a late answer for the organization left, or for a list emptied since, is dropped, and a late failure is not reported. 12 of the 16 fail without the guard.
  • organization-switch.service.spec.ts (+1): whether a timer start is under way can be read at once.
  • time-tracker.component.spec.ts (+3): the link is hidden while the address is unknown, opens the signed-in employee's page, and connectivity changes are notified.

Also run:

  • npx tsc -p packages/desktop-lib/tsconfig.lib.json --noEmit: clean.
  • npx tsc -p packages/desktop-ui-lib/tsconfig.lib.json --noEmit: only the existing Cannot find name 'google' errors in ui-core/shared location-form, none in touched files.
  • ESLint on the touched files: no findings on changed lines.

Manual verification still needed

The Electron app was not run here.

  • #7811: start/stop the timer offline and online. No Empty .update() call detected! should appear in the logs.
  • #8415: toggle the network while the timer runs. Expect a warning that stays until clicked or until the connection returns, then "You are back online" with the pending count. The offline warning should appear within 30 s of the connection dropping, without touching the app.
  • #7753:
    1. Sign in on device A, select a project/task, then start and stop.
    2. Sign in on device B with an empty selection. It should show A's selection.
    • This only works if the employee tracked time today (see question below).
  • #7753, extra checks:
    • On B, also try a team and a task beyond the first page of their lists.
    • Switch organization while B is loading. Nothing from the old organization should appear.
    • Press Start while B is still prefilling. The timer should track exactly what the selectors show.
  • #9517: check the footer link on Gauzy Cloud, where it opens app.gauzy.co. Check there is no link with Gauzy Desktop's integrated server (API only) or with a custom server. Check the dividers look right in light and dark themes.

Risk and rollback

  • The risk is low, and the changes sit in the desktop packages and i18n only. No API, database or migration changes.
  • Rollback is a revert of this PR. The commits are split by issue, so a single one can be reverted:
    • 7adf679d75 / c126deb988: #7811
    • 73af8d1d95: #8415 building blocks
    • d6945a5a5b: #7753 building blocks
    • b2feeb70ac: #9517 building blocks
    • 257f5fcf89: wiring and i18n
    • 2a89c370ca: #8415 offline detection within 30 s
    • d164981071 / 94fc540183 / a9984f16d5: review follow-ups (#7753 prefill in stages, settled only after a complete attempt; #9517 link only to a running web UI)
    • 35210a3ddf: selectors drop late answers for a list emptied since, or for another organization
    • 98cbfb528f: the prefill stops once a timer start begins, and starts over after a reload

Scope decisions

  1. #7753: when nothing runs, the server's timer status returns only today's last time log. So a second device prefills only if the employee tracked earlier that day.
    • Covering earlier days needs a server-side "last selection" per employee: the lastProjectId / lastTaskId / lastTeamId idea in the issue.
    • That is not in this PR, so it uses Refs #7753.
  2. #9517: custom (self-hosted, remote) servers get no link, because their web app address is unknown. An optional "Web app URL" setting is not planned for now.
  3. #8415: the notice stays an in-app toast, with no native OS notification.

Closes #7811
Closes #8415
Refs #7753
Closes #9517

šŸ¤– Generated with Claude Code


Summary by cubic

Fixes four Desktop Timer issues: the timer no longer fails with Knex's Empty .update() call detected!, shows notices when the connection drops and returns, prefills an empty selection from the employee's last time log, and adds a footer link to the employee's web app time and activity.

Bug Fixes

  • Local SQLite updates now drop undefined values first and skip the write, with a warning, when nothing is left; previously Knex threw and the timer start failed (#7811).
  • A lost API connection is now noticed within 30 seconds: each connectivity probe is bounded to 20 seconds with 10 seconds between probes, instead of a probe that could hang indefinitely before the offline notice appeared (#8415).
  • Selector list loads now drop answers that no longer belong to the list — it was emptied since the request, or the organization changed — so stale or cross-organization entries can't be picked.
  • The selection prefill no longer changes the selection while a timer start is under way or after a reload restores the saved one.

New Features

  • Connectivity notices: a sticky warning while offline, closed on reconnect, and a success toast with the pending entry count when back online.
  • Selection prefill (#7753): when nothing runs and nothing is selected, the timer prefills client, project, team, task, and note from the employee's last time log for the current organization. Only today's last log is available from the server, so a second device prefills only if time was tracked earlier that day. Choices are applied in the selectors' order, each list reloaded for the previous choices so a choice beyond its first page is not dropped. A failed or interrupted attempt takes back its choices, stores nothing, and starts over on the next load.
  • Web app link (#9517): a footer icon opens the employee's time and activity for today in the web app. It shows only when the web app address is known: Gauzy Cloud, or a web UI this app actually serves — the integrated server runs the API only and gets no link.