diff --git a/.github/workflows/cd-release.yml b/.github/workflows/cd-release.yml index 4de9b10c..5966dc7b 100644 --- a/.github/workflows/cd-release.yml +++ b/.github/workflows/cd-release.yml @@ -1130,13 +1130,44 @@ jobs: if-no-files-found: error retention-days: 90 + # ============================================================================ + # Build Browser Extension + # ============================================================================ + build-browser-extension: + name: Build Browser Extension + runs-on: ubuntu-latest + needs: [determine-version] + continue-on-error: true + timeout-minutes: 5 + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: ${{ env.NODE_VERSION }} + + - name: Package extension + working-directory: browser-extension + run: npm run zip + + - name: Upload extension zip + if: success() + uses: actions/upload-artifact@v4 + with: + name: browser-extension-${{ needs.determine-version.outputs.version }} + path: browser-extension/dist/*.zip + retention-days: 90 + # ============================================================================ # Create GitHub Release # ============================================================================ create-release: name: Create GitHub Release runs-on: ubuntu-latest - needs: [build-and-push, determine-version, build-desktop-windows, build-desktop-linux, build-desktop-macos, build-mobile-android, build-mobile-ios] + needs: [build-and-push, determine-version, build-desktop-windows, build-desktop-linux, build-desktop-macos, build-mobile-android, build-mobile-ios, build-browser-extension] if: github.event_name != 'release' permissions: contents: write @@ -1166,6 +1197,13 @@ jobs: with: pattern: 'mobile-*' merge-multiple: false + + - name: Download browser extension artifacts + uses: actions/download-artifact@v4 + continue-on-error: true + with: + pattern: 'browser-extension-*' + merge-multiple: false - name: Prepare release files run: | @@ -1227,6 +1265,11 @@ jobs: mkdir -p "$MOBILE_DIR/ios" cp mobile-ios-$VERSION/* "$MOBILE_DIR/ios/" 2>/dev/null || true fi + + # Browser extension + if [ -d "browser-extension-$VERSION" ]; then + cp browser-extension-$VERSION/*.zip "$RELEASE_DIR/" 2>/dev/null || true + fi # Create file list for release (for debugging) find "$RELEASE_DIR" -type f > release-files-list.txt || true @@ -1307,6 +1350,7 @@ jobs: release-files/k8s-deployment.yml release-files/desktop/**/* release-files/mobile/**/* + release-files/*.zip env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} continue-on-error: false @@ -1346,7 +1390,7 @@ jobs: release-summary: name: Release Summary runs-on: ubuntu-latest - needs: [security-audit, build-and-push, determine-version, create-release, trigger-demo-deploy, build-desktop-windows, build-desktop-linux, build-desktop-macos, build-mobile-android, build-mobile-ios] + needs: [security-audit, build-and-push, determine-version, create-release, trigger-demo-deploy, build-desktop-windows, build-desktop-linux, build-desktop-macos, build-mobile-android, build-mobile-ios, build-browser-extension] if: always() steps: @@ -1389,8 +1433,15 @@ jobs: echo "- ⚠️ iOS: ${{ needs.build-mobile-ios.result }}" >> $GITHUB_STEP_SUMMARY fi echo "" >> $GITHUB_STEP_SUMMARY + echo "### Browser Extension" >> $GITHUB_STEP_SUMMARY + if [ "${{ needs.build-browser-extension.result }}" == "success" ]; then + echo "- ✅ Chrome extension zip: Success" >> $GITHUB_STEP_SUMMARY + else + echo "- ⚠️ Chrome extension zip: ${{ needs.build-browser-extension.result }}" >> $GITHUB_STEP_SUMMARY + fi + echo "" >> $GITHUB_STEP_SUMMARY echo "ℹ️ *Full test suite already ran on PR before merge*" >> $GITHUB_STEP_SUMMARY - echo "ℹ️ *Desktop and mobile builds are optional - release continues even if they fail*" >> $GITHUB_STEP_SUMMARY + echo "ℹ️ *Desktop, mobile, and browser extension builds are optional - release continues even if they fail*" >> $GITHUB_STEP_SUMMARY echo "" >> $GITHUB_STEP_SUMMARY echo "### 🔐 Analytics Configuration" >> $GITHUB_STEP_SUMMARY echo "Analytics credentials were **successfully injected** from GitHub Secret Store:" >> $GITHUB_STEP_SUMMARY diff --git a/CHANGELOG.md b/CHANGELOG.md index 698aede1..a002d73a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [5.13.1] - 2026-08-26 + +### Changed + +- **Projects sorted by last used (#738)** — Timers and manual entries stamp `last_used_at` on projects so pickers and API lists surface recent work ahead of alphabetical order (migration **182**). +- **Client versions** — Synced Electron (`desktop/package.json`), Flutter (`mobile/pubspec.yaml`), and Chromium extension (`browser-extension/manifest.json` / `package.json`) to **5.13.1** with the webapp (`setup.py`). +- **Chrome extension release zip** — GitHub Releases now attach a ready-to-install Chromium extension zip alongside desktop and mobile artifacts. + +### Fixed + +- **Idle timeout duration & prompts (#722)** — Auto-stop credits `last_active + idle_timeout` instead of recording 0 minutes; web and extension surface “Still working?” notifications, and mobile polls `idle_notified` while backgrounded. +- **Searchable picker blur & Create project (#728)** — Comboboxes reset to the committed selection on blur so typed-but-unselected text cannot look like a form change; Create project stays hidden until a client is chosen; no auto-selected project; Create labels are translated. +- **Extension Create project guard** — Re-applied the client-required guard on the project picker after the last-used sort change so create-without-client stays blocked. + +### Documentation + +- **Version** — Bumped `setup.py` to **5.13.1** (single source of truth for the application version). + ## [5.13.0] - 2026-08-23 ### Added diff --git a/README.md b/README.md index ecf9bea1..74daf947 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,10 @@ TimeTracker has been continuously enhanced with powerful new features! Here's wh **Current version** is defined in `setup.py` (single source of truth). See [CHANGELOG.md](CHANGELOG.md) for versioned release history. +### ✨ Highlights of v5.13.1 + +**Patch (5.13.1):** **Projects by last used (#738)** — pickers and API lists surface recent work first. **Idle timeout (#722)** — auto-stop records the configured duration and surfaces Still working? on web, extension, and mobile. **Picker fixes (#728)** — blur resets typed-but-unselected text, Create project requires a client, translated Create labels. **Extension release zip** — Chromium extension package attached to GitHub Releases. See [CHANGELOG.md](CHANGELOG.md#5131---2026-08-26). + ### ✨ Highlights of v5.13.0 **Minor (5.13.0):** **Daily progress widget** — compact strip on the dashboard with today's hours vs target and a quick-start timer. **Quick-log & tasks due today** — one-click log form and due-today task widget without leaving home. **Timer long-run warning** — alert when a timer exceeds the configurable threshold, with workday progress. **Navigation declutter** — sidebar and mobile More drawer reorganised with key destinations promoted. **Recurring tasks list** — status filters, pause/resume/run-now inline actions. **Floating timer bar & idle-stop** — redesigned compact bar and smoother idle boundary. **Fixes** — productivity heatmap, invoice tax rate/currency resolution, swallowed errors (#736), timer heartbeats (#736), portal admin template, recurring tasks CRUD. See [CHANGELOG.md](CHANGELOG.md#5130---2026-08-23). diff --git a/app/models/project.py b/app/models/project.py index 02041b5c..12f127e9 100644 --- a/app/models/project.py +++ b/app/models/project.py @@ -27,6 +27,7 @@ class Project(db.Model): custom_fields = db.Column(db.JSON, nullable=True) created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False) updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False) + last_used_at = db.Column(db.DateTime, nullable=True, index=True) # Archiving metadata archived_at = db.Column(db.DateTime, nullable=True, index=True) archived_by = db.Column(db.Integer, db.ForeignKey("users.id", ondelete="SET NULL"), nullable=True) @@ -315,6 +316,10 @@ def activate(self): self.updated_at = datetime.utcnow() db.session.commit() + def touch_last_used(self): + """Mark this project as recently used (timer start or manual entry).""" + self.last_used_at = datetime.utcnow() + def is_favorited_by(self, user): """Check if this project is favorited by a specific user""" from .user import User @@ -391,6 +396,7 @@ def to_dict(self, user=None): "budget_threshold_percent": self.budget_threshold_percent, "created_at": self.created_at.isoformat() if self.created_at else None, "updated_at": self.updated_at.isoformat() if self.updated_at else None, + "last_used_at": self.last_used_at.isoformat() if self.last_used_at else None, "total_hours": self.total_hours, "total_billable_hours": self.total_billable_hours, "estimated_cost": float(self.estimated_cost) if self.estimated_cost else None, diff --git a/app/repositories/project_repository.py b/app/repositories/project_repository.py index 6ba2060b..0c6655bc 100644 --- a/app/repositories/project_repository.py +++ b/app/repositories/project_repository.py @@ -34,7 +34,7 @@ def get_active_projects( # If user_id provided, filter projects user has access to # (This would need permission logic in a real implementation) - return query.order_by(Project.name).all() + return query.order_by(Project.last_used_at.desc().nullslast(), Project.name).all() def get_by_client( self, client_id: int, status: Optional[str] = None, include_relations: bool = False @@ -48,7 +48,7 @@ def get_by_client( if include_relations: query = query.options(joinedload(Project.client_obj)) - return query.order_by(Project.name).all() + return query.order_by(Project.last_used_at.desc().nullslast(), Project.name).all() def get_with_stats(self, project_id: int) -> Optional[Project]: """Get project with related statistics (time entries, costs, etc.)""" @@ -88,4 +88,4 @@ def get_billable_projects(self, client_id: Optional[int] = None) -> List[Project if client_id: query = query.filter_by(client_id=client_id) - return query.order_by(Project.name).all() + return query.order_by(Project.last_used_at.desc().nullslast(), Project.name).all() diff --git a/app/services/project_service.py b/app/services/project_service.py index cc0c5b41..18102b39 100644 --- a/app/services/project_service.py +++ b/app/services/project_service.py @@ -336,7 +336,7 @@ def list_projects( query = query.filter(search_filter) # Order and paginate - query = query.order_by(Project.name) + query = query.order_by(Project.last_used_at.desc().nullslast(), Project.name) pagination = query.paginate(page=page, per_page=per_page, error_out=False) projects = pagination.items diff --git a/app/services/time_tracking_service.py b/app/services/time_tracking_service.py index 18243207..a00595d5 100644 --- a/app/services/time_tracking_service.py +++ b/app/services/time_tracking_service.py @@ -131,6 +131,8 @@ def start_timer( "message": "Selected task is invalid for the chosen project", "error": "invalid_task", } + + project.touch_last_used() else: # Client-only timer from app.repositories import ClientRepository @@ -318,6 +320,8 @@ def create_manual_entry( if not task: return {"success": False, "message": "Invalid task for selected project", "error": "invalid_task"} + project.touch_last_used() + # Validate client if provided if client_id: from app.repositories import ClientRepository diff --git a/app/static/idle.js b/app/static/idle.js index 63be8169..463f22db 100644 --- a/app/static/idle.js +++ b/app/static/idle.js @@ -17,6 +17,8 @@ let countdownIntervalId = null; let lastHeartbeatSent = 0; let hasActiveTimer = false; + let notificationPermissionRequested = false; + let activeIdleNotification = null; const HEARTBEAT_THROTTLE_MS = 60 * 1000; function sendHeartbeat(){ @@ -34,6 +36,38 @@ } catch(e) {} } + /** Ask for Notification permission once a timer is running so idle alerts + * can surface when the TimeTracker tab is not focused (Issue #722). */ + function requestNotificationPermission(){ + if (notificationPermissionRequested) return; + if (typeof Notification === 'undefined') return; + if (Notification.permission !== 'default') { + notificationPermissionRequested = true; + return; + } + notificationPermissionRequested = true; + try { + Notification.requestPermission().catch(function(){}); + } catch(e) {} + } + + function closeIdleNotification(){ + try { + if (activeIdleNotification) { + activeIdleNotification.close(); + activeIdleNotification = null; + } + } catch(e) { + activeIdleNotification = null; + } + } + + /** Credit the idle window on auto-stop: last_active + threshold (Issue #722). */ + function creditedStopTs(lastActiveTs){ + const credited = (lastActiveTs || Date.now()) + getIdleThresholdMs(); + return Math.min(credited, Date.now()); + } + function markActive(){ // While the "Still working?" grace prompt is open, only Yes/No (or the // 5-minute auto-stop) may clear it — incidental mouse/keyboard activity @@ -84,6 +118,7 @@ async function stopNow(){ clearGraceTimers(); + closeIdleNotification(); promptShown = false; try { const r = await fetch('/api/timer/stop', { method: 'POST', headers: { 'Content-Type': 'application/json' }, __ttQuiet: true }); @@ -101,6 +136,7 @@ async function stopAt(ts){ clearGraceTimers(); + closeIdleNotification(); promptShown = false; try { const r = await fetch('/api/timer/stop_at', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stop_time: new Date(ts).toISOString() }) }); @@ -120,6 +156,7 @@ function snoozeIdlePrompt(toastEl){ clearGraceTimers(); + closeIdleNotification(); lastActivity = Date.now(); promptShown = false; lastHeartbeatSent = 0; // force immediate heartbeat so server clears idle_notified_at @@ -127,6 +164,34 @@ try { if (toastEl) toastEl.remove(); } catch(e) {} } + function showNativeIdleNotification(stopTs, onStillWorking){ + if (typeof Notification === 'undefined') return; + if (Notification.permission !== 'granted') return; + closeIdleNotification(); + try { + const title = window.i18n?.messages?.stillWorkingTitle || 'Still working?'; + const body = window.i18n?.messages?.stillWorkingPrompt || + ('You seem inactive since ' + formatTime(new Date(stopTs)) + + '. Click to confirm you are still working, or the timer will stop automatically.'); + const n = new Notification(title, { + body: body, + tag: 'tt-still-working', + requireInteraction: true, + renotify: true, + }); + activeIdleNotification = n; + n.onclick = function(ev){ + try { if (ev && ev.preventDefault) ev.preventDefault(); } catch(e) {} + try { window.focus(); } catch(e) {} + closeIdleNotification(); + if (typeof onStillWorking === 'function') onStillWorking(); + }; + n.onclose = function(){ + if (activeIdleNotification === n) activeIdleNotification = null; + }; + } catch(e) {} + } + function showIdlePrompt(stopTs){ if (promptShown) return; promptShown = true; clearGraceTimers(); @@ -139,6 +204,8 @@ '. Timer will stop automatically if you do not answer.'); const deadline = Date.now() + GRACE_MS; + // Auto-stop credits the idle window (last activity + threshold), not 0 min. + const autoStopTs = creditedStopTs(stopTs); function buildMessage(){ return baseMsg + ' (' + formatCountdown(deadline - Date.now()) + ')'; @@ -158,13 +225,21 @@ graceTimerId = setTimeout(function(){ clearGraceTimers(); + closeIdleNotification(); try { toastEl.remove(); } catch(e){} - stopAt(stopTs); + stopAt(autoStopTs); }, GRACE_MS); } + // Native OS notification so the prompt is visible from other tabs (#722) + showNativeIdleNotification(stopTs, function(){ + const toastEl = document.querySelector('[data-tt-idle-prompt]'); + snoozeIdlePrompt(toastEl); + }); + if (window.toastManager) { const toastEl = document.createElement('div'); + toastEl.setAttribute('data-tt-idle-prompt', '1'); toastEl.className = 'flex items-center gap-3 p-4 bg-amber-100 dark:bg-amber-900/30 border border-amber-300 dark:border-amber-700 rounded-lg shadow-lg pointer-events-auto'; toastEl.innerHTML = '
' + buildMessage() + '
' + @@ -180,6 +255,7 @@ } const t = document.createElement('div'); + t.setAttribute('data-tt-idle-prompt', '1'); t.className = 'toast align-items-center text-white bg-warning border-0 fade show'; t.innerHTML = '
' + @@ -241,6 +317,7 @@ const active = await getTimer(); hasActiveTimer = !!active; if (!active) return; + requestNotificationPermission(); // Send periodic heartbeat while tab is open (throttled to HEARTBEAT_THROTTLE_MS) if (!promptShown) sendHeartbeat(); const threshold = getIdleThresholdMs(); diff --git a/app/static/searchable-select.js b/app/static/searchable-select.js index 604b07b7..ed9945c2 100644 --- a/app/static/searchable-select.js +++ b/app/static/searchable-select.js @@ -15,12 +15,17 @@ (function () { 'use strict'; - var CREATE_LABEL = { + var DEFAULT_CREATE_LABEL = { client: 'Create client', project: 'Create project', task: 'Create task', }; + function getCreateLabel(kind) { + var labels = window.ttCreateLabels || {}; + return labels[kind] || DEFAULT_CREATE_LABEL[kind] || 'Create'; + } + function getCreatePermission(select) { return select.getAttribute('data-can-create') === '1'; } @@ -308,7 +313,7 @@ createLi.setAttribute('role', 'option'); createLi.setAttribute('aria-selected', 'false'); createLi.setAttribute('data-create', '1'); - var createPrefix = CREATE_LABEL[kind] || 'Create'; + var createPrefix = getCreateLabel(kind); createLi.innerHTML = '' + createPrefix + @@ -379,6 +384,14 @@ } }); + // Reset visible input when user types but does not commit a selection (Issue #728) + input.addEventListener('blur', function () { + setTimeout(function () { + input.value = selectedLabel(select); + closeList(); + }, 150); + }); + document.addEventListener('click', function (e) { if (!wrapper.contains(e.target)) closeList(); }); diff --git a/app/templates/base.html b/app/templates/base.html index 15a0268c..c4c9bdcf 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -1278,6 +1278,13 @@

{{ _('AI H {# Issue #728: searchable combobox + inline create (available on all authenticated pages) #} + {% block scripts_extra %}{% endblock %} diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py index 9ea636be..93a105b6 100644 --- a/app/utils/scheduled_tasks.py +++ b/app/utils/scheduled_tasks.py @@ -901,8 +901,9 @@ def check_idle_timers(): is older than ``settings.idle_timeout_minutes``: 1. First pass: set ``idle_notified_at`` and send a browser push "Still working?". - 2. Second pass (after 5-minute grace): auto-stop the timer backdated to the - last heartbeat (or start time). + 2. Second pass (after 5-minute grace): auto-stop the timer at + ``last_active + idle_timeout`` so a forgotten timer still records at least + the configured idle window (Issue #722 — not 0 minutes). Clients that keep sending heartbeats keep ``last_heartbeat_at`` fresh and clear ``idle_notified_at``, so they are never auto-stopped while active. @@ -955,10 +956,14 @@ def check_idle_timers(): if getattr(notified_at, "tzinfo", None) is not None: notified_at = notified_at.replace(tzinfo=None) if (now - notified_at) >= grace: - stop_at = entry.last_heartbeat_at or (now - threshold) - if getattr(stop_at, "tzinfo", None) is not None: - stop_at = stop_at.replace(tzinfo=None) - if stop_at and stop_at > now: + # Credit the configured idle window: stop at last known + # activity + threshold (not last_active alone, which + # recorded 0 min when heartbeats never arrived). + last_active = entry.last_heartbeat_at or entry.start_time + if getattr(last_active, "tzinfo", None) is not None: + last_active = last_active.replace(tzinfo=None) + stop_at = last_active + threshold if last_active else now + if stop_at > now: stop_at = now # stop_timer commits; avoid double-commit issues by calling it last entry_id = entry.id diff --git a/app/utils/scope_filter.py b/app/utils/scope_filter.py index 94a8ab60..94b2fc04 100644 --- a/app/utils/scope_filter.py +++ b/app/utils/scope_filter.py @@ -102,7 +102,9 @@ def get_active_projects_for_user(user, *, status="active"): """Return projects visible to user (respects scope).""" from app.models import Project - query = Project.query.filter_by(status=status).order_by(Project.name) + query = Project.query.filter_by(status=status).order_by( + Project.last_used_at.desc().nullslast(), Project.name + ) scope = apply_project_scope_to_model(Project, user) if scope is not None: query = query.filter(scope) diff --git a/browser-extension/background.js b/browser-extension/background.js index 52712278..8b470195 100644 --- a/browser-extension/background.js +++ b/browser-extension/background.js @@ -197,11 +197,9 @@ async function refreshTimerStatus({ force = false } = {}) { status?.idle_notified || status?.timer?.idle_notified ); if (idleNotified) { - const minutes = clampIdleTimeoutMinutes( - status?.idle_timeout_minutes ?? idleTimeoutMinutes - ); - const stopAtMs = Date.now() - minutes * 60 * 1000; - await beginIdleGrace(stopAtMs); + // Credit the idle window (Issue #722): stop at last_active + threshold ≈ now + // when the server just notified, not at last_active alone (0 min). + await beginIdleGrace(Date.now()); } // Only refresh server heartbeat while the OS reports the user as active. // Heartbeating during idle/locked would defeat the server-side safety net. @@ -328,19 +326,16 @@ chrome.idle.onStateChanged.addListener(async (newState) => { return; } - const { last_timer_status, idle_timeout_minutes } = await chrome.storage.local.get([ + const { last_timer_status } = await chrome.storage.local.get([ 'last_timer_status', - 'idle_timeout_minutes', ]); if (!last_timer_status?.active || !last_timer_status?.timer) { return; } - const minutes = clampIdleTimeoutMinutes(idle_timeout_minutes); - // When chrome.idle reports idle/locked, the user has already been inactive - // for the configured detection interval — stop at that last-active moment. - const stopAtMs = Date.now() - minutes * 60 * 1000; - await beginIdleGrace(stopAtMs); + // chrome.idle already waited the detection interval; credit that window + // so auto-stop records idle_timeout minutes of work (Issue #722), not 0. + await beginIdleGrace(Date.now()); }); chrome.notifications.onButtonClicked.addListener(async (notificationId, buttonIndex) => { @@ -348,8 +343,8 @@ chrome.notifications.onButtonClicked.addListener(async (notificationId, buttonIn if (buttonIndex === 0) { await confirmStillWorking(); } else if (buttonIndex === 1) { - const { idle_grace_stop_at } = await chrome.storage.local.get('idle_grace_stop_at'); - await stopTimerForIdle({ stopAtMs: idle_grace_stop_at }); + // "No" stops immediately (like web stopNow), not backdated to last activity. + await stopTimerForIdle({ stopAtMs: Date.now() }); } }); diff --git a/browser-extension/lib/picker.js b/browser-extension/lib/picker.js index 32ad81dc..1ce956d9 100644 --- a/browser-extension/lib/picker.js +++ b/browser-extension/lib/picker.js @@ -10,6 +10,8 @@ * canCreate?: boolean, * placeholder?: string, * onCreate?: (typedName: string) => void | Promise, + * canCreateGuard?: () => boolean, + * canCreateGuardHint?: string, * }} options */ export function enhanceSelect(select, options = {}) { @@ -113,18 +115,27 @@ export function enhanceSelect(select, options = {}) { if (canCreate && q) { const exact = optionsList.some((o) => (o.label || '').toLowerCase() === q); if (!exact) { - const createLi = document.createElement('li'); - createLi.className = 'tt-picker-create'; - createLi.setAttribute('role', 'option'); - createLi.textContent = `${createLabels[kind] || 'Create'} “${query.trim()}”`; - createLi.addEventListener('mousedown', (e) => { - e.preventDefault(); - closeList(); - if (typeof options.onCreate === 'function') { - options.onCreate(query.trim()); - } - }); - list.appendChild(createLi); + const guardOk = + typeof options.canCreateGuard !== 'function' || options.canCreateGuard(); + if (!guardOk) { + const hint = document.createElement('li'); + hint.className = 'tt-picker-hint'; + hint.textContent = options.canCreateGuardHint || 'Not available'; + list.appendChild(hint); + } else { + const createLi = document.createElement('li'); + createLi.className = 'tt-picker-create'; + createLi.setAttribute('role', 'option'); + createLi.textContent = `${createLabels[kind] || 'Create'} “${query.trim()}”`; + createLi.addEventListener('mousedown', (e) => { + e.preventDefault(); + closeList(); + if (typeof options.onCreate === 'function') { + options.onCreate(query.trim()); + } + }); + list.appendChild(createLi); + } } } @@ -156,6 +167,14 @@ export function enhanceSelect(select, options = {}) { } }); + // Reset visible input when user types but does not commit a selection (Issue #728) + input.addEventListener('blur', () => { + setTimeout(() => { + input.value = selectedLabel(); + closeList(); + }, 150); + }); + document.addEventListener('click', (e) => { if (!wrapper.contains(e.target)) closeList(); }); diff --git a/browser-extension/manifest.json b/browser-extension/manifest.json index 398236c1..3937a1ad 100644 --- a/browser-extension/manifest.json +++ b/browser-extension/manifest.json @@ -1,7 +1,7 @@ { "manifest_version": 3, "name": "TimeTracker Timer", - "version": "5.13.0", + "version": "5.13.1", "description": "Start and stop TimeTracker timers from your browser toolbar.", "permissions": ["storage", "alarms", "idle", "notifications"], "optional_host_permissions": ["http://*/*", "https://*/*"], diff --git a/browser-extension/package.json b/browser-extension/package.json index a0a4313c..94d982c2 100644 --- a/browser-extension/package.json +++ b/browser-extension/package.json @@ -1,7 +1,7 @@ { "name": "timetracker-browser-extension", "private": true, - "version": "5.13.0", + "version": "5.13.1", "description": "TimeTracker Chromium extension (Manifest V3)", "type": "module", "scripts": { diff --git a/browser-extension/popup.css b/browser-extension/popup.css index e3cf7b33..8a2e36f2 100644 --- a/browser-extension/popup.css +++ b/browser-extension/popup.css @@ -365,7 +365,8 @@ hr.sep { .tt-picker-option, .tt-picker-create, -.tt-picker-empty { +.tt-picker-empty, +.tt-picker-hint { padding: 7px 10px; cursor: pointer; } @@ -386,7 +387,8 @@ hr.sep { font-weight: 600; } -.tt-picker-empty { +.tt-picker-empty, +.tt-picker-hint { color: var(--muted); cursor: default; } diff --git a/browser-extension/popup.js b/browser-extension/popup.js index c786906a..6a2ce9d6 100644 --- a/browser-extension/popup.js +++ b/browser-extension/popup.js @@ -37,7 +37,7 @@ const CLOSED_TASK_STATUSES = new Set(['done', 'cancelled']); /** @type {ApiClient|null} */ let client = null; -/** @type {Array<{id:number,name:string,client_id?:number|null,favorite?:boolean}>} */ +/** @type {Array<{id:number,name:string,client_id?:number|null,favorite?:boolean,last_used_at?:string|null}>} */ let projects = []; /** @type {Array<{id:number,name:string}>} */ let clients = []; @@ -163,7 +163,7 @@ function fillProjectSelect(selectedId = null) { const current = selectedId != null ? String(selectedId) - : els.projectSelect.value || (list[0] ? String(list[0].id) : ''); + : els.projectSelect.value || ''; els.projectSelect.innerHTML = ''; if (!list.length) { @@ -255,9 +255,13 @@ async function loadProjects(preferredProjectId = null) { name: p.name, client_id: p.client_id ?? null, favorite: favIds.has(p.id), + last_used_at: p.last_used_at ?? null, })) .sort((a, b) => { if (a.favorite !== b.favorite) return a.favorite ? -1 : 1; + const aUsed = a.last_used_at ? Date.parse(a.last_used_at) : 0; + const bUsed = b.last_used_at ? Date.parse(b.last_used_at) : 0; + if (aUsed !== bUsed) return bUsed - aUsed; return a.name.localeCompare(b.name); }); @@ -328,6 +332,10 @@ async function createClientInline(name) { async function createProjectInline(name) { clearMessage(); const clientId = els.clientSelect.value ? Number(els.clientSelect.value) : null; + if (!clientId) { + showMessage('Select a client before creating a project.'); + return; + } try { const result = await client.createProject({ name, clientId }); const created = result?.project || result; @@ -371,6 +379,8 @@ function initPickers() { canCreate: true, placeholder: 'Type to search projects…', onCreate: createProjectInline, + canCreateGuard: () => !!els.clientSelect.value, + canCreateGuardHint: 'Select a client first', }); taskPicker = enhanceSelect(els.taskSelect, { kind: 'task', diff --git a/desktop/package.json b/desktop/package.json index 242f5f48..85365160 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -1,6 +1,6 @@ { "name": "timetracker-desktop", - "version": "5.13.0", + "version": "5.13.1", "description": "TimeTracker desktop app for Windows, Linux, and macOS", "main": "src/main/main.js", "scripts": { diff --git a/migrations/versions/182_add_project_last_used_at.py b/migrations/versions/182_add_project_last_used_at.py new file mode 100644 index 00000000..e78bb4d8 --- /dev/null +++ b/migrations/versions/182_add_project_last_used_at.py @@ -0,0 +1,54 @@ +"""Add last_used_at to projects for recency-based sorting. + +Revision ID: 182_add_project_last_used_at +Revises: 181_add_quote_signature +""" + +import sqlalchemy as sa +from alembic import op +from sqlalchemy import inspect + +revision = "182_add_project_last_used_at" +down_revision = "181_add_quote_signature" +branch_labels = None +depends_on = None + + +def _has_column(inspector, table_name, column_name): + try: + return column_name in {c["name"] for c in inspector.get_columns(table_name)} + except Exception: + return False + + +def _has_index(inspector, table_name, index_name): + try: + if table_name not in inspector.get_table_names(): + return False + return any(idx["name"] == index_name for idx in inspector.get_indexes(table_name)) + except Exception: + return False + + +def upgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "projects" not in inspector.get_table_names(): + return + if not _has_column(inspector, "projects", "last_used_at"): + op.add_column("projects", sa.Column("last_used_at", sa.DateTime(), nullable=True)) + inspector = inspect(bind) + if not _has_index(inspector, "projects", "ix_projects_last_used_at"): + op.create_index("ix_projects_last_used_at", "projects", ["last_used_at"]) + + +def downgrade(): + bind = op.get_bind() + inspector = inspect(bind) + if "projects" not in inspector.get_table_names(): + return + if _has_index(inspector, "projects", "ix_projects_last_used_at"): + op.drop_index("ix_projects_last_used_at", table_name="projects") + inspector = inspect(bind) + if _has_column(inspector, "projects", "last_used_at"): + op.drop_column("projects", "last_used_at") diff --git a/mobile/lib/core/services/foreground_task_handler.dart b/mobile/lib/core/services/foreground_task_handler.dart index ca88cb3e..e1ca834a 100644 --- a/mobile/lib/core/services/foreground_task_handler.dart +++ b/mobile/lib/core/services/foreground_task_handler.dart @@ -7,21 +7,40 @@ void timerForegroundStartCallback() { } /// Updates the persistent timer notification every second while the -/// foreground service is running. +/// foreground service is running, and periodically nudges the main isolate +/// to poll server idle status (Issue #722). class TimerForegroundTaskHandler extends TaskHandler { static const String keyStartTimeMillis = 'timer_start_time_millis'; static const String keyBreakSeconds = 'timer_break_seconds'; static const String keyProjectName = 'timer_project_name'; static const String keyTaskName = 'timer_task_name'; + static const Duration idleNudgeInterval = Duration(seconds: 30); + + DateTime? _lastIdleNudge; @override Future onStart(DateTime timestamp, TaskStarter starter) async { await _updateNotification(); + _nudgeIdleCheck(timestamp); } @override void onRepeatEvent(DateTime timestamp) { _updateNotification(); + _nudgeIdleCheck(timestamp); + } + + void _nudgeIdleCheck(DateTime timestamp) { + final last = _lastIdleNudge; + if (last != null && timestamp.difference(last) < idleNudgeInterval) { + return; + } + _lastIdleNudge = timestamp; + try { + FlutterForegroundTask.sendDataToMain({'type': 'idle_check'}); + } catch (_) { + // Main isolate may be gone; server check_idle_timers is the safety net. + } } @override diff --git a/mobile/lib/core/services/idle_detection_service.dart b/mobile/lib/core/services/idle_detection_service.dart index bf951e22..2222d254 100644 --- a/mobile/lib/core/services/idle_detection_service.dart +++ b/mobile/lib/core/services/idle_detection_service.dart @@ -2,6 +2,7 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; import 'package:flutter/widgets.dart'; +import 'package:flutter_foreground_task/flutter_foreground_task.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:timetracker_mobile/core/services/notification_service.dart'; import 'package:timetracker_mobile/domain/repositories/time_tracking_repository.dart'; @@ -13,6 +14,8 @@ import 'package:timetracker_mobile/domain/repositories/time_tracking_repository. /// [idleTimeoutMinutes] of no activity shows a "Still working?" notification /// with a 5-minute grace window, then auto-stops the timer. /// +/// When the app is backgrounded, polls the server for ``idle_notified`` (nudged +/// by the Android foreground task) so the local notification still fires. /// When the app is killed, the server-side `check_idle_timers` job is the /// safety net (requires heartbeats to have been flowing while the app was open). class IdleDetectionService with WidgetsBindingObserver { @@ -36,6 +39,7 @@ class IdleDetectionService with WidgetsBindingObserver { bool _started = false; bool _timerActive = false; bool _inForeground = true; + bool _taskDataCallbackRegistered = false; DateTime? _idleStopAt; bool get isRunning => _started; @@ -53,12 +57,14 @@ class IdleDetectionService with WidgetsBindingObserver { Timer.periodic(heartbeatInterval, (_) => _sendHeartbeat()); _checkTimer = Timer.periodic(checkInterval, (_) => _tick()); NotificationService.instance.onIdleAction = respondToIdlePrompt; + _registerForegroundTaskCallback(); } void stop() { if (!_started) return; _started = false; WidgetsBinding.instance.removeObserver(this); + _unregisterForegroundTaskCallback(); _heartbeatTimer?.cancel(); _checkTimer?.cancel(); _graceTimer?.cancel(); @@ -90,9 +96,8 @@ class IdleDetectionService with WidgetsBindingObserver { return; } if (idleNotified && !_promptShown) { - _idleStopAt = DateTime.now().subtract( - Duration(minutes: _idleTimeoutMinutes), - ); + // Server already waited idle_timeout; credit that window (Issue #722). + _idleStopAt = _creditedStopAt(_lastActivity); await _showPrompt(); } } @@ -129,8 +134,7 @@ class IdleDetectionService with WidgetsBindingObserver { return; } - final stopAt = _idleStopAt ?? - DateTime.now().subtract(Duration(minutes: _idleTimeoutMinutes)); + final stopAt = _idleStopAt ?? _creditedStopAt(_lastActivity); _promptShown = false; _idleStopAt = null; try { @@ -141,6 +145,41 @@ class IdleDetectionService with WidgetsBindingObserver { _timerActive = false; } + /// last_active + idle_timeout, capped at now (Issue #722 — not 0 min). + DateTime _creditedStopAt(DateTime lastActive) { + final credited = + lastActive.add(Duration(minutes: _idleTimeoutMinutes)); + final now = DateTime.now(); + return credited.isAfter(now) ? now : credited; + } + + void _registerForegroundTaskCallback() { + if (_taskDataCallbackRegistered) return; + try { + FlutterForegroundTask.addTaskDataCallback(_onForegroundTaskData); + _taskDataCallbackRegistered = true; + } catch (e) { + debugPrint('IdleDetectionService FGS callback register failed: $e'); + } + } + + void _unregisterForegroundTaskCallback() { + if (!_taskDataCallbackRegistered) return; + try { + FlutterForegroundTask.removeTaskDataCallback(_onForegroundTaskData); + } catch (e) { + debugPrint('IdleDetectionService FGS callback unregister failed: $e'); + } + _taskDataCallbackRegistered = false; + } + + void _onForegroundTaskData(Object data) { + if (data is Map && data['type'] == 'idle_check') { + // Keep idle detection alive while the Android FGS is running (#722). + unawaited(_pollServerIdleStatus()); + } + } + Future _sendHeartbeat() async { if (!_timerActive || _repository == null || _promptShown || !_inForeground) { return; @@ -161,14 +200,36 @@ class IdleDetectionService with WidgetsBindingObserver { if (!_timerActive || _repository == null) return; if (_promptShown) return; + // When backgrounded, rely on server idle_notified (heartbeats stop). + if (!_inForeground) { + await _pollServerIdleStatus(); + return; + } + final idleFor = DateTime.now().difference(_lastActivity); final threshold = Duration(minutes: _idleTimeoutMinutes); if (idleFor >= threshold) { - _idleStopAt = DateTime.now().subtract(idleFor); + _idleStopAt = _creditedStopAt(_lastActivity); await _showPrompt(); } } + Future _pollServerIdleStatus() async { + if (!_timerActive || _repository == null || _promptShown) return; + try { + final status = await _repository!.getTimerStatusDetailed(); + final timer = status.timer; + final active = timer != null && !timer.isPaused; + await updateFromTimerStatus( + active: active, + idleTimeoutMinutes: status.idleTimeoutMinutes, + idleNotified: status.idleNotified, + ); + } catch (e) { + debugPrint('IdleDetectionService background poll failed: $e'); + } + } + Future _showPrompt() async { if (_promptShown) return; _promptShown = true; diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index f871aa96..70cd0216 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -1,7 +1,7 @@ name: timetracker_mobile description: TimeTracker mobile app for Android and iOS publish_to: 'none' -version: 5.13.0+1 +version: 5.13.1+1 environment: sdk: '>=3.0.0 <4.0.0' diff --git a/setup.py b/setup.py index 4f706c81..6d6503e3 100644 --- a/setup.py +++ b/setup.py @@ -7,7 +7,7 @@ setup( name='timetracker', - version='5.13.0', + version='5.13.1', packages=find_packages(), include_package_data=True, package_data={ diff --git a/tests/test_api_v1.py b/tests/test_api_v1.py index 6ed50786..b0546268 100644 --- a/tests/test_api_v1.py +++ b/tests/test_api_v1.py @@ -578,13 +578,23 @@ def test_check_idle_timers_notifies_then_stops(self, client, api_token, test_use assert refreshed.end_time is None assert refreshed.idle_notified_at is not None - # Second pass after grace: auto-stop + # Second pass after grace: auto-stop at last_heartbeat + idle_timeout + last_hb = refreshed.last_heartbeat_at refreshed.idle_notified_at = local_now() - timedelta(minutes=6) db.session.commit() check_idle_timers() stopped = db.session.get(TimeEntry, timer_id) assert stopped.end_time is not None assert stopped.idle_notified_at is None + expected_stop = last_hb + timedelta(minutes=30) + if getattr(expected_stop, "tzinfo", None) is not None: + expected_stop = expected_stop.replace(tzinfo=None) + end = stopped.end_time + if getattr(end, "tzinfo", None) is not None: + end = end.replace(tzinfo=None) + assert abs((end - expected_stop).total_seconds()) < 5 + # At least ~90 min recorded (start was 2h ago, stop at hb+30m) + assert (stopped.duration_seconds or 0) >= 85 * 60 class TestTasks: