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: