Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
88e8f7c
feat: add models and migrations for product gap backlog
evilguy4000 Sep 16, 2026
bc9bdbd
feat(focus): wire Pomodoro sessions into APIs and timer UI
evilguy4000 Sep 16, 2026
38e5f73
feat(finance): add expense lifecycle parity on mobile and desktop
evilguy4000 Sep 16, 2026
1ba1585
feat(payroll): add configurable payroll export templates
evilguy4000 Sep 16, 2026
97994f0
feat(integrations): deepen QuickBooks and Xero sync coverage
evilguy4000 Sep 16, 2026
474799f
feat(activitywatch): add rules, merge filters, and review inbox
evilguy4000 Sep 16, 2026
301d832
feat(gamification): expose badges, leaderboards, and award hooks
evilguy4000 Sep 16, 2026
b29c182
feat(calendar): enable drag-and-drop move and resize for events
evilguy4000 Sep 16, 2026
efb6034
feat(projects): add recurring project cost engine
evilguy4000 Sep 16, 2026
d91bd44
feat(reports): add public tokenized shareable report links
evilguy4000 Sep 16, 2026
a34c1d2
feat(attendance): add geofencing policies for workday clock-in
evilguy4000 Sep 16, 2026
faeac46
feat(integrations): add ActivityWatch rules/inbox and sync-error routes
evilguy4000 Sep 16, 2026
c82ab82
feat(admin): add payroll template and geofence management pages
evilguy4000 Sep 16, 2026
82c3a0f
feat(api): expose focus, recurring-cost, and shared-report endpoints
evilguy4000 Sep 16, 2026
aa83c61
feat: register gap features and surface them on the dashboard
evilguy4000 Sep 16, 2026
0e217e6
feat(mobile): extend API client for expenses, reports, and geofencing
evilguy4000 Sep 16, 2026
b790966
fix(inventory): correct PO line stock IDs and restore edit form (#759)
evilguy4000 Sep 16, 2026
7751f57
feat(timer): allow overriding start time when starting or while runni…
evilguy4000 Sep 16, 2026
5eead2c
fix(migrations): use boolean literals for PostgreSQL server defaults
evilguy4000 Sep 16, 2026
fc94779
fix(dashboard): use correct timer_page endpoint for Open timer link
evilguy4000 Sep 16, 2026
f4def7c
chore: release 5.15.0 — timer override, Pomodoro, expense lifecycle, …
evilguy4000 Sep 16, 2026
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
33 changes: 33 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

## [5.15.0] - 2026-09-16

### Added

- **Timer start-time override (#760)** — Start a new timer at a custom time, or adjust the start while already running; the timer page and API both support the override.
- **Pomodoro / Focus sessions** — Pomodoro sessions are now wired into the timer API and UI so focus blocks are tracked and surfaced alongside regular time entries.
- **Expense lifecycle on mobile & desktop** — Full expense CRUD (submit, approve, reject, reimburse) now available in the Flutter mobile and Electron desktop clients.
- **Payroll export templates** — Admins can configure reusable payroll export templates (admin panel + geofence management pages); exports are rendered via the template engine.
- **QuickBooks & Xero sync depth** — Extended accounting sync covers additional entity types, mapping, and error-route handling for both integrations.
- **ActivityWatch rules, inbox & sync errors** — Rules engine and merge filters for ActivityWatch events; a dedicated review inbox and sync-error route surface unmatched entries.
- **Gamification — badges, leaderboards & award hooks** — Badge definitions, leaderboard calculations, and award-hook endpoints exposed via the API for team engagement features.
- **Calendar drag-and-drop** — Events on the calendar view can be moved and resized by dragging, updating the underlying time entry in real time.
- **Recurring project cost engine** — Projects can have recurring costs that are calculated and posted automatically on a configured schedule.
- **Public shareable report links** — Reports can be shared via a public, tokenized URL without requiring the recipient to log in.
- **Geofencing policies** — Attendance module gains location-based clock-in policies; admin UI added for geofence management.
- **Gap-feature models & dashboard surface** — New database models and migrations for the product-gap backlog; key features registered and surfaced on the main dashboard.
- **Mobile API extensions** — Flutter client extended with expense, report, and geofencing API calls.
- **New API endpoints** — Focus sessions, recurring project costs, and shared report endpoints added to the REST API.

### Fixed

- **Inventory PO lines & edit form (#759)** — Correct stock IDs on PO lines and restore the broken edit form.
- **Migrations PostgreSQL boolean defaults** — Migration server defaults now use `true`/`false` literals instead of `1`/`0` for PostgreSQL compatibility.
- **Dashboard Open Timer link** — Use the correct `timer_page` endpoint for the "Open timer" link so it no longer 404s.

### Changed

- **Dependencies** — Bumped `weasyprint` (pip group).

### Documentation

- **Version** — Bumped `setup.py` to **5.15.0** (single source of truth for the application version).

## [5.14.1] - 2026-09-09

### Fixed
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.15.0

**Minor (5.15.0):** **Timer start override (#760)** — start or adjust a running timer at a custom time. **Pomodoro sessions** — focus blocks tracked via timer API and UI. **Expense lifecycle** — full mobile/desktop expense CRUD. **Payroll templates** — configurable export templates. **QuickBooks & Xero** — deeper accounting sync. **ActivityWatch inbox** — rules, merge filters, and sync-error review. **Gamification** — badges, leaderboards, and award hooks. **Calendar DnD** — drag-to-move and resize events. **Recurring costs** — automatic recurring project cost engine. **Shareable reports** — public tokenized report links. **Geofencing** — location-based attendance clock-in policies. See [CHANGELOG.md](CHANGELOG.md#5150---2026-09-16).

### ✨ Highlights of v5.14.1

**Patch (5.14.1):** **Docker base images** — Node 20 and Python Bookworm replace expired Bullseye bases so fresh CI/image builds succeed. See [CHANGELOG.md](CHANGELOG.md#5141---2026-09-09).
Expand Down
3 changes: 3 additions & 0 deletions app/blueprint_registry.py
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ def register_all_blueprints(app, logger=None):
from app.routes.api_v1 import api_v1_bp

import_module("app.routes.api_v1_workday")
import_module("app.routes.api_v1_geofences")
import_module("app.routes.api_v1_attendance")
from app.routes.api_v1_ai import api_v1_ai_bp
from app.routes.api_v1_clients import api_v1_clients_bp
Expand Down Expand Up @@ -114,6 +115,7 @@ def register_all_blueprints(app, logger=None):
from app.routes.webhooks import webhooks_bp
from app.routes.weekly_goals import weekly_goals_bp
from app.routes.workday import workday_bp
from app.routes.gamification import gamification_bp

try:
from app.routes.audit_logs import audit_logs_bp
Expand Down Expand Up @@ -202,6 +204,7 @@ def register_all_blueprints(app, logger=None):
app.register_blueprint(expenses_bp)
app.register_blueprint(permissions_bp)
app.register_blueprint(calendar_bp)
app.register_blueprint(gamification_bp)
app.register_blueprint(expense_categories_bp)
app.register_blueprint(mileage_bp)
app.register_blueprint(per_diem_bp)
Expand Down
208 changes: 167 additions & 41 deletions app/integrations/activitywatch.py
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,10 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
default_project_id = cfg.get("default_project_id")
if default_project_id is not None:
default_project_id = int(default_project_id)
min_duration_seconds = int(cfg.get("min_duration_seconds") or 30)
merge_gap_seconds = int(cfg.get("merge_gap_seconds") or 120)
review_mode = bool(cfg.get("review_mode"))
default_billable = bool(cfg.get("default_billable", False))

# Parse bucket_ids: optional list; if empty, use aw-watcher-window_* and aw-watcher-web_*
bucket_ids_cfg = cfg.get("bucket_ids")
Expand Down Expand Up @@ -199,6 +203,11 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
skipped = 0
errors: List[str] = []

from app.models.activitywatch_rule import ActivityWatchRule, PendingActivity

# Collect raw parsed events first for merge
parsed_events: List[Dict[str, Any]] = []

for bucket_id in selected:
try:
events_data = self._get(
Expand All @@ -220,7 +229,6 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
skipped += 1
continue

# Parse timestamp (ISO UTC)
if isinstance(ts, str):
if ts.endswith("Z"):
ts = ts[:-1] + "+00:00"
Expand All @@ -243,9 +251,10 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
except (TypeError, ValueError):
pass

end_dt = start_dt + timedelta(seconds=dur_sec)
if dur_sec < min_duration_seconds:
skipped += 1
continue

# Notes: app+title or url+title
app = (data.get("app") or "").strip()
title = (data.get("title") or "").strip()
url = (data.get("url") or "").strip()
Expand All @@ -260,54 +269,124 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
else:
notes = "ActivityWatch: (no app/title)"

# external_uid for idempotency (max 255)
data_str = (app or "") + "|" + (title or "") + (url or "")
h = hashlib.md5(data_str.encode("utf-8")).hexdigest()[:16]
external_uid = f"{bucket_id}|{ts}|{dur_sec}|{h}"[:255]

existing = IntegrationExternalEventLink.query.filter_by(
integration_id=self.integration.id,
external_uid=external_uid,
).first()
if existing:
skipped += 1
continue
parsed_events.append(
{
"bucket_id": bucket_id,
"start_dt": start_dt,
"dur_sec": dur_sec,
"app": app,
"title": title,
"url": url,
"notes": notes,
"external_uid": external_uid,
"merge_key": (app or url or title or "").lower(),
}
)
except Exception as e:
errors.append(f"Event {ev.get('timestamp', '?')}: {e}")

start_local = _to_local_naive(start_dt)
end_local = _to_local_naive(end_dt)
if end_local <= start_local:
# Merge consecutive same-app events within gap threshold
parsed_events.sort(key=lambda e: e["start_dt"])
merged: List[Dict[str, Any]] = []
for ev in parsed_events:
if not merged:
merged.append(ev)
continue
prev = merged[-1]
prev_end = prev["start_dt"] + timedelta(seconds=prev["dur_sec"])
gap = (ev["start_dt"] - prev_end).total_seconds()
if prev["merge_key"] and prev["merge_key"] == ev["merge_key"] and 0 <= gap <= merge_gap_seconds:
prev["dur_sec"] = int((ev["start_dt"] + timedelta(seconds=ev["dur_sec"]) - prev["start_dt"]).total_seconds())
prev["external_uid"] = f"{prev['external_uid']}+{ev['external_uid']}"[:255]
else:
merged.append(ev)

for ev in merged:
try:
existing = IntegrationExternalEventLink.query.filter_by(
integration_id=self.integration.id,
external_uid=ev["external_uid"],
).first()
if existing:
skipped += 1
continue

start_local = _to_local_naive(ev["start_dt"])
end_local = _to_local_naive(ev["start_dt"] + timedelta(seconds=ev["dur_sec"]))
if end_local <= start_local:
skipped += 1
continue

rule = ActivityWatchRule.match_event(
self.integration.user_id, ev["app"], ev["title"], ev["url"]
)
project_id = rule.project_id if rule else default_project_id
task_id = rule.task_id if rule else None
billable = rule.billable if rule else default_billable
tags = rule.tags if rule and rule.tags else None
if rule and rule.min_duration_seconds and ev["dur_sec"] < rule.min_duration_seconds:
skipped += 1
continue

if review_mode:
existing_pending = PendingActivity.query.filter_by(
user_id=self.integration.user_id,
external_uid=ev["external_uid"],
).first()
if existing_pending:
skipped += 1
continue

# Omit duration_seconds so calculate_duration() runs and applies
# per-user rounding (start/end already encode the ActivityWatch duration).
entry = TimeEntry(
pending = PendingActivity(
user_id=self.integration.user_id,
project_id=default_project_id,
client_id=None,
task_id=None,
start_time=start_local,
end_time=end_local,
notes=notes[:5000] if notes else None,
tags=None,
source="auto",
billable=True,
paid=False,
)
db.session.add(entry)
db.session.flush()

link = IntegrationExternalEventLink(
integration_id=self.integration.id,
time_entry_id=entry.id,
external_uid=external_uid,
external_href=None,
aw_bucket=ev["bucket_id"],
app_name=ev["app"] or None,
title=ev["title"] or None,
url=ev["url"] or None,
started_at=start_local,
duration_seconds=ev["dur_sec"],
matched_rule_id=rule.id if rule else None,
suggested_project_id=project_id,
suggested_task_id=task_id,
suggested_billable=billable,
external_uid=ev["external_uid"],
status="pending",
notes=ev["notes"][:5000] if ev["notes"] else None,
)
db.session.add(link)
db.session.add(pending)
imported += 1
continue

entry = TimeEntry(
user_id=self.integration.user_id,
project_id=project_id,
client_id=None,
task_id=task_id,
start_time=start_local,
end_time=end_local,
notes=ev["notes"][:5000] if ev["notes"] else None,
tags=",".join(tags) if isinstance(tags, list) else tags,
source="auto",
billable=billable,
paid=False,
)
db.session.add(entry)
db.session.flush()

link = IntegrationExternalEventLink(
integration_id=self.integration.id,
time_entry_id=entry.id,
external_uid=ev["external_uid"],
external_href=None,
)
db.session.add(link)
imported += 1

except Exception as e:
errors.append(f"Event {ev.get('timestamp', '?')}: {e}")
except Exception as e:
errors.append(f"Commit event: {e}")

self.integration.last_sync_at = datetime.utcnow()
self.integration.last_sync_status = "success" if not errors else "partial"
Expand All @@ -326,6 +405,15 @@ def sync_data(self, sync_type: str = "full") -> Dict[str, Any]:
"message": msg,
}

def list_buckets(self) -> List[str]:
"""Return discovered bucket IDs from aw-server."""
data = self._get("buckets/")
if isinstance(data, dict):
return list(data.keys())
if isinstance(data, list):
return [b.get("id") if isinstance(b, dict) else str(b) for b in data if b]
return []

def get_config_schema(self) -> Dict[str, Any]:
return {
"fields": [
Expand Down Expand Up @@ -363,6 +451,34 @@ def get_config_schema(self) -> Dict[str, Any]:
"description": "Comma-separated or JSON array; leave empty for all window and web buckets",
"help": "e.g. aw-watcher-window_hostname. Empty = use all aw-watcher-window_* and aw-watcher-web_*.",
},
{
"name": "min_duration_seconds",
"type": "number",
"label": "Minimum Duration (seconds)",
"default": 30,
"description": "Skip events shorter than this",
},
{
"name": "merge_gap_seconds",
"type": "number",
"label": "Merge Gap (seconds)",
"default": 120,
"description": "Merge consecutive same-app events within this gap",
},
{
"name": "review_mode",
"type": "boolean",
"label": "Review Mode",
"default": False,
"description": "Queue imports for approval instead of creating time entries immediately",
},
{
"name": "default_billable",
"type": "boolean",
"label": "Default Billable",
"default": False,
"description": "Billable flag when no rule matches",
},
{
"name": "auto_sync",
"type": "boolean",
Expand Down Expand Up @@ -393,7 +509,17 @@ def get_config_schema(self) -> Dict[str, Any]:
{
"title": "Import Settings",
"description": "What to import and where",
"fields": ["default_project_id", "lookback_days", "bucket_ids", "auto_sync", "sync_interval"],
"fields": [
"default_project_id",
"lookback_days",
"bucket_ids",
"min_duration_seconds",
"merge_gap_seconds",
"review_mode",
"default_billable",
"auto_sync",
"sync_interval",
],
},
],
"sync_settings": {
Expand Down
Loading
Loading