Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 54 additions & 3 deletions .github/workflows/cd-release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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: |
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
Expand Down
18 changes: 18 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
Expand Down
6 changes: 6 additions & 0 deletions app/models/project.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions app/repositories/project_repository.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.)"""
Expand Down Expand Up @@ -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()
2 changes: 1 addition & 1 deletion app/services/project_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
4 changes: 4 additions & 0 deletions app/services/time_tracking_service.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down
79 changes: 78 additions & 1 deletion app/static/idle.js
Original file line number Diff line number Diff line change
Expand Up @@ -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(){
Expand All @@ -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
Expand Down Expand Up @@ -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 });
Expand All @@ -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() }) });
Expand All @@ -120,13 +156,42 @@

function snoozeIdlePrompt(toastEl){
clearGraceTimers();
closeIdleNotification();
lastActivity = Date.now();
promptShown = false;
lastHeartbeatSent = 0; // force immediate heartbeat so server clears idle_notified_at
sendHeartbeat();
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();
Expand All @@ -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()) + ')';
Expand All @@ -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 =
'<div class="flex-1 text-sm text-amber-900 dark:text-amber-100" data-countdown>' + buildMessage() + '</div>' +
Expand All @@ -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 =
'<div class="d-flex">' +
Expand Down Expand Up @@ -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();
Expand Down
Loading
Loading